diff --git a/authentik/core/signals.py b/authentik/core/signals.py index 649681180..750cdbb1d 100644 --- a/authentik/core/signals.py +++ b/authentik/core/signals.py @@ -12,6 +12,8 @@ from django.http.request import HttpRequest # Arguments: user: User, password: str password_changed = Signal() +# Arguments: credentials: dict[str, any], request: HttpRequest, stage: Stage +login_failed = Signal() if TYPE_CHECKING: from authentik.core.models import AuthenticatedSession, User diff --git a/authentik/events/signals.py b/authentik/events/signals.py index 7e599b667..bc730edfb 100644 --- a/authentik/events/signals.py +++ b/authentik/events/signals.py @@ -2,15 +2,16 @@ from threading import Thread from typing import Any, Optional -from django.contrib.auth.signals import user_logged_in, user_logged_out, user_login_failed +from django.contrib.auth.signals import user_logged_in, user_logged_out from django.db.models.signals import post_save, pre_delete from django.dispatch import receiver from django.http import HttpRequest from authentik.core.models import User -from authentik.core.signals import password_changed +from authentik.core.signals import login_failed, password_changed from authentik.events.models import Event, EventAction from authentik.events.tasks import event_notification_handler, gdpr_cleanup +from authentik.flows.models import Stage from authentik.flows.planner import PLAN_CONTEXT_SOURCE, FlowPlan from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.stages.invitation.models import Invitation @@ -77,11 +78,18 @@ def on_user_write(sender, request: HttpRequest, user: User, data: dict[str, Any] thread.run() -@receiver(user_login_failed) +@receiver(login_failed) # pylint: disable=unused-argument -def on_user_login_failed(sender, credentials: dict[str, str], request: HttpRequest, **_): - """Failed Login""" - thread = EventNewThread(EventAction.LOGIN_FAILED, request, **credentials) +def on_login_failed( + signal, + sender, + credentials: dict[str, str], + request: HttpRequest, + stage: Optional[Stage] = None, + **kwargs, +): + """Failed Login, authentik custom event""" + thread = EventNewThread(EventAction.LOGIN_FAILED, request, **credentials, stage=stage, **kwargs) thread.run() diff --git a/authentik/flows/stage.py b/authentik/flows/stage.py index bf26b7435..fd962ab67 100644 --- a/authentik/flows/stage.py +++ b/authentik/flows/stage.py @@ -23,6 +23,7 @@ from authentik.flows.challenge import ( ) from authentik.flows.models import InvalidResponseAction from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER +from authentik.lib.utils.reflection import class_to_path if TYPE_CHECKING: from authentik.flows.views.executor import FlowExecutorView @@ -44,7 +45,7 @@ class StageView(View): current_stage = getattr(self.executor, "current_stage", None) self.logger = get_logger().bind( stage=getattr(current_stage, "name", None), - stage_view=self, + stage_view=class_to_path(type(self)), ) super().__init__(**kwargs) diff --git a/authentik/policies/reputation/signals.py b/authentik/policies/reputation/signals.py index e1471f937..a9ee7b32f 100644 --- a/authentik/policies/reputation/signals.py +++ b/authentik/policies/reputation/signals.py @@ -1,10 +1,11 @@ """authentik reputation request signals""" -from django.contrib.auth.signals import user_logged_in, user_login_failed +from django.contrib.auth.signals import user_logged_in from django.core.cache import cache from django.dispatch import receiver from django.http import HttpRequest from structlog.stdlib import get_logger +from authentik.core.signals import login_failed from authentik.lib.config import CONFIG from authentik.lib.utils.http import get_client_ip from authentik.policies.reputation.models import CACHE_KEY_PREFIX @@ -35,7 +36,7 @@ def update_score(request: HttpRequest, identifier: str, amount: int): save_reputation.delay() -@receiver(user_login_failed) +@receiver(login_failed) # pylint: disable=unused-argument def handle_failed_login(sender, request, credentials, **_): """Lower Score for failed login attempts""" diff --git a/authentik/policies/reputation/tests.py b/authentik/policies/reputation/tests.py index 985e0586d..07aa3fb46 100644 --- a/authentik/policies/reputation/tests.py +++ b/authentik/policies/reputation/tests.py @@ -1,5 +1,4 @@ """test reputation signals and policy""" -from django.contrib.auth import authenticate from django.core.cache import cache from django.test import RequestFactory, TestCase @@ -7,6 +6,8 @@ from authentik.core.models import User from authentik.policies.reputation.models import CACHE_KEY_PREFIX, Reputation, ReputationPolicy from authentik.policies.reputation.tasks import save_reputation from authentik.policies.types import PolicyRequest +from authentik.stages.password import BACKEND_INBUILT +from authentik.stages.password.stage import authenticate class TestReputationPolicy(TestCase): @@ -21,11 +22,14 @@ class TestReputationPolicy(TestCase): cache.delete_many(keys) # We need a user for the one-to-one in userreputation self.user = User.objects.create(username=self.test_username) + self.backends = [BACKEND_INBUILT] def test_ip_reputation(self): """test IP reputation""" # Trigger negative reputation - authenticate(self.request, username=self.test_username, password=self.test_username) + authenticate( + self.request, self.backends, username=self.test_username, password=self.test_username + ) # Test value in cache self.assertEqual( cache.get(CACHE_KEY_PREFIX + self.test_ip + self.test_username), @@ -38,7 +42,9 @@ class TestReputationPolicy(TestCase): def test_user_reputation(self): """test User reputation""" # Trigger negative reputation - authenticate(self.request, username=self.test_username, password=self.test_username) + authenticate( + self.request, self.backends, username=self.test_username, password=self.test_username + ) # Test value in cache self.assertEqual( cache.get(CACHE_KEY_PREFIX + self.test_ip + self.test_username), diff --git a/authentik/stages/authenticator_validate/challenge.py b/authentik/stages/authenticator_validate/challenge.py index fd6b8b41a..c747be7a4 100644 --- a/authentik/stages/authenticator_validate/challenge.py +++ b/authentik/stages/authenticator_validate/challenge.py @@ -18,9 +18,12 @@ from webauthn.helpers.structs import AuthenticationCredential from authentik.core.api.utils import PassiveSerializer from authentik.core.models import User +from authentik.core.signals import login_failed +from authentik.flows.stage import StageView from authentik.lib.utils.http import get_client_ip from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice from authentik.stages.authenticator_sms.models import SMSDevice +from authentik.stages.authenticator_validate.models import DeviceClasses from authentik.stages.authenticator_webauthn.models import WebAuthnDevice from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id @@ -92,24 +95,32 @@ def select_challenge_sms(request: HttpRequest, device: SMSDevice): device.stage.send(device.token, device) -def validate_challenge_code(code: str, request: HttpRequest, user: User) -> Device: +def validate_challenge_code(code: str, stage_view: StageView, 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: + login_failed.send( + sender=__name__, + credentials={"username": user.username}, + request=stage_view.request, + stage=stage_view.executor.current_stage, + device_class=DeviceClasses.TOTP.value, + ) raise ValidationError(_("Invalid Token")) return device # pylint: disable=unused-argument -def validate_challenge_webauthn(data: dict, request: HttpRequest, user: User) -> Device: +def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) -> Device: """Validate WebAuthn Challenge""" + request = stage_view.request challenge = request.session.get(SESSION_KEY_WEBAUTHN_CHALLENGE) credential_id = data.get("id") device = WebAuthnDevice.objects.filter(credential_id=credential_id).first() if not device: - raise ValidationError("Device does not exist.") + raise Http404() try: authentication_verification = verify_authentication_response( @@ -121,16 +132,23 @@ def validate_challenge_webauthn(data: dict, request: HttpRequest, user: User) -> credential_current_sign_count=device.sign_count, require_user_verification=False, ) - except InvalidAuthenticationResponse as exc: LOGGER.warning("Assertion failed", exc=exc) + login_failed.send( + sender=__name__, + credentials={"username": user.username}, + request=stage_view.request, + stage=stage_view.executor.current_stage, + device=device, + device_class=DeviceClasses.WEBAUTHN.value, + ) raise ValidationError("Assertion failed") from exc device.set_sign_count(authentication_verification.new_sign_count) return device -def validate_challenge_duo(device_pk: int, request: HttpRequest, user: User) -> Device: +def validate_challenge_duo(device_pk: int, stage_view: StageView, user: User) -> Device: """Duo authentication""" device = get_object_or_404(DuoDevice, pk=device_pk) if device.user != user: @@ -140,13 +158,20 @@ def validate_challenge_duo(device_pk: int, request: HttpRequest, user: User) -> response = stage.client.auth( "auto", user_id=device.duo_user_id, - ipaddr=get_client_ip(request), + ipaddr=get_client_ip(stage_view.request), type="authentik Login request", display_username=user.username, device="auto", ) # {'result': 'allow', 'status': 'allow', 'status_msg': 'Success. Logging you in...'} if response["result"] == "deny": + login_failed.send( + sender=__name__, + credentials={"username": user.username}, + request=stage_view.request, + stage=stage_view.executor.current_stage, + device_class=DeviceClasses.DUO.value, + ) raise ValidationError("Duo denied access") device.save() return device diff --git a/authentik/stages/authenticator_validate/models.py b/authentik/stages/authenticator_validate/models.py index fbe51c47e..77495c274 100644 --- a/authentik/stages/authenticator_validate/models.py +++ b/authentik/stages/authenticator_validate/models.py @@ -14,7 +14,7 @@ class DeviceClasses(models.TextChoices): """Device classes this stage can validate""" # device class must match Device's class name so StaticDevice -> static - STATIC = "static" + STATIC = "static", _("Static") TOTP = "totp", _("TOTP") WEBAUTHN = "webauthn", _("WebAuthn") DUO = "duo", _("Duo") diff --git a/authentik/stages/authenticator_validate/stage.py b/authentik/stages/authenticator_validate/stage.py index 906df5fa6..72f6e587e 100644 --- a/authentik/stages/authenticator_validate/stage.py +++ b/authentik/stages/authenticator_validate/stage.py @@ -85,9 +85,7 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse): 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]) - self.device = validate_challenge_code( - code, self.stage.request, self.stage.get_pending_user() - ) + self.device = validate_challenge_code(code, self.stage, self.stage.get_pending_user()) return code def validate_webauthn(self, webauthn: dict) -> dict: @@ -95,14 +93,14 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse): or response is invalid""" self._challenge_allowed([DeviceClasses.WEBAUTHN]) self.device = validate_challenge_webauthn( - webauthn, self.stage.request, self.stage.get_pending_user() + webauthn, self.stage, self.stage.get_pending_user() ) return webauthn def validate_duo(self, duo: int) -> int: """Initiate Duo authentication""" self._challenge_allowed([DeviceClasses.DUO]) - self.device = validate_challenge_duo(duo, self.stage.request, self.stage.get_pending_user()) + self.device = validate_challenge_duo(duo, self.stage, self.stage.get_pending_user()) return duo def validate_selected_challenge(self, challenge: dict) -> dict: diff --git a/authentik/stages/authenticator_validate/tests/test_duo.py b/authentik/stages/authenticator_validate/tests/test_duo.py index ada3867b6..e22cab9ce 100644 --- a/authentik/stages/authenticator_validate/tests/test_duo.py +++ b/authentik/stages/authenticator_validate/tests/test_duo.py @@ -5,7 +5,9 @@ from django.test.client import RequestFactory from rest_framework.exceptions import ValidationError from authentik.core.tests.utils import create_test_admin_user +from authentik.flows.stage import StageView from authentik.flows.tests import FlowTestCase +from authentik.flows.views.executor import FlowExecutorView from authentik.lib.generators import generate_id, generate_key from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice from authentik.stages.authenticator_validate.challenge import validate_challenge_duo @@ -22,7 +24,7 @@ class AuthenticatorValidateStageDuoTests(FlowTestCase): """Test duo""" request = self.request_factory.get("/") stage = AuthenticatorDuoStage.objects.create( - name="test", + name=generate_id(), client_id=generate_id(), client_secret=generate_key(), api_hostname="", @@ -45,10 +47,21 @@ class AuthenticatorValidateStageDuoTests(FlowTestCase): "authentik.stages.authenticator_duo.models.AuthenticatorDuoStage.client", duo_mock, ): - self.assertEqual(duo_device, validate_challenge_duo(duo_device.pk, request, self.user)) + self.assertEqual( + duo_device, + validate_challenge_duo( + duo_device.pk, + StageView(FlowExecutorView(current_stage=stage), request=request), + self.user, + ), + ) with patch( "authentik.stages.authenticator_duo.models.AuthenticatorDuoStage.client", failed_duo_mock, ): with self.assertRaises(ValidationError): - validate_challenge_duo(duo_device.pk, request, self.user) + validate_challenge_duo( + duo_device.pk, + StageView(FlowExecutorView(current_stage=stage), request=request), + self.user, + ) diff --git a/authentik/stages/authenticator_validate/tests/test_sms.py b/authentik/stages/authenticator_validate/tests/test_sms.py index 0758c49a6..7288bc96c 100644 --- a/authentik/stages/authenticator_validate/tests/test_sms.py +++ b/authentik/stages/authenticator_validate/tests/test_sms.py @@ -7,6 +7,7 @@ from django.urls.base import reverse from authentik.core.tests.utils import create_test_admin_user from authentik.flows.models import Flow, FlowStageBinding, NotConfiguredAction from authentik.flows.tests import FlowTestCase +from authentik.lib.generators import generate_id 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 @@ -28,7 +29,7 @@ class AuthenticatorValidateStageSMSTests(FlowTestCase): def test_last_auth_threshold(self): """Test last_auth_threshold""" ident_stage = IdentificationStage.objects.create( - name="conf", + name=generate_id(), user_fields=[ UserFields.USERNAME, ], @@ -40,7 +41,7 @@ class AuthenticatorValidateStageSMSTests(FlowTestCase): ) stage = AuthenticatorValidateStage.objects.create( - name="foo", + name=generate_id(), last_auth_threshold="milliseconds=0", not_configured_action=NotConfiguredAction.CONFIGURE, device_classes=[DeviceClasses.SMS], @@ -65,7 +66,7 @@ class AuthenticatorValidateStageSMSTests(FlowTestCase): def test_last_auth_threshold_valid(self): """Test last_auth_threshold""" ident_stage = IdentificationStage.objects.create( - name="conf", + name=generate_id(), user_fields=[ UserFields.USERNAME, ], @@ -77,7 +78,7 @@ class AuthenticatorValidateStageSMSTests(FlowTestCase): ) stage = AuthenticatorValidateStage.objects.create( - name="foo", + name=generate_id(), last_auth_threshold="hours=1", not_configured_action=NotConfiguredAction.CONFIGURE, device_classes=[DeviceClasses.SMS], @@ -120,7 +121,7 @@ class AuthenticatorValidateStageSMSTests(FlowTestCase): def test_sms_hashed(self): """Test hashed SMS device""" ident_stage = IdentificationStage.objects.create( - name="conf", + name=generate_id(), user_fields=[ UserFields.USERNAME, ], @@ -133,7 +134,7 @@ class AuthenticatorValidateStageSMSTests(FlowTestCase): ) stage = AuthenticatorValidateStage.objects.create( - name="foo", + name=generate_id(), last_auth_threshold="hours=1", not_configured_action=NotConfiguredAction.DENY, device_classes=[DeviceClasses.SMS], diff --git a/authentik/stages/authenticator_validate/tests/test_stage.py b/authentik/stages/authenticator_validate/tests/test_stage.py index 0001acdb4..6fb9768ec 100644 --- a/authentik/stages/authenticator_validate/tests/test_stage.py +++ b/authentik/stages/authenticator_validate/tests/test_stage.py @@ -9,6 +9,7 @@ from authentik.flows.models import Flow, FlowStageBinding, NotConfiguredAction from authentik.flows.stage import StageView from authentik.flows.tests import FlowTestCase from authentik.flows.views.executor import FlowExecutorView +from authentik.lib.generators import generate_id from authentik.lib.tests.utils import dummy_get_response from authentik.stages.authenticator_validate.api import AuthenticatorValidateStageSerializer from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage @@ -29,13 +30,13 @@ class AuthenticatorValidateStageTests(FlowTestCase): def test_not_configured_action(self): """Test not_configured_action""" conf_stage = IdentificationStage.objects.create( - name="conf", + name=generate_id(), user_fields=[ UserFields.USERNAME, ], ) stage = AuthenticatorValidateStage.objects.create( - name="foo", + name=generate_id(), not_configured_action=NotConfiguredAction.CONFIGURE, ) stage.configuration_stages.set([conf_stage]) @@ -67,12 +68,12 @@ class AuthenticatorValidateStageTests(FlowTestCase): """Test serializer validation""" self.client.force_login(self.user) serializer = AuthenticatorValidateStageSerializer( - data={"name": "foo", "not_configured_action": NotConfiguredAction.CONFIGURE} + data={"name": generate_id(), "not_configured_action": NotConfiguredAction.CONFIGURE} ) self.assertFalse(serializer.is_valid()) self.assertIn("not_configured_action", serializer.errors) serializer = AuthenticatorValidateStageSerializer( - data={"name": "foo", "not_configured_action": NotConfiguredAction.DENY} + data={"name": generate_id(), "not_configured_action": NotConfiguredAction.DENY} ) self.assertTrue(serializer.is_valid()) diff --git a/authentik/stages/authenticator_validate/tests/test_totp.py b/authentik/stages/authenticator_validate/tests/test_totp.py index d5b5f9ed6..54ccfb912 100644 --- a/authentik/stages/authenticator_validate/tests/test_totp.py +++ b/authentik/stages/authenticator_validate/tests/test_totp.py @@ -14,7 +14,10 @@ from rest_framework.exceptions import ValidationError from authentik.core.tests.utils import create_test_admin_user, create_test_flow from authentik.flows.models import FlowDesignation, FlowStageBinding, NotConfiguredAction +from authentik.flows.stage import StageView from authentik.flows.tests import FlowTestCase +from authentik.flows.views.executor import FlowExecutorView +from authentik.lib.generators import generate_id from authentik.stages.authenticator_validate.challenge import ( get_challenge_for_device, validate_challenge_code, @@ -35,7 +38,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase): def test_last_auth_threshold(self): """Test last_auth_threshold""" ident_stage = IdentificationStage.objects.create( - name="conf", + name=generate_id(), user_fields=[ UserFields.USERNAME, ], @@ -49,7 +52,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase): sleep(1) self.assertTrue(device.verify_token(totp.token())) stage = AuthenticatorValidateStage.objects.create( - name="foo", + name=generate_id(), last_auth_threshold="milliseconds=0", not_configured_action=NotConfiguredAction.CONFIGURE, device_classes=[DeviceClasses.TOTP], @@ -76,7 +79,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase): def test_last_auth_threshold_valid(self) -> SimpleCookie: """Test last_auth_threshold""" ident_stage = IdentificationStage.objects.create( - name="conf", + name=generate_id(), user_fields=[ UserFields.USERNAME, ], @@ -86,7 +89,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase): confirmed=True, ) stage = AuthenticatorValidateStage.objects.create( - name="foo", + name=generate_id(), last_auth_threshold="hours=1", not_configured_action=NotConfiguredAction.CONFIGURE, device_classes=[DeviceClasses.TOTP], @@ -133,7 +136,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase): def test_last_auth_stage_pk(self): """Test MFA cookie with wrong stage PK""" ident_stage = IdentificationStage.objects.create( - name="conf", + name=generate_id(), user_fields=[ UserFields.USERNAME, ], @@ -143,7 +146,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase): confirmed=True, ) stage = AuthenticatorValidateStage.objects.create( - name="foo", + name=generate_id(), last_auth_threshold="hours=1", not_configured_action=NotConfiguredAction.CONFIGURE, device_classes=[DeviceClasses.TOTP], @@ -154,7 +157,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase): self.client.cookies[COOKIE_NAME_MFA] = encode( payload={ "device": device.pk, - "stage": stage.pk.hex + "foo", + "stage": stage.pk.hex + generate_id(), "exp": (datetime.now() + timedelta(days=3)).timestamp(), }, key=sha256(f"{settings.SECRET_KEY}:{stage.pk.hex}".encode("ascii")).hexdigest(), @@ -172,7 +175,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase): def test_last_auth_stage_device(self): """Test MFA cookie with wrong device PK""" ident_stage = IdentificationStage.objects.create( - name="conf", + name=generate_id(), user_fields=[ UserFields.USERNAME, ], @@ -182,7 +185,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase): confirmed=True, ) stage = AuthenticatorValidateStage.objects.create( - name="foo", + name=generate_id(), last_auth_threshold="hours=1", not_configured_action=NotConfiguredAction.CONFIGURE, device_classes=[DeviceClasses.TOTP], @@ -211,7 +214,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase): def test_last_auth_stage_expired(self): """Test MFA cookie with expired cookie""" ident_stage = IdentificationStage.objects.create( - name="conf", + name=generate_id(), user_fields=[ UserFields.USERNAME, ], @@ -221,7 +224,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase): confirmed=True, ) stage = AuthenticatorValidateStage.objects.create( - name="foo", + name=generate_id(), last_auth_threshold="hours=1", not_configured_action=NotConfiguredAction.CONFIGURE, device_classes=[DeviceClasses.TOTP], @@ -251,6 +254,14 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase): """Test device challenge""" request = self.request_factory.get("/") totp_device = TOTPDevice.objects.create(user=self.user, confirmed=True, digits=6) + stage = AuthenticatorValidateStage.objects.create( + name=generate_id(), + last_auth_threshold="hours=1", + not_configured_action=NotConfiguredAction.CONFIGURE, + device_classes=[DeviceClasses.TOTP], + ) self.assertEqual(get_challenge_for_device(request, totp_device), {}) with self.assertRaises(ValidationError): - validate_challenge_code("1234", request, self.user) + validate_challenge_code( + "1234", StageView(FlowExecutorView(current_stage=stage), request=request), self.user + ) diff --git a/authentik/stages/authenticator_validate/tests/test_webauthn.py b/authentik/stages/authenticator_validate/tests/test_webauthn.py index feb131502..d13c04035 100644 --- a/authentik/stages/authenticator_validate/tests/test_webauthn.py +++ b/authentik/stages/authenticator_validate/tests/test_webauthn.py @@ -1,14 +1,17 @@ """Test validator stage""" from time import sleep +from django.http import Http404 from django.test.client import RequestFactory from django.urls.base import reverse -from rest_framework.exceptions import ValidationError from webauthn.helpers import bytes_to_base64url from authentik.core.tests.utils import create_test_admin_user from authentik.flows.models import Flow, FlowStageBinding, NotConfiguredAction +from authentik.flows.stage import StageView from authentik.flows.tests import FlowTestCase +from authentik.flows.views.executor import FlowExecutorView +from authentik.lib.generators import generate_id from authentik.lib.tests.utils import get_request from authentik.stages.authenticator_validate.challenge import ( get_challenge_for_device, @@ -29,7 +32,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): def test_last_auth_threshold(self): """Test last_auth_threshold""" ident_stage = IdentificationStage.objects.create( - name="conf", + name=generate_id(), user_fields=[ UserFields.USERNAME, ], @@ -40,7 +43,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): ) device.set_sign_count(device.sign_count + 1) stage = AuthenticatorValidateStage.objects.create( - name="foo", + name=generate_id(), last_auth_threshold="milliseconds=0", not_configured_action=NotConfiguredAction.CONFIGURE, device_classes=[DeviceClasses.WEBAUTHN], @@ -76,7 +79,13 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): public_key=bytes_to_base64url(b"qwerqwerqre"), credential_id=bytes_to_base64url(b"foobarbaz"), sign_count=0, - rp_id="foo", + rp_id=generate_id(), + ) + stage = AuthenticatorValidateStage.objects.create( + name=generate_id(), + last_auth_threshold="milliseconds=0", + not_configured_action=NotConfiguredAction.CONFIGURE, + device_classes=[DeviceClasses.WEBAUTHN], ) challenge = get_challenge_for_device(request, webauthn_device) del challenge["challenge"] @@ -95,5 +104,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): }, ) - with self.assertRaises(ValidationError): - validate_challenge_webauthn({}, request, self.user) + with self.assertRaises(Http404): + validate_challenge_webauthn( + {}, StageView(FlowExecutorView(current_stage=stage), request=request), self.user + ) diff --git a/authentik/stages/identification/stage.py b/authentik/stages/identification/stage.py index 7656edbb4..728668c3e 100644 --- a/authentik/stages/identification/stage.py +++ b/authentik/stages/identification/stage.py @@ -127,6 +127,7 @@ class IdentificationChallengeResponse(ChallengeResponse): user = authenticate( self.stage.request, current_stage.password_stage.backends, + current_stage, username=self.pre_user.username, password=password, ) diff --git a/authentik/stages/password/stage.py b/authentik/stages/password/stage.py index d6df90407..db70586d1 100644 --- a/authentik/stages/password/stage.py +++ b/authentik/stages/password/stage.py @@ -3,7 +3,6 @@ from typing import Any, Optional from django.contrib.auth import _clean_credentials from django.contrib.auth.backends import BaseBackend -from django.contrib.auth.signals import user_login_failed from django.core.exceptions import PermissionDenied from django.http import HttpRequest, HttpResponse from django.urls import reverse @@ -14,13 +13,14 @@ from sentry_sdk.hub import Hub from structlog.stdlib import get_logger from authentik.core.models import User +from authentik.core.signals import login_failed from authentik.flows.challenge import ( Challenge, ChallengeResponse, ChallengeTypes, WithUserInfoChallenge, ) -from authentik.flows.models import Flow, FlowDesignation +from authentik.flows.models import Flow, FlowDesignation, Stage from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.stage import ChallengeStageView from authentik.lib.utils.reflection import path_to_class @@ -33,7 +33,9 @@ PLAN_CONTEXT_METHOD_ARGS = "auth_method_args" SESSION_KEY_INVALID_TRIES = "authentik/stages/password/user_invalid_tries" -def authenticate(request: HttpRequest, backends: list[str], **credentials: Any) -> Optional[User]: +def authenticate( + request: HttpRequest, backends: list[str], stage: Optional[Stage] = None, **credentials: Any +) -> Optional[User]: """If the given credentials are valid, return a User object. Customized version of django's authenticate, which accepts a list of backends""" @@ -58,8 +60,11 @@ def authenticate(request: HttpRequest, backends: list[str], **credentials: Any) return user # The credentials supplied are invalid to all backends, fire signal - user_login_failed.send( - sender=__name__, credentials=_clean_credentials(credentials), request=request + login_failed.send( + sender=__name__, + credentials=_clean_credentials(credentials), + request=request, + stage=stage, ) @@ -130,7 +135,10 @@ class PasswordStageView(ChallengeStageView): description="User authenticate call", ): user = authenticate( - self.request, self.executor.current_stage.backends, **auth_kwargs + self.request, + self.executor.current_stage.backends, + self.executor.current_stage, + **auth_kwargs, ) except PermissionDenied: del auth_kwargs["password"]