Files
curo1305 16584ade00 docs(02): create phase 2 plan — Users & Authentication
5 plans across 5 waves covering AUTH-01..08, SEC-01..03/05..07,
ADMIN-01..05/07. Includes security hardening (Origin validation,
per-account rate limiting, TOTP replay prevention, refresh token
family revocation with security alert), TOTP + backup code login,
and admin panel frontend.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 19:13:44 +02:00

315 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 810 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"
---
<objective>
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.
</objective>
<execution_context>
@/Users/nik/.claude/get-shit-done/workflows/execute-plan.md
@/Users/nik/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<interfaces>
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
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Backend — TOTP, backup codes, password reset, logout-all endpoints</name>
<files>
backend/api/auth.py,
backend/tests/test_auth_totp.py
</files>
<read_first>
- 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)
</read_first>
<behavior>
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 810 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
</behavior>
<action>
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.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_auth_totp.py -x -q 2>&1 | tail -10</automated>
<automated>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')"</automated>
</verify>
<acceptance_criteria>
- 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"
</acceptance_criteria>
<done>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.</done>
</task>
<task type="auto">
<name>Task 2: Frontend — AccountView, TotpEnrollment, BackupCodesDisplay, PasswordReset views, ConfirmBlock</name>
<files>
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
</files>
<read_first>
- 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)
</read_first>
<action>
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 <img :src="qrUri"> 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).
</action>
<verify>
<automated>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');"</automated>
<automated>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');"</automated>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run build 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>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.</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
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
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
Create `.planning/phases/02-users-authentication/02-03-SUMMARY.md` when done.
</output>