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
68 changes: 56 additions & 12 deletions hypha/apply/users/passkey_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.http import Http404, JsonResponse
from django.http import Http404, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, render, resolve_url
from django.utils import timezone
from django.utils.http import url_has_allowed_host_and_scheme
Expand Down Expand Up @@ -36,21 +36,18 @@
UserVerificationRequirement,
)

from hypha.elevate.views import redirect_to_elevate

from .models import Passkey
from .services import send_passkey_notification
from .utils import passkeys_enabled

logger = logging.getLogger(__name__)

SESSION_CHALLENGE_KEY_REGISTER = "webauthn_challenge_register"
SESSION_CHALLENGE_KEY_AUTH = "webauthn_challenge_auth"


def passkeys_enabled() -> bool:
"""Passkeys require WEBAUTHN_RP_ID in production. In DEBUG (local/dev)
we fall back to the request host so the feature can be exercised locally.
"""
return bool(getattr(settings, "WEBAUTHN_RP_ID", None)) or settings.DEBUG


def passkeys_required(view_func):
@wraps(view_func)
def _wrapped(request, *args, **kwargs):
Expand All @@ -61,6 +58,36 @@ def _wrapped(request, *args, **kwargs):
return _wrapped


def passkey_elevate_required(view_func):
"""Require an elevated (recently re-authenticated) session for sensitive
passkey management actions — adding and removing passkeys.

This mirrors the elevation gate used for disabling 2FA and changing the
account email. All users must re-authenticate before the action is allowed:
users with a usable password confirm it again, while users without one
(e.g. OAuth logins) are routed to the same elevate page where they confirm
access via an emailed one-time code.

These endpoints are called via ``fetch`` (registration) and HTMX (delete),
so instead of returning a normal redirect we hand the client the elevate
URL: an ``HX-Redirect`` header for HTMX requests, otherwise a JSON body with
an ``elevate_url`` the JavaScript can navigate to.
"""

@wraps(view_func)
def _wrapped(request, *args, **kwargs):
if not request.is_elevated():
elevate_url = redirect_to_elevate(resolve_url("users:account"))["Location"]
if request.headers.get("HX-Request"):
response = HttpResponse(status=204)
response["HX-Redirect"] = elevate_url
return response
return JsonResponse({"elevate_url": elevate_url}, status=403)
return view_func(request, *args, **kwargs)

return _wrapped


def _get_rp_id(request):
rp_id = getattr(settings, "WEBAUTHN_RP_ID", None)
if rp_id:
Expand All @@ -80,15 +107,26 @@ def _get_origin(request):
return f"{scheme}://{request.get_host()}"


# WebAuthn challenges are single-use, but they should also be short-lived.
# Reject any challenge older than this to match spec guidance (a few minutes).
CHALLENGE_TTL_SECONDS = 300


def _store_challenge(request, challenge: bytes, key: str):
request.session[key] = base64.b64encode(challenge).decode()
request.session[key] = {
"challenge": base64.b64encode(challenge).decode(),
"created": timezone.now().timestamp(),
}


def _load_challenge(request, key: str) -> bytes:
encoded = request.session.pop(key, None)
if not encoded:
stored = request.session.pop(key, None)
if not isinstance(stored, dict):
raise PermissionDenied("No active WebAuthn challenge.")
return base64.b64decode(encoded)
created = stored.get("created")
if created is None or timezone.now().timestamp() - created > CHALLENGE_TTL_SECONDS:
raise PermissionDenied("WebAuthn challenge expired.")
return base64.b64decode(stored["challenge"])


_VALID_TRANSPORTS = {t.value for t in AuthenticatorTransport}
Expand All @@ -111,6 +149,7 @@ def _clean_transports(raw) -> list[str]:
@passkeys_required
@login_required
@require_POST
@passkey_elevate_required
@ratelimit(key="user", rate=settings.DEFAULT_RATE_LIMIT, method="POST")
def passkey_register_begin(request):
user = request.user
Expand Down Expand Up @@ -149,6 +188,7 @@ def passkey_register_begin(request):
@passkeys_required
@login_required
@require_POST
@passkey_elevate_required
@ratelimit(key="user", rate=settings.DEFAULT_RATE_LIMIT, method="POST")
def passkey_register_complete(request):
try:
Expand Down Expand Up @@ -207,6 +247,7 @@ def passkey_register_complete(request):
)
return JsonResponse({"error": _("Could not save passkey")}, status=500)
logger.info("Passkey registered for user %s (name=%r)", request.user.pk, name)
send_passkey_notification(request, request.user, name, added=True)
return JsonResponse({"status": "ok"})


Expand Down Expand Up @@ -348,6 +389,7 @@ def passkey_list(request):
@passkeys_required
@login_required
@require_POST
@passkey_elevate_required
def passkey_delete(request, pk):
passkey = get_object_or_404(Passkey, pk=pk, user=request.user)
logger.info(
Expand All @@ -356,7 +398,9 @@ def passkey_delete(request, pk):
pk,
passkey.name,
)
passkey_name = passkey.name
passkey.delete()
send_passkey_notification(request, request.user, passkey_name, added=False)
passkeys = request.user.passkeys.all()
return render(request, "users/partials/passkey-list.html", {"passkeys": passkeys})

Expand Down
44 changes: 43 additions & 1 deletion hypha/apply/users/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,53 @@

