Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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/0028_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", "0027_remove_drupal_id_field"),
]

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=1024)),
("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 @@ -406,3 +406,32 @@ def __str__(self):
class Meta:
ordering = ("modified",)
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=1024)
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