Repository navigation
Add MFA with Django-AllAuth #48
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
991094d
346f948
42b5e50
b20cb08
91fb0f7
74ed043
f044c49
5655eda
a4a7273
acd3ec6
451c06d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -232,7 +232,26 @@ | |
| # if sso is disabled then exit | ||
| return Response(status=status.HTTP_400_BAD_REQUEST) | ||
|
|
||
| from membermatters.mfa_policy import ( | ||
|
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
|
||
|
rechner marked this conversation as resolved.
|
||
| }, | ||
| status=status.HTTP_403_FORBIDDEN, | ||
| ) | ||
|
|
||
| if discourse_login: | ||
| payload = { | ||
| "nonce": discourse_nonce, | ||
|
|
@@ -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) | ||
|
|
@@ -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 | ||
|
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) | ||
|
|
@@ -1227,6 +1268,17 @@ | |
| user.save(update_fields=["email_verified"]) | ||
|
|
||
| if is_fresh: | ||
| from membermatters.mfa_policy import admin_mfa_required | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
|
||
| 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): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why does |
||
| return None | ||
| return user | ||
| 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): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. would highly recommend that the functions in here use type hinted returns
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: I would create the |
||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.TokenObtainPairViewis the old token granting endpoint from Django REST framework, so here are replacing that with our own view contingent on doing multi-factor auth.