Skip to content

Commit 92dfdcb

Browse files
frjowes-otf
andauthored
Add support for passkeys to Hypha (#4772)
Fixes #4563 This implementation uses [duo-labs/py_webauthn: Pythonic WebAuthn](https://github.com/duo-labs/py_webauthn) directly implementing its own Django wrapper. This is so passkeys are used as a stand alone login method and not as a 2FA option. The interesting parts are in `passkey_views.py` and `passkeys.js`. ## Test Steps - [ ] Test that setting up and logging in with passkeys works on Mac, Windows, iPhone, Android and Linux. - [ ] Using built in OS support, using usb keys etc. - [ ] Test that ENFORCE_TWO_FACTOR are bypassed for passkey users. Passkeys are more secure than 2FA. - [ ] Audit the implementation for any issues. --------- Co-authored-by: Wes Appler <145372368+wes-otf@users.noreply.github.com>
1 parent ef99758 commit 92dfdcb

26 files changed

Lines changed: 1852 additions & 18 deletions

docs/setup/deployment/production/stand-alone.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,11 @@ SERVER_EMAIL: app@example.org
185185
SEND_MESSAGES: true
186186
```
187187

188+
**Passkeys:**
189+
190+
To activate passkeys in production you must set at least `WEBAUTHN_RP_ID` to the relying party domain, e.g. "example.com" (no port, no scheme).
191+
192+
188193
**Turn on Hypha features that are off by default:**
189194

190195
```text

hypha/apply/users/forms.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ def __init__(self, *args, **kwargs):
3636
super().__init__(*args, **kwargs)
3737
self.user_settings = AuthSettings.load(request_or_site=self.request)
3838
self.extra_text = self.user_settings.extra_text
39+
# Enable passkey autofill (conditional mediation) on the username field
40+
self.fields["username"].widget.attrs["autocomplete"] = "username webauthn"
3941
if self.user_settings.consent_show:
4042
self.fields["consent"] = forms.BooleanField(
4143
label=self.user_settings.consent_text,
@@ -55,7 +57,9 @@ class PasswordlessAuthForm(forms.Form):
5557
label=_("Email address"),
5658
required=True,
5759
max_length=254,
58-
widget=forms.EmailInput(attrs={"autofocus": True, "autocomplete": "email"}),
60+
widget=forms.EmailInput(
61+
attrs={"autofocus": True, "autocomplete": "username webauthn"}
62+
),
5963
)
6064

6165
if settings.SESSION_COOKIE_AGE <= settings.SESSION_COOKIE_AGE_LONG:

hypha/apply/users/middleware.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,11 @@ def __call__(self, request):
103103
# code to execute before the view
104104
user = request.user
105105
if user.is_authenticated:
106-
if user.social_auth.exists() or user.is_verified():
106+
if (
107+
user.social_auth.exists()
108+
or user.is_verified()
109+
or request.session.get("passkey_authenticated")
110+
):
107111
return self._accept(request)
108112

109113
# Allow rounds and lab detail pages
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Generated by Django 5.2.12 on 2026-03-23 21:24
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
dependencies = [
10+
("users", "0029_alter_confirmaccesstoken_options_and_more"),
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name="Passkey",
16+
fields=[
17+
(
18+
"id",
19+
models.AutoField(
20+
auto_created=True,
21+
primary_key=True,
22+
serialize=False,
23+
verbose_name="ID",
24+
),
25+
),
26+
("name", models.CharField(blank=True, max_length=255)),
27+
("credential_id", models.CharField(max_length=2048, unique=True)),
28+
("public_key", models.CharField(max_length=2048)),
29+
("sign_count", models.PositiveBigIntegerField(default=0)),
30+
("transports", models.JSONField(blank=True, default=list)),
31+
("created_at", models.DateTimeField(auto_now_add=True)),
32+
("last_used_at", models.DateTimeField(blank=True, null=True)),
33+
(
34+
"user",
35+
models.ForeignKey(
36+
on_delete=django.db.models.deletion.CASCADE,
37+
related_name="passkeys",
38+
to=settings.AUTH_USER_MODEL,
39+
),
40+
),
41+
],
42+
options={
43+
"ordering": ["-created_at"],
44+
},
45+
),
46+
]

hypha/apply/users/models.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,3 +470,32 @@ class Meta:
470470
ordering = ("modified",)
471471
verbose_name = _("Confirm Access Token")
472472
verbose_name_plural = _("Confirm Access Tokens")
473+
474+
475+
class Passkey(models.Model):
476+
"""Stores a WebAuthn passkey credential for a user.
477+
478+
credential_id and public_key are stored as base64url-encoded strings,
479+
matching the convention used by django-two-factor-auth's WebAuthn plugin.
480+
"""
481+
482+
user = models.ForeignKey(
483+
settings.AUTH_USER_MODEL,
484+
on_delete=models.CASCADE,
485+
related_name="passkeys",
486+
)
487+
name = models.CharField(max_length=255, blank=True)
488+
# base64url-encoded credential id (unique per authenticator)
489+
credential_id = models.CharField(max_length=2048, unique=True)
490+
# base64url-encoded public key
491+
public_key = models.CharField(max_length=2048)
492+
sign_count = models.PositiveBigIntegerField(default=0)
493+
transports = models.JSONField(default=list, blank=True)
494+
created_at = models.DateTimeField(auto_now_add=True)
495+
last_used_at = models.DateTimeField(null=True, blank=True)
496+
497+
class Meta:
498+
ordering = ["-created_at"]
499+
500+
def __str__(self):
501+
return self.name or f"Passkey {self.pk}"

0 commit comments

Comments
 (0)