|
| 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) |
0 commit comments