Two-Factor Authentication (TOTP)¶
zeython.mfa adds a second factor to the session login you already have
(see Authentication): a user enrolls an authenticator
app (Google Authenticator, 1Password, Authy, ...), confirms it with a live
code, and from then on a correct password alone isn't enough to log in.
No new dependency — RFC 6238 TOTP only needs HMAC-SHA1 and base32, both already in the standard library.
Why this exists¶
A leaked or guessed password shouldn't be enough to take over an account. TOTP is the standard second factor every major provider offers: a 30-second, 6-digit code derived from a shared secret and the current time, generated by an app the user already has installed, no SMS or third-party service required.
Setup¶
Add the columns to your user model — the same convention
Authenticatable uses for password_hash:
# app/Models/user.py
from sqlalchemy import JSON, Boolean, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from zeython import Authenticatable, MfaEnrollable, Model, required, email
class User(Model, Authenticatable, MfaEnrollable):
__tablename__ = "users"
__hidden__ = ("password_hash", "mfa_secret", "mfa_recovery_codes")
__rules__ = {"name": [required()], "email": [required(), email()]}
name: Mapped[str] = mapped_column(String(255))
email: Mapped[str] = mapped_column(String(255), unique=True)
password_hash: Mapped[str] = mapped_column(String(255))
mfa_secret: Mapped[str | None] = mapped_column(String(64), nullable=True)
mfa_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
mfa_recovery_codes: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
mfa_last_counter: Mapped[int | None] = mapped_column(Integer, nullable=True)
mfa_secret and mfa_recovery_codes belong in __hidden__ — they're as
sensitive as password_hash and must never round-trip through to_dict().
mfa_last_counter is optional, but worth adding: without it, a valid
TOTP code stays usable for its entire ~90s validity window and can be
replayed any number of times within it — an attacker who captures one
in flight (a phishing proxy, a compromised logging middlebox,
shoulder-surfing) can submit that same code themselves and get in too,
even though the victim already used it. With the column present,
confirm()/verify_and_consume() reject a code whose 30s step was
already consumed. Omit it and nothing changes from before this existed —
it's opt-in, add it (and a migration) whenever you're ready.
No service provider or middleware to register: unlike auth itself, MFA is
just a set of functions operating on your user model and the existing
session — AuthServiceProvider (for the session cookie and CSRF
protection it needs) is the only prerequisite. A generated project already
has all of this wired up — see app/Controllers/auth_controller.py.
Enrolling¶
Enrollment is two steps, both requiring the user to already be logged in: generate a secret, then confirm it with a live code from the app that just scanned it — proving the user actually finished setting it up before MFA starts gating their login.
# app/Controllers/auth_controller.py
from zeython.mfa import confirm, enroll
async def mfa_enroll(self, request):
user = await current_user(request)
if user is None:
raise UnauthorizedException("Authentication required.")
enrollment = await enroll(user, account_name=user.email)
return JSONResponse({"secret": enrollment.secret, "uri": enrollment.uri})
async def mfa_confirm(self, request):
user = await current_user(request)
if user is None:
raise UnauthorizedException("Authentication required.")
data = await request.json()
recovery_codes = await confirm(user, data.get("code", ""))
return JSONResponse({"recovery_codes": recovery_codes})
enrollment.uri is an otpauth://totp/... URI — render it as a QR code
client-side (any JS QR library) for the user to scan, or show
enrollment.secret directly for manual entry. Nothing here pulls in an
image-processing dependency; that choice is left to your frontend.
confirm() raises ValidationException (a 422) if the code doesn't
verify or there's no enrollment in progress, turns mfa_enabled on, and
returns 8 recovery codes in plaintext — the only time they're ever
visible. Only their hash is stored, the same way a password is. Show
them to the user once and tell them to save them somewhere safe; there's
no way to retrieve them again short of disabling and re-enrolling.
Calling enroll() again before confirm() replaces the pending secret,
so an abandoned enrollment can't be confirmed later with a stale one.
Gating login behind the second factor¶
Once mfa_enabled is true, a correct password moves the user into a
pending state instead of logging them in outright:
# app/Controllers/auth_controller.py
from zeython.mfa import complete_challenge
from zeython.mfa import start_challenge as start_mfa_challenge
async def login(self, request):
data = await request.json()
user = await manager.attempt(data.get("email", ""), data.get("password", ""))
if user is None:
raise UnauthorizedException("Invalid email or password.")
if user.mfa_enabled:
start_mfa_challenge(request, user)
return JSONResponse({"mfa_required": True})
auth_login(request, user)
return JSONResponse(user.to_dict())
async def mfa_challenge(self, request):
data = await request.json()
user = await complete_challenge(request, data.get("code", ""))
if user is None:
raise UnauthorizedException("Invalid or expired code.")
return JSONResponse(user.to_dict())
start_challenge() stashes the user's id in the session under its own
key — a different key than the one zeython.auth.login() uses — so
current_user()/require_auth() keep returning
nothing until complete_challenge() succeeds. A client that never
finishes the second step never gets treated as logged in, even though the
password check already passed.
complete_challenge() accepts either a live TOTP code or an unused
recovery code, and on success calls zeython.auth.login() for you — no
separate step needed. On failure it returns None and leaves the pending
challenge in place, so the client can retry.
Rate-limited by default (rate_limit=True), keyed to the pending
user, not the caller's IP — a 6-digit TOTP code has only ~1,000,000
live combinations, brute-forceable by an attacker who already has the
victim's password and is only missing the second factor; keying by
account rather than IP still catches guesses spread across many source
IPs/proxies against the one account being targeted. This only takes
effect if RateLimitServiceProvider is registered
(it is by default in a generated project) — without it, complete_challenge()
behaves exactly as if rate_limit=False, not a crash. Tune it or turn it
off per call:
await complete_challenge(request, code, rate_limit_attempts=5, rate_limit_window=60.0)
await complete_challenge(request, code, rate_limit=False) # apply your own throttle() instead
Recovery codes¶
Each of the 8 codes confirm() returns works exactly once — passing one
to complete_challenge() (or verify_and_consume() directly) removes it
from storage on success, so a captured or reused code can't be replayed,
including by two requests presenting it at the same time: the check locks
the user's row (Model.find(..., for_update=True), see
Locking a row) so the second request's check
blocks until the first's transaction actually commits, then correctly
finds the code already gone. On SQLite specifically — no row-level
locking there — this guarantee doesn't hold; two genuinely concurrent
requests can still both succeed, the same as if the lock weren't there at
all. They exist for the case an authenticator app is lost or a phone is
wiped; without them, a locked-out user has no way back into their own
account short of a manual database fix.
Disabling¶
from zeython.mfa import disable
async def mfa_disable(self, request):
user = await current_user(request)
if user is None:
raise UnauthorizedException("Authentication required.")
await disable(user)
return JSONResponse({"message": "Two-factor authentication disabled."})
Clears the secret, the enabled flag, and every recovery code. A real app should require re-entering the password (or a fresh TOTP code) before calling this — an attacker who's already hijacked a session shouldn't be able to turn off the account's own second factor.
Clock drift¶
verify_totp() tolerates ±1 time step (30 seconds either side) by
default, via its valid_window parameter — enough for ordinary clock
drift between the server and the authenticator app's device without
meaningfully widening the guessing window an attacker gets per code.
API reference¶
See zeython.mfa for the full function list —
generate_secret(), provisioning_uri(), and verify_totp() if you need
the TOTP primitives directly instead of the enroll/confirm/challenge
flow above.