diff --git a/CHANGELOG.md b/CHANGELOG.md index e1bb9b5..6280a72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/django_mfa/admin.py b/django_mfa/admin.py index 9e77e89..fa99279 100644 --- a/django_mfa/admin.py +++ b/django_mfa/admin.py @@ -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) @@ -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 diff --git a/django_mfa/apps.py b/django_mfa/apps.py index cff8ab7..ef3de92 100644 --- a/django_mfa/apps.py +++ b/django_mfa/apps.py @@ -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) diff --git a/django_mfa/checks.py b/django_mfa/checks.py index ed3263e..220319c 100644 --- a/django_mfa/checks.py +++ b/django_mfa/checks.py @@ -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 [] diff --git a/django_mfa/conf.py b/django_mfa/conf.py index 1b5fa8c..62c77e8 100644 --- a/django_mfa/conf.py +++ b/django_mfa/conf.py @@ -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, diff --git a/django_mfa/decorators.py b/django_mfa/decorators.py index 3d1dfcf..b3f80de 100644 --- a/django_mfa/decorators.py +++ b/django_mfa/decorators.py @@ -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 @@ -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 @@ -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) diff --git a/django_mfa/events.py b/django_mfa/events.py index 97ddb5b..28c54cf 100644 --- a/django_mfa/events.py +++ b/django_mfa/events.py @@ -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 +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 @@ -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. @@ -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() diff --git a/django_mfa/management/__init__.py b/django_mfa/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_mfa/management/commands/__init__.py b/django_mfa/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_mfa/management/commands/_support.py b/django_mfa/management/commands/_support.py new file mode 100644 index 0000000..623d22d --- /dev/null +++ b/django_mfa/management/commands/_support.py @@ -0,0 +1,63 @@ +"""Shared helpers for django_mfa's management commands.""" + +from django.contrib.auth import get_user_model +from django.core.management import CommandError +from django.utils import timezone + + +def resolve_user(identifier): + """Find a user by USERNAME_FIELD, falling back to pk. + + USERNAME_FIELD first, then pk only when the first lookup missed and the + identifier is all digits -- so an installation whose usernames are + numeric still resolves the username, which is the value an operator + typed on purpose. + + Derived from USERNAME_FIELD rather than hardcoding "username": + AUTH_USER_MODEL is swappable and a host project's user model need not + have that field at all (AuthenticatorAdmin.get_search_fields does the + same). + """ + model = get_user_model() + field = model.USERNAME_FIELD + user = model.objects.filter(**{field: identifier}).first() + if user is not None: + return user + if identifier.isdigit(): + user = model.objects.filter(pk=int(identifier)).first() + if user is not None: + return user + raise CommandError( + f"No user matched {identifier!r} — tried {field} (username) and pk." + ) + + +def format_date(value): + """Format a datetime as YYYY-MM-DD in the operator's local timezone. + + Guarded on is_aware(): timezone.localtime() raises ValueError on a + naive datetime, and USE_TZ is False by default on Django 4.2, this + package's floor. Same guard as MfaExemption.__str__ (django_mfa/models.py) + -- duplicated here rather than shared because unifying the two copies is + a later-pass refactor, out of scope for the command that first needed it. + """ + if timezone.is_aware(value): + value = timezone.localtime(value) + return f"{value:%Y-%m-%d}" + + +def describe_authenticator(auth): + """One human-readable line for an Authenticator. + + NEVER includes `auth.data`: it holds the TOTP shared secret, the + recovery-code hashes and the WebAuthn credential. See AuthenticatorAdmin's + docstring for why that blob stays unprinted everywhere, not just in the + admin. + """ + parts = [auth.get_type_display()] + if auth.name: + parts.append(f"({auth.name})") + parts.append(f"added {format_date(auth.created_at)}") + parts.append(f"last used {format_date(auth.last_used_at)}" + if auth.last_used_at else "never used") + return " ".join(parts) diff --git a/django_mfa/management/commands/mfa_disable.py b/django_mfa/management/commands/mfa_disable.py new file mode 100644 index 0000000..af56872 --- /dev/null +++ b/django_mfa/management/commands/mfa_disable.py @@ -0,0 +1,99 @@ +"""Grant or revoke an MFA_REQUIRED exemption for one user.""" + +from datetime import datetime, timedelta + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone + +from django_mfa import events +from django_mfa.management.commands._support import resolve_user +from django_mfa.models import MfaExemption + + +class Command(BaseCommand): + help = "Exempt a user from MFA_REQUIRED (or revoke that exemption)." + + def add_arguments(self, parser): + parser.add_argument("user", help="username or pk") + parser.add_argument("--reason", help="why this user is exempt") + parser.add_argument("--until", help="expiry date, YYYY-MM-DD") + parser.add_argument("--revoke", action="store_true", + help="remove an existing exemption") + + def handle(self, *args, **options): + user = resolve_user(options["user"]) + + if options["revoke"]: + deleted, _ = MfaExemption.objects.filter(user=user).delete() + if not deleted: + self.stdout.write(f"{user} has no exemption; nothing to do.") + return + events.mfa_exemption_changed.send_robust( + sender=MfaExemption, user=user, reason=None, + expires_at=None, revoked=True, request=None) + self.stdout.write(self.style.SUCCESS( + f"Revoked the MFA exemption for {user}.")) + return + + # Stripped, not just truthy: "--reason ' '" is a non-empty string + # that would otherwise sail past a bare `if not reason`, defeating + # the entire point of a mandatory reason. + reason = (options["reason"] or "").strip() + if not reason: + # Mandatory on the write path: an unexplained permanent exemption + # from a security requirement outlives everyone who remembers why + # it exists. + raise CommandError( + "--reason is required when granting an exemption.") + + expires_at = None + if options["until"]: + try: + parsed = datetime.strptime(options["until"], "%Y-%m-%d") + except ValueError: + raise CommandError( + f"--until must be YYYY-MM-DD, got {options['until']!r}." + ) from None + # End of the named date, not its start: an operator granting + # "--until 2030-06-15" means the exemption covers the whole of + # that day. Storing midnight instead (the original bug) makes + # MfaExemption.objects.active_for() return None for the entire + # named date -- a "one-day" exemption granting zero days of + # coverage. Add a day and subtract a microsecond rather than + # setting hour=23/minute=59/... by hand, so this can't silently + # drop a field if datetime ever grows one. + end_of_day = parsed + timedelta(days=1) - timedelta(microseconds=1) + # Match the datetime's awareness to USE_TZ rather than always + # calling make_aware(): test_runner.py never sets USE_TZ, so it + # follows Django's default -- False on Django 4.2, this + # project's floor and a leg of the CI matrix. Under + # USE_TZ=False, storing a timezone-aware datetime raises + # (SQLite rejects it outright), and MfaExemption.active_for() + # compares expires_at__gt=timezone.now(), where timezone.now() + # is naive -- mixing the two raises TypeError. Only make it + # aware when the project is actually using aware datetimes. + expires_at = (timezone.make_aware(end_of_day) + if settings.USE_TZ else end_of_day) + if expires_at <= timezone.now(): + # A past --until must never be accepted: update_or_create() + # below would otherwise overwrite a *live* exemption with an + # already-expired one -- a functional revoke -- while the + # command still reports a grant and emits + # mfa_exemption_changed(revoked=False, expires_at=). + # The audit trail would then record the inverse of what + # actually happened. + raise CommandError( + f"--until {options['until']} is in the past.") + + MfaExemption.objects.update_or_create( + user=user, + defaults={"reason": reason, "expires_at": expires_at}, + ) + events.mfa_exemption_changed.send_robust( + sender=MfaExemption, user=user, reason=reason, + expires_at=expires_at, revoked=False, request=None) + + until = f" until {options['until']}" if expires_at else " permanently" + self.stdout.write(self.style.WARNING( + f"{user} is now exempt from MFA_REQUIRED{until}: {reason}")) diff --git a/django_mfa/management/commands/mfa_import_django_mfa2.py b/django_mfa/management/commands/mfa_import_django_mfa2.py new file mode 100644 index 0000000..d2a9ac5 --- /dev/null +++ b/django_mfa/management/commands/mfa_import_django_mfa2.py @@ -0,0 +1,336 @@ +"""Import factors from django-mfa2 (``mfa.models.User_Keys``). + +django-mfa2 keys rows by *username string*, not a foreign key, so a row +whose username no longer resolves to a user is reported rather than +silently dropped. + +That username string is NOT reliably ``get_user_model().USERNAME_FIELD``, +which is why the matching below tries it and then falls back to a literal +``username`` attribute rather than deriving purely from USERNAME_FIELD the +way ``mfa_import_django_otp`` and ``_support.resolve_user`` do. Checked +against the installed django-mfa2: every TOTP-writing call site +(``mfa/totp.py:84``) and the RECOVERY/U2F/FIDO2/Trusted-Device ones +(``recovery.py:54``, ``U2F.py:163``, ``FIDO2.py:118``, +``TrustedDevice.py:116``) write ``request.user.username`` -- the literal +attribute -- while ``Email.py``'s *enrolment* path (``start()``, line 42) +writes ``getattr(request.user, USERNAME_FIELD)`` instead. django-mfa2 is +not internally consistent about this, so on the default user model +(``USERNAME_FIELD == "username"``) the two always agree and nothing here +matters; on a swapped ``AUTH_USER_MODEL`` they can name different users, +and TOTP -- the row type this command actually cares most about -- +consistently uses the literal attribute. Trying USERNAME_FIELD first +preserves the one write path that does use it, falling back to plain +``username`` covers every other path including all of TOTP. + +Only TOTP and email are migrated -- see the task this command was written +for. ``User_Keys.key_type`` has four other real values, all handled +explicitly rather than falling through a generic "unrecognised" branch: + +* ``FIDO2`` / ``U2F`` -- genuinely no counterpart here. Both would need the + source package's own WebAuthn credential/user-handle state to keep + working, which this command has no way to carry over. +* ``Trusted Device`` -- a browser-remembered-device cookie feature with no + django_mfa equivalent at all. +* ``RECOVERY`` -- django_mfa *does* have a counterpart + (``Authenticator.Type.RECOVERY_CODES``), but converting it is out of this + command's scope (TOTP + email only); reported distinctly from the three + above so the message doesn't claim "no counterpart" for something that in + fact has one. + +Parameter check for TOTP *generation* (see the ``mfa_import_django_otp`` +sibling command for the shape of this problem, where django-otp's +``TOTPDevice.drift`` was missed on the first pass and produced silent +lockouts): django-mfa2's own TOTP flow (``mfa/totp.py``, the only code +that ever writes a ``TOTP`` ``User_Keys`` row) always calls +``pyotp.TOTP(secret_key)`` with no ``digits``/``digest``/``interval`` +override, and ``properties`` never holds anything for a TOTP row besides +``secret_key``. pyotp's own defaults (6 digits, SHA1, a 30-second interval) +already match django_mfa's fixed parameters, and pyotp's +``TOTP.timecode()`` is `int(unix_time / interval)` -- no ``t0`` offset and +no persisted ``drift`` exist in pyotp's model at all, let alone in what +``User_Keys`` stores. There is therefore nothing to validate on the +*generation* side, for any row: every enabled TOTP row is representable, or +its secret is simply missing. The secret itself needs no format conversion +either -- ``pyotp.random_base32()`` (``mfa/totp.py``'s ``getToken``) +produces the same base32 text ``django_mfa.otp.OTP.byte_secret`` expects, +unlike django-otp's hex-stored key. + +The *acceptance* side is a different, unconditional mismatch that no +per-row check can catch or skip around: ``mfa/totp.py``'s own verification +(line 24, ``totp.verify(token, valid_window=30)``) accepts a code up to 30 +pyotp ``valid_window`` ticks either side of the current one -- pyotp counts +that argument in whole *steps* (30s each), so mfa2 has been accepting codes +up to +/-15 minutes out of step. django_mfa's ``TOTP_VALID_WINDOW = 1`` is ++/-30 seconds, a 30x narrower acceptance window. A user whose device clock +has drifted several minutes (mfa2 never *stores* that drift -- it just +tolerates it fresh on every check) will have logged in successfully under +mfa2 for as long as they liked and will be silently locked out under +django_mfa from the very first login after import, with no signal to the +operator that anything is wrong: the secret imported byte-perfect and the +summary says "imported". This is announced once, unconditionally, in the +command's own summary output rather than attempted per-row, precisely +because whether a given user's clock is skewed enough to matter cannot be +determined from anything ``User_Keys`` stores. +""" + +from django.apps import apps +from django.contrib.auth import get_user_model +from django.core.exceptions import FieldError +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from django_mfa.conf import settings as mfa_settings +from django_mfa.crypto import encrypt +from django_mfa.models import Authenticator +from django_mfa.registry import registry +from django_mfa.utils import user_email + +#: key_type -> our factor type. The only two key_types this command migrates. +MIGRATABLE = {"TOTP": Authenticator.Type.TOTP, "Email": Authenticator.Type.EMAIL} + +#: No django_mfa counterpart at all. +NO_COUNTERPART = ("FIDO2", "U2F", "Trusted Device") + +#: Has a django_mfa counterpart (recovery_codes) but converting it is out of +#: this command's scope -- reported separately from NO_COUNTERPART so the +#: message is accurate. +OUT_OF_SCOPE = ("RECOVERY",) + + +def _get_model(): + try: + return apps.get_model("mfa", "User_Keys") + except LookupError: + return None + + +class Command(BaseCommand): + help = "Import TOTP and email factors from django-mfa2." + + def add_arguments(self, parser): + parser.add_argument("--dry-run", action="store_true", + help="report what would happen; write nothing") + parser.add_argument("--users", nargs="+", metavar="USER", + help="limit to these usernames or pks") + parser.add_argument("--overwrite", action="store_true", + help="replace an existing factor of the same type") + + def handle(self, *args, **options): + model = _get_model() + if model is None: + raise CommandError( + "django-mfa2 is not installed, or 'mfa' is not in " + "INSTALLED_APPS. Run this on the deployment that still has " + "it." + ) + + self.dry_run = options["dry_run"] + self.overwrite = options["overwrite"] + # Every line this command prints while it's not actually writing + # must say so -- not just the final summary. Without this, a real + # cutover's per-row "imported ... for ..." lines are indistinguishable + # from a --dry-run rehearsal's once the summary has scrolled off, and + # an operator reads a run that wrote nothing as though it had. + self.prefix = "[dry run] " if self.dry_run else "" + self.counts = {"imported": 0, "skipped_existing": 0, + "skipped_unsupported": 0, "unknown_user": 0} + # What this run has already decided for a given (user, factor type) + # -- imported OR skipped-as-existing both count. django-mfa2 puts no + # uniqueness constraint on User_Keys, so one user can hold several + # enabled rows of the same key_type; without this, a second row + # would be evaluated against whatever _write() already committed + # and, under --overwrite, silently replace it -- non-deterministically, + # since the queryset below is what decides which row is seen first. + self._seen = set() + self._registered_types = {a.type for a in registry.all()} + + user_model = get_user_model() + field = user_model.USERNAME_FIELD + usernames = None + if options["users"]: + from django_mfa.management.commands._support import resolve_user + # Both possible values a User_Keys row could have been written + # with (see the module docstring) -- USERNAME_FIELD for a row + # from Email.py's enrolment path, literal `username` for every + # other write site including all of TOTP. On the default user + # model these coincide, so this is a no-op there. + usernames = set() + for identifier in options["users"]: + resolved = resolve_user(identifier) + usernames.add(getattr(resolved, field)) + literal = getattr(resolved, "username", None) + if literal: + usernames.add(literal) + + query = model.objects.filter(enabled=True).order_by("pk") + if usernames is not None: + query = query.filter(username__in=usernames) + + with transaction.atomic(): + for row in query: + self._import_row(row, user_model, field) + if self.dry_run: + transaction.set_rollback(True) + + self._report() + + def _find_user(self, user_model, field, source_username): + """Resolve one User_Keys.username value to a user on this install. + + Tries USERNAME_FIELD first (correct for a row written by + Email.py's enrolment path), then falls back to a literal + `username` attribute (what every other django-mfa2 write site + uses, including all of TOTP) -- see the module docstring for why + neither alone is reliable on a swapped AUTH_USER_MODEL. On the + default user model `field == "username"`, so the fallback is + never reached and this is exactly the single query it always was. + """ + user = user_model.objects.filter(**{field: source_username}).first() + if user is not None or field == "username": + return user + try: + return user_model.objects.filter(username=source_username).first() + except FieldError: + # This user model has no field literally called "username" at + # all (a fully custom AUTH_USER_MODEL) -- nothing more to try. + return None + + def _import_row(self, row, user_model, field): + if row.key_type in NO_COUNTERPART: + self.counts["skipped_unsupported"] += 1 + self.stdout.write(self.style.WARNING( + f"{self.prefix} skipped {row.username}: {row.key_type} has " + f"no counterpart in django_mfa; that user must re-enrol.")) + return + if row.key_type in OUT_OF_SCOPE: + self.counts["skipped_unsupported"] += 1 + self.stdout.write(self.style.WARNING( + f"{self.prefix} skipped {row.username}: {row.key_type} has " + f"a django_mfa counterpart (recovery_codes) but this " + f"command migrates TOTP and email only -- not imported. " + f"That user should generate fresh recovery codes from " + f"django_mfa's security settings page once they hold a " + f"primary factor again.")) + return + factor_type = MIGRATABLE.get(row.key_type) + if factor_type is None: + self.counts["skipped_unsupported"] += 1 + self.stdout.write(self.style.WARNING( + f"{self.prefix} skipped {row.username}: unrecognised " + f"key_type {row.key_type!r}")) + return + + user = self._find_user(user_model, field, row.username) + if user is None: + self.counts["unknown_user"] += 1 + self.stdout.write(self.style.WARNING( + f"{self.prefix} no user matches {row.username!r}; row " + f"left alone")) + return + + if factor_type == Authenticator.Type.TOTP: + properties = row.properties or {} + secret = properties.get("secret_key") + if not secret: + self.counts["skipped_unsupported"] += 1 + self.stdout.write(self.style.WARNING( + f"{self.prefix} skipped {user}: TOTP row has no " + f"secret_key")) + return + data = {"secret": encrypt(secret)} + else: + # mfa2 stores no per-key address for an Email row (Email.py's + # `start` view never sets `properties` at all): sendEmail() + # always targets the account's own address, so that is the only + # source, unlike django-otp's EmailDevice which carries its own + # optional address field. user_email() rather than a hardcoded + # `user.email` -- AUTH_USER_MODEL is swappable and need not call + # its address field "email" at all (see utils.user_email's own + # docstring); EmailAdapter itself never reads the raw attribute. + address = user_email(user) + if not address: + self.counts["skipped_unsupported"] += 1 + self.stdout.write(self.style.WARNING( + f"{self.prefix} skipped {user}: no email address is " + f"on file for this user; mfa2 always sends to the " + f"account's own address, so nothing can be migrated.")) + return + data = {"address": address} + + self._write(user, factor_type, data) + + def _write(self, user, factor_type, data): + """Create the row unless one exists, honouring --overwrite. + + Refuses to write a type nothing on this install can ever verify + with, and refuses to let a second source row for the same (user, + factor_type) overwrite what an earlier row in this same run already + decided. + """ + if factor_type not in self._registered_types: + # MFA_FACTORS excludes this type (email is off by default) -- + # nothing will ever offer or verify this row, so it would sit in + # the database looking like protection while + # registry.has_primary_factor(user) stays False and the user is + # bounced to enrolment. Naming MFA_FACTORS is the point: it's + # the one setting an operator can change and re-run against. + self.counts["skipped_unsupported"] += 1 + self.stdout.write(self.style.WARNING( + # str(factor_type), not !r: Authenticator.Type is a + # TextChoices (str subclass) whose repr is the noisy + # "Authenticator.Type.EMAIL" rather than plain "email". + f"{self.prefix} skipped {user}: no adapter is registered " + f"for '{factor_type}' on this install (MFA_FACTORS=" + f"{list(mfa_settings.MFA_FACTORS)!r}) -- the row would be " + f"inert. Add '{factor_type}' to MFA_FACTORS and re-run.")) + return + + key = (user.pk, factor_type) + if key in self._seen: + self.counts["skipped_unsupported"] += 1 + self.stdout.write(self.style.WARNING( + f"{self.prefix} skipped {user}: a second enabled " + f"{factor_type} source row was found for this user in the " + f"same run. django_mfa holds one {factor_type} factor per " + f"user, so only the first (ordered by pk) was considered " + f"-- this one was left out rather than silently " + f"overwriting it.")) + return + self._seen.add(key) + + existing = Authenticator.objects.filter(user=user, type=factor_type) + if existing.exists(): + if not self.overwrite: + self.counts["skipped_existing"] += 1 + self.stdout.write( + f"{self.prefix} skipped {user}: already has a " + f"{factor_type} factor") + return + existing.delete() + # Deliberately no events.factor_added: with MFA_NOTIFY_ON_CHANGE on, + # a bulk import would mail every migrated user "a factor was added" + # on cutover day. This is a migration of a factor they already have, + # not the addition of a new one. + Authenticator.objects.create(user=user, type=factor_type, data=data) + self.counts["imported"] += 1 + self.stdout.write(f"{self.prefix} imported {factor_type} for {user}") + + def _report(self): + # Unconditional, not per-row: see the module docstring's "acceptance + # side" section. mfa2's own valid_window=30 verification call + # (mfa/totp.py:24) accepts codes up to +/-15 minutes out of step; + # django_mfa's fixed TOTP_VALID_WINDOW=1 accepts +/-30s. Nothing in + # User_Keys records how skewed any given user's clock actually is, + # so this can't be detected or skipped per row -- it's printed once + # so an operator knows to expect some of these imported users to + # need a clock fix or a re-enrolment, not a bug report. + self.stdout.write(self.style.WARNING( + "note: django-mfa2 accepted codes up to +/-15 minutes out of " + "step (valid_window=30); django_mfa accepts +/-30s. Users with " + "badly-skewed device clocks will need to correct the clock or " + "re-enrol.")) + self.stdout.write(self.style.SUCCESS( + f"{self.prefix}imported {self.counts['imported']}, " + f"skipped {self.counts['skipped_existing']} already present, " + f"skipped {self.counts['skipped_unsupported']} unrepresentable, " + f"{self.counts['unknown_user']} rows with no matching user.")) diff --git a/django_mfa/management/commands/mfa_import_django_otp.py b/django_mfa/management/commands/mfa_import_django_otp.py new file mode 100644 index 0000000..a564d18 --- /dev/null +++ b/django_mfa/management/commands/mfa_import_django_otp.py @@ -0,0 +1,265 @@ +"""Import factors from django-otp (and therefore django-two-factor-auth). + +django-two-factor-auth needs no importer of its own: it stores its TOTP and +static tokens as django-otp rows (``django_otp.plugins.otp_totp.TOTPDevice``, +``django_otp.plugins.otp_static.StaticDevice``/``StaticToken``), so this +command covers both. Its own ``PhoneDevice`` rows (SMS/call) have no +counterpart in django_mfa and are not handled here -- that model lives in +django-two-factor-auth's own ``two_factor`` app, which is not part of the +django-otp dependency this command resolves through ``apps.get_model()``, so +those rows are simply left untouched rather than guessed at. +""" + +import base64 + +from django.apps import apps +from django.contrib.auth.hashers import make_password +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from django_mfa.conf import settings as mfa_settings +from django_mfa.crypto import encrypt +from django_mfa.models import Authenticator +from django_mfa.registry import registry +from django_mfa.utils import user_email + +#: django_mfa.totp is fixed at these. A device that differs cannot be +#: represented, and importing it would produce a factor whose codes never +#: match -- a lockout the user discovers, not the operator. +#: +#: `drift` belongs here even though it looks like verification-only state: +#: django_otp.oath.TOTP.t() adds it directly into the counter +#: (`((time - t0) // step) + drift`), so a drift of k is mathematically the +#: same shift as a t0 of `-k * step` -- and unlike t0, it is not something an +#: operator would think to check, because it is not part of enrolment. It +#: *accumulates*: TOTPDevice.verify_token() saves a new drift on every +#: successful verification whenever OTP_TOTP_SYNC is on, which is +#: django-otp's default. A user whose phone clock runs fast gains drift on +#: every login, silently compensated by django-otp forever -- import that +#: device without checking drift and it "imports clean" while the user's +#: codes land outside django_mfa's fixed +/-1 step window from the next +#: login onward. +#: +#: `tolerance` is the sibling of django_mfa's own TOTP_VALID_WINDOW = 1 -- +#: TOTPDevice.verify_token() calls totp.verify(token, self.tolerance, ...), +#: so it is django-otp's acceptance window in units of steps either side of +#: current, the exact quantity the mfa_import_django_mfa2 importer devotes a +#: standing warning to because it *cannot* be checked per row there. +#: TOTPDevice.tolerance defaults to 1 (verified against the installed +#: django-otp's TOTPDevice model), matching django_mfa exactly -- but it is +#: a stored, per-device field an operator could have widened, and unlike +#: mfa2's importer this one CAN check it per row, so it does: a +#: TOTPDevice(tolerance=5) would otherwise "import clean" and then narrow +#: silently, exactly the failure mode `drift` above exists to catch. +SUPPORTED_TOTP = {"digits": 6, "step": 30, "t0": 0, "drift": 0, "tolerance": 1} + + +def _get_model(app_label, model_name): + try: + return apps.get_model(app_label, model_name) + except LookupError: + return None + + +class Command(BaseCommand): + help = ("Import TOTP, static-token (recovery code) and email factors " + "from django-otp.") + + def add_arguments(self, parser): + parser.add_argument("--dry-run", action="store_true", + help="report what would happen; write nothing") + parser.add_argument("--users", nargs="+", metavar="USER", + help="limit to these usernames or pks") + parser.add_argument("--overwrite", action="store_true", + help="replace an existing factor of the same type") + + def handle(self, *args, **options): + totp_model = _get_model("otp_totp", "TOTPDevice") + static_model = _get_model("otp_static", "StaticDevice") + email_model = _get_model("otp_email", "EmailDevice") + if totp_model is None and static_model is None and email_model is None: + raise CommandError( + "No django-otp models found. Add 'django_otp' and its plugin " + "apps (otp_totp, otp_static, otp_email) to INSTALLED_APPS, or " + "run this on the deployment that still has them." + ) + + self.dry_run = options["dry_run"] + self.overwrite = options["overwrite"] + # Every line this command prints while it's not actually writing + # must say so -- not just the final summary. Without this, a real + # cutover's per-row "imported ... for ..." lines are indistinguishable + # from a --dry-run rehearsal's once the summary has scrolled off, and + # an operator reads a run that wrote nothing as though it had. + self.prefix = "[dry run] " if self.dry_run else "" + self.counts = {"imported": 0, "skipped_existing": 0, + "skipped_unsupported": 0} + # What this run has already decided for a given (user, factor type) + # -- imported OR skipped-as-existing both count. Without this, a + # second confirmed source device of the same type for the same user + # (django-otp puts no uniqueness constraint on Device.user, so this + # is a legal, real-world state after a re-enrolment that never + # cleaned up its old device) would be evaluated against whatever + # _write() had already committed and, under --overwrite, silently + # replace it -- non-deterministically, since neither queryset below + # was ordered, so which device "won" depended on what the database + # felt like returning first. + self._seen = set() + self._registered_types = {a.type for a in registry.all()} + users = self._users(options["users"]) + + with transaction.atomic(): + if totp_model is not None: + self._import_totp(totp_model, users) + if static_model is not None: + self._import_static(static_model, users) + if email_model is not None: + self._import_email(email_model, users) + if self.dry_run: + transaction.set_rollback(True) + + self._report() + + def _users(self, identifiers): + if not identifiers: + return None + from django_mfa.management.commands._support import resolve_user + return [resolve_user(i) for i in identifiers] + + def _filter(self, queryset, users): + return queryset if users is None else queryset.filter(user__in=users) + + def _write(self, user, factor_type, data, name=""): + """Create the row unless one exists, honouring --overwrite. + + Refuses to write a type nothing on this install can ever verify + with, and refuses to let a second source device for the same + (user, factor_type) overwrite what an earlier device in this same + run already decided -- see the two comments below for why each + matters. + """ + if factor_type not in self._registered_types: + # MFA_FACTORS excludes this type (email is off by default; a + # host project can drop recovery_codes or totp too), so nothing + # will ever offer or verify this row -- it would sit in the + # database looking like protection while + # registry.has_primary_factor(user) stays False and the user is + # bounced to enrolment. Naming MFA_FACTORS is the point: this is + # the one setting an operator can change and re-run against. + self.counts["skipped_unsupported"] += 1 + self.stdout.write(self.style.WARNING( + # str(factor_type), not !r: Authenticator.Type is a + # TextChoices (str subclass) whose repr is the noisy + # "Authenticator.Type.EMAIL" rather than plain "email" -- + # str() gives the value an operator can paste straight into + # MFA_FACTORS. + f"{self.prefix} skipped {user}: no adapter is registered " + f"for '{factor_type}' on this install (MFA_FACTORS=" + f"{list(mfa_settings.MFA_FACTORS)!r}) -- the row would be " + f"inert. Add '{factor_type}' to MFA_FACTORS and re-run.")) + return + + key = (user.pk, factor_type) + if key in self._seen: + self.counts["skipped_unsupported"] += 1 + self.stdout.write(self.style.WARNING( + f"{self.prefix} skipped {user}: a second confirmed " + f"{factor_type} source device was found for this user in " + f"the same run. django_mfa holds one {factor_type} factor " + f"per user, so only the first (ordered by pk) was " + f"considered -- this one was left out rather than silently " + f"overwriting it. Remove the stale source device and " + f"re-run if the discarded one should have won instead.")) + return + self._seen.add(key) + + existing = Authenticator.objects.filter(user=user, type=factor_type) + if existing.exists(): + if not self.overwrite: + self.counts["skipped_existing"] += 1 + self.stdout.write( + f"{self.prefix} skipped {user}: already has a " + f"{factor_type} factor") + return + existing.delete() + # Deliberately no events.factor_added: with MFA_NOTIFY_ON_CHANGE on, + # a bulk import would mail every migrated user "a factor was added" + # on cutover day. This is a migration of a factor they already have, + # not the addition of a new one. + Authenticator.objects.create( + user=user, type=factor_type, data=data, name=name) + self.counts["imported"] += 1 + self.stdout.write(f"{self.prefix} imported {factor_type} for {user}") + + def _import_totp(self, model, users): + query = model.objects.filter(confirmed=True).order_by("pk") + for device in self._filter(query, users): + # No default on getattr(): a field SUPPORTED_TOTP names must + # exist on the installed django-otp's TOTPDevice, or this check + # -- the one check whose entire purpose is preventing a silent + # bad import -- would itself silently pass a device it cannot + # actually evaluate. Let a missing field raise loudly instead. + mismatched = [ + field for field, expected in SUPPORTED_TOTP.items() + if getattr(device, field) != expected + ] + if mismatched: + self.counts["skipped_unsupported"] += 1 + self.stdout.write(self.style.WARNING( + f"{self.prefix} skipped {device.user}: TOTP device " + f"uses non-default {', '.join(mismatched)} — django_mfa " + f"is fixed at 6 digits, a 30-second step, T0=0, a +/-1 " + f"step acceptance window, and no stored clock-drift " + f"compensation, so importing it would produce codes " + f"that never match or accept a narrower window than " + f"the user is used to. Re-enrol this user with a fresh " + f"authenticator app entry instead.")) + continue + # TOTPDevice.key is hex; django_mfa.totp secrets are base32. + secret = base64.b32encode(bytes.fromhex(device.key)).decode("utf-8") + self._write(device.user, Authenticator.Type.TOTP, + {"secret": encrypt(secret)}) + + def _import_static(self, model, users): + # confirmed=True, matching _import_totp/_import_email: StaticDevice + # inherits `confirmed` from django_otp's abstract Device model same as + # the other two, and an unconfirmed device is one the user never + # finished setting up. + query = model.objects.filter(confirmed=True).order_by("pk") + for device in self._filter(query, users): + tokens = list(device.token_set.values_list("token", flat=True)) + if not tokens: + continue + # Hashed on the way in, exactly as RecoveryCodesAdapter.generate + # writes them -- so the codes the user has already printed keep + # working, and nothing lands in the database in plaintext. NOT + # the migrated_plaintext path adapters/recovery_codes.py still + # carries for legacy rows -- these codes were never plaintext + # here to begin with. + self._write(device.user, Authenticator.Type.RECOVERY_CODES, + {"codes": [make_password(t) for t in tokens], + "used": []}) + + def _import_email(self, model, users): + query = model.objects.filter(confirmed=True).order_by("pk") + for device in self._filter(query, users): + # user_email(), not a hardcoded device.user.email -- AUTH_USER_ + # MODEL is swappable and need not call its address field + # "email" at all (see utils.user_email's own docstring); + # EmailAdapter itself never reads the raw attribute either. + address = getattr(device, "email", None) or user_email(device.user) + if not address: + self.counts["skipped_unsupported"] += 1 + self.stdout.write(self.style.WARNING( + f"{self.prefix} skipped {device.user}: email device " + f"has no address of its own and the user has none on " + f"file either.")) + continue + self._write(device.user, Authenticator.Type.EMAIL, + {"address": address}) + + def _report(self): + self.stdout.write(self.style.SUCCESS( + f"{self.prefix}imported {self.counts['imported']}, " + f"skipped {self.counts['skipped_existing']} already present, " + f"skipped {self.counts['skipped_unsupported']} unrepresentable.")) diff --git a/django_mfa/management/commands/mfa_report.py b/django_mfa/management/commands/mfa_report.py new file mode 100644 index 0000000..f9dd40b --- /dev/null +++ b/django_mfa/management/commands/mfa_report.py @@ -0,0 +1,61 @@ +"""Rollout reporting: who is covered, and who still owes you a factor.""" + +import csv + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand +from django.db.models import Count + +from django_mfa import policy +from django_mfa.models import Authenticator +from django_mfa.registry import registry + + +class Command(BaseCommand): + help = "Report MFA coverage, and users MFA_REQUIRED applies to who have none." + + def add_arguments(self, parser): + parser.add_argument("--required-only", action="store_true", + help="skip the per-type counts") + parser.add_argument("--format", choices=["text", "csv"], default="text") + + def handle(self, *args, **options): + model = get_user_model() + field = model.USERNAME_FIELD + + # --format csv means machine-readable output: a consumer piping this + # into a file or a parser must see the header row first and nothing + # else. Gate the prose counts block on the format too, not just + # --required-only, or `mfa_report --format csv` (no --required-only) + # prints two lines of prose ahead of the CSV header. + if not options["required_only"] and options["format"] == "text": + counts = (Authenticator.objects.values("type") + .annotate(n=Count("id")).order_by("type")) + self.stdout.write("Enrolled factors by type:") + for row in counts: + self.stdout.write(f" {row['type']}: {row['n']}") + if not counts: + self.stdout.write(" (none)") + + # mfa_required_for() resolves a host-supplied predicate, so this + # cannot be pushed into the query -- MFA_REQUIRED may be an arbitrary + # callable. has_primary_factor() first: it is the cheaper of the two + # and excludes most users before the predicate (and its exemption + # lookup) runs at all. + outstanding = [ + user for user in model.objects.all().iterator() + if not registry.has_primary_factor(user) + and policy.mfa_required_for(user) + ] + + if options["format"] == "csv": + writer = csv.writer(self.stdout) + writer.writerow(["pk", field]) + for user in outstanding: + writer.writerow([user.pk, getattr(user, field)]) + return + + self.stdout.write( + f"Required but unenrolled: {len(outstanding)}") + for user in outstanding: + self.stdout.write(f" - {getattr(user, field)} (pk={user.pk})") diff --git a/django_mfa/management/commands/mfa_reset.py b/django_mfa/management/commands/mfa_reset.py new file mode 100644 index 0000000..8418798 --- /dev/null +++ b/django_mfa/management/commands/mfa_reset.py @@ -0,0 +1,57 @@ +"""Remove every factor from a user, so a locked-out user can re-enrol.""" + +from django.core.management.base import BaseCommand, CommandError + +from django_mfa import events +from django_mfa.management.commands._support import ( + describe_authenticator, + resolve_user, +) +from django_mfa.models import Authenticator +from django_mfa.registry import registry + + +class Command(BaseCommand): + help = "Remove all of a user's MFA factors." + + def add_arguments(self, parser): + parser.add_argument("user", help="username or pk") + parser.add_argument("--yes", action="store_true", + help="skip the confirmation prompt") + + def handle(self, *args, **options): + user = resolve_user(options["user"]) + authenticators = list(Authenticator.objects.filter(user=user)) + if not authenticators: + self.stdout.write(f"{user} has no factors enrolled; nothing to do.") + return + + for auth in authenticators: + self.stdout.write(f" - {describe_authenticator(auth)}") + if not options["yes"]: + answer = input(f"Remove {len(authenticators)} factor(s) from " + f"{user}? [y/N] ") + if answer.strip().lower() not in ("y", "yes"): + raise CommandError("Aborted.") + + for auth in authenticators: + factor_type, name = auth.type, auth.name + auth.delete() + try: + sender = type(registry.get(factor_type)) + except KeyError: + # A row whose type is no longer registered -- MFA_FACTORS + # narrowed, or registry.unregister(). Removing one is still a + # supported action; there is simply no adapter class to name. + # Same fallback as views/manage.py:manage_factors. + sender = None + # request=None: this event originates outside any request. Every + # signal in events.py documents that as possible precisely so a + # support-desk reset still reaches audit receivers, rather than + # being the one factor removal that leaves no trace. + events.factor_removed.send_robust( + sender=sender, user=user, factor_type=factor_type, + name=name, request=None) + + self.stdout.write(self.style.SUCCESS( + f"Removed {len(authenticators)} factor(s) from {user}.")) diff --git a/django_mfa/management/commands/mfa_status.py b/django_mfa/management/commands/mfa_status.py new file mode 100644 index 0000000..ad67d9e --- /dev/null +++ b/django_mfa/management/commands/mfa_status.py @@ -0,0 +1,55 @@ +"""Print one user's enrolled factors. Read-only.""" + +from django.core.management.base import BaseCommand + +from django_mfa import policy +from django_mfa.adapters.recovery_codes import RecoveryCodesAdapter +from django_mfa.management.commands._support import ( + describe_authenticator, + format_date, + resolve_user, +) +from django_mfa.models import Authenticator, MfaExemption +from django_mfa.registry import registry + + +class Command(BaseCommand): + help = "Show a user's enrolled MFA factors." + + def add_arguments(self, parser): + parser.add_argument("user", help="username or pk") + + def handle(self, *args, **options): + user = resolve_user(options["user"]) + self.stdout.write(f"User: {user} (pk={user.pk})") + + authenticators = Authenticator.objects.filter(user=user).order_by( + "type", "created_at") + if authenticators: + for auth in authenticators: + self.stdout.write(f" - {describe_authenticator(auth)}") + else: + self.stdout.write(" (no factors enrolled)") + + remaining = RecoveryCodesAdapter().remaining(user) + self.stdout.write(f"Recovery codes remaining: {remaining}") + self.stdout.write( + f"Protected: {'yes' if registry.has_primary_factor(user) else 'no'}") + + # policy.mfa_required_for() is the single source of truth for "is + # this user required to hold a factor" -- reimplementing its body + # here (resolve() + predicate(user) + is_authenticated + + # has_active_exemption()) would make this command a second, + # independent definition of a security predicate, exactly the + # pattern models.PRIMARY_FACTOR_TYPES showed is costly (it silently + # diverged from the registry). A second query for the exemption row + # itself below is fine -- this is a support command, not a hot path. + required = policy.mfa_required_for(user) + self.stdout.write(f"Required: {'yes' if required else 'no'}") + + exemption = MfaExemption.objects.active_for(user) + + if exemption is not None: + until = (f" until {format_date(exemption.expires_at)}" + if exemption.expires_at else " (permanent)") + self.stdout.write(f"Exempt: {exemption.reason}{until}") diff --git a/django_mfa/middleware.py b/django_mfa/middleware.py index b45fd64..bc5798f 100644 --- a/django_mfa/middleware.py +++ b/django_mfa/middleware.py @@ -89,8 +89,18 @@ def process_request(self, request): # .exists() query per registered adapter (via enabled_for()) on every # single authenticated request just to build a list this branch # immediately discards. - if (policy.mfa_required_for(request.user) - and not registry.has_primary_factor(request.user)): + # + # policy.resolve() gates the other two so MFA_REQUIRED = False (the + # default) still costs nothing: has_primary_factor() is a query, so + # it cannot be the unconditional first operand or every authenticated + # request pays it even with MFA_REQUIRED off. Once past that gate, + # has_primary_factor() runs before mfa_required_for() so the + # exemption lookup inside mfa_required_for() runs only for a user who + # is actually about to be walled, not for every enrolled user this + # policy applies to. + if (policy.resolve() + and not registry.has_primary_factor(request.user) + and policy.mfa_required_for(request.user)): if self._is_exempt(request.path, self.enrollment_exempt_paths()): return None return redirect_to_login( diff --git a/django_mfa/migrations/0009_mfa_exemption.py b/django_mfa/migrations/0009_mfa_exemption.py new file mode 100644 index 0000000..91ecf2c --- /dev/null +++ b/django_mfa/migrations/0009_mfa_exemption.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.17 on 2026-08-13 19:10 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_mfa', '0008_email_factor'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='MfaExemption', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('reason', models.CharField(max_length=255)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('expires_at', models.DateTimeField(blank=True, null=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='mfa_exemption', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/django_mfa/models.py b/django_mfa/models.py index e6980b5..6ef0dc9 100644 --- a/django_mfa/models.py +++ b/django_mfa/models.py @@ -54,3 +54,55 @@ def record_usage(self): self.last_used_at = timezone.now() self.save(update_fields=["last_used_at"]) + +class MfaExemptionManager(models.Manager): + def active_for(self, user): + """This user's exemption if it is currently in force, else None.""" + return self.filter(user=user).filter( + models.Q(expires_at__isnull=True) + | models.Q(expires_at__gt=timezone.now()) + ).first() + + +class MfaExemption(models.Model): + """A user MFA_REQUIRED does not apply to, despite the predicate. + + Written by the `mfa_disable` management command, never through the web + UI: exempting somebody from a security requirement is an operator action + that needs a reason attached and an audit trail (see the + mfa_exemption_changed signal), not something a user can do to themselves. + + Suppresses MFA_REQUIRED ONLY. It does not open @mfa_required views -- + MFA_REQUIRED picks users, the decorator picks views, and the two are not + interchangeable (see decorators.py's module docstring). + """ + + user = models.OneToOneField(settings.AUTH_USER_MODEL, + related_name="mfa_exemption", + on_delete=models.CASCADE) + reason = models.CharField(max_length=255) + created_at = models.DateTimeField(default=timezone.now) + #: None means permanent. A dated exemption is strongly preferred; the + #: mfa_disable command surfaces --until for exactly that reason. + expires_at = models.DateTimeField(null=True, blank=True) + + objects = MfaExemptionManager() + + def __str__(self): + # localtime(), not a bare strftime on the stored value: expires_at is + # UTC under USE_TZ=True, and formatting it directly shows an operator + # in a negative-offset timezone a date up to a day earlier than the + # one the exemption actually expires on. But localtime() itself + # raises on a naive datetime, and expires_at IS naive under + # USE_TZ=False (the Django 4.2-era default, still unset by conf.py) + # -- so only convert when there is a timezone to convert from; a + # naive value is already in the meaning the operator entered it in. + expires = self.expires_at + if expires is not None and timezone.is_aware(expires): + expires = timezone.localtime(expires) + suffix = f" until {expires:%Y-%m-%d}" if expires else "" + return f"MFA exemption for {self.user}{suffix}" + + def is_active(self): + return self.expires_at is None or self.expires_at > timezone.now() + diff --git a/django_mfa/policy.py b/django_mfa/policy.py index f9bc0c6..1910031 100644 --- a/django_mfa/policy.py +++ b/django_mfa/policy.py @@ -78,9 +78,26 @@ def resolve(): return value +def has_active_exemption(user): + """Is this user currently exempted from MFA_REQUIRED by an operator?""" + from django_mfa.models import MfaExemption + + return MfaExemption.objects.active_for(user) is not None + + def mfa_required_for(user): """Is this user required to hold a primary second factor?""" if not getattr(user, "is_authenticated", False): return False predicate = resolve() - return bool(predicate and predicate(user)) + if not (predicate and predicate(user)): + return False + # Checked last, on purpose: an install with the default + # MFA_REQUIRED = False never reaches the database for this, and one with + # a predicate pays one query -- MfaExemption.objects.active_for()'s + # SELECT ... LIMIT 1, not a plain .exists() -- only for the users it + # matches. It fetches the row rather than a bare bool because a later + # consumer (the mfa_status command) needs the reason/expiry to display, + # not just yes/no, and there is no reason for the two to run separate + # queries for the same answer. + return not has_active_exemption(user) diff --git a/django_mfa/session.py b/django_mfa/session.py index c44bba3..ccbbc53 100644 --- a/django_mfa/session.py +++ b/django_mfa/session.py @@ -20,5 +20,34 @@ def is_pending(request): return SESSION_KEY in request.session and not is_verified(request) +def verified_at(request): + """Unix timestamp of this session's last successful challenge, or None.""" + return request.session.get(SESSION_KEY, {}).get("at") + + +def is_fresh(request, max_age): + """Did this session verify a factor within the last ``max_age`` seconds? + + A verified session with no ``at`` counts as STALE. start_pending() writes + at=None, and a session verified by 4.1.0 or earlier -- the release before + anything read this field -- can carry one too. Stale is the safe + direction: it costs one re-verification and self-heals, where the reverse + would hand every pre-upgrade session a permanent bypass of the gate. + + Compares against ``int(time.time())``, not the raw float, because ``at`` + is always an integer (mark_verified() stores int(time.time())). Comparing + a float "now" against an int "at" would make the exactly-at-the-boundary + case fail almost every time: at is already rounded down by up to ~1s at + the moment it's stored, so a float "now" taken any time later -- even + microseconds -- reliably pushes ``now - at`` a hair past ``max_age``. + """ + if not is_verified(request): + return False + at = verified_at(request) + if at is None: + return False + return (int(time.time()) - at) <= max_age + + def reset(request): request.session.pop(SESSION_KEY, None) diff --git a/django_mfa/signals.py b/django_mfa/signals.py index b3aa2f8..9e4f4fd 100644 --- a/django_mfa/signals.py +++ b/django_mfa/signals.py @@ -10,6 +10,7 @@ from django_mfa.events import ( # noqa: F401 factor_added, factor_removed, + mfa_exemption_changed, mfa_verification_failed, mfa_verified, recovery_code_used, diff --git a/django_mfa/templates/django_mfa/picker.html b/django_mfa/templates/django_mfa/picker.html index ff2d82f..e5cfd27 100644 --- a/django_mfa/templates/django_mfa/picker.html +++ b/django_mfa/templates/django_mfa/picker.html @@ -8,7 +8,7 @@

