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.")} +
`; case DeviceClassesEnum.Webauthn: return html`
@@ -221,6 +228,14 @@ export class AuthenticatorValidateStage .showBackButton=${(this.challenge?.deviceChallenges || []).length > 1} > `; + case DeviceClassesEnum.Mobile: + return html` 1} + > + `; } return html``; } diff --git a/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStageMobile.ts b/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStageMobile.ts new file mode 100644 index 000000000..adaa79e92 --- /dev/null +++ b/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStageMobile.ts @@ -0,0 +1,104 @@ +import "@goauthentik/elements/EmptyState"; +import "@goauthentik/elements/forms/FormElement"; +import "@goauthentik/flow/FormStatic"; +import { AuthenticatorValidateStage } from "@goauthentik/flow/stages/authenticator_validate/AuthenticatorValidateStage"; +import { BaseStage } from "@goauthentik/flow/stages/base"; + +import { msg } from "@lit/localize"; +import { CSSResult, TemplateResult, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import PFButton from "@patternfly/patternfly/components/Button/button.css"; +import PFForm from "@patternfly/patternfly/components/Form/form.css"; +import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; +import PFLogin from "@patternfly/patternfly/components/Login/login.css"; +import PFTitle from "@patternfly/patternfly/components/Title/title.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +import { + AuthenticatorValidationChallenge, + AuthenticatorValidationChallengeResponseRequest, + DeviceChallenge, +} from "@goauthentik/api"; + +@customElement("ak-stage-authenticator-validate-mobile") +export class AuthenticatorValidateStageWebMobile extends BaseStage< + AuthenticatorValidationChallenge, + AuthenticatorValidationChallengeResponseRequest +> { + @property({ attribute: false }) + deviceChallenge?: DeviceChallenge; + + @property({ type: Boolean }) + showBackButton = false; + + static get styles(): CSSResult[] { + return [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, PFButton]; + } + + firstUpdated(): void { + this.host?.submit({ + duo: this.deviceChallenge?.deviceUid, + }); + } + + render(): TemplateResult { + if (!this.challenge) { + return html` + `; + } + const errors = this.challenge.responseErrors?.duo || []; + return html`
+
{ + this.submitForm(e); + }} + > + +
+ ${msg("Not you?")} +
+
+ + ${errors.length > 0 + ? errors.map((err) => { + if (err.code === "denied") { + return html` + `; + } + return html`

${err.string}

`; + }) + : html`${msg("Sending Duo push notification")}`} +
+
+ `; + } +}