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
2 changes: 1 addition & 1 deletion docker/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ http {
add_header Cache-Control "public, must-revalidate, proxy-revalidate";
}

location ~ ^/(api|admin) {
location ~ ^/(_allauth|api|admin) {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $http_x_real_ip;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
Expand Down
Empty file.
503 changes: 503 additions & 0 deletions memberportal/api_general/tests/test_mfa.py

Large diffs are not rendered by default.

12 changes: 9 additions & 3 deletions memberportal/api_general/urls.py
Original file line number Diff line number Diff line change
@@ -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"),
Expand Down
52 changes: 52 additions & 0 deletions memberportal/api_general/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,26 @@
# if sso is disabled then exit
return Response(status=status.HTTP_400_BAD_REQUEST)

from membermatters.mfa_policy import (
admin_mfa_required,
request_has_verified_mfa,
)

if request.user.is_authenticated:
if admin_mfa_required(request.user) and not request_has_verified_mfa(
request, request.user
):
# Discard a password-only session so the frontend can restart
# through AllAuth and complete its MFA stage.
logout(request)
return Response(
{
"code": "mfa_required",
"detail": "Use the AllAuth login endpoint to complete MFA.",

Check failure on line 250 in memberportal/api_general/views.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "Use the AllAuth login endpoint to complete MFA." 3 times.

See more on https://sonarcloud.io/project/issues?id=PawprintPrototyping_MemberMatters&issues=AaDF-PqqMHm34slMEOHx&open=AaDF-PqqMHm34slMEOHx&pullRequest=48
},
status=status.HTTP_403_FORBIDDEN,
)

if discourse_login:
payload = {
"nonce": discourse_nonce,
Expand Down Expand Up @@ -263,6 +282,15 @@

# correct login details
if user is not None:
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)
Expand Down Expand Up @@ -359,6 +387,19 @@
{"message": ERROR_EMAIL_NOT_VERIFIED}, 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)
Expand Down Expand Up @@ -1227,6 +1268,17 @@
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)
Expand Down
19 changes: 19 additions & 0 deletions memberportal/membermatters/adapters.py
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions memberportal/membermatters/authentication.py
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 6 additions & 0 deletions memberportal/membermatters/constance_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
"",
"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. "
"IMPORTANT: Make sure MFA is configured for your user before enabling this or you will be locked out!",
),
# Email config
"EMAIL_SYSADMIN": (
"example@example.com",
Expand Down Expand Up @@ -603,6 +608,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",
Expand Down
Loading
Loading