From 991094df10175600b3a7058f174c2eed7cea59c8 Mon Sep 17 00:00:00 2001 From: Rechner Fox <659028+rechner@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:48:11 -0700 Subject: [PATCH 1/9] feat(auth): Add MFA with Django-Allauth - Add MFA policy enforcement for admin users with config toggle - Integrate django-allauth for headless authentication with JWT tokens - TOTP, WebAuthn, and recovery code MFA methods - Create MFA setup allowed paths for first enrollment - Update frontend authentication flow with MFA-aware login component - Enable passkey-based login with MFA as optional verification layer --- memberportal/api_general/tests/__init__.py | 0 memberportal/api_general/tests/test_mfa.py | 237 +++++++++ memberportal/api_general/urls.py | 12 +- memberportal/api_general/views.py | 37 ++ memberportal/membermatters/adapters.py | 19 + memberportal/membermatters/authentication.py | 26 + .../membermatters/constance_config.py | 5 + memberportal/membermatters/mfa_policy.py | 129 +++++ memberportal/membermatters/settings.py | 31 +- memberportal/membermatters/token_views.py | 51 ++ memberportal/membermatters/urls.py | 1 + memberportal/pyproject.toml | 1 + memberportal/uv.lock | 62 +++ src-frontend/quasar.config.js | 4 + src-frontend/src/App.vue | 26 +- src-frontend/src/components/LoginCard.vue | 487 +++++++++++++++--- src-frontend/src/i18n/en-AU/index.ts | 10 + src-frontend/src/i18n/sv-SE/index.ts | 11 + src-frontend/src/pages/Logout.vue | 29 +- 19 files changed, 1096 insertions(+), 82 deletions(-) create mode 100644 memberportal/api_general/tests/__init__.py create mode 100644 memberportal/api_general/tests/test_mfa.py create mode 100644 memberportal/membermatters/adapters.py create mode 100644 memberportal/membermatters/authentication.py create mode 100644 memberportal/membermatters/mfa_policy.py create mode 100644 memberportal/membermatters/token_views.py diff --git a/memberportal/api_general/tests/__init__.py b/memberportal/api_general/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/memberportal/api_general/tests/test_mfa.py b/memberportal/api_general/tests/test_mfa.py new file mode 100644 index 00000000..bb1ab11a --- /dev/null +++ b/memberportal/api_general/tests/test_mfa.py @@ -0,0 +1,237 @@ +from django.conf import settings +from django.contrib.auth import user_logged_out +from django.http import HttpResponse +from django.test import RequestFactory, TestCase +from django.urls import resolve +from django.contrib.sessions.middleware import SessionMiddleware +from rest_framework import status +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import RefreshToken + +from allauth.mfa.models import Authenticator +from allauth.mfa.signals import authenticator_used +from constance.test.unittest import override_config + +from membermatters.mfa_policy import ( + ALLAUTH_PREFIX, + MFA_SESSION_KEY, + MFA_SETUP_ALLOWED_PATHS, + AdminMFAMiddleware, + admin_mfa_required, + request_has_verified_mfa, +) +from profile.models import User + + +class MFAUserTestMixin: + def make_user(self, email, staff=False): + user = User.objects.create_user(email, password="test-password") + if staff: + user.staff = True + user.save(update_fields=["staff"]) + return user + + +class AllAuthConfigurationTests(TestCase): + def test_headless_mfa_and_passkey_routes_are_available(self): + self.assertTrue(settings.HEADLESS_ONLY) + self.assertEqual(settings.HEADLESS_CLIENTS, ("browser", "app")) + self.assertEqual( + settings.HEADLESS_TOKEN_STRATEGY, + "allauth.headless.tokens.strategies.jwt.JWTTokenStrategy", + ) + self.assertCountEqual( + settings.MFA_SUPPORTED_TYPES, + ["totp", "webauthn", "recovery_codes"], + ) + self.assertTrue(settings.MFA_PASSKEY_LOGIN_ENABLED) + self.assertFalse(settings.MFA_TRUST_ENABLED) + + expected_routes = ( + ("/_allauth/browser/v1/auth/login", "login"), + ("/_allauth/app/v1/auth/login", "login"), + ("/_allauth/browser/v1/config", "config"), + ( + "/_allauth/browser/v1/account/authenticators/totp", + "manage_totp", + ), + ( + "/_allauth/app/v1/auth/webauthn/login", + "login_webauthn", + ), + ) + for path, url_name in expected_routes: + with self.subTest(path=path): + self.assertEqual(resolve(path).url_name, url_name) + + +class MFAAuthenticationPolicyTests(MFAUserTestMixin, TestCase): + def request_with_session(self, path, method="get"): + request = getattr(RequestFactory(), method)(path) + SessionMiddleware(lambda _: HttpResponse()).process_request(request) + request.session.save() + return request + + def test_admin_mfa_required_only_matches_staff_when_enabled(self): + staff = self.make_user("mfa-staff@example.test", staff=True) + member = self.make_user("mfa-member@example.test") + + self.assertFalse(admin_mfa_required(staff)) + with override_config(ENFORCE_MFA_FOR_ADMIN_USERS=True): + self.assertTrue(admin_mfa_required(staff)) + self.assertFalse(admin_mfa_required(member)) + self.assertFalse(admin_mfa_required(User())) + + def test_middleware_blocks_unverified_staff_requests(self): + staff = self.make_user("middleware-staff@example.test", staff=True) + request = self.request_with_session("/api/profile/") + request.user = staff + + with override_config(ENFORCE_MFA_FOR_ADMIN_USERS=True): + response = AdminMFAMiddleware(lambda _: HttpResponse()).process_view( + request, None, (), {} + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual( + response.content, + b'{"code": "mfa_required", "detail": "Complete MFA before accessing this account."}', + ) + + def test_middleware_allows_setup_paths_and_completed_sessions(self): + staff = self.make_user("middleware-allowed@example.test", staff=True) + middleware = AdminMFAMiddleware(lambda _: HttpResponse()) + + with override_config(ENFORCE_MFA_FOR_ADMIN_USERS=True): + for path in MFA_SETUP_ALLOWED_PATHS | { + f"{ALLAUTH_PREFIX}browser/v1/auth/login" + }: + with self.subTest(path=path): + request = self.request_with_session(path) + request.user = staff + self.assertIsNone(middleware.process_view(request, None, (), {})) + + request = self.request_with_session("/api/profile/") + request.user = staff + request.session[MFA_SESSION_KEY] = str(staff.pk) + self.assertIsNone(middleware.process_view(request, None, (), {})) + + def test_middleware_does_not_block_non_staff_or_options(self): + member = self.make_user("middleware-member@example.test") + middleware = AdminMFAMiddleware(lambda _: HttpResponse()) + + with override_config(ENFORCE_MFA_FOR_ADMIN_USERS=True): + request = self.request_with_session("/api/profile/") + request.user = member + self.assertIsNone(middleware.process_view(request, None, (), {})) + + request = self.request_with_session("/api/profile/", method="options") + request.user = member + self.assertIsNone(middleware.process_view(request, None, (), {})) + + def test_mfa_signal_marks_and_logout_clears_session(self): + user = self.make_user("mfa-signals@example.test") + request = self.request_with_session("/api/profile/") + + authenticator_used.send( + sender=Authenticator, + request=request, + user=user, + authenticator=None, + ) + self.assertEqual(request.session[MFA_SESSION_KEY], str(user.pk)) + self.assertTrue(request.session.modified) + self.assertTrue(request_has_verified_mfa(request, user)) + + user_logged_out.send(sender=None, request=request, user=user) + self.assertNotIn(MFA_SESSION_KEY, request.session) + self.assertFalse(request_has_verified_mfa(request, user)) + + def test_token_session_marker_is_accepted(self): + user = self.make_user("mfa-token-session@example.test") + token_session = self.request_with_session("/api/profile/").session + token_session[MFA_SESSION_KEY] = str(user.pk) + token_session.save() + + request = self.request_with_session("/api/profile/") + request.auth = {"sid": token_session.session_key} + self.assertTrue(request_has_verified_mfa(request, user)) + + def test_successful_enrollment_marks_the_current_session(self): + user = self.make_user("mfa-enrollment@example.test") + middleware = AdminMFAMiddleware(lambda _: HttpResponse()) + + for path in ( + "/_allauth/browser/v1/account/authenticators/totp", + "/_allauth/app/v1/account/authenticators/webauthn", + ): + with self.subTest(path=path): + request = self.request_with_session(path, method="post") + request.user = user + middleware.process_response(request, HttpResponse(status=200)) + self.assertEqual(request.session[MFA_SESSION_KEY], str(user.pk)) + + request = self.request_with_session( + "/_allauth/browser/v1/account/authenticators/totp", method="post" + ) + request.user = user + middleware.process_response(request, HttpResponse(status=400)) + self.assertNotIn(MFA_SESSION_KEY, request.session) + + +class LegacyTokenMFAEnforcementTests(MFAUserTestMixin, TestCase): + def test_staff_obtain_and_refresh_are_blocked_when_mfa_is_enforced(self): + user = self.make_user("token-staff@example.test", staff=True) + client = APIClient() + + with override_config(ENFORCE_MFA_FOR_ADMIN_USERS=True): + obtain = client.post( + "/api/token/obtain/", + {"email": user.email, "password": "test-password"}, + format="json", + ) + refresh = client.post( + "/api/token/refresh/", + {"refresh": str(RefreshToken.for_user(user))}, + format="json", + ) + + for response in (obtain, refresh): + with self.subTest(response=response): + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.json()["code"], "mfa_required") + + def test_non_staff_tokens_continue_to_work_when_enforcement_is_enabled(self): + user = self.make_user("token-member@example.test") + client = APIClient() + + with override_config(ENFORCE_MFA_FOR_ADMIN_USERS=True): + obtain = client.post( + "/api/token/obtain/", + {"email": user.email, "password": "test-password"}, + format="json", + ) + refresh = client.post( + "/api/token/refresh/", + {"refresh": obtain.json()["refresh"]}, + format="json", + ) + + self.assertEqual(obtain.status_code, status.HTTP_200_OK) + self.assertIn("access", obtain.json()) + self.assertEqual(refresh.status_code, status.HTTP_200_OK) + self.assertIn("access", refresh.json()) + + def test_staff_tokens_work_when_enforcement_is_disabled(self): + user = self.make_user("token-disabled@example.test", staff=True) + client = APIClient() + + with override_config(ENFORCE_MFA_FOR_ADMIN_USERS=False): + response = client.post( + "/api/token/obtain/", + {"email": user.email, "password": "test-password"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("access", response.json()) diff --git a/memberportal/api_general/urls.py b/memberportal/api_general/urls.py index 32f69459..bc4cff91 100644 --- a/memberportal/api_general/urls.py +++ b/memberportal/api_general/urls.py @@ -1,16 +1,22 @@ from django.urls import path -from rest_framework_simplejwt import views as jwt_views +from membermatters.token_views import ( + StaffMFAEnforcedTokenObtainPairView, + StaffMFAEnforcedTokenRefreshView, +) +from django.urls import path from . import views urlpatterns = [ path("api/config/", views.GetConfig.as_view(), name="get_config"), path( "api/token/obtain/", - jwt_views.TokenObtainPairView.as_view(), + StaffMFAEnforcedTokenObtainPairView.as_view(), name="token_create", ), path( - "api/token/refresh/", jwt_views.TokenRefreshView.as_view(), name="token_refresh" + "api/token/refresh/", + StaffMFAEnforcedTokenRefreshView.as_view(), + name="token_refresh", ), path("api/login/", views.Login.as_view(), name="login"), path("api/loggedin/", views.LoggedIn.as_view(), name="loggedin"), diff --git a/memberportal/api_general/views.py b/memberportal/api_general/views.py index cf1b6197..f415036d 100644 --- a/memberportal/api_general/views.py +++ b/memberportal/api_general/views.py @@ -238,6 +238,19 @@ def post(self, request): # correct login details if user is not None: + # Enforced staff users must use AllAuth so its MFA stage cannot be + # bypassed by the legacy custom login endpoint. + from membermatters.mfa_policy import admin_mfa_required + + if admin_mfa_required(user): + return Response( + { + "code": "mfa_required", + "detail": "Use the AllAuth login endpoint to complete MFA.", + }, + status=status.HTTP_403_FORBIDDEN, + ) + # if their email is verified if user.email_verified: login(request, user) @@ -334,6 +347,19 @@ def post(self, request): {"message": "error.emailNotVerified"}, status=status.HTTP_403_FORBIDDEN ) + # RFID login is a single factor and should not satisfy enforced staff MFA. + # We might consider adding a PIN for this usecase? + from membermatters.mfa_policy import admin_mfa_required + + if admin_mfa_required(user): + return Response( + { + "code": "mfa_required", + "detail": "Staff users must use AllAuth MFA login.", + }, + status=status.HTTP_403_FORBIDDEN, + ) + # rfid matches a user so log them in if user is not None: login(request, user) @@ -1205,6 +1231,17 @@ def post(self, request, verify_token): user.save(update_fields=["email_verified"]) if is_fresh: + from membermatters.mfa_policy import admin_mfa_required + + if admin_mfa_required(user): + return Response( + { + "code": "mfa_required", + "detail": "Use the AllAuth login endpoint to complete MFA.", + }, + status=status.HTTP_403_FORBIDDEN, + ) + # Session login runs after the DB commit so a session-store # write cannot extend the transaction's row-lock window. login(request, user) diff --git a/memberportal/membermatters/adapters.py b/memberportal/membermatters/adapters.py new file mode 100644 index 00000000..31196b2d --- /dev/null +++ b/memberportal/membermatters/adapters.py @@ -0,0 +1,19 @@ +from allauth.account.adapter import DefaultAccountAdapter + + +class MemberMattersAccountAdapter(DefaultAccountAdapter): + """Adapt AllAuth account flows to MemberMatters' existing signup flow.""" + + def is_open_for_signup(self, request): + # Registration remains owned by api_general.Register, which also + # creates the required MemberMatters Profile and sends its emails. + return False + + def get_user_display(self, user): + return user.email + + def authenticate(self, request, **credentials): + user = super().authenticate(request, **credentials) + if user is not None and not getattr(user, "email_verified", True): + return None + return user diff --git a/memberportal/membermatters/authentication.py b/memberportal/membermatters/authentication.py new file mode 100644 index 00000000..21d13751 --- /dev/null +++ b/memberportal/membermatters/authentication.py @@ -0,0 +1,26 @@ +from django.http import HttpRequest +from rest_framework.authentication import BaseAuthentication +from rest_framework.exceptions import AuthenticationFailed +from rest_framework_simplejwt.authentication import JWTAuthentication + +from allauth.headless.contrib.rest_framework.authentication import ( + JWTTokenAuthentication, +) + + +class HybridJWTAuthentication(BaseAuthentication): + """Accept AllAuth JWTs and legacy Simple JWTs during the migration.""" + + def authenticate(self, request: HttpRequest): + if not request.headers.get("Authorization"): + return None + + try: + result = JWTTokenAuthentication().authenticate(request) + except AuthenticationFailed: + result = None + + if result is not None: + return result + + return JWTAuthentication().authenticate(request) diff --git a/memberportal/membermatters/constance_config.py b/memberportal/membermatters/constance_config.py index 23ec482a..b2dc37b1 100644 --- a/memberportal/membermatters/constance_config.py +++ b/memberportal/membermatters/constance_config.py @@ -20,6 +20,10 @@ "", "A site wide banner that can display useful information. Leave empty to turn off.", ), + "ENFORCE_MFA_FOR_ADMIN_USERS": ( + False, + "Require completed MFA for all staff users before allowing portal or Django admin access.", + ), # Email config "EMAIL_SYSADMIN": ( "example@example.com", @@ -603,6 +607,7 @@ "ENABLE_RECENT_SWIPES_PAGE", ), ), + ("Security", ("ENFORCE_MFA_FOR_ADMIN_USERS",)), ("Stats Settings", ("ENABLE_STATS_PAGE", "STATS_MAX_DAYS", "METRICS_API_KEY")), ( "Sentry Error Reporting", diff --git a/memberportal/membermatters/mfa_policy.py b/memberportal/membermatters/mfa_policy.py new file mode 100644 index 00000000..c0664500 --- /dev/null +++ b/memberportal/membermatters/mfa_policy.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from django.conf import settings +from django.contrib.auth.signals import user_logged_out +from django.http import HttpRequest, JsonResponse +from django.utils.deprecation import MiddlewareMixin +from django.dispatch import receiver +from django.utils.module_loading import import_string + +from allauth.mfa.signals import authenticator_used +from constance import config + +MFA_SESSION_KEY = "membermatters.mfa_verified_user" +ALLAUTH_PREFIX = "/_allauth/" +MFA_SETUP_ALLOWED_PATHS = { + "/api/logout/", + "/api/config/", + "/api/login/", + "/api/token/obtain/", + "/api/token/refresh/", +} + + +def admin_mfa_required(user) -> bool: + return bool( + getattr(user, "is_authenticated", False) + and getattr(user, "is_staff", False) + and config.ENFORCE_MFA_FOR_ADMIN_USERS + ) + + +def _session_has_mfa_marker(session, user) -> bool: + return str(session.get(MFA_SESSION_KEY, "")) == str(user.pk) + + +def _session_for_token(request: HttpRequest): + auth = getattr(request, "auth", None) + candidates = [auth] + if isinstance(auth, tuple): + candidates.extend(auth) + for candidate in candidates: + if not isinstance(candidate, dict): + continue + session_id = candidate.get("sid") + if not session_id: + continue + session_store = import_string(settings.SESSION_ENGINE + ".SessionStore") + return session_store(session_key=session_id) + return None + + +def request_has_verified_mfa(request: HttpRequest, user) -> bool: + if _session_has_mfa_marker(request.session, user): + return True + + token_session = _session_for_token(request) + return bool(token_session and _session_has_mfa_marker(token_session, user)) + + +@receiver(authenticator_used) +def mark_mfa_session(sender, request, user, **kwargs): + if request is not None and user is not None: + request.session[MFA_SESSION_KEY] = str(user.pk) + request.session.modified = True + + +@receiver(user_logged_out) +def clear_mfa_session(sender, request, user, **kwargs): + if request is not None: + request.session.pop(MFA_SESSION_KEY, None) + + +class AdminMFAMiddleware(MiddlewareMixin): + """Require completed AllAuth MFA for staff when the flag is enabled.""" + + def process_view(self, request, view_func, view_args, view_kwargs): + if request.method == "OPTIONS": + return None + + if request.path.startswith(ALLAUTH_PREFIX): + # AllAuth needs these endpoints to remain available for first-time + # enrollment when a staff member has no authenticator yet. + return None + + user = request.user + if not user.is_authenticated: + try: + from membermatters.authentication import HybridJWTAuthentication + + authentication = HybridJWTAuthentication().authenticate(request) + if authentication: + user, request.auth = authentication + request.user = user + except Exception: + user = request.user + + if not admin_mfa_required(user): + return None + + if request.path in MFA_SETUP_ALLOWED_PATHS: + return None + + if request_has_verified_mfa(request, user): + return None + + return JsonResponse( + { + "code": "mfa_required", + "detail": "Complete MFA before accessing this account.", + }, + status=403, + ) + + def process_response(self, request, response): + if ( + request.path.startswith(ALLAUTH_PREFIX) + and request.method == "POST" + and ( + request.path.endswith("/account/authenticators/totp") + or request.path.endswith("/account/authenticators/webauthn") + ) + and response.status_code < 300 + and getattr(request.user, "is_authenticated", False) + ): + # Enrollment verifies the TOTP code or WebAuthn assertion. Treat + # that successful ceremony as MFA completion for this session. + request.session[MFA_SESSION_KEY] = str(request.user.pk) + request.session.modified = True + return response diff --git a/memberportal/membermatters/settings.py b/memberportal/membermatters/settings.py index ea4c6742..c079a647 100644 --- a/memberportal/membermatters/settings.py +++ b/memberportal/membermatters/settings.py @@ -93,6 +93,10 @@ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", + "allauth", + "allauth.account", + "allauth.mfa", + "allauth.headless", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", @@ -126,6 +130,8 @@ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "allauth.account.middleware.AccountMiddleware", + "membermatters.mfa_policy.AdminMFAMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "membermatters.middleware.Sentry", @@ -383,7 +389,7 @@ "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework.authentication.SessionAuthentication", - "rest_framework_simplejwt.authentication.JWTAuthentication", + "membermatters.authentication.HybridJWTAuthentication", ), "DEFAULT_THROTTLE_CLASSES": ("rest_framework.throttling.ScopedRateThrottle",), "NUM_PROXIES": int(_num_proxies) if _num_proxies else None, @@ -437,6 +443,29 @@ AUTH_USER_MODEL = "profile.User" +# Django-AllAuth is used in headless mode by the Vue SPA. Existing custom +# registration and password-reset APIs remain the source of truth. +AUTHENTICATION_BACKENDS = ("allauth.account.auth_backends.AuthenticationBackend",) +ACCOUNT_ADAPTER = "membermatters.adapters.MemberMattersAccountAdapter" +ACCOUNT_USER_MODEL_USERNAME_FIELD = None +ACCOUNT_USER_MODEL_EMAIL_FIELD = "email" +ACCOUNT_LOGIN_METHODS = {"email"} +ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"] +ACCOUNT_EMAIL_VERIFICATION = "none" +ACCOUNT_LOGIN_TIMEOUT = 900 + +HEADLESS_ONLY = True +HEADLESS_CLIENTS = ("browser", "app") +HEADLESS_TOKEN_STRATEGY = "allauth.headless.tokens.strategies.jwt.JWTTokenStrategy" +HEADLESS_JWT_AUTHORIZATION_HEADER_SCHEME = "Bearer" + +MFA_SUPPORTED_TYPES = ["totp", "webauthn", "recovery_codes"] +MFA_PASSKEY_LOGIN_ENABLED = True +MFA_RECOVERY_CODES_SHOW_ONCE = True +MFA_TOTP_ISSUER = "MemberMatters" +MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = DEBUG +MFA_TRUST_ENABLED = False + REQUEST_TIMEOUT = 0.05 # Celery configuration diff --git a/memberportal/membermatters/token_views.py b/memberportal/membermatters/token_views.py new file mode 100644 index 00000000..6a4a8ec7 --- /dev/null +++ b/memberportal/membermatters/token_views.py @@ -0,0 +1,51 @@ +from django.conf import settings +from django.contrib.auth import get_user_model +from rest_framework import status +from rest_framework.response import Response +from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView + + +class StaffMFAEnforcedTokenObtainPairView(TokenObtainPairView): + """Prevent the legacy JWT endpoint from bypassing enforced staff MFA.""" + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + from membermatters.mfa_policy import admin_mfa_required + + if admin_mfa_required(serializer.user): + return Response( + { + "code": "mfa_required", + "detail": "Use the AllAuth login endpoint to complete MFA.", + }, + status=status.HTTP_403_FORBIDDEN, + ) + + return Response(serializer.validated_data, status=status.HTTP_200_OK) + + +class StaffMFAEnforcedTokenRefreshView(TokenRefreshView): + """Prevent legacy refresh tokens from bypassing enforced staff MFA.""" + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + from membermatters.mfa_policy import admin_mfa_required + + refresh = RefreshToken(request.data["refresh"]) + user_id = refresh.get(settings.SIMPLE_JWT["USER_ID_CLAIM"]) + user = get_user_model().objects.filter(pk=user_id).first() + if admin_mfa_required(user): + return Response( + { + "code": "mfa_required", + "detail": "Use the AllAuth login endpoint to complete MFA.", + }, + status=status.HTTP_403_FORBIDDEN, + ) + + return Response(serializer.validated_data, status=status.HTTP_200_OK) diff --git a/memberportal/membermatters/urls.py b/memberportal/membermatters/urls.py index 2d3b8fb0..11ec21ad 100644 --- a/memberportal/membermatters/urls.py +++ b/memberportal/membermatters/urls.py @@ -14,6 +14,7 @@ def safe_constance_get(fld: str): urlpatterns = [ + path("_allauth/", include("allauth.headless.urls")), path("api/openid/", include("oidc_provider.urls", namespace="oidc_provider")), path("", include("api_metrics.urls")), path("", include("api_spacedirectory.urls")), diff --git a/memberportal/pyproject.toml b/memberportal/pyproject.toml index b95421d6..6d383456 100644 --- a/memberportal/pyproject.toml +++ b/memberportal/pyproject.toml @@ -55,6 +55,7 @@ dependencies = [ "nh3~=0.3.5", "pillow>=12,<13", "docuseal~=1.0.9", + "django-allauth[headless,mfa]>=65,<66", ] [dependency-groups] diff --git a/memberportal/uv.lock b/memberportal/uv.lock index 30e3bf80..16994d1d 100644 --- a/memberportal/uv.lock +++ b/memberportal/uv.lock @@ -453,6 +453,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "constantly" version = "23.10.4" @@ -607,6 +616,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2e/b4/23e74d261eebfa9c8baed5e8810f09858b9afd613d1037f42ff7aa95c87f/django-6.0.8-py3-none-any.whl", hash = "sha256:9b98b7e1902e0e575ea4f42c175fc9512784f7f2580898286aee3388b322219d", size = 8376956, upload-time = "2026-08-04T15:03:35.138Z" }, ] +[[package]] +name = "django-allauth" +version = "65.19.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/2c/4c7a046f363396093d825df208d1b621ec9591ac30ea4657c26cda5f549c/django_allauth-65.19.4.tar.gz", hash = "sha256:7ea5f0057f91fa25f9ca7814f84aed63a66096b43df073fbdf1ef0167bc92f1d", size = 2293509, upload-time = "2026-09-17T13:29:20.775Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/04/7e47e7f33c25f1edbc5d5278fc4cdf1875399eecdcb36b5588c53550f770/django_allauth-65.19.4-py3-none-any.whl", hash = "sha256:a4f13476ad460ab1098af6be41ae72ed1d07498dd4514e05b9daef09a6c1cabd", size = 2075786, upload-time = "2026-09-17T13:27:51.745Z" }, +] + +[package.optional-dependencies] +headless = [ + { name = "pyjwt", extra = ["crypto"] }, +] +mfa = [ + { name = "fido2" }, + { name = "qrcode" }, +] + [[package]] name = "django-celery-beat" version = "2.9.0" @@ -783,6 +814,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cd/2a/33d635fb5e70f5b837046abefd173087cdbf60c2f831813a48544ce7ee68/docuseal-1.0.9-py3-none-any.whl", hash = "sha256:99ab08e31e29b597873fbb675082877e71bc2c0dcf0610a4b559dc276297cf19", size = 7421, upload-time = "2026-07-17T14:16:29.604Z" }, ] +[[package]] +name = "fido2" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/ea/6f08c354b7aeb8019249d46a86c2153f8218499cced4d21bf16b6d49fc16/fido2-2.2.1.tar.gz", hash = "sha256:85787428a94c3f8eaf72f0ff30afba983b559a1b1b795c93318c81b4ad4062c4", size = 327147, upload-time = "2026-06-29T17:41:11.927Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/84/198d99c3312557ef6121cf78c38281efe9b3bc88cba0e2c05446f38a024d/fido2-2.2.1-py3-none-any.whl", hash = "sha256:ed397da981b9ab133da6ead7309e41f924b566b749956129efe286fae097749f", size = 238354, upload-time = "2026-06-29T17:41:09.921Z" }, +] + [[package]] name = "filelock" version = "3.32.7" @@ -984,6 +1027,7 @@ dependencies = [ { name = "channels-redis" }, { name = "daphne" }, { name = "django" }, + { name = "django-allauth", extra = ["headless", "mfa"] }, { name = "django-celery-beat" }, { name = "django-celery-results" }, { name = "django-constance" }, @@ -1032,6 +1076,7 @@ requires-dist = [ { name = "channels-redis", specifier = ">=4.3,<5" }, { name = "daphne", specifier = ">=4,<5" }, { name = "django", specifier = ">=6,<6.1" }, + { name = "django-allauth", extras = ["headless", "mfa"], specifier = ">=65,<66" }, { name = "django-celery-beat", specifier = ">=2.7,<3" }, { name = "django-celery-results", specifier = ">=2.6,<3" }, { name = "django-constance", specifier = ">=4,<5" }, @@ -1411,6 +1456,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, ] +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pyopenssl" version = "26.4.0" @@ -1491,6 +1541,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "qrcode" +version = "8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" }, +] + [[package]] name = "redis" version = "8.1.0" diff --git a/src-frontend/quasar.config.js b/src-frontend/quasar.config.js index be0ec0fe..40736284 100644 --- a/src-frontend/quasar.config.js +++ b/src-frontend/quasar.config.js @@ -131,6 +131,10 @@ module.exports = configure(function (ctx) { target: 'http://127.0.0.1:8000', changeOrigin: false, }, + '/_allauth': { + target: 'http://127.0.0.1:8000', + changeOrigin: false, + }, '/admin': { target: 'http://127.0.0.1:8000', changeOrigin: true, diff --git a/src-frontend/src/App.vue b/src-frontend/src/App.vue index daa6b13c..6a4bd876 100644 --- a/src-frontend/src/App.vue +++ b/src-frontend/src/App.vue @@ -69,17 +69,31 @@ export default defineComponent({ Platform.is.capacitor && error.response.data?.messages[0]?.token_class === 'AccessToken' ) { - this.$axios - .post('/api/token/refresh/', { - refresh: this.refreshToken, - }) + const refreshAllauth = this.$axios.post( + '/_allauth/app/v1/tokens/refresh', + { refresh_token: this.refreshToken }, + { headers: { Accept: 'application/json' } }, + ); + refreshAllauth + .catch(() => + this.$axios.post('/api/token/refresh/', { + refresh: this.refreshToken, + }), + ) .then((response) => { - this.setAuth(response.data); + const tokenData = response.data.data || response.data; + this.setAuth({ + access: tokenData.access_token || tokenData.access, + refresh: + tokenData.refresh_token || + tokenData.refresh || + this.refreshToken, + }); this.setLoggedIn(true); return Promise.resolve(); }) .catch(() => { - // if we fail to refresh, send them back to the login page + // if both refresh endpoints fail, send them back to login this.$router.push('/login'); return Promise.resolve(); }); diff --git a/src-frontend/src/components/LoginCard.vue b/src-frontend/src/components/LoginCard.vue index e0384fe0..4d79e273 100644 --- a/src-frontend/src/components/LoginCard.vue +++ b/src-frontend/src/components/LoginCard.vue @@ -11,7 +11,7 @@ + + + + diff --git a/src-frontend/src/i18n/en-AU/index.ts b/src-frontend/src/i18n/en-AU/index.ts index d5973b1f..ee6ee347 100644 --- a/src-frontend/src/i18n/en-AU/index.ts +++ b/src-frontend/src/i18n/en-AU/index.ts @@ -213,6 +213,38 @@ export default { unverifiedEmail: 'Your email address is not verified. We just sent you another link so please try again.', }, + mfaSettings: { + title: 'Multi-factor authentication', + description: + 'Add an authenticator app, passkey, or recovery codes to protect your account.', + noMethods: 'No MFA methods are configured.', + totp: 'Authenticator app', + passkey: 'Passkey', + recoveryCodes: 'Recovery codes', + enableTotp: 'Set up authenticator app', + addPasskey: 'Add passkey', + passkeyName: 'Passkey name', + passkeyNameHint: 'For example: MacBook, iPhone, or YubiKey', + disableTotp: 'Disable authenticator app', + removePasskey: 'Remove passkey', + generateRecoveryCodes: 'Generate recovery codes', + setupDescription: + 'Scan the QR code with your authenticator app, then enter the generated code.', + setupKey: 'Setup key', + verificationCode: 'Authentication code', + recoveryCodesDescription: + 'Store these recovery codes somewhere safe. Each code can be used once.', + confirmDisable: + 'Disabling your authenticator app reduces the security of your account. Continue?', + saved: 'MFA settings saved.', + failed: 'Unable to update MFA settings. Please try again.', + reauthenticateTitle: 'Confirm your password', + reauthenticateDescription: + 'Enter your current password to continue changing MFA settings.', + reauthenticatePassword: 'Current password', + reauthenticate: 'Continue', + reauthenticateFailed: 'The password was incorrect. Please try again.', + }, changePasswordCard: { pageTitle: 'Change Password', success: 'Your new password was saved successfully.', diff --git a/src-frontend/src/i18n/sv-SE/index.ts b/src-frontend/src/i18n/sv-SE/index.ts index d895d7dd..ae67717e 100644 --- a/src-frontend/src/i18n/sv-SE/index.ts +++ b/src-frontend/src/i18n/sv-SE/index.ts @@ -207,6 +207,38 @@ export default { unverifiedEmail: 'Din email är inte verifierad. Vi skickade precies en ny länk via email så försök verifiera igen.', }, + mfaSettings: { + title: 'Multifaktorautentisering', + description: + 'Lägg till en autentiseringsapp, lösenordsnyckel eller återställningskoder för att skydda ditt konto.', + noMethods: 'Inga MFA-metoder är konfigurerade.', + totp: 'Autentiseringsapp', + passkey: 'Lösenordsnyckel', + recoveryCodes: 'Återställningskoder', + enableTotp: 'Konfigurera autentiseringsapp', + addPasskey: 'Lägg till lösenordsnyckel', + passkeyName: 'Namn på lösenordsnyckel', + passkeyNameHint: 'Till exempel: MacBook, iPhone eller YubiKey', + disableTotp: 'Inaktivera autentiseringsapp', + removePasskey: 'Ta bort lösenordsnyckel', + generateRecoveryCodes: 'Skapa återställningskoder', + setupDescription: + 'Skanna QR-koden med din autentiseringsapp och ange sedan den genererade koden.', + setupKey: 'Installationsnyckel', + verificationCode: 'Autentiseringskod', + recoveryCodesDescription: + 'Förvara återställningskoderna säkert. Varje kod kan användas en gång.', + confirmDisable: + 'Att inaktivera autentiseringsappen minskar kontots säkerhet. Vill du fortsätta?', + saved: 'MFA-inställningarna har sparats.', + failed: 'MFA-inställningarna kunde inte uppdateras. Försök igen.', + reauthenticateTitle: 'Bekräfta ditt lösenord', + reauthenticateDescription: + 'Ange ditt nuvarande lösenord för att fortsätta ändra MFA-inställningar.', + reauthenticatePassword: 'Nuvarande lösenord', + reauthenticate: 'Fortsätt', + reauthenticateFailed: 'Lösenordet var felaktigt. Försök igen.', + }, changePasswordCard: { pageTitle: 'Byt lösenord', success: 'Ditt nya lösenord har ändrats.', diff --git a/src-frontend/src/pages/Profile.vue b/src-frontend/src/pages/Profile.vue index e330f63d..8bd0946d 100644 --- a/src-frontend/src/pages/Profile.vue +++ b/src-frontend/src/pages/Profile.vue @@ -63,6 +63,13 @@ @click="changePassword = true" /> + + + + + + @@ -109,16 +120,23 @@ import ProfileForm from '@components/ProfileForm.vue'; import icons from '../icons'; import DigitalIdCard from '@components/DigitalIdCard.vue'; import ChangePasswordCard from '@components/ChangePasswordCard.vue'; +import MfaSettingsCard from '@components/MfaSettingsCard.vue'; import { mapGetters } from 'vuex'; export default { name: 'ProfilePage', - components: { ChangePasswordCard, DigitalIdCard, ProfileForm }, + components: { + ChangePasswordCard, + DigitalIdCard, + MfaSettingsCard, + ProfileForm, + }, data() { return { text: '', digitalId: false, changePassword: false, + mfaSettings: false, }; }, computed: {