from .models import PendingSignup
from .tokens import PasswordlessLoginTokenGenerator, PasswordlessSignupTokenGenerator
from .utils import get_redirect_url, get_user_by_email
from .utils import get_redirect_url, get_user_by_email, local_event_time

User = get_user_model()


def send_passkey_notification(request, user, passkey_name, *, added):
"""Notify the user by email that a passkey was added to or removed from
their account. Mirrors the login notification so the two feel consistent.

Args:
request: The current request (used for timezone and site resolution).
user: The user whose account changed.
passkey_name: Display name of the affected passkey.
added: True if the passkey was added, False if it was removed.
"""
if not settings.SEND_MESSAGES or not user.email:
return

if added:
subject = _("A passkey was added to your %(org)s account") % {
"org": settings.ORG_LONG_NAME
}
template = "users/emails/passkey_added_notification.md"
else:
subject = _("A passkey was removed from your %(org)s account") % {
"org": settings.ORG_LONG_NAME
}
template = "users/emails/passkey_removed_notification.md"

if settings.EMAIL_SUBJECT_PREFIX:
subject = str(settings.EMAIL_SUBJECT_PREFIX) + str(subject)

email = MarkdownMail(template)
email.send(
to=user.email,
subject=subject,
from_email=settings.DEFAULT_FROM_EMAIL,
context={
"user": user,
"passkey_name": passkey_name,
"event_time": local_event_time(request),
"site": Site.find_for_request(request) if request else None,
"ORG_EMAIL": settings.ORG_EMAIL,
},
)


class PasswordlessAuthService:
login_token_generator_class = PasswordlessLoginTokenGenerator
signup_token_generator_class = PasswordlessSignupTokenGenerator
Expand Down
15 changes: 2 additions & 13 deletions hypha/apply/users/signals.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
from django.conf import settings
from django.contrib.auth.signals import user_logged_in
from django.dispatch import receiver
from django.utils import formats, timezone
from django.utils.translation import gettext_lazy as _
from wagtail.models import Site

from hypha.core.mail import MarkdownMail

from .utils import get_zoneinfo
from .utils import local_event_time

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

tz_name = (
getattr(request, "session", {}).get("user_timezone", "") if request else ""
)
user_tz = get_zoneinfo(tz_name)

subject = _("Successful login to %(org)s") % {"org": settings.ORG_LONG_NAME}
if settings.EMAIL_SUBJECT_PREFIX:
subject = str(settings.EMAIL_SUBJECT_PREFIX) + str(subject)
Expand All @@ -45,12 +39,7 @@ def send_login_notification(sender, request, user, **kwargs):
from_email=settings.DEFAULT_FROM_EMAIL,
context={
"user": user,
"login_time": "{} ({})".format(
formats.date_format(
timezone.localtime(timezone=user_tz), "SHORT_DATETIME_FORMAT"
),
tz_name or timezone.get_current_timezone_name(),
),
"login_time": local_event_time(request),
"site": Site.find_for_request(request) if request else None,
"ORG_EMAIL": settings.ORG_EMAIL,
},
Expand Down
2 changes: 1 addition & 1 deletion hypha/apply/users/templates/elevate/elevate.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

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

<p class="mb-4 text-center">
<p class="m-4 text-center">
{% if request.user.full_name %}
{% blocktrans with name=request.user.full_name email=request.user.email trimmed %}
Signed in as <strong>{{ name }}({{ email }})</strong>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{% load i18n wagtailadmin_tags %}{% base_url_setting as base_url %}
{% blocktrans %}Dear {{ user }},{% endblocktrans %}

{% blocktrans %}This is to notify you that a new passkey was added to your account at {{ ORG_LONG_NAME }}.{% endblocktrans %}

{% blocktrans %}Passkey: {{ passkey_name }}{% endblocktrans %}
{% blocktrans with event_time=event_time %}Added: {{ event_time }}{% endblocktrans %}

{% blocktrans %}If you did not add this passkey, please contact us immediately and consider removing it from your account.{% endblocktrans %}

{% if ORG_EMAIL %}
{% blocktrans %}If you have any questions, please contact us at {{ ORG_EMAIL }}.{% endblocktrans %}
{% endif %}

{% blocktrans %}Kind Regards,
The {{ ORG_SHORT_NAME }} Team{% endblocktrans %}

--
{{ ORG_LONG_NAME }}
{% if site %}{{ site.root_url }}{% else %}{{ base_url }}{% endif %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{% load i18n wagtailadmin_tags %}{% base_url_setting as base_url %}
{% blocktrans %}Dear {{ user }},{% endblocktrans %}

{% blocktrans %}This is to notify you that a passkey was removed from your account at {{ ORG_LONG_NAME }}.{% endblocktrans %}

{% blocktrans %}Passkey: {{ passkey_name }}{% endblocktrans %}
{% blocktrans with event_time=event_time %}Removed: {{ event_time }}{% endblocktrans %}

{% blocktrans %}If you did not remove this passkey, please contact us immediately and consider changing your password if you have one.{% endblocktrans %}

{% if ORG_EMAIL %}
{% blocktrans %}If you have any questions, please contact us at {{ ORG_EMAIL }}.{% endblocktrans %}
{% endif %}

{% blocktrans %}Kind Regards,
The {{ ORG_SHORT_NAME }} Team{% endblocktrans %}

--
{{ ORG_LONG_NAME }}
{% if site %}{{ site.root_url }}{% else %}{{ base_url }}{% endif %}
Loading