From 55f53e64e98e3ef8d8c2675b3792ae2c3360ae26 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Thu, 14 Dec 2023 23:17:49 +0100 Subject: [PATCH] add UI to show code, add validation Signed-off-by: Jens Langhammer --- authentik/events/middleware.py | 2 + .../stages/authenticator_mobile/models.py | 33 ++++++++--- .../stages/authenticator_mobile/stage.py | 2 + .../authenticator_validate/challenge.py | 58 +++++++++---------- .../stages/authenticator_validate/stage.py | 15 +++-- .../AuthenticatorValidateStageMobile.ts | 42 +++++++++++++- 6 files changed, 105 insertions(+), 47 deletions(-) diff --git a/authentik/events/middleware.py b/authentik/events/middleware.py index ea7e6001f..eb2bdc19a 100644 --- a/authentik/events/middleware.py +++ b/authentik/events/middleware.py @@ -31,6 +31,7 @@ from authentik.policies.models import Policy, PolicyBindingModel from authentik.policies.reputation.models import Reputation from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken from authentik.providers.scim.models import SCIMGroup, SCIMUser +from authentik.stages.authenticator_mobile.models import MobileTransaction from authentik.stages.authenticator_static.models import StaticToken IGNORED_MODELS = ( @@ -56,6 +57,7 @@ IGNORED_MODELS = ( SCIMGroup, Reputation, ConnectionToken, + MobileTransaction, ) diff --git a/authentik/stages/authenticator_mobile/models.py b/authentik/stages/authenticator_mobile/models.py index 979e55fcb..4953e5e1b 100644 --- a/authentik/stages/authenticator_mobile/models.py +++ b/authentik/stages/authenticator_mobile/models.py @@ -1,5 +1,6 @@ """Mobile authenticator stage""" from json import dumps +from secrets import choice from time import sleep from typing import Optional from uuid import uuid4 @@ -61,11 +62,22 @@ class AuthenticatorMobileStage(ConfigurableStage, FriendlyNamedStage, Stage): """Create a transaction for `device` with the config of this stage.""" transaction = MobileTransaction(device=device) if self.item_matching_mode == ItemMatchingMode.ACCEPT_DENY: - transaction.item_matching = [TransactionStates.ACCEPT, TransactionStates.DENY] + transaction.decision_items = [TransactionStates.ACCEPT, TransactionStates.DENY] + transaction.correct_item = TransactionStates.ACCEPT if self.item_matching_mode == ItemMatchingMode.NUMBER_MATCHING_2: - transaction.item_matching = [generate_code_fixed_length(2)] * 3 + transaction.decision_items = [ + generate_code_fixed_length(2), + generate_code_fixed_length(2), + generate_code_fixed_length(2), + ] + transaction.correct_item = choice(transaction.decision_items) if self.item_matching_mode == ItemMatchingMode.NUMBER_MATCHING_3: - transaction.item_matching = [generate_code_fixed_length(3)] * 3 + transaction.decision_items = [ + generate_code_fixed_length(3), + generate_code_fixed_length(3), + generate_code_fixed_length(3), + ] + transaction.correct_item = choice(transaction.decision_items) transaction.save() return transaction @@ -160,6 +172,9 @@ class MobileTransaction(ExpiringModel): """Get the status""" if not self.selected_item: return TransactionStates.WAIT + # These are two different failure cases, but currently they are handled the same + if self.selected_item not in self.decision_items: + return TransactionStates.DENY if self.selected_item != self.correct_item: return TransactionStates.DENY return TransactionStates.ACCEPT @@ -190,8 +205,8 @@ class MobileTransaction(ExpiringModel): priority="normal", notification=AndroidNotification(icon="stock_ticker_update", color="#f45342"), data={ - "tx_id": str(self.tx_id), - "user_decision_items": dumps(self.item_matching), + "authentik_tx_id": str(self.tx_id), + "authentik_user_decision_items": dumps(self.decision_items), }, ), apns=APNSConfig( @@ -204,17 +219,17 @@ class MobileTransaction(ExpiringModel): category="cat_authentik_push_authorization", ), interruption_level="time-sensitive", - tx_id=str(self.tx_id), - user_decision_items=self.item_matching, + authentik_tx_id=str(self.tx_id), + authentik_user_decision_items=self.decision_items, ), ), token=self.device.firebase_token, ) try: response = send(message, app=app) - LOGGER.debug("Sent notification", id=response) + LOGGER.debug("Sent notification", id=response, tx_id=self.tx_id) except (ValueError, FirebaseError) as exc: - LOGGER.warning("failed to push", exc=exc) + LOGGER.warning("failed to push", exc=exc, tx_id=self.tx_id) return True def wait_for_response(self, max_checks=30) -> TransactionStates: diff --git a/authentik/stages/authenticator_mobile/stage.py b/authentik/stages/authenticator_mobile/stage.py index 86be1f52b..b4e560af7 100644 --- a/authentik/stages/authenticator_mobile/stage.py +++ b/authentik/stages/authenticator_mobile/stage.py @@ -16,6 +16,8 @@ from authentik.stages.authenticator_mobile.models import MobileDevice, MobileDev FLOW_PLAN_MOBILE_ENROLL_TOKEN = "authentik/stages/authenticator_mobile/enroll/token" # nosec FLOW_PLAN_MOBILE_ENROLL_DEVICE = "authentik/stages/authenticator_mobile/enroll/device" +SESSION_KEY_MOBILE_TRANSACTION = "authentik/stages/authenticator_mobile/transaction" + class AuthenticatorMobilePayloadChallenge(PassiveSerializer): """Payload within the QR code given to the mobile app, hence the short variable names""" diff --git a/authentik/stages/authenticator_validate/challenge.py b/authentik/stages/authenticator_validate/challenge.py index 78b858557..1fa125c8a 100644 --- a/authentik/stages/authenticator_validate/challenge.py +++ b/authentik/stages/authenticator_validate/challenge.py @@ -22,11 +22,13 @@ from authentik.core.signals import login_failed from authentik.events.models import Event, EventAction from authentik.flows.stage import StageView from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE +from authentik.lib.utils.errors import exception_to_string 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, TransactionStates +from authentik.stages.authenticator_mobile.stage import SESSION_KEY_MOBILE_TRANSACTION 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 @@ -50,6 +52,8 @@ def get_challenge_for_device( """Generate challenge for a single device""" if isinstance(device, WebAuthnDevice): return get_webauthn_challenge(request, stage, device) + if isinstance(device, MobileDevice): + return get_mobile_challenge(request, stage, device) # Code-based challenges have no hints return {} @@ -105,7 +109,20 @@ def get_webauthn_challenge( ) -def select_challenge(request: HttpRequest,stage_view: StageView, device: Device): +def get_mobile_challenge( + request: HttpRequest, stage: AuthenticatorValidateStage, device: Optional[MobileDevice] = None +) -> dict: + """Create a mobile transaction""" + request.session.pop(SESSION_KEY_MOBILE_TRANSACTION, None) + transaction = device.create_transaction() + request.session[SESSION_KEY_MOBILE_TRANSACTION] = transaction + return { + "item_mode": transaction.device.stage.item_matching_mode, + "item": transaction.correct_item, + } + + +def select_challenge(request: HttpRequest, stage_view: StageView, device: Device): """Callback when the user selected a challenge in the frontend.""" if isinstance(device, SMSDevice): select_challenge_sms(request, stage_view, device) @@ -129,31 +146,19 @@ def select_challenge_mobile(request: HttpRequest, stage_view: StageView, device: push_context[__("Application")] = stage_view.request.session.get( SESSION_KEY_APPLICATION_PRE, Application() ).name + if SESSION_KEY_MOBILE_TRANSACTION not in request.session: + raise ValidationError() try: - transaction = device.create_transaction() + transaction = request.session.get(SESSION_KEY_MOBILE_TRANSACTION) transaction.send_message(stage_view.request, **push_context) - status = transaction.wait_for_response() - if status == TransactionStates.DENY: - LOGGER.debug("mobile push response", result=status) - 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=status, - ) - raise ValidationError("Mobile denied access", code="denied") - return device - except TimeoutError: - raise ValidationError("Mobile push notification timed out.") 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) + message="Failed to Mobile authenticate user", + exception=exception_to_string(exc), + user=device.user, + ).from_http(stage_view.request, device.user) raise ValidationError("Mobile denied access", code="denied") @@ -224,18 +229,9 @@ def validate_challenge_mobile(device_pk: str, stage_view: StageView, 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 + transaction = stage_view.request.session[SESSION_KEY_MOBILE_TRANSACTION] try: - transaction = device.create_transaction() - transaction.send_message(stage_view.request, **push_context) status = transaction.wait_for_response() if status == TransactionStates.DENY: LOGGER.debug("mobile push response", result=status) @@ -258,6 +254,8 @@ def validate_challenge_mobile(device_pk: str, stage_view: StageView, user: User) user=user, ).from_http(stage_view.request, user) raise ValidationError("Mobile denied access", code="denied") + finally: + stage_view.request.session.delete(SESSION_KEY_MOBILE_TRANSACTION) def validate_challenge_duo(device_pk: int, stage_view: StageView, user: User) -> Device: diff --git a/authentik/stages/authenticator_validate/stage.py b/authentik/stages/authenticator_validate/stage.py index ce88d094e..a379732d6 100644 --- a/authentik/stages/authenticator_validate/stage.py +++ b/authentik/stages/authenticator_validate/stage.py @@ -21,6 +21,7 @@ from authentik.lib.utils.time import timedelta_from_string from authentik.root.install_id import get_install_id from authentik.stages.authenticator import devices_for_user from authentik.stages.authenticator.models import Device +from authentik.stages.authenticator_mobile.models import MobileDevice from authentik.stages.authenticator_sms.models import SMSDevice from authentik.stages.authenticator_validate.challenge import ( DeviceChallenge, @@ -122,12 +123,16 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse): if not allowed: raise ValidationError("invalid challenge selected") - if challenge.get("device_class", "") != "sms": - return challenge - devices = SMSDevice.objects.filter(pk=int(challenge.get("device_uid", "0"))) - if not devices.exists(): + device = None + match challenge.get("device_class", ""): + # This is a bit unclean and hardcoded, but alas + case "mobile": + device = MobileDevice.objects.filter(pk=challenge.get("device_uid")).first() + case "sms": + device = SMSDevice.objects.filter(pk=int(challenge.get("device_uid"))).first() + if not device: raise ValidationError("invalid challenge selected") - select_challenge(self.stage.request, self.stage, devices.first()) + select_challenge(self.stage.request, self.stage, device) return challenge def validate_selected_stage(self, stage_pk: str) -> str: diff --git a/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStageMobile.ts b/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStageMobile.ts index 719070e55..f6a8043f7 100644 --- a/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStageMobile.ts +++ b/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStageMobile.ts @@ -5,11 +5,12 @@ import { AuthenticatorValidateStage } from "@goauthentik/flow/stages/authenticat import { BaseStage } from "@goauthentik/flow/stages/base"; import { msg } from "@lit/localize"; -import { CSSResult, TemplateResult, html } from "lit"; +import { CSSResult, TemplateResult, css, 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 PFContent from "@patternfly/patternfly/components/Content/content.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"; @@ -20,6 +21,7 @@ import { AuthenticatorValidationChallenge, AuthenticatorValidationChallengeResponseRequest, DeviceChallenge, + ItemMatchingModeEnum, } from "@goauthentik/api"; @customElement("ak-stage-authenticator-validate-mobile") @@ -34,13 +36,32 @@ export class AuthenticatorValidateStageWebMobile extends BaseStage< showBackButton = false; static get styles(): CSSResult[] { - return [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, PFButton]; + return [ + PFBase, + PFContent, + PFLogin, + PFForm, + PFFormControl, + PFTitle, + PFButton, + css` + .pf-c-content { + display: flex; + flex-direction: row; + justify-content: center; + } + .pf-c-content h1 { + font-size: calc(var(--pf-c-content--h1--FontSize) * 2); + } + `, + ]; } firstUpdated(): void { this.host?.submit({ mobile: this.deviceChallenge?.deviceUid, }); + this.host.loading = false; } render(): TemplateResult { @@ -49,6 +70,21 @@ export class AuthenticatorValidateStageWebMobile extends BaseStage< `; } const errors = this.challenge.responseErrors?.mobile || []; + const challengeData = this.deviceChallenge?.challenge as { + item_mode: ItemMatchingModeEnum; + item: string; + }; + let body = html``; + if ( + challengeData.item_mode === ItemMatchingModeEnum.NumberMatching2 || + challengeData.item_mode === ItemMatchingModeEnum.NumberMatching3 + ) { + body = html` +
+

${challengeData.item}

+
+ `; + } return html` - + ${body} ${errors.length > 0 ? errors.map((err) => { if (err.code === "denied") {