TOTP — Time-based 2FA¶
Craft Easy ships with an RFC 6238-compliant TOTP implementation (craft_easy.core.auth.totp.TOTPService). It works with Google Authenticator, Authy, 1Password, Bitwarden and every other authenticator app that speaks the otpauth:// URI scheme.
TOTP is always a second factor on top of OTP or OAuth2 login — never the only factor. When a user has TOTP enabled, the first-factor login returns a pre-auth token with requires_2fa=true and the caller must then call /auth/2fa/verify with a TOTP code.
Setup flow¶
1. User logs in with OTP/OAuth2 → JWT user token
2. POST /auth/2fa/setup → secret + otpauth:// URI
3. User scans QR, enters first code
4. POST /auth/2fa/enable → 2FA is now required on all logins
Generating the secret and QR URI¶
resp = client.post("/auth/2fa/setup")
body = resp.json()
print(body["secret"]) # "JBSWY3DPEHPK3PXP..." (base32)
print(body["uri"]) # otpauth://totp/My%20App:user@example.com?secret=...&issuer=My%20App&algorithm=SHA1&digits=6&period=30
print(body["backup_codes"]) # list of one-time recovery codes
Render the URI as a QR code in the client (any QR library will do — the URI is standard). The user scans it with their authenticator app.
Under the hood, TOTPService.generate_secret() returns a base32-encoded random string, and get_provisioning_uri() assembles the standard URI:
from craft_easy.core.auth import TOTPService
totp = TOTPService(digits=6, period=30, window=1)
secret = totp.generate_secret() # "JBSWY3DPEHPK3PXP..."
uri = totp.get_provisioning_uri(secret, "user@example.com", issuer="My App")
Enabling 2FA¶
The user types the code shown in their app to confirm enrolment:
resp = client.post("/auth/2fa/enable", json={"code": "123456"})
resp.raise_for_status()
# 2FA is now active on this account
The server verifies the code with TOTPService.verify(secret, code) and only flips the two_factor_enabled flag if the code matches.
Login with 2FA enabled¶
# Step 1 — regular OTP login
r = httpx.post(f"{BASE}/auth/login/verify", json={
"target": "user@example.com",
"code": "123456",
"application": "my-app",
})
token = r.json()["token"]
if r.json()["requires_2fa"]:
# Step 2 — verify the TOTP code
r2 = httpx.post(
f"{BASE}/auth/2fa/verify",
json={"code": "874321"},
headers={"X-API-Key": token},
)
token = r2.json()["token"]
Only the final token (from /auth/2fa/verify) is usable on other endpoints. A pre-auth token rejected by any resource with 403 Complete two-factor authentication first.
Backup codes¶
At enrolment the user is given a list of single-use backup codes — TOTP_BACKUP_CODE_COUNT of them, defaulting to 10. Each can be used exactly once in place of a TOTP code:
Show the codes once, tell the user to store them somewhere safe, and offer a regeneration endpoint:
Verifying codes programmatically¶
from craft_easy.core.auth import TOTPService
totp = TOTPService()
assert totp.verify("JBSWY3DPEHPK3PXP...", "874321") in (True, False)
verify() accepts codes within ±window periods of the current time to absorb clock drift between the client and server. With the default window=1 and period=30, a code is valid from 30s in the past to 30s in the future — 90s of wall-clock tolerance.
Configuration¶
| Setting | Default | Purpose |
|---|---|---|
TOTP_ISSUER |
"My App" |
Name shown in the authenticator app. Always set this to your brand. |
TOTP_DIGITS |
6 |
Code length. Google Authenticator expects 6. |
TOTP_INTERVAL |
30 |
Time step in seconds. Do not change unless you know what you are doing. |
TOTP_VALID_WINDOW |
1 |
How many extra periods (past and future) to accept. |
TOTP_BACKUP_CODE_COUNT |
10 |
Backup codes generated at setup. |
Disabling 2FA¶
The user must re-authenticate with their current TOTP code as part of the request. Disabling does not delete the user; it clears two_factor_enabled, totp_secret, and any backup codes.
Endpoints reference¶
| Method | Path | Purpose |
|---|---|---|
POST |
/auth/2fa/setup |
Generate a new secret, QR URI and backup codes. |
POST |
/auth/2fa/enable |
Confirm enrolment by submitting the first code. |
POST |
/auth/2fa/verify |
Submit a TOTP or backup code after a pre-auth login. |
DELETE |
/auth/2fa |
Disable 2FA for the current user. |
GET |
/auth/2fa/status |
Whether 2FA is active and how many backup codes remain. |
POST |
/auth/2fa/regenerate-backup-codes |
Invalidate existing backup codes and issue a new set. |