Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
cff6fdb
Implementing passkeys as new login method for Hypha.
frjo Mar 23, 2026
70bcff1
Add some url checks. Show last_used_at for each passkey. Inline form …
frjo Mar 23, 2026
79c0831
Some ux improvments to the user account buttons.
frjo Mar 23, 2026
046197b
Tighting up the passkeys.js.
frjo Mar 23, 2026
e3f61fd
More generic errors and pass through next on passkey login.
frjo Mar 23, 2026
a40e8fb
Add MAX_PASSKEYS_PER_USER. Add ratelimit to more views.
frjo Mar 23, 2026
a026b02
Make sure it works for Linux desktop users with hardware keys etc. as…
frjo Mar 23, 2026
119f24d
Use CharField insgead of TextField and fix a comment.
frjo Mar 23, 2026
be5fa7d
Multi-tab challenge collision fix.
frjo Mar 23, 2026
acc9f1a
Store the transports info.
frjo Mar 23, 2026
0bcabfb
Fix login bug.
frjo Mar 23, 2026
cb88074
Remove shrink-0 class, not needed.
frjo Mar 24, 2026
a1fdc35
Improve some strings.
frjo Mar 24, 2026
f466f9d
Fix migration after rebase.
frjo Apr 1, 2026
892ff46
Change all passkey_views from class views to function views.
frjo Apr 6, 2026
ca6a131
Implement remember_me support for passkeys.
frjo Apr 6, 2026
3c1e7cf
Update hypha/apply/users/templates/users/partials/list.html
frjo Apr 15, 2026
654adfc
Escape user unput with escapejs and make the same button look like a …
frjo Apr 15, 2026
cd844e4
Check that redirect_url is local, just to be safe. Move values from h…
frjo Apr 15, 2026
6359437
Get CSRFToken from exisitng hxHeaders.
frjo Apr 15, 2026
edf7f10
Ensure passkey name is max 128 chars.
frjo Apr 16, 2026
4cdcc24
Add some logging.
frjo Apr 16, 2026
ad3b234
Make all error strings translateble.
frjo Apr 16, 2026
344d8fe
Use transaction.atomic for passkeys loggin.
frjo Apr 16, 2026
eacc919
Log cloned authenticators.
frjo Apr 16, 2026
03251f8
Get CSRFToken from exisitng hxHeaders, part 2.
frjo Apr 16, 2026
652fa7a
Wrap create key in a try statement.
frjo Apr 16, 2026
fbd2a1c
Settings comment regarding production.
frjo Apr 16, 2026
e5d9990
Set maxlength on new key name field as well.
frjo Apr 16, 2026
e6f135f
Up public_key to 2048.
frjo Apr 21, 2026
f24b22b
Add tests.
frjo Apr 21, 2026
f8d5619
Renamed template to passkey-list.html. Made green check not look like…
frjo Apr 22, 2026
b02a2d2
Move GOOGLE_OAUTH2 login button last and make login pages wider so bu…
frjo Apr 22, 2026
f6ca4bc
Update migrations after rebase.
frjo Apr 22, 2026
b929a60
Fix passkey login button so it mimic password login button.
frjo May 1, 2026
d12a42d
Only store valid transports values.
frjo May 19, 2026
338b946
Disable passkeys in production unless WEBAUTHN_RP_ID is set.
frjo May 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/setup/deployment/production/stand-alone.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,11 @@ SERVER_EMAIL: app@example.org
SEND_MESSAGES: true
```

**Passkeys:**

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).


**Turn on Hypha features that are off by default:**

```text
Expand Down
6 changes: 5 additions & 1 deletion hypha/apply/users/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.user_settings = AuthSettings.load(request_or_site=self.request)
self.extra_text = self.user_settings.extra_text
# Enable passkey autofill (conditional mediation) on the username field
self.fields["username"].widget.attrs["autocomplete"] = "username webauthn"
if self.user_settings.consent_show:
self.fields["consent"] = forms.BooleanField(
label=self.user_settings.consent_text,
Expand All @@ -55,7 +57,9 @@ class PasswordlessAuthForm(forms.Form):
label=_("Email address"),
required=True,
max_length=254,
widget=forms.EmailInput(attrs={"autofocus": True, "autocomplete": "email"}),
widget=forms.EmailInput(
attrs={"autofocus": True, "autocomplete": "username webauthn"}
),
)

if settings.SESSION_COOKIE_AGE <= settings.SESSION_COOKIE_AGE_LONG:
Expand Down
6 changes: 5 additions & 1 deletion hypha/apply/users/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,11 @@ def __call__(self, request):
# code to execute before the view
user = request.user
if user.is_authenticated:
if user.social_auth.exists() or user.is_verified():
if (
user.social_auth.exists()
or user.is_verified()
or request.session.get("passkey_authenticated")
):
return self._accept(request)

# Allow rounds and lab detail pages
Expand Down
46 changes: 46 additions & 0 deletions hypha/apply/users/migrations/0030_passkeys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Generated by Django 5.2.12 on 2026-03-23 21:24

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("users", "0029_alter_confirmaccesstoken_options_and_more"),
]

operations = [
migrations.CreateModel(
name="Passkey",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(blank=True, max_length=255)),
("credential_id", models.CharField(max_length=2048, unique=True)),
("public_key", models.CharField(max_length=2048)),
("sign_count", models.PositiveBigIntegerField(default=0)),
("transports", models.JSONField(blank=True, default=list)),
("created_at", models.DateTimeField(auto_now_add=True)),
("last_used_at", models.DateTimeField(blank=True, null=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="passkeys",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-created_at"],
},
),
]
29 changes: 29 additions & 0 deletions hypha/apply/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -470,3 +470,32 @@ class Meta:
ordering = ("modified",)
verbose_name = _("Confirm Access Token")
verbose_name_plural = _("Confirm Access Tokens")


class Passkey(models.Model):
"""Stores a WebAuthn passkey credential for a user.

credential_id and public_key are stored as base64url-encoded strings,
matching the convention used by django-two-factor-auth's WebAuthn plugin.
"""

user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="passkeys",
)
name = models.CharField(max_length=255, blank=True)
# base64url-encoded credential id (unique per authenticator)
credential_id = models.CharField(max_length=2048, unique=True)
# base64url-encoded public key
public_key = models.CharField(max_length=2048)
sign_count = models.PositiveBigIntegerField(default=0)
transports = models.JSONField(default=list, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
last_used_at = models.DateTimeField(null=True, blank=True)

class Meta:
ordering = ["-created_at"]

def __str__(self):
return self.name or f"Passkey {self.pk}"
Loading
Loading