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
63 changes: 63 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,69 @@ 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.4.0

### Added

- **A JSON API**, opt-in via a separate URL include:

path("api/mfa/", include("django_mfa.api.urls"))

Every flow the HTML views offer — state, enroll, verify, recovery codes,
factor removal, passwordless sign-in — as JSON, for an SPA or mobile
client that renders its own screens. No new dependency: plain Django
views, so it works inside a DRF, django-ninja or plain-Django project
alike. Session authentication by default; `MFA_API_AUTHENTICATION`
(new setting, default `None`) supplies a hook for token or JWT clients.
Note that **MFA state remains session-backed**, so a client must persist
the session cookie — see [docs/rest_api.md](docs/rest_api.md), which is
explicit about what that rules out.
- **System check `django_mfa.E006`**, rejecting an unimportable or
non-callable `MFA_API_AUTHENTICATION`, the way `E004` already does for
`MFA_REQUIRED`.
- **Passkey autofill (WebAuthn conditional mediation)**, opt-in per form
with `data-conditional="true"` plus `autocomplete="username webauthn"` on
your username input. Offers a returning user their passkey from the
browser's own dropdown instead of behind a button. Off by default because
it moves `mfa:passkey_begin` to once per login-page view for every
anonymous visitor, and that endpoint writes a session — see
[docs/recipes.md](docs/recipes.md).
- **`tools/compile_catalogs.py`**, which refreshes catalog source
references and compiles every `.mo`. It is `makemessages` + `msgfmt` in
pure Python, because gettext's binaries are not a dependency this project
imposes — including on its own CI.
- The sandbox login page now demonstrates passkey sign-in, including
autofill. It previously demonstrated neither.

### Changed

- **The six translations are now live.** `de`, `es`, `fr`, `pt_BR`, `ja`
and `zh_Hans` shipped in 4.3.0 with every entry marked `fuzzy`, which
meant users still saw English. Every entry is now translated and
unfuzzed, and compiled `.mo` files ship — Django reads only those, so
without them the catalogs did nothing. They remain machine-drafted and
maintainer-reviewed rather than reviewed by a native speaker; corrections
are welcome. See [docs/translations.md](docs/translations.md).
- The order of operations for an enrollment or verification attempt moved
to `django_mfa.flows`, and the enforcement rungs to
`decorators.enforcement_state`/`recent_enforcement_state`. Both are
shared verbatim by the HTML views and the API, so the two cannot come to
apply different rules. No behaviour change — this is why the HTML views
are shorter in this release.

### Fixed

- **`"Remove"` was rendering in `django.contrib.admin`'s words, not ours,
in every language admin translates.** gettext keys on the string itself
and Django merges all installed apps' catalogs, with the app listed
*first* in `INSTALLED_APPS` winning a shared key — and admin is listed
first in nearly every project. The button now carries a
`context "second-factor method"`, which makes the key ours alone, and a
test fails on any bare msgid a bundled Django app also translates.
- The "managed by your organization" message shown when
`MFA_OWNED_BY_ENTERPRISE` blocks a removal was the one user-facing string
never wrapped for translation.

## 4.3.0

### Added
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ Django's own `user_logged_in` signal.
| ✉️ **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. |
| 🌍 **Six languages** | German, Spanish, French, Brazilian Portuguese, Japanese and Simplified Chinese ship translated. Switch on `USE_I18N` and they work. |
| 🔌 **A JSON API** | Opt-in. Every flow above as JSON, for an SPA or mobile client that renders its own screens. No DRF dependency. |

## Install

