diff --git a/authentik/enterprise/models.py b/authentik/enterprise/models.py index 5def8ad37..fb9d8367d 100644 --- a/authentik/enterprise/models.py +++ b/authentik/enterprise/models.py @@ -62,7 +62,7 @@ class LicenseKey: except PyJWTError: raise ValidationError("Unable to verify license") x5c: list[str] = headers.get("x5c", []) - if len(x5c) < 1: + if len(x5c) < 2: raise ValidationError("Unable to verify license") try: our_cert = load_der_x509_certificate(b64decode(x5c[0])) diff --git a/authentik/stages/authenticator_mobile/api/stage.py b/authentik/stages/authenticator_mobile/api/stage.py index ac0031cae..b0712a7dc 100644 --- a/authentik/stages/authenticator_mobile/api/stage.py +++ b/authentik/stages/authenticator_mobile/api/stage.py @@ -15,7 +15,7 @@ class AuthenticatorMobileStageSerializer(StageSerializer): "configure_flow", "friendly_name", "item_matching_mode", - "firebase_config", + "cgw_endpoint", ] diff --git a/authentik/stages/authenticator_mobile/cloud_gateway.py b/authentik/stages/authenticator_mobile/cloud_gateway.py new file mode 100644 index 000000000..9bbb8197e --- /dev/null +++ b/authentik/stages/authenticator_mobile/cloud_gateway.py @@ -0,0 +1,59 @@ +"""Cloud-gateway client helpers""" +from functools import lru_cache + +from authentik_cloud_gateway_client.authenticationPush_pb2_grpc import AuthenticationPushStub +from django.conf import settings +from grpc import ( + UnaryStreamClientInterceptor, + UnaryUnaryClientInterceptor, + insecure_channel, + intercept_channel, +) +from grpc._interceptor import _ClientCallDetails + + +class AuthInterceptor(UnaryUnaryClientInterceptor, UnaryStreamClientInterceptor): + """GRPC auth interceptor""" + + def __init__(self, token: str) -> None: + super().__init__() + self.token = token + + def _intercept_client_call_details(self, details: _ClientCallDetails) -> _ClientCallDetails: + """inject auth header""" + metadata = [] + if details.metadata is not None: + metadata = list(details.metadata) + metadata.append( + ( + "authorization", + f"Bearer {self.token}", + ) + ) + return _ClientCallDetails( + details.method, + details.timeout, + metadata, + details.credentials, + details.wait_for_ready, + details.compression, + ) + + def intercept_unary_unary(self, continuation, client_call_details: _ClientCallDetails, request): + return continuation(self._intercept_client_call_details(client_call_details), request) + + def intercept_unary_stream( + self, continuation, client_call_details: _ClientCallDetails, request + ): + return continuation(self._intercept_client_call_details(client_call_details), request) + + +@lru_cache() +def get_client(addr: str): + """get a cached client to a cloud-gateway""" + target = addr + if settings.DEBUG: + target = insecure_channel(target) + channel = intercept_channel(target, AuthInterceptor("foo")) + client = AuthenticationPushStub(channel) + return client diff --git a/authentik/stages/authenticator_mobile/migrations/0001_initial.py b/authentik/stages/authenticator_mobile/migrations/0001_initial.py index 3f1242f93..9ebc5a851 100644 --- a/authentik/stages/authenticator_mobile/migrations/0001_initial.py +++ b/authentik/stages/authenticator_mobile/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.7 on 2023-12-14 20:06 +# Generated by Django 4.2.7 on 2023-12-15 16:02 import uuid @@ -45,7 +45,7 @@ class Migration(migrations.Migration): default="number_matching_3", ), ), - ("firebase_config", models.JSONField(default=dict, help_text="temp")), + ("cgw_endpoint", models.URLField()), ( "configure_flow", models.ForeignKey( diff --git a/authentik/stages/authenticator_mobile/models.py b/authentik/stages/authenticator_mobile/models.py index 4953e5e1b..7177aa83c 100644 --- a/authentik/stages/authenticator_mobile/models.py +++ b/authentik/stages/authenticator_mobile/models.py @@ -1,28 +1,21 @@ """Mobile authenticator stage""" -from json import dumps from secrets import choice -from time import sleep from typing import Optional from uuid import uuid4 +from authentik_cloud_gateway_client.authenticationPush_pb2 import ( + AuthenticationCheckRequest, + AuthenticationRequest, + AuthenticationResponse, + AuthenticationResponseStatus, +) from django.contrib.auth import get_user_model from django.db import models from django.http import HttpRequest from django.utils.translation import gettext as __ from django.utils.translation import gettext_lazy as _ from django.views import View -from firebase_admin import credentials, initialize_app -from firebase_admin.exceptions import FirebaseError -from firebase_admin.messaging import ( - AndroidConfig, - AndroidNotification, - APNSConfig, - APNSPayload, - Aps, - Message, - Notification, - send, -) +from grpc import RpcError from rest_framework.serializers import BaseSerializer, Serializer from structlog.stdlib import get_logger @@ -32,6 +25,7 @@ from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage from authentik.lib.generators import generate_code_fixed_length, generate_id from authentik.lib.models import SerializerModel from authentik.stages.authenticator.models import Device +from authentik.stages.authenticator_mobile.cloud_gateway import get_client from authentik.tenants.utils import DEFAULT_TENANT LOGGER = get_logger() @@ -56,7 +50,7 @@ class AuthenticatorMobileStage(ConfigurableStage, FriendlyNamedStage, Stage): item_matching_mode = models.TextField( choices=ItemMatchingMode.choices, default=ItemMatchingMode.NUMBER_MATCHING_3 ) - firebase_config = models.JSONField(default=dict, help_text="temp") + cgw_endpoint = models.URLField() def create_transaction(self, device: "MobileDevice") -> "MobileTransaction": """Create a transaction for `device` with the config of this stage.""" @@ -66,16 +60,16 @@ class AuthenticatorMobileStage(ConfigurableStage, FriendlyNamedStage, Stage): transaction.correct_item = TransactionStates.ACCEPT if self.item_matching_mode == ItemMatchingMode.NUMBER_MATCHING_2: transaction.decision_items = [ - generate_code_fixed_length(2), - generate_code_fixed_length(2), - generate_code_fixed_length(2), + str(generate_code_fixed_length(2)), + str(generate_code_fixed_length(2)), + str(generate_code_fixed_length(2)), ] transaction.correct_item = choice(transaction.decision_items) if self.item_matching_mode == ItemMatchingMode.NUMBER_MATCHING_3: transaction.decision_items = [ - generate_code_fixed_length(3), - generate_code_fixed_length(3), - generate_code_fixed_length(3), + str(generate_code_fixed_length(3)), + str(generate_code_fixed_length(3)), + str(generate_code_fixed_length(3)), ] transaction.correct_item = choice(transaction.decision_items) transaction.save() @@ -181,70 +175,56 @@ class MobileTransaction(ExpiringModel): def send_message(self, request: Optional[HttpRequest], **context): """Send mobile message""" - app = initialize_app( - credentials.Certificate(self.device.stage.firebase_config), name=str(self.tx_id) - ) branding = DEFAULT_TENANT.branding_title domain = "" if request: branding = request.tenant.branding_title domain = request.get_host() user: User = self.device.user - message = Message( - notification=Notification( - title=__("%(brand)s authentication request" % {"brand": branding}), - body=__( - "%(user)s is attempting to log in to %(domain)s" - % { - "user": user.username, # pylint: disable=no-member - "domain": domain, - } - ), - ), - android=AndroidConfig( - priority="normal", - notification=AndroidNotification(icon="stock_ticker_update", color="#f45342"), - data={ - "authentik_tx_id": str(self.tx_id), - "authentik_user_decision_items": dumps(self.decision_items), - }, - ), - apns=APNSConfig( - headers={"apns-push-type": "alert", "apns-priority": "10"}, - payload=APNSPayload( - aps=Aps( - badge=0, - sound="default", - content_available=True, - category="cat_authentik_push_authorization", - ), - interruption_level="time-sensitive", - authentik_tx_id=str(self.tx_id), - authentik_user_decision_items=self.decision_items, - ), - ), - token=self.device.firebase_token, - ) + + client = get_client(self.device.stage.cgw_endpoint) try: - response = send(message, app=app) + response = client.SendRequest( + AuthenticationRequest( + device_token=self.device.firebase_token, + title=__("%(brand)s authentication request" % {"brand": branding}), + body=__( + "%(user)s is attempting to log in to %(domain)s" + % { + "user": user.username, # pylint: disable=no-member + "domain": domain, + } + ), + tx_id=str(self.tx_id), + items=self.decision_items, + mode=self.device.stage.item_matching_mode, + ) + ) LOGGER.debug("Sent notification", id=response, tx_id=self.tx_id) - except (ValueError, FirebaseError) as exc: + except RpcError as exc: + LOGGER.warning("failed to push", exc=exc, code=exc.code(), tx_id=self.tx_id) + except ValueError as exc: LOGGER.warning("failed to push", exc=exc, tx_id=self.tx_id) return True def wait_for_response(self, max_checks=30) -> TransactionStates: """Wait for a change in status""" - checks = 0 - while True: - self.refresh_from_db() - if self.status in [TransactionStates.ACCEPT, TransactionStates.DENY]: - self.delete() - return self.status - checks += 1 - if checks > max_checks: - self.delete() + client = get_client(self.device.stage.cgw_endpoint) + for response in client.CheckStatus( + AuthenticationCheckRequest(tx_id=self.tx_id, attempts=max_checks) + ).next(): + response: AuthenticationResponse + if response.status == AuthenticationResponseStatus.ANSWERED: + self.selected_item = response.decided_item + self.save() + elif response.status == AuthenticationResponseStatus.FAILED: raise TimeoutError() - sleep(1) + elif response.status in [ + AuthenticationResponseStatus.UNKNOWN, + AuthenticationResponseStatus.SENT, + ]: + continue + self.delete() class MobileDeviceToken(ExpiringModel): diff --git a/blueprints/schema.json b/blueprints/schema.json index 32e6ea330..fc740b729 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -6153,11 +6153,12 @@ ], "title": "Item matching mode" }, - "firebase_config": { - "type": "object", - "additionalProperties": true, - "title": "Firebase config", - "description": "temp" + "cgw_endpoint": { + "type": "string", + "format": "uri", + "maxLength": 200, + "minLength": 1, + "title": "Cgw endpoint" } }, "required": [] diff --git a/pyproject.toml b/pyproject.toml index dbe261a89..863989131 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -177,7 +177,7 @@ wsproto = "*" xmlsec = "*" zxcvbn = "*" jsonpatch = "*" -firebase-admin = "*" +authentik-cloud-gateway-client-dev = {version = "*", allow-prereleases = true, source = "test-pypi"} [tool.poetry.dev-dependencies] bandit = "*" @@ -203,6 +203,16 @@ requests-mock = "*" ruff = "*" selenium = "*" +[[tool.poetry.source]] +name = "test-pypi" +url = "https://test.pypi.org/simple/" +priority = "primary" + + +[[tool.poetry.source]] +name = "PyPI" +priority = "primary" + [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" diff --git a/schema.yml b/schema.yml index 1eddf0dd5..9f731b14c 100644 --- a/schema.yml +++ b/schema.yml @@ -30427,11 +30427,12 @@ components: nullable: true item_matching_mode: $ref: '#/components/schemas/ItemMatchingModeEnum' - firebase_config: - type: object - additionalProperties: {} - description: temp + cgw_endpoint: + type: string + format: uri + maxLength: 200 required: + - cgw_endpoint - component - meta_model_name - name @@ -30461,11 +30462,13 @@ components: minLength: 1 item_matching_mode: $ref: '#/components/schemas/ItemMatchingModeEnum' - firebase_config: - type: object - additionalProperties: {} - description: temp + cgw_endpoint: + type: string + format: uri + minLength: 1 + maxLength: 200 required: + - cgw_endpoint - name AuthenticatorSMSChallenge: type: object @@ -38231,10 +38234,11 @@ components: minLength: 1 item_matching_mode: $ref: '#/components/schemas/ItemMatchingModeEnum' - firebase_config: - type: object - additionalProperties: {} - description: temp + cgw_endpoint: + type: string + format: uri + minLength: 1 + maxLength: 200 PatchedAuthenticatorSMSStageRequest: type: object description: AuthenticatorSMSStage Serializer diff --git a/web/src/admin/stages/authenticator_mobile/AuthenticatorMobileStageForm.ts b/web/src/admin/stages/authenticator_mobile/AuthenticatorMobileStageForm.ts index 7c7abf505..87fe24b2d 100644 --- a/web/src/admin/stages/authenticator_mobile/AuthenticatorMobileStageForm.ts +++ b/web/src/admin/stages/authenticator_mobile/AuthenticatorMobileStageForm.ts @@ -110,16 +110,15 @@ export class AuthenticatorMobileStageForm extends ModelForm - - -

${msg("Firebase JSON.")}

+