---
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"
---

<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 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
  </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>
