From 17e3039dd68908b0b83ce857bc5f9ed6d1903b45 Mon Sep 17 00:00:00 2001 From: Ashwin Date: Fri, 14 Aug 2026 09:44:26 +0530 Subject: [PATCH] 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. --- CHANGELOG.md | 63 +++ README.md | 4 + django_mfa/api/__init__.py | 24 + django_mfa/api/auth.py | 59 ++ django_mfa/api/urls.py | 42 ++ django_mfa/api/views.py | 385 +++++++++++++ django_mfa/apps.py | 2 + django_mfa/checks.py | 31 ++ django_mfa/conf.py | 1 + django_mfa/decorators.py | 83 ++- django_mfa/flows.py | 133 +++++ django_mfa/locale/de/LC_MESSAGES/django.mo | Bin 0 -> 10142 bytes django_mfa/locale/de/LC_MESSAGES/django.po | 140 ++--- django_mfa/locale/django.pot | 54 +- django_mfa/locale/es/LC_MESSAGES/django.mo | Bin 0 -> 9788 bytes django_mfa/locale/es/LC_MESSAGES/django.po | 140 ++--- django_mfa/locale/fr/LC_MESSAGES/django.mo | Bin 0 -> 10209 bytes django_mfa/locale/fr/LC_MESSAGES/django.po | 140 ++--- django_mfa/locale/ja/LC_MESSAGES/django.mo | Bin 0 -> 10515 bytes django_mfa/locale/ja/LC_MESSAGES/django.po | 144 ++--- django_mfa/locale/pt_BR/LC_MESSAGES/django.mo | Bin 0 -> 9603 bytes django_mfa/locale/pt_BR/LC_MESSAGES/django.po | 140 ++--- .../locale/zh_Hans/LC_MESSAGES/django.mo | Bin 0 -> 8784 bytes .../locale/zh_Hans/LC_MESSAGES/django.po | 140 ++--- django_mfa/middleware.py | 39 +- django_mfa/static/django_mfa/webauthn.js | 133 ++++- django_mfa/templates/django_mfa/security.html | 10 +- django_mfa/tests/support/api_urls.py | 18 + django_mfa/tests/support/i18n.py | 186 ++++++- django_mfa/tests/test_api.py | 510 ++++++++++++++++++ django_mfa/tests/test_i18n.py | 194 ++++++- django_mfa/tests/test_packaging.py | 36 +- django_mfa/views/enroll.py | 28 +- django_mfa/views/manage.py | 23 +- django_mfa/views/verify.py | 180 +++---- docs/index.md | 1 + docs/recipes.md | 50 +- docs/rest_api.md | 207 +++++++ docs/settings.md | 19 +- docs/translations.md | 132 +++-- pyproject.toml | 2 +- sandbox/sample/forms.py | 6 + sandbox/templates/login.html | 36 ++ tools/compile_catalogs.py | 80 +++ uv.lock | 2 +- 45 files changed, 2752 insertions(+), 865 deletions(-) create mode 100644 django_mfa/api/__init__.py create mode 100644 django_mfa/api/auth.py create mode 100644 django_mfa/api/urls.py create mode 100644 django_mfa/api/views.py create mode 100644 django_mfa/flows.py create mode 100644 django_mfa/locale/de/LC_MESSAGES/django.mo create mode 100644 django_mfa/locale/es/LC_MESSAGES/django.mo create mode 100644 django_mfa/locale/fr/LC_MESSAGES/django.mo create mode 100644 django_mfa/locale/ja/LC_MESSAGES/django.mo create mode 100644 django_mfa/locale/pt_BR/LC_MESSAGES/django.mo create mode 100644 django_mfa/locale/zh_Hans/LC_MESSAGES/django.mo create mode 100644 django_mfa/tests/support/api_urls.py create mode 100644 django_mfa/tests/test_api.py create mode 100644 docs/rest_api.md create mode 100644 tools/compile_catalogs.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bcc6942..751d718 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 39f7d5c..6e9f1ed 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/django_mfa/api/__init__.py b/django_mfa/api/__init__.py new file mode 100644 index 0000000..f90d987 --- /dev/null +++ b/django_mfa/api/__init__.py @@ -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. +""" diff --git a/django_mfa/api/auth.py b/django_mfa/api/auth.py new file mode 100644 index 0000000..922000d --- /dev/null +++ b/django_mfa/api/auth.py @@ -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 diff --git a/django_mfa/api/urls.py b/django_mfa/api/urls.py new file mode 100644 index 0000000..dc1eb95 --- /dev/null +++ b/django_mfa/api/urls.py @@ -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//begin/", + views.enroll_begin, name="enroll_begin"), + path("enroll//complete/", + views.enroll_complete, name="enroll_complete"), + + path("verify//begin/", + views.verify_begin, name="verify_begin"), + path("verify//complete/", + views.verify_complete, name="verify_complete"), + + path("recovery-codes/", views.recovery_codes, name="recovery_codes"), + path("factors//", 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" diff --git a/django_mfa/api/views.py b/django_mfa/api/views.py new file mode 100644 index 0000000..4cc85f8 --- /dev/null +++ b/django_mfa/api/views.py @@ -0,0 +1,385 @@ +"""JSON endpoints, one per thing the HTML views do. + +Every response body is a JSON object. Failures are always:: + + {"error": {"code": "", "detail": "..."}} + +``code`` is the part to branch on; ``detail`` is translated prose that may +be reworded in any release. + +Two properties are inherited from the HTML views and must survive any change +here, because losing either is silent: + +* **Every verification failure looks the same.** A wrong code, a malformed + payload, a replayed ceremony, a clone-detected authenticator and a + rate-limited attempt all return the identical 400 body. Distinguishing + them would hand an attacker an oracle -- and the rate-limit case + especially, since a distinguishable lockout tells them exactly when to + back off. +* **The enroll endpoints are not reachable while a session is pending.** + Enrolling marks a session verified, so a pending user allowed to enroll + could satisfy their own challenge with a factor of their choosing instead + of the one they hold -- a complete second-factor bypass for anyone who has + the password. + + MfaMiddleware does **not** back this up. It lets every request into this + namespace through, because the only thing it could do is redirect, and a + 302 to an HTML page is not an answer a JSON client can act on (see + MfaMiddleware.is_api_request). The ``endpoint()`` decorator below is + therefore the *only* gate on these URLs, and an endpoint added without it + is not a smaller bug than it would be elsewhere -- it is a bigger one. + test_api.py's EveryEndpointIsGatedTests walks this module's URLconf and + asserts the refusal for every route outside a small explicit allowlist, + so a new one cannot arrive ungated unnoticed. +""" + +import json +from functools import wraps + +from django.contrib import auth as django_auth +from django.http import Http404, JsonResponse +from django.shortcuts import get_object_or_404 +from django.utils.translation import gettext_lazy as _ +from django.views.decorators.csrf import csrf_protect + +from django_mfa import decorators, events, flows, session +from django_mfa.adapters.recovery_codes import RecoveryCodesAdapter +from django_mfa.api import auth +from django_mfa.conf import settings as mfa_settings +from django_mfa.models import Authenticator +from django_mfa.registry import registry +from django_mfa.views import verify as verify_views +from django_mfa.views.verify import GENERIC_ERROR + +#: Reused verbatim from the HTML passkey endpoint rather than reworded, so +#: the two cannot come to describe the same failure differently. +PASSKEY_ERROR = _("Passkey sign-in failed.") + +#: How each enforcement rung renders as JSON. The rungs themselves come from +#: django_mfa.decorators, so this table is a *rendering*, never a second +#: policy -- add a rung there and this fails loudly with a KeyError rather +#: than quietly letting the request through. +GATES = { + decorators.UNAUTHENTICATED: ( + 401, "unauthenticated", _("Authentication is required.")), + decorators.PENDING: ( + 403, "verification_required", + _("This session must complete a second-factor challenge first.")), + decorators.UNENROLLED: ( + 403, "enrollment_required", + _("This account must enroll a second factor first.")), + decorators.STALE: ( + 403, "stepup_required", + _("This action needs a recent second-factor challenge.")), +} + + +def error(status, code, detail): + """The one failure shape. + + Built fresh per call rather than reused from a module-level constant: a + response object is mutated as it is rendered, so sharing one across + requests is a bug that only shows under concurrency. + """ + return JsonResponse({"error": {"code": code, "detail": detail}}, + status=status) + + +def _gate(state): + return error(*GATES[state]) + + +def json_body(request): + """The request body as a dict. + + An absent body is ``{}`` rather than an error: several endpoints take no + input, and requiring `{}` from them would be pedantry. A body that is + valid JSON but not an object is rejected, because every adapter indexes + what it is handed. + """ + if not request.body: + return {} + data = json.loads(request.body.decode("utf-8")) + if not isinstance(data, dict): + raise ValueError("body must be a JSON object") + return data + + +def endpoint(*methods, anonymous=False, require_verified=True, + allow_unenrolled=True, stepup=False): + """Wrap a view with the method check, authentication and gating. + + ``require_verified=False`` is what lets the *verify* endpoints work while + a session is pending -- they are how it stops being pending. Everything + else keeps the default, and in particular the enroll endpoints must + never be given it (see this module's docstring). + + ``stepup=True`` adds the freshness rung, matching + ``@mfa_recent_required`` on the corresponding HTML view. + """ + def decorator(view): + @wraps(view) + def wrapped(request, *args, **kwargs): + if request.method not in methods: + return error(405, "method_not_allowed", + _("This endpoint does not accept %(method)s.") + % {"method": request.method}) + if anonymous: + return view(request, *args, **kwargs) + + user = auth.resolve_user(request) + if user is None: + return _gate(decorators.UNAUTHENTICATED) + # Assigned before anything downstream runs: the adapters create + # and query Authenticator rows against request.user directly, so + # a custom MFA_API_AUTHENTICATION resolver would otherwise enroll + # onto whichever account the session happened to name. + request.user = user + + state = decorators.enforcement_state( + request, require_primary_factor=not allow_unenrolled) + if state == decorators.PENDING and not require_verified: + state = None + if state is None and stepup: + state = decorators.recent_enforcement_state(request) + if state is not None: + return _gate(state) + + try: + return view(request, *args, **kwargs) + except ValueError as exc: + # json_body() and nothing else: an adapter's own ValueError + # is caught inside flows, never here. + return error(400, "malformed_body", str(exc)) + # CSRF applies to every non-safe method, which is nearly all of + # these. A session-authenticated JSON endpoint is exactly as + # forgeable as a form post without it; a token-authenticated client + # is unaffected, having no cookie to ride on. + return csrf_protect(wrapped) + + return decorator + + +def _adapter_or_404(factor_type): + try: + return registry.get(factor_type) + except KeyError: + raise Http404(f"Unknown factor {factor_type!r}") from None + + +def serialize_authenticator(authenticator): + """One enrolled factor, as JSON. + + ``Authenticator.data`` is absent and must stay absent: it holds the TOTP + shared secret, the recovery-code hashes and the WebAuthn credential. + This is the same rule AuthenticatorAdmin follows, for the same reason -- + see its docstring. + """ + return { + "id": authenticator.pk, + "type": authenticator.type, + "name": authenticator.name, + "verbose_name": authenticator.get_type_display(), + "created_at": authenticator.created_at, + "last_used_at": authenticator.last_used_at, + } + + +def serialize_adapter(adapter): + return {"type": adapter.type, "verbose_name": adapter.verbose_name, + "supports_multiple": adapter.supports_multiple} + + +@endpoint("GET", require_verified=False) +def state(request): + """Everything a client needs to render the right screen. + + Deliberately reachable while pending: a client that could not ask "what + can I verify with?" until after verifying would have nothing to draw the + challenge screen from. + """ + return JsonResponse({ + "verified": session.is_verified(request), + "pending": session.is_pending(request), + "verified_at": session.verified_at(request), + "authenticators": [ + serialize_authenticator(a) for a in Authenticator.objects.filter( + user=request.user).order_by("type", "created_at")], + # What this user can verify with right now (includes recovery codes) + # versus what they could still add. The two answer different + # questions and a client needs both -- see Registry.enabled_for. + "can_verify_with": [serialize_adapter(a) + for a in registry.enabled_for(request.user)], + "can_enroll": [serialize_adapter(a) + for a in registry.available_for(request.user)], + "has_primary_factor": registry.has_primary_factor(request.user), + "recovery_codes_remaining": + RecoveryCodesAdapter().remaining(request.user), + "stepup_max_age": mfa_settings.MFA_STEPUP_MAX_AGE, + }) + + +@endpoint("POST", stepup=True) +def enroll_begin(request, factor_type): + """Start enrolling a factor. + + POST rather than GET despite reading like a fetch: beginning has real + side effects -- the email factor *sends mail*, WebAuthn stashes ceremony + state in the session -- and POST is also what brings CSRF protection. + The HTML views use GET here only because rendering a page has to. + + The body is whatever the adapter returned, passed through unchanged. For + WebAuthn that means ``options`` is a JSON-encoded *string* to be parsed, + not a nested object: it is the identical blob the bundled JavaScript + parses, and re-encoding it here would create a second shape of the same + thing for somebody to keep in sync. + """ + adapter = _adapter_or_404(factor_type) + if not adapter.supports_enroll: + raise Http404(f"Factor {factor_type!r} is not enrolled this way") + return JsonResponse(adapter.begin_enroll(request)) + + +@endpoint("POST", stepup=True) +def enroll_complete(request, factor_type): + adapter = _adapter_or_404(factor_type) + if not adapter.supports_enroll: + raise Http404(f"Factor {factor_type!r} is not enrolled this way") + try: + authenticator = flows.attempt_enroll( + request, factor_type, json_body(request)) + except flows.FactorRejected: + return error(400, "invalid", GENERIC_ERROR) + return JsonResponse({ + "authenticator": serialize_authenticator(authenticator), + # The HTML flow redirects a user with no codes to generate some. + # An API client has no redirect to follow, so it is told instead. + "recovery_codes_pending": not Authenticator.objects.filter( + user=request.user, + type=Authenticator.Type.RECOVERY_CODES).exists(), + }, status=201) + + +@endpoint("POST", require_verified=False) +def verify_begin(request, factor_type): + """Issue a challenge. Reachable while pending, by definition.""" + adapter = _adapter_or_404(factor_type) + if (not session.is_verified(request) + and registry.primary_enabled_for(request.user)): + # Same defensive stamp verify_factor makes, and guarded the same + # way: a user with no primary factor must never be marked pending, + # or nothing can ever un-pend them. + session.start_pending(request) + return JsonResponse(adapter.begin_verify(request, request.user)) + + +@endpoint("POST", require_verified=False) +def verify_complete(request, factor_type): + """Answer a challenge. + + Both outcomes are as uniform as the HTML view's: one 200 shape, one 400 + shape, with nothing in either saying which check failed or whether the + attempt was even evaluated. + """ + _adapter_or_404(factor_type) + if (not session.is_verified(request) + and registry.primary_enabled_for(request.user)): + session.start_pending(request) + if not flows.attempt_verify(request, request.user, factor_type, + json_body(request)): + return error(400, "invalid", GENERIC_ERROR) + return JsonResponse({"verified": True, + "verified_at": session.verified_at(request)}) + + +@endpoint("POST", stepup=True) +def recovery_codes(request): + """Generate recovery codes, once. + + ``codes`` is null when a set already exists. The plaintext is returned + by generate() exactly once and is never stored, so there is nothing to + return on a second call -- see views.manage.recovery_codes. + """ + adapter = RecoveryCodesAdapter() + if adapter.is_enabled(request.user): + return JsonResponse({"codes": None, + "remaining": adapter.remaining(request.user)}) + + codes = adapter.generate(request.user) + events.factor_added.send_robust( + sender=RecoveryCodesAdapter, user=request.user, + authenticator=Authenticator.objects.get( + user=request.user, type=Authenticator.Type.RECOVERY_CODES), + request=request) + return JsonResponse({"codes": codes, "remaining": len(codes)}, status=201) + + +@endpoint("DELETE", stepup=True) +def remove_factor(request, pk): + try: + authenticator = get_object_or_404( + Authenticator, pk=int(pk), user=request.user) + except (TypeError, ValueError, OverflowError): + # A pk that does not parse, or parses too large for the column, must + # be the same 404 a someone-else's pk gets rather than a 500. + raise Http404("No such authenticator") from None + + if (authenticator.type == Authenticator.Type.WEBAUTHN + and mfa_settings.MFA_OWNED_BY_ENTERPRISE): + return error(403, "managed_by_enterprise", + _("This security key is managed by your organization " + "and cannot be removed here.")) + + flows.remove_factor(request, authenticator) + return JsonResponse({"removed": True}) + + +# --- Passwordless (passkey) login ------------------------------------------- +# +# The HTML pair already speaks JSON on the way in (mfa:passkey_begin returns +# options as JSON), but mfa:passkey_complete answers success with a 302 to +# LOGIN_REDIRECT_URL, which is exactly wrong for a client that wanted to know +# whether it is now logged in. These return that answer as data instead. +# +# The existing endpoints keep their current shapes unchanged, including +# _passkey_failure()'s flat {"error": "..."} body, which is deliberately NOT +# migrated to this module's envelope: it is a response hosts already parse. + + +@endpoint("GET", anonymous=True) +def passkey_begin(request): + """Start a passwordless ceremony. Identical to the HTML endpoint.""" + return verify_views.passkey_begin(request) + + +@endpoint("POST", anonymous=True) +def passkey_complete(request): + """Finish a passwordless ceremony and log the resolved user in. + + Every failure returns one identical 400 -- see + resolve_passkey_assertion(), which is where that uniformity is enforced + and why it matters. + + ``verified`` distinguishes the two kinds of success that look alike from + here: a User-Verified assertion (a PIN or biometric) satisfies both + factors at once, while a User-Present-only one logs the user in with a + session still pending a second factor. A client that treats them alike + will drop people at a screen they cannot leave. + """ + try: + credential = json_body(request).get("credential") + except ValueError: + return error(400, "invalid", PASSKEY_ERROR) + if credential is None: + return error(400, "invalid", PASSKEY_ERROR) + + user = verify_views.resolve_passkey_assertion(request, credential) + if user is None: + return error(400, "invalid", PASSKEY_ERROR) + + django_auth.login(request, user, backend=verify_views.BACKEND_PATH) + return JsonResponse({ + "authenticated": True, + "verified": session.is_verified(request), + "verified_at": session.verified_at(request), + }) diff --git a/django_mfa/apps.py b/django_mfa/apps.py index ef3de92..7f54219 100644 --- a/django_mfa/apps.py +++ b/django_mfa/apps.py @@ -10,6 +10,7 @@ class DjangoMfaAppConfig(AppConfig): def ready(self): from django_mfa.checks import ( check_fido2_rp_id, + check_mfa_api_authentication, check_mfa_required_predicate, check_stepup_max_age, check_webauthn_backend_configured, @@ -19,6 +20,7 @@ def ready(self): register(check_webauthn_backend_configured) register(check_mfa_required_predicate) register(check_stepup_max_age) + register(check_mfa_api_authentication) from django_mfa import ( adapters, # noqa: F401 (registers built-ins) diff --git a/django_mfa/checks.py b/django_mfa/checks.py index 220319c..1d7a394 100644 --- a/django_mfa/checks.py +++ b/django_mfa/checks.py @@ -174,3 +174,34 @@ def check_stepup_max_age(app_configs, **kwargs): id="django_mfa.E005", )] return [] + + +def check_mfa_api_authentication(app_configs, **kwargs): + """``MFA_API_AUTHENTICATION`` must be something api.auth.resolve() can use. + + Deliberately NOT gated on ``_webauthn_active()`` -- like E004 and E005, + this has nothing to do with WebAuthn. + + It IS gated on the setting being set at all, which is the default: a + project that never mounts the JSON API never sets it and never sees + this. + + Without the check, a bad dotted path surfaces as an ImportError from + inside the first API request, which for an API client means an opaque + 500 rather than a refused deploy. + """ + from django.core.exceptions import ImproperlyConfigured + + from django_mfa.api import auth + + try: + auth.resolve() + except (ImportError, ImproperlyConfigured, TypeError) as exc: + return [Error( + f"MFA_API_AUTHENTICATION is not usable: {exc}", + hint="Set it to None (use request.user, i.e. Django's session " + "authentication), a callable taking a request and returning " + "a user or None, or a dotted path to one.", + id="django_mfa.E006", + )] + return [] diff --git a/django_mfa/conf.py b/django_mfa/conf.py index 62c77e8..4b87d2f 100644 --- a/django_mfa/conf.py +++ b/django_mfa/conf.py @@ -25,6 +25,7 @@ "MFA_EMAIL_SUBJECT": None, "MFA_FROM_EMAIL": None, "MFA_NOTIFY_ON_CHANGE": False, + "MFA_API_AUTHENTICATION": None, } diff --git a/django_mfa/decorators.py b/django_mfa/decorators.py index b3f80de..d99d315 100644 --- a/django_mfa/decorators.py +++ b/django_mfa/decorators.py @@ -24,42 +24,64 @@ from django_mfa import session from django_mfa.conf import settings as mfa_settings - -def _enforce(request, require_primary_factor=True): - """Return a redirect response, or None to let the request through. - - ``require_primary_factor`` gates only the third rung below. It exists - for mfa_recent_required/MfaRecentRequiredMixin, which reuse this - function for the authenticated/pending rungs. By default those callers - pass it True too, so a factorless user is redirected here exactly as - mfa_required does. Their own allow_unenrolled=True escape hatch (for the - built-in enrollment views only) passes False instead, deferring to - _enforce_recent()'s own, more permissive handling of a factorless user - (let them through, since there is nothing for them to re-verify) -- - which would otherwise never be reached, since this rung would redirect - first. mfa_required/MfaRequiredMixin never pass this, so their - behaviour is unchanged. +#: The rung a request fails, as a bare name. `enforcement_state()` and +#: `recent_enforcement_state()` return one of these or None ("let it +#: through"), and the renderings live separately: these decorators turn a +#: rung into a redirect, django_mfa.api turns the same rung into a status +#: code. Keeping the *policy* in one place and the *rendering* in two is +#: what stops a JSON client from being held to a different standard than a +#: browser -- which is the sort of divergence nobody notices until it is a +#: bypass. +UNAUTHENTICATED = "unauthenticated" +PENDING = "pending" +UNENROLLED = "unenrolled" +STALE = "stale" + + +def enforcement_state(request, require_primary_factor=True): + """Which rung this request fails, or None to let it through. + + ``require_primary_factor`` gates only the third rung. It exists for + mfa_recent_required/MfaRecentRequiredMixin, which reuse this for the + authenticated/pending rungs. By default those callers pass it True too, + so a factorless user is stopped here exactly as mfa_required stops them. + Their own allow_unenrolled=True escape hatch (for the built-in + enrollment views only) passes False instead, deferring to + recent_enforcement_state()'s own, more permissive handling of a + factorless user (let them through, since there is nothing for them to + re-verify) -- which would otherwise never be reached, since this rung + would stop them first. mfa_required/MfaRequiredMixin never pass this, so + their behaviour is unchanged. """ from django_mfa.registry import registry user = request.user if not user.is_authenticated: - return redirect_to_login(request.get_full_path()) + return UNAUTHENTICATED if session.is_pending(request): - return redirect_to_login(request.get_full_path(), - resolve_url(reverse("mfa:verify")), "next") + return PENDING if require_primary_factor and not registry.has_primary_factor(user): # has_primary_factor(), not enabled_for(): a user holding only # recovery codes is not protected, and recovery codes must never be # somebody's sole second factor. has_primary_factor(), not # primary_enabled_for(): only the yes/no answer is needed here, and # this runs on every request to a decorated view. - return redirect_to_login( - request.get_full_path(), - resolve_url(reverse("mfa:security_settings")), "next") + return UNENROLLED return None +def _enforce(request, require_primary_factor=True): + """Render enforcement_state() as a redirect, or None to let it through.""" + state = enforcement_state(request, require_primary_factor) + if state is None: + return None + if state == UNAUTHENTICATED: + return redirect_to_login(request.get_full_path()) + target = ("mfa:verify" if state == PENDING else "mfa:security_settings") + return redirect_to_login(request.get_full_path(), + resolve_url(reverse(target)), "next") + + def mfa_required(view_func): """Require a verified second factor for this view.""" @wraps(view_func) @@ -91,12 +113,12 @@ def dispatch(self, request, *args, **kwargs): SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"}) -def _enforce_recent(request, max_age, next_url): - """The step-up rung: a recent challenge, not merely a verified session. +def recent_enforcement_state(request, max_age=None): + """STALE if this session needs a fresh challenge, else None. - Returns a redirect response, or None to let the request through. Runs - only AFTER _enforce() has passed, so request.user is authenticated and - the session is verified by the time this is reached. + The step-up rung: a recent challenge, not merely a verified session. + Runs only AFTER enforcement_state() has passed, so request.user is + authenticated and the session is verified by the time this is reached. """ from django_mfa.registry import registry @@ -106,7 +128,7 @@ def _enforce_recent(request, max_age, next_url): return None if not registry.has_primary_factor(request.user): # Only reachable at all when the caller passed allow_unenrolled=True - # (_enforce() already redirected a factorless user away otherwise). + # (enforcement_state() already stopped a factorless user otherwise). # Nothing to re-verify, and this is the first-enrollment path. # Gating it would wall a factorless user out of the only pages that # could give them a factor -- the same lockout @@ -115,6 +137,13 @@ def _enforce_recent(request, max_age, next_url): return None if session.is_fresh(request, resolved): return None + return STALE + + +def _enforce_recent(request, max_age, next_url): + """Render recent_enforcement_state() as a redirect, or None.""" + if recent_enforcement_state(request, max_age) is None: + return None if request.method in SAFE_METHODS: target = request.get_full_path() else: diff --git a/django_mfa/flows.py b/django_mfa/flows.py new file mode 100644 index 0000000..d3b87d2 --- /dev/null +++ b/django_mfa/flows.py @@ -0,0 +1,133 @@ +"""What must happen, and in what order, for one enrollment or verification. + +Two view layers drive the same factors: the HTML views (``django_mfa/views/``) +and the JSON API (``django_mfa/api/``). They differ only in how a result is +rendered -- a redirect and a template versus a status code and a body -- and +that difference is all either of them should contain. + +Everything else is here, once. The ordering below is not incidental; each +step is load-bearing, and the failure modes if a second copy drifts are the +quiet kind: + +* **Rate-limit check before the adapter, always.** Skip it in one layer and + that layer is an unmetered oracle for every factor. +* **Every failed attempt records a failure**, including one caused by a + malformed payload rather than a wrong code. A layer that records only + "wrong code" lets an attacker spend unlimited attempts by malforming them. +* **Clear the counter only on success**, and mark the session verified only + after the adapter has said yes. +* **Emit the event even for an attempt the limiter refused.** A brute-force + detector needs the refused attempts most of all -- see events.py. + +Nothing here renders, and nothing here decides *whether* a caller is allowed +to make the attempt: authentication, step-up freshness and the enrolled-vs- +pending distinction all belong to the view layer, which is where the two +layers legitimately differ. +""" + +from django_mfa import events, ratelimit, session +from django_mfa.registry import registry + + +class FactorRejected(Exception): + """An enrollment the adapter refused. + + Normalises the three exception types an adapter may raise -- ValueError + (a wrong code, a stale ceremony), TypeError (a payload that is valid + JSON but the wrong shape) and KeyError (a missing field) -- into one, so + that no view layer has to remember all three and none of them can leak + which check failed by handling one differently from the others. + """ + + +def attempt_verify(request, user, factor_type, data): + """Run one verification attempt. True if the session is now verified. + + ``data`` is any mapping the adapter understands -- ``request.POST`` from + a form, a decoded JSON body from the API. + """ + adapter = registry.get(factor_type) + + # A locked-out attempt is never evaluated, and gets exactly the response + # a wrong code gets, so that the lockout is not itself an oracle. That + # sameness is the caller's job to preserve; this returns False for both. + allowed = ratelimit.check(user, factor_type) + verified = False + if allowed: + try: + verified = adapter.complete_verify(request, user, data) + except (ValueError, TypeError, KeyError): + # Clone detection (ValueError), a tampered credential payload + # (TypeError out of fido2 parsing a non-mapping), or a missing + # field (KeyError). All three must be indistinguishable from an + # ordinary wrong code -- to the caller AND to the rate limiter, + # which is why this falls through rather than returning early. + verified = False + + if verified: + ratelimit.clear(user, factor_type) + session.mark_verified(request, factor_type) + events.mfa_verified.send_robust( + sender=type(adapter), user=user, method=factor_type, + request=request) + return True + + if allowed: + # Budget accounting: only an attempt that was actually evaluated + # spends from the budget. + ratelimit.record_failure(user, factor_type) + # An observation, not accounting -- so it fires either way, including + # for the attempt the limiter refused. + events.mfa_verification_failed.send_robust( + sender=type(adapter), user=user, method=factor_type, request=request) + return False + + +def attempt_enroll(request, factor_type, data): + """Complete one enrollment. Returns the created ``Authenticator``. + + Raises ``FactorRejected`` if the adapter refuses the submission. + + Deliberately not rate-limited, unlike attempt_verify: the code checked + here is checked against a secret the caller supplied in the same request + (TOTP's ``secret_key``, WebAuthn's ceremony state), so there is nothing + an attacker could brute-force but their own value. + + Enrolling marks the session verified -- the user just proved possession + of the factor. That is exactly why the enroll and verify exempt sets in + MfaMiddleware must stay separate: a *pending* user let onto an enrollment + path could satisfy their session with a factor of their own choosing + instead of the one they already hold. + """ + adapter = registry.get(factor_type) + try: + authenticator = adapter.complete_enroll(request, data) + except (ValueError, TypeError, KeyError) as exc: + raise FactorRejected(factor_type) from exc + + session.mark_verified(request, factor_type) + events.factor_added.send_robust( + sender=type(adapter), user=request.user, + authenticator=authenticator, request=request) + return authenticator + + +def remove_factor(request, authenticator): + """Delete one enrolled factor and announce it. + + Takes the type and name before deleting, because both are wanted by the + signal and neither survives the delete. + """ + factor_type, name = authenticator.type, authenticator.name + authenticator.delete() + try: + sender = type(registry.get(factor_type)) + except KeyError: + # A row whose type is no longer registered (MFA_FACTORS narrowed, or + # registry.unregister()). Removing one is a supported action and must + # not raise after the delete has already committed; there is simply + # no adapter class to name as the sender. + sender = None + events.factor_removed.send_robust( + sender=sender, user=request.user, factor_type=factor_type, + name=name, request=request) diff --git a/django_mfa/locale/de/LC_MESSAGES/django.mo b/django_mfa/locale/de/LC_MESSAGES/django.mo new file mode 100644 index 0000000000000000000000000000000000000000..eab1343c3a8493c4158d8f446b53a3495261d72e GIT binary patch literal 10142 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 literal 0 HcmV?d00001 diff --git a/django_mfa/locale/de/LC_MESSAGES/django.po b/django_mfa/locale/de/LC_MESSAGES/django.po index 0e3d11f..65dd3df 100644 --- a/django_mfa/locale/de/LC_MESSAGES/django.po +++ b/django_mfa/locale/de/LC_MESSAGES/django.po @@ -2,12 +2,12 @@ # Copyright (C) MicroPyramid # This file is distributed under the same licence as the django-mfa package. # -# MACHINE-DRAFTED, NOT REVIEWED. Every entry below is marked "fuzzy", which -# means gettext ignores it and users see the English source instead. Nothing -# here reaches a user until a human reviews an entry and removes its fuzzy -# flag. See docs/translations.md. -# -#, fuzzy +# MACHINE-DRAFTED, MAINTAINER-REVIEWED -- not reviewed by a native speaker. +# Unlike the first release of this catalog, it is LIVE: no entry is marked +# fuzzy, so these strings are what users of this language actually see. +# Corrections are welcome and wanted; please open an issue or a pull +# request. See docs/translations.md. + msgid "" msgstr "" "Project-Id-Version: django-mfa\n" @@ -20,292 +20,259 @@ msgstr "" #: django_mfa/templates/django_mfa/enroll_email.html:14 #: django_mfa/templates/django_mfa/verify_email.html:15 -#, fuzzy msgid "%(code_length)s-digit code" msgstr "%(code_length)s-stelliger Code" -#: django_mfa/templates/django_mfa/security.html:78 -#, fuzzy +#: django_mfa/templates/django_mfa/security.html:87 msgid "%(remaining)s code remaining." msgid_plural "%(remaining)s codes remaining." msgstr[0] "Noch %(remaining)s Code übrig." msgstr[1] "Noch %(remaining)s Codes übrig." #: django_mfa/templates/django_mfa/email/factor_added_subject.txt:1 -#, fuzzy msgid "A new two-factor method was added to your account" msgstr "Ihrem Konto wurde eine neue Zwei-Faktor-Methode hinzugefügt" #: django_mfa/templates/django_mfa/email/factor_added.txt:1 -#, fuzzy msgid "A new two-factor method was added to your account: %(factor)s" msgstr "Ihrem Konto wurde eine neue Zwei-Faktor-Methode hinzugefügt: %(factor)s" #: django_mfa/templates/django_mfa/email/recovery_code_used.txt:1 -#, fuzzy msgid "A recovery code was just used to sign in to your account." msgstr "Soeben wurde ein Wiederherstellungscode zur Anmeldung bei Ihrem Konto verwendet." #: django_mfa/templates/django_mfa/email/recovery_code_used_subject.txt:1 -#, fuzzy msgid "A recovery code was used to sign in" msgstr "Ein Wiederherstellungscode wurde zur Anmeldung verwendet" #: django_mfa/templates/django_mfa/email/factor_removed_subject.txt:1 -#, fuzzy msgid "A two-factor method was removed from your account" msgstr "Eine Zwei-Faktor-Methode wurde von Ihrem Konto entfernt" -#: django_mfa/templates/django_mfa/security.html:59 -#, fuzzy +#: django_mfa/templates/django_mfa/security.html:68 msgid "Add a method" msgstr "Methode hinzufügen" #: django_mfa/templates/django_mfa/security.html:11 -#, fuzzy msgid "Add a method below to continue. Until you do, the rest of the site is unavailable." msgstr "Fügen Sie unten eine Methode hinzu, um fortzufahren. Bis dahin ist der Rest der Website nicht verfügbar." #: django_mfa/templates/django_mfa/security.html:7 -#, fuzzy msgid "Add a second step to your sign-in so a stolen password isn't enough on its own." msgstr "Fügen Sie Ihrer Anmeldung einen zweiten Schritt hinzu, damit ein gestohlenes Passwort allein nicht ausreicht." #: django_mfa/templates/django_mfa/enroll_webauthn.html:5 -#, fuzzy msgid "Add a security key or passkey" msgstr "Sicherheitsschlüssel oder Passkey hinzufügen" #: django_mfa/templates/django_mfa/security.html:30 -#, fuzzy msgid "Added %(created)s" msgstr "Hinzugefügt am %(created)s" +#: django_mfa/api/views.py:64 +msgid "Authentication is required." +msgstr "Authentifizierung ist erforderlich." + #: django_mfa/adapters/totp.py:47 #: django_mfa/models.py:34 -#, fuzzy msgid "Authenticator app" msgstr "Authenticator-App" #: django_mfa/templates/django_mfa/enroll_email.html:31 -#, fuzzy msgid "Back" msgstr "Zurück" #: django_mfa/templates/django_mfa/enroll_totp.html:11 -#, fuzzy msgid "Can't scan it?" msgstr "Können Sie ihn nicht scannen?" #: django_mfa/templates/django_mfa/enroll_email.html:25 #: django_mfa/templates/django_mfa/enroll_totp.html:31 #: django_mfa/templates/django_mfa/enroll_webauthn.html:42 -#, fuzzy msgid "Cancel" msgstr "Abbrechen" #: django_mfa/templates/django_mfa/picker.html:6 -#, fuzzy msgid "Choose how you'd like to confirm your identity." msgstr "Wählen Sie, wie Sie Ihre Identität bestätigen möchten." #: django_mfa/templates/django_mfa/recovery_codes.html:23 #: django_mfa/templates/django_mfa/recovery_codes.html:41 -#, fuzzy msgid "Done" msgstr "Fertig" #: django_mfa/templates/django_mfa/recovery_codes.html:22 -#, fuzzy msgid "Download" msgstr "Herunterladen" #: django_mfa/adapters/email.py:104 #: django_mfa/models.py:37 -#, fuzzy msgid "Emailed code" msgstr "Per E-Mail gesendeter Code" #: django_mfa/templates/django_mfa/verify_recovery_codes.html:5 -#, fuzzy msgid "Enter a recovery code" msgstr "Wiederherstellungscode eingeben" #: django_mfa/templates/django_mfa/verify_email.html:5 #: django_mfa/templates/django_mfa/verify_totp.html:5 -#, fuzzy msgid "Enter your code" msgstr "Geben Sie Ihren Code ein" #: django_mfa/templates/django_mfa/email/recovery_code_used.txt:5 -#, fuzzy msgid "If this wasn't you, change your password and generate a fresh set of codes." msgstr "Falls Sie das nicht waren, ändern Sie Ihr Passwort und erzeugen Sie neue Codes." #: django_mfa/templates/django_mfa/email/mfa_disabled.txt:3 -#, fuzzy msgid "If this wasn't you, change your password and set up two-factor authentication again immediately." msgstr "Falls Sie das nicht waren, ändern Sie sofort Ihr Passwort und richten Sie die Zwei-Faktor-Authentifizierung erneut ein." #: django_mfa/templates/django_mfa/email/factor_removed.txt:3 -#, fuzzy msgid "If this wasn't you, change your password immediately." msgstr "Falls Sie das nicht waren, ändern Sie sofort Ihr Passwort." #: django_mfa/templates/django_mfa/email/factor_added.txt:3 -#, fuzzy msgid "If this wasn't you, remove it and change your password immediately." msgstr "Falls Sie das nicht waren, entfernen Sie sie und ändern Sie sofort Ihr Passwort." #: django_mfa/templates/django_mfa/email/otp_code.txt:5 -#, fuzzy msgid "If you didn't try to sign in, someone may know your password. Change it." msgstr "Wenn Sie sich nicht anmelden wollten, kennt möglicherweise jemand Ihr Passwort. Ändern Sie es." #: django_mfa/templates/django_mfa/email/otp_code.txt:3 -#, fuzzy msgid "It expires in %(validity_minutes)s minutes and can be used once." msgstr "Er läuft in %(validity_minutes)s Minuten ab und kann einmal verwendet werden." -#: django_mfa/templates/django_mfa/security.html:46 -#, fuzzy +#: django_mfa/templates/django_mfa/security.html:55 msgid "Managed by your organization" msgstr "Von Ihrer Organisation verwaltet" #: django_mfa/templates/django_mfa/enroll_webauthn.html:29 -#, fuzzy msgid "Name this key" msgstr "Diesen Schlüssel benennen" #: django_mfa/templates/django_mfa/verify_totp.html:6 -#, fuzzy msgid "Open your authenticator app and enter the six-digit code it shows." msgstr "Öffnen Sie Ihre Authenticator-App und geben Sie den angezeigten sechsstelligen Code ein." #: django_mfa/templates/django_mfa/enroll_webauthn.html:36 -#, fuzzy msgid "Optional. Helps you tell your keys apart later." msgstr "Optional. Hilft Ihnen später, Ihre Schlüssel zu unterscheiden." -#: django_mfa/views/verify.py:54 -#, fuzzy +#: django_mfa/api/views.py:56 +#: django_mfa/views/verify.py:56 msgid "Passkey sign-in failed." msgstr "Passkey-Anmeldung fehlgeschlagen." #: django_mfa/templates/django_mfa/verify_recovery_codes.html:14 -#, fuzzy msgid "Recovery code" msgstr "Wiederherstellungscode" #: django_mfa/adapters/recovery_codes.py:21 #: django_mfa/models.py:36 #: django_mfa/templates/django_mfa/recovery_codes.html:5 -#: django_mfa/templates/django_mfa/security.html:75 -#, fuzzy +#: django_mfa/templates/django_mfa/security.html:84 msgid "Recovery codes" msgstr "Wiederherstellungscodes" #: django_mfa/templates/django_mfa/enroll_webauthn.html:41 -#, fuzzy msgid "Register" msgstr "Registrieren" -#: django_mfa/templates/django_mfa/security.html:42 -#, fuzzy -msgid "Remove" -msgstr "Entfernen" - #: django_mfa/templates/django_mfa/security.html:41 -#, fuzzy msgid "Remove this method?" msgstr "Diese Methode entfernen?" #: django_mfa/templates/django_mfa/recovery_codes.html:8 -#, fuzzy msgid "Save these somewhere safe. Each code works once, and this is the only time they'll be shown." msgstr "Bewahren Sie diese an einem sicheren Ort auf. Jeder Code funktioniert einmal, und sie werden nur dieses eine Mal angezeigt." #: django_mfa/templates/django_mfa/enroll_totp.html:6 -#, fuzzy msgid "Scan this code with an authenticator app, then enter the six-digit code it shows." msgstr "Scannen Sie diesen Code mit einer Authenticator-App und geben Sie dann den angezeigten sechsstelligen Code ein." #: django_mfa/adapters/webauthn.py:72 #: django_mfa/models.py:35 -#, fuzzy msgid "Security key or passkey" msgstr "Sicherheitsschlüssel oder Passkey" #: django_mfa/templates/django_mfa/verify_webauthn.html:6 -#, fuzzy msgid "Select the button below, then follow your browser's prompt to use your security key or passkey." msgstr "Wählen Sie die Schaltfläche unten und folgen Sie der Aufforderung Ihres Browsers, um Ihren Sicherheitsschlüssel oder Passkey zu verwenden." #: django_mfa/templates/django_mfa/enroll_totp.html:5 -#, fuzzy msgid "Set up an authenticator app" msgstr "Authenticator-App einrichten" #: django_mfa/templates/django_mfa/enroll_email.html:5 -#, fuzzy msgid "Set up email codes" msgstr "E-Mail-Codes einrichten" #: django_mfa/templates/django_mfa/enroll_totp.html:20 #: django_mfa/templates/django_mfa/verify_totp.html:12 -#, fuzzy msgid "Six-digit code" msgstr "Sechsstelliger Code" -#: django_mfa/templates/django_mfa/security.html:69 -#, fuzzy +#: django_mfa/templates/django_mfa/security.html:78 msgid "There is nothing left to add." msgstr "Es gibt nichts mehr hinzuzufügen." #: django_mfa/templates/django_mfa/verify_email.html:30 -#, fuzzy msgid "This account has no email code to verify. Use another method, or contact support if you believe this is a mistake." msgstr "Für dieses Konto gibt es keinen zu prüfenden E-Mail-Code. Verwenden Sie eine andere Methode oder wenden Sie sich an den Support, wenn Sie glauben, dass dies ein Fehler ist." +#: django_mfa/api/views.py:70 +msgid "This account must enroll a second factor first." +msgstr "Für dieses Konto muss zuerst ein zweiter Faktor eingerichtet werden." + +#: django_mfa/api/views.py:73 +msgid "This action needs a recent second-factor challenge." +msgstr "Für diese Aktion ist eine kürzlich erfolgte Zwei-Faktor-Abfrage erforderlich." + #: django_mfa/templates/django_mfa/enroll_webauthn.html:12 #: django_mfa/templates/django_mfa/verify_webauthn.html:12 -#, fuzzy msgid "This browser doesn't support security keys or passkeys. Try another browser, or choose a different method." msgstr "Dieser Browser unterstützt keine Sicherheitsschlüssel oder Passkeys. Versuchen Sie einen anderen Browser oder wählen Sie eine andere Methode." +#: django_mfa/api/views.py:125 +msgid "This endpoint does not accept %(method)s." +msgstr "Dieser Endpunkt akzeptiert %(method)s nicht." + +#: django_mfa/api/views.py:330 +#: django_mfa/views/manage.py:103 +msgid "This security key is managed by your organization and cannot be removed here." +msgstr "Dieser Sicherheitsschlüssel wird von Ihrer Organisation verwaltet und kann hier nicht entfernt werden." + +#: django_mfa/api/views.py:67 +msgid "This session must complete a second-factor challenge first." +msgstr "Diese Sitzung muss zuerst eine Zwei-Faktor-Abfrage abschließen." + #: django_mfa/templates/django_mfa/email/factor_removed.txt:1 -#, fuzzy msgid "This two-factor method was removed from your account: %(factor)s" msgstr "Diese Zwei-Faktor-Methode wurde von Ihrem Konto entfernt: %(factor)s" #: django_mfa/templates/django_mfa/enroll_email.html:24 -#, fuzzy msgid "Turn on email codes" msgstr "E-Mail-Codes aktivieren" #: django_mfa/templates/django_mfa/enroll_totp.html:30 -#, fuzzy msgid "Turn on two-factor authentication" msgstr "Zwei-Faktor-Authentifizierung aktivieren" #: django_mfa/templates/django_mfa/base.html:19 #: django_mfa/templates/django_mfa/security.html:6 -#, fuzzy msgid "Two-factor authentication" msgstr "Zwei-Faktor-Authentifizierung" #: django_mfa/templates/django_mfa/email/mfa_disabled_subject.txt:1 -#, fuzzy msgid "Two-factor authentication is off for your account" msgstr "Die Zwei-Faktor-Authentifizierung ist für Ihr Konto deaktiviert" #: django_mfa/templates/django_mfa/security.html:10 -#, fuzzy msgid "Two-factor authentication is required for your account." msgstr "Für Ihr Konto ist die Zwei-Faktor-Authentifizierung erforderlich." #: django_mfa/templates/django_mfa/enroll_webauthn.html:6 -#, fuzzy msgid "Use a hardware security key, or a passkey built into this device such as Touch ID or Windows Hello." msgstr "Verwenden Sie einen Hardware-Sicherheitsschlüssel oder einen in dieses Gerät integrierten Passkey wie Touch ID oder Windows Hello." @@ -314,17 +281,14 @@ msgstr "Verwenden Sie einen Hardware-Sicherheitsschlüssel oder einen in dieses #: django_mfa/templates/django_mfa/verify_recovery_codes.html:25 #: django_mfa/templates/django_mfa/verify_totp.html:23 #: django_mfa/templates/django_mfa/verify_webauthn.html:29 -#, fuzzy msgid "Use another method" msgstr "Andere Methode verwenden" -#: django_mfa/templates/django_mfa/security.html:79 -#, fuzzy +#: django_mfa/templates/django_mfa/security.html:88 msgid "Use one to sign in if you lose access to your other methods." msgstr "Verwenden Sie einen davon zur Anmeldung, wenn Sie den Zugriff auf Ihre anderen Methoden verlieren." #: django_mfa/templates/django_mfa/verify_webauthn.html:5 -#, fuzzy msgid "Use your security key" msgstr "Verwenden Sie Ihren Sicherheitsschlüssel" @@ -332,88 +296,76 @@ msgstr "Verwenden Sie Ihren Sicherheitsschlüssel" #: django_mfa/templates/django_mfa/verify_recovery_codes.html:24 #: django_mfa/templates/django_mfa/verify_totp.html:22 #: django_mfa/templates/django_mfa/verify_webauthn.html:28 -#, fuzzy msgid "Verify" msgstr "Bestätigen" #: django_mfa/templates/django_mfa/picker.html:5 -#, fuzzy msgid "Verify it's you" msgstr "Bestätigen Sie, dass Sie es sind" -#: django_mfa/templates/django_mfa/security.html:82 -#, fuzzy +#: django_mfa/templates/django_mfa/security.html:91 msgid "View recovery codes" msgstr "Wiederherstellungscodes anzeigen" #: django_mfa/templates/django_mfa/enroll_email.html:8 -#, fuzzy msgid "We've emailed a code to %(address)s. Enter it below to turn on email codes." msgstr "Wir haben einen Code an %(address)s gesendet. Geben Sie ihn unten ein, um E-Mail-Codes zu aktivieren." #: django_mfa/templates/django_mfa/verify_email.html:8 -#, fuzzy msgid "We've emailed a code to %(address)s. It expires shortly." msgstr "Wir haben einen Code an %(address)s gesendet. Er läuft in Kürze ab." #: django_mfa/templates/django_mfa/verify_recovery_codes.html:7 -#, fuzzy msgid "You have %(remaining)s recovery code left. Each one works only once." msgid_plural "You have %(remaining)s recovery codes left. Each one works only once." msgstr[0] "Sie haben noch %(remaining)s Wiederherstellungscode. Jeder funktioniert nur einmal." msgstr[1] "Sie haben noch %(remaining)s Wiederherstellungscodes. Jeder funktioniert nur einmal." #: django_mfa/templates/django_mfa/email/recovery_code_used.txt:3 -#, fuzzy msgid "You have %(remaining)s recovery codes left." msgstr "Sie haben noch %(remaining)s Wiederherstellungscodes." -#: django_mfa/templates/django_mfa/security.html:53 -#, fuzzy +#: django_mfa/templates/django_mfa/security.html:62 msgid "You haven't set up two-factor authentication yet." msgstr "Sie haben die Zwei-Faktor-Authentifizierung noch nicht eingerichtet." #: django_mfa/templates/django_mfa/enroll_email.html:29 -#, fuzzy msgid "Your account has no email address, so codes can't be delivered. Add one to your profile first." msgstr "Ihr Konto hat keine E-Mail-Adresse, daher können keine Codes zugestellt werden. Fügen Sie zuerst eine zu Ihrem Profil hinzu." #: django_mfa/templates/django_mfa/email/mfa_disabled.txt:1 -#, fuzzy msgid "Your account is no longer protected by two-factor authentication. A password is now all that's needed to sign in." msgstr "Ihr Konto ist nicht mehr durch Zwei-Faktor-Authentifizierung geschützt. Zum Anmelden genügt jetzt ein Passwort." #: django_mfa/views/verify.py:20 -#, fuzzy msgid "Your code is expired or invalid." msgstr "Ihr Code ist abgelaufen oder ungültig." #: django_mfa/templates/django_mfa/security.html:17 -#, fuzzy msgid "Your methods" msgstr "Ihre Methoden" #: django_mfa/templates/django_mfa/recovery_codes.html:38 -#, fuzzy msgid "Your recovery codes were generated earlier and can't be shown again. If you've lost them, remove and re-add two-factor authentication to get a fresh set." msgstr "Ihre Wiederherstellungscodes wurden bereits erzeugt und können nicht erneut angezeigt werden. Wenn Sie sie verloren haben, entfernen Sie die Zwei-Faktor-Authentifizierung und richten Sie sie erneut ein, um neue Codes zu erhalten." #: django_mfa/templates/django_mfa/email/otp_code_subject.txt:1 -#, fuzzy msgid "Your sign-in code" msgstr "Ihr Anmeldecode" #: django_mfa/templates/django_mfa/email/otp_code.txt:1 -#, fuzzy msgid "Your sign-in code is %(code)s." msgstr "Ihr Anmeldecode lautet %(code)s." #: django_mfa/templates/django_mfa/enroll_webauthn.html:32 -#, fuzzy msgid "e.g. YubiKey, work laptop" msgstr "z. B. YubiKey, Arbeitslaptop" #: django_mfa/templates/django_mfa/security.html:32 -#, fuzzy msgid "last used %(used)s" msgstr "zuletzt verwendet %(used)s" + +#: django_mfa/templates/django_mfa/security.html:51 +msgctxt "second-factor method" +msgid "Remove" +msgstr "Entfernen" diff --git a/django_mfa/locale/django.pot b/django_mfa/locale/django.pot index 4aa7cbf..8a8343f 100644 --- a/django_mfa/locale/django.pot +++ b/django_mfa/locale/django.pot @@ -3,6 +3,7 @@ # This file is distributed under the same licence as the django-mfa package. # #, fuzzy + msgid "" msgstr "" "Project-Id-Version: django-mfa\n" @@ -17,7 +18,7 @@ msgstr "" msgid "%(code_length)s-digit code" msgstr "" -#: django_mfa/templates/django_mfa/security.html:78 +#: django_mfa/templates/django_mfa/security.html:87 msgid "%(remaining)s code remaining." msgid_plural "%(remaining)s codes remaining." msgstr[0] "" @@ -43,7 +44,7 @@ msgstr "" msgid "A two-factor method was removed from your account" msgstr "" -#: django_mfa/templates/django_mfa/security.html:59 +#: django_mfa/templates/django_mfa/security.html:68 msgid "Add a method" msgstr "" @@ -63,6 +64,10 @@ msgstr "" msgid "Added %(created)s" msgstr "" +#: django_mfa/api/views.py:64 +msgid "Authentication is required." +msgstr "" + #: django_mfa/adapters/totp.py:47 #: django_mfa/models.py:34 msgid "Authenticator app" @@ -133,7 +138,7 @@ msgstr "" msgid "It expires in %(validity_minutes)s minutes and can be used once." msgstr "" -#: django_mfa/templates/django_mfa/security.html:46 +#: django_mfa/templates/django_mfa/security.html:55 msgid "Managed by your organization" msgstr "" @@ -149,7 +154,8 @@ msgstr "" msgid "Optional. Helps you tell your keys apart later." msgstr "" -#: django_mfa/views/verify.py:54 +#: django_mfa/api/views.py:56 +#: django_mfa/views/verify.py:56 msgid "Passkey sign-in failed." msgstr "" @@ -160,7 +166,7 @@ msgstr "" #: django_mfa/adapters/recovery_codes.py:21 #: django_mfa/models.py:36 #: django_mfa/templates/django_mfa/recovery_codes.html:5 -#: django_mfa/templates/django_mfa/security.html:75 +#: django_mfa/templates/django_mfa/security.html:84 msgid "Recovery codes" msgstr "" @@ -168,10 +174,6 @@ msgstr "" msgid "Register" msgstr "" -#: django_mfa/templates/django_mfa/security.html:42 -msgid "Remove" -msgstr "" - #: django_mfa/templates/django_mfa/security.html:41 msgid "Remove this method?" msgstr "" @@ -206,7 +208,7 @@ msgstr "" msgid "Six-digit code" msgstr "" -#: django_mfa/templates/django_mfa/security.html:69 +#: django_mfa/templates/django_mfa/security.html:78 msgid "There is nothing left to add." msgstr "" @@ -214,11 +216,32 @@ msgstr "" msgid "This account has no email code to verify. Use another method, or contact support if you believe this is a mistake." msgstr "" +#: django_mfa/api/views.py:70 +msgid "This account must enroll a second factor first." +msgstr "" + +#: django_mfa/api/views.py:73 +msgid "This action needs a recent second-factor challenge." +msgstr "" + #: django_mfa/templates/django_mfa/enroll_webauthn.html:12 #: django_mfa/templates/django_mfa/verify_webauthn.html:12 msgid "This browser doesn't support security keys or passkeys. Try another browser, or choose a different method." msgstr "" +#: django_mfa/api/views.py:125 +msgid "This endpoint does not accept %(method)s." +msgstr "" + +#: django_mfa/api/views.py:330 +#: django_mfa/views/manage.py:103 +msgid "This security key is managed by your organization and cannot be removed here." +msgstr "" + +#: django_mfa/api/views.py:67 +msgid "This session must complete a second-factor challenge first." +msgstr "" + #: django_mfa/templates/django_mfa/email/factor_removed.txt:1 msgid "This two-factor method was removed from your account: %(factor)s" msgstr "" @@ -256,7 +279,7 @@ msgstr "" msgid "Use another method" msgstr "" -#: django_mfa/templates/django_mfa/security.html:79 +#: django_mfa/templates/django_mfa/security.html:88 msgid "Use one to sign in if you lose access to your other methods." msgstr "" @@ -275,7 +298,7 @@ msgstr "" msgid "Verify it's you" msgstr "" -#: django_mfa/templates/django_mfa/security.html:82 +#: django_mfa/templates/django_mfa/security.html:91 msgid "View recovery codes" msgstr "" @@ -297,7 +320,7 @@ msgstr[1] "" msgid "You have %(remaining)s recovery codes left." msgstr "" -#: django_mfa/templates/django_mfa/security.html:53 +#: django_mfa/templates/django_mfa/security.html:62 msgid "You haven't set up two-factor authentication yet." msgstr "" @@ -336,3 +359,8 @@ msgstr "" #: django_mfa/templates/django_mfa/security.html:32 msgid "last used %(used)s" msgstr "" + +#: django_mfa/templates/django_mfa/security.html:51 +msgctxt "second-factor method" +msgid "Remove" +msgstr "" diff --git a/django_mfa/locale/es/LC_MESSAGES/django.mo b/django_mfa/locale/es/LC_MESSAGES/django.mo new file mode 100644 index 0000000000000000000000000000000000000000..560387bbec509882823abc917d0d08d2c524d12a GIT binary patch literal 9788 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 literal 0 HcmV?d00001 diff --git a/django_mfa/locale/ja/LC_MESSAGES/django.po b/django_mfa/locale/ja/LC_MESSAGES/django.po index 5cb3341..5f672e1 100644 --- a/django_mfa/locale/ja/LC_MESSAGES/django.po +++ b/django_mfa/locale/ja/LC_MESSAGES/django.po @@ -2,12 +2,12 @@ # Copyright (C) MicroPyramid # This file is distributed under the same licence as the django-mfa package. # -# MACHINE-DRAFTED, NOT REVIEWED. Every entry below is marked "fuzzy", which -# means gettext ignores it and users see the English source instead. Nothing -# here reaches a user until a human reviews an entry and removes its fuzzy -# flag. See docs/translations.md. -# -#, fuzzy +# MACHINE-DRAFTED, MAINTAINER-REVIEWED -- not reviewed by a native speaker. +# Unlike the first release of this catalog, it is LIVE: no entry is marked +# fuzzy, so these strings are what users of this language actually see. +# Corrections are welcome and wanted; please open an issue or a pull +# request. See docs/translations.md. + msgid "" msgstr "" "Project-Id-Version: django-mfa\n" @@ -20,291 +20,258 @@ msgstr "" #: django_mfa/templates/django_mfa/enroll_email.html:14 #: django_mfa/templates/django_mfa/verify_email.html:15 -#, fuzzy msgid "%(code_length)s-digit code" -msgstr "%(code_length)s桁のコード" +msgstr "%(code_length)s 桁のコード" -#: django_mfa/templates/django_mfa/security.html:78 -#, fuzzy +#: django_mfa/templates/django_mfa/security.html:87 msgid "%(remaining)s code remaining." msgid_plural "%(remaining)s codes remaining." msgstr[0] "残り %(remaining)s 個のコードがあります。" #: django_mfa/templates/django_mfa/email/factor_added_subject.txt:1 -#, fuzzy msgid "A new two-factor method was added to your account" msgstr "アカウントに新しい二要素認証の方法が追加されました" #: django_mfa/templates/django_mfa/email/factor_added.txt:1 -#, fuzzy msgid "A new two-factor method was added to your account: %(factor)s" msgstr "アカウントに新しい二要素認証の方法が追加されました: %(factor)s" #: django_mfa/templates/django_mfa/email/recovery_code_used.txt:1 -#, fuzzy msgid "A recovery code was just used to sign in to your account." msgstr "アカウントへのサインインに復旧コードが使用されました。" #: django_mfa/templates/django_mfa/email/recovery_code_used_subject.txt:1 -#, fuzzy msgid "A recovery code was used to sign in" msgstr "復旧コードでサインインしました" #: django_mfa/templates/django_mfa/email/factor_removed_subject.txt:1 -#, fuzzy msgid "A two-factor method was removed from your account" msgstr "アカウントから二要素認証の方法が削除されました" -#: django_mfa/templates/django_mfa/security.html:59 -#, fuzzy +#: django_mfa/templates/django_mfa/security.html:68 msgid "Add a method" msgstr "方法を追加" #: django_mfa/templates/django_mfa/security.html:11 -#, fuzzy msgid "Add a method below to continue. Until you do, the rest of the site is unavailable." msgstr "続行するには、以下から方法を追加してください。追加するまでサイトの他の部分は利用できません。" #: django_mfa/templates/django_mfa/security.html:7 -#, fuzzy msgid "Add a second step to your sign-in so a stolen password isn't enough on its own." msgstr "サインインに 2 段階目を追加すると、パスワードが盗まれただけでは不十分になります。" #: django_mfa/templates/django_mfa/enroll_webauthn.html:5 -#, fuzzy msgid "Add a security key or passkey" msgstr "セキュリティキーまたはパスキーを追加" #: django_mfa/templates/django_mfa/security.html:30 -#, fuzzy msgid "Added %(created)s" msgstr "%(created)s に追加" +#: django_mfa/api/views.py:64 +msgid "Authentication is required." +msgstr "認証が必要です。" + #: django_mfa/adapters/totp.py:47 #: django_mfa/models.py:34 -#, fuzzy msgid "Authenticator app" msgstr "認証アプリ" #: django_mfa/templates/django_mfa/enroll_email.html:31 -#, fuzzy msgid "Back" msgstr "戻る" #: django_mfa/templates/django_mfa/enroll_totp.html:11 -#, fuzzy msgid "Can't scan it?" msgstr "スキャンできませんか?" #: django_mfa/templates/django_mfa/enroll_email.html:25 #: django_mfa/templates/django_mfa/enroll_totp.html:31 #: django_mfa/templates/django_mfa/enroll_webauthn.html:42 -#, fuzzy msgid "Cancel" msgstr "キャンセル" #: django_mfa/templates/django_mfa/picker.html:6 -#, fuzzy msgid "Choose how you'd like to confirm your identity." msgstr "本人確認の方法を選択してください。" #: django_mfa/templates/django_mfa/recovery_codes.html:23 #: django_mfa/templates/django_mfa/recovery_codes.html:41 -#, fuzzy msgid "Done" msgstr "完了" #: django_mfa/templates/django_mfa/recovery_codes.html:22 -#, fuzzy msgid "Download" msgstr "ダウンロード" #: django_mfa/adapters/email.py:104 #: django_mfa/models.py:37 -#, fuzzy msgid "Emailed code" msgstr "メールで届くコード" #: django_mfa/templates/django_mfa/verify_recovery_codes.html:5 -#, fuzzy msgid "Enter a recovery code" msgstr "復旧コードを入力" #: django_mfa/templates/django_mfa/verify_email.html:5 #: django_mfa/templates/django_mfa/verify_totp.html:5 -#, fuzzy msgid "Enter your code" msgstr "コードを入力" #: django_mfa/templates/django_mfa/email/recovery_code_used.txt:5 -#, fuzzy msgid "If this wasn't you, change your password and generate a fresh set of codes." msgstr "心当たりがない場合は、パスワードを変更し、新しいコードを生成してください。" #: django_mfa/templates/django_mfa/email/mfa_disabled.txt:3 -#, fuzzy msgid "If this wasn't you, change your password and set up two-factor authentication again immediately." msgstr "心当たりがない場合は、直ちにパスワードを変更し、二要素認証を再設定してください。" #: django_mfa/templates/django_mfa/email/factor_removed.txt:3 -#, fuzzy msgid "If this wasn't you, change your password immediately." msgstr "心当たりがない場合は、直ちにパスワードを変更してください。" #: django_mfa/templates/django_mfa/email/factor_added.txt:3 -#, fuzzy msgid "If this wasn't you, remove it and change your password immediately." msgstr "心当たりがない場合は、それを削除し、直ちにパスワードを変更してください。" #: django_mfa/templates/django_mfa/email/otp_code.txt:5 -#, fuzzy msgid "If you didn't try to sign in, someone may know your password. Change it." msgstr "サインインを試みていない場合、誰かがパスワードを知っている可能性があります。変更してください。" #: django_mfa/templates/django_mfa/email/otp_code.txt:3 -#, fuzzy msgid "It expires in %(validity_minutes)s minutes and can be used once." msgstr "%(validity_minutes)s 分で期限切れになり、一度だけ使用できます。" -#: django_mfa/templates/django_mfa/security.html:46 -#, fuzzy +#: django_mfa/templates/django_mfa/security.html:55 msgid "Managed by your organization" msgstr "組織によって管理されています" #: django_mfa/templates/django_mfa/enroll_webauthn.html:29 -#, fuzzy msgid "Name this key" msgstr "このキーに名前を付ける" #: django_mfa/templates/django_mfa/verify_totp.html:6 -#, fuzzy msgid "Open your authenticator app and enter the six-digit code it shows." msgstr "認証アプリを開き、表示された 6 桁のコードを入力してください。" #: django_mfa/templates/django_mfa/enroll_webauthn.html:36 -#, fuzzy msgid "Optional. Helps you tell your keys apart later." msgstr "任意です。後でキーを見分けるのに役立ちます。" -#: django_mfa/views/verify.py:54 -#, fuzzy +#: django_mfa/api/views.py:56 +#: django_mfa/views/verify.py:56 msgid "Passkey sign-in failed." msgstr "パスキーでのサインインに失敗しました。" #: django_mfa/templates/django_mfa/verify_recovery_codes.html:14 -#, fuzzy msgid "Recovery code" msgstr "復旧コード" #: django_mfa/adapters/recovery_codes.py:21 #: django_mfa/models.py:36 #: django_mfa/templates/django_mfa/recovery_codes.html:5 -#: django_mfa/templates/django_mfa/security.html:75 -#, fuzzy +#: django_mfa/templates/django_mfa/security.html:84 msgid "Recovery codes" msgstr "復旧コード" #: django_mfa/templates/django_mfa/enroll_webauthn.html:41 -#, fuzzy msgid "Register" msgstr "登録" -#: django_mfa/templates/django_mfa/security.html:42 -#, fuzzy -msgid "Remove" -msgstr "削除" - #: django_mfa/templates/django_mfa/security.html:41 -#, fuzzy msgid "Remove this method?" msgstr "この方法を削除しますか?" #: django_mfa/templates/django_mfa/recovery_codes.html:8 -#, fuzzy msgid "Save these somewhere safe. Each code works once, and this is the only time they'll be shown." msgstr "安全な場所に保管してください。各コードは一度だけ使用でき、表示されるのはこの一度きりです。" #: django_mfa/templates/django_mfa/enroll_totp.html:6 -#, fuzzy msgid "Scan this code with an authenticator app, then enter the six-digit code it shows." msgstr "このコードを認証アプリでスキャンし、表示された 6 桁のコードを入力してください。" #: django_mfa/adapters/webauthn.py:72 #: django_mfa/models.py:35 -#, fuzzy msgid "Security key or passkey" msgstr "セキュリティキーまたはパスキー" #: django_mfa/templates/django_mfa/verify_webauthn.html:6 -#, fuzzy msgid "Select the button below, then follow your browser's prompt to use your security key or passkey." msgstr "下のボタンを選択し、ブラウザの指示に従ってセキュリティキーまたはパスキーを使用してください。" #: django_mfa/templates/django_mfa/enroll_totp.html:5 -#, fuzzy msgid "Set up an authenticator app" msgstr "認証アプリを設定" #: django_mfa/templates/django_mfa/enroll_email.html:5 -#, fuzzy msgid "Set up email codes" msgstr "メールコードを設定" #: django_mfa/templates/django_mfa/enroll_totp.html:20 #: django_mfa/templates/django_mfa/verify_totp.html:12 -#, fuzzy msgid "Six-digit code" msgstr "6 桁のコード" -#: django_mfa/templates/django_mfa/security.html:69 -#, fuzzy +#: django_mfa/templates/django_mfa/security.html:78 msgid "There is nothing left to add." msgstr "追加できるものはありません。" #: django_mfa/templates/django_mfa/verify_email.html:30 -#, fuzzy msgid "This account has no email code to verify. Use another method, or contact support if you believe this is a mistake." msgstr "このアカウントには確認できるメールコードがありません。別の方法を使用するか、誤りだと思われる場合はサポートにお問い合わせください。" +#: django_mfa/api/views.py:70 +msgid "This account must enroll a second factor first." +msgstr "このアカウントでは先に二要素目の設定が必要です。" + +#: django_mfa/api/views.py:73 +msgid "This action needs a recent second-factor challenge." +msgstr "この操作には最近の二要素認証の確認が必要です。" + #: django_mfa/templates/django_mfa/enroll_webauthn.html:12 #: django_mfa/templates/django_mfa/verify_webauthn.html:12 -#, fuzzy msgid "This browser doesn't support security keys or passkeys. Try another browser, or choose a different method." msgstr "このブラウザはセキュリティキーやパスキーに対応していません。別のブラウザを試すか、他の方法を選択してください。" +#: django_mfa/api/views.py:125 +msgid "This endpoint does not accept %(method)s." +msgstr "このエンドポイントは %(method)s を受け付けません。" + +#: django_mfa/api/views.py:330 +#: django_mfa/views/manage.py:103 +msgid "This security key is managed by your organization and cannot be removed here." +msgstr "このセキュリティキーは組織によって管理されているため、ここでは削除できません。" + +#: django_mfa/api/views.py:67 +msgid "This session must complete a second-factor challenge first." +msgstr "このセッションでは先に二要素認証の確認が必要です。" + #: django_mfa/templates/django_mfa/email/factor_removed.txt:1 -#, fuzzy msgid "This two-factor method was removed from your account: %(factor)s" msgstr "この二要素認証の方法がアカウントから削除されました: %(factor)s" #: django_mfa/templates/django_mfa/enroll_email.html:24 -#, fuzzy msgid "Turn on email codes" msgstr "メールコードを有効にする" #: django_mfa/templates/django_mfa/enroll_totp.html:30 -#, fuzzy msgid "Turn on two-factor authentication" msgstr "二要素認証を有効にする" #: django_mfa/templates/django_mfa/base.html:19 #: django_mfa/templates/django_mfa/security.html:6 -#, fuzzy msgid "Two-factor authentication" msgstr "二要素認証" #: django_mfa/templates/django_mfa/email/mfa_disabled_subject.txt:1 -#, fuzzy msgid "Two-factor authentication is off for your account" msgstr "アカウントの二要素認証が無効になっています" #: django_mfa/templates/django_mfa/security.html:10 -#, fuzzy msgid "Two-factor authentication is required for your account." msgstr "このアカウントには二要素認証が必要です。" #: django_mfa/templates/django_mfa/enroll_webauthn.html:6 -#, fuzzy msgid "Use a hardware security key, or a passkey built into this device such as Touch ID or Windows Hello." msgstr "ハードウェアセキュリティキー、または Touch ID や Windows Hello などこの端末に組み込まれたパスキーを使用してください。" @@ -313,17 +280,14 @@ msgstr "ハードウェアセキュリティキー、または Touch ID や Wind #: django_mfa/templates/django_mfa/verify_recovery_codes.html:25 #: django_mfa/templates/django_mfa/verify_totp.html:23 #: django_mfa/templates/django_mfa/verify_webauthn.html:29 -#, fuzzy msgid "Use another method" msgstr "別の方法を使う" -#: django_mfa/templates/django_mfa/security.html:79 -#, fuzzy +#: django_mfa/templates/django_mfa/security.html:88 msgid "Use one to sign in if you lose access to your other methods." msgstr "他の方法が使えなくなった場合、いずれか 1 つを使ってサインインできます。" #: django_mfa/templates/django_mfa/verify_webauthn.html:5 -#, fuzzy msgid "Use your security key" msgstr "セキュリティキーを使用" @@ -331,87 +295,75 @@ msgstr "セキュリティキーを使用" #: django_mfa/templates/django_mfa/verify_recovery_codes.html:24 #: django_mfa/templates/django_mfa/verify_totp.html:22 #: django_mfa/templates/django_mfa/verify_webauthn.html:28 -#, fuzzy msgid "Verify" msgstr "確認" #: django_mfa/templates/django_mfa/picker.html:5 -#, fuzzy msgid "Verify it's you" msgstr "本人確認" -#: django_mfa/templates/django_mfa/security.html:82 -#, fuzzy +#: django_mfa/templates/django_mfa/security.html:91 msgid "View recovery codes" msgstr "復旧コードを表示" #: django_mfa/templates/django_mfa/enroll_email.html:8 -#, fuzzy msgid "We've emailed a code to %(address)s. Enter it below to turn on email codes." msgstr "%(address)s にコードを送信しました。以下に入力してメールコードを有効にしてください。" #: django_mfa/templates/django_mfa/verify_email.html:8 -#, fuzzy msgid "We've emailed a code to %(address)s. It expires shortly." msgstr "%(address)s にコードを送信しました。まもなく期限切れになります。" #: django_mfa/templates/django_mfa/verify_recovery_codes.html:7 -#, fuzzy msgid "You have %(remaining)s recovery code left. Each one works only once." msgid_plural "You have %(remaining)s recovery codes left. Each one works only once." msgstr[0] "復旧コードが残り %(remaining)s 個あります。各コードは一度だけ使用できます。" #: django_mfa/templates/django_mfa/email/recovery_code_used.txt:3 -#, fuzzy msgid "You have %(remaining)s recovery codes left." msgstr "復旧コードが残り %(remaining)s 個あります。" -#: django_mfa/templates/django_mfa/security.html:53 -#, fuzzy +#: django_mfa/templates/django_mfa/security.html:62 msgid "You haven't set up two-factor authentication yet." msgstr "まだ二要素認証を設定していません。" #: django_mfa/templates/django_mfa/enroll_email.html:29 -#, fuzzy msgid "Your account has no email address, so codes can't be delivered. Add one to your profile first." msgstr "アカウントにメールアドレスが登録されていないため、コードを配信できません。先にプロフィールに追加してください。" #: django_mfa/templates/django_mfa/email/mfa_disabled.txt:1 -#, fuzzy msgid "Your account is no longer protected by two-factor authentication. A password is now all that's needed to sign in." msgstr "アカウントは二要素認証で保護されなくなりました。今はパスワードだけでサインインできます。" #: django_mfa/views/verify.py:20 -#, fuzzy msgid "Your code is expired or invalid." msgstr "コードの有効期限が切れているか、無効です。" #: django_mfa/templates/django_mfa/security.html:17 -#, fuzzy msgid "Your methods" -msgstr "あなたの方法" +msgstr "登録済みの方法" #: django_mfa/templates/django_mfa/recovery_codes.html:38 -#, fuzzy msgid "Your recovery codes were generated earlier and can't be shown again. If you've lost them, remove and re-add two-factor authentication to get a fresh set." msgstr "復旧コードは以前に生成されたもので、再表示はできません。紛失した場合は、二要素認証を削除して再度追加すると新しいコードを取得できます。" #: django_mfa/templates/django_mfa/email/otp_code_subject.txt:1 -#, fuzzy msgid "Your sign-in code" msgstr "サインインコード" #: django_mfa/templates/django_mfa/email/otp_code.txt:1 -#, fuzzy msgid "Your sign-in code is %(code)s." msgstr "サインインコードは %(code)s です。" #: django_mfa/templates/django_mfa/enroll_webauthn.html:32 -#, fuzzy msgid "e.g. YubiKey, work laptop" msgstr "例: YubiKey、仕事用ノート PC" #: django_mfa/templates/django_mfa/security.html:32 -#, fuzzy msgid "last used %(used)s" msgstr "最終使用 %(used)s" + +#: django_mfa/templates/django_mfa/security.html:51 +msgctxt "second-factor method" +msgid "Remove" +msgstr "削除" diff --git a/django_mfa/locale/pt_BR/LC_MESSAGES/django.mo b/django_mfa/locale/pt_BR/LC_MESSAGES/django.mo new file mode 100644 index 0000000000000000000000000000000000000000..8205ef1958feea51d2ac19d84f69a1a7f23240e2 GIT binary patch literal 9603 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 literal 0 HcmV?d00001 diff --git a/django_mfa/locale/pt_BR/LC_MESSAGES/django.po b/django_mfa/locale/pt_BR/LC_MESSAGES/django.po index 14d4cad..df381b5 100644 --- a/django_mfa/locale/pt_BR/LC_MESSAGES/django.po +++ b/django_mfa/locale/pt_BR/LC_MESSAGES/django.po @@ -2,12 +2,12 @@ # Copyright (C) MicroPyramid # This file is distributed under the same licence as the django-mfa package. # -# MACHINE-DRAFTED, NOT REVIEWED. Every entry below is marked "fuzzy", which -# means gettext ignores it and users see the English source instead. Nothing -# here reaches a user until a human reviews an entry and removes its fuzzy -# flag. See docs/translations.md. -# -#, fuzzy +# MACHINE-DRAFTED, MAINTAINER-REVIEWED -- not reviewed by a native speaker. +# Unlike the first release of this catalog, it is LIVE: no entry is marked +# fuzzy, so these strings are what users of this language actually see. +# Corrections are welcome and wanted; please open an issue or a pull +# request. See docs/translations.md. + msgid "" msgstr "" "Project-Id-Version: django-mfa\n" @@ -20,292 +20,259 @@ msgstr "" #: django_mfa/templates/django_mfa/enroll_email.html:14 #: django_mfa/templates/django_mfa/verify_email.html:15 -#, fuzzy msgid "%(code_length)s-digit code" msgstr "Código de %(code_length)s dígitos" -#: django_mfa/templates/django_mfa/security.html:78 -#, fuzzy +#: django_mfa/templates/django_mfa/security.html:87 msgid "%(remaining)s code remaining." msgid_plural "%(remaining)s codes remaining." msgstr[0] "Resta %(remaining)s código." msgstr[1] "Restam %(remaining)s códigos." #: django_mfa/templates/django_mfa/email/factor_added_subject.txt:1 -#, fuzzy msgid "A new two-factor method was added to your account" msgstr "Um novo método de dois fatores foi adicionado à sua conta" #: django_mfa/templates/django_mfa/email/factor_added.txt:1 -#, fuzzy msgid "A new two-factor method was added to your account: %(factor)s" msgstr "Um novo método de dois fatores foi adicionado à sua conta: %(factor)s" #: django_mfa/templates/django_mfa/email/recovery_code_used.txt:1 -#, fuzzy msgid "A recovery code was just used to sign in to your account." msgstr "Um código de recuperação acabou de ser usado para entrar na sua conta." #: django_mfa/templates/django_mfa/email/recovery_code_used_subject.txt:1 -#, fuzzy msgid "A recovery code was used to sign in" msgstr "Um código de recuperação foi usado para entrar" #: django_mfa/templates/django_mfa/email/factor_removed_subject.txt:1 -#, fuzzy msgid "A two-factor method was removed from your account" msgstr "Um método de dois fatores foi removido da sua conta" -#: django_mfa/templates/django_mfa/security.html:59 -#, fuzzy +#: django_mfa/templates/django_mfa/security.html:68 msgid "Add a method" msgstr "Adicionar um método" #: django_mfa/templates/django_mfa/security.html:11 -#, fuzzy msgid "Add a method below to continue. Until you do, the rest of the site is unavailable." msgstr "Adicione um método abaixo para continuar. Até lá, o restante do site fica indisponível." #: django_mfa/templates/django_mfa/security.html:7 -#, fuzzy msgid "Add a second step to your sign-in so a stolen password isn't enough on its own." msgstr "Adicione uma segunda etapa ao seu login para que uma senha roubada não baste sozinha." #: django_mfa/templates/django_mfa/enroll_webauthn.html:5 -#, fuzzy msgid "Add a security key or passkey" msgstr "Adicionar uma chave de segurança ou passkey" #: django_mfa/templates/django_mfa/security.html:30 -#, fuzzy msgid "Added %(created)s" msgstr "Adicionado em %(created)s" +#: django_mfa/api/views.py:64 +msgid "Authentication is required." +msgstr "É necessário estar autenticado." + #: django_mfa/adapters/totp.py:47 #: django_mfa/models.py:34 -#, fuzzy msgid "Authenticator app" msgstr "Aplicativo autenticador" #: django_mfa/templates/django_mfa/enroll_email.html:31 -#, fuzzy msgid "Back" msgstr "Voltar" #: django_mfa/templates/django_mfa/enroll_totp.html:11 -#, fuzzy msgid "Can't scan it?" msgstr "Não consegue escanear?" #: django_mfa/templates/django_mfa/enroll_email.html:25 #: django_mfa/templates/django_mfa/enroll_totp.html:31 #: django_mfa/templates/django_mfa/enroll_webauthn.html:42 -#, fuzzy msgid "Cancel" msgstr "Cancelar" #: django_mfa/templates/django_mfa/picker.html:6 -#, fuzzy msgid "Choose how you'd like to confirm your identity." msgstr "Escolha como deseja confirmar sua identidade." #: django_mfa/templates/django_mfa/recovery_codes.html:23 #: django_mfa/templates/django_mfa/recovery_codes.html:41 -#, fuzzy msgid "Done" msgstr "Concluído" #: django_mfa/templates/django_mfa/recovery_codes.html:22 -#, fuzzy msgid "Download" msgstr "Baixar" #: django_mfa/adapters/email.py:104 #: django_mfa/models.py:37 -#, fuzzy msgid "Emailed code" msgstr "Código por e-mail" #: django_mfa/templates/django_mfa/verify_recovery_codes.html:5 -#, fuzzy msgid "Enter a recovery code" msgstr "Digite um código de recuperação" #: django_mfa/templates/django_mfa/verify_email.html:5 #: django_mfa/templates/django_mfa/verify_totp.html:5 -#, fuzzy msgid "Enter your code" msgstr "Digite seu código" #: django_mfa/templates/django_mfa/email/recovery_code_used.txt:5 -#, fuzzy msgid "If this wasn't you, change your password and generate a fresh set of codes." msgstr "Se não foi você, troque sua senha e gere um novo conjunto de códigos." #: django_mfa/templates/django_mfa/email/mfa_disabled.txt:3 -#, fuzzy msgid "If this wasn't you, change your password and set up two-factor authentication again immediately." msgstr "Se não foi você, troque sua senha e configure a autenticação de dois fatores novamente imediatamente." #: django_mfa/templates/django_mfa/email/factor_removed.txt:3 -#, fuzzy msgid "If this wasn't you, change your password immediately." msgstr "Se não foi você, troque sua senha imediatamente." #: django_mfa/templates/django_mfa/email/factor_added.txt:3 -#, fuzzy msgid "If this wasn't you, remove it and change your password immediately." msgstr "Se não foi você, remova-o e troque sua senha imediatamente." #: django_mfa/templates/django_mfa/email/otp_code.txt:5 -#, fuzzy msgid "If you didn't try to sign in, someone may know your password. Change it." msgstr "Se você não tentou entrar, alguém pode saber sua senha. Troque-a." #: django_mfa/templates/django_mfa/email/otp_code.txt:3 -#, fuzzy msgid "It expires in %(validity_minutes)s minutes and can be used once." msgstr "Ele expira em %(validity_minutes)s minutos e pode ser usado uma vez." -#: django_mfa/templates/django_mfa/security.html:46 -#, fuzzy +#: django_mfa/templates/django_mfa/security.html:55 msgid "Managed by your organization" msgstr "Gerenciado pela sua organização" #: django_mfa/templates/django_mfa/enroll_webauthn.html:29 -#, fuzzy msgid "Name this key" msgstr "Dê um nome a esta chave" #: django_mfa/templates/django_mfa/verify_totp.html:6 -#, fuzzy msgid "Open your authenticator app and enter the six-digit code it shows." msgstr "Abra seu aplicativo autenticador e digite o código de seis dígitos exibido." #: django_mfa/templates/django_mfa/enroll_webauthn.html:36 -#, fuzzy msgid "Optional. Helps you tell your keys apart later." msgstr "Opcional. Ajuda você a diferenciar suas chaves depois." -#: django_mfa/views/verify.py:54 -#, fuzzy +#: django_mfa/api/views.py:56 +#: django_mfa/views/verify.py:56 msgid "Passkey sign-in failed." msgstr "Falha ao entrar com passkey." #: django_mfa/templates/django_mfa/verify_recovery_codes.html:14 -#, fuzzy msgid "Recovery code" msgstr "Código de recuperação" #: django_mfa/adapters/recovery_codes.py:21 #: django_mfa/models.py:36 #: django_mfa/templates/django_mfa/recovery_codes.html:5 -#: django_mfa/templates/django_mfa/security.html:75 -#, fuzzy +#: django_mfa/templates/django_mfa/security.html:84 msgid "Recovery codes" msgstr "Códigos de recuperação" #: django_mfa/templates/django_mfa/enroll_webauthn.html:41 -#, fuzzy msgid "Register" msgstr "Registrar" -#: django_mfa/templates/django_mfa/security.html:42 -#, fuzzy -msgid "Remove" -msgstr "Remover" - #: django_mfa/templates/django_mfa/security.html:41 -#, fuzzy msgid "Remove this method?" msgstr "Remover este método?" #: django_mfa/templates/django_mfa/recovery_codes.html:8 -#, fuzzy msgid "Save these somewhere safe. Each code works once, and this is the only time they'll be shown." msgstr "Guarde-os em um lugar seguro. Cada código funciona uma vez, e esta é a única vez que serão exibidos." #: django_mfa/templates/django_mfa/enroll_totp.html:6 -#, fuzzy msgid "Scan this code with an authenticator app, then enter the six-digit code it shows." msgstr "Escaneie este código com um aplicativo autenticador e digite o código de seis dígitos exibido." #: django_mfa/adapters/webauthn.py:72 #: django_mfa/models.py:35 -#, fuzzy msgid "Security key or passkey" msgstr "Chave de segurança ou passkey" #: django_mfa/templates/django_mfa/verify_webauthn.html:6 -#, fuzzy msgid "Select the button below, then follow your browser's prompt to use your security key or passkey." msgstr "Selecione o botão abaixo e siga as instruções do navegador para usar sua chave de segurança ou passkey." #: django_mfa/templates/django_mfa/enroll_totp.html:5 -#, fuzzy msgid "Set up an authenticator app" msgstr "Configurar um aplicativo autenticador" #: django_mfa/templates/django_mfa/enroll_email.html:5 -#, fuzzy msgid "Set up email codes" msgstr "Configurar códigos por e-mail" #: django_mfa/templates/django_mfa/enroll_totp.html:20 #: django_mfa/templates/django_mfa/verify_totp.html:12 -#, fuzzy msgid "Six-digit code" msgstr "Código de seis dígitos" -#: django_mfa/templates/django_mfa/security.html:69 -#, fuzzy +#: django_mfa/templates/django_mfa/security.html:78 msgid "There is nothing left to add." msgstr "Não há mais nada a adicionar." #: django_mfa/templates/django_mfa/verify_email.html:30 -#, fuzzy msgid "This account has no email code to verify. Use another method, or contact support if you believe this is a mistake." msgstr "Esta conta não tem código por e-mail para verificar. Use outro método ou contate o suporte se achar que isso é um engano." +#: django_mfa/api/views.py:70 +msgid "This account must enroll a second factor first." +msgstr "Esta conta precisa configurar antes um segundo fator." + +#: django_mfa/api/views.py:73 +msgid "This action needs a recent second-factor challenge." +msgstr "Esta ação exige uma verificação de dois fatores recente." + #: django_mfa/templates/django_mfa/enroll_webauthn.html:12 #: django_mfa/templates/django_mfa/verify_webauthn.html:12 -#, fuzzy msgid "This browser doesn't support security keys or passkeys. Try another browser, or choose a different method." msgstr "Este navegador não oferece suporte a chaves de segurança ou passkeys. Tente outro navegador ou escolha um método diferente." +#: django_mfa/api/views.py:125 +msgid "This endpoint does not accept %(method)s." +msgstr "Este endpoint não aceita %(method)s." + +#: django_mfa/api/views.py:330 +#: django_mfa/views/manage.py:103 +msgid "This security key is managed by your organization and cannot be removed here." +msgstr "Esta chave de segurança é gerenciada pela sua organização e não pode ser removida aqui." + +#: django_mfa/api/views.py:67 +msgid "This session must complete a second-factor challenge first." +msgstr "Esta sessão precisa concluir antes uma verificação de dois fatores." + #: django_mfa/templates/django_mfa/email/factor_removed.txt:1 -#, fuzzy msgid "This two-factor method was removed from your account: %(factor)s" msgstr "Este método de dois fatores foi removido da sua conta: %(factor)s" #: django_mfa/templates/django_mfa/enroll_email.html:24 -#, fuzzy msgid "Turn on email codes" msgstr "Ativar códigos por e-mail" #: django_mfa/templates/django_mfa/enroll_totp.html:30 -#, fuzzy msgid "Turn on two-factor authentication" msgstr "Ativar a autenticação de dois fatores" #: django_mfa/templates/django_mfa/base.html:19 #: django_mfa/templates/django_mfa/security.html:6 -#, fuzzy msgid "Two-factor authentication" msgstr "Autenticação de dois fatores" #: django_mfa/templates/django_mfa/email/mfa_disabled_subject.txt:1 -#, fuzzy msgid "Two-factor authentication is off for your account" msgstr "A autenticação de dois fatores está desativada na sua conta" #: django_mfa/templates/django_mfa/security.html:10 -#, fuzzy msgid "Two-factor authentication is required for your account." msgstr "A autenticação de dois fatores é obrigatória para sua conta." #: django_mfa/templates/django_mfa/enroll_webauthn.html:6 -#, fuzzy msgid "Use a hardware security key, or a passkey built into this device such as Touch ID or Windows Hello." msgstr "Use uma chave de segurança física ou uma passkey integrada a este dispositivo, como Touch ID ou Windows Hello." @@ -314,17 +281,14 @@ msgstr "Use uma chave de segurança física ou uma passkey integrada a este disp #: django_mfa/templates/django_mfa/verify_recovery_codes.html:25 #: django_mfa/templates/django_mfa/verify_totp.html:23 #: django_mfa/templates/django_mfa/verify_webauthn.html:29 -#, fuzzy msgid "Use another method" msgstr "Usar outro método" -#: django_mfa/templates/django_mfa/security.html:79 -#, fuzzy +#: django_mfa/templates/django_mfa/security.html:88 msgid "Use one to sign in if you lose access to your other methods." msgstr "Use um deles para entrar caso perca o acesso aos seus outros métodos." #: django_mfa/templates/django_mfa/verify_webauthn.html:5 -#, fuzzy msgid "Use your security key" msgstr "Use sua chave de segurança" @@ -332,88 +296,76 @@ msgstr "Use sua chave de segurança" #: django_mfa/templates/django_mfa/verify_recovery_codes.html:24 #: django_mfa/templates/django_mfa/verify_totp.html:22 #: django_mfa/templates/django_mfa/verify_webauthn.html:28 -#, fuzzy msgid "Verify" msgstr "Verificar" #: django_mfa/templates/django_mfa/picker.html:5 -#, fuzzy msgid "Verify it's you" msgstr "Confirme que é você" -#: django_mfa/templates/django_mfa/security.html:82 -#, fuzzy +#: django_mfa/templates/django_mfa/security.html:91 msgid "View recovery codes" msgstr "Ver códigos de recuperação" #: django_mfa/templates/django_mfa/enroll_email.html:8 -#, fuzzy msgid "We've emailed a code to %(address)s. Enter it below to turn on email codes." msgstr "Enviamos um código para %(address)s. Digite-o abaixo para ativar os códigos por e-mail." #: django_mfa/templates/django_mfa/verify_email.html:8 -#, fuzzy msgid "We've emailed a code to %(address)s. It expires shortly." msgstr "Enviamos um código para %(address)s. Ele expira em breve." #: django_mfa/templates/django_mfa/verify_recovery_codes.html:7 -#, fuzzy msgid "You have %(remaining)s recovery code left. Each one works only once." msgid_plural "You have %(remaining)s recovery codes left. Each one works only once." msgstr[0] "Resta %(remaining)s código de recuperação. Cada um funciona apenas uma vez." msgstr[1] "Restam %(remaining)s códigos de recuperação. Cada um funciona apenas uma vez." #: django_mfa/templates/django_mfa/email/recovery_code_used.txt:3 -#, fuzzy msgid "You have %(remaining)s recovery codes left." msgstr "Restam %(remaining)s códigos de recuperação." -#: django_mfa/templates/django_mfa/security.html:53 -#, fuzzy +#: django_mfa/templates/django_mfa/security.html:62 msgid "You haven't set up two-factor authentication yet." msgstr "Você ainda não configurou a autenticação de dois fatores." #: django_mfa/templates/django_mfa/enroll_email.html:29 -#, fuzzy msgid "Your account has no email address, so codes can't be delivered. Add one to your profile first." msgstr "Sua conta não tem endereço de e-mail, então os códigos não podem ser entregues. Adicione um ao seu perfil primeiro." #: django_mfa/templates/django_mfa/email/mfa_disabled.txt:1 -#, fuzzy msgid "Your account is no longer protected by two-factor authentication. A password is now all that's needed to sign in." msgstr "Sua conta não está mais protegida por autenticação de dois fatores. Agora basta uma senha para entrar." #: django_mfa/views/verify.py:20 -#, fuzzy msgid "Your code is expired or invalid." msgstr "Seu código expirou ou é inválido." #: django_mfa/templates/django_mfa/security.html:17 -#, fuzzy msgid "Your methods" msgstr "Seus métodos" #: django_mfa/templates/django_mfa/recovery_codes.html:38 -#, fuzzy msgid "Your recovery codes were generated earlier and can't be shown again. If you've lost them, remove and re-add two-factor authentication to get a fresh set." msgstr "Seus códigos de recuperação foram gerados antes e não podem ser exibidos novamente. Se você os perdeu, remova e adicione novamente a autenticação de dois fatores para obter um novo conjunto." #: django_mfa/templates/django_mfa/email/otp_code_subject.txt:1 -#, fuzzy msgid "Your sign-in code" msgstr "Seu código de acesso" #: django_mfa/templates/django_mfa/email/otp_code.txt:1 -#, fuzzy msgid "Your sign-in code is %(code)s." msgstr "Seu código de acesso é %(code)s." #: django_mfa/templates/django_mfa/enroll_webauthn.html:32 -#, fuzzy msgid "e.g. YubiKey, work laptop" msgstr "ex.: YubiKey, notebook do trabalho" #: django_mfa/templates/django_mfa/security.html:32 -#, fuzzy msgid "last used %(used)s" msgstr "usado pela última vez em %(used)s" + +#: django_mfa/templates/django_mfa/security.html:51 +msgctxt "second-factor method" +msgid "Remove" +msgstr "Remover" diff --git a/django_mfa/locale/zh_Hans/LC_MESSAGES/django.mo b/django_mfa/locale/zh_Hans/LC_MESSAGES/django.mo new file mode 100644 index 0000000000000000000000000000000000000000..9e56347f7b415f1b2bd9a8cc4119158f125ac8a6 GIT binary patch literal 8784 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/` for every pk, and would + silently miss any endpoint added later without being added to it -- + the exact failure this whole method exists to prevent. + + Only reached for a request that is *about* to be redirected, which + is rare, so the resolve() call is not on the common path. + """ + from django.urls import Resolver404, resolve + + from django_mfa.api.urls import NAMESPACE + + try: + return resolve(request.path).namespace == NAMESPACE + except Resolver404: + return False + def process_request(self, request): from django_mfa import policy from django_mfa.registry import registry @@ -73,7 +106,8 @@ def process_request(self, request): return None if session.is_pending(request): - if self._is_exempt(request.path, self.exempt_paths()): + if (self._is_exempt(request.path, self.exempt_paths()) + or self.is_api_request(request)): return None return redirect_to_login(request.get_full_path(), resolve_url(reverse("mfa:verify")), "next") @@ -101,7 +135,8 @@ def process_request(self, request): if (policy.resolve() and not registry.has_primary_factor(request.user) and policy.mfa_required_for(request.user)): - if self._is_exempt(request.path, self.enrollment_exempt_paths()): + if (self._is_exempt(request.path, self.enrollment_exempt_paths()) + or self.is_api_request(request)): return None return redirect_to_login( request.get_full_path(), diff --git a/django_mfa/static/django_mfa/webauthn.js b/django_mfa/static/django_mfa/webauthn.js index bf8b27c..ddd5628 100644 --- a/django_mfa/static/django_mfa/webauthn.js +++ b/django_mfa/static/django_mfa/webauthn.js @@ -178,6 +178,49 @@ }); } + // Conditional-mediation ("passkey autofill") variant of runPasskeyLogin. + // + // Same fetch/decode/get/encode/submit shape, with two additions that are + // what make it autofill rather than a modal prompt: + // + // mediation: "conditional" -- do not show a modal. The promise stays + // pending, quietly, until the user chooses one of their passkeys from + // the browser's own autofill dropdown on an input marked + // autocomplete="username webauthn". If they never do, it never + // settles, which is the intended behaviour and not a leak. + // signal -- an AbortSignal, because the platform allows only ONE + // outstanding navigator.credentials.get() at a time. Without it, the + // "Sign in with a passkey" button silently does nothing for the rest + // of the page's life: its own get() is rejected on arrival because + // this one still holds the slot. See the button handler below, which + // aborts this before starting its own. + // + // Note both `mediation` and `signal` are siblings of `publicKey` in + // CredentialRequestOptions, NOT fields inside it -- nesting them under + // publicKey is silently ignored, and the ceremony then shows a modal + // exactly as if this function did not exist. + function runConditionalPasskeyLogin(form, controller) { + var beginUrl = form.getAttribute("data-begin-url"); + return fetch(beginUrl, {credentials: "same-origin"}) + .then(function (response) { + if (!response.ok) { + throw new Error("Unable to start passkey sign-in."); + } + return response.json(); + }) + .then(function (body) { + var options = decodeOptions(JSON.parse(body.options)); + options.mediation = "conditional"; + options.signal = controller.signal; + return navigator.credentials.get(options); + }) + .then(function (credential) { + form.querySelector("[name=credential]").value = + JSON.stringify(encodeCredential(credential)); + form.submit(); + }); + } + document.addEventListener("DOMContentLoaded", function () { var form = document.getElementById("webauthn-form"); if (form) { @@ -213,12 +256,22 @@ // Passwordless login form, e.g. on a host project's own login page: //
+ // data-begin-url="{% url 'mfa:passkey_begin' %}" + // data-conditional="true"> // {% csrf_token %} // // // //
+ // + // data-conditional="true" additionally offers the user's passkeys from + // the browser's own autofill dropdown, with no click on the button + // above. It needs a cooperating input ELSEWHERE on the page (typically + // the host's existing username field, which is not django-mfa's to + // render): + // + // Without that autocomplete token the ceremony starts and simply never + // surfaces anywhere -- see docs/recipes.md. // Deliberately independent of the #webauthn-form block above (separate // element ids throughout) so a page is free to have neither, either, or // -- for a factor management page that also wants a "sign in as someone @@ -228,6 +281,38 @@ var passkeyButton = document.getElementById("webauthn-passkey-start"); var passkeyUnsupported = document.getElementById("webauthn-passkey-unsupported"); var passkeyError = document.getElementById("webauthn-passkey-error"); + // Opt-in, per form: data-conditional="true". Off by default because + // it is not free -- see startConditional() below. + var wantsConditional = + passkeyForm.getAttribute("data-conditional") === "true"; + // The in-flight conditional ceremony's controller, or null. Module + // state rather than a closure variable inside the handler, because + // the button handler and the restart path both need to reach it. + var conditionalAbort = null; + + // Begin (or re-begin) an autofill ceremony. Kept restartable: the + // button aborts whatever is running here to claim the single + // get() slot, and hands it back if its own ceremony fails, so a user + // who opens the modal and presses Escape still has working autofill + // afterwards instead of a dropdown that has quietly gone dead. + function startConditional() { + conditionalAbort = new AbortController(); + runConditionalPasskeyLogin(passkeyForm, conditionalAbort) + .catch(function (err) { + // AbortError is the normal, expected outcome every time the + // button takes over -- reporting it would put "signal is + // aborted without reason" in front of a user who did nothing + // wrong. Anything else is a real failure, but this ceremony + // was never explicitly requested by the user, so it is logged + // rather than rendered into the page: the visible passkey + // button is still there and still works. + if (!err || err.name !== "AbortError") { + if (window.console && window.console.debug) { + window.console.debug("[django-mfa] passkey autofill:", err); + } + } + }); + } if (!window.PublicKeyCredential) { if (passkeyUnsupported) { @@ -236,17 +321,47 @@ if (passkeyButton) { passkeyButton.disabled = true; } - } else if (passkeyButton) { - passkeyButton.addEventListener("click", function () { - if (passkeyError) { - passkeyError.textContent = ""; - } - runPasskeyLogin(passkeyForm).catch(function (err) { + } else { + // isConditionalMediationAvailable() is itself newer than WebAuthn, + // so its absence means "no autofill here" rather than an error -- + // a browser without it still gets the button, unchanged. + if (wantsConditional + && typeof PublicKeyCredential.isConditionalMediationAvailable + === "function") { + PublicKeyCredential.isConditionalMediationAvailable() + .then(function (available) { + if (available) { + startConditional(); + } + }) + .catch(function () { + // Availability could not be determined; the button remains. + }); + } + + if (passkeyButton) { + passkeyButton.addEventListener("click", function () { if (passkeyError) { - passkeyError.textContent = err && err.message ? err.message : String(err); + passkeyError.textContent = ""; + } + // Claim the single outstanding-get() slot before asking for + // one, or this click resolves to nothing at all. + var wasConditional = conditionalAbort !== null; + if (conditionalAbort) { + conditionalAbort.abort(); + conditionalAbort = null; } + runPasskeyLogin(passkeyForm).catch(function (err) { + if (passkeyError) { + passkeyError.textContent = + err && err.message ? err.message : String(err); + } + if (wasConditional) { + startConditional(); + } + }); }); - }); + } } } }); diff --git a/django_mfa/templates/django_mfa/security.html b/django_mfa/templates/django_mfa/security.html index 551a1ce..84e91f5 100755 --- a/django_mfa/templates/django_mfa/security.html +++ b/django_mfa/templates/django_mfa/security.html @@ -39,7 +39,15 @@

{% trans "Your methods" %}

{% else %} diff --git a/django_mfa/tests/support/api_urls.py b/django_mfa/tests/support/api_urls.py new file mode 100644 index 0000000..90df5fc --- /dev/null +++ b/django_mfa/tests/support/api_urls.py @@ -0,0 +1,18 @@ +"""Root URLconf for the JSON API tests. + +The suite's default ROOT_URLCONF is `django_mfa.urls` (see test_runner.py), +which mounts the HTML views at `/` and does not mount the API at all -- +the API being opt-in is the point, and most tests should exercise the +default. This adds it under a prefix, the way a host project would. + +A prefix, not `/`, on purpose: it is what makes the tests notice if +anything in the API or the middleware ever assumes the API lives at the +root. +""" + +from django.urls import include, path + +urlpatterns = [ + path("", include("django_mfa.urls")), + path("api/mfa/", include("django_mfa.api.urls")), +] diff --git a/django_mfa/tests/support/i18n.py b/django_mfa/tests/support/i18n.py index 7acf631..974d007 100644 --- a/django_mfa/tests/support/i18n.py +++ b/django_mfa/tests/support/i18n.py @@ -29,6 +29,7 @@ import ast import re +import struct from pathlib import Path from django.utils.translation.template import templatize @@ -222,16 +223,20 @@ def parse_po(path): one) and ``flags``. Deliberately not a general-purpose PO parser: it understands exactly the - subset this project's catalogs use, which is also the subset the writer - in tools/ emits. It judges nothing -- the tests do that. + subset this project's catalogs use, which is also the subset + tools/compile_catalogs.py consumes. It judges nothing -- the tests do + that. """ entries, current, field = [], _blank_entry(), None - # Flags precede the entry they describe ("#, fuzzy" sits above its - # msgid), so they are buffered and attached to the NEXT entry. Adding - # them to `current` as they are read would file every flag against the - # preceding entry instead -- which, for a catalog whose central claim is - # "every entry is fuzzy", would be a guard that reads the wrong rows. + # Flags and msgctxt both PRECEDE the entry they belong to ("#, fuzzy" + # and `msgctxt "..."` sit above the msgid), so both are buffered and + # attached to the NEXT entry. Writing either into `current` as it is + # read files it against the preceding entry instead -- which for a + # context is doubly wrong, because it also leaves the entry that really + # has one looking uncontexted, and the two errors cancel out into a + # catalog that merely looks shifted by one rather than obviously broken. pending_flags = set() + pending_ctxt = None for raw in path.read_text(encoding="utf-8").splitlines(): line = raw.strip() @@ -253,7 +258,11 @@ def parse_po(path): entries.append(current) current = _blank_entry() current["flags"], pending_flags = pending_flags, set() - if keyword.startswith("msgstr"): + current["msgctxt"], pending_ctxt = pending_ctxt, None + if keyword == "msgctxt": + pending_ctxt = value + field = (keyword, None) + elif keyword.startswith("msgstr"): current["msgstrs"].append(value) field = ("msgstrs", len(current["msgstrs"]) - 1) else: @@ -262,7 +271,10 @@ def parse_po(path): elif line.startswith('"') and field: name, index = field chunk = po_unescape(line[1:-1]) - if index is None: + if name == "msgctxt": + # Still buffered -- its entry has not started yet. + pending_ctxt = (pending_ctxt or "") + chunk + elif index is None: current[name] = (current[name] or "") + chunk else: current[name][index] += chunk @@ -285,3 +297,159 @@ def locale_files(): po.parent.parent.name: po for po in sorted(LOCALE_DIR.glob("*/LC_MESSAGES/django.po")) } + + +def format_entry(entry, refs=()): + """One PO entry as text, with ``refs`` as its ``#:`` source references. + + Deliberately does not wrap long lines. gettext's own tools wrap at 77 + columns, but wrapping is cosmetic and unwrapped is both valid and far + easier to diff -- a reworded sentence shows up as one changed line + rather than a reflowed paragraph. + """ + lines = [f"#: {ref}" for ref in refs] + if entry["flags"]: + lines.append(f"#, {', '.join(sorted(entry['flags']))}") + if entry["msgctxt"] is not None: + lines.append(f'msgctxt "{po_escape(entry["msgctxt"])}"') + lines.append(f'msgid "{po_escape(entry["msgid"])}"') + if entry["msgid_plural"] is None: + lines.append(f'msgstr "{po_escape(entry["msgstrs"][0])}"') + else: + lines.append(f'msgid_plural "{po_escape(entry["msgid_plural"])}"') + for index, msgstr in enumerate(entry["msgstrs"]): + lines.append(f'msgstr[{index}] "{po_escape(msgstr)}"') + return "\n".join(lines) + + +def write_po(path, comment, header, entries, references): + """Rewrite a .po/.pot from parsed entries, refreshing source references. + + Translations are carried over verbatim from ``entries`` (normally + parse_po() of the same file), so this is a *reference* refresh, not a + regeneration: it is the one part of a catalog derived from the code + rather than from a translator, and the only part that goes stale on its + own every time a template gains a line. + + ``references`` is extract()'s mapping, so an entry no longer present in + the source simply loses its references rather than silently keeping + wrong ones. + """ + blocks = [comment.rstrip("\n"), 'msgid ""\nmsgstr ""\n' + "\n".join( + f'"{po_escape(line)}\\n"' for line in header.rstrip("\n").split("\n"))] + for entry in entries: + blocks.append(format_entry(entry, references.get( + po_key(entry), []))) + path.write_text("\n\n".join(blocks) + "\n", encoding="utf-8") + + +def po_comment(path): + """The leading comment block, above the header entry.""" + lines = [] + for raw in path.read_text(encoding="utf-8").splitlines(): + if not raw.startswith("#"): + break + lines.append(raw) + return "\n".join(lines) + + +def po_header(path): + """The catalog header -- the msgstr belonging to the empty msgid. + + parse_po() drops it, since it is metadata rather than an entry, but a + compiled catalog cannot do without it: ``gettext`` reads the charset to + decode with and the Plural-Forms expression to select a plural form from + exactly here. A .mo with no header entry decodes as ASCII and counts + plurals the Germanic way regardless of language. + """ + collecting, chunks = False, [] + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not collecting: + # The header is the first entry in the file, so the first + # `msgstr ""` encountered is necessarily its own. + collecting = line == 'msgstr ""' + continue + if not line.startswith('"'): + break + chunks.append(po_unescape(line[1:-1])) + return "".join(chunks) + + +# --- MO files --------------------------------------------------------------- +# +# `msgfmt` is a gettext binary, and this project cannot assume gettext is +# installed -- the same constraint that produced the extractor above. But +# unlike the .po files, a .mo is not optional decoration: Django's +# translation machinery reads *only* compiled catalogs, so an uncompiled +# language is an untranslated one no matter how complete its .po is. +# +# The format is small and fully specified (GNU gettext manual, "The Format of +# GNU MO Files"), so it is written out here directly. Tests do not trust this +# implementation to check itself: they read the shipped .mo back with the +# standard library's own gettext.GNUTranslations, which is an independent +# reader, and then assert Django actually serves the translations. + +MO_MAGIC = 0x950412DE + +#: gettext's separators, which are part of the lookup key rather than +#: decoration: a context is joined to its msgid with EOT, and the two halves +#: of a plural pair (and the plural forms of its translation) with NUL. +CONTEXT_GLUE = "\x04" +PLURAL_GLUE = "\x00" + + +def mo_key(entry): + """The exact byte string gettext will look this entry up by.""" + msgid = entry["msgid"] + if entry["msgctxt"] is not None: + msgid = entry["msgctxt"] + CONTEXT_GLUE + msgid + if entry["msgid_plural"] is not None: + msgid = msgid + PLURAL_GLUE + entry["msgid_plural"] + return msgid.encode("utf-8") + + +def mo_value(entry): + """The translation blob: NUL-joined, one part per plural form.""" + return PLURAL_GLUE.join(entry["msgstrs"]).encode("utf-8") + + +def compile_mo(path): + """Compile one .po file into GNU MO bytes. + + Untranslated and fuzzy entries are omitted rather than written empty, + which is what `msgfmt` does and is load-bearing: an entry present with an + empty translation makes gettext return the empty string, so the UI would + render blank instead of falling back to the English source. + """ + items = [(b"", po_header(path).encode("utf-8"))] + for entry in parse_po(path): + if "fuzzy" in entry["flags"] or not any(entry["msgstrs"]): + continue + items.append((mo_key(entry), mo_value(entry))) + # Sorted by key because readers are entitled to binary-search the tables + # (the C library does; Python's reads them linearly into a dict). Also + # makes the output byte-for-byte reproducible from the same input. + items.sort(key=lambda item: item[0]) + + count = len(items) + keys_start = 7 * 4 + 16 * count + keys, values, key_index, value_index = b"", b"", [], [] + for key, _value in items: + key_index.append((len(key), keys_start + len(keys))) + keys += key + b"\x00" # NUL-terminated for C readers; the + values_start = keys_start + len(keys) # length prefix is what Python uses + for _key, value in items: + value_index.append((len(value), values_start + len(values))) + values += value + b"\x00" + + out = struct.pack( + "<7I", MO_MAGIC, 0, count, 7 * 4, 7 * 4 + count * 8, 0, 0) + for length, offset in key_index + value_index: + out += struct.pack("<2I", length, offset) + return out + keys + values + + +def mo_path(po): + """Where a given .po's compiled form belongs.""" + return po.with_name("django.mo") diff --git a/django_mfa/tests/test_api.py b/django_mfa/tests/test_api.py new file mode 100644 index 0000000..142c2ad --- /dev/null +++ b/django_mfa/tests/test_api.py @@ -0,0 +1,510 @@ +"""The JSON API, with the emphasis on what it must refuse. + +The API is a second way into the same factors. Its whole risk is being a +*weaker* way in -- an endpoint that skips a gate the HTML view applies, or +distinguishes two failures the HTML view deliberately renders alike. Both +keep working when broken, and neither is visible from the outside, so most +of what is here is about refusals rather than happy paths. +""" + +import json +import time + +from django.contrib.auth.models import User +from django.core.cache import cache +from django.test import TestCase, override_settings +from django.urls import reverse + +from django_mfa.adapters.totp import generate_secret +from django_mfa.api import urls as api_urls +from django_mfa.crypto import encrypt +from django_mfa.middleware import MfaMiddleware +from django_mfa.models import Authenticator +from django_mfa.totp import TOTP + +API_URLS = "django_mfa.tests.support.api_urls" + + +def code_for(secret): + return TOTP(secret).now() + + +@override_settings(ROOT_URLCONF=API_URLS) +class ApiTestCase(TestCase): + def setUp(self): + # The rate limiter is cache-backed, and Django's TestCase rolls back + # the database between tests but not the cache. Without this, one + # test's failed attempts spend the next test's budget and a correct + # code comes back rejected -- an inter-test dependency that shows up + # as a failure in whichever test happens to run second. + cache.clear() + self.secret = generate_secret() + self.user = User.objects.create_user("ada", password="pw") + self.client.force_login(self.user) + + def enroll_totp(self): + return Authenticator.objects.create( + user=self.user, type=Authenticator.Type.TOTP, + data={"secret": encrypt(self.secret)}) + + def url(self, name, **kwargs): + return reverse(f"mfa_api:{name}", kwargs=kwargs or None) + + def post(self, name, body=None, **kwargs): + return self.client.post( + self.url(name, **kwargs), data=json.dumps(body or {}), + content_type="application/json") + + def body(self, response): + return json.loads(response.content) + + def error_code(self, response): + return self.body(response)["error"]["code"] + + def set_session_mfa(self, **state): + store = self.client.session + store["mfa"] = {"verified": False, "method": None, "at": None} | state + store.save() + + +class AuthenticationTests(ApiTestCase): + def test_anonymous_gets_401(self): + self.client.logout() + response = self.client.get(self.url("state")) + self.assertEqual(response.status_code, 401) + self.assertEqual(self.error_code(response), "unauthenticated") + + def test_wrong_method_is_405(self): + response = self.client.get(self.url("recovery_codes")) + self.assertEqual(response.status_code, 405) + self.assertEqual(self.error_code(response), "method_not_allowed") + + def test_malformed_body_is_rejected(self): + self.enroll_totp() + response = self.client.post( + self.url("verify_complete", factor_type="totp"), + data="[1, 2, 3]", content_type="application/json") + self.assertEqual(response.status_code, 400) + self.assertEqual(self.error_code(response), "malformed_body") + + @override_settings(MFA_API_AUTHENTICATION="django_mfa.tests.test_api.as_ada") + def test_custom_resolver_identifies_the_user(self): + """And request.user follows it. + + The adapters create Authenticator rows against request.user, so a + resolver that named a different user than the session would enroll + onto the wrong account if the view did not reassign it. + """ + self.client.logout() + response = self.client.get(self.url("state")) + self.assertEqual(response.status_code, 200) + self.assertTrue(self.body(response)["can_enroll"]) + + @override_settings(MFA_API_AUTHENTICATION="django_mfa.tests.test_api.nobody") + def test_custom_resolver_returning_none_is_401(self): + response = self.client.get(self.url("state")) + self.assertEqual(response.status_code, 401) + + +def as_ada(request): + return User.objects.get(username="ada") + + +def nobody(request): + return None + + +class StateTests(ApiTestCase): + def test_state_is_reachable_while_pending(self): + """A client cannot draw a challenge screen it is not allowed to ask + about.""" + self.enroll_totp() + self.set_session_mfa(verified=False) + response = self.client.get(self.url("state")) + self.assertEqual(response.status_code, 200) + body = self.body(response) + self.assertTrue(body["pending"]) + self.assertFalse(body["verified"]) + self.assertEqual([a["type"] for a in body["can_verify_with"]], ["totp"]) + + def test_state_never_exposes_authenticator_data(self): + """The TOTP secret, the recovery-code hashes and the WebAuthn + credential all live in `data`. None of it may leave the server.""" + self.enroll_totp() + response = self.client.get(self.url("state")) + raw = response.content.decode() + self.assertNotIn("data", self.body(response)["authenticators"][0]) + self.assertNotIn(self.secret, raw) + self.assertNotIn(encrypt(self.secret), raw) + + +class PendingSessionTests(ApiTestCase): + """The enroll/verify split, which is the API's sharpest edge. + + Enrolling marks a session verified. So a *pending* user who could reach + an enroll endpoint could satisfy their own challenge with a brand new + factor of their choosing, without ever presenting the one they hold -- + a complete bypass of the second factor for anyone holding only the + password. The HTML views keep enroll out of the pending-exempt set for + exactly this reason; the API must not be the way around it. + """ + + def test_enroll_begin_is_refused_while_pending(self): + self.enroll_totp() + self.set_session_mfa(verified=False) + response = self.post("enroll_begin", factor_type="webauthn") + self.assertEqual(response.status_code, 403) + self.assertEqual(self.error_code(response), "verification_required") + + def test_enroll_complete_is_refused_while_pending(self): + self.enroll_totp() + self.set_session_mfa(verified=False) + fresh = generate_secret() + response = self.post("enroll_complete", factor_type="totp", + body={"secret_key": fresh, + "code": code_for(fresh)}) + self.assertEqual(response.status_code, 403) + self.assertEqual(self.error_code(response), "verification_required") + self.assertFalse(self.client.session["mfa"]["verified"]) + + def test_recovery_codes_are_refused_while_pending(self): + self.enroll_totp() + self.set_session_mfa(verified=False) + response = self.post("recovery_codes") + self.assertEqual(response.status_code, 403) + self.assertEqual(self.error_code(response), "verification_required") + + def test_removal_is_refused_while_pending(self): + authenticator = self.enroll_totp() + self.set_session_mfa(verified=False) + response = self.client.delete( + self.url("remove_factor", pk=authenticator.pk)) + self.assertEqual(response.status_code, 403) + self.assertTrue( + Authenticator.objects.filter(pk=authenticator.pk).exists()) + + def test_verify_is_reachable_while_pending(self): + """The one thing that must work -- it is how a session stops being + pending.""" + self.enroll_totp() + self.set_session_mfa(verified=False) + response = self.post("verify_complete", factor_type="totp", + body={"code": code_for(self.secret)}) + self.assertEqual(response.status_code, 200) + self.assertTrue(self.body(response)["verified"]) + + +class VerifyTests(ApiTestCase): + def setUp(self): + super().setUp() + self.enroll_totp() + self.set_session_mfa(verified=False) + + def test_correct_code_verifies_the_session(self): + response = self.post("verify_complete", factor_type="totp", + body={"code": code_for(self.secret)}) + self.assertEqual(response.status_code, 200) + self.assertIsNotNone(self.body(response)["verified_at"]) + self.assertTrue(self.client.session["mfa"]["verified"]) + + def test_unknown_factor_is_404(self): + response = self.post("verify_complete", factor_type="carrier-pigeon") + self.assertEqual(response.status_code, 404) + + def test_every_failure_looks_identical(self): + """A wrong code, a missing field and a rate-limited attempt must be + indistinguishable. Any difference is an oracle -- and a + distinguishable lockout tells an attacker precisely when to pause. + """ + bodies = [] + for attempt in ({"code": "000000"}, {}, {"code": "not-a-code"}): + response = self.post("verify_complete", factor_type="totp", + body=attempt) + self.assertEqual(response.status_code, 400) + bodies.append(response.content) + + # MFA_VERIFY_RATE_LIMIT defaults to 5/5m, so this one is refused + # without being evaluated at all. + for _ in range(5): + self.post("verify_complete", factor_type="totp", + body={"code": "000000"}) + locked = self.post("verify_complete", factor_type="totp", + body={"code": code_for(self.secret)}) + self.assertEqual(locked.status_code, 400) + bodies.append(locked.content) + + self.assertEqual(len(set(bodies)), 1, "failure responses differ") + + def test_a_malformed_attempt_still_spends_from_the_budget(self): + """Otherwise an attacker gets unlimited attempts by malforming + them.""" + for _ in range(5): + self.post("verify_complete", factor_type="totp", body={}) + response = self.post("verify_complete", factor_type="totp", + body={"code": code_for(self.secret)}) + self.assertEqual(response.status_code, 400, + "the correct code was accepted despite the budget " + "being spent, so malformed attempts were free") + + +class EnrollTests(ApiTestCase): + def setUp(self): + super().setUp() + self.set_session_mfa(verified=True, method="totp", at=int(time.time())) + + def test_enroll_round_trip(self): + begin = self.post("enroll_begin", factor_type="totp") + self.assertEqual(begin.status_code, 200) + secret = self.body(begin)["secret_key"] + + response = self.post("enroll_complete", factor_type="totp", + body={"secret_key": secret, + "code": code_for(secret)}) + self.assertEqual(response.status_code, 201) + body = self.body(response) + self.assertEqual(body["authenticator"]["type"], "totp") + self.assertTrue(body["recovery_codes_pending"]) + self.assertNotIn("data", body["authenticator"]) + self.assertTrue(Authenticator.objects.filter( + user=self.user, type="totp").exists()) + + def test_wrong_code_creates_nothing(self): + secret = self.body(self.post("enroll_begin", + factor_type="totp"))["secret_key"] + response = self.post("enroll_complete", factor_type="totp", + body={"secret_key": secret, "code": "000000"}) + self.assertEqual(response.status_code, 400) + self.assertEqual(self.error_code(response), "invalid") + self.assertFalse(Authenticator.objects.filter(user=self.user).exists()) + + def test_recovery_codes_are_not_enrollable(self): + """They are generated, not enrolled -- the same 404 the HTML view + gives a hand-typed URL.""" + self.assertEqual( + self.post("enroll_begin", factor_type="recovery_codes").status_code, + 404) + + +class StepUpTests(ApiTestCase): + """MFA_STEPUP_MAX_AGE applies here exactly as it does to the HTML views.""" + + def setUp(self): + super().setUp() + self.enroll_totp() + + def test_stale_session_cannot_enroll(self): + self.set_session_mfa(verified=True, method="totp", + at=int(time.time()) - 3600) + response = self.post("enroll_begin", factor_type="webauthn") + self.assertEqual(response.status_code, 403) + self.assertEqual(self.error_code(response), "stepup_required") + + def test_stale_session_cannot_remove_a_factor(self): + row = Authenticator.objects.get(user=self.user, type="totp") + self.set_session_mfa(verified=True, method="totp", + at=int(time.time()) - 3600) + response = self.client.delete(self.url("remove_factor", pk=row.pk)) + self.assertEqual(response.status_code, 403) + self.assertEqual(self.error_code(response), "stepup_required") + self.assertTrue(Authenticator.objects.filter(pk=row.pk).exists()) + + def test_fresh_session_may_enroll(self): + self.set_session_mfa(verified=True, method="totp", at=int(time.time())) + self.assertEqual( + self.post("enroll_begin", factor_type="webauthn").status_code, 200) + + @override_settings(MFA_STEPUP_MAX_AGE=None) + def test_step_up_can_be_switched_off(self): + self.set_session_mfa(verified=True, method="totp", + at=int(time.time()) - 3600) + self.assertEqual( + self.post("enroll_begin", factor_type="webauthn").status_code, 200) + + +class RemovalTests(ApiTestCase): + def setUp(self): + super().setUp() + self.set_session_mfa(verified=True, method="totp", at=int(time.time())) + + def test_removal_deletes_the_row(self): + row = self.enroll_totp() + response = self.client.delete(self.url("remove_factor", pk=row.pk)) + self.assertEqual(response.status_code, 200) + self.assertFalse(Authenticator.objects.filter(pk=row.pk).exists()) + + def test_another_users_factor_is_404_not_403(self): + """404, so the endpoint is not a probe for which ids exist.""" + other = User.objects.create_user("bob", password="pw") + row = Authenticator.objects.create( + user=other, type=Authenticator.Type.TOTP, + data={"secret": encrypt(generate_secret())}) + self.enroll_totp() + response = self.client.delete(self.url("remove_factor", pk=row.pk)) + self.assertEqual(response.status_code, 404) + self.assertTrue(Authenticator.objects.filter(pk=row.pk).exists()) + + def test_unparseable_pk_is_404_not_500(self): + self.enroll_totp() + self.assertEqual( + self.client.delete(self.url("remove_factor", pk="nonsense") + ).status_code, 404) + + @override_settings(MFA_OWNED_BY_ENTERPRISE=True) + def test_enterprise_owned_key_cannot_be_removed(self): + self.enroll_totp() + row = Authenticator.objects.create( + user=self.user, type=Authenticator.Type.WEBAUTHN, data={}) + response = self.client.delete(self.url("remove_factor", pk=row.pk)) + self.assertEqual(response.status_code, 403) + self.assertEqual(self.error_code(response), "managed_by_enterprise") + self.assertTrue(Authenticator.objects.filter(pk=row.pk).exists()) + + +class RecoveryCodeTests(ApiTestCase): + def setUp(self): + super().setUp() + self.enroll_totp() + self.set_session_mfa(verified=True, method="totp", at=int(time.time())) + + def test_codes_are_returned_once(self): + first = self.post("recovery_codes") + self.assertEqual(first.status_code, 201) + codes = self.body(first)["codes"] + self.assertEqual(len(codes), 10) + + second = self.post("recovery_codes") + self.assertEqual(second.status_code, 200) + self.assertIsNone(self.body(second)["codes"], + "plaintext is not stored, so there is nothing " + "honest to return a second time") + self.assertEqual(self.body(second)["remaining"], 10) + + def test_codes_are_not_recoverable_from_the_state_endpoint(self): + codes = self.body(self.post("recovery_codes"))["codes"] + raw = self.client.get(self.url("state")).content.decode() + for code in codes: + self.assertNotIn(code, raw) + + +class CsrfTests(ApiTestCase): + """A session-authenticated JSON endpoint is as forgeable as a form post + without CSRF protection.""" + + def setUp(self): + super().setUp() + self.client = self.client_class(enforce_csrf_checks=True) + self.client.force_login(self.user) + self.set_session_mfa(verified=True, method="totp", at=int(time.time())) + + def test_post_without_a_token_is_refused(self): + response = self.client.post( + self.url("enroll_begin", factor_type="totp"), + data="{}", content_type="application/json") + self.assertEqual(response.status_code, 403) + + def test_get_needs_no_token(self): + self.assertEqual(self.client.get(self.url("state")).status_code, 200) + + +class NoRedirectsTests(ApiTestCase): + """The middleware never answers an API request with a redirect. + + A 302 to an HTML page is not something a JSON client can act on, and + following it yields a 200 full of markup where JSON was expected -- + which is worse, because it looks like success. + """ + + def test_a_pending_session_gets_json_not_a_redirect(self): + self.enroll_totp() + self.set_session_mfa(verified=False) + for response in ( + self.post("enroll_begin", factor_type="totp"), + self.post("recovery_codes"), + self.client.get(self.url("state")), + ): + self.assertNotEqual(response.status_code, 302) + self.assertEqual(response["Content-Type"], "application/json") + + @override_settings(MFA_REQUIRED=True) + def test_a_walled_unenrolled_user_gets_json_not_a_redirect(self): + response = self.client.get(self.url("state")) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Content-Type"], "application/json") + self.assertFalse(self.body(response)["has_primary_factor"]) + + def test_html_pages_still_redirect(self): + """The exemption is scoped to the API namespace, not switched on + globally by mounting it.""" + self.enroll_totp() + self.set_session_mfa(verified=False) + response = self.client.get(reverse("mfa:security_settings")) + self.assertEqual(response.status_code, 302) + + +class EveryEndpointIsGatedTests(ApiTestCase): + """Enumerated, so a new endpoint cannot quietly arrive ungated. + + Since the middleware no longer stops a pending user from reaching these + URLs, each view's own gate is the only thing left -- and an endpoint + added without one would be a complete second-factor bypass for anyone + holding just a password. This walks the URLconf rather than a list, so + the guard covers routes that did not exist when it was written. + """ + + #: Reachable while pending, and each for a reason. state: a client + #: cannot draw a challenge screen it may not ask about. verify_*: they + #: are how a session stops being pending. passkey_*: anonymous by + #: definition -- they are a login. + PENDING_ALLOWED = {"state", "verify_begin", "verify_complete", + "passkey_begin", "passkey_complete"} + + def test_every_endpoint_refuses_a_pending_session(self): + self.enroll_totp() + self.set_session_mfa(verified=False) + + checked = set() + for pattern in api_urls.api_patterns[0]: + name = pattern.name + if name in self.PENDING_ALLOWED: + continue + checked.add(name) + route = str(pattern.pattern) + kwargs = {} + if "factor_type" in route: + kwargs["factor_type"] = "totp" + if "pk" in route: + kwargs["pk"] = str(self.user.pk) + url = self.url(name, **kwargs) + for method in ("get", "post", "delete"): + response = getattr(self.client, method)(url) + with self.subTest(endpoint=name, method=method): + self.assertIn(response.status_code, (403, 405), + f"{method.upper()} {url} answered " + f"{response.status_code} to a pending " + f"session; it must refuse or reject the " + f"method") + if response.status_code == 403: + self.assertEqual(self.error_code(response), + "verification_required") + self.assertTrue(checked, "no endpoints were checked at all") + + +class ApiNotMountedTests(TestCase): + """The API is opt-in, so most installs never mount it. + + MfaMiddleware reverses `mfa_api:` names to build its exempt sets. Under + the suite's default ROOT_URLCONF those names do not resolve, which must + be an ordinary "nothing to add" rather than a NoReverseMatch on every + single request. + """ + + def test_exempt_sets_build_without_the_api(self): + middleware = MfaMiddleware(lambda request: None) + self.assertTrue(middleware.exempt_paths()) + self.assertTrue(middleware.enrollment_exempt_paths()) + + def test_an_ordinary_request_still_works(self): + user = User.objects.create_user("ada", password="pw") + self.client.force_login(user) + self.assertEqual( + self.client.get(reverse("mfa:security_settings")).status_code, 200) diff --git a/django_mfa/tests/test_i18n.py b/django_mfa/tests/test_i18n.py index 68048f7..c7092bf 100644 --- a/django_mfa/tests/test_i18n.py +++ b/django_mfa/tests/test_i18n.py @@ -9,10 +9,11 @@ source -- so a half-translated page is the only symptom, and only in a language nobody on the team reads. -2. **An unreviewed machine draft going live.** The shipped .po files were - machine-drafted and are marked fuzzy, which is exactly what keeps them - inert. Strip a fuzzy flag by accident (a bulk edit, an over-eager - `msgattrib`) and unreviewed text starts appearing in a security UI. +2. **A translation that never reaches anyone.** The catalogs are live now, + and Django reads *only* compiled .mo files. A .po edited without + rerunning `tools/compile_catalogs.py` therefore changes nothing at all: + it looks right in the diff, it looks right in review, and the user keeps + seeing the old text -- or English. 3. **A lazy string reaching somewhere that can't take one.** gettext_lazy returns a proxy, not a str. Templates resolve it, and so does @@ -24,11 +25,15 @@ installed in CI. """ +import gettext +import importlib import json import re import unittest +from pathlib import Path from django.test import SimpleTestCase +from django.utils import translation from django.utils.functional import Promise from django_mfa.adapters.email import EmailAdapter @@ -85,35 +90,174 @@ def test_every_language_covers_the_whole_template(self): ) -class DraftsStayInertTests(unittest.TestCase): - """Nothing unreviewed reaches a user. +class LiveCatalogTests(unittest.TestCase): + """Every shipped language is complete and compiled. - Every shipped translation is machine-drafted. `fuzzy` is what makes that - safe: gettext skips a fuzzy entry entirely and falls back to the English - source. A language graduates by a human reviewing its entries and - removing the flags -- at which point this test is the thing that has to - be updated deliberately, which is the point. + These catalogs are live: no entry is fuzzy, so what is in them is what + users of that language see. Two things can quietly break that, and + neither raises anywhere on its own: + + * A half-translated language. An entry with an empty msgstr falls back to + English, so the page renders in two languages at once and nothing + errors. + * A .po edited without recompiling. Django reads only .mo files, so the + edit changes precisely nothing -- the reviewer sees their fix in the + diff, and the user never sees it at all. """ - def test_every_translated_entry_is_marked_fuzzy(self): + def test_every_entry_is_translated(self): for code, path in i18n.locale_files().items(): with self.subTest(language=code): - live = [e["msgid"] for e in i18n.parse_po(path) - if any(e["msgstrs"]) and "fuzzy" not in e["flags"]] + untranslated = [e["msgid"] for e in i18n.parse_po(path) + if not all(e["msgstrs"])] self.assertEqual( - live, [], - f"{code} has non-fuzzy translations, which means they are " - f"live for users: {live}. Either mark them fuzzy, or -- if " - f"a human really has reviewed this language -- update this " - f"test and docs/translations.md together.", + untranslated, [], + f"{code} has entries with no translation, which render in " + f"English beside translated text: {untranslated}", ) - def test_no_compiled_catalogs_are_committed(self): - """A fully fuzzy catalog compiles to an empty one, so a .mo here is - either dead weight or evidence something was compiled from unreviewed - drafts. Either way it should not ship.""" - found = sorted(p.name for p in i18n.LOCALE_DIR.rglob("*.mo")) - self.assertEqual(found, [], f"unexpected compiled catalogs: {found}") + def test_no_entry_is_still_fuzzy(self): + """fuzzy is how a draft is kept inert; these are no longer drafts. + + A fuzzy entry is skipped by gettext AND dropped by compile_mo(), so + one left behind here is an invisible hole in an otherwise live + language rather than an error anybody would see. + """ + for code, path in i18n.locale_files().items(): + with self.subTest(language=code): + fuzzy = [e["msgid"] for e in i18n.parse_po(path) + if "fuzzy" in e["flags"]] + self.assertEqual(fuzzy, [], f"{code} has fuzzy entries: {fuzzy}") + + def test_compiled_catalogs_are_current(self): + """Each .mo is exactly what its .po compiles to, right now. + + This is the guard for the failure mode above: edit a .po, forget + `tools/compile_catalogs.py`, ship a translation nobody receives. + """ + for code, po in i18n.locale_files().items(): + with self.subTest(language=code): + mo = i18n.mo_path(po) + self.assertTrue( + mo.exists(), + f"{code} has no compiled catalog; Django reads only .mo " + f"files, so this language is not actually translated. " + f"Run `python tools/compile_catalogs.py`.") + self.assertEqual( + mo.read_bytes(), i18n.compile_mo(po), + f"{code}.mo is stale -- its .po has changed since it was " + f"compiled. Run `python tools/compile_catalogs.py`.") + + +class DjangoServesTranslationsTests(SimpleTestCase): + """The compiled catalogs work through Django, not just on paper. + + Everything above reads the catalogs with this project's own code, which + cannot catch a malformed .mo: compile_mo() would have to be wrong in the + same way twice. This reads them back through Django (and so through the + standard library's gettext.GNUTranslations, an implementation nothing + here had a hand in), which is also the exact path a request takes. + """ + + def test_django_serves_every_language(self): + for code, path in i18n.locale_files().items(): + # Django normalises locale names ("pt_BR" -> "pt-br"); its + # override() wants the language code, the directory is named for + # the locale. + language = code.lower().replace("_", "-") + for entry in i18n.parse_po(path): + with self.subTest(language=code, msgid=entry["msgid"]): + with translation.override(language): + if entry["msgctxt"] is not None: + self.assertEqual( + translation.pgettext( + entry["msgctxt"], entry["msgid"]), + entry["msgstrs"][0]) + continue + if entry["msgid_plural"] is None: + self.assertEqual( + translation.gettext(entry["msgid"]), + entry["msgstrs"][0]) + continue + # n=1 selects form 0 under every plural rule these + # six languages use. At n=2 the two-form languages + # move to form 1 while the one-form languages (ja, + # zh_Hans) stay on form 0 -- which is what the + # length of msgstrs tells us. + self.assertEqual( + translation.ngettext( + entry["msgid"], entry["msgid_plural"], 1), + entry["msgstrs"][0]) + self.assertEqual( + translation.ngettext( + entry["msgid"], entry["msgid_plural"], 2), + entry["msgstrs"][-1]) + + +class MsgidCollisionTests(SimpleTestCase): + """No bare msgid of ours is one a bundled Django app also translates. + + gettext keys on the string itself, and Django merges every app's catalog + into one per language. `_add_installed_apps_translations` merges + ``reversed(app_configs)``, so the app listed FIRST in INSTALLED_APPS is + merged LAST and wins any key two apps share. `django.contrib.admin` is + listed first in almost every project. + + The result is invisible: the page renders, in the right language, using + another app's wording -- and only in the languages that app happens to + translate, so it cannot be caught by reading the English UI. `Remove` + was exactly this, silently served as admin's `Enlever`/`删除` rather + than ours, until it was given a context. + + A generic new string ("Save", "Close", "Yes", "Delete") reintroduces the + problem, which is why this is a test and not a note. The fix is never to + reword around admin -- it is to add a ``context``, which makes the key + ours alone. + """ + + #: The apps a host project realistically has installed alongside this + #: one. django.conf's own catalog is the base layer under all of them. + BUNDLED = [ + "django.conf", "django.contrib.admin", "django.contrib.admindocs", + "django.contrib.auth", "django.contrib.contenttypes", + "django.contrib.flatpages", "django.contrib.humanize", + "django.contrib.messages", "django.contrib.postgres", + "django.contrib.redirects", "django.contrib.sessions", + "django.contrib.sites", + ] + + @staticmethod + def _bundled_msgids(module_name, language): + module = importlib.import_module(module_name) + mo = (Path(module.__file__).parent / "locale" / language + / "LC_MESSAGES" / "django.mo") + if not mo.exists(): + return set() + with mo.open("rb") as handle: + catalog = gettext.GNUTranslations(handle)._catalog + # Plural entries are keyed (msgid, index) tuples; only bare string + # keys can collide with a bare msgid of ours. + return {key for key in catalog if isinstance(key, str)} + + def test_no_bare_msgid_collides_with_a_bundled_app(self): + ours = {entry["msgid"] for path in i18n.locale_files().values() + for entry in i18n.parse_po(path) + if entry["msgctxt"] is None} + for language in i18n.locale_files(): + for module_name in self.BUNDLED: + try: + theirs = self._bundled_msgids(module_name, language) + except ImportError: + continue # optional app (psycopg for contrib.postgres) + clashing = sorted(ours & theirs) + with self.subTest(language=language, app=module_name): + self.assertEqual( + clashing, [], + f"{module_name} also translates {clashing} into " + f"{language}, and wins the key whenever it is listed " + f"before django_mfa in INSTALLED_APPS. Give ours a " + f'{{% trans "..." context "..." %}} so the key is ours.', + ) class PlaceholderTests(unittest.TestCase): diff --git a/django_mfa/tests/test_packaging.py b/django_mfa/tests/test_packaging.py index 0d9be53..67c72d1 100644 --- a/django_mfa/tests/test_packaging.py +++ b/django_mfa/tests/test_packaging.py @@ -111,23 +111,29 @@ def test_translation_catalogs_ship(self): source, so a fully translated install looks untranslated with nothing in the logs to say why. - Asserted on the .po files and the .pot: no .mo ships yet, because - every entry is still a fuzzy machine draft (see - django_mfa/tests/test_i18n.py and docs/translations.md). When a - language is reviewed and starts shipping compiled catalogs, add the - .mo check here -- it is the file gettext actually reads at runtime. + The .mo files are the load-bearing half: gettext reads only those, + so a wheel carrying every .po and no .mo is a wheel with no + translations in it whatsoever, and looks complete in a file listing. + They are also the half most easily lost, being the only binary + artifact here and the natural thing for an ignore rule to sweep up + (which .gitignore's blanket `*.mo` did, until the negation in it). """ self.assertIn("django_mfa/locale/django.pot", self.names) - catalogs = [n for n in self.names - if n.startswith("django_mfa/locale/") - and n.endswith("/LC_MESSAGES/django.po")] - on_disk = sorted( - p.relative_to(REPO_ROOT).as_posix() - for p in (REPO_ROOT / "django_mfa" / "locale").glob( - "*/LC_MESSAGES/django.po")) - self.assertEqual( - sorted(catalogs), on_disk, - "the locale directory on disk and in the wheel disagree") + locale = REPO_ROOT / "django_mfa" / "locale" + for suffix in (".po", ".mo"): + in_wheel = sorted( + n for n in self.names + if n.startswith("django_mfa/locale/") + and n.endswith(f"/LC_MESSAGES/django{suffix}")) + on_disk = sorted( + p.relative_to(REPO_ROOT).as_posix() + for p in locale.glob(f"*/LC_MESSAGES/django{suffix}")) + with self.subTest(suffix=suffix): + self.assertTrue(on_disk, f"no django{suffix} files on disk") + self.assertEqual( + in_wheel, on_disk, + f"the django{suffix} catalogs on disk and in the wheel " + f"disagree") def test_migrations_ship(self): """A Django app whose migrations don't ship leaves `migrate` with diff --git a/django_mfa/views/enroll.py b/django_mfa/views/enroll.py index b1c91e4..8f7f085 100644 --- a/django_mfa/views/enroll.py +++ b/django_mfa/views/enroll.py @@ -3,7 +3,7 @@ from django.shortcuts import redirect, render from django.urls import reverse -from django_mfa import events, session +from django_mfa import flows from django_mfa.conf import settings as mfa_settings from django_mfa.decorators import mfa_recent_required from django_mfa.models import Authenticator @@ -26,32 +26,18 @@ def enroll_factor(request, factor_type): if request.method == "POST": try: - authenticator = adapter.complete_enroll(request, request.POST) - except (ValueError, TypeError, KeyError): - # ValueError: the adapter's own "this ceremony/code is invalid" - # signal (e.g. TOTP's wrong code, or a stale/replayed WebAuthn - # registration challenge). - # TypeError: a tampered WebAuthn credential payload -- valid - # JSON, but not the mapping shape fido2 expects (e.g. a JSON - # array), which fido2's own parsing rejects with TypeError - # rather than ValueError. - # KeyError / MultiValueDictKeyError (a KeyError subclass): a - # required POST field is simply missing -- `secret_key` for - # TOTP, `credential` for WebAuthn. - # All three must render the exact same generic 400 a wrong code + # The adapter call, the normalisation of its three rejection + # types into one, the session stamp and the factor_added signal + # are all in flows.attempt_enroll, shared with the JSON API. + flows.attempt_enroll(request, factor_type, request.POST) + except flows.FactorRejected: + # Every rejection renders the exact same generic 400 a wrong code # does: a different message (or an unhandled 500) per failure # mode would itself be a weak oracle about which check failed. context["error_message"] = GENERIC_ERROR context.update(adapter.begin_enroll(request)) return render(request, adapter.enroll_template, context, status=400) - # Enrolling a factor satisfies this session's requirement — the user - # just proved possession. - session.mark_verified(request, factor_type) - events.factor_added.send_robust( - sender=type(adapter), user=request.user, - authenticator=authenticator, request=request) - has_codes = Authenticator.objects.filter( user=request.user, type=Authenticator.Type.RECOVERY_CODES).exists() if not has_codes: diff --git a/django_mfa/views/manage.py b/django_mfa/views/manage.py index 19c8700..9201a97 100644 --- a/django_mfa/views/manage.py +++ b/django_mfa/views/manage.py @@ -3,8 +3,9 @@ from django.http import Http404, HttpResponseForbidden, HttpResponseNotAllowed from django.shortcuts import get_object_or_404, redirect, render, resolve_url from django.urls import reverse +from django.utils.translation import gettext_lazy as _ -from django_mfa import events, policy +from django_mfa import events, flows, policy from django_mfa.adapters.recovery_codes import RecoveryCodesAdapter from django_mfa.conf import settings as mfa_settings from django_mfa.decorators import mfa_recent_required @@ -99,23 +100,9 @@ def manage_factors(request): raise Http404("No such authenticator") from None if (authenticator.type == Authenticator.Type.WEBAUTHN and mfa_settings.MFA_OWNED_BY_ENTERPRISE): - return HttpResponseForbidden( + return HttpResponseForbidden(_( "This security key is managed by your organization and cannot " - "be removed here.") + "be removed here.")) - factor_type, name = authenticator.type, authenticator.name - authenticator.delete() - try: - sender = type(registry.get(factor_type)) - except KeyError: - # A row whose type is no longer registered -- MFA_FACTORS narrowed, - # or registry.unregister() (which checks.py recommends as the - # WebAuthn opt-out). security_settings lists every row regardless of - # the registry, so removing one of these is a supported action and - # must not 500 after the delete has already committed. There is no - # adapter class to name as the sender in that case. - sender = None - events.factor_removed.send_robust( - sender=sender, user=request.user, - factor_type=factor_type, name=name, request=request) + flows.remove_factor(request, authenticator) return redirect(reverse("mfa:security_settings")) diff --git a/django_mfa/views/verify.py b/django_mfa/views/verify.py index cc1cc57..089b3e9 100644 --- a/django_mfa/views/verify.py +++ b/django_mfa/views/verify.py @@ -10,7 +10,7 @@ from django.utils.translation import gettext_lazy as _ from fido2.webauthn import AuthenticationResponse -from django_mfa import events, ratelimit, session +from django_mfa import events, flows, session from django_mfa.adapters.webauthn import AUTH_STATE_KEY, WebAuthnAdapter, get_server from django_mfa.backends import WebAuthnBackend, user_from_handle from django_mfa.conf import settings as mfa_settings @@ -110,49 +110,18 @@ def verify_factor(request, factor_type): session.start_pending(request) if request.method == "POST": - # A locked-out user gets the same response as a wrong code, so the - # lockout is not itself an oracle. - allowed = ratelimit.check(request.user, factor_type) - verified = False - if allowed: - try: - verified = adapter.complete_verify( - request, request.user, request.POST) - except (ValueError, TypeError, KeyError): - # Adapter ceremony failures -- clone detection (ValueError), - # a tampered/malformed credential payload (TypeError from - # fido2 parsing something that isn't a mapping), or a missing - # POST field (KeyError/MultiValueDictKeyError, e.g. no - # `credential`) -- must be indistinguishable from an ordinary - # wrong code: both in the response returned (falls straight - # into the same GENERIC_ERROR branch below) and for rate - # limiting (falls through to record_failure exactly like any - # other failed attempt). Letting any of these propagate would - # be an unhandled 500, and the 500-vs-400 split would itself - # be a weak oracle against that deliberately uniform - # response. - verified = False - if verified: - ratelimit.clear(request.user, factor_type) - session.mark_verified(request, factor_type) - events.mfa_verified.send_robust( - sender=type(adapter), user=request.user, - method=factor_type, request=request) + # Rate limiting, the uniform treatment of every adapter failure, the + # session stamp and both events all live in flows.attempt_verify -- + # shared verbatim with the JSON API, which must not be able to drift + # from this on any of them. All that is left here is how to render + # the answer. + if flows.attempt_verify(request, request.user, factor_type, + request.POST): # Trust this browser for MFA_REMEMBER_DAYS so a future login can # skip the challenge (see the user_logged_in signal in # signals.py, which checks verify_rmb_cookie()). update_rmb_cookie # is a no-op unless MFA_REMEMBER_MY_BROWSER is enabled. return update_rmb_cookie(request, redirect(next_url)) - if allowed: - ratelimit.record_failure(request.user, factor_type) - # Emitted for a refused (rate-limited) attempt too -- see the signal's - # own comment in events.py. This is below the `if allowed` guard on - # purpose: record_failure is budget accounting and must not run when - # the attempt was never evaluated, while the event is an observation - # and must fire either way. - events.mfa_verification_failed.send_robust( - sender=type(adapter), user=request.user, - method=factor_type, request=request) context["error_message"] = GENERIC_ERROR context.update(adapter.begin_verify(request, request.user)) return render(request, adapter.verify_template, context, status=400) @@ -200,77 +169,68 @@ def passkey_begin(request): return JsonResponse({"options": json.dumps(dict(options))}) -def passkey_complete(request): - """Finish a passwordless WebAuthn authentication ceremony and log the - resolved user in. +def resolve_passkey_assertion(request, credential): + """Validate a passwordless assertion and return the user it proves. - POST only. Every failure path -- missing/expired state, a malformed + Returns None for EVERY failure -- missing/expired state, a malformed payload, a userHandle that resolves to no user, a credential ID the resolved user does not hold, a bad signature, or a clone-detected - (regressed) sign counter -- returns the exact same generic 400 via - _passkey_failure(). This is deliberate and security-critical: if - "unknown handle" and "bad signature" produced different responses, an - attacker could use that difference as an oracle to enumerate which - opaque handles correspond to real accounts. None of the branches below - are allowed to leak which specific check failed, in the response body, - the status code, or which branch raises vs. returns -- they must all - funnel into the same `return _passkey_failure()`. - - Sign-count/clone-detection enforcement is NOT reimplemented here: once - the user is resolved, this delegates to - WebAuthnAdapter.complete_verify(), the exact same method the - second-factor verification path (verify_factor) uses, so there is only - ever one copy of that logic to keep correct. The two paths use different - session keys for their in-flight ceremony state (PASSKEY_STATE_KEY here - vs. AUTH_STATE_KEY there) since this ceremony starts before any user is - known and that one starts after -- so the state popped from - PASSKEY_STATE_KEY is re-stashed under AUTH_STATE_KEY immediately before - calling complete_verify(), which pops it from there itself. Both keys - hold the exact same opaque `state` value authenticate_begin()/ - authenticate_complete() round-trip unchanged; only the key name differs. + (regressed) sign counter. That uniformity is security-critical rather + than tidy: if "unknown handle" and "bad signature" were distinguishable, + the difference would be an oracle for enumerating which opaque handles + correspond to real accounts. No branch below may leak which check + failed -- not through the return value, not by raising where another + branch returns. + + On success the session is marked verified *if* the assertion carried + User Verification, and the caller is left to log the user in. It does + not call login() itself, because the two callers (the HTML view and the + JSON API) differ in nothing else and must not differ in this either. + + Sign-count/clone detection is NOT reimplemented here: once the user is + resolved this delegates to WebAuthnAdapter.complete_verify(), the exact + same method the second-factor path uses, so there is only ever one copy + of that logic. The two paths use different session keys for in-flight + ceremony state (PASSKEY_STATE_KEY here vs. AUTH_STATE_KEY there) since + this ceremony starts before any user is known and that one starts after + -- so the state popped from PASSKEY_STATE_KEY is re-stashed under + AUTH_STATE_KEY immediately before calling complete_verify(), which pops + it from there itself. Both hold the identical opaque `state` value; + only the key name differs. """ - if request.method != "POST": - return HttpResponseNotAllowed(["POST"]) - state = request.session.pop(PASSKEY_STATE_KEY, None) if state is None: - return _passkey_failure() + return None try: - raw_credential = json.loads(request.POST["credential"]) - parsed = AuthenticationResponse.from_dict(raw_credential) - except (KeyError, ValueError, TypeError): - # KeyError: no `credential` field in the POST body. + parsed = AuthenticationResponse.from_dict(json.loads(credential)) + except (ValueError, TypeError): # ValueError: not valid JSON, or valid JSON that from_dict() can't # parse as an AuthenticationResponse (missing/malformed fields). # TypeError: from_dict() handed something that isn't a mapping at # all (e.g. a JSON array or scalar). - return _passkey_failure() + return None user_handle = parsed.response.user_handle if not user_handle: # A real discoverable-credential assertion always carries a # userHandle; an authenticator/credential this server never issued a # handle to (e.g. a different site's passkey, or a handcrafted - # payload) does not. Fails the same generic way as every other - # unresolvable case -- see the docstring above. - return _passkey_failure() + # payload) does not. + return None try: user = user_from_handle(user_handle.decode("utf-8")) except UnicodeDecodeError: user = None if user is None: - return _passkey_failure() + return None - # Bridge to WebAuthnAdapter.complete_verify() -- see the docstring above - # for why the state has to move to AUTH_STATE_KEY rather than - # complete_verify() being handed PASSKEY_STATE_KEY directly. request.session[AUTH_STATE_KEY] = state adapter = WebAuthnAdapter() try: verified = adapter.complete_verify( - request, user, {"credential": request.POST["credential"]}) + request, user, {"credential": credential}) except ValueError: # Ceremony rejection (wrong challenge/origin/RP ID, bad signature, # credential ID not among this user's own credentials -- which is @@ -279,37 +239,53 @@ def passkey_complete(request): # (regressed sign counter) both raise ValueError here. Both fail # exactly the same way as an unknown handle. request.session.pop(AUTH_STATE_KEY, None) - return _passkey_failure() + return None if not verified: # complete_verify() returns False (rather than raising) for its own # narrower set of "cannot honour this assertion" cases -- see its # docstring in adapters/webauthn.py. Same generic failure either way. - return _passkey_failure() + return None # A passkey assertion only satisfies BOTH factors when it carries the - # User Verification flag (proof of PIN/biometric, not just possession) -- - # see is_user_verified() below. authenticate_complete() itself only - # *requires* UV when MFA_FIDO2_USER_VERIFICATION="required"; at the - # default "preferred" it happily accepts a User-Present-only assertion, - # so that has to be checked independently here, the same way the - # adapter independently re-parses the sign counter (fido2 2.2.1's - # authenticate_complete() return value carries neither). Mark the - # session verified BEFORE calling auth.login(): the user_logged_in - # receiver in signals.py (stamp_pending_verification) checks - # session.is_verified(request) and skips re-stamping the session pending - # when it's already True -- doing this first is what makes a - # UV-carrying passkey login satisfy both factors in one step. For a - # UP-only assertion, mark_verified() is deliberately NOT called: the - # user is still logged in (WebAuthn login succeeded), but that same - # signal receiver then stamps the session pending exactly as it would - # for a plain password login, since is_verified() is still False at - # that point -- the user must still complete a second-factor challenge. + # User Verification flag (proof of PIN/biometric, not just possession). + # authenticate_complete() itself only *requires* UV when + # MFA_FIDO2_USER_VERIFICATION="required"; at the default "preferred" it + # happily accepts a User-Present-only assertion, so that has to be + # checked independently here, the same way the adapter independently + # re-parses the sign counter (fido2 2.2.1's authenticate_complete() + # return value carries neither). Marking the session verified BEFORE the + # caller's login() is what makes a UV-carrying passkey satisfy both + # factors in one step: the user_logged_in receiver in signals.py + # (stamp_pending_verification) checks session.is_verified(request) and + # skips re-stamping a session that already is. For a UP-only assertion + # mark_verified() is deliberately NOT called -- the user is still logged + # in, but that receiver then stamps the session pending exactly as for a + # plain password login, and a second factor is still required. if parsed.response.authenticator_data.is_user_verified(): session.mark_verified(request, "webauthn") events.mfa_verified.send_robust( - sender=type(adapter), user=user, - method="webauthn", request=request) + sender=type(adapter), user=user, method="webauthn", + request=request) + return user + + +def passkey_complete(request): + """Finish a passwordless ceremony and log the resolved user in. + + POST only. The ceremony itself is resolve_passkey_assertion() above, + shared with the JSON API; everything here is the browser rendering of + its answer -- one generic 400 for every failure, a redirect on success. + """ + if request.method != "POST": + return HttpResponseNotAllowed(["POST"]) + + credential = request.POST.get("credential") + if credential is None: + return _passkey_failure() + user = resolve_passkey_assertion(request, credential) + if user is None: + return _passkey_failure() auth.login(request, user, backend=BACKEND_PATH) return redirect(_safe_next(request)) diff --git a/docs/index.md b/docs/index.md index 1d2c16f..d0eab98 100644 --- a/docs/index.md +++ b/docs/index.md @@ -43,6 +43,7 @@ operations settings api +rest_api security ``` diff --git a/docs/recipes.md b/docs/recipes.md index ed17bc7..efc7a20 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -82,6 +82,48 @@ otherwise silent. It also requires discoverable credentials, so leave Whether one passkey tap satisfies *both* factors depends on user verification (PIN or biometric) rather than mere presence — see {doc}`mfa_flow`. +### Offering passkeys from the autofill dropdown + +A button is a second thing to notice. **Conditional mediation** — "passkey autofill" — +instead offers the user's passkeys from the browser's own dropdown the moment they +focus the username field, the way saved passwords appear. Add `data-conditional="true"` +to the form, and the `webauthn` token to your existing username input: + +
+ ... +
+ + + +Both halves are required, and the failure mode when you forget the input is the +confusing one: the ceremony starts, no error appears anywhere, and nothing is ever +offered — because the dropdown it would have appeared in doesn't exist. The input does +not have to be inside the passkey form; it is normally your ordinary login form's own +username field. + +The button keeps working alongside it. Only one WebAuthn request may be outstanding at +a time, so clicking it aborts the autofill ceremony first and restarts it if the modal +is dismissed. + +It degrades quietly rather than breaking: a browser without +`PublicKeyCredential.isConditionalMediationAvailable()`, or one that reports autofill +unavailable, simply gets the button. Errors on this path go to `console.debug` rather +than into your error element — the user never asked for this ceremony, so a failure in +it shouldn't put a message in front of them while the button is still there and still +works. + +:::{warning} +This is opt-in for a reason: it moves `mfa:passkey_begin` from "once, when someone +clicks the button" to **once per login-page view, for every anonymous visitor**. That +endpoint writes a challenge to the session, so with a database session backend you get +a session row per login-page hit, bots included. Check that your session backend and +`django-admin clearsessions` schedule can absorb that before enabling it on a +high-traffic login page. +::: + ### Greeting a returning user by name With `MFA_QUICKLOGIN = True`, django-mfa sets a hint cookie naming the last account @@ -100,9 +142,15 @@ missing, stale, or tampered cookie returns `None`. ## APIs and non-browser clients +If what you want is for your users to *do* MFA from a non-browser client — enroll, +be challenged, verify — that's {doc}`the JSON API `. Mount it and skip +this section; everything below is about your *own* API endpoints coexisting with +django-mfa, which is a different problem. + `MfaMiddleware` responds to a pending session with a **redirect**, which is right for a browser and wrong for an API client — a mobile app or `fetch()` caller sees a 302 -to an HTML page instead of a useful error. +to an HTML page instead of a useful error. (It already makes this exception for +django-mfa's own API endpoints; it has no way to recognise yours.) If your API sits under a path prefix and authenticates with tokens rather than session cookies, the simplest fix is to exempt it, since token auth doesn't go diff --git a/docs/rest_api.md b/docs/rest_api.md new file mode 100644 index 0000000..942b226 --- /dev/null +++ b/docs/rest_api.md @@ -0,0 +1,207 @@ +# JSON API + +Everything the bundled screens do, as JSON, for a single-page app or a mobile +client that renders its own MFA UI. + +It is **opt-in**. Installing the app does not expose it; you mount it, wherever +you like: + + urlpatterns = [ + ..., + path("mfa/", include("django_mfa.urls")), # the HTML views + path("api/mfa/", include("django_mfa.api.urls")), # this + ] + +You can mount both, or only this one. The `mfa_api` namespace is baked into the +pattern list — don't pass `namespace=` to `include()`. + +## Authentication + +By default the caller is `request.user`: Django's session authentication, which +a same-origin SPA already has, plus CSRF on every non-safe method. Send the +`X-CSRFToken` header exactly as you would for a form post. + +For a client that authenticates another way — a DRF token, a JWT, an API key — +set `MFA_API_AUTHENTICATION` to a callable taking a request and returning a user +or `None`: + + # myapp/api.py + def user_from_token(request): + token = request.headers.get("Authorization", "").removeprefix("Bearer ") + return lookup_user(token) # or None + + # settings.py + MFA_API_AUTHENTICATION = "myapp.api.user_from_token" + +`manage.py check` refuses a path that doesn't import or doesn't resolve to a +callable (`django_mfa.E006`), so a typo fails the deploy rather than the first +request. + +:::{warning} +**MFA state lives in the session, and that is not negotiable by this setting.** +`MFA_API_AUTHENTICATION` answers *who you are*; whether this session has passed +a challenge, and how recently, is read from and written to `request.session` +exactly as it is for a browser. + +So a client must persist the session cookie across requests. One that discards +cookies can call every endpoint here and will still never get anywhere: each +request arrives as a brand-new session, so a verification completed in one +request is gone by the next, and step-up-gated endpoints reject it forever. + +If your clients genuinely cannot hold a cookie, this API is not yet the right +shape for them — say so in an issue rather than working around it, because the +workarounds all end in a bearer token that means "MFA passed" with none of the +session's revocation. +::: + +## Endpoints + +All paths are relative to wherever you mounted it. Every response is a JSON +object. + +| Method | Path | Does | +|---|---|---| +| `GET` | `state/` | Everything needed to render the right screen | +| `POST` | `enroll//begin/` | Start enrolling a factor | +| `POST` | `enroll//complete/` | Finish enrolling it | +| `POST` | `verify//begin/` | Issue a challenge | +| `POST` | `verify//complete/` | Answer a challenge | +| `POST` | `recovery-codes/` | Generate recovery codes, once | +| `DELETE` | `factors//` | Remove an enrolled factor | +| `GET` | `passkey/begin/` | Start passwordless sign-in | +| `POST` | `passkey/complete/` | Finish it, and log in | + +**`begin` is `POST`, not `GET`.** Beginning has real side effects — the email +factor *sends mail*, WebAuthn writes ceremony state to the session — and `POST` +is also what brings CSRF protection. The HTML views use `GET` there only because +rendering a page has to. + +### `GET state/` + +Reachable while a session is still pending, deliberately: a client that couldn't +ask what the user can verify with until *after* verifying would have nothing to +draw the challenge screen from. + + { + "verified": false, + "pending": true, + "verified_at": null, + "authenticators": [ + {"id": 7, "type": "webauthn", "name": "Work laptop", + "verbose_name": "Security key or passkey", + "created_at": "2026-08-14T09:12:03.114Z", "last_used_at": null} + ], + "can_verify_with": [{"type": "webauthn", "verbose_name": "...", + "supports_multiple": true}], + "can_enroll": [{"type": "totp", "verbose_name": "...", + "supports_multiple": false}], + "has_primary_factor": true, + "recovery_codes_remaining": 10, + "stepup_max_age": 300 + } + +`can_verify_with` and `can_enroll` answer different questions and you need both: +the first includes recovery codes (a valid way to prove identity), the second is +what may still be added. `has_primary_factor` is the "is this account actually +protected" answer, which recovery codes alone do not satisfy. + +`Authenticator.data` — the TOTP secret, the recovery-code hashes, the WebAuthn +credential — is never in any response, from any endpoint. + +### Enrolling + + POST enroll/totp/begin/ + → {"secret_key": "...", "provisioning_uri": "otpauth://..."} + + POST enroll/totp/complete/ {"secret_key": "...", "code": "492013"} + → 201 {"authenticator": {...}, "recovery_codes_pending": true} + +The `begin` body is whatever the adapter produced, passed through unchanged. For +WebAuthn that means **`options` is a JSON-encoded string**, not a nested object — +the identical blob the bundled JavaScript parses. Call `JSON.parse` on it. It is +passed through rather than re-encoded so there is only one shape of it in the +project for anyone to keep in sync. + +`recovery_codes_pending` replaces the redirect the HTML flow performs: the user +has no recovery codes yet and should be sent to generate some. + +### Verifying + + POST verify/totp/begin/ → {} (TOTP has nothing to send) + POST verify/email/begin/ → {"address": "a•••@example.com", "code_length": 6} + + POST verify/totp/complete/ {"code": "492013"} + → {"verified": true, "verified_at": 1755149523} + +### Removing a factor + + DELETE factors/7/ → {"removed": true} + +## Errors + +Failures are always: + + {"error": {"code": "verification_required", "detail": "..."}} + +Branch on `code`. `detail` is translated prose meant for a human, and may be +reworded in any release. + +| Code | Status | Means | +|---|---|---| +| `unauthenticated` | 401 | No user, or `MFA_API_AUTHENTICATION` returned `None` | +| `verification_required` | 403 | The session is pending — challenge first | +| `enrollment_required` | 403 | No primary factor enrolled | +| `stepup_required` | 403 | Needs a challenge within `MFA_STEPUP_MAX_AGE` | +| `managed_by_enterprise` | 403 | `MFA_OWNED_BY_ENTERPRISE` protects this key | +| `invalid` | 400 | The submission was rejected | +| `malformed_body` | 400 | The body wasn't a JSON object | +| `method_not_allowed` | 405 | Wrong HTTP method | + +:::{warning} +**`invalid` is deliberately uninformative, and stays that way.** A wrong code, a +malformed payload, a replayed ceremony, a clone-detected authenticator and an +attempt the rate limiter refused all return the identical 400. That is the same +property the HTML views have, for the same reason: any difference between them +is an oracle, and the rate-limit case is the worst one to leak — it tells an +attacker exactly when to back off. + +In particular, **do not show the user "too many attempts"** based on this API. +It cannot tell you that, on purpose. +::: + +## Enforcement and the middleware + +`MfaMiddleware` answers a pending session with a *redirect*, which is wrong for +an API client. When these URLs are mounted, the middleware adds them to its +exempt sets automatically — so the endpoints answer with a status code instead, +and no configuration is needed. + +They go into the *same two separate sets* the HTML views do, and the split is +load-bearing: `state` and the `verify` pair are reachable while pending, the +`enroll` endpoints are not. Enrolling marks a session verified, so a pending user +allowed to enroll could satisfy their own challenge with a factor of their +choosing instead of the one they hold. Each endpoint enforces this itself as +well, rather than trusting the middleware to have done it. + +## Passwordless sign-in + + GET passkey/begin/ → {"options": ""} + POST passkey/complete/ {"credential": ""} + → {"authenticated": true, "verified": true, "verified_at": 1755149523} + +Both are anonymous — that is the point. `verified` distinguishes the two kinds of +success: a passkey used with a PIN or biometric (User Verification) satisfies +both factors at once, while a presence-only assertion logs the user in with a +session **still pending a second factor**. A client that treats them alike will +strand people on a screen they can't leave. + +Every failure returns one identical 400 — unknown handle, unknown credential, bad +signature, expired ceremony, tampered payload. Distinguishing them would let an +attacker enumerate which opaque user handles belong to real accounts. + +:::{note} +`django_mfa.urls` already exposes `mfa:passkey_begin` and `mfa:passkey_complete`. +Those keep their existing shapes — including a `302` on success and a flat +`{"error": "..."}` body — because host projects already parse them. The endpoints +here are the JSON-native equivalents; the ceremony itself is the same code. +::: diff --git a/docs/settings.md b/docs/settings.md index ed91a91..d28e32f 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -179,6 +179,17 @@ Paths are matched exactly against `request.path`, so include the full path as mounted, with its trailing slash. ::: +## JSON API + +| Setting | Default | Description | +|---|---|---| +| `MFA_API_AUTHENTICATION` | `None` | How {doc}`the JSON API ` identifies the caller. `None` uses `request.user` — Django's own session authentication. Otherwise a callable taking a request and returning a user or `None`, or a dotted path to one, for clients that authenticate with a DRF token, a JWT or an API key. | + +This setting is only consulted by `django_mfa.api`, which a project has to mount +explicitly; it does nothing on an install that never did. It answers *identity* +only — see {doc}`rest_api` for why MFA state still lives in the session, and what +that means for a client that discards cookies. + ## System checks django-mfa registers system checks that run on `manage.py check` — and therefore on @@ -189,9 +200,10 @@ E001–E003 are WebAuthn-only: they return no errors at all unless WebAuthn is actually switched on for this install, meaning `MFA_QUICKLOGIN` is on or a WebAuthn adapter is registered (true by default). A project with `MFA_FACTORS = ["totp", "recovery_codes"]` never trips any of them. `E004` and -`E005` are not gated the same way — `MFA_REQUIRED` and `MFA_STEPUP_MAX_AGE` are -not WebAuthn settings, so there is nothing to gate on, and both apply to every -install regardless of which factors are registered. +`E004`–`E006` are not gated the same way — `MFA_REQUIRED`, `MFA_STEPUP_MAX_AGE` +and `MFA_API_AUTHENTICATION` are not WebAuthn settings, so there is nothing to +gate on, and all three apply to every install regardless of which factors are +registered. | Check ID | Severity | Condition | |---|---|---| @@ -200,6 +212,7 @@ install regardless of which factors are registered. | `django_mfa.E003` | Error | WebAuthn is active and `django_mfa.backends.WebAuthnBackend` is missing from `AUTHENTICATION_BACKENDS`. | | `django_mfa.E004` | Error | `MFA_REQUIRED` is a dotted path that fails to import, or resolves to a value that isn't callable. | | `django_mfa.E005` | Error | `MFA_STEPUP_MAX_AGE` is not a positive integer or `None`. | +| `django_mfa.E006` | Error | `MFA_API_AUTHENTICATION` is a dotted path that fails to import, or resolves to a value that isn't callable. | `E003` exists because the failure it prevents is otherwise completely silent. Passwordless login logs a user in by calling `django.contrib.auth.login()` with an diff --git a/docs/translations.md b/docs/translations.md index d9502f5..7106ce9 100644 --- a/docs/translations.md +++ b/docs/translations.md @@ -1,31 +1,34 @@ # Translations -Every string django-mfa shows a user is translatable. What ships today is the -machinery plus six machine-drafted catalogs; **no translation is live yet**, -by design. This page covers what you get out of the box, what your project -has to do to use it, and how to review a language so it starts appearing. +Every string django-mfa shows a user is translatable, and **six languages are +live**: a user whose browser asks for one of them gets django-mfa's screens in +it, with no configuration beyond switching Django's own i18n on. ## What ships - django_mfa/locale/django.pot the template, 75 entries - django_mfa/locale//LC_MESSAGES/django.po + django_mfa/locale/django.pot the template, 75 entries + django_mfa/locale//LC_MESSAGES/django.po the source catalog + django_mfa/locale//LC_MESSAGES/django.mo the compiled catalog -Six languages have draft catalogs: `de`, `es`, `fr`, `pt_BR`, `ja`, `zh_Hans`. +`de`, `es`, `fr`, `pt_BR`, `ja`, `zh_Hans` — complete, with no entry left +untranslated or `fuzzy`. -Every entry in every one of them is marked `#, fuzzy`. That is not an -oversight — it is the whole safety model. gettext skips a fuzzy entry and -falls back to the English source, so a machine draft nobody has read cannot -put words in your product's mouth on a sign-in screen. `django_mfa/tests/` -`test_i18n.py` fails the build if a fuzzy flag disappears without the -deliberate steps below. +:::{note} +These catalogs were **machine-drafted and maintainer-reviewed, not reviewed by +a native speaker.** They are live because a good translation that reaches +users beats a perfect one that never ships — but if something reads wrong to +you, that is a bug worth reporting, and a one-line pull request against a +`.po` file is a genuinely welcome contribution. +::: -For the same reason **no compiled `.mo` files ship**: a fully fuzzy catalog -compiles to an empty one, so shipping it would add bytes and change nothing. +The `.mo` files are the half that matters at runtime: **Django reads only +compiled catalogs.** A `.po` is the source a translator edits; nothing serves +it directly. ## Using them in your project -Django's i18n has to be switched on in the host project — django-mfa can't -do it for you: +Django's i18n has to be switched on in the host project — django-mfa can't do +it for you: USE_I18N = True @@ -46,47 +49,74 @@ the shipped file *and* its `{% trans %}` tags — the strings in your copy are yours to translate, in your project's own catalog. See {doc}`customizing`. ::: -## Reviewing a language so it goes live +## Working on the catalogs + +One command does everything mechanical: + + uv run python tools/compile_catalogs.py + +It refreshes the `#:` source references in every catalog and recompiles every +`.mo`. It never invents, reorders or drops a translation — editing those is +the human part. `test_i18n.py` fails if a `.mo` is out of date with its `.po`, +so a forgotten run is caught in CI rather than shipped as a translation +nobody receives. + +This is `makemessages` + `msgfmt` reimplemented in Python +(`django_mfa/tests/support/i18n.py`) because gettext's binaries are a system +package this project declines to require — including of its own CI. + +### Fixing a translation -A draft becomes a real translation when a human who reads the language has -checked it. The steps, in order: +Edit the `msgstr` in `django_mfa/locale//LC_MESSAGES/django.po`, run the +command above, and commit the `.po` and `.mo` together. -1. Read `django_mfa/locale//LC_MESSAGES/django.po` end to end. Fix - what's wrong. Pay particular attention to `%(name)s` placeholders: they - must appear in the translation exactly as in the source, or interpolation - raises `KeyError` in the middle of somebody's sign-in. A test enforces - this, but understanding why matters more than the test. -2. Remove the `#, fuzzy` line above each entry you have reviewed. An entry - keeps falling back to English until you do. -3. Compile it: `django-admin compilemessages -l `. This needs the - `gettext` tools installed (`apt install gettext`, `brew install gettext`). -4. Commit the `.mo` alongside the `.po`. It is deliberately un-ignored in - `.gitignore` — the `.mo` is the file gettext reads at runtime, and - hatchling won't put an ignored file in the wheel. -5. Update `test_i18n.py`'s `DraftsStayInertTests`, which asserts nothing is - live. Make it assert what's now true — that this language is reviewed and - the others are not. Changing that test should feel deliberate. +Placeholders like `%(name)s` must appear in the translation exactly as in the +source. Get one wrong and interpolation raises `KeyError` in the middle of +somebody's sign-in — this is the one translation mistake that is an outage +rather than an embarrassment. A test enforces it; understanding why matters +more than the test does. -## Adding a language +### Adding a language - django-admin makemessages -l # run from django_mfa/ +Copy `django.pot` to `django_mfa/locale//LC_MESSAGES/django.po`, set +`Language:` and `Plural-Forms:` in its header, translate every `msgstr`, and +run the command above. -Then follow the review steps above. There is no draft to start from, which -is fine: an empty `msgstr` falls back to English exactly as a fuzzy one does. +Getting `Plural-Forms` right matters more than it looks: it is what selects +between `msgstr[0]` and `msgstr[1]`, so a wrong rule produces fluent text +attached to the wrong number. The +[gettext manual's table](https://www.gnu.org/software/gettext/manual/html_node/Plural-forms.html) +has the correct expression for each language. -## Adding or changing a string +A partial catalog is not useful here — a page rendered half in the user's +language and half in English is worse than one rendered wholly in English — +and `test_i18n.py` requires every entry to be translated. -Wrap it — `{% trans %}` / `{% blocktrans %}` in a template, -`gettext_lazy as _` in Python — then regenerate: +### Adding or changing a string - django-admin makemessages -a --keep-pot # from django_mfa/ +Wrap it: `{% trans %}` / `{% blocktrans %}` in a template, `gettext_lazy as _` +in Python. Then add the entry by hand to `django.pot` and to each `.po`, and +run the command above. `test_i18n.py` fails while the catalogs and the code +disagree, so this cannot be half-done quietly. -`test_i18n.py` fails if the catalog and the code disagree, so a forgotten -regeneration is caught in CI rather than discovered by a translator months -later. That check runs a pure-Python extractor (`tests/support/i18n.py`) -rather than shelling out to `xgettext`, so it works on machines and CI -images that have no gettext installed. +:::{warning} +**Give a generic string a `context`.** gettext keys on the string itself, and +Django merges every installed app's catalog into one per language — so a bare +`{% trans "Save" %}` collides with `django.contrib.admin`'s own `"Save"`, and +whichever app `INSTALLED_APPS` lists *first* wins the key. Admin is listed +first in nearly every project. + +The result is invisible: the page renders, in the right language, in another +app's words — and only in the languages that app translates, so reading the +English UI will never reveal it. `"Remove"` was exactly this, silently served +as admin's wording, until it became: + + {% trans "Remove" context "second-factor method" %} + +`test_i18n.py` now fails on any bare msgid that a bundled Django app also +translates. The fix is always a context, never rewording around admin. +::: -Two things are deliberately **not** translated: management-command output -and system-check messages. Both are read by operators and developers, not -end users, and both are matched against by scripts. +Two things are deliberately **not** translated: management-command output and +system-check messages. Both are read by operators and developers, not end +users, and both are matched against by scripts. diff --git a/pyproject.toml b/pyproject.toml index e9c051c..dc1635d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "django-mfa" -version = "4.3.0" +version = "4.4.0" description = "Second-factor authentication for Django: authenticator apps (TOTP), security keys and passkeys (WebAuthn), and recovery codes." readme = "README.md" requires-python = ">=3.10" diff --git a/sandbox/sample/forms.py b/sandbox/sample/forms.py index 79bcb2e..9938d6d 100644 --- a/sandbox/sample/forms.py +++ b/sandbox/sample/forms.py @@ -28,6 +28,12 @@ def __init__(self, *args, **kwargs): super(LoginForm, self).__init__(*args, **kwargs) for field in self.fields.values(): field.widget.attrs = {'class': 'form-control'} + # The "webauthn" token is what makes passkey autofill (conditional + # mediation) surface anything -- the browser offers the user's + # passkeys in this field's own dropdown. Set after the loop above, + # which replaces attrs wholesale rather than updating it. See + # login.html's data-conditional form and docs/recipes.md. + self.fields['email'].widget.attrs['autocomplete'] = 'username webauthn' def clean(self): email = self.cleaned_data.get('email') diff --git a/sandbox/templates/login.html b/sandbox/templates/login.html index 664a26a..52eedcc 100644 --- a/sandbox/templates/login.html +++ b/sandbox/templates/login.html @@ -168,6 +168,36 @@

Django MFA

+ + {# Passwordless sign-in. Everything here is driven by + django_mfa's own webauthn.js purely from these element + ids -- see docs/recipes.md. data-conditional="true" + additionally offers passkeys from the email field's + autofill dropdown above (which carries + autocomplete="username webauthn", set in forms.py). #} +
+
+ +

+
+ {% csrf_token %} + +
+
+ +
+
+
+
+