Stack: Django (argon2) + pyotp · Project: TicketFlow Status: Published Prerequisite: Lesson 19 — OAuth2
Objectives
- Store passwords with modern KDFs (argon2/bcrypt) understanding salt, iterations and memory cost.
- Add TOTP (Google Authenticator) with QR, verification and single-use recovery codes.
- Design account recovery without turning it into the system's back door.
1. Why not a plain "hash"
SHA-256 of a password is useless: GPUs do billions/second. KDFs are designed to be slow on purpose:
| KDF | Cost | Note |
|---|---|---|
| argon2id | configurable CPU + MEMORY | the contest winner; memory hurts GPUs |
| bcrypt | CPU (cost 10-12) | veteran, 72-byte limit |
| PBKDF2 | pure CPU | the weakest of the three, but ubiquitous |
Django: PASSWORD_HASHERS with Argon2 first (pip install django[argon2]). The hash stores algorithm+parameters+salt+digest (argon2id$v=19$m=65536,t=3,p=4$...): upgrade parameters per user at the next login (transparent upgrade).
The salt (random per user, included in the hash) kills rainbow tables: two users with "password123" have different hashes. The slow cost kills offline brute force: if your DB leaks, "password123" still costs hours per mass attempt.
Verification with check_password is constant-time: it doesn't leak through timing how much matched.
2. The password in the flow (beyond the hash)
- Rate limit of login per user+IP (23): the slow hash also protects the endpoint, but 10 attempts/s against the real user's "p@ssw0rd" = online dictionary. Limit + backoff + progressive captcha.
- Identical error messages: "Invalid credentials" whether the email or the password fails (don't enumerate users).
- Never log passwords (not even in exception handlers): a log sanitizer (45).
- Length > complexity: allow 64+ chars, don't force blind monthly changes (NIST: no forced rotation; yes: a breached-password list —
django-pwned-passwordsagainst the Have I Been Pwned API with k-anonymity).
3. TOTP: the second factor that actually works
- Enrolment: you generate a per-user shared secret (
pyotp.random_base32()), show it as a QR (otpauth://totp/TicketFlow:ana?secret=...&issuer=TicketFlow), the user's app scans it. - Verification:
pyotp.TOTP(secret).verify(code, valid_window=1)— a ±30s window for desynchronized clocks. - Storing the secret: encrypted at rest (a field encrypted with a settings/KMS key — 23). If your DB leaks and the secret is plain, your 2FA is decorative.
- Recovery codes: 8-10 single-use codes, hashed like passwords, shown ONCE at activation. Without them, a lost phone = a lost account (support = a back door if it is lax).
- Remember device (optional): a signed 30-day cookie with an "MFA completed" mark — a conscious UX/risk balance.
4. Account recovery: the door that must not be a back door
The "forgot my password" flow is the most attacked spot (it bypasses hash and MFA if you do it wrong):
- Token: random (secrets.token_urlsafe), hashed in DB (like a password), TTL 1h, single use, bound to the user.
- Delivery: email to the registered user (NEVER "is this your email?" in the response — it enumerates users). The response is always the same: "If an account exists, you will receive an email".
- On reset: invalidate all sessions/tokens (global logout), notify by email, and if they had MFA... do NOT allow a reset without MFA (or demand recovery codes: the attacker with only the email doesn't pass the TOTP).
- Links: your own domain, no token in logs/referers (the email client may pre-load links: the single-use token + hash in DB mitigate).
5. MFA and the module's future
TOTP covers 95%. SMS is weak (SIM swapping) but better than nothing for users without the app. Passkeys/WebAuthn (OS keys) is the 2026 destination — cited as the ADR's natural evolution, not implemented today to avoid opening another front.
5. Biometrics and hardware: MFA beyond TOTP
Lesson 20's TOTP covers the standard; the complete map of the second factor, from weakest to strongest:
| Factor | What it is | Phishing resistance |
|---|---|---|
| SMS | a code by text message | LOW: SIM-swapping and SMS forwarding; discouraged by NIST |
| a code/click by email | LOW: email is the most stolen factor in the world | |
| TOTP (app) | a 6-digit code rotating every 30 s | MEDIUM: real-time phishing (a proxy between user and site) captures it and uses it instantly |
| Push with number | a notification with cross-verification | MEDIUM-HIGH: it demands user attention ("MFA fatigue" is the attack) |
| WebAuthn / passkey | a cryptographic key bound to the origin (TPM/key) | HIGH: phishing does NOT steal it — it only answers the legitimate domain |
TicketFlow's recommendation: TOTP as the universal factor (what the lesson implemented), passkeys/WebAuthn as the premium path (the browser does everything; the backend stores the public key and verifies the challenge with django-webauthn or py_webauthn), and SMS only as a last-resort recovery channel — documented as accepted risk. The phishing rule: a factor that does NOT know which site it is on (SMS, TOTP) can be fooled; one that does (WebAuthn) cannot.
6. Account recovery: the door that opens and closes
The reset flow already implemented (MFA demanded, hashed codes, no enumeration) is half of it; the other half is the REST of the side doors an attacker uses: (1) human support (the "I call and they reset it over the phone": a support protocol with verification equivalent to the automatic reset — social engineering is the real #1 vector); (2) the account's email (recovering someone's email = recovering everything: document the dependency chain); (3) recovery codes (the 10 single-use ones: hashed, already implemented — the user must store them OFFLINE, not in the same compromisable password manager); (4) SESSION rotation after the reset (invalidate all the user's active sessions — 18: the attacker already inside does not inherit the recovery).
The door test: an account-takeover pentest lists EVERY way back in and verifies each one demands the same strength. A secure reset with one weak side door is a lock on the front door and the window open.
- Which three argon2 parameters would you tune on a 2GB server, and why is memory part of the defense?
- Why is "change your password every month" no longer recommended, and what is done instead?
- Why is the TOTP secret encrypted at rest, and what is your MFA worth if your whole DB leaks with plain secrets?
- Design the reset flow for an account WITH MFA: what does it demand besides the email, and why?
- Why is the "forgot password" endpoint's response identical whether or not the user exists?
Continue with the exercises. The solutions only after trying it yourself.