---
phase: 02-users-authentication
plan: 03
type: execute
wave: 3
depends_on:
- 02-01
- 02-02
files_modified:
- backend/api/auth.py
- frontend/src/views/auth/PasswordResetView.vue
- frontend/src/views/auth/NewPasswordView.vue
- frontend/src/views/AccountView.vue
- frontend/src/components/auth/TotpEnrollment.vue
- frontend/src/components/auth/BackupCodesDisplay.vue
- frontend/src/components/ui/ConfirmBlock.vue
autonomous: true
requirements:
- AUTH-03
- AUTH-04
- AUTH-05
- AUTH-06
- AUTH-07
- AUTH-08
- SEC-06
must_haves:
truths:
- "A user can enroll TOTP: scan QR code, enter a 6-digit code to verify, receive and acknowledge 8–10 backup codes, then TOTP is marked active"
- "A backup code is invalidated on first use (used_at set to now)"
- "TOTP codes cannot be replayed within the 90-second validity window (Redis key prevents reuse)"
- "A user can reset password via a link that expires in 1 hour and does not auto-login after reset"
- "A user can sign out all devices, invalidating all refresh tokens"
- "All token comparison (backup codes) uses constant-time verify_password"
- "POST /api/auth/totp/enable returns 429 after 10 calls within 60 seconds"
artifacts:
- path: "backend/api/auth.py"
provides: "Adds: GET /api/auth/totp/setup, POST /api/auth/totp/enable, DELETE /api/auth/totp, POST /api/auth/logout-all, POST /api/auth/password-reset, POST /api/auth/password-reset/confirm"
- path: "frontend/src/views/AccountView.vue"
provides: "Account settings page with TOTP status, change password, sign-out-all confirmation"
- path: "frontend/src/components/auth/TotpEnrollment.vue"
provides: "Three-step TOTP enrollment: QR display → code verify → backup codes acknowledge"
- path: "frontend/src/components/auth/BackupCodesDisplay.vue"
provides: "Grid display of backup codes, copy-all button, acknowledgment checkbox"
key_links:
- from: "frontend/src/components/auth/TotpEnrollment.vue"
to: "/api/auth/totp/setup"
via: "api.totpSetup() in startSetup()"
pattern: "totpSetup"
- from: "frontend/src/views/AccountView.vue"
to: "useAuthStore().logoutAll()"
via: "ConfirmBlock confirmed event handler"
pattern: "logoutAll"
---
Complete the TOTP enrollment flow, backup codes, password reset, and sign-out-all — delivered as a complete vertical slice so that after this plan a user can set up 2FA end-to-end, recover their account via email, and revoke all sessions.
Purpose: This plan runs after Plans 01 and 02. It appends TOTP and recovery endpoints to api/auth.py (which Plan 02 created) and creates the frontend account management views.
Output: TOTP endpoints (setup/enable/disable), password reset endpoints, logout-all endpoint, AccountView, TotpEnrollment, BackupCodesDisplay, PasswordResetView, NewPasswordView, ConfirmBlock.
@/Users/nik/.claude/get-shit-done/workflows/execute-plan.md
@/Users/nik/.claude/get-shit-done/templates/summary.md
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/02-users-authentication/02-CONTEXT.md
@.planning/phases/02-users-authentication/02-PATTERNS.md
@.planning/phases/02-users-authentication/02-UI-SPEC.md
@.planning/phases/02-users-authentication/02-01-SUMMARY.md
From backend/services/auth.py (Plan 01 output):
async def provision_totp(session, user_id) -> tuple[str, str] # (secret, provisioning_uri)
async def verify_totp(session, user_id, code, redis_client) -> bool
async def generate_backup_codes(n=10) -> list[str]
async def store_backup_codes(session, user_id, codes) -> None
async def verify_backup_code(session, user_id, code) -> bool
def create_password_reset_token(user_id: str) -> str
def decode_password_reset_token(token: str) -> str # raises ValueError
async def hash_password(plain: str) -> str
async def revoke_all_refresh_tokens(session, user_id) -> int
From backend/db/models.py:
class User: id, handle, email, password_hash, totp_secret, totp_enabled, role, is_active
class BackupCode: id, user_id, code_hash, used_at (nullable used_at = unused)
From backend/deps/auth.py (Plan 01):
async def get_current_user(...) -> User # raises 401 if not authenticated
From frontend/src/stores/auth.js (Plan 02):
const user = ref(null) # { id, handle, email, role, totp_enabled }
async function logoutAll()
async function refresh()
From frontend/src/api/client.js (Plan 02 adds these stubs):
export function totpSetup() # GET /api/auth/totp/setup
export function totpEnable(code) # POST /api/auth/totp/enable { code }
export function totpDisable() # DELETE /api/auth/totp
export function logoutAll() # POST /api/auth/logout-all
export function passwordResetRequest(email) # POST /api/auth/password-reset { email }
export function passwordResetConfirm(token, pw) # POST /api/auth/password-reset/confirm { token, password }
export function changePassword(body) # POST /api/auth/change-password
Task 1: Backend — TOTP, backup codes, password reset, logout-all endpoints
backend/api/auth.py,
backend/tests/test_auth_totp.py
- backend/api/auth.py (existing file after Plan 02 — append new routes, do not recreate)
- backend/services/auth.py (provision_totp, verify_totp, generate_backup_codes, store_backup_codes, verify_backup_code, create_password_reset_token, decode_password_reset_token, hash_password, revoke_all_refresh_tokens signatures)
- backend/db/models.py (User.totp_secret, User.totp_enabled, User.password_hash fields)
- backend/tasks/email_tasks.py (send_reset_email.delay(to_address, reset_link) call pattern)
- .planning/phases/02-users-authentication/02-CONTEXT.md (D-02, D-03: Celery email; dev fallback logs link to stdout)
GET /api/auth/totp/setup (requires get_current_user):
- If user.totp_enabled: raise 400 "TOTP already enabled"
- Call provision_totp(session, current_user.id) — stores secret in users.totp_secret
- Return { provisioning_uri, secret }
POST /api/auth/totp/enable (requires get_current_user):
- Apply @limiter.limit("10/minute") rate limit (SEC-02) to this endpoint
- Body: { code: str }
- Fetch Redis client from app.state.redis (add redis connection to lifespan in Plan 02, or use aioredis.from_url(settings.redis_url) here)
- verify_totp(session, user.id, code, redis_client) — if False raise 400 "Incorrect or expired code"
- Set user.totp_enabled = True, commit
- Generate 8–10 backup codes (generate_backup_codes(10)), store via store_backup_codes
- Return { backup_codes: [list of plaintext codes] } — only time codes are returned in plaintext
DELETE /api/auth/totp (requires get_current_user):
- Set user.totp_enabled = False, user.totp_secret = None, delete all BackupCode rows for user
- Return 200 { message: "TOTP disabled" }
POST /api/auth/logout-all (requires get_current_user):
- Call revoke_all_refresh_tokens(session, current_user.id)
- Clear the current session's cookie: response.delete_cookie("refresh_token", path="/api/auth/refresh")
- Return 200 { message: "All sessions revoked" }
POST /api/auth/password-reset:
- Body: { email: str }
- Look up user by email — whether found or not, return 202 { message: "If an account exists for that email, you will receive a reset link shortly." } (anti-enumeration)
- If user found: create_password_reset_token(str(user.id)); build reset_link = f"{settings.frontend_url}/password-reset/confirm?token={token}" (add frontend_url: str = "http://localhost:5173" to Settings); enqueue send_reset_email.delay(user.email, reset_link)
- Apply rate limiting: @limiter.limit("5/hour") on this endpoint
POST /api/auth/password-reset/confirm:
- Body: { token: str, password: str }
- decode_password_reset_token(token) — on ValueError raise 400 "Invalid or expired reset link"
- Validate password strength (same rules as register)
- check_hibp(password) — if True raise 422 "This password has appeared in a data breach"
- hash_password(password), update user.password_hash, revoke_all_refresh_tokens
- Return 200 { message: "Password updated. Please sign in." } — do NOT issue tokens (AUTH-05: must pass TOTP gate on next login)
Write backend/tests/test_auth_totp.py:
- test_totp_setup_returns_uri: register user, login to get Bearer, call GET /api/auth/totp/setup → 200, response has "provisioning_uri" and "secret"
- test_totp_setup_already_enabled: set user.totp_enabled=True in DB, call setup → 400
- test_password_reset_always_202: POST /api/auth/password-reset with nonexistent email → 202
- test_logout_all_revokes_tokens: register + login; call POST /api/auth/logout-all with Bearer → 200
- test_totp_enable_rate_limit: 11 POST /api/auth/totp/enable calls in 60 seconds → 429 on the 11th
Append the new routes to backend/api/auth.py (do not recreate — file was created in Plan 02).
For the TOTP enable endpoint, apply @limiter.limit("10/minute") decorator (limiter object is already defined in api/auth.py from Plan 02's slowapi setup). The rate limit guards against brute-force TOTP code submission.
For Redis in TOTP verify: the lifespan wiring in Plan 02 adds app.state.redis. In the TOTP enable handler, get the client via request.app.state.redis. Add Request as a parameter: async def enable_totp(request: Request, body: TotpEnableRequest, session: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)).
Add frontend_url: str = "http://localhost:5173" to Settings in config.py.
Add rate limiting decorator @limiter.limit("5/hour") to the password-reset endpoint. The limiter object is defined in api/auth.py (from the Plan 02 slowapi setup).
BackupCode cleanup on TOTP disable: use `await session.execute(delete(BackupCode).where(BackupCode.user_id == current_user.id))` from sqlalchemy import delete.
Write the test file using async_client fixture; override get_current_user dep where needed to avoid full login flow in unit tests.
cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_auth_totp.py -x -q 2>&1 | tail -10
cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "from api.auth import router; routes = [r.path for r in router.routes]; assert '/api/auth/totp/setup' in routes or any('totp/setup' in r for r in routes); print('TOTP routes OK')"
- tests/test_auth_totp.py passes (pytest exits 0)
- GET /api/auth/totp/setup requires Authorization Bearer header (returns 403 without it)
- POST /api/auth/totp/enable returns { backup_codes: [...] } array of 10 strings when code is correct
- POST /api/auth/totp/enable has @limiter.limit("10/minute") — 11 calls in 60 seconds returns 429 on the 11th
- POST /api/auth/password-reset with any email returns 202 (anti-enumeration)
- POST /api/auth/password-reset/confirm with valid token but weak password returns 422
- POST /api/auth/password-reset/confirm with valid token and strong password returns 200 with "Please sign in" — no access_token in response
- POST /api/auth/logout-all returns 200 { message: "All sessions revoked" }
- backend/config.py Settings contains "frontend_url"
TOTP enrollment (with rate limiting on /totp/enable), backup code issuance, password reset (Celery-dispatched email, 1-hour token, no auto-login), and sign-out-all endpoints are functional and tested.
Task 2: Frontend — AccountView, TotpEnrollment, BackupCodesDisplay, PasswordReset views, ConfirmBlock
frontend/src/views/AccountView.vue,
frontend/src/views/auth/PasswordResetView.vue,
frontend/src/views/auth/NewPasswordView.vue,
frontend/src/components/auth/TotpEnrollment.vue,
frontend/src/components/auth/BackupCodesDisplay.vue,
frontend/src/components/ui/ConfirmBlock.vue
- frontend/src/views/SettingsView.vue (tabbed sections pattern — v-if on activeTab ref, card layout)
- frontend/src/components/upload/DropZone.vue (interactive multi-step component, defineEmits pattern)
- frontend/src/components/upload/UploadProgress.vue (display list + action component)
- .planning/phases/02-users-authentication/02-UI-SPEC.md (TOTP Enrollment Flow, Account View, Copywriting Contract, form field states, loading states, error placement)
- .planning/phases/02-users-authentication/02-PATTERNS.md (TotpEnrollment, BackupCodesDisplay, ConfirmBlock, AccountView, PasswordResetView sections)
frontend/src/components/auth/ConfirmBlock.vue: Props: { message: String, confirmLabel: { type: String, default: 'Confirm' }, confirmClass: { type: String, default: '' } }. Emits: 'confirmed', 'cancelled'. Renders: message text paragraph + "Keep signed in" / destructive "Confirm" button pair. No checkbox (that is BackupCodesDisplay's acknowledge pattern). Use in sign-out-all flow.
frontend/src/components/auth/BackupCodesDisplay.vue: Props: { codes: Array }. Emits: 'acknowledged'. State: acknowledged = ref(false), copied = ref(false). Template: heading "Save your backup codes", subheading per copywriting contract, 2-column grid (grid grid-cols-2 gap-2) of code cells (font-mono text-sm text-gray-800 bg-gray-50 border border-gray-200 rounded-md px-3 py-2 text-center). Copy-all button: on click, navigator.clipboard.writeText(codes.join('\n')), set copied=true for 2s. Acknowledgment checkbox: "I have saved these codes in a secure place. I understand they will not be shown again." CTA "Enable two-factor authentication" disabled until acknowledged checked. On CTA click: emit('acknowledged').
frontend/src/components/auth/TotpEnrollment.vue: Emits: 'enrolled'. State: step = ref('setup'), qrUri = ref(''), secret = ref(''), verifyCode = ref(''), error = ref(null), loading = ref(false). Step 'setup': button "Set up two-factor authentication" that calls startSetup() → api.totpSetup() → sets qrUri and secret, moves to 'verify'. Step 'verify': QR code rendered as
200x200px centered (provisioning URI is a data URL or the backend can return a base64 QR — if the backend returns the raw URI, use qrcode.js or render as a link; simpler: render the manual secret only + link to open in app, OR use an img tag if URI is an otpauth:// scheme — for MVP, render manual secret prominently + small note to scan with app using the provisioning_uri). Manual secret display in font-mono text-sm bg-gray-50 px-3 py-2 rounded-md border. Copy icon button aria-label="Copy secret key". Single input w-36 centered inputmode="numeric" maxlength="6" for the verification code. "Verify code" button → api.totpEnable(verifyCode.value) → on success, backupCodes = response.backup_codes, step = 'backup-codes'. Step 'backup-codes': render BackupCodesDisplay :codes="backupCodes" @acknowledged="finishEnrollment". finishEnrollment emits 'enrolled' to parent.
Note on QR rendering: The backend returns a provisioning_uri (otpauth:// scheme). To render a QR code without a library, display the URI as a link "Open in authenticator app" AND show the manual secret. The QR image specification (200x200, white card) can be deferred to a visual polish pass; the functional flow works without it. If qrcode library is available in node_modules, use it — otherwise render the secret only.
frontend/src/views/AccountView.vue: Uses standard sidebar layout (wrapped in existing App.vue layout — not AuthLayout). Sections as stacked cards (space-y-6): 1. Account information (email display, role badge). 2. Two-factor authentication: if user.totp_enabled show status "Enabled" + "Disable 2FA" button; else show TotpEnrollment component. 3. Change password: current password + new password + PasswordStrengthBar + confirm. Wire the Change password form to call client.changePassword({current_password, new_password}); show inline error on 400 (wrong current password), breach error on 422 with detail containing "breach", strength error client-side. 4. Sessions: "Sign out all devices" button → shows ConfirmBlock inline → on 'confirmed' call authStore.logoutAll() → router.push('/login'). Copywriting: "Account settings" heading. Role badge: admin = bg-indigo-100 text-indigo-700; user = bg-gray-100 text-gray-600. After successful 2FA enrollment (TotpEnrollment 'enrolled' event): update user.value.totp_enabled = true in authStore.
frontend/src/views/auth/PasswordResetView.vue: Single email input, "Send reset link" button. On submit: api.passwordResetRequest(email) → show success state (replace form with green message block per copywriting: "If an account exists for that email, you will receive a reset link shortly. Check your inbox.") — anti-enumeration: always show success regardless of actual result. Use v-if on submitted ref.
frontend/src/views/auth/NewPasswordView.vue: Read token from route.query.token. Fields: new password + confirm password. PasswordStrengthBar below password. On submit: api.passwordResetConfirm(token, password) → on success show message "Password updated. Please sign in." with router.push('/login') after 2s. On error show form-level error block. Do NOT auto-login (AUTH-05).
cd /Users/nik/Documents/Progamming/document_scanner/frontend && node -e "const fs = require('fs'); ['src/views/AccountView.vue','src/components/auth/TotpEnrollment.vue','src/components/auth/BackupCodesDisplay.vue','src/components/ui/ConfirmBlock.vue','src/views/auth/PasswordResetView.vue','src/views/auth/NewPasswordView.vue'].forEach(f => { if (!fs.existsSync(f)) throw new Error('Missing: ' + f); }); console.log('All files exist');"
cd /Users/nik/Documents/Progamming/document_scanner/frontend && node -e "const fs = require('fs'); const av = fs.readFileSync('src/views/AccountView.vue','utf8'); if (!av.includes('changePassword')) throw new Error('changePassword not wired in AccountView'); console.log('AccountView changePassword OK');"
cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run build 2>&1 | tail -5
- All 6 files exist
- TotpEnrollment.vue contains "setup" and "verify" and "backup-codes" (three step values)
- TotpEnrollment.vue emits 'enrolled' — grep -c "enrolled" frontend/src/components/auth/TotpEnrollment.vue returns at least 1
- BackupCodesDisplay.vue contains "grid grid-cols-2" and "navigator.clipboard.writeText"
- BackupCodesDisplay.vue contains the acknowledgment checkbox text "I have saved these codes"
- AccountView.vue contains "Sign out all devices" and "Account settings"
- AccountView.vue contains "changePassword" (change-password form wired to client)
- AccountView.vue shows inline error for breach (detail containing "breach") and wrong current password (401)
- AccountView.vue does NOT contain "localStorage"
- PasswordResetView.vue contains "If an account exists" (anti-enumeration copy)
- NewPasswordView.vue does NOT contain "accessToken" (no auto-login after reset)
- npm run build exits 0
TOTP enrollment flow (QR/secret display → code verify → backup codes acknowledge → enable) delivered end-to-end in Vue. Password reset views, change-password form (wired to client.changePassword), and sign-out-all confirmation functional.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client→API (TOTP enable) | 6-digit code and backup codes cross this boundary — one-time use enforced |
| API→Redis (TOTP replay) | Used code keys written with TTL; Redis on internal Docker network |
| client→API (password reset) | Reset token in URL query param — signed JWT, verified server-side |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-17 | Spoofing | TOTP replay attack | mitigate | Redis key "totp_used:{user_id}:{code}" TTL=90s prevents reuse within valid_window=1 (AUTH-08) |
| T-02-18 | Spoofing | Backup code reuse | mitigate | BackupCode.used_at set to now() on first use; verify_backup_code always returns False for used codes |
| T-02-19 | Information Disclosure | Backup codes exposure | mitigate | Codes returned plaintext ONCE (POST /totp/enable response only); stored as Argon2 hashes in DB; subsequent GET /account shows only count |
| T-02-20 | Elevation of Privilege | Password reset token type confusion | mitigate | decode_password_reset_token validates typ="password-reset"; cannot be reused as access token |
| T-02-21 | Elevation of Privilege | Password reset auto-login | mitigate | Confirm endpoint returns 200 with message, NO tokens issued — user must re-authenticate through /login (AUTH-05) |
| T-02-22 | Information Disclosure | Email enumeration via password reset | mitigate | Always return 202 regardless of whether email exists; copy is anti-enumeration per copywriting contract |
| T-02-23 | Tampering | TOTP code constant-time compare | accept | pyotp.TOTP.verify() uses Python string comparison — timing difference negligible for 6-digit codes; rate limiting (10/min per SEC-02) is primary defense |
| T-02-24 | Spoofing | Sign-out-all confirmation | mitigate | ConfirmBlock requires explicit user action (click "Sign out all devices" button) — not triggered by passive navigation |
| T-02-25 | Denial of Service | TOTP brute force | mitigate | @limiter.limit("10/minute") on POST /api/auth/totp/enable prevents rapid code guessing |
1. GET /api/auth/totp/setup without Bearer → 403 (not 200)
2. POST /api/auth/totp/enable with a valid TOTP code → 200 with { backup_codes: [...] } length 10
3. POST /api/auth/totp/enable again with same code within 90s → 401 (replay rejected)
4. 11 POST /api/auth/totp/enable calls in 60 seconds → 429 on the 11th
5. POST /api/auth/password-reset with nonexistent email → 202 (anti-enumeration)
6. POST /api/auth/password-reset/confirm valid token + strong password → 200 body contains "Please sign in" — no access_token key in response
7. npm run build exits 0
8. pytest tests/test_auth_totp.py passes
9. grep -c "used_at" backend/db/models.py returns at least 1 (BackupCode.used_at column present)
10. AccountView change-password form sends to client.changePassword and shows breach/wrong-password errors
- TOTP enrollment delivers QR/secret, verifies code with replay prevention and rate limiting, returns 10 backup codes
- Backup code single-use enforced via BackupCode.used_at
- Password reset token (1-hour JWT) dispatched via Celery; confirm endpoint does not auto-login
- Sign-out-all revokes all refresh tokens in DB
- AccountView change-password section wired to client.changePassword with error handling
- All frontend views match UI-SPEC copywriting and visual contract
- npm run build clean