Files
kite/.planning/phases/02-users-authentication/02-02-PLAN.md
T
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

449 lines
36 KiB
Markdown

---
phase: 02-users-authentication
plan: 02
type: execute
wave: 2
depends_on:
- 02-01
files_modified:
- backend/api/auth.py
- backend/services/email.py
- backend/tasks/email_tasks.py
- backend/main.py
- frontend/src/api/client.js
- frontend/src/stores/auth.js
- frontend/src/router/index.js
- frontend/src/layouts/AuthLayout.vue
- frontend/src/views/auth/LoginView.vue
- frontend/src/views/auth/RegisterView.vue
- frontend/src/components/auth/PasswordStrengthBar.vue
- frontend/src/components/ui/AppSpinner.vue
autonomous: true
requirements:
- AUTH-01
- AUTH-02
- AUTH-04
- SEC-01
- SEC-02
- SEC-03
- SEC-05
must_haves:
truths:
- "A new user can register with email/password; a HIBP-breached password is rejected with inline error"
- "A registered user can log in; access token lives in Pinia memory only — never localStorage"
- "The httpOnly refresh cookie is set on login with SameSite=Strict"
- "All responses include Content-Security-Policy and X-Frame-Options headers"
- "Auth endpoints return 429 when IP rate limit exceeded (10 req/min) or when per-account login attempts exceed 10 within 15 minutes"
- "POST requests with an Origin header not in CORS_ORIGINS are rejected with 403"
- "CORS is locked to CORS_ORIGINS env var — allow_origins='*' removed"
- "Unauthenticated Vue Router navigation to a protected route redirects to /login"
- "After login, user is redirected to the originally requested route"
- "A user with password_must_change=True receives {requires_password_change: true} on login without tokens"
- "A logged-in user can change their password; a breached new password is rejected"
- "A user with TOTP enabled can log in using a one-time backup code; the code is invalidated after use"
artifacts:
- path: "backend/api/auth.py"
provides: "POST /api/auth/register, POST /api/auth/login, POST /api/auth/refresh, POST /api/auth/logout, GET /api/auth/me, POST /api/auth/change-password"
exports:
- "router"
- path: "backend/main.py"
provides: "CSP headers middleware, Origin validation middleware, CORS locked to settings.cors_origins, Redis lifespan wiring (app.state.redis), auth/admin routers included, admin bootstrap in lifespan"
contains: "cors_origins"
- path: "frontend/src/stores/auth.js"
provides: "useAuthStore with accessToken (memory only), user, login(email, password, options), logout(), refresh(), register()"
- path: "frontend/src/views/auth/LoginView.vue"
provides: "Two-step login UI: password step → optional TOTP step → optional backup code step"
key_links:
- from: "frontend/src/api/client.js"
to: "/api/auth/refresh"
via: "authStore.refresh() called on 401"
pattern: "authStore\\.refresh"
- from: "frontend/src/router/index.js"
to: "useAuthStore().accessToken"
via: "beforeEach guard"
pattern: "accessToken"
- from: "backend/main.py"
to: "settings.cors_origins"
via: "CORSMiddleware allow_origins"
pattern: "cors_origins"
- from: "backend/main.py"
to: "aioredis"
via: "app.state.redis = await aioredis.from_url(settings.redis_url) in lifespan startup"
pattern: "app\\.state\\.redis"
---
<objective>
Deliver the first working vertical slice: a user can register, log in, and the app enforces authentication. This plan wires the register/login/logout/refresh/change-password API endpoints, the Vue auth store, the router guard, and the two auth views (Login, Register). Security headers, Origin validation, and rate limiting are applied in this plan. The login endpoint fully supports both TOTP codes and one-time backup codes (AUTH-04).
Purpose: After this plan executes, a user can open the app, register an account, log in (including via backup code if TOTP is active), and be redirected correctly — the auth wall is live.
Output: backend/api/auth.py (register, login, refresh, logout, me, change-password), backend/main.py (CSP, Origin middleware, CORS, Redis lifespan, lifespan bootstrap), frontend auth store + router guard + Login/Register views.
</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 (output of Plan 01):
async def hash_password(plain: str) -> str
async def verify_password(plain: str, hashed: str) -> bool
def create_access_token(user_id: str, role: str) -> str
def decode_access_token(token: str) -> dict # raises ValueError
async def create_refresh_token(session: AsyncSession, user_id: uuid.UUID) -> str # returns raw token
async def rotate_refresh_token(session: AsyncSession, raw_token: str) -> tuple[str, str] # (new_raw, user_id_str)
async def revoke_all_refresh_tokens(session: AsyncSession, user_id: uuid.UUID) -> int
async def check_hibp(password: str) -> bool # True = pwned
async def bootstrap_admin(session: AsyncSession) -> None
async def verify_backup_code(session: AsyncSession, user_id: uuid.UUID, code: str) -> bool
From backend/deps/auth.py (output of Plan 01):
async def get_current_user(credentials, session) -> User # raises 401
async def get_current_admin(user) -> User # raises 403
From backend/db/models.py:
class User: id, handle, email, password_hash, totp_enabled, role, is_active, password_must_change, created_at
class Quota: user_id, limit_bytes, used_bytes
From backend/main.py (current — must be extended, not replaced):
app = FastAPI(..., lifespan=lifespan)
app.add_middleware(CORSMiddleware, allow_origins=["*"], ...)
app.include_router(documents_router)
app.include_router(topics_router)
app.include_router(settings_router)
From frontend/src/stores/documents.js (Pinia store pattern):
export const useDocumentsStore = defineStore('documents', () => {
const loading = ref(false)
const error = ref(null)
async function fetchDocuments(...) {
loading.value = true; error.value = null
try { ... } catch (e) { error.value = e.message; throw e } finally { loading.value = false }
}
return { ..., fetchDocuments }
})
From frontend/src/api/client.js (current — must be extended, not replaced):
async function request(path, options = {}) { ... }
export function uploadDocument(...) { ... }
// All exports follow: return request('/api/...', { method: 'POST', ... })
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Backend — register/login/refresh/logout/me/change-password endpoints + security hardening</name>
<files>
backend/api/auth.py,
backend/services/email.py,
backend/main.py,
backend/tests/test_auth_api.py
</files>
<read_first>
- backend/main.py (full file — extend lifespan and middleware, do not recreate)
- backend/api/documents.py (router declaration, Pydantic body pattern, error mapping pattern)
- backend/celery_app.py (task_routes dict — add email queue route)
- backend/tasks/document_tasks.py (Celery task pattern with asyncio.run + deferred imports)
- backend/services/classifier.py (pure-Python service pattern for email.py)
- .planning/phases/02-users-authentication/02-PATTERNS.md (api/auth.py and email service sections)
- .planning/phases/02-users-authentication/02-CONTEXT.md (D-01, D-02, D-03, D-07, D-08, D-09)
</read_first>
<behavior>
POST /api/auth/register:
- Body: { handle, email, password }
- Validate password strength server-side: min 12 chars, has uppercase, lowercase, digit, special char — return 422 if fails with detail matching "Password must be at least 12 characters"
- check_hibp(password) — if True return 422 with detail "This password has appeared in a data breach"
- hash_password(password), insert User row (password_must_change=False), insert Quota row (limit_bytes=104857600, used_bytes=0)
- Return 201 { id, handle, email, role, totp_enabled, created_at }
- If email/handle already exists: raise 409 with detail "Email or handle already in use"
POST /api/auth/login:
- Body: { email, password, totp_code: str | None = None, backup_code: str | None = None }
- Per-account rate limiting (SEC-02): before password verification, check Redis counter keyed f'login_attempts:{email}' using app.state.redis.incr and .expire. If count > 10 within a 15-minute window (TTL=900s), return HTTP 429 with body {'detail': 'Too many login attempts. Try again in 15 minutes.'}. This check runs before any DB lookup.
- Look up User by email; if not found or wrong password: raise 401 "Incorrect email or password" (anti-enumeration)
- If user.is_active is False: raise 401 "Account deactivated"
- password_must_change check: if user.password_must_change is True, return 200 { requires_password_change: true, user_id: str(user.id) } WITHOUT issuing tokens or setting a cookie
- TOTP/backup-code branch (when user.totp_enabled is True):
* If both totp_code is None AND backup_code is None: return 200 { requires_totp: true } (no tokens yet)
* If totp_code is provided (non-None): treat as TOTP path — call verify_totp(session, user.id, totp_code, redis_client); on failure raise 401 "Incorrect code". (totp_code takes precedence if both fields are provided.)
* If backup_code is provided (non-None) and totp_code is None: call auth_service.verify_backup_code(session, user.id, backup_code); if True proceed to token issuance; if False raise HTTPException(401, "Invalid or already used code").
- On success: create_access_token, create_refresh_token, set httpOnly cookie
- Cookie: name="refresh_token", httponly=True, secure=True, samesite="strict", path="/api/auth/refresh", max_age=settings.refresh_token_expire_days * 86400
- Return 200 { access_token, user: { id, handle, email, role, totp_enabled } }
POST /api/auth/refresh:
- Read refresh_token from cookie (request.cookies.get("refresh_token")); if missing raise 401
- rotate_refresh_token(session, raw_token): on ValueError("token_family_revoked") raise 401 "Session revoked"
- Set new httpOnly cookie, return { access_token, user: {...} }
POST /api/auth/logout:
- Read refresh_token from cookie; if present, look up RefreshToken row by token_hash, set revoked=True
- Clear cookie: response.delete_cookie("refresh_token", path="/api/auth/refresh")
- Return 200 { message: "Logged out" }
GET /api/auth/me:
- Requires get_current_user dep
- Return { id, handle, email, role, totp_enabled, created_at }
POST /api/auth/change-password (requires get_current_user):
- Body: { current_password: str, new_password: str }
- Verify current_password via auth_service.verify_password(current_password, user.password_hash); if False raise 401 "Current password is incorrect"
- call check_hibp(new_password); if True raise 422 with detail "This password has appeared in a data breach"
- Validate new_password strength (same rules as registration); if fails raise 422
- Update user.password_hash = auth_service.hash_password(new_password); commit
- Return 200 { message: "Password updated" }
Rate limiting (SEC-02, IP-level): apply slowapi Limiter with key_func=get_remote_address. Apply @limiter.limit("10/minute") to /register, /login, /refresh. Mount SlowAPIMiddleware on app in main.py.
Origin validation middleware (SEC-01): in backend/main.py, add a BaseHTTPMiddleware (or @app.middleware("http")) that checks incoming requests. For any request where method is not in {"GET", "HEAD", "OPTIONS"}: if the Origin header is present and not in settings.cors_origins, return Response(status_code=403, content="Forbidden"). Place this middleware registration BEFORE the CORSMiddleware registration in main.py (middleware is applied in reverse insertion order in Starlette — placing it before CORSMiddleware ensures it runs first).
CSP headers (SEC-05): add a middleware in main.py that sets on every response:
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
CORS (D-09): update main.py CORSMiddleware to allow_origins=settings.cors_origins, allow_credentials=True.
Redis lifespan wiring: in backend/main.py lifespan startup, after existing MinIO init, add:
import aioredis
app.state.redis = await aioredis.from_url(settings.redis_url)
In lifespan shutdown (finally/cleanup block), add:
await app.state.redis.close()
This makes app.state.redis available to all route handlers (used by /login per-account rate limiting and TOTP verify).
Admin bootstrap (D-04): in the lifespan function after existing MinIO init and Redis init, add: async with AsyncSessionLocal() as session: await bootstrap_admin(session).
email.py: create backend/services/email.py with send_password_reset_email(to_address, reset_link) — if settings.smtp_host empty, log.info("DEV MODE — reset link: %s", reset_link) and return; otherwise smtplib.SMTP send. Never raises (errors logged).
Write backend/tests/test_auth_api.py with:
- test_register_success: POST /api/auth/register with valid data → 201, response has "id", "handle"
- test_register_weak_password: password "short" → 422
- test_register_duplicate_email: register twice with same email → 409
- test_login_wrong_password: → 401 with "Incorrect email or password"
- test_login_success: register then login → 200, response has "access_token"
- test_me_requires_auth: GET /api/auth/me without Bearer → 403 (HTTPBearer returns 403 on missing creds)
- test_login_password_must_change: create user with password_must_change=True; POST /api/auth/login → 200 with requires_password_change=true and NO Set-Cookie header
- test_change_password_breach: create user, login, call POST /api/auth/change-password with a mocked breached password → 422 with detail containing "breach"
- test_change_password_wrong_current: POST /api/auth/change-password with incorrect current_password → 401
- test_change_password_success: POST /api/auth/change-password with correct current_password and strong non-breached new_password → 200
- test_origin_rejected: POST /api/auth/login with Origin: https://evil.example → 403
- test_origin_allowed: POST /api/auth/login with Origin: http://localhost:5173 → proceeds to auth check (not 403)
- test_per_account_rate_limit: 11 consecutive POST /api/auth/login requests with same email → 429 on the 11th
- test_login_backup_code_success: create user with totp_enabled=True and an unused BackupCode row; POST /api/auth/login with { email, password, backup_code: <plaintext_code> } → 200 with access_token; confirm BackupCode.used_at is now set (code consumed)
- test_login_backup_code_reuse: use the same backup code a second time → 401 with "Invalid or already used code"
- test_login_backup_code_invalid: POST /api/auth/login with backup_code "XXXXXXXX" (no matching code) → 401 with "Invalid or already used code"
- test_login_totp_takes_precedence: provide both totp_code and backup_code; endpoint routes through verify_totp (not verify_backup_code) — assert backup_code path not taken when totp_code is present
Use async_client fixture from conftest.py; override get_db with db_session.
</behavior>
<action>
Create backend/api/auth.py. Router: APIRouter(prefix="/api/auth", tags=["auth"]).
The LoginRequest Pydantic model must declare: email: str, password: str, totp_code: str | None = None, backup_code: str | None = None.
Implement all endpoints described in behavior. For per-account rate limiting in /login: access app.state.redis via request.app.state.redis — add Request as a parameter to the login handler.
In the /login handler's TOTP branch, implement the three-way dispatch exactly as specified in behavior: (1) both None → requires_totp; (2) totp_code present → verify_totp path; (3) backup_code present and totp_code is None → verify_backup_code path, raise 401 "Invalid or already used code" on False.
Add all main.py changes described in behavior: Origin validation middleware (before CORSMiddleware), CSP middleware, updated CORSMiddleware (allow_origins=settings.cors_origins, allow_credentials=True), Redis lifespan wiring, admin bootstrap.
Create backend/services/email.py with send_password_reset_email as described. Note: backend/tasks/email_tasks.py was already created in Plan 01 Task 2 — do not recreate it; verify it exists before writing any email_tasks reference.
Write backend/tests/test_auth_api.py with all tests listed in behavior.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_auth_api.py -x -q 2>&1 | tail -10</automated>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "from api.auth import router; print('auth router OK')"</automated>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "import ast; src=open('main.py').read(); assert 'app.state.redis' in src, 'Redis not wired in lifespan'; print('Redis lifespan OK')"</automated>
</verify>
<acceptance_criteria>
- tests/test_auth_api.py pytest run exits 0 (all tests pass)
- POST /api/auth/register with password "short" returns 422
- POST /api/auth/login with wrong password returns 401 with body containing "Incorrect email or password"
- Successful POST /api/auth/login sets a Set-Cookie header containing "refresh_token" and "HttpOnly" and "SameSite=Strict"
- GET /api/auth/me without Authorization header returns 403
- backend/main.py contains "cors_origins" (not allow_origins=["*"])
- backend/main.py contains "Content-Security-Policy" or a middleware that sets it
- backend/main.py contains "app.state.redis" (Redis wired in lifespan)
- backend/services/email.py exists with function send_password_reset_email
- grep -c 'allow_origins=\["\\*"\]' backend/main.py returns 0 (wildcard CORS removed)
- POST /api/auth/login with Origin: https://evil.example returns 403 (Origin validation middleware)
- POST /api/auth/login with Origin: http://localhost:5173 proceeds normally (not rejected by Origin middleware)
- 11 consecutive POST /api/auth/login requests with the same email returns 429 on the 11th attempt (per-account rate limit)
- POST /api/auth/login for a user with password_must_change=True returns 200 { requires_password_change: true } without setting a refresh cookie
- POST /api/auth/change-password with a breached new_password returns 422 with detail containing "breach"
- POST /api/auth/change-password with correct current_password and strong new_password returns 200
- POST /api/auth/change-password with wrong current_password returns 401
- POST /api/auth/login with a valid backup_code (user has totp_enabled=True) returns 200 + access_token and invalidates the code (subsequent use of same code returns 401 "Invalid or already used code")
- POST /api/auth/login with an already-used or nonexistent backup_code returns 401 "Invalid or already used code"
- LoginRequest Pydantic model contains both totp_code and backup_code fields (grep -c "backup_code" backend/api/auth.py returns at least 1)
</acceptance_criteria>
<done>Register, login (TOTP + backup code paths), refresh, logout, me, and change-password endpoints functional. Rate limiting (IP + per-account), Origin validation, CSP headers, Redis lifespan, correct httpOnly cookie, and CORS locked to env var. Email service scaffolded. Tests pass including backup code login and invalidation.</done>
</task>
<task type="auto">
<name>Task 2: Frontend — auth store, API client, router guard, Login/Register views</name>
<files>
frontend/src/stores/auth.js,
frontend/src/api/client.js,
frontend/src/router/index.js,
frontend/src/layouts/AuthLayout.vue,
frontend/src/views/auth/LoginView.vue,
frontend/src/views/auth/RegisterView.vue,
frontend/src/components/auth/PasswordStrengthBar.vue,
frontend/src/components/ui/AppSpinner.vue
</files>
<read_first>
- frontend/src/api/client.js (full file — extend request() function and append auth exports)
- frontend/src/stores/documents.js (Pinia store pattern — defineStore, ref, loading/error pattern)
- frontend/src/router/index.js (full file — extend routes array and add beforeEach guard)
- frontend/src/views/SettingsView.vue (form view pattern — template, script setup, store call)
- frontend/src/components/layout/AppSidebar.vue (layout pattern)
- .planning/phases/02-users-authentication/02-UI-SPEC.md (Auth page layout, copywriting, form field states, password strength, loading states)
- .planning/phases/02-users-authentication/02-PATTERNS.md (auth.js, client.js, router, LoginView, RegisterView sections)
- .planning/phases/02-users-authentication/02-CONTEXT.md (D-10, D-11, D-12)
</read_first>
<action>
frontend/src/stores/auth.js: Create Pinia store using defineStore('auth', () => {...}) composition API (not options API). State: accessToken = ref(null), user = ref(null), loading = ref(false), error = ref(null). NEVER write accessToken to localStorage or sessionStorage.
Actions:
- register(handle, email, password): POST /api/auth/register; set user.value = response.user if returned; do not auto-login (return response for caller to handle redirect to /login)
- login(email, password, options = {}): POST /api/auth/login with { email, password, totp_code: options.totpCode ?? null, backup_code: options.backupCode ?? null }; if response.requires_totp return { requires_totp: true } without setting accessToken; if response.requires_password_change return { requires_password_change: true, user_id: response.user_id } without setting accessToken; otherwise set accessToken.value = data.access_token and user.value = data.user
- refresh(): POST /api/auth/refresh (httpOnly cookie sent automatically by browser); set accessToken.value + user.value; throw on failure
- logout(): POST /api/auth/logout; set accessToken.value = null and user.value = null regardless of response
- logoutAll(): POST /api/auth/logout-all; set accessToken.value = null; user.value = null
frontend/src/api/client.js: Extend the existing request() function (do not replace the file). At the top of request(), import useAuthStore dynamically to avoid circular imports — use: const { useAuthStore } = await import('../stores/auth.js'); const authStore = useAuthStore(). Inject Authorization: Bearer header if authStore.accessToken. On 401, if not options._retry: call authStore.refresh(), retry once with _retry: true. If refresh fails: set authStore.accessToken = null, throw Error('Session expired').
Add new exports to client.js:
login(body), register(body), refreshToken(), logout(), logoutAll()
totpSetup(), totpEnable(code), totpDisable()
passwordResetRequest(email), passwordResetConfirm(token, newPassword)
changePassword(body) // POST /api/auth/change-password with { current_password, new_password }
adminListUsers(), adminCreateUser(body), adminDeactivateUser(id), adminReactivateUser(id), adminResetUserPassword(id), adminUpdateQuota(id, limitBytes), adminUpdateAiConfig(id, provider, model)
getMe()
frontend/src/router/index.js: Extend the existing routes array — do not remove existing routes. Add:
{ path: '/login', component: () => import('../views/auth/LoginView.vue'), meta: { public: true } }
{ path: '/register', component: () => import('../views/auth/RegisterView.vue'), meta: { public: true } }
{ path: '/password-reset', component: () => import('../views/auth/PasswordResetView.vue'), meta: { public: true } }
{ path: '/password-reset/confirm', component: () => import('../views/auth/NewPasswordView.vue'), meta: { public: true } }
{ path: '/account', component: () => import('../views/AccountView.vue') }
{ path: '/admin', component: () => import('../views/AdminView.vue') }
After createRouter, add router.beforeEach((to, from) => { const { useAuthStore } = await import('../stores/auth.js') — NOTE: beforeEach cannot be async; import useAuthStore at top of the router file instead (not inside the guard). Guard: if (!to.meta.public && !authStore.accessToken) return { path: '/login', query: { redirect: to.fullPath } }.
frontend/src/layouts/AuthLayout.vue: Create bare centered layout (no sidebar). Template: div.min-h-screen.bg-gray-50.flex.items-center.justify-center > div.w-full.max-w-sm > (logo h1 + router-view). Logo: "DocuVault" in text-xl font-semibold text-indigo-600. Use <router-view /> with no sidebar or AppLayout wrapper.
frontend/src/views/auth/LoginView.vue: Three-step flow using v-if on step ref ('password' | 'totp' | 'backup'). Step 1: email + password inputs, "Sign in" button. On submit: authStore.login(email, password) — if requires_totp returned, switch to step='totp'. If requires_password_change returned, router.push('/account') or a dedicated /change-password route so the user can update their password before proceeding. On full success: router.push(route.query.redirect || '/'). TOTP step: single input w-36 centered, inputmode="numeric" maxlength="6", "Verify code" button. On TOTP submit: authStore.login(email, password, { totpCode: totpInput }). Secondary "Use a backup code instead" link toggles step='backup'. Backup step: text input (ref backupCodeInput) placeholder "XXXXXXXX". On backup submit: authStore.login(email, password, { backupCode: backupCodeInput }) — on success redirect as normal; on failure display error "Invalid or already used code". Back link on TOTP/backup steps: "Back to sign in" resets step='password'. UI spec copywriting: "Sign in to DocuVault" heading, no subheading, "Sign in" CTA, "Don't have an account? Create one" link to /register, "Forgot your password?" link to /password-reset. Error placement: form-level error above submit button in div.p-3.rounded-lg.bg-red-50.border.border-red-200.text-sm.text-red-700. Loading: AppSpinner inline left of button label + disabled + opacity-75.
frontend/src/views/auth/RegisterView.vue: Fields: handle, email, password, confirmPassword. Below password input: PasswordStrengthBar :password="password". Validate confirmPassword === password before submit. Call authStore.register(...) then router.push('/login'). Copywriting per UI-SPEC: "Create your account" heading, "Start managing your documents securely." subheading, "Create account" CTA, "Already have an account? Sign in" link. HaveIBeenPwned error displayed as inline field error below strength bar.
frontend/src/components/auth/PasswordStrengthBar.vue: Props: { password: String }. Computed strength score (0-4): +1 if length >= 12, +1 if /[A-Z]/, +1 if /[0-9]/, +1 if /[^A-Za-z0-9]/. Render 4 segment divs (h-1 rounded, gap-1 flex). Segment colors: score=1 bg-red-500, score=2 bg-amber-500, score=3 bg-amber-400, score=4 bg-green-500. Unlit: bg-gray-200. Label right-aligned text-xs font-semibold in same color. Hidden when password is empty.
frontend/src/components/ui/AppSpinner.vue: No props, no emits, no script. Template: svg.animate-spin with border-2 border-current border-t-transparent rounded-full as per UI-SPEC spinner spec. h-4 w-4 default size via class on the svg.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && node -e "const fs = require('fs'); const store = fs.readFileSync('src/stores/auth.js','utf8'); if (store.includes('localStorage')) throw new Error('localStorage found!'); console.log('No localStorage in auth store');"</automated>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && node -e "const fs = require('fs'); const r = fs.readFileSync('src/router/index.js','utf8'); if (!r.includes('/login')) throw new Error('No /login route'); if (!r.includes('beforeEach')) throw new Error('No guard'); console.log('Router OK');"</automated>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && node -e "const fs = require('fs'); const c = fs.readFileSync('src/api/client.js','utf8'); if (!c.includes('changePassword')) throw new Error('changePassword missing from client.js'); console.log('changePassword OK');"</automated>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && node -e "const fs = require('fs'); const s = fs.readFileSync('src/stores/auth.js','utf8'); if (!s.includes('backupCode')) throw new Error('backupCode not wired in auth store login()'); console.log('backup_code wiring OK');"</automated>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && node -e "const fs = require('fs'); const v = fs.readFileSync('src/views/auth/LoginView.vue','utf8'); if (!v.includes('backupCode') && !v.includes('backup_code')) throw new Error('backup code input missing from LoginView'); console.log('LoginView backup code OK');"</automated>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run build 2>&1 | tail -10</automated>
</verify>
<acceptance_criteria>
- frontend/src/stores/auth.js exists and contains "defineStore('auth'" and "accessToken" and does NOT contain "localStorage" or "sessionStorage" — grep -c "localStorage" frontend/src/stores/auth.js returns 0
- frontend/src/stores/auth.js login() accepts options object and passes backup_code: options.backupCode to the API call
- frontend/src/router/index.js contains "beforeEach" and "/login" and "meta: { public: true }"
- frontend/src/layouts/AuthLayout.vue exists with "router-view" in template
- frontend/src/views/auth/LoginView.vue exists with "Sign in to DocuVault" text
- frontend/src/views/auth/LoginView.vue contains a backup code input and passes it via authStore.login(email, password, { backupCode: ... }) — "Use a backup code instead" link is present
- frontend/src/views/auth/RegisterView.vue exists with "Create your account" text
- frontend/src/components/auth/PasswordStrengthBar.vue exists
- frontend/src/components/ui/AppSpinner.vue exists with "animate-spin"
- frontend/src/api/client.js contains "Authorization" and "Bearer" and "_retry" and "refresh" and "changePassword"
- npm run build exits 0 (no compile errors)
</acceptance_criteria>
<done>Auth store login() accepts options.backupCode and passes backup_code to the API; LoginView toggles to backup-code input step and submits via the dedicated field; API client extended with auth calls including changePassword and 401 refresh retry; router guard redirects unauthenticated users to /login; Login and Register views render with correct UI-SPEC copywriting.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser→FastAPI (register/login) | Untrusted email, password, handle, totp_code, backup_code in JSON body cross this boundary |
| FastAPI→Redis (rate limiter + per-account) | IP-keyed and email-keyed counters written; Redis on internal Docker network |
| FastAPI→browser (cookies) | httpOnly refresh token cookie set here; must have SameSite=Strict |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-09 | Spoofing | Login — email enumeration | mitigate | Return identical 401 "Incorrect email or password" for non-existent email and wrong password; constant response time not guaranteed but message is identical (SEC-06 applies to code verify) |
| T-02-10 | Spoofing | Password reset — email enumeration | mitigate | "If an account exists..." copy regardless of whether email is found (UI-SPEC copywriting contract) |
| T-02-11 | Tampering | CSRF on state-changing auth endpoints | mitigate | SameSite=Strict on refresh cookie (SEC-01); Origin validation middleware rejects POST/PUT/DELETE/PATCH requests with Origin not in cors_origins with 403 |
| T-02-12 | Information Disclosure | access_token in JavaScript | accept | Access token in Pinia memory (ref()) only — never written to localStorage/sessionStorage; lost on page refresh (intentional — use refresh endpoint) |
| T-02-13 | Denial of Service | Login/register endpoints | mitigate | slowapi @limiter.limit("10/minute") on /login, /register, /refresh (IP-level, SEC-02); additionally per-account Redis counter (login_attempts:{email}) caps at 10 within 15 min |
| T-02-14 | Information Disclosure | Response headers missing security headers | mitigate | Middleware sets CSP, X-Frame-Options: DENY, X-Content-Type-Options: nosniff on every response (SEC-05) |
| T-02-15 | Tampering | CORS wildcard | mitigate | allow_origins changed from ["*"] to settings.cors_origins (D-09); allow_credentials=True required for cookie flow |
| T-02-16 | Elevation of Privilege | password_must_change bypass | mitigate | /login returns 200 {requires_password_change: true} without tokens when flag is set; client must redirect to change-password flow before any protected resource is accessible |
| T-02-26 | Spoofing | Backup code reuse at login | mitigate | verify_backup_code() sets BackupCode.used_at on first use; subsequent calls always return False — enforced by auth service layer (AUTH-04) |
| T-02-27 | Spoofing | Backup code brute force at login | mitigate | Per-account rate limit (login_attempts:{email}, 10 attempts / 15 min) applies to all /login calls including backup_code path — same counter as password auth |
</threat_model>
<verification>
1. POST /api/auth/register with { handle: "testuser", email: "t@t.com", password: "ValidPass12!" } → 201
2. POST /api/auth/login with wrong password → 401, body.detail = "Incorrect email or password"
3. POST /api/auth/login with correct credentials → 200, Set-Cookie contains "HttpOnly" and "SameSite=Strict"
4. curl -I /health → response headers include X-Frame-Options: DENY and X-Content-Type-Options: nosniff
5. grep -c 'allow_origins=\["\\*"\]' backend/main.py → 0
6. grep -c 'localStorage' frontend/src/stores/auth.js → 0
7. npm run build exits 0
8. pytest tests/test_auth_api.py -x passes
9. POST /api/auth/login with Origin: https://evil.example → 403
10. 11 consecutive POST /api/auth/login with same email → 429 on 11th
11. POST /api/auth/login for user with password_must_change=True → 200 { requires_password_change: true }, no Set-Cookie
12. POST /api/auth/change-password with correct credentials and strong password → 200
13. POST /api/auth/login with a valid backup_code (user totp_enabled=True) → 200 + access_token; same code used again → 401 "Invalid or already used code"
14. grep -c 'backup_code' backend/api/auth.py → at least 1 (LoginRequest field present)
</verification>
<success_criteria>
- Register endpoint creates user with Argon2-hashed password; HIBP breach check rejects known passwords
- Login sets httpOnly SameSite=Strict refresh cookie; access token returned in JSON only
- Login returns requires_password_change without tokens when user.password_must_change is True
- Login accepts backup_code field; valid code issues tokens and invalidates the code; used/invalid code returns 401 (AUTH-04)
- Change-password endpoint enforces current password, HIBP, and strength checks
- CSP, X-Frame-Options, X-Content-Type-Options headers on all responses
- Origin validation middleware rejects cross-origin state-changing requests with 403
- IP-level and per-account rate limiting active on auth endpoints
- CORS locked to CORS_ORIGINS env var
- Redis wired into app.state.redis at lifespan startup with cleanup on shutdown
- Vue auth store uses ref() memory only, never localStorage; login() accepts options.backupCode
- Router beforeEach guard redirects unauthenticated to /login with redirect param
- LoginView renders backup-code input step toggled by "Use a backup code instead" link
- Login and Register views match UI-SPEC copywriting and visual contract
</success_criteria>
<output>
Create `.planning/phases/02-users-authentication/02-02-SUMMARY.md` when done.
</output>