Skip to content

Commit 53d8f9b

Browse files
authored
stages/authenticator_webauthn: add option to configure max attempts (#15041)
* house keeping - migrate to session part 1 Signed-off-by: Jens Langhammer <jens@goauthentik.io> * cleanup v2 Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add max_attempts Signed-off-by: Jens Langhammer <jens@goauthentik.io> * teeny tiny cleanup Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add ui Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
1 parent f76becf commit 53d8f9b

File tree

12 files changed

+287
-83
lines changed

12 files changed

+287
-83
lines changed

authentik/core/management/commands/change_user_type.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ def add_arguments(self, parser):
1313
parser.add_argument("usernames", nargs="*", type=str)
1414

1515
def handle_per_tenant(self, **options):
16-
print(options)
1716
new_type = UserTypes(options["type"])
1817
qs = (
1918
User.objects.exclude_anonymous()

authentik/stages/authenticator_validate/challenge.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Validation stage challenge checking"""
22

33
from json import loads
4+
from typing import TYPE_CHECKING
45
from urllib.parse import urlencode
56

67
from django.http import HttpRequest
@@ -36,10 +37,12 @@
3637
from authentik.stages.authenticator_sms.models import SMSDevice
3738
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
3839
from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice
39-
from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE
40+
from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE
4041
from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id
4142

4243
LOGGER = get_logger()
44+
if TYPE_CHECKING:
45+
from authentik.stages.authenticator_validate.stage import AuthenticatorValidateStageView
4346

4447

4548
class DeviceChallenge(PassiveSerializer):
@@ -52,38 +55,42 @@ class DeviceChallenge(PassiveSerializer):
5255

5356

5457
def get_challenge_for_device(
55-
request: HttpRequest, stage: AuthenticatorValidateStage, device: Device
58+
stage_view: "AuthenticatorValidateStageView", stage: AuthenticatorValidateStage, device: Device
5659
) -> dict:
5760
"""Generate challenge for a single device"""
5861
if isinstance(device, WebAuthnDevice):
59-
return get_webauthn_challenge(request, stage, device)
62+
return get_webauthn_challenge(stage_view, stage, device)
6063
if isinstance(device, EmailDevice):
6164
return {"email": mask_email(device.email)}
6265
# Code-based challenges have no hints
6366
return {}
6467

6568

6669
def get_webauthn_challenge_without_user(
67-
request: HttpRequest, stage: AuthenticatorValidateStage
70+
stage_view: "AuthenticatorValidateStageView", stage: AuthenticatorValidateStage
6871
) -> dict:
6972
"""Same as `get_webauthn_challenge`, but allows any client device. We can then later check
7073
who the device belongs to."""
71-
request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)
74+
stage_view.executor.plan.context.pop(PLAN_CONTEXT_WEBAUTHN_CHALLENGE, None)
7275
authentication_options = generate_authentication_options(
73-
rp_id=get_rp_id(request),
76+
rp_id=get_rp_id(stage_view.request),
7477
allow_credentials=[],
7578
user_verification=UserVerificationRequirement(stage.webauthn_user_verification),
7679
)
77-
request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge
80+
stage_view.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = (
81+
authentication_options.challenge
82+
)
7883

7984
return loads(options_to_json(authentication_options))
8085

8186

8287
def get_webauthn_challenge(
83-
request: HttpRequest, stage: AuthenticatorValidateStage, device: WebAuthnDevice | None = None
88+
stage_view: "AuthenticatorValidateStageView",
89+
stage: AuthenticatorValidateStage,
90+
device: WebAuthnDevice | None = None,
8491
) -> dict:
8592
"""Send the client a challenge that we'll check later"""
86-
request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)
93+
stage_view.executor.plan.context.pop(PLAN_CONTEXT_WEBAUTHN_CHALLENGE, None)
8794

8895
allowed_credentials = []
8996

@@ -94,12 +101,14 @@ def get_webauthn_challenge(
94101
allowed_credentials.append(user_device.descriptor)
95102

96103
authentication_options = generate_authentication_options(
97-
rp_id=get_rp_id(request),
104+
rp_id=get_rp_id(stage_view.request),
98105
allow_credentials=allowed_credentials,
99106
user_verification=UserVerificationRequirement(stage.webauthn_user_verification),
100107
)
101108

102-
request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge
109+
stage_view.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = (
110+
authentication_options.challenge
111+
)
103112

104113
return loads(options_to_json(authentication_options))
105114

@@ -146,7 +155,7 @@ def validate_challenge_code(code: str, stage_view: StageView, user: User) -> Dev
146155
def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) -> Device:
147156
"""Validate WebAuthn Challenge"""
148157
request = stage_view.request
149-
challenge = request.session.get(SESSION_KEY_WEBAUTHN_CHALLENGE)
158+
challenge = stage_view.executor.plan.context.get(PLAN_CONTEXT_WEBAUTHN_CHALLENGE)
150159
stage: AuthenticatorValidateStage = stage_view.executor.current_stage
151160
try:
152161
credential = parse_authentication_credential_json(data)

authentik/stages/authenticator_validate/stage.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ def get_device_challenges(self) -> list[dict]:
224224
data={
225225
"device_class": device_class,
226226
"device_uid": device.pk,
227-
"challenge": get_challenge_for_device(self.request, stage, device),
227+
"challenge": get_challenge_for_device(self, stage, device),
228228
"last_used": device.last_used,
229229
}
230230
)
@@ -243,7 +243,7 @@ def get_webauthn_challenge_without_user(self) -> list[dict]:
243243
"device_class": DeviceClasses.WEBAUTHN,
244244
"device_uid": -1,
245245
"challenge": get_webauthn_challenge_without_user(
246-
self.request,
246+
self,
247247
self.executor.current_stage,
248248
),
249249
"last_used": None,

