Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,88 @@ Versions follow [PEP 440](https://peps.python.org/pep-0440/). The version in
`pyproject.toml` is the only place it is written; the git tag and the GitHub
Release are derived from it (see [docs/contributing.md](docs/contributing.md)).

## 4.2.0

### Added

- **Step-up re-authentication.** `mfa_recent_required`/`MfaRecentRequiredMixin`
(`django_mfa.decorators`) and `MFA_STEPUP_MAX_AGE` (default `300` seconds)
require a *recent* challenge, not merely a verified session, before a
factor can be added, removed or regenerated. Set `MFA_STEPUP_MAX_AGE =
None` to switch it off for django-mfa's own three built-in views (they
never pass their own `max_age`, so they fall back to the setting) and
restore 4.1.0 behaviour there. It does **not** override a host view's own
explicit `@mfa_recent_required(max_age=60)` — an explicit per-view
`max_age` always wins over the global setting, by design. See
[docs/enforcement.md](docs/enforcement.md).
- **Four management commands** for day-to-day operation:
`mfa_status` (read-only — one user's enrolled factors and MFA status),
`mfa_reset` (remove every factor from a locked-out user so they can
re-enroll), `mfa_report` (rollout coverage, and who `MFA_REQUIRED` applies
to but who hasn't enrolled — text or CSV), and `mfa_disable` (grant or
`--revoke` an `MfaExemption` from `MFA_REQUIRED` for one user; does not
touch that user's enrolled factors). See
[docs/operations.md](docs/operations.md).
- **Two importers** for migrating factors in from another package:
`mfa_import_django_otp` (also covers **django-two-factor-auth**, which
stores its TOTP and static tokens as django-otp rows) and
`mfa_import_django_mfa2`. Both support `--dry-run`, `--users`, and
`--overwrite`, are idempotent, and never destroy a working factor unless
`--overwrite` is passed. See
[docs/operations.md](docs/operations.md#migrating-from-another-package) for
what each does and does not migrate — several factor shapes (a
clock-drifted django-otp TOTP device, django-mfa2's wider acceptance
window, `RECOVERY` rows, and any factor type absent from `MFA_FACTORS`) are
reported rather than imported, and are worth reading before a cutover.
- **`MfaExemption`** model and manager (`MfaExemption.objects.active_for()`),
and the **`mfa_exemption_changed`** signal (`user`, `reason`, `expires_at`,
`revoked`, `request`) it fires. Written only by `mfa_disable` — there is no
web UI for granting yourself an exemption from a security requirement.
- **System check `django_mfa.E005`**, rejecting an `MFA_STEPUP_MAX_AGE` that
isn't a positive integer or `None`, the same way `E004` already does for
`MFA_REQUIRED`.

### Changed

- **Behaviour change.** Adding, removing or regenerating a factor now
requires a session that completed a challenge within the last
`MFA_STEPUP_MAX_AGE` seconds (default 300), not merely a verified one.
Set `MFA_STEPUP_MAX_AGE = None` to restore 4.1.0 behaviour for
django-mfa's own views (see the Added entry above for the one case this
doesn't cover). This is the one place this release does not upgrade to
byte-identical behaviour by default — see
[docs/upgrading.md](docs/upgrading.md).
- **`MFA_REMEMBER_MY_BROWSER` now interacts with step-up.** A trusted
browser still skips the challenge at *login* exactly as before — the RMB
cookie check marks the session verified immediately — but that session is
only fresh the moment it's created. `MFA_STEPUP_MAX_AGE` is enforced on
every factor change regardless of how the session became verified, so a
trusted browser that adds, removes or regenerates a factor more than
`MFA_STEPUP_MAX_AGE` seconds after logging in is now challenged for that
action — the RMB cookie is consulted only at login, not re-checked by the
step-up gate. This is a visible change for installs that enabled RMB
specifically to avoid challenges. See
[docs/settings.md](docs/settings.md).
- **Signals may now carry `request=None`.** `mfa_reset` and `mfa_disable`
emit `factor_removed`/`mfa_exemption_changed` from outside any request, so
that an operator action is exactly as auditable as the equivalent
user-initiated one. A receiver that reaches for `request.META`
unconditionally must be updated to tolerate `None` first — see
[docs/api.md](docs/api.md)'s Signals section.
- The verification picker now honours `?next=`, so a single-factor user is
returned to the page they requested after logging in rather than to
`LOGIN_REDIRECT_URL`.

### Upgrading

Run `manage.py migrate django_mfa`. Migration `0009_mfa_exemption` adds the
`MfaExemption` table; it is reversible.

Nothing else is required to keep 4.1.0 behaviour, with one exception: factor
changes are gated on `MFA_STEPUP_MAX_AGE` by default (see above). Set it to
`None` if you need the previous, unconditional behaviour immediately after
upgrading.

## 4.1.0

Three additions, all opt-in. **An install that sets none of the new settings
Expand Down
33 changes: 32 additions & 1 deletion django_mfa/admin.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from django.contrib import admin
from django.contrib.auth import get_user_model

from .models import Authenticator
from .models import Authenticator, MfaExemption


@admin.register(Authenticator)
Expand Down Expand Up @@ -54,3 +54,34 @@ def has_add_permission(self, request):

def has_change_permission(self, request, obj=None):
return False


@admin.register(MfaExemption)
class MfaExemptionAdmin(admin.ModelAdmin):
"""Inspect-and-revoke only, for the same reasons as AuthenticatorAdmin.

Creating an exemption is a deliberate command-line act with a mandatory
reason (`manage.py mfa_disable`), so add and change are off. Deleting is
allowed and is A revoke path: it is fail-safe -- it re-imposes MFA --
and denying it would push operators to editing the database by hand.
It is NOT an audited one, though: unlike `manage.py mfa_disable
--revoke`, deleting here fires no `mfa_exemption_changed` (Django's
admin has nothing django-mfa listens for on delete) -- see
docs/operations.md's "Auditing operator actions" section. An operator
who needs the deletion to reach an audit receiver should use the
command instead of this page.
"""

list_display = ("user", "reason", "created_at", "expires_at")
list_filter = ("created_at", "expires_at")
fields = ("user", "reason", "created_at", "expires_at")
readonly_fields = fields

def get_search_fields(self, request):
return ("reason", f"user__{get_user_model().USERNAME_FIELD}")

def has_add_permission(self, request):
return False

def has_change_permission(self, request, obj=None):
return False
2 changes: 2 additions & 0 deletions django_mfa/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ def ready(self):
from django_mfa.checks import (
check_fido2_rp_id,
check_mfa_required_predicate,
check_stepup_max_age,
check_webauthn_backend_configured,
)

register(check_fido2_rp_id)
register(check_webauthn_backend_configured)
register(check_mfa_required_predicate)
register(check_stepup_max_age)

from django_mfa import (
adapters, # noqa: F401 (registers built-ins)
Expand Down
29 changes: 29 additions & 0 deletions django_mfa/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,32 @@ def check_mfa_required_predicate(app_configs, **kwargs):
id="django_mfa.E004",
)]
return []


def check_stepup_max_age(app_configs, **kwargs):
"""``MFA_STEPUP_MAX_AGE`` must be a positive integer, or None to disable.

Deliberately NOT gated on ``_webauthn_active()`` -- like E004, this
applies to every install.

Zero is refused rather than accepted because it means "always stale":
every gated view would redirect to mfa:verify, which marks the session
verified and redirects back, which is stale again the instant any
measurable time has passed -- a redirect loop rather than a security
setting. django_mfa.ratelimit.parse() refuses a zero count and a zero
window for the same class of reason. bool is excluded explicitly because
it is a subclass of int, so ``True`` would otherwise be accepted as a
one-second window.
"""
value = mfa_settings.MFA_STEPUP_MAX_AGE
if value is None:
return []
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
return [Error(
f"MFA_STEPUP_MAX_AGE must be a positive integer or None, "
f"got {value!r}.",
hint="It is a number of seconds -- 300 is the default. Set it to "
"None to switch step-up re-authentication off entirely.",
id="django_mfa.E005",
)]
return []
1 change: 1 addition & 0 deletions django_mfa/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"MFA_FIDO2_USER_VERIFICATION": "preferred",
"MFA_FIDO2_ATTESTATION_PREFERENCE": "none",
"MFA_EXEMPT_PATHS": [],
"MFA_STEPUP_MAX_AGE": 300,
"MFA_EMAIL_CODE_LENGTH": 6,
"MFA_EMAIL_CODE_VALIDITY": 300,
"MFA_EMAIL_SUBJECT": None,
Expand Down
132 changes: 129 additions & 3 deletions django_mfa/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,24 @@
from django.urls import reverse

from django_mfa import session
from django_mfa.conf import settings as mfa_settings


def _enforce(request):
"""Return a redirect response, or None to let the request through."""
def _enforce(request, require_primary_factor=True):
"""Return a redirect response, or None to let the request through.

``require_primary_factor`` gates only the third rung below. It exists
for mfa_recent_required/MfaRecentRequiredMixin, which reuse this
function for the authenticated/pending rungs. By default those callers
pass it True too, so a factorless user is redirected here exactly as
mfa_required does. Their own allow_unenrolled=True escape hatch (for the
built-in enrollment views only) passes False instead, deferring to
_enforce_recent()'s own, more permissive handling of a factorless user
(let them through, since there is nothing for them to re-verify) --
which would otherwise never be reached, since this rung would redirect
first. mfa_required/MfaRequiredMixin never pass this, so their
behaviour is unchanged.
"""
from django_mfa.registry import registry

user = request.user
Expand All @@ -34,7 +48,7 @@ def _enforce(request):
if session.is_pending(request):
return redirect_to_login(request.get_full_path(),
resolve_url(reverse("mfa:verify")), "next")
if not registry.has_primary_factor(user):
if require_primary_factor and not registry.has_primary_factor(user):
# has_primary_factor(), not enabled_for(): a user holding only
# recovery codes is not protected, and recovery codes must never be
# somebody's sole second factor. has_primary_factor(), not
Expand Down Expand Up @@ -69,3 +83,115 @@ def dispatch(self, request, *args, **kwargs):
if response is not None:
return response
return super().dispatch(request, *args, **kwargs)


#: Methods that are safe to replay after a detour through the verify flow.
#: An unsafe request's body cannot survive the redirect, so those are sent
#: to a landing page instead -- see _enforce_recent.
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"})


def _enforce_recent(request, max_age, next_url):
"""The step-up rung: a recent challenge, not merely a verified session.

Returns a redirect response, or None to let the request through. Runs
only AFTER _enforce() has passed, so request.user is authenticated and
the session is verified by the time this is reached.
"""
from django_mfa.registry import registry

resolved = (max_age if max_age is not None
else mfa_settings.MFA_STEPUP_MAX_AGE)
if resolved is None:
return None
if not registry.has_primary_factor(request.user):
# Only reachable at all when the caller passed allow_unenrolled=True
# (_enforce() already redirected a factorless user away otherwise).
# Nothing to re-verify, and this is the first-enrollment path.
# Gating it would wall a factorless user out of the only pages that
# could give them a factor -- the same lockout
# signals.stamp_pending_verification guards against by refusing to
# stamp such a user pending.
return None
if session.is_fresh(request, resolved):
return None
if request.method in SAFE_METHODS:
target = request.get_full_path()
else:
# A POST body does not survive a redirect, and manage_factors is
# POST-only (405 on GET), so replaying its URL after verification
# would land the user on that 405. Send them to a page they can act
# from instead; they re-click.
target = next_url or reverse("mfa:security_settings")
return redirect_to_login(target, resolve_url(reverse("mfa:verify")), "next")


def mfa_recent_required(max_age=None, next_url=None, allow_unenrolled=False):
"""Require a *recent* second-factor challenge, not just a verified session.

By default (``allow_unenrolled=False``) this is strictly stronger than
``mfa_required``: it applies every rung ``mfa_required`` does --
including redirecting a factorless user to ``mfa:security_settings`` --
and then, for a user who passes that, the freshness rung on top. This is
what a host project's own sensitive views (e.g. ``transfer_funds`` in
docs/enforcement.md) get.

``allow_unenrolled=True`` switches off the factorless-user redirect and
lets such a user through instead, since they have nothing to re-verify.
This is for the built-in enrollment views only (``enroll_factor``,
``recovery_codes``) -- gating the very pages that let a user acquire a
factor would lock them out permanently. Most callers should not pass
this.

Both spellings work -- bare, or called::

@mfa_recent_required
@mfa_recent_required(max_age=60)
@mfa_recent_required(allow_unenrolled=True)

``max_age=None`` means MFA_STEPUP_MAX_AGE, resolved per request so
override_settings() is honoured.
"""
if callable(max_age):
return mfa_recent_required()(max_age)

def decorator(view_func):
@wraps(view_func)
def _wrapped(request, *args, **kwargs):
response = _enforce(
request,
require_primary_factor=not allow_unenrolled) or _enforce_recent(
request, max_age, next_url)
if response is not None:
return response
return view_func(request, *args, **kwargs)

return _wrapped

return decorator


class MfaRecentRequiredMixin:
"""Class-based-view form of ``mfa_recent_required``.

Mix in FIRST, so dispatch() runs before the view's own.

``mfa_allow_unenrolled = False`` by default -- a factorless user is
redirected to ``mfa:security_settings`` exactly as ``MfaRequiredMixin``
would. Set it ``True`` only for the built-in enrollment views, where a
factorless user must be let through instead. See
``mfa_recent_required``'s docstring for the full rationale.
"""

mfa_stepup_max_age = None
mfa_stepup_next_url = None
mfa_allow_unenrolled = False

def dispatch(self, request, *args, **kwargs):
response = _enforce(
request,
require_primary_factor=not self.mfa_allow_unenrolled) or _enforce_recent(
request, self.mfa_stepup_max_age, self.mfa_stepup_next_url)
if response is not None:
return response
return super().dispatch(request, *args, **kwargs)
22 changes: 19 additions & 3 deletions django_mfa/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,13 @@
re-export is one-way (signals imports events, never the reverse) and safe.

`sender` is uniformly the Adapter *class* for the factor involved, so a
receiver can narrow with `sender=TOTPAdapter`. `request` is always supplied:
every emission point sits inside a request path.
receiver can narrow with `sender=TOTPAdapter`. `request` is the request the
event happened in, or **None** when it did not happen in one: the management
commands (`mfa_reset`, `mfa_disable`) emit these too, because a factor
removed by an operator is exactly the event an audit receiver most needs to
Comment on lines 14 to +18
see. Receivers must therefore tolerate `request=None` rather than reaching
straight for `request.META`. Every emission uses send_robust(), so a receiver
that does not is contained rather than breaking the action.
"""

import django.dispatch
Expand All @@ -25,7 +30,9 @@

#: A user removed a factor. kwargs: user, factor_type, name, request.
#: The row is already gone by the time this fires, so its type and name are
#: passed by value rather than as an instance.
#: passed by value rather than as an instance. `request` is None when a
#: management command (mfa_reset, mfa_disable) is the one removing it --
#: there is no request to pass.
factor_removed = django.dispatch.Signal()

#: A second-factor challenge succeeded. kwargs: user, method, request.
Expand All @@ -42,3 +49,12 @@
#: Emitted from the adapter rather than the view: only the adapter knows how
#: many codes are left.
recovery_code_used = django.dispatch.Signal()

#: An operator granted or revoked an MFA_REQUIRED exemption.
#: kwargs: user, reason, expires_at, revoked, request.
#: sender is the MfaExemption model class -- the other five signals use the
#: Adapter subclass, and there is no adapter behind this one. Exempting
#: somebody from a security requirement is exactly as audit-worthy as
#: changing their factors, so it gets the same hook. `request` is None when
#: it comes from `manage.py mfa_disable`, which today is the only writer.
mfa_exemption_changed = django.dispatch.Signal()
Empty file.
Empty file.
Loading
Loading