--- 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 Create `.planning/phases/02-users-authentication/02-03-SUMMARY.md` when done.