Skip to content

Commit 2b3c8ec

Browse files
authored
Use elevate for adding and deleting passkeys (#4904)
Also sending e-mail notifications when user add or delete a passkey. Made the default elevate cookie time 15 minuts instead of 60 minuts.
1 parent 192c9e0 commit 2b3c8ec

12 files changed

Lines changed: 379 additions & 34 deletions

File tree

hypha/apply/users/passkey_views.py

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from django.contrib.auth.decorators import login_required
99
from django.core.exceptions import PermissionDenied
1010
from django.db import transaction
11-
from django.http import Http404, JsonResponse
11+
from django.http import Http404, HttpResponse, JsonResponse
1212
from django.shortcuts import get_object_or_404, render, resolve_url
1313
from django.utils import timezone
1414
from django.utils.http import url_has_allowed_host_and_scheme
@@ -36,21 +36,18 @@
3636
UserVerificationRequirement,
3737
)
3838

39+
from hypha.elevate.views import redirect_to_elevate
40+
3941
from .models import Passkey
42+
from .services import send_passkey_notification
43+
from .utils import passkeys_enabled
4044

4145
logger = logging.getLogger(__name__)
4246

4347
SESSION_CHALLENGE_KEY_REGISTER = "webauthn_challenge_register"
4448
SESSION_CHALLENGE_KEY_AUTH = "webauthn_challenge_auth"
4549

4650

47-
def passkeys_enabled() -> bool:
48-
"""Passkeys require WEBAUTHN_RP_ID in production. In DEBUG (local/dev)
49-
we fall back to the request host so the feature can be exercised locally.
50-
"""
51-
return bool(getattr(settings, "WEBAUTHN_RP_ID", None)) or settings.DEBUG
52-
53-
5451
def passkeys_required(view_func):
5552
@wraps(view_func)
5653
def _wrapped(request, *args, **kwargs):
@@ -61,6 +58,36 @@ def _wrapped(request, *args, **kwargs):
6158
return _wrapped
6259

6360

61+
def passkey_elevate_required(view_func):
62+
"""Require an elevated (recently re-authenticated) session for sensitive
63+
passkey management actions — adding and removing passkeys.
64+
65+
This mirrors the elevation gate used for disabling 2FA and changing the
66+
account email. All users must re-authenticate before the action is allowed:
67+
users with a usable password confirm it again, while users without one
68+
(e.g. OAuth logins) are routed to the same elevate page where they confirm
69+
access via an emailed one-time code.
70+
71+
These endpoints are called via ``fetch`` (registration) and HTMX (delete),
72+
so instead of returning a normal redirect we hand the client the elevate
73+
URL: an ``HX-Redirect`` header for HTMX requests, otherwise a JSON body with
74+
an ``elevate_url`` the JavaScript can navigate to.
75+
"""
76+
77+
@wraps(view_func)
78+
def _wrapped(request, *args, **kwargs):
79+
if not request.is_elevated():
80+
elevate_url = redirect_to_elevate(resolve_url("users:account"))["Location"]
81+
if request.headers.get("HX-Request"):
82+
response = HttpResponse(status=204)
83+
response["HX-Redirect"] = elevate_url
84+
return response
85+
return JsonResponse({"elevate_url": elevate_url}, status=403)
86+
return view_func(request, *args, **kwargs)
87+
88+
return _wrapped
89+
90+
6491
def _get_rp_id(request):
6592
rp_id = getattr(settings, "WEBAUTHN_RP_ID", None)
6693
if rp_id:
@@ -80,15 +107,26 @@ def _get_origin(request):
80107
return f"{scheme}://{request.get_host()}"
81108

82109

110+
# WebAuthn challenges are single-use, but they should also be short-lived.
111+
# Reject any challenge older than this to match spec guidance (a few minutes).
112+
CHALLENGE_TTL_SECONDS = 300
113+
114+
83115
def _store_challenge(request, challenge: bytes, key: str):
84-
request.session[key] = base64.b64encode(challenge).decode()
116+
request.session[key] = {
117+
"challenge": base64.b64encode(challenge).decode(),
118+
"created": timezone.now().timestamp(),
119+
}
85120

86121

87122
def _load_challenge(request, key: str) -> bytes:
88-
encoded = request.session.pop(key, None)
89-
if not encoded:
123+
stored = request.session.pop(key, None)
124+
if not isinstance(stored, dict):
90125
raise PermissionDenied("No active WebAuthn challenge.")
91-
return base64.b64decode(encoded)
126+
created = stored.get("created")
127+
if created is None or timezone.now().timestamp() - created > CHALLENGE_TTL_SECONDS:
128+
raise PermissionDenied("WebAuthn challenge expired.")
129+
return base64.b64decode(stored["challenge"])
92130

