Skip to content

Commit 17e3039

Browse files
committed
feat: Update passkey handling and introduce JSON API
- Refactor passkey verification logic to streamline user authentication. - Implement conditional mediation for passkey autofill in login forms. - Add a new JSON API for MFA operations, allowing SPA and mobile clients to interact with MFA features. - Enhance documentation to cover new API endpoints and passkey autofill functionality. - Update translation handling with a new script for managing catalogs. - Bump version to 4.4.0 to reflect new features and improvements.
1 parent a6281dc commit 17e3039

45 files changed

Lines changed: 2752 additions & 865 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: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,69 @@ Versions follow [PEP 440](https://peps.python.org/pep-0440/). The version in
1212
`pyproject.toml` is the only place it is written; the git tag and the GitHub
1313
Release are derived from it (see [docs/contributing.md](docs/contributing.md)).
1414

15+
## 4.4.0
16+
17+
### Added
18+
19+
- **A JSON API**, opt-in via a separate URL include:
20+
21+
path("api/mfa/", include("django_mfa.api.urls"))
22+
23+
Every flow the HTML views offer — state, enroll, verify, recovery codes,
24+
factor removal, passwordless sign-in — as JSON, for an SPA or mobile
25+
client that renders its own screens. No new dependency: plain Django
26+
views, so it works inside a DRF, django-ninja or plain-Django project
27+
alike. Session authentication by default; `MFA_API_AUTHENTICATION`
28+
(new setting, default `None`) supplies a hook for token or JWT clients.
29+
Note that **MFA state remains session-backed**, so a client must persist
30+
the session cookie — see [docs/rest_api.md](docs/rest_api.md), which is
31+
explicit about what that rules out.
32+
- **System check `django_mfa.E006`**, rejecting an unimportable or
33+
non-callable `MFA_API_AUTHENTICATION`, the way `E004` already does for
34+
`MFA_REQUIRED`.
35+
- **Passkey autofill (WebAuthn conditional mediation)**, opt-in per form
36+
with `data-conditional="true"` plus `autocomplete="username webauthn"` on
37+
your username input. Offers a returning user their passkey from the
38+
browser's own dropdown instead of behind a button. Off by default because
39+
it moves `mfa:passkey_begin` to once per login-page view for every
40+
anonymous visitor, and that endpoint writes a session — see
41+
[docs/recipes.md](docs/recipes.md).
42+
- **`tools/compile_catalogs.py`**, which refreshes catalog source
43+
references and compiles every `.mo`. It is `makemessages` + `msgfmt` in
44+
pure Python, because gettext's binaries are not a dependency this project
45+
imposes — including on its own CI.
46+
- The sandbox login page now demonstrates passkey sign-in, including
47+
autofill. It previously demonstrated neither.
48+
49+
### Changed
50+
51+
- **The six translations are now live.** `de`, `es`, `fr`, `pt_BR`, `ja`
52+
and `zh_Hans` shipped in 4.3.0 with every entry marked `fuzzy`, which
53+
meant users still saw English. Every entry is now translated and
54+
unfuzzed, and compiled `.mo` files ship — Django reads only those, so
55+
without them the catalogs did nothing. They remain machine-drafted and
56+
maintainer-reviewed rather than reviewed by a native speaker; corrections
57+
are welcome. See [docs/translations.md](docs/translations.md).
58+
- The order of operations for an enrollment or verification attempt moved
59+
to `django_mfa.flows`, and the enforcement rungs to
60+
`decorators.enforcement_state`/`recent_enforcement_state`. Both are
61+
shared verbatim by the HTML views and the API, so the two cannot come to
62+
apply different rules. No behaviour change — this is why the HTML views
63+
are shorter in this release.
64+
65+
### Fixed
66+
67+
- **`"Remove"` was rendering in `django.contrib.admin`'s words, not ours,
68+
in every language admin translates.** gettext keys on the string itself
69+
and Django merges all installed apps' catalogs, with the app listed
70+
*first* in `INSTALLED_APPS` winning a shared key — and admin is listed
71+
first in nearly every project. The button now carries a
72+
`context "second-factor method"`, which makes the key ours alone, and a
73+
test fails on any bare msgid a bundled Django app also translates.
74+
- The "managed by your organization" message shown when
75+
`MFA_OWNED_BY_ENTERPRISE` blocks a removal was the one user-facing string
76+
never wrapped for translation.
77+
1578
## 4.3.0
1679

1780
### Added

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ Django's own `user_logged_in` signal.
4545
| ✉️ **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. |
4646
| 🖥️ **Remember this browser** | Optional, off by default. Trust a browser for N days after one successful challenge. |
4747
|**Several keys at once** | A user can register a work laptop's Touch ID *and* a backup YubiKey, each with its own name. |
48+
| 🌍 **Six languages** | German, Spanish, French, Brazilian Portuguese, Japanese and Simplified Chinese ship translated. Switch on `USE_I18N` and they work. |
49+
| 🔌 **A JSON API** | Opt-in. Every flow above as JSON, for an SPA or mobile client that renders its own screens. No DRF dependency. |
4850

4951
## Install
5052

@@ -222,6 +224,8 @@ against it from outside the source tree.
222224

223225
- [Getting started](http://django-mfa.readthedocs.io/en/latest/installation_setup.html) — install and wire it up in five minutes
224226
- [Settings reference](http://django-mfa.readthedocs.io/en/latest/settings.html) — every setting, its default, and what it does
227+
- [JSON API](http://django-mfa.readthedocs.io/en/latest/rest_api.html) — endpoints, error codes, and what it needs from your client
228+
- [Translations](http://django-mfa.readthedocs.io/en/latest/translations.html) — the six shipped languages, and how to fix or add one
225229
- [Customizing the UI](http://django-mfa.readthedocs.io/en/latest/customizing.html) — templates, context, and the WebAuthn JS contract
226230
- [Enforcing MFA](http://django-mfa.readthedocs.io/en/latest/enforcement.html) — requiring it for some or all users, and per-view enforcement
227231
- [Integration recipes](http://django-mfa.readthedocs.io/en/latest/recipes.html) — allauth, passkey buttons, APIs, testing, troubleshooting

django_mfa/api/__init__.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""A JSON interface to the same factors the HTML views drive.
2+
3+
Opt-in. Mount it where you like, alongside (or instead of) the HTML views::
4+
5+
urlpatterns += [path("api/mfa/", include("django_mfa.api.urls"))]
6+
7+
Nothing here is registered by installing the app, deliberately: an upgrade
8+
must not silently give an existing deployment a new, unauthenticated-by-
9+
default-looking surface it never asked for. This matches how the email
10+
factor, MFA_REQUIRED and change notifications all default to off.
11+
12+
The views are thin on purpose. Every security decision they make is imported
13+
rather than written here:
14+
15+
* the order of operations for an attempt -- ``django_mfa.flows``
16+
* who may make it -- ``django_mfa.decorators.enforcement_state`` and
17+
``recent_enforcement_state``, the same predicates the decorators render as
18+
redirects
19+
* what a factor actually does -- the adapters, unchanged
20+
21+
so that a JSON client cannot end up held to a weaker standard than a browser.
22+
That is a failure nobody notices from the outside: both layers keep working,
23+
and only one of them is enforcing.
24+
"""

django_mfa/api/auth.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Who is making this API request.
2+
3+
By default: whoever ``request.user`` says, i.e. Django's session
4+
authentication, which is what a same-origin SPA already has.
5+
6+
``MFA_API_AUTHENTICATION`` replaces that with a dotted path to
7+
``callable(request) -> user | None`` for a project whose API clients
8+
authenticate some other way -- a DRF token, a JWT, an API key. It answers
9+
*identity only*; every other decision (pending, enrolled, fresh) is made
10+
from the same state the browser flow uses.
11+
12+
That last point is a real constraint rather than a footnote: MFA state lives
13+
in the *session*, so a client must carry the session cookie for a completed
14+
challenge to still count on the next request. A pure token client that
15+
discards cookies can call these endpoints, but every request looks like a
16+
brand new session to it, and a verification will never stick. See
17+
docs/rest_api.md.
18+
"""
19+
20+
from django.core.exceptions import ImproperlyConfigured
21+
from django.utils.module_loading import import_string
22+
23+
from django_mfa.conf import settings as mfa_settings
24+
25+
26+
def resolve():
27+
"""``MFA_API_AUTHENTICATION`` as a callable, or None when unset.
28+
29+
Raises ImproperlyConfigured for a value it cannot use. Caught at startup
30+
by checks.check_mfa_api_authentication (django_mfa.E006), so a typo
31+
surfaces from `manage.py check` rather than as a 500 on a client's first
32+
request -- the same treatment MFA_REQUIRED gets.
33+
"""
34+
value = mfa_settings.MFA_API_AUTHENTICATION
35+
if value is None:
36+
return None
37+
if isinstance(value, str):
38+
value = import_string(value)
39+
if not callable(value):
40+
raise ImproperlyConfigured(
41+
f"MFA_API_AUTHENTICATION must be a callable, or a dotted path to "
42+
f"one -- got {value!r}.")
43+
return value
44+
45+
46+
def resolve_user(request):
47+
"""The authenticated user for this request, or None.
48+
49+
Callers assign the result to ``request.user`` before doing anything
50+
else. That is not tidiness: the adapters create and query
51+
``Authenticator`` rows against ``request.user`` directly, so a resolver
52+
that returned a different user without this would enroll a factor onto
53+
the wrong account.
54+
"""
55+
resolver = resolve()
56+
user = resolver(request) if resolver else getattr(request, "user", None)
57+
if user is None or not getattr(user, "is_authenticated", False):
58+
return None
59+
return user

django_mfa/api/urls.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""URLs for the JSON API.
2+
3+
Mounted by the host project, wherever it likes::
4+
5+
path("api/mfa/", include("django_mfa.api.urls"))
6+
7+
The ``mfa_api`` namespace is baked into the pattern list, the same way
8+
``django_mfa/urls.py`` bakes in ``mfa`` -- do not pass ``namespace=`` to
9+
include(). MfaMiddleware reverses these names to build its exempt sets, so
10+
the namespace has to be predictable rather than whatever a host chose.
11+
"""
12+
13+
from django.urls import include, path
14+
15+
from django_mfa.api import views
16+
17+
api_patterns = ([
18+
path("state/", views.state, name="state"),
19+
20+
path("enroll/<str:factor_type>/begin/",
21+
views.enroll_begin, name="enroll_begin"),
22+
path("enroll/<str:factor_type>/complete/",
23+
views.enroll_complete, name="enroll_complete"),
24+
25+
path("verify/<str:factor_type>/begin/",
26+
views.verify_begin, name="verify_begin"),
27+
path("verify/<str:factor_type>/complete/",
28+
views.verify_complete, name="verify_complete"),
29+
30+
path("recovery-codes/", views.recovery_codes, name="recovery_codes"),
31+
path("factors/<str:pk>/", views.remove_factor, name="remove_factor"),
32+
33+
path("passkey/begin/", views.passkey_begin, name="passkey_begin"),
34+
path("passkey/complete/", views.passkey_complete, name="passkey_complete"),
35+
], "mfa_api")
36+
37+
urlpatterns = [path("", include(api_patterns))]
38+
39+
40+
#: The namespace MfaMiddleware recognises a request to this API by. See
41+
#: MfaMiddleware.is_api_request.
42+
NAMESPACE = "mfa_api"

0 commit comments

Comments
 (0)