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(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious what motivated this change. It seems important but I am not super familiar with this part of the membermatters code.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Traditional Django applications with server-side templates handle authentication with a built-in framework auth and cookie-based session middleware that checks the validity of a cookie included with every request for authenticated views.

Since MemberMatters uses a separate javascript web client and backend REST API, the frontend needs to authenticate and get a token to authenticate API calls to the backend. jwt_views.TokenObtainPairView is the old token granting endpoint from Django REST framework, so here are replacing that with our own view contingent on doing multi-factor auth.

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 (
Comment thread
rechner marked this conversation as resolved.
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
Comment thread
rechner marked this conversation as resolved.
},
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
Comment thread
rechner marked this conversation as resolved.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gating imports behind an if statement is very smelly. I've done this only a couple times for very specific reasons.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I usually have isort as a pre-commit hook on projects which reformats lazy imports like this but I'll have to do that in another PR since it will touch all the source files.


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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does email_verified default to True here? Surely a user missing email_verified shouldn't pass this check…

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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would highly recommend that the functions in here use type hinted returns

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll start adding hints, tbh I'd like to do a pass to add types and turn on mypy as another roadmap item as an overall quality improvement item.

"""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)
Comment on lines +19 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I would create the JWTTokenAuthentication and JWTAuthentication in __init__() and store them in properties on self, and then use those in authenticate() instead of creating them every time.

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