Sign in by email, an authenticator app in place of the emailed code, a cooldown per address, and no Django admin (2026-10-08)¶
Until now a user signed in with a username and password, then a six-digit
code sent to their email address; the application counted no failed
passwords, leaving nginx as the only brake; and the Django admin site at
/admin/ was installed, reachable by a staff user through the same
sign-in, with every app carrying an admin.py.
Decision¶
Four changes, made together:
- The email address is the sign-in name.
EmailBackendis the only authentication backend, matchingemail__iexact; a partial unique constraint onLower("email")(blank excluded, for the inactive system user) keeps each address to one user, and every form that edits the address requires it and checks the same rule. The username stays as a display name, which is what the rest of the application uses it for. - An authenticator app (TOTP) replaces the emailed code for a user who
sets one up, under Settings > Security. It does not sit on top of the
emailed code: with the app enrolled, no email is sent and the emailed
step is closed to that user. The secret is stored in clear on its own
row, as cpl stores it; the last accepted step is recorded, in one
guarded update, so a code is good once. The firm can require the app
(
Firm.require_authenticator): a middleware then confines an unenrolled user to the Security page until they enrol. A lost phone is an administrator's reset from the Users page, or thereset_authenticatorcommand. - Failed sign-ins start a cooldown, counted per email address as
typed, in a table (
SignInThrottle) rather than the per-process cache: five free, then 30 seconds doubling to 15 minutes. Wrong authenticator codes count toward it; the password is not checked during it; a completed sign-in clears it; an hour's quiet forgets it. Addresses that belong to nobody are counted the same, so the cooldown does not say which exist. - The Django admin is removed:
django.contrib.adminis out ofINSTALLED_APPS, its URLs and theadmin_loginredirect are gone, and everyadmin.pyis deleted. Repairs are the application's screens, two commands (changepassword,reset_authenticator) and the shell.
Alternatives¶
- Keep the emailed code as a fallback behind the app. Rejected: the app would then be only as strong as the inbox, which is exactly what it is meant to improve on. The cost, a lost phone, is met by the administrator's reset and the command for the last administrator.
- Encrypting the secret with a key derived from
SECRET_KEY, as the AI provider keys are. Built first, then taken out. It protects the seeds only in a database copy taken without the server'sconfig/.env, which already holds the password hashes and the emailed codes in clear; and it tied every enrolment toSECRET_KEY, so rotating the key, or the nightly copy of production onto a development machine with another key, un-enrolled everyone. The alternative of a separate sharedAUTHENTICATOR_KEYwas a second secret to keep in step for a narrow gain. cpl had reached the same answer. - Recovery codes. Not built. The firm has an administrator a user can reach, and a reset that falls back to the emailed code covers the case with less to explain and nothing to store.
- Count failures in the cache. The cache is per process
(
config/settings.pysays so), so three gunicorn workers would allow three times the failures. A table is one row per address and shared. - Key the cooldown on the client address. nginx already limits by address. Keying the application's count on the typed address is what stops a password being guessed against one account from many addresses; the trade, that someone who knows an address can keep its owner waiting up to 15 minutes at a time, is accepted and written into the security checklist.
- Keep the admin behind the new sign-in. Rejected by the request
itself. The admin bypassed every rule the application enforces (the
last-administrator guard, what deleting a matter removes, the
permission flags), and its one operator use, the Django-Q task pages,
is covered by
qinfo,qmonitorand the shell.
Consequences¶
client.login(username=...)in a test reachesEmailBackendand finds nobody; tests sign in withforce_login.- The sub-package models (
invoicing/*/models.py,activity/*/models.py,matters/*/models.py) are imported from their app'smodels.py. Theadmin.pyfiles had been importing them at start-up as a side effect; without that, the registry lacked them until a URL import loaded them, and the test database's serialisation failed on trust's relation toinvoicing.Payment. - A database backup carries the authenticator seeds, as it carries the
password hashes; the security checklist says so. Enrolments survive a
SECRET_KEYrotation and a copy of the database to another machine. - Deploying this needs the two migrations (
accounts 0019,settings 0011) and a restart of both services. The nginx login location no longer needs to name/admin/login; an installed site file that still does is harmless. - There is no
collectstaticstep any more: the admin's assets were the only files it collected. - Operators lose the admin's change-history pages.
simple_historystill records every change; the rows are reached from the shell.
Evidence¶
apps/accounts/backends.py,apps/accounts/totp.py,apps/accounts/views.py,apps/accounts/middleware.py(AuthenticatorRequiredMiddleware),apps/settings/security/views.py.apps/accounts/migrations/0019_authenticator_and_sign_in_throttle.py,apps/settings/migrations/0011_firm_require_authenticator.py.apps/accounts/tests/test_signin.py,test_authenticator.py,test_no_admin.py.apps/invoicing/models.pyand the comments at the foot ofapps/activity/models.pyandapps/matters/models.py.
Related¶
- Wrong sign-in codes are counted on the code, not the session, which this keeps for the emailed code.
- Identity and access, the Sign-in section.
- Users and permissions and the Security checklist.