Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
a735651
Implementing passkeys as new login method for Hypha.
frjo Mar 23, 2026
a2a6f26
Add some url checks. Show last_used_at for each passkey. Inline form …
frjo Mar 23, 2026
7ba819a
Some ux improvments to the user account buttons.
frjo Mar 23, 2026
a09aac8
Tighting up the passkeys.js.
frjo Mar 23, 2026
678f9d3
More generic errors and pass through next on passkey login.
frjo Mar 23, 2026
e6f2841
Add MAX_PASSKEYS_PER_USER. Add ratelimit to more views.
frjo Mar 23, 2026
b789d19
Make sure it works for Linux desktop users with hardware keys etc. as…
frjo Mar 23, 2026
5fb6565
Use CharField insgead of TextField and fix a comment.
frjo Mar 23, 2026
2e37fbd
Multi-tab challenge collision fix.
frjo Mar 23, 2026
ddd7d8f
Store the transports info.
frjo Mar 23, 2026
4e53bc8
Fix login bug.
frjo Mar 23, 2026
88ef18f
Remove shrink-0 class, not needed.
frjo Mar 24, 2026
5acb265
Improve some strings.
frjo Mar 24, 2026
a98c0ab
Fix migration after rebase.
frjo Apr 1, 2026
8232bc1
Change all passkey_views from class views to function views.
frjo Apr 6, 2026
d9cd8c1
Implement remember_me support for passkeys.
frjo Apr 6, 2026
25bb4a7
Update hypha/apply/users/templates/users/partials/list.html
frjo Apr 15, 2026
a802a4a
Escape user unput with escapejs and make the same button look like a …
frjo Apr 15, 2026
4c61a24
Check that redirect_url is local, just to be safe. Move values from h…
frjo Apr 15, 2026
d21c0d9
Get CSRFToken from exisitng hxHeaders.
frjo Apr 15, 2026
d73b177
Ensure passkey name is max 128 chars.
frjo Apr 16, 2026
80201e1
Add some logging.
frjo Apr 16, 2026
0a89476
Make all error strings translateble.
frjo Apr 16, 2026
0a50f8c
Use transaction.atomic for passkeys loggin.
frjo Apr 16, 2026
c356dae
Log cloned authenticators.
frjo Apr 16, 2026
dd80582
Get CSRFToken from exisitng hxHeaders, part 2.
frjo Apr 16, 2026
bbbf495
Wrap create key in a try statement.
frjo Apr 16, 2026
89ba320
Settings comment regarding production.
frjo Apr 16, 2026
9b08ac3
Set maxlength on new key name field as well.
frjo Apr 16, 2026
655071b
Up public_key to 2048.
frjo Apr 21, 2026
725c285
Add tests.
frjo Apr 21, 2026
50be813
Renamed template to passkey-list.html. Made green check not look like…
frjo Apr 22, 2026
77dd539
Move GOOGLE_OAUTH2 login button last and make login pages wider so bu…
frjo Apr 22, 2026
53d11ea
Update migrations after rebase.
frjo Apr 22, 2026
0599085
Fix passkey login button so it mimic password login button.
frjo May 1, 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
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