Skip to content

Latest commit

 

History

History
260 lines (194 loc) · 14 KB

File metadata and controls

260 lines (194 loc) · 14 KB

Public API

The Python surface a host project can depend on. Anything not listed here — the adapters' internals, django_mfa.otp, django_mfa.totp, the view functions themselves — is an implementation detail and may change without a deprecation cycle.

django_mfa.session

Read and write the MFA state on a session. Always use these rather than touching request.session["mfa"]: the dict's shape is not a public contract and has changed once already.

Function Purpose
is_verified(request) True if this session has completed a second factor. The one you'll actually use.
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:

from django_mfa import session

if session.is_verified(request):
    ...

You rarely need the writers: the user_logged_in receiver calls start_pending() and the verify view calls mark_verified().

django_mfa.registry

The registry decides which factors exist and what each user holds. Import the singleton — do not construct your own:

from django_mfa.registry import registry
Method Returns
primary_enabled_for(user) Adapters that mean this user is protected. Excludes recovery codes. This is the predicate for "does this user have MFA".
has_primary_factor(user) The same question as a bool, in one query rather than one per registered adapter. Use this when you only need yes/no — the enrollment wall, @mfa_required and the notification receivers all do, and on a busy site the difference is per request. Both methods read Adapter.counts_as_primary_factor, so they cannot disagree.
enabled_for(user) Adapters this user can verify with right now. Includes recovery codes. This is what to offer on a challenge screen.
available_for(user) Adapters the user could still add. Excludes singletons they already hold and factors that aren't enrolled at all.
all() Every registered adapter.
get(type) One adapter by type string. Raises KeyError if absent.
register(adapter) Add an adapter instance. Raises ValueError on a duplicate type.
unregister(type) Remove one. Raises KeyError if it wasn't registered.

The distinction between the first two is the one that matters. A user holding only recovery codes appears in enabled_for() but not primary_enabled_for(), because recovery codes are exhaustible and must never be someone's sole factor. Deciding whether to challenge from enabled_for() would challenge users you cannot protect; deciding what to offer from primary_enabled_for() would hide the recovery option from exactly the people who need it.

Adapter is the base class for factor types — see {doc}custom_factors.

django_mfa.models.Authenticator

One row per enrolled factor.

Field Notes
user FK to AUTH_USER_MODEL, related name mfa_authenticators.
type "totp", "webauthn", or "recovery_codes" — see Authenticator.Type.
name User-supplied label. WebAuthn only, so several keys are distinguishable.
data JSONField of factor-specific state. Shape is owned by the adapter; don't reach into it.
created_at, last_used_at Timestamps. last_used_at is None until first use.

record_usage() stamps last_used_at. Adapters call it on successful verification.

A database constraint (mfa_one_singleton_authenticator_per_user) allows at most one totp and one recovery_codes row per user; WebAuthn is unlimited.

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:

from django_mfa.conf import settings as mfa_settings
mfa_settings.MFA_VERIFY_RATE_LIMIT      # "5/5m" unless overridden

Asking for a name that isn't a django-mfa setting raises AttributeError, so typos surface immediately. See {doc}settings for the full list.

django_mfa.utils

Function Purpose
strings_equal(a, b) Timing-safe comparison. Normalizes to NFKC, then hmac.compare_digest. Use for any secret comparison in a custom factor.
build_uri(secret, name, initial_count=None, issuer_name=None) Build an otpauth:// provisioning URI.

django_mfa.crypto

Function Purpose
encrypt(value) Encrypt with the first MFA_SECRET_ENCRYPTION_KEYS entry. Returns value unchanged when the setting is unset.
decrypt(value) Decrypt, trying every configured key. Passes through values stored before encryption was enabled. Raises BadSignature if no key works.

Both are no-ops until a host project opts in, so a custom factor can use them unconditionally and gain encryption the day someone sets the keys.

django_mfa.handles

WebAuthn user handles — opaque, stored, SECRET_KEY-independent.

Function Purpose
user_handle_for(user) The user's stable handle, creating it on first call. Idempotent.
user_from_handle(handle) Resolve a handle back to a user, or None. Never raises — input is untrusted.

django_mfa.quicklogin

For a login page that offers a passkey to a returning visitor. Requires MFA_QUICKLOGIN = True.

Name Purpose
COOKIE_NAME "mfa_quicklogin".
user_from_hint(value) Resolve the cookie to a user, or None. Never raises.
set_hint(response, user, secure) Attach the cookie. No-op when the setting is off.
clear_hint(response) Remove it. Unconditional, so a stale cookie is cleaned up even after the feature is switched off.

The cookie is a UX hint, never a credential — it identifies whose passkey prompt to show and authenticates nobody. See {doc}recipes.

django_mfa.backends.WebAuthnBackend

Required in AUTHENTICATION_BACKENDS for passwordless login. It performs no cryptography: the view validates the assertion first, and the backend only returns the already-resolved user through Django's standard contract.

authenticate() deliberately ignores username/password and returns a user only when handed one explicitly as mfa_user, so another backend's call passing credentials through the chain can never authenticate anyone here.

Omitting this backend breaks passkey login silently, one request laterdjango_mfa.E003 exists to catch that at startup.

django_mfa.middleware.MfaMiddleware

Redirects authenticated-but-unverified requests to the picker. Its exempt set is derived from the registry (the picker, plus each factor's verify page) unioned with MFA_EXEMPT_PATHS. Subclass and override process_request to carve out a path prefix — see {doc}recipes.

django_mfa.ratelimit

Applied for you by the verify view; documented because a custom factor's tests may need to reset it.

Function Purpose
check(user, factor_type) True if another attempt is allowed on the per-user budget.
record_failure(user, factor_type) Count a failure against it.
clear(user, factor_type) Reset it, as a success does.
check_client(request, factor_type) The same question for the per-IP budget (MFA_VERIFY_IP_RATE_LIMIT).
record_client(request, factor_type) Count a failure against the client's address.
client_ip(request) The address that budget bills to, or None.
prune() Delete expired counters; what manage.py mfa_prune calls.

There is deliberately no clear_client(). A successful verification clears the user's counter but must never clear the shared per-IP one — an attacker needs only one account they can log into for that to be a reset button. If a test needs the IP counter gone, delete the rows (or clear the cache) directly.

Signals

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 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
factor_added user, authenticator, request An adapter's complete_enroll() succeeds — every ordinary enrollment, plus the first time a user generates recovery codes. authenticator is the created Authenticator row.
factor_removed user, factor_type, name, request mfa:manage deletes a row. factor_type and name are passed by value, not as an instance — the row is already gone by the time this fires.
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 order is forced, since the login signal's own receiver checks whether the session is already verified — so request.user is still AnonymousUser there, while the user kwarg is correct on every path.

sender is the Adapter class for the factor involved (e.g. TOTPAdapter), so 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 mfa_exemption_changed, sender is always the MfaExemption model class — there is no adapter behind an exemption at all.

A receiver:

import logging

from django.dispatch import receiver
from django_mfa.signals import factor_removed

logger = logging.getLogger("myapp.security")

@receiver(factor_removed)
def audit_factor_removal(sender, user, factor_type, name, request, **kwargs):
    # 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 than letting it propagate, so a receiver that fails does so silently unless it logs its own failure.

To react with something other than a signal receiver, post_save on Authenticator still works too — these signals are additional, not a replacement for the model layer.