From 6c1a59136e1a3c073461a5e331ce1ae7a1723f51 Mon Sep 17 00:00:00 2001 From: Ashwin Date: Thu, 13 Aug 2026 06:34:40 +0530 Subject: [PATCH 1/3] Refactor Django MFA views and remove legacy U2F support - Introduced a new package structure for views, splitting them into separate files for enrollment, management, and verification. - Implemented `enroll_factor`, `security_settings`, `recovery_codes`, `manage_factors`, and `verify` views to handle MFA functionalities. - Removed the `configure.html` template and U2F-related JavaScript from the login template. - Updated the login view to utilize Django MFA's session management for pending verification. - Adjusted settings to support Django 4.2 and 5.2, including changes to the storage backend for static files. - Updated the test runner and tox configuration to reflect the new Django versions and removed deprecated dependencies. --- django_mfa/adapters/__init__.py | 60 + django_mfa/adapters/recovery_codes.py | 68 + django_mfa/adapters/totp.py | 58 + django_mfa/adapters/webauthn.py | 277 ++++ django_mfa/admin.py | 8 +- django_mfa/apps.py | 12 + django_mfa/backends.py | 42 + django_mfa/checks.py | 117 ++ django_mfa/conf.py | 30 + django_mfa/crypto.py | 26 + django_mfa/forms.py | 46 - django_mfa/handles.py | 80 ++ django_mfa/middleware.py | 71 +- django_mfa/migrations/0004_authenticator.py | 53 + .../0005_migrate_to_authenticator.py | 20 + django_mfa/migrations/0006_mfa_user_handle.py | 25 + .../migrations/0007_drop_legacy_models.py | 60 + django_mfa/migrations/_helpers.py | 50 + django_mfa/models.py | 85 +- django_mfa/quicklogin.py | 58 + django_mfa/ratelimit.py | 49 + django_mfa/registry.py | 142 ++ django_mfa/session.py | 24 + django_mfa/signals.py | 99 ++ django_mfa/static/django_mfa/webauthn.js | 253 ++++ django_mfa/static/django_u2f/django_u2f.js | 39 - django_mfa/static/django_u2f/u2f-api.js | 748 ---------- django_mfa/static/style.css | 299 +++- django_mfa/templates/django_mfa/base.html | 5 +- .../templates/django_mfa/configure.html | 72 - .../templates/django_mfa/disable_mfa.html | 14 - .../templates/django_mfa/enable_mfa.html | 18 - .../templates/django_mfa/enroll_totp.html | 23 + .../templates/django_mfa/enroll_webauthn.html | 32 + .../templates/django_mfa/login_verify.html | 31 - django_mfa/templates/django_mfa/picker.html | 13 + .../templates/django_mfa/recovery_codes.html | 36 +- django_mfa/templates/django_mfa/security.html | 108 +- .../django_mfa/verify_recovery_codes.html | 21 + .../django_mfa/verify_second_factor.html | 34 - .../django_mfa/verify_second_factor_mfa.html | 32 - .../templates/django_mfa/verify_totp.html | 21 + .../templates/django_mfa/verify_webauthn.html | 26 + django_mfa/templates/u2f/add_key.html | 24 - django_mfa/templates/u2f/base.html | 16 - django_mfa/templates/u2f/key_list.html | 60 - .../u2f/verify_second_factor_u2f.html | 38 - django_mfa/templates/u2f_base.html | 36 - django_mfa/templatetags/otp_tags.py | 43 +- django_mfa/templatetags/webauthn_tags.py | 40 + django_mfa/tests/support/__init__.py | 0 django_mfa/tests/support/authenticator.py | 181 +++ django_mfa/tests/test_adapter_recovery.py | 132 ++ django_mfa/tests/test_adapter_totp.py | 98 ++ django_mfa/tests/test_adapter_webauthn.py | 368 +++++ django_mfa/tests/test_compat.py | 15 + django_mfa/tests/test_conf.py | 169 +++ django_mfa/tests/test_crypto.py | 26 + django_mfa/tests/test_enforcement.py | 276 ++++ django_mfa/tests/test_forms.py | 32 - django_mfa/tests/test_handles.py | 72 + django_mfa/tests/test_migrations.py | 307 +++++ django_mfa/tests/test_models.py | 108 +- django_mfa/tests/test_passwordless.py | 552 ++++++++ django_mfa/tests/test_ratelimit.py | 82 ++ django_mfa/tests/test_registry.py | 106 ++ django_mfa/tests/test_sandbox_smoke.py | 20 + django_mfa/tests/test_session.py | 39 + .../tests/test_support_authenticator.py | 197 +++ django_mfa/tests/test_templatetags.py | 68 + django_mfa/tests/test_u2f_removal.py | 42 + django_mfa/tests/test_views.py | 1197 ++++++++++++++--- django_mfa/urls.py | 35 +- django_mfa/views.py | 431 ------ django_mfa/views/__init__.py | 13 + django_mfa/views/enroll.py | 58 + django_mfa/views/manage.py | 82 ++ django_mfa/views/picker.py | 17 + django_mfa/views/verify.py | 342 +++++ sandbox/sample/views.py | 14 +- sandbox/templates/configure.html | 84 -- sandbox/templates/login.html | 4 - sandbox/test_django_mfa/settings.py | 47 +- sandbox/test_django_mfa/urls.py | 8 - sandbox/test_django_mfa/wsgi.py | 7 +- setup.py | 23 +- test_runner.py | 22 +- tox.ini | 8 +- 88 files changed, 6655 insertions(+), 2269 deletions(-) create mode 100644 django_mfa/adapters/__init__.py create mode 100644 django_mfa/adapters/recovery_codes.py create mode 100644 django_mfa/adapters/totp.py create mode 100644 django_mfa/adapters/webauthn.py create mode 100644 django_mfa/backends.py create mode 100644 django_mfa/checks.py create mode 100644 django_mfa/conf.py create mode 100644 django_mfa/crypto.py delete mode 100644 django_mfa/forms.py create mode 100644 django_mfa/handles.py create mode 100644 django_mfa/migrations/0004_authenticator.py create mode 100644 django_mfa/migrations/0005_migrate_to_authenticator.py create mode 100644 django_mfa/migrations/0006_mfa_user_handle.py create mode 100644 django_mfa/migrations/0007_drop_legacy_models.py create mode 100644 django_mfa/migrations/_helpers.py create mode 100644 django_mfa/quicklogin.py create mode 100644 django_mfa/ratelimit.py create mode 100644 django_mfa/registry.py create mode 100644 django_mfa/session.py create mode 100644 django_mfa/signals.py create mode 100644 django_mfa/static/django_mfa/webauthn.js delete mode 100644 django_mfa/static/django_u2f/django_u2f.js delete mode 100644 django_mfa/static/django_u2f/u2f-api.js delete mode 100755 django_mfa/templates/django_mfa/configure.html delete mode 100755 django_mfa/templates/django_mfa/disable_mfa.html delete mode 100755 django_mfa/templates/django_mfa/enable_mfa.html create mode 100644 django_mfa/templates/django_mfa/enroll_totp.html create mode 100644 django_mfa/templates/django_mfa/enroll_webauthn.html delete mode 100755 django_mfa/templates/django_mfa/login_verify.html create mode 100644 django_mfa/templates/django_mfa/picker.html create mode 100644 django_mfa/templates/django_mfa/verify_recovery_codes.html delete mode 100644 django_mfa/templates/django_mfa/verify_second_factor.html delete mode 100755 django_mfa/templates/django_mfa/verify_second_factor_mfa.html create mode 100644 django_mfa/templates/django_mfa/verify_totp.html create mode 100644 django_mfa/templates/django_mfa/verify_webauthn.html delete mode 100644 django_mfa/templates/u2f/add_key.html delete mode 100644 django_mfa/templates/u2f/base.html delete mode 100644 django_mfa/templates/u2f/key_list.html delete mode 100644 django_mfa/templates/u2f/verify_second_factor_u2f.html delete mode 100644 django_mfa/templates/u2f_base.html create mode 100644 django_mfa/templatetags/webauthn_tags.py create mode 100644 django_mfa/tests/support/__init__.py create mode 100644 django_mfa/tests/support/authenticator.py create mode 100644 django_mfa/tests/test_adapter_recovery.py create mode 100644 django_mfa/tests/test_adapter_totp.py create mode 100644 django_mfa/tests/test_adapter_webauthn.py create mode 100644 django_mfa/tests/test_compat.py create mode 100644 django_mfa/tests/test_conf.py create mode 100644 django_mfa/tests/test_crypto.py create mode 100644 django_mfa/tests/test_enforcement.py delete mode 100644 django_mfa/tests/test_forms.py create mode 100644 django_mfa/tests/test_handles.py create mode 100644 django_mfa/tests/test_migrations.py create mode 100644 django_mfa/tests/test_passwordless.py create mode 100644 django_mfa/tests/test_ratelimit.py create mode 100644 django_mfa/tests/test_registry.py create mode 100644 django_mfa/tests/test_sandbox_smoke.py create mode 100644 django_mfa/tests/test_session.py create mode 100644 django_mfa/tests/test_support_authenticator.py create mode 100644 django_mfa/tests/test_templatetags.py create mode 100644 django_mfa/tests/test_u2f_removal.py delete mode 100644 django_mfa/views.py create mode 100644 django_mfa/views/__init__.py create mode 100644 django_mfa/views/enroll.py create mode 100644 django_mfa/views/manage.py create mode 100644 django_mfa/views/picker.py create mode 100644 django_mfa/views/verify.py delete mode 100644 sandbox/templates/configure.html diff --git a/django_mfa/adapters/__init__.py b/django_mfa/adapters/__init__.py new file mode 100644 index 0000000..3646769 --- /dev/null +++ b/django_mfa/adapters/__init__.py @@ -0,0 +1,60 @@ +# django_mfa/adapters/__init__.py +# +# Importing this package registers the built-in MFA factor adapters with +# django_mfa.registry.registry. It is imported from DjangoMfaAppConfig.ready() +# so that registration happens once, at app startup, before any view or +# management command asks the registry for adapters. +# +# Which of the built-ins actually get registered is controlled by +# MFA_FACTORS (django_mfa.conf.DEFAULTS), default +# ["totp", "recovery_codes", "webauthn"] -- i.e. every install behaves +# exactly as before unless a host project opts out of one. This exists so a +# TOTP-only project can set MFA_FACTORS = ["totp", "recovery_codes"] and +# never register a WebAuthn adapter at all, which in turn is what lets +# django_mfa.checks (E001/E002/E003) skip its WebAuthn-only requirements +# (MFA_FIDO2_RP_ID, WebAuthnBackend) for that project -- see checks.py's +# _webauthn_active(). +from django.core.exceptions import ImproperlyConfigured + +from django_mfa.conf import settings as mfa_settings +from django_mfa.registry import registry + +from .totp import TOTPAdapter +from .recovery_codes import RecoveryCodesAdapter +from .webauthn import WebAuthnAdapter + +#: Every built-in adapter class, keyed by the same type string MFA_FACTORS +#: names it with. A later task adding a new built-in factor adds it here. +BUILTIN_ADAPTERS = { + "totp": TOTPAdapter, + "recovery_codes": RecoveryCodesAdapter, + "webauthn": WebAuthnAdapter, +} + + +def register_default_adapters(target_registry, factor_types): + """Register the built-ins named in ``factor_types`` (in order) into + ``target_registry``. + + Split out from the module-level call below so tests can exercise "what + does a given MFA_FACTORS list register" directly, against a throwaway + Registry() -- the production registry is populated exactly once, at app + startup, from whatever MFA_FACTORS was set to at that time (see + DjangoMfaAppConfig.ready()), so overriding the setting in a test after + startup has no effect on it. + """ + for factor_type in factor_types: + try: + adapter_class = BUILTIN_ADAPTERS[factor_type] + except KeyError: + # A typo here would otherwise surface as a bare KeyError out of + # AppConfig.ready(), naming neither the setting nor the valid + # values. + raise ImproperlyConfigured( + f"MFA_FACTORS contains unknown factor {factor_type!r}. " + f"Valid values are: {', '.join(sorted(BUILTIN_ADAPTERS))}." + ) + target_registry.register(adapter_class()) + + +register_default_adapters(registry, mfa_settings.MFA_FACTORS) diff --git a/django_mfa/adapters/recovery_codes.py b/django_mfa/adapters/recovery_codes.py new file mode 100644 index 0000000..9e410dc --- /dev/null +++ b/django_mfa/adapters/recovery_codes.py @@ -0,0 +1,68 @@ +# django_mfa/adapters/recovery_codes.py +import secrets +import string + +from django.contrib.auth.hashers import check_password, make_password + +from django_mfa.models import Authenticator +from django_mfa.registry import Adapter +from django_mfa.utils import strings_equal + +CODE_COUNT = 10 +CODE_LENGTH = 10 +ALPHABET = string.ascii_letters + string.digits + + +class RecoveryCodesAdapter(Adapter): + type = Authenticator.Type.RECOVERY_CODES + verbose_name = "Recovery codes" + # Recovery codes are exhaustible and must never be a user's sole second + # factor. They still appear in the verification picker (you can verify with + # one), but must not make primary_enabled_for() non-empty. + counts_as_primary_factor = False + # Recovery codes are generated at mfa:recovery_codes, not enrolled: this + # adapter implements no begin_enroll/complete_enroll, so it must never be + # offered as something to "add" (see Registry.available_for). + supports_enroll = False + + def generate(self, user): + codes = [] + while len(codes) < CODE_COUNT: + code = "".join(secrets.choice(ALPHABET) for _ in range(CODE_LENGTH)) + if code not in codes: + codes.append(code) + Authenticator.objects.filter(user=user, type=self.type).delete() + Authenticator.objects.create( + user=user, type=self.type, + data={"codes": [make_password(c) for c in codes], "used": []}, + ) + return codes # plaintext, shown to the user exactly once + + def remaining(self, user): + auth = self.get_instances(user).first() + if auth is None: + return 0 + return len(auth.data.get("codes", [])) - len(auth.data.get("used", [])) + + def begin_verify(self, request, user): + return {"remaining": self.remaining(user)} + + def complete_verify(self, request, user, data): + auth = self.get_instances(user).first() + if auth is None: + return False + submitted = data.get("code", "") + used = set(auth.data.get("used", [])) + plaintext = auth.data.get("migrated_plaintext", False) + + for index, stored in enumerate(auth.data.get("codes", [])): + if index in used: + continue + matched = (strings_equal(stored, submitted) if plaintext + else check_password(submitted, stored)) + if matched: + auth.data["used"] = sorted(used | {index}) + auth.save(update_fields=["data"]) + auth.record_usage() + return True + return False diff --git a/django_mfa/adapters/totp.py b/django_mfa/adapters/totp.py new file mode 100644 index 0000000..0939c44 --- /dev/null +++ b/django_mfa/adapters/totp.py @@ -0,0 +1,58 @@ +import base64 +import codecs +import random +import re + +from django_mfa import totp as totp_mod +from django_mfa.conf import settings as mfa_settings +from django_mfa.crypto import decrypt, encrypt +from django_mfa.models import Authenticator +from django_mfa.registry import Adapter + + +#: Accept the previous and next 30-second code as well as the current one. +#: The legacy code used 0 (no tolerance), which rejects a correct code when the +#: phone's clock drifts a few seconds or the user submits across a window +#: boundary. 1 is what Google Authenticator, django-otp and allauth all use. +#: The 3x larger guess space is immaterial against a 6-digit code, and a later +#: task adds rate limiting on top. +TOTP_VALID_WINDOW = 1 + + +def generate_secret(): + raw = codecs.decode(codecs.encode("{0:020x}".format(random.getrandbits(80))), + "hex_codec") + return base64.b32encode(raw).decode("utf-8") + + +class TOTPAdapter(Adapter): + type = Authenticator.Type.TOTP + verbose_name = "Authenticator app" + + def begin_enroll(self, request): + secret = generate_secret() + uri = totp_mod.TOTP(secret).provisioning_uri( + request.user.get_username(), + issuer_name=mfa_settings.MFA_ISSUER_NAME, + ) + return {"secret_key": secret, "provisioning_uri": re.sub(r"=+$", "", uri)} + + def complete_enroll(self, request, data): + secret = data["secret_key"] + if not totp_mod.TOTP(secret).verify(data["code"], valid_window=TOTP_VALID_WINDOW): + raise ValueError("Verification code did not match.") + return Authenticator.objects.create( + user=request.user, type=self.type, data={"secret": encrypt(secret)}) + + def begin_verify(self, request, user): + return {} + + def complete_verify(self, request, user, data): + auth = self.get_instances(user).first() + if auth is None: + return False + if totp_mod.TOTP(decrypt(auth.data["secret"])).verify( + data.get("code", ""), valid_window=TOTP_VALID_WINDOW): + auth.record_usage() + return True + return False diff --git a/django_mfa/adapters/webauthn.py b/django_mfa/adapters/webauthn.py new file mode 100644 index 0000000..1552cce --- /dev/null +++ b/django_mfa/adapters/webauthn.py @@ -0,0 +1,277 @@ +# django_mfa/adapters/webauthn.py +# +# WebAuthn (security key / passkey) adapter -- registration AND verification. +# Browser JS and passwordless login are later tasks; nothing here implements +# those. +# +# Written against the *installed* fido2 2.2.1, not the 1.x API the original +# task brief was drafted against. See +# .superpowers/sdd/2026-08-12-mfa2-parity-foundation/task-14-report.md, +# task-15-report.md and task-16-report.md for the verified 2.2.1 API map and +# every place this deviates from the brief's hypothesised calls. +import json + +from fido2.server import Fido2Server +from fido2.webauthn import (AttestedCredentialData, AuthenticationResponse, + PublicKeyCredentialRpEntity, + PublicKeyCredentialUserEntity) + +from django_mfa.conf import settings as mfa_settings +from django_mfa.handles import user_handle_for +from django_mfa.models import Authenticator +from django_mfa.registry import Adapter + +#: Session key the in-progress registration challenge/state is stashed under +#: between begin_enroll() and complete_enroll(). Popped (not just read) on +#: use so a completed -- or abandoned -- registration ceremony cannot be +#: replayed: see complete_enroll(). +REGISTER_STATE_KEY = "webauthn_register_state" + +#: Session key the in-progress authentication challenge/state is stashed +#: under between begin_verify() and complete_verify(). Popped (not just +#: read) on use for the same single-use reason as REGISTER_STATE_KEY -- see +#: complete_verify(). +AUTH_STATE_KEY = "webauthn_auth_state" + + +def get_server(): + """Build a Fido2Server bound to the configured relying party. + + Constructed fresh per call (cheap: no I/O) rather than as a module-level + singleton so it always reflects current settings, including in tests that + use @override_settings(MFA_FIDO2_RP_ID=...). + """ + rp = PublicKeyCredentialRpEntity(id=mfa_settings.MFA_FIDO2_RP_ID, + name=mfa_settings.MFA_FIDO2_RP_NAME) + return Fido2Server(rp, attestation=mfa_settings.MFA_FIDO2_ATTESTATION_PREFERENCE) + + +def user_entity(user): + """Build the PublicKeyCredentialUserEntity for a ceremony. + + ``id`` is the signed, opaque user handle (django_mfa.handles), never the + username or primary key directly -- see handles.py for why. Derived + entirely from ``user``, which callers must obtain from ``request.user``, + never from client-supplied data: this is what stops a caller from + registering a credential against someone else's account. + """ + return PublicKeyCredentialUserEntity( + id=user_handle_for(user).encode("utf-8"), + name=user.get_username(), + display_name=user.get_username(), + ) + + +class WebAuthnAdapter(Adapter): + type = Authenticator.Type.WEBAUTHN + verbose_name = "Security key or passkey" + supports_multiple = True + + def _existing_credentials(self, user): + """AttestedCredentialData for every credential this user already holds. + + Dual purpose: passed to register_begin() as ``excludeCredentials`` so + a compliant authenticator refuses to create a second resident + credential for a device it has already enrolled (and so an existing + credential continues to work under a different RP ID is impossible + to fake -- registration binds to whatever RP the server was built + with; see get_server()); and passed to authenticate_begin()/ + authenticate_complete() as the allow-list a verification ceremony is + checked against -- see begin_verify()/complete_verify() below. Using + the *same* per-user query for both means a credential can only ever + be verified against the user it was actually enrolled to: a + different user's request.user simply never sees it in this list. + """ + return [ + AttestedCredentialData(bytes.fromhex(auth.data["credential_data"])) + for auth in self.get_instances(user) + ] + + def begin_enroll(self, request): + server = get_server() + options, state = server.register_begin( + user_entity(request.user), + self._existing_credentials(request.user), + resident_key_requirement=mfa_settings.MFA_FIDO2_RESIDENT_KEY, + user_verification=mfa_settings.MFA_FIDO2_USER_VERIFICATION, + authenticator_attachment=mfa_settings.MFA_FIDO2_AUTHENTICATOR_ATTACHMENT, + # Request the credProps extension so the client tells us, in + # clientExtensionResults, whether the credential it created is + # actually discoverable/resident. This is the only honest source + # of that information -- see complete_enroll() below for why the + # authenticatorData AT flag is NOT it. register_begin()'s + # `extensions` parameter is a free-form Mapping[str, Any] in + # fido2 2.2.1 (confirmed by reading PublicKeyCredentialCreationOptions + # in fido2/webauthn.py), passed straight through with no + # allowlist, so this is supported as-is. + extensions={"credProps": True}, + ) + # dict(options) walks the CredentialCreationOptions dataclass into the + # camelCase, base64url-string shape navigator.credentials.create() + # expects, omitting any optional field left None. See task-14-report.md + # API map item 7. + request.session[REGISTER_STATE_KEY] = state + return {"options": json.dumps(dict(options))} + + def complete_enroll(self, request, data): + """Complete a registration ceremony started by begin_enroll(). + + Note the deliberate asymmetry with complete_verify(): a missing/ + already-used challenge makes *this* method raise ValueError, but + makes complete_verify() return False instead. Callers (views) must + handle both conventions deliberately rather than assuming a single + shared error-handling pattern works for both ceremonies. + """ + # .pop(), not .get(): makes the challenge single-use. A second + # complete_enroll() call -- whether a genuine replay of a captured + # request or a double-submit -- finds nothing here and fails closed, + # rather than re-validating (and accepting) the same signed + # attestation twice. + state = request.session.pop(REGISTER_STATE_KEY, None) + if state is None: + raise ValueError("Registration challenge missing or already used.") + + credential = json.loads(data["credential"]) + # register_complete() accepts the plain dict navigator.credentials + # produces directly (internally calls RegistrationResponse.from_dict); + # no need to construct that dataclass by hand. Raises ValueError on + # any ceremony failure: wrong challenge, wrong origin, wrong RP ID + # hash, bad/missing attestation signature, etc. -- so a credential + # registered under one MFA_FIDO2_RP_ID cannot be completed against a + # server configured with a different one. + auth_data = get_server().register_complete(state, credential) + + # Resident/discoverable-ness must come from the credProps client + # extension result, NOT from AuthenticatorData.FLAG.AT: that flag + # means "attested credential data is present in this authData", + # which is true on every single registration ceremony (it's what + # carries the new credential ID/public key back to the server) -- + # it says nothing about whether the credential is stored as a + # discoverable/resident credential. Using it as a resident indicator + # would mark every credential resident, always, which is exactly the + # bug this replaced. credProps is opt-in and not universally + # supported by authenticators/browsers, so its absence is a real, + # distinct outcome from "not resident" -- report it as None ("unknown") + # rather than guessing True or False. + client_extensions = credential.get("clientExtensionResults") or {} + cred_props = client_extensions.get("credProps") or {} + resident = cred_props.get("rk") # True / False / None (unknown) + + return Authenticator.objects.create( + user=request.user, + type=self.type, + name=data.get("name", "")[:100], + data={ + "credential_id": auth_data.credential_data.credential_id.hex(), + "credential_data": bytes(auth_data.credential_data).hex(), + "sign_count": auth_data.counter, + "resident": resident, + "transports": credential.get("response", {}).get("transports", []), + }, + ) + + def _authenticator_for(self, user, credential_id): + """Find the stored Authenticator row a verified assertion belongs to. + + Looked up by (user, credential_id) rather than credential_id alone: + combined with credentials_for()/_existing_credentials() only ever + handing authenticate_begin()/authenticate_complete() *this user's* + credentials, a credential enrolled to a different user can never + reach this point in the first place -- authenticate_complete() + itself raises ValueError before we get here, because the assertion's + credential id won't be found in the allow-list it was given. This is + therefore a lookup, not an access-control check. + """ + return self.get_instances(user).get( + data__credential_id=credential_id.hex()) + + def begin_verify(self, request, user): + options, state = get_server().authenticate_begin( + self._existing_credentials(user), + user_verification=mfa_settings.MFA_FIDO2_USER_VERIFICATION, + ) + request.session[AUTH_STATE_KEY] = state + return {"options": json.dumps(dict(options))} + + def complete_verify(self, request, user, data): + """Complete a verification ceremony started by begin_verify(). + + Note the deliberate asymmetry with complete_enroll(): a missing/ + already-used challenge makes complete_enroll() raise ValueError, but + makes this method return False instead (see the brief's own + test_missing_state_is_rejected, which pins this exact behaviour). + Callers (views) must handle both conventions -- a False return here + does not mean "no exception was possible", it means "this specific, + expected failure mode returns a sentinel instead of raising." A + cloned-credential rejection still raises ValueError; a credential + that vanished mid-ceremony (see below) also returns False, by + design, for the same reason as the missing-state case: both are + "this assertion cannot be honoured", not "something is broken." + """ + # .pop(), not .get(): same single-use reasoning as complete_enroll(). + state = request.session.pop(AUTH_STATE_KEY, None) + if state is None: + return False + + credential = json.loads(data["credential"]) + # authenticate_complete() accepts the plain dict navigator.credentials + # produces directly. In fido2 2.2.1 it returns the SAME + # AttestedCredentialData object that was passed in via `credentials`, + # matched by id -- not a freshly parsed object, and NOT something + # carrying the new sign count (there is no `.new_sign_count` in this + # version; see task-14-report.md API map item 4). Raises ValueError + # on any ceremony failure: wrong challenge, wrong origin, bad/missing + # signature, or (crucially) a credential id not present in the + # `credentials` list we handed it -- which is how an assertion for a + # credential enrolled to a different user gets rejected, since + # _existing_credentials(user) only ever contains this user's own + # credentials. + result = get_server().authenticate_complete( + state, self._existing_credentials(user), credential) + + try: + auth = self._authenticator_for(user, result.credential_id) + except Authenticator.DoesNotExist: + # Race: the credential row was deleted in the window between + # the server accepting the assertion (authenticate_complete, + # above -- which validated against a snapshot of + # _existing_credentials(user) taken at the top of this method) + # and this lookup running -- e.g. the user removed this + # security key from another tab/device while this login was in + # flight. There is no row left to update a counter on and + # nothing left to attribute a clone-check outcome to, so the + # assertion is refused the same way a missing/expired session + # state is refused above: return False rather than let + # Authenticator.DoesNotExist propagate as an unhandled 500. + # This is deliberately narrow -- only DoesNotExist is caught + # here; a ValueError from the clone-detection check below must + # still propagate unchanged, since that is a distinct, + # deliberate "reject this" signal, not a missing-row race. + return False + stored = auth.data.get("sign_count", 0) + # The return value of authenticate_complete() carries no counter at + # all in fido2 2.2.1, so the new counter has to be recovered + # independently by re-parsing the same response the client sent. + # Confirmed empirically (task-14-report.md) that this equals the + # authenticator's own idea of its counter. + new_count = AuthenticationResponse.from_dict( + credential).response.authenticator_data.counter + # Clone detection: a signature counter that fails to advance + # suggests the authenticator (or its key material) has been cloned + # and a second device is replaying/racing assertions. But counters + # are OPTIONAL in the WebAuthn spec -- many authenticators + # (notably Apple/iCloud passkeys) never implement one and always + # report 0. Treat "both stored and new are 0" as the legitimate + # counter-less case and accept it unconditionally; for every other + # case, the new counter must be strictly greater than the stored + # one or this assertion is rejected as a possible clone. Do NOT + # simplify this to a plain "new > stored" check -- that would lock + # out every user of a counter-less authenticator, which today is a + # very large share of them. + if not (new_count == 0 and stored == 0) and new_count <= stored: + raise ValueError("Authenticator sign count did not increase.") + + auth.data["sign_count"] = new_count + auth.save(update_fields=["data"]) + auth.record_usage() + return True diff --git a/django_mfa/admin.py b/django_mfa/admin.py index 7ec26a7..65633b6 100644 --- a/django_mfa/admin.py +++ b/django_mfa/admin.py @@ -1,7 +1,5 @@ from django.contrib import admin -from .models import * -# Register your models here. -admin.site.register(UserOTP) -admin.site.register(UserRecoveryCodes) -admin.site.register(U2FKey) +from .models import Authenticator + +admin.site.register(Authenticator) diff --git a/django_mfa/apps.py b/django_mfa/apps.py index b50bd8d..a16a52c 100644 --- a/django_mfa/apps.py +++ b/django_mfa/apps.py @@ -1,7 +1,19 @@ from __future__ import unicode_literals from django.apps import AppConfig +from django.core.checks import register class DjangoMfaAppConfig(AppConfig): name = 'django_mfa' + default_auto_field = "django.db.models.BigAutoField" + + def ready(self): + from django_mfa.checks import (check_fido2_rp_id, + check_webauthn_backend_configured) + + register(check_fido2_rp_id) + register(check_webauthn_backend_configured) + + from django_mfa import adapters # noqa: F401 (registers built-ins) + from django_mfa import signals # noqa: F401 diff --git a/django_mfa/backends.py b/django_mfa/backends.py new file mode 100644 index 0000000..3f64f49 --- /dev/null +++ b/django_mfa/backends.py @@ -0,0 +1,42 @@ +# django_mfa/backends.py +# +# Authentication backend for passwordless (passkey) login. +# +# The actual WebAuthn assertion is validated entirely by +# django_mfa.views.verify.passkey_complete BEFORE this backend is ever +# consulted -- authenticate() here does no cryptographic work of its own. Its +# only job is to hand back the user the view already resolved and verified, +# through the standard django.contrib.auth.login()/get_user() contract, so +# that a passkey-authenticated session behaves like any other authenticated +# session (request.user, session persistence across requests, etc.). +# +# user_handle_for()/user_from_handle() are NOT redefined here: they were +# built in django_mfa/handles.py (a stored, random MfaUserHandle row, not the +# django.core.signing-based scheme an earlier version of the spec assumed -- +# see handles.py's module docstring for why that changed). Re-exported here +# under `# noqa: F401` because django_mfa.tests.test_passwordless imports +# them from this module, per the task brief's interface contract. +from django.contrib.auth import get_user_model + +from django_mfa.handles import user_from_handle, user_handle_for # noqa: F401 + + +class WebAuthnBackend: + """Authenticates a user from a verified WebAuthn assertion. + + The assertion is validated by the view before this is called; the + backend's job is only to resolve and return the user. ``authenticate()`` + deliberately does not accept the standard ``username``/``password`` + keywords other backends look for -- passing this backend a username and + password (e.g. because some other code in the AUTHENTICATION_BACKENDS + chain tries every backend with the same call) must not accidentally + authenticate anyone; it only ever returns a user when the caller + explicitly hands it one via ``mfa_user`` (the passkey view, having + already verified the assertion, is the only such caller). + """ + + def authenticate(self, request, mfa_user=None, **kwargs): + return mfa_user + + def get_user(self, user_id): + return get_user_model().objects.filter(pk=user_id).first() diff --git a/django_mfa/checks.py b/django_mfa/checks.py new file mode 100644 index 0000000..5c9718a --- /dev/null +++ b/django_mfa/checks.py @@ -0,0 +1,117 @@ +from django.conf import settings as django_settings +from django.core.checks import Error + +from django_mfa.conf import settings as mfa_settings + + +def _webauthn_active(): + """Whether this install has WebAuthn switched on at all. + + The single predicate every WebAuthn-only system check (E001, E002, E003 + below) is gated on. True when either: + + - a WebAuthn-type adapter is registered (the default -- see + django_mfa.adapters.BUILTIN_ADAPTERS/MFA_FACTORS -- but a host project + that sets ``MFA_FACTORS = ["totp", "recovery_codes"]`` never registers + one), or + - ``MFA_QUICKLOGIN`` is on: passwordless login (``mfa:passkey_begin``/ + ``mfa:passkey_complete``) is exposed by ``django_mfa.urls`` + unconditionally and always builds a ``Fido2Server`` bound to + ``MFA_FIDO2_RP_ID`` (see ``get_server()`` in + ``adapters/webauthn.py``) regardless of whether a "webauthn"-type + adapter is registered for enrollment/verification, so this needs its + own settings even with the adapter switched off. + + A project where neither is true (TOTP-only, no quicklogin) has no code + path that ever touches WebAuthn settings or WebAuthnBackend, so none of + E001/E002/E003 have anything to check. + """ + from django_mfa.models import Authenticator + from django_mfa.registry import registry + + webauthn_registered = any( + adapter.type == Authenticator.Type.WEBAUTHN for adapter in registry.all()) + return mfa_settings.MFA_QUICKLOGIN or webauthn_registered + + +def check_fido2_rp_id(app_configs, **kwargs): + if not _webauthn_active(): + return [] + + rp_id = mfa_settings.MFA_FIDO2_RP_ID + if not rp_id: + return [Error( + "MFA_FIDO2_RP_ID is not set.", + hint="WebAuthn credentials are bound to the RP ID. Set it to your " + "registrable domain, e.g. 'example.com'. It cannot be changed " + "later without invalidating every registered credential.", + id="django_mfa.E001", + )] + + hosts = [h for h in django_settings.ALLOWED_HOSTS if h not in ("*", "")] + if hosts and not any(h == rp_id or h.endswith("." + rp_id) for h in hosts): + return [Error( + f"MFA_FIDO2_RP_ID {rp_id!r} is not a suffix of any ALLOWED_HOSTS entry.", + hint="Credentials will fail to resolve. The RP ID must equal or be a " + "parent domain of the host serving the ceremony.", + id="django_mfa.E002", + )] + return [] + + +def check_webauthn_backend_configured(app_configs, **kwargs): + """``django_mfa.backends.WebAuthnBackend`` must be in + ``AUTHENTICATION_BACKENDS`` whenever passwordless (passkey) login is + reachable. + + ``django_mfa.urls`` always exposes ``mfa:passkey_begin``/ + ``mfa:passkey_complete`` -- no setting gates the URLs themselves off. + ``passkey_complete()`` (django_mfa/views/verify.py) calls + ``auth.login(request, user, backend=BACKEND_PATH)`` with this backend's + dotted path passed explicitly, which *succeeds* regardless of + ``AUTHENTICATION_BACKENDS``: ``django.contrib.auth.login()`` only + consults that setting when ``backend`` is omitted, never to validate one + that was passed in. The failure surfaces one request later instead: + ``django.contrib.auth.get_user()``, called by ``AuthenticationMiddleware`` + on every subsequent request, DOES check the stored backend path against + ``AUTHENTICATION_BACKENDS`` and silently returns ``AnonymousUser`` if + it's missing -- no exception, no log line. A user who just + "successfully" signed in with a passkey is anonymous again on their very + next click, and stays that way (the stale, still-authenticated-looking + session key is never cleared) until they explicitly log out. + + Raised as an ``Error`` -- like ``check_fido2_rp_id`` above -- rather + than a ``Warning``: the failure mode is not a style nit, it is a + completely silent authentication break with no exception or log output + anywhere in the request/response cycle to reveal it, and ``manage.py + runserver``/``migrate`` refusing to start is a far cheaper way to learn + about it than a support ticket from a confused user. + + Gated on ``_webauthn_active()`` above -- either ``MFA_QUICKLOGIN`` (the + strongest signal a host project is deliberately building a + passwordless-login UI around this) or a registered WebAuthn-type adapter + (the underlying capability -- registered by default, but ``MFA_FACTORS`` + or ``registry.unregister("webauthn")`` can turn it off, at which point + neither passkey URL can resolve a real credential and the check no + longer applies). Checking only one of the two would miss a host project + that relies on the other. + """ + from django_mfa.backends import WebAuthnBackend + + if not _webauthn_active(): + return [] + + backend_path = f"{WebAuthnBackend.__module__}.{WebAuthnBackend.__qualname__}" + if backend_path in django_settings.AUTHENTICATION_BACKENDS: + return [] + + return [Error( + f"{backend_path} is not in AUTHENTICATION_BACKENDS.", + hint="Passwordless (passkey) login logs a user in via this backend, " + "but Django's own get_user() re-checks that backend path " + "against AUTHENTICATION_BACKENDS on every later request. " + "Without it, a passkey login appears to succeed and then " + "silently degrades to an anonymous session on the very next " + f"request. Add {backend_path!r} to AUTHENTICATION_BACKENDS.", + id="django_mfa.E003", + )] diff --git a/django_mfa/conf.py b/django_mfa/conf.py new file mode 100644 index 0000000..a20b13b --- /dev/null +++ b/django_mfa/conf.py @@ -0,0 +1,30 @@ +from django.conf import settings as django_settings + +DEFAULTS = { + "MFA_ISSUER_NAME": None, + "MFA_REMEMBER_MY_BROWSER": False, + "MFA_REMEMBER_DAYS": 90, + "MFA_BASE_TEMPLATE": "django_mfa/base.html", + "MFA_SECRET_ENCRYPTION_KEYS": None, + "MFA_VERIFY_RATE_LIMIT": "5/5m", + "MFA_QUICKLOGIN": False, + "MFA_OWNED_BY_ENTERPRISE": False, + "MFA_FACTORS": ["totp", "recovery_codes", "webauthn"], + "MFA_FIDO2_RP_ID": None, + "MFA_FIDO2_RP_NAME": "django-mfa", + "MFA_FIDO2_RESIDENT_KEY": "preferred", + "MFA_FIDO2_AUTHENTICATOR_ATTACHMENT": None, + "MFA_FIDO2_USER_VERIFICATION": "preferred", + "MFA_FIDO2_ATTESTATION_PREFERENCE": "none", + "MFA_EXEMPT_PATHS": [], +} + + +class Settings: + def __getattr__(self, name): + if name not in DEFAULTS: + raise AttributeError(f"{name} is not a django-mfa setting") + return getattr(django_settings, name, DEFAULTS[name]) + + +settings = Settings() diff --git a/django_mfa/crypto.py b/django_mfa/crypto.py new file mode 100644 index 0000000..81855bd --- /dev/null +++ b/django_mfa/crypto.py @@ -0,0 +1,26 @@ +from django.core import signing + +from django_mfa.conf import settings as mfa_settings + +PREFIX = "mfa1:" + + +def encrypt(value): + keys = mfa_settings.MFA_SECRET_ENCRYPTION_KEYS + if not keys: + return value + return PREFIX + signing.dumps(value, key=keys[0], salt="django_mfa.secret", + compress=True) + + +def decrypt(value): + if not value.startswith(PREFIX): + return value # stored before encryption was enabled + payload = value[len(PREFIX):] + keys = mfa_settings.MFA_SECRET_ENCRYPTION_KEYS or [] + for key in keys: + try: + return signing.loads(payload, key=key, salt="django_mfa.secret") + except signing.BadSignature: + continue + raise signing.BadSignature("No configured key could decrypt this secret.") diff --git a/django_mfa/forms.py b/django_mfa/forms.py deleted file mode 100644 index 6b6e4f5..0000000 --- a/django_mfa/forms.py +++ /dev/null @@ -1,46 +0,0 @@ -import json -from django import forms -from django.utils import timezone -from u2flib_server import u2f - - -class SecondFactorForm(forms.Form): - def __init__(self, *args, **kwargs): - self.user = kwargs.pop('user') - self.request = kwargs.pop('request') - self.appId = kwargs.pop('appId') - return super(SecondFactorForm, self).__init__(*args, **kwargs) - - -class KeyResponseForm(SecondFactorForm): - response = forms.CharField() - - def __init__(self, *args, **kwargs): - super(KeyResponseForm, self).__init__(*args, **kwargs) - if self.data: - self.sign_request = self.request.session['u2f_sign_request'] - else: - self.sign_request = u2f.begin_authentication(self.appId, [ - d.to_json() for d in self.user.u2f_keys.all() - ]) - self.request.session['u2f_sign_request'] = self.sign_request - - def validate_second_factor(self): - response = json.loads(self.cleaned_data['response']) - try: - device, login_counter, _ = u2f.complete_authentication( - self.sign_request, response) - # TODO: store login_counter and verify it's increasing - device = self.user.u2f_keys.get(key_handle=device['keyHandle']) - device.last_used_at = timezone.now() - device.save() - del self.request.session['u2f_sign_request'] - return True - except ValueError: - self.add_error( - '__all__', 'U2F validation failed -- bad signature.') - return False - - -class KeyRegistrationForm(SecondFactorForm): - response = forms.CharField() diff --git a/django_mfa/handles.py b/django_mfa/handles.py new file mode 100644 index 0000000..b01e24f --- /dev/null +++ b/django_mfa/handles.py @@ -0,0 +1,80 @@ +# django_mfa/handles.py +# +# WebAuthn user handles. +# +# The spec's PublicKeyCredentialUserEntity.id ("user handle") is an opaque +# byte string an authenticator stores alongside a discoverable credential and +# hands back during a passwordless (usernameless) login so the relying party +# can look up who is authenticating before any username has been typed. +# +# It must NOT be the username (or anything else human-legible): the handle is +# readable by anyone with access to the authenticator/credential metadata, so +# a legible handle would leak identity, and it would break the moment a user +# renames. +# +# Stored rather than derived. An earlier version of this module signed the +# user's primary key with django.core.signing (keyed off SECRET_KEY). That is +# wrong: rotating SECRET_KEY is a routine, expected security practice, but +# passkeys are registered once and used for years, so a rotation would +# silently make every previously issued handle unresolvable -- breaking +# passwordless login for every user with a passkey, with no way to detect it +# until they try to log in. A stored random value has no dependency on any +# secret and survives key rotation as well as username changes, while still +# leaking no identity (it's an opaque UUID, unrelated to the username or pk). +# +# Lives in its own module, rather than in django_mfa/adapters/webauthn.py or +# a future backends.py, because both WebAuthn registration (this task) and +# passwordless login (a later task) need it, and the latter must not have to +# import the registration adapter module to get it. +import uuid + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models + + +class MfaUserHandle(models.Model): + """Stable opaque WebAuthn user handle. + + Stored rather than derived: a handle signed with SECRET_KEY becomes + unresolvable when that key is rotated, which would break passwordless + login for every user with a passkey. A stored random value survives key + rotation and username changes, and leaks no identity. + """ + + user = models.OneToOneField(settings.AUTH_USER_MODEL, + related_name="mfa_user_handle", + on_delete=models.CASCADE) + handle = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) + + +def user_handle_for(user): + """Return a stable, opaque WebAuthn user handle for ``user``. + + Idempotent: the first call for a given user creates and persists a + handle; every subsequent call returns the same value. + """ + obj, _ = MfaUserHandle.objects.get_or_create(user=user) + return str(obj.handle) + + +def user_from_handle(handle): + """Resolve a handle produced by ``user_handle_for`` back to a user. + + Returns ``None`` if the handle is missing, malformed (not a valid UUID), + or does not match any stored handle -- never raises, since callers use + this on untrusted client-supplied input during a login ceremony. + """ + try: + obj = MfaUserHandle.objects.filter(handle=handle).first() + except (ValueError, TypeError, ValidationError): + # UUIDField.get_prep_value() raises django.core.exceptions + # .ValidationError for a malformed string (confirmed empirically -- + # NOT a bare ValueError/TypeError, despite what that might suggest by + # analogy with uuid.UUID() itself, which does raise ValueError). + # filter(handle=None) does not raise at all: it is prepared as a + # normal `handle IS NULL` lookup, which simply matches zero rows + # since the column is NOT NULL -- so None flows through to the + # `return obj.user if obj else None` line below unexceptionally. + return None + return obj.user if obj else None diff --git a/django_mfa/middleware.py b/django_mfa/middleware.py index 893bb69..be26162 100644 --- a/django_mfa/middleware.py +++ b/django_mfa/middleware.py @@ -1,28 +1,57 @@ -from django.urls import reverse +from django.contrib.auth.views import redirect_to_login from django.shortcuts import resolve_url -from django.contrib.auth import REDIRECT_FIELD_NAME as redirect_field_name -from .models import is_mfa_enabled, is_u2f_enabled -from .views import verify_rmb_cookie +from django.urls import reverse +from django.utils.deprecation import MiddlewareMixin -try: - from django.utils.deprecation import MiddlewareMixin -except ImportError: # Django < 1.10 - # Works perfectly for everyone using MIDDLEWARE_CLASSES - MiddlewareMixin = object +from django_mfa import quicklogin, session class MfaMiddleware(MiddlewareMixin): + def exempt_paths(self): + """Paths reachable before verification. + + Built from the registry plus a settings allowlist rather than fully + hardcoded, so adding a factor does not mean editing this middleware + — the three-path hardcoded list in the old implementation was + exactly that maintenance trap. + + ``MFA_EXEMPT_PATHS`` (default ``[]``) covers everything the registry + can't know about: most importantly a host project's own logout URL. + Without it, a pending user who cannot complete their second factor + (lost device, no recovery codes, etc.) has no way out of the + redirect loop this middleware creates — they can't even log out. + """ + from django_mfa.conf import settings as mfa_settings + from django_mfa.registry import registry + + paths = {reverse("mfa:verify")} + for adapter in registry.all(): + paths.add(reverse("mfa:verify_factor", args=[adapter.type])) + paths.update(mfa_settings.MFA_EXEMPT_PATHS) + return paths def process_request(self, request): - if request.user.is_authenticated and ((is_mfa_enabled(request.user) and not verify_rmb_cookie(request)) or is_u2f_enabled(request.user)): - if not request.session.get('verfied_otp') and not request.session.get('verfied_u2f'): - current_path = request.path - paths = [reverse("mfa:verify_second_factor"), reverse( - "mfa:verify_second_factor_u2f"), reverse("mfa:verify_second_factor_totp")] - if current_path not in paths: - path = request.get_full_path() - resolved_login_url = resolve_url( - reverse("mfa:verify_second_factor")) - from django.contrib.auth.views import redirect_to_login - return redirect_to_login(path, resolved_login_url, redirect_field_name) - return None + if not request.user.is_authenticated: + return None + if not session.is_pending(request): + return None + if request.path in self.exempt_paths(): + return None + return redirect_to_login(request.get_full_path(), + resolve_url(reverse("mfa:verify")), "next") + + def process_response(self, request, response): + """Finish applying the MFA_QUICKLOGIN hint cookie set/cleared by the + user_logged_in/user_logged_out receivers in signals.py. + + Those receivers can only stash *intent* on the request (no response + exists yet at signal time -- see set_quicklogin_hint()'s docstring); + this is the first point in the request lifecycle with both the + intent and a real response to attach the cookie to. + """ + user = getattr(request, "_mfa_quicklogin_login", None) + if user is not None: + quicklogin.set_hint(response, user, secure=request.is_secure()) + if getattr(request, "_mfa_quicklogin_logout", False): + quicklogin.clear_hint(response) + return response diff --git a/django_mfa/migrations/0004_authenticator.py b/django_mfa/migrations/0004_authenticator.py new file mode 100644 index 0000000..31ba87c --- /dev/null +++ b/django_mfa/migrations/0004_authenticator.py @@ -0,0 +1,53 @@ +# Generated by Django 4.2.30 on 2026-08-12 20:18 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('django_mfa', '0003_change_secret_code_max_length'), + ] + + operations = [ + migrations.AlterField( + model_name='u2fkey', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='userotp', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='userrecoverycodes', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='userrecoverycodes', + name='secret_code', + field=models.CharField(max_length=10), + ), + migrations.CreateModel( + name='Authenticator', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.CharField(choices=[('totp', 'Authenticator app'), ('webauthn', 'Security key or passkey'), ('recovery_codes', 'Recovery codes')], max_length=32)), + ('name', models.CharField(blank=True, max_length=100)), + ('data', models.JSONField(default=dict)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('last_used_at', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mfa_authenticators', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddConstraint( + model_name='authenticator', + constraint=models.UniqueConstraint(condition=models.Q(('type__in', ['totp', 'recovery_codes'])), fields=('user', 'type'), name='mfa_one_singleton_authenticator_per_user'), + ), + ] diff --git a/django_mfa/migrations/0005_migrate_to_authenticator.py b/django_mfa/migrations/0005_migrate_to_authenticator.py new file mode 100644 index 0000000..ea6134b --- /dev/null +++ b/django_mfa/migrations/0005_migrate_to_authenticator.py @@ -0,0 +1,20 @@ +from django.db import migrations + +from django_mfa.migrations import _helpers + + +def forwards(apps, schema_editor): + _helpers.forward( + apps.get_model("django_mfa", "Authenticator"), + apps.get_model("django_mfa", "UserOTP"), + apps.get_model("django_mfa", "UserRecoveryCodes"), + ) + + +def backwards(apps, schema_editor): + _helpers.reverse(apps.get_model("django_mfa", "Authenticator")) + + +class Migration(migrations.Migration): + dependencies = [("django_mfa", "0004_authenticator")] + operations = [migrations.RunPython(forwards, backwards)] diff --git a/django_mfa/migrations/0006_mfa_user_handle.py b/django_mfa/migrations/0006_mfa_user_handle.py new file mode 100644 index 0000000..8aae7dc --- /dev/null +++ b/django_mfa/migrations/0006_mfa_user_handle.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.30 on 2026-08-13 00:09 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('django_mfa', '0005_migrate_to_authenticator'), + ] + + operations = [ + migrations.CreateModel( + name='MfaUserHandle', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('handle', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='mfa_user_handle', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/django_mfa/migrations/0007_drop_legacy_models.py b/django_mfa/migrations/0007_drop_legacy_models.py new file mode 100644 index 0000000..3759f4b --- /dev/null +++ b/django_mfa/migrations/0007_drop_legacy_models.py @@ -0,0 +1,60 @@ +# NOTE: numbered 0007, not 0006 — Task 15 added 0006_mfa_user_handle.py for the +# stored WebAuthn user handle. Depend on that, not on 0005. +# +# UserOTP, UserRecoveryCodes and U2FKey are dropped here. Their data was +# already copied into Authenticator by 0005_migrate_to_authenticator — this +# migration is only safe to apply because that copy already happened. +# +# THIS MIGRATION IS DELIBERATELY IRREVERSIBLE (fix round 1, task 20). +# +# Django's auto-generated reverse for RemoveField/DeleteModel only recreates +# the table SCHEMA — it cannot undo the DROP TABLE and bring back the rows +# that were in it. That sounds like a merely cosmetic gap ("tables come back +# empty") but it is not: a full staged rollback (0007 -> 0006 -> 0005) makes +# it a silent, total-data-loss bug. 0005's reverse() deletes every +# Authenticator row it created (identified by the migrated_from_legacy +# marker) on the assumption that the original UserOTP/UserRecoveryCodes rows +# are "still present and untouched" (see 0005's own docstring) — a promise +# that was true when 0005 was the last-applied migration, but becomes false +# the moment 0007 has ever been applied and then reversed: by the time +# 0005's reverse runs, the legacy tables 0007 dropped have already come back +# EMPTY, not with their original contents. The end state is that every +# user's TOTP secret and both recovery codes are gone from both places +# (Authenticator was cleared by 0005's reverse; UserOTP/UserRecoveryCodes +# never got their rows back), and no step in that sequence raises — the +# operator sees every migration report success. +# +# The `RunPython(RunPython.noop)` operation below has no reverse_code, so +# `Migration.unapply()` refuses to reverse ANY operation in this migration +# and raises IrreversibleError before touching the database — see +# django/db/migrations/migration.py:Migration.unapply(), which validates +# reversibility of every operation in the migration (phase 1) before running +# any of them backwards (phase 2). It forwards as a no-op, so it has zero +# effect on applying this migration normally. +# +# Operator-facing consequence: once this migration has been applied, "roll +# back" cannot mean "run migrate backwards" — the pre-migration TOTP +# secrets/recovery codes (and any never-migrated U2F registrations, per the +# note below) no longer exist anywhere for Django to restore. Rolling back +# means restoring a database backup taken before this migration was applied. +# This should be called out explicitly in the release notes (task 19). +# +# Separately, and independently of the above: U2F registrations were never +# copied onto Authenticator by 0005 in the first place (there is no +# UserOTP/UserRecoveryCodes-style migration path for U2FKey), so they have +# no forward path in this migration set regardless of reversibility. +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [("django_mfa", "0006_mfa_user_handle")] + operations = [ + migrations.RemoveField(model_name="userrecoverycodes", name="user"), + migrations.RemoveField(model_name="userotp", name="user"), + migrations.RemoveField(model_name="u2fkey", name="user"), + migrations.DeleteModel(name="UserRecoveryCodes"), + migrations.DeleteModel(name="UserOTP"), + migrations.DeleteModel(name="U2FKey"), + # Deliberately irreversible marker — see module docstring above. + migrations.RunPython(migrations.RunPython.noop, reverse_code=None), + ] diff --git a/django_mfa/migrations/_helpers.py b/django_mfa/migrations/_helpers.py new file mode 100644 index 0000000..1e37f32 --- /dev/null +++ b/django_mfa/migrations/_helpers.py @@ -0,0 +1,50 @@ +from django_mfa.crypto import encrypt + + +def forward(Authenticator, UserOTP, UserRecoveryCodes): + for otp in UserOTP.objects.all().iterator(): + Authenticator.objects.create( + user_id=otp.user_id, + type="totp", + # migrated_from_legacy marks rows THIS migration created, so a + # rollback can delete only those and leave post-cutover + # enrollments alone. + data={"secret": encrypt(otp.secret_key), + "migrated_from_legacy": True}, + ) + codes = list( + UserRecoveryCodes.objects.filter(user=otp) + .order_by("id") + .values_list("secret_code", flat=True) + ) + if codes: + Authenticator.objects.create( + user_id=otp.user_id, + type="recovery_codes", + # Pre-existing codes are plaintext and cannot be hashed + # retroactively without invalidating them. Flagged so the UI can + # prompt regeneration; hashing begins at next regeneration. + data={"codes": codes, "used": [], "migrated_plaintext": True, + "migrated_from_legacy": True}, + ) + + +def reverse(Authenticator): + """Undo the copy. + + ``forward()`` only ever COPIED — the legacy ``UserOTP`` and + ``UserRecoveryCodes`` rows are still present and untouched — so rolling back + 0005 means deleting the copies, not recreating originals that were never + removed. Recreating them would hit ``IntegrityError`` on ``UserOTP.user``, + which is a OneToOneField. + + Deletes ONLY rows this migration created, identified by the + ``migrated_from_legacy`` marker. Rows created by ordinary enrollment after + 0005 ran carry no marker and survive a rollback — without this filter, an + operator rolling back after any user had enrolled would silently destroy + their MFA with no legacy row to fall back on. + """ + Authenticator.objects.filter( + type__in=["totp", "recovery_codes"], + data__migrated_from_legacy=True, + ).delete() diff --git a/django_mfa/models.py b/django_mfa/models.py index c71f85f..7b7fe97 100644 --- a/django_mfa/models.py +++ b/django_mfa/models.py @@ -1,57 +1,50 @@ from __future__ import division from django.conf import settings from django.db import models +from django.utils import timezone +# Whether a factor type counts as "primary" (protects a user on its own) is +# NOT decided here. It used to be a second, independent definition +# (a frozenset of type strings) that could -- and did -- silently diverge +# from django_mfa.registry.Adapter.counts_as_primary_factor, the one place +# that actually matters (see django_mfa.signals.stamp_pending_verification +# and django_mfa.views.verify.verify_factor, the production callers of that +# decision). The registry is the single source of truth: use +# registry.primary_enabled_for(user) instead. -class UserOTP(models.Model): - OTP_TYPES = ( - ('HOTP', 'hotp'), - ('TOTP', 'totp'), - ) +class AuthenticatorManager(models.Manager): + def for_user(self, user): + return self.filter(user=user) - user = models.OneToOneField( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - otp_type = models.CharField(max_length=20, choices=OTP_TYPES) - secret_key = models.CharField(max_length=100, blank=True) +class Authenticator(models.Model): + class Type(models.TextChoices): + TOTP = "totp", "Authenticator app" + WEBAUTHN = "webauthn", "Security key or passkey" + RECOVERY_CODES = "recovery_codes", "Recovery codes" -def is_mfa_enabled(user): - """ - Determine if a user has MFA enabled - """ - return hasattr(user, 'userotp') - -MFA_RECOVERY_CODE_LENGTH = 10 -class UserRecoveryCodes(models.Model): - user = models.ForeignKey(UserOTP, - on_delete=models.CASCADE) - secret_code = models.CharField(max_length=MFA_RECOVERY_CODE_LENGTH) - - - -class U2FKey(models.Model): - user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='u2f_keys', + user = models.ForeignKey(settings.AUTH_USER_MODEL, + related_name="mfa_authenticators", on_delete=models.CASCADE) - created_at = models.DateTimeField(auto_now_add=True) - last_used_at = models.DateTimeField(null=True) - - public_key = models.TextField() - key_handle = models.TextField() - app_id = models.TextField() - - def to_json(self): - return { - 'publicKey': self.public_key, - 'keyHandle': self.key_handle, - 'appId': self.app_id, - 'version': 'U2F_V2', - } - - -def is_u2f_enabled(user): - """ - Determine if a user has U2F enabled - """ - return user.u2f_keys.all().exists() + type = models.CharField(max_length=32, choices=Type.choices) + name = models.CharField(max_length=100, blank=True) + data = models.JSONField(default=dict) + created_at = models.DateTimeField(default=timezone.now) + last_used_at = models.DateTimeField(null=True, blank=True) + + objects = AuthenticatorManager() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["user", "type"], + condition=models.Q(type__in=["totp", "recovery_codes"]), + name="mfa_one_singleton_authenticator_per_user", + ), + ] + + def record_usage(self): + self.last_used_at = timezone.now() + self.save(update_fields=["last_used_at"]) diff --git a/django_mfa/quicklogin.py b/django_mfa/quicklogin.py new file mode 100644 index 0000000..c66eb50 --- /dev/null +++ b/django_mfa/quicklogin.py @@ -0,0 +1,58 @@ +# django_mfa/quicklogin.py +# +# MFA_QUICKLOGIN: an opt-in, signed-nothing hint cookie identifying which +# account this browser last logged into, so a host project's login page can +# greet a returning user with a passkey prompt instead of a bare +# username/password form. It is a UX hint only, never a credential -- it +# does not, by itself, authenticate or log anyone in. Resolving the cookie +# back to a user (user_from_hint) only tells the login page *whose* passkey +# prompt to show; the user must still complete a real WebAuthn ceremony +# (django_mfa.views.verify.passkey_begin/passkey_complete) to actually sign +# in. +# +# Reuses the handles.py idiom (an opaque, stored per-user handle) rather +# than inventing a second signed-cookie scheme: the same "must survive +# SECRET_KEY rotation and never leak identity" reasoning documented in +# handles.py applies here too, since this cookie is set with a 1-year +# max-age and would otherwise go stale on every key rotation exactly like an +# unstored WebAuthn user handle would. +from django_mfa.conf import settings as mfa_settings +from django_mfa.handles import user_from_handle, user_handle_for + +COOKIE_NAME = "mfa_quicklogin" +MAX_AGE = 60 * 60 * 24 * 365 + + +def set_hint(response, user, secure): + """Attach the quicklogin hint cookie for ``user`` to ``response``. + + A no-op (returns ``response`` unchanged) whenever MFA_QUICKLOGIN is off + -- the default -- so nothing about this feature is observable unless a + host project opts in. + """ + if not mfa_settings.MFA_QUICKLOGIN: + return response + response.set_cookie(COOKIE_NAME, user_handle_for(user), max_age=MAX_AGE, + httponly=True, samesite="Lax", secure=secure) + return response + + +def clear_hint(response): + """Remove the quicklogin hint cookie from ``response`` (e.g. on logout). + + Unconditional -- unlike set_hint(), this runs even if MFA_QUICKLOGIN has + since been turned off, so a stale cookie set while the feature was on + doesn't outlive the session that created it. + """ + response.delete_cookie(COOKIE_NAME, samesite="Lax") + return response + + +def user_from_hint(value): + """Resolve a quicklogin cookie value back to a user, or None. + + Never raises: ``value`` is untrusted client-supplied input (a missing, + tampered, or stale cookie), and user_from_handle() already returns None + rather than raising for anything malformed. + """ + return user_from_handle(value) if value else None diff --git a/django_mfa/ratelimit.py b/django_mfa/ratelimit.py new file mode 100644 index 0000000..db806ef --- /dev/null +++ b/django_mfa/ratelimit.py @@ -0,0 +1,49 @@ +import re + +from django.core.cache import cache + +from django_mfa.conf import settings as mfa_settings + +UNITS = {"s": 1, "m": 60, "h": 3600} +PATTERN = re.compile(r"^(\d+)/(\d+)([smh])$") + + +def parse(spec): + match = PATTERN.match(spec) + if not match: + raise ValueError(f"Invalid MFA_VERIFY_RATE_LIMIT: {spec!r}") + count, amount, unit = match.groups() + count, window = int(count), int(amount) * UNITS[unit] + # A zero limit locks every user out permanently — check() compares + # `attempts < limit`, which is false before any failure is recorded. A zero + # window expires the counter instantly, silently disabling the throttle. + # Both are almost certainly typos, so refuse them loudly. + if count < 1: + raise ValueError( + f"MFA_VERIFY_RATE_LIMIT count must be at least 1, got {spec!r} — " + "a zero limit would lock out every user permanently." + ) + if window < 1: + raise ValueError( + f"MFA_VERIFY_RATE_LIMIT window must be at least 1 second, got {spec!r}" + ) + return count, window + + +def _key(user, factor_type): + return f"django_mfa:rl:{user.pk}:{factor_type}" + + +def check(user, factor_type): + limit, _window = parse(mfa_settings.MFA_VERIFY_RATE_LIMIT) + return cache.get(_key(user, factor_type), 0) < limit + + +def record_failure(user, factor_type): + _limit, window = parse(mfa_settings.MFA_VERIFY_RATE_LIMIT) + key = _key(user, factor_type) + cache.set(key, cache.get(key, 0) + 1, window) + + +def clear(user, factor_type): + cache.delete(_key(user, factor_type)) diff --git a/django_mfa/registry.py b/django_mfa/registry.py new file mode 100644 index 0000000..1c4437b --- /dev/null +++ b/django_mfa/registry.py @@ -0,0 +1,142 @@ +# django_mfa/registry.py +from django_mfa.models import Authenticator + + +class Adapter: + """Base class for a single MFA factor. + + Subclasses set ``type`` to an ``Authenticator.Type`` value and implement the + enroll/verify pairs. ``begin_*`` returns a dict of template context and + stashes any challenge in the session; ``complete_*`` consumes the POSTed + payload. + """ + + type = None + verbose_name = None + supports_multiple = False + + #: Whether this factor is added through the enroll flow at all. + #: False for recovery codes: they are *generated* (at mfa:recovery_codes), + #: not enrolled, and implement no begin_enroll/complete_enroll. A factor + #: with this False is never offered by available_for() and is rejected + #: with a 404 by views.enroll.enroll_factor -- without that, the security + #: page renders an "add recovery codes" link whose target raises + #: NotImplementedError from Adapter.begin_enroll (a 500 on a link this + #: package itself puts in front of every user who has no codes yet). + supports_enroll = True + + #: Whether holding only this factor means the user is protected. False for + #: recovery codes: they are exhaustible and must never be a user's sole + #: second factor. This is the SINGLE source of truth for that question -- + #: see Registry.primary_enabled_for() below and its two production + #: callers, django_mfa.signals.stamp_pending_verification and + #: django_mfa.views.verify.verify_factor. + counts_as_primary_factor = True + + def is_available(self, user): + """May this user add (another) instance of this factor? + + This is an enrollment-capacity check only: singleton factors (like + TOTP) become unavailable once the user already has one, while + ``supports_multiple`` factors (like WebAuthn) are always available. + + It does NOT encode site-level policy about which factor types are + permitted at all (e.g. a future ``MFA_UNALLOWED_METHODS`` setting). + That kind of gating must be composed as a separate filter on top of + this — do not fold it into this method or into ``Adapter`` subclasses. + """ + if self.supports_multiple: + return True + return not self.get_instances(user).exists() + + def get_instances(self, user): + return Authenticator.objects.filter(user=user, type=self.type) + + def is_enabled(self, user): + return self.get_instances(user).exists() + + def begin_enroll(self, request): + raise NotImplementedError + + def complete_enroll(self, request, data): + raise NotImplementedError + + def begin_verify(self, request, user): + raise NotImplementedError + + def complete_verify(self, request, user, data): + raise NotImplementedError + + @property + def enroll_template(self): + return f"django_mfa/enroll_{self.type}.html" + + @property + def verify_template(self): + return f"django_mfa/verify_{self.type}.html" + + +class Registry: + def __init__(self): + self._adapters = {} + + def register(self, adapter): + if adapter.type in self._adapters: + raise ValueError(f"Adapter for {adapter.type!r} already registered") + self._adapters[adapter.type] = adapter + + def unregister(self, type): + """Remove the adapter registered for ``type``. + + Raises ``KeyError`` if nothing is registered for ``type``, mirroring + dict.pop() semantics rather than silently no-opping — callers (mostly + tests swapping the production singleton's contents) should notice if + they try to remove something that was never there. + """ + del self._adapters[type] + + def get(self, type): + return self._adapters[type] + + def all(self): + return list(self._adapters.values()) + + def enabled_for(self, user): + """Adapters this user currently holds an instance of. + + Answers "what can this user verify with right now?" — the set of + methods to offer on a verification/challenge screen. This + deliberately INCLUDES recovery codes: they are a valid way to prove + identity even though they don't count as a factor in themselves. + + This is NOT the right call for deciding whether a user has MFA + enabled / is protected — a user holding only recovery codes is not + protected, since recovery codes are exhaustible and must never be + someone's sole second factor. Use ``primary_enabled_for()`` for that + question instead. + """ + return [a for a in self.all() if a.is_enabled(user)] + + def primary_enabled_for(self, user): + """Adapters that on their own mean this user is protected. + + Use this to decide whether to CHALLENGE a user. Use enabled_for() to + decide what to OFFER them once challenged — that one includes recovery + codes, which are a valid way to verify but not a factor in themselves. + """ + return [a for a in self.enabled_for(user) if a.counts_as_primary_factor] + + def available_for(self, user): + """Adapters this user could add right now, via the enroll flow. + + Excludes anything that isn't enrolled at all (``supports_enroll = + False``, i.e. recovery codes) as well as singleton factors the user + already holds. The result is what the security page offers as "add a + method", so every entry must be a factor ``enroll_factor`` can + actually serve. + """ + return [a for a in self.all() + if a.supports_enroll and a.is_available(user)] + + +registry = Registry() diff --git a/django_mfa/session.py b/django_mfa/session.py new file mode 100644 index 0000000..c44bba3 --- /dev/null +++ b/django_mfa/session.py @@ -0,0 +1,24 @@ +import time + +SESSION_KEY = "mfa" + + +def start_pending(request): + request.session[SESSION_KEY] = {"verified": False, "method": None, "at": None} + + +def mark_verified(request, method): + request.session[SESSION_KEY] = { + "verified": True, "method": method, "at": int(time.time())} + + +def is_verified(request): + return bool(request.session.get(SESSION_KEY, {}).get("verified")) + + +def is_pending(request): + return SESSION_KEY in request.session and not is_verified(request) + + +def reset(request): + request.session.pop(SESSION_KEY, None) diff --git a/django_mfa/signals.py b/django_mfa/signals.py new file mode 100644 index 0000000..655a29d --- /dev/null +++ b/django_mfa/signals.py @@ -0,0 +1,99 @@ +from django.contrib.auth.signals import user_logged_in, user_logged_out +from django.dispatch import receiver + +from django_mfa import session +from django_mfa.conf import settings as mfa_settings +from django_mfa.registry import registry +from django_mfa.views import verify_rmb_cookie + +# A module-level import was tried and works cleanly: django_mfa.views (via +# django_mfa.views.verify) imports django_mfa.registry, which imports +# django_mfa.models -- neither of those import django_mfa.signals or +# django_mfa.apps back, so there is no cycle. apps.ready() already imports +# django_mfa.adapters (which also pulls in the registry) before importing +# this module, so by the time this file is imported the registry is fully +# populated too. + + +@receiver(user_logged_in) +def stamp_pending_verification(sender, request, user, **kwargs): + """Mark the session as awaiting a second factor. + + Replaces the old contract where host projects had to set + ``u2f_pre_verify_user_pk`` and ``u2f_pre_verify_user_backend`` by hand. + Skipped when the session is already verified — a passkey login (Task 18) + satisfies both factors at once. + + A trusted browser (see ``MFA_REMEMBER_MY_BROWSER`` / + ``django_mfa.views.verify_rmb_cookie``) short-circuits the challenge: if + the signed cookie proves this browser already passed a second factor + within ``MFA_REMEMBER_DAYS``, the session is marked verified immediately + instead of pending. ``verify_rmb_cookie`` itself returns ``False`` + whenever ``MFA_REMEMBER_MY_BROWSER`` is off, so this is a no-op and every + login is challenged normally unless a host project opts in. + """ + if session.is_verified(request): + return + if registry.primary_enabled_for(user): + # registry.primary_enabled_for() (not models.Authenticator's manager) + # is the single source of truth for "is this user protected" -- see + # Adapter.counts_as_primary_factor / Registry.primary_enabled_for() + # in registry.py. Using anything else here (e.g. a hardcoded set of + # type strings) can silently diverge from it: a third-party adapter + # that opts in via counts_as_primary_factor would be enrollable and + # shown as enabled without ever being challenged, and + # registry.unregister("webauthn") -- the opt-out checks.py itself + # recommends -- would leave already-enrolled users facing a + # zero-option picker instead of simply not being challenged for a + # factor type that's no longer offered. + # + # verify_rmb_cookie() reads request.user. django.contrib.auth.login() + # only back-fills that attribute when the request already had one + # (true for a real request, which passed through + # AuthenticationMiddleware first) -- Client.login(), used pervasively + # in this test suite, fires this signal via a bare HttpRequest that + # never had .user set at all. Set it explicitly rather than relying + # on login()'s conditional assignment; we already have the user this + # signal is about. + request.user = user + if verify_rmb_cookie(request): + session.mark_verified(request, "remember_my_browser") + else: + session.start_pending(request) + + +@receiver(user_logged_in) +def set_quicklogin_hint(sender, request, user, **kwargs): + """Stash the logging-in user on the request for MfaMiddleware to turn + into a signed MFA_QUICKLOGIN hint cookie once a response exists. + + This can't set the cookie directly: a signal receiver fires deep inside + auth.login(), with no HttpResponse in hand yet, and django_mfa does not + own the login view whose response that eventually becomes -- the + password login page belongs to the host project, and this signal is the + only hook common to every login regardless of which view (or, for + passkey logins, django_mfa.views.verify.passkey_complete itself) called + auth.login(). Stashing the user on `request` and letting MfaMiddleware. + process_response() apply it once the view has returned a real response + is the only place in the request lifecycle where both pieces (who + logged in, and a response to attach a cookie to) are available together. + + (django.test.Client.login(), used throughout this suite, deliberately + reproduces the "no response" half of this problem: it fabricates a bare + HttpRequest and never produces an HttpResponse at all -- see + django.test.client.Client._login(). Tests that need to observe this + cookie use RequestFactory + MfaMiddleware directly instead, mirroring + the pattern test_enforcement.py already uses for the RMB cookie.) + """ + if mfa_settings.MFA_QUICKLOGIN: + request._mfa_quicklogin_login = user + + +@receiver(user_logged_out) +def clear_quicklogin_hint(sender, request, **kwargs): + """Mirror of set_quicklogin_hint() for logout: flag the request so + MfaMiddleware.process_response() deletes the hint cookie. Unconditional + (not gated on MFA_QUICKLOGIN) so a cookie set while the feature was on + still gets cleaned up if it's since been turned off. + """ + request._mfa_quicklogin_logout = True diff --git a/django_mfa/static/django_mfa/webauthn.js b/django_mfa/static/django_mfa/webauthn.js new file mode 100644 index 0000000..bf8b27c --- /dev/null +++ b/django_mfa/static/django_mfa/webauthn.js @@ -0,0 +1,253 @@ +// django_mfa/static/django_mfa/webauthn.js +// +// Browser-side glue for the WebAuthn enroll/verify ceremonies. No +// framework, no build step -- this file is served and executed exactly as +// written, so it is deliberately plain ES5-compatible syntax throughout +// (var, function expressions, Promise#then/#catch): no let/const, arrow +// functions, async/await, spread/rest, destructuring, template literals, +// classes, or Array#includes/String#repeat (none of those are ES5). +// +// It reads the registration/authentication options the server embedded via +// {% webauthn_options_script %} into +// - \ No newline at end of file diff --git a/django_mfa/templates/django_mfa/configure.html b/django_mfa/templates/django_mfa/configure.html deleted file mode 100755 index 522b629..0000000 --- a/django_mfa/templates/django_mfa/configure.html +++ /dev/null @@ -1,72 +0,0 @@ -{% extends 'django_mfa/base.html' %} -{% load otp_tags %} -{% block content %} -{% if is_verified %} -
- You have successfully enabled mfa on your account. -
-{% else %} -{% if qr_code %} -
-
-

Add your app to your two-factor authentication app

-

- You will need to install a two-factor authentication application on your phone. -

-
-
-
-
-

1. Scan barcode

- {{qr_code|qrcode:"my alt"}} - -
-

- After scanning the barcode image, the app will display a six-digit code that - you can enter below. -

-
- -
- {% csrf_token %} -
-
-
- - - -
-
- -
-
-
-
-{% else %} -
- Two-factor authentication - adds an extra layer of security to your account. In addition to your username and password, you’ll need to - enter a code that this app sends to you via text or an app on your phone.

-
-
-
When you sign into your app you’ll enter your username and password, like always.
-
When logging in from a new browser, you’ll need to enter an additional code from your phone or tablet.
-
Once you enter the code on the website, you’ll be logged into your app.
-
-
-
-

Use an application on your phone to get two-factor authentication codes when prompted.

-
{% csrf_token %} - - -
-
-
-
-{% endif %} -{% endif %} -{% endblock %} \ No newline at end of file diff --git a/django_mfa/templates/django_mfa/disable_mfa.html b/django_mfa/templates/django_mfa/disable_mfa.html deleted file mode 100755 index ccc4a7c..0000000 --- a/django_mfa/templates/django_mfa/disable_mfa.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends 'django_mfa/base.html' %} -{% block content %} -
-
-

Are you sure, you want to disable two-factor authentication?

-
-
-
- {% csrf_token %} - -
-
-
-{% endblock %} \ No newline at end of file diff --git a/django_mfa/templates/django_mfa/enable_mfa.html b/django_mfa/templates/django_mfa/enable_mfa.html deleted file mode 100755 index bb3c7b8..0000000 --- a/django_mfa/templates/django_mfa/enable_mfa.html +++ /dev/null @@ -1,18 +0,0 @@ - -{% load otp_tags %} - - - Enable MFA - - -
- {% csrf_token %} - - -
- - {% if qr_code %} - {{qr_code|qrcode:"my alt"}} - {% endif %} - - \ No newline at end of file diff --git a/django_mfa/templates/django_mfa/enroll_totp.html b/django_mfa/templates/django_mfa/enroll_totp.html new file mode 100644 index 0000000..75e709e --- /dev/null +++ b/django_mfa/templates/django_mfa/enroll_totp.html @@ -0,0 +1,23 @@ +{% extends base_template %} +{% load i18n otp_tags %} +{% block content %} +

{% trans "Set up an authenticator app" %}

+

{% trans "Scan this barcode with your two-factor authentication app." %}

+{% qrcode provisioning_uri "Scan this code" %} +

{% trans "Or enter this code manually:" %} {{ secret_key }}

+
+ {% csrf_token %} + +
+
+
+ + {% if error_message %} +

{{ error_message }}

+ {% endif %} +
+
+ +
+{% endblock %} diff --git a/django_mfa/templates/django_mfa/enroll_webauthn.html b/django_mfa/templates/django_mfa/enroll_webauthn.html new file mode 100644 index 0000000..cd2e0ca --- /dev/null +++ b/django_mfa/templates/django_mfa/enroll_webauthn.html @@ -0,0 +1,32 @@ +{% extends base_template %} +{% load i18n static webauthn_tags %} +{% block content %} +

{% trans "Set up a security key or passkey" %}

+

{% trans "Register a hardware security key or a platform passkey (e.g. Touch ID, Windows Hello) as a second factor." %}

+ + + +{% if error_message %} +

{{ error_message }}

+{% endif %} +

+ +{% webauthn_options_script options "webauthn-options" %} + +
+ {% csrf_token %} + +
+
+
+ +
+
+ +
+ + +{% endblock %} diff --git a/django_mfa/templates/django_mfa/login_verify.html b/django_mfa/templates/django_mfa/login_verify.html deleted file mode 100755 index 301515b..0000000 --- a/django_mfa/templates/django_mfa/login_verify.html +++ /dev/null @@ -1,31 +0,0 @@ -{% extends 'django_mfa/base.html' %} -{% load otp_tags %} -{% block content %} - -
- -
-
-
- {% csrf_token %} - {% if next_url %} - - {% endif %} -
-
-
- - - {% if error_message %} -

{{ error_message }}

- {% endif %} -
-
- -
-
-
- -{% endblock %} diff --git a/django_mfa/templates/django_mfa/picker.html b/django_mfa/templates/django_mfa/picker.html new file mode 100644 index 0000000..51934a7 --- /dev/null +++ b/django_mfa/templates/django_mfa/picker.html @@ -0,0 +1,13 @@ +{% extends base_template %} +{% load i18n %} +{% block content %} +

{% trans "Verify your identity" %}

+

{% trans "Choose how you'd like to verify it's you." %}

+ +{% endblock %} diff --git a/django_mfa/templates/django_mfa/recovery_codes.html b/django_mfa/templates/django_mfa/recovery_codes.html index 171461a..cf4a63d 100644 --- a/django_mfa/templates/django_mfa/recovery_codes.html +++ b/django_mfa/templates/django_mfa/recovery_codes.html @@ -1,16 +1,38 @@ -{% extends 'django_mfa/base.html' %} +{% extends base_template %} {% load i18n %} {% block content %}
-

Recovery Codes

+

{% trans "Recovery Codes" %}

- {% for i in codes %} -

{{i}}

+ {% if codes %} + {% for code in codes %} +

{{ code }}

{% endfor %} + {% else %} +

{% trans "Recovery codes were already generated and can no longer be displayed. Remove and re-add two-factor authentication if you need a fresh set." %}

+ {% endif %}
-Download -Home Page -{% endblock %} \ No newline at end of file +{% if codes %} + +{% trans "Download" %} + +{% endif %} +{% trans "Home Page" %} +{% endblock %} diff --git a/django_mfa/templates/django_mfa/security.html b/django_mfa/templates/django_mfa/security.html index 334f604..ad262e8 100755 --- a/django_mfa/templates/django_mfa/security.html +++ b/django_mfa/templates/django_mfa/security.html @@ -1,84 +1,68 @@ -{% extends 'django_mfa/base.html' %} +{% extends base_template %} {% load i18n %} {% block content %} -

{% trans "Two Factor Settings" %}

-{% trans '← Back to settings' %} -
-
+

{% trans "Two-Factor Settings" %}

+
-

Two-factor authentication

-
- {% if twofactor_enabled %} -
-

- Status: - - On - - -

- Disable MFA +

{% trans "Enabled methods" %}

- {% else %}
-

- Status: - - Off - - -

- Enable MFA -

- Two-factor authentication provides another layer of security to your account. -

+ {% if authenticators %} +
    + {% for authenticator in authenticators %} +
  • + {{ authenticator.get_type_display }}{% if authenticator.name %} — {{ authenticator.name }}{% endif %} + {% blocktrans with created=authenticator.created_at %}(added {{ created }}){% endblocktrans %} + {% if authenticator.type != "webauthn" or not owned_by_enterprise %} +
    + {% csrf_token %} + + +
    + {% else %} + {% trans "Managed by your organization" %} + {% endif %} +
  • + {% endfor %} +
+ {% else %} +

{% trans "You have not enabled two-factor authentication." %}

+ {% endif %}
- {% endif %}
+
+
-

U2F authentication

+

{% trans "Add a method" %}

-

- Status: - - {% if u2f_enabled %}{% trans "On" %}{% else %}{% trans "Off" %}{% endif %} - - -

- Manage U2F -

- U2F authentication provides another layer of security to your account. -

+ {% if available_adapters %} + + {% else %} +

{% trans "There is nothing left to add." %}

+ {% endif %}
+
+
-

Backup Codes Status

+

{% trans "Recovery codes" %}

-

- {% trans "Backup codes remaining" %}: - - {{ backup_codes | length }} - - -

- Get Backup Codes -

-

+

{% blocktrans %}Recovery codes remaining: {{ recovery_codes_remaining }}{% endblocktrans %}

+ {% trans "View recovery codes" %}
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/django_mfa/templates/django_mfa/verify_recovery_codes.html b/django_mfa/templates/django_mfa/verify_recovery_codes.html new file mode 100644 index 0000000..d1ba080 --- /dev/null +++ b/django_mfa/templates/django_mfa/verify_recovery_codes.html @@ -0,0 +1,21 @@ +{% extends base_template %} +{% load i18n %} +{% block content %} +

{% trans "Enter a recovery code" %}

+

{% blocktrans %}You have {{ remaining }} recovery codes remaining.{% endblocktrans %}

+
+ {% csrf_token %} + +
+
+
+ + {% if error_message %} +

{{ error_message }}

+ {% endif %} +
+
+ +
+{% endblock %} diff --git a/django_mfa/templates/django_mfa/verify_second_factor.html b/django_mfa/templates/django_mfa/verify_second_factor.html deleted file mode 100644 index 1c65d9b..0000000 --- a/django_mfa/templates/django_mfa/verify_second_factor.html +++ /dev/null @@ -1,34 +0,0 @@ -{% extends 'django_mfa/base.html' %} -{% load i18n %} -{% load static %} -{% block content %} -

{% trans 'Please verify one of the authentication methods below.' %}

-{% if u2f_enabled %} -
-
-

U2F authentication

-
-
-

- click below for u2f authentication -

- U2F Authentication -
-
-{% endif %} -{% if twofactor_enabled %} -
-
-

Two-factor authentication

-
-
-

- click below for two factor authentication -

- Two-factor authentication -
-
-{% endif %} - - -{% endblock %} \ No newline at end of file diff --git a/django_mfa/templates/django_mfa/verify_second_factor_mfa.html b/django_mfa/templates/django_mfa/verify_second_factor_mfa.html deleted file mode 100755 index d6c8d6e..0000000 --- a/django_mfa/templates/django_mfa/verify_second_factor_mfa.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends 'django_mfa/base.html' %} -{% load otp_tags %} -{% block content %} -

- Please verify using MFA authentication method below. -

-
-
-
-
- {% csrf_token %} - {% if next_url %} - - {% endif %} -
-
-
- - - {% if error_message %} -

{{ error_message }}

- {% endif %} -
-
- -
-
-
-
-{% endblock %} \ No newline at end of file diff --git a/django_mfa/templates/django_mfa/verify_totp.html b/django_mfa/templates/django_mfa/verify_totp.html new file mode 100644 index 0000000..b670c89 --- /dev/null +++ b/django_mfa/templates/django_mfa/verify_totp.html @@ -0,0 +1,21 @@ +{% extends base_template %} +{% load i18n %} +{% block content %} +

{% trans "Enter your authentication code" %}

+

{% trans "Enter the six-digit code from your authenticator app." %}

+
+ {% csrf_token %} + +
+
+
+ + {% if error_message %} +

{{ error_message }}

+ {% endif %} +
+
+ +
+{% endblock %} diff --git a/django_mfa/templates/django_mfa/verify_webauthn.html b/django_mfa/templates/django_mfa/verify_webauthn.html new file mode 100644 index 0000000..ba5a9a7 --- /dev/null +++ b/django_mfa/templates/django_mfa/verify_webauthn.html @@ -0,0 +1,26 @@ +{% extends base_template %} +{% load i18n static webauthn_tags %} +{% block content %} +

{% trans "Verify with your security key or passkey" %}

+

{% trans "Use your registered security key or passkey to finish signing in." %}

+ + + +{% if error_message %} +

{{ error_message }}

+{% endif %} +

+ +{% webauthn_options_script options "webauthn-options" %} + +
+ {% csrf_token %} + + + +
+ + +{% endblock %} diff --git a/django_mfa/templates/u2f/add_key.html b/django_mfa/templates/u2f/add_key.html deleted file mode 100644 index 3ce38a4..0000000 --- a/django_mfa/templates/u2f/add_key.html +++ /dev/null @@ -1,24 +0,0 @@ -{% extends "u2f/base.html" %} -{% load argonauts %} -{% load i18n %} -{% load static %} - -{% block content %} -{{ block.super }} -
-
-

{% trans 'To add a security key to your account, insert (and tap) it.' %}

-
-
-

-
- {% csrf_token %} - {{ form.response }} -
-
-
- - -{% endblock %} \ No newline at end of file diff --git a/django_mfa/templates/u2f/base.html b/django_mfa/templates/u2f/base.html deleted file mode 100644 index c4b12b3..0000000 --- a/django_mfa/templates/u2f/base.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends base_template|default:"u2f_base.html" %} -{% load static %} -{% load i18n %} -{% block content %} - -{{ block.super }} - -{% endblock %} \ No newline at end of file diff --git a/django_mfa/templates/u2f/key_list.html b/django_mfa/templates/u2f/key_list.html deleted file mode 100644 index 796af10..0000000 --- a/django_mfa/templates/u2f/key_list.html +++ /dev/null @@ -1,60 +0,0 @@ -{% extends "u2f/base.html" %} -{% load i18n %} -{% block content %} -{{ block.super }} -

U2F Keys

-{% trans '← Back to settings' %} -
-
-
-
-

U2F Management

-
-
- - - - - - - - - - - - - - - - {% for key in object_list %} - - - - - - - {% trans 'Never' as never %} - - - - - - - - - - - - {% endfor %} - -
{% trans ' U2F Device Added on' %}{% trans 'U2F Device Last used on' %}{% trans 'Remove' %}
{{ key.created_at }}{{ key.last_used_at|default:never }} -
{% csrf_token %} - - -
-
-
-
-
-{% trans 'Add another key' %} -{% endblock %} \ No newline at end of file diff --git a/django_mfa/templates/u2f/verify_second_factor_u2f.html b/django_mfa/templates/u2f/verify_second_factor_u2f.html deleted file mode 100644 index da1b3d8..0000000 --- a/django_mfa/templates/u2f/verify_second_factor_u2f.html +++ /dev/null @@ -1,38 +0,0 @@ -{% extends "u2f/base.html" %} -{% load argonauts %} -{% load i18n %} -{% load static %} -{% block content %} -{{ block.super }} -{% if forms.u2f %} -

- {% trans 'Insert your Security Key' %} -

-
-
-

{% blocktrans %} - If your Security Key has a button, tap it.
- If it doesn't, remove and re-insert it. - {% endblocktrans %} -

-
-
-

- {% trans 'Insert your Security Key' %} -

-
-

-
- {% csrf_token %} - {{ forms.u2f.as_p }} - -
- -
-
-
- -{% endif %} -{% endblock %} \ No newline at end of file diff --git a/django_mfa/templates/u2f_base.html b/django_mfa/templates/u2f_base.html deleted file mode 100644 index 989c39b..0000000 --- a/django_mfa/templates/u2f_base.html +++ /dev/null @@ -1,36 +0,0 @@ - -{% load static %} - - - - - - - - {{title}} - - - - -
-
-
- {% if messages %} -
    - {% for message in messages %} - - {% if message.level == DEFAULT_MESSAGE_LEVELS.ERROR %}Important: {% endif %} - {{ message }} - - {% endfor %} -
- {% endif %} - {% block content %} - {% endblock %} -
-
-
- - - - \ No newline at end of file diff --git a/django_mfa/templatetags/otp_tags.py b/django_mfa/templatetags/otp_tags.py index bfc78ab..399ee39 100644 --- a/django_mfa/templatetags/otp_tags.py +++ b/django_mfa/templatetags/otp_tags.py @@ -1,19 +1,38 @@ -try: - from urllib import urlencode -except ImportError: - from urllib.parse import urlencode +"""Template tags for rendering django-mfa templates. + +The previous ``qrcode`` filter pointed the browser straight at +``chart.apis.google.com`` -- a chart-rendering service Google decommissioned +years ago. Every TOTP enrollment page therefore showed a broken image and no +user could actually scan a code. This renders the QR code as an SVG entirely +on the server and inlines it as a base64 data URI instead, so enrollment +never depends on any third-party host being reachable (or even existing). + +SVG rather than PNG because ``qrcode``'s ``SvgPathImage`` factory needs only +the standard library. PNG output would require Pillow (a large dependency +with C build requirements, for one image on one page) or a hand-rolled binary +image encoder (bespoke compression/checksum/chunk-packing code inside an auth +library). SVG scales crisply and is supported by every browser that supports +WebAuthn. +""" +import base64 +import io + +import qrcode as qrcode_lib +import qrcode.image.svg as qrcode_svg from django import template -from django.template.defaultfilters import stringfilter from django.utils.html import conditional_escape, format_html register = template.Library() -@register.filter -@stringfilter +@register.simple_tag(name="qrcode") def qrcode(value, alt=None): - url = conditional_escape("http://chart.apis.google.com/chart?%s" % - urlencode({'chs': '150x150', 'cht': 'qr', 'chl': value, 'choe': 'UTF-8'})) - alt = conditional_escape(alt or value) - - return format_html(u"""%s""" % (url, alt)) + """Render an OTP provisioning URI as an inline SVG ````.""" + img = qrcode_lib.make(value, image_factory=qrcode_svg.SvgPathImage) + buffer = io.BytesIO() + img.save(buffer) + encoded = base64.b64encode(buffer.getvalue()).decode("ascii") + return format_html( + '{}', + encoded, conditional_escape(alt or "Scan this code"), + ) diff --git a/django_mfa/templatetags/webauthn_tags.py b/django_mfa/templatetags/webauthn_tags.py new file mode 100644 index 0000000..52fadae --- /dev/null +++ b/django_mfa/templatetags/webauthn_tags.py @@ -0,0 +1,40 @@ +# django_mfa/templatetags/webauthn_tags.py +"""Template tag for safely embedding WebAuthn ceremony options in a page. + +``WebAuthnAdapter.begin_enroll()``/``begin_verify()`` (django_mfa/adapters/ +webauthn.py) hand the enroll/verify views a context key ``"options"`` that +is already a JSON-encoded *string* -- ``json.dumps(dict(options))`` -- not a +plain Python object. Piping that string straight through Django's built-in +``json_script`` filter (``{{ options|json_script:"webauthn-options" }}``) +would double-encode it: ``json.dumps()`` of a string wraps it in an extra +pair of quotes and escapes the embedded quotes, so the single +``JSON.parse()`` call in webauthn.js would hand back the *original JSON +text as a JS string* instead of the options object, and +``options.publicKey`` would be ``undefined`` -- a bug that only shows up in +a real browser, never in a Python test that stops at "did the page render". + +This tag undoes that: it decodes the string back into a Python object with +``json.loads`` and then calls Django's own ``django.utils.html.json_script`` +-- the exact function backing the ``json_script`` filter -- to do the real +HTML-safe escaping and ``') + self.assertNotIn("', + html, re.S) + return match.group(1) if match else None + + def test_enroll_page_embeds_options_and_script(self): + with self.settings(MFA_FIDO2_RP_ID="testserver"): + response = self.client.get( + reverse("mfa:enroll_factor", args=["webauthn"])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "django_mfa/webauthn.js") + self.assertContains(response, 'id="webauthn-options"') + + def test_verify_page_embeds_options_and_script(self): + self._create_webauthn_authenticator(self.user) + with self.settings(MFA_FIDO2_RP_ID="testserver"): + response = self.client.get( + reverse("mfa:verify_factor", args=["webauthn"])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "django_mfa/webauthn.js") + self.assertContains(response, 'id="webauthn-options"') + + def test_enroll_form_posts_credential_and_name(self): + """complete_enroll() reads POST["credential"] and POST["name"] (see + adapters/webauthn.py) -- the form must offer exactly those field + names.""" + with self.settings(MFA_FIDO2_RP_ID="testserver"): + response = self.client.get( + reverse("mfa:enroll_factor", args=["webauthn"])) + content = response.content.decode() + self.assertIn('id="webauthn-form"', content) + self.assertIn('data-mode="create"', content) + self.assertRegex(content, r']+name="credential"') + self.assertRegex(content, r']+name="name"') + + def test_verify_form_posts_credential(self): + """complete_verify() reads only POST["credential"].""" + self._create_webauthn_authenticator(self.user) + with self.settings(MFA_FIDO2_RP_ID="testserver"): + response = self.client.get( + reverse("mfa:verify_factor", args=["webauthn"])) + content = response.content.decode() + self.assertIn('id="webauthn-form"', content) + self.assertIn('data-mode="get"', content) + self.assertRegex(content, r']+name="credential"') + + def test_unsupported_message_present_but_hidden_by_default(self): + """A browser without window.PublicKeyCredential must see a clear + message rather than a silent failure or a thrown exception -- the + template must render the element (hidden), and webauthn.js reveals + it (checked separately, since no browser runs here -- see + WebAuthnJsSourceTests).""" + with self.settings(MFA_FIDO2_RP_ID="testserver"): + response = self.client.get( + reverse("mfa:enroll_factor", args=["webauthn"])) + content = response.content.decode() + self.assertRegex( + content, r'<[^>]*id="webauthn-unsupported"[^>]*\bhidden\b') + + def test_options_are_embedded_once_not_double_json_encoded(self): + with self.settings(MFA_FIDO2_RP_ID="testserver"): + response = self.client.get( + reverse("mfa:enroll_factor", args=["webauthn"])) + raw = self._extract_options_script(response.content.decode()) + self.assertIsNotNone(raw, "no " (e.g. an unusual username reflected + into publicKey.user.name/displayName) must not be able to close the + ", + "displayName": "attacker", + }, + "pubKeyCredParams": [], + } + } + hostile_json = json.dumps(hostile) + with patch.object(WebAuthnAdapter, "begin_enroll", + return_value={"options": hostile_json}): + with self.settings(MFA_FIDO2_RP_ID="testserver"): + response = self.client.get( + reverse("mfa:enroll_factor", args=["webauthn"])) + self.assertEqual(response.status_code, 200) + content = response.content.decode() + + # The dangerous substring must never appear literally: if it did, + # the injected would run as a second, + # attacker-controlled script element. + self.assertNotIn("", content) + # json_script (via django.utils.html.json_script) represents "<" + # inside the JSON text as the unicode escape \u003C, never as a + # literal character -- confirms real escaping ran, not just luck. + self.assertIn("\\u003C/script\\u003E", content) + + # It must still be valid, single-encoded JSON: parsing the embedded + # block once returns exactly the original dict. + raw = self._extract_options_script(content) + self.assertEqual(json.loads(raw), hostile) + + +class WebAuthnBase64UrlPortTests(TestCase): + """Proves the base64url<->ArrayBuffer conversion webauthn.js implements + (b64urlToBuf / bufToB64url) round-trips correctly, by porting the exact + same algorithm (translate the URL-safe alphabet back to standard + base64, restore/strip padding) into Python and testing it two ways: + + 1. against Python's own `base64.urlsafe_b64encode`/`urlsafe_b64decode` + (padding stripped), which is the same RFC 4648 base64url convention; + 2. against `fido2.utils.websafe_encode`/`websafe_decode`, the exact + function the SERVER side (adapters/webauthn.py, via Fido2Server) + uses to produce/consume `challenge`, `user.id`, and credential `id`. + + What this proves: the algorithm is correct RFC 4648 base64url and + round-trips for arbitrary byte strings, including the empty string and + lengths that require every padding case (0/1/2/3 leftover bytes). + + What this does NOT prove: that atob()/btoa()/Uint8Array/ArrayBuffer + behave this way in an actual browser, that webauthn.js's own copy of + this logic is free of a transcription typo, or that + navigator.credentials.create()/get() accept the resulting ArrayBuffers. + No JavaScript was executed to produce this test -- see the task report. + """ + + def setUp(self): + self.user = User.objects.create_user("a@example.com", password="pw") + self.client = Client() + self.client.login(username="a@example.com", password="pw") + + @staticmethod + def b64url_to_buf(value): + """Python port of webauthn.js's b64urlToBuf().""" + remainder = len(value) % 4 + pad = "=" * (4 - remainder) if remainder else "" + base64_str = (value + pad).replace("-", "+").replace("_", "/") + return base64.b64decode(base64_str) + + @staticmethod + def buf_to_b64url(buf): + """Python port of webauthn.js's bufToB64url().""" + encoded = base64.b64encode(buf).decode("ascii") + return encoded.replace("+", "-").replace("/", "_").rstrip("=") + + def test_round_trip_every_padding_case(self): + # Lengths 0..7 cover every value of (length % 4), i.e. every padding + # case the real fields (challenge/id, arbitrary byte lengths) can + # land on. + for length in range(0, 40): + data = bytes((i * 37 + 5) % 256 for i in range(length)) + encoded = self.buf_to_b64url(data) + self.assertNotIn("+", encoded) + self.assertNotIn("/", encoded) + self.assertNotIn("=", encoded) + self.assertEqual(self.b64url_to_buf(encoded), data) + + def test_matches_pythons_urlsafe_b64_with_padding_stripped(self): + for length in range(0, 40): + data = bytes((i * 91 + 3) % 256 for i in range(length)) + expected = base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + self.assertEqual(self.buf_to_b64url(data), expected) + self.assertEqual(self.b64url_to_buf(expected), data) + + def test_matches_fido2_websafe_encode_decode(self): + from fido2.utils import websafe_decode, websafe_encode + + for length in (0, 1, 16, 32, 64): + data = os.urandom(length) + server_encoded = websafe_encode(data) + self.assertEqual(self.buf_to_b64url(data), server_encoded) + self.assertEqual(self.b64url_to_buf(server_encoded), data) + self.assertEqual(websafe_decode(self.buf_to_b64url(data)), data) + + def test_round_trips_a_real_challenge_and_user_id_from_the_adapter(self): + """Not just made-up vectors: decode/re-encode the actual base64url + challenge and user.id a real enroll page embeds, the way + webauthn.js's decodeOptions()/encodeCredential() would.""" + with self.settings(MFA_FIDO2_RP_ID="testserver"): + response = self.client.get( + reverse("mfa:enroll_factor", args=["webauthn"])) + match = re.search( + r'', + response.content.decode(), re.S) + options = json.loads(match.group(1))["publicKey"] + + for value in (options["challenge"], options["user"]["id"]): + buf = self.b64url_to_buf(value) + self.assertEqual(self.buf_to_b64url(buf), value) + + +class WebAuthnJsSourceTests(TestCase): + """Static checks against the shipped webauthn.js source. + + No JavaScript engine/browser runs here, so these cannot prove the + script behaves correctly -- only that the specific pieces the templates + and the server-side adapter contract rely on are actually present in + the file that ships. See the task report for what these do not prove. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + js_path = os.path.join( + os.path.dirname(__file__), "..", "static", "django_mfa", "webauthn.js") + with open(js_path) as fh: + cls.source = fh.read() + + def test_file_exists_and_is_non_trivial(self): + self.assertGreater(len(self.source), 500) + + def test_decodes_challenge_user_id_and_both_credential_lists(self): + """The most likely bug per the task brief: handling challenge but + forgetting that excludeCredentials/allowCredentials are ARRAYS of + objects each carrying an id that also needs decoding.""" + for needle in ("pk.challenge", "pk.user.id", + "excludeCredentials", "allowCredentials"): + self.assertIn(needle, self.source) + + def test_reveals_unsupported_message_when_publickeycredential_missing(self): + self.assertIn("window.PublicKeyCredential", self.source) + self.assertIn("webauthn-unsupported", self.source) + self.assertIn("unsupported.hidden = false", self.source) + + def test_posts_credential_via_form_submit(self): + self.assertIn("name=credential", self.source) + self.assertIn("form.submit()", self.source) + + def test_uses_get_client_extension_results_method_not_a_property(self): + """credential.clientExtensionResults is not a real property on the + PublicKeyCredential navigator.credentials.create() resolves with -- + the only way to read extension outputs is the + getClientExtensionResults() method. Reading a plain property here + would always be undefined, silently breaking resident-key detection + in every real browser.""" + self.assertIn("getClientExtensionResults", self.source) + self.assertNotIn("credential.clientExtensionResults", self.source) + + +class HiddenAttributeStylesheetTests(TestCase): + """The shipped stylesheet must not defeat the HTML `hidden` attribute. + + Found by driving the sandbox in a real browser (Chrome, CDP virtual + authenticator) -- not by any test in this suite, and not reachable by + one: it is a CSS *cascade* outcome, which requires a browser to compute. + + `webauthn.js` reveals its error/unsupported banners by toggling the + `hidden` property, and the templates render them with `hidden` set. + `hidden` is implemented by a user-agent rule, `[hidden] {display:none}`, + whose specificity is lower than ANY class selector that sets `display` -- + and style.css has one (`.help-block {display:block}`) that matches those + exact elements. The result was that "Your browser does not support + security keys or passkeys (WebAuthn)" was permanently visible on the + enroll AND verify pages of every browser that fully supports WebAuthn, + directly above a working button. + + This test can only pin that the corrective rule is still present; the + cascade itself was verified in the browser. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + css_path = os.path.join( + os.path.dirname(__file__), "..", "static", "style.css") + with open(css_path) as fh: + cls.raw = fh.read() + # Strip comments before matching: the rule is explained by a comment + # that necessarily quotes the UA rule it is defeating, and matching + # that instead of the real declaration would make this test pass on a + # stylesheet that only *talks* about [hidden]. + cls.source = re.sub(r"/\*.*?\*/", "", cls.raw, flags=re.DOTALL) + + def test_hidden_attribute_rule_is_present_and_wins(self): + match = re.search(r"\[hidden\]\s*\{([^}]*)\}", self.source) + self.assertIsNotNone( + match, "style.css must carry a [hidden] rule -- see this class's " + "docstring for what breaks without it") + body = match.group(1) + self.assertIn("display", body) + self.assertIn("none", body) + # Without !important a later/equal-specificity class rule setting + # `display` still wins, which is the exact bug this guards. + self.assertIn("!important", body) + + def test_hidden_rule_precedes_any_display_setting_class_rule(self): + """Order matters for equal specificity, and `!important` on the UA- + level rule is what actually settles it -- but keeping the rule ahead + of the class rules it protects makes the intent legible to whoever + edits this file next. + """ + hidden_at = self.source.index("[hidden]") + help_block_at = self.source.index(".help-block") + self.assertLess(hidden_at, help_block_at) diff --git a/django_mfa/urls.py b/django_mfa/urls.py index 6e186bf..abd0cbc 100644 --- a/django_mfa/urls.py +++ b/django_mfa/urls.py @@ -1,25 +1,16 @@ -from .views import * -from django.urls import path, include -from . import views +from django.urls import include, path + +from django_mfa.views import enroll, manage, picker, verify security_patterns = ([ - path('verify-second-factor-options/', - verify_second_factor, name='verify_second_factor'), - path('verify/token/u2f/', views.verify_second_factor_u2f, - name='verify_second_factor_u2f'), - path('verify/token/totp/', verify_second_factor_totp, - name='verify_second_factor_totp'), - path('keys/', views.keys, name='u2f_keys'), - path('add-key/', views.add_key, name='add_u2f_key'), - path('security/', security_settings, name='security_settings'), - path('mfa/configure/', configure_mfa, name='configure_mfa'), - path('mfa/enable/', enable_mfa, name='enable_mfa'), - path('mfa/disable/', disable_mfa, name='disable_mfa'), - path('recovery/codes/', recovery_codes, name='recovery_codes'), - path('recovery/codes/downloads/', recovery_codes_download, - name='recovery_codes_download'), -], 'mfa') + path("security/", manage.security_settings, name="security_settings"), + path("manage/", manage.manage_factors, name="manage"), + path("verify/", picker.verify, name="verify"), + path("verify//", verify.verify_factor, name="verify_factor"), + path("enroll//", enroll.enroll_factor, name="enroll_factor"), + path("recovery/codes/", manage.recovery_codes, name="recovery_codes"), + path("passkey/begin/", verify.passkey_begin, name="passkey_begin"), + path("passkey/complete/", verify.passkey_complete, name="passkey_complete"), +], "mfa") -urlpatterns = [ - path("", include(security_patterns)), -] +urlpatterns = [path("", include(security_patterns))] diff --git a/django_mfa/views.py b/django_mfa/views.py deleted file mode 100644 index 3c07e69..0000000 --- a/django_mfa/views.py +++ /dev/null @@ -1,431 +0,0 @@ -import base64 -import codecs -import random -import hashlib -import re -import string -from django.http import HttpResponse, HttpResponseRedirect -from django.conf import settings -from django.contrib.auth.decorators import login_required -from django.shortcuts import render, redirect, resolve_url, get_object_or_404 -from django_mfa.models import * -from . import totp -from django.views.generic import FormView, ListView, TemplateView -from django.contrib.auth import load_backend -from django.contrib import auth, messages -from django.urls import reverse, reverse_lazy -from django.utils.http import is_safe_url -from django.utils.translation import ugettext as _ -from u2flib_server import u2f -from .forms import * - -class OriginMixin(object): - def get_origin(self): - return '{scheme}://{host}'.format( - scheme=self.request.scheme, - host=self.request.get_host(), - ) - - -@login_required -def security_settings(request): - twofactor_enabled = is_mfa_enabled(request.user) - u2f_enabled = is_u2f_enabled(request.user) - backup_codes = UserRecoveryCodes.objects.filter( - user=UserOTP.objects.filter(user=request.user).first()).all() - return render(request, 'django_mfa/security.html', {"prev_url": settings.LOGIN_REDIRECT_URL, "backup_codes": backup_codes, "u2f_enabled": u2f_enabled, "twofactor_enabled": twofactor_enabled}) - - -@login_required -def configure_mfa(request): - qr_code = None - base_32_secret_utf8 = None - if request.method == "POST": - base_32_secret = base64.b32encode( - codecs.decode(codecs.encode( - '{0:020x}'.format(random.getrandbits(80)) - ), 'hex_codec') - ) - base_32_secret_utf8 = base_32_secret.decode("utf-8") - totp_obj = totp.TOTP(base_32_secret_utf8) - - try: - issuer_name = settings.MFA_ISSUER_NAME - except: - issuer_name = None - qr_code = re.sub( - r'=+$', '', totp_obj.provisioning_uri(request.user.username, issuer_name=issuer_name)) - - return render(request, 'django_mfa/configure.html', {"qr_code": qr_code, "secret_key": base_32_secret_utf8}) - - -@login_required -def enable_mfa(request): - user = request.user - if is_mfa_enabled(user): - return HttpResponseRedirect(reverse("mfa:disable_mfa")) - qr_code = None - base_32_secret = None - is_verified = False - if request.method == "POST": - base_32_secret = request.POST['secret_key'] - totp_obj = totp.TOTP(request.POST['secret_key']) - is_verified = totp_obj.verify(request.POST["verification_code"]) - if is_verified: - request.session['verfied_otp'] = True - UserOTP.objects.get_or_create(otp_type=request.POST["otp_type"], - user=request.user, - secret_key=request.POST['secret_key']) - messages.success( - request, "You have successfully enabled multi-factor authentication on your account.") - response = redirect(reverse("mfa:recovery_codes")) - return response - else: - totp_obj = totp.TOTP(base_32_secret) - qr_code = totp_obj.provisioning_uri(request.user.email) - - return render(request, 'django_mfa/configure.html', {"is_verified": is_verified, "qr_code": qr_code, "secret_key": base_32_secret}) - - -def _generate_cookie_salt(user): - try: - otp_ = UserOTP.objects.get(user=user) - except UserOTP.DoesNotExist: - return '' - # out of paranoia only use half the secret to generate the salt - uselen = int(len(otp_.secret_key) / 2) - half_secret = otp_.secret_key[:uselen] - m = hashlib.sha256() - m.update(half_secret.encode("utf-8")) - cookie_salt = m.hexdigest() - return cookie_salt - - -MFA_COOKIE_PREFIX = "RMB_" - - -# update Remember-My-Browser cookie -def update_rmb_cookie(request, response): - try: - remember_my_browser = settings.MFA_REMEMBER_MY_BROWSER - remember_days = settings.MFA_REMEMBER_DAYS - except: - remember_my_browser = False - if remember_my_browser: - # better not to reveal the username. Revealing the number seems harmless - cookie_name = MFA_COOKIE_PREFIX + str(request.user.pk) - cookie_salt = _generate_cookie_salt(request.user) - response.set_signed_cookie(cookie_name, True, salt=cookie_salt, max_age=remember_days * 24 * 3600, - secure=(not settings.DEBUG), httponly=True) - return response - - -# verify Remember-My-Browser cookie -# returns True if browser is trusted and no code verification needed -def verify_rmb_cookie(request): - try: - remember_my_browser = settings.MFA_REMEMBER_MY_BROWSER - max_cookie_age = settings.MFA_REMEMBER_DAYS * 24 * 3600 - except: - return False - if not remember_my_browser: - return False - - cookie_name = MFA_COOKIE_PREFIX + str(request.user.pk) - cookie_salt = _generate_cookie_salt(request.user) - if not cookie_salt: - return False - cookie_value = request.get_signed_cookie( - cookie_name, False, max_age=max_cookie_age, salt=cookie_salt) - # if the cookie value is True and the signature is good than the browser can be trusted - return cookie_value - - -def delete_rmb_cookie(request, response): - cookie_name = MFA_COOKIE_PREFIX + str(request.user.pk) - response.delete_cookie(cookie_name) - return response - - -@login_required -def disable_mfa(request): - user = request.user - if not is_mfa_enabled(user): - return HttpResponseRedirect(reverse("mfa:configure_mfa")) - if request.method == "POST": - user_mfa = user.userotp - user_mfa.delete() - messages.success( - request, "You have successfully disabled multi-factor authentication on your account.") - response = redirect(settings.LOGIN_REDIRECT_URL) - return delete_rmb_cookie(request, response) - return render(request, 'django_mfa/disable_mfa.html') - - -@login_required -def verify_second_factor_totp(request): - """ - Verify a OTP request - """ - ctx = {} - if request.method == 'GET': - ctx['next'] = request.GET.get('next', settings.LOGIN_REDIRECT_URL) - return render(request, 'django_mfa/verify_second_factor_mfa.html', ctx) - - if request.method == "POST": - verification_code = request.POST.get('verification_code') - ctx['next'] = request.POST.get("next", settings.LOGIN_REDIRECT_URL) - - if verification_code is None: - ctx['error_message'] = "Missing verification code." - - else: - user_recovery_codes = UserRecoveryCodes.objects.values_list('secret_code', flat=True).filter( - user=UserOTP.objects.get(user=request.user.id)) - if verification_code in user_recovery_codes: - UserRecoveryCodes.objects.filter(user=UserOTP.objects.get( - user=request.user.id), secret_code=verification_code).delete() - is_verified = True - else: - otp_ = UserOTP.objects.get(user=request.user) - totp_ = totp.TOTP(otp_.secret_key) - - is_verified = totp_.verify(verification_code) - - if is_verified: - request.session['verfied_otp'] = True - request.session['verfied_u2f'] = True - response = redirect(request.POST.get( - "next", settings.LOGIN_REDIRECT_URL)) - return update_rmb_cookie(request, response) - ctx['error_message'] = "Your code is expired or invalid." - else: - ctx['next'] = request.GET.get('next', settings.LOGIN_REDIRECT_URL) - - return render(request, 'django_mfa/verify_second_factor_mfa.html', ctx, status=400) - - -def generate_user_recovery_codes(user_id): - no_of_recovery_codes = 10 - size_of_recovery_code = MFA_RECOVERY_CODE_LENGTH - recovery_codes_list = [] - chars = string.ascii_uppercase + string.digits + string.ascii_lowercase - while(no_of_recovery_codes > 0): - code = ''.join(random.choice(chars) - for _ in range(size_of_recovery_code)) - Total_recovery_codes = UserRecoveryCodes.objects.values_list('secret_code', flat=True).filter( - user=UserOTP.objects.get(user=user_id)) - if code not in Total_recovery_codes: - no_of_recovery_codes = no_of_recovery_codes - 1 - UserRecoveryCodes.objects.create( - user=UserOTP.objects.get(user=user_id), secret_code=code) - recovery_codes_list.append(code) - return recovery_codes_list - - -@login_required -def recovery_codes(request): - if request.method == "GET": - if is_mfa_enabled(request.user): - if UserRecoveryCodes.objects.filter(user=UserOTP.objects.get(user=request.user.id)).exists(): - codes = UserRecoveryCodes.objects.values_list('secret_code', flat=True).filter( - user=UserOTP.objects.get(user=request.user.id)) - else: - codes = generate_user_recovery_codes(request.user.id) - next_url = resolve_url(settings.LOGIN_REDIRECT_URL) - return render(request, "django_mfa/recovery_codes.html", {"codes": codes, "next_url": next_url}) - else: - return HttpResponse("please enable twofactor_authentication!") - - -@login_required -def verify_second_factor(request): - if request.method == "GET": - twofactor_enabled = is_mfa_enabled(request.user) - u2f_enabled = is_u2f_enabled(request.user) - if twofactor_enabled and u2f_enabled: - return render(request, 'django_mfa/verify_second_factor.html', {"u2f_enabled": u2f_enabled, "twofactor_enabled": twofactor_enabled}) - if u2f_enabled: - return redirect("mfa:verify_second_factor_u2f") - if twofactor_enabled: - return redirect("mfa:verify_second_factor_totp") - -@login_required -def recovery_codes_download(request): - codes_list = [] - codes = UserRecoveryCodes.objects.values_list('secret_code', flat=True).filter( - user=UserOTP.objects.get(user=request.user.id)) - for i in codes: - codes_list.append(i) - codes_list.append("\n") - response = HttpResponse( - codes_list, content_type='text/plain') - response['Content-Disposition'] = 'attachment; filename=%s' % 'recovery_codes.txt' - return response - - -class AddKeyView(OriginMixin, FormView): - template_name = 'u2f/add_key.html' - form_class = KeyRegistrationForm - success_url = reverse_lazy('mfa:u2f_keys') - - def dispatch(self, request, *args, **kwargs): - return super(AddKeyView, self).dispatch(request, *args, **kwargs) - - def get_form_kwargs(self): - kwargs = super(AddKeyView, self).get_form_kwargs() - kwargs.update( - user=self.request.user, - request=self.request, - appId=self.get_origin(), - ) - return kwargs - - def get_context_data(self, **kwargs): - kwargs = super(AddKeyView, self).get_context_data(**kwargs) - request = u2f.begin_registration(self.get_origin(), [ - key.to_json() for key in self.request.user.u2f_keys.all() - ]) - self.request.session['u2f_registration_request'] = request - kwargs['registration_request'] = request - return kwargs - - def form_valid(self, form): - request = self.request.session['u2f_registration_request'] - response = form.cleaned_data['response'] - del self.request.session['u2f_registration_request'] - device, attestation_cert = u2f.complete_registration(request, response) - self.request.user.u2f_keys.create( - public_key=device['publicKey'], - key_handle=device['keyHandle'], - app_id=device['appId'], - ) - self.request.session['verfied_u2f'] = True - messages.success(self.request, _("Key added.")) - return super(AddKeyView, self).form_valid(form) - - def get_success_url(self): - if 'next' in self.request.GET and is_safe_url(self.request.GET['next']): - return self.request.GET['next'] - else: - return super(AddKeyView, self).get_success_url() - - -class VerifySecondFactorView(OriginMixin, TemplateView): - template_name = 'u2f/verify_second_factor_u2f.html' - - @property - def form_classes(self): - ret = {} - if self.user.u2f_keys.exists(): - ret['u2f'] = KeyResponseForm - return ret - - def get_user(self): - try: - user_id = self.request.session['u2f_pre_verify_user_pk'] - backend_path = self.request.session['u2f_pre_verify_user_backend'] - self.request.session['verfied_u2f'] = False - assert backend_path in settings.AUTHENTICATION_BACKENDS - backend = load_backend(backend_path) - user = backend.get_user(user_id) - if user is not None: - user.backend = backend_path - return user - except (KeyError, AssertionError): - return None - - def dispatch(self, request, *args, **kwargs): - self.user = self.get_user() - if self.user is None: - return HttpResponseRedirect(settings.LOGIN_URL) - return super(VerifySecondFactorView, self).dispatch(request, *args, **kwargs) - - def post(self, request, *args, **kwargs): - forms = self.get_forms() - form = forms[request.POST['type']] - if form.is_valid(): - return self.form_valid(form, forms) - else: - return self.form_invalid(forms) - - def form_invalid(self, forms): - return self.render_to_response(self.get_context_data( - forms=forms, - )) - - def get_form_kwargs(self): - return { - 'user': self.user, - 'request': self.request, - 'appId': self.get_origin(), - } - - def get_forms(self): - kwargs = self.get_form_kwargs() - if self.request.method == 'GET': - forms = {key: form(**kwargs) - for key, form in self.form_classes.items()} - else: - method = self.request.POST['type'] - forms = { - key: form(**kwargs) - for key, form in self.form_classes.items() - if key != method - } - forms[method] = self.form_classes[method]( - self.request.POST, **kwargs) - return forms - - def get_context_data(self, **kwargs): - if 'forms' not in kwargs: - kwargs['forms'] = self.get_forms() - kwargs = super(VerifySecondFactorView, self).get_context_data(**kwargs) - if self.request.GET.get('admin'): - kwargs['base_template'] = 'admin/base_site.html' - else: - kwargs['base_template'] = 'u2f_base.html' - kwargs['user'] = self.user - return kwargs - - def form_valid(self, form, forms): - if not form.validate_second_factor(): - return self.form_invalid(forms) - - del self.request.session['u2f_pre_verify_user_pk'] - del self.request.session['u2f_pre_verify_user_backend'] - self.request.session['verfied_otp'] = True - self.request.session['verfied_u2f'] = True - - auth.login(self.request, self.user) - - redirect_to = self.request.POST.get(auth.REDIRECT_FIELD_NAME, - self.request.GET.get(auth.REDIRECT_FIELD_NAME, '')) - if not is_safe_url(url=redirect_to, allowed_hosts=self.request.get_host()): - redirect_to = resolve_url(settings.LOGIN_REDIRECT_URL) - return HttpResponseRedirect(redirect_to) - - -class KeyManagementView(ListView): - template_name = 'u2f/key_list.html' - - def get_queryset(self): - return self.request.user.u2f_keys.all() - - def post(self, request): - assert 'delete' in self.request.POST - key = get_object_or_404(self.get_queryset(), - pk=self.request.POST['key_id']) - key.delete() - if self.get_queryset().exists(): - messages.success(request, _("Key removed.")) - else: - messages.success(request, _( - "Key removed. Two-factor auth disabled.")) - return HttpResponseRedirect(reverse('mfa:u2f_keys')) - - -add_key = login_required(AddKeyView.as_view()) -verify_second_factor_u2f = VerifySecondFactorView.as_view() -keys = login_required(KeyManagementView.as_view()) - diff --git a/django_mfa/views/__init__.py b/django_mfa/views/__init__.py new file mode 100644 index 0000000..f2e4643 --- /dev/null +++ b/django_mfa/views/__init__.py @@ -0,0 +1,13 @@ +# Views package: registry-driven MFA screens. +# +# Split by concern (picker / verify / enroll / manage) rather than the single +# flat views.py the legacy UserOTP-based flow used. `verify_rmb_cookie` (and +# its update/delete counterparts) are re-exported here because +# `django_mfa.middleware` imports `verify_rmb_cookie` directly from +# `django_mfa.views` -- splitting the module into a package must not change +# that import path. +from django_mfa.views.verify import ( # noqa: F401 + delete_rmb_cookie, + update_rmb_cookie, + verify_rmb_cookie, +) diff --git a/django_mfa/views/enroll.py b/django_mfa/views/enroll.py new file mode 100644 index 0000000..d655d8f --- /dev/null +++ b/django_mfa/views/enroll.py @@ -0,0 +1,58 @@ +from django.contrib.auth.decorators import login_required +from django.http import Http404 +from django.shortcuts import redirect, render +from django.urls import reverse + +from django_mfa import session +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.verify import GENERIC_ERROR, _adapter_or_404 + + +@login_required +def enroll_factor(request, factor_type): + adapter = _adapter_or_404(factor_type) + if not adapter.supports_enroll: + # Registered and verifiable, but not enrollable (recovery codes are + # generated at mfa:recovery_codes). available_for() already keeps + # these off the security page; this covers a hand-typed URL, which + # would otherwise reach Adapter.begin_enroll and raise + # NotImplementedError as a 500. + raise Http404(f"Factor {factor_type!r} is not enrolled this way") + context = {"adapter": adapter, + "base_template": mfa_settings.MFA_BASE_TEMPLATE} + + if request.method == "POST": + try: + 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 + # 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) + + has_codes = Authenticator.objects.filter( + user=request.user, type=Authenticator.Type.RECOVERY_CODES).exists() + if not has_codes: + return redirect(reverse("mfa:recovery_codes")) + return redirect(reverse("mfa:security_settings")) + + context.update(adapter.begin_enroll(request)) + return render(request, adapter.enroll_template, context) diff --git a/django_mfa/views/manage.py b/django_mfa/views/manage.py new file mode 100644 index 0000000..682d475 --- /dev/null +++ b/django_mfa/views/manage.py @@ -0,0 +1,82 @@ +from django.conf import settings +from django.contrib.auth.decorators import login_required +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_mfa.adapters.recovery_codes import RecoveryCodesAdapter +from django_mfa.conf import settings as mfa_settings +from django_mfa.models import Authenticator +from django_mfa.registry import registry + + +@login_required +def security_settings(request): + context = { + "enabled_adapters": registry.enabled_for(request.user), + "available_adapters": registry.available_for(request.user), + # Individual Authenticator rows (not just the distinct types + # enabled_adapters above gives) -- this is what lets the template + # list, and offer removal of, each authenticator separately (e.g. a + # user with three security keys can tell them apart by name/date, + # and remove exactly one). See security.html. + "authenticators": Authenticator.objects.filter( + user=request.user).order_by("type", "created_at"), + "recovery_codes_remaining": RecoveryCodesAdapter().remaining(request.user), + "owned_by_enterprise": mfa_settings.MFA_OWNED_BY_ENTERPRISE, + "base_template": mfa_settings.MFA_BASE_TEMPLATE, + } + return render(request, "django_mfa/security.html", context) + + +@login_required +def recovery_codes(request): + """Show the user their recovery codes. + + Codes are hashed at rest (see RecoveryCodesAdapter) and generate() + returns plaintext exactly once. That plaintext is used ONLY to render + this response -- it must never be written to the session, the database, + or anywhere else that outlives this request/response cycle. Once an + Authenticator row already exists, there is nothing left to show: the + plaintext is gone for good, and this renders with codes=None. Downloading + the codes is handled client-side (see recovery_codes.html) by building a + file from the codes already in the rendered page, precisely so the + server never needs to retain them. + """ + has_codes = Authenticator.objects.filter( + user=request.user, type=Authenticator.Type.RECOVERY_CODES).exists() + codes = None if has_codes else RecoveryCodesAdapter().generate(request.user) + next_url = resolve_url(settings.LOGIN_REDIRECT_URL) + return render(request, "django_mfa/recovery_codes.html", { + "codes": codes, + "next_url": next_url, + "base_template": mfa_settings.MFA_BASE_TEMPLATE, + }) + + +@login_required +def manage_factors(request): + if request.method != "POST": + return HttpResponseNotAllowed(["POST"]) + + # A non-integer pk makes the ORM raise ValueError before it can raise + # DoesNotExist, which get_object_or_404 does not catch -- so an + # unparseable pk has to be turned into the same 404 a missing or + # someone-else's pk gets, rather than a 500. + try: + authenticator = get_object_or_404( + Authenticator, pk=int(request.POST.get("pk", "")), + user=request.user) + except (TypeError, ValueError, OverflowError): + # ValueError/TypeError: int() rejects it outright. OverflowError: it + # parses but exceeds what the column can hold, which only surfaces + # when the query executes. + raise Http404("No such authenticator") + if (authenticator.type == Authenticator.Type.WEBAUTHN + and mfa_settings.MFA_OWNED_BY_ENTERPRISE): + return HttpResponseForbidden( + "This security key is managed by your organization and cannot " + "be removed here.") + + authenticator.delete() + return redirect(reverse("mfa:security_settings")) diff --git a/django_mfa/views/picker.py b/django_mfa/views/picker.py new file mode 100644 index 0000000..fba8434 --- /dev/null +++ b/django_mfa/views/picker.py @@ -0,0 +1,17 @@ +from django.contrib.auth.decorators import login_required +from django.shortcuts import redirect, render +from django.urls import reverse + +from django_mfa.conf import settings as mfa_settings +from django_mfa.registry import registry + + +@login_required +def verify(request): + adapters = registry.enabled_for(request.user) + if len(adapters) == 1: + return redirect(reverse("mfa:verify_factor", args=[adapters[0].type])) + return render(request, "django_mfa/picker.html", { + "adapters": adapters, + "base_template": mfa_settings.MFA_BASE_TEMPLATE, + }) diff --git a/django_mfa/views/verify.py b/django_mfa/views/verify.py new file mode 100644 index 0000000..09e0438 --- /dev/null +++ b/django_mfa/views/verify.py @@ -0,0 +1,342 @@ +import hashlib +import json + +from django.conf import settings +from django.contrib import auth +from django.contrib.auth.decorators import login_required +from django.http import Http404, HttpResponseNotAllowed, JsonResponse +from django.shortcuts import redirect, render, resolve_url +from django.utils.http import url_has_allowed_host_and_scheme + +from fido2.webauthn import AuthenticationResponse + +from django_mfa import ratelimit, 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 +from django_mfa.crypto import decrypt +from django_mfa.models import Authenticator +from django_mfa.registry import registry + +GENERIC_ERROR = "Your code is expired or invalid." + +#: Session key the in-progress *passwordless* authentication +#: challenge/state is stashed under between passkey_begin() and +#: passkey_complete(). Deliberately separate from +#: django_mfa.adapters.webauthn.AUTH_STATE_KEY -- that key belongs to a +#: ceremony scoped to an already-known, already-authenticated request.user +#: (the second-factor picker flow); this one is scoped to no user at all +#: (an anonymous visitor on the login page). Keeping them distinct means a +#: passwordless ceremony can never be finished against state meant for a +#: logged-in user's second-factor challenge, or vice versa. +PASSKEY_STATE_KEY = "webauthn_passkey_state" + + +def _passkey_failure(): + """The one and only response returned by passkey_complete() for every + failure mode -- unknown handle, unknown credential, bad signature, + missing/expired state, tampered payload, clone-detected sign counter. + + Deliberately generic and byte-for-byte identical across all of them + (same status code, same body, constructed the same way every time): see + passkey_complete()'s docstring for why. A fresh JsonResponse is built on + every call rather than a module-level singleton being reused, since a + response object is mutated as it's rendered/sent and must not be shared + across requests. + """ + return JsonResponse({"error": "Passkey sign-in failed."}, status=400) + + +def _adapter_or_404(factor_type): + try: + return registry.get(factor_type) + except KeyError: + raise Http404(f"Unknown factor {factor_type!r}") + + +def _safe_next(request): + candidate = request.POST.get("next") or request.GET.get("next") + if candidate and url_has_allowed_host_and_scheme( + candidate, allowed_hosts={request.get_host()}, require_https=request.is_secure() + ): + return candidate + return resolve_url(settings.LOGIN_REDIRECT_URL) + + +@login_required +def verify_factor(request, factor_type): + adapter = _adapter_or_404(factor_type) + next_url = _safe_next(request) + context = {"adapter": adapter, "next": next_url, + "base_template": mfa_settings.MFA_BASE_TEMPLATE} + + # The user_logged_in signal (signals.py:stamp_pending_verification) + # already stamps the session pending as soon as a user who HAS a primary + # factor logs in. For any session that reaches this view without having + # gone through that (e.g. a stale/pre-existing session), establish it + # here so a failed attempt has a defined verified=False to fail into, + # without clobbering a session that already completed verification. + # + # Guarded on registry.primary_enabled_for(): a user with NO primary + # factor must never be stamped pending here. Without this guard, a + # not-yet-enrolled user GETting this view (reachable by a plain, + # cross-site top-level navigation) would be marked pending, and + # MfaMiddleware would then redirect every subsequent request -- including + # to enroll_factor, the only page that could add a factor -- to a picker + # with zero options, permanently locking them out. + if (not session.is_verified(request) + and registry.primary_enabled_for(request.user)): + 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) + # 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) + context["error_message"] = GENERIC_ERROR + context.update(adapter.begin_verify(request, request.user)) + return render(request, adapter.verify_template, context, status=400) + + context.update(adapter.begin_verify(request, request.user)) + return render(request, adapter.verify_template, context) + + +# --- Passwordless (passkey) login -------------------------------------------- +# +# WebAuthn registration/verification-as-a-second-factor +# (django_mfa/adapters/webauthn.py) assume request.user is already known: +# begin_verify()/complete_verify() scope the credential allow-list to a +# specific, already-authenticated user. Passwordless login is the opposite +# shape -- an anonymous visitor with no username or password, whose identity +# is only discovered *from* the assertion (via the discoverable credential's +# userHandle) -- so it cannot reuse those methods as-is for the begin half. +# It DOES reuse WebAuthnAdapter.complete_verify() for the actual ceremony +# validation and clone detection once the user has been resolved: see +# passkey_complete() below for how the two session-state keys are bridged so +# that reuse is possible without duplicating any of that logic. + +BACKEND_PATH = f"{WebAuthnBackend.__module__}.{WebAuthnBackend.__qualname__}" + + +def passkey_begin(request): + """Start a passwordless WebAuthn authentication ceremony. + + GET only (it has no side effect beyond stashing a challenge, same as + verify_factor's GET branch). Passes an *empty* credential list to + authenticate_begin() -- this is what makes the resulting + allowCredentials empty in the returned options, which is what tells the + browser/authenticator "let the user pick any resident (discoverable) + credential it holds for this RP" instead of restricting the prompt to + one specific, already-known user's credentials. That emptiness is the + entire mechanism behind "usernameless" login; see + test_passkey_begin_produces_an_empty_allow_list in test_passwordless.py. + """ + if request.method != "GET": + return HttpResponseNotAllowed(["GET"]) + + options, state = get_server().authenticate_begin( + [], user_verification=mfa_settings.MFA_FIDO2_USER_VERIFICATION) + request.session[PASSKEY_STATE_KEY] = state + return JsonResponse({"options": json.dumps(dict(options))}) + + +def passkey_complete(request): + """Finish a passwordless WebAuthn authentication ceremony and log the + resolved user in. + + POST only. Every failure path -- 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. + """ + if request.method != "POST": + return HttpResponseNotAllowed(["POST"]) + + state = request.session.pop(PASSKEY_STATE_KEY, None) + if state is None: + return _passkey_failure() + + 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. + # 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() + + 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() + + try: + user = user_from_handle(user_handle.decode("utf-8")) + except UnicodeDecodeError: + user = None + if user is None: + return _passkey_failure() + + # 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"]}) + except ValueError: + # Ceremony rejection (wrong challenge/origin/RP ID, bad signature, + # credential ID not among this user's own credentials -- which is + # also how a passkey enrolled to a different user is rejected, see + # WebAuthnAdapter._existing_credentials()) and clone detection + # (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() + + 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() + + # 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. + if parsed.response.authenticator_data.is_user_verified(): + session.mark_verified(request, "webauthn") + + auth.login(request, user, backend=BACKEND_PATH) + return redirect(_safe_next(request)) + + +# --- Remember-my-browser cookie helpers ------------------------------------- +# +# Carried over from the legacy views.py unchanged apart from reading +# Authenticator (type=totp, data["secret"]) instead of UserOTP.secret_key. +# The secret may be encrypted at rest (see django_mfa.crypto), so it is run +# through decrypt() before being used to derive the cookie salt. + +MFA_COOKIE_PREFIX = "RMB_" + + +def _generate_cookie_salt(user): + auth = Authenticator.objects.filter( + user=user, type=Authenticator.Type.TOTP).first() + if auth is None: + return '' + secret = decrypt(auth.data.get("secret", "")) + # out of paranoia only use half the secret to generate the salt + uselen = int(len(secret) / 2) + half_secret = secret[:uselen] + m = hashlib.sha256() + m.update(half_secret.encode("utf-8")) + cookie_salt = m.hexdigest() + return cookie_salt + + +# update Remember-My-Browser cookie +def update_rmb_cookie(request, response): + remember_my_browser = mfa_settings.MFA_REMEMBER_MY_BROWSER + remember_days = mfa_settings.MFA_REMEMBER_DAYS + if remember_my_browser: + # better not to reveal the username. Revealing the number seems harmless + cookie_name = MFA_COOKIE_PREFIX + str(request.user.pk) + cookie_salt = _generate_cookie_salt(request.user) + response.set_signed_cookie(cookie_name, True, salt=cookie_salt, max_age=remember_days * 24 * 3600, + secure=(not settings.DEBUG), httponly=True) + return response + + +# verify Remember-My-Browser cookie +# returns True if browser is trusted and no code verification needed +def verify_rmb_cookie(request): + remember_my_browser = mfa_settings.MFA_REMEMBER_MY_BROWSER + max_cookie_age = mfa_settings.MFA_REMEMBER_DAYS * 24 * 3600 + if not remember_my_browser: + return False + + cookie_name = MFA_COOKIE_PREFIX + str(request.user.pk) + cookie_salt = _generate_cookie_salt(request.user) + if not cookie_salt: + return False + cookie_value = request.get_signed_cookie( + cookie_name, False, max_age=max_cookie_age, salt=cookie_salt) + # if the cookie value is True and the signature is good than the browser can be trusted + return cookie_value + + +def delete_rmb_cookie(request, response): + cookie_name = MFA_COOKIE_PREFIX + str(request.user.pk) + response.delete_cookie(cookie_name) + return response diff --git a/sandbox/sample/views.py b/sandbox/sample/views.py index 8e090d3..8acccf5 100644 --- a/sandbox/sample/views.py +++ b/sandbox/sample/views.py @@ -3,7 +3,6 @@ from django.http.response import HttpResponseRedirect, JsonResponse from .forms import RegistrationForm, LoginForm from django.contrib.auth.models import User -from django_mfa.models import is_u2f_enabled from django.conf import settings @@ -13,10 +12,15 @@ def index(request): if request.method == 'POST': form = LoginForm(request.POST, request.FILES) if form.is_valid(): - user = form.user - if is_u2f_enabled(user): - request.session['u2f_pre_verify_user_pk'] = user.pk - request.session['u2f_pre_verify_user_backend'] = user.backend + # django_mfa's own user_logged_in signal receiver + # (django_mfa.signals.stamp_pending_verification) now marks the + # session pending a second factor, if the user has one, as soon + # as login() below fires the signal -- a host project's login + # view no longer has to know or care whether MFA is enabled for + # this user, and the old u2f_pre_verify_user_pk/ + # u2f_pre_verify_user_backend session dance (and the U2F-specific + # is_u2f_enabled() check that gated it) is gone. See + # docs/upgrading.rst item 6. login(request, form.user) return JsonResponse({"error": False}) else: diff --git a/sandbox/templates/configure.html b/sandbox/templates/configure.html deleted file mode 100644 index d55a215..0000000 --- a/sandbox/templates/configure.html +++ /dev/null @@ -1,84 +0,0 @@ -{% extends 'django_mfa/base.html' %} -{% load otp_tags %} -{% block content %} - -{% if is_verified %} -
- You have successfully enabled mfa on your account. - Please Logout to test Django MFA (Multi Factored Authentication) - -
-{% else %} -{% if qr_code %} - -
-
-

Add your app to your two-factor authentication app

-

- You will need to install a two-factor authentication application on your phone. -

-
- -
-
- -
-

1. Scan barcode

- {{qr_code|qrcode:"my alt"}} - -
- -

- After scanning the barcode image, the app will display a six-digit code that - you can enter below. -

- -
- -
- {% csrf_token %} -
-
-
- - - -
-
- -
-
- -
- -{% else %} -
- Two-factor authentication - adds an extra layer of security to your account. In addition to your username and password, you’ll need to - enter a code that this app sends to you via text or an app on your phone.

-
-
-
When you sign into your app you’ll enter your username and password, like always.
-
When logging in from a new browser, you’ll need to enter an additional code from your phone or tablet.
-
Once you enter the code on the website, you’ll be logged into your app.
-
- -
-
-

Use an application on your phone to get two-factor authentication codes when prompted.

-
{% csrf_token %} - - -
-
- -
- -
-{% endif %} -{% endif %} -{% endblock %} diff --git a/sandbox/templates/login.html b/sandbox/templates/login.html index 5632751..664a26a 100644 --- a/sandbox/templates/login.html +++ b/sandbox/templates/login.html @@ -1,5 +1,4 @@ -{% load argonauts %} {% load i18n %} {% load static %} @@ -206,9 +205,6 @@

Django MFA

- - - {% endblock %} diff --git a/django_mfa/templates/django_mfa/picker.html b/django_mfa/templates/django_mfa/picker.html index 51934a7..ff2d82f 100644 --- a/django_mfa/templates/django_mfa/picker.html +++ b/django_mfa/templates/django_mfa/picker.html @@ -1,13 +1,16 @@ {% extends base_template %} {% load i18n %} {% block content %} -

{% trans "Verify your identity" %}

-

{% trans "Choose how you'd like to verify it's you." %}

- +
+

{% trans "Verify it's you" %}

+

{% trans "Choose how you'd like to confirm your identity." %}

+ + +
{% endblock %} diff --git a/django_mfa/templates/django_mfa/recovery_codes.html b/django_mfa/templates/django_mfa/recovery_codes.html index cf4a63d..dd8c218 100644 --- a/django_mfa/templates/django_mfa/recovery_codes.html +++ b/django_mfa/templates/django_mfa/recovery_codes.html @@ -1,38 +1,45 @@ {% extends base_template %} {% load i18n %} {% block content %} -
-
-

{% trans "Recovery Codes" %}

-
-
- {% if codes %} +
+

{% trans "Recovery codes" %}

+ + {% if codes %} +

{% trans "Save these somewhere safe. Each code works once, and this is the only time they'll be shown." %}

+ +
{% for code in codes %}

{{ code }}

{% endfor %} - {% else %} -

{% trans "Recovery codes were already generated and can no longer be displayed. Remove and re-add two-factor authentication if you need a fresh set." %}

- {% endif %}
-
-{% if codes %} - -{% trans "Download" %} - -{% endif %} -{% trans "Home Page" %} + + + + + {% else %} +

+ {% trans "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." %} +

+ + {% endif %} + {% endblock %} diff --git a/django_mfa/templates/django_mfa/security.html b/django_mfa/templates/django_mfa/security.html index ad262e8..85b2a4a 100755 --- a/django_mfa/templates/django_mfa/security.html +++ b/django_mfa/templates/django_mfa/security.html @@ -1,68 +1,78 @@ {% extends base_template %} {% load i18n %} +{% block main_modifier %} mfa-main--wide{% endblock %} {% block content %} -

{% trans "Two-Factor Settings" %}

+

{% trans "Two-factor authentication" %}

+

{% trans "Add a second step to your sign-in so a stolen password isn't enough on its own." %}

-
-
-

{% trans "Enabled methods" %}

+
+
+

{% trans "Your methods" %}

-
- {% if authenticators %} -
    - {% for authenticator in authenticators %} -
  • - {{ authenticator.get_type_display }}{% if authenticator.name %} — {{ authenticator.name }}{% endif %} - {% blocktrans with created=authenticator.created_at %}(added {{ created }}){% endblocktrans %} + + {% if authenticators %} +
      + {% for authenticator in authenticators %} +
    • +
      +
      + + {{ authenticator.get_type_display }}{% if authenticator.name %} — {{ authenticator.name }}{% endif %} + + + {% blocktrans with created=authenticator.created_at|date:"j M Y" %}Added {{ created }}{% endblocktrans %} + {% if authenticator.last_used_at %} + · {% blocktrans with used=authenticator.last_used_at|date:"j M Y" %}last used {{ used }}{% endblocktrans %} + {% endif %} + +
      {% if authenticator.type != "webauthn" or not owned_by_enterprise %} -
      + {% csrf_token %} -
      {% else %} - {% trans "Managed by your organization" %} + {% trans "Managed by your organization" %} {% endif %} -
    • - {% endfor %} -
    - {% else %} -

    {% trans "You have not enabled two-factor authentication." %}

    - {% endif %} -
-
+
+ + {% endfor %} + + {% else %} +

{% trans "You haven't set up two-factor authentication yet." %}

+ {% endif %} + -
- -
-
-

{% trans "Add a method" %}

-
-
- {% if available_adapters %} - - {% else %} -

{% trans "There is nothing left to add." %}

- {% endif %} +
+
+

{% trans "Add a method" %}

-
-
+ {% if available_adapters %} + + {% else %} +

{% trans "There is nothing left to add." %}

+ {% endif %} + -
-
-

{% trans "Recovery codes" %}

+
+
+

{% trans "Recovery codes" %}

-
-

{% blocktrans %}Recovery codes remaining: {{ recovery_codes_remaining }}{% endblocktrans %}

+

+ {% blocktrans count remaining=recovery_codes_remaining %}{{ remaining }} code remaining.{% plural %}{{ remaining }} codes remaining.{% endblocktrans %} + {% trans "Use one to sign in if you lose access to your other methods." %} +

+ -
+
{% endblock %} diff --git a/django_mfa/templates/django_mfa/verify_recovery_codes.html b/django_mfa/templates/django_mfa/verify_recovery_codes.html index d1ba080..4f44ed7 100644 --- a/django_mfa/templates/django_mfa/verify_recovery_codes.html +++ b/django_mfa/templates/django_mfa/verify_recovery_codes.html @@ -1,21 +1,29 @@ {% extends base_template %} {% load i18n %} {% block content %} -

{% trans "Enter a recovery code" %}

-

{% blocktrans %}You have {{ remaining }} recovery codes remaining.{% endblocktrans %}

-
- {% csrf_token %} - -
-
-
- - {% if error_message %} -

{{ error_message }}

- {% endif %} -
-
- -
+
+

{% trans "Enter a recovery code" %}

+

+ {% blocktrans count remaining=remaining %}You have {{ remaining }} recovery code left. Each one works only once.{% plural %}You have {{ remaining }} recovery codes left. Each one works only once.{% endblocktrans %} +

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

{% if error_message %}{{ error_message }}{% endif %}

+
+
+
+ + {% trans "Use another method" %} +
+
+
{% endblock %} diff --git a/django_mfa/templates/django_mfa/verify_totp.html b/django_mfa/templates/django_mfa/verify_totp.html index b670c89..8158156 100644 --- a/django_mfa/templates/django_mfa/verify_totp.html +++ b/django_mfa/templates/django_mfa/verify_totp.html @@ -1,21 +1,27 @@ {% extends base_template %} {% load i18n %} {% block content %} -

{% trans "Enter your authentication code" %}

-

{% trans "Enter the six-digit code from your authenticator app." %}

-
- {% csrf_token %} - -
-
-
- - {% if error_message %} -

{{ error_message }}

- {% endif %} -
-
- -
+
+

{% trans "Enter your code" %}

+

{% trans "Open your authenticator app and enter the six-digit code it shows." %}

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

{% if error_message %}{{ error_message }}{% endif %}

+
+
+
+ + {% trans "Use another method" %} +
+
+
{% endblock %} diff --git a/django_mfa/templates/django_mfa/verify_webauthn.html b/django_mfa/templates/django_mfa/verify_webauthn.html index ba5a9a7..015a224 100644 --- a/django_mfa/templates/django_mfa/verify_webauthn.html +++ b/django_mfa/templates/django_mfa/verify_webauthn.html @@ -1,26 +1,34 @@ {% extends base_template %} {% load i18n static webauthn_tags %} {% block content %} -

{% trans "Verify with your security key or passkey" %}

-

{% trans "Use your registered security key or passkey to finish signing in." %}

+
+

{% trans "Use your security key" %}

+

{% trans "Select the button below, then follow your browser's prompt to use your security key or passkey." %}

- + {% comment %}webauthn.js reveals this by setting .hidden = false when the + browser has no WebAuthn support. It MUST render with `hidden` set.{% endcomment %} + -{% if error_message %} -

{{ error_message }}

-{% endif %} -

+ {% if error_message %} +

{{ error_message }}

+ {% endif %} + {# Left empty on purpose: webauthn.js writes ceremony errors here. #} + -{% webauthn_options_script options "webauthn-options" %} + {% webauthn_options_script options "webauthn-options" %} -
- {% csrf_token %} - - - -
+
+ {% csrf_token %} + + +
+ + {% trans "Use another method" %} +
+
+
{% endblock %} diff --git a/django_mfa/tests/support/authenticator.py b/django_mfa/tests/support/authenticator.py index de28786..8b0ee14 100644 --- a/django_mfa/tests/support/authenticator.py +++ b/django_mfa/tests/support/authenticator.py @@ -18,7 +18,6 @@ from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ec - from fido2 import cbor # cbor2 is NOT installed; fido2 vendors its own from fido2.cose import ES256 from fido2.webauthn import AttestedCredentialData, AuthenticatorData diff --git a/django_mfa/tests/test_adapter_recovery.py b/django_mfa/tests/test_adapter_recovery.py index fd0c8ad..eed25c0 100644 --- a/django_mfa/tests/test_adapter_recovery.py +++ b/django_mfa/tests/test_adapter_recovery.py @@ -34,7 +34,7 @@ def test_generate_returns_plaintext_while_storing_hashes(self): """ codes = self.adapter.generate(self.user) auth = Authenticator.objects.get(user=self.user, type="recovery_codes") - for plaintext, stored in zip(codes, auth.data["codes"]): + for plaintext, stored in zip(codes, auth.data["codes"], strict=True): self.assertNotEqual(plaintext, stored) self.assertTrue(check_password(plaintext, stored)) diff --git a/django_mfa/tests/test_adapter_webauthn.py b/django_mfa/tests/test_adapter_webauthn.py index b410129..040f63a 100644 --- a/django_mfa/tests/test_adapter_webauthn.py +++ b/django_mfa/tests/test_adapter_webauthn.py @@ -4,7 +4,6 @@ from django.contrib.auth.models import User from django.test import RequestFactory, TestCase, override_settings - from fido2.server import Fido2Server from django_mfa.adapters.webauthn import WebAuthnAdapter diff --git a/django_mfa/tests/test_compat.py b/django_mfa/tests/test_compat.py index d0f6b71..5128a1f 100644 --- a/django_mfa/tests/test_compat.py +++ b/django_mfa/tests/test_compat.py @@ -8,6 +8,7 @@ def test_views_import_on_modern_django(self): def test_no_removed_symbols_referenced(self): import inspect + import django_mfa.views as views source = inspect.getsource(views) diff --git a/django_mfa/tests/test_conf.py b/django_mfa/tests/test_conf.py index 2890df8..8921827 100644 --- a/django_mfa/tests/test_conf.py +++ b/django_mfa/tests/test_conf.py @@ -18,7 +18,9 @@ def test_project_setting_wins(self): def test_unknown_setting_raises(self): with self.assertRaises(AttributeError): - mfa_settings.MFA_NOT_A_REAL_SETTING + # The bare attribute access IS the assertion -- evaluating it is + # what must raise. Binding it to a name would not change that. + mfa_settings.MFA_NOT_A_REAL_SETTING # noqa: B018 class ChecksTests(TestCase): diff --git a/django_mfa/tests/test_docs.py b/django_mfa/tests/test_docs.py new file mode 100644 index 0000000..a1858f2 --- /dev/null +++ b/django_mfa/tests/test_docs.py @@ -0,0 +1,154 @@ +"""Guard the Markdown docs against silent Sphinx misconfiguration. + +The docs were converted from reStructuredText to Markdown, which means Sphinx +only understands them via myst_parser, and MyST only understands some of the +syntax they use once the matching extension is switched on. + +The failure mode this exists for is specifically NOT a build error: with +`colon_fence` disabled, MyST does not complain about `:::{warning}` -- it +renders the literal text ":::{warning}" into the published page and reports +"build succeeded" with zero warnings. Watching the build output cannot catch +it. These tests read the sources instead, so they run in the ordinary suite +without Sphinx installed; CI additionally builds the docs and asserts the +admonitions become real admonition boxes. +""" + +import re +import unittest +from pathlib import Path + +DOCS = Path(__file__).resolve().parents[2] / "docs" + + +def _pages(): + return sorted(p for p in DOCS.glob("*.md")) + + +def _toctree_entries(): + """The document names actually listed in index.md's toctree blocks. + + Lines starting with ':' are directive options (:maxdepth:, :caption:), + not entries. + """ + index = (DOCS / "index.md").read_text() + entries = set() + for body in re.findall(r"```\{toctree\}\n(.*?)```", index, re.S): + for line in body.splitlines(): + line = line.strip() + if line and not line.startswith(":"): + entries.add(line) + return entries + + +def _conf_namespace(): + """Execute docs/conf.py and return the settings it actually defines. + + Deliberately NOT a text search of the file. The first version of these + tests asserted `"colon_fence" in conf_source`, which passed even with the + extension switched off -- because the explanatory comment above the setting + mentions "colon_fence". Reading the evaluated values is the only check that + distinguishes configuration from prose about configuration. + """ + namespace = {"__file__": str(DOCS / "conf.py")} + exec(compile((DOCS / "conf.py").read_text(), "conf.py", "exec"), namespace) + return namespace + + +class DocsSourceTests(unittest.TestCase): + def setUp(self): + self.conf = _conf_namespace() + + def test_docs_exist_and_are_markdown(self): + """The pages that are linked from elsewhere must not silently vanish. + + A subset check, not an equality one: adding a page should not require + editing this test (test_every_page_is_reachable_from_the_toctree + already covers a page being added and forgotten). Removing or + renaming one of these DOES break links in README.md, in other pages' + {doc} roles, or both -- so those are pinned. + """ + names = {p.name for p in _pages()} + expected = { + "index.md", "installation_setup.md", "mfa_flow.md", "settings.md", + "customizing.md", "recipes.md", "custom_factors.md", "api.md", + "security.md", "upgrading.md", "contributing.md", + } + self.assertEqual(expected - names, set(), + "a page other docs link to has gone missing") + self.assertEqual(list(DOCS.glob("*.rst")), [], + "docs are Markdown now; a stray .rst will be ignored " + "by the toctree and silently go unpublished") + + def test_every_setting_is_documented(self): + """Every name in conf.DEFAULTS appears in the settings reference. + + Moved here from test_sandbox_smoke.py, which despite its name only + ever tested this (its own docstring said so). It also read + installation_setup.md, which stopped being the settings reference + when that page was split -- so it would have kept passing against a + page that no longer documented anything. + """ + from django_mfa.conf import DEFAULTS + + reference = (DOCS / "settings.md").read_text() + undocumented = [name for name in DEFAULTS if name not in reference] + self.assertEqual(undocumented, [], + f"Undocumented settings: {undocumented}") + + def test_myst_parser_is_enabled(self): + """Without it Sphinx finds no documents at all -- it does not read .md.""" + self.assertIn("myst_parser", self.conf.get("extensions", [])) + + def test_colon_fence_enabled_if_any_page_uses_it(self): + users = [p.name for p in _pages() if ":::{" in p.read_text()] + if not users: + self.skipTest("no page uses ::: directives") + self.assertIn( + "colon_fence", self.conf.get("myst_enable_extensions", []), + f"{users} use ':::{{...}}' directives, but myst_enable_extensions " + f"does not include 'colon_fence' -- MyST will render the literal " + f"text ':::{{warning}}' into the page and still report success") + + def test_heading_anchors_enabled_if_any_page_links_to_one(self): + """In-page links like [System checks](#system-checks) only resolve when + myst_heading_anchors generates slugs; otherwise they are dead links + (these do at least warn at build time).""" + users = [p.name for p in _pages() if re.search(r"\]\(#[\w-]+\)", p.read_text())] + if not users: + self.skipTest("no page uses in-page anchor links") + self.assertGreaterEqual(self.conf.get("myst_heading_anchors", 0), 2) + + def test_every_page_is_reachable_from_the_toctree(self): + """A page missing from index.md's toctree builds fine and is simply + never linked -- it just quietly vanishes from the published docs. + + This parses the toctree bodies rather than searching index.md for the + page name. The substring version of this test passed with `security` + deleted from the toctree, because index.md's own prose says "security + keys and passkeys" -- the page name appearing *somewhere* on the page + says nothing about whether it is actually listed. + """ + entries = _toctree_entries() + self.assertTrue(entries, "index.md has no toctree entries at all") + for page in _pages(): + if page.name == "index.md": + continue + with self.subTest(page=page.name): + self.assertIn(page.stem, entries) + + def test_no_leftover_rst_role_syntax(self): + """`:doc:`x`` is reStructuredText; in Markdown it renders as literal + text. The MyST spelling is {doc}`x`. + """ + for page in _pages(): + with self.subTest(page=page.name): + self.assertNotIn(":doc:`", page.read_text()) + + def test_readme_carries_no_sphinx_only_syntax(self): + """README.md is rendered by PyPI and GitHub, neither of which is + Sphinx. A {doc} role or a ```{toctree} there does not fail the build -- + it just renders as noise on the project page. + """ + readme = (DOCS.parent / "README.md").read_text() + for needle in ("{doc}`", "{ref}`", "```{toctree}", ":::{"): + self.assertNotIn(needle, readme) diff --git a/django_mfa/tests/test_enforcement.py b/django_mfa/tests/test_enforcement.py index bc9b2cb..e7dcafe 100644 --- a/django_mfa/tests/test_enforcement.py +++ b/django_mfa/tests/test_enforcement.py @@ -81,7 +81,7 @@ def _verify_and_get_cookie(self): response = self.client.post( reverse("mfa:verify_factor", args=["totp"]), {"code": totp_mod.TOTP(self.secret).now()}) - cookie_name = "RMB_%d" % self.user.pk + cookie_name = f"RMB_{self.user.pk}" self.assertIn(cookie_name, response.cookies) return cookie_name, response.cookies[cookie_name].value @@ -141,7 +141,7 @@ def test_no_cookie_is_set_on_successful_verification(self): response = self.client.post( reverse("mfa:verify_factor", args=["totp"]), {"code": totp_mod.TOTP(self.secret).now()}) - self.assertNotIn("RMB_%d" % self.user.pk, response.cookies) + self.assertNotIn(f"RMB_{self.user.pk}", response.cookies) def test_every_login_is_still_challenged(self): self.client.login(username="a@example.com", password="pw") @@ -207,12 +207,12 @@ def test_factorless_user_can_still_reach_enroll_after_visiting_verify(self): response = self.client.get(reverse("mfa:enroll_factor", args=["totp"])) self.assertEqual(response.status_code, 200) - def test_factorless_user_can_still_reach_security_settings_after_visiting_verify(self): + def test_factorless_user_still_reaches_security_settings_after_verify(self): self.client.get(reverse("mfa:verify_factor", args=["totp"])) response = self.client.get(reverse("mfa:security_settings")) self.assertEqual(response.status_code, 200) - def test_user_with_a_primary_factor_is_still_stamped_pending_on_a_stale_session(self): + def test_user_with_a_primary_factor_is_stamped_pending_on_stale_session(self): """Regression guard: the new no-factors guard must not weaken the existing behaviour for a user who DOES have a primary factor -- a stale/pre-existing session reaching this view directly (without diff --git a/django_mfa/tests/test_migrations.py b/django_mfa/tests/test_migrations.py index e929653..4833014 100644 --- a/django_mfa/tests/test_migrations.py +++ b/django_mfa/tests/test_migrations.py @@ -113,7 +113,8 @@ def test_reverse_removes_copies_and_leaves_legacy_rows_intact(self): otp = self.UserOTP.objects.get(user_id=self.user.pk) self.assertEqual(otp.secret_key, "JBSWY3DPEHPK3PXP") self.assertEqual( - sorted(self.UserRecoveryCodes.objects.values_list("secret_code", flat=True)), + sorted(self.UserRecoveryCodes.objects.values_list( + "secret_code", flat=True)), ["aaaaaaaaaa", "bbbbbbbbbb"], ) diff --git a/django_mfa/tests/test_models.py b/django_mfa/tests/test_models.py index 617f901..dd4a15f 100644 --- a/django_mfa/tests/test_models.py +++ b/django_mfa/tests/test_models.py @@ -31,7 +31,8 @@ def test_record_usage_sets_last_used_at(self): def test_for_user_returns_only_that_users_authenticators(self): other = User.objects.create_user("b@example.com", password="pw") - mine = Authenticator.objects.create(user=self.user, type=Authenticator.Type.TOTP) + mine = Authenticator.objects.create( + user=self.user, type=Authenticator.Type.TOTP) Authenticator.objects.create(user=other, type=Authenticator.Type.TOTP) self.assertEqual(list(Authenticator.objects.for_user(self.user)), [mine]) diff --git a/django_mfa/tests/test_packaging.py b/django_mfa/tests/test_packaging.py new file mode 100644 index 0000000..d85bc2e --- /dev/null +++ b/django_mfa/tests/test_packaging.py @@ -0,0 +1,111 @@ +"""Guard the built distribution, not the source tree. + +Every other test in this suite runs from a source checkout, where every +subpackage is present on disk no matter what the packaging metadata says. +That is exactly why the packaging bug these tests exist to prevent survived +21 implementation tasks, two code reviews, and a real-browser validation +pass: the old setup.py hardcoded + + packages=["django_mfa", "django_mfa.templatetags", "django_mfa.migrations"] + +which predated django_mfa/adapters/ and django_mfa/views/. The built wheel +contained neither, so `pip install django-mfa` produced a package that raised +ImportError from DjangoMfaAppConfig.ready() before a host project could serve +a single request -- while this suite stayed green. + +These tests build a real wheel and inspect it. They are skipped (never +failed) when the build backend isn't available, so a contributor without +`build` installed doesn't get a spurious failure. +""" + +import subprocess +import sys +import tempfile +import unittest +import zipfile +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _build_wheel(outdir): + """Build a wheel into ``outdir``; return its path, or None if unbuildable.""" + try: + subprocess.run( + [sys.executable, "-m", "build", "--wheel", "--outdir", str(outdir), + str(REPO_ROOT)], + check=True, capture_output=True, timeout=600, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, + FileNotFoundError): + return None + wheels = list(Path(outdir).glob("*.whl")) + return wheels[0] if wheels else None + + +def _on_disk_packages(): + """Every importable package under django_mfa/, as dotted names.""" + packages = set() + for init in (REPO_ROOT / "django_mfa").rglob("__init__.py"): + packages.add(".".join(init.parent.relative_to(REPO_ROOT).parts)) + return packages + + +@unittest.skipUnless( + __import__("importlib.util", fromlist=["util"]).find_spec("build"), + "the `build` package is not installed", +) +class WheelContentsTests(unittest.TestCase): + """Build the wheel once for the whole class -- it is the slow part.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._tmp = tempfile.TemporaryDirectory() + cls.wheel = _build_wheel(cls._tmp.name) + if cls.wheel is None: + cls._tmp.cleanup() + raise unittest.SkipTest("wheel build failed in this environment") + cls.names = zipfile.ZipFile(cls.wheel).namelist() + + @classmethod + def tearDownClass(cls): + cls._tmp.cleanup() + super().tearDownClass() + + def test_every_package_on_disk_ships_in_the_wheel(self): + """The regression guard. A new subpackage added under django_mfa/ is + shipped automatically by hatchling's `packages = ["django_mfa"]`; if + someone reintroduces an explicit per-package list and forgets one, + this fails naming the missing package. + """ + shipped = { + ".".join(Path(n).parent.parts) + for n in self.names if n.endswith("/__init__.py") + } + missing = _on_disk_packages() - shipped + self.assertEqual( + missing, set(), + f"these packages exist on disk but are NOT in the built wheel: " + f"{sorted(missing)} -- a host project installing from PyPI would " + f"get an ImportError, exactly as it did when setup.py's hardcoded " + f"packages=[...] list omitted adapters/ and views/") + + def test_templates_and_static_files_ship(self): + """These live inside the package but are not .py files, so they ride + on the build backend's file inclusion rather than package discovery. + MANIFEST.in used to declare them; hatchling includes them because they + sit under the package root. + """ + for needle in ("django_mfa/templates/django_mfa/security.html", + "django_mfa/static/django_mfa/webauthn.js", + "django_mfa/static/django_mfa/style.css"): + self.assertIn(needle, self.names, f"{needle} missing from the wheel") + + def test_migrations_ship(self): + """A Django app whose migrations don't ship leaves `migrate` with + nothing to apply and no error -- the tables simply never exist. + """ + migrations = [n for n in self.names + if n.startswith("django_mfa/migrations/") and n.endswith(".py")] + self.assertGreaterEqual(len(migrations), 8) # 0001..0007 + __init__ diff --git a/django_mfa/tests/test_passwordless.py b/django_mfa/tests/test_passwordless.py index c91a7f9..ebc614b 100644 --- a/django_mfa/tests/test_passwordless.py +++ b/django_mfa/tests/test_passwordless.py @@ -32,14 +32,12 @@ from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ec - from django.contrib.auth import login as auth_login from django.contrib.auth.models import User from django.contrib.sessions.backends.db import SessionStore from django.http import HttpResponse from django.test import Client, RequestFactory, TestCase, override_settings from django.urls import reverse - from fido2.webauthn import AuthenticatorData from django_mfa.backends import user_from_handle, user_handle_for @@ -221,7 +219,8 @@ def test_every_named_failure_mode_returns_a_byte_identical_response(self): # is (falsely) tagged with this user's real handle. impostor = SoftwareAuthenticator(origin="https://testserver", rp_id="testserver") - impostor.create({"publicKey": {"challenge": "AAAA"}}) # mint local key material only + # mint local key material only + impostor.create({"publicKey": {"challenge": "AAAA"}}) begin = self.client.get(reverse("mfa:passkey_begin")) assertion = impostor.get(json.loads(begin.json()["options"]), user_handle=self._handle_bytes(self.user)) @@ -277,7 +276,8 @@ def test_forged_user_handle_does_not_authenticate_as_the_claimed_user(self): victim = User.objects.create_user("victim@example.com", password="pw") begin = self.client.get(reverse("mfa:passkey_begin")) assertion = self.device.get(json.loads(begin.json()["options"])) - forged = urlsafe_b64encode(self._handle_bytes(victim)).rstrip(b"=").decode("ascii") + forged = urlsafe_b64encode( + self._handle_bytes(victim)).rstrip(b"=").decode("ascii") assertion["response"]["userHandle"] = forged response = self.client.post(reverse("mfa:passkey_complete"), @@ -301,7 +301,8 @@ def test_regressed_sign_counter_is_rejected_on_the_passwordless_path(self): auth.save() begin = self.client.get(reverse("mfa:passkey_begin")) - assertion = self.device.get(json.loads(begin.json()["options"])) # counter -> 2, still < 99 + # counter -> 2, still < 99 + assertion = self.device.get(json.loads(begin.json()["options"])) response = self.client.post(reverse("mfa:passkey_complete"), {"credential": json.dumps(assertion)}) @@ -433,7 +434,8 @@ def test_up_only_passkey_login_reaches_full_verification_without_a_loop(self): # Step 1: passwordless login with a UP-only assertion from the # enrolled device. _UpOnlyAuthenticator is a distinct Python object # from self.device sharing the same key material (see - # PasswordlessLoginTests.test_up_only_assertion_authenticates_but_leaves_second_factor_pending + # PasswordlessLoginTests. + # test_up_only_assertion_authenticates_but_leaves_second_factor_pending # above for why this class exists) -- its sign_count starts wherever # self.device's does and is copied back afterwards so the ordinary # get() in step 4 doesn't trip clone detection against a counter the diff --git a/django_mfa/tests/test_sandbox_smoke.py b/django_mfa/tests/test_sandbox_smoke.py deleted file mode 100644 index c581ee2..0000000 --- a/django_mfa/tests/test_sandbox_smoke.py +++ /dev/null @@ -1,20 +0,0 @@ -# django_mfa/tests/test_sandbox_smoke.py -# -# Not actually about the sandbox app (that has no automated test coverage -- -# see task-19-report.md for why a Django TestCase can't drive `manage.py -# runserver` in a meaningful way). This guards the one thing task 19 can -# reliably pin with a unit test: every setting `conf.DEFAULTS` exposes has a -# matching entry in the documentation, so a future setting can never ship -# undocumented the way the FIDO2 block briefly did during this task. -from django.test import TestCase - - -class SettingsDocumentationTests(TestCase): - def test_every_setting_is_documented(self): - from django_mfa.conf import DEFAULTS - - with open("docs/installation_setup.rst") as handle: - docs = handle.read() - - undocumented = [name for name in DEFAULTS if name not in docs] - self.assertEqual(undocumented, [], f"Undocumented settings: {undocumented}") diff --git a/django_mfa/tests/test_support_authenticator.py b/django_mfa/tests/test_support_authenticator.py index 8e5b346..4bc9a99 100644 --- a/django_mfa/tests/test_support_authenticator.py +++ b/django_mfa/tests/test_support_authenticator.py @@ -1,7 +1,6 @@ import base64 from django.test import TestCase - from fido2.server import Fido2Server from fido2.webauthn import ( AuthenticationResponse, diff --git a/django_mfa/tests/test_templates.py b/django_mfa/tests/test_templates.py new file mode 100644 index 0000000..8464e12 --- /dev/null +++ b/django_mfa/tests/test_templates.py @@ -0,0 +1,116 @@ +"""Guards on the shipped templates that only a browser would otherwise catch. + +Every failure pinned here renders a perfectly valid HTTP 200 that no view test +would notice, and looks broken (or silently does nothing) to a real user. +""" +import unittest +from pathlib import Path + +TEMPLATES = Path(__file__).resolve().parents[1] / "templates" / "django_mfa" + + +def _templates(): + return sorted(TEMPLATES.glob("*.html")) + + +class TemplateCommentTests(unittest.TestCase): + def test_no_multiline_hash_comments(self): + """`{# ... #}` is SINGLE-LINE ONLY in the Django template language. + + Spanning one across two lines does not raise, and does not comment + anything out -- Django emits the whole thing as literal text. This was + shipped in base.html and printed + + {# These pages are part of an authentication flow ... #} + + as the first visible line of every single MFA page. All 276 tests + passed; it was found by loading the page in a browser. + + Use {% comment %}...{% endcomment %} for anything multi-line. + """ + offenders = [] + for path in _templates(): + for line_no, line in enumerate(path.read_text().splitlines(), 1): + # An opening {# whose #} is not on the same line. + if "{#" in line and "#}" not in line[line.index("{#"):]: + offenders.append(f"{path.name}:{line_no}") + self.assertEqual( + offenders, [], + f"multi-line {{# #}} comments render as visible page text: " + f"{offenders}") + + +class WebAuthnTemplateContractTests(unittest.TestCase): + """webauthn.js finds its elements by ID and its payload field by name. + + Dropping any of them -- easy to do while restyling -- does not error. + The script simply never binds, and the button becomes inert: no ceremony, + no message, nothing in the console. The page still returns 200 and still + looks right in a screenshot. + """ + + CONTRACT = { + "enroll_webauthn.html": "create", + "verify_webauthn.html": "get", + } + + def test_required_hooks_present(self): + for name in self.CONTRACT: + source = (TEMPLATES / name).read_text() + for hook in ('id="webauthn-form"', 'id="webauthn-start"', + 'id="webauthn-unsupported"', 'id="webauthn-error"', + 'name="credential"'): + with self.subTest(template=name, hook=hook): + self.assertIn(hook, source) + + def test_form_declares_the_right_ceremony_mode(self): + """data-mode picks registration vs authentication. Swapped, the page + runs the wrong ceremony against the right options -- and fails in a + way that reads as a broken authenticator. + """ + for name, mode in self.CONTRACT.items(): + with self.subTest(template=name): + self.assertIn(f'data-mode="{mode}"', + (TEMPLATES / name).read_text()) + + def test_options_use_the_dedicated_tag_not_json_script(self): + """`options` is already a JSON string. Django's json_script filter + would encode it twice, so the browser's JSON.parse() returns a string + and options.publicKey is undefined. Server-side tests can't see it. + """ + for name in self.CONTRACT: + source = (TEMPLATES / name).read_text() + with self.subTest(template=name): + self.assertIn("webauthn_options_script", source) + self.assertNotIn("|json_script", source) + + +class BaseTemplateTests(unittest.TestCase): + def test_pages_extend_the_configured_base(self): + """Every page must extend `base_template` (the MFA_BASE_TEMPLATE the + views pass in), not the built-in path directly -- hardcoding it makes + the setting silently do nothing for that page. + """ + for path in _templates(): + if path.name == "base.html": + continue + with self.subTest(template=path.name): + first = path.read_text().splitlines()[0].strip() + self.assertEqual(first, "{% extends base_template %}") + + def test_viewport_meta_present(self): + """Without it, mobile browsers render at ~980px and scale down: the + pages are then technically responsive and practically unreadable. + """ + source = (TEMPLATES / "base.html").read_text() + self.assertIn('name="viewport"', source) + self.assertIn("width=device-width", source) + + def test_stylesheet_is_namespaced(self): + """static/style.css sits at the ROOT of the static namespace, where a + host project's own style.css collides with it -- Django's + app-directories finder resolves the clash by INSTALLED_APPS order and + silently serves one instead of the other. + """ + source = (TEMPLATES / "base.html").read_text() + self.assertIn("django_mfa/style.css", source) diff --git a/django_mfa/tests/test_views.py b/django_mfa/tests/test_views.py index ae91ac1..4a2d300 100644 --- a/django_mfa/tests/test_views.py +++ b/django_mfa/tests/test_views.py @@ -1040,7 +1040,7 @@ class HiddenAttributeStylesheetTests(TestCase): def setUpClass(cls): super().setUpClass() css_path = os.path.join( - os.path.dirname(__file__), "..", "static", "style.css") + os.path.dirname(__file__), "..", "static", "django_mfa", "style.css") with open(css_path) as fh: cls.raw = fh.read() # Strip comments before matching: the rule is explained by a comment @@ -1061,12 +1061,29 @@ def test_hidden_attribute_rule_is_present_and_wins(self): # `display` still wins, which is the exact bug this guards. self.assertIn("!important", body) - def test_hidden_rule_precedes_any_display_setting_class_rule(self): + def test_hidden_rule_precedes_every_display_setting_class_rule(self): """Order matters for equal specificity, and `!important` on the UA- level rule is what actually settles it -- but keeping the rule ahead of the class rules it protects makes the intent legible to whoever edits this file next. + + Checks EVERY class rule that sets `display`, not just `.help-block`. + The earlier version named that one selector, so it kept passing as + new display-setting classes were added after it -- including + `.mfa-alert`, which is on the very banner this guards. """ hidden_at = self.source.index("[hidden]") - help_block_at = self.source.index(".help-block") - self.assertLess(hidden_at, help_block_at) + offenders = [ + (m.start(), m.group(1).strip()) + for m in re.finditer(r"(\.[\w-]+[^{}]*)\{([^}]*)\}", self.source) + if re.search(r"(^|;)\s*display\s*:", m.group(2)) + ] + self.assertTrue( + offenders, + "expected at least one class rule setting `display` -- if none " + "remain, this guard is no longer testing anything") + too_early = [name for at, name in offenders if at < hidden_at] + self.assertEqual( + too_early, [], + f"these class rules set `display` before the [hidden] rule: " + f"{too_early}") diff --git a/django_mfa/totp.py b/django_mfa/totp.py index 2a7a4dd..c549be6 100644 --- a/django_mfa/totp.py +++ b/django_mfa/totp.py @@ -1,4 +1,3 @@ -from __future__ import print_function, unicode_literals, division, absolute_import import datetime import time @@ -14,7 +13,7 @@ def __init__(self, *args, **kwargs): for OTP This defaults to 30 which is standard. """ self.interval = kwargs.pop('interval', 30) - super(TOTP, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def at(self, for_time, counter_offset=0): """ @@ -38,7 +37,8 @@ def verify(self, otp, for_time=None, valid_window=0): """ Verifies the OTP passed in against the current time OTP @param [String/Integer] otp the OTP to check against - @param [Integer] valid_window extends the validity to this many counter ticks before and after the current one + @param [Integer] valid_window extends the validity to this many + counter ticks before and after the current one """ if for_time is None: for_time = datetime.datetime.now() diff --git a/django_mfa/utils.py b/django_mfa/utils.py index 8c3a25a..61f7ddb 100644 --- a/django_mfa/utils.py +++ b/django_mfa/utils.py @@ -1,6 +1,6 @@ -from __future__ import print_function, unicode_literals, division, absolute_import import unicodedata + try: from itertools import izip_longest except ImportError: @@ -36,23 +36,19 @@ def build_uri(secret, name, initial_count=None, issuer_name=None): is_initial_count_present = (initial_count is not None) otp_type = 'hotp' if is_initial_count_present else 'totp' - base = 'otpauth://%s/' % otp_type + base = f'otpauth://{otp_type}/' if issuer_name: issuer_name = quote(issuer_name) - base += '%s:' % issuer_name + base += f'{issuer_name}:' - uri = '%(base)s%(name)s?secret=%(secret)s' % { - 'name': quote(name, safe='@'), - 'secret': secret, - 'base': base, - } + uri = f"{base}{quote(name, safe='@')}?secret={secret}" if is_initial_count_present: - uri += '&counter=%s' % initial_count + uri += f'&counter={initial_count}' if issuer_name: - uri += '&issuer=%s' % issuer_name + uri += f'&issuer={issuer_name}' return uri @@ -86,10 +82,12 @@ def strings_equal(s1, s2): still reveal to a timing attack whether the strings are the same length. """ - try: - s1 = unicodedata.normalize('NFKC', str(s1)) - s2 = unicodedata.normalize('NFKC', str(s2)) - except: - s1 = unicodedata.normalize('NFKC', unicode(s1)) - s2 = unicodedata.normalize('NFKC', unicode(s2)) + # This used to be a try/except around str() whose handler called unicode() + # -- a Python 2 fallback that, on Python 3, raises NameError instead of + # doing anything. Since the except was bare, ANY failure in the try block + # (including KeyboardInterrupt) landed in a handler that could only ever + # raise NameError, on the timing-safe comparison used for recovery codes. + # str() is always available here, so there is nothing left to fall back to. + s1 = unicodedata.normalize('NFKC', str(s1)) + s2 = unicodedata.normalize('NFKC', str(s2)) return compare_digest(s1, s2) diff --git a/django_mfa/views/enroll.py b/django_mfa/views/enroll.py index d655d8f..3835ac9 100644 --- a/django_mfa/views/enroll.py +++ b/django_mfa/views/enroll.py @@ -6,7 +6,6 @@ from django_mfa import session 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.verify import GENERIC_ERROR, _adapter_or_404 diff --git a/django_mfa/views/manage.py b/django_mfa/views/manage.py index 682d475..a285b96 100644 --- a/django_mfa/views/manage.py +++ b/django_mfa/views/manage.py @@ -71,7 +71,7 @@ def manage_factors(request): # ValueError/TypeError: int() rejects it outright. OverflowError: it # parses but exceeds what the column can hold, which only surfaces # when the query executes. - raise Http404("No such authenticator") + raise Http404("No such authenticator") from None if (authenticator.type == Authenticator.Type.WEBAUTHN and mfa_settings.MFA_OWNED_BY_ENTERPRISE): return HttpResponseForbidden( diff --git a/django_mfa/views/verify.py b/django_mfa/views/verify.py index 09e0438..6a2c707 100644 --- a/django_mfa/views/verify.py +++ b/django_mfa/views/verify.py @@ -7,7 +7,6 @@ from django.http import Http404, HttpResponseNotAllowed, JsonResponse from django.shortcuts import redirect, render, resolve_url from django.utils.http import url_has_allowed_host_and_scheme - from fido2.webauthn import AuthenticationResponse from django_mfa import ratelimit, session @@ -51,7 +50,7 @@ def _adapter_or_404(factor_type): try: return registry.get(factor_type) except KeyError: - raise Http404(f"Unknown factor {factor_type!r}") + raise Http404(f"Unknown factor {factor_type!r}") from None def _safe_next(request): @@ -313,7 +312,8 @@ def update_rmb_cookie(request, response): # better not to reveal the username. Revealing the number seems harmless cookie_name = MFA_COOKIE_PREFIX + str(request.user.pk) cookie_salt = _generate_cookie_salt(request.user) - response.set_signed_cookie(cookie_name, True, salt=cookie_salt, max_age=remember_days * 24 * 3600, + response.set_signed_cookie(cookie_name, True, salt=cookie_salt, + max_age=remember_days * 24 * 3600, secure=(not settings.DEBUG), httponly=True) return response @@ -332,7 +332,8 @@ def verify_rmb_cookie(request): return False cookie_value = request.get_signed_cookie( cookie_name, False, max_age=max_cookie_age, salt=cookie_salt) - # if the cookie value is True and the signature is good than the browser can be trusted + # if the cookie value is True and the signature is good then the browser + # can be trusted return cookie_value diff --git a/docs/Makefile b/docs/Makefile index 5bf536d..64d1a24 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,192 +1,39 @@ -# Makefile for Sphinx documentation +# Minimal Sphinx makefile. # +# Replaces a 190-line sphinx-quickstart dump that carried ~20 targets nobody +# ran (qthelp, devhelp, applehelp, texinfo, latexpdfja, pseudoxml...) and set +# BUILDDIR = build, while conf.py excludes `_build` and both CI and Read the +# Docs build into `_build`. The stale target was the one people actually hit. +# +# You do not need Sphinx on your PATH: `uv run` provides it from the `docs` +# dependency group. +# +# make html build into _build/html +# make linkcheck verify every external link still resolves +# make clean remove _build -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = -BUILDDIR = build - -# User-friendly check for sphinx-build -ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) -$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) -endif +SPHINXBUILD ?= uv run --group docs sphinx-build +SOURCEDIR = . +BUILDDIR = _build -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# -W turns warnings into errors, matching CI and .readthedocs.yaml, so a dead +# cross-reference fails here rather than shipping. +SPHINXOPTS ?= -W -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext +.PHONY: help html linkcheck clean help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " applehelp to make an Apple Help Book" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " xml to make Docutils-native XML files" - @echo " pseudoxml to make pseudoxml-XML files for display purposes" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - @echo " coverage to run coverage check of the documentation (if enabled)" - -clean: - rm -rf $(BUILDDIR)/* + @echo "make html build the HTML docs into $(BUILDDIR)/html" + @echo "make linkcheck check external links" + @echo "make clean remove $(BUILDDIR)" html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/a.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/a.qhc" - -applehelp: - $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp - @echo - @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." - @echo "N.B. You won't be able to view it unless you put it in" \ - "~/Library/Documentation/Help or install it in your application" \ - "bundle." - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/a" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/a" - @echo "# devhelp" - -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + $(SPHINXBUILD) $(SPHINXOPTS) -b html $(SOURCEDIR) $(BUILDDIR)/html @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -latexpdfja: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through platex and dvipdfmx..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." + @echo "Done. Open $(BUILDDIR)/html/index.html" linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." + $(SPHINXBUILD) -b linkcheck $(SOURCEDIR) $(BUILDDIR)/linkcheck -coverage: - $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage - @echo "Testing of coverage in the sources finished, look at the " \ - "results in $(BUILDDIR)/coverage/python.txt." - -xml: - $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml - @echo - @echo "Build finished. The XML files are in $(BUILDDIR)/xml." - -pseudoxml: - $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml - @echo - @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." +clean: + rm -rf $(BUILDDIR) diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..f6cd41f --- /dev/null +++ b/docs/api.md @@ -0,0 +1,164 @@ +# Public API + +The Python surface a host project can depend on. Anything not listed here — the +adapters' internals, `django_mfa.otp`, `django_mfa.totp`, the view functions +themselves — is an implementation detail and may change without a deprecation cycle. + +## `django_mfa.session` + +Read and write the MFA state on a session. **Always use these** rather than touching +`request.session["mfa"]`: the dict's shape is not a public contract and has changed +once already. + +| Function | Purpose | +|---|---| +| `is_verified(request)` | `True` if this session has completed a second factor. The one you'll actually use. | +| `is_pending(request)` | `True` if the session is awaiting a second factor. | +| `start_pending(request)` | Mark the session as awaiting verification. | +| `mark_verified(request, method)` | Mark it satisfied by `method` (a factor type string). | +| `reset(request)` | Remove MFA state entirely. | + +Example: + + from django_mfa import session + + if session.is_verified(request): + ... + +You rarely need the writers: the `user_logged_in` receiver calls `start_pending()` +and the verify view calls `mark_verified()`. + +## `django_mfa.registry` + +The registry decides which factors exist and what each user holds. Import the +singleton — do not construct your own: + + from django_mfa.registry import registry + +| Method | Returns | +|---|---| +| `primary_enabled_for(user)` | Adapters that mean this user **is protected**. Excludes recovery codes. **This is the predicate for "does this user have MFA".** | +| `enabled_for(user)` | Adapters this user can **verify with right now**. Includes recovery codes. This is what to offer on a challenge screen. | +| `available_for(user)` | Adapters the user could still **add**. Excludes singletons they already hold and factors that aren't enrolled at all. | +| `all()` | Every registered adapter. | +| `get(type)` | One adapter by type string. Raises `KeyError` if absent. | +| `register(adapter)` | Add an adapter instance. Raises `ValueError` on a duplicate type. | +| `unregister(type)` | Remove one. Raises `KeyError` if it wasn't registered. | + +The distinction between the first two is the one that matters. A user holding only +recovery codes appears in `enabled_for()` but not `primary_enabled_for()`, because +recovery codes are exhaustible and must never be someone's sole factor. Deciding +whether to challenge from `enabled_for()` would challenge users you cannot protect; +deciding what to offer from `primary_enabled_for()` would hide the recovery option +from exactly the people who need it. + +`Adapter` is the base class for factor types — see {doc}`custom_factors`. + +## `django_mfa.models.Authenticator` + +One row per enrolled factor. + +| Field | Notes | +|---|---| +| `user` | FK to `AUTH_USER_MODEL`, related name `mfa_authenticators`. | +| `type` | `"totp"`, `"webauthn"`, or `"recovery_codes"` — see `Authenticator.Type`. | +| `name` | User-supplied label. WebAuthn only, so several keys are distinguishable. | +| `data` | `JSONField` of factor-specific state. Shape is owned by the adapter; don't reach into it. | +| `created_at`, `last_used_at` | Timestamps. `last_used_at` is `None` until first use. | + +`record_usage()` stamps `last_used_at`. Adapters call it on successful verification. + +A database constraint (`mfa_one_singleton_authenticator_per_user`) allows at most one +`totp` and one `recovery_codes` row per user; WebAuthn is unlimited. + +To remove a user's MFA — the administrative recovery path: + + Authenticator.objects.filter(user=user).delete() + +## `django_mfa.conf.settings` + +Resolved settings with defaults applied: + + from django_mfa.conf import settings as mfa_settings + mfa_settings.MFA_VERIFY_RATE_LIMIT # "5/5m" unless overridden + +Asking for a name that isn't a django-mfa setting raises `AttributeError`, so typos +surface immediately. See {doc}`settings` for the full list. + +## `django_mfa.utils` + +| Function | Purpose | +|---|---| +| `strings_equal(a, b)` | Timing-safe comparison. Normalizes to NFKC, then `hmac.compare_digest`. Use for any secret comparison in a custom factor. | +| `build_uri(secret, name, initial_count=None, issuer_name=None)` | Build an `otpauth://` provisioning URI. | + +## `django_mfa.crypto` + +| Function | Purpose | +|---|---| +| `encrypt(value)` | Encrypt with the first `MFA_SECRET_ENCRYPTION_KEYS` entry. Returns `value` unchanged when the setting is unset. | +| `decrypt(value)` | Decrypt, trying every configured key. Passes through values stored before encryption was enabled. Raises `BadSignature` if no key works. | + +Both are no-ops until a host project opts in, so a custom factor can use them +unconditionally and gain encryption the day someone sets the keys. + +## `django_mfa.handles` + +WebAuthn user handles — opaque, stored, `SECRET_KEY`-independent. + +| Function | Purpose | +|---|---| +| `user_handle_for(user)` | The user's stable handle, creating it on first call. Idempotent. | +| `user_from_handle(handle)` | Resolve a handle back to a user, or `None`. Never raises — input is untrusted. | + +## `django_mfa.quicklogin` + +For a login page that offers a passkey to a returning visitor. Requires +`MFA_QUICKLOGIN = True`. + +| Name | Purpose | +|---|---| +| `COOKIE_NAME` | `"mfa_quicklogin"`. | +| `user_from_hint(value)` | Resolve the cookie to a user, or `None`. Never raises. | +| `set_hint(response, user, secure)` | Attach the cookie. No-op when the setting is off. | +| `clear_hint(response)` | Remove it. Unconditional, so a stale cookie is cleaned up even after the feature is switched off. | + +The cookie is a **UX hint, never a credential** — it identifies whose passkey prompt +to show and authenticates nobody. See {doc}`recipes`. + +## `django_mfa.backends.WebAuthnBackend` + +Required in `AUTHENTICATION_BACKENDS` for passwordless login. It performs no +cryptography: the view validates the assertion first, and the backend only returns +the already-resolved user through Django's standard contract. + +`authenticate()` deliberately ignores `username`/`password` and returns a user only +when handed one explicitly as `mfa_user`, so another backend's call passing +credentials through the chain can never authenticate anyone here. + +Omitting this backend breaks passkey login **silently, one request later** — +`django_mfa.E003` exists to catch that at startup. + +## `django_mfa.middleware.MfaMiddleware` + +Redirects authenticated-but-unverified requests to the picker. Its exempt set is +derived from the registry (the picker, plus each factor's verify page) unioned with +`MFA_EXEMPT_PATHS`. Subclass and override `process_request` to carve out a path +prefix — see {doc}`recipes`. + +## `django_mfa.ratelimit` + +Applied for you by the verify view; documented because a custom factor's tests may +need to reset it. + +| Function | Purpose | +|---|---| +| `check(user, factor_type)` | `True` if another attempt is allowed. | +| `record_failure(user, factor_type)` | Count a failure. | +| `clear(user, factor_type)` | Reset the counter, as a success does. | + +## Signals + +django-mfa sends no signals of its own. It **receives** `user_logged_in` (to stamp +the session pending) and `user_logged_out` (to clear the quicklogin hint). To react +to enrollment or verification, use `post_save` on `Authenticator`. diff --git a/docs/conf.py b/docs/conf.py index 375975a..1f9fe8c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,579 +1,64 @@ -# -*- coding: utf-8 -*- -# -# pietrack documentation build configuration file, created by -# sphinx-quickstart on Wed Jul 29 18:10:32 2015. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -import sys -import os -import shlex +"""Sphinx configuration. -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) +Replaces a 579-line sphinx-quickstart dump in which the ENTIRE config block +appeared twice (so the second copy silently won), 503 of those lines were +commented-out boilerplate, and `version`/`release` still read "2.1" long +after the package reached 4.0.0a1. -# -- General configuration ------------------------------------------------ +The version is now read from the installed package metadata, so it cannot +drift from pyproject.toml again. +""" -# If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +from importlib.metadata import PackageNotFoundError, version as _version -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - 'sphinx.ext.autodoc', -] +project = "django-mfa" +author = "Micro Pyramid" +copyright = f"2016-2026, {author}" # noqa: A001 -- Sphinx requires this name -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +try: + release = _version("django-mfa") +except PackageNotFoundError: # docs built from a source checkout + release = "0.0.0.dev0" +version = release -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# source_suffix = ['.rst', '.md'] -source_suffix = '.rst' - -# The encoding of source files. -#source_encoding = 'utf-8-sig' - -# The master toctree document. -master_doc = 'index' - -# General information about the project. -project = u'django-mfa' -copyright = u'2016, Micro Pyramid' -author = u'Micro Pyramid' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. +# The docs are Markdown, not reStructuredText. Sphinx cannot read Markdown on +# its own -- myst_parser is what registers the .md suffix and the MyST syntax +# the pages use ({doc} roles for cross-references, ```{toctree} / :::{warning} +# fenced directives). Without it, `sphinx-build` finds no documents at all. # -# The short X.Y version. -version = '2.1' -# The full version, including alpha/beta/rc tags. -release = '2.1' - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -#today = '' -# Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -exclude_patterns = [] - -# The reST default role (used for this markup: `text`) to use for all -# documents. -#default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -#add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -#show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = False - - -# -- Options for HTML output ---------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -import sphinx_rtd_theme +# sphinx.ext.autodoc used to be listed here and was never used by a single +# page -- no automodule/autoclass/autofunction directive existed anywhere. It +# is deliberately not re-added: the public API is documented by hand in +# api.md, so the docs describe the surface host projects should actually +# depend on rather than everything that happens to be importable. Adding +# autodoc back would also mean running django.setup() in this file, since +# importing django_mfa.models without configured settings raises +# ImproperlyConfigured. +extensions = ["myst_parser"] + +# Generate anchor slugs for headings down to level 3, so in-page links like +# [System checks](#system-checks) resolve. The pages carry several of these, +# converted from reStructuredText internal references; without this they build +# as "cross-reference target not found" warnings and render as dead links. +myst_heading_anchors = 3 + +# colon_fence enables `:::{warning}` ... `:::` directives. Without it MyST does +# NOT error -- it renders the literal text ":::{warning}" into the page and the +# build still reports success, so this cannot be caught by watching for +# warnings. django_mfa/tests/test_docs.py pins the setting; the `docs` CI job +# builds the pages and asserts the admonitions become real admonition boxes. +myst_enable_extensions = ["colon_fence"] + +# "superpowers" used to be excluded here: internal spec/plan artifacts lived +# in docs/superpowers/. They now live in .superpowers/ at the repo root, +# outside the docs tree entirely, so there is nothing left to exclude but the +# build output. +exclude_patterns = ["_build"] +language = "en" html_theme = "sphinx_rtd_theme" +htmlhelp_basename = "django-mfa" -html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -#html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] - -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". -#html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -#html_logo = None - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -#html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -#html_extra_path = [] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -#html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -#html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -#html_additional_pages = {} - -# If false, no module index is generated. -#html_domain_indices = True - -# If false, no index is generated. -#html_use_index = True - -# If true, the index is split into individual pages for each letter. -#html_split_index = False - -# If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -#html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None - -# Language to be used for generating the HTML full-text search index. -# Sphinx supports the following languages: -# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' -# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' -#html_search_language = 'en' - -# A dictionary with options for the search language support, empty by default. -# Now only 'ja' uses this config value -#html_search_options = {'type': 'default'} - -# The name of a javascript file (relative to the configuration directory) that -# implements a search results scorer. If empty, the default will be used. -#html_search_scorer = 'scorer.js' - -# Output file base name for HTML help builder. -htmlhelp_basename = 'django-mfa' - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', - -# Latex figure (float) alignment -#'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - (master_doc, 'djangomfa.tex', u'Django MFA Documentation', - u'Micro Pyramid', 'manual'), -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -#latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -#latex_use_parts = False - -# If true, show page references after internal links. -#latex_show_pagerefs = False - -# If true, show URL addresses after external links. -#latex_show_urls = False - -# Documents to append as an appendix to all manuals. -#latex_appendices = [] - -# If false, no module index is generated. -#latex_domain_indices = True - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'djangomfa', u'Django MFA Documentation', - [author], 1) -] - -# If true, show URL addresses after external links. -#man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - (master_doc, 'djangomfa', u'Django MFA Documentation', - author, 'djangomfa', 'One line description of project.', - 'Miscellaneous'), -] - -# Documents to append as an appendix to all manuals. -#texinfo_appendices = [] - -# If false, no module index is generated. -#texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False# -*- coding: utf-8 -*- -# -# pietrack documentation build configuration file, created by -# sphinx-quickstart on Wed Jul 29 18:10:32 2015. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -import sys -import os -import shlex - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) - -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - 'sphinx.ext.autodoc', -] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# source_suffix = ['.rst', '.md'] -source_suffix = '.rst' - -# The encoding of source files. -#source_encoding = 'utf-8-sig' - -# The master toctree document. -master_doc = 'index' - -# General information about the project. -project = u'django-mfa' -copyright = u'2016, Micro Pyramid' -author = u'Micro Pyramid' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = '2.1' -# The full version, including alpha/beta/rc tags. -release = '2.1' - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -#today = '' -# Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -exclude_patterns = [] - -# The reST default role (used for this markup: `text`) to use for all -# documents. -#default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -#add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -#show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = False - - -# -- Options for HTML output ---------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -import sphinx_rtd_theme - -html_theme = "sphinx_rtd_theme" - -html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -#html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] - -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". -#html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -#html_logo = None - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -#html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -#html_extra_path = [] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -#html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -#html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -#html_additional_pages = {} - -# If false, no module index is generated. -#html_domain_indices = True - -# If false, no index is generated. -#html_use_index = True - -# If true, the index is split into individual pages for each letter. -#html_split_index = False - -# If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -#html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None - -# Language to be used for generating the HTML full-text search index. -# Sphinx supports the following languages: -# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' -# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' -#html_search_language = 'en' - -# A dictionary with options for the search language support, empty by default. -# Now only 'ja' uses this config value -#html_search_options = {'type': 'default'} - -# The name of a javascript file (relative to the configuration directory) that -# implements a search results scorer. If empty, the default will be used. -#html_search_scorer = 'scorer.js' - -# Output file base name for HTML help builder. -htmlhelp_basename = 'djangomfa' - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', - -# Latex figure (float) alignment -#'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - (master_doc, 'djangomfa.tex', u'Django MFA Documentation', - u'Micro Pyramid', 'manual'), -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -#latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -#latex_use_parts = False - -# If true, show page references after internal links. -#latex_show_pagerefs = False - -# If true, show URL addresses after external links. -#latex_show_urls = False - -# Documents to append as an appendix to all manuals. -#latex_appendices = [] - -# If false, no module index is generated. -#latex_domain_indices = True - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'djangomfa', u'Django MFA Documentation', - [author], 1) -] - -# If true, show URL addresses after external links. -#man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - (master_doc, 'djangomfa', u'Django MFA Documentation', - author, 'djangomfa', 'One line description of project.', - 'Miscellaneous'), -] - -# Documents to append as an appendix to all manuals. -#texinfo_appendices = [] - -# If false, no module index is generated. -#texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# No templates_path/html_static_path: docs/_templates and docs/_static never +# existed, and naming a directory that isn't there is a build warning. -# If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..62f99f9 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,57 @@ +# Contributing + +Feel free to create a new Pull request if you want to propose a new feature or fix a bug. + +## Development setup + +This project uses [uv](https://docs.astral.sh/uv/). There is no `requirements.txt` or `setup.py` -- `pyproject.toml` is the only packaging file, and `uv run` creates and syncs the environment for you, so there is no virtualenv to activate: + + uv run python test_runner.py # the whole suite + uv run ruff check . # lint + uv run ruff check . --fix # and fix what's auto-fixable + +To run a single test, pass a label -- `test_runner.py` falls back to the whole suite only when given none: + + uv run python test_runner.py django_mfa.tests.test_conf + +To check one Python/Django combination locally (CI runs the full grid of Python 3.10-3.13 against Django 4.2 and 5.2): + + uv run --python 3.12 --with "django~=4.2.0" python test_runner.py + +## Documentation + +The docs are Markdown, read by Sphinx through [MyST](https://myst-parser.readthedocs.io/), and live in `docs/`: + + cd docs && make html # build into docs/_build/html + cd docs && make linkcheck # verify external links still resolve + +`-W` is on by default, so a warning fails the build here exactly as it does in CI and on Read the Docs. + +Two things that build output alone will *not* catch, both pinned by `django_mfa/tests/test_docs.py` instead: + +- With MyST's `colon_fence` extension off, `:::{warning}` is not an error -- it renders the literal text `:::{warning}` into the published page and the build still reports success. +- A page missing from `index.md`'s toctree builds fine and simply never gets linked. + +When you add a setting to `django_mfa/conf.py`, document it in `docs/settings.md` in the same change; a test asserts every key in `DEFAULTS` appears there. + +## Sending pull requests + +1. Fork the repo: + + https://github.com/MicroPyramid/django-mfa + +2. Create a branch for your specific changes: + + $ git checkout master + $ git pull + $ git checkout -b feature + + To simplify things, please, make one branch per issue (pull request). It's also important to make sure your branch is up-to-date with upstream master, so that maintainers can merge changes easily. + +3. Commit changes. Please update docs, if relevant. + +4. Don't forget to run the tests (and `ruff`) to check that nothing breaks. + +5. Ideally, write your own tests for new feature/bug fix. + +6. Submit a [pull request](https://help.github.com/articles/using-pull-requests). diff --git a/docs/contributing.rst b/docs/contributing.rst deleted file mode 100644 index 9d1e94c..0000000 --- a/docs/contributing.rst +++ /dev/null @@ -1,33 +0,0 @@ -************ -Contributing -************ - -Feel free to create a new Pull request if you want to propose a new feature -or fix a bug. - -Sending pull requests -===================== - -1. Fork the repo:: - - https://github.com/MicroPyramid/django-mfa - -2. Create a branch for your specific changes:: - - $ git checkout master - $ git pull - $ git checkout -b feature - - To simplify things, please, make one branch per issue (pull request). - It's also important to make sure your branch is up-to-date with upstream master, - so that maintainers can merge changes easily. - -3. Commit changes. Please update docs, if relevant. - -4. Don't forget to run tests to check than nothing breaks. - -5. Ideally, write your own tests for new feature/bug fix. - -6. Submit a `pull request`_. - -.. _pull request: https://help.github.com/articles/using-pull-requests \ No newline at end of file diff --git a/docs/custom_factors.md b/docs/custom_factors.md new file mode 100644 index 0000000..c110c4f --- /dev/null +++ b/docs/custom_factors.md @@ -0,0 +1,229 @@ +# Writing a custom factor + +Adding a factor type means writing one class and registering it. You do not write +views, URLs, or middleware changes — those are generic and dispatch to whatever the +registry holds. The three built-ins (`totp`, `webauthn`, `recovery_codes`) are +written against exactly the API below; there is no privileged path. + +## The shape of it + +A factor is a subclass of `django_mfa.registry.Adapter`. It answers four questions: + + from django_mfa.registry import Adapter + + class MyAdapter(Adapter): + type = "my_factor" # the URL segment and the DB `type` value + verbose_name = "My factor" # shown in the picker and on the security page + + def begin_enroll(self, request): ... + def complete_enroll(self, request, data): ... + def begin_verify(self, request, user): ... + def complete_verify(self, request, user, data): ... + +The `begin_*` methods return a dict that is merged into the template context. The +`complete_*` methods receive the POSTed `QueryDict` as `data`. + +Once registered, `type` is what appears in `/mfa/enroll//` and +`/mfa/verify//`, and what gets stored in `Authenticator.type`. + +## A worked example: email codes + +A complete second factor that emails a one-time code. Roughly 40 lines. + + # myapp/mfa.py + import secrets + + from django.core.mail import send_mail + from django.utils import timezone + + from django_mfa.models import Authenticator + from django_mfa.registry import Adapter + from django_mfa.utils import strings_equal + + SESSION_KEY = "myapp_email_code" + + + class EmailAdapter(Adapter): + type = "email" + verbose_name = "Emailed code" + + # One enrollment per user: the address lives on the row, and a second + # row would just be a duplicate. (This is the default; shown for + # clarity — set it True only if several instances make sense, the way + # several security keys do.) + supports_multiple = False + + def _issue(self, user): + code = f"{secrets.randbelow(1_000_000):06d}" + send_mail("Your sign-in code", f"Your code is {code}.", + None, [user.email]) + return code + + def begin_enroll(self, request): + request.session[SESSION_KEY] = self._issue(request.user) + return {"email": request.user.email} + + def complete_enroll(self, request, data): + expected = request.session.pop(SESSION_KEY, None) + if not expected or not strings_equal(data["code"], expected): + raise ValueError("Code did not match.") + return Authenticator.objects.create( + user=request.user, type=self.type, + data={"email": request.user.email}) + + def begin_verify(self, request, user): + request.session[SESSION_KEY] = self._issue(user) + return {"email": user.email} + + def complete_verify(self, request, user, data): + expected = request.session.pop(SESSION_KEY, None) + if not expected or not strings_equal(data.get("code", ""), expected): + return False + auth = self.get_instances(user).first() + if auth is None: + return False + auth.record_usage() + return True + +Two templates, which can be as short as this: + + {% comment %} myapp/templates/django_mfa/verify_email.html {% endcomment %} + {% extends base_template %} + {% block content %} +

Check your email

+

We sent a code to {{ email }}.

+
+ {% csrf_token %} + + + {% if error_message %}

{{ error_message }}

{% endif %} + +
+ {% endblock %} + +`enroll_email.html` is the same minus the `next` field. See {doc}`customizing` for +the context every page receives. + +Then register it once, at startup: + + # myapp/apps.py + from django.apps import AppConfig + + class MyAppConfig(AppConfig): + name = "myapp" + + def ready(self): + from django_mfa.registry import registry + from myapp.mfa import EmailAdapter + registry.register(EmailAdapter()) + +That's the whole integration. The factor now appears on the security page, in the +picker, in the middleware's exempt set, and under rate limiting — none of which you +touched. + +## What you get for free + +Registering an adapter is enough for all of this: + +- **Enroll and verify views**, dispatching on `type`, with `@login_required`. +- **Rate limiting.** `MFA_VERIFY_RATE_LIMIT` applies per user per factor type. You + do not call it; `verify_factor` does. +- **Uniform failure responses.** Returning `False` and raising `ValueError`, + `TypeError`, or `KeyError` all produce the same generic HTTP 400. Let a malformed + payload raise — do not catch it into a distinct message, which would leak more + than the generic one. +- **Middleware exemption.** `MfaMiddleware` derives its exempt set from + `registry.all()`, so your verify page is reachable by a pending session + automatically. +- **Session handling.** The view calls `session.mark_verified()` on success. Do not + touch `request.session["mfa"]` yourself. +- **Enforcement.** Once a user enrolls, the `user_logged_in` receiver starts + challenging them. + +## The four flags + +| Flag | Default | Set it when | +|---|---|---| +| `supports_multiple` | `False` | A user can hold several — WebAuthn does, so someone can register a laptop and a backup key. `is_available()` uses this to decide whether to keep offering the factor. | +| `supports_enroll` | `True` | Set `False` for a factor that is *generated* rather than enrolled. Recovery codes do this; `enroll_factor` then returns 404 for it and the security page never offers it. | +| `counts_as_primary_factor` | `True` | Set `False` for something that must never be a user's only factor. Recovery codes do this — they're exhaustible. It's the difference between "can verify with this" and "is protected by this". | +| `type` / `verbose_name` | — | Always. `verbose_name` is user-visible. | + +`counts_as_primary_factor` is the subtle one. `registry.primary_enabled_for()` is the +**single source of truth** for whether to challenge a user, and it filters on this +flag. A factor with it `False` still appears in the picker (you can verify with it) +but never, on its own, causes a challenge — because a user holding only that factor +isn't protected. + +## Things to get right + +**Raise, don't return, for bad input during enrollment.** `complete_enroll` signals +failure by raising `ValueError`; its return value is the created `Authenticator`. +`complete_verify` signals failure by returning `False`. They are deliberately +different: enrollment has nothing meaningful to return on failure. + +**`begin_verify` runs again on failure.** After a wrong code the view re-renders the +challenge page, which means `begin_verify` is called a second time. If yours has a +side effect — sending an email, as above — every wrong code sends another one. That +may be what you want, or you may want to cache the issued code with a TTL and reuse +it; decide deliberately. + +**Store state in `data`, not in new columns.** `Authenticator.data` is a `JSONField`. +There is no per-factor table and adding one is not the intended extension point. + +**Compare secrets with `django_mfa.utils.strings_equal`**, which normalizes and then +uses `hmac.compare_digest`. `==` on a secret is a timing side channel. + +**Hash or encrypt anything worth stealing.** `django_mfa.crypto.encrypt`/`decrypt` +give you `MFA_SECRET_ENCRYPTION_KEYS` handling for free — they're a no-op when the +setting is unset, so using them costs nothing and starts working the day someone sets +it. For values you never need to read back, hash instead, as recovery codes do. + +## Known rough edges + +Being honest about where a custom factor is a slightly second-class citizen: + +- **`Authenticator.Type` is a fixed `TextChoices`** with the three built-ins. A row + with a custom `type` saves and queries fine — Django only validates choices in + `full_clean()`, which these code paths don't call — but `get_type_display()` + returns the raw string rather than a label, so `security.html` shows `my_factor` + instead of `My factor`. Override `security.html` and render + `adapter.verbose_name` if that matters to you. +- **The singleton database constraint names its types explicitly** + (`mfa_one_singleton_authenticator_per_user` covers `totp` and `recovery_codes`). + `supports_multiple = False` is enforced in `is_available()` at the application + level, not by your database. For most factors that's fine; if you need the + guarantee, add your own constraint in a migration in your app. +- **`MFA_FACTORS` only controls the built-ins.** Your adapter is registered by your + own `ready()`, so listing it there does nothing — and an unrecognized name raises + `ImproperlyConfigured`. Gate registration on your own setting if you need it + toggleable. +- **`registry.register()` raises `ValueError` on a duplicate `type`.** If your + `ready()` can run twice (some test setups), guard it or use + `registry.unregister()` first. + +## Testing your factor + +The suite in `django_mfa/tests/` uses plain `django.test.TestCase` and `Client`, and +is the best reference. A minimal check that yours is wired up end to end: + + from django.contrib.auth import get_user_model + from django.test import TestCase + from django_mfa.registry import registry + + class EmailFactorTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user( + "u", "u@example.com", "pw") + self.client.force_login(self.user) + + def test_enrolling_protects_the_user(self): + self.client.get("/mfa/enroll/email/") # issues a code + code = self.client.session["myapp_email_code"] + response = self.client.post("/mfa/enroll/email/", {"code": code}) + self.assertEqual(response.status_code, 302) + self.assertTrue(registry.primary_enabled_for(self.user)) + +Assert on `primary_enabled_for()` rather than on "a row exists" — that's the +predicate enforcement actually uses, so it's the one that proves your factor +protects anybody. diff --git a/docs/customizing.md b/docs/customizing.md new file mode 100644 index 0000000..17b2a4e --- /dev/null +++ b/docs/customizing.md @@ -0,0 +1,197 @@ +# Customizing the UI + +django-mfa ships working screens so you don't have to build them, not so you have to +keep them. Everything below is overridable, in roughly increasing order of effort. + +## Start here: point at your own base template + +Every django-mfa page extends whatever `MFA_BASE_TEMPLATE` names, and the built-in +`django_mfa/base.html` exists only so the package works before you've configured +anything. Replacing it is usually the entire job: + + MFA_BASE_TEMPLATE = "base.html" # your project's own base template + +Your template needs to provide one thing: a `{% block content %}` for the pages to +fill. If yours already has one (most do), you are finished — the MFA screens now +carry your navigation, your CSS, and your footer. + +If your base block has a different name, wrap it rather than renaming everything: + + {% comment %} myapp/templates/mfa_base.html {% endcomment %} + {% extends "base.html" %} + {% block main %}{% block content %}{% endblock %}{% endblock %} + + MFA_BASE_TEMPLATE = "mfa_base.html" + +The built-in pages also set a `title` context variable where relevant, which +`django_mfa/base.html` renders as `{{ title|default:_("Two-factor authentication") }}`. +Use it in your own base template if you want per-page titles. + +`base.html` exposes two blocks of its own: `nav_menu` (the small header bar) and +`main_modifier` (an extra class on `
`, which the security page uses to widen +itself). An earlier release also had an empty `left_bar` block for a sidebar column +that was never filled; it is gone. + +## Replacing individual pages + +Templates are loaded by path, so an app listed **before** `django_mfa` in +`INSTALLED_APPS` — or any directory in `TEMPLATES["DIRS"]` — can shadow any of them +by putting a file at the same path: + + myproject/templates/django_mfa/security.html + +The full set: + +| Template | Rendered by | When | +|---|---|---| +| `django_mfa/base.html` | all pages | the default `MFA_BASE_TEMPLATE` | +| `django_mfa/security.html` | `mfa:security_settings` | the account's MFA overview | +| `django_mfa/picker.html` | `mfa:verify` | at login, when the user holds more than one method | +| `django_mfa/enroll_totp.html` | `mfa:enroll_factor` | setting up an authenticator app | +| `django_mfa/enroll_webauthn.html` | `mfa:enroll_factor` | registering a security key or passkey | +| `django_mfa/verify_totp.html` | `mfa:verify_factor` | the TOTP challenge | +| `django_mfa/verify_webauthn.html` | `mfa:verify_factor` | the WebAuthn challenge | +| `django_mfa/verify_recovery_codes.html` | `mfa:verify_factor` | the recovery-code challenge | +| `django_mfa/recovery_codes.html` | `mfa:recovery_codes` | displaying freshly generated codes | + +Enroll and verify templates are resolved from the factor type, as +`django_mfa/enroll_.html` and `django_mfa/verify_.html`. That naming is +the contract — it's also how a custom factor gets its screens, with no view changes +(see {doc}`custom_factors`). + +## Template context + +What each page gives you. Every page additionally receives `base_template`, which is +what the `{% extends base_template %}` line at the top resolves. + +### `security.html` + +| Variable | What it is | +|---|---| +| `enabled_adapters` | Adapters this user holds at least one of. Distinct *types*. | +| `available_adapters` | Adapters the user could still add. Already excludes singletons they hold and factors that aren't enrolled at all. Link each to `{% url 'mfa:enroll_factor' adapter.type %}`. | +| `authenticators` | The individual `Authenticator` rows, ordered by type then creation. This — not `enabled_adapters` — is what lets you list three security keys separately so a user can tell them apart and remove exactly one. | +| `recovery_codes_remaining` | Integer count of unused codes. | +| `owned_by_enterprise` | The `MFA_OWNED_BY_ENTERPRISE` setting, so the template can hide the remove button for WebAuthn. | + +### `picker.html` + +| Variable | What it is | +|---|---| +| `adapters` | Every method this user can verify with **right now**, including recovery codes. Link each to `{% url 'mfa:verify_factor' adapter.type %}`. | + +The picker is only rendered when the user has more than one method — with exactly +one, the view redirects straight to that method's challenge. Don't rely on this page +being seen. + +### Enroll pages + +| Variable | What it is | +|---|---| +| `adapter` | The adapter being enrolled. `adapter.verbose_name` is the display name. | +| `error_message` | Present only after a failed POST; the page re-renders with HTTP 400. | +| *(TOTP)* `secret_key` | The freshly generated Base32 secret. Must be POSTed back in a hidden field — the server does not stash it in the session. | +| *(TOTP)* `provisioning_uri` | The `otpauth://` URI. Render it with `{% qrcode provisioning_uri "alt text" %}`. | +| *(WebAuthn)* `options` | JSON ceremony options. Render with `{% webauthn_options_script options "webauthn-options" %}`. | + +### Verify pages + +| Variable | What it is | +|---|---| +| `adapter` | The adapter being challenged. | +| `next` | Where to redirect after success, already validated against open redirects. Pass it through in a hidden field named `next`. | +| `error_message` | Present only after a failed attempt; the page re-renders with HTTP 400. | +| *(recovery codes)* `remaining` | How many unused codes are left, so you can warn people running low. | +| *(WebAuthn)* `options` | JSON ceremony options, as above. | + +### `recovery_codes.html` + +| Variable | What it is | +|---|---| +| `codes` | A list of plaintext codes on the visit that generates them, and `None` on every visit after. Codes are hashed at rest and cannot be shown twice — your template must handle `None`. | +| `next_url` | Resolved `LOGIN_REDIRECT_URL`, for the "done" link. | + +## The QR code + +`{% qrcode provisioning_uri "Scan this code" %}` (from `{% load otp_tags %}`) renders +an inline SVG data URI. It is generated entirely server-side — no third-party +service, no external request, and your users' TOTP secrets never leave your process. +If you replace `enroll_totp.html`, keep using this tag rather than a QR image API. + +## Rules for the WebAuthn templates + +WebAuthn pages are driven by `django_mfa/static/django_mfa/webauthn.js`, which finds +its elements **by ID**. Restyle these pages freely, but keep the IDs and the +`name` attributes — the script silently does nothing if they're missing, and it will +look like the button is simply dead. + +`enroll_webauthn.html` and `verify_webauthn.html` need: + +| Hook | Why | +|---|---| +| `id="webauthn-form"` with `data-mode="create"` (enroll) or `data-mode="get"` (verify) | The form the script drives; `data-mode` picks the ceremony. | +| `id="webauthn-start"` | The button the script binds its click handler to. Keep it `type="button"`. | +| `` | Where the script writes the ceremony result before submitting. | +| `id="webauthn-options"` via `{% webauthn_options_script options "webauthn-options" %}` | The ceremony options. Use the tag — see below. | +| `id="webauthn-unsupported"` | Shown when the browser has no WebAuthn support. | +| `id="webauthn-error"` | Where ceremony errors are written. | + +For a passkey button on your own login page the IDs are +`webauthn-passkey-form`, `webauthn-passkey-start`, `webauthn-passkey-unsupported` +and `webauthn-passkey-error` — see {doc}`recipes`. + +:::{warning} +**Use `{% webauthn_options_script %}`, not `{{ options|json_script:"..." }}`.** +`options` is already a JSON *string*. Django's built-in `json_script` filter would +encode it a second time, so the browser's single `JSON.parse()` hands back the +original JSON text as a string instead of an object, `options.publicKey` comes out +`undefined`, and the ceremony never starts. This breaks only in a real browser — +every server-side test still passes, because the page rendered fine. +::: + +The bundled stylesheet (`static/django_mfa/style.css`) carries one rule that is not +cosmetic: + + [hidden] { display: none !important; } + +The HTML `hidden` attribute is implemented by a user-agent rule whose specificity +loses to *any* class selector that sets `display`. Without the `!important`, a +`.mfa-alert { display: block }` beats it and the "your browser does not support +WebAuthn" banner shows permanently — on browsers that support it perfectly well. If +you replace the stylesheet entirely, carry that rule over. + +## Text and translation + +Every string is wrapped in `{% trans %}` / `{% blocktrans %}` and ships under the +`django_mfa` app, so `makemessages` picks it up like any other app's text. To change +wording without translating, override the template. + +## Styling without touching templates + +The bundled stylesheet is self-contained — no framework, no webfont, no network +request of any kind, because these pages sit in a login flow and have to render on a +locked-down network. It adapts to `prefers-color-scheme`, so dark mode works out of +the box, and it is built from CSS custom properties: if you only want to match your +brand colour, redefine the tokens instead of forking anything. + + :root { + --mfa-accent: #7c3aed; + --mfa-radius: 4px; + } + +The structural class names are `mfa-`-prefixed and stable: + +| Class | Where | +|---|---| +| `.mfa-card` | Each panel/section | +| `.mfa-list` / `.mfa-item` | The enrolled-methods list on the security page | +| `.mfa-picker` | The method chooser, and "add a method" | +| `.mfa-actions` | A row of buttons (stacks full-width under 26rem) | +| `.mfa-alert` | Error banners, including the WebAuthn ones | +| `.mfa-code-input` | The one-time-code field | +| `.recovery-code` | Each individual recovery code | +| `.qrcode` | The TOTP QR image | + +`.btn` / `.btn-primary` / `.btn-default` / `.btn-danger`, `.form-control`, +`.help-block` and `.text-danger` are kept under their conventional names so a host +project already loading a CSS framework gets a reasonable result either way. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..03659ec --- /dev/null +++ b/docs/index.md @@ -0,0 +1,52 @@ +# django-mfa + +Second-factor authentication for Django: **authenticator apps (TOTP)**, **security +keys and passkeys (WebAuthn)**, and **recovery codes** — with the enrollment pages, +challenge screens, and enforcement middleware already written. + +Add the app, the middleware, and a URL include, and your users have a second factor. +Your login view doesn't change: django-mfa hooks Django's own `user_logged_in` +signal, so whatever authenticates your users today keeps doing so. + + INSTALLED_APPS += ["django_mfa"] + MIDDLEWARE += ["django_mfa.middleware.MfaMiddleware"] + urlpatterns += [path("mfa/", include("django_mfa.urls"))] + +New here? Start with {doc}`installation_setup`, then skim {doc}`mfa_flow` to see what +your users will actually go through. + +Source code: + +```{toctree} +:maxdepth: 2 +:caption: Getting started + +installation_setup +mfa_flow +``` + +```{toctree} +:maxdepth: 2 +:caption: Guides + +customizing +recipes +custom_factors +``` + +```{toctree} +:maxdepth: 2 +:caption: Reference + +settings +api +security +``` + +```{toctree} +:maxdepth: 2 +:caption: Project + +upgrading +contributing +``` diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index 725d556..0000000 --- a/docs/index.rst +++ /dev/null @@ -1,18 +0,0 @@ -========================= -Django MFA -========================= - -This application provides an implementation of Multi-Factor Authentication. - -The **source code** for this app is hosted at -https://github.com/MicroPyramid/django-mfa - - -Contents: - -.. toctree:: - :maxdepth: 2 - - installation_setup - mfa_flow - contributing diff --git a/docs/installation_setup.md b/docs/installation_setup.md new file mode 100644 index 0000000..703056e --- /dev/null +++ b/docs/installation_setup.md @@ -0,0 +1,123 @@ +# Getting started + +## Requirements + +| | | +|--------|------------------------| +| Python | 3.10, 3.11, 3.12, 3.13 | +| Django | 4.2, 5.2 | + +Any database Django supports works: factor state lives in a `JSONField`, and no +backend-specific features are used. + +Upgrading from an older release? See {doc}`upgrading` first — several changes are +breaking, and one migration is irreversible. + +## Installation + + pip install django-mfa # or: uv add django-mfa + +:::{warning} +**While 4.0 is in pre-release**, the current PyPI release is 3.2, which predates +WebAuthn and passkeys and does not match this documentation. Install it explicitly +until 4.0 is final: + + pip install --pre django-mfa +::: + +## Wiring it up + +### 1. Add the app and the middleware + +`MfaMiddleware` must come **after** `AuthenticationMiddleware` — it needs +`request.user` to exist. + + INSTALLED_APPS = [ + ..., + "django_mfa", + ] + + MIDDLEWARE = [ + ..., + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django_mfa.middleware.MfaMiddleware", + ] + +### 2. Run migrations + + python manage.py migrate + +### 3. Mount the URLs + + urlpatterns = [ + ..., + path("mfa/", include("django_mfa.urls")), + ] + +`django_mfa.urls` bakes the `mfa` namespace into the pattern list itself — do not +pass `namespace=` to `include()`. Every `reverse()` call inside django-mfa uses the +`mfa:` prefix, so the URL *prefix* you mount it under (`mfa/` above) is entirely +yours to choose. + +### 4. Exempt your logout URL + +django-mfa has no way to discover it, and a user who cannot complete their second +factor needs a way out: + + MFA_EXEMPT_PATHS = ["/logout/"] + +Skip this and a user with a lost device is stuck in a redirect loop they cannot even +log out of. See the warning in {doc}`settings`. + +### 5. For security keys and passkeys + +Only needed if you want WebAuthn. To offer authenticator apps alone, set +`MFA_FACTORS = ["totp", "recovery_codes"]` and skip this step entirely — the +WebAuthn system checks switch themselves off with it. + + MFA_FIDO2_RP_ID = "example.com" # your registrable domain + + AUTHENTICATION_BACKENDS = [ + "django_mfa.backends.WebAuthnBackend", + "django.contrib.auth.backends.ModelBackend", + ] + +:::{warning} +**Set `MFA_FIDO2_RP_ID` once and never change it.** Changing it invalidates every +security key and passkey already registered, with no migration path. See +{doc}`settings` for the full explanation. +::: + +Both of these are verified by `manage.py check` (and therefore by `migrate` and +`runserver`) — see [System checks](settings.md#system-checks). If you got either +wrong, you will hear about it at startup rather than from a user. + +### 6. Name yourself in authenticator apps + +Optional, but users with more than one account will thank you. Without it their app +shows a bare username with no clue which site it belongs to. + + MFA_ISSUER_NAME = "Cool Django App" + +## Check that it works + +That's the whole setup. Your login view does not change — django-mfa hooks Django's +own `user_logged_in` signal, so whatever authenticates your users today keeps doing +so. + + python manage.py check # should report no issues + +Then, logged in as any user, visit `/mfa/security/`. You should see a page listing +enabled methods (none yet), methods available to add, and recovery codes remaining. +Enroll one, log out, and log back in: you'll be challenged for it. + +Users with **no** factor enrolled are never challenged and never blocked, so you can +turn this on for a live site and let people opt in at their own pace. + +## Where to go next + +- {doc}`mfa_flow` — the URLs this exposes and what happens at each step +- {doc}`customizing` — make the screens look like the rest of your site +- {doc}`recipes` — allauth, custom login views, testing, and troubleshooting +- {doc}`settings` — every setting, its default, and what it does +- {doc}`security` — what django-mfa defends against, and your pre-launch checklist diff --git a/docs/installation_setup.rst b/docs/installation_setup.rst deleted file mode 100644 index 45b6947..0000000 --- a/docs/installation_setup.rst +++ /dev/null @@ -1,48 +0,0 @@ -Getting started -=============== - -Requirements -~~~~~~~~~~~~ - -====== ==================== -Python 3.6, 3.7, 3.8, 3.9, 3.10 -Django 2.2, 3.0, 3.1, 3.2 -====== ==================== - -Installation -~~~~~~~~~~~~ - -The Git repository can be cloned with this command:: - - git clone https://github.com/MicroPyramid/django-mfa - -The ``django_mfa`` package, included in the distribution, should be -placed on the ``PYTHONPATH``. - -Otherwise you can just ``easy_install -Z django-mfa`` -or ``pip install django-mfa``. - -Settings -~~~~~~~~ - -Add ``django_mfa'`` to the ``INSTALLED_APPS`` to your *settings.py*. - -See the :doc:`settings` section for other settings. - -Add ``'django_mfa.middleware.MfaMiddleware'`` to the ``MIDDLEWARE_CLASSES`` - -Urls -~~~~ - -Add the following to your root urls.py file. - -.. code:: django - - urlpatterns = [ - ... - - url(r'^settings/', include('django_mfa.urls', namespace="mfa")), - ] - - -Done. With these settings you have now, you will get the MFA features. diff --git a/docs/mfa_flow.md b/docs/mfa_flow.md new file mode 100644 index 0000000..a1106e8 --- /dev/null +++ b/docs/mfa_flow.md @@ -0,0 +1,47 @@ +# Flow and URLs + +Once basic setup (see {doc}`installation_setup`) is done, django-mfa exposes the following URLs under whatever prefix you mounted `django_mfa.urls` at (e.g. `/mfa/`). All names below are reversed with the `mfa:` namespace, e.g. `reverse("mfa:security_settings")`. + +| Name | Path | Purpose | +|-------------------------|-------------------------|-----------------------------------------------------------------------------------------------------------------| +| `mfa:security_settings` | `security/` | Overview: enabled factors, factors still available to add, recovery codes remaining. | +| `mfa:manage` | `manage/` | POST-only: delete one of the user's own authenticators. | +| `mfa:verify` | `verify/` | The second-factor picker (see below). | +| `mfa:verify_factor` | `verify//` | Challenge screen for one specific factor (`totp`, `webauthn`, or `recovery_codes`). | +| `mfa:enroll_factor` | `enroll//` | Enrollment screen for one specific factor (`totp` or `webauthn` -- recovery codes are generated, not enrolled). | +| `mfa:recovery_codes` | `recovery/codes/` | View (and, on first visit, generate) recovery codes. | +| `mfa:passkey_begin` | `passkey/begin/` | GET: start a passwordless WebAuthn ceremony (see below). | +| `mfa:passkey_complete` | `passkey/complete/` | POST: finish it and log the resolved user in. | + +`factor_type` is whatever `Authenticator.Type` values are registered -- out of the box, `totp`, `webauthn`, and `recovery_codes`. + +## Enrolling a second factor + +1. An already-logged-in user visits `mfa:security_settings`, which lists the factor types they don't yet have (`available_adapters`) as links to `mfa:enroll_factor`. +2. `GET mfa:enroll_factor//` renders that factor's enrollment page -- for TOTP, a freshly generated secret and QR code; for WebAuthn, a registration ceremony driven by `django_mfa/static/django_mfa/webauthn.js`. +3. Completing the ceremony (entering the six-digit code, or a browser completing `navigator.credentials.create()`) POSTs back to the same URL. On success this creates an `Authenticator` row, marks the current session's second factor as satisfied (the user just proved possession), and redirects to `mfa:recovery_codes` if the user has no recovery codes yet, or `mfa:security_settings` otherwise. + +## Logging in with a second factor + +1. The host project's own login view authenticates the user as usual (username/password, SSO, etc.) and calls `django.contrib.auth.login()`. A `user_logged_in` signal handler (`django_mfa/signals.py`) then marks the session pending a second factor, if -- and only if -- the user has at least one factor counted as a primary factor (`totp` or `webauthn`; recovery codes alone do not count, since they're exhaustible). This replaces the old contract where a host project's login view had to set session keys by hand. +2. From that point on, `MfaMiddleware` redirects every request from that session to `mfa:verify` except the picker itself, each registered factor's own verify page, and anything listed in `MFA_EXEMPT_PATHS` (see the warning in {doc}`settings` -- this should include your logout URL). +3. `mfa:verify` (the picker) looks at which factors this user actually holds. If there's exactly one, it redirects straight to `mfa:verify_factor//` -- no need to make a user with a single factor choose from a list of one. Otherwise it renders a list to choose from. +4. `GET mfa:verify_factor//` renders that factor's challenge screen; POSTing the code/assertion marks the session fully verified on success (or a generic error on failure -- see the rate-limiting note below) and redirects to `next` (validated against open redirects) or `LOGIN_REDIRECT_URL`. + +Failed attempts against a single factor are rate-limited (`MFA_VERIFY_RATE_LIMIT`, default 5 per 5 minutes per user per factor type); a locked-out attempt gets the exact same response as a wrong code, so the lockout itself never reveals whether MFA is even enabled for that account. + +## Passwordless (passkey) login + +A user whose only factor is a WebAuthn passkey doesn't have to go through "password, then second factor" at all -- see {doc}`installation_setup` for the `AUTHENTICATION_BACKENDS` setup this requires. + +1. `GET mfa:passkey_begin` starts a WebAuthn authentication ceremony with an *empty* credential allow-list. That emptiness is what makes the browser prompt offer any discoverable credential it holds for this site, rather than asking for a specific already-known user -- i.e. what makes the login "usernameless". +2. The browser resolves a credential and its assertion (including the opaque user handle the credential was registered with) is POSTed to `mfa:passkey_complete`. The view resolves the handle back to a user, validates the assertion (reusing the exact same verification code path as `mfa:verify_factor/webauthn/`, including clone detection), and logs the user in via `django_mfa.backends.WebAuthnBackend`. +3. Whether this satisfies *both* factors in one step depends on whether the assertion carried the WebAuthn User Verification flag (PIN/biometric), not just User Presence: + - **User-verified** assertion: the session is marked fully verified immediately. A user whose only factor is this same passkey reaches `LOGIN_REDIRECT_URL` directly. + - **Presence-only** assertion (e.g. a security key with no PIN set up): the user is logged in, but the second factor is left pending exactly as it would be after a plain password login. If that passkey is the user's *only* factor, `MfaMiddleware` sends them to the picker, which sees exactly one factor -- this same passkey -- and redirects straight back to `mfa:verify_factor/webauthn/`, where re-tapping the same key completes the challenge normally. This is deliberate, not a bug: WebAuthn login only strictly requires User Presence, so django-mfa cannot assume User Verification happened without checking. + +Every failure mode on the passwordless path -- an unknown handle, an unknown credential, a bad signature, missing/expired ceremony state, a tampered payload -- returns the exact same generic response, so a client can never use timing or response differences to enumerate which accounts exist. + +## Removing a factor + +`POST mfa:manage` with the authenticator's primary key deletes it (a user may only delete their own). If `MFA_OWNED_BY_ENTERPRISE` is set, WebAuthn authenticators cannot be removed this way -- typically because they represent an organization-issued security key an administrator manages instead of the end user. diff --git a/docs/mfa_flow.rst b/docs/mfa_flow.rst deleted file mode 100644 index ef433b9..0000000 --- a/docs/mfa_flow.rst +++ /dev/null @@ -1,27 +0,0 @@ -Flow And Urls -====================== - -.. _templatetags-paginate: - -Once we have done with the basic setup as given in installation and setup, we are ready to go. - -Step1: Enabling MFA -~~~~~~~~~~~~~~~~~~~ - -Once your app is up and running with settings, just go to url ``/settings/security``. There we can see if the MFA is enabled or not. Lets say MFA is not enabled. When we click on ``'Enable MFA'``. Then user will be redirected to setup types url. - -Step2: Setup with APP -~~~~~~~~~~~~~~~~~~~~~ - -When user completes step1 successfully, he/she will be redirected to url ``/settings/mfa/configure/``. Here user will have option to Setup using APP. This means we are going to use google authenticator for this. When user click on ``'Setup using APP'`` then will be redirected to page where we get a QR code which we have to scan in our google authenticator. - - -Step3: Scan QR code -~~~~~~~~~~~~~~~~~~~~~ - -In this step user will see a QR code generated with user's email. User have to scan this code with Google Authenticator app. Once user scanned this image, user will get a 6 digit code which expire for every 30 secs. User has to enter this six digit code in the text box. Once user successfully enters the six digit code, thats it from now onwards your MFA is enabled. You can check this by logging out of the system and login again. Even when user login with username and password the application will ask for 6 digit code to complete the login. - -Step4: Disabing the MFA -~~~~~~~~~~~~~~~~~~~~~~~ - -Disabling MFA is quite simple, we can do this by visiting the url ``/settings/security``. By clicking the ``'Disable MFA'`` your account will be free from MFA. diff --git a/docs/recipes.md b/docs/recipes.md new file mode 100644 index 0000000..c7a228b --- /dev/null +++ b/docs/recipes.md @@ -0,0 +1,224 @@ +# Integration recipes + +Common things host projects need to do, and the failure modes people hit first. + +## Using it with your existing login + +**There is nothing to do.** django-mfa hooks `django.contrib.auth.signals.user_logged_in`, +so anything that ends in a call to `django.contrib.auth.login()` is already +integrated: + +- Django's own `LoginView` +- `django-allauth` (including its social logins) +- A custom view calling `authenticate()` then `login()` +- SSO and SAML packages that call `login()` +- `Client.force_login()` in your tests + +If your login path does **not** call `login()` — a hand-rolled view that sets +`request.session[SESSION_KEY]` directly, for instance — no signal fires and no +challenge happens. Call `login()`; that's the contract. + +Earlier releases required host projects to set session keys by hand. If you still +have that code, delete it — see {doc}`upgrading`. + +## Sending people to set up MFA + +There is no built-in "you must enroll" enforcement, deliberately: whether MFA is +mandatory, and for whom, is a policy decision. Point users at `mfa:security_settings` +from your account area: + + Two-factor authentication + +To *require* it for some group, write a small middleware of your own. Note what has +to be exempt, or you build a redirect loop: + + from django.shortcuts import redirect + from django.urls import reverse + from django_mfa.registry import registry + + class RequireMfaForStaff: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + user = request.user + if (user.is_authenticated and user.is_staff + and not registry.primary_enabled_for(user) + and not request.path.startswith("/mfa/") + and request.path != reverse("logout")): + return redirect("mfa:security_settings") + return self.get_response(request) + +Use `registry.primary_enabled_for()` rather than counting `Authenticator` rows — a +user holding only recovery codes has rows but is not protected. + +## Checking whether the current session passed MFA + + from django_mfa import session + + if session.is_verified(request): + ... + +Use these helpers rather than reading `request.session["mfa"]`. The dict's shape is +not a public contract and has changed once already. + +For a template, pass it through your own context processor — django-mfa doesn't +install one. + +## Adding a passkey button to your login page + +Lets a returning user sign in with Touch ID or a security key without typing +anything. Two endpoints do the work: `mfa:passkey_begin` (GET, returns ceremony +options as JSON) and `mfa:passkey_complete` (POST). + +The bundled `webauthn.js` already drives this if you give it the elements it looks +for by ID: + + {% load static %} + + +

+ +
+ {% csrf_token %} + + +
+ + + +This requires `django_mfa.backends.WebAuthnBackend` in `AUTHENTICATION_BACKENDS` — +`django_mfa.E003` will tell you at startup if it's missing, because the failure is +otherwise silent. It also requires discoverable credentials, so leave +`MFA_FIDO2_RESIDENT_KEY` at `"preferred"` or set it to `"required"`. + +Whether one passkey tap satisfies *both* factors depends on user verification (PIN or +biometric) rather than mere presence — see {doc}`mfa_flow`. + +### Greeting a returning user by name + +With `MFA_QUICKLOGIN = True`, django-mfa sets a hint cookie naming the last account +to log in on this browser, so your login page can show "Sign in as Ashwin" before any +username is typed: + + from django_mfa.quicklogin import COOKIE_NAME, user_from_hint + + def login_page(request): + hinted = user_from_hint(request.COOKIES.get(COOKIE_NAME)) + return render(request, "login.html", {"hinted_user": hinted}) + +The cookie is a **hint, not a credential**. It never authenticates anyone; the user +still completes a real WebAuthn ceremony. `user_from_hint()` never raises — a +missing, stale, or tampered cookie returns `None`. + +## APIs and non-browser clients + +`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. + +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 +through the session at all: + + MFA_EXEMPT_PATHS = ["/logout/"] # plus your API's paths if session-authed + +Note that `MFA_EXEMPT_PATHS` matches `request.path` **exactly** — it is not a prefix +match. For a whole API subtree, wrap the middleware instead: + + class MfaMiddlewareExceptApi(MfaMiddleware): + def process_request(self, request): + if request.path.startswith("/api/"): + return None + return super().process_request(request) + +Exempting a session-authenticated API means a half-verified session can reach it. +That's a real trade-off, not a formality — make it deliberately. + +## Testing your integration + +The package's own tests are plain `django.test.TestCase` and `Client`; yours can be +too. + +Enrolling TOTP without a phone: + + from django_mfa import totp + from django_mfa.adapters.totp import generate_secret + + secret = generate_secret() + response = self.client.post("/mfa/enroll/totp/", { + "secret_key": secret, + "code": totp.TOTP(secret).now(), + }) + +Passing the challenge later uses the same `.now()` against `/mfa/verify/totp/`. + +Asserting that a user is actually protected: + + from django_mfa.registry import registry + self.assertTrue(registry.primary_enabled_for(user)) + +**For tests of other features, where MFA is incidental, use a user with no enrolled +factors.** They are never stamped pending and never challenged, so nothing about your +existing tests needs to change. Reach for a real enroll-and-verify round trip only +when the test is about MFA itself — that exercises the same code path your users do, +and doesn't depend on the session dict's shape. + +For WebAuthn, don't hand-build fixtures. `django_mfa/tests/support/authenticator.py` +provides `SoftwareAuthenticator`, an in-process fake authenticator with real +EC P-256 keys and real `fido2` ceremony objects, which drives registration and +authentication end to end without a browser. + +## Troubleshooting + +**Every request redirects to the verify page, and I can't even log out.** +Your logout URL isn't in `MFA_EXEMPT_PATHS`. Add it. Matching is exact, so include +the trailing slash exactly as mounted. + +**`django_mfa.E001` / `E002` at startup.** +`MFA_FIDO2_RP_ID` is unset, or isn't a suffix of any `ALLOWED_HOSTS` entry. For local +development that usually means `MFA_FIDO2_RP_ID = "localhost"` and `"localhost"` in +`ALLOWED_HOSTS`. If you don't want WebAuthn at all, set +`MFA_FACTORS = ["totp", "recovery_codes"]` and the checks stop applying. + +**`django_mfa.E003` at startup.** +Add `django_mfa.backends.WebAuthnBackend` to `AUTHENTICATION_BACKENDS`, keeping +`ModelBackend` for password login. + +**The "register security key" button does nothing.** +Three usual causes: the page isn't served over HTTPS (WebAuthn requires a secure +context — `http://localhost` is exempt, `http://192.168.x.x` is not); +`MFA_FIDO2_RP_ID` doesn't match the domain in the address bar; or you overrode the +template and dropped one of the element IDs `webauthn.js` needs. See +{doc}`customizing`. + +**"Your browser does not support WebAuthn" on a browser that clearly does.** +Your CSS is overriding the `hidden` attribute. The UA rule `[hidden] {display:none}` +loses to any class selector that sets `display`, so a framework's +`.help-block {display:block}` wins. Add `[hidden] { display: none !important; }`. + +**Passkey login works, then the user is anonymous on the next request.** +`WebAuthnBackend` is missing from `AUTHENTICATION_BACKENDS`. This is exactly what +`E003` catches — check you haven't silenced it. + +**Users report their TOTP codes are rejected.** +Server clock drift. django-mfa accepts the previous and next 30-second window +already; beyond that, fix NTP on the server. + +**A user is locked out with no recovery codes.** +There is no back door by design. Delete their `Authenticator` rows — from the Django +admin, or: + + from django_mfa.models import Authenticator + Authenticator.objects.filter(user=user).delete() + +They can then log in with their password alone and re-enroll. Verify identity out of +band first; this removes their second factor entirely. + +**Everything works locally and fails in production.** +Almost always `MFA_FIDO2_RP_ID`: it must be the registrable domain, not the full +origin. `"example.com"` — not `"https://example.com"`, not `"www.example.com"` if +users reach you at both. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..5d4f9f6 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,184 @@ +# Security model + +What django-mfa defends against, how, and — just as important — what it doesn't. + +## What it assumes + +django-mfa is a **second factor**, not an authentication system. It assumes your +project already authenticates users correctly (password hashing, session security, +CSRF, HTTPS) and adds proof of possession on top. It assumes an attacker may know a +victim's password. + +It does not assume your users are careful, that their devices are clean, or that your +login page is the only way into your app. + +## Controls + +### No account enumeration + +Every failure on the passwordless login path returns an identical generic response: +an unknown user handle, an unknown credential, a bad signature, missing or expired +ceremony state, and a tampered payload are indistinguishable to the client. There is +no status-code split, no differing message, and no exception path that would turn one +case into a 500 while others return 400 — that difference would itself be an oracle. + +The same holds for second-factor verification: a wrong code, a malformed payload, a +missing field, and a rate-limited attempt all produce the same HTTP 400 and the same +message. + +### Rate limiting + +Failed attempts are counted per user **per factor type** and capped by +`MFA_VERIFY_RATE_LIMIT` (default `"5/5m"`). A successful verification clears the +counter. + +A locked-out attempt returns exactly what a wrong code returns. The lockout is +therefore not observable, and cannot be used to probe whether an account exists or +has MFA enabled. + +Two properties worth understanding before you rely on it: + +- **The counter lives in Django's cache, with no database fallback.** If the cache is + empty, flushed, or restarted, attempts are allowed again. This is a deliberate + availability-over-control trade: a secondary throttle should not be able to lock + every user out of your site. +- **It is only as shared as your cache is.** With `LocMemCache` and four worker + processes, each process keeps its own counter, so the effective limit is four times + what you configured. See the checklist below. + +Invalid values are rejected loudly at parse time rather than silently misbehaving: a +count of `0` would lock out every user permanently, and a window of `0` would expire +the counter instantly and disable the throttle. Both raise `ValueError`. + +### Cloned-authenticator detection + +WebAuthn authenticators may maintain a signature counter that increments on every +assertion. A counter that fails to advance suggests the credential has been copied. +django-mfa checks this on every assertion and refuses the attempt when it regresses. + +There's a carve-out: authenticators that never implement a counter always report `0`, +which includes Apple/iCloud passkeys. Treating "not greater than stored" as a clone +would reject every iCloud passkey login. The check distinguishes the two cases — do +not simplify it to `new > stored`. + +### Recovery codes + +Ten codes, generated together, each usable once. They are hashed with Django's +password hasher, and used codes are marked individually rather than deleted, so a +replay is detected rather than silently accepted. + +Plaintext exists only for the duration of the response that displays them. It is +never written to the session, the database, or a log; the download button builds the +file client-side from the codes already on the page, precisely so the server never +has to retain them. Revisiting the page shows nothing — there is nothing left to +show. + +**Recovery codes can never be a user's only factor.** They're exhaustible, so +`counts_as_primary_factor = False`: a user holding only recovery codes is offered +them at the picker but is never challenged on their strength alone. + +### Secrets at rest + +TOTP secrets are stored in plaintext by default and encrypted when +`MFA_SECRET_ENCRYPTION_KEYS` is set — see {doc}`settings` for rotation. WebAuthn +stores only a public key, so there is no secret to protect. Recovery codes are +hashed, not encrypted, because they never need to be read back. + +Any comparison between a submitted value and a stored one goes through +`django_mfa.utils.strings_equal`, which normalizes and then uses +`hmac.compare_digest`. There is no `==` on a secret anywhere in the package. + +### WebAuthn user handles + +The opaque handle a passkey hands back during passwordless login is a **stored random +UUID**, not a signed derivation of the user's primary key. + +That distinction is deliberate and load-bearing. Rotating `SECRET_KEY` is routine +security hygiene; passkeys are registered once and used for years. A handle derived +from `SECRET_KEY` would become unresolvable the moment you rotated it — breaking +passwordless login for every user with a passkey, silently, with no sign until they +tried to log in. A stored value survives both key rotation and username changes while +leaking no identity. + +### Open redirects + +The `next` parameter on verification is validated against the request's host and +scheme before use, falling back to `LOGIN_REDIRECT_URL`. A verification link cannot +be used to bounce a user to an attacker's site. + +### Startup checks + +Three misconfigurations that would otherwise fail silently in production are `Error`s +at startup rather than warnings — see [System checks](settings.md#system-checks). + +## What it does not defend against + +Stated plainly, so you can decide what else you need: + +- **Real-time phishing / adversary-in-the-middle for TOTP.** A convincing fake login + page can collect a password and a TOTP code and replay both immediately. This is + inherent to shared-secret OTP, not specific to this package. **WebAuthn is the + mitigation** — credentials are bound to the origin, so a passkey cannot be used on + an attacker's domain. If phishing is in your threat model, prefer passkeys and + consider not offering TOTP. +- **Compromised sessions.** Once verified, the session is verified. django-mfa does + not re-challenge for sensitive actions; if you want step-up authentication for, + say, changing a password, build it on `session.is_verified()` plus your own policy. +- **A compromised server.** TOTP secrets are decryptable by your application by + definition. Encryption at rest protects against a leaked database dump, not + against code execution on your host. +- **Malware on the user's device**, SIM swapping (there is no SMS factor — that's + intentional), or a stolen unlocked phone. +- **Enrollment-time identity.** django-mfa verifies that whoever is logged in + controls the factor; whether that person should have been logged in is your login + flow's job. +- **Account recovery policy.** There is no back door. An administrator deleting a + user's `Authenticator` rows is the recovery path, and verifying identity before + doing so is on you. + +## Production checklist + +Before turning this on for real users: + +**Configuration** + +- [ ] `MFA_EXEMPT_PATHS` includes your logout URL, and you have tested logging out + from a half-verified session. +- [ ] `MFA_FIDO2_RP_ID` is your registrable domain, set once, and recorded somewhere + as never-to-be-changed. +- [ ] `manage.py check` is clean, with nothing added to `SILENCED_SYSTEM_CHECKS` that + you can't justify. +- [ ] `MFA_ISSUER_NAME` is set, so authenticator apps show your product name. + +**Infrastructure** + +- [ ] **A shared cache backend** (Redis, Memcached) — not `LocMemCache` — or rate + limiting is per-process and your effective limit is multiplied by your worker + count. +- [ ] HTTPS everywhere. WebAuthn requires a secure context, and a session cookie + carrying a verified MFA state deserves `SESSION_COOKIE_SECURE = True`. +- [ ] Server clock synced via NTP. TOTP tolerates one 30-second window either side; + drift beyond that rejects correct codes. + +**Data** + +- [ ] Decide about `MFA_SECRET_ENCRYPTION_KEYS`. If you enable it, the keys are in + your secret store, and you know that existing plaintext secrets are not + retroactively encrypted. +- [ ] Your database backups are protected at least as well as your password hashes — + they now contain second-factor material too. + +**Process** + +- [ ] A documented recovery procedure for a user with no device and no recovery + codes, including how staff verify identity first. +- [ ] Your support team knows that deleting an `Authenticator` row removes the user's + second factor entirely. +- [ ] If you plan to narrow `MFA_FACTORS` later, you know it silently de-protects + users enrolled in the removed factor — see the warning in {doc}`settings`. + +## Reporting a vulnerability + +Please report security issues privately rather than in a public issue tracker: +[open a security advisory](https://github.com/MicroPyramid/django-mfa/security/advisories/new) +on the repository. diff --git a/docs/settings.md b/docs/settings.md new file mode 100644 index 0000000..b626b72 --- /dev/null +++ b/docs/settings.md @@ -0,0 +1,182 @@ +# Settings reference + +Every setting below lives in `django_mfa.conf.DEFAULTS` and is read through +`django_mfa.conf.settings`, which falls back to the default shown when the host +project hasn't set it. Set them in your project's normal settings module — there +is no separate config object to construct. + +django-mfa reads **only** the names listed here. Asking `django_mfa.conf.settings` +for anything else raises `AttributeError` rather than silently returning `None`, +so a typo in a setting name surfaces immediately instead of quietly disabling a +feature. + +Nothing here is required to get started except the two marked **required for +WebAuthn** — see {doc}`installation_setup`. + +## Core + +| Setting | Default | Purpose | +|---|---|---| +| `MFA_FACTORS` | `["totp", "recovery_codes", "webauthn"]` | Which built-in factor adapters get registered at startup. See [Choosing which factors to offer](#choosing-which-factors-to-offer) below. | +| `MFA_ISSUER_NAME` | `None` | Issuer label shown next to the username in the user's authenticator app when enrolling TOTP. Set it to your product name; without it the app shows the username alone, which is confusing for anyone with more than one account. | +| `MFA_EXEMPT_PATHS` | `[]` | URL paths reachable while a session is pending a second factor. **Must include your logout URL** — see the warning below. | + +## Appearance + +| Setting | Default | Purpose | +|---|---|---| +| `MFA_BASE_TEMPLATE` | `"django_mfa/base.html"` | Template every django-mfa page extends. Pointing this at your own base template is usually all the theming you need — see {doc}`customizing`. | + +## Convenience + +| Setting | Default | Purpose | +|---|---|---| +| `MFA_REMEMBER_MY_BROWSER` | `False` | When `True`, a signed cookie trusts a browser for `MFA_REMEMBER_DAYS` after it completes one second-factor challenge, skipping the challenge on later logins. | +| `MFA_REMEMBER_DAYS` | `90` | How long a trusted-browser cookie stays valid. Ignored unless the above is on. | +| `MFA_QUICKLOGIN` | `False` | Sets a non-authenticating hint cookie naming the last account to log in on this browser, so your login page can offer a passkey before asking for a username. See {doc}`recipes`. | + +## Hardening + +| Setting | Default | Purpose | +|---|---|---| +| `MFA_VERIFY_RATE_LIMIT` | `"5/5m"` | Failed second-factor attempts allowed per user per factor type, as `"/"` where unit is `s`, `m`, or `h`. See {doc}`security`. | +| `MFA_SECRET_ENCRYPTION_KEYS` | `None` | List of keys used to encrypt TOTP secrets at rest. The first key encrypts; every key is tried when decrypting. See [Encrypting stored secrets](#encrypting-stored-secrets). | +| `MFA_OWNED_BY_ENTERPRISE` | `False` | When `True`, users cannot remove their own WebAuthn authenticators from the security settings page — e.g. an organization-issued security key an administrator manages instead. | + +## WebAuthn (security keys and passkeys) + +Only consulted when a WebAuthn adapter is registered. A project that sets +`MFA_FACTORS = ["totp", "recovery_codes"]` can ignore this whole section. + +| Setting | Default | Purpose | +|---|---|---| +| `MFA_FIDO2_RP_ID` | `None` | **Required for WebAuthn.** Your registrable domain, e.g. `"example.com"`. See the warning below before setting it. | +| `MFA_FIDO2_RP_NAME` | `"django-mfa"` | Human-readable relying party name shown by some authenticators during registration. Set this to your product name. | +| `MFA_FIDO2_RESIDENT_KEY` | `"preferred"` | The `residentKey` requirement passed to `navigator.credentials.create()`: `"required"`, `"preferred"`, or `"discouraged"`. Must be `"required"` or `"preferred"` for passwordless login, which depends on a discoverable credential. | +| `MFA_FIDO2_USER_VERIFICATION` | `"preferred"` | The `userVerification` requirement: `"required"`, `"preferred"`, or `"discouraged"`. At `"required"`, a passkey login only ever succeeds when it *also* satisfies the second factor — see {doc}`mfa_flow`. | +| `MFA_FIDO2_AUTHENTICATOR_ATTACHMENT` | `None` | Restricts registration to `"platform"` (built-in: Touch ID, Windows Hello) or `"cross-platform"` (roaming: a USB security key). `None` allows either, which is almost always what you want. | +| `MFA_FIDO2_ATTESTATION_PREFERENCE` | `"none"` | The `attestation` conveyance preference: `"none"`, `"indirect"`, or `"direct"`. Leave at `"none"` unless you have a compliance reason to collect attestation statements — `"direct"` makes some browsers show an extra privacy prompt. | + +:::{warning} +**`MFA_FIDO2_RP_ID` cannot be changed later without invalidating every registered credential.** +WebAuthn credentials are cryptographically bound to the relying party ID at +registration time; changing it makes every previously registered security key and +passkey unusable, with no way to migrate them — affected users must delete and +re-enroll. Set it once, up front, to your registrable domain (e.g. `"example.com"`), +and treat any later change as equivalent to revoking every WebAuthn credential in +the system. `django_mfa.checks` (`django_mfa.E001` / `django_mfa.E002`) refuses to +start the project at all if this is unset or does not match `ALLOWED_HOSTS` — that +is deliberate: it is far cheaper to catch this before a single credential is ever +registered against the wrong value than after. +::: + +## Choosing which factors to offer + +`MFA_FACTORS` controls which built-in adapters are registered at app startup +(`django_mfa.adapters.register_builtins`, called from `DjangoMfaAppConfig.ready()`). +The default registers all three, so an existing install behaves the same unless you +set this explicitly. + + # TOTP only: no security keys, no passkeys. + MFA_FACTORS = ["totp", "recovery_codes"] + +Narrowing the list also switches off the WebAuthn-only system checks below, since +no WebAuthn adapter is ever registered and there is nothing for them to validate — +a TOTP-only project needs no `MFA_FIDO2_*` settings and no `WebAuthnBackend`. + +An unknown name in this list raises `ImproperlyConfigured` at startup, naming the +valid values, rather than silently registering nothing. + +:::{warning} +**Narrowing `MFA_FACTORS` silently de-protects users already enrolled in the factor you removed.** +Whether a user is challenged is decided from the *registered* adapters, so dropping +`"totp"` means a user whose only factor is TOTP now logs in with a password alone — +no error, no warning, and their `Authenticator` row still sitting in the database. +This is deliberate: the alternative is challenging users with a picker offering zero +ways to answer it, which locks them out instead. Treat removing a factor as a +migration — require affected users to enroll in a remaining factor *before* you +narrow the list, not after. +::: + +:::{warning} +**Set `MFA_EXEMPT_PATHS` to include your project's logout URL.** +`MfaMiddleware` redirects any authenticated, not-yet-verified request to the +second-factor picker, for every path except the ones it derives automatically (the +picker itself and each registered factor's verify page) plus whatever you list here. +A user who cannot complete their second factor — lost device, no recovery codes left +— and whose logout view isn't exempt has **no way to log out**: every request they +make, including to `/logout/`, bounces back to the picker. django-mfa cannot know +your logout URL on its own; list it explicitly: + + MFA_EXEMPT_PATHS = ["/logout/"] + +Paths are matched exactly against `request.path`, so include the full path as +mounted, with its trailing slash. +::: + +## System checks + +django-mfa registers system checks that run on `manage.py check` — and therefore on +`migrate` and `runserver`, which run checks first — to catch the WebAuthn +misconfigurations above before they can affect a real user. + +All three 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. + +| Check ID | Severity | Condition | +|---|---|---| +| `django_mfa.E001` | Error | WebAuthn is active and `MFA_FIDO2_RP_ID` is unset. | +| `django_mfa.E002` | Error | WebAuthn is active and `MFA_FIDO2_RP_ID` is not a suffix of any `ALLOWED_HOSTS` entry. | +| `django_mfa.E003` | Error | WebAuthn is active and `django_mfa.backends.WebAuthnBackend` is missing from `AUTHENTICATION_BACKENDS`. | + +`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 +explicit `backend=` argument, which succeeds regardless of `AUTHENTICATION_BACKENDS`. +The break only shows up one request later, when Django's own `get_user()` re-checks +that backend path against `AUTHENTICATION_BACKENDS` and, finding it absent, silently +resolves `request.user` to `AnonymousUser` — no exception, no log line, just a user +who was "logged in" a moment ago and is now anonymous again. Catching this at startup +is far cheaper than a support ticket. + +All three are `Error` rather than `Warning` deliberately: each guards a failure mode +that is otherwise silent in production, not a style nit. If a check fires for a +reason you understand and have already accounted for — say you provision +`MFA_FIDO2_RP_ID` from a source Django's check framework can't see at check time — +the standard Django escape hatch applies: + + SILENCED_SYSTEM_CHECKS = ["django_mfa.E001"] + +Prefer fixing the configuration (or narrowing `MFA_FACTORS`) over silencing one of +these. Silencing does not make the failure mode go away, only the warning about it. + +## Encrypting stored secrets + +By default, TOTP secrets are stored in the database in plaintext. Setting +`MFA_SECRET_ENCRYPTION_KEYS` to a list of one or more keys encrypts every secret +written from that point on, using the first key in the list, while still reading +anything encrypted with an older key still present: + + MFA_SECRET_ENCRYPTION_KEYS = [ + "current-key", # everything new is encrypted with this one + "previous-key", # still readable; drop once nothing uses it + ] + +That list *is* the rotation mechanism: add the new key at the front, redeploy, and +once nothing needs the old key anymore, drop it. There is no separate rotation +command to run. + +Two limits worth knowing: + +- **Existing plaintext secrets are not retroactively encrypted.** They are read + unchanged and only encrypted the next time they're written — in practice, when the + user re-enrolls. Turning this on protects new and re-enrolled secrets, not your + existing table. +- **Losing every key in the list makes those secrets unreadable**, which locks + affected users out of TOTP until they re-enroll. Keep the keys wherever you keep + `SECRET_KEY`, and with the same care. + +WebAuthn credentials store no secret — only a public key — so this setting does not +apply to them. Recovery codes are hashed rather than encrypted, which is not +configurable. diff --git a/docs/upgrading.md b/docs/upgrading.md new file mode 100644 index 0000000..5808c5f --- /dev/null +++ b/docs/upgrading.md @@ -0,0 +1,88 @@ +# Upgrading from django-mfa 2.x/3.x + +This release is a ground-up rewrite: U2F support is gone, TOTP and WebAuthn/passkey support are built around a single polymorphic `Authenticator` model instead of one table per factor, and several URLs, session keys, and settings from earlier releases no longer exist. Read this whole page before upgrading a project that has real users with MFA already enabled -- in particular the note on migration `0007` below, which is **irreversible**. + +## 1. Django 4.2+ and Python 3.10+ are now required + +Earlier releases supported Django 2.2-3.2 and Python 3.6-3.10. This release requires Django 4.2 or 5.2, and Python 3.10-3.13 (see `pyproject.toml`). The old code could not run on a newer Django at all -- it imported `django.utils.http.is_safe_url` and `django.utils.translation.ugettext`, both removed in Django 4.0 -- so there is no supported path that runs both the old and new code against the same Django version; the Django/Python upgrade and the django-mfa upgrade have to happen together. + +## 2. U2F support is removed entirely + +U2F (the `U2FKey` model, `python-u2flib-server` dependency, and every `u2f`-prefixed view/URL) is gone. There is no migration path for previously registered U2F keys: migration `0007_drop_legacy_models` drops the `U2FKey` table outright, and no prior migration ever copied U2F registrations onto the new `Authenticator` model (unlike TOTP and recovery codes -- see the next section). **Every user whose only second factor was a U2F key loses that factor on upgrade and must re-enroll** with TOTP or a WebAuthn security key/passkey (which is the modern, browser-native successor to U2F and covers the same "physical security key" use case). Warn your users before upgrading if any of them rely on U2F today. + +## 3. `UserOTP`, `UserRecoveryCodes`, and `U2FKey` are replaced by `Authenticator` + +The three old models are gone from `django_mfa/models.py`. In their place: + +- `Authenticator` -- one polymorphic model for every factor type (`type` is `"totp"`, `"webauthn"`, or `"recovery_codes"`), factor-specific data in a `data` `JSONField`, and a `user` FK. A user can hold multiple `Authenticator` rows (e.g. several WebAuthn keys), but at most one `totp` and one `recovery_codes` row each (a database constraint enforces this). +- `MfaUserHandle` (`django_mfa/handles.py`) -- a new model, one row per user, holding the stable opaque WebAuthn "user handle" passkey ceremonies use to resolve a user before any password is typed. Created lazily, the first time a user's handle is needed. + +Migration `0005_migrate_to_authenticator` copies every existing `UserOTP` row to an `Authenticator` row of type `totp`, and every user's `UserRecoveryCodes` rows to a single `Authenticator` row of type `recovery_codes` holding all of that user's codes together (see item 10 below for what state those codes are in afterwards). Migration `0007` then drops the old tables -- see item 8. + +If your project queries `UserOTP`/`UserRecoveryCodes`/`U2FKey` directly (rather than only through django-mfa's own views), that code must be updated to query `Authenticator` instead before upgrading. + +## 4. `request.session["verfied_otp"]`/`["verfied_u2f"]` are replaced by `request.session["mfa"]` + +The old middleware tracked verification state in two separate, historically **misspelled** session keys, `verfied_otp` and `verfied_u2f` (not "verified"). Both are gone. There is now a single `request.session["mfa"]` dict: + + {"verified": bool, "method": str | None, "at": int | None} + +managed entirely through `django_mfa.session` (`start_pending()`, `mark_verified()`, `is_verified()`, `is_pending()`, `reset()`) -- do not read or write `request.session["mfa"]` directly from host-project code; use those functions, since the shape of the dict is not a public contract. If any host-project code inspected the old keys directly (e.g. to customise a template based on MFA status), update it to use `django_mfa.session.is_verified(request)` instead. + +## 5. Nine URL names are gone + +| Removed name | Replaced by | +|-----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `verify_second_factor` | `mfa:verify` (the factor picker) | +| `verify_second_factor_totp` | `mfa:verify_factor` with `factor_type="totp"` | +| `verify_second_factor_u2f` | nothing -- U2F is gone (see item 2) | +| `u2f_keys` | nothing -- U2F is gone | +| `add_u2f_key` | nothing -- U2F is gone | +| `configure_mfa` | `mfa:enroll_factor` with `factor_type="totp"` | +| `enable_mfa` | `mfa:enroll_factor` (enrolling *is* enabling) | +| `disable_mfa` | `mfa:manage` (POST, deletes one authenticator) | +| `recovery_codes_download` | nothing -- download is now built client-side from the codes already in the `mfa:recovery_codes` page (see `recovery_codes.html`); the server never serves plaintext codes a second time. | + +`mfa:security_settings` and `mfa:recovery_codes` keep their old names (their URLs/views changed, but not what host projects call them by). Any `{% url %}` tag, hardcoded link, or `reverse()` call in your project referencing a removed name will raise `NoReverseMatch` after upgrading -- search your templates and Python code for the names in the left column above before deploying. + +## 6. Host projects no longer set `u2f_pre_verify_user_pk`/`u2f_pre_verify_user_backend` + +Under the old U2F contract, a host project's own login view had to stash `request.session['u2f_pre_verify_user_pk']` and `['u2f_pre_verify_user_backend']` by hand *before* calling `django.contrib.auth.login()`, so the (U2F-specific) verify view could rebuild the pending user from those keys. That contract is gone along with U2F itself. A `user_logged_in` signal receiver (`django_mfa.signals.stamp_pending_verification`) now does this automatically for every login, regardless of which view performed it or which authentication backend was used -- **remove any code your login view has that sets those two session keys**; it's dead code that no longer does anything (the new signal doesn't read them), and leaving it in place isn't harmful, just pointless. + +## 7. `MFA_FIDO2_RP_ID` is now required for WebAuthn + +New setting, required if you want to offer security keys or passkeys at all. There is no sensible default django-mfa could pick on your behalf -- it must be your registrable domain. `manage.py check` (and therefore `migrate`/`runserver`) now refuses to start if it's unset, or set to something that isn't a suffix of any `ALLOWED_HOSTS` entry (checks `django_mfa.E001`/`django_mfa.E002` -- see {doc}`settings`). + +:::{warning} +Set this once and do not change it later. WebAuthn credentials are cryptographically bound to the RP ID at registration time; changing it invalidates every previously registered security key and passkey, with no way to migrate them. +::: + +## 8. Migration `0007` is irreversible -- roll back from a backup, not `migrate` + +`0007_drop_legacy_models` drops the `UserOTP`, `UserRecoveryCodes`, and `U2FKey` tables. It is **deliberately marked irreversible**: running `migrate django_mfa 0006` (or further back) after `0007` has been applied raises `IrreversibleError` and refuses to touch the database. + +This is not a technical limitation left unfixed -- it is intentional, and here is why. Django's auto-generated reverse for a dropped table only recreates the table's *schema*; it cannot un-drop the table and bring back the rows that were in it. That looks like a merely cosmetic gap ("the table comes back empty") until you consider the full rollback sequence a `migrate` backwards would perform: unwinding `0007` recreates empty `UserOTP`/`UserRecoveryCodes` tables, and unwinding `0005` right after it *deletes every \`\`Authenticator\`\` row that migration created* (it assumes the original legacy rows it copied from are "still present and untouched" -- true only if `0007` was never applied). The end state: every migrated user's TOTP secret and recovery codes are gone from **both** places at once, and no step in that sequence raises an error -- the operator sees every migration report success. `0007` blocks exactly that sequence from being possible. + +**If you need to roll back an upgrade that has already applied migration 0007, the only correct way is to restore a database backup taken before the upgrade ran.** Take that backup *before* running `migrate` as a matter of course for this upgrade specifically -- do not rely on being able to `migrate` your way back out. Once `0007` has run, the pre-migration TOTP secrets and recovery codes exist nowhere else for Django to restore them from. + +(U2F registrations were never copied onto `Authenticator` in the first place -- see item 2 -- so they have no forward path regardless of this migration's reversibility. This item is only about the TOTP/recovery-code data that migration `0005` did copy forward.) + +## 9. TOTP now tolerates a small clock/window drift + +The old TOTP verification used zero tolerance: a code was only accepted in the exact 30-second window the server computed at that instant. This release accepts the previous and next 30-second code as well as the current one (`TOTP_VALID_WINDOW = 1` in `django_mfa/adapters/totp.py`), matching what Google Authenticator, django-otp, and allauth all do. This is strictly more permissive than before -- it fixes spurious rejections from phone clock drift or a user submitting right at a window boundary, and cannot cause a previously-accepted code to be rejected -- so no action is required, but it's worth knowing the acceptance window widened slightly (three times the guess space of a single 6-digit code, which rate limiting, see `MFA_VERIFY_RATE_LIMIT`, already accounts for). + +## 10. Recovery codes are hashed at rest -- migrated codes are the exception, until regenerated + +Recovery codes generated by this release are hashed at rest (via Django's own password hasher) and, like a password, can never be displayed again once generated -- only shown once, at generation time. Codes carried forward by migration `0005` from a pre-upgrade `UserRecoveryCodes` table are the one exception: they were stored in plaintext under the old schema, and the migration copies them across *as plaintext*, marking that `Authenticator` row with `data["migrated_plaintext"] = True` so verification (`django_mfa/adapters/recovery_codes.py`) knows to compare them directly instead of via the password hasher. **Those specific codes remain plaintext in the database until the user regenerates them** -- simply upgrading does not hash anything that was already stored. There is currently no dedicated "regenerate my recovery codes" affordance in the UI beyond removing the recovery-codes authenticator via `mfa:manage` and revisiting `mfa:recovery_codes` (which then generates a fresh, hashed set); if this matters for your threat model, prompt affected users to do exactly that after upgrading, or run a one-off script that does the same for every user with `data["migrated_plaintext"]` set. + +## 11. Passwordless login requires adding `WebAuthnBackend` to `AUTHENTICATION_BACKENDS` + +New in this release. If you want users to be able to sign in with a passkey alone (no password, no separate second-factor step), add: + + AUTHENTICATION_BACKENDS = [ + "django_mfa.backends.WebAuthnBackend", + "django.contrib.auth.backends.ModelBackend", + # ... any other backends your project already used + ] + +Passwordless login (`mfa:passkey_begin`/`mfa:passkey_complete`) is exposed by `django_mfa.urls` unconditionally -- there is no setting that turns the URLs themselves off. If a user manages to register a WebAuthn credential and this backend is missing, the login *appears* to succeed (`auth.login()` doesn't validate the backend it's given against `AUTHENTICATION_BACKENDS`) and then silently, permanently degrades that session to `AnonymousUser` on the very next request, with no error anywhere. `manage.py check` now catches this at startup (`django_mfa.E003` -- see {doc}`settings`) whenever `MFA_QUICKLOGIN` is on or a WebAuthn adapter is registered (true by default), so a project missing this backend will fail to start rather than fail silently in production. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f1b3056 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,120 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "django-mfa" +version = "4.0.0a1" +description = "Second-factor authentication for Django: authenticator apps (TOTP), security keys and passkeys (WebAuthn), and recovery codes." +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +license-files = ["LICENSE"] +authors = [{ name = "Micropyramid", email = "hello@micropyramid.com" }] +keywords = ["django", "mfa", "2fa", "totp", "webauthn", "passkey", "fido2"] + +dependencies = [ + "Django>=4.2", + "fido2>=1.1.2,<3", + "qrcode>=7.0.0,<9", +] + +# No "License ::" classifier: PyPI rejects a license classifier alongside the +# SPDX `license` expression above (PEP 639). The expression is the source of +# truth. +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Web Environment", + "Framework :: Django", + "Framework :: Django :: 4.2", + "Framework :: Django :: 5.2", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", + "Topic :: Security", +] + +[project.urls] +Homepage = "https://github.com/MicroPyramid/django-mfa" +Documentation = "http://django-mfa.readthedocs.io/en/latest/" +Source = "https://github.com/MicroPyramid/django-mfa" +Issues = "https://github.com/MicroPyramid/django-mfa/issues" + +# PEP 735 dependency groups. These are DEVELOPMENT-ONLY and are never seen by +# a project that installs django-mfa -- unlike `dependencies` above, they are +# not published in the wheel's metadata. `uv sync` installs them by default; +# pip needs `pip install --group dev`. +[dependency-groups] +dev = [ + "coverage>=7", + "ruff>=0.14", + # Required by django_mfa/tests/test_packaging.py, which builds a real + # wheel and asserts every package on disk ships in it. Without this the + # packaging tests skip -- and skipping is exactly how the missing + # adapters/ and views/ packages went unnoticed in the first place. + "build>=1.2", +] +# The docs are Markdown; myst-parser is what lets Sphinx read them at all. +# Kept as its own group so a test run doesn't pull the whole Sphinx stack. +docs = [ + "sphinx>=7", + "sphinx-rtd-theme>=2", + "myst-parser>=3", +] + +# --------------------------------------------------------------------------- +# Build configuration +# +# `packages` names the ROOT package only. Hatchling then ships every module +# under it -- django_mfa.adapters, django_mfa.views, and anything added later +# -- with nothing to enumerate and therefore nothing to forget. The setup.py +# this replaced hardcoded a three-entry `packages=[...]` list that predated +# django_mfa/adapters/ and django_mfa/views/, so the built wheel omitted both +# and `pip install django-mfa` raised ImportError on startup. See +# django_mfa/tests/test_packaging.py, which builds a wheel and asserts every +# package present on disk is actually in it. +# +# Templates and static files live inside the package directory, so they are +# included automatically -- the MANIFEST.in that used to declare them is gone. +# --------------------------------------------------------------------------- +[tool.hatch.build.targets.wheel] +packages = ["django_mfa"] + +[tool.hatch.build.targets.sdist] +# Ship what a consumer or packager might need to build and verify the project, +# and nothing else. The sandbox is a demo host project, not part of the +# distribution. +include = [ + "/django_mfa", + "/docs", + "/test_runner.py", + "/README.md", + "/LICENSE", +] + +[tool.ruff] +line-length = 88 +target-version = "py310" +# migrations are generated; docs/conf.py is Sphinx boilerplate; sandbox is a +# throwaway demo host project, not part of the distribution. +extend-exclude = ["django_mfa/migrations", "sandbox", "docs/conf.py"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear + "DJ", # flake8-django +] + +[tool.ruff.lint.isort] +known-first-party = ["django_mfa"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 93d4d41..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -black==20.8b1 -django-argonauts==1.2.0 -python-u2flib-server==5.0.0 diff --git a/sandbox/requirements.txt b/sandbox/requirements.txt deleted file mode 100644 index a4edd41..0000000 --- a/sandbox/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -cffi==1.14.6 -cryptography==3.3.2 -Django==2.2.19 -django-argonauts==1.2.0 -django-debug-toolbar==1.11 -pycparser==2.19 -python-u2flib-server==5.0.0 -pytz==2018.9 -qrcode==6.1 -six==1.12.0 -sqlparse==0.3.0 -whitenoise==3.1 diff --git a/sandbox/sample/views.py b/sandbox/sample/views.py index 8acccf5..cf85564 100644 --- a/sandbox/sample/views.py +++ b/sandbox/sample/views.py @@ -20,7 +20,7 @@ def index(request): # this user, and the old u2f_pre_verify_user_pk/ # u2f_pre_verify_user_backend session dance (and the U2F-specific # is_u2f_enabled() check that gated it) is gone. See - # docs/upgrading.rst item 6. + # docs/upgrading.md item 6. login(request, form.user) return JsonResponse({"error": False}) else: diff --git a/sandbox/test_django_mfa/settings.py b/sandbox/test_django_mfa/settings.py index 18078ce..fc575fd 100644 --- a/sandbox/test_django_mfa/settings.py +++ b/sandbox/test_django_mfa/settings.py @@ -158,12 +158,11 @@ # } # # with 'whitenoise.middleware.WhiteNoiseMiddleware' added to MIDDLEWARE -# (directly after SecurityMiddleware) and 'whitenoise' in requirements.txt. +# (directly after SecurityMiddleware) and whitenoise installed. # Not wired in here: this sandbox targets local `manage.py runserver`, which # serves static files itself via django.contrib.staticfiles without any -# storage backend needing to be importable, and task 19 could not add a new -# runtime dependency to verify against. Wire up the STORAGES block above (and -# add whitenoise back to sandbox/requirements.txt) before deploying this +# storage backend needing to be importable. Wire up the STORAGES block above +# (and add whitenoise, e.g. `uv run --with whitenoise`) before deploying this # sandbox anywhere `runserver` isn't serving static files for you. STORAGES = { "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, @@ -173,7 +172,7 @@ } # Required for WebAuthn (security keys/passkeys) -- see the warning in -# docs/installation_setup.rst: this cannot be changed later without +# docs/settings.md: this cannot be changed later without # invalidating every credential registered against it. "localhost" is only # appropriate because this sandbox is for local development; a real # deployment must set this to its actual registrable domain. @@ -181,5 +180,5 @@ # Without this, a user stuck pending a second factor (lost device, no # recovery codes) could never reach /logout/ -- see the warning in -# docs/installation_setup.rst. +# docs/settings.md. MFA_EXEMPT_PATHS = ['/logout/'] diff --git a/setup.py b/setup.py deleted file mode 100755 index 45bf4fb..0000000 --- a/setup.py +++ /dev/null @@ -1,58 +0,0 @@ -import os -from setuptools import setup - -with open(os.path.join(os.path.dirname(__file__), "README.rst")) as readme: - README = readme.read() - -# allow setup.py to be run from any path -os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) -PROJECT_NAME = "django-mfa" - -data_files = [] -for dirpath, dirnames, filenames in os.walk(PROJECT_NAME): - for i, dirname in enumerate(dirnames): - if dirname.startswith("."): - del dirnames[i] - if "__init__.py" in filenames: - continue - elif filenames: - for f in filenames: - data_files.append(os.path.join( - dirpath[len(PROJECT_NAME) + 1:], f)) - -install_requires = [ - "Django>=4.2", - "fido2>=1.1.2,<3", - "qrcode>=7.0.0,<9", -] - -setup( - name="django-mfa", - version="4.0.0a1", - packages=["django_mfa", "django_mfa.templatetags", "django_mfa.migrations"], - include_package_data=True, - description="A Django deployment package for all hosting types.", - long_description=README, - url="https://github.com/MicroPyramid/django-mfa", - author="Micropyramid", - author_email="hello@micropyramid.com", - python_requires=">=3.10", - classifiers=[ - "Environment :: Web Environment", - "Framework :: Django", - "Framework :: Django :: 4.2", - "Framework :: Django :: 5.2", - "Intended Audience :: Developers", - "Operating System :: OS Independent", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Topic :: Internet :: WWW/HTTP", - "Topic :: Internet :: WWW/HTTP :: Dynamic Content", - ], - install_requires=install_requires, -) diff --git a/test_runner.py b/test_runner.py index 19325f2..4accea8 100644 --- a/test_runner.py +++ b/test_runner.py @@ -1,6 +1,7 @@ #!/usr/bin/env python import os import sys + import django from django.conf import settings from django.test.utils import get_runner diff --git a/tox.ini b/tox.ini deleted file mode 100644 index ce7e83b..0000000 --- a/tox.ini +++ /dev/null @@ -1,9 +0,0 @@ -[tox] -envlist = py{310,311,312,313}-django{42,52} - -[testenv] -deps = - -rrequirements.txt - django42: Django>=4.2,<4.3 - django52: Django>=5.2,<5.3 -commands = python test_runner.py diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..b88305a --- /dev/null +++ b/uv.lock @@ -0,0 +1,1316 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "asgiref" +version = "3.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "build" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10.2'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/e0/df5e171f685f82f37b12e1f208064e24244911079d7b767447d1af7e0d70/build-1.5.0.tar.gz", hash = "sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647", size = 89796, upload-time = "2026-04-30T03:18:25.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl", hash = "sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f", size = 26018, upload-time = "2026-04-30T03:18:23.644Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/d2/2cde336b375f55c76ca670f0be3978cc048e31e24f3b4d7ce8473150a388/cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be", size = 183779, upload-time = "2026-08-03T21:19:15.602Z" }, + { url = "https://files.pythonhosted.org/packages/94/1a/4b2f7c92293ba05cbd4a9a1b28faaf0326272d9488e6354657571c48a7aa/cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b", size = 184178, upload-time = "2026-08-03T21:19:16.67Z" }, + { url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" }, + { url = "https://files.pythonhosted.org/packages/37/15/180e0dab27b9312c7479003d14c9e547634b7dcb934e2cc4650e1b131a7a/cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98", size = 205422, upload-time = "2026-08-03T21:19:19.96Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/03026f0c850cbbaa9030750490225b4a7f4d524ea4df72c3cc740a90f4ef/cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9", size = 205444, upload-time = "2026-08-03T21:19:21.246Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" }, + { url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" }, + { url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" }, + { url = "https://files.pythonhosted.org/packages/99/7f/040f9e163e4acac3ee3d85b02d00b2576e7ca980d8785f0a3a5f1a9bf7f5/cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41", size = 174578, upload-time = "2026-08-03T21:19:27.338Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0b/644a2ec1a4eaba49c2939410bb1eb1d25b09d6d0582f5d2f95c537043725/cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1", size = 185082, upload-time = "2026-08-03T21:19:28.409Z" }, + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/31/4971872b3ed8715346231fb6eb4da8fcba65a4143c189db151ee28a2812b/charset_normalizer-3.5.0.tar.gz", hash = "sha256:49bd5feb59b0bf3cbf6ebcf4352e371c95b9da9bacd4449f8b64d0ad2c10a26e", size = 169295, upload-time = "2026-08-12T14:35:31.624Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/8e/1e9a03657c2f12960c67b05e0550059787d4c8fc3fae822672f4fe7f40c5/charset_normalizer-3.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d2478bd3b2ead3962a484fb802891be40d10049fb74f83e09cb4463fad023fea", size = 349795, upload-time = "2026-08-12T14:31:47.546Z" }, + { url = "https://files.pythonhosted.org/packages/76/e5/a42c8884ec0321c56124a5b43c135efe75260dad4f70ea0ffba4dcac85ec/charset_normalizer-3.5.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7cdded069549b5eae3d5d9bb6c2e5bb4fe83f9b81863e2a193cd747bf197aebb", size = 250677, upload-time = "2026-08-12T14:31:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/5d/21/d94021b005d2e660fd13634eecfe4ad378b0d0bbb45134809aed42b94a99/charset_normalizer-3.5.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aff38231e3171c578b2c449a01afa44e9ff40844597a32873da102394f63d28e", size = 239868, upload-time = "2026-08-12T14:31:50.107Z" }, + { url = "https://files.pythonhosted.org/packages/47/d0/8b9b07e41501bfcdfe380b826481033ca2eb5fa4b5507eb64dbc4cba9885/charset_normalizer-3.5.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4346a693c08b1d0cfc0e3325bfb0ecd4322fb1a6904d68cf416f8da5e981b234", size = 280688, upload-time = "2026-08-12T14:31:51.182Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/b83b3469e596adff778bf3c8ca8f705822c1c3c0ada8477a0d00113bfa59/charset_normalizer-3.5.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b787efadba00f5da6fe89513bfbe3852d52ca3a448fdec165765cb3b44a80248", size = 277133, upload-time = "2026-08-12T14:31:52.362Z" }, + { url = "https://files.pythonhosted.org/packages/0c/aa/a080c4facaab4ece837558649eb6e2c760867d1c0ea52220cbbc4e89dcaa/charset_normalizer-3.5.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:143792a43e06dc3b27fc891948406e251502dc19ff9216cd80182b79131be5c5", size = 261492, upload-time = "2026-08-12T14:31:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/f9/24/e23bcc9d4bc897bd620bc39c6b6052574f5ef93b85b50b175dd427a7dc07/charset_normalizer-3.5.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d74bcf1cdd8ac8267fb216473ce6b112efa07b163536288094541415084d131c", size = 259739, upload-time = "2026-08-12T14:31:54.86Z" }, + { url = "https://files.pythonhosted.org/packages/ef/85/f4c391c807f67c6483d02c02d28683e2fe229403728369c0cd33f61e4292/charset_normalizer-3.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbe543d957213fc9a3db4979a8e171b7aa7504c1d737029defdb03a6095a38", size = 252120, upload-time = "2026-08-12T14:31:56.227Z" }, + { url = "https://files.pythonhosted.org/packages/4e/cb/dcf5aa14e24270613fafa2f02b3979820c244610410e5508696ca96bd0d2/charset_normalizer-3.5.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3587d94b5c9f05c2dc4c3f3d47aba6375ff141a21adae3051d8d4d53e8a937c0", size = 241622, upload-time = "2026-08-12T14:31:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/83/a9/decd1c1cc01ccbfce66845096e3945397e4ede354f31cc5cb28fc6ca601b/charset_normalizer-3.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a7cb4cd266bd85613367fb85a30cfbf6fe6349919e87e18ca8dba584951bfb8a", size = 280745, upload-time = "2026-08-12T14:31:58.91Z" }, + { url = "https://files.pythonhosted.org/packages/8b/47/420ddefc04f089e4497b3bcbadea37586477713300c13204fb4a7c736071/charset_normalizer-3.5.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ffdd7ac514301d0a67f7c23b9f2b431ef909a3c3dd6c3766668d0a6f5900c94e", size = 258669, upload-time = "2026-08-12T14:32:00.069Z" }, + { url = "https://files.pythonhosted.org/packages/58/73/01d5173097a5f0a13417e77db9ba3aea24de54421344c8e0c4973bf8cf21/charset_normalizer-3.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3684ebbdffd51329ac44245d1d227d90b965797aa1a8abd026568a1f6ae88811", size = 277344, upload-time = "2026-08-12T14:32:01.211Z" }, + { url = "https://files.pythonhosted.org/packages/41/4a/300ef2598da520744aca7c49370e48732cab1f04de5f784f0e4f0e9e1d11/charset_normalizer-3.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8b8788f114845c01f2b520e0b91ea58d143276cfc0483aa943e815f7b9555c15", size = 263440, upload-time = "2026-08-12T14:32:02.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/5e/78dd366df9ae4810039c0f799ec47d325a5cb76e92bb707ca9039b7c2306/charset_normalizer-3.5.0-cp310-cp310-win32.whl", hash = "sha256:9a1d9b13e5e394e13e3c316f0d910d100b17681ff59797f30da1dba032061296", size = 181680, upload-time = "2026-08-12T14:32:03.932Z" }, + { url = "https://files.pythonhosted.org/packages/67/d6/455982837da317ce47cafe35260f446f218f5843090b77a59ee69aa0ad94/charset_normalizer-3.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:5a54587f93f2e289f8faf25b35c997d4cc75cf677485ac6f50c985715989f99c", size = 205665, upload-time = "2026-08-12T14:32:05.045Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e5/5b285d50d4fc82111c18ea4fff8b5e1916a257b98dcd5cd0abf1c165473b/charset_normalizer-3.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:38a395079f229a631dece74e24c69c1f612536dd51f345a7d6a98abe2d3e047a", size = 184409, upload-time = "2026-08-12T14:32:06.503Z" }, + { url = "https://files.pythonhosted.org/packages/50/42/71e4e3bfe59202feef062c68487f54c6adf501cfbe087ecd93e3cd597fea/charset_normalizer-3.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e46a37ea7fcf9ae01d71b2e5ece19f1565987f3e308394b829197cbefc061f92", size = 349110, upload-time = "2026-08-12T14:32:07.832Z" }, + { url = "https://files.pythonhosted.org/packages/0f/dd/fd3386d0fbd358d3b5c7a2fa5bf312afe6159b04fafeb67d39fa971d7448/charset_normalizer-3.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cdfed4d7a59333c8220c67dd3be4e7a6c887b67453a64394022dcc919570add", size = 250773, upload-time = "2026-08-12T14:32:09.113Z" }, + { url = "https://files.pythonhosted.org/packages/17/ad/4901a66d6d3b17f1096725d7e50266132c16555aa6a70047fe1cf262b4b2/charset_normalizer-3.5.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9491f594859b68052edebd69e05fb045055a713b57a67974e6c1553b4e503c39", size = 240229, upload-time = "2026-08-12T14:32:10.43Z" }, + { url = "https://files.pythonhosted.org/packages/43/b7/1d790e0425e0f4c99e9b89a3956a94a6d2d0c6f01b2a5eca93d8f082d5ac/charset_normalizer-3.5.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:420b19411959eec115063229536788e6b32d0a7fa907d6b940317919120d702d", size = 280757, upload-time = "2026-08-12T14:32:11.628Z" }, + { url = "https://files.pythonhosted.org/packages/44/bb/4b8c8086c67636e52d6354ad17697f54a00b40041ca53dc765737e21709b/charset_normalizer-3.5.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a565303d118ea3b94a4b6c076bf568069726be414e43b06d58f7070b076ce11d", size = 276174, upload-time = "2026-08-12T14:32:12.808Z" }, + { url = "https://files.pythonhosted.org/packages/25/fa/690a11924c40c766d258d2b74d817cc5efe7bbcfceeedc5c0f35256d7524/charset_normalizer-3.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:815f143a91983ba3041bba066e492ae3c42de523fb1c699685a1abf3313b7d1b", size = 261808, upload-time = "2026-08-12T14:32:14.138Z" }, + { url = "https://files.pythonhosted.org/packages/53/2d/4a6945eff0c8f684e3f5b7b978644ab18ba9198da060dbaa1d9206bc6cc9/charset_normalizer-3.5.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c5e981a5ac8641381efe6f0029467500661616a530d27bc6eedfe45f840599f8", size = 259922, upload-time = "2026-08-12T14:32:15.485Z" }, + { url = "https://files.pythonhosted.org/packages/60/4d/10ac7e07bbf7ea569effeb9524e32f345f7e643800b20d538fb4706eab4e/charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1a573e1e428f93908e79e04b349717f400e720f2f82285f0aaaf3ee0ff7f4c79", size = 252315, upload-time = "2026-08-12T14:32:16.764Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/1ddacf7aa7da12097f229a6a4e71a70200937a621b3617f6dc819fb99a66/charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:32e6d56dd825205f81e5c45bcebb4df6a11fb2bbf4969a01ef156d6ced90c224", size = 240600, upload-time = "2026-08-12T14:32:17.956Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/293444d48c8d86fe51552262117134a9fc666d68cbbbc9b8c1b2b35a29be/charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:30ae26a1adcd943690dcbbc47f28be762bae9e08ad7442b78c86b1c0dd5a626c", size = 280788, upload-time = "2026-08-12T14:32:19.1Z" }, + { url = "https://files.pythonhosted.org/packages/12/ed/0e34e40584f51eda38d4a5daf25fd8586366347efd4b2470dbf64710e778/charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f51a19dc52197a20218b05ec5336d0c6b3b09935f838724722032c8d45dc91a", size = 258425, upload-time = "2026-08-12T14:32:20.207Z" }, + { url = "https://files.pythonhosted.org/packages/72/b2/c1b1c27f6f0ef35b21a8bdb592854bbf7f219629db3477f74c0b1380e0ed/charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e44bc2780516b3df986d6fe33103c7080cd9dcd5576fe3cb4b0f64309c8f22b", size = 277437, upload-time = "2026-08-12T14:32:21.345Z" }, + { url = "https://files.pythonhosted.org/packages/b2/bd/a1b7d959a37847675adb2f5978f81703306dd78df4fefb82ee5a1cc5e37f/charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7faa47b56070b3dd6f4898ed28528843ab130d53266cb9948d9b1f3bb1a5c5e8", size = 262947, upload-time = "2026-08-12T14:32:22.613Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fd/a76a2d7e639bfc6a9c869371e34f28231eaca7d7126ec19895aa38eedb51/charset_normalizer-3.5.0-cp311-cp311-win32.whl", hash = "sha256:830c04a49998b5ed58c8b642c65b7b26419397f52392a64121ba9fd0e95e7f9f", size = 181313, upload-time = "2026-08-12T14:32:23.736Z" }, + { url = "https://files.pythonhosted.org/packages/97/84/6fc03e802578df41a2ab9b6a1f26657fb92e32285a603ad5852a4a4f68c1/charset_normalizer-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:8cb9b6892b53bd6d11fa4cde3dbee020b1f0b6656be1fbaa1ec0d4324a7839db", size = 206197, upload-time = "2026-08-12T14:32:24.938Z" }, + { url = "https://files.pythonhosted.org/packages/0a/27/7208360ff1901607359869fcc45ca0989d597f3e30777ba30c7254170587/charset_normalizer-3.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:2403b489c103e9a18c835863fc6dd54361355c8291d4cafdb37492b683440b9b", size = 184925, upload-time = "2026-08-12T14:32:26.123Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3c/045ea64ea5a550870dd8ab60b2242870328d53f17d2be593b4f9f3121474/charset_normalizer-3.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:98820e1ceb25c6df7a80c4fd8efa59cb121f99bc7c4c1693ad94a2caff5b311d", size = 343861, upload-time = "2026-08-12T14:32:27.254Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1b/7502be709db899d5b4801509829188b3a5a10969411da9c846115a5f1b70/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:608553f476fca509537e804c4a71f5eb166ce63b75141f89c2c686ce1aa36956", size = 237550, upload-time = "2026-08-12T14:32:28.385Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ce/66392661375148d9455c17bee25509a54e28c39969e34befa48ec8777936/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6753de11eef42f1c321b26d682957d92c7f7bbce6530f34bbe0f9291dd37cc6f", size = 229673, upload-time = "2026-08-12T14:32:29.668Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/f58c32a8d4ecf55b82ee61ee9aa6a664d4afcd36c72feb4c926fd6fe9af8/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f76dc0a47f94cb9b69d86f01e477f4b0371ca70208b9ccea7e063c41eed9046", size = 260768, upload-time = "2026-08-12T14:32:30.891Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ea/36c90e59a96386174377e855479ec154221ef001e96637e0b23be92489c4/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c387c6bf91b4774e359a48a179e2872b8e8bf741e4fde06ba8d1665eb9a4760a", size = 257880, upload-time = "2026-08-12T14:32:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/23/35/5b85772eb82528ef22ba29487ad544a7049dfd27f35b1a5a55dbc0843048/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14f6904a3cf870abf044df3a8c4924ac6c8ef77e9896586fd37e73ae96cff2af", size = 247547, upload-time = "2026-08-12T14:32:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/b6/14/ba11a99c2a22ab04c2d5383a700b378cb463a78ab15f36444cabc10cd671/charset_normalizer-3.5.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cce46dd29d73e135e8087b96eb62a4aca6d69391b7f97808c6588ebed3178f3", size = 243326, upload-time = "2026-08-12T14:32:34.794Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4a/cadba3f2400b45aa1d62a4ae0298bf58a3b30b1158baf15c338c7ce5b601/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b476cdb63df22da2b91837593380be3ddbe406f36c506c1c91d80e7196b66288", size = 238820, upload-time = "2026-08-12T14:32:35.984Z" }, + { url = "https://files.pythonhosted.org/packages/47/41/d5188b9342d75b72c2b05d3ee373f01a691397e770f001ee05e3b37925f5/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1f56ce84b317ef2a59d7d3461891c7597c79247d2192bb8114c68a1a1debfcc0", size = 231661, upload-time = "2026-08-12T14:32:37.191Z" }, + { url = "https://files.pythonhosted.org/packages/13/5f/df38fa972c4e945c3d8cee2bc4e610613af522fd359c7dc7a74c419f0278/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9ce0f885239357379d92fd9a5fddbe20f0e30e0527c29ba69f8e99eeb1304a76", size = 261459, upload-time = "2026-08-12T14:32:38.369Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d7/043ff7720067a3beee05523465ac9c1c846c68b7884930dd483f72ee5ab6/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:96ae7ab5d8155fde927aa0864fbc8ba3cc4fde6d41ab0c7cea9d6012b4978603", size = 242300, upload-time = "2026-08-12T14:32:39.505Z" }, + { url = "https://files.pythonhosted.org/packages/19/f6/33980b7b802a048e546a6d9ad2ea783a6cf6b10a86aaccd10db462d8b913/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bf91921009025e96ce57a03ced6d14604fc3baf0530351638e9504a55da6fa3b", size = 259101, upload-time = "2026-08-12T14:32:41.02Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b7/0d19bde844bff9165377c1da9ef3c4792a4c24bd49b5b7094d9e6f6ab58b/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0b2e44e6d42d1a4ff78ccc219a93c5449105d10b16198d1aea581080df8073f9", size = 249246, upload-time = "2026-08-12T14:32:42.47Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cc/2c34fdfaacdf0e96e880ef562cbf80a9b5f8ea97e0dd9e57ba348a9c65cf/charset_normalizer-3.5.0-cp312-cp312-win32.whl", hash = "sha256:deb99535e9bf0bea8e274c6413eb939a21be35a3f492678dba4d5b1f4d70f142", size = 178025, upload-time = "2026-08-12T14:32:43.909Z" }, + { url = "https://files.pythonhosted.org/packages/76/d0/c34dbd1df23bcbdc1b5d2f48256340d72fa747f1eb03924a9d2fa35ed85b/charset_normalizer-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54dd1a66fa4bce0ccaf0db9dde336e49b3eec646dc4c1c0991279369d373a14", size = 200143, upload-time = "2026-08-12T14:32:45.254Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/4e6bf1465d60c3d8f488d5bde140d0cb91cd23ccab0dc2895cc6c6982047/charset_normalizer-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:b8ea208b304587d47931b36481342d20336e0d338ab052f8b4305926482598d6", size = 180046, upload-time = "2026-08-12T14:32:46.545Z" }, + { url = "https://files.pythonhosted.org/packages/1d/be/cc7b7b6fc41984902c0d31b06f5d9297e67705c1dae9352608e5540fad09/charset_normalizer-3.5.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:5c23fa4f6eccdd601949cb00f3988c01d64e671d8faba356397971077022e144", size = 211050, upload-time = "2026-08-12T14:32:47.81Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ae/e3ec8f17313609f43f7b323012fdb1ee37b83432277ca4eceba83e00366c/charset_normalizer-3.5.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:07f6f42b5a6325df35b458004fb5f9f29bf502d89287a33c7cdef3590e31de0f", size = 222768, upload-time = "2026-08-12T14:32:49.027Z" }, + { url = "https://files.pythonhosted.org/packages/c5/50/9f9c0d7ccc1512d49e27a0e7c12c58ec71dfe91698fa4326f058c33e1f1b/charset_normalizer-3.5.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8efc3f1563ed431882dd0dc0411b5f8ace1b1b89074981deaf6bd8af77dbe1bc", size = 193907, upload-time = "2026-08-12T14:32:50.414Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1d/cfe7b745ef7f4c3b7214581955b5a0869ba2ac551a58fc11036281ae167c/charset_normalizer-3.5.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:368eb2fc9482158b3a3386e8f01fa61f479c968e9a19ceab8f0188b86b312991", size = 197135, upload-time = "2026-08-12T14:32:51.605Z" }, + { url = "https://files.pythonhosted.org/packages/28/55/30fafdcfca9ba616bc394240545e4cd52f4f66dea43ded81b7d2d5274fde/charset_normalizer-3.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:826a295a039178479a325be1ae60eded1f0b10f7dda749df59e2440de8f61d64", size = 339892, upload-time = "2026-08-12T14:32:52.82Z" }, + { url = "https://files.pythonhosted.org/packages/f4/08/bdca5fc2bdc36ee443673dc7d12b23885a5a7b282bef85a1a4c3b325b40e/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70ff1c16eb0eb5ee6bb12739292347f981a5ba764cc4df1bc2e69b0405d4ac3b", size = 239439, upload-time = "2026-08-12T14:32:54.058Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9e/506c8d7a7722bba7c8cdd78c1b5ef23bda92bfbe0b3e28ea84673d519a0f/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3cfdab178a4add5483e26a9bb1c16d8018ccf39b4be7a3aea6c3979e6828f2ee", size = 227896, upload-time = "2026-08-12T14:32:55.326Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/dd828f2b1d6f4bf10821b9a74d866be05ffcdbfcddfc501d6fe6428762a7/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6083d10a846218502d664375b9448508d9fa580bd834567423156c6abfbe899d", size = 262548, upload-time = "2026-08-12T14:32:56.484Z" }, + { url = "https://files.pythonhosted.org/packages/be/81/196d26f6bd78b93e0d451b69082a71027ceeddd4b0be9170b81bb038f824/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0f211c21aa316cb6e2662e54a1194633a79d98a50a876addacfce7ba5b34b09f", size = 259986, upload-time = "2026-08-12T14:32:57.661Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6a/5b964a1eb0f9075ecd45083eeb21aaec215334f98bac3d400302ea73875d/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b08ebf9488c7ff5eff038e48e6ea938178dfd9dcc8598b5ca941e4ae27b20be", size = 249853, upload-time = "2026-08-12T14:32:59.123Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b4/aee3a9d82edd0e931091ef3e9f03e46491ae3590e96e998d0975dadbe17c/charset_normalizer-3.5.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95450fce59f00c6d08eff6572ec2e736e5054c9450253afd5748f8416f2eb9", size = 244217, upload-time = "2026-08-12T14:33:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/22/3e/33f72ca11c1b619b220fd9f35905ebd171cbd0e0470f2357e467b9e861ee/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5780a29823e1d2bec69b7a104ead4195a43f3e97782efaedbf1f79a0157af715", size = 241307, upload-time = "2026-08-12T14:33:01.589Z" }, + { url = "https://files.pythonhosted.org/packages/78/27/6029dccba958621c7f3a65136f87c5512d712aef9e890f09512cc171bd03/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:054420b5db984971d886e5e4e2c37c760ae6682aedbd066687ff0949d9ed5f08", size = 233305, upload-time = "2026-08-12T14:33:02.866Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/691c967be459153fe9faf49bf78bc95639ef8bf6dd008f38cc6389a349eb/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d016dc857136c726958102c3b8a3986acdc65ace6fbf12cfdc09cc4bfa2935b2", size = 263465, upload-time = "2026-08-12T14:33:04.166Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2d/9202221be5c90b2a835924191e362690ed8dc8c7d6606100c2bd03fe0f8c/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f2ce3d39fb4a9d674e6639dd5d3146b2e273475d2260f10163228d66fc04433d", size = 245060, upload-time = "2026-08-12T14:33:05.325Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9a/298772fd0a0cbccadf36451a1cd7eef4b66a11e99b4a7f6fafc47cc62c75/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5613a3a82c974227bde18f03409e30c467f8065cb56d822e3eb83708a5f223d", size = 261091, upload-time = "2026-08-12T14:33:06.475Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/1a11fe66e555dbe2f5714ade6ba74fa29edc9155d9cf1001d4d6ed096aa7/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fded2e82ff082e5d8e017e2ddcc1411bd8cb83b8585097fc401ef574f756b888", size = 251857, upload-time = "2026-08-12T14:33:07.784Z" }, + { url = "https://files.pythonhosted.org/packages/17/fc/73b817e8af3f1d25ec5cf458d405abba5a144cf9812238a61530f5eac186/charset_normalizer-3.5.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:8f006866047c6ec4b627ec144b1e0bbc7427cb31fd7c08d19897d0ac9032af3d", size = 139745, upload-time = "2026-08-12T14:33:09.123Z" }, + { url = "https://files.pythonhosted.org/packages/b3/fb/ddb66303c86f7dc5043a457dad9fa82b4d6d0cb97094f9bcdde21693fd58/charset_normalizer-3.5.0-cp313-cp313-win32.whl", hash = "sha256:196e270c4e80827b5072eed7d6aa661d133afada94fe366669f9609e718d305e", size = 177217, upload-time = "2026-08-12T14:33:10.305Z" }, + { url = "https://files.pythonhosted.org/packages/fb/88/6018cc8d76ea2b7cb02918f37e23e86c261d1a102713d7e88d2cfb8b211c/charset_normalizer-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:72982d9958a42f8132bf2d6b90214ed66477295ef1188731f98ae3511c6eeb5a", size = 198896, upload-time = "2026-08-12T14:33:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/20/2e/04c0bbfc8d9abf91959f7a3d207d45cbf63a8116984caae2381890019bb5/charset_normalizer-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:0b373bab0b867b68b8eb249da9478cab9181a42993437cd2f5dba5fb0b4fbd1b", size = 179193, upload-time = "2026-08-12T14:33:12.726Z" }, + { url = "https://files.pythonhosted.org/packages/43/14/d098868dac5ff27e0258f548b1c74c6484be528384965d8fcf8fc6a4011d/charset_normalizer-3.5.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d95244906ed69d0f79f190893c65e336c15959003e21449256dc05c001b52ea2", size = 211664, upload-time = "2026-08-12T14:33:14.153Z" }, + { url = "https://files.pythonhosted.org/packages/e7/da/a944b32a46601ae5a4c3499e8d64ecd14fe82313f00da74dcdf00273a0b4/charset_normalizer-3.5.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d788e2ded0c4c47efa4d73cfe59eaf975ee32f425219873d2cb3e3fbaa00f636", size = 224375, upload-time = "2026-08-12T14:33:15.472Z" }, + { url = "https://files.pythonhosted.org/packages/f7/db/eabb5996be2f529744755e7b2fc9396eff4a64961f034e7fd49d54b9afb2/charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:f9f91d3e8382900f3a68fa0ce94294479de9cd2de6bc0c70acd0f0dfd511836b", size = 194364, upload-time = "2026-08-12T14:33:16.607Z" }, + { url = "https://files.pythonhosted.org/packages/78/65/4ad3c5be108930310d8003f5602861d5b89f728293b9f09c3a4837f7ba10/charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d54625cbf4e6b60bf0639728cb8b4cb541e340f6d7cafae5806051a40ddf4c45", size = 197643, upload-time = "2026-08-12T14:33:17.88Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8fee3201b98d52289be60a775797d69be05a04fb6cfb48c1587dad33e649/charset_normalizer-3.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1f99a8c3a1da5d955edbad18208b3d627bdd54c48a6e739fa877bdca98c686d6", size = 341384, upload-time = "2026-08-12T14:33:19.239Z" }, + { url = "https://files.pythonhosted.org/packages/a7/dd/9e757101d1f76c35c0643684ba499ac3a181fb2b264c68174bf727d627e8/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf1e75dc07a3850b53d1e5f75e04d3ae12afe56284be7821771eaa2466350c73", size = 241637, upload-time = "2026-08-12T14:33:20.619Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/7857023015400bc4aa0a82fbcca29fa2dc7ec25f971a130764cb2dc7a589/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ac5a9cc079c67d75f4ddf343276031879eadbb333d1bb231cce297b8d7b9aae8", size = 226170, upload-time = "2026-08-12T14:33:21.773Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ae/8b52935b304f7b6bbf33151ed2b75266b09aa4b6f8f04230d948885b2577/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0dfe83c1b4d00abbf433998117a14f56a5c2bc68226c0d331709eed0d1ce539b", size = 265093, upload-time = "2026-08-12T14:33:22.999Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/6e838f6bb059f2c0afc60a4e7f294252f043c254656ad4114c50302cae4d/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e4e8fa586df2208ef040684751345f10f503834a757c9a74ecd19c1a2f9b1ccd", size = 262789, upload-time = "2026-08-12T14:33:24.214Z" }, + { url = "https://files.pythonhosted.org/packages/c2/08/189b27e51fddc9d6b3695331da0e31792c1d88b953ad854e57f06e9b2cc8/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82cc5835997ec78afe293a192e385099355770a7db94b2fb1239d36b32796f1c", size = 250580, upload-time = "2026-08-12T14:33:25.707Z" }, + { url = "https://files.pythonhosted.org/packages/ac/55/64854e99b25841f83e8e37d9df2f3d1f96f693439f80e5fabd542a7e47ab/charset_normalizer-3.5.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:19e52bda45086df8a4be4bb5910af6f5d9d3b538c78712c8ae09ef10b85bf458", size = 245008, upload-time = "2026-08-12T14:33:26.971Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/7720c904fa635d4260b4dced6029cf3d298c57b26741365d5a8d28c54043/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3418edd0ecb72a0a3861cf72f31be0ad9b7fe338ce2b58fb5cc80b9aeb792700", size = 243892, upload-time = "2026-08-12T14:33:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/70/50/7bfcb327631d4870c720872b548745f6ec8baa044d51c21b5d1d32ac4e3a/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:125ee619611019471b177c70bc3e9d4cda9fad7e01d93523501d3b188df0193a", size = 230996, upload-time = "2026-08-12T14:33:29.511Z" }, + { url = "https://files.pythonhosted.org/packages/24/51/40c45d6d940c04005ed721aa54bdebf1ebb2930f8a2ae537e8d60484fb27/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a3ad0e3da22852533858663848608f3f24c0d35e5cde415a4903476f2b4c88ec", size = 265834, upload-time = "2026-08-12T14:33:30.689Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d4/ef7a227ef89d215b47f9df79c3966610b17faa13bb2f236989207a631622/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4ebebb410bc517e1d284c52a123e82704b21e4e7e26a21ebecf7439d0647b8a3", size = 245544, upload-time = "2026-08-12T14:33:31.859Z" }, + { url = "https://files.pythonhosted.org/packages/37/a9/a4ca9156964ded61c7718eba410ce11be2fd2b263fda4bcf08367b6578cd/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:91f9f7c151e772acebe489eaec96e96a2877202d7dd144e3f96b8676881715a0", size = 264110, upload-time = "2026-08-12T14:33:33.13Z" }, + { url = "https://files.pythonhosted.org/packages/38/6a/838364bb8702229c6e5f8b23f80ff0f052a12dfaf3113a12fd6acbe92a44/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:401ea6e7af9e7852ed818f64714b579c1935482049670847ca3bd7ba45dc63fb", size = 252303, upload-time = "2026-08-12T14:33:34.98Z" }, + { url = "https://files.pythonhosted.org/packages/9c/5f/d88032edce951f499a2321cf7ae0d35a043c74be12bc22d81084cc7afbcc/charset_normalizer-3.5.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f7496aed56b06325a1ad419c5bf23c6dd042558e874f71dd1b958f3e255f3053", size = 139964, upload-time = "2026-08-12T14:33:36.195Z" }, + { url = "https://files.pythonhosted.org/packages/37/ae/1c4a46b6b00d1c34d2ee355ef99ad6173674166800d1af0f05f85028d513/charset_normalizer-3.5.0-cp314-cp314-win32.whl", hash = "sha256:606a86c1c3196f3738de39a67a7490bbd61cb31c0e0436070bd0c6a48170b38e", size = 179790, upload-time = "2026-08-12T14:33:37.356Z" }, + { url = "https://files.pythonhosted.org/packages/01/51/f94dcf34fa8eba48c1fb89b6490a5f1426e19488fe5f38aac6c648c99057/charset_normalizer-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ec6c464cf45867f66a2273e2214d9199a8fbad5cb95ca0fd45f6a2fe1d9d2cf4", size = 203723, upload-time = "2026-08-12T14:33:38.639Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ba/91d386870b5d9e4b0d8c4034f63877cc2e47b99c81ef05f3e6d42bf9a53f/charset_normalizer-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:dc28949de1bb5f7f30a46f15d74ce7ac5aaa63e03c5de04d68f571c7423af834", size = 183423, upload-time = "2026-08-12T14:33:39.899Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c9/534ecb17b7fb95f9052c4a44cf316316a27d4a8f73e8475ff55e778dcdd7/charset_normalizer-3.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:68b7e84ae8239a94f8d2c8f3f3a3a81bcde54805ec8f42a34de927d155688ec6", size = 368967, upload-time = "2026-08-12T14:33:41.093Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/ad7c3242d7fe55cd55126c22c65cb1b49779782cdf8932fd01d12232d86a/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58ca5dc0a0ef99f2801ec0574214c978e9574055bc783830bbb6e7433218609f", size = 239478, upload-time = "2026-08-12T14:33:42.428Z" }, + { url = "https://files.pythonhosted.org/packages/7e/62/77f0b850048e430fc350ec58876b0c020f5c8d0d3956fd1a4d6ae2fa292f/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6c57af4084c10cb3286688d65e4c654190ff5edcbc2411d08cdca0a8a44c59a1", size = 227036, upload-time = "2026-08-12T14:33:43.635Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ba/47d951e1a51dddbaad0a1410baf49fb1d897ceb00281568f1183b79bce9a/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1619a3cc174a7e3963dd34348e6fceb6e50db0ddeb0031bd7c73a58286454fa", size = 260772, upload-time = "2026-08-12T14:33:44.96Z" }, + { url = "https://files.pythonhosted.org/packages/b0/61/8c7ff4c81b2a88271126acf4b83ab3e31f6d63868b0f01d331eaa0f9cb67/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1328cc57dd4372be1265f68232cee890e087416e3e6e93e6ffb32c2bad4d36a4", size = 259273, upload-time = "2026-08-12T14:33:46.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fd/36129689be08dc287b951306946657ff70d76e287dd57018861f86d0e474/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06f4fb62a9139bef056b8b2da6773c94c2f259f90e4b8e53b166f3d0372d7cf6", size = 248086, upload-time = "2026-08-12T14:33:47.54Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f8/bcae67f994c8fd31dda445e5ebf84045823c31443fe46f0e9ee6aca99aa0/charset_normalizer-3.5.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:478650a70a750d75d5add401606c77f77069c32e4ba2c9131dc6cee566962ca0", size = 242671, upload-time = "2026-08-12T14:33:48.746Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/0472cdad1061c2f0e4d3aee29973eb6e81bb8fe256ff2860cf115b15f1c9/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:48920bf6fe83eb2226756ac623fa54940487154eb18f80889d5735cf234965c0", size = 241311, upload-time = "2026-08-12T14:33:50.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/89/04a03de5d27c77c624d9fcf6287073754bd438df1b58cb7d030c57c2824d/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:168a0cb536b5123a77bc42ecf5e0bf6f923d0d9ae43c42a14eb0677c19ac6c19", size = 229898, upload-time = "2026-08-12T14:33:51.523Z" }, + { url = "https://files.pythonhosted.org/packages/42/a2/639c4278adcb7ed1f4db608dd9ac19b6774fa2285a96b1c0bdb9c124ccbd/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f278e131afa96a3622cef9211c406ea2ad1b68eb06f8837cd443684a40e0ae50", size = 262852, upload-time = "2026-08-12T14:33:52.924Z" }, + { url = "https://files.pythonhosted.org/packages/9d/95/02e34c97bedfd0c5574efb9179c850591acc7f967ba039ed8dd29d332b73/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d6100f877d2ed95f0856a3fde25334153add94bf2224c43f45f88e7039262aaa", size = 242913, upload-time = "2026-08-12T14:33:54.18Z" }, + { url = "https://files.pythonhosted.org/packages/a3/64/0946aeab6462dad9f160a50dfb4704d3f58a5ee708f085abc2105fbbff0c/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4c440122e1ea68b1f8b44a631ebf49c39180f6869b1da22d76e8a724208ec6e9", size = 257938, upload-time = "2026-08-12T14:33:55.802Z" }, + { url = "https://files.pythonhosted.org/packages/79/77/36787d41ead124746506a4425c729f4f17c68280af8a6a5baa0a598cae86/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e5f834965c2fe589837bac1002e07e25734ff70381903ccd95b3d649e22bfa40", size = 249467, upload-time = "2026-08-12T14:33:57.081Z" }, + { url = "https://files.pythonhosted.org/packages/65/10/d9f6c5589cd24198d4ce6cd2948191c18e657272f433e5a00d258d9f5c22/charset_normalizer-3.5.0-cp314-cp314t-win32.whl", hash = "sha256:076cf9d3f3c7e410295c09d96355cf3b1bcae74990034d80e4371e20fe1ba4c6", size = 190624, upload-time = "2026-08-12T14:33:58.449Z" }, + { url = "https://files.pythonhosted.org/packages/6c/81/43e0584a802051a22c725795ebe1df78263abc7de858eef6cdc9b36637e9/charset_normalizer-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3288a560dc3114d5d2ebe309b1ef43f8af355eafe25856832415c2a8196c9db3", size = 215902, upload-time = "2026-08-12T14:33:59.753Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/af6a1160fef0eac4510d035241e11eccf78e5350e4cd4de79e79fe02a5e5/charset_normalizer-3.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a284c36b9c6616bf0a8aa4aabba668a0c75ba65ccf40a79868aeaa69ad996897", size = 193452, upload-time = "2026-08-12T14:34:01.017Z" }, + { url = "https://files.pythonhosted.org/packages/42/a4/dee470afb7a55c4f78b6fef37306c51fed17ebf94dbe530798c91d394350/charset_normalizer-3.5.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c38d1e9bc2073b0984d2099ea647fd7f6c0d8f83a1e14e0cd32926f16e4c44ce", size = 341595, upload-time = "2026-08-12T14:34:02.4Z" }, + { url = "https://files.pythonhosted.org/packages/6a/32/9c3126dc429c6d9d7f79c52681a7c4453ed20a26267c9a8275d7ab620aba/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9f45186390aee4d1f26f723c615b67df346766c3b16df000d84d6e374f06757", size = 242177, upload-time = "2026-08-12T14:34:03.741Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9d/5b616a887301ff4cc0916b39ba44257390d3da80deeed6e8b6f2f26b14a8/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f0fde5e5100c735b2274ab898f0742a5dcde492796296cfbe7e0ad6a4cd1a396", size = 236730, upload-time = "2026-08-12T14:34:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/d6d3e70be93ebe5fabef65e4c7ac113e1d1705cbaeb5fb72467e713aca17/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d00e18e7bbf47e332ab63903d18bae31efc701b1d8cca0382b97784a621fc44", size = 265158, upload-time = "2026-08-12T14:34:06.235Z" }, + { url = "https://files.pythonhosted.org/packages/30/e7/3f1fafa87e2643257474f9c4eec609f2193a61d907dce7dd4f3f2390ebd5/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b81980668800dd1c69faad8aea6e85a8cee0e13bcd3bba7671695ff16260293", size = 262931, upload-time = "2026-08-12T14:34:07.511Z" }, + { url = "https://files.pythonhosted.org/packages/0a/df/ebeb224a949d91829e5e114c6b64372a3c792b00762a9e951ce416f3a32d/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb3e0d1345b9c0fe73673ea656375f38a78ec679c2edeae0c24800f04798a85", size = 251388, upload-time = "2026-08-12T14:34:08.949Z" }, + { url = "https://files.pythonhosted.org/packages/a0/64/9a6ce2e7acc5cf1b4636f78f82e89ff581e06a0216a40678b28bd4d832c4/charset_normalizer-3.5.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2401f7671242e921e604f609d429f6b282ea4ca787a6ffd22ed7372011ddb9d1", size = 251821, upload-time = "2026-08-12T14:34:10.138Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b1/6e69b8056f615e5ccff6b91ca16db2d47922251f016821a300c115267fef/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:96720f2aeed3434bc48f4d52fbad64ecc820cfed88915d664780ed9ba09ede78", size = 244507, upload-time = "2026-08-12T14:34:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/0f/34/02c15d6a0aa6b934dcdc136b111da63ae857b9fd51cf5505b0736337c2eb/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:c455829625df983f716cbaecbba77f2d1dc2e0e0ed1638c059cece15a279344b", size = 240951, upload-time = "2026-08-12T14:34:12.991Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/0fe893d3e1c7d111280bd6c4bd4c1e431487a1124a1bcbce78dfeda3a3a8/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:c41b067eddcfa5ee6b1169c287605be7fb6b0ea22bba6474c5bb978a668def4f", size = 266162, upload-time = "2026-08-12T14:34:14.232Z" }, + { url = "https://files.pythonhosted.org/packages/55/ea/eca03527307670f5d102c295671a800c404ca958cf94fefd10fc963a72f0/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:e31786a947b136329bfdc458c82c06d4ec539b4a4436b7da4df4aafc9902ee80", size = 251835, upload-time = "2026-08-12T14:34:15.48Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/fee5633081e595fe9e191df6f215106c791ad596eddf5e41e39b8ea0f2e2/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:c5c6d47a865147e0ae3322ce92e7fb52ba3169d94b447deda56897ea2aa6fac9", size = 264314, upload-time = "2026-08-12T14:34:16.679Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fb/17f47ae6ca35b562fb6e6f4b05f7aec6034217353eb4a23aaa3566dc7340/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c75191e3c8052045179646cb40e280800a4e0bdfda34d9c949c2f268d44e80e4", size = 253194, upload-time = "2026-08-12T14:34:17.975Z" }, + { url = "https://files.pythonhosted.org/packages/59/88/f2b0f7ebb92493e925889ff29239b3b0073ffafd91230dbfc69e5cf9389c/charset_normalizer-3.5.0-cp315-cp315-win32.whl", hash = "sha256:83b62410bd36bb1178a7d563e2ee0cf21eb1c980c912ab99c2c78f06227f1731", size = 179800, upload-time = "2026-08-12T14:34:19.409Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f0/afb5bfdea52fd943b1960403847a276b8e900c6e4cd6a38752321b4eda64/charset_normalizer-3.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:e3b9eaa99a6d8c9ace4cd303915947ef55088d4cd87c6676874f98c5c03aa040", size = 203726, upload-time = "2026-08-12T14:34:20.656Z" }, + { url = "https://files.pythonhosted.org/packages/fc/71/219783eb691aa2ec879c0e521afdfe2b826f9678eed51b9c039d03e0db2b/charset_normalizer-3.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:fec352b793cdc183cc9e7e0b6c10fd7bff38ec54ba44cc43599b9b56f7f3db2e", size = 183428, upload-time = "2026-08-12T14:34:21.975Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d0/14aef3b9f80f2593c039d897e89034635b9eb0eb44b6ce5173bbd79ff338/charset_normalizer-3.5.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c9bde7a960720c8b8e1b5ef7afaa0c9a2f3b55c44abd635b2b29dd066b298e3a", size = 368728, upload-time = "2026-08-12T14:34:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/10/fc/b249466ddbbeffa448b6597631e9091d1f01b5132ff8e7a0e21a6eb72b63/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8cd1283a9fe6c2065c807e9d5da81afe5e1e004caef39adc0d8ae86dd883698", size = 240925, upload-time = "2026-08-12T14:34:24.504Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b9/c17e72aaa1b3e1ca6c184e8025cf138ed492d01a54f85286ff7d31253a4b/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1c010dd86d3f4c4433c9634d33ce8147393b270dfa54f217f965540b8ae8e075", size = 234932, upload-time = "2026-08-12T14:34:25.822Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/f84ef0966bbe216f71029e34e7fa425a16b1682e2a40265e679dedf2b655/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5e68229977b2dea28e7061c0c0630a23f2f9f6e9c6fb38d77d3d6dbfe3768b74", size = 261733, upload-time = "2026-08-12T14:34:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/3b/73/3e887fa0781a395339355ed934ab6561ceb5bb52574160f070224039c630/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a60773eb5fda796e6e6f76b9c152d270fe59f9788a51a6ff8ba44082d8548ae4", size = 258460, upload-time = "2026-08-12T14:34:28.431Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/52ebd9849bf9e35d0b21fff115cb6543162a8e1f2f564e8f87121a336b8c/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9419f44e568f7fafcdc0b3b5c766a2364e705a9b34fb8a56b431e0d1f3f4258", size = 249894, upload-time = "2026-08-12T14:34:29.698Z" }, + { url = "https://files.pythonhosted.org/packages/85/f3/9366492b8a5fe0187de282e001d61345740cf79eb4a5f20181d769be02b5/charset_normalizer-3.5.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75e243abbb528c1a774390ed71e3f868a9f37b1373442e4bbadd401cfc505ff4", size = 249540, upload-time = "2026-08-12T14:34:30.96Z" }, + { url = "https://files.pythonhosted.org/packages/0b/af/28bb5e5dbd3e67cb9196a62781ac2b6d79492f4fc7a069b6ca7d6d6c8d58/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:54c963ce6404e52255b737e8a06d356fc762d59096ae566203a67cf2b7d050f2", size = 242734, upload-time = "2026-08-12T14:34:32.481Z" }, + { url = "https://files.pythonhosted.org/packages/26/d6/7ccfa62b53b40fc06b2d3504825aa400764740bd10cf248fdc4272441b93/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:63ea0cc840c66670183578c2630d138c0e944aeadfc33f25173ee240f5db780d", size = 239580, upload-time = "2026-08-12T14:34:33.916Z" }, + { url = "https://files.pythonhosted.org/packages/0b/82/71c0c9b046697b8da66b3acefa8d5f92d00a9ef433ad7c3522b971d0369a/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6c06875a1d4a7537bef70f659b55c6b55b9a47ec3ba8f2db610350c2d9915e6e", size = 263281, upload-time = "2026-08-12T14:34:35.333Z" }, + { url = "https://files.pythonhosted.org/packages/d6/01/d027583c869f40ba980c1c76994adbd522c360a6327e72beb44d7c267385/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:4253da1b4456b633651a8d59eb1dc7a8a8fa38241014dd7c217b353e547ae394", size = 250027, upload-time = "2026-08-12T14:34:36.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e9/6475d739e0ec8bb1236e06263dc3affaffdf947d8114ad27024932f325da/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:f044cb1cf44012184715f46584658993b5fee9344d71c4b0c455a17a299730c0", size = 257547, upload-time = "2026-08-12T14:34:38.277Z" }, + { url = "https://files.pythonhosted.org/packages/a5/60/d1f502fcaa048a2aca3ab80bfef8407659c131e4f1792fa805fec14b4960/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a17864853f7c518ae7d4b368af98f427f9396805476af40af8698560f09d7d97", size = 251718, upload-time = "2026-08-12T14:34:39.562Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/76343dcf4381a698807ff8a20d89f66bbdd9f6222b0b17740f77ab764335/charset_normalizer-3.5.0-cp315-cp315t-win32.whl", hash = "sha256:9e726478d7a213847860219d74665a6892a643ac93b8f76580f6cf9ed39996b7", size = 190757, upload-time = "2026-08-12T14:34:41.053Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ea/d18147626a1667cc773c42104ab155a4ca5d6d4d174b7a35e01062213ea5/charset_normalizer-3.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:d7229a99120c6c2792d96f4857c2648ce5530e93667a2c2388c5ef69a6b84775", size = 215431, upload-time = "2026-08-12T14:34:42.501Z" }, + { url = "https://files.pythonhosted.org/packages/47/21/4869598aae0872d94faa5933918a4fe37ab2c5af9d095786e241f9506fed/charset_normalizer-3.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:527e28a5e751d9e11369b9c5f9ab35c748eb9c109101920c7deb40d6eadf8d03", size = 193205, upload-time = "2026-08-12T14:34:43.781Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f3/7b523d807cb5e73562ef8acf21d39cdb9d704955327362c781bc3478a73d/charset_normalizer-3.5.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:5a4ee37248dfac25107c758bda99d545ce73e60b44d2dd39e4a2bb9f2831e9f5", size = 330840, upload-time = "2026-08-12T14:34:45.06Z" }, + { url = "https://files.pythonhosted.org/packages/f0/de/fc68978fe78ca97063c96d764e41ff92ca639948f319271e0ff450e577a2/charset_normalizer-3.5.0-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a864bdcacd8bff58bb4845304e031f821a3ec64b2b7259f2d409cd49c9e59ca3", size = 251862, upload-time = "2026-08-12T14:34:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/a9/cb/82b41a0ab7fb1a88065f1d78ad32696ad88ea3fe8e25b8189d08833938de/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84b736e3b391601bc47b86da381c749c0f894e9191aaca9f31f30c2632206df3", size = 239484, upload-time = "2026-08-12T14:34:47.869Z" }, + { url = "https://files.pythonhosted.org/packages/e8/0c/19608b631f4538f908098d4a2d56a8f79a665e27cc58e9d90479761a9227/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6abb1f356fb865baeb6ebc3fadd843e9a96fbf49b9adcca55037f3cceccb7438", size = 230602, upload-time = "2026-08-12T14:34:49.265Z" }, + { url = "https://files.pythonhosted.org/packages/29/db/f648eb30e14eba301aed61e11672156f137905c1bdbb530151abe8065943/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d366548d2ee28a8cfdcc4296363978cc644a728333be9824d2de4652e83df0a", size = 259208, upload-time = "2026-08-12T14:34:50.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e0/ed2c8bdbac484d69614d6993143aeb6cb0f4dd1561c883402517b623c8ef/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d672f329ae504ee240eb39b6effb3318aa8e7e8924c0ce8eee5760b3fad98539", size = 253659, upload-time = "2026-08-12T14:34:52.11Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/b4907cb9ec5b521d9d024ced13611240b86ef065c2eb15b3ad2334dc9940/charset_normalizer-3.5.0-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c54036a518748b6c02e666f6d46c3817561998fb904c3be25b56fb4fe3dc5706", size = 248821, upload-time = "2026-08-12T14:34:53.399Z" }, + { url = "https://files.pythonhosted.org/packages/12/b2/e2d1abcfbc05822f0030869efb4e9f8a3658e13b4821796d4b62da917327/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:22a1889f1c9b752c63c36758a0c2145458e3cadb20fced7a0790002e9dd12b26", size = 240271, upload-time = "2026-08-12T14:34:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f9/4ba127ad610542fa3eabfa41c45bf12d357860a815b3566374ec0188e213/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b7eb3eab5c646d3de7dcb14a7c9caebace5249c5767da39e1761cb1576e521a3", size = 232155, upload-time = "2026-08-12T14:34:56.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/68/40613182366d00bd6dbd5f6c84a926cbd120960e038a8269e9ae7d782762/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:2080aa129a28267984cdc902898993d788c995c384e285d0d19199f56760d52e", size = 259674, upload-time = "2026-08-12T14:34:57.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/53/4574a14fa4c9de4a6c9f31725354bfa40b67f653e6d594ce1654f9a41b32/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:fd68c825548a611158230e2f9222e210ceb2e3391995c0aa5865cbdf3ab4bd49", size = 246122, upload-time = "2026-08-12T14:34:59.337Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/bd8030d13d92c058ca7b2b9615bbb3169569e144db64d65c149cd45abf5e/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:d8a9316f4da85e937242642b537c6d55d7e9287dd38e5634732f8233932aff45", size = 255221, upload-time = "2026-08-12T14:35:00.71Z" }, + { url = "https://files.pythonhosted.org/packages/77/9d/10ecd3bcbe2666b3d4d4026c97b48f73990682815db516052a1e8f4a31c5/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d90254c8f609338c53ec180fcd4c4f9c16502e238e3fc88ca7fd4c2f38d445b8", size = 253450, upload-time = "2026-08-12T14:35:02.217Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/d044c4872c0938a84f87b5027a698c0e61bacfc5c3551a4e749ca9b7bc5c/charset_normalizer-3.5.0-cp37-abi3-win32.whl", hash = "sha256:8b3e9e29b8b07cc461b9ce7768db7693a93979d0dadf22046f6f3555ded2f516", size = 173594, upload-time = "2026-08-12T14:35:03.845Z" }, + { url = "https://files.pythonhosted.org/packages/10/6b/6046773901f1944b9a89436351529811ee958afc7b774563be9d74a6f0c3/charset_normalizer-3.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:0c8953d9d1617794cfc40d81179571c9ba3805dd029623a15c93f1fb70e60a74", size = 198959, upload-time = "2026-08-12T14:35:05.187Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/b57708ac92aefc8e8389d51d5178129b81f03196da61ee2c23e687b8178a/charset_normalizer-3.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:562d24ca7797c1af8852994950c2e623a907b201fc4b0ed29e92af173d3828ca", size = 267055, upload-time = "2026-08-12T14:35:06.533Z" }, + { url = "https://files.pythonhosted.org/packages/22/c7/754d09943a616937df61e4ba367c409ded2a987e872972098d51a6fcf73b/charset_normalizer-3.5.0-py3-none-any.whl", hash = "sha256:993dfcbe75a85a3784abb5084f2c41b915767c90546fcc92803cffa28611baea", size = 67943, upload-time = "2026-08-12T14:35:30.363Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/70/b052a519a584663a7bd052841a2debe11c8309ec49a7786340003f9c0a02/coverage-7.15.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d0be6daac4cce6b8c8dc65886bae1b082ddbca4da8e5cbb5e15166acf253e264", size = 222245, upload-time = "2026-08-06T13:46:55.253Z" }, + { url = "https://files.pythonhosted.org/packages/67/39/892fa511aba3d1c3c8f49509a0ff5c71eab9f9f88d08e1a38da395821660/coverage-7.15.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b24e078eabcd6a9caa8b0713f9bc1eeb310bcc960a29d45a3b4fcd4b16d5b11d", size = 222762, upload-time = "2026-08-06T13:46:57.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/95/b2c724ce1e64bc23cb5b1d7eeffa9548dc3d811f7a6297b2d01607f4e062/coverage-7.15.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe20cc8cf8821d4fe54f89106cbf06aa27f37b5bbe3535568065a81539b4150", size = 249498, upload-time = "2026-08-06T13:46:59.012Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4f/b1973f67a1382af65b572a31ed692f8e490a6ad707191eab59148376832a/coverage-7.15.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:83cf06cdd687677742caff1a9134833b7a8b75f111519d2cb0e0ba1b9a851e15", size = 251328, upload-time = "2026-08-06T13:47:00.764Z" }, + { url = "https://files.pythonhosted.org/packages/a2/09/03efa6722a132abcac91b32a60b64b240dd707c189c64eee697e48992c96/coverage-7.15.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8fa4de68e2a752468ff14b4e15db7def689a71be759e826a31ccecbef69c5fd0", size = 253194, upload-time = "2026-08-06T13:47:01.976Z" }, + { url = "https://files.pythonhosted.org/packages/45/63/8299201d9c80fb65551ce99c966cab83d706ec4066ac999bef08201346de/coverage-7.15.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4dff9daa47d83120c3ec38ce921214242944a832aa04e903e50b5b7ebac8972d", size = 255106, upload-time = "2026-08-06T13:47:03.281Z" }, + { url = "https://files.pythonhosted.org/packages/ee/16/26fd8a691eb8d9a230128685f6d23309d7402cb030aa553001788c8c50fc/coverage-7.15.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a093fd37229918976f602aa07aa59e0973cde82186f220c8e197f721f5be0ce4", size = 250177, upload-time = "2026-08-06T13:47:04.713Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/3c7556f33783a0a566e01443ca62bd8eb2cdfe22d271efdc02e08beb5654/coverage-7.15.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:317db01a2cb02552fd67e2b1cca77a4b528a2a277176c5e0bf2cecbb639d3f54", size = 251234, upload-time = "2026-08-06T13:47:06.104Z" }, + { url = "https://files.pythonhosted.org/packages/29/49/640a34043edac950738f36a3567832db5731d4cb2ed84b59cdb89c6bccbf/coverage-7.15.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:8ee3838dcb656602c3b51e16aed9bfb0822f8d8d6d1c5966d32ec8c104be8e20", size = 249237, upload-time = "2026-08-06T13:47:07.467Z" }, + { url = "https://files.pythonhosted.org/packages/48/f5/e80f212669dd1be954ff844f883ef11a437ef4fd0089c6e0effc7b66b15d/coverage-7.15.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:425920379052ff1fe465268f3361d35804a241bbdd5a1b592c8cb60df4c52325", size = 253050, upload-time = "2026-08-06T13:47:08.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e9/e5da0fe39f7fde1bca9edc09c60921bb5fdba4cec7db5bbad41ddfd8c230/coverage-7.15.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:69bb2400abef928e365ea7d4d9925169ada78ed2295546780002d4b65de3df88", size = 249508, upload-time = "2026-08-06T13:47:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/7d/38/41bf25774a0c8bba6b467f917cb1c9a0a2605e02dc93aad489fc7050ed59/coverage-7.15.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:81661f82d302484e3119e7c80c519c02fa9bcc2a6b339baf67d67bc89c580f04", size = 250110, upload-time = "2026-08-06T13:47:11.35Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/26f2e54b79acc29d179ee4272922625aedb69198c4eb61f7ff4f098f3c78/coverage-7.15.4-cp310-cp310-win32.whl", hash = "sha256:cb476b2e828ecb71cb6b6a928d23fd20a7ddb501188022dae1c37499149cc338", size = 224294, upload-time = "2026-08-06T13:47:12.753Z" }, + { url = "https://files.pythonhosted.org/packages/7b/06/9a318fc3ae040d4d6cb2d86101c6aa963fab20899a5c58666adf52cde0ca/coverage-7.15.4-cp310-cp310-win_amd64.whl", hash = "sha256:3fc2130bf37df31852a8384f12601563a45a0024bccc6624f38355cba7a8d360", size = 224919, upload-time = "2026-08-06T13:47:14.17Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/edcec7d7a0b524aa8923e22925fde6fe50ce005a113dca13ae1581455c4c/coverage-7.15.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbac5abad70df71019988f83f26ac7092ff2642975def4429e98dc7585ef3490", size = 222367, upload-time = "2026-08-06T13:47:15.578Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c6/ab8de429e2e8548faf58ec7e1674a4ce00414b4113942d3fe87109cf0f68/coverage-7.15.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:357a173465c7ce028d07a95cc2b63b5bf59f50ecdd5ad75c5cbb78ada984048e", size = 222874, upload-time = "2026-08-06T13:47:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/3b7b49587e8a6b9af79b3eb468d443d6042b6d65b47aa26586846a0d6566/coverage-7.15.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21b803935e2efc3acebe9697197a294fccf5dc4e5382bd6369542ff7a7d2a1d7", size = 253287, upload-time = "2026-08-06T13:47:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/fb/65/ec03b743a2a229c72cc1eff3e57be9d3564e9c6b4d5aba2d70744a3fc0d8/coverage-7.15.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a2b580774a4786c1053157c0165e04476e03ff293993d7c148eee784a94bae6", size = 255199, upload-time = "2026-08-06T13:47:19.765Z" }, + { url = "https://files.pythonhosted.org/packages/41/4b/5163729e4b6582d61975cfd3ccab45b4ec53e21cf156d9941cb025188468/coverage-7.15.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9464451c4efffe8d47ace5a540b10b0dc10e879066290f8600872b7f54a419d", size = 257308, upload-time = "2026-08-06T13:47:21.206Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/2167a0f08fb87d702fa423a48578a32865464b7c9e1db3911ad7812ab414/coverage-7.15.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de602f34123c2f4af1c1869c6dbbbd60da6d5983bf01937367295d135cccbfce", size = 259268, upload-time = "2026-08-06T13:47:22.503Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e5/68eebae3053dbd48508edea559c21b23fbdf3460784f91370c83a86a6acd/coverage-7.15.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6879ded16a27f3eeca19b900c147e81616e7054db451471a611b2755ee5249f7", size = 253392, upload-time = "2026-08-06T13:47:23.88Z" }, + { url = "https://files.pythonhosted.org/packages/1a/46/fd4ced40a2b691c774e515c9b69500bfa64c7960b67fcee4b2f6fad97fc3/coverage-7.15.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:986be58c3ab54aae8d3496a6225eea74f760fdbe739b38bd442c7e8d133aa53b", size = 255001, upload-time = "2026-08-06T13:47:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/53/25/ae2e5fa710bb6957a9aadeb9e3598d3b3e4af6587ce857ad42e8639a3f30/coverage-7.15.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6103639613fe6c1e989082948419bc77a2d26b6c825c99d7fad25f7d3d87afc", size = 253061, upload-time = "2026-08-06T13:47:26.845Z" }, + { url = "https://files.pythonhosted.org/packages/d7/31/67ddc0365db2c6e93ac8580bc4bbc50f65273262f973f63ebcdbc15c0495/coverage-7.15.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d3af93dddb5659276c63bc16ac6466ac2033a70ca816097bbc06345b8ccdf571", size = 256831, upload-time = "2026-08-06T13:47:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/f6/78/82b8fd18f57fb13f12d98fe874995bb2c4f9f17be8aff762c426323fdb96/coverage-7.15.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b10075e5421d04265766a6d1dac809bbeb8a946fbb23c8f82c227409b2190719", size = 252781, upload-time = "2026-08-06T13:47:29.712Z" }, + { url = "https://files.pythonhosted.org/packages/0a/eb/6c74ef4dd12b252e573c49bdef9e2ac265bf3dbb79b8d7feb3266e084e9e/coverage-7.15.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a67a9f78b2942d87ba8ce3059c642164d2aedd65337377fb52fe9803656bc5c7", size = 253692, upload-time = "2026-08-06T13:47:31.192Z" }, + { url = "https://files.pythonhosted.org/packages/5a/66/eb9aed1c3fd2d36ee00eb173f434b14fa607fc056739c9a89ff4244010ea/coverage-7.15.4-cp311-cp311-win32.whl", hash = "sha256:69484d1aca26e322e1c3ce03f09341e84524ababad2d7202161738d83cc9f82e", size = 224461, upload-time = "2026-08-06T13:47:32.572Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6d/81fa4161dfb3ed9d74e40d58647eff83a56b7612e78352581280fce2f477/coverage-7.15.4-cp311-cp311-win_amd64.whl", hash = "sha256:63fd6fcd1dd6e158f7eb78606e72933b3f6d01e7b747f99c6c12d764307a0fdc", size = 224937, upload-time = "2026-08-06T13:47:34.205Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c1/d8dacf683c6cad3cf85ce68fd3774a6774ec402128822fdfaed920f11e6a/coverage-7.15.4-cp311-cp311-win_arm64.whl", hash = "sha256:ea82116c9893fa89e929b7f197ee5a1950a76e91cc5c85ba503fc02379d04890", size = 224479, upload-time = "2026-08-06T13:47:36.118Z" }, + { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" }, + { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" }, + { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" }, + { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" }, + { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" }, + { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" }, + { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" }, + { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" }, + { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" }, + { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" }, + { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" }, + { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" }, + { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" }, + { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" }, + { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" }, + { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" }, + { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" }, + { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" }, + { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" }, + { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" }, + { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" }, + { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" }, + { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" }, + { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" }, + { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, +] + +[[package]] +name = "django" +version = "5.2.17" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] +dependencies = [ + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/d8/43e9d000519adceb189620b6869ff88031e046df91c2e9da72f8f6918399/django-5.2.17.tar.gz", hash = "sha256:9d4d93be539a18ab80d058eb515900e10951e04c537c5a6b394fc49528d3251f", size = 10889740, upload-time = "2026-08-04T15:04:03.173Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/f8/ce120525ca78f12b07daf65786679c5d0b54a75285a8958d3ae55e39da35/django-5.2.17-py3-none-any.whl", hash = "sha256:f04fb3b36ee119e1af4fa1d397d5fd6cf12700f49321e84d4f4c642c5b1973db", size = 8315563, upload-time = "2026-08-04T15:03:59.1Z" }, +] + +[[package]] +name = "django" +version = "6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/42/6cb20996733984c1f6661daeda3877990836c76c633c6c8879d39f7120eb/django-6.1.tar.gz", hash = "sha256:86a2aacd59b817e4d6ac2ebfe22356c58f66f7b24e503f71b7c2fead677ee48b", size = 11223034, upload-time = "2026-08-05T19:21:53.789Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/9c/ce847620134cfab903e75690c498af73b46abbede2912ea89bd76d5c1e76/django-6.1-py3-none-any.whl", hash = "sha256:6c132cd980c9392b06807d4ca52d72530d631dc65a85d9dacede00a780cefbbe", size = 8417399, upload-time = "2026-08-05T19:21:47.285Z" }, +] + +[[package]] +name = "django-mfa" +version = "4.0.0a1" +source = { editable = "." } +dependencies = [ + { name = "django", version = "5.2.17", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "fido2" }, + { name = "qrcode" }, +] + +[package.dev-dependencies] +dev = [ + { name = "build" }, + { name = "coverage" }, + { name = "ruff" }, +] +docs = [ + { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "myst-parser", version = "5.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinx-rtd-theme" }, +] + +[package.metadata] +requires-dist = [ + { name = "django", specifier = ">=4.2" }, + { name = "fido2", specifier = ">=1.1.2,<3" }, + { name = "qrcode", specifier = ">=7.0.0,<9" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "build", specifier = ">=1.2" }, + { name = "coverage", specifier = ">=7" }, + { name = "ruff", specifier = ">=0.14" }, +] +docs = [ + { name = "myst-parser", specifier = ">=3" }, + { name = "sphinx", specifier = ">=7" }, + { name = "sphinx-rtd-theme", specifier = ">=2" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "fido2" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/ea/6f08c354b7aeb8019249d46a86c2153f8218499cced4d21bf16b6d49fc16/fido2-2.2.1.tar.gz", hash = "sha256:85787428a94c3f8eaf72f0ff30afba983b559a1b1b795c93318c81b4ad4062c4", size = 327147, upload-time = "2026-06-29T17:41:11.927Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/84/198d99c3312557ef6121cf78c38281efe9b3bc88cba0e2c05446f38a024d/fido2-2.2.1-py3-none-any.whl", hash = "sha256:ed397da981b9ab133da6ead7309e41f924b566b749956129efe286fae097749f", size = 238354, upload-time = "2026-06-29T17:41:09.921Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "myst-parser" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" } }, + { name = "jinja2" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" } }, + { name = "mdit-py-plugins" }, + { name = "pyyaml" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" }, +] + +[[package]] +name = "myst-parser" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, + { name = "jinja2" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" } }, + { name = "mdit-py-plugins" }, + { name = "pyyaml" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/dc/603751677fff302f34396e206b610f556a59d7fe58b9a2145f54e96b48e8/myst_parser-5.1.0.tar.gz", hash = "sha256:ab69322dc6719dcc7f296479dbb70181b66df6ed315064f92dbc85c0e1bf2f02", size = 101182, upload-time = "2026-05-13T09:38:19.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/dc/f3dfb7488b770f3f67e6545085bf2abea5172e88f57b8ad25ef860ca704c/myst_parser-5.1.0-py3-none-any.whl", hash = "sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a", size = 85817, upload-time = "2026-05-13T09:38:17.904Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "qrcode" +version = "8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" }, + { url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" }, + { url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" }, + { url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" }, + { url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" }, + { url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" }, + { url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" }, + { url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" } }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx" +version = "9.0.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, +] + +[[package]] +name = "sphinx-rtd-theme" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jquery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/68/a1bfbf38c0f7bccc9b10bbf76b94606f64acb1552ae394f0b8285bfaea25/sphinx_rtd_theme-3.1.0.tar.gz", hash = "sha256:b44276f2c276e909239a4f6c955aa667aaafeb78597923b1c60babc76db78e4c", size = 7620915, upload-time = "2026-01-12T16:03:31.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/c7/b5c8015d823bfda1a346adb2c634a2101d50bb75d421eb6dcb31acd25ebc/sphinx_rtd_theme-3.1.0-py2.py3-none-any.whl", hash = "sha256:1785824ae8e6632060490f67cf3a72d404a85d2d9fc26bce3619944de5682b89", size = 7655617, upload-time = "2026-01-12T16:03:28.101Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "sqlparse" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +] From 91790a2fff7245fd683919da26d6a20c24218d59 Mon Sep 17 00:00:00 2001 From: Ashwin Date: Thu, 13 Aug 2026 08:24:51 +0530 Subject: [PATCH 3/3] Add CI workflow for publishing to PyPI and document release process --- .github/scripts/smoke_installed_wheel.py | 75 ++++++++++++ .github/workflows/ci.yml | 42 ++----- .github/workflows/publish.yml | 148 +++++++++++++++++++++++ docs/contributing.md | 34 ++++++ 4 files changed, 265 insertions(+), 34 deletions(-) create mode 100644 .github/scripts/smoke_installed_wheel.py create mode 100644 .github/workflows/publish.yml diff --git a/.github/scripts/smoke_installed_wheel.py b/.github/scripts/smoke_installed_wheel.py new file mode 100644 index 0000000..bde9e86 --- /dev/null +++ b/.github/scripts/smoke_installed_wheel.py @@ -0,0 +1,75 @@ +"""Start Django against the INSTALLED django-mfa wheel, not the source tree. + +Run this with the interpreter of a virtualenv that has the built wheel +installed, from a working directory that is not the checkout:: + + uv venv /tmp/smoke + VIRTUAL_ENV=/tmp/smoke uv pip install dist/*.whl + cd /tmp && /tmp/smoke/bin/python \ + "$GITHUB_WORKSPACE/.github/scripts/smoke_installed_wheel.py" + +Python puts *this file's* directory on sys.path, not the repo root, so a +module missing from the wheel cannot be masked by the checkout — which is the +whole point. The previous setup.py enumerated subpackages by hand, its list +predated django_mfa/adapters/ and django_mfa/views/, and the published wheel +shipped without either: `pip install django-mfa` raised ImportError from +DjangoMfaAppConfig.ready() while the entire test suite stayed green, because +tests run from a source checkout where those directories exist regardless. + +Shared verbatim by the `package` job in ci.yml and the `build` job in +publish.yml. Both need exactly this check, and a copy in each file is a copy +that will drift. +""" + +import django +from django.conf import settings + +settings.configure( + DEBUG=False, + SECRET_KEY="x", + ALLOWED_HOSTS=["example.com"], + DATABASES={ + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"} + }, + INSTALLED_APPS=[ + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.staticfiles", + "django_mfa", + ], + MIDDLEWARE=[], + MFA_FIDO2_RP_ID="example.com", + USE_TZ=True, + STATIC_URL="/static/", + TEMPLATES=[ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "APP_DIRS": True, + "DIRS": [], + "OPTIONS": {}, + } + ], + # Required, or django_mfa.E003 fails the check below -- which is the check + # working as intended, not a packaging problem. + AUTHENTICATION_BACKENDS=[ + "django_mfa.backends.WebAuthnBackend", + "django.contrib.auth.backends.ModelBackend", + ], +) +django.setup() + +from django.core.management import call_command # noqa: E402 + +call_command("check") +call_command("migrate", run_syncdb=True, verbosity=0) + +# Templates and static files are the parts that ride on file inclusion rather +# than package discovery, so prove them explicitly. +from django.contrib.staticfiles import finders # noqa: E402 +from django.template.loader import get_template # noqa: E402 + +get_template("django_mfa/security.html") +assert finders.find("django_mfa/webauthn.js"), "webauthn.js missing" +assert finders.find("django_mfa/style.css"), "style.css missing" + +print("installed package starts, checks, migrates, renders, serves static") diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0d78db..902a3a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,7 +163,10 @@ jobs: # nothing else in this pipeline noticed. Still worth running now that # the README is Markdown: MyST syntax valid in docs/ (```{toctree}, # :::{warning}, {doc} roles) is equally meaningless to PyPI. - run: uv run --with twine twine check dist/* + # Same invocation as publish.yml's, on purpose: if the two differ, a + # README can pass here and fail at release time, when the fix is a new + # version rather than a new commit. + run: uv run --with "twine" --with "readme-renderer[md]" twine check --strict dist/* - name: Install the wheel into a clean environment run: | @@ -171,40 +174,11 @@ jobs: VIRTUAL_ENV=/tmp/smoke uv pip install dist/*.whl - name: Start Django against the installed package + # Shared verbatim with publish.yml's build job, which runs the same + # check before anything reaches PyPI. Inlining it in both files is + # inlining it in one of them a year from now. working-directory: /tmp - run: | - /tmp/smoke/bin/python - <<'PY' - import django - from django.conf import settings - settings.configure( - DEBUG=False, SECRET_KEY="x", ALLOWED_HOSTS=["example.com"], - DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", - "NAME": ":memory:"}}, - INSTALLED_APPS=["django.contrib.auth", "django.contrib.contenttypes", - "django.contrib.staticfiles", "django_mfa"], - MIDDLEWARE=[], MFA_FIDO2_RP_ID="example.com", USE_TZ=True, - STATIC_URL="/static/", - TEMPLATES=[{"BACKEND": "django.template.backends.django.DjangoTemplates", - "APP_DIRS": True, "DIRS": [], "OPTIONS": {}}], - # Required, or django_mfa.E003 fails the check below -- which is - # the check working as intended, not a packaging problem. - AUTHENTICATION_BACKENDS=["django_mfa.backends.WebAuthnBackend", - "django.contrib.auth.backends.ModelBackend"], - ) - django.setup() - from django.core.management import call_command - call_command("check") - call_command("migrate", run_syncdb=True, verbosity=0) - - # Templates and static files are the parts that ride on file - # inclusion rather than package discovery, so prove them explicitly. - from django.template.loader import get_template - from django.contrib.staticfiles import finders - get_template("django_mfa/security.html") - assert finders.find("django_mfa/webauthn.js"), "webauthn.js missing" - assert finders.find("django_mfa/style.css"), "style.css missing" - print("installed package starts, checks, migrates, renders, serves static") - PY + run: /tmp/smoke/bin/python "$GITHUB_WORKSPACE/.github/scripts/smoke_installed_wheel.py" - uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..a0ca82d --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,148 @@ +name: Publish + +# Publishing to PyPI uses a Trusted Publisher (OIDC), configured on PyPI as: +# +# repository: MicroPyramid/django-mfa +# workflow: publish.yml <- this file's NAME is part of the trust +# environment: pypi <- and so is the environment below +# +# There is no API token anywhere: PyPI trades this workflow's short-lived OIDC +# token for an upload token, scoped to this project, valid for minutes. Renaming +# this file, or the environment, silently breaks publishing -- PyPI will reject +# the token because the claims no longer match. Change either one on PyPI first. +# +# The `pypi` environment additionally restricts deployment to tags matching +# `v*`, so a release cut from a branch, or tagged `4.0.0` without the `v`, +# never reaches the publish job at all. +# +# One trigger, deliberately: a published GitHub Release. The tag on that +# release is what the version guard checks the package against. Note that +# ci.yml's `push` trigger is branch-scoped, so pushing a tag runs NO tests of +# its own -- that is why this workflow re-runs the corners of the test matrix +# before it builds anything. +# +# GitHub resolves `release` triggers against the DEFAULT branch, so this file +# does nothing until it is on master -- having it on a feature branch is not +# enough, and there is no warning to that effect anywhere. +on: + release: + types: [published] + +# Read-only by default. Only the `publish` job gets id-token: write, and it is +# the only job that can reach the `pypi` environment. +permissions: + contents: read + +concurrency: + group: publish-${{ github.ref }} + # Never cancel a run that may be mid-upload. + cancel-in-progress: false + +jobs: + test: + name: py${{ matrix.python-version }} / Django ${{ matrix.django }} + runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + # The corners of ci.yml's grid: the oldest supported combination and + # the newest. A release that breaks either must not reach PyPI, and a + # release is exactly the moment nobody is watching CI. + include: + - python-version: "3.10" + django: "4.2" + - python-version: "3.13" + django: "5.2" + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Run the test suite + run: > + uv run + --python ${{ matrix.python-version }} + --with "django~=${{ matrix.django }}.0" + python test_runner.py + + build: + name: Build and install-smoke + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Refuse to publish if the tag and the packaged version disagree + # Nothing else connects the two. hatchling reads the version from + # pyproject.toml and never looks at the tag, so a release tagged + # v4.0.0 cut from a tree still saying 4.0.0a1 would publish 4.0.0a1 + # and report success -- the mismatch would surface only to users, and + # PyPI does not allow re-uploading a version to fix it. + run: | + tag="${GITHUB_REF_NAME#v}" + version=$(python3 -c "import tomllib, pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])") + if [ "$tag" != "$version" ]; then + echo "::error::release tag ${GITHUB_REF_NAME} does not match pyproject.toml version ${version}" + exit 1 + fi + echo "publishing django-mfa ${version}" + + - name: Build sdist and wheel + run: uv build + + - name: Check the README renders on PyPI + # PyPI's renderer is not Sphinx, and a README it cannot render is + # published as an EMPTY project page rather than an error. `--strict` + # makes warnings fail here instead. + run: uv run --with "twine" --with "readme-renderer[md]" twine check --strict dist/* + + - name: Install the wheel into a clean environment + run: | + uv venv /tmp/smoke + VIRTUAL_ENV=/tmp/smoke uv pip install dist/*.whl + + - name: Start Django against the installed package + # Runs from /tmp so the checkout is not on sys.path: this is the job + # that catches a wheel missing a subpackage. See the script's docstring. + working-directory: /tmp + run: /tmp/smoke/bin/python "$GITHUB_WORKSPACE/.github/scripts/smoke_installed_wheel.py" + + - name: Upload the distributions for the publish job + uses: actions/upload-artifact@v4 + with: + name: distributions + path: dist/ + + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/django-mfa + permissions: + # Mints the OIDC token PyPI trades for a scoped upload token. This is + # the only job in the repository that has it. + id-token: write + steps: + - name: Download the distributions built above + # Deliberately not a rebuild: what gets uploaded is byte-for-byte the + # artifact the install-smoke actually exercised. + uses: actions/download-artifact@v4 + with: + name: distributions + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + # No `with:` block on purpose -- no user, no password, no api-token. + # `skip-existing` stays off: re-running a release whose version is + # already on PyPI should fail loudly, not report a green success. diff --git a/docs/contributing.md b/docs/contributing.md index 62f99f9..b48c301 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -34,6 +34,40 @@ Two things that build output alone will *not* catch, both pinned by `django_mfa/ When you add a setting to `django_mfa/conf.py`, document it in `docs/settings.md` in the same change; a test asserts every key in `DEFAULTS` appears there. +## Releasing + +Releases publish themselves. There is no API token to hold, and nobody runs +`twine upload` by hand: PyPI is configured to trust `.github/workflows/publish.yml` +in this repository via OpenID Connect, and mints a short-lived, project-scoped +upload token for that workflow alone. + +To cut a release: + +1. Bump `version` in `pyproject.toml` and land it on `master`. +2. Publish a GitHub Release whose tag is that version with a leading `v` -- + `v4.0.0a1` for version `4.0.0a1`. + +Publishing the release runs `publish.yml`, which tests the oldest and newest +supported Python/Django combinations, builds the sdist and wheel, checks the +README renders on PyPI, installs the wheel into a clean environment and starts +Django against it, and only then uploads. + +Two guards exist because getting either wrong is unfixable after the fact -- +PyPI does not allow re-uploading a version: + +- The tag must match `pyproject.toml`'s `version`. Nothing else connects them: + hatchling never looks at the tag, so a release tagged `v4.0.0` cut from a tree + still saying `4.0.0a1` would publish `4.0.0a1` and report success. +- The tag must start with `v`. The `pypi` deployment environment only accepts + tags matching `v*`, so a release cut from a branch cannot publish at all. + +:::{warning} +Renaming `publish.yml`, or the `pypi` environment, breaks publishing -- +both names are part of what PyPI trusts, and a mismatch is rejected at upload +time with an authentication error that does not mention the rename. Change it on +[PyPI](https://pypi.org/manage/project/django-mfa/settings/publishing/) first. +::: + ## Sending pull requests 1. Fork the repo: