Skip to content

Commit dd5e55b

Browse files
committed
feat: Implement grace period for MFA enrollment
- Added support for MFA grace period through `MFA_REQUIRED_FROM` and `MFA_GRACE_PERIOD` settings. - Introduced `policy.grace_state()` to manage user states during the grace period. - Updated tests to cover scenarios for users in grace, including security settings and enrollment wall behavior. - Enhanced documentation to explain the rollout process and admin protection settings. - Bumped version to 4.6.0 to reflect new features and changes.
1 parent dc3ea93 commit dd5e55b

29 files changed

Lines changed: 1737 additions & 47 deletions

CHANGELOG.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,68 @@ Versions follow [PEP 440](https://peps.python.org/pep-0440/). The version in
1212
`pyproject.toml` is the only place it is written; the git tag and the GitHub
1313
Release are derived from it (see [docs/contributing.md](docs/contributing.md)).
1414

15+
## 4.6.0
16+
17+
### Added
18+
19+
- **A rollout ramp for `MFA_REQUIRED`**, for a project turning it on against
20+
users who already exist rather than a fresh install. `MFA_REQUIRED_FROM`
21+
(a `date`/`datetime` before which nobody is walled by `MFA_REQUIRED`,
22+
however it answers) and `MFA_GRACE_PERIOD` (an `int` number of days, or a
23+
`timedelta`, each user gets from their own anchor) combine so that a user
24+
is required from whichever of `MFA_REQUIRED_FROM` and
25+
`anchor + MFA_GRACE_PERIOD` is *later* — an existing account is governed
26+
by the announced cutover, while someone who signs up after it still gets
27+
their full window. `MFA_GRACE_ANCHOR` supplies a per-user anchor other
28+
than `user.date_joined` (a dotted path or callable, `(user) -> datetime |
29+
None`) for a clock `date_joined` can't express, such as a migration
30+
cohort. `policy.required_at(user)` and `policy.grace_state(user)` (a
31+
`GraceState(required_at, days_remaining)`, display-only) are the public
32+
entry points. Grace suppresses `MFA_REQUIRED` only — it does not open
33+
`@mfa_required`/`MfaRequiredMixin` views, exactly like an `MfaExemption`,
34+
and it does **not** open the `MFA_PROTECT_ADMIN` gate either: that gate is
35+
enforced through `decorators.enforcement_state()`, which never consults
36+
`django_mfa.policy` at all. See [docs/enforcement.md](docs/enforcement.md).
37+
- **Grace surfaced everywhere the enrollment wall already is**: a `grace`
38+
key in `security_settings`'s context, the opt-in
39+
`django_mfa.context_processors.mfa` template context processor
40+
(`mfa_grace`), the JSON API's `state` endpoint (`grace` field), and
41+
`mfa_status`'s new "In grace until" line. `mfa_report` now also lists
42+
users currently in grace — see the Changed entry below for its CSV
43+
output.
44+
- **`MFA_PROTECT_ADMIN`** (default `False`). When `True`, no page on the
45+
default admin site (`django.contrib.admin.site`) is reachable without a
46+
verified session — enforced by `django_mfa.admin_site.protect_admin_site()`
47+
wrapping that site's own `has_permission()`/`login()` in place, so it
48+
holds even on a project that never installed `MfaMiddleware`. A project
49+
mounting its own `AdminSite` instance gets no protection from the setting
50+
alone; `django_mfa.admin_site.MfaAdminMixin` covers that case. It also
51+
unions `is_staff` into `policy.resolve()`'s predicate, so staff become
52+
subject to `MFA_REQUIRED` without a second setting — without rewriting
53+
`MFA_REQUIRED` itself. **`MFA_ADMIN_STEPUP`** (default `False`) additionally
54+
requires a challenge within `MFA_STEPUP_MAX_AGE`, not merely a verified
55+
session, once `MFA_PROTECT_ADMIN` is on. See
56+
[docs/enforcement.md](docs/enforcement.md).
57+
- **System checks `django_mfa.E010`** (grace is configured but cannot
58+
apply — `MFA_REQUIRED_FROM` isn't a date, `MFA_GRACE_PERIOD` is negative
59+
or not a number, or it's set with no usable anchor) and
60+
**`django_mfa.E011`** (`MFA_ADMIN_STEPUP` set without `MFA_PROTECT_ADMIN`,
61+
so nothing reads it and the admin stays unprotected).
62+
- **`decorators.enforcement_redirect(request, require_primary_factor=True,
63+
next_url=None)`** is now public — it renders an `enforcement_state()` rung
64+
as a redirect, and `django_mfa.admin_site` calls it directly to reuse the
65+
same two destinations `MfaMiddleware` and `@mfa_required` already redirect
66+
to. The previous private name, `_enforce`, remains as a back-compat alias.
67+
68+
### Changed
69+
70+
- `mfa_report --format csv` gains a `grace_until` column, appended after the
71+
existing ones (so indexing by position still works for those). Users
72+
inside a grace window are now listed by `mfa_report`; previously — before
73+
grace existed — there was no such state.
74+
75+
No new migration — this release adds no models.
76+
1577
## 4.5.0
1678

1779
### Added

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Django's own `user_logged_in` signal.
4747
|**Several keys at once** | A user can register a work laptop's Touch ID *and* a backup YubiKey, each with its own name. |
4848
| 🌍 **Six languages** | German, Spanish, French, Brazilian Portuguese, Japanese and Simplified Chinese ship translated. Switch on `USE_I18N` and they work. |
4949
| 🔌 **A JSON API** | Opt-in. Every flow above as JSON, for an SPA or mobile client that renders its own screens. No DRF dependency, and a revocable session token for clients that hold no cookie. |
50+
| 🛡️ **Admin protection** | `MFA_PROTECT_ADMIN = True`. No page on `django.contrib.admin`'s default site without a verified second factor — enforced by the admin itself, so it holds even without the middleware. A project mounting its own `AdminSite` needs the `MfaAdminMixin` instead. Optionally require a *recent* challenge, not just a verified session. |
5051

5152
## Install
5253

django_mfa/admin_site.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"""Second-factor protection for the Django admin.
2+
3+
The admin is the single most common reason a project wants MFA at all, and
4+
the settings-only recipe (MFA_REQUIRED = is_staff, plus MfaMiddleware, plus
5+
no admin path in MFA_EXEMPT_PATHS) fails SILENTLY if any part is missed.
6+
This enforces it from inside the admin site, so it holds with no middleware
7+
installed.
8+
9+
Neither override restates a rule. has_permission() reads
10+
decorators.enforcement_state(); login() renders it through
11+
decorators.enforcement_redirect(), which is public for exactly this reason.
12+
"""
13+
14+
import functools
15+
16+
from django.contrib.auth.views import redirect_to_login
17+
from django.shortcuts import resolve_url
18+
from django.urls import reverse
19+
from django.utils.decorators import method_decorator
20+
from django.views.decorators.cache import never_cache
21+
22+
from django_mfa import decorators
23+
from django_mfa.conf import settings as mfa_settings
24+
25+
#: Marks a site instance as already wrapped. Some runners call AppConfig.
26+
#: ready() more than once, and double-wrapping would evaluate every gate
27+
#: twice per request.
28+
_PATCHED = "_django_mfa_protected"
29+
30+
31+
def _gates_pass(request):
32+
"""Does this request clear every MFA gate the admin imposes?
33+
34+
Deliberately does not re-check MFA_PROTECT_ADMIN here -- once
35+
protect_admin_site() has patched a site, the gate stays active for that
36+
site's lifetime regardless of what the setting says afterward. Right for
37+
production (nothing ever flips this off mid-process), but worth knowing
38+
if a test suite patches a site directly and expects toggling the setting
39+
alone to unpatch it -- it doesn't; call protect_admin_site() again after
40+
removing the patch attributes, or don't patch until the setting is what
41+
you want.
42+
"""
43+
if decorators.enforcement_state(request) is not None:
44+
return False
45+
if (mfa_settings.MFA_ADMIN_STEPUP
46+
and decorators.recent_enforcement_state(request) is not None):
47+
return False
48+
return True
49+
50+
51+
def _login_redirect(request):
52+
"""Where an authenticated-but-ungated admin request goes, or None.
53+
54+
Only ever acts on an authenticated user. AdminSite.admin_view renders a
55+
failed has_permission() as a redirect to admin:login, so without this an
56+
already-logged-in staff user is shown a login form -- confusing, and
57+
django-two-factor-auth's long-standing wart. An anonymous user is left
58+
entirely alone: the admin's own form is the right answer for them, and
59+
enforcement_redirect()'s UNAUTHENTICATED branch must not compete with it.
60+
61+
next_url is the admin index rather than request.get_full_path(), which
62+
here is /admin/login/?next=... -- replaying that after verifying is a
63+
round trip through a login view the user is already past.
64+
"""
65+
if not request.user.is_authenticated:
66+
return None
67+
68+
next_url = request.GET.get("next") or reverse("admin:index")
69+
70+
response = decorators.enforcement_redirect(request, next_url=next_url)
71+
if response is not None:
72+
return response
73+
74+
# enforcement_state() passed but the step-up rung has not: MFA_ADMIN_STEPUP
75+
# is on and the challenge is older than MFA_STEPUP_MAX_AGE. Admin pages
76+
# reaching this are GETs, so replaying the path is safe and
77+
# _enforce_recent()'s POST carve-out does not apply.
78+
if (mfa_settings.MFA_ADMIN_STEPUP
79+
and decorators.recent_enforcement_state(request) is not None):
80+
return redirect_to_login(
81+
next_url, resolve_url(reverse("mfa:verify")), "next")
82+
83+
return None
84+
85+
86+
class MfaAdminMixin:
87+
"""Mix in front of AdminSite to require a verified session.
88+
89+
Public for hosts that would rather wire this explicitly than let
90+
MFA_PROTECT_ADMIN patch the default site. That route needs an AdminConfig
91+
subclass with default_site in INSTALLED_APPS, which is not a one-liner
92+
and collides with any other package claiming default_site -- hence the
93+
setting being the advertised path.
94+
"""
95+
96+
def has_permission(self, request):
97+
return super().has_permission(request) and _gates_pass(request)
98+
99+
@method_decorator(never_cache)
100+
def login(self, request, extra_context=None):
101+
response = _login_redirect(request)
102+
if response is not None:
103+
return response
104+
return super().login(request, extra_context)
105+
106+
# AdminSite.login is decorated `@login_not_required` (Django >= 5.1),
107+
# which LoginRequiredMiddleware reads off the URL pattern's callable to
108+
# decide whether an anonymous request may reach it at all. Overriding
109+
# the method here replaces that callable and drops the marker unless it
110+
# is restored explicitly -- without this, an anonymous visitor to
111+
# admin:login is bounced to LOGIN_URL by that middleware, which is
112+
# usually a page that doesn't exist. Setting the attribute directly
113+
# (rather than importing login_not_required) keeps this working on
114+
# Django 4.2, which has neither the decorator nor anything that reads
115+
# the attribute -- the assignment is simply inert there.
116+
login.login_required = False
117+
118+
119+
def protect_admin_site(site):
120+
"""Wrap an existing AdminSite instance in place.
121+
122+
Wraps the bound methods rather than swapping the class, so this composes
123+
with a host project's own AdminSite subclass instead of replacing it --
124+
and so there is no zero-argument super() to break when a function is
125+
bound to an instance after class creation.
126+
127+
Timing is two different stories for the two methods this patches, and
128+
only one of them is forgiving:
129+
130+
- has_permission is read fresh on every request -- AdminSite.get_urls()
131+
wraps most views in a closure that calls ``self.has_permission(request)``
132+
at call time, not at get_urls() time -- so patching it is safe
133+
regardless of when get_urls() first runs relative to this call.
134+
- login is NOT read fresh. AdminSite.get_urls() wires the `login/` URL
135+
directly to ``self.login`` (unlike every other view, it is not passed
136+
through that closure), so whatever ``self.login`` resolves to AT THE
137+
MOMENT get_urls() first runs is what every future request to
138+
admin:login gets, forever -- get_urls() only ever runs once, the
139+
first time admin.site.urls is accessed, and Python caches the
140+
importing module so nothing re-evaluates it later. This function
141+
MUST therefore run before the URLconf module that mounts admin.site.urls
142+
is first imported. Calling it from AppConfig.ready() satisfies that in
143+
a normal deployment, since URL resolution is lazy and does not happen
144+
until the first real request, well after django.setup() (and every
145+
app's ready()) has completed.
146+
"""
147+
if getattr(site, _PATCHED, False):
148+
return
149+
150+
original_has_permission = site.has_permission
151+
original_login = site.login
152+
153+
def has_permission(request):
154+
return original_has_permission(request) and _gates_pass(request)
155+
156+
@never_cache
157+
def login(request, extra_context=None):
158+
response = _login_redirect(request)
159+
if response is not None:
160+
return response
161+
return original_login(request, extra_context)
162+
163+
# original_login is AdminSite.login, decorated `@login_not_required`
164+
# (Django >= 5.1) so LoginRequiredMiddleware lets an anonymous visitor
165+
# reach it at all -- that decorator just sets login_required = False in
166+
# the function's __dict__. update_wrapper copies __dict__, so this
167+
# carries the marker (and anything else a host's own AdminSite subclass
168+
# set) onto the replacement without this module needing to import
169+
# login_not_required itself, which does not exist before Django 5.1 and
170+
# would break the Django 4.2 floor. Applied after @never_cache so the
171+
# final object -- what site.login actually becomes -- is the one that
172+
# gets the copy, not an intermediate.
173+
functools.update_wrapper(login, original_login)
174+
175+
site.has_permission = has_permission
176+
site.login = login
177+
setattr(site, _PATCHED, True)

django_mfa/api/views.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
from django.utils.translation import gettext_lazy as _
4343
from django.views.decorators.csrf import csrf_protect
4444

45-
from django_mfa import decorators, events, flows, session
45+
from django_mfa import decorators, events, flows, policy, session
4646
from django_mfa.adapters.recovery_codes import RecoveryCodesAdapter
4747
from django_mfa.api import auth, tokens
4848
from django_mfa.conf import settings as mfa_settings
@@ -251,6 +251,29 @@ def serialize_adapter(adapter):
251251
"supports_multiple": adapter.supports_multiple}
252252

253253

254+
def serialize_grace(user):
255+
"""policy.GraceState as JSON, or None.
256+
257+
Reachable while pending (state always is), so a client can draw the
258+
"you have N days" banner before the user has verified anything.
259+
260+
``required_at`` is passed through as a datetime rather than
261+
``.isoformat()``'d here, so JsonResponse's DjangoJSONEncoder renders it
262+
the same way it renders every other datetime in this response (e.g.
263+
``created_at`` in serialize_authenticator) -- "2026-08-14T09:12:03.114Z",
264+
not isoformat()'s "2026-08-14T09:12:03.114000+00:00". A second encoding
265+
of the same kind of value in the same response is a bug waiting for a
266+
client that only handles one of them.
267+
"""
268+
grace = policy.grace_state(user)
269+
if grace is None:
270+
return None
271+
return {
272+
"required_at": grace.required_at,
273+
"days_remaining": grace.days_remaining,
274+
}
275+
276+
254277
@endpoint("GET", require_verified=False)
255278
def state(request):
256279
"""Everything a client needs to render the right screen.
@@ -277,6 +300,7 @@ def state(request):
277300
"recovery_codes_remaining":
278301
RecoveryCodesAdapter().remaining(request.user),
279302
"stepup_max_age": mfa_settings.MFA_STEPUP_MAX_AGE,
303+
"grace": serialize_grace(request.user),
280304
})
281305

282306

django_mfa/apps.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ class DjangoMfaAppConfig(AppConfig):
99

1010
def ready(self):
1111
from django_mfa.checks import (
12+
check_admin_stepup,
1213
check_client_ip_resolver,
1314
check_fido2_rp_id,
15+
check_grace_configuration,
1416
check_mfa_api_authentication,
1517
check_mfa_required_predicate,
1618
check_rate_limit_backend,
@@ -27,9 +29,24 @@ def ready(self):
2729
register(check_rate_limit_specs)
2830
register(check_rate_limit_backend)
2931
register(check_client_ip_resolver)
32+
register(check_grace_configuration)
33+
register(check_admin_stepup)
34+
35+
from django.apps import apps as django_apps
3036

3137
from django_mfa import (
3238
adapters, # noqa: F401 (registers built-ins)
3339
notifications, # noqa: F401 (connects notification receivers)
3440
signals, # noqa: F401
3541
)
42+
from django_mfa.conf import settings as mfa_settings
43+
44+
# No-op without django.contrib.admin: a project with no admin that
45+
# sets MFA_PROTECT_ADMIN is odd but must not crash at startup.
46+
if (mfa_settings.MFA_PROTECT_ADMIN
47+
and django_apps.is_installed("django.contrib.admin")):
48+
from django.contrib import admin
49+
50+
from django_mfa.admin_site import protect_admin_site
51+
52+
protect_admin_site(admin.site)

0 commit comments

Comments
 (0)