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:
Jens L 2022-06-04 15:30:56 +02:00 committed by GitHub
parent 6739ded5a9
commit fa04883ac1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 148 additions and 61 deletions

View File

@ -12,6 +12,8 @@ from django.http.request import HttpRequest
# Arguments: user: User, password: str # Arguments: user: User, password: str
password_changed = Signal() password_changed = Signal()
# Arguments: credentials: dict[str, any], request: HttpRequest, stage: Stage
login_failed = Signal()
if TYPE_CHECKING: if TYPE_CHECKING:
from authentik.core.models import AuthenticatedSession, User from authentik.core.models import AuthenticatedSession, User

View File

@ -2,15 +2,16 @@
from threading import Thread from threading import Thread
from typing import Any, Optional 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.db.models.signals import post_save, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.http import HttpRequest from django.http import HttpRequest
from authentik.core.models import User 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.models import Event, EventAction
from authentik.events.tasks import event_notification_handler, gdpr_cleanup 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.planner import PLAN_CONTEXT_SOURCE, FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.stages.invitation.models import Invitation 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() thread.run()
@receiver(user_login_failed) @receiver(login_failed)
# pylint: disable=unused-argument # pylint: disable=unused-argument
def on_user_login_failed(sender, credentials: dict[str, str], request: HttpRequest, **_): def on_login_failed(
"""Failed Login""" signal,
thread = EventNewThread(EventAction.LOGIN_FAILED, request, **credentials) 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() thread.run()

View File

@ -23,6 +23,7 @@ from authentik.flows.challenge import (
) )
from authentik.flows.models import InvalidResponseAction from authentik.flows.models import InvalidResponseAction
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER
from authentik.lib.utils.reflection import class_to_path
if TYPE_CHECKING: if TYPE_CHECKING:
from authentik.flows.views.executor import FlowExecutorView from authentik.flows.views.executor import FlowExecutorView
@ -44,7 +45,7 @@ class StageView(View):
current_stage = getattr(self.executor, "current_stage", None) current_stage = getattr(self.executor, "current_stage", None)
self.logger = get_logger().bind( self.logger = get_logger().bind(
stage=getattr(current_stage, "name", None), stage=getattr(current_stage, "name", None),
stage_view=self, stage_view=class_to_path(type(self)),
) )
super().__init__(**kwargs) super().__init__(**kwargs)

View File

@ -1,10 +1,11 @@
"""authentik reputation request signals""" """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.core.cache import cache
from django.dispatch import receiver from django.dispatch import receiver
from django.http import HttpRequest from django.http import HttpRequest
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.signals import login_failed
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
from authentik.lib.utils.http import get_client_ip from authentik.lib.utils.http import get_client_ip
from authentik.policies.reputation.models import CACHE_KEY_PREFIX 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() save_reputation.delay()
@receiver(user_login_failed) @receiver(login_failed)
# pylint: disable=unused-argument # pylint: disable=unused-argument
def handle_failed_login(sender, request, credentials, **_): def handle_failed_login(sender, request, credentials, **_):
"""Lower Score for failed login attempts""" """Lower Score for failed login attempts"""

View File

@ -1,5 +1,4 @@
"""test reputation signals and policy""" """test reputation signals and policy"""
from django.contrib.auth import authenticate
from django.core.cache import cache from django.core.cache import cache
from django.test import RequestFactory, TestCase 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.models import CACHE_KEY_PREFIX, Reputation, ReputationPolicy
from authentik.policies.reputation.tasks import save_reputation from authentik.policies.reputation.tasks import save_reputation
from authentik.policies.types import PolicyRequest from authentik.policies.types import PolicyRequest
from authentik.stages.password import BACKEND_INBUILT
from authentik.stages.password.stage import authenticate
class TestReputationPolicy(TestCase): class TestReputationPolicy(TestCase):
@ -21,11 +22,14 @@ class TestReputationPolicy(TestCase):
cache.delete_many(keys) cache.delete_many(keys)
# We need a user for the one-to-one in userreputation # We need a user for the one-to-one in userreputation
self.user = User.objects.create(username=self.test_username) self.user = User.objects.create(username=self.test_username)
self.backends = [BACKEND_INBUILT]
def test_ip_reputation(self): def test_ip_reputation(self):
"""test IP reputation""" """test IP reputation"""
# Trigger negative 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 # Test value in cache
self.assertEqual( self.assertEqual(
cache.get(CACHE_KEY_PREFIX + self.test_ip + self.test_username), cache.get(CACHE_KEY_PREFIX + self.test_ip + self.test_username),
@ -38,7 +42,9 @@ class TestReputationPolicy(TestCase):
def test_user_reputation(self): def test_user_reputation(self):
"""test User reputation""" """test User reputation"""
# Trigger negative 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 # Test value in cache
self.assertEqual( self.assertEqual(
cache.get(CACHE_KEY_PREFIX + self.test_ip + self.test_username), cache.get(CACHE_KEY_PREFIX + self.test_ip + self.test_username),

View File

@ -18,9 +18,12 @@ from webauthn.helpers.structs import AuthenticationCredential
from authentik.core.api.utils import PassiveSerializer from authentik.core.api.utils import PassiveSerializer
from authentik.core.models import User 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.lib.utils.http import get_client_ip
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
from authentik.stages.authenticator_sms.models import SMSDevice 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.models import WebAuthnDevice
from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE
from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id 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) 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 """Validate code-based challenges. We test against every device, on purpose, as
the user mustn't choose between totp and static devices.""" the user mustn't choose between totp and static devices."""
device = match_token(user, code) device = match_token(user, code)
if not device: 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")) raise ValidationError(_("Invalid Token"))
return device return device
# pylint: disable=unused-argument # 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""" """Validate WebAuthn Challenge"""
request = stage_view.request
challenge = request.session.get(SESSION_KEY_WEBAUTHN_CHALLENGE) challenge = request.session.get(SESSION_KEY_WEBAUTHN_CHALLENGE)
credential_id = data.get("id") credential_id = data.get("id")
device = WebAuthnDevice.objects.filter(credential_id=credential_id).first() device = WebAuthnDevice.objects.filter(credential_id=credential_id).first()
if not device: if not device:
raise ValidationError("Device does not exist.") raise Http404()
try: try:
authentication_verification = verify_authentication_response( 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, credential_current_sign_count=device.sign_count,
require_user_verification=False, require_user_verification=False,
) )
except InvalidAuthenticationResponse as exc: except InvalidAuthenticationResponse as exc:
LOGGER.warning("Assertion failed", exc=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 raise ValidationError("Assertion failed") from exc
device.set_sign_count(authentication_verification.new_sign_count) device.set_sign_count(authentication_verification.new_sign_count)
return device 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""" """Duo authentication"""
device = get_object_or_404(DuoDevice, pk=device_pk) device = get_object_or_404(DuoDevice, pk=device_pk)
if device.user != user: if device.user != user:
@ -140,13 +158,20 @@ def validate_challenge_duo(device_pk: int, request: HttpRequest, user: User) ->
response = stage.client.auth( response = stage.client.auth(
"auto", "auto",
user_id=device.duo_user_id, user_id=device.duo_user_id,
ipaddr=get_client_ip(request), ipaddr=get_client_ip(stage_view.request),
type="authentik Login request", type="authentik Login request",
display_username=user.username, display_username=user.username,
device="auto", device="auto",
) )
# {'result': 'allow', 'status': 'allow', 'status_msg': 'Success. Logging you in...'} # {'result': 'allow', 'status': 'allow', 'status_msg': 'Success. Logging you in...'}
if response["result"] == "deny": 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") raise ValidationError("Duo denied access")
device.save() device.save()
return device return device

View File

@ -14,7 +14,7 @@ class DeviceClasses(models.TextChoices):
"""Device classes this stage can validate""" """Device classes this stage can validate"""
# device class must match Device's class name so StaticDevice -> static # device class must match Device's class name so StaticDevice -> static
STATIC = "static" STATIC = "static", _("Static")
TOTP = "totp", _("TOTP") TOTP = "totp", _("TOTP")
WEBAUTHN = "webauthn", _("WebAuthn") WEBAUTHN = "webauthn", _("WebAuthn")
DUO = "duo", _("Duo") DUO = "duo", _("Duo")

View File

@ -85,9 +85,7 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
def validate_code(self, code: str) -> str: def validate_code(self, code: str) -> str:
"""Validate code-based response, raise error if code isn't allowed""" """Validate code-based response, raise error if code isn't allowed"""
self._challenge_allowed([DeviceClasses.TOTP, DeviceClasses.STATIC, DeviceClasses.SMS]) self._challenge_allowed([DeviceClasses.TOTP, DeviceClasses.STATIC, DeviceClasses.SMS])
self.device = validate_challenge_code( self.device = validate_challenge_code(code, self.stage, self.stage.get_pending_user())
code, self.stage.request, self.stage.get_pending_user()
)
return code return code
def validate_webauthn(self, webauthn: dict) -> dict: def validate_webauthn(self, webauthn: dict) -> dict:
@ -95,14 +93,14 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
or response is invalid""" or response is invalid"""
self._challenge_allowed([DeviceClasses.WEBAUTHN]) self._challenge_allowed([DeviceClasses.WEBAUTHN])
self.device = validate_challenge_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 return webauthn
def validate_duo(self, duo: int) -> int: def validate_duo(self, duo: int) -> int:
"""Initiate Duo authentication""" """Initiate Duo authentication"""
self._challenge_allowed([DeviceClasses.DUO]) 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 return duo
def validate_selected_challenge(self, challenge: dict) -> dict: def validate_selected_challenge(self, challenge: dict) -> dict:

View File

@ -5,7 +5,9 @@ from django.test.client import RequestFactory
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from authentik.core.tests.utils import create_test_admin_user 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.tests import FlowTestCase
from authentik.flows.views.executor import FlowExecutorView
from authentik.lib.generators import generate_id, generate_key from authentik.lib.generators import generate_id, generate_key
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
from authentik.stages.authenticator_validate.challenge import validate_challenge_duo from authentik.stages.authenticator_validate.challenge import validate_challenge_duo
@ -22,7 +24,7 @@ class AuthenticatorValidateStageDuoTests(FlowTestCase):
"""Test duo""" """Test duo"""
request = self.request_factory.get("/") request = self.request_factory.get("/")
stage = AuthenticatorDuoStage.objects.create( stage = AuthenticatorDuoStage.objects.create(
name="test", name=generate_id(),
client_id=generate_id(), client_id=generate_id(),
client_secret=generate_key(), client_secret=generate_key(),
api_hostname="", api_hostname="",
@ -45,10 +47,21 @@ class AuthenticatorValidateStageDuoTests(FlowTestCase):
"authentik.stages.authenticator_duo.models.AuthenticatorDuoStage.client", "authentik.stages.authenticator_duo.models.AuthenticatorDuoStage.client",
duo_mock, 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( with patch(
"authentik.stages.authenticator_duo.models.AuthenticatorDuoStage.client", "authentik.stages.authenticator_duo.models.AuthenticatorDuoStage.client",
failed_duo_mock, failed_duo_mock,
): ):
with self.assertRaises(ValidationError): 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,
)

View File

@ -7,6 +7,7 @@ from django.urls.base import reverse
from authentik.core.tests.utils import create_test_admin_user from authentik.core.tests.utils import create_test_admin_user
from authentik.flows.models import Flow, FlowStageBinding, NotConfiguredAction from authentik.flows.models import Flow, FlowStageBinding, NotConfiguredAction
from authentik.flows.tests import FlowTestCase 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_sms.models import AuthenticatorSMSStage, SMSDevice, SMSProviders
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
from authentik.stages.authenticator_validate.stage import COOKIE_NAME_MFA from authentik.stages.authenticator_validate.stage import COOKIE_NAME_MFA
@ -28,7 +29,7 @@ class AuthenticatorValidateStageSMSTests(FlowTestCase):
def test_last_auth_threshold(self): def test_last_auth_threshold(self):
"""Test last_auth_threshold""" """Test last_auth_threshold"""
ident_stage = IdentificationStage.objects.create( ident_stage = IdentificationStage.objects.create(
name="conf", name=generate_id(),
user_fields=[ user_fields=[
UserFields.USERNAME, UserFields.USERNAME,
], ],
@ -40,7 +41,7 @@ class AuthenticatorValidateStageSMSTests(FlowTestCase):
) )
stage = AuthenticatorValidateStage.objects.create( stage = AuthenticatorValidateStage.objects.create(
name="foo", name=generate_id(),
last_auth_threshold="milliseconds=0", last_auth_threshold="milliseconds=0",
not_configured_action=NotConfiguredAction.CONFIGURE, not_configured_action=NotConfiguredAction.CONFIGURE,
device_classes=[DeviceClasses.SMS], device_classes=[DeviceClasses.SMS],
@ -65,7 +66,7 @@ class AuthenticatorValidateStageSMSTests(FlowTestCase):
def test_last_auth_threshold_valid(self): def test_last_auth_threshold_valid(self):
"""Test last_auth_threshold""" """Test last_auth_threshold"""
ident_stage = IdentificationStage.objects.create( ident_stage = IdentificationStage.objects.create(
name="conf", name=generate_id(),
user_fields=[ user_fields=[
UserFields.USERNAME, UserFields.USERNAME,
], ],
@ -77,7 +78,7 @@ class AuthenticatorValidateStageSMSTests(FlowTestCase):
) )
stage = AuthenticatorValidateStage.objects.create( stage = AuthenticatorValidateStage.objects.create(
name="foo", name=generate_id(),
last_auth_threshold="hours=1", last_auth_threshold="hours=1",
not_configured_action=NotConfiguredAction.CONFIGURE, not_configured_action=NotConfiguredAction.CONFIGURE,
device_classes=[DeviceClasses.SMS], device_classes=[DeviceClasses.SMS],
@ -120,7 +121,7 @@ class AuthenticatorValidateStageSMSTests(FlowTestCase):
def test_sms_hashed(self): def test_sms_hashed(self):
"""Test hashed SMS device""" """Test hashed SMS device"""
ident_stage = IdentificationStage.objects.create( ident_stage = IdentificationStage.objects.create(
name="conf", name=generate_id(),
user_fields=[ user_fields=[
UserFields.USERNAME, UserFields.USERNAME,
], ],
@ -133,7 +134,7 @@ class AuthenticatorValidateStageSMSTests(FlowTestCase):
) )
stage = AuthenticatorValidateStage.objects.create( stage = AuthenticatorValidateStage.objects.create(
name="foo", name=generate_id(),
last_auth_threshold="hours=1", last_auth_threshold="hours=1",
not_configured_action=NotConfiguredAction.DENY, not_configured_action=NotConfiguredAction.DENY,
device_classes=[DeviceClasses.SMS], device_classes=[DeviceClasses.SMS],

View File

@ -9,6 +9,7 @@ from authentik.flows.models import Flow, FlowStageBinding, NotConfiguredAction
from authentik.flows.stage import StageView from authentik.flows.stage import StageView
from authentik.flows.tests import FlowTestCase from authentik.flows.tests import FlowTestCase
from authentik.flows.views.executor import FlowExecutorView 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.lib.tests.utils import dummy_get_response
from authentik.stages.authenticator_validate.api import AuthenticatorValidateStageSerializer from authentik.stages.authenticator_validate.api import AuthenticatorValidateStageSerializer
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage
@ -29,13 +30,13 @@ class AuthenticatorValidateStageTests(FlowTestCase):
def test_not_configured_action(self): def test_not_configured_action(self):
"""Test not_configured_action""" """Test not_configured_action"""
conf_stage = IdentificationStage.objects.create( conf_stage = IdentificationStage.objects.create(
name="conf", name=generate_id(),
user_fields=[ user_fields=[
UserFields.USERNAME, UserFields.USERNAME,
], ],
) )
stage = AuthenticatorValidateStage.objects.create( stage = AuthenticatorValidateStage.objects.create(
name="foo", name=generate_id(),
not_configured_action=NotConfiguredAction.CONFIGURE, not_configured_action=NotConfiguredAction.CONFIGURE,
) )
stage.configuration_stages.set([conf_stage]) stage.configuration_stages.set([conf_stage])
@ -67,12 +68,12 @@ class AuthenticatorValidateStageTests(FlowTestCase):
"""Test serializer validation""" """Test serializer validation"""
self.client.force_login(self.user) self.client.force_login(self.user)
serializer = AuthenticatorValidateStageSerializer( 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.assertFalse(serializer.is_valid())
self.assertIn("not_configured_action", serializer.errors) self.assertIn("not_configured_action", serializer.errors)
serializer = AuthenticatorValidateStageSerializer( serializer = AuthenticatorValidateStageSerializer(
data={"name": "foo", "not_configured_action": NotConfiguredAction.DENY} data={"name": generate_id(), "not_configured_action": NotConfiguredAction.DENY}
) )
self.assertTrue(serializer.is_valid()) self.assertTrue(serializer.is_valid())

View File

@ -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.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.flows.models import FlowDesignation, FlowStageBinding, NotConfiguredAction from authentik.flows.models import FlowDesignation, FlowStageBinding, NotConfiguredAction
from authentik.flows.stage import StageView
from authentik.flows.tests import FlowTestCase 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 ( from authentik.stages.authenticator_validate.challenge import (
get_challenge_for_device, get_challenge_for_device,
validate_challenge_code, validate_challenge_code,
@ -35,7 +38,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase):
def test_last_auth_threshold(self): def test_last_auth_threshold(self):
"""Test last_auth_threshold""" """Test last_auth_threshold"""
ident_stage = IdentificationStage.objects.create( ident_stage = IdentificationStage.objects.create(
name="conf", name=generate_id(),
user_fields=[ user_fields=[
UserFields.USERNAME, UserFields.USERNAME,
], ],
@ -49,7 +52,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase):
sleep(1) sleep(1)
self.assertTrue(device.verify_token(totp.token())) self.assertTrue(device.verify_token(totp.token()))
stage = AuthenticatorValidateStage.objects.create( stage = AuthenticatorValidateStage.objects.create(
name="foo", name=generate_id(),
last_auth_threshold="milliseconds=0", last_auth_threshold="milliseconds=0",
not_configured_action=NotConfiguredAction.CONFIGURE, not_configured_action=NotConfiguredAction.CONFIGURE,
device_classes=[DeviceClasses.TOTP], device_classes=[DeviceClasses.TOTP],
@ -76,7 +79,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase):
def test_last_auth_threshold_valid(self) -> SimpleCookie: def test_last_auth_threshold_valid(self) -> SimpleCookie:
"""Test last_auth_threshold""" """Test last_auth_threshold"""
ident_stage = IdentificationStage.objects.create( ident_stage = IdentificationStage.objects.create(
name="conf", name=generate_id(),
user_fields=[ user_fields=[
UserFields.USERNAME, UserFields.USERNAME,
], ],
@ -86,7 +89,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase):
confirmed=True, confirmed=True,
) )
stage = AuthenticatorValidateStage.objects.create( stage = AuthenticatorValidateStage.objects.create(
name="foo", name=generate_id(),
last_auth_threshold="hours=1", last_auth_threshold="hours=1",
not_configured_action=NotConfiguredAction.CONFIGURE, not_configured_action=NotConfiguredAction.CONFIGURE,
device_classes=[DeviceClasses.TOTP], device_classes=[DeviceClasses.TOTP],
@ -133,7 +136,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase):
def test_last_auth_stage_pk(self): def test_last_auth_stage_pk(self):
"""Test MFA cookie with wrong stage PK""" """Test MFA cookie with wrong stage PK"""
ident_stage = IdentificationStage.objects.create( ident_stage = IdentificationStage.objects.create(
name="conf", name=generate_id(),
user_fields=[ user_fields=[
UserFields.USERNAME, UserFields.USERNAME,
], ],
@ -143,7 +146,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase):
confirmed=True, confirmed=True,
) )
stage = AuthenticatorValidateStage.objects.create( stage = AuthenticatorValidateStage.objects.create(
name="foo", name=generate_id(),
last_auth_threshold="hours=1", last_auth_threshold="hours=1",
not_configured_action=NotConfiguredAction.CONFIGURE, not_configured_action=NotConfiguredAction.CONFIGURE,
device_classes=[DeviceClasses.TOTP], device_classes=[DeviceClasses.TOTP],
@ -154,7 +157,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase):
self.client.cookies[COOKIE_NAME_MFA] = encode( self.client.cookies[COOKIE_NAME_MFA] = encode(
payload={ payload={
"device": device.pk, "device": device.pk,
"stage": stage.pk.hex + "foo", "stage": stage.pk.hex + generate_id(),
"exp": (datetime.now() + timedelta(days=3)).timestamp(), "exp": (datetime.now() + timedelta(days=3)).timestamp(),
}, },
key=sha256(f"{settings.SECRET_KEY}:{stage.pk.hex}".encode("ascii")).hexdigest(), 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): def test_last_auth_stage_device(self):
"""Test MFA cookie with wrong device PK""" """Test MFA cookie with wrong device PK"""
ident_stage = IdentificationStage.objects.create( ident_stage = IdentificationStage.objects.create(
name="conf", name=generate_id(),
user_fields=[ user_fields=[
UserFields.USERNAME, UserFields.USERNAME,
], ],
@ -182,7 +185,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase):
confirmed=True, confirmed=True,
) )
stage = AuthenticatorValidateStage.objects.create( stage = AuthenticatorValidateStage.objects.create(
name="foo", name=generate_id(),
last_auth_threshold="hours=1", last_auth_threshold="hours=1",
not_configured_action=NotConfiguredAction.CONFIGURE, not_configured_action=NotConfiguredAction.CONFIGURE,
device_classes=[DeviceClasses.TOTP], device_classes=[DeviceClasses.TOTP],
@ -211,7 +214,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase):
def test_last_auth_stage_expired(self): def test_last_auth_stage_expired(self):
"""Test MFA cookie with expired cookie""" """Test MFA cookie with expired cookie"""
ident_stage = IdentificationStage.objects.create( ident_stage = IdentificationStage.objects.create(
name="conf", name=generate_id(),
user_fields=[ user_fields=[
UserFields.USERNAME, UserFields.USERNAME,
], ],
@ -221,7 +224,7 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase):
confirmed=True, confirmed=True,
) )
stage = AuthenticatorValidateStage.objects.create( stage = AuthenticatorValidateStage.objects.create(
name="foo", name=generate_id(),
last_auth_threshold="hours=1", last_auth_threshold="hours=1",
not_configured_action=NotConfiguredAction.CONFIGURE, not_configured_action=NotConfiguredAction.CONFIGURE,
device_classes=[DeviceClasses.TOTP], device_classes=[DeviceClasses.TOTP],
@ -251,6 +254,14 @@ class AuthenticatorValidateStageTOTPTests(FlowTestCase):
"""Test device challenge""" """Test device challenge"""
request = self.request_factory.get("/") request = self.request_factory.get("/")
totp_device = TOTPDevice.objects.create(user=self.user, confirmed=True, digits=6) 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), {}) self.assertEqual(get_challenge_for_device(request, totp_device), {})
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
validate_challenge_code("1234", request, self.user) validate_challenge_code(
"1234", StageView(FlowExecutorView(current_stage=stage), request=request), self.user
)

View File

@ -1,14 +1,17 @@
"""Test validator stage""" """Test validator stage"""
from time import sleep from time import sleep
from django.http import Http404
from django.test.client import RequestFactory from django.test.client import RequestFactory
from django.urls.base import reverse from django.urls.base import reverse
from rest_framework.exceptions import ValidationError
from webauthn.helpers import bytes_to_base64url from webauthn.helpers import bytes_to_base64url
from authentik.core.tests.utils import create_test_admin_user from authentik.core.tests.utils import create_test_admin_user
from authentik.flows.models import Flow, FlowStageBinding, NotConfiguredAction from authentik.flows.models import Flow, FlowStageBinding, NotConfiguredAction
from authentik.flows.stage import StageView
from authentik.flows.tests import FlowTestCase 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.lib.tests.utils import get_request
from authentik.stages.authenticator_validate.challenge import ( from authentik.stages.authenticator_validate.challenge import (
get_challenge_for_device, get_challenge_for_device,
@ -29,7 +32,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
def test_last_auth_threshold(self): def test_last_auth_threshold(self):
"""Test last_auth_threshold""" """Test last_auth_threshold"""
ident_stage = IdentificationStage.objects.create( ident_stage = IdentificationStage.objects.create(
name="conf", name=generate_id(),
user_fields=[ user_fields=[
UserFields.USERNAME, UserFields.USERNAME,
], ],
@ -40,7 +43,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
) )
device.set_sign_count(device.sign_count + 1) device.set_sign_count(device.sign_count + 1)
stage = AuthenticatorValidateStage.objects.create( stage = AuthenticatorValidateStage.objects.create(
name="foo", name=generate_id(),
last_auth_threshold="milliseconds=0", last_auth_threshold="milliseconds=0",
not_configured_action=NotConfiguredAction.CONFIGURE, not_configured_action=NotConfiguredAction.CONFIGURE,
device_classes=[DeviceClasses.WEBAUTHN], device_classes=[DeviceClasses.WEBAUTHN],
@ -76,7 +79,13 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
public_key=bytes_to_base64url(b"qwerqwerqre"), public_key=bytes_to_base64url(b"qwerqwerqre"),
credential_id=bytes_to_base64url(b"foobarbaz"), credential_id=bytes_to_base64url(b"foobarbaz"),
sign_count=0, 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) challenge = get_challenge_for_device(request, webauthn_device)
del challenge["challenge"] del challenge["challenge"]
@ -95,5 +104,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
}, },
) )
with self.assertRaises(ValidationError): with self.assertRaises(Http404):
validate_challenge_webauthn({}, request, self.user) validate_challenge_webauthn(
{}, StageView(FlowExecutorView(current_stage=stage), request=request), self.user
)

View File

@ -127,6 +127,7 @@ class IdentificationChallengeResponse(ChallengeResponse):
user = authenticate( user = authenticate(
self.stage.request, self.stage.request,
current_stage.password_stage.backends, current_stage.password_stage.backends,
current_stage,
username=self.pre_user.username, username=self.pre_user.username,
password=password, password=password,
) )

View File

@ -3,7 +3,6 @@ from typing import Any, Optional
from django.contrib.auth import _clean_credentials from django.contrib.auth import _clean_credentials
from django.contrib.auth.backends import BaseBackend from django.contrib.auth.backends import BaseBackend
from django.contrib.auth.signals import user_login_failed
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.urls import reverse from django.urls import reverse
@ -14,13 +13,14 @@ from sentry_sdk.hub import Hub
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.models import User from authentik.core.models import User
from authentik.core.signals import login_failed
from authentik.flows.challenge import ( from authentik.flows.challenge import (
Challenge, Challenge,
ChallengeResponse, ChallengeResponse,
ChallengeTypes, ChallengeTypes,
WithUserInfoChallenge, 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.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import ChallengeStageView from authentik.flows.stage import ChallengeStageView
from authentik.lib.utils.reflection import path_to_class 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" 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. """If the given credentials are valid, return a User object.
Customized version of django's authenticate, which accepts a list of backends""" 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 return user
# The credentials supplied are invalid to all backends, fire signal # The credentials supplied are invalid to all backends, fire signal
user_login_failed.send( login_failed.send(
sender=__name__, credentials=_clean_credentials(credentials), request=request sender=__name__,
credentials=_clean_credentials(credentials),
request=request,
stage=stage,
) )
@ -130,7 +135,10 @@ class PasswordStageView(ChallengeStageView):
description="User authenticate call", description="User authenticate call",
): ):
user = authenticate( 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: except PermissionDenied:
del auth_kwargs["password"] del auth_kwargs["password"]