Skip to content

Commit 338b946

Browse files
committed
Disable passkeys in production unless WEBAUTHN_RP_ID is set.
1 parent d12a42d commit 338b946

9 files changed

Lines changed: 159 additions & 29 deletions

File tree

docs/setup/deployment/production/stand-alone.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,11 @@ SERVER_EMAIL: app@example.org
185185
SEND_MESSAGES: true
186186
```
187187

188+
**Passkeys:**
189+
190+
To activate passkeys in production you must set at least `WEBAUTHN_RP_ID` to the relying party domain, e.g. "example.com" (no port, no scheme).
191+
192+
188193
**Turn on Hypha features that are off by default:**
189194

190195
```text

hypha/apply/users/passkey_views.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import base64
22
import json
33
import logging
4+
from functools import wraps
45

56
from django.conf import settings
67
from django.contrib.auth import login
78
from django.contrib.auth.decorators import login_required
89
from django.core.exceptions import PermissionDenied
910
from django.db import transaction
10-
from django.http import JsonResponse
11+
from django.http import Http404, JsonResponse
1112
from django.shortcuts import get_object_or_404, render, resolve_url
1213
from django.utils import timezone
1314
from django.utils.http import url_has_allowed_host_and_scheme
@@ -43,6 +44,23 @@
4344
SESSION_CHALLENGE_KEY_AUTH = "webauthn_challenge_auth"
4445

4546

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+
54+
def passkeys_required(view_func):
55+
@wraps(view_func)
56+
def _wrapped(request, *args, **kwargs):
57+
if not passkeys_enabled():
58+
raise Http404
59+
return view_func(request, *args, **kwargs)
60+
61+
return _wrapped
62+
63+
4664
def _get_rp_id(request):
4765
rp_id = getattr(settings, "WEBAUTHN_RP_ID", None)
4866
if rp_id:
@@ -90,6 +108,7 @@ def _clean_transports(raw) -> list[str]:
90108
MAX_PASSKEYS_PER_USER = 10
91109

92110

111+
@passkeys_required
93112
@login_required
94113
@require_POST
95114
@ratelimit(key="user", rate=settings.DEFAULT_RATE_LIMIT, method="POST")
@@ -127,6 +146,7 @@ def passkey_register_begin(request):
127146
return JsonResponse(json.loads(options_to_json(options)))
128147

129148

149+
@passkeys_required
130150
@login_required
131151
@require_POST
132152
@ratelimit(key="user", rate=settings.DEFAULT_RATE_LIMIT, method="POST")
@@ -195,6 +215,7 @@ def passkey_register_complete(request):
195215
# ---------------------------------------------------------------------------
196216

197217

218+
@passkeys_required
198219
@require_POST
199220
@ratelimit(key="ip", rate=settings.DEFAULT_RATE_LIMIT, method="POST")
200221
def passkey_auth_begin(request):
@@ -206,6 +227,7 @@ def passkey_auth_begin(request):
206227
return JsonResponse(json.loads(options_to_json(options)))
207228

208229

230+
@passkeys_required
209231
@require_POST
210232
@ratelimit(key="ip", rate=settings.DEFAULT_RATE_LIMIT, method="POST")
211233
def passkey_auth_complete(request):
@@ -315,13 +337,15 @@ def passkey_auth_complete(request):
315337
# ---------------------------------------------------------------------------
316338

317339

340+
@passkeys_required
318341
@login_required
319342
@require_GET
320343
def passkey_list(request):
321344
passkeys = request.user.passkeys.all()
322345
return render(request, "users/partials/passkey-list.html", {"passkeys": passkeys})
323346

324347

348+
@passkeys_required
325349
@login_required
326350
@require_POST
327351
def passkey_delete(request, pk):
@@ -337,6 +361,7 @@ def passkey_delete(request, pk):
337361
return render(request, "users/partials/passkey-list.html", {"passkeys": passkeys})
338362

339363

364+
@passkeys_required
340365
@login_required
341366
@require_POST
342367
def passkey_rename(request, pk):

hypha/apply/users/templates/users/account.html

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -93,18 +93,20 @@ <h3 class="mb-2 text-sm font-semibold">
9393
{% endif %}
9494
</p>
9595

96-
<h3 class="mt-6 mb-2 text-sm font-semibold">{% trans "Passkeys" %}</h3>
97-
<p class="mb-2 text-sm text-fg-muted">
98-
{% trans "With passkeys you can use your fingerprint, face, or screen lock to login securely without a password." %}
99-
</p>
96+
{% if PASSKEYS_ENABLED %}
97+
<h3 class="mt-6 mb-2 text-sm font-semibold">{% trans "Passkeys" %}</h3>
98+
<p class="mb-2 text-sm text-fg-muted">
99+
{% trans "With passkeys you can use your fingerprint, face, or screen lock to login securely without a password." %}
100+
</p>
100101

