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>
This commit is contained in:
curo1305
2026-05-22 19:13:44 +02:00
parent 333978d7cb
commit 16584ade00
8 changed files with 2754 additions and 11 deletions
@@ -0,0 +1,387 @@
---
phase: 02-users-authentication
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- backend/config.py
- backend/requirements.txt
- backend/deps/auth.py
- backend/services/auth.py
- backend/db/models.py
- backend/migrations/versions/
- backend/tasks/email_tasks.py
- .env.example
autonomous: true
requirements:
- AUTH-01
- AUTH-02
- AUTH-07
- SEC-03
- SEC-06
must_haves:
truths:
- "User passwords are Argon2-hashed and verified in constant time"
- "JWT access token creation and decode function reject tampered tokens"
- "Refresh token rotation revokes the entire family on reuse, and a security alert email is enqueued when a revoked family token is presented"
- "Users can receive and later redeem single-use backup codes"
- "All auth and email configuration reads from environment variables at startup without falling back to hard-coded values"
artifacts:
- path: "backend/services/auth.py"
provides: "Auth service: hash_password, verify_password, create_access_token, decode_access_token, create_refresh_token, rotate_refresh_token, revoke_all_refresh_tokens, provision_totp, verify_totp, verify_backup_code, generate_backup_codes, bootstrap_admin"
exports:
- "hash_password"
- "verify_password"
- "create_access_token"
- "decode_access_token"
- "rotate_refresh_token"
- "revoke_all_refresh_tokens"
- path: "backend/deps/auth.py"
provides: "FastAPI dependencies: get_current_user, get_current_admin"
exports:
- "get_current_user"
- "get_current_admin"
- path: "backend/db/models.py"
provides: "BackupCode ORM model added, password_must_change field on User"
contains: "class BackupCode"
- path: "backend/tasks/email_tasks.py"
provides: "Celery tasks: send_reset_email, send_security_alert_email"
exports:
- "send_reset_email"
- "send_security_alert_email"
key_links:
- from: "backend/deps/auth.py"
to: "backend/services/auth.py"
via: "decode_access_token(credentials.credentials)"
pattern: "decode_access_token"
- from: "backend/services/auth.py"
to: "backend/db/models.py"
via: "RefreshToken, BackupCode ORM queries"
pattern: "session\\.get.*RefreshToken|BackupCode"
- from: "backend/services/auth.py"
to: "backend/tasks/email_tasks.py"
via: "send_security_alert_email.delay(user_id) on family revocation"
pattern: "send_security_alert_email\\.delay"
---
<objective>
Build the auth service layer and database foundations that all subsequent plans depend on. This plan produces no user-visible endpoints — it creates the pure-Python services, FastAPI dependency chain, and a migration adding the `backup_codes` table and `password_must_change` field.
Purpose: Every auth endpoint in Plans 0206 imports from this plan's outputs. Getting contracts right here prevents rework across all later plans.
Output: backend/services/auth.py (full auth logic), backend/deps/auth.py (FastAPI deps), BackupCode ORM model, password_must_change column on User, Alembic migration, updated requirements.txt and config.py, send_security_alert_email Celery task.
</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/STATE.md
@.planning/phases/02-users-authentication/02-CONTEXT.md
@.planning/phases/02-users-authentication/02-PATTERNS.md
</context>
<interfaces>
From backend/db/models.py (existing ORM models for reference):
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] # primary key
handle: Mapped[str] # unique
email: Mapped[str] # unique
password_hash: Mapped[str]
totp_secret: Mapped[Optional[str]]
totp_enabled: Mapped[bool] # default False
role: Mapped[str] # "user" | "admin"
is_active: Mapped[bool] # default True
ai_provider: Mapped[Optional[str]]
ai_model: Mapped[Optional[str]]
created_at: Mapped[datetime]
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
id: Mapped[uuid.UUID]
user_id: Mapped[uuid.UUID] # FK users.id CASCADE
token_hash: Mapped[str] # unique
expires_at: Mapped[datetime]
revoked: Mapped[bool] # default False
created_at: Mapped[datetime]
class Quota(Base):
__tablename__ = "quotas"
user_id: Mapped[uuid.UUID] # PK, FK users.id CASCADE
limit_bytes: Mapped[int] # default 104857600
used_bytes: Mapped[int] # default 0
From backend/config.py (current Settings fields):
database_url: str
database_migrate_url: str
minio_endpoint, minio_access_key, minio_secret_key, minio_bucket: str
redis_url: str = "redis://:changeme_redis@redis:6379/0"
secret_key: str = "CHANGEME"
From backend/deps/db.py (dependency pattern):
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add BackupCode ORM model, password_must_change field, Alembic migration, and extend Settings</name>
<files>
backend/db/models.py,
backend/config.py,
backend/requirements.txt,
.env.example
</files>
<read_first>
- backend/db/models.py (full file — understand existing model patterns before adding BackupCode and password_must_change)
- backend/config.py (full file — extend Settings class in place, do not recreate)
- backend/requirements.txt (full file — append new packages, preserve existing)
- .env.example (full file — append new vars with comments)
- backend/celery_app.py (task_routes pattern — add email queue route)
</read_first>
<behavior>
- BackupCode model stores code_hash (Argon2), user_id FK, used_at nullable timestamp
- BackupCode.used_at is None when unused; set to now() on first use (single-use enforcement)
- User model gains: password_must_change: Mapped[bool] = mapped_column(Boolean, server_default="false") — added immediately after is_active column
- Settings.cors_origins parses comma-separated env var as list[str] via pydantic-settings JSON parse
- Settings loads access_token_expire_minutes=15, refresh_token_expire_days=30
- requirements.txt gains: PyJWT>=2.8.0, pwdlib[argon2]>=0.2.1, pyotp>=2.9.0, httpx (already present for HIBP check), aioredis>=2.0.0, slowapi>=0.1.9
</behavior>
<action>
Add BackupCode to backend/db/models.py. The model needs: id (UUID PK), user_id (UUID FK users.id CASCADE NOT NULL), code_hash (Text NOT NULL), used_at (TIMESTAMP timezone=True nullable). Add Index on user_id. Place after the RefreshToken class.
Add password_must_change to the User model (per ADMIN-01): password_must_change: Mapped[bool] = mapped_column(Boolean, server_default="false"). Insert this field immediately after the is_active field in the User class body.
Extend backend/config.py Settings class (do not recreate the file) by appending these fields inside the Settings class body — per D-01, D-04, D-05, D-06, D-09:
access_token_expire_minutes: int = 15
refresh_token_expire_days: int = 30
smtp_host: str = ""
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
smtp_from: str = "noreply@docuvault.local"
admin_email: str = ""
admin_password: str = ""
cors_origins: list[str] = ["http://localhost:5173"]
In SettingsConfigDict, add env_list_separator="," to handle cors_origins env var as comma-separated string.
Append to requirements.txt:
PyJWT>=2.8.0
pwdlib[argon2]>=0.2.1
pyotp>=2.9.0
aioredis>=2.0.0
slowapi>=0.1.9
Append to .env.example with comments explaining each var (D-01, D-04, D-09):
SECRET_KEY, ADMIN_EMAIL, ADMIN_PASSWORD, SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM, CORS_ORIGINS.
Generate an Alembic migration (run: cd backend && alembic revision --autogenerate -m "add_backup_codes_and_password_must_change") then verify and clean the generated file. The migration must: create backup_codes table with columns (id UUID PK, user_id UUID FK NOT NULL, code_hash TEXT NOT NULL, used_at TIMESTAMPTZ nullable); add column password_must_change BOOLEAN NOT NULL DEFAULT false to users table. Do NOT add NOT NULL to documents.user_id in this migration (per D-03 from Phase 1).
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "from db.models import BackupCode; print('BackupCode OK')"</automated>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "from db.models import User; assert hasattr(User, 'password_must_change'); print('password_must_change OK')"</automated>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "from config import settings; assert hasattr(settings, 'cors_origins'); assert hasattr(settings, 'admin_email'); print('Settings OK')"</automated>
</verify>
<acceptance_criteria>
- backend/db/models.py contains class BackupCode with __tablename__ = "backup_codes", columns: id, user_id, code_hash, used_at
- backend/db/models.py User class contains password_must_change column with server_default="false"
- backend/config.py Settings class contains: cors_origins, admin_email, admin_password, smtp_host, access_token_expire_minutes, refresh_token_expire_days
- A new migration file exists in backend/migrations/versions/ with "backup_codes" in the filename
- The migration adds both the backup_codes table AND the password_must_change column to users
- requirements.txt contains lines matching: PyJWT, pwdlib, pyotp, aioredis, slowapi
- .env.example contains lines for SECRET_KEY, ADMIN_EMAIL, SMTP_HOST, CORS_ORIGINS
- `python -c "from config import settings"` exits 0
</acceptance_criteria>
<done>BackupCode model exists, password_must_change field added to User, migration generated and runnable, Settings extended with all Phase 2 env vars, requirements.txt updated.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Implement services/auth.py — full auth service layer</name>
<files>
backend/services/auth.py,
backend/tasks/email_tasks.py
</files>
<read_first>
- backend/services/classifier.py (pure-Python service pattern — module docstring, async functions, no FastAPI imports, no HTTPException)
- backend/db/models.py (User, RefreshToken, Quota, BackupCode — after Task 1 adds BackupCode and password_must_change)
- backend/config.py (settings.secret_key, settings.access_token_expire_minutes, settings.refresh_token_expire_days)
- backend/celery_app.py (task_routes dict — add email queue route for send_security_alert_email)
- backend/tasks/document_tasks.py (Celery task pattern with asyncio.run + deferred imports)
- .planning/phases/02-users-authentication/02-CONTEXT.md (specifics section: PyOTP valid_window=1, reset token format, TOTP replay prevention)
- .planning/phases/02-users-authentication/02-PATTERNS.md (services/auth.py section — function signatures)
</read_first>
<behavior>
- hash_password("plain") returns Argon2 hash string (pwdlib PasswordHash with argon2 scheme)
- verify_password("plain", "hash") uses hmac.compare_digest internally (constant-time, SEC-06)
- create_access_token(user_id, role) returns signed JWT with sub=str(user_id), role=role, typ="access", exp=now+15min
- decode_access_token(token) returns payload dict; raises ValueError on expired/tampered/wrong typ
- create_refresh_token(session, user_id) inserts RefreshToken row with token_hash=sha256(raw), returns raw token string
- rotate_refresh_token(session, raw_token): if revoked=True, revoke ALL tokens in family (where user_id=this.user_id), enqueue send_security_alert_email.delay(str(user_id)) via Celery, and raise ValueError("token_family_revoked"); if valid, revoke old, create new, return (new_raw, user_id_str)
- revoke_all_refresh_tokens(session, user_id) sets revoked=True on all user's active tokens, returns count
- provision_totp(session, user_id) generates pyotp.random_base32() secret, stores in users.totp_secret (not enabled yet), returns (secret, provisioning_uri) where uri uses pyotp.totp.TOTP(secret).provisioning_uri(email, issuer_name="DocuVault")
- verify_totp(session, user_id, code, redis_client): get user.totp_secret, check pyotp.TOTP(secret).verify(code, valid_window=1); if valid, store used code in Redis key "totp_used:{user_id}:{code}" with TTL=90s; return False if key already exists (replay prevention, AUTH-08)
- generate_backup_codes(n=10) returns list of 10 random 8-char alphanumeric strings (secrets.token_hex(4).upper() format)
- store_backup_codes(session, user_id, codes) inserts BackupCode rows with code_hash=hash_password(code) for each code, deletes any existing unused codes first
- verify_backup_code(session, user_id, code): fetch all unused BackupCode rows for user; iterate and hmac.compare_digest-based check via verify_password; on match set used_at=now(), commit, return True; return False if none match (constant-time: always iterate all codes)
- check_hibp(password) calls https://api.pwnedpasswords.com/range/{sha1_prefix} and returns True if count > 0 (breach found) — async, uses httpx.AsyncClient
- create_password_reset_token(user_id) returns signed JWT with sub=str(user_id), typ="password-reset", exp=now+3600s
- decode_password_reset_token(token) returns user_id str; raises ValueError if expired/wrong typ
- bootstrap_admin(session) is an idempotent async function: if users table is empty AND settings.admin_email AND settings.admin_password are non-empty, create User(role="admin", password_must_change=False) + Quota(limit_bytes=104857600) rows; log WARNING if vars not set (D-04, D-05, D-06)
</behavior>
<action>
Create backend/services/auth.py as a pure-Python module (no FastAPI imports, no HTTPException — per the classifier.py pattern).
Imports needed: hashlib, hmac, secrets, uuid, logging, datetime, from typing, from sqlalchemy.ext.asyncio import AsyncSession, from sqlalchemy import select, from jose/jwt (use PyJWT: import jwt), from pwdlib import PasswordHash, from pwdlib.hashers.argon2 import Argon2Hasher, import pyotp, import httpx, from db.models import User, RefreshToken, Quota, BackupCode, from config import settings.
Password hasher: _pwd = PasswordHash([Argon2Hasher()]). Use _pwd.hash(plain) and _pwd.verify(plain, hashed).
JWT: use jwt.encode(payload, settings.secret_key, algorithm="HS256") and jwt.decode(token, settings.secret_key, algorithms=["HS256"]). In decode_access_token, verify payload["typ"] == "access" after decoding.
Refresh token: raw = secrets.token_urlsafe(32). token_hash = hashlib.sha256(raw.encode()).hexdigest(). Store token_hash in RefreshToken.token_hash. Never store raw token in DB.
Refresh family revocation (AUTH-07): add family_id column is NOT in the schema — use user_id as the family proxy. When reuse detected (token exists but revoked=True), call revoke_all_refresh_tokens(session, this_token.user_id), then import and enqueue send_security_alert_email.delay(str(this_token.user_id)), then raise ValueError("token_family_revoked").
TOTP replay: Redis key = f"totp_used:{user_id}:{code}", TTL = 90 (covers valid_window=1 which spans ±30s = 90s total window). Use aioredis (or redis.asyncio — check which is available) for async Redis operations. Accept redis_client as parameter to allow injection in tests.
HIBP check: k-anonymity model — sha1(password.upper()).hexdigest()[:5] as prefix, check response lines for matching suffix. Function is async, uses httpx.AsyncClient with timeout=5. Returns True (pwned) / False (not found or network error — fail open, log warning on error).
All functions that raise on bad input raise plain ValueError, never HTTPException.
Also create backend/tasks/email_tasks.py following document_tasks.py pattern. Add two tasks:
1. "tasks.email_tasks.send_reset_email" — calls send_password_reset_email(to_address, reset_link) from services.email
2. "tasks.email_tasks.send_security_alert_email" — sends a security alert to the user identified by user_id (fetch email from DB inside the task using asyncio.run; if SMTP not configured, log WARNING "Security alert for user {user_id}: suspicious refresh token reuse detected"). No top-level config or model imports (deferred import pattern). Add both task routes to celery_app.conf.task_routes under "tasks.email_tasks.*": {"queue": "email"}.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "from services.auth import hash_password, verify_password, create_access_token, decode_access_token; h = hash_password('TestPass123!'); assert verify_password('TestPass123!', h); t = create_access_token('test-id', 'user'); p = decode_access_token(t); assert p['sub'] == 'test-id'; print('auth service OK')"</automated>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "from tasks.email_tasks import send_reset_email, send_security_alert_email; print('email tasks OK')"</automated>
</verify>
<acceptance_criteria>
- `from services.auth import hash_password` imports without error
- hash_password("test") returns a string starting with "$argon2"
- verify_password("correct", hash_password("correct")) returns True
- verify_password("wrong", hash_password("correct")) returns False
- create_access_token("uid", "user") returns a string with 2 dots (JWT format)
- decode_access_token with a tampered token raises ValueError
- generate_backup_codes(10) returns a list of 10 strings each len 8
- create_password_reset_token("uid") returns JWT; decode_password_reset_token on it returns "uid"
- decode_password_reset_token on an access token raises ValueError (wrong typ)
- No import of FastAPI, HTTPException, or any fastapi module in services/auth.py
- grep -c "HTTPException" backend/services/auth.py returns 0
- rotate_refresh_token enqueues send_security_alert_email.delay when family_revoked=True — grep -c "send_security_alert_email" backend/services/auth.py returns at least 1
- POST /api/auth/refresh with a revoked family token triggers send_security_alert_email Celery task (assert task enqueued in test using mock or celery task_always_eager=True)
- backend/tasks/email_tasks.py exists with both send_reset_email and send_security_alert_email Celery tasks
</acceptance_criteria>
<done>services/auth.py implements all auth primitives: Argon2 hashing, constant-time verify, JWT creation/decode, refresh token lifecycle with security alert on family revocation, TOTP provisioning and replay-prevention, backup code generation/storage/verification, HIBP check, admin bootstrap. email_tasks.py has both send_reset_email and send_security_alert_email tasks.</done>
</task>
<task type="auto">
<name>Task 3: Implement deps/auth.py — FastAPI dependency chain</name>
<files>
backend/deps/auth.py,
backend/tests/test_auth_deps.py
</files>
<read_first>
- backend/deps/db.py (dependency function pattern — how get_db is structured)
- backend/services/auth.py (decode_access_token signature — after Task 2)
- backend/db/models.py (User model fields: id, role, is_active)
- .planning/phases/02-users-authentication/02-PATTERNS.md (deps/auth.py section — get_current_user, get_current_admin code)
</read_first>
<action>
Create backend/deps/auth.py with:
Imports: uuid, from fastapi import Depends, HTTPException, status, from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials, from sqlalchemy.ext.asyncio import AsyncSession, from deps.db import get_db, from services import auth as auth_service, from db.models import User.
security = HTTPBearer()
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security), session: AsyncSession = Depends(get_db)) -> User:
Calls auth_service.decode_access_token(credentials.credentials). On ValueError raises HTTPException(401, "Invalid or expired token"). Loads User via session.get(User, uuid.UUID(payload["sub"])). Raises 401 if user is None or not user.is_active.
async def get_current_admin(user: User = Depends(get_current_user)) -> User:
Raises HTTPException(403, "Admin access required") if user.role != "admin". Returns user.
Create backend/tests/test_auth_deps.py with two tests:
test_get_current_user_returns_user: override get_db with db_session; insert a User row; create a valid access token; call the endpoint with the Bearer token; assert 200 (use a minimal /api/auth/me stub if needed — or test the dep directly by calling it in an app context).
test_get_current_admin_rejects_non_admin: create user with role="user"; assert 403 when get_current_admin is called.
Test pattern: use the async_client fixture + app.dependency_overrides[get_db] = lambda: db_session from conftest.py.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "from deps.auth import get_current_user, get_current_admin; print('deps/auth OK')"</automated>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_auth_deps.py -x -q 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- `from deps.auth import get_current_user, get_current_admin` imports without error
- get_current_user raises HTTPException(401) when token is expired or tampered
- get_current_user raises HTTPException(401) when user.is_active is False
- get_current_admin raises HTTPException(403) when user.role == "user"
- get_current_admin returns user when user.role == "admin"
- grep -c "HTTPException(status.HTTP_403" backend/deps/auth.py returns at least 1
- tests/test_auth_deps.py exists and pytest exits 0
</acceptance_criteria>
<done>FastAPI dependency chain complete: get_current_user validates Bearer JWT and returns User ORM object; get_current_admin enforces role="admin" or raises 403.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client→API (auth service) | Untrusted email, password, handle in JSON body |
| API→Redis | Token replay keys written/read; Redis on internal network only |
| API→HIBP external | Password SHA-1 prefix sent to third-party; no full password leaves the system |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-01 | Spoofing | JWT decode in decode_access_token | mitigate | Verify `typ` claim after decode to prevent password-reset tokens being used as access tokens |
| T-02-02 | Spoofing | refresh token reuse | mitigate | Family revocation: if revoked token is presented, revoke all tokens for that user_id and enqueue security alert email; RFC 9700 per AUTH-07 |
| T-02-03 | Tampering | backup code storage | mitigate | Store Argon2 hash of each backup code (never plaintext); verify via constant-time compare (SEC-06) |
| T-02-04 | Repudiation | bootstrap_admin idempotency | mitigate | bootstrap_admin checks `users table empty` before insert; logs WARNING when env vars missing; idempotent |
| T-02-05 | Information Disclosure | HIBP check | mitigate | k-anonymity model: only SHA-1 prefix (5 chars) sent to HIBP API; full hash never leaves the system |
| T-02-06 | Denial of Service | HIBP network call | accept | httpx timeout=5s; on network error function returns False (fail-open, log warning) — auth proceeds |
| T-02-07 | Elevation of Privilege | get_current_admin | mitigate | Explicit role != "admin" check raises 403; no path to skip this dependency |
| T-02-08 | Elevation of Privilege | Admin impersonation | mitigate | Architectural exclusion: no endpoint or code path that sets a JWT sub to a different user exists; ADMIN-07 |
| T-02-SC | Tampering | npm/pip installs (PyJWT, pwdlib, pyotp, slowapi) | mitigate | Packages added to requirements.txt; legitimacy verified: PyJWT (jpadilla, 70M+/mo), pwdlib (frankie567, auth library), pyotp (0.9.0, crypto vetted), slowapi (laurentS, 1M+/mo) — all [VERIFIED] |
</threat_model>
<verification>
1. `python -c "from services.auth import hash_password, verify_password, create_access_token, decode_access_token, create_refresh_token, rotate_refresh_token, revoke_all_refresh_tokens, provision_totp, verify_totp, verify_backup_code, generate_backup_codes, check_hibp, bootstrap_admin"` — exits 0
2. `python -c "from deps.auth import get_current_user, get_current_admin"` — exits 0
3. `python -c "from config import settings; assert settings.cors_origins == ['http://localhost:5173']"` — exits 0
4. `grep -c 'class BackupCode' backend/db/models.py` — returns 1
5. `grep -c 'password_must_change' backend/db/models.py` — returns at least 1
6. Migration file exists: `ls backend/migrations/versions/ | grep backup_codes`
7. `pytest tests/test_auth_deps.py -x -q` passes
8. `grep -c 'send_security_alert_email' backend/services/auth.py` — returns at least 1
</verification>
<success_criteria>
- All auth primitives implemented in services/auth.py with no FastAPI coupling
- rotate_refresh_token enqueues send_security_alert_email.delay on family revocation (AUTH-07)
- FastAPI dependency chain in deps/auth.py with get_current_user and get_current_admin
- BackupCode ORM model and Alembic migration in place
- password_must_change column added to User model and included in migration
- Settings class extended with all Phase 2 env vars
- requirements.txt includes PyJWT, pwdlib[argon2], pyotp, aioredis, slowapi
- Test file for deps/auth.py passes
</success_criteria>
<output>
Create `.planning/phases/02-users-authentication/02-01-SUMMARY.md` when done.
</output>
@@ -0,0 +1,448 @@
---
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>
@@ -0,0 +1,314 @@
---
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>
@@ -0,0 +1,312 @@
---
phase: 02-users-authentication
plan: 04
type: execute
wave: 4
depends_on:
- 02-02
- 02-03
files_modified:
- backend/api/admin.py
- backend/tests/test_admin_api.py
autonomous: true
requirements:
- ADMIN-01
- ADMIN-02
- ADMIN-03
- ADMIN-04
- ADMIN-05
- ADMIN-07
- SEC-07
must_haves:
truths:
- "Admin can create a user account with a temporary password that must be changed on first login"
- "Admin can deactivate and reactivate a user account — deactivated users cannot log in"
- "Admin can initiate password reset for a user (sends email, does not grant admin access)"
- "Admin can view and adjust individual user storage quotas with a warning when new limit is below current usage"
- "Admin can assign AI provider and model per user"
- "Admin endpoints NEVER return document content, extracted text, or credentials_enc"
- "Admin impersonation endpoint does not exist anywhere in the codebase"
- "Every admin handler verifies role via get_current_admin dependency — no handler uses get_current_user alone"
artifacts:
- path: "backend/api/admin.py"
provides: "GET /api/admin/users, POST /api/admin/users, PATCH /api/admin/users/{id}/status, POST /api/admin/users/{id}/password-reset, GET /api/admin/users/{id}/quota, PATCH /api/admin/users/{id}/quota, PATCH /api/admin/users/{id}/ai-config"
exports:
- "router"
- path: "backend/tests/test_admin_api.py"
provides: "Admin API tests: list users, create user, deactivate, quota, AI config"
key_links:
- from: "backend/api/admin.py"
to: "backend/deps/auth.py:get_current_admin"
via: "Depends(get_current_admin) on every handler"
pattern: "get_current_admin"
- from: "backend/api/admin.py"
to: "backend/main.py"
via: "app.include_router(admin_router)"
pattern: "admin_router"
---
<objective>
Build the complete admin backend: user management (create, deactivate, reactivate, password reset), quota management, and AI provider assignment — all enforced by get_current_admin. Admin-created users are flagged with password_must_change=True so they are forced to set a new password on first login. Admin impersonation is explicitly excluded by architecture.
Purpose: After this plan, the admin panel frontend (Plan 05) can be wired to real endpoints. This plan runs after Plans 02 and 03 confirm the auth dependency chain is functional.
Output: backend/api/admin.py with all 7 admin endpoints, registered in main.py, covered by tests.
</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-02-SUMMARY.md
@.planning/phases/02-users-authentication/02-03-SUMMARY.md
</context>
<interfaces>
From backend/deps/auth.py (Plan 01):
async def get_current_admin(user: User = Depends(get_current_user)) -> User
# Raises 403 if user.role != "admin"
From backend/db/models.py:
class User: id, handle, email, password_hash, role, is_active, totp_enabled, ai_provider, ai_model, password_must_change, created_at
class Quota: user_id, limit_bytes, used_bytes
class RefreshToken: id, user_id, token_hash, revoked
From backend/services/auth.py (Plan 01):
async def hash_password(plain: str) -> str
async def revoke_all_refresh_tokens(session, user_id) -> int
def create_password_reset_token(user_id: str) -> str
From backend/tasks/email_tasks.py (Plan 01):
send_reset_email.delay(to_address: str, reset_link: str)
From backend/api/settings.py (pattern analog for admin router):
router = APIRouter(prefix="/api/settings", tags=["settings"])
class SomeRequest(BaseModel): field: type
@router.get("/") async def handler(session = Depends(get_db)): ...
From backend/config.py (Plan 03 addition):
settings.frontend_url: str # used to build reset link
User response shape (NEVER include: password_hash, credentials_enc, extracted_text, document content):
{ id, handle, email, role, is_active, totp_enabled, ai_provider, ai_model, created_at }
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create backend/api/admin.py with all admin endpoints</name>
<files>
backend/api/admin.py,
backend/main.py
</files>
<read_first>
- backend/api/settings.py (router declaration, Pydantic body pattern, error mapping — full file)
- backend/main.py (current state after Plans 02/03 — verify admin router not yet included before adding it)
- backend/db/models.py (User, Quota model fields — full file; note password_must_change field added in Plan 01)
- backend/deps/auth.py (get_current_user, get_current_admin signatures)
- backend/services/auth.py (hash_password, revoke_all_refresh_tokens, create_password_reset_token)
- backend/tasks/email_tasks.py (send_reset_email.delay call pattern)
- .planning/phases/02-users-authentication/02-PATTERNS.md (api/admin.py section — router with admin dep, Pydantic models)
- .planning/phases/02-users-authentication/02-CONTEXT.md (D-06, D-08 — admin router, quota default, ADMIN-07 exclusion)
</read_first>
<behavior>
GET /api/admin/users:
- _admin = Depends(get_current_admin) (on every handler)
- SELECT all User rows ordered by created_at DESC
- Return { items: [{ id, handle, email, role, is_active, totp_enabled, ai_provider, ai_model, created_at }] }
- NEVER include password_hash, credentials_enc, or any document data in response
POST /api/admin/users (ADMIN-01):
- Body: { handle, email, password, role="user" }
- Validate password strength (same rules: ≥12 chars, uppercase, lowercase, digit, special)
- hash_password(password), insert User (is_active=True, totp_enabled=False, password_must_change=True) — admin-created users must change their password on first login
- insert Quota (limit_bytes=104857600, used_bytes=0)
- Return 201 { id, handle, email, role, created_at }
- 409 if email or handle taken
PATCH /api/admin/users/{id}/status (ADMIN-02):
- Body: { is_active: bool }
- Fetch User by id — 404 if not found
- Prevent deactivating the only admin account: if is_active=False and user.role="admin": count remaining active admins; if count would be 0, raise 400 "Cannot deactivate the only admin"
- Update user.is_active = body.is_active; if deactivating: revoke_all_refresh_tokens(session, user.id)
- Return { id, handle, email, is_active }
POST /api/admin/users/{id}/password-reset (ADMIN-03):
- Fetch User by id — 404 if not found
- create_password_reset_token(str(user.id)) → build reset_link
- send_reset_email.delay(user.email, reset_link)
- Return 202 { message: "Password reset email sent" }
- Does NOT grant admin access to the account; does NOT log admin in as user (ADMIN-07 exclusion)
GET /api/admin/users/{id}/quota (ADMIN-04):
- Fetch Quota by user_id — 404 if not found
- Return { user_id, limit_bytes, used_bytes, limit_mb: limit_bytes//1048576, used_mb: used_bytes//1048576 }
PATCH /api/admin/users/{id}/quota (ADMIN-04):
- Body: { limit_bytes: int } (must be > 0)
- Fetch Quota by user_id — 404 if not found
- If new limit_bytes < quota.used_bytes: return 200 with warning=True and message "New limit is below current usage. Uploads will be blocked but existing documents are preserved." — still apply the update
- Update quota.limit_bytes; return { user_id, limit_bytes, used_bytes, warning }
PATCH /api/admin/users/{id}/ai-config (ADMIN-05):
- Body: { ai_provider: str | None, ai_model: str | None }
- Fetch User — 404 if not found
- Update user.ai_provider and user.ai_model
- Return { id, email, ai_provider, ai_model }
ADMIN-07: No impersonation endpoint. No handler that creates a JWT with sub set to a different user than the authenticated admin. No route path containing "/impersonate" or "/login-as". This is enforced by omission — no such code exists.
In main.py: add `from api.admin import router as admin_router` and `app.include_router(admin_router)` after the auth router include.
</behavior>
<action>
Create backend/api/admin.py from scratch. Router: APIRouter(prefix="/api/admin", tags=["admin"]).
Pydantic request models (inside admin.py):
class UserCreate(BaseModel): handle: str; email: EmailStr; password: str; role: str = "user"
class UserStatusUpdate(BaseModel): is_active: bool
class QuotaUpdate(BaseModel): limit_bytes: int
class AiConfigUpdate(BaseModel): ai_provider: str | None = None; ai_model: str | None = None
Helper: _user_to_dict(user: User) -> dict — returns only safe fields: id, handle, email, role, is_active, totp_enabled, ai_provider, ai_model, created_at. This function is used on every response to prevent accidental field leakage.
Password strength validation: extract into a helper validate_password_strength(password: str) -> None that raises ValueError with the spec message if any rule fails. Reuse between register and admin create.
All handlers use: session: AsyncSession = Depends(get_db), _admin: User = Depends(get_current_admin).
In POST /api/admin/users: set user.password_must_change = True before db.add(user). This ensures admin-created users are forced to change their password on first login (ADMIN-01 per D-06).
Update backend/main.py to include admin_router (do not recreate the file — append the include after existing router includes).
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "from api.admin import router; paths = [r.path for r in router.routes]; print(paths)"</automated>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "import ast, sys; tree = ast.parse(open('api/admin.py').read()); has_impersonate = any('impersonate' in ast.dump(node) or 'login_as' in ast.dump(node) for node in ast.walk(tree)); sys.exit(1) if has_impersonate else print('No impersonation code found')"</automated>
</verify>
<acceptance_criteria>
- backend/api/admin.py exists with APIRouter prefix="/api/admin"
- Every route handler in admin.py includes `Depends(get_current_admin)` — grep -c "get_current_admin" backend/api/admin.py returns at least 7 (one per handler)
- Response helper _user_to_dict or equivalent exists and does NOT include password_hash field
- grep -c "password_hash" backend/api/admin.py returns 0 (never in responses)
- grep -c "impersonate\|login_as\|login-as" backend/api/admin.py returns 0 (ADMIN-07)
- backend/main.py contains "admin_router" (router is registered)
- PATCH /api/admin/users/{id}/status with { is_active: false } calls revoke_all_refresh_tokens
- POST /api/admin/users creates user with password_must_change=True in DB — grep -c "password_must_change" backend/api/admin.py returns at least 1
</acceptance_criteria>
<done>All admin endpoints created with get_current_admin enforced on every handler. Admin-created users have password_must_change=True. User responses use safe field helper. No impersonation endpoint exists. Router registered in main.py.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Admin API tests</name>
<files>
backend/tests/test_admin_api.py
</files>
<read_first>
- backend/tests/conftest.py (async_client fixture, db_session fixture, dependency override pattern)
- backend/api/admin.py (after Task 1 — understand endpoint paths and response shapes before writing tests)
- backend/deps/auth.py (get_current_admin dep — needs to be overridden in tests)
- .planning/phases/02-users-authentication/02-PATTERNS.md (Test — Async Client Override section, make_authed_client pattern)
</read_first>
<behavior>
- test_list_users_requires_admin: GET /api/admin/users with non-admin user Bearer → 403
- test_list_users_as_admin: override get_current_admin to return an admin User; GET /api/admin/users → 200 with "items" key
- test_create_user_as_admin: POST /api/admin/users with valid body → 201; response contains "id", "email"; no "password_hash" in response
- test_create_user_sets_password_must_change: POST /api/admin/users → 201; query DB for created user and assert user.password_must_change is True
- test_create_user_weak_password: POST /api/admin/users with password "short" → 422
- test_deactivate_user: create user, PATCH /api/admin/users/{id}/status { is_active: false } → 200, user.is_active False
- test_update_quota: PATCH /api/admin/users/{id}/quota { limit_bytes: 52428800 } → 200, response.limit_bytes = 52428800
- test_quota_below_usage_warning: set quota.used_bytes > new limit_bytes → 200 with warning=True in response
- test_update_ai_config: PATCH /api/admin/users/{id}/ai-config { ai_provider: "openai", ai_model: "gpt-4o" } → 200
- test_admin_impersonation_not_found: GET /api/admin/users/impersonate, GET /api/admin/login-as → 404 or 405 (route does not exist)
- test_admin_response_no_password_hash: list users response items do not contain "password_hash" key
</behavior>
<action>
Write backend/tests/test_admin_api.py using async_client fixture + db_session.
Override pattern for admin tests: in each test, define an async fixture or inline override:
app.dependency_overrides[get_current_admin] = lambda: admin_user_obj
app.dependency_overrides[get_db] = lambda: db_session
Helper function make_admin_user(db_session) that inserts a User(role="admin", is_active=True, password_hash=...) and Quota row, returns the ORM object.
Helper function make_regular_user(db_session) similarly with role="user".
For test_create_user_sets_password_must_change: after POST /api/admin/users returns 201, query db_session for User by the returned id and assert user.password_must_change is True.
For test_admin_impersonation_not_found: use async_client to GET "/api/admin/users/impersonate" and verify it returns 404 or 422 (not 200) — proving no such route exists.
For test_admin_response_no_password_hash: call GET /api/admin/users with admin dep override; parse response JSON; assert "password_hash" not in response["items"][0].
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_admin_api.py -x -q 2>&1 | tail -10</automated>
</verify>
<acceptance_criteria>
- pytest tests/test_admin_api.py exits 0 (all tests pass)
- test_create_user_sets_password_must_change passes (user.password_must_change is True in DB after admin create)
- test_admin_impersonation_not_found passes (route does not exist)
- test_admin_response_no_password_hash passes (no password_hash in response)
- test_list_users_requires_admin passes (403 for non-admin users)
- test_quota_below_usage_warning passes (warning=True when limit < used)
</acceptance_criteria>
<done>Admin API fully tested: all CRUD operations, role enforcement, quota warning, password_must_change=True on create, no impersonation route, no password_hash in responses.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| admin JWT→API (admin endpoints) | Admin Bearer token verified on every request via get_current_admin |
| admin→user data | Admin can read user metadata but must never see document content or credentials |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-26 | Elevation of Privilege | Admin endpoint without role check | mitigate | get_current_admin Depends() on every handler — no handler uses only get_current_user (SEC-07); verified by grep count in acceptance criteria |
| T-02-27 | Information Disclosure | Admin user list returns sensitive fields | mitigate | _user_to_dict() helper explicitly whitelists safe fields; password_hash and credentials_enc excluded by construction (SEC-07) |
| T-02-28 | Elevation of Privilege | Admin impersonation | mitigate | No endpoint exists (ADMIN-07); test_admin_impersonation_not_found asserts 404; AST check in acceptance criteria confirms no impersonation code |
| T-02-29 | Denial of Service | Admin deactivating all admins | mitigate | PATCH /status checks remaining active admin count before applying deactivation; raises 400 if count would reach 0 |
| T-02-30 | Tampering | Admin-initiated password reset grants admin access | mitigate | create_password_reset_token issues a reset-type JWT only; admin never gets the token; email goes to user's inbox; endpoint returns 202 not a token |
| T-02-31 | Information Disclosure | Quota endpoint exposes storage details | accept | Quota (limit_bytes, used_bytes) is admin-visible operational data — no PII, no document content |
| T-02-32 | Elevation of Privilege | Admin-created user skips password change | mitigate | password_must_change=True set on POST /api/admin/users; /login returns 200 {requires_password_change: true} without tokens until password is changed (ADMIN-01) |
</threat_model>
<verification>
1. GET /api/admin/users with user-role Bearer → 403
2. GET /api/admin/users with admin Bearer → 200, items[0] has no "password_hash"
3. GET /api/admin/users/impersonate → 404 or 405
4. PATCH /api/admin/users/{id}/quota { limit_bytes: 0 } → 422 (limit must be > 0)
5. PATCH /api/admin/users/{id}/status { is_active: false } → 200; subsequent login attempt returns 401 "Account deactivated"
6. POST /api/admin/users → 201; DB query confirms user.password_must_change is True
7. pytest tests/test_admin_api.py passes
8. grep -c "get_current_admin" backend/api/admin.py — count equals number of route handlers (at least 7)
9. grep -c "password_must_change" backend/api/admin.py returns at least 1
</verification>
<success_criteria>
- All 7 admin endpoints operational and returning correct shapes
- get_current_admin enforced on every handler (verified by test + grep)
- Admin-created users have password_must_change=True (forced password change on first login)
- No password_hash, credentials_enc, or document content in any admin response
- No impersonation route (verified by test + AST check)
- Admin password reset sends email via Celery; does not grant admin access
- Quota warning returned when new limit below current usage
- pytest tests/test_admin_api.py passes
</success_criteria>
<output>
Create `.planning/phases/02-users-authentication/02-04-SUMMARY.md` when done.
</output>
@@ -0,0 +1,268 @@
---
phase: 02-users-authentication
plan: 05
type: execute
wave: 5
depends_on:
- 02-02
- 02-03
- 02-04
files_modified:
- frontend/src/views/AdminView.vue
- frontend/src/components/admin/AdminUsersTab.vue
- frontend/src/components/admin/AdminQuotasTab.vue
- frontend/src/components/admin/AdminAiConfigTab.vue
- frontend/src/components/layout/AppSidebar.vue
autonomous: false
requirements:
- ADMIN-01
- ADMIN-02
- ADMIN-03
- ADMIN-04
- ADMIN-05
- ADMIN-07
must_haves:
truths:
- "Admin panel is accessible at /admin and visible in sidebar only for role='admin' users"
- "Admin can view all users in a table with email, role, status, and action links"
- "Admin can create a user with email, temporary password, and role via an inline form above the table"
- "Admin can deactivate a user account with inline confirmation showing the user email"
- "Admin can reset a user password, sending an email via the backend"
- "Admin can edit a user's storage quota inline with a warning when new limit is below current usage"
- "Admin can assign AI provider and model per user from dropdown selectors"
- "Admin panel link in sidebar is absent for non-admin users"
- "No impersonation action or UI exists anywhere"
artifacts:
- path: "frontend/src/views/AdminView.vue"
provides: "Admin panel with horizontal tab strip: Users | Quotas | AI Config"
- path: "frontend/src/components/admin/AdminUsersTab.vue"
provides: "User table with create form, deactivate/reactivate/reset-password actions"
- path: "frontend/src/components/admin/AdminQuotasTab.vue"
provides: "Quota inline-edit table with usage warning"
- path: "frontend/src/components/admin/AdminAiConfigTab.vue"
provides: "AI provider/model dropdown per user with save"
- path: "frontend/src/components/layout/AppSidebar.vue"
provides: "Admin nav link (conditional) + user identity footer with sign-out button"
key_links:
- from: "frontend/src/views/AdminView.vue"
to: "frontend/src/components/admin/AdminUsersTab.vue"
via: "v-if on activeTab"
pattern: "AdminUsersTab"
- from: "frontend/src/components/layout/AppSidebar.vue"
to: "useAuthStore().user?.role"
via: "v-if conditional admin link"
pattern: "role.*admin"
---
<objective>
Deliver the complete admin panel frontend. After this plan, an admin user can manage all user accounts, quotas, and AI configurations from the web UI. The sidebar shows the admin link only for admin users and the user identity footer enables sign-out.
Purpose: This is the final wave plan — it wires all the admin API endpoints (Plan 04) into working Vue components with the exact UI-SPEC visual contract.
Output: AdminView.vue, three admin tab components, AppSidebar.vue update with admin link and user footer.
</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-04-SUMMARY.md
</context>
<interfaces>
From frontend/src/api/client.js (Plan 02 exports):
adminListUsers() → GET /api/admin/users → { items: [{ id, handle, email, role, is_active, totp_enabled, ai_provider, ai_model, created_at }] }
adminCreateUser(body) → POST /api/admin/users → 201 { id, handle, email, role, created_at }
adminDeactivateUser(id) → PATCH /api/admin/users/{id}/status { is_active: false }
adminReactivateUser(id) → PATCH /api/admin/users/{id}/status { is_active: true }
adminResetUserPassword(id) → POST /api/admin/users/{id}/password-reset → 202
adminUpdateQuota(id, limitBytes) → PATCH /api/admin/users/{id}/quota { limit_bytes: limitBytes }
adminUpdateAiConfig(id, provider, model) → PATCH /api/admin/users/{id}/ai-config { ai_provider, ai_model }
From frontend/src/stores/auth.js (Plan 02):
const user = ref(null) // { id, handle, email, role, totp_enabled }
function logout() // clears accessToken and user
From frontend/src/components/layout/AppSidebar.vue (current — must be extended, not recreated):
<script setup>
import { useTopicsStore } from '../../stores/topics.js'
const topicsStore = useTopicsStore()
</script>
UI-SPEC admin panel visual contract:
Tab strip: flex border-b border-gray-200 mb-6
Active tab: px-4 py-2 text-sm font-semibold text-indigo-600 border-b-2 border-indigo-600
Inactive tab: px-4 py-2 text-sm font-semibold text-gray-500 hover:text-gray-700 border-b-2 border-transparent
Table: bg-white rounded-xl border border-gray-200 overflow-hidden divide-y divide-gray-200
Deactivate link: text-red-600 hover:text-red-700 text-sm
Other actions: text-indigo-600 hover:text-indigo-700 text-sm
Create button: bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-semibold px-4 py-2 rounded-lg
</interfaces>
<tasks>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
After Plans 02, 03, and 04 complete, the full auth and admin backend is live. This checkpoint verifies the backend before building the admin frontend against it.
</what-built>
<how-to-verify>
1. `docker compose up` — confirm all services start cleanly
2. `curl -s http://localhost:8000/health` → { status: "ok", ... }
3. Register a user: `curl -s -X POST http://localhost:8000/api/auth/register -H 'Content-Type: application/json' -d '{"handle":"testuser","email":"test@test.com","password":"TestPass12!"}'` → 201
4. Login: `curl -s -X POST http://localhost:8000/api/auth/login -H 'Content-Type: application/json' -d '{"email":"test@test.com","password":"TestPass12!"}' -v` → 200 + Set-Cookie header containing "HttpOnly" and "SameSite=Strict"
5. Try GET /api/admin/users without admin token → 403
6. Check docker compose logs for admin bootstrap: should see "Admin bootstrap" log line or "ADMIN_EMAIL not set" warning
</how-to-verify>
<resume-signal>Type "backend verified" or describe any issues found</resume-signal>
</task>
<task type="auto">
<name>Task 2: Admin tab components and AdminView</name>
<files>
frontend/src/views/AdminView.vue,
frontend/src/components/admin/AdminUsersTab.vue,
frontend/src/components/admin/AdminQuotasTab.vue,
frontend/src/components/admin/AdminAiConfigTab.vue
</files>
<read_first>
- frontend/src/views/SettingsView.vue (tabbed sections pattern — v-if on activeTab, button tab strip, card layout)
- frontend/src/views/TopicsView.vue (list + action pattern for AdminUsersTab)
- .planning/phases/02-users-authentication/02-UI-SPEC.md (Admin View section — table structure, tab strip classes, row states, create user panel, quota inline edit, AI config table)
- .planning/phases/02-users-authentication/02-PATTERNS.md (AdminUsersTab, AdminQuotasTab, AdminAiConfigTab, AdminView sections)
- .planning/phases/02-users-authentication/02-UI-SPEC.md (Copywriting Contract — admin entries, loading states)
</read_first>
<action>
frontend/src/views/AdminView.vue: Heading "Admin panel" (text-2xl font-semibold text-gray-900). Horizontal tab strip using UI-SPEC tab classes. Tabs: "Users" | "Quotas" | "AI Config". v-if switch: AdminUsersTab, AdminQuotasTab, AdminAiConfigTab. Import all three components. No guard needed here — /admin route is protected by router guard; AdminView only renders when admin user is authenticated.
frontend/src/components/admin/AdminUsersTab.vue: On mounted, call adminListUsers() and store in users ref. Table columns per UI-SPEC: Email | Role | Status | Created | Actions. Role badge: same indigo/gray badge styles as AccountView. Status badge: active=bg-green-100 text-green-700, deactivated=bg-gray-100 text-gray-500. Actions column: for active rows "Reset password" · "Deactivate"; for deactivated rows "Reactivate". Action link classes per UI-SPEC.
Deactivate flow: clicking "Deactivate" replaces the row's action cell with an inline confirmation showing the user email ("Deactivate [email]? They will lose access immediately. Their data is preserved." + "Deactivate" / "Keep account" buttons). On confirm: call adminDeactivateUser(id), update row is_active=false. No modal — inline replacement per UI-SPEC.
Create user: "Create user" button top-right of table. On click: show inline panel above table (not a modal) with: email input, role selector (User/Admin dropdown), password field (pre-generated 12-char random password, read-only, with copy button) — executor should generate the temp password client-side via crypto.getRandomValues to produce a strong alphanumeric string. On submit: adminCreateUser({ handle: email.split('@')[0], email, password, role }) then prepend to users list. Copywriting: "Create user" CTA.
Loading states: per UI-SPEC loading table — row-level spinner (animate-spin rounded-full border-2 border-current border-t-transparent w-4 h-4 inline) in the action column while deactivate/reset operations are in flight; pointer-events-none on the row.
Empty state: if users.length === 0 after load: heading "No users yet" + body "Create the first user account to get started." — center in table area.
frontend/src/components/admin/AdminQuotasTab.vue: On mounted, call adminListUsers(). Table columns: Email | Used | Limit | Usage % | Actions. Used and Limit displayed in MB (Math.round(bytes / 1048576)) + " MB" suffix. Usage %: Math.round(used/limit * 100) + "%". Actions: "Edit" button per row. On "Edit": the Limit cell becomes an input type="number" min="1" step="1" (value in MB) + Save/Cancel buttons. On save: adminUpdateQuota(id, newMB * 1048576). If response.warning is true, show inline warning below input: text-xs text-amber-600 "New limit is below current usage (X MB). Existing documents will not be deleted, but uploads will be blocked." Still apply the update. Loading: spinner inline in save button + disabled.
frontend/src/components/admin/AdminAiConfigTab.vue: On mounted, call adminListUsers(). Table columns: Email | AI Provider | AI Model | Actions. Provider: dropdown <select> with options: "openai", "anthropic", "ollama", "lmstudio" (hardcoded list matching DEFAULT_SETTINGS providers in config.py). Model: text input (or dropdown if provider selected — for MVP, use a text input pre-filled with user's current ai_model or empty). "Save" button per row. On save: adminUpdateAiConfig(id, provider, model). Success: row briefly shows green "Saved" text for 1.5s. Loading: spinner in Save button + disabled.
No impersonation action, link, or UI element exists in any of these components.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && node -e "const fs = require('fs'); ['src/views/AdminView.vue','src/components/admin/AdminUsersTab.vue','src/components/admin/AdminQuotasTab.vue','src/components/admin/AdminAiConfigTab.vue'].forEach(f => { const c = fs.readFileSync(f,'utf8'); if (c.includes('impersonate') || c.includes('login-as') || c.includes('loginAs')) throw new Error('Impersonation found in ' + f); }); console.log('No impersonation in admin components');"</automated>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run build 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- frontend/src/views/AdminView.vue contains "Admin panel" heading and three tab references (AdminUsersTab, AdminQuotasTab, AdminAiConfigTab)
- AdminUsersTab.vue contains "adminListUsers" and "adminDeactivateUser" and "adminResetUserPassword"
- AdminUsersTab.vue contains "Deactivate" and "Reset password" and "Reactivate" action text
- AdminUsersTab.vue contains "No users yet" (empty state)
- AdminQuotasTab.vue contains "adminUpdateQuota" and "MB" and "warning"
- AdminAiConfigTab.vue contains "adminUpdateAiConfig"
- grep -c "impersonate\|loginAs\|login-as" frontend/src/components/admin/AdminUsersTab.vue returns 0
- npm run build exits 0
</acceptance_criteria>
<done>Admin panel frontend delivered: Users tab with create/deactivate/reset, Quotas tab with inline edit and warning, AI Config tab with provider/model dropdowns. No impersonation UI exists.</done>
</task>
<task type="auto">
<name>Task 3: AppSidebar.vue — admin link and user identity footer</name>
<files>
frontend/src/components/layout/AppSidebar.vue
</files>
<read_first>
- frontend/src/components/layout/AppSidebar.vue (full file — extend in-place, understand existing nav-link structure before modifying)
- frontend/src/stores/auth.js (useAuthStore — user.role, logout() function signature)
- .planning/phases/02-users-authentication/02-UI-SPEC.md (Sidebar Updates section — admin link spec, user identity footer layout and class spec)
- .planning/phases/02-users-authentication/02-PATTERNS.md (AppSidebar.vue section — import pattern, admin link template, user footer template)
</read_first>
<action>
Extend AppSidebar.vue in-place (do not recreate the file). Read the full current file first, then apply targeted additions.
Script block: add `import { useAuthStore } from '../../stores/auth.js'` and `const authStore = useAuthStore()` alongside the existing topicsStore import.
Admin link: add a router-link to "/admin" inside the nav section, above the Settings link. Conditional: v-if="authStore.user?.role === 'admin'". Icon: inline SVG shield (heroicon stroke style, w-4 h-4). Label: "Admin". Apply same nav-link scoped class as other nav items. Active state: :class="{ 'nav-link-active': $route.path === '/admin' }".
User identity footer: add below the settings link, inside the sidebar's bottom section. Layout per UI-SPEC:
div.flex.items-center.gap-3.px-4.py-3.border-t.border-gray-100
div (avatar circle): initials from authStore.user?.email (first char uppercase). Classes: bg-indigo-100 text-indigo-700 text-xs font-semibold rounded-full w-8 h-8 flex items-center justify-center shrink-0
span (email): authStore.user?.email. Classes: text-xs text-gray-600 truncate flex-1
button (sign-out icon): @click="authStore.logout(); $router.push('/login')". aria-label="Sign out". Icon: inline SVG arrow-right-on-rectangle (w-4 h-4). Classes: text-gray-400 hover:text-gray-600
The entire footer block is conditionally rendered: v-if="authStore.user".
Do not modify any existing nav links, styles, or the topicsStore import. Only add the new elements.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && node -e "const fs = require('fs'); const c = fs.readFileSync('src/components/layout/AppSidebar.vue','utf8'); if (!c.includes('useAuthStore')) throw new Error('No useAuthStore'); if (!c.includes('role.*admin') && !c.includes('admin.*role')) throw new Error('No admin role check'); if (!c.includes('aria-label')) throw new Error('No aria-label on sign-out'); console.log('Sidebar OK');"</automated>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run build 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- AppSidebar.vue contains "useAuthStore" import
- AppSidebar.vue contains v-if with 'admin' role check for the admin link — grep -c "admin" frontend/src/components/layout/AppSidebar.vue returns at least 2
- AppSidebar.vue contains "aria-label" (sign-out button accessibility)
- AppSidebar.vue contains "border-t border-gray-100" (footer separator)
- AppSidebar.vue contains authStore.logout() call
- The existing nav links, topicsStore reference, and scoped styles are preserved unchanged
- npm run build exits 0
</acceptance_criteria>
<done>Sidebar updated with conditional admin link and user identity footer including initials avatar, email display, and sign-out button. Existing nav links untouched.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser→admin API | Admin Bearer token required for all admin API calls from the frontend |
| admin UI→user data | Admin can view user metadata; UI must never render password_hash or credentials_enc |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-30 | Elevation of Privilege | Admin link visible to non-admin users | mitigate | v-if="authStore.user?.role === 'admin'" on the sidebar link; security enforced at API layer regardless |
| T-02-31 | Elevation of Privilege | Admin UI impersonation | mitigate | No "Log in as user" button, link, or action in any admin component; acceptance criteria grep confirms zero occurrences |
| T-02-32 | Information Disclosure | Admin panel renders sensitive user data | mitigate | Admin API response shape excludes password_hash/credentials_enc; UI only binds to safe fields (email, role, is_active, ai_provider, ai_model, limit_bytes, used_bytes) |
| T-02-33 | Tampering | Inline deactivation without confirmation | mitigate | Deactivate action shows inline confirmation with user email before calling API; prevents accidental mass-deactivation |
| T-02-34 | Denial of Service | Admin creates unlimited users | accept | No rate limit on admin user creation — admin is a trusted role; acceptable risk for single-tenant deployment |
</threat_model>
<verification>
1. Log in as a regular user → sidebar does NOT show "Admin" link
2. Log in as admin user → sidebar shows "Admin" link navigating to /admin
3. At /admin, Users tab shows user table with Email, Role, Status, Created, Actions columns
4. Create a user via admin panel → user appears in table
5. Deactivate a user → inline confirmation appears with email; on confirm, row status badge changes to "Deactivated"
6. Quotas tab: click Edit on a row → limit cell becomes input; enter value below current usage → warning text appears after save
7. AI Config tab: change provider dropdown for a user → Save → "Saved" confirmation
8. npm run build exits 0
9. grep -c "impersonate\|loginAs\|login-as" frontend/src/components/admin/AdminUsersTab.vue returns 0
</verification>
<success_criteria>
- Admin panel renders with correct tab navigation and visual design matching UI-SPEC
- All three admin tabs functional: user CRUD, quota inline edit with warning, AI config per-user
- Sidebar admin link appears only for role='admin' users
- Sidebar user identity footer shows initials, email, and sign-out button
- No impersonation UI exists anywhere in admin components
- npm run build exits 0
</success_criteria>
<output>
Create `.planning/phases/02-users-authentication/02-05-SUMMARY.md` when done.
</output>
@@ -0,0 +1,992 @@
# Phase 2: Users & Authentication - Pattern Map
**Mapped:** 2026-05-22
**Files analyzed:** 20 new/modified files
**Analogs found:** 18 / 20
---
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `backend/config.py` | config | request-response | `backend/config.py` (current) | exact — extend in-place |
| `backend/services/auth.py` | service | CRUD | `backend/services/classifier.py` | role-match (pure-Python service, no FastAPI) |
| `backend/services/email.py` | service | request-response | `backend/services/classifier.py` | role-match (pure-Python service) |
| `backend/api/auth.py` | controller | request-response | `backend/api/documents.py` | exact role-match |
| `backend/api/admin.py` | controller | request-response | `backend/api/settings.py` | role-match (settings-admin pattern) |
| `backend/deps/auth.py` | middleware | request-response | `backend/deps/db.py` | exact role-match (dependency function) |
| `backend/tasks/email_tasks.py` | service | event-driven | `backend/tasks/document_tasks.py` | exact role-match |
| `backend/main.py` | config | request-response | `backend/main.py` (current) | exact — extend in-place |
| `frontend/src/stores/auth.js` | store | request-response | `frontend/src/stores/documents.js` | exact role-match |
| `frontend/src/api/client.js` | utility | request-response | `frontend/src/api/client.js` (current) | exact — extend in-place |
| `frontend/src/router/index.js` | config | request-response | `frontend/src/router/index.js` (current) | exact — extend in-place |
| `frontend/src/views/auth/LoginView.vue` | component | request-response | `frontend/src/views/SettingsView.vue` | role-match (form view + store call) |
| `frontend/src/views/auth/RegisterView.vue` | component | request-response | `frontend/src/views/SettingsView.vue` | role-match (form view + store call) |
| `frontend/src/views/auth/PasswordResetView.vue` | component | request-response | `frontend/src/views/SettingsView.vue` | role-match |
| `frontend/src/views/auth/NewPasswordView.vue` | component | request-response | `frontend/src/views/SettingsView.vue` | role-match |
| `frontend/src/views/AccountView.vue` | component | request-response | `frontend/src/views/SettingsView.vue` | role-match (settings-style view) |
| `frontend/src/views/AdminView.vue` | component | request-response | `frontend/src/views/SettingsView.vue` | role-match (tabbed settings-style view) |
| `frontend/src/layouts/AuthLayout.vue` | component | request-response | `frontend/src/components/layout/AppSidebar.vue` | partial-match (layout component) |
| `frontend/src/components/auth/PasswordStrengthBar.vue` | component | transform | `frontend/src/components/topics/TopicBadge.vue` | partial-match (display-only component) |
| `frontend/src/components/auth/TotpEnrollment.vue` | component | request-response | `frontend/src/components/upload/DropZone.vue` | partial-match (interactive UI component) |
| `frontend/src/components/auth/BackupCodesDisplay.vue` | component | request-response | `frontend/src/components/upload/UploadProgress.vue` | partial-match (display + action component) |
| `frontend/src/components/ui/AppSpinner.vue` | component | transform | `frontend/src/components/topics/TopicBadge.vue` | partial-match (display-only) |
| `frontend/src/components/ui/ConfirmBlock.vue` | component | event-driven | `frontend/src/components/upload/DropZone.vue` | partial-match (emit-based interaction) |
| `frontend/src/components/admin/AdminUsersTab.vue` | component | CRUD | `frontend/src/views/TopicsView.vue` | role-match (CRUD list view) |
| `frontend/src/components/admin/AdminQuotasTab.vue` | component | CRUD | `frontend/src/views/SettingsView.vue` | role-match (form-based edit) |
| `frontend/src/components/admin/AdminAiConfigTab.vue` | component | request-response | `frontend/src/views/SettingsView.vue` | exact role-match (AI provider config) |
| `frontend/src/components/layout/AppSidebar.vue` | component | request-response | `frontend/src/components/layout/AppSidebar.vue` (current) | exact — modify in-place |
---
## Pattern Assignments
### `backend/config.py` (config, extend in-place)
**Analog:** `backend/config.py` (current, lines 135)
**Existing Settings class pattern** (lines 135):
```python
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# PostgreSQL
database_url: str = "postgresql+psycopg://..."
# Redis / Celery
redis_url: str = "redis://:changeme_redis@redis:6379/0"
# Security (Phase 2 — documented now, not read by Phase 1 code paths)
secret_key: str = "CHANGEME"
settings = Settings()
```
**Phase 2 additions — append to the `Settings` class body:**
```python
# Auth / JWT (Phase 2)
access_token_expire_minutes: int = 15
refresh_token_expire_days: int = 30
# SMTP (Phase 2 — D-01)
smtp_host: str = ""
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
smtp_from: str = "noreply@docuvault.local"
# Admin bootstrap (Phase 2 — D-04)
admin_email: str = ""
admin_password: str = ""
# CORS (Phase 2 — D-09)
cors_origins: list[str] = ["http://localhost:5173"]
```
**Parsing note:** `cors_origins` as `list[str]` with pydantic-settings parses a comma-separated env var automatically when the env value is JSON-like (`["a","b"]`) or by overriding `model_config` with `env_list_separator=","`.
---
### `backend/services/auth.py` (service, CRUD)
**Analog:** `backend/services/classifier.py` (lines 156)
**Module docstring + import pattern** (lines 113):
```python
"""
Auth service — pure Python, no FastAPI coupling.
Handles password hashing (Argon2), JWT creation/verification,
refresh token lifecycle, TOTP provisioning and verification.
"""
from sqlalchemy.ext.asyncio import AsyncSession
```
**Pure-service function signature pattern** (lines 1730 of classifier.py):
```python
async def some_operation(
session: AsyncSession,
arg1: str,
arg2: list[str] | None = None,
) -> ReturnType:
"""Docstring."""
# ... no FastAPI imports, no HTTPException here
```
**Key functions to implement following this signature style:**
- `async def hash_password(plain: str) -> str` — argon2 via pwdlib
- `async def verify_password(plain: str, hashed: str) -> bool` — constant-time via pwdlib
- `async def create_access_token(user_id: str, role: str) -> str` — PyJWT, `typ=access`
- `async def create_refresh_token(session, user_id: uuid.UUID) -> str` — DB row + hashed token
- `async def rotate_refresh_token(session, raw_token: str) -> tuple[str, str]` — family revocation on reuse
- `async def revoke_all_refresh_tokens(session, user_id: uuid.UUID) -> int` — sign-out-all-devices
- `async def provision_totp(session, user_id: uuid.UUID) -> tuple[str, str]` — pyotp, returns (secret, provisioning_uri)
- `async def verify_totp(session, user_id: uuid.UUID, code: str, redis_client) -> bool` — replay prevention via Redis
- `async def verify_backup_code(session, user_id: uuid.UUID, code: str) -> bool` — constant-time check + mark used
**Error style:** raise plain `ValueError` or custom exceptions — never `HTTPException`. Callers in `api/auth.py` convert to HTTP errors.
---
### `backend/services/email.py` (service, request-response)
**Analog:** `backend/services/classifier.py` (pure-Python service pattern)
**Module structure pattern:**
```python
"""
Email service — pure Python, no FastAPI coupling.
Sends via SMTP when SMTP_HOST is configured; logs to stdout otherwise (D-02).
"""
import logging
import smtplib
from email.mime.text import MIMEText
from config import settings
logger = logging.getLogger(__name__)
def send_password_reset_email(to_address: str, reset_link: str) -> None:
"""Send (or log) the password reset email. Never raises — failures are logged."""
if not settings.smtp_host:
# D-02: dev fallback — log token link to stdout
logger.info("DEV MODE — password reset link: %s", reset_link)
return
# ... smtplib send
```
**Never raises:** email failures are non-fatal; log and return. The Celery task wraps in try/except (see `document_tasks.py` pattern).
---
### `backend/api/auth.py` (controller, request-response)
**Analog:** `backend/api/documents.py` (lines 1117)
**Router declaration pattern** (lines 110 of documents.py):
```python
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from deps.db import get_db
from services import auth as auth_service
router = APIRouter(prefix="/api/auth", tags=["auth"])
```
**Request body Pydantic model pattern** (from settings.py lines 1118):
```python
from pydantic import BaseModel, EmailStr
class LoginRequest(BaseModel):
email: EmailStr
password: str
class RegisterRequest(BaseModel):
handle: str
email: EmailStr
password: str
```
**Route handler pattern** (documents.py lines 2666):
```python
@router.post("/login")
async def login(
body: LoginRequest,
response: Response,
session: AsyncSession = Depends(get_db),
):
try:
tokens = await auth_service.authenticate(session, body.email, body.password)
except ValueError as e:
raise HTTPException(401, str(e))
response.set_cookie(
"refresh_token",
tokens.refresh_token,
httponly=True,
secure=True,
samesite="strict",
max_age=settings.refresh_token_expire_days * 86400,
path="/api/auth/refresh",
)
return {"access_token": tokens.access_token, "user": tokens.user_dict}
```
**Error mapping convention:** `ValueError` from service layer → `HTTPException(4xx)`. Let other exceptions propagate (500).
**Endpoints to implement:**
- `POST /api/auth/register` → 201
- `POST /api/auth/login` → 200 + set httpOnly cookie
- `POST /api/auth/refresh` → 200 (reads httpOnly cookie, no body needed)
- `POST /api/auth/logout` → 200 + clear cookie
- `POST /api/auth/logout-all` → 200
- `POST /api/auth/password-reset` → 202 (enqueues Celery task)
- `POST /api/auth/password-reset/confirm` → 200
- `GET /api/auth/totp/setup` → 200 (returns QR URI + secret)
- `POST /api/auth/totp/enable` → 200 (verifies code, enables TOTP, returns backup codes)
- `DELETE /api/auth/totp` → 200 (disables TOTP)
- `GET /api/auth/me` → 200
---
### `backend/api/admin.py` (controller, request-response)
**Analog:** `backend/api/settings.py` (lines 184)
**Router with dependency guard pattern** (from settings.py):
```python
from fastapi import APIRouter, Depends, HTTPException
from deps.auth import get_current_admin # blocks non-admins at dep level
router = APIRouter(prefix="/api/admin", tags=["admin"])
@router.get("/users")
async def list_users(
session: AsyncSession = Depends(get_db),
_admin=Depends(get_current_admin), # enforces role on every handler
):
...
```
**All handlers in this router must inject `get_current_admin`.** Do not expose document content, extracted text, or `credentials_enc` — return only user metadata fields.
**Pydantic request bodies follow settings.py style:**
```python
class QuotaAdjust(BaseModel):
limit_bytes: int
class UserCreate(BaseModel):
handle: str
email: EmailStr
password: str
role: str = "user"
```
---
### `backend/deps/auth.py` (middleware, request-response)
**Analog:** `backend/deps/db.py` (lines 126) + `backend/ai/__init__.py` (factory pattern)
**Dependency function pattern** (db.py lines 2026):
```python
from typing import AsyncGenerator
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession
from db.session import AsyncSessionLocal
from services import auth as auth_service
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
session: AsyncSession = Depends(get_db),
):
"""Validate Bearer token; return User ORM object or raise 401."""
try:
payload = auth_service.decode_access_token(credentials.credentials)
except ValueError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid or expired token")
user = await session.get(User, uuid.UUID(payload["sub"]))
if user is None or not user.is_active:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User not found or deactivated")
return user
async def get_current_admin(user=Depends(get_current_user)):
"""Require admin role; raise 403 otherwise."""
if user.role != "admin":
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin access required")
return user
```
**Module-level `get_db` import pattern** (db.py line 17):
```python
from db.session import AsyncSessionLocal
```
---
### `backend/tasks/email_tasks.py` (service, event-driven)
**Analog:** `backend/tasks/document_tasks.py` (lines 195)
**Celery task pattern** (document_tasks.py lines 2225):
```python
import asyncio
from celery_app import celery_app
@celery_app.task(name="tasks.email_tasks.send_reset_email")
def send_reset_email(to_address: str, reset_link: str) -> dict:
"""Synchronous Celery entry-point — delegates to async _run via asyncio.run."""
return asyncio.run(_run_send_reset(to_address, reset_link))
```
**Async body pattern** (document_tasks.py lines 2894):
```python
async def _run_send_reset(to_address: str, reset_link: str) -> dict:
"""Async body. Imports deferred inside to avoid circular imports."""
from services.email import send_password_reset_email
try:
send_password_reset_email(to_address, reset_link)
return {"status": "sent", "to": to_address}
except Exception as e:
return {"status": "failed", "error": str(e)}
```
**Critical import rule** (celery_app.py lines 111): do NOT import from `config` or FastAPI at module top level — use deferred imports inside `_run_*` functions to avoid circular import issues (same as document_tasks.py pattern).
**celery_app.conf.task_routes extension:**
```python
celery_app.conf.task_routes = {
"tasks.document_tasks.*": {"queue": "documents"},
"tasks.email_tasks.*": {"queue": "email"}, # add this
}
```
---
### `backend/main.py` (config, extend in-place)
**Analog:** `backend/main.py` (current, lines 188)
**Lifespan extension pattern** (lines 1638):
```python
@asynccontextmanager
async def lifespan(app: FastAPI):
# ... existing MinIO init ...
# Phase 2: admin bootstrap (D-04, D-05)
from services.auth import bootstrap_admin
await bootstrap_admin() # idempotent, logs WARNING if env vars not set
yield
await engine.dispose()
```
**CORS update pattern** (lines 4348):
```python
# Phase 2: CORS_ORIGINS from settings (D-09) — replaces allow_origins=["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True, # required for httpOnly cookie
allow_methods=["*"],
allow_headers=["*"],
)
```
**Router include pattern** (lines 8587):
```python
app.include_router(documents_router)
app.include_router(topics_router)
app.include_router(settings_router)
# Phase 2 additions:
app.include_router(auth_router)
app.include_router(admin_router)
```
---
### `frontend/src/stores/auth.js` (store, request-response)
**Analog:** `frontend/src/stores/documents.js` (lines 146)
**Store structure pattern** (documents.js lines 146):
```javascript
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as api from '../api/client.js'
export const useAuthStore = defineStore('auth', () => {
// State — accessToken in memory only (CLAUDE.md rule: never localStorage)
const accessToken = ref(null)
const user = ref(null) // { id, handle, email, role, totp_enabled }
const loading = ref(false)
const error = ref(null)
async function login(email, password, totpCode = null) {
loading.value = true
error.value = null
try {
const data = await api.login({ email, password, totp_code: totpCode })
accessToken.value = data.access_token
user.value = data.user
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
async function logout() {
await api.logout()
accessToken.value = null
user.value = null
}
async function refresh() {
// Called by api/client.js on 401; uses httpOnly cookie automatically
const data = await api.refreshToken()
accessToken.value = data.access_token
user.value = data.user
}
return { accessToken, user, loading, error, login, logout, refresh }
})
```
**Key rule:** `accessToken` lives only in `ref()` memory. Never write to `localStorage` or `sessionStorage`.
---
### `frontend/src/api/client.js` (utility, extend in-place)
**Analog:** `frontend/src/api/client.js` (current, lines 1106)
**`request()` function extension pattern** (lines 614, extend):
```javascript
import { useAuthStore } from '../stores/auth.js'
async function request(path, options = {}) {
const authStore = useAuthStore()
// Inject Bearer token if present
const headers = { ...(options.headers || {}) }
if (authStore.accessToken) {
headers['Authorization'] = `Bearer ${authStore.accessToken}`
}
const res = await fetch(path, { ...options, headers })
// 401 → attempt refresh → retry once
if (res.status === 401 && !options._retry) {
try {
await authStore.refresh()
return request(path, { ...options, headers: {}, _retry: true })
} catch {
authStore.accessToken = null
authStore.user = null
// Let caller handle redirect (router guard will catch unauthenticated state)
throw new Error('Session expired')
}
}
if (!res.ok) {
let msg = `HTTP ${res.status}`
try { msg = (await res.json()).detail || msg } catch {}
throw new Error(msg)
}
return res.json()
}
```
**New auth API functions** (follow existing export style):
```javascript
export function login(body) {
return request('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
export function refreshToken() {
// No body — httpOnly cookie sent automatically by browser
return request('/api/auth/refresh', { method: 'POST' })
}
export function logout() {
return request('/api/auth/logout', { method: 'POST' })
}
```
---
### `frontend/src/router/index.js` (config, extend in-place)
**Analog:** `frontend/src/router/index.js` (current, lines 118)
**Route definition pattern** (lines 713):
```javascript
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth.js'
const routes = [
// Existing routes...
{ path: '/', component: HomeView },
// Phase 2 — auth routes (no guard)
{ 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 } },
// Phase 2 — authenticated routes
{ path: '/account', component: () => import('../views/AccountView.vue') },
{ path: '/admin', component: () => import('../views/AdminView.vue') },
]
```
**Navigation guard pattern** (D-10 — add after router creation):
```javascript
const router = createRouter({ history: createWebHistory(), routes })
router.beforeEach((to, from) => {
const authStore = useAuthStore()
if (!to.meta.public && !authStore.accessToken) {
// Preserve intended destination for post-login redirect
return { path: '/login', query: { redirect: to.fullPath } }
}
})
export default router
```
---
### `frontend/src/views/auth/LoginView.vue` (component, request-response)
**Analog:** `frontend/src/views/SettingsView.vue` (full file)
**View structure pattern** (SettingsView.vue):
```vue
<template>
<div class="p-8 max-w-md mx-auto">
<h2 class="text-2xl font-bold text-gray-900 mb-1">Sign in</h2>
<form @submit.prevent="submit" class="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Email</label>
<input v-model="email" type="email" required
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400" />
</div>
<!-- ... password field ... -->
<button type="submit" :disabled="loading"
class="w-full px-6 py-2.5 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-50">
{{ loading ? 'Signing in…' : 'Sign in' }}
</button>
<p v-if="error" class="text-sm text-red-500">{{ error }}</p>
</form>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '../../stores/auth.js'
const authStore = useAuthStore()
const router = useRouter()
const route = useRoute()
const email = ref('')
const password = ref('')
const loading = ref(false)
const error = ref(null)
async function submit() {
loading.value = true
error.value = null
try {
await authStore.login(email.value, password.value)
const redirect = route.query.redirect || '/'
router.push(redirect)
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
</script>
```
**Tailwind class vocabulary** (from SettingsView.vue): `border border-gray-200 rounded-xl p-6`, `border border-gray-300 rounded-lg px-3 py-2`, `focus:ring-2 focus:ring-indigo-400`, `bg-indigo-600 text-white hover:bg-indigo-700`, `disabled:opacity-50`, `text-red-500`, `text-green-600`.
**TOTP step:** after successful login response with `requires_totp: true`, show a second step (inline in same view or separate `v-if` section) with a 6-digit code input, then call `authStore.loginTotp(code)`.
---
### `frontend/src/views/auth/RegisterView.vue` (component, request-response)
**Analog:** `frontend/src/views/SettingsView.vue`
Same structure as LoginView.vue. Additional fields: `handle` (username), `email`, `password`, `confirmPassword`. Inline password strength bar via `<PasswordStrengthBar :password="password" />`. Validate `password === confirmPassword` client-side before calling `authStore.register(...)`.
---
### `frontend/src/views/AccountView.vue` (component, request-response)
**Analog:** `frontend/src/views/SettingsView.vue` (full file — tabbed settings pattern)
Sections (use `v-if` on active tab, not router sub-routes):
- **Profile** — handle, email display, change password form
- **2FA** — TOTP enrollment status, `<TotpEnrollment />` sub-component, backup code regeneration
- **Danger zone** — account deletion (confirm-before-action via `<ConfirmBlock />`)
```vue
<script setup>
import { ref } from 'vue'
import { useAuthStore } from '../stores/auth.js'
import TotpEnrollment from '../components/auth/TotpEnrollment.vue'
import ConfirmBlock from '../components/ui/ConfirmBlock.vue'
const authStore = useAuthStore()
const activeTab = ref('profile') // 'profile' | '2fa' | 'danger'
</script>
```
---
### `frontend/src/views/AdminView.vue` (component, request-response)
**Analog:** `frontend/src/views/SettingsView.vue` (tabbed structure)
Three tabs: Users, Quotas, AI Config. Use same tab-button pattern as SettingsView.vue provider buttons:
```vue
<div class="flex flex-wrap gap-2 mb-6">
<button v-for="tab in tabs" :key="tab.id" @click="activeTab = tab.id"
class="px-4 py-2 rounded-lg text-sm font-medium border transition-colors"
:class="activeTab === tab.id ? 'bg-indigo-600 text-white border-indigo-600' : 'border-gray-300 text-gray-600 hover:bg-gray-50'">
{{ tab.label }}
</button>
</div>
<AdminUsersTab v-if="activeTab === 'users'" />
<AdminQuotasTab v-if="activeTab === 'quotas'" />
<AdminAiConfigTab v-if="activeTab === 'ai'" />
```
---
### `frontend/src/layouts/AuthLayout.vue` (component, request-response)
**Analog:** `frontend/src/components/layout/AppSidebar.vue` (layout structure)
Centered card layout — no sidebar. Used by `/login`, `/register`, `/password-reset`:
```vue
<template>
<div class="min-h-screen bg-gray-50 flex items-center justify-center">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<h1 class="text-2xl font-bold text-indigo-600">DocuVault</h1>
</div>
<router-view />
</div>
</div>
</template>
```
---
### `frontend/src/components/auth/TotpEnrollment.vue` (component, request-response)
**Analog:** `frontend/src/components/upload/DropZone.vue` (interactive multi-step component)
**defineEmits pattern** (DropZone.vue line 42):
```vue
<script setup>
import { ref } from 'vue'
const emit = defineEmits(['enrolled'])
const step = ref('setup') // 'setup' | 'verify' | 'backup-codes'
const qrUri = ref('')
const secret = ref('')
const verifyCode = ref('')
const backupCodes = ref([])
async function startSetup() {
const data = await api.totpSetup()
qrUri.value = data.provisioning_uri
secret.value = data.secret
step.value = 'verify'
}
async function confirmEnrollment() {
const data = await api.totpEnable(verifyCode.value)
backupCodes.value = data.backup_codes
step.value = 'backup-codes'
}
</script>
```
---
### `frontend/src/components/auth/BackupCodesDisplay.vue` (component, request-response)
**Analog:** `frontend/src/components/upload/UploadProgress.vue` (display list + action)
```vue
<script setup>
const props = defineProps({ codes: Array })
const emit = defineEmits(['acknowledged'])
const acknowledged = ref(false)
function copyAll() {
navigator.clipboard.writeText(props.codes.join('\n'))
}
</script>
```
---
### `frontend/src/components/auth/PasswordStrengthBar.vue` (component, transform)
**Analog:** `frontend/src/components/topics/TopicBadge.vue` (display-only, prop-driven)
```vue
<script setup>
const props = defineProps({ password: String })
// Compute strength: 0-4 based on length, uppercase, numbers, symbols
const strength = computed(() => {
let score = 0
if (props.password.length >= 8) score++
if (/[A-Z]/.test(props.password)) score++
if (/[0-9]/.test(props.password)) score++
if (/[^A-Za-z0-9]/.test(props.password)) score++
return score
})
</script>
```
Render 4 colored bar segments, changing color from red → yellow → green as score increases. No emits.
---
### `frontend/src/components/ui/AppSpinner.vue` (component, transform)
**Analog:** `frontend/src/components/topics/TopicBadge.vue` (minimal display-only component)
```vue
<template>
<svg class="animate-spin h-5 w-5 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</template>
```
No props, no emits, no script block needed.
---
### `frontend/src/components/ui/ConfirmBlock.vue` (component, event-driven)
**Analog:** `frontend/src/components/upload/DropZone.vue` (emit-based interaction)
```vue
<script setup>
const props = defineProps({ message: String, confirmLabel: { type: String, default: 'Confirm' } })
const emit = defineEmits(['confirmed', 'cancelled'])
const confirmed = ref(false)
</script>
```
Renders a warning text + checkbox ("I understand") + confirm button that emits `confirmed` when clicked, `cancelled` on dismiss.
---
### `frontend/src/components/admin/AdminUsersTab.vue` (component, CRUD)
**Analog:** `frontend/src/stores/documents.js` + `frontend/src/views/HomeView.vue` (list + action pattern)
```vue
<script setup>
import { ref, onMounted } from 'vue'
import * as api from '../../../api/client.js'
const users = ref([])
const loading = ref(false)
onMounted(async () => {
loading.value = true
users.value = (await api.adminListUsers()).items
loading.value = false
})
async function deactivate(id) {
await api.adminDeactivateUser(id)
users.value = users.value.map(u => u.id === id ? { ...u, is_active: false } : u)
}
</script>
```
---
### `frontend/src/components/admin/AdminAiConfigTab.vue` (component, request-response)
**Analog:** `frontend/src/views/SettingsView.vue` (exact — same AI provider config UI pattern)
This tab renders the same provider-switcher and per-provider config fields as SettingsView.vue but targets `PATCH /api/admin/users/{id}/ai-config` instead of the global settings endpoint.
---
### `frontend/src/components/layout/AppSidebar.vue` (component, modify in-place)
**Analog:** `frontend/src/components/layout/AppSidebar.vue` (current, lines 188)
**Admin link — add before the Settings link section** (after line 55):
```vue
<router-link
v-if="authStore.user?.role === 'admin'"
to="/admin"
class="nav-link"
:class="{ 'nav-link-active': $route.path === '/admin' }"
>
<!-- shield icon -->
Admin
</router-link>
```
**User info / logout — add to bottom section** (alongside Settings link):
```vue
<div v-if="authStore.user" class="px-3 py-1 text-xs text-gray-500 truncate">
{{ authStore.user.handle }}
</div>
<button @click="authStore.logout()" class="nav-link w-full text-left text-red-500 hover:text-red-700">
Sign out
</button>
```
**Script block extension** (lines 7578):
```vue
<script setup>
import { useTopicsStore } from '../../stores/topics.js'
import { useAuthStore } from '../../stores/auth.js' // add
const topicsStore = useTopicsStore()
const authStore = useAuthStore() // add
</script>
```
---
## Shared Patterns
### FastAPI Dependency Injection
**Source:** `backend/deps/db.py` (lines 2026)
**Apply to:** All new backend route handlers in `api/auth.py` and `api/admin.py`
```python
from fastapi import Depends
from deps.db import get_db
from deps.auth import get_current_user, get_current_admin
from sqlalchemy.ext.asyncio import AsyncSession
@router.get("/me")
async def get_me(
session: AsyncSession = Depends(get_db),
current_user=Depends(get_current_user),
):
...
```
### Error Handling — Backend
**Source:** `backend/api/documents.py` (lines 110116) + `backend/api/settings.py` (lines 3638)
**Apply to:** All handlers in `api/auth.py` and `api/admin.py`
```python
try:
result = await some_service.operation(session, ...)
except ValueError as e:
raise HTTPException(400, str(e))
# ValueError from auth → 400 or 401; let other exceptions surface as 500
```
### Pinia Store Error Pattern
**Source:** `frontend/src/stores/documents.js` (lines 1223)
**Apply to:** `frontend/src/stores/auth.js` and all admin API calls
```javascript
loading.value = true
error.value = null
try {
const data = await api.someCall()
// update state
} catch (e) {
error.value = e.message
throw e // re-throw so calling component can handle (show TOTP step, etc.)
} finally {
loading.value = false
}
```
### Celery Task — No Top-Level Config Imports
**Source:** `backend/celery_app.py` (lines 111) + `backend/tasks/document_tasks.py` (lines 3439)
**Apply to:** `backend/tasks/email_tasks.py`
```python
# WRONG — do not do this at module level:
# from config import settings ← triggers pydantic side effects in worker process
# CORRECT — deferred import inside async body:
async def _run_send_reset(to_address, reset_link):
from services.email import send_password_reset_email
from config import settings
...
```
### Tailwind CSS Utility Vocabulary
**Source:** `frontend/src/components/layout/AppSidebar.vue`, `frontend/src/views/SettingsView.vue`
**Apply to:** All new Vue components
- Container: `p-8 max-w-md mx-auto` (auth forms), `max-w-4xl` (admin)
- Card: `bg-white border border-gray-200 rounded-xl p-6`
- Input: `w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400`
- Primary button: `px-6 py-2.5 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-50`
- Secondary button: `text-sm px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors`
- Nav link: `.nav-link` scoped class (AppSidebar.vue lines 8186)
- Error text: `text-sm text-red-500`
- Success text: `text-sm text-green-600`
### Test — Async Client Override
**Source:** `backend/tests/conftest.py` (lines 144155)
**Apply to:** New `backend/tests/test_auth.py` and `backend/tests/test_admin.py`
```python
@pytest_asyncio.fixture
async def async_client(db_session: AsyncSession):
from deps.db import get_db
from main import app
app.dependency_overrides[get_db] = lambda: db_session
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
app.dependency_overrides.clear()
```
**Auth test helper pattern** — override `get_current_user` for protected endpoint tests:
```python
from deps.auth import get_current_user
async def make_authed_client(db_session, user_obj):
app.dependency_overrides[get_db] = lambda: db_session
app.dependency_overrides[get_current_user] = lambda: user_obj
async with AsyncClient(...) as c:
yield c
app.dependency_overrides.clear()
```
---
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `backend/services/auth.py` (PyJWT + pwdlib + pyotp logic) | service | CRUD | No auth service exists; JWT/TOTP are new to codebase — use RESEARCH.md + REQUIREMENTS.md patterns |
| `frontend/src/views/auth/PasswordResetView.vue` + `NewPasswordView.vue` | component | request-response | Password reset flow is new; follow LoginView.vue structural pattern once that file is written |
---
## Metadata
**Analog search scope:** `backend/` (all Python modules), `frontend/src/` (all Vue/JS files)
**Files scanned:** 27
**Pattern extraction date:** 2026-05-22