2222from django .urls import reverse
2323
2424from django_mfa import session
25+ from django_mfa .conf import settings as mfa_settings
2526
2627
27- def _enforce (request ):
28- """Return a redirect response, or None to let the request through."""
28+ def _enforce (request , require_primary_factor = True ):
29+ """Return a redirect response, or None to let the request through.
30+
31+ ``require_primary_factor`` gates only the third rung below. It exists
32+ for mfa_recent_required/MfaRecentRequiredMixin, which reuse this
33+ function for the authenticated/pending rungs. By default those callers
34+ pass it True too, so a factorless user is redirected here exactly as
35+ mfa_required does. Their own allow_unenrolled=True escape hatch (for the
36+ built-in enrollment views only) passes False instead, deferring to
37+ _enforce_recent()'s own, more permissive handling of a factorless user
38+ (let them through, since there is nothing for them to re-verify) --
39+ which would otherwise never be reached, since this rung would redirect
40+ first. mfa_required/MfaRequiredMixin never pass this, so their
41+ behaviour is unchanged.
42+ """
2943 from django_mfa .registry import registry
3044
3145 user = request .user
@@ -34,7 +48,7 @@ def _enforce(request):
3448 if session .is_pending (request ):
3549 return redirect_to_login (request .get_full_path (),
3650 resolve_url (reverse ("mfa:verify" )), "next" )
37- if not registry .has_primary_factor (user ):
51+ if require_primary_factor and not registry .has_primary_factor (user ):
3852 # has_primary_factor(), not enabled_for(): a user holding only
3953 # recovery codes is not protected, and recovery codes must never be
4054 # somebody's sole second factor. has_primary_factor(), not
@@ -69,3 +83,115 @@ def dispatch(self, request, *args, **kwargs):
6983 if response is not None :
7084 return response
7185 return super ().dispatch (request , * args , ** kwargs )
86+
87+
88+ #: Methods that are safe to replay after a detour through the verify flow.
89+ #: An unsafe request's body cannot survive the redirect, so those are sent
90+ #: to a landing page instead -- see _enforce_recent.
91+ SAFE_METHODS = frozenset ({"GET" , "HEAD" , "OPTIONS" , "TRACE" })
92+
93+
94+ def _enforce_recent (request , max_age , next_url ):
95+ """The step-up rung: a recent challenge, not merely a verified session.
96+
97+ Returns a redirect response, or None to let the request through. Runs
98+ only AFTER _enforce() has passed, so request.user is authenticated and
99+ the session is verified by the time this is reached.
100+ """
101+ from django_mfa .registry import registry
102+
103+ resolved = (max_age if max_age is not None
104+ else mfa_settings .MFA_STEPUP_MAX_AGE )
105+ if resolved is None :
106+ return None
107+ if not registry .has_primary_factor (request .user ):
108+ # Only reachable at all when the caller passed allow_unenrolled=True
109+ # (_enforce() already redirected a factorless user away otherwise).
110+ # Nothing to re-verify, and this is the first-enrollment path.
111+ # Gating it would wall a factorless user out of the only pages that
112+ # could give them a factor -- the same lockout
113+ # signals.stamp_pending_verification guards against by refusing to
114+ # stamp such a user pending.
115+ return None
116+ if session .is_fresh (request , resolved ):
117+ return None
118+ if request .method in SAFE_METHODS :
119+ target = request .get_full_path ()
120+ else :
121+ # A POST body does not survive a redirect, and manage_factors is
122+ # POST-only (405 on GET), so replaying its URL after verification
123+ # would land the user on that 405. Send them to a page they can act
124+ # from instead; they re-click.
125+ target = next_url or reverse ("mfa:security_settings" )
126+ return redirect_to_login (target , resolve_url (reverse ("mfa:verify" )), "next" )
127+
128+
129+ def mfa_recent_required (max_age = None , next_url = None , allow_unenrolled = False ):
130+ """Require a *recent* second-factor challenge, not just a verified session.
131+
132+ By default (``allow_unenrolled=False``) this is strictly stronger than
133+ ``mfa_required``: it applies every rung ``mfa_required`` does --
134+ including redirecting a factorless user to ``mfa:security_settings`` --
135+ and then, for a user who passes that, the freshness rung on top. This is
136+ what a host project's own sensitive views (e.g. ``transfer_funds`` in
137+ docs/enforcement.md) get.
138+
139+ ``allow_unenrolled=True`` switches off the factorless-user redirect and
140+ lets such a user through instead, since they have nothing to re-verify.
141+ This is for the built-in enrollment views only (``enroll_factor``,
142+ ``recovery_codes``) -- gating the very pages that let a user acquire a
143+ factor would lock them out permanently. Most callers should not pass
144+ this.
145+
146+ Both spellings work -- bare, or called::
147+
148+ @mfa_recent_required
149+ @mfa_recent_required(max_age=60)
150+ @mfa_recent_required(allow_unenrolled=True)
151+
152+ ``max_age=None`` means MFA_STEPUP_MAX_AGE, resolved per request so
153+ override_settings() is honoured.
154+ """
155+ if callable (max_age ):
156+ return mfa_recent_required ()(max_age )
157+
158+ def decorator (view_func ):
159+ @wraps (view_func )
160+ def _wrapped (request , * args , ** kwargs ):
161+ response = _enforce (
162+ request ,
163+ require_primary_factor = not allow_unenrolled ) or _enforce_recent (
164+ request , max_age , next_url )
165+ if response is not None :
166+ return response
167+ return view_func (request , * args , ** kwargs )
168+
169+ return _wrapped
170+
171+ return decorator
172+
173+
174+ class MfaRecentRequiredMixin :
175+ """Class-based-view form of ``mfa_recent_required``.
176+
177+ Mix in FIRST, so dispatch() runs before the view's own.
178+
179+ ``mfa_allow_unenrolled = False`` by default -- a factorless user is
180+ redirected to ``mfa:security_settings`` exactly as ``MfaRequiredMixin``
181+ would. Set it ``True`` only for the built-in enrollment views, where a
182+ factorless user must be let through instead. See
183+ ``mfa_recent_required``'s docstring for the full rationale.
184+ """
185+
186+ mfa_stepup_max_age = None
187+ mfa_stepup_next_url = None
188+ mfa_allow_unenrolled = False
189+
190+ def dispatch (self , request , * args , ** kwargs ):
191+ response = _enforce (
192+ request ,
193+ require_primary_factor = not self .mfa_allow_unenrolled ) or _enforce_recent (
194+ request , self .mfa_stepup_max_age , self .mfa_stepup_next_url )
195+ if response is not None :
196+ return response
197+ return super ().dispatch (request , * args , ** kwargs )
0 commit comments