-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ Fixes #3 -- make 2FA check conditional on authentication backend
- Loading branch information
1 parent
01928c2
commit e8c4024
Showing
8 changed files
with
270 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
import functools | ||
from typing import TypeAlias | ||
|
||
from django.conf import settings | ||
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser | ||
from django.http import HttpRequest | ||
|
||
from django_otp.middleware import ( | ||
OTPMiddleware as _OTPMiddleware, | ||
is_verified as otp_is_verified, | ||
) | ||
|
||
# probably a TypeVar(bound=...) makes more sense but we don't allow using generics here, | ||
# so let's cover the most ground using a simple union. | ||
AnyUser: TypeAlias = AbstractBaseUser | AnonymousUser | ||
|
||
|
||
def is_verified(user: AbstractBaseUser) -> bool: | ||
""" | ||
Modified version of :func:`django_otp.middleware.is_verified` to add bypass logic. | ||
This function may not be called with an :class:`AnonymousUser` instance! | ||
If the user backend that the user authenticated with is on the allow list, we do | ||
not perform the real OTP device check from the library, but just mark the user as | ||
verified. | ||
""" | ||
# check our allow list for authentication backends | ||
backends_to_skip_verification_for = getattr( | ||
settings, "MAYKIN_2FA_ALLOW_MFA_BYPASS_BACKENDS", [] | ||
) | ||
if ( | ||
backends_to_skip_verification_for | ||
and user.is_authenticated | ||
and user.backend in backends_to_skip_verification_for # type: ignore | ||
): | ||
return True | ||
# fall back to default library behaviour | ||
return otp_is_verified(user) | ||
|
||
|
||
class OTPMiddleware(_OTPMiddleware): | ||
""" | ||
Substitute our own :func:`is_verified` check instead of the upstream one. | ||
This middleware *replaces* :class:`django_otp.middleware.OTPMiddleware` to add | ||
allow-list behaviour for certain authentication backends. This marks the user | ||
authentication as being verified even though the 2FA requirements in the project | ||
itself have been bypassen. | ||
This setup allows us to enforce 2FA when logging in with username + password, but | ||
be less strict when signing it via OIDC/other SSO solutions that already enforce | ||
MFA at another level outside of our scope. | ||
""" | ||
|
||
def _verify_user(self, request: HttpRequest, user: AnyUser): | ||
# call the super but replace the `is_verified` callable | ||
user = super()._verify_user(request, user) | ||
user.is_verified = functools.partial(is_verified, user) # type: ignore | ||
return user |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
from django.contrib.auth.backends import ModelBackend | ||
|
||
|
||
class No2FAModelBackend(ModelBackend): | ||
""" | ||
Custom backend which is allow-listed to bypass 2FA via the settings. | ||
""" | ||
|
||
pass |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,22 @@ | ||
# import pytest | ||
import pytest | ||
|
||
|
||
# @pytest.fixture | ||
# def some_fixture(request): | ||
# return "foo" | ||
@pytest.fixture | ||
def user(db: None, django_user_model): | ||
""" | ||
Inspired on pytest-django's admin_user fixture. | ||
""" | ||
UserModel = django_user_model | ||
username = "johny" | ||
try: | ||
# The default behavior of `get_by_natural_key()` is to look up by `username_field`. | ||
# However the user model is free to override it with any sort of custom behavior. | ||
# The Django authentication backend already assumes the lookup is by username, | ||
# so we can assume so as well. | ||
user = UserModel._default_manager.get_by_natural_key(username) | ||
except UserModel.DoesNotExist: | ||
user_data = {"email": "[email protected]"} | ||
user_data["password"] = "password" | ||
user_data["username"] = username | ||
user = UserModel._default_manager.create_user(**user_data) | ||
return user |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
from django.http import HttpRequest, HttpResponse | ||
from django.utils.functional import SimpleLazyObject | ||
|
||
import pytest | ||
|
||
from maykin_2fa.middleware import OTPMiddleware | ||
|
||
|
||
@pytest.fixture() | ||
def user_request(request, client, rf, user) -> HttpRequest: | ||
marker = request.node.get_closest_marker("user_request_auth_backend") | ||
backend = None if not marker else marker.args[0] | ||
# sets the backend and session | ||
client.force_login(user, backend=backend) | ||
|
||
# create a standalone request instance, by copying over the session and mimicking | ||
# the `django.contrib.auth.middleware.AuthenticationMiddleware` middleware | ||
request = rf.get("/irrelevant") | ||
request.session = client.session | ||
request.user = SimpleLazyObject(lambda: user) | ||
return request | ||
|
||
|
||
@pytest.mark.user_request_auth_backend("django.contrib.auth.backends.ModelBackend") | ||
def test_authenticated_but_not_verified(user_request, settings): | ||
""" | ||
Test that a user who logs in is not verified by default. | ||
""" | ||
settings.MAYKIN_2FA_ALLOW_MFA_BYPASS_BACKENDS = [] | ||
middleware = OTPMiddleware(lambda req: HttpResponse()) | ||
|
||
middleware(user_request) | ||
|
||
# standard Django behaviour | ||
user = user_request.user | ||
assert user.is_authenticated | ||
assert user.backend == "django.contrib.auth.backends.ModelBackend" | ||
# OTP middleware adds the `is_verified` callable | ||
assert hasattr(user, "is_verified") | ||
assert callable(user.is_verified) | ||
# we didn't go through any 2FA flows, so the user is *NOT* verified | ||
assert user.is_verified() is False | ||
|
||
|
||
@pytest.mark.user_request_auth_backend("testapp.backends.No2FAModelBackend") | ||
def test_authenticated_and_2fa_verification_bypassed(user_request, settings): | ||
""" | ||
Test that a user is "2FA-verified" when authenticated through a backend on the allow list. | ||
""" | ||
settings.MAYKIN_2FA_ALLOW_MFA_BYPASS_BACKENDS = [ | ||
"testapp.backends.No2FAModelBackend" | ||
] | ||
middleware = OTPMiddleware(lambda req: HttpResponse()) | ||
|
||
middleware(user_request) | ||
|
||
# standard Django behaviour | ||
user = user_request.user | ||
assert user.is_authenticated | ||
assert user.backend == "testapp.backends.No2FAModelBackend" | ||
# OTP middleware adds the `is_verified` callable | ||
assert hasattr(user, "is_verified") | ||
assert callable(user.is_verified) | ||
# didn't go through any 2FA flows, but the backend is on the allow list | ||
assert user.is_verified() |