diff --git a/authentik/stages/authenticator_mobile/api/device.py b/authentik/stages/authenticator_mobile/api/device.py index c773c22fa..f9302c2a4 100644 --- a/authentik/stages/authenticator_mobile/api/device.py +++ b/authentik/stages/authenticator_mobile/api/device.py @@ -4,7 +4,7 @@ from django_filters.rest_framework.backends import DjangoFilterBackend from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer from rest_framework import mixins from rest_framework.decorators import action -from rest_framework.fields import CharField, ChoiceField +from rest_framework.fields import CharField, ChoiceField, UUIDField from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.permissions import IsAdminUser from rest_framework.request import Request @@ -70,7 +70,7 @@ class MobileDeviceSetPushKeySerializer(PassiveSerializer): class MobileDeviceResponseSerializer(PassiveSerializer): """Response from push sent to phone""" - tx_id = CharField(required=True) + tx_id = UUIDField(required=True) status = ChoiceField( TransactionStates.choices, required=True, @@ -205,11 +205,12 @@ class MobileDeviceViewSet( def receive_response(self, request: Request, pk: str) -> Response: """Get response from notification on phone""" data = MobileDeviceResponseSerializer(data=request.data) - data.is_valid() + data.is_valid(raise_exception=True) transaction = MobileTransaction.objects.filter(tx_id=data.validated_data["tx_id"]).first() if not transaction: raise Http404 transaction.status = data.validated_data["status"] + transaction.save() return Response(status=204) diff --git a/authentik/stages/authenticator_mobile/models.py b/authentik/stages/authenticator_mobile/models.py index aed0ea710..c552e2677 100644 --- a/authentik/stages/authenticator_mobile/models.py +++ b/authentik/stages/authenticator_mobile/models.py @@ -1,4 +1,5 @@ """Mobile authenticator stage""" +from time import sleep from typing import Optional from uuid import uuid4 @@ -128,9 +129,6 @@ class MobileTransaction(ExpiringModel): branding = request.tenant.branding_title domain = request.get_host() message = Message( - data={ - "tx_id": str(self.tx_id), - }, notification=Notification( title=__("%(brand)s authentication request" % {"brand": branding}), body=__( @@ -155,6 +153,7 @@ class MobileTransaction(ExpiringModel): category="cat_authentik_push_authorization", ), interruption_level="time-sensitive", + tx_id=str(self.tx_id), ), ), token=self.device.firebase_token, @@ -166,6 +165,20 @@ class MobileTransaction(ExpiringModel): LOGGER.warning("failed to push", exc=exc) 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() + raise TimeoutError() + sleep(1) + class MobileDeviceToken(ExpiringModel): """Mobile device token""" diff --git a/authentik/stages/authenticator_validate/challenge.py b/authentik/stages/authenticator_validate/challenge.py index acd32a226..4b23860d4 100644 --- a/authentik/stages/authenticator_validate/challenge.py +++ b/authentik/stages/authenticator_validate/challenge.py @@ -26,7 +26,11 @@ 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, MobileTransaction +from authentik.stages.authenticator_mobile.models import ( + MobileDevice, + MobileTransaction, + TransactionStates, +) 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 @@ -194,20 +198,22 @@ def validate_challenge_mobile(device_pk: str, stage_view: StageView, user: User) try: tx = MobileTransaction.objects.create(device=device) - response = tx.send_message(stage_view.request, **push_context) - # {'result': 'allow', 'status': 'allow', 'status_msg': 'Success. Logging you in...'} - if not response: - LOGGER.debug("mobile push response", result=response) + tx.send_message(stage_view.request, **push_context) + status = tx.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=response, + 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, diff --git a/schema.yml b/schema.yml index 36249ed11..7fd141ce9 100644 --- a/schema.yml +++ b/schema.yml @@ -34352,7 +34352,7 @@ components: properties: tx_id: type: string - minLength: 1 + format: uuid status: $ref: '#/components/schemas/MobileDeviceResponseStatusEnum' required: