Skip to content

Commit b4970af

Browse files
committed
feat: Add email factor for MFA and enhance enforcement options
- Introduced an email-based one-time code factor for MFA, allowing users to receive codes via email as a fallback option. - Updated documentation to reflect the new email factor, including its usage, security considerations, and configuration settings. - Implemented enforcement settings to require MFA for specific users or views, enhancing security policies. - Added new system checks to validate the configuration of MFA requirements. - Bumped version to 4.1.0 to reflect the new features and changes.
1 parent 7e559e6 commit b4970af

61 files changed

Lines changed: 3089 additions & 139 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Changelog
2+
3+
Notable changes to django-mfa, newest first.
4+
5+
This file starts at 4.1.0. For the 2.x/3.x → 4.0 rewrite — which was a
6+
ground-up rebuild with breaking changes to models, URLs, session keys and
7+
settings — see [docs/upgrading.md](docs/upgrading.md); it is far more than a
8+
changelog entry could carry. Releases before 4.1.0 are on the
9+
[GitHub releases page](https://github.com/MicroPyramid/django-mfa/releases).
10+
11+
Versions follow [PEP 440](https://peps.python.org/pep-0440/). The version in
12+
`pyproject.toml` is the only place it is written; the git tag and the GitHub
13+
Release are derived from it (see [docs/contributing.md](docs/contributing.md)).
14+
15+
## 4.1.0
16+
17+
Three additions, all opt-in. **An install that sets none of the new settings
18+
behaves identically to 4.0.1** — the only required step is running the new
19+
migration.
20+
21+
### Added
22+
23+
- **`MFA_REQUIRED` — require a second factor.** Until now enrollment was
24+
entirely voluntary: the middleware only challenged users who had *already*
25+
enrolled, so anyone who never opted in was never prompted, and there was no
26+
way to require MFA of staff. Accepts `False` (default), `True`, a callable
27+
taking a user, or a dotted path to one; `django_mfa.policy` supplies
28+
`is_staff` and `in_groups(*names)`. A required user holding no primary factor
29+
is walled to the security page — only the enroll pages, recovery codes and
30+
`MFA_EXEMPT_PATHS` stay reachable — until they enroll. See
31+
[docs/enforcement.md](docs/enforcement.md).
32+
- **`@mfa_required` and `MfaRequiredMixin`** (`django_mfa.decorators`) for
33+
per-view enforcement regardless of `MFA_REQUIRED`. The setting picks users,
34+
the decorator picks views, and neither can express the other.
35+
- **System check `django_mfa.E004`**, rejecting an unimportable or
36+
non-callable `MFA_REQUIRED` at `manage.py check` rather than from inside
37+
middleware on a user's first live request. Unlike E001–E003 it is not gated
38+
on WebAuthn being active.
39+
- **An emailed one-time-code factor** (`"email"`) — the only built-in that does
40+
not assume the user still holds a device they enrolled earlier, which makes
41+
it the lost-phone path. **Not in the `MFA_FACTORS` default**: add it
42+
explicitly, so that upgrading cannot silently acquire a factor that sends
43+
mail through a backend this package does not control. New settings
44+
`MFA_EMAIL_CODE_LENGTH` (6), `MFA_EMAIL_CODE_VALIDITY` (300s),
45+
`MFA_EMAIL_SEND_RATE_LIMIT` (`"3/5m"`), `MFA_EMAIL_SUBJECT`, and
46+
`MFA_FROM_EMAIL`.
47+
- **Five signals**`factor_added`, `factor_removed`, `mfa_verified`,
48+
`mfa_verification_failed`, `recovery_code_used` — importable from
49+
`django_mfa.signals`, always on. All sent with `send_robust()`, so a raising
50+
receiver cannot break a security action such as removing a compromised key.
51+
`mfa_verification_failed` also fires for attempts the rate limiter refuses: a
52+
brute-force detector needs the refused attempts, not only the evaluated ones.
53+
See [docs/api.md](docs/api.md).
54+
- **`MFA_NOTIFY_ON_CHANGE`** (default `False`) — emails the user when a factor
55+
is added or removed, when a recovery code is spent, and when their last
56+
factor goes. Sending is synchronous and best-effort: a failure is logged,
57+
never raised, because a mail outage must not turn "remove this key I think is
58+
compromised" into a 500. For async delivery or non-email routing, connect
59+
your own receiver to the signals above and leave this off — that is why the
60+
signals ship independently of the emails.
61+
- **`Registry.has_primary_factor(user)`** — the boolean form of
62+
`primary_enabled_for()` in one query instead of one per registered adapter.
63+
Both derive from `Adapter.counts_as_primary_factor`, so they cannot disagree.
64+
- New documentation page, **Enforcement**.
65+
66+
### Changed
67+
68+
- `Adapter.complete_enroll()` **must return the created `Authenticator`**. This
69+
was always true of the built-ins, but it is now a documented contract: the
70+
`factor_added` signal carries the return value, so a custom adapter returning
71+
`None` silently degrades every host project's audit trail for that factor
72+
type.
73+
- `ratelimit.parse/check/record_failure` gained a `setting=` keyword argument so
74+
a second budget (emailed-code sends) can be counted against its own setting.
75+
Existing positional calls are unaffected; `record_failure` is now an alias of
76+
the more general `record`.
77+
- `docs/custom_factors.md`'s worked example is now a printed-backup-token
78+
factor. Its previous example was an emailed-code factor, which now ships as a
79+
built-in, so the page had begun documenting how to reimplement something the
80+
package provides.
81+
82+
### Upgrading
83+
84+
Run `manage.py migrate django_mfa`. Migration `0008_email_factor` adds the
85+
`email` factor type and extends the `mfa_one_singleton_authenticator_per_user`
86+
constraint to cover it — a singleton factor missing from that condition would
87+
not actually be constrained. Unlike `0007`, it is reversible.
88+
89+
Nothing else is required. `MFA_REQUIRED`, `MFA_NOTIFY_ON_CHANGE` and the
90+
absence of `"email"` from `MFA_FACTORS`'s default are what keep existing
91+
behaviour unchanged.

README.md

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Django's own `user_logged_in` signal.
4242
| 🔑 **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. |
4343
| 📱 **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. |
4444
| 🧾 **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. |
45+
| ✉️ **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. |
4546
| 🖥️ **Remember this browser** | Optional, off by default. Trust a browser for N days after one successful challenge. |
4647
|**Several keys at once** | A user can register a work laptop's Touch ID *and* a backup YubiKey, each with its own name. |
4748

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

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

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

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

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

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

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

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

202207
## Compatibility
203208

@@ -217,6 +222,7 @@ outside the source tree.
217222
- [Getting started](http://django-mfa.readthedocs.io/en/latest/installation_setup.html) — install and wire it up in five minutes
218223
- [Settings reference](http://django-mfa.readthedocs.io/en/latest/settings.html) — every setting, its default, and what it does
219224
- [Customizing the UI](http://django-mfa.readthedocs.io/en/latest/customizing.html) — templates, context, and the WebAuthn JS contract
225+
- [Enforcing MFA](http://django-mfa.readthedocs.io/en/latest/enforcement.html) — requiring it for some or all users, and per-view enforcement
220226
- [Integration recipes](http://django-mfa.readthedocs.io/en/latest/recipes.html) — allauth, passkey buttons, APIs, testing, troubleshooting
221227
- [Writing a custom factor](http://django-mfa.readthedocs.io/en/latest/custom_factors.html) — the Adapter API, with a worked example
222228
- [Security model](http://django-mfa.readthedocs.io/en/latest/security.html) — controls, non-goals, and a production checklist

django_mfa/adapters/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from django_mfa.conf import settings as mfa_settings
2020
from django_mfa.registry import registry
2121

22+
from .email import EmailAdapter
2223
from .recovery_codes import RecoveryCodesAdapter
2324
from .totp import TOTPAdapter
2425
from .webauthn import WebAuthnAdapter
@@ -29,6 +30,7 @@
2930
"totp": TOTPAdapter,
3031
"recovery_codes": RecoveryCodesAdapter,
3132
"webauthn": WebAuthnAdapter,
33+
"email": EmailAdapter,
3234
}
3335

3436

0 commit comments

Comments
 (0)