88from django .contrib .auth .decorators import login_required
99from django .core .exceptions import PermissionDenied
1010from django .db import transaction
11- from django .http import Http404 , JsonResponse
11+ from django .http import Http404 , HttpResponse , JsonResponse
1212from django .shortcuts import get_object_or_404 , render , resolve_url
1313from django .utils import timezone
1414from django .utils .http import url_has_allowed_host_and_scheme
3636 UserVerificationRequirement ,
3737)
3838
39+ from hypha .elevate .views import redirect_to_elevate
40+
3941from .models import Passkey
42+ from .services import send_passkey_notification
43+ from .utils import passkeys_enabled
4044
4145logger = logging .getLogger (__name__ )
4246
4347SESSION_CHALLENGE_KEY_REGISTER = "webauthn_challenge_register"
4448SESSION_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-
5451def 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+
6491def _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+
83115def _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
87122def _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" )
115154def 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" )
153193def 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
351393def 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
0 commit comments