events: use custom login failed signal, also send for mfa errors, add stage and more to context (#3039)
* use custom login failed signal, also send for mfa errors, add stage and more to context closes #3027 Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * include device class in event Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> * update tests Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
parent
6739ded5a9
commit
fa04883ac1
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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())
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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"]
|
||||
|
|
Reference in New Issue