93131

94132
_VALID_TRANSPORTS = {t.value for t in AuthenticatorTransport}
@@ -111,6 +149,7 @@ def _clean_transports(raw) -> list[str]:
111149
@passkeys_required
112150
@login_required
113151
@require_POST
152+
@passkey_elevate_required
114153
@ratelimit(key="user", rate=settings.DEFAULT_RATE_LIMIT, method="POST")
115154
def passkey_register_begin(request):
116155
user = request.user
@@ -149,6 +188,7 @@ def passkey_register_begin(request):
149188
@passkeys_required
150189
@login_required
151190
@require_POST
191+
@passkey_elevate_required
152192
@ratelimit(key="user", rate=settings.DEFAULT_RATE_LIMIT, method="POST")
153193
def passkey_register_complete(request):
154194
try:
@@ -207,6 +247,7 @@ def passkey_register_complete(request):
207247
)
208248
return JsonResponse({"error": _("Could not save passkey")}, status=500)
209249
logger.info("Passkey registered for user %s (name=%r)", request.user.pk, name)
250+
send_passkey_notification(request, request.user, name, added=True)
210251
return JsonResponse({"status": "ok"})
211252

212253

@@ -348,6 +389,7 @@ def passkey_list(request):
348389
@passkeys_required
349390
@login_required
350391
@require_POST
392+
@passkey_elevate_required
351393
def passkey_delete(request, pk):
352394
passkey = get_object_or_404(Passkey, pk=pk, user=request.user)
353395
logger.info(
@@ -356,7 +398,9 @@ def passkey_delete(request, pk):
356398
pk,
357399
passkey.name,
358400
)
401+
passkey_name = passkey.name
359402
passkey.delete()
403+
send_passkey_notification(request, request.user, passkey_name, added=False)
360404
passkeys = request.user.passkeys.all()
361405
return render(request, "users/partials/passkey-list.html", {"passkeys": passkeys})
362406

hypha/apply/users/services.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,53 @@
1414

1515
from .models import PendingSignup
1616
from .tokens import PasswordlessLoginTokenGenerator, PasswordlessSignupTokenGenerator
17-
from .utils import get_redirect_url, get_user_by_email
17+
from .utils import get_redirect_url, get_user_by_email, local_event_time
1818

1919
User = get_user_model()
2020

2121

22+
def send_passkey_notification(request, user, passkey_name, *, added):
23+
"""Notify the user by email that a passkey was added to or removed from
24+
their account. Mirrors the login notification so the two feel consistent.
25+
26+
Args:
27+
request: The current request (used for timezone and site resolution).
28+
user: The user whose account changed.
29+
passkey_name: Display name of the affected passkey.
30+
added: True if the passkey was added, False if it was removed.
31+
"""
32+
if not settings.SEND_MESSAGES or not user.email:
33+
return
34+
35+
if added:
36+
subject = _("A passkey was added to your %(org)s account") % {
37+
"org": settings.ORG_LONG_NAME
38+
}
39+
template = "users/emails/passkey_added_notification.md"
40+
else:
41+
subject = _("A passkey was removed from your %(org)s account") % {
42+
"org": settings.ORG_LONG_NAME
43+
}
44+
template = "users/emails/passkey_removed_notification.md"
45+
46+
if settings.EMAIL_SUBJECT_PREFIX:
47+
subject = str(settings.EMAIL_SUBJECT_PREFIX) + str(subject)
48+
49+
email = MarkdownMail(template)
50+
email.send(
51+
to=user.email,
52+
subject=subject,
53+
from_email=settings.DEFAULT_FROM_EMAIL,
54+
context={
55+
"user": user,
56+
"passkey_name": passkey_name,
57+
"event_time": local_event_time(request),
58+
"site": Site.find_for_request(request) if request else None,
59+
"ORG_EMAIL": settings.ORG_EMAIL,
60+
},
61+
)
62+
63+
2264
class PasswordlessAuthService:
2365
login_token_generator_class = PasswordlessLoginTokenGenerator
2466
signup_token_generator_class = PasswordlessSignupTokenGenerator

