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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Changelog

Notable changes to django-mfa, newest first.

This file starts at 4.1.0. For the 2.x/3.x → 4.0 rewrite — which was a
ground-up rebuild with breaking changes to models, URLs, session keys and
settings — see [docs/upgrading.md](docs/upgrading.md); it is far more than a
changelog entry could carry. Releases before 4.1.0 are on the
[GitHub releases page](https://github.com/MicroPyramid/django-mfa/releases).

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.1.0

Three additions, all opt-in. **An install that sets none of the new settings
behaves identically to 4.0.1** — the only required step is running the new
migration.

### Added

- **`MFA_REQUIRED` — require a second factor.** Until now enrollment was
entirely voluntary: the middleware only challenged users who had *already*
enrolled, so anyone who never opted in was never prompted, and there was no
way to require MFA of staff. Accepts `False` (default), `True`, a callable
taking a user, or a dotted path to one; `django_mfa.policy` supplies
`is_staff` and `in_groups(*names)`. A required user holding no primary factor
is walled to the security page — only the enroll pages, recovery codes and
`MFA_EXEMPT_PATHS` stay reachable — until they enroll. See
[docs/enforcement.md](docs/enforcement.md).
- **`@mfa_required` and `MfaRequiredMixin`** (`django_mfa.decorators`) for
per-view enforcement regardless of `MFA_REQUIRED`. The setting picks users,
the decorator picks views, and neither can express the other.
- **System check `django_mfa.E004`**, rejecting an unimportable or
non-callable `MFA_REQUIRED` at `manage.py check` rather than from inside
middleware on a user's first live request. Unlike E001–E003 it is not gated
on WebAuthn being active.
- **An emailed one-time-code factor** (`"email"`) — the only built-in that does
not assume the user still holds a device they enrolled earlier, which makes
it the lost-phone path. **Not in the `MFA_FACTORS` default**: add it
explicitly, so that upgrading cannot silently acquire a factor that sends
mail through a backend this package does not control. New settings
`MFA_EMAIL_CODE_LENGTH` (6), `MFA_EMAIL_CODE_VALIDITY` (300s),
`MFA_EMAIL_SEND_RATE_LIMIT` (`"3/5m"`), `MFA_EMAIL_SUBJECT`, and
`MFA_FROM_EMAIL`.
- **Five signals** — `factor_added`, `factor_removed`, `mfa_verified`,
`mfa_verification_failed`, `recovery_code_used` — importable from
`django_mfa.signals`, always on. All sent with `send_robust()`, so a raising
receiver cannot break a security action such as removing a compromised key.
`mfa_verification_failed` also fires for attempts the rate limiter refuses: a
brute-force detector needs the refused attempts, not only the evaluated ones.
See [docs/api.md](docs/api.md).
- **`MFA_NOTIFY_ON_CHANGE`** (default `False`) — emails the user when a factor
is added or removed, when a recovery code is spent, and when their last
factor goes. Sending is synchronous and best-effort: a failure is logged,
never raised, because a mail outage must not turn "remove this key I think is
compromised" into a 500. For async delivery or non-email routing, connect
your own receiver to the signals above and leave this off — that is why the
signals ship independently of the emails.
- **`Registry.has_primary_factor(user)`** — the boolean form of
`primary_enabled_for()` in one query instead of one per registered adapter.
Both derive from `Adapter.counts_as_primary_factor`, so they cannot disagree.
- New documentation page, **Enforcement**.

### Changed

- `Adapter.complete_enroll()` **must return the created `Authenticator`**. This
was always true of the built-ins, but it is now a documented contract: the
`factor_added` signal carries the return value, so a custom adapter returning
`None` silently degrades every host project's audit trail for that factor
type.
- `ratelimit.parse/check/record_failure` gained a `setting=` keyword argument so
a second budget (emailed-code sends) can be counted against its own setting.
Existing positional calls are unaffected; `record_failure` is now an alias of
the more general `record`.
- `docs/custom_factors.md`'s worked example is now a printed-backup-token
factor. Its previous example was an emailed-code factor, which now ships as a
built-in, so the page had begun documenting how to reimplement something the
package provides.

### Upgrading

Run `manage.py migrate django_mfa`. Migration `0008_email_factor` adds the
`email` factor type and extends the `mfa_one_singleton_authenticator_per_user`
constraint to cover it — a singleton factor missing from that condition would
not actually be constrained. Unlike `0007`, it is reversible.

Nothing else is required. `MFA_REQUIRED`, `MFA_NOTIFY_ON_CHANGE` and the
absence of `"email"` from `MFA_FACTORS`'s default are what keep existing
behaviour unchanged.
18 changes: 12 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Django's own `user_logged_in` signal.
| 🔑 **Passkeys & security keys** | WebAuthn/FIDO2 — Touch ID, Windows Hello, Face ID, YubiKey. Usable as a second factor *or* for full passwordless login, with no username typed. |
| 📱 **Authenticator apps** | Standard TOTP (RFC 6238) — Google Authenticator, 1Password, Aegis, anything. QR code rendered server-side as inline SVG; no third-party service ever sees your users' secrets. |
| 🧾 **Recovery codes** | Ten single-use codes, hashed at rest, shown exactly once. The answer to "I lost my phone" that isn't a support ticket. |
| ✉️ **Emailed codes** | Opt-in (`"email"` in `MFA_FACTORS`): a one-time code sent to the address on file, for a user who's lost everything else. Not in the default factor list — an existing install has to opt in. |
| 🖥️ **Remember this browser** | Optional, off by default. Trust a browser for N days after one successful challenge. |
| ➕ **Several keys at once** | A user can register a work laptop's Touch ID *and* a backup YubiKey, each with its own name. |

Expand Down Expand Up @@ -116,9 +117,12 @@ built-in view, allauth, or your own SSO handler, all django-mfa needs is that
`login()` gets called. A `user_logged_in` receiver marks the session pending, and the
middleware takes it from there.

**Users without a second factor are never blocked.** Someone with no factor enrolled
logs in exactly as before. Enforcement applies only to users who actually have one, so
you can roll MFA out gradually instead of on a flag day.
**Users without a second factor are never blocked, unless you ask for it.** Someone
with no factor enrolled logs in exactly as before, so you can roll MFA out gradually
instead of on a flag day. Want to *require* it instead — for everyone, for staff, for
one group — set `MFA_REQUIRED`; a required user with no factor is walled to the
security page until they enroll one. See
[Enforcing MFA](http://django-mfa.readthedocs.io/en/latest/enforcement.html).

**The screens are yours.** Every page extends `MFA_BASE_TEMPLATE`, so pointing that at
your own base template is usually all the theming you need. Want more? Shadow any
Expand Down Expand Up @@ -163,7 +167,7 @@ The parts that are easy to get subtly wrong, done deliberately:

### It tells you when you've misconfigured it

Three system checks run on `manage.py check` (and therefore on `migrate` and
Four system checks run on `manage.py check` (and therefore on `migrate` and
`runserver`), because each one guards a failure that is otherwise *silent in
production*:

Expand All @@ -172,6 +176,7 @@ production*:
| `django_mfa.E001` | `MFA_FIDO2_RP_ID` is unset |
| `django_mfa.E002` | `MFA_FIDO2_RP_ID` doesn't match any `ALLOWED_HOSTS` entry |
| `django_mfa.E003` | `WebAuthnBackend` is missing from `AUTHENTICATION_BACKENDS` |
| `django_mfa.E004` | `MFA_REQUIRED` is a dotted path that fails to import, or resolves to something that isn't callable |

`E003` is the instructive one. Passwordless login calls `login()` with an explicit
`backend=`, which succeeds no matter what `AUTHENTICATION_BACKENDS` says. One request
Expand All @@ -196,8 +201,8 @@ class Adapter:

Add `enroll_<type>.html` and `verify_<type>.html`, register the adapter, and it appears
in the security page, the picker, and the middleware's exempt set automatically. The
three built-ins (`totp`, `webauthn`, `recovery_codes`) are written against this same
API — there's no privileged path.
four built-ins (`totp`, `webauthn`, `recovery_codes`, `email`) are written against
this same API — there's no privileged path.

## Compatibility

Expand All @@ -217,6 +222,7 @@ outside the source tree.
- [Getting started](http://django-mfa.readthedocs.io/en/latest/installation_setup.html) — install and wire it up in five minutes
- [Settings reference](http://django-mfa.readthedocs.io/en/latest/settings.html) — every setting, its default, and what it does
- [Customizing the UI](http://django-mfa.readthedocs.io/en/latest/customizing.html) — templates, context, and the WebAuthn JS contract
- [Enforcing MFA](http://django-mfa.readthedocs.io/en/latest/enforcement.html) — requiring it for some or all users, and per-view enforcement
- [Integration recipes](http://django-mfa.readthedocs.io/en/latest/recipes.html) — allauth, passkey buttons, APIs, testing, troubleshooting
- [Writing a custom factor](http://django-mfa.readthedocs.io/en/latest/custom_factors.html) — the Adapter API, with a worked example
- [Security model](http://django-mfa.readthedocs.io/en/latest/security.html) — controls, non-goals, and a production checklist
Expand Down
2 changes: 2 additions & 0 deletions django_mfa/adapters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from django_mfa.conf import settings as mfa_settings
from django_mfa.registry import registry

from .email import EmailAdapter
from .recovery_codes import RecoveryCodesAdapter
from .totp import TOTPAdapter
from .webauthn import WebAuthnAdapter
Expand All @@ -29,6 +30,7 @@
"totp": TOTPAdapter,
"recovery_codes": RecoveryCodesAdapter,
"webauthn": WebAuthnAdapter,
"email": EmailAdapter,
}


Expand Down
Loading
Loading