{% trans "Verify it's you" %}

diff --git a/django_mfa/tests/test_commands.py b/django_mfa/tests/test_commands.py new file mode 100644 index 0000000..d93b979 --- /dev/null +++ b/django_mfa/tests/test_commands.py @@ -0,0 +1,309 @@ +from datetime import datetime, timedelta +from datetime import timezone as dt_timezone +from io import StringIO + +from django.contrib.auth.models import User +from django.core.management import CommandError, call_command +from django.test import TestCase, override_settings +from django.utils import timezone + +from django_mfa import events +from django_mfa.adapters.recovery_codes import RecoveryCodesAdapter +from django_mfa.crypto import encrypt +from django_mfa.models import Authenticator, MfaExemption +from django_mfa.registry import registry + +KNOWN_SECRET = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP" +FAKE_CREDENTIAL_ID = "FAKE_CREDENTIAL_ID_MARKER" +FAKE_PUBLIC_KEY = "FAKE_PUBLIC_KEY_MARKER" + + +class ResolveUserTests(TestCase): + def setUp(self): + self.user = User.objects.create_user("alice", password="pw") + + def test_resolves_by_username(self): + from django_mfa.management.commands._support import resolve_user + self.assertEqual(resolve_user("alice"), self.user) + + def test_resolves_by_pk(self): + from django_mfa.management.commands._support import resolve_user + self.assertEqual(resolve_user(str(self.user.pk)), self.user) + + def test_username_wins_over_pk(self): + from django_mfa.management.commands._support import resolve_user + numeric = User.objects.create_user(str(self.user.pk), password="pw") + self.assertEqual(resolve_user(str(self.user.pk)), numeric) + + def test_unknown_raises_command_error_naming_both_lookups(self): + from django_mfa.management.commands._support import resolve_user + with self.assertRaises(CommandError) as ctx: + resolve_user("nobody") + self.assertIn("username", str(ctx.exception)) + self.assertIn("pk", str(ctx.exception)) + + +class MfaStatusTests(TestCase): + def setUp(self): + self.user = User.objects.create_user("alice", password="pw") + + def _run(self, *args): + out = StringIO() + call_command("mfa_status", *args, stdout=out) + return out.getvalue() + + def test_reports_no_factors(self): + self.assertIn("no factors", self._run("alice").lower()) + + def test_lists_a_totp_factor(self): + Authenticator.objects.create( + user=self.user, type=Authenticator.Type.TOTP, + data={"secret": encrypt(KNOWN_SECRET)}) + output = self._run("alice") + self.assertIn("Authenticator app", output) + + def test_never_prints_the_secret(self): + # LOAD-BEARING. Authenticator.data holds the TOTP shared secret, the + # WebAuthn credential and the recovery-code hashes; a command that + # printed any of them would reopen the hole AuthenticatorAdmin was + # hardened to close. Covers all three factor types, not just TOTP -- + # describe_authenticator() must never start formatting per-type + # detail from `data` for ANY of them, including ones added later. + Authenticator.objects.create( + user=self.user, type=Authenticator.Type.TOTP, + data={"secret": encrypt(KNOWN_SECRET)}) + Authenticator.objects.create( + user=self.user, type=Authenticator.Type.WEBAUTHN, name="YubiKey", + data={"credential_id": FAKE_CREDENTIAL_ID, + "public_key": FAKE_PUBLIC_KEY, "sign_count": 7}) + RecoveryCodesAdapter().generate(self.user) + recovery_auth = Authenticator.objects.get( + user=self.user, type=Authenticator.Type.RECOVERY_CODES) + + output = self._run("alice") + + self.assertNotIn(KNOWN_SECRET, output) + self.assertNotIn(FAKE_CREDENTIAL_ID, output) + self.assertNotIn(FAKE_PUBLIC_KEY, output) + for hashed_code in recovery_auth.data["codes"]: + self.assertNotIn(hashed_code, output) + self.assertNotIn("secret", output.lower()) + + def test_reports_recovery_codes_remaining(self): + RecoveryCodesAdapter().generate(self.user) + self.assertIn("10", self._run("alice")) + + @override_settings(MFA_REQUIRED=True) + def test_reports_required(self): + self.assertIn("required: yes", self._run("alice").lower()) + + @override_settings(MFA_REQUIRED=True) + def test_reports_an_active_exemption(self): + MfaExemption.objects.create(user=self.user, reason="service account") + output = self._run("alice").lower() + self.assertIn("exempt", output) + self.assertIn("service account", output) + + def test_unknown_user_errors(self): + with self.assertRaises(CommandError): + self._run("nobody") + + @override_settings(USE_TZ=True, TIME_ZONE="Pacific/Kiritimati") + def test_authenticator_date_is_formatted_in_the_local_timezone(self): + # Kiritimati is UTC+14 year-round (no DST), so a late-evening UTC + # timestamp lands on the *next* calendar date locally. A + # mid-afternoon UTC timestamp would print the same date whether or + # not the command actually converts to local time first, so it + # couldn't discriminate a regression back to a bare strftime on the + # stored (UTC) value -- this timestamp can. + created_at = datetime(2024, 1, 1, 23, 0, tzinfo=dt_timezone.utc) + Authenticator.objects.create( + user=self.user, type=Authenticator.Type.TOTP, + data={"secret": encrypt(KNOWN_SECRET)}, created_at=created_at) + output = self._run("alice") + self.assertIn("2024-01-02", output) + self.assertNotIn("2024-01-01", output) + + @override_settings(USE_TZ=True, TIME_ZONE="Pacific/Kiritimati") + def test_exemption_until_date_is_formatted_in_the_local_timezone(self): + # MfaExemption.objects.active_for() only returns rows that are still + # in force (expires_at in the future), so -- unlike the authenticator + # date above -- this needs a timestamp computed relative to "now" + # rather than a fixed past date. Fixing the UTC hour at 23:00 still + # guarantees the UTC/local dates differ under Kiritimati (UTC+14, + # no DST): 23:00 UTC + 14h always rolls onto the next calendar date + # locally, regardless of which future day is chosen. + expires_at = (timezone.now() + timedelta(days=30)).replace( + hour=23, minute=0, second=0, microsecond=0) + MfaExemption.objects.create( + user=self.user, reason="service account", expires_at=expires_at) + output = self._run("alice") + local_date = timezone.localtime(expires_at).date().isoformat() + utc_date = expires_at.date().isoformat() + self.assertNotEqual(local_date, utc_date) # sanity: the two differ + self.assertIn(local_date, output) + self.assertNotIn(utc_date, output) + + +class MfaResetTests(TestCase): + def setUp(self): + self.user = User.objects.create_user("alice", password="pw") + self.totp = Authenticator.objects.create( + user=self.user, type=Authenticator.Type.TOTP, + data={"secret": encrypt(KNOWN_SECRET)}) + RecoveryCodesAdapter().generate(self.user) + + def _run(self, *args): + out = StringIO() + call_command("mfa_reset", *args, stdout=out) + return out.getvalue() + + def test_removes_every_factor(self): + self._run("alice", "--yes") + self.assertFalse(Authenticator.objects.filter(user=self.user).exists()) + + def test_emits_factor_removed_per_row(self): + seen = [] + + def receiver(sender, **kwargs): + seen.append(kwargs) + + events.factor_removed.connect(receiver) + try: + self._run("alice", "--yes") + finally: + events.factor_removed.disconnect(receiver) + + self.assertEqual(len(seen), 2) + self.assertEqual({k["factor_type"] for k in seen}, + {"totp", "recovery_codes"}) + # No request exists in a command. Receivers must tolerate None. + self.assertTrue(all(k["request"] is None for k in seen)) + + def test_a_raising_receiver_does_not_break_the_command(self): + def boom(sender, **kwargs): + raise RuntimeError("receiver is broken") + + events.factor_removed.connect(boom) + try: + self._run("alice", "--yes") + finally: + events.factor_removed.disconnect(boom) + self.assertFalse(Authenticator.objects.filter(user=self.user).exists()) + + def test_row_of_an_unregistered_type_still_resets(self): + # A row can outlive its adapter's registration -- MFA_FACTORS + # narrowed, or registry.unregister() (the WebAuthn opt-out checks.py + # itself recommends). Registration only happens once, at app + # startup (see adapters/__init__.py), so @override_settings cannot + # simulate a narrowed MFA_FACTORS here -- unregister directly, same + # as test_events.py's analogous manage_factors test. manage_factors + # handles this with sender=None; so must the command. + adapter = registry.get("webauthn") + registry.unregister("webauthn") + self.addCleanup(registry.register, adapter) + + Authenticator.objects.create( + user=self.user, type=Authenticator.Type.WEBAUTHN, + name="old key", data={}) + seen = [] + + def receiver(sender, **kwargs): + seen.append((sender, kwargs["factor_type"])) + + events.factor_removed.connect(receiver) + try: + self._run("alice", "--yes") + finally: + events.factor_removed.disconnect(receiver) + + self.assertFalse(Authenticator.objects.filter(user=self.user).exists()) + self.assertIn((None, "webauthn"), seen) + + def test_reports_what_it_removed(self): + self.assertIn("2 factor", self._run("alice", "--yes")) + + def test_leaves_an_exemption_alone(self): + MfaExemption.objects.create(user=self.user, reason="service account") + self._run("alice", "--yes") + self.assertTrue(MfaExemption.objects.filter(user=self.user).exists()) + + def test_no_factors_is_not_an_error(self): + Authenticator.objects.filter(user=self.user).delete() + self.assertIn("no factors", self._run("alice", "--yes").lower()) + + +class MfaReportTests(TestCase): + def setUp(self): + self.enrolled = User.objects.create_user("enrolled", password="pw") + Authenticator.objects.create( + user=self.enrolled, type=Authenticator.Type.TOTP, + data={"secret": encrypt(KNOWN_SECRET)}) + self.bare = User.objects.create_user("bare", password="pw") + + def _run(self, *args): + out = StringIO() + call_command("mfa_report", *args, stdout=out) + return out.getvalue() + + def test_counts_by_type(self): + output = self._run() + self.assertIn("totp", output) + self.assertIn("1", output) + + @override_settings(MFA_REQUIRED=True) + def test_lists_required_but_unenrolled(self): + output = self._run("--required-only") + self.assertIn("bare", output) + # The user who already holds a factor is not outstanding. Asserted on + # the --required-only output so the per-type counts (which legitimately + # mention totp) cannot satisfy or break this. + self.assertNotIn(f"(pk={self.enrolled.pk})", output) + + @override_settings(MFA_REQUIRED=False) + def test_nobody_required_when_setting_is_off(self): + self.assertIn("0", self._run("--required-only")) + + @override_settings(MFA_REQUIRED=True) + def test_exempt_user_is_not_listed_as_outstanding(self): + MfaExemption.objects.create(user=self.bare, reason="service account") + self.assertNotIn("bare", self._run("--required-only")) + + @override_settings(MFA_REQUIRED=True) + def test_csv_format(self): + output = self._run("--required-only", "--format", "csv") + self.assertIn("pk,username", output) + self.assertIn(f"{self.bare.pk},bare", output) + + @override_settings(MFA_REQUIRED=True) + def test_csv_format_without_required_only_has_no_leading_prose(self): + # --format csv is a machine-readable contract on its own -- a + # consumer piping `mfa_report --format csv > report.csv` must get the + # header as line one, not two lines of "Enrolled factors by type:" + # prose ahead of it. Assert on the first line specifically, not a + # substring: a substring check would pass even with the prose block + # still printed above the header. + output = self._run("--format", "csv") + first_line = output.splitlines()[0] + self.assertEqual(first_line, "pk,username") + + def test_never_prints_factor_data(self): + self.assertNotIn(KNOWN_SECRET, self._run()) + + @override_settings(MFA_REQUIRED=True) + def test_recovery_codes_only_user_is_outstanding(self): + # counts_as_primary_factor = False for recovery codes (they're + # exhaustible and must never be a user's sole factor), so a user + # holding only a recovery-codes row is NOT protected and must appear + # in the outstanding list -- even though a naive + # `Authenticator.objects.filter(user=user).exists()` check would + # wrongly treat them as covered because a row exists. This pins the + # registry.has_primary_factor() call specifically, not just + # "some check runs". + recovery_only = User.objects.create_user("recovery_only", password="pw") + RecoveryCodesAdapter().generate(recovery_only) + + output = self._run("--required-only") + + self.assertIn("recovery_only", output) + self.assertIn(f"(pk={recovery_only.pk})", output) diff --git a/django_mfa/tests/test_events.py b/django_mfa/tests/test_events.py index 516d5d5..30e6e86 100644 --- a/django_mfa/tests/test_events.py +++ b/django_mfa/tests/test_events.py @@ -1,3 +1,5 @@ +import time + from django.contrib.auth.models import User from django.core.cache import cache from django.test import Client, TestCase @@ -55,7 +57,10 @@ def enroll_totp(self): def verified_login(self): self.client.login(username="a@example.com", password="pw") session = self.client.session - session["mfa"] = {"verified": True, "method": "totp", "at": 0} + # A recent "at", not 0 -- these tests drive enroll_factor/manage, + # which now require a fresh challenge (mfa_recent_required), not + # merely a verified session. See test_stepup.py. + session["mfa"] = {"verified": True, "method": "totp", "at": int(time.time())} session.save() diff --git a/django_mfa/tests/test_exemptions.py b/django_mfa/tests/test_exemptions.py new file mode 100644 index 0000000..00397ab --- /dev/null +++ b/django_mfa/tests/test_exemptions.py @@ -0,0 +1,385 @@ +from datetime import datetime, time, timedelta +from io import StringIO + +from django.contrib.auth.models import User +from django.core.management import CommandError, call_command +from django.http import HttpResponse +from django.test import RequestFactory, TestCase, override_settings +from django.urls import reverse +from django.utils import timezone +from django.views.generic import View + +from django_mfa import events, policy +from django_mfa.decorators import MfaRequiredMixin, mfa_required +from django_mfa.models import MfaExemption + + +class MfaExemptionModelTests(TestCase): + def setUp(self): + self.user = User.objects.create_user("a@example.com", password="pw") + + def test_permanent_exemption_is_active(self): + e = MfaExemption.objects.create(user=self.user, reason="service account") + self.assertTrue(e.is_active()) + + def test_future_expiry_is_active(self): + e = MfaExemption.objects.create( + user=self.user, reason="onboarding", + expires_at=timezone.now() + timedelta(days=1)) + self.assertTrue(e.is_active()) + + def test_past_expiry_is_not_active(self): + e = MfaExemption.objects.create( + user=self.user, reason="onboarding", + expires_at=timezone.now() - timedelta(seconds=1)) + self.assertFalse(e.is_active()) + + def test_active_for_returns_none_when_expired(self): + MfaExemption.objects.create( + user=self.user, reason="x", + expires_at=timezone.now() - timedelta(seconds=1)) + self.assertIsNone(MfaExemption.objects.active_for(self.user)) + + def test_one_exemption_per_user(self): + # OneToOneField -> a unique constraint at the database level. The + # atomic() block is required: an IntegrityError inside a TestCase + # poisons the surrounding transaction otherwise, and every later + # assertion in this test method fails with TransactionManagementError. + from django.db import IntegrityError, transaction + + MfaExemption.objects.create(user=self.user, reason="a") + with self.assertRaises(IntegrityError), transaction.atomic(): + MfaExemption.objects.create(user=self.user, reason="b") + + +class MfaExemptionStrTests(TestCase): + """str(exemption) is not just a display nicety: MfaExemptionAdmin's + docstring names delete as the documented revoke path, and Django's admin + calls str(obj) to render the confirm-delete page and again to write + LogEntry.object_repr on deletion -- both real code paths that must not + 500. conf.py/checks.py never require USE_TZ=True, and this project's + floor (Django 4.2, part of the CI matrix) defaults it to False, under + which every stored/`timezone.now()` datetime is naive -- so this must + hold under both settings, not just whichever one the dev machine's + Django version happens to default to. + """ + + def setUp(self): + self.user = User.objects.create_user("a@example.com", password="pw") + + @override_settings(USE_TZ=True) + def test_str_with_expiry_under_use_tz_true(self): + e = MfaExemption.objects.create( + user=self.user, reason="x", + expires_at=timezone.now() + timedelta(days=1)) + self.assertIn("until", str(e)) + + @override_settings(USE_TZ=False) + def test_str_with_expiry_under_use_tz_false(self): + e = MfaExemption.objects.create( + user=self.user, reason="x", + expires_at=timezone.now() + timedelta(days=1)) + self.assertIn("until", str(e)) + + def test_str_without_expiry_has_no_until_suffix(self): + e = MfaExemption.objects.create(user=self.user, reason="x") + self.assertNotIn("until", str(e)) + + +@override_settings(MFA_REQUIRED=True) +class PolicyExemptionTests(TestCase): + def setUp(self): + self.user = User.objects.create_user("a@example.com", password="pw") + + def test_required_without_exemption(self): + self.assertTrue(policy.mfa_required_for(self.user)) + + def test_active_exemption_suppresses_the_requirement(self): + MfaExemption.objects.create(user=self.user, reason="service account") + self.assertFalse(policy.mfa_required_for(self.user)) + + def test_expired_exemption_does_not_suppress(self): + MfaExemption.objects.create( + user=self.user, reason="x", + expires_at=timezone.now() - timedelta(seconds=1)) + self.assertTrue(policy.mfa_required_for(self.user)) + + @override_settings(MFA_REQUIRED=False) + def test_no_query_when_nobody_is_required(self): + # The exemption lookup runs only after the predicate says yes, so the + # default install pays nothing for this feature. + MfaExemption.objects.create(user=self.user, reason="x") + with self.assertNumQueries(0): + self.assertFalse(policy.mfa_required_for(self.user)) + + +@override_settings(MFA_REQUIRED=True) +class MiddlewareExemptionTests(TestCase): + def setUp(self): + self.user = User.objects.create_user("a@example.com", password="pw") + self.client.force_login(self.user) + + def test_unenrolled_required_user_is_walled(self): + response = self.client.get(reverse("mfa:manage")) + self.assertEqual(response.status_code, 302) + self.assertIn(reverse("mfa:security_settings"), response["Location"]) + + def test_exempt_user_is_not_walled(self): + MfaExemption.objects.create(user=self.user, reason="service account") + response = self.client.get(reverse("mfa:manage")) + # 405: reached the POST-only view rather than being redirected away. + self.assertEqual(response.status_code, 405) + + +@override_settings(MFA_REQUIRED=True) +class ExemptionDoesNotOpenDecoratedViewsTests(TestCase): + """An MfaExemption suppresses MFA_REQUIRED only. It must never let a + factorless, exempt user through a view behind @mfa_required or + MfaRequiredMixin -- MFA_REQUIRED picks *users*, the decorator picks + *views*, and decorators.py's own module docstring says the two are not + interchangeable. decorators._enforce() does not import django_mfa.policy + at all today, so this passes for a structural reason rather than by + coincidence -- these tests exist to keep it that way: the obvious future + "simplification" of having _enforce()'s factorless rung also consult + policy.mfa_required_for(), "for consistency with the middleware", would + silently let every exempt-but-factorless user reach every decorated + view. Called by invoking the decorator/mixin directly rather than through + self.client, so MfaMiddleware (which is not involved in this question) + cannot mask a regression here by redirecting first. + """ + + def setUp(self): + self.user = User.objects.create_user("a@example.com", password="pw") + MfaExemption.objects.create(user=self.user, reason="service account") + + def _request(self): + request = RequestFactory().get("/billing/") + request.user = self.user + request.session = self.client.session + return request + + def test_decorator_still_blocks_an_exempt_user_with_no_factor(self): + view = mfa_required(lambda request: HttpResponse("ok")) + response = view(self._request()) + self.assertEqual(response.status_code, 302) + self.assertIn(reverse("mfa:security_settings"), response["Location"]) + + def test_mixin_still_blocks_an_exempt_user_with_no_factor(self): + class ProtectedView(MfaRequiredMixin, View): + def get(self, request): + return HttpResponse("ok") + + response = ProtectedView.as_view()(self._request()) + self.assertEqual(response.status_code, 302) + self.assertIn(reverse("mfa:security_settings"), response["Location"]) + + +class MfaDisableCommandTests(TestCase): + def setUp(self): + self.user = User.objects.create_user("alice", password="pw") + + def _run(self, *args): + out = StringIO() + call_command("mfa_disable", *args, stdout=out) + return out.getvalue() + + def test_creates_an_exemption(self): + self._run("alice", "--reason", "service account") + exemption = MfaExemption.objects.get(user=self.user) + self.assertEqual(exemption.reason, "service account") + self.assertIsNone(exemption.expires_at) + + def test_reason_is_mandatory(self): + with self.assertRaises(CommandError): + self._run("alice") + + def test_reason_of_only_whitespace_is_rejected(self): + # Truthiness alone lets " " through -- it's a non-empty string -- + # which defeats the entire point of a mandatory reason. Must be + # stripped before the emptiness check. + with self.assertRaises(CommandError): + self._run("alice", "--reason", " ") + + def test_until_sets_an_expiry(self): + # Derived, not hardcoded: a literal date only stays in the future + # until it doesn't, at which point the past-date guard below turns + # this test into a CommandError failure with nothing to do with the + # code under test. +365 days is always in the future regardless of + # when this suite runs. + future_date = (datetime.now() + timedelta(days=365)).date() + self._run("alice", "--reason", "onboarding", "--until", + future_date.isoformat()) + exemption = MfaExemption.objects.get(user=self.user) + expires = exemption.expires_at + if timezone.is_aware(expires): + # Storage is always UTC; the exemption's expiry is computed as + # the last instant of the named date in the *local* (settings. + # TIME_ZONE) calendar, which -- for any zone behind UTC, e.g. + # this suite's ambient 'America/Chicago' -- lands on the next + # calendar day once read back as a raw UTC value. localtime() + # recovers the date an operator who typed --until actually + # meant, the same conversion MfaExemption.__str__ and + # format_date() already apply for display. + expires = timezone.localtime(expires) + self.assertEqual(expires.date().isoformat(), future_date.isoformat()) + + def test_bad_until_errors(self): + with self.assertRaises(CommandError): + self._run("alice", "--reason", "x", "--until", "not-a-date") + + @override_settings(USE_TZ=False) + def test_until_keeps_the_exemption_active_through_the_whole_named_date(self): + # MUST FIX 1: a midnight-of-the-named-date expiry (the pre-fix + # behaviour) makes MfaExemption.objects.active_for() return None for + # the entire named date -- a "one-day" exemption that grants zero + # days of coverage. "--until " must keep the exemption in + # force through the *end* of that date, not release it at its start. + # USE_TZ=False sidesteps the local/UTC calendar-date shift that + # aware storage introduces (see test_until_sets_an_expiry above), + # keeping this test's arithmetic a direct, unambiguous check of the + # stored instant. + # + # Derived, not hardcoded: see test_until_sets_an_expiry for why a + # literal date is a time bomb here. datetime.now(), not + # timezone.localdate(), because USE_TZ=False makes timezone.now() + # naive and localdate()/localtime() both raise on a naive value. + future_date = (datetime.now() + timedelta(days=365)).date() + self._run("alice", "--reason", "onboarding", "--until", + future_date.isoformat()) + exemption = MfaExemption.objects.get(user=self.user) + self.assertGreater( + exemption.expires_at, + datetime.combine(future_date, time(23, 0, 0))) + self.assertLess( + exemption.expires_at, + datetime.combine(future_date + timedelta(days=1), time(0, 0, 0))) + + def test_past_until_is_rejected(self): + # MUST FIX 1: a past --until must never be accepted, whether or not + # an exemption already exists for the user -- see the next test for + # why silently accepting one is actively dangerous, not just + # pointless. + with self.assertRaises(CommandError): + self._run("alice", "--reason", "x", "--until", "2020-01-01") + + def test_past_until_does_not_overwrite_an_active_exemption(self): + # MUST FIX 1: update_or_create() would otherwise overwrite a live + # exemption with an already-expired one -- a functional revoke -- + # while the command still reports a grant and emits + # mfa_exemption_changed(revoked=False, expires_at=). The audit + # trail would then record the inverse of what happened. Rejecting + # the past date outright (previous test) prevents this outcome by + # never reaching update_or_create() at all. + self._run("alice", "--reason", "first", "--until", "2030-01-01") + with self.assertRaises(CommandError): + self._run("alice", "--reason", "revoke-attempt", + "--until", "2020-01-01") + exemption = MfaExemption.objects.get(user=self.user) + self.assertEqual(exemption.reason, "first") + self.assertTrue(exemption.is_active()) + + def test_revoke_removes_it(self): + MfaExemption.objects.create(user=self.user, reason="x") + self._run("--revoke", "alice") + self.assertFalse(MfaExemption.objects.filter(user=self.user).exists()) + + def test_revoke_without_one_is_not_an_error(self): + self.assertIn("no exemption", self._run("--revoke", "alice").lower()) + + def test_rerunning_replaces_the_reason(self): + self._run("alice", "--reason", "first") + self._run("alice", "--reason", "second") + self.assertEqual(MfaExemption.objects.get(user=self.user).reason, + "second") + + def test_emits_the_signal_on_create_and_revoke(self): + seen = [] + + def receiver(sender, **kwargs): + seen.append(kwargs) + + events.mfa_exemption_changed.connect(receiver) + try: + self._run("alice", "--reason", "service account") + self._run("--revoke", "alice") + finally: + events.mfa_exemption_changed.disconnect(receiver) + + self.assertEqual(len(seen), 2) + self.assertFalse(seen[0]["revoked"]) + self.assertEqual(seen[0]["reason"], "service account") + self.assertIsNone(seen[0]["request"]) + self.assertTrue(seen[1]["revoked"]) + + def test_signal_is_reexported_from_signals(self): + from django_mfa.signals import mfa_exemption_changed + self.assertIs(mfa_exemption_changed, events.mfa_exemption_changed) + + +class MfaDisableUntilUseTzTests(TestCase): + """`--until` must produce a datetime whose awareness matches USE_TZ. + + The brief this command was written from has `handle()` call + `timezone.make_aware(parsed)` unconditionally. test_runner.py never sets + USE_TZ, so it follows Django's default -- False on Django 4.2, this + project's floor and a leg of the CI matrix. Under USE_TZ=False, saving a + timezone-aware datetime raises (SQLite rejects it outright), and + MfaExemption.objects.active_for() compares expires_at__gt=timezone.now(), + where timezone.now() is naive -- mixing the two raises TypeError. Same + class of bug as Task 5's timezone.localtime() crash and Task 6's UTC + date display; this locks in the fix for the third occurrence. + + Both tests grant a dated exemption through the real command and read it + back through the real manager method, under the setting each is named + for, so a regression back to the unguarded call is caught by an outright + exception under USE_TZ=False (SQLite backend does not support + timezone-aware datetimes when USE_TZ is False) rather than merely a + wrong value. + """ + + def setUp(self): + self.user = User.objects.create_user("alice", password="pw") + + def _run(self, *args): + out = StringIO() + call_command("mfa_disable", *args, stdout=out) + return out.getvalue() + + @override_settings(USE_TZ=True) + def test_until_round_trips_under_use_tz_true(self): + # Derived, not hardcoded: see test_until_sets_an_expiry (above, in + # MfaDisableCommandTests) for why a literal date is a time bomb + # against the past-date guard. datetime.now(), not + # timezone.localdate(): this method's own override_settings makes + # USE_TZ=True locally, but the helper must work the same way as its + # USE_TZ=False sibling below, so both use the same plain stdlib call. + future_date = (datetime.now() + timedelta(days=365)).date() + self._run("alice", "--reason", "onboarding", "--until", + future_date.isoformat()) + exemption = MfaExemption.objects.active_for(self.user) + self.assertIsNotNone(exemption) + self.assertTrue(timezone.is_aware(exemption.expires_at)) + # localtime(), not a bare .date() on the stored (UTC) value: the + # expiry is the last instant of the named date in the *local* + # (settings.TIME_ZONE) calendar, which -- behind UTC, as this + # suite's ambient 'America/Chicago' is -- reads back as the next + # calendar day in raw UTC. Same conversion + # MfaExemption.__str__/format_date() already apply so an operator + # sees the date they typed. + self.assertEqual( + timezone.localtime(exemption.expires_at).date().isoformat(), + future_date.isoformat()) + + @override_settings(USE_TZ=False) + def test_until_round_trips_under_use_tz_false(self): + # datetime.now(), not timezone.localdate(): USE_TZ=False makes + # timezone.now() naive, and localdate()/localtime() both raise on a + # naive value. + future_date = (datetime.now() + timedelta(days=365)).date() + self._run("alice", "--reason", "onboarding", "--until", + future_date.isoformat()) + exemption = MfaExemption.objects.active_for(self.user) + self.assertIsNotNone(exemption) + self.assertFalse(timezone.is_aware(exemption.expires_at)) + self.assertEqual(exemption.expires_at.date().isoformat(), + future_date.isoformat()) diff --git a/django_mfa/tests/test_import_django_mfa2.py b/django_mfa/tests/test_import_django_mfa2.py new file mode 100644 index 0000000..410b7aa --- /dev/null +++ b/django_mfa/tests/test_import_django_mfa2.py @@ -0,0 +1,471 @@ +from io import StringIO + +import pyotp +from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType +from django.core.management import call_command +from django.test import TestCase, override_settings +from mfa.models import User_Keys + +from django_mfa import totp as totp_mod +from django_mfa.adapters.email import EmailAdapter +from django_mfa.crypto import decrypt +from django_mfa.management.commands.mfa_import_django_mfa2 import Command +from django_mfa.models import Authenticator +from django_mfa.registry import registry + +SECRET = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP" +#: A second, distinct secret for tests about two source rows in one run. +OTHER_SECRET = "KRSXG5CTMVRXEZLUKN2XAZLSEBBHK5A=" + + +class ImportDjangoMfa2Tests(TestCase): + def setUp(self): + self.user = User.objects.create_user("alice", password="pw") + + def _run(self, *args): + out = StringIO() + call_command("mfa_import_django_mfa2", *args, stdout=out) + return out.getvalue() + + # --- TOTP --------------------------------------------------------- + + def test_imports_totp(self): + User_Keys.objects.create( + username=self.user.username, key_type="TOTP", enabled=True, + properties={"secret_key": SECRET}) + self._run() + auth = Authenticator.objects.get(user=self.user, + type=Authenticator.Type.TOTP) + self.assertEqual(decrypt(auth.data["secret"]), SECRET) + + def test_imported_secret_matches_pyotp_cross_implementation(self): + """The real acceptance test for the "no conversion needed" claim. + + Unlike mfa_import_django_otp (hex -> base32), this command performs + NO transformation on the secret: mfa2's own `properties["secret_key"]` + is already base32 (pyotp.random_base32() writes it -- see + mfa/totp.py's getToken), the exact format django_mfa's own + otp.OTP.byte_secret() expects. Proven two ways here: the stored + value round-trips byte-identical (checked by test_imports_totp via + decrypt()), and an independent pyotp instance fed the same secret + produces the identical code django_mfa's own TOTP does, at a time + pinned so the two calls can't straddle a 30s window boundary + differently. + """ + User_Keys.objects.create( + username=self.user.username, key_type="TOTP", enabled=True, + properties={"secret_key": SECRET}) + self._run() + auth = Authenticator.objects.get(user=self.user, + type=Authenticator.Type.TOTP) + secret = decrypt(auth.data["secret"]) + self.assertEqual(secret, SECRET) # byte-identical; no conversion + + fixed_time = 1_700_000_000 # arbitrary Unix timestamp, pinned + expected = pyotp.TOTP(SECRET).at(fixed_time) + ours = totp_mod.TOTP(secret).at(fixed_time) + self.assertEqual(ours, expected) + + @override_settings(MFA_SECRET_ENCRYPTION_KEYS=["k"]) + def test_totp_secret_is_actually_encrypted(self): + """Pins that the command genuinely calls encrypt(), not just that + the stored value round-trips through decrypt(). + + crypto.encrypt() is a no-op unless MFA_SECRET_ENCRYPTION_KEYS is + set, and the test settings baseline (test_runner.py) doesn't set + it -- so test_imports_totp alone would pass identically whether the + command called encrypt(secret) or just stored the raw secret + directly. With a key configured here, encrypt() actually transforms + the value, so the stored blob must differ from the plaintext and + still decrypt back to it. + """ + User_Keys.objects.create( + username=self.user.username, key_type="TOTP", enabled=True, + properties={"secret_key": SECRET}) + self._run() + auth = Authenticator.objects.get(user=self.user, + type=Authenticator.Type.TOTP) + self.assertNotEqual(auth.data["secret"], SECRET) + self.assertEqual(decrypt(auth.data["secret"]), SECRET) + + def test_reports_acceptance_window_mismatch_unconditionally(self): + # mfa2's own verification (mfa/totp.py:24) accepts codes up to + # +/-15 minutes out of step; django_mfa accepts +/-30s. No per-row + # check can catch this (User_Keys stores no clock-skew state at + # all), so it must be printed every run, even one that imports + # nothing. + output = self._run() + self.assertIn("valid_window=30", output) + self.assertIn("+/-30s", output) + + def test_skips_disabled_keys(self): + User_Keys.objects.create( + username=self.user.username, key_type="TOTP", enabled=False, + properties={"secret_key": SECRET}) + self._run() + self.assertFalse(Authenticator.objects.filter(user=self.user).exists()) + + def test_missing_secret_is_skipped(self): + User_Keys.objects.create( + username=self.user.username, key_type="TOTP", enabled=True, + properties={}) + self._run() + self.assertFalse(Authenticator.objects.filter(user=self.user).exists()) + + def test_second_totp_key_in_same_run_is_not_silently_overwritten(self): + # django-mfa2 puts no uniqueness constraint on User_Keys -- two + # enabled TOTP rows for one user is a legal state after a + # re-enrolment that never disabled/removed the old key. With + # --overwrite and no ordering/dedup, which one "wins" would depend + # on undefined queryset order, and the summary would over-count + # "imported" against the single row actually left on disk. + User_Keys.objects.create( + username=self.user.username, key_type="TOTP", enabled=True, + properties={"secret_key": SECRET}) + User_Keys.objects.create( + username=self.user.username, key_type="TOTP", enabled=True, + properties={"secret_key": OTHER_SECRET}) + output = self._run("--overwrite") + self.assertEqual(Authenticator.objects.filter( + user=self.user, type=Authenticator.Type.TOTP).count(), 1) + auth = Authenticator.objects.get(user=self.user, + type=Authenticator.Type.TOTP) + # Deterministic: the lower-pk (first-created) row is the one kept. + self.assertEqual(decrypt(auth.data["secret"]), SECRET) + self.assertIn("second", output.lower()) + + # --- Email ---------------------------------------------------------- + + def test_imports_email(self): + # "email" is off MFA_FACTORS by default (adapters/email.py's own + # module docstring), so nothing registers an EmailAdapter unless a + # test opts in -- same pattern test_import_django_otp.py and + # test_adapter_email.py use, since MFA_FACTORS is read once at app + # startup and override_settings() afterwards has no effect on the + # already-populated registry. + registry.register(EmailAdapter()) + self.addCleanup(registry.unregister, "email") + self.user.email = "alice@example.com" + self.user.save() + User_Keys.objects.create( + username=self.user.username, key_type="Email", enabled=True, + properties={}) + self._run() + auth = Authenticator.objects.get(user=self.user, + type=Authenticator.Type.EMAIL) + self.assertEqual(auth.data["address"], self.user.email) + + def test_email_with_no_address_on_file_is_skipped(self): + # mfa2 stores no per-key address for an Email row (Email.py's start + # view never sets `properties`); sendEmail() always targets the + # account's own user.email. If that's blank there is nothing to + # migrate -- and writing a factor with a blank address would make + # registry.has_primary_factor(user) True while + # EmailAdapter.is_available() stays False, i.e. "protected" but + # never actually verifiable. + registry.register(EmailAdapter()) + self.addCleanup(registry.unregister, "email") + User_Keys.objects.create( + username=self.user.username, key_type="Email", enabled=True, + properties={}) + output = self._run() + self.assertFalse(Authenticator.objects.filter( + user=self.user, type=Authenticator.Type.EMAIL).exists()) + self.assertIn("no email address", output) + + def test_email_import_skipped_when_adapter_not_registered(self): + # Default MFA_FACTORS excludes "email". Importing under that default + # must not create a row that registry.has_primary_factor(user) can + # never see -- an inert row that looks like protection to an + # operator reading the summary but leaves the user bounced to + # enrolment. + self.user.email = "alice@example.com" + self.user.save() + User_Keys.objects.create( + username=self.user.username, key_type="Email", enabled=True, + properties={}) + output = self._run() + self.assertFalse(Authenticator.objects.filter( + user=self.user, type=Authenticator.Type.EMAIL).exists()) + self.assertIn("MFA_FACTORS", output) + + # --- key_types with no counterpart / out of scope -------------------- + + def test_reports_fido2_as_unmigratable(self): + User_Keys.objects.create( + username=self.user.username, key_type="FIDO2", enabled=True, + properties={"device": {}}) + output = self._run() + self.assertFalse(Authenticator.objects.filter(user=self.user).exists()) + self.assertIn("FIDO2", output) + + def test_reports_u2f_as_unmigratable(self): + User_Keys.objects.create( + username=self.user.username, key_type="U2F", enabled=True, + properties={}) + output = self._run() + self.assertFalse(Authenticator.objects.filter(user=self.user).exists()) + self.assertIn("U2F", output) + + def test_reports_trusted_device_as_unmigratable(self): + # User_Keys.save() has a special case for this key_type: when no + # "signature" is already present it builds one via + # jwt.encode({..., "key": self.properties["key"]}, ...), so an empty + # properties dict raises KeyError before the row can even be + # created. The brief's own sample test used properties={}, which + # does not survive against the real, installed model. + User_Keys.objects.create( + username=self.user.username, key_type="Trusted Device", + enabled=True, properties={"key": "irrelevant-to-this-test"}) + self.assertIn("Trusted Device", self._run()) + + def test_reports_recovery_as_out_of_scope(self): + # RECOVERY is real (mfa/recovery.py) and DOES have a django_mfa + # counterpart (recovery_codes) -- unlike FIDO2/U2F/Trusted Device, + # which have none at all. The brief this command was written from + # omitted RECOVERY entirely; converting it is out of this command's + # stated scope (TOTP + email only), so it must be reported with a + # message that doesn't falsely claim "no counterpart". + User_Keys.objects.create( + username=self.user.username, key_type="RECOVERY", enabled=True, + properties={"secret_keys": ["somehash"], "salt": "abc"}) + output = self._run() + self.assertFalse(Authenticator.objects.filter(user=self.user).exists()) + self.assertIn("RECOVERY", output) + self.assertNotIn("no counterpart", output) + + def test_unrecognised_key_type_is_skipped(self): + User_Keys.objects.create( + username=self.user.username, key_type="Bogus", enabled=True, + properties={}) + output = self._run() + self.assertFalse(Authenticator.objects.filter(user=self.user).exists()) + self.assertIn("Bogus", output) + + # --- username-keyed rows --------------------------------------------- + + def test_unknown_username_is_reported_not_fatal(self): + User_Keys.objects.create( + username="ghost", key_type="TOTP", enabled=True, + properties={"secret_key": SECRET}) + output = self._run() + self.assertIn("ghost", output) + + def test_username_field_lookup_is_tried_before_the_literal_fallback(self): + # On the default user model USERNAME_FIELD == "username", so this + # is exercising the *first* branch of _find_user -- the direct + # lookup succeeds and the literal-username fallback is never + # reached. See FindUserFallbackTests below for the fallback branch + # itself, which needs a field that differs from "username" to + # exercise at all (not available from a real end-to-end run in + # this suite -- AUTH_USER_MODEL is not swapped here). + User_Keys.objects.create( + username=self.user.username, key_type="TOTP", enabled=True, + properties={"secret_key": SECRET}) + self._run() + self.assertTrue(Authenticator.objects.filter(user=self.user).exists()) + + def test_users_flag_narrows(self): + other = User.objects.create_user("bob", password="pw") + User_Keys.objects.create( + username=self.user.username, key_type="TOTP", enabled=True, + properties={"secret_key": SECRET}) + User_Keys.objects.create( + username=other.username, key_type="TOTP", enabled=True, + properties={"secret_key": OTHER_SECRET}) + self._run("--users", "alice") + self.assertTrue(Authenticator.objects.filter(user=self.user).exists()) + self.assertFalse(Authenticator.objects.filter(user=other).exists()) + + # --- existing factor / overwrite -------------------------------------- + + def test_existing_factor_is_skipped_not_replaced(self): + existing = Authenticator.objects.create( + user=self.user, type=Authenticator.Type.TOTP, + data={"secret": "untouched"}) + User_Keys.objects.create( + username=self.user.username, key_type="TOTP", enabled=True, + properties={"secret_key": SECRET}) + output = self._run() + existing.refresh_from_db() + self.assertEqual(existing.data["secret"], "untouched") + self.assertIn("skipped", output.lower()) + + def test_overwrite_replaces(self): + Authenticator.objects.create( + user=self.user, type=Authenticator.Type.TOTP, + data={"secret": "untouched"}) + User_Keys.objects.create( + username=self.user.username, key_type="TOTP", enabled=True, + properties={"secret_key": SECRET}) + self._run("--overwrite") + auth = Authenticator.objects.get(user=self.user, + type=Authenticator.Type.TOTP) + self.assertEqual(decrypt(auth.data["secret"]), SECRET) + + # --- dry-run ------------------------------------------------------ + + def test_dry_run_writes_nothing(self): + User_Keys.objects.create( + username=self.user.username, key_type="TOTP", enabled=True, + properties={"secret_key": SECRET}) + output = self._run("--dry-run") + self.assertFalse(Authenticator.objects.filter(user=self.user).exists()) + self.assertIn("1", output) + + def test_dry_run_marks_the_per_row_imported_line_too(self): + # Not just the summary: an operator reading a real cutover's + # "imported totp for alice" line has no way to tell it apart from a + # --dry-run rehearsal's identical line once the summary has scrolled + # off. Every line printed while nothing is actually being written + # must say so. + User_Keys.objects.create( + username=self.user.username, key_type="TOTP", enabled=True, + properties={"secret_key": SECRET}) + output = self._run("--dry-run") + imported_line = next( + line for line in output.splitlines() if "imported totp" in line) + self.assertIn("[dry run]", imported_line) + + def test_dry_run_marks_the_per_row_skipped_line_too(self): + User_Keys.objects.create( + username=self.user.username, key_type="FIDO2", enabled=True) + output = self._run("--dry-run") + skipped_line = next( + line for line in output.splitlines() if "skipped" in line) + self.assertIn("[dry run]", skipped_line) + + def test_dry_run_overwrite_does_not_destroy_existing_factor(self): + # The one path where a bug would destroy a working factor: a + # --dry-run that (correctly) decides to overwrite must still not + # actually write anything. + existing = Authenticator.objects.create( + user=self.user, type=Authenticator.Type.TOTP, + data={"secret": "untouched"}) + User_Keys.objects.create( + username=self.user.username, key_type="TOTP", enabled=True, + properties={"secret_key": SECRET}) + self._run("--dry-run", "--overwrite") + existing.refresh_from_db() + self.assertEqual(existing.data["secret"], "untouched") + + def test_rerun_is_a_noop(self): + User_Keys.objects.create( + username=self.user.username, key_type="TOTP", enabled=True, + properties={"secret_key": SECRET}) + self._run() + self._run() + self.assertEqual(Authenticator.objects.filter( + user=self.user, type=Authenticator.Type.TOTP).count(), 1) + + # --- signals ------------------------------------------------------ + + def test_emits_no_factor_added(self): + # A bulk import must not mail every migrated user under + # MFA_NOTIFY_ON_CHANGE on cutover day. + from django_mfa import events + seen = [] + + def receiver(sender, **kwargs): + seen.append(kwargs) + + events.factor_added.connect(receiver) + try: + User_Keys.objects.create( + username=self.user.username, key_type="TOTP", enabled=True, + properties={"secret_key": SECRET}) + self._run() + finally: + # Must disconnect, or this receiver leaks into every later test + # in the process and silently pollutes their signal assertions. + events.factor_added.disconnect(receiver) + self.assertEqual(seen, []) + + # --- absent source ----------------------------------------------- + + def test_missing_mfa2_raises_clean_command_error(self): + from django.core.management import CommandError as CE + + from django_mfa.management.commands import mfa_import_django_mfa2 as cmd_mod + + original = cmd_mod._get_model + cmd_mod._get_model = lambda: None + try: + with self.assertRaises(CE): + call_command("mfa_import_django_mfa2") + finally: + cmd_mod._get_model = original + + +class FindUserFallbackTests(TestCase): + """Unit tests for Command._find_user(), the fix for SHOULD FIX 5. + + django-mfa2 is not internally consistent about what it writes into + User_Keys.username -- USERNAME_FIELD from one Email.py write site, + a literal `username` attribute from every other site including all of + TOTP (see the module docstring). On this suite's user model + USERNAME_FIELD == "username", so a real end-to-end command run can + never actually exercise the mismatch -- AUTH_USER_MODEL is not swapped + anywhere in this test suite, and standing one up is out of scope for + this fix. These tests call _find_user() directly with a `field` that + deliberately differs from "username", using django.contrib.contenttypes + .ContentType (already installed, not the real user model, but any model + works for exercising this method's pure lookup-and-fallback logic) + standing in for "a user model whose USERNAME_FIELD isn't literally + 'username'". + """ + + def setUp(self): + self.command = Command() + self.ct = ContentType.objects.create( + app_label="a-distinctive-label", model="widget") + + def test_prefers_the_named_field_when_it_matches(self): + found = self.command._find_user( + ContentType, "app_label", "a-distinctive-label") + self.assertEqual(found, self.ct) + + def test_falls_back_to_literal_username_when_the_field_lookup_misses(self): + # ContentType has no "username" field at all, so the fallback branch + # needs a model that has one -- reuse the real user model, but with + # a `field` ("email") that deliberately does NOT hold the value the + # source row stored. alice's email is blank (create_user doesn't + # set one), so the direct "email" lookup misses and only the + # literal-`username` fallback can find her. + alice = User.objects.create_user("alice", password="pw") + found = self.command._find_user(User, "email", "alice") + self.assertEqual(found, alice) + + def test_prefers_the_named_field_over_the_fallback_when_both_could_match(self): + # Two different users: one whose USERNAME_FIELD-analogue ("email" + # here) is "shared-value", another whose literal `username` is + # "shared-value". The named-field lookup must win -- it is tried + # first and is correct for a row written by Email.py's enrolment + # path. + field_match = User.objects.create_user( + "field-match", email="shared-value", password="pw") + User.objects.create_user("shared-value", password="pw") + found = self.command._find_user(User, "email", "shared-value") + self.assertEqual(found, field_match) + + def test_returns_none_when_neither_the_field_nor_username_matches(self): + found = self.command._find_user(User, "email", "nobody-at-all") + self.assertIsNone(found) + + def test_field_error_from_a_model_with_no_username_field_is_swallowed(self): + # ContentType has neither "username" nor any value equal to this + # bogus app_label, so the primary lookup misses and the fallback's + # own `.filter(username=...)` raises FieldError (no such field) -- + # this must return None, not propagate. + found = self.command._find_user( + ContentType, "app_label", "no-such-label") + self.assertIsNone(found) + + def test_default_field_never_reaches_the_fallback(self): + # field == "username" is the default-user-model case (this + # project's own test suite runs under it end to end): the fallback + # branch is unreachable because it's the same lookup already tried. + alice = User.objects.create_user("alice", password="pw") + found = self.command._find_user(User, "username", "alice") + self.assertEqual(found, alice) diff --git a/django_mfa/tests/test_import_django_otp.py b/django_mfa/tests/test_import_django_otp.py new file mode 100644 index 0000000..ac2ea29 --- /dev/null +++ b/django_mfa/tests/test_import_django_otp.py @@ -0,0 +1,380 @@ +import base64 +from io import StringIO + +from django.contrib.auth.models import User +from django.core.management import call_command +from django.test import TestCase +from django_otp.oath import TOTP as UpstreamTOTP +from django_otp.plugins.otp_email.models import EmailDevice +from django_otp.plugins.otp_static.models import StaticDevice, StaticToken +from django_otp.plugins.otp_totp.models import TOTPDevice + +from django_mfa import totp as totp_mod +from django_mfa.adapters.email import EmailAdapter +from django_mfa.adapters.recovery_codes import RecoveryCodesAdapter +from django_mfa.crypto import decrypt +from django_mfa.models import Authenticator +from django_mfa.registry import registry + +HEX_KEY = "3132333435363738393031323334353637383930" # b"12345678901234567890" +#: A second, distinct key for tests about two source devices in one run. +OTHER_HEX_KEY = "61" * 20 # b"a" * 20 + + +class ImportDjangoOtpTests(TestCase): + def setUp(self): + self.user = User.objects.create_user("alice", password="pw") + + def _run(self, *args): + out = StringIO() + call_command("mfa_import_django_otp", *args, stdout=out) + return out.getvalue() + + def test_imports_a_confirmed_totp_device(self): + TOTPDevice.objects.create(user=self.user, key=HEX_KEY, confirmed=True) + self._run() + auth = Authenticator.objects.get( + user=self.user, type=Authenticator.Type.TOTP) + self.assertEqual( + decrypt(auth.data["secret"]), + base64.b32encode(bytes.fromhex(HEX_KEY)).decode()) + + def test_imported_secret_matches_django_otp_cross_implementation(self): + """The real acceptance test for the hex->base32 conversion. + + The previous version of this test compared totp_mod.TOTP(secret).now() + against totp_mod.TOTP(secret).verify(...) -- both calls into our OWN + code, which is true for any well-formed base32 secret and proves + nothing about whether the conversion from django-otp's hex key is + actually correct (a deliberately wrong conversion, e.g. + base64.b32encode(HEX_KEY.encode()) -- base32 of the hex ASCII text + instead of the hex-decoded bytes -- also passes it). + + This instead compares against django-otp's OWN token computation, fed + the same raw key bytes the import started from (device.bin_key), at a + time pinned so the comparison can't flake across a 30s window + boundary the two calls might straddle differently. + """ + device = TOTPDevice.objects.create( + user=self.user, key=HEX_KEY, confirmed=True) + self._run() + auth = Authenticator.objects.get( + user=self.user, type=Authenticator.Type.TOTP) + secret = decrypt(auth.data["secret"]) + + fixed_time = 1_700_000_000 # arbitrary Unix timestamp, pinned + + upstream = UpstreamTOTP(device.bin_key) + upstream.time = fixed_time + # django_otp's token() returns an unpadded int; django_mfa's own + # generate_otp() already zero-pads to `digits` characters. + expected = f"{upstream.token():0{device.digits}d}" + + ours = totp_mod.TOTP(secret).at(fixed_time) + + self.assertEqual(ours, expected) + + def test_skips_unconfirmed_devices(self): + TOTPDevice.objects.create(user=self.user, key=HEX_KEY, confirmed=False) + self._run() + self.assertFalse(Authenticator.objects.filter(user=self.user).exists()) + + def test_skips_nondefault_digits(self): + # django_mfa.totp is fixed at 6 digits / 30s / T0=0 / no drift. + # Importing an 8-digit device would produce a factor whose codes + # never match -- a silent lockout, the worst possible migration + # failure. + TOTPDevice.objects.create(user=self.user, key=HEX_KEY, confirmed=True, + digits=8) + output = self._run() + self.assertFalse(Authenticator.objects.filter(user=self.user).exists()) + self.assertIn("digits", output) + self.assertIn("re-enrol", output.lower()) + + def test_skips_nondefault_step(self): + TOTPDevice.objects.create(user=self.user, key=HEX_KEY, confirmed=True, + step=60) + self._run() + self.assertFalse(Authenticator.objects.filter(user=self.user).exists()) + + def test_skips_nondefault_t0(self): + TOTPDevice.objects.create(user=self.user, key=HEX_KEY, confirmed=True, + t0=1000) + output = self._run() + self.assertFalse(Authenticator.objects.filter(user=self.user).exists()) + self.assertIn("t0", output) + + def test_skips_nondefault_drift(self): + # drift is a counter term, not verification-only bookkeeping: + # django_otp.oath.TOTP.t() computes + # `((time - t0) // step) + drift` -- a nonzero drift shifts the + # counter exactly like a nonzero t0 would. It also *accumulates*: + # TOTPDevice.verify_token() persists a new drift on every successful + # verification whenever OTP_TOTP_SYNC is on (django-otp's default), + # so a device with ordinary digits/step/t0 can still carry a + # nonzero drift purely from a phone clock that runs fast. Importing + # it without checking drift would "succeed" and then produce codes + # outside django_mfa's fixed +/-1 step window from the very next + # login. + TOTPDevice.objects.create(user=self.user, key=HEX_KEY, confirmed=True, + drift=4) + output = self._run() + self.assertFalse(Authenticator.objects.filter(user=self.user).exists()) + self.assertIn("drift", output) + + def test_skips_nondefault_tolerance(self): + # tolerance is TOTPDevice's acceptance-window field -- the number of + # steps either side of "now" verify_token() will accept + # (TOTPDevice.verify_token() calls totp.verify(token, + # self.tolerance, ...)). It defaults to 1, matching django_mfa's own + # TOTP_VALID_WINDOW = 1, but it is a stored per-device value an + # operator could have widened -- importing a device with a wider + # tolerance without checking it would "import clean" today and then + # silently narrow the window the user is used to, on a source + # install with OTP_TOTP_SYNC off (so drift never grows to + # compensate). + TOTPDevice.objects.create(user=self.user, key=HEX_KEY, confirmed=True, + tolerance=5) + output = self._run() + self.assertFalse(Authenticator.objects.filter(user=self.user).exists()) + self.assertIn("tolerance", output) + + def test_default_tolerance_device_imports_cleanly(self): + # Sanity check for the fix above: a device that only differs by NOT + # differing (tolerance at its default of 1) must still import -- + # confirms SUPPORTED_TOTP's new "tolerance": 1 entry matches + # TOTPDevice's real default rather than accidentally rejecting every + # ordinary device. + TOTPDevice.objects.create(user=self.user, key=HEX_KEY, confirmed=True) + self._run() + self.assertTrue(Authenticator.objects.filter( + user=self.user, type=Authenticator.Type.TOTP).exists()) + + def test_imports_static_tokens_as_recovery_codes(self): + device = StaticDevice.objects.create(user=self.user, name="backup") + StaticToken.objects.create(device=device, token="aaaa1111") + StaticToken.objects.create(device=device, token="bbbb2222") + self._run() + auth = Authenticator.objects.get( + user=self.user, type=Authenticator.Type.RECOVERY_CODES) + self.assertEqual(len(auth.data["codes"]), 2) + self.assertEqual(auth.data["used"], []) + # Hashed, not stored plaintext. + self.assertNotIn("aaaa1111", str(auth.data["codes"])) + + def test_imported_recovery_codes_still_verify(self): + device = StaticDevice.objects.create(user=self.user, name="backup") + StaticToken.objects.create(device=device, token="aaaa1111") + self._run() + adapter = RecoveryCodesAdapter() + self.assertTrue(adapter.complete_verify( + None, self.user, {"code": "aaaa1111"})) + + def test_skips_unconfirmed_static_device(self): + # StaticDevice inherits `confirmed` from django_otp's abstract + # Device model exactly as TOTPDevice/EmailDevice do -- an + # unconfirmed set of backup codes is one the user never finished + # setting up and must not be imported. + device = StaticDevice.objects.create( + user=self.user, name="backup", confirmed=False) + StaticToken.objects.create(device=device, token="aaaa1111") + self._run() + self.assertFalse(Authenticator.objects.filter( + user=self.user, type=Authenticator.Type.RECOVERY_CODES).exists()) + + def test_static_device_with_no_tokens_is_not_imported(self): + StaticDevice.objects.create(user=self.user, name="backup") + self._run() + self.assertFalse(Authenticator.objects.filter( + user=self.user, type=Authenticator.Type.RECOVERY_CODES).exists()) + + def test_imports_confirmed_email_device(self): + # "email" is off MFA_FACTORS by default (adapters/email.py's own + # module docstring), so nothing registers an EmailAdapter unless a + # test opts in -- same pattern test_adapter_email.py uses, since + # MFA_FACTORS is read once at app startup and + # override_settings(MFA_FACTORS=...) afterwards has no effect on + # the already-populated registry. + registry.register(EmailAdapter()) + self.addCleanup(registry.unregister, "email") + self.user.email = "alice@example.com" + self.user.save() + EmailDevice.objects.create(user=self.user, confirmed=True) + self._run() + auth = Authenticator.objects.get( + user=self.user, type=Authenticator.Type.EMAIL) + self.assertEqual(auth.data["address"], "alice@example.com") + + def test_email_device_prefers_its_own_address(self): + registry.register(EmailAdapter()) + self.addCleanup(registry.unregister, "email") + self.user.email = "alice@example.com" + self.user.save() + EmailDevice.objects.create( + user=self.user, confirmed=True, email="alt@example.com") + self._run() + auth = Authenticator.objects.get( + user=self.user, type=Authenticator.Type.EMAIL) + self.assertEqual(auth.data["address"], "alt@example.com") + + def test_skips_unconfirmed_email_device(self): + self.user.email = "alice@example.com" + self.user.save() + EmailDevice.objects.create(user=self.user, confirmed=False) + self._run() + self.assertFalse(Authenticator.objects.filter( + user=self.user, type=Authenticator.Type.EMAIL).exists()) + + def test_email_device_with_no_address_anywhere_is_skipped(self): + EmailDevice.objects.create(user=self.user, confirmed=True) + output = self._run() + self.assertFalse(Authenticator.objects.filter( + user=self.user, type=Authenticator.Type.EMAIL).exists()) + self.assertIn("no address", output) + + def test_email_import_skipped_when_adapter_not_registered(self): + # Default MFA_FACTORS excludes "email". Importing an EmailDevice + # under that default must not create a row that + # registry.has_primary_factor(user) can never see -- an inert row + # that looks like protection to an operator reading the summary but + # leaves the user bounced to enrolment. This is the scenario the + # other two "happy path" email tests above deliberately opt out of + # by registering EmailAdapter themselves. + self.user.email = "alice@example.com" + self.user.save() + EmailDevice.objects.create(user=self.user, confirmed=True) + output = self._run() + self.assertFalse(Authenticator.objects.filter( + user=self.user, type=Authenticator.Type.EMAIL).exists()) + self.assertIn("MFA_FACTORS", output) + self.assertFalse(registry.has_primary_factor(self.user)) + + def test_second_totp_device_in_same_run_is_not_silently_overwritten(self): + # django-otp puts no uniqueness constraint on Device.user -- two + # confirmed TOTPDevices for one user is a legal state after a + # re-enrolment that never cleaned up the old device. With + # --overwrite and no ordering/dedup, which one "wins" depended on + # undefined queryset order; the summary also over-counted + # "imported" against the single row actually left on disk. + TOTPDevice.objects.create(user=self.user, key=HEX_KEY, confirmed=True) + TOTPDevice.objects.create( + user=self.user, key=OTHER_HEX_KEY, confirmed=True) + output = self._run("--overwrite") + self.assertEqual(Authenticator.objects.filter( + user=self.user, type=Authenticator.Type.TOTP).count(), 1) + auth = Authenticator.objects.get( + user=self.user, type=Authenticator.Type.TOTP) + # Deterministic: the lower-pk (first-created) device is the one + # that's kept. + self.assertEqual( + decrypt(auth.data["secret"]), + base64.b32encode(bytes.fromhex(HEX_KEY)).decode()) + self.assertIn("second", output.lower()) + + def test_second_static_device_in_same_run_is_not_silently_overwritten(self): + device_a = StaticDevice.objects.create(user=self.user, name="a") + StaticToken.objects.create(device=device_a, token="aaaa1111") + device_b = StaticDevice.objects.create(user=self.user, name="b") + StaticToken.objects.create(device=device_b, token="bbbb2222") + output = self._run("--overwrite") + self.assertEqual(Authenticator.objects.filter( + user=self.user, type=Authenticator.Type.RECOVERY_CODES).count(), 1) + auth = Authenticator.objects.get( + user=self.user, type=Authenticator.Type.RECOVERY_CODES) + # device_a's single code survived; device_b's was not silently + # merged in nor allowed to replace it. + self.assertEqual(len(auth.data["codes"]), 1) + self.assertIn("second", output.lower()) + + def test_existing_factor_is_skipped_not_replaced(self): + existing = Authenticator.objects.create( + user=self.user, type=Authenticator.Type.TOTP, + data={"secret": "untouched"}) + TOTPDevice.objects.create(user=self.user, key=HEX_KEY, confirmed=True) + output = self._run() + existing.refresh_from_db() + self.assertEqual(existing.data["secret"], "untouched") + self.assertIn("skipped", output.lower()) + + def test_overwrite_replaces(self): + Authenticator.objects.create( + user=self.user, type=Authenticator.Type.TOTP, + data={"secret": "untouched"}) + TOTPDevice.objects.create(user=self.user, key=HEX_KEY, confirmed=True) + self._run("--overwrite") + auth = Authenticator.objects.get(user=self.user, + type=Authenticator.Type.TOTP) + self.assertNotEqual(auth.data["secret"], "untouched") + + def test_dry_run_writes_nothing(self): + TOTPDevice.objects.create(user=self.user, key=HEX_KEY, confirmed=True) + output = self._run("--dry-run") + self.assertFalse(Authenticator.objects.filter(user=self.user).exists()) + self.assertIn("1", output) + + def test_dry_run_marks_the_per_row_imported_line_too(self): + # Not just the summary: an operator reading a real cutover's + # "imported totp for alice" line has no way to tell it apart from a + # --dry-run rehearsal's identical line once the summary has scrolled + # off. Every line printed while nothing is actually being written + # must say so. + TOTPDevice.objects.create(user=self.user, key=HEX_KEY, confirmed=True) + output = self._run("--dry-run") + imported_line = next( + line for line in output.splitlines() if "imported totp" in line) + self.assertIn("[dry run]", imported_line) + + def test_dry_run_marks_the_per_row_skipped_line_too(self): + TOTPDevice.objects.create(user=self.user, key=HEX_KEY, confirmed=True, + digits=8) + output = self._run("--dry-run") + skipped_line = next( + line for line in output.splitlines() if "skipped" in line) + self.assertIn("[dry run]", skipped_line) + + def test_dry_run_overwrite_does_not_destroy_existing_factor(self): + # The one path where a bug would destroy a working factor: a + # --dry-run that (correctly) decides to overwrite must still not + # actually write anything. + existing = Authenticator.objects.create( + user=self.user, type=Authenticator.Type.TOTP, + data={"secret": "untouched"}) + TOTPDevice.objects.create(user=self.user, key=HEX_KEY, confirmed=True) + self._run("--dry-run", "--overwrite") + existing.refresh_from_db() + self.assertEqual(existing.data["secret"], "untouched") + + def test_rerun_is_a_noop(self): + TOTPDevice.objects.create(user=self.user, key=HEX_KEY, confirmed=True) + self._run() + self._run() + self.assertEqual(Authenticator.objects.filter( + user=self.user, type=Authenticator.Type.TOTP).count(), 1) + + def test_users_flag_narrows(self): + other = User.objects.create_user("bob", password="pw") + TOTPDevice.objects.create(user=self.user, key=HEX_KEY, confirmed=True) + TOTPDevice.objects.create(user=other, key=HEX_KEY, confirmed=True) + self._run("--users", "alice") + self.assertTrue(Authenticator.objects.filter(user=self.user).exists()) + self.assertFalse(Authenticator.objects.filter(user=other).exists()) + + def test_emits_no_factor_added(self): + # A bulk import must not mail every migrated user under + # MFA_NOTIFY_ON_CHANGE on cutover day. + from django_mfa import events + seen = [] + + def receiver(sender, **kwargs): + seen.append(kwargs) + + events.factor_added.connect(receiver) + try: + TOTPDevice.objects.create(user=self.user, key=HEX_KEY, + confirmed=True) + self._run() + finally: + # Must disconnect, or this receiver leaks into every later test in + # the process and silently pollutes their signal assertions. + events.factor_added.disconnect(receiver) + self.assertEqual(seen, []) diff --git a/django_mfa/tests/test_migrations.py b/django_mfa/tests/test_migrations.py index 14cb974..4f018a5 100644 --- a/django_mfa/tests/test_migrations.py +++ b/django_mfa/tests/test_migrations.py @@ -305,6 +305,7 @@ def test_forward_from_zero_still_applies_through_0007(self): "0006_mfa_user_handle", "0007_drop_legacy_models", "0008_email_factor", + "0009_mfa_exemption", }, ) diff --git a/django_mfa/tests/test_models.py b/django_mfa/tests/test_models.py index 52f5702..7b1756b 100644 --- a/django_mfa/tests/test_models.py +++ b/django_mfa/tests/test_models.py @@ -50,11 +50,13 @@ def test_authenticator_is_the_only_registered_model(self): # app label. The original brief predates that addition and asserted # {"Authenticator"} alone; the task-20 instructions explicitly say # Authenticator and MfaUserHandle are the only models the app - # registers, so that is what this test checks. + # registers, so that is what this test checks. MfaExemption + # (django_mfa/models.py, added for the MFA_REQUIRED exemption + # feature) is the third and, as of that feature, final entry. from django.apps import apps names = {m.__name__ for m in apps.get_app_config("django_mfa").get_models()} - self.assertEqual(names, {"Authenticator", "MfaUserHandle"}) + self.assertEqual(names, {"Authenticator", "MfaUserHandle", "MfaExemption"}) class EmailIsASingletonFactorTests(TestCase): diff --git a/django_mfa/tests/test_notifications.py b/django_mfa/tests/test_notifications.py index 14def1c..8cb5fce 100644 --- a/django_mfa/tests/test_notifications.py +++ b/django_mfa/tests/test_notifications.py @@ -1,3 +1,4 @@ +import time from unittest import mock from django.contrib.auth.models import User @@ -5,7 +6,7 @@ from django.test import Client, TestCase, override_settings from django.urls import reverse -from django_mfa import events +from django_mfa import events, notifications from django_mfa.adapters.recovery_codes import RecoveryCodesAdapter from django_mfa.adapters.totp import generate_secret from django_mfa.crypto import encrypt @@ -24,7 +25,10 @@ def verified_login(self): data={"secret": encrypt(generate_secret())}) self.client.login(username="ashwin", password="pw") session = self.client.session - session["mfa"] = {"verified": True, "method": "totp", "at": 0} + # A recent "at", not 0 -- these tests drive enroll_factor/manage, + # which now require a fresh challenge (mfa_recent_required), not + # merely a verified session. See test_stepup.py. + session["mfa"] = {"verified": True, "method": "totp", "at": int(time.time())} session.save() @@ -62,14 +66,20 @@ def test_removing_a_factor_does_not_touch_the_registry_when_off(self): The early return at the top of the receiver must make a default install exactly as cheap as before notifications existed: the registry is never even consulted. - """ - self.verified_login() - webauthn = Authenticator.objects.create( - user=self.user, type="webauthn", data={}) + Calls the receiver directly rather than going through mfa:manage: + that view is now gated by mfa_recent_required (see test_stepup.py), + which legitimately calls registry.has_primary_factor() itself on + every request regardless of this setting -- routing through the full + request would make those two calls indistinguishable from a + (hypothetical, regressed) call by the receiver this test exists to + catch. + """ with mock.patch( "django_mfa.registry.registry.has_primary_factor") as mocked: - self.client.post(reverse("mfa:manage"), {"pk": webauthn.pk}) + notifications.notify_factor_removed( + sender=None, user=self.user, factor_type="webauthn", + name="k") mocked.assert_not_called() diff --git a/django_mfa/tests/test_stepup.py b/django_mfa/tests/test_stepup.py new file mode 100644 index 0000000..74a37cc --- /dev/null +++ b/django_mfa/tests/test_stepup.py @@ -0,0 +1,420 @@ +import time + +from django.contrib.auth.models import User +from django.http import HttpResponse +from django.test import RequestFactory, TestCase, override_settings +from django.urls import reverse +from django.views import View + +from django_mfa import session +from django_mfa.adapters.totp import generate_secret +from django_mfa.checks import check_stepup_max_age +from django_mfa.crypto import encrypt +from django_mfa.decorators import MfaRecentRequiredMixin, mfa_recent_required +from django_mfa.models import Authenticator + + +class FreshnessTests(TestCase): + def setUp(self): + self.factory = RequestFactory() + + def _request(self, mfa_state): + request = self.factory.get("/") + request.session = {} + if mfa_state is not None: + request.session["mfa"] = mfa_state + return request + + def test_no_mfa_state_is_not_fresh(self): + self.assertFalse(session.is_fresh(self._request(None), 300)) + + def test_pending_session_is_not_fresh(self): + request = self._request({"verified": False, "method": None, "at": None}) + self.assertFalse(session.is_fresh(request, 300)) + + def test_verified_without_at_is_stale(self): + # A session verified by 4.1.0 or earlier. Stale is the safe direction. + request = self._request({"verified": True, "method": "totp", "at": None}) + self.assertFalse(session.is_fresh(request, 300)) + + def test_recently_verified_is_fresh(self): + request = self._request( + {"verified": True, "method": "totp", "at": int(time.time())}) + self.assertTrue(session.is_fresh(request, 300)) + + def test_old_verification_is_stale(self): + request = self._request( + {"verified": True, "method": "totp", "at": int(time.time()) - 301}) + self.assertFalse(session.is_fresh(request, 300)) + + def test_exactly_at_the_boundary_is_fresh(self): + request = self._request( + {"verified": True, "method": "totp", "at": int(time.time()) - 300}) + self.assertTrue(session.is_fresh(request, 300)) + + def test_verified_at_returns_the_stamp(self): + stamp = int(time.time()) + request = self._request({"verified": True, "method": "totp", "at": stamp}) + self.assertEqual(session.verified_at(request), stamp) + + def test_verified_at_is_none_without_state(self): + self.assertIsNone(session.verified_at(self._request(None))) + + +class StepUpCheckTests(TestCase): + def test_default_passes(self): + self.assertEqual(check_stepup_max_age(None), []) + + @override_settings(MFA_STEPUP_MAX_AGE=None) + def test_none_passes(self): + self.assertEqual(check_stepup_max_age(None), []) + + @override_settings(MFA_STEPUP_MAX_AGE=0) + def test_zero_is_refused(self): + errors = check_stepup_max_age(None) + self.assertEqual([e.id for e in errors], ["django_mfa.E005"]) + + @override_settings(MFA_STEPUP_MAX_AGE=-1) + def test_negative_is_refused(self): + self.assertEqual([e.id for e in check_stepup_max_age(None)], + ["django_mfa.E005"]) + + @override_settings(MFA_STEPUP_MAX_AGE=True) + def test_bool_is_refused(self): + # bool is a subclass of int; True would silently mean "1 second". + self.assertEqual([e.id for e in check_stepup_max_age(None)], + ["django_mfa.E005"]) + + @override_settings(MFA_STEPUP_MAX_AGE="300") + def test_string_is_refused(self): + self.assertEqual([e.id for e in check_stepup_max_age(None)], + ["django_mfa.E005"]) + + +@mfa_recent_required() +def gated_view(request): + return HttpResponse("ok") + + +@mfa_recent_required +def bare_gated_view(request): + return HttpResponse("ok") + + +@mfa_recent_required(max_age=60) +def tight_gated_view(request): + return HttpResponse("ok") + + +@mfa_recent_required(allow_unenrolled=True) +def permissive_gated_view(request): + return HttpResponse("ok") + + +class GatedCBV(MfaRecentRequiredMixin, View): + def get(self, request): + return HttpResponse("ok") + + +class PermissiveGatedCBV(MfaRecentRequiredMixin, View): + mfa_allow_unenrolled = True + + def get(self, request): + return HttpResponse("ok") + + +class GateTests(TestCase): + def setUp(self): + self.factory = RequestFactory() + self.user = User.objects.create_user("a@example.com", password="pw") + + def _request(self, path="/thing/", method="get", at=None, verified=True, + user=None): + request = getattr(self.factory, method)(path) + request.user = user if user is not None else self.user + request.session = {} + if verified is not None: + request.session["mfa"] = { + "verified": verified, "method": "totp", "at": at} + return request + + def _give_totp(self): + Authenticator.objects.create( + user=self.user, type=Authenticator.Type.TOTP, + data={"secret": encrypt(generate_secret())}) + + def test_fresh_session_passes(self): + self._give_totp() + response = gated_view(self._request(at=int(time.time()))) + self.assertEqual(response.status_code, 200) + + def test_stale_session_redirects_to_verify(self): + self._give_totp() + response = gated_view(self._request(at=int(time.time()) - 3600)) + self.assertEqual(response.status_code, 302) + self.assertIn("/verify/", response["Location"]) + # redirect_to_login builds the querystring via + # QueryDict.urlencode(safe="/"), which deliberately leaves "/" + # unescaped -- see the matching comment in test_enforcement.py. + self.assertIn("next=/thing/", response["Location"]) + + def test_user_without_primary_factor_passes_while_stale(self): + # LOAD-BEARING: this is the first-enrolment path. A user with nothing + # to re-verify must never be gated when the view opts in via + # allow_unenrolled=True (as the built-in enrollment views do), or + # they are locked out of the only pages that could give them a + # factor. Uses permissive_gated_view, not gated_view: the default + # (allow_unenrolled=False) is exercised separately below and is + # strict, not permissive. + response = permissive_gated_view(self._request(at=None)) + self.assertEqual(response.status_code, 200) + + def test_recovery_codes_alone_do_not_trigger_the_gate(self): + # Recovery codes set counts_as_primary_factor = False, so a user + # holding only those has no primary factor and, under + # allow_unenrolled=True, must pass through. + Authenticator.objects.create( + user=self.user, type=Authenticator.Type.RECOVERY_CODES, + data={"codes": [], "used": []}) + response = permissive_gated_view(self._request(at=None)) + self.assertEqual(response.status_code, 200) + + def test_factorless_user_is_gated_by_default(self): + # allow_unenrolled defaults to False: mfa_recent_required is then + # strictly stronger than mfa_required, so a factorless user gets + # exactly the security_settings redirect mfa_required would give, + # never reaching the freshness rung at all. + response = gated_view(self._request(at=None)) + self.assertEqual(response.status_code, 302) + self.assertIn("/security/", response["Location"]) + self.assertIn("next=/thing/", response["Location"]) + + def test_factorless_user_passes_with_allow_unenrolled(self): + response = permissive_gated_view(self._request(at=None)) + self.assertEqual(response.status_code, 200) + + def test_unsafe_method_redirects_to_security_settings_not_the_post_url(self): + # manage_factors is POST-only and 405s on GET; bouncing the POST URL + # back through the verify flow would land the user on that 405. + self._give_totp() + response = gated_view(self._request(method="post", at=0)) + self.assertEqual(response.status_code, 302) + self.assertIn("next=/security/", response["Location"]) + + def test_explicit_next_url_overrides_for_unsafe_methods(self): + self._give_totp() + + @mfa_recent_required(next_url="/elsewhere/") + def view(request): + return HttpResponse("ok") + + response = view(self._request(method="post", at=0)) + self.assertIn("next=/elsewhere/", response["Location"]) + + @override_settings(MFA_STEPUP_MAX_AGE=None) + def test_setting_none_disables_the_gate(self): + self._give_totp() + response = gated_view(self._request(at=0)) + self.assertEqual(response.status_code, 200) + + def test_explicit_max_age_overrides_the_setting(self): + self._give_totp() + request = self._request(at=int(time.time()) - 120) + self.assertEqual(gated_view(request).status_code, 200) # 300s window + self.assertEqual(tight_gated_view(request).status_code, 302) # 60s window + + def test_bare_and_called_forms_behave_identically(self): + self._give_totp() + stale = self._request(at=0) + self.assertEqual(bare_gated_view(stale).status_code, 302) + self.assertEqual(gated_view(stale).status_code, 302) + + def test_pending_session_is_handled_by_the_earlier_rung(self): + self._give_totp() + response = gated_view(self._request(at=None, verified=False)) + self.assertEqual(response.status_code, 302) + self.assertIn("/verify/", response["Location"]) + + def test_mixin_matches_the_decorator(self): + self._give_totp() + view = GatedCBV.as_view() + self.assertEqual(view(self._request(at=int(time.time()))).status_code, 200) + self.assertEqual(view(self._request(at=0)).status_code, 302) + + def test_mixin_factorless_user_is_gated_by_default(self): + view = GatedCBV.as_view() + response = view(self._request(at=None)) + self.assertEqual(response.status_code, 302) + self.assertIn("/security/", response["Location"]) + + def test_mixin_factorless_user_passes_with_allow_unenrolled(self): + view = PermissiveGatedCBV.as_view() + response = view(self._request(at=None)) + self.assertEqual(response.status_code, 200) + + +class PickerNextTests(TestCase): + def setUp(self): + self.user = User.objects.create_user("a@example.com", password="pw") + self.client.force_login(self.user) + + def _give_totp(self): + Authenticator.objects.create( + user=self.user, type=Authenticator.Type.TOTP, + data={"secret": encrypt(generate_secret())}) + + def test_single_factor_forward_keeps_next(self): + self._give_totp() + response = self.client.get(reverse("mfa:verify") + "?next=/thing/") + self.assertEqual(response.status_code, 302) + self.assertEqual( + response["Location"], + reverse("mfa:verify_factor", args=["totp"]) + "?next=%2Fthing%2F") + + def test_single_factor_forward_drops_an_unsafe_next(self): + self._give_totp() + response = self.client.get( + reverse("mfa:verify") + "?next=https://evil.example.com/") + self.assertEqual(response["Location"], + reverse("mfa:verify_factor", args=["totp"])) + + def test_single_factor_forward_without_next_is_unchanged(self): + self._give_totp() + response = self.client.get(reverse("mfa:verify")) + self.assertEqual(response["Location"], + reverse("mfa:verify_factor", args=["totp"])) + + def test_picker_links_carry_next(self): + self._give_totp() + Authenticator.objects.create( + user=self.user, type=Authenticator.Type.RECOVERY_CODES, + data={"codes": [], "used": []}) + response = self.client.get(reverse("mfa:verify") + "?next=/thing/") + self.assertEqual(response.status_code, 200) + # The picker.html link is built with the `urlencode` template filter, + # not urllib.parse.urlencode -- Django's filter defaults to safe="/" + # (see django.template.defaultfilters.urlencode), so "/" survives + # unescaped here even though the single-factor redirect case below + # (built with urllib.parse.urlencode, no safe param) percent-encodes + # it to %2F. Both are safe; they're just two different encoders. + self.assertContains(response, "?next=/thing/") + + def test_picker_links_omit_an_unsafe_next(self): + self._give_totp() + Authenticator.objects.create( + user=self.user, type=Authenticator.Type.RECOVERY_CODES, + data={"codes": [], "used": []}) + response = self.client.get( + reverse("mfa:verify") + "?next=https://evil.example.com/") + self.assertNotContains(response, "evil.example.com") + + +class GatedViewTests(TestCase): + def setUp(self): + self.user = User.objects.create_user("a@example.com", password="pw") + self.client.force_login(self.user) + + def _verify(self, at): + s = self.client.session + s["mfa"] = {"verified": True, "method": "totp", "at": at} + s.save() + + def _give_totp(self): + return Authenticator.objects.create( + user=self.user, type=Authenticator.Type.TOTP, + data={"secret": encrypt(generate_secret())}) + + def test_stale_enroll_redirects(self): + self._give_totp() + self._verify(0) + response = self.client.get( + reverse("mfa:enroll_factor", args=["webauthn"])) + self.assertEqual(response.status_code, 302) + self.assertIn(reverse("mfa:verify"), response["Location"]) + + def test_fresh_enroll_allowed(self): + self._give_totp() + self._verify(int(time.time())) + response = self.client.get( + reverse("mfa:enroll_factor", args=["webauthn"])) + self.assertEqual(response.status_code, 200) + + def test_first_enrollment_is_never_gated(self): + # LOAD-BEARING lockout regression: no factor yet, no verified session. + response = self.client.get(reverse("mfa:enroll_factor", args=["totp"])) + self.assertEqual(response.status_code, 200) + + def test_stale_removal_is_refused_and_keeps_the_factor(self): + auth = self._give_totp() + self._verify(0) + response = self.client.post(reverse("mfa:manage"), {"pk": auth.pk}) + self.assertEqual(response.status_code, 302) + self.assertIn(reverse("mfa:verify"), response["Location"]) + self.assertTrue(Authenticator.objects.filter(pk=auth.pk).exists()) + + def test_stale_removal_returns_to_security_settings_not_a_405(self): + auth = self._give_totp() + self._verify(0) + response = self.client.post(reverse("mfa:manage"), {"pk": auth.pk}) + # redirect_to_login builds the querystring via + # QueryDict.urlencode(safe="/"), which deliberately leaves "/" + # unescaped -- see the matching comment on GateTests above. + self.assertIn("next=" + reverse("mfa:security_settings"), + response["Location"]) + + def test_fresh_removal_succeeds(self): + auth = self._give_totp() + self._verify(int(time.time())) + response = self.client.post(reverse("mfa:manage"), {"pk": auth.pk}) + self.assertEqual(response.status_code, 302) + self.assertFalse(Authenticator.objects.filter(pk=auth.pk).exists()) + + def test_stale_recovery_code_regeneration_is_refused(self): + self._give_totp() + self._verify(0) + response = self.client.get(reverse("mfa:recovery_codes")) + self.assertEqual(response.status_code, 302) + self.assertFalse(Authenticator.objects.filter( + user=self.user, type=Authenticator.Type.RECOVERY_CODES).exists()) + + def test_first_recovery_codes_without_a_factor_are_not_gated(self): + response = self.client.get(reverse("mfa:recovery_codes")) + self.assertEqual(response.status_code, 200) + + @override_settings(MFA_STEPUP_MAX_AGE=None) + def test_disabled_gate_restores_4_1_behaviour(self): + auth = self._give_totp() + self._verify(0) + self.client.post(reverse("mfa:manage"), {"pk": auth.pk}) + self.assertFalse(Authenticator.objects.filter(pk=auth.pk).exists()) + + @override_settings(MFA_STEPUP_MAX_AGE=None) + def test_disabled_gate_restores_4_1_behaviour_for_a_factorless_user_too(self): + # Fix round 1: the test above (and GateTests. + # test_setting_none_disables_the_gate) both call _give_totp() first, + # so they only exercise the population for which MFA_STEPUP_MAX_AGE + # = None already worked -- a user WITH a primary factor. A user + # whose only row is recovery_codes has none + # (counts_as_primary_factor = False), and manage_factors used to + # wall such a user to mfa:security_settings regardless of this + # setting, because _enforce()'s factorless rung ran before + # _enforce_recent() ever consulted MFA_STEPUP_MAX_AGE. That made + # "restore 4.1.0 behaviour exactly" false for exactly this user. + auth = Authenticator.objects.create( + user=self.user, type=Authenticator.Type.RECOVERY_CODES, + data={"codes": [], "used": []}) + self._verify(0) + self.client.post(reverse("mfa:manage"), {"pk": auth.pk}) + self.assertFalse(Authenticator.objects.filter(pk=auth.pk).exists()) + + def test_security_settings_itself_is_never_gated(self): + # The gate's own redirect target must not itself require freshness -- + # a stale user bounced here by manage_factors/enroll_factor/ + # recovery_codes has to be able to land, or the redirect loops + # forever. Only implicitly covered elsewhere (test_views.py's + # SecuritySettingsTests, which never stamps a stale session); pin + # the invariant here by name, next to the rest of the gate's tests. + self._give_totp() + self._verify(0) + response = self.client.get(reverse("mfa:security_settings")) + self.assertEqual(response.status_code, 200) diff --git a/django_mfa/tests/test_views.py b/django_mfa/tests/test_views.py index bd3d52e..1eae7e3 100644 --- a/django_mfa/tests/test_views.py +++ b/django_mfa/tests/test_views.py @@ -2,6 +2,7 @@ import json import os import re +import time import xml.etree.ElementTree as ET from unittest.mock import patch @@ -236,6 +237,21 @@ def setUp(self): self.auth = adapter.complete_enroll( request, {"credential": json.dumps(credential), "name": "k"}) + def _stamp_fresh_session(self): + """This authenticator was enrolled directly through the adapter, not + through enroll_factor, so self.client's session was never marked + verified. enroll_factor is now gated by mfa_recent_required, so the + enroll_* tests below need a fresh stamp to reach the adapter failure + modes they actually test (covered separately by test_stepup.py's + GatedViewTests). Only used by those -- the verify_factor tests in + this class rely on the session starting out NOT verified, since they + assert on how it changes. + """ + session = self.client.session + session["mfa"] = {"verified": True, "method": "webauthn", + "at": int(time.time())} + session.save() + # -- verify_factor (webauthn) -------------------------------------------- def _verify_webauthn(self, data): @@ -336,11 +352,13 @@ def test_control_genuine_assertion_succeeds_without_a_prior_failure(self): # -- enroll_factor (totp + webauthn) ------------------------------------- def test_enroll_totp_missing_fields_is_400_not_500(self): + self._stamp_fresh_session() response = self.client.post(reverse("mfa:enroll_factor", args=["totp"]), {}) self.assertEqual(response.status_code, 400) self.assertEqual(response.context["error_message"], GENERIC_ERROR) def test_enroll_webauthn_missing_credential_field_is_400_not_500(self): + self._stamp_fresh_session() with self.settings(MFA_FIDO2_RP_ID="testserver"): self.client.get(reverse("mfa:enroll_factor", args=["webauthn"])) response = self.client.post( @@ -349,6 +367,7 @@ def test_enroll_webauthn_missing_credential_field_is_400_not_500(self): self.assertEqual(response.context["error_message"], GENERIC_ERROR) def test_enroll_webauthn_tampered_credential_shape_is_400_not_500(self): + self._stamp_fresh_session() with self.settings(MFA_FIDO2_RP_ID="testserver"): self.client.get(reverse("mfa:enroll_factor", args=["webauthn"])) response = self.client.post( @@ -426,6 +445,14 @@ def test_a_user_can_delete_their_own_authenticator_through_the_rendered_page(sel get_response = self.client.get(reverse("mfa:security_settings")) self.assertIn(f'value="{auth.pk}"', get_response.content.decode()) + # mfa:manage is gated by mfa_recent_required -- give this session a + # fresh stamp so the delete below exercises manage_factors itself + # rather than the gate (covered by test_stepup.py's GatedViewTests). + session = self.client.session + session["mfa"] = {"verified": True, "method": "totp", + "at": int(time.time())} + session.save() + post_response = self.client.post(reverse("mfa:manage"), {"pk": auth.pk}) self.assertRedirects(post_response, reverse("mfa:security_settings")) self.assertFalse(Authenticator.objects.filter(pk=auth.pk).exists()) @@ -539,6 +566,18 @@ def setUp(self): self.other = User.objects.create_user("b@example.com", password="pw") self.client = Client() self.client.login(username="a@example.com", password="pw") + # A baseline factor (webauthn: supports_multiple, so it never + # collides with a test's own totp/webauthn row) plus a fresh + # verified session, so these tests exercise manage_factors' own + # logic (delete, 404s, 405, forbidden) rather than the + # mfa_recent_required gate now in front of it -- gate behaviour + # itself is covered by test_stepup.py's GatedViewTests. + Authenticator.objects.create( + user=self.user, type="webauthn", name="baseline", data={}) + session = self.client.session + session["mfa"] = {"verified": True, "method": "webauthn", + "at": int(time.time())} + session.save() def test_deletes_own_authenticator_and_redirects(self): auth = Authenticator.objects.create(user=self.user, type="totp") diff --git a/django_mfa/views/enroll.py b/django_mfa/views/enroll.py index 4b0c8e9..b1c91e4 100644 --- a/django_mfa/views/enroll.py +++ b/django_mfa/views/enroll.py @@ -5,11 +5,13 @@ from django_mfa import events, session from django_mfa.conf import settings as mfa_settings +from django_mfa.decorators import mfa_recent_required from django_mfa.models import Authenticator from django_mfa.views.verify import GENERIC_ERROR, _adapter_or_404 @login_required +@mfa_recent_required(allow_unenrolled=True) def enroll_factor(request, factor_type): adapter = _adapter_or_404(factor_type) if not adapter.supports_enroll: diff --git a/django_mfa/views/manage.py b/django_mfa/views/manage.py index 5bc3bd9..19c8700 100644 --- a/django_mfa/views/manage.py +++ b/django_mfa/views/manage.py @@ -7,6 +7,7 @@ from django_mfa import events, policy from django_mfa.adapters.recovery_codes import RecoveryCodesAdapter from django_mfa.conf import settings as mfa_settings +from django_mfa.decorators import mfa_recent_required from django_mfa.models import Authenticator from django_mfa.registry import registry @@ -31,15 +32,21 @@ def security_settings(request): # has_primary_factor, not primary_enabled_for: only the yes/no answer # is needed here, and this view is the one every enrollment-required # user lands back on after every action, so it runs on every one of - # those requests. - "mfa_enrollment_required": ( - policy.mfa_required_for(request.user) - and not registry.has_primary_factor(request.user)), + # those requests. Same three-part order as the middleware's rung: + # policy.resolve() gates the other two so MFA_REQUIRED = False costs + # nothing, and has_primary_factor() then runs before + # mfa_required_for() so the exemption lookup inside it only runs for + # a user actually about to be walled. + "mfa_enrollment_required": bool( + policy.resolve() + and not registry.has_primary_factor(request.user) + and policy.mfa_required_for(request.user)), } return render(request, "django_mfa/security.html", context) @login_required +@mfa_recent_required(allow_unenrolled=True) def recovery_codes(request): """Show the user their recovery codes. @@ -72,6 +79,7 @@ def recovery_codes(request): @login_required +@mfa_recent_required(allow_unenrolled=True) def manage_factors(request): if request.method != "POST": return HttpResponseNotAllowed(["POST"]) diff --git a/django_mfa/views/picker.py b/django_mfa/views/picker.py index fba8434..ba39d68 100644 --- a/django_mfa/views/picker.py +++ b/django_mfa/views/picker.py @@ -1,17 +1,29 @@ +from urllib.parse import urlencode + from django.contrib.auth.decorators import login_required from django.shortcuts import redirect, render from django.urls import reverse from django_mfa.conf import settings as mfa_settings from django_mfa.registry import registry +from django_mfa.views.verify import safe_next_or_none @login_required def verify(request): adapters = registry.enabled_for(request.user) + # `next` has to survive both branches. Without it a single-factor user is + # sent to LOGIN_REDIRECT_URL after logging in rather than to the page they + # asked for, and the step-up gate (decorators.mfa_recent_required) can + # never return anyone to the view it interrupted. + next_url = safe_next_or_none(request) if len(adapters) == 1: - return redirect(reverse("mfa:verify_factor", args=[adapters[0].type])) + url = reverse("mfa:verify_factor", args=[adapters[0].type]) + if next_url: + url = f"{url}?{urlencode({'next': next_url})}" + return redirect(url) return render(request, "django_mfa/picker.html", { "adapters": adapters, + "next": next_url, "base_template": mfa_settings.MFA_BASE_TEMPLATE, }) diff --git a/django_mfa/views/verify.py b/django_mfa/views/verify.py index 6cd3ece..8e1ca3a 100644 --- a/django_mfa/views/verify.py +++ b/django_mfa/views/verify.py @@ -52,13 +52,25 @@ def _adapter_or_404(factor_type): raise Http404(f"Unknown factor {factor_type!r}") from None -def _safe_next(request): +def safe_next_or_none(request): + """The request's ``next``, or None when absent or not safe to honour. + + Split out of _safe_next() so views.picker can ask the same question + without inheriting the LOGIN_REDIRECT_URL fallback: the picker forwards + a URL and must append `next` only when there is a real one to append. + Both callers therefore apply one identical safety rule, and the picker + cannot become an open-redirect hop that verify_factor would have refused. + """ candidate = request.POST.get("next") or request.GET.get("next") if candidate and url_has_allowed_host_and_scheme( candidate, allowed_hosts={request.get_host()}, require_https=request.is_secure() ): return candidate - return resolve_url(settings.LOGIN_REDIRECT_URL) + return None + + +def _safe_next(request): + return safe_next_or_none(request) or resolve_url(settings.LOGIN_REDIRECT_URL) @login_required diff --git a/docs/api.md b/docs/api.md index 8f0eb33..a723a75 100644 --- a/docs/api.md +++ b/docs/api.md @@ -16,6 +16,8 @@ once already. | `is_pending(request)` | `True` if the session is awaiting a second factor. | | `start_pending(request)` | Mark the session as awaiting verification. | | `mark_verified(request, method)` | Mark it satisfied by `method` (a factor type string). | +| `verified_at(request)` | Unix timestamp of this session's last successful challenge, or `None`. | +| `is_fresh(request, max_age)` | `True` if the session verified a factor within the last `max_age` seconds. A verified session with no `verified_at()` (e.g. one from before 4.2.0) counts as stale, not fresh — see {doc}`enforcement`'s step-up section. This is the primitive `mfa_recent_required`/`MfaRecentRequiredMixin` are built on; use it directly if you're writing your own step-up policy instead of the decorator/mixin. | | `reset(request)` | Remove MFA state entirely. | Example: @@ -76,6 +78,30 @@ To remove a user's MFA — the administrative recovery path: Authenticator.objects.filter(user=user).delete() +## `django_mfa.models.MfaExemption` + +A user `MFA_REQUIRED` does not apply to, despite the predicate — see +{doc}`enforcement`'s "Exempting a user" section. Written only by the +`mfa_disable` management command (or by deleting the row, to revoke — see +{doc}`operations`), never through a web view: exempting somebody from a +security requirement is an operator action, not something a user can do to +themselves. + +| Field | Notes | +|---|---| +| `user` | `OneToOneField` to `AUTH_USER_MODEL`, related name `mfa_exemption`. | +| `reason` | Required, free text. `mfa_disable` refuses to grant an exemption without one. | +| `created_at` | Timestamp. | +| `expires_at` | `None` means permanent. Otherwise the exemption stops applying once this passes. | + +| Method | Returns | +|---|---| +| `MfaExemption.objects.active_for(user)` | This user's exemption if it is currently in force (`expires_at` is `None` or in the future), else `None`. What `policy.mfa_required_for()` consults. | +| `is_active()` | The same freshness check as an instance method, on an object you already have in hand. | + +Suppresses `MFA_REQUIRED` only. It does not open `@mfa_required` views — see +{doc}`enforcement` for why the two are deliberately independent. + ## `django_mfa.conf.settings` Resolved settings with defaults applied: @@ -164,7 +190,7 @@ django-mfa also **receives** `user_logged_in` (to stamp the session pending) and `user_logged_out` (to clear the quicklogin hint) — you don't connect anything for those, they're internal. -It **sends** five signals of its own, defined in `django_mfa.events` and re-exported +It **sends** six signals of its own, defined in `django_mfa.events` and re-exported from `django_mfa.signals` (either import path works): | Signal | kwargs | Fires when | @@ -174,6 +200,12 @@ from `django_mfa.signals` (either import path works): | `mfa_verified` | `user`, `method`, `request` | A second-factor challenge succeeds — from `verify_factor`'s success branch, and from `passkey_complete` when the passkey assertion carries User Verification (a UP-only passkey login logs the user in but has not satisfied a second factor, so it does not fire this). | | `mfa_verification_failed` | `user`, `method`, `request` | A challenge fails: a wrong code, a caught adapter exception (`ValueError`/`TypeError`/`KeyError`), **or an attempt refused outright by the rate limiter** — no adapter call happens in that last case, so a receiver watching for brute force needs to see refused attempts too, not only evaluated ones. | | `recovery_code_used` | `user`, `remaining`, `request` | A recovery code is spent. Sent from the adapter itself, not the view — only the adapter knows how many codes are left. | +| `mfa_exemption_changed` | `user`, `reason`, `expires_at`, `revoked`, `request` | The `mfa_disable` management command grants or revokes an `MfaExemption`. `reason` and `expires_at` are `None` on a revoke. `sender` is the `MfaExemption` model class, not an `Adapter` subclass — there is no adapter behind this one. | + +`request` is `None` when the event did not originate in a request — the +`mfa_reset` and `mfa_disable` management commands emit these signals too, so +that operator actions are auditable. Receivers must handle both, as the +example below does. Read the user off the `user` kwarg, never off `request.user`. On the passkey path `mfa_verified` fires between `session.mark_verified()` and `auth.login()` — the @@ -185,7 +217,9 @@ kwarg is correct on every path. a receiver can narrow with `sender=TOTPAdapter` — except on `factor_removed`, whose `sender` is `None` when the removed row's type is no longer registered (`MFA_FACTORS` was narrowed since it was enrolled, or a third-party adapter was unregistered): match -on the `factor_type` kwarg instead of `sender` if you need to handle that case. +on the `factor_type` kwarg instead of `sender` if you need to handle that case. On +`mfa_exemption_changed`, `sender` is always the `MfaExemption` model class — there is +no adapter behind an exemption at all. A receiver: @@ -198,10 +232,14 @@ A receiver: @receiver(factor_removed) def audit_factor_removal(sender, user, factor_type, name, request, **kwargs): - logger.info("factor removed: user=%s type=%s name=%r", - user.pk, factor_type, name) - -All five are sent with `send_robust()`, not `send()`: a raising receiver cannot break + # request is None when mfa_reset/mfa_disable removed the row instead + # of a request to django_mfa:manage -- log a source that makes sense + # either way rather than assuming request is never None. + source = request.META.get("REMOTE_ADDR") if request else "console" + logger.info("factor removed: user=%s type=%s name=%r from=%s", + user.pk, factor_type, name, source) + +All six are sent with `send_robust()`, not `send()`: a raising receiver cannot break the security action it's observing — enrolling, verifying, or removing a factor succeeds or fails independently of what your receiver does with the event. The other side of that trade is that `send_robust()` catches and discards the exception rather diff --git a/docs/enforcement.md b/docs/enforcement.md index 8e883ab..72810b6 100644 --- a/docs/enforcement.md +++ b/docs/enforcement.md @@ -50,6 +50,20 @@ import, or resolves to something that isn't callable — catching a misconfigure predicate at startup is a lot cheaper than discovering it from an exception raised inside the middleware on some user's live request. +### Exempting a user + + manage.py mfa_disable alice --reason "service account" --until 2099-12-31 + +An exemption suppresses **`MFA_REQUIRED` only**. It does not open +`@mfa_required` views: `MFA_REQUIRED` picks users, the decorator picks views, +and an exempt user reaching a decorated billing page is still redirected. +Revoke with `manage.py mfa_disable alice --revoke`, or by deleting the row in +the admin — both work, but they are not equivalent: only the command path +fires `mfa_exemption_changed`. Deleting the row in the admin is not audited +at all — Django's admin emits no signal of its own for it that django-mfa +listens for. If the deletion needs to appear in whatever is consuming that +signal, use `mfa_disable --revoke`, not the admin. + ## What a required user sees A user `MFA_REQUIRED` applies to, who holds no *primary* factor yet @@ -126,6 +140,51 @@ regardless of `MFA_REQUIRED`: the decorator is itself the policy for that view, a mirror of the site-wide one. A view behind `@mfa_required` requires a primary factor even on a `MFA_REQUIRED = False` project. +## Step-up re-authentication + +Adding, removing or regenerating a factor requires a *recent* challenge, not +merely a verified session. A session that verified longer ago than +`MFA_STEPUP_MAX_AGE` (default 300 seconds) is sent back through +`mfa:verify` first. For a safe request (`GET`) it is then returned to the +exact page it asked for. For an unsafe request (`POST`) it is returned to +`mfa:security_settings` instead, or wherever the decorator's own `next_url` +points — the POST body cannot survive the redirect through `mfa:verify`, so +there is nothing to replay; the user re-submits from a page they can act +from. + +Without this, a stolen or borrowed session could strip every factor from an +account and enrol its own without presenting anything — and the victim's +password reset would not evict the attacker, because the attacker now holds +a second factor of their own. + +Apply it to your own sensitive views: + + from django_mfa.decorators import mfa_recent_required, MfaRecentRequiredMixin + + @mfa_recent_required(max_age=60) + def transfer_funds(request): + ... + + class TransferView(MfaRecentRequiredMixin, FormView): + mfa_stepup_max_age = 60 + +`mfa_required`/`MfaRequiredMixin` take no configuration at all — `mfa_required` +is a plain decorator with no arguments, and `MfaRequiredMixin` defines no +attributes. `mfa_recent_required`/`MfaRecentRequiredMixin` enforce everything +those two do (pending session, then no-primary-factor) and add a freshness +rung on top, configured with three options neither of the plain forms takes +any of: `max_age` (falls back to `MFA_STEPUP_MAX_AGE` / `mfa_stepup_max_age` +when omitted), `next_url` / `mfa_stepup_next_url`, and `allow_unenrolled` / +`mfa_allow_unenrolled`, **`False` by default**. With the default, a user who +holds no primary factor is redirected to `mfa:security_settings` exactly as +`mfa_required` does — so `@mfa_recent_required(max_age=60)` is never +silently weaker than `@mfa_required`. Pass `allow_unenrolled=True` only for +a view that is itself part of a user's first-enrolment path (as +`enroll_factor` and `recovery_codes` do internally) — such a user has +nothing to re-verify yet, and gating that view would lock them out of the +only pages that could give them a factor. Most host-project views should +leave this at its default. + ## What is not enforced - **Unauthenticated requests.** Every wall above only applies once @@ -133,8 +192,9 @@ factor even on a `MFA_REQUIRED = False` project. password-reset flows are still yours to protect — `MFA_REQUIRED` cannot make MFA a precondition of authenticating in the first place, only of what an already-authenticated session can go on to reach. -- **Step-up re-authentication.** Once a session is verified, it stays verified for - the life of that session; django-mfa does not re-challenge before a sensitive - action. If you want that — say, requiring a fresh code before changing a password - — build it yourself on `session.is_verified()` plus your own policy. See - {doc}`security`. +- **Step-up re-authentication for your own views.** django-mfa applies + `mfa_recent_required` to its own three factor-mutating views (see above) but does + not — cannot — apply it to a view it doesn't know about. If you want a fresh + challenge before some other sensitive action of your own — say, changing a + password — apply `mfa_recent_required`/`MfaRecentRequiredMixin` yourself, as shown + above. See {doc}`security`. diff --git a/docs/index.md b/docs/index.md index 38b4307..24ca45e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -33,6 +33,7 @@ customizing enforcement recipes custom_factors +operations ``` ```{toctree} diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..f6e0fbe --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,290 @@ +# Operations + +Six `manage.py` commands for running django-mfa day to day, once it's already +wired into your project: helping a locked-out user, checking rollout +progress, granting a policy exemption, and migrating factors in from another +package. Nothing on this page changes how django-mfa behaves — see +{doc}`enforcement` and {doc}`settings` for that. This page is about the +commands themselves. + +Three of the six — `mfa_status`, `mfa_reset`, `mfa_disable` — take a `user` +argument as **username or pk** (`django_mfa`'s own `resolve_user()` tries +`USERNAME_FIELD` first, then falls back to pk only when the value is all +digits). `mfa_report` takes no `user` argument at all — it reports across +every user. The two importers take an optional `--users` instead, to narrow +an otherwise site-wide run — see +[Migrating from another package](#migrating-from-another-package) below. + +None of these commands write to a session directly, but that does **not** +mean their effect always waits for a user's next login — and whether it +does depends on the state of the user's *current* session, not only on +whether `MFA_REQUIRED` applies to them. `MfaMiddleware` checks two separate +walls, in order (see {doc}`enforcement`), and which of them a given session +hits determines the outcome: + +**A session already awaiting verification (pending) is hit on its very next +request, regardless of `MFA_REQUIRED` — and, after `mfa_reset`, badly.** The +pending-verification wall runs *before* the enrollment wall below and +applies unconditionally to any authenticated session that hasn't completed +its challenge yet — this is the state a user who is mid-login, or who is +phoning the support desk because they're stuck at the verify screen, is +actually in. It redirects to `mfa:verify`, and once `mfa_reset` has run, +that page has nothing to offer: the picker lists `registry.enabled_for(user)`, +which is now empty, and the template has no `{% empty %}` clause, so the +user sees the picker's heading over an empty list. `mfa:security_settings` +is reachable from the *enrollment* wall's exempt set but not from this one, +so there is no page this session can reach that would let the user +re-enroll. **The only way out is to log out and log back in** — a fresh +login re-evaluates the session against the user's now-empty factor set and +does not mark it pending. If you're resetting a user who is on the phone +mid-login, tell them to log out first, or to expect to have to. + +**A session that has already completed a challenge (verified) is affected +only through the enrollment wall, which is conditional on `MFA_REQUIRED`.** +That wall re-evaluates `registry.has_primary_factor(user)` and the +`MFA_REQUIRED` predicate on **every request** an authenticated, verified +user makes, not only at login. For a user `MFA_REQUIRED` currently applies +to, `mfa_reset` (which can drop their factor count to zero) or +`mfa_disable --revoke` (which can restore the requirement) takes effect on +that user's **very next request** — mid-session, with no re-login involved +— and lands them on `mfa:security_settings`, which (unlike the pending case +above) they can actually reach. For a user `MFA_REQUIRED` does not apply +to, there is no wall left to re-evaluate, so the effect genuinely is +deferred: their session stays exactly as verified as it was until they next +log in. + +## Inspecting one user + + manage.py mfa_status alice + +Read-only. Prints every enrolled factor (type, name for WebAuthn, when it was +added, when it was last used), how many recovery codes remain, whether the +user counts as "protected" (`registry.has_primary_factor()`), whether +`MFA_REQUIRED` applies to them, and whether they hold an active +`MfaExemption`. It never prints `Authenticator.data` — the TOTP secret, the +WebAuthn credential, or the recovery-code hashes — for the same reason +`AuthenticatorAdmin` doesn't (see {doc}`security`). + +## Unlocking a user: removing every factor + + manage.py mfa_reset alice + +Lists what it's about to delete and asks for confirmation; pass `--yes` to +skip the prompt for scripting. This is the support-desk answer to "I lost my +phone and my recovery codes" — it removes *every* factor (TOTP, WebAuthn, +recovery codes, email) so the user can log in with just their password and +re-enroll from scratch. It leaves any `MfaExemption` alone; resetting factors +and exempting from `MFA_REQUIRED` are independent. + +:::{warning} +**If the user's session is already pending verification — e.g. they're the +one on the phone because they're stuck at the verify screen right now — +this command leaves them stuck worse, not unstuck.** `mfa:verify`'s picker +has nothing to offer once every factor is gone, and there is no exempt path +from a pending session to `mfa:security_settings` either. **Tell them to log +out and log back in** before they try again; a fresh login is the only way +their session recovers. See the note above the command list on this page +for the full explanation. + +**If `MFA_REQUIRED` applies to this user and their session is already +verified, the effect is immediate — not "next login."** `MfaMiddleware` +re-checks `registry.has_primary_factor(user)` on every request from an +already-authenticated, already-verified session. The moment their factor +count hits zero, their very next request — whatever they're in the middle +of doing — is redirected to `mfa:security_settings` instead of served. +Running this mid-day against a required user interrupts their current +session; it does not wait for them to log back in. +::: + +Each row it deletes fires `factor_removed` (`request=None` — see +[Auditing operator actions](#auditing-operator-actions) below), so an audit +receiver sees a support-desk reset exactly as it would see a user removing +their own factor. + +## Rollout reporting + + manage.py mfa_report + manage.py mfa_report --required-only + manage.py mfa_report --required-only --format csv > outstanding.csv + +With no arguments, prints enrolled-factor counts by type, then "Required but +unenrolled" — every user `policy.mfa_required_for()` applies to who does not +hold a primary factor (recovery codes alone don't count; see +{doc}`enforcement`) and does not hold an active `MfaExemption`. `--required-only` +skips the per-type counts. `--format csv` switches the outstanding list to +`pk,` CSV on stdout with nothing else printed ahead of the +header — pipe it straight into a file. It never prints `Authenticator.data`. + +## Exempting a user from `MFA_REQUIRED` + + manage.py mfa_disable alice --reason "service account, no interactive login" --until 2099-12-31 + manage.py mfa_disable alice --revoke + +`mfa_disable` does **not** remove a user's enrolled factors — that's +`mfa_reset`. It grants (or, with `--revoke`, removes) an `MfaExemption` row, +which suppresses `MFA_REQUIRED` for that one user only. `--reason` is +mandatory when granting: an unexplained permanent exemption from a security +requirement outlives everyone who remembers why it was created. `--until` +(`YYYY-MM-DD`) is optional — omit it for a permanent exemption. Re-running +the command for a user who already has one replaces the reason/expiry rather +than erroring. + +An exemption only suppresses the enrollment wall `MFA_REQUIRED` builds. It +does **not** open a view behind `@mfa_required`/`MfaRequiredMixin` — see +{doc}`enforcement` for why the two are deliberately independent. + +Granting or revoking fires `mfa_exemption_changed` (`sender` is the +`MfaExemption` model class, not an `Adapter` subclass — there's no adapter +behind an exemption). See {doc}`api` for the full signal reference. + +## Migrating from another package + + manage.py mfa_import_django_otp --dry-run + manage.py mfa_import_django_otp + + manage.py mfa_import_django_mfa2 --dry-run + manage.py mfa_import_django_mfa2 + +Run the dry run first: it performs every read exactly as the real run would, +writes nothing (the whole import runs inside one transaction that's rolled +back), and prints the identical summary. Both importers are idempotent — +re-running after a successful import finds the rows already present and +imports nothing new — and neither will replace a factor that already works +unless you pass `--overwrite`. Both also accept `--users alice bob ...` to +limit the run to specific accounts (usernames or pks), useful for a staged +cutover or for retrying just the users a first pass reported as unmatched. + +`mfa_import_django_otp` reads `django_otp.plugins.otp_totp.TOTPDevice`, +`otp_static.StaticDevice`/`StaticToken`, and, if installed, +`otp_email.EmailDevice`. **This command also covers +django-two-factor-auth**, which stores its TOTP and static (recovery) tokens +as these same django-otp models — there is nothing two-factor-auth-specific +to run. Its `PhoneDevice` (SMS/call) rows have no counterpart here and are +left untouched. + +`mfa_import_django_mfa2` reads `mfa.models.User_Keys`, migrating `TOTP` and +`Email` key types only. + +Both importers create rows without emitting `factor_added`, so +`MFA_NOTIFY_ON_CHANGE` will **not** mail every migrated user "a factor was +added" on cutover day — this is a migration of a factor a user already had, +not the addition of a new one. Nothing else about the imported rows is +special: they verify, count toward "protected", and behave exactly like a +factor enrolled through the web UI. + +:::{warning} +**Some factors cannot be migrated, and are reported rather than silently +dropped. Read the summary every run — an import that "succeeds" can still +leave specific users unprotected or unable to log in.** + +From **either** importer: + +- **A factor type with no registered adapter on this install is skipped**, + naming `MFA_FACTORS` in the message. Email is the common case — + `MFA_FACTORS` defaults to `["totp", "recovery_codes", "webauthn"]`, so + email rows are skipped until you add `"email"` to it. Writing the row + anyway would create an `Authenticator` that looks like protection but + that nothing on this install will ever offer or verify — the user stays + bounced to enrollment regardless. +- **A second confirmed/enabled source device of the same type for a user + who already got one this run is skipped, not merged or overwritten.** + Neither source package enforces one-device-per-user-per-type, so this is + a real state after a re-enrollment that never cleaned up its old device. + The lower-pk (first-created) row wins, deterministically; the discarded + one is named in the output if it should have won instead. +- **An existing django_mfa factor of the same type is left alone** unless + you pass `--overwrite`; a working factor is never silently destroyed. + +From `mfa_import_django_otp` specifically: + +- **Unconfirmed devices are skipped.** A device the user never finished + setting up (`confirmed=False`) is not a working factor to migrate. +- **A TOTP device using non-default `digits`, `step`, `t0`, *or `drift`* is + skipped.** `django_mfa` is fixed at 6 digits, a 30-second step, and + `t0=0`, so an imported 8-digit or 60-second-step device would produce + codes that never match. **`drift` deserves particular attention**: it's a + django-otp counter-offset term, not enrollment-time configuration, and it + *accumulates* — `TOTPDevice.verify_token()` saves a new drift on every + successful login whenever `OTP_TOTP_SYNC` is on, django-otp's default. A + user whose phone clock runs fast silently gains drift on every login + under django-otp and would have logged in without issue indefinitely; a + nonzero drift is mathematically the same shift as a nonzero `t0`, which + `django_mfa` cannot represent either. Affected users must re-enroll — + there is no way to import a device that already needs this + compensation. + +From `mfa_import_django_mfa2` specifically: + +- **`FIDO2`, `U2F`, and `Trusted Device` rows have no django_mfa + counterpart at all** and are reported as such. +- **`RECOVERY` rows are not imported**, even though `recovery_codes` *is* a + django_mfa factor type — this command's stated scope is TOTP and email + only, and it reports `RECOVERY` separately from the three above so the + message doesn't claim "no counterpart" for something that in fact has + one. Don't read "out of scope" as "not implemented yet, but importable in + principle": it isn't, for a second and independent reason. django-mfa2 + hashes each recovery code with a hasher class it defines locally in its + own module (`mfa.recovery.Hash(PBKDF2PasswordHasher)`, `algorithm = + "pbkdf2_sha256_custom"`, invoked as `make_password(token, salt, + "pbkdf2_sha256_custom")`), which a django_mfa install has no reason to + register in `PASSWORD_HASHERS` — `RecoveryCodesAdapter` hashes with + Django's own default hasher via plain `make_password`/`check_password`. + Copying those hashes across would produce codes that never verify here + unless the host project registers a hasher class from the very package + it's migrating away from. Affected users must generate a fresh set of + recovery codes from django_mfa's security settings page once they hold a + primary factor again. +- **The acceptance window narrows, for every migrated TOTP user, and this + cannot be caught per row.** django-mfa2's own verification + (`mfa/totp.py`, `valid_window=30`, counted in 30-second ticks) accepts a + code up to **±15 minutes** out of step; `django_mfa`'s + `TOTP_VALID_WINDOW = 1` accepts **±30 seconds** — thirty times narrower. + `User_Keys` records no clock-skew state, so whether any given user's + device is skewed enough for this to matter cannot be determined from the + data being migrated. The command prints this warning **unconditionally, + every run**, not just when it detects a problem, because it cannot + detect the problem. A user whose phone clock has drifted several minutes + logged in without trouble under mfa2 and will be silently locked out on + their first login after cutover, with the summary having said + "imported". Tell affected users to sync their device clock, or to + re-enroll if that doesn't fix it — before they file a ticket, not after. +- A row whose `username` matches no user on this install is counted + separately (`unknown_user`) and reported, not treated as an error — + django-mfa2 keys rows by username string rather than a foreign key, so a + stale row is expected, not exceptional. +::: + +## Auditing operator actions + +`mfa_reset` and `mfa_disable` are the only two of these six commands that +write anything, and both fire the same signals the web UI fires for the +equivalent user-initiated action (`factor_removed`, +`mfa_exemption_changed`) — with one difference worth building a receiver +around: **`request` is `None`**, because there is no request to pass from a +management command. Every signal in `django_mfa.events` documents this as +possible, precisely so a support-desk action still reaches an audit +receiver instead of being the one factor removal or exemption change that +leaves no trace. + +**This only covers the command path.** Deleting an `MfaExemption` row +directly in the admin also revokes it (see {doc}`enforcement`'s +"Exempting a user" section), but that path fires no signal at all — Django's +admin has nothing django-mfa listens for on delete. An operator who needs +the deletion to reach whatever is consuming `mfa_exemption_changed` must use +`mfa_disable --revoke` instead of the admin. + +A receiver that reaches for `request.META` unconditionally +will raise on this path — since every signal here is sent with +`send_robust()`, that raise is swallowed rather than breaking the command, +but the receiver still won't have logged anything. Write receivers the way +{doc}`api`'s own example does: + + @receiver(factor_removed) + def audit_factor_removal(sender, user, factor_type, name, request, **kwargs): + source = request.META.get("REMOTE_ADDR") if request else "console" + logger.info("factor removed: user=%s type=%s from=%s", + user.pk, factor_type, source) + +The importers are deliberately the exception: they never emit +`factor_added` at all (see above), so they need no such handling — there is +nothing for a receiver to see from a bulk import in the first place. diff --git a/docs/security.md b/docs/security.md index fcdcd58..ecbc5bf 100644 --- a/docs/security.md +++ b/docs/security.md @@ -210,9 +210,12 @@ Stated plainly, so you can decide what else you need: mitigation** — credentials are bound to the origin, so a passkey cannot be used on an attacker's domain. If phishing is in your threat model, prefer passkeys and consider not offering TOTP. -- **Compromised sessions.** Once verified, the session is verified. django-mfa does - not re-challenge for sensitive actions; if you want step-up authentication for, - say, changing a password, build it on `session.is_verified()` plus your own policy. +- **Compromised sessions, for actions django-mfa doesn't know about.** Adding, + removing or regenerating a factor already requires a *recent* challenge, not + merely a verified session — see [Step-up re-authentication](enforcement.md#step-up-re-authentication). + For any other sensitive action of your own, say, changing a password, apply the + same decorator (`mfa_recent_required`/`MfaRecentRequiredMixin`) yourself; a plain + verified session is otherwise good for the life of that session. - **A compromised server.** TOTP secrets are decryptable by your application by definition. Encryption at rest protects against a leaked database dump, not against code execution on your host. diff --git a/docs/settings.md b/docs/settings.md index 480b278..ed91a91 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -32,7 +32,7 @@ WebAuthn** — see {doc}`installation_setup`. | Setting | Default | Purpose | |---|---|---| -| `MFA_REMEMBER_MY_BROWSER` | `False` | When `True`, a signed cookie trusts a browser for `MFA_REMEMBER_DAYS` after it completes one second-factor challenge, skipping the challenge on later logins. Works with any factor type. The cookie is bound to the user's enrolled factors, so enrolling or removing one invalidates every previously trusted browser. | +| `MFA_REMEMBER_MY_BROWSER` | `False` | When `True`, a signed cookie trusts a browser for `MFA_REMEMBER_DAYS` after it completes one second-factor challenge, skipping the challenge on later logins. Works with any factor type. The cookie is bound to the user's enrolled factors, so enrolling or removing one invalidates every previously trusted browser. Interacts with step-up (`MFA_STEPUP_MAX_AGE`) — see the note below. | | `MFA_REMEMBER_DAYS` | `90` | How long a trusted-browser cookie stays valid. Ignored unless the above is on. | | `MFA_QUICKLOGIN` | `False` | Sets a non-authenticating hint cookie naming the last account to log in on this browser, so your login page can offer a passkey before asking for a username. See {doc}`recipes`. | @@ -45,6 +45,41 @@ WebAuthn** — see {doc}`installation_setup`. | `MFA_SECRET_ENCRYPTION_KEYS` | `None` | **Deprecated, and does not encrypt.** List of keys used to *sign* TOTP secrets at rest. The first key signs; every key is tried when reading. See [Signing stored secrets](#signing-stored-secrets-deprecated). | | `MFA_OWNED_BY_ENTERPRISE` | `False` | When `True`, users cannot remove their own WebAuthn authenticators from the security settings page — e.g. an organization-issued security key an administrator manages instead. | | `MFA_NOTIFY_ON_CHANGE` | `False` | When `True`, emails the user when a factor is added or removed, when a recovery code is spent, and when their last factor goes. Off by default so an upgrade doesn't start sending mail unannounced. The `django_mfa.events` signals fire either way — connect your own receiver for async delivery or for routing somewhere other than email. Sending is synchronous and best-effort: a failure is logged, never raised. | +| `MFA_STEPUP_MAX_AGE` | `300` (seconds) | How recently a session must have completed a factor challenge before it may add, remove or regenerate a factor. See [below](#mfa_stepup_max_age). | + +### `MFA_STEPUP_MAX_AGE` + +Default: `300` (seconds). + +How recently a session must have completed a factor challenge before it may +add, remove or regenerate a factor. A session older than this is redirected +back through `mfa:verify` first. Set to `None` to switch the gate off for +django-mfa's own three built-in views (`enroll_factor` and the two +`manage_factors` views) and restore pre-4.2.0 behaviour there — they never +pass their own `max_age`, so they fall back to this setting. + +**This does not disable a host view's own explicit `max_age`.** +`@mfa_recent_required(max_age=60)` (or `mfa_stepup_max_age = 60` on +`MfaRecentRequiredMixin`) keeps enforcing 60 seconds regardless of what this +setting is — `decorators._enforce_recent` resolves `max_age if max_age is +not None else MFA_STEPUP_MAX_AGE`, so an explicit per-view value always +outranks the global one. Setting this to `None` is a *default*, not a +ceiling: it switches off step-up for views that don't ask for their own +freshness window, not for ones that do. + +**Interacts with `MFA_REMEMBER_MY_BROWSER`.** A trusted browser still skips +the *login* challenge exactly as before, but the session it starts is only +fresh at the moment it's created — `MFA_STEPUP_MAX_AGE` (or a view's own +`max_age`) is enforced on every factor change regardless of how the session +became verified. A trusted browser that changes a factor more than +`MFA_STEPUP_MAX_AGE` seconds after logging in is challenged for that +action, because the RMB cookie is consulted only at login, not re-checked +by the step-up gate. This is expected, and a visible change for installs +that enabled RMB specifically to avoid challenges — see the 4.2.0 entry in +`CHANGELOG.md`. + +This exists because a stolen or borrowed session could otherwise strip every +factor from an account and enrol its own without presenting anything. ## Email codes @@ -153,10 +188,10 @@ described above before they can affect a real user. E001–E003 are WebAuthn-only: they return no errors at all unless WebAuthn is actually switched on for this install, meaning `MFA_QUICKLOGIN` is on or a WebAuthn adapter is registered (true by default). A project with -`MFA_FACTORS = ["totp", "recovery_codes"]` never trips any of them. `E004` is not -gated the same way — `MFA_REQUIRED` is not a WebAuthn setting, so there is nothing -to gate on, and it applies to every install regardless of which factors are -registered. +`MFA_FACTORS = ["totp", "recovery_codes"]` never trips any of them. `E004` and +`E005` are not gated the same way — `MFA_REQUIRED` and `MFA_STEPUP_MAX_AGE` are +not WebAuthn settings, so there is nothing to gate on, and both apply to every +install regardless of which factors are registered. | Check ID | Severity | Condition | |---|---|---| @@ -164,6 +199,7 @@ registered. | `django_mfa.E002` | Error | WebAuthn is active and `MFA_FIDO2_RP_ID` is not a suffix of any `ALLOWED_HOSTS` entry. | | `django_mfa.E003` | Error | WebAuthn is active and `django_mfa.backends.WebAuthnBackend` is missing from `AUTHENTICATION_BACKENDS`. | | `django_mfa.E004` | Error | `MFA_REQUIRED` is a dotted path that fails to import, or resolves to a value that isn't callable. | +| `django_mfa.E005` | Error | `MFA_STEPUP_MAX_AGE` is not a positive integer or `None`. | `E003` exists because the failure it prevents is otherwise completely silent. Passwordless login logs a user in by calling `django.contrib.auth.login()` with an @@ -174,7 +210,7 @@ resolves `request.user` to `AnonymousUser` — no exception, no log line, just a who was "logged in" a moment ago and is now anonymous again. Catching this at startup is far cheaper than a support ticket. -All four are `Error` rather than `Warning` deliberately, though what each guards +All five are `Error` rather than `Warning` deliberately, though what each guards against differs slightly. E001–E003 guard a failure mode that is otherwise silent in production (see E003's own explanation below). A misconfigured `MFA_REQUIRED` is not silent even without E004 — `policy.resolve()` raises `ImproperlyConfigured` diff --git a/docs/upgrading.md b/docs/upgrading.md index 2d68668..38211ea 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -104,3 +104,19 @@ These are fixes to real defects in 4.0.0. All are worth knowing about before you - **`Authenticator` is no longer editable in the Django admin.** It is registered read-only, with add and change disabled and `data` excluded from every field, changelist and search list — a staff account with `view_authenticator` could previously read (and with `change_authenticator`, overwrite) another user's TOTP secret. Deleting is still allowed, so revoking a lost authenticator for a locked-out user still works. **If your project relied on editing `Authenticator` rows through the admin, that will now fail**; register your own `ModelAdmin` if you genuinely need it, and keep `data` out of it. Separately, the documentation for `MFA_SECRET_ENCRYPTION_KEYS` was wrong: it described the setting as encrypting TOTP secrets at rest. It signs them (`django.core.signing`) and provides integrity only — the payload is plain base64 and recovers without any key. Nothing about the code changed; see {doc}`settings` and {doc}`security` for the corrected description, and re-check any risk assessment that relied on the old wording. + +## 4.2.0: factor changes now require a recent challenge + +`enroll_factor`, `manage_factors` and `recovery_codes` are gated on +`MFA_STEPUP_MAX_AGE` (default 300 seconds). A user who verified more than +five minutes ago is redirected through `mfa:verify` before the change is +accepted. + +This is the one place 4.2.0 does not upgrade to byte-identical behaviour, and +it is deliberate: the alternative leaves the hole open for every install that +does not read this file. Set `MFA_STEPUP_MAX_AGE = None` to restore 4.1.0 +behaviour exactly. + +The verification picker now also honours `?next=`, so a single-factor user is +returned to the page they requested after logging in rather than to +`LOGIN_REDIRECT_URL`. diff --git a/pyproject.toml b/pyproject.toml index 604a740..2205ed3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "django-mfa" -version = "4.1.0" +version = "4.2.0" description = "Second-factor authentication for Django: authenticator apps (TOTP), security keys and passkeys (WebAuthn), and recovery codes." readme = "README.md" requires-python = ">=3.10" @@ -60,6 +60,24 @@ dev = [ # packaging tests skip -- and skipping is exactly how the missing # adapters/ and views/ packages went unnoticed in the first place. "build>=1.2", + # Source packages for the mfa_import_django_otp / mfa_import_django_mfa2 + # management commands' tests (tasks 11-12). These are test-only fixtures, + # never runtime dependencies -- the importer commands resolve the foreign + # models through apps.get_model() inside try/except LookupError so the + # package never imports either at runtime (see + # django_mfa/tests/test_packaging.py, which asserts [project.dependencies] + # stays Django/fido2/qrcode only). Kept in `dev` rather than a separate + # opt-in group for the same reason `build` above is in `dev`: an opt-in + # group is exactly how the missing adapters/ and views/ packages went + # unnoticed, because `uv run python test_runner.py` alone would silently + # skip the importer tests instead of running them against real models. + # django-mfa2 pulls in python-u2flib-server transitively (its own U2F + # support) -- that's expected, not a regression: this package removed + # U2F entirely (see test_u2f_removal.py, docs/upgrading.md) and never + # imports python-u2flib-server itself; it only rides along in uv.lock + # as django-mfa2's dependency. + "django-otp>=1.5", + "django-mfa2>=2.9", ] # The docs are Markdown; myst-parser is what lets Sphinx read them at all. # Kept as its own group so a test run doesn't pull the whole Sphinx stack. diff --git a/test_runner.py b/test_runner.py index 4accea8..a5212fb 100644 --- a/test_runner.py +++ b/test_runner.py @@ -22,6 +22,18 @@ 'django.contrib.messages', 'django.contrib.staticfiles', 'django_mfa', + # Test-only: the importer commands (tasks 11-12) read these + # packages' models. Real models rather than stubs, so a field + # rename upstream fails the test instead of silently + # invalidating the importer. They are never runtime + # dependencies of django_mfa -- see mfa_import_django_otp.py + # and mfa_import_django_mfa2.py, which resolve them through + # apps.get_model() and degrade to a clean CommandError. + 'django_otp', + 'django_otp.plugins.otp_totp', + 'django_otp.plugins.otp_static', + 'django_otp.plugins.otp_email', + 'mfa', ), MIDDLEWARE=( 'django.middleware.security.SecurityMiddleware', @@ -51,6 +63,16 @@ }, ], SECRET_KEY='test_secret_key', + # django-mfa2's own AppConfig ('mfa', pulled in for the importer + # tests -- see INSTALLED_APPS above) omits default_auto_field, which + # otherwise trips models.W042 on its User_Keys model. django_mfa's + # own AppConfig already sets default_auto_field explicitly + # (django_mfa/apps.py), so this global only affects apps that don't + # set their own -- it does not change django_mfa's behaviour. Django + # migrations hard-code their field types per file, so this also + # cannot alter any already-applied migration; it only affects a + # future makemigrations, which this project never runs against 'mfa'. + DEFAULT_AUTO_FIELD='django.db.models.BigAutoField', MFA_FIDO2_RP_ID='testserver', ALLOWED_HOSTS=['testserver', 'localhost'], # django_mfa.checks.E003 (task 19) flags a WebAuthn-capable install @@ -66,6 +88,20 @@ ], CACHES={"default": { "BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}, + # Pinned explicitly rather than left to Django's own default: + # that default is USE_TZ=False on Django 4.2 (this project's floor, + # and a leg of the CI matrix) but USE_TZ=True on Django 5.2 (another + # leg), so an unpinned suite exercises naive/aware datetime handling + # differently per Django version and a regression on one leg can go + # uncaught on the other. Three separate naive/aware bugs surfaced + # during the branch that added MfaExemption and its --until date + # handling, and the suite as it stood didn't catch any of them. + # Pinning True here doesn't remove USE_TZ=False coverage: the tests + # that specifically need it (e.g. MfaExemptionStrTests, + # MfaDisableUntilUseTzTests) wrap themselves in their own + # override_settings(USE_TZ=False) -- that's the deliberate other + # half of this pin, not a gap in it. + USE_TZ=True, ) django.setup() diff --git a/uv.lock b/uv.lock index 472f5d6..5a95f78 100644 --- a/uv.lock +++ b/uv.lock @@ -566,7 +566,7 @@ wheels = [ [[package]] name = "django-mfa" -version = "4.1.0" +version = "4.2.0" source = { editable = "." } dependencies = [ { name = "django", version = "5.2.17", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, @@ -579,6 +579,8 @@ dependencies = [ dev = [ { name = "build" }, { name = "coverage" }, + { name = "django-mfa2" }, + { name = "django-otp" }, { name = "ruff" }, ] docs = [ @@ -601,6 +603,8 @@ requires-dist = [ dev = [ { name = "build", specifier = ">=1.2" }, { name = "coverage", specifier = ">=7" }, + { name = "django-mfa2", specifier = ">=2.9" }, + { name = "django-otp", specifier = ">=1.5" }, { name = "ruff", specifier = ">=0.14" }, ] docs = [ @@ -609,6 +613,35 @@ docs = [ { name = "sphinx-rtd-theme", specifier = ">=2" }, ] +[[package]] +name = "django-mfa2" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django", version = "5.2.17", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "fido2" }, + { name = "pyotp" }, + { name = "python-jose" }, + { name = "python-u2flib-server" }, + { name = "ua-parser" }, + { name = "user-agents" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/7d/f6b7a23cc5b6387c9f5ce48386a1c8cfdc682bc49ed5899e81c912be3121/django_mfa2-3.2.0.tar.gz", hash = "sha256:d052e4e19164029438eef7da5555cf4489f8d7c3cafcd78dd4bc1c545b17a9c6", size = 175751, upload-time = "2026-05-01T13:11:46.52Z" } + +[[package]] +name = "django-otp" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django", version = "5.2.17", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/a3/32b6b53ef1026387375e11236255db7c630d9527a2d33fa6529500a880cd/django_otp-1.7.0.tar.gz", hash = "sha256:961ccf2d80a67303cb46d97427b16c476ee075acfa2b4c82a59d8f1e0745a454", size = 75858, upload-time = "2026-01-07T19:57:17.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/f0/75ee6cdcf916b7c67dffa87aecdd173e4d68e456839dd53b9313e9cc9201/django_otp-1.7.0-py3-none-any.whl", hash = "sha256:406d2d7f797dc313569270e06d6c360c7d986c9f653eab80b190d663ed5f1133", size = 71331, upload-time = "2026-01-07T19:57:18.655Z" }, +] + [[package]] name = "docutils" version = "0.21.2" @@ -634,6 +667,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] +[[package]] +name = "ecdsa" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" }, +] + [[package]] name = "fido2" version = "2.2.1" @@ -877,6 +922,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -895,6 +949,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyotp" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/c6/c5d96a86fd0bf6fa1bbb5c5c341ff3208638b692727a683c8289068d9a11/pyotp-2.10.0.tar.gz", hash = "sha256:d01e9703443616b03c57c700b5cbffd56a1f929c1b0f8f03131bc78c1fca9d3f", size = 18625, upload-time = "2026-06-14T03:48:49.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/33/7b83bde70eddaaaaef487751a9c3a5cc0c0be54620ded0e120ebdc401ff9/pyotp-2.10.0-py3-none-any.whl", hash = "sha256:1df2f6a1bcc3bb0716172a5215ddc2f8c7c7fd26a13df9927d52e1746934836c", size = 13768, upload-time = "2026-06-14T03:48:47.831Z" }, +] + [[package]] name = "pyproject-hooks" version = "1.2.0" @@ -904,6 +967,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, ] +[[package]] +name = "python-jose" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, +] + +[[package]] +name = "python-u2flib-server" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/a0/9310402002ce9a714ccfd6fe621eb68848d90f7a309cb938231c90eda47d/python-u2flib-server-5.0.1.tar.gz", hash = "sha256:b5e1712bf8f703c6fc9bac6643efb2d57e6c9d9f0b9ab0c0df74981b2c349632", size = 25057, upload-time = "2020-11-03T12:02:53.605Z" } + [[package]] name = "pyyaml" version = "6.0.3" @@ -1004,6 +1091,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, ] +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + [[package]] name = "ruff" version = "0.16.2" @@ -1029,6 +1128,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "snowballstemmer" version = "3.1.1" @@ -1297,6 +1405,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, ] +[[package]] +name = "ua-parser" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ua-parser-builtins" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/98/5e4b52d772a048af122a6fc5ce365c311efb9f5e79c55fd4fdd7c9f59e83/ua_parser-1.0.2.tar.gz", hash = "sha256:bab404ad42fb37f943107da2f6003ffc79724d11cc95076a7a539513371779da", size = 33239, upload-time = "2026-04-05T20:14:28.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/7c/6367995ff57aaa2d9e1055adbaec2519cf5a979780a83a93fdf8c6ec37be/ua_parser-1.0.2-py3-none-any.whl", hash = "sha256:0f8e6d0484af2a9ff804bba5a4fe696e87c028eaba98ad9a7dfae873fef7788a", size = 31219, upload-time = "2026-04-05T20:14:26.913Z" }, +] + +[[package]] +name = "ua-parser-builtins" +version = "202606" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/a3/f26edd95a2ce2ffbf559da432e5ff544e8dcda3673d1c775f71afffb18b7/ua_parser_builtins-202606-py3-none-any.whl", hash = "sha256:13b483eb12a5419c1094ce02b7df705fefc6b5d869764b3ffbf6c940c6d014cb", size = 90676, upload-time = "2026-06-01T22:40:53.008Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" @@ -1306,6 +1434,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] +[[package]] +name = "user-agents" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ua-parser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/e1/63c5bfb485a945010c8cbc7a52f85573561737648d36b30394248730a7bc/user-agents-2.2.0.tar.gz", hash = "sha256:d36d25178db65308d1458c5fa4ab39c9b2619377010130329f3955e7626ead26", size = 9525, upload-time = "2020-08-23T06:01:56.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/1c/20bb3d7b2bad56d881e3704131ddedbb16eb787101306887dff349064662/user_agents-2.2.0-py3-none-any.whl", hash = "sha256:a98c4dc72ecbc64812c4534108806fb0a0b3a11ec3fd1eafe807cee5b0a942e7", size = 9614, upload-time = "2020-08-23T06:01:54.047Z" }, +] + [[package]] name = "zipp" version = "4.1.0"