diff --git a/authentik/stages/authenticator_mobile/api/device.py b/authentik/stages/authenticator_mobile/api/device.py index 553823b9e..1f517ac78 100644 --- a/authentik/stages/authenticator_mobile/api/device.py +++ b/authentik/stages/authenticator_mobile/api/device.py @@ -1,7 +1,6 @@ """AuthenticatorMobileStage API Views""" from django_filters.rest_framework.backends import DjangoFilterBackend -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import extend_schema, inline_serializer +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse from rest_framework import mixins from rest_framework.decorators import action from rest_framework.fields import CharField, ChoiceField @@ -101,7 +100,7 @@ class MobileDeviceViewSet( ) @extend_schema( - request=OpenApiTypes.NONE, + request=None, responses={ 200: inline_serializer( "MobileDeviceEnrollmentStatusSerializer", @@ -128,7 +127,7 @@ class MobileDeviceViewSet( @extend_schema( responses={ - 204: OpenApiTypes.STR, + 204: OpenApiResponse(description="Key successfully set"), }, request=MobileDeviceSetPushKeySerializer, ) @@ -138,10 +137,10 @@ class MobileDeviceViewSet( permission_classes=[], authentication_classes=[MobileDeviceTokenAuthentication], ) - def set_notification_key(self, request: Request) -> Response: + def set_notification_key(self, request: Request, pk: str) -> Response: """Called by the phone whenever the firebase key changes and we need to update it""" device: MobileDevice = self.get_object() - data = MobileDeviceSetPushKeySerializer(data=request) + data = MobileDeviceSetPushKeySerializer(data=request.data) data.is_valid(raise_exception=True) device.firebase_token = data.validated_data["firebase_key"] device.save() @@ -153,7 +152,7 @@ class MobileDeviceViewSet( permission_classes=[], authentication_classes=[MobileDeviceTokenAuthentication], ) - def receive_response(self, request: Request) -> Response: + def receive_response(self, request: Request, pk: str) -> Response: """Get response from notification on phone""" print(request.data) return Response(status=204) diff --git a/authentik/stages/authenticator_mobile/models.py b/authentik/stages/authenticator_mobile/models.py index 4bfc9d7bc..bacf94afe 100644 --- a/authentik/stages/authenticator_mobile/models.py +++ b/authentik/stages/authenticator_mobile/models.py @@ -1,8 +1,18 @@ """Mobile authenticator stage""" from typing import Optional from uuid import uuid4 -from firebase_admin.messaging import Message, send - +from firebase_admin.messaging import ( + Message, + send, + AndroidConfig, + AndroidNotification, + APNSConfig, + APNSPayload, + Notification, + Aps, +) +from firebase_admin.exceptions import FirebaseError +from structlog.stdlib import get_logger from django.contrib.auth import get_user_model from django.db import models from django.utils.translation import gettext_lazy as _ @@ -16,6 +26,13 @@ from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage from authentik.lib.generators import generate_id from authentik.lib.models import SerializerModel +from firebase_admin import initialize_app +from firebase_admin import credentials + +cred = credentials.Certificate("firebase.json") +initialize_app(cred) + +LOGGER = get_logger() def default_token_key(): """Default token key""" @@ -78,21 +95,29 @@ class MobileDevice(SerializerModel, Device): return MobileDeviceSerializer - def send_message(self): - # See documentation on defining a message payload. + def send_message(self, **context): message = Message( - data={ - 'score': '850', - 'time': '2:45', - }, + notification=Notification( + title="$GOOG up 1.43% on the day", + body="$GOOG gained 11.80 points to close at 835.67, up 1.43% on the day.", + ), + android=AndroidConfig( + priority="normal", + notification=AndroidNotification(icon="stock_ticker_update", color="#f45342"), + ), + apns=APNSConfig( + payload=APNSPayload( + aps=Aps(badge=0), + interruption_level="time-sensitive", + ), + ), token=self.firebase_token, ) - - # Send a message to the device corresponding to the provided - # registration token. - response = send(message) - # Response is a message ID string. - print('Successfully sent message:', response) + try: + response = send(message) + LOGGER.debug("Sent notification", id=response) + except (ValueError, FirebaseError) as exc: + LOGGER.warning("failed to push", exc=exc) def __str__(self): return str(self.name) or str(self.user) diff --git a/authentik/stages/authenticator_validate/challenge.py b/authentik/stages/authenticator_validate/challenge.py index d883b903a..79ee49be2 100644 --- a/authentik/stages/authenticator_validate/challenge.py +++ b/authentik/stages/authenticator_validate/challenge.py @@ -26,6 +26,7 @@ from authentik.root.middleware import ClientIPMiddleware from authentik.stages.authenticator import match_token from authentik.stages.authenticator.models import Device from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice +from authentik.stages.authenticator_mobile.models import MobileDevice from authentik.stages.authenticator_sms.models import SMSDevice from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice @@ -176,6 +177,45 @@ def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) - return device +def validate_challenge_mobile(device_pk: str, stage_view: StageView, user: User) -> Device: + device: MobileDevice = get_object_or_404(MobileDevice, pk=device_pk) + if device.user != user: + LOGGER.warning("device mismatch") + raise Http404 + + # Get additional context for push + push_context = { + __("Domain"): stage_view.request.get_host(), + } + if SESSION_KEY_APPLICATION_PRE in stage_view.request.session: + push_context[__("Application")] = stage_view.request.session.get( + SESSION_KEY_APPLICATION_PRE, Application() + ).name + + try: + response = device.send_message(**push_context) + # {'result': 'allow', 'status': 'allow', 'status_msg': 'Success. Logging you in...'} + if response["result"] == "deny": + LOGGER.debug("mobile push response", result=response) + login_failed.send( + sender=__name__, + credentials={"username": user.username}, + request=stage_view.request, + stage=stage_view.executor.current_stage, + device_class=DeviceClasses.MOBILE.value, + mobile_response=response, + ) + raise ValidationError("Mobile denied access", code="denied") + return device + except RuntimeError as exc: + Event.new( + EventAction.CONFIGURATION_ERROR, + message=f"Failed to Mobile authenticate user: {str(exc)}", + user=user, + ).from_http(stage_view.request, user) + raise ValidationError("Mobile denied access", code="denied") + + def validate_challenge_duo(device_pk: int, stage_view: StageView, user: User) -> Device: """Duo authentication""" device = get_object_or_404(DuoDevice, pk=device_pk) diff --git a/authentik/stages/authenticator_validate/models.py b/authentik/stages/authenticator_validate/models.py index 45bf071b5..702143713 100644 --- a/authentik/stages/authenticator_validate/models.py +++ b/authentik/stages/authenticator_validate/models.py @@ -20,6 +20,7 @@ class DeviceClasses(models.TextChoices): WEBAUTHN = "webauthn", _("WebAuthn") DUO = "duo", _("Duo") SMS = "sms", _("SMS") + MOBILE = "mobile", _("authentik Mobile") def default_device_classes() -> list: diff --git a/authentik/stages/authenticator_validate/stage.py b/authentik/stages/authenticator_validate/stage.py index defd55154..ef03274b2 100644 --- a/authentik/stages/authenticator_validate/stage.py +++ b/authentik/stages/authenticator_validate/stage.py @@ -29,6 +29,7 @@ from authentik.stages.authenticator_validate.challenge import ( select_challenge, validate_challenge_code, validate_challenge_duo, + validate_challenge_mobile, validate_challenge_webauthn, ) from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses @@ -70,6 +71,7 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse): code = CharField(required=False) webauthn = JSONDictField(required=False) duo = IntegerField(required=False) + mobile = CharField(required=False) component = CharField(default="ak-stage-authenticator-validate") def _challenge_allowed(self, classes: list): @@ -100,6 +102,12 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse): self.device = validate_challenge_duo(duo, self.stage, self.stage.get_pending_user()) return duo + def validate_mobile(self, mobile: str) -> str: + """Initiate mobile authentication""" + self._challenge_allowed([DeviceClasses.MOBILE]) + self.device = validate_challenge_mobile(mobile, self.stage, self.stage.get_pending_user()) + return mobile + def validate_selected_challenge(self, challenge: dict) -> dict: """Check which challenge the user has selected. Actual logic only used for SMS stage.""" # First check if the challenge is valid @@ -134,7 +142,7 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse): def validate(self, attrs: dict): # Checking if the given data is from a valid device class is done above # Here we only check if the any data was sent at all - if "code" not in attrs and "webauthn" not in attrs and "duo" not in attrs: + if "code" not in attrs and "webauthn" not in attrs and "duo" not in attrs and "mobile" not in attrs: raise ValidationError("Empty response") self.stage.executor.plan.context.setdefault(PLAN_CONTEXT_METHOD, "auth_mfa") self.stage.executor.plan.context.setdefault(PLAN_CONTEXT_METHOD_ARGS, {}) diff --git a/blueprints/schema.json b/blueprints/schema.json index 590ae5e28..582cb0dbd 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -6693,7 +6693,8 @@ "totp", "webauthn", "duo", - "sms" + "sms", + "mobile" ], "title": "Device classes" }, diff --git a/schema.yml b/schema.yml index 3b1e55e03..bae7089ae 100644 --- a/schema.yml +++ b/schema.yml @@ -2307,11 +2307,7 @@ paths: - mobile_device_token: [] responses: '204': - content: - application/json: - schema: - type: string - description: '' + description: Key successfully set '400': content: application/json: @@ -30985,6 +30981,9 @@ components: additionalProperties: {} duo: type: integer + mobile: + type: string + minLength: 1 AuthenticatorWebAuthnChallenge: type: object description: WebAuthn Challenge @@ -31897,6 +31896,7 @@ components: - webauthn - duo - sms + - mobile type: string description: |- * `static` - Static @@ -31904,6 +31904,7 @@ components: * `webauthn` - WebAuthn * `duo` - Duo * `sms` - SMS + * `mobile` - authentik Mobile DigestAlgorithmEnum: enum: - http://www.w3.org/2000/09/xmldsig#sha1 diff --git a/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStage.ts b/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStage.ts index 403a80756..fc555d85e 100644 --- a/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStage.ts +++ b/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStage.ts @@ -1,6 +1,7 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import "@goauthentik/flow/stages/authenticator_validate/AuthenticatorValidateStageCode"; import "@goauthentik/flow/stages/authenticator_validate/AuthenticatorValidateStageDuo"; +import "@goauthentik/flow/stages/authenticator_validate/AuthenticatorValidateStageMobile"; import "@goauthentik/flow/stages/authenticator_validate/AuthenticatorValidateStageWebAuthn"; import { BaseStage, StageHost } from "@goauthentik/flow/stages/base"; import { PasswordManagerPrefill } from "@goauthentik/flow/stages/identification/IdentificationStage"; @@ -118,6 +119,12 @@ export class AuthenticatorValidateStage
${msg("Duo push-notifications")}
${msg("Receive a push notification on your device.")} `; + case DeviceClassesEnum.Mobile: + return html` +${msg("Push-notifications")}
+ ${msg("Receive a push notification on your device.")} +