hypha/apply/users/signals.py

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
from django.conf import settings
22
from django.contrib.auth.signals import user_logged_in
33
from django.dispatch import receiver
4-
from django.utils import formats, timezone
54
from django.utils.translation import gettext_lazy as _
65
from wagtail.models import Site
76

87
from hypha.core.mail import MarkdownMail
98

10-
from .utils import get_zoneinfo
9+
from .utils import local_event_time
1110

1211
HIJACK_VIEW_NAMES = {
1312
"hijack-become",
@@ -29,11 +28,6 @@ def send_login_notification(sender, request, user, **kwargs):
2928
if request.resolver_match.view_name in HIJACK_VIEW_NAMES:
3029
return
3130

32-
tz_name = (
33-
getattr(request, "session", {}).get("user_timezone", "") if request else ""
34-
)
35-
user_tz = get_zoneinfo(tz_name)
36-
3731
subject = _("Successful login to %(org)s") % {"org": settings.ORG_LONG_NAME}
3832
if settings.EMAIL_SUBJECT_PREFIX:
3933
subject = str(settings.EMAIL_SUBJECT_PREFIX) + str(subject)
@@ -45,12 +39,7 @@ def send_login_notification(sender, request, user, **kwargs):
4539
from_email=settings.DEFAULT_FROM_EMAIL,
4640
context={
4741
"user": user,
48-
"login_time": "{} ({})".format(
49-
formats.date_format(
50-
timezone.localtime(timezone=user_tz), "SHORT_DATETIME_FORMAT"
51-
),
52-
tz_name or timezone.get_current_timezone_name(),
53-
),
42+
"login_time": local_event_time(request),
5443
"site": Site.find_for_request(request) if request else None,
5544
"ORG_EMAIL": settings.ORG_EMAIL,
5645
},

hypha/apply/users/templates/elevate/elevate.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
<h2 class="text-2xl text-center">{% trans "Confirm access" %}</h2>
1111

12-
<p class="mb-4 text-center">
12+
<p class="m-4 text-center">
1313
{% if request.user.full_name %}
1414
{% blocktrans with name=request.user.full_name email=request.user.email trimmed %}
1515
Signed in as <strong>{{ name }}({{ email }})</strong>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{% load i18n wagtailadmin_tags %}{% base_url_setting as base_url %}
2+
{% blocktrans %}Dear {{ user }},{% endblocktrans %}
3+
4+
{% blocktrans %}This is to notify you that a new passkey was added to your account at {{ ORG_LONG_NAME }}.{% endblocktrans %}
5+
6+
{% blocktrans %}Passkey: {{ passkey_name }}{% endblocktrans %}
7+
{% blocktrans with event_time=event_time %}Added: {{ event_time }}{% endblocktrans %}
8+
9+
{% blocktrans %}If you did not add this passkey, please contact us immediately and consider removing it from your account.{% endblocktrans %}
10+
11+
{% if ORG_EMAIL %}
12+
{% blocktrans %}If you have any questions, please contact us at {{ ORG_EMAIL }}.{% endblocktrans %}
13+
{% endif %}
14+
15+
{% blocktrans %}Kind Regards,
16+
The {{ ORG_SHORT_NAME }} Team{% endblocktrans %}
17+
18+
--
19+
{{ ORG_LONG_NAME }}
20+
{% if site %}{{ site.root_url }}{% else %}{{ base_url }}{% endif %}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{% load i18n wagtailadmin_tags %}{% base_url_setting as base_url %}
2+
{% blocktrans %}Dear {{ user }},{% endblocktrans %}
3+
4+
{% blocktrans %}This is to notify you that a passkey was removed from your account at {{ ORG_LONG_NAME }}.{% endblocktrans %}
5+
6+
{% blocktrans %}Passkey: {{ passkey_name }}{% endblocktrans %}
7+
{% blocktrans with event_time=event_time %}Removed: {{ event_time }}{% endblocktrans %}
8+
9+
{% blocktrans %}If you did not remove this passkey, please contact us immediately and consider changing your password if you have one.{% endblocktrans %}
10+
11+
{% if ORG_EMAIL %}
12+
{% blocktrans %}If you have any questions, please contact us at {{ ORG_EMAIL }}.{% endblocktrans %}
13+
{% endif %}
14+
15+
{% blocktrans %}Kind Regards,
16+
The {{ ORG_SHORT_NAME }} Team{% endblocktrans %}
17+
18+
--
19+
{{ ORG_LONG_NAME }}
20+
{% if site %}{{ site.root_url }}{% else %}{{ base_url }}{% endif %}

0 commit comments

Comments
 (0)