101-
<div
102-
hx-get="{% url 'users:passkey_list' %}"
103-
hx-trigger="load"
104-
hx-swap="innerHTML"
105-
>
106-
<div class="w-full h-8 rounded animate-pulse bg-base-300"></div>
107-
</div>
102+
<div
103+
hx-get="{% url 'users:passkey_list' %}"
104+
hx-trigger="load"
105+
hx-swap="innerHTML"
106+
>
107+
<div class="w-full h-8 rounded animate-pulse bg-base-300"></div>
108+
</div>
109+
{% endif %}
108110

109111
{# Remove the comment block tags below when such need arises. e.g. adding new providers #}
110112
{% comment %}
@@ -123,5 +125,7 @@ <h3 class="mb-2 text-base">{% trans "Manage OAuth" %}</h3>
123125
{% endblock %}
124126

125127
{% block extra_js %}
126-
<script src="{% static 'js/passkeys.js' %}"></script>
128+
{% if PASSKEYS_ENABLED %}
129+
<script src="{% static 'js/passkeys.js' %}"></script>
130+
{% endif %}
127131
{% endblock %}

hypha/apply/users/templates/users/login.html

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,9 @@ <h1 class="mb-4 text-h1">
9191

9292
<section class="flex-wrap card-actions">
9393
{% include "users/includes/passwordless_login_button.html" %}
94-
{% include "users/includes/passkey_login_button.html" %}
94+
{% if PASSKEYS_ENABLED %}
95+
{% include "users/includes/passkey_login_button.html" %}
96+
{% endif %}
9597
{% if GOOGLE_OAUTH2 %}
9698
{% include "users/includes/org_login_button.html" %}
9799
{% endif %}
@@ -143,12 +145,14 @@ <h1 class="mb-4 text-h1">
143145

144146
{% block extra_js %}
145147
{{ block.super }}
146-
<script src="{% static 'js/passkeys.js' %}"></script>
147-
<script>
148-
document.addEventListener("DOMContentLoaded", function () {
149-
hypha.passkeys.initUI();
150-
});
151-
</script>
148+
{% if PASSKEYS_ENABLED %}
149+
<script src="{% static 'js/passkeys.js' %}"></script>
150+
<script>
151+
document.addEventListener("DOMContentLoaded", function () {
152+
hypha.passkeys.initUI();
153+
});
154+
</script>
155+
{% endif %}
152156
{# Fix copy of dynamic fields label #}
153157
<script>
154158
var labelOtpToken = document.querySelector("label[for=id_token-otp_token]");

hypha/apply/users/templates/users/passwordless_login_signup.html

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ <h1 class="mb-4 text-h1">
5151

5252
<section class="flex-wrap card-actions">
5353
{% include "users/includes/password_login_button.html" %}
54-
{% include "users/includes/passkey_login_button.html" %}
54+
{% if PASSKEYS_ENABLED %}
55+
{% include "users/includes/passkey_login_button.html" %}
56+
{% endif %}
5557
{% if GOOGLE_OAUTH2 %}
5658
{% include "users/includes/org_login_button.html" %}
5759
{% endif %}
@@ -62,10 +64,12 @@ <h1 class="mb-4 text-h1">
6264
{% endblock %}
6365

6466
{% block extra_js %}
65-
<script src="{% static 'js/passkeys.js' %}"></script>
66-
<script>
67-
document.addEventListener("DOMContentLoaded", function () {
68-
hypha.passkeys.initUI();
69-
});
70-
</script>
67+
{% if PASSKEYS_ENABLED %}
68+
<script src="{% static 'js/passkeys.js' %}"></script>
69+
<script>
70+
document.addEventListener("DOMContentLoaded", function () {
71+
hypha.passkeys.initUI();
72+
});
73+
</script>
74+
{% endif %}
7175
{% endblock %}

hypha/apply/users/tests/test_passkey_views.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,3 +661,83 @@ def test_without_passkey_flag_unverified_user_is_blocked(self):
661661

662662
response = self.client.get(settings.LOGIN_REDIRECT_URL, follow=True)
663663
self.assertContains(response, "Permission Denied")
664+
665+
666+
# ---------------------------------------------------------------------------
667+
# Production gate — passkeys disabled unless WEBAUTHN_RP_ID is set
668+
# ---------------------------------------------------------------------------
669+
670+
671+
@override_settings(DEBUG=False, WEBAUTHN_RP_ID=None, RATELIMIT_ENABLE=False)
672+
class TestPasskeysDisabledInProduction(TestCase):
673+
"""Without WEBAUTHN_RP_ID and DEBUG=False, all passkey views must 404."""
674+
675+
def setUp(self):
676+
self.user = UserFactory()
677+
678+
def test_auth_begin_returns_404(self):
679+
response = self.client.post(AUTH_BEGIN_URL)
680+
self.assertEqual(response.status_code, 404)
681+
682+
def test_auth_complete_returns_404(self):
683+
response = self.client.post(
684+
AUTH_COMPLETE_URL,
685+
data=json.dumps({}),
686+
content_type="application/json",
687+
)
688+
self.assertEqual(response.status_code, 404)
689+
690+
def test_register_begin_returns_404(self):
691+
self.client.force_login(self.user)
692+
response = self.client.post(REGISTER_BEGIN_URL)
693+
self.assertEqual(response.status_code, 404)
694+
695+
def test_register_complete_returns_404(self):
696+
self.client.force_login(self.user)
697+
response = self.client.post(
698+
REGISTER_COMPLETE_URL,
699+
data=json.dumps({}),
700+
content_type="application/json",
701+
)
702+
self.assertEqual(response.status_code, 404)
703+
704+
def test_passkey_list_returns_404(self):
705+
self.client.force_login(self.user)
706+
response = self.client.get(PASSKEY_LIST_URL)
707+
self.assertEqual(response.status_code, 404)
708+
709+
def test_passkey_delete_returns_404(self):
710+
passkey = make_passkey(self.user)
711+
self.client.force_login(self.user)
712+
response = self.client.post(reverse("users:passkey_delete", args=[passkey.pk]))
713+
self.assertEqual(response.status_code, 404)
714+
self.assertTrue(Passkey.objects.filter(pk=passkey.pk).exists())
715+
716+
def test_passkey_rename_returns_404(self):
717+
passkey = make_passkey(self.user, name="Original")
718+
self.client.force_login(self.user)
719+
response = self.client.post(
720+
reverse("users:passkey_rename", args=[passkey.pk]),
721+
{"name": "New Name"},
722+
)
723+
self.assertEqual(response.status_code, 404)
724+
passkey.refresh_from_db()
725+
self.assertEqual(passkey.name, "Original")
726+
727+
728+
@override_settings(DEBUG=True, WEBAUTHN_RP_ID=None, RATELIMIT_ENABLE=False)
729+
class TestPasskeysEnabledInDev(TestCase):
730+
"""DEBUG=True falls back to the request host even without WEBAUTHN_RP_ID."""
731+
732+
def test_auth_begin_works_in_debug_without_rp_id(self):
733+
response = self.client.post(AUTH_BEGIN_URL)
734+
self.assertEqual(response.status_code, 200)
735+
736+
737+
@override_settings(DEBUG=False, WEBAUTHN_RP_ID="example.com", RATELIMIT_ENABLE=False)
738+
class TestPasskeysEnabledWhenRpIdSet(TestCase):
739+
"""DEBUG=False with WEBAUTHN_RP_ID set enables passkeys in production."""
740+
741+
def test_auth_begin_works_in_production_with_rp_id(self):
742+
response = self.client.post(AUTH_BEGIN_URL)
743+
self.assertEqual(response.status_code, 200)

hypha/core/context_processors.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from django.conf import settings
22

3+
from hypha.apply.users.passkey_views import passkeys_enabled
34
from hypha.home.models import ApplyHomePage
45

56

@@ -15,6 +16,7 @@ def global_vars(request):
1516
"HIDE_IDENTITY_FROM_REVIEWERS": settings.HIDE_IDENTITY_FROM_REVIEWERS,
1617
"GOOGLE_OAUTH2": settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY,
1718
"ENABLE_PUBLIC_SIGNUP": settings.ENABLE_PUBLIC_SIGNUP,
19+
"PASSKEYS_ENABLED": passkeys_enabled(),
1820
"SENTRY_TRACES_SAMPLE_RATE": settings.SENTRY_TRACES_SAMPLE_RATE,
1921
"SENTRY_ENVIRONMENT": settings.SENTRY_ENVIRONMENT,
2022
"SENTRY_DENY_URLS": settings.SENTRY_DENY_URLS,

hypha/settings/base.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,18 @@
3535
ENFORCE_TWO_FACTOR = env.bool("ENFORCE_TWO_FACTOR", False)
3636

3737
# WebAuthn / Passkey settings.
38+
# Passkeys are disabled in production unless WEBAUTHN_RP_ID is set. In local
39+
# development (DEBUG=True) they fall back to the request host so the feature
40+
# can be tried without extra configuration.
41+
#
3842
# WEBAUTHN_RP_ID: the relying party domain, e.g. "example.com" (no port, no scheme).
39-
# Defaults to the request host if not set. OBS! Do not use default in production!
4043
# WEBAUTHN_ORIGIN: the full origin, e.g. "https://example.com".
4144
# Defaults to the request origin if not set.
4245
# WEBAUTHN_RP_NAME: display name shown in the browser passkey UI.
4346
# Defaults to ORG_LONG_NAME.
4447
WEBAUTHN_RP_ID = env.str("WEBAUTHN_RP_ID", None)
45-
WEBAUTHN_RP_NAME = env.str("WEBAUTHN_RP_NAME", None)
4648
WEBAUTHN_ORIGIN = env.str("WEBAUTHN_ORIGIN", None)
49+
WEBAUTHN_RP_NAME = env.str("WEBAUTHN_RP_NAME", None)
4750

4851
# Set the allowed file extension for all uploads fields.
4952
FILE_ALLOWED_EXTENSIONS = [

hypha/settings/test.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@
3535

3636
ENFORCE_TWO_FACTOR = False
3737

38+
# Enable passkeys in tests so feature views are exercisable without DEBUG.
39+
WEBAUTHN_RP_ID = "testserver"
40+
3841
SECURE_SSL_REDIRECT = False
3942

4043
# No async celery workers

0 commit comments

Comments
 (0)