authentik/stages/authenticator_validate/tests/test_webauthn.py

Lines changed: 57 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
WebAuthnDevice,
3232
WebAuthnDeviceType,
3333
)
34-
from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE
34+
from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE
3535
from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import
3636
from authentik.stages.identification.models import IdentificationStage, UserFields
3737
from authentik.stages.user_login.models import UserLoginStage
@@ -103,7 +103,11 @@ def test_device_challenge_webauthn(self):
103103
device_classes=[DeviceClasses.WEBAUTHN],
104104
webauthn_user_verification=UserVerification.PREFERRED,
105105
)
106-
challenge = get_challenge_for_device(request, stage, webauthn_device)
106+
plan = FlowPlan("")
107+
stage_view = AuthenticatorValidateStageView(
108+
FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request
109+
)
110+
challenge = get_challenge_for_device(stage_view, stage, webauthn_device)
107111
del challenge["challenge"]
108112
self.assertEqual(
109113
challenge,
@@ -122,7 +126,9 @@ def test_device_challenge_webauthn(self):
122126

123127
with self.assertRaises(ValidationError):
124128
validate_challenge_webauthn(
125-
{}, StageView(FlowExecutorView(current_stage=stage), request=request), self.user
129+
{},
130+
StageView(FlowExecutorView(current_stage=stage, plan=plan), request=request),
131+
self.user,
126132
)
127133

128134
def test_device_challenge_webauthn_restricted(self):
@@ -193,22 +199,35 @@ def test_raw_get_challenge(self):
193199
sign_count=0,
194200
rp_id=generate_id(),
195201
)
196-
challenge = get_challenge_for_device(request, stage, webauthn_device)
197-
webauthn_challenge = request.session[SESSION_KEY_WEBAUTHN_CHALLENGE]
202+
plan = FlowPlan("")
203+
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
204+
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
205+
)
206+
stage_view = AuthenticatorValidateStageView(
207+
FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request
208+
)
209+
challenge = get_challenge_for_device(stage_view, stage, webauthn_device)
198210
self.assertEqual(
199-
challenge,
200-
{
201-
"allowCredentials": [
202-
{
203-
"id": "QKZ97ASJAOIDyipAs6mKUxDUZgDrWrbAsUb5leL7-oU",
204-
"type": "public-key",
205-
}
206-
],
207-
"challenge": bytes_to_base64url(webauthn_challenge),
208-
"rpId": "testserver",
209-
"timeout": 60000,
210-
"userVerification": "preferred",
211-
},
211+
challenge["allowCredentials"],
212+
[
213+
{
214+
"id": "QKZ97ASJAOIDyipAs6mKUxDUZgDrWrbAsUb5leL7-oU",
215+
"type": "public-key",
216+
}
217+
],
218+
)
219+
self.assertIsNotNone(challenge["challenge"])
220+
self.assertEqual(
221+
challenge["rpId"],
222+
"testserver",
223+
)
224+
self.assertEqual(
225+
challenge["timeout"],
226+
60000,
227+
)
228+
self.assertEqual(
229+
challenge["userVerification"],
230+
"preferred",
212231
)
213232

214233
def test_get_challenge_userless(self):
@@ -228,18 +247,16 @@ def test_get_challenge_userless(self):
228247
sign_count=0,
229248
rp_id=generate_id(),
230249
)
231-
challenge = get_webauthn_challenge_without_user(request, stage)
232-
webauthn_challenge = request.session[SESSION_KEY_WEBAUTHN_CHALLENGE]
233-
self.assertEqual(
234-
challenge,
235-
{
236-
"allowCredentials": [],
237-
"challenge": bytes_to_base64url(webauthn_challenge),
238-
"rpId": "testserver",
239-
"timeout": 60000,
240-
"userVerification": "preferred",
241-
},
242-
)
250+
plan = FlowPlan("")
251+
stage_view = AuthenticatorValidateStageView(
252+
FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request
253+
)
254+
challenge = get_webauthn_challenge_without_user(stage_view, stage)
255+
self.assertEqual(challenge["allowCredentials"], [])
256+
self.assertIsNotNone(challenge["challenge"])
257+
self.assertEqual(challenge["rpId"], "testserver")
258+
self.assertEqual(challenge["timeout"], 60000)
259+
self.assertEqual(challenge["userVerification"], "preferred")
243260

244261
def test_validate_challenge_unrestricted(self):
245262
"""Test webauthn authentication (unrestricted webauthn device)"""
@@ -275,10 +292,10 @@ def test_validate_challenge_unrestricted(self):
275292
"last_used": None,
276293
}
277294
]
278-
session[SESSION_KEY_PLAN] = plan
279-
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
295+
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
280296
"aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ"
281297
)
298+
session[SESSION_KEY_PLAN] = plan
282299
session.save()
283300

284301
response = self.client.post(
@@ -352,10 +369,10 @@ def test_validate_challenge_restricted(self):
352369
"last_used": None,
353370
}
354371
]
355-
session[SESSION_KEY_PLAN] = plan
356-
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
372+
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
357373
"aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ"
358374
)
375+
session[SESSION_KEY_PLAN] = plan
359376
session.save()
360377

361378
response = self.client.post(
@@ -433,10 +450,10 @@ def test_validate_challenge_userless(self):
433450
"last_used": None,
434451
}
435452
]
436-
session[SESSION_KEY_PLAN] = plan
437-
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
453+
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
438454
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
439455
)
456+
session[SESSION_KEY_PLAN] = plan
440457
session.save()
441458

442459
response = self.client.post(
@@ -496,17 +513,14 @@ def test_validate_challenge_invalid(self):
496513
not_configured_action=NotConfiguredAction.CONFIGURE,
497514
device_classes=[DeviceClasses.WEBAUTHN],
498515
)
499-
stage_view = AuthenticatorValidateStageView(
500-
FlowExecutorView(flow=flow, current_stage=stage), request=request
501-
)
502-
request = get_request("/")
503-
request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
516+
plan = FlowPlan(flow.pk.hex)
517+
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
504518
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
505519
)
506-
request.session.save()
520+
request = get_request("/")
507521

508522
stage_view = AuthenticatorValidateStageView(
509-
FlowExecutorView(flow=flow, current_stage=stage), request=request
523+
FlowExecutorView(flow=flow, current_stage=stage, plan=plan), request=request
510524
)
511525
request.META["SERVER_NAME"] = "localhost"
512526
request.META["SERVER_PORT"] = "9000"

authentik/stages/authenticator_webauthn/api/stages.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class Meta:
2525
"resident_key_requirement",
2626
"device_type_restrictions",
2727
"device_type_restrictions_obj",
28+
"max_attempts",
2829
]
2930

3031

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated by Django 5.1.11 on 2025-06-13 22:41
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
(
10+
"authentik_stages_authenticator_webauthn",
11+
"0012_webauthndevice_created_webauthndevice_last_updated_and_more",
12+
),
13+
]
14+
15+
operations = [
16+
migrations.AddField(
17+
model_name="authenticatorwebauthnstage",
18+
name="max_attempts",
19+
field=models.PositiveIntegerField(default=0),
20+
),
21+
]

authentik/stages/authenticator_webauthn/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ class AuthenticatorWebAuthnStage(ConfigurableStage, FriendlyNamedStage, Stage):
8484

8585
device_type_restrictions = models.ManyToManyField("WebAuthnDeviceType", blank=True)
8686

87+
max_attempts = models.PositiveIntegerField(default=0)
88+
8789
@property
8890
def serializer(self) -> type[BaseSerializer]:
8991
from authentik.stages.authenticator_webauthn.api.stages import (

0 commit comments

Comments
 (0)