Second factor¶
Experimental
New in 0.6: its API may still change in any release, as its use shows what's missing. Scope and stability lists the tools that are settled.
The codes of an authenticator app (Google Authenticator, 1Password, Authy, Bitwarden…) after the
password, recovery codes for a lost phone, and a recent code for sensitive actions: FastAPITOTP,
with the crypto extra, which seals the secrets.
In an app: the example. Every class and function: the API reference.
from fastapi_alcazaba import FastAPITOTP
totp = FastAPITOTP(issuer="Example", secret=settings.totp_secret, audit=audit)
Adding the app¶
@app.post("/totp/enroll")
async def enroll(request: Request, user: CurrentUser) -> dict[str, str]:
enrollment = totp.enroll(user.id, label=user.email)
request.session["enrolling"] = enrollment.sealed # until a code of it is confirmed
return {"uri": enrollment.uri, "secret": enrollment.secret.get_secret_value()}
@app.post("/totp/confirm")
async def confirm(request: Request, form: Code, user: CurrentUser) -> dict[str, list[str]]:
sealed = request.session["enrolling"]
if not await totp.verify(user.id, sealed, form.code, request=request):
raise HTTPException(400, "Wrong code: try the next one")
recovery = totp.recovery_codes(user.id)
await db.users.update(user.id, totp=sealed, recovery=recovery.digests)
return {"recovery_codes": recovery.codes} # shown once
- The secret: 160 random bits (RFC 4226).
uriis theotpauth://URI to show as a QR code (with a library such assegnoorqrcode, in your page);secret, the same in base32, for those who type it. - Sealed, never in clear:
sealedis the secret encrypted with AES-256-GCM, bound to its user: store it, not the secret. It doesn't open for another user, nor withoutsecret, so a copy of the database, or a sealed secret moved to another account, gives nobody codes. A list rotatessecret: the first one seals, all of them open. - Confirmed first: turn the second factor on once the user has typed a code of the new app, so that a QR code scanned wrong doesn't lock them out.
Logging in¶
@app.post("/login/totp")
async def login_totp(request: Request, form: Code) -> dict[str, str]:
user = await db.users.get(request.session["pending"]) # set by the password step
if not await totp.verify(user.id, user.totp, form.code, request=request):
raise HTTPException(401, "Wrong code")
log_in(request, user)
totp.mark(request, user.id) # the second factor is recent: step-up
...
- The codes: RFC 6238's, SHA1, 6 digits every 30 seconds, as every app does (
digits=8,algorithm="SHA256",period=60for those that support it). The current one is accepted, andwindow=1step before and after, for clocks out of step. - Each code once: a code accepted is refused afterwards (RFC 6238, 5.2), in every process: someone who saw it over the user's shoulder, or a phishing page that relays it, gets nothing. Of two requests with the same code, one wins.
- Guesses: after
max_attempts(5) wrong codes, recovery codes included, the user's codes are refused forlockout(15 minutes) from the first of them, the right one too. A right code forgives the wrong ones before it. The other users aren't affected. - Halfway in: between the password and the code, keep in the session who's logging in, not that they're logged in: the routes for logged-in users don't take them yet.
- When the backend fails, nothing is accepted: no code is checked for replay, nor guesses counted.
Recovery codes¶
used = await totp.use_recovery_code(user.id, form.code, user.recovery, request=request)
if used is None:
raise HTTPException(401, "Wrong code")
await db.users.update(user.id, recovery=[d for d in user.recovery if d != used])
- Ten codes,
XXXXX-XXXXXof letters and digits that can't be mistaken for each other (about 50 bits each), typed in any case, with or without the dash. Shown once; the database keeps their digests (HMAC-SHA256 withsecret, bound to the user). - Each once:
use_recovery_codereturns the digest used, to delete; until it's deleted, another request with the same code is refused. Wrong ones count as guesses. - New ones replace the old ones:
recovery_codes(user.id)again, and store its digests instead.
Step-up¶
@app.delete(
"/account",
dependencies=[Depends(totp.require_recent(current_user_id, max_age=300))],
)
async def delete_account(...) -> None: ...
- A recent code:
require_recentlets through the users who gave their second factor in this session in the lastmax_ageseconds; the others get a 403 (Second factor required), to ask for a code, and requests without a user a 401.totp.mark(request, user.id)notes it, afterverifyoruse_recovery_code, and gives the session a new id: it holds more privileges now. - Bound to the user: the mark says whose it is; another user who logs in in the same browser session doesn't inherit it.
- Sessions: with
ServerSessionMiddleware(or Starlette'sSessionMiddleware). - Shared with passkeys: a login with a verified passkey marks the session the
same way (
webauthn.mark), and eitherrequire_recenttakes a code or a passkey; both arefastapi_alcazaba.auth.stepup's.
In the audit trail¶
With audit=, each code checked is an auth.mfa event and each recovery code tried an
auth.recovery_code one, with the user, the outcome (the reason when it failed: wrong,
replayed, malformed, locked, unchecked), and the request's route and client. Never the code.
Counted in fastapi_alcazaba.totp.verifications and fastapi_alcazaba.totp.recovery, by
outcome: a burst of wrong or locked is someone guessing.