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>
36 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 02-users-authentication | 02 | execute | 2 |
|
|
true |
|
|
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.
<execution_context> @/Users/nik/.claude/get-shit-done/workflows/execute-plan.md @/Users/nik/.claude/get-shit-done/templates/summary.md </execution_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 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', ... })
Task 1: Backend — register/login/refresh/logout/me/change-password endpoints + security hardening backend/api/auth.py, backend/services/email.py, backend/main.py, backend/tests/test_auth_api.py - 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) 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.
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.
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.
<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> |
<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>