Expand Down Expand Up @@ -222,6 +224,8 @@ against it from 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
- [JSON API](http://django-mfa.readthedocs.io/en/latest/rest_api.html) — endpoints, error codes, and what it needs from your client
- [Translations](http://django-mfa.readthedocs.io/en/latest/translations.html) — the six shipped languages, and how to fix or add one
- [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
Expand Down
24 changes: 24 additions & 0 deletions django_mfa/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""A JSON interface to the same factors the HTML views drive.

Opt-in. Mount it where you like, alongside (or instead of) the HTML views::

urlpatterns += [path("api/mfa/", include("django_mfa.api.urls"))]

Nothing here is registered by installing the app, deliberately: an upgrade
must not silently give an existing deployment a new, unauthenticated-by-
default-looking surface it never asked for. This matches how the email
factor, MFA_REQUIRED and change notifications all default to off.

The views are thin on purpose. Every security decision they make is imported
rather than written here:

* the order of operations for an attempt -- ``django_mfa.flows``
* who may make it -- ``django_mfa.decorators.enforcement_state`` and
``recent_enforcement_state``, the same predicates the decorators render as
redirects
* what a factor actually does -- the adapters, unchanged

so that a JSON client cannot end up held to a weaker standard than a browser.
That is a failure nobody notices from the outside: both layers keep working,
and only one of them is enforcing.
"""
59 changes: 59 additions & 0 deletions django_mfa/api/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Who is making this API request.

By default: whoever ``request.user`` says, i.e. Django's session
authentication, which is what a same-origin SPA already has.

``MFA_API_AUTHENTICATION`` replaces that with a dotted path to
``callable(request) -> user | None`` for a project whose API clients
authenticate some other way -- a DRF token, a JWT, an API key. It answers
*identity only*; every other decision (pending, enrolled, fresh) is made
from the same state the browser flow uses.

That last point is a real constraint rather than a footnote: MFA state lives
in the *session*, so a client must carry the session cookie for a completed
challenge to still count on the next request. A pure token client that
discards cookies can call these endpoints, but every request looks like a
brand new session to it, and a verification will never stick. See
docs/rest_api.md.
"""

from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string

from django_mfa.conf import settings as mfa_settings


def resolve():
"""``MFA_API_AUTHENTICATION`` as a callable, or None when unset.

Raises ImproperlyConfigured for a value it cannot use. Caught at startup
by checks.check_mfa_api_authentication (django_mfa.E006), so a typo
surfaces from `manage.py check` rather than as a 500 on a client's first
request -- the same treatment MFA_REQUIRED gets.
"""
value = mfa_settings.MFA_API_AUTHENTICATION
if value is None:
return None
if isinstance(value, str):
value = import_string(value)
if not callable(value):
raise ImproperlyConfigured(
f"MFA_API_AUTHENTICATION must be a callable, or a dotted path to "
f"one -- got {value!r}.")
return value


def resolve_user(request):
"""The authenticated user for this request, or None.

Callers assign the result to ``request.user`` before doing anything
else. That is not tidiness: the adapters create and query
``Authenticator`` rows against ``request.user`` directly, so a resolver
that returned a different user without this would enroll a factor onto
the wrong account.
"""
resolver = resolve()
user = resolver(request) if resolver else getattr(request, "user", None)
if user is None or not getattr(user, "is_authenticated", False):
return None
return user
42 changes: 42 additions & 0 deletions django_mfa/api/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""URLs for the JSON API.

Mounted by the host project, wherever it likes::

path("api/mfa/", include("django_mfa.api.urls"))

The ``mfa_api`` namespace is baked into the pattern list, the same way
``django_mfa/urls.py`` bakes in ``mfa`` -- do not pass ``namespace=`` to
include(). MfaMiddleware reverses these names to build its exempt sets, so
the namespace has to be predictable rather than whatever a host chose.
"""

from django.urls import include, path

from django_mfa.api import views

api_patterns = ([
path("state/", views.state, name="state"),

path("enroll/<str:factor_type>/begin/",
views.enroll_begin, name="enroll_begin"),
path("enroll/<str:factor_type>/complete/",
views.enroll_complete, name="enroll_complete"),

path("verify/<str:factor_type>/begin/",
views.verify_begin, name="verify_begin"),
path("verify/<str:factor_type>/complete/",
views.verify_complete, name="verify_complete"),

path("recovery-codes/", views.recovery_codes, name="recovery_codes"),
path("factors/<str:pk>/", views.remove_factor, name="remove_factor"),

path("passkey/begin/", views.passkey_begin, name="passkey_begin"),
path("passkey/complete/", views.passkey_complete, name="passkey_complete"),
], "mfa_api")

urlpatterns = [path("", include(api_patterns))]


#: The namespace MfaMiddleware recognises a request to this API by. See
#: MfaMiddleware.is_api_request.
NAMESPACE = "mfa_api"
Loading
Loading