diff --git a/authentik/providers/oauth2/views/token.py b/authentik/providers/oauth2/views/token.py index 6e41449fd..d286da06b 100644 --- a/authentik/providers/oauth2/views/token.py +++ b/authentik/providers/oauth2/views/token.py @@ -9,7 +9,7 @@ from typing import Any, Optional from django.http import HttpRequest, HttpResponse from django.utils.timezone import datetime, now from django.views import View -from jwt import InvalidTokenError, PyJWK, decode +from jwt import PyJWK, PyJWTError, decode from sentry_sdk.hub import Hub from structlog.stdlib import get_logger @@ -302,8 +302,8 @@ class TokenParams: "verify_aud": False, }, ) - except (InvalidTokenError, ValueError, TypeError) as last_exc: - LOGGER.warning("failed to validate jwt", last_exc=last_exc) + except (PyJWTError, ValueError, TypeError) as exc: + LOGGER.warning("failed to validate jwt", exc=exc) # TODO: End remove block source: Optional[OAuthSource] = None @@ -325,7 +325,7 @@ class TokenParams: ) # AttributeError is raised when the configured JWK is a private key # and not a public key - except (InvalidTokenError, ValueError, TypeError, AttributeError) as exc: + except (PyJWTError, ValueError, TypeError, AttributeError) as exc: LOGGER.warning("failed to validate jwt", exc=exc) if not token: diff --git a/authentik/stages/authenticator_validate/challenge.py b/authentik/stages/authenticator_validate/challenge.py index 6043ea143..cf25f326a 100644 --- a/authentik/stages/authenticator_validate/challenge.py +++ b/authentik/stages/authenticator_validate/challenge.py @@ -91,13 +91,13 @@ def select_challenge_sms(request: HttpRequest, device: SMSDevice): device.stage.send(device.token, device) -def validate_challenge_code(code: str, request: HttpRequest, user: User) -> str: +def validate_challenge_code(code: str, request: HttpRequest, user: User) -> Device: """Validate code-based challenges. We test against every device, on purpose, as the user mustn't choose between totp and static devices.""" device = match_token(user, code) if not device: raise ValidationError(_("Invalid Token")) - return code + return device # pylint: disable=unused-argument @@ -129,7 +129,7 @@ def validate_challenge_webauthn(data: dict, request: HttpRequest, user: User) -> return device -def validate_challenge_duo(device_pk: int, request: HttpRequest, user: User) -> int: +def validate_challenge_duo(device_pk: int, request: HttpRequest, user: User) -> Device: """Duo authentication""" device = get_object_or_404(DuoDevice, pk=device_pk) if device.user != user: @@ -148,4 +148,4 @@ def validate_challenge_duo(device_pk: int, request: HttpRequest, user: User) -> if response["result"] == "deny": raise ValidationError("Duo denied access") device.save() - return device_pk + return device diff --git a/authentik/stages/authenticator_validate/stage.py b/authentik/stages/authenticator_validate/stage.py index bb6565454..6dab05b8d 100644 --- a/authentik/stages/authenticator_validate/stage.py +++ b/authentik/stages/authenticator_validate/stage.py @@ -1,10 +1,13 @@ """Authenticator Validation""" -from datetime import timezone +from datetime import datetime +from hashlib import sha256 +from typing import Optional +from django.conf import settings from django.http import HttpRequest, HttpResponse -from django.utils.timezone import datetime, now from django_otp import devices_for_user from django_otp.models import Device +from jwt import PyJWTError, decode, encode from rest_framework.fields import CharField, IntegerField, JSONField, ListField, UUIDField from rest_framework.serializers import ValidationError from structlog.stdlib import get_logger @@ -34,6 +37,7 @@ from authentik.stages.authenticator_webauthn.models import WebAuthnDevice from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS LOGGER = get_logger() +COOKIE_NAME_MFA = "authentik_mfa" SESSION_STAGES = "goauthentik.io/stages/authenticator_validate/stages" SESSION_SELECTED_STAGE = "goauthentik.io/stages/authenticator_validate/selected_stage" SESSION_DEVICE_CHALLENGES = "goauthentik.io/stages/authenticator_validate/device_challenges" @@ -59,6 +63,8 @@ class AuthenticatorValidationChallenge(WithUserInfoChallenge): class AuthenticatorValidationChallengeResponse(ChallengeResponse): """Challenge used for Code-based and WebAuthn authenticators""" + device: Optional[Device] + selected_challenge = DeviceChallenge(required=False) selected_stage = CharField(required=False) @@ -68,33 +74,40 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse): component = CharField(default="ak-stage-authenticator-validate") def _challenge_allowed(self, classes: list): - device_challenges: list[dict] = self.stage.request.session.get(SESSION_DEVICE_CHALLENGES) + device_challenges: list[dict] = self.stage.request.session.get( + SESSION_DEVICE_CHALLENGES, [] + ) if not any(x["device_class"] in classes for x in device_challenges): raise ValidationError("No compatible device class allowed") def validate_code(self, code: str) -> str: """Validate code-based response, raise error if code isn't allowed""" self._challenge_allowed([DeviceClasses.TOTP, DeviceClasses.STATIC, DeviceClasses.SMS]) - return validate_challenge_code(code, self.stage.request, self.stage.get_pending_user()) + self.device = validate_challenge_code( + code, self.stage.request, self.stage.get_pending_user() + ) + return code def validate_webauthn(self, webauthn: dict) -> dict: """Validate webauthn response, raise error if webauthn wasn't allowed or response is invalid""" self._challenge_allowed([DeviceClasses.WEBAUTHN]) - return validate_challenge_webauthn( + self.device = validate_challenge_webauthn( webauthn, self.stage.request, self.stage.get_pending_user() ) + return webauthn def validate_duo(self, duo: int) -> int: """Initiate Duo authentication""" self._challenge_allowed([DeviceClasses.DUO]) - return validate_challenge_duo(duo, self.stage.request, self.stage.get_pending_user()) + self.device = validate_challenge_duo(duo, self.stage.request, self.stage.get_pending_user()) + return duo 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 allowed = False - for device_challenge in self.stage.request.session.get(SESSION_DEVICE_CHALLENGES): + for device_challenge in self.stage.request.session.get(SESSION_DEVICE_CHALLENGES, []): if device_challenge.get("device_class", "") == challenge.get( "device_class", "" ) and device_challenge.get("device_uid", "") == challenge.get("device_uid", ""): @@ -127,15 +140,6 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse): return attrs -def get_device_last_usage(device: Device) -> datetime: - """Get a datetime object from last_t""" - if not hasattr(device, "last_t"): - return datetime.fromtimestamp(0, tz=timezone.utc) - if isinstance(device.last_t, datetime): - return device.last_t - return datetime.fromtimestamp(device.last_t * device.step, tz=timezone.utc) - - class AuthenticatorValidateStageView(ChallengeStageView): """Authenticator Validation""" @@ -154,23 +158,19 @@ class AuthenticatorValidateStageView(ChallengeStageView): stage: AuthenticatorValidateStage = self.executor.current_stage - _now = now() threshold = timedelta_from_string(stage.last_auth_threshold) + allowed_devices = [] for device in user_devices: device_class = device.__class__.__name__.lower().replace("device", "") if device_class not in stage.device_classes: LOGGER.debug("device class not allowed", device_class=device_class) continue + allowed_devices.append(device) # Ensure only one challenge per device class # WebAuthn does another device loop to find all webuahtn devices if device_class in seen_classes: continue - # check if device has been used within threshold and skip this stage if so - if threshold.total_seconds() > 0: - if _now - get_device_last_usage(device) <= threshold: - LOGGER.info("Device has been used within threshold", device=device) - raise FlowSkipStageException() if device_class not in seen_classes: seen_classes.append(device_class) challenge = DeviceChallenge( @@ -183,6 +183,9 @@ class AuthenticatorValidateStageView(ChallengeStageView): challenge.is_valid() challenges.append(challenge.data) LOGGER.debug("adding challenge for device", challenge=challenge) + # check if we have an MFA cookie and if it's valid + if threshold.total_seconds() > 0: + self.check_mfa_cookie(allowed_devices) return challenges def get_userless_webauthn_challenge(self) -> list[dict]: @@ -301,6 +304,68 @@ class AuthenticatorValidateStageView(ChallengeStageView): } ) + @property + def cookie_jwt_key(self) -> str: + """Signing key for MFA Cookie for this stage""" + return sha256( + f"{settings.SECRET_KEY}:{self.executor.current_stage.pk.hex}".encode("ascii") + ).hexdigest() + + def check_mfa_cookie(self, allowed_devices: list[Device]): + """Check if an MFA cookie has been set, whether it's valid and applies + to the current stage and device. + + The list of devices passed to this function must only contain devices for the + correct user and with an allowed class""" + if COOKIE_NAME_MFA not in self.request.COOKIES: + return + stage: AuthenticatorValidateStage = self.executor.current_stage + threshold = timedelta_from_string(stage.last_auth_threshold) + latest_allowed = datetime.now() + threshold + try: + payload = decode(self.request.COOKIES[COOKIE_NAME_MFA], self.cookie_jwt_key, ["HS256"]) + if payload["stage"] != stage.pk.hex: + LOGGER.warning("Invalid stage PK") + return + if datetime.fromtimestamp(payload["exp"]) > latest_allowed: + LOGGER.warning("Expired MFA cookie") + return + if not any(device.pk == payload["device"] for device in allowed_devices): + LOGGER.warning("Invalid device PK") + return + LOGGER.info("MFA has been used within threshold") + raise FlowSkipStageException() + except (PyJWTError, ValueError, TypeError) as exc: + LOGGER.info("Invalid mfa cookie for device", exc=exc) + + def set_valid_mfa_cookie(self, device: Device) -> HttpResponse: + """Set an MFA cookie to allow users to skip MFA validation in this context (browser) + + The cookie is JWT which is signed with a hash of the secret key and the UID of the stage""" + stage: AuthenticatorValidateStage = self.executor.current_stage + delta = timedelta_from_string(stage.last_auth_threshold) + if delta.total_seconds() < 1: + LOGGER.info("Not setting MFA cookie since threshold is not set.") + return self.executor.stage_ok() + expiry = datetime.now() + delta + cookie_payload = { + "device": device.pk, + "stage": stage.pk.hex, + "exp": expiry.timestamp(), + } + response = self.executor.stage_ok() + cookie = encode(cookie_payload, self.cookie_jwt_key) + response.set_cookie( + COOKIE_NAME_MFA, + cookie, + expires=expiry, + path="/", + max_age=delta, + domain=settings.SESSION_COOKIE_DOMAIN, + samesite="Lax", + ) + return response + # pylint: disable=unused-argument def challenge_valid(self, response: AuthenticatorValidationChallengeResponse) -> HttpResponse: # All validation is done by the serializer @@ -309,7 +374,7 @@ class AuthenticatorValidateStageView(ChallengeStageView): webauthn_device: WebAuthnDevice = response.data.get("webauthn", None) if not webauthn_device: return self.executor.stage_ok() - LOGGER.debug("Set user from userless flow", user=webauthn_device.user) + LOGGER.debug("Set user from user-less flow", user=webauthn_device.user) self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = webauthn_device.user self.executor.plan.context[PLAN_CONTEXT_METHOD] = "auth_webauthn_pwl" self.executor.plan.context[PLAN_CONTEXT_METHOD_ARGS] = cleanse_dict( @@ -319,4 +384,4 @@ class AuthenticatorValidateStageView(ChallengeStageView): } ) ) - return self.executor.stage_ok() + return self.set_valid_mfa_cookie(response.device) diff --git a/authentik/stages/authenticator_validate/tests/test_duo.py b/authentik/stages/authenticator_validate/tests/test_duo.py index ce76469af..ada3867b6 100644 --- a/authentik/stages/authenticator_validate/tests/test_duo.py +++ b/authentik/stages/authenticator_validate/tests/test_duo.py @@ -45,9 +45,7 @@ class AuthenticatorValidateStageDuoTests(FlowTestCase): "authentik.stages.authenticator_duo.models.AuthenticatorDuoStage.client", duo_mock, ): - self.assertEqual( - duo_device.pk, validate_challenge_duo(duo_device.pk, request, self.user) - ) + self.assertEqual(duo_device, validate_challenge_duo(duo_device.pk, request, self.user)) with patch( "authentik.stages.authenticator_duo.models.AuthenticatorDuoStage.client", failed_duo_mock, diff --git a/authentik/stages/authenticator_validate/tests/test_sms.py b/authentik/stages/authenticator_validate/tests/test_sms.py index f68a5d171..8c4afaf32 100644 --- a/authentik/stages/authenticator_validate/tests/test_sms.py +++ b/authentik/stages/authenticator_validate/tests/test_sms.py @@ -1,5 +1,5 @@ """Test validator stage""" -from time import sleep +from unittest.mock import MagicMock, patch from django.test.client import RequestFactory from django.urls.base import reverse @@ -9,6 +9,7 @@ from authentik.flows.models import Flow, FlowStageBinding, NotConfiguredAction from authentik.flows.tests import FlowTestCase from authentik.stages.authenticator_sms.models import AuthenticatorSMSStage, SMSDevice, SMSProviders from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses +from authentik.stages.authenticator_validate.stage import COOKIE_NAME_MFA from authentik.stages.identification.models import IdentificationStage, UserFields @@ -26,7 +27,7 @@ class AuthenticatorValidateStageSMSTests(FlowTestCase): def test_last_auth_threshold(self): """Test last_auth_threshold""" - conf_stage = IdentificationStage.objects.create( + ident_stage = IdentificationStage.objects.create( name="conf", user_fields=[ UserFields.USERNAME, @@ -37,19 +38,16 @@ class AuthenticatorValidateStageSMSTests(FlowTestCase): confirmed=True, stage=self.stage, ) - # Verify token once here to set last_t etc - token = device.generate_token() - device.verify_token(token) + stage = AuthenticatorValidateStage.objects.create( name="foo", last_auth_threshold="milliseconds=0", not_configured_action=NotConfiguredAction.CONFIGURE, device_classes=[DeviceClasses.SMS], ) - sleep(1) - stage.configuration_stages.set([conf_stage]) + stage.configuration_stages.set([ident_stage]) flow = Flow.objects.create(name="test", slug="test", title="test") - FlowStageBinding.objects.create(target=flow, stage=conf_stage, order=0) + FlowStageBinding.objects.create(target=flow, stage=ident_stage, order=0) FlowStageBinding.objects.create(target=flow, stage=stage, order=1) response = self.client.post( @@ -57,19 +55,16 @@ class AuthenticatorValidateStageSMSTests(FlowTestCase): {"uid_field": self.user.username}, ) self.assertEqual(response.status_code, 302) - response = self.client.get( + device.generate_token() + response = self.client.post( reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), - follow=True, - ) - self.assertStageResponse( - response, - flow, - component="ak-stage-authenticator-validate", + {"code": device.token}, ) + self.assertNotIn(COOKIE_NAME_MFA, response.cookies) def test_last_auth_threshold_valid(self): """Test last_auth_threshold""" - conf_stage = IdentificationStage.objects.create( + ident_stage = IdentificationStage.objects.create( name="conf", user_fields=[ UserFields.USERNAME, @@ -80,27 +75,44 @@ class AuthenticatorValidateStageSMSTests(FlowTestCase): confirmed=True, stage=self.stage, ) - # Verify token once here to set last_t etc - token = device.generate_token() - device.verify_token(token) + stage = AuthenticatorValidateStage.objects.create( name="foo", last_auth_threshold="hours=1", not_configured_action=NotConfiguredAction.CONFIGURE, device_classes=[DeviceClasses.SMS], ) - stage.configuration_stages.set([conf_stage]) + stage.configuration_stages.set([ident_stage]) flow = Flow.objects.create(name="test", slug="test", title="test") - FlowStageBinding.objects.create(target=flow, stage=conf_stage, order=0) + FlowStageBinding.objects.create(target=flow, stage=ident_stage, order=0) FlowStageBinding.objects.create(target=flow, stage=stage, order=1) response = self.client.post( reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), {"uid_field": self.user.username}, - ) - self.assertEqual(response.status_code, 302) - response = self.client.get( - reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), follow=True, ) + self.assertEqual(response.status_code, 200) + send_mock = MagicMock() + with patch( + "authentik.stages.authenticator_sms.models.AuthenticatorSMSStage.send", send_mock + ): + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), + { + "component": "ak-stage-authenticator-validate", + "selected_challenge": { + "device_class": "sms", + "device_uid": str(device.pk), + "challenge": {}, + }, + }, + ) + self.assertEqual(send_mock.call_count, 1) + device.generate_token() + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), + {"code": device.token}, + ) + self.assertIn(COOKIE_NAME_MFA, response.cookies) self.assertStageResponse(response, component="xak-flow-redirect", to="/") diff --git a/authentik/stages/authenticator_validate/tests/test_totp.py b/authentik/stages/authenticator_validate/tests/test_totp.py index dc4101159..d5b5f9ed6 100644 --- a/authentik/stages/authenticator_validate/tests/test_totp.py +++ b/authentik/stages/authenticator_validate/tests/test_totp.py @@ -1,20 +1,26 @@ """Test validator stage""" +from datetime import datetime, timedelta +from hashlib import sha256 +from http.cookies import SimpleCookie from time import sleep +from django.conf import settings from django.test.client import RequestFactory from django.urls.base import reverse from django_otp.oath import TOTP from django_otp.plugins.otp_totp.models import TOTPDevice +from jwt import encode from rest_framework.exceptions import ValidationError -from authentik.core.tests.utils import create_test_admin_user -from authentik.flows.models import Flow, FlowStageBinding, NotConfiguredAction +from authentik.core.tests.utils import create_test_admin_user, create_test_flow +from authentik.flows.models import FlowDesignation, FlowStageBinding, NotConfiguredAction from authentik.flows.tests import FlowTestCase from authentik.stages.authenticator_validate.challenge import ( get_challenge_for_device, validate_challenge_code, ) from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses +from authentik.stages.authenticator_validate.stage import COOKIE_NAME_MFA from authentik.stages.identification.models import IdentificationStage, UserFields @@ -24,10 +30,11 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase): def setUp(self) -> None: self.user = create_test_admin_user() self.request_factory = RequestFactory() + self.flow = create_test_flow(FlowDesignation.AUTHENTICATION) def test_last_auth_threshold(self): """Test last_auth_threshold""" - conf_stage = IdentificationStage.objects.create( + ident_stage = IdentificationStage.objects.create( name="conf", user_fields=[ UserFields.USERNAME, @@ -47,29 +54,28 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase): not_configured_action=NotConfiguredAction.CONFIGURE, device_classes=[DeviceClasses.TOTP], ) - stage.configuration_stages.set([conf_stage]) - flow = Flow.objects.create(name="test", slug="test", title="test") - FlowStageBinding.objects.create(target=flow, stage=conf_stage, order=0) - FlowStageBinding.objects.create(target=flow, stage=stage, order=1) + stage.configuration_stages.set([ident_stage]) + FlowStageBinding.objects.create(target=self.flow, stage=ident_stage, order=0) + FlowStageBinding.objects.create(target=self.flow, stage=stage, order=1) response = self.client.post( - reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), {"uid_field": self.user.username}, ) self.assertEqual(response.status_code, 302) response = self.client.get( - reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), follow=True, ) self.assertStageResponse( response, - flow, + self.flow, component="ak-stage-authenticator-validate", ) - def test_last_auth_threshold_valid(self): + def test_last_auth_threshold_valid(self) -> SimpleCookie: """Test last_auth_threshold""" - conf_stage = IdentificationStage.objects.create( + ident_stage = IdentificationStage.objects.create( name="conf", user_fields=[ UserFields.USERNAME, @@ -79,32 +85,168 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase): user=self.user, confirmed=True, ) - # Verify token once here to set last_t etc - totp = TOTP(device.bin_key) - sleep(1) - self.assertTrue(device.verify_token(totp.token())) stage = AuthenticatorValidateStage.objects.create( name="foo", last_auth_threshold="hours=1", not_configured_action=NotConfiguredAction.CONFIGURE, device_classes=[DeviceClasses.TOTP], ) - stage.configuration_stages.set([conf_stage]) - flow = Flow.objects.create(name="test", slug="test", title="test") - FlowStageBinding.objects.create(target=flow, stage=conf_stage, order=0) - FlowStageBinding.objects.create(target=flow, stage=stage, order=1) + stage.configuration_stages.set([ident_stage]) + FlowStageBinding.objects.create(target=self.flow, stage=ident_stage, order=0) + FlowStageBinding.objects.create(target=self.flow, stage=stage, order=1) response = self.client.post( - reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), {"uid_field": self.user.username}, ) self.assertEqual(response.status_code, 302) response = self.client.get( - reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), - follow=True, + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + ) + # Verify token once here to set last_t etc + totp = TOTP(device.bin_key) + sleep(1) + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + {"code": str(totp.token())}, + ) + self.assertIn(COOKIE_NAME_MFA, response.cookies) + self.assertStageResponse(response, component="xak-flow-redirect", to="/") + return response.cookies + + def test_last_auth_skip(self): + """Test valid cookie""" + cookies = self.test_last_auth_threshold_valid() + mfa_cookie = cookies[COOKIE_NAME_MFA] + self.client.logout() + self.client.cookies[COOKIE_NAME_MFA] = mfa_cookie + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + {"uid_field": self.user.username}, + ) + self.assertEqual(response.status_code, 302) + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), ) self.assertStageResponse(response, component="xak-flow-redirect", to="/") + def test_last_auth_stage_pk(self): + """Test MFA cookie with wrong stage PK""" + ident_stage = IdentificationStage.objects.create( + name="conf", + user_fields=[ + UserFields.USERNAME, + ], + ) + device: TOTPDevice = TOTPDevice.objects.create( + user=self.user, + confirmed=True, + ) + stage = AuthenticatorValidateStage.objects.create( + name="foo", + last_auth_threshold="hours=1", + not_configured_action=NotConfiguredAction.CONFIGURE, + device_classes=[DeviceClasses.TOTP], + ) + stage.configuration_stages.set([ident_stage]) + FlowStageBinding.objects.create(target=self.flow, stage=ident_stage, order=0) + FlowStageBinding.objects.create(target=self.flow, stage=stage, order=1) + self.client.cookies[COOKIE_NAME_MFA] = encode( + payload={ + "device": device.pk, + "stage": stage.pk.hex + "foo", + "exp": (datetime.now() + timedelta(days=3)).timestamp(), + }, + key=sha256(f"{settings.SECRET_KEY}:{stage.pk.hex}".encode("ascii")).hexdigest(), + ) + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + {"uid_field": self.user.username}, + ) + self.assertEqual(response.status_code, 302) + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + ) + self.assertStageResponse(response, component="ak-stage-authenticator-validate") + + def test_last_auth_stage_device(self): + """Test MFA cookie with wrong device PK""" + ident_stage = IdentificationStage.objects.create( + name="conf", + user_fields=[ + UserFields.USERNAME, + ], + ) + device: TOTPDevice = TOTPDevice.objects.create( + user=self.user, + confirmed=True, + ) + stage = AuthenticatorValidateStage.objects.create( + name="foo", + last_auth_threshold="hours=1", + not_configured_action=NotConfiguredAction.CONFIGURE, + device_classes=[DeviceClasses.TOTP], + ) + stage.configuration_stages.set([ident_stage]) + FlowStageBinding.objects.create(target=self.flow, stage=ident_stage, order=0) + FlowStageBinding.objects.create(target=self.flow, stage=stage, order=1) + self.client.cookies[COOKIE_NAME_MFA] = encode( + payload={ + "device": device.pk + 1, + "stage": stage.pk.hex, + "exp": (datetime.now() + timedelta(days=3)).timestamp(), + }, + key=sha256(f"{settings.SECRET_KEY}:{stage.pk.hex}".encode("ascii")).hexdigest(), + ) + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + {"uid_field": self.user.username}, + ) + self.assertEqual(response.status_code, 302) + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + ) + self.assertStageResponse(response, component="ak-stage-authenticator-validate") + + def test_last_auth_stage_expired(self): + """Test MFA cookie with expired cookie""" + ident_stage = IdentificationStage.objects.create( + name="conf", + user_fields=[ + UserFields.USERNAME, + ], + ) + device: TOTPDevice = TOTPDevice.objects.create( + user=self.user, + confirmed=True, + ) + stage = AuthenticatorValidateStage.objects.create( + name="foo", + last_auth_threshold="hours=1", + not_configured_action=NotConfiguredAction.CONFIGURE, + device_classes=[DeviceClasses.TOTP], + ) + stage.configuration_stages.set([ident_stage]) + FlowStageBinding.objects.create(target=self.flow, stage=ident_stage, order=0) + FlowStageBinding.objects.create(target=self.flow, stage=stage, order=1) + self.client.cookies[COOKIE_NAME_MFA] = encode( + payload={ + "device": device.pk, + "stage": stage.pk.hex, + "exp": (datetime.now() - timedelta(days=3)).timestamp(), + }, + key=sha256(f"{settings.SECRET_KEY}:{stage.pk.hex}".encode("ascii")).hexdigest(), + ) + response = self.client.post( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + {"uid_field": self.user.username}, + ) + self.assertEqual(response.status_code, 302) + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + ) + self.assertStageResponse(response, component="ak-stage-authenticator-validate") + def test_device_challenge_totp(self): """Test device challenge""" request = self.request_factory.get("/") diff --git a/authentik/stages/authenticator_validate/tests/test_webauthn.py b/authentik/stages/authenticator_validate/tests/test_webauthn.py index 41ca9d0ce..feb131502 100644 --- a/authentik/stages/authenticator_validate/tests/test_webauthn.py +++ b/authentik/stages/authenticator_validate/tests/test_webauthn.py @@ -28,7 +28,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): def test_last_auth_threshold(self): """Test last_auth_threshold""" - conf_stage = IdentificationStage.objects.create( + ident_stage = IdentificationStage.objects.create( name="conf", user_fields=[ UserFields.USERNAME, @@ -46,9 +46,9 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): device_classes=[DeviceClasses.WEBAUTHN], ) sleep(1) - stage.configuration_stages.set([conf_stage]) + stage.configuration_stages.set([ident_stage]) flow = Flow.objects.create(name="test", slug="test", title="test") - FlowStageBinding.objects.create(target=flow, stage=conf_stage, order=0) + FlowStageBinding.objects.create(target=flow, stage=ident_stage, order=0) FlowStageBinding.objects.create(target=flow, stage=stage, order=1) response = self.client.post( @@ -66,41 +66,6 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): component="ak-stage-authenticator-validate", ) - def test_last_auth_threshold_valid(self): - """Test last_auth_threshold""" - conf_stage = IdentificationStage.objects.create( - name="conf", - user_fields=[ - UserFields.USERNAME, - ], - ) - device: WebAuthnDevice = WebAuthnDevice.objects.create( - user=self.user, - confirmed=True, - ) - device.set_sign_count(device.sign_count + 1) - stage = AuthenticatorValidateStage.objects.create( - name="foo", - last_auth_threshold="hours=1", - not_configured_action=NotConfiguredAction.CONFIGURE, - device_classes=[DeviceClasses.WEBAUTHN], - ) - stage.configuration_stages.set([conf_stage]) - flow = Flow.objects.create(name="test", slug="test", title="test") - FlowStageBinding.objects.create(target=flow, stage=conf_stage, order=0) - FlowStageBinding.objects.create(target=flow, stage=stage, order=1) - - response = self.client.post( - reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), - {"uid_field": self.user.username}, - ) - self.assertEqual(response.status_code, 302) - response = self.client.get( - reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), - follow=True, - ) - self.assertStageResponse(response, component="xak-flow-redirect", to="/") - def test_device_challenge_webauthn(self): """Test webauthn""" request = get_request("/") diff --git a/authentik/stages/authenticator_webauthn/stage.py b/authentik/stages/authenticator_webauthn/stage.py index e24208fce..beae565c9 100644 --- a/authentik/stages/authenticator_webauthn/stage.py +++ b/authentik/stages/authenticator_webauthn/stage.py @@ -104,6 +104,7 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView): ) self.request.session["challenge"] = registration_options.challenge + self.request.session.save() return AuthenticatorWebAuthnChallenge( data={ "type": ChallengeTypes.NATIVE.value, diff --git a/web/src/api/Users.ts b/web/src/api/Users.ts index ee29746bb..6dfa34d26 100644 --- a/web/src/api/Users.ts +++ b/web/src/api/Users.ts @@ -31,10 +31,11 @@ export function me(): Promise { avatar: "", uid: "", username: "", - name: "" + name: "", + settings: {}, } }; - if (ex.status === 401 || ex.status === 403) { + if (ex.response.status === 401 || ex.response.status === 403) { window.location.assign("/"); } return defaultUser;