diff --git a/.planning/STATE.md b/.planning/STATE.md index 7c8ae9e..a0cc91a 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -4,13 +4,13 @@ milestone: v1.0 milestone_name: milestone current_phase: 2 status: in_progress -last_updated: "2026-05-22T19:30:00.000Z" +last_updated: "2026-05-22T17:55:55Z" progress: total_phases: 5 completed_phases: 1 total_plans: 10 - completed_plans: 7 - percent: 28 + completed_plans: 8 + percent: 30 --- # Project State @@ -25,7 +25,7 @@ progress: | Phase | Name | Status | |---|---|---| | 1 | Infrastructure Foundation | ✓ Complete | -| 2 | Users & Authentication | In Progress (2/5 plans complete) | +| 2 | Users & Authentication | In Progress (3/5 plans complete) | | 3 | Document Migration & Multi-User Isolation | Not Started | | 4 | Folders, Sharing, Quotas & Document UX | Not Started | | 5 | Cloud Storage Backends | Not Started | @@ -33,8 +33,8 @@ progress: ## Current Position **Phase:** 02-users-authentication — In Progress -**Plan:** 2/5 complete (Plan 02: Auth API endpoints + frontend auth wall) -**Progress:** ███░░░░░░░ 28% (1/5 phases + 2/5 Phase 2 plans) +**Plan:** 3/5 complete (Plan 03: TOTP enrollment + password reset + account management UI) +**Progress:** ███░░░░░░░ 30% (1/5 phases + 3/5 Phase 2 plans) ## Performance Metrics @@ -43,7 +43,7 @@ progress: | Phases complete | 1 / 5 | | Requirements mapped | 54 / 54 | | Plans written | 5 (Phase 1) | -| Plans complete | 7 (5 Phase 1 + 2 Phase 2) | +| Plans complete | 8 (5 Phase 1 + 3 Phase 2) | ## Accumulated Context @@ -76,6 +76,9 @@ progress: | STORE-02 key enforced in code | MinIOBackend.put_object constructs {user_id}/{document_id}/{uuid4()}{ext}; no filename parameter — only extension passes through | | null-user D-03 sentinel | services/storage.save_upload uses user_id="null-user" in Phase 1 (no auth); Phase 2 replaces with str(current_user.id) | | load_settings flat-file Phase 1 | users.ai_provider/ai_model columns cannot be populated until Phase 2; settings remain flat-file JSON for Phase 1 | +| Deferred Celery import in /password-reset | send_reset_email.delay called via from tasks.email_tasks import send_reset_email inside handler body — same circular-import fix as document_tasks | +| TOTP QR code as otpauth:// link | No QR library installed; plan permits manual secret display for MVP; functional flow complete without rendered QR image | +| ConfirmBlock no acknowledgment checkbox | ConfirmBlock handles message + button pair; BackupCodesDisplay owns its separate acknowledgment checkbox — no overlap | ### Open Questions @@ -94,6 +97,6 @@ _Updated at each phase transition._ | Field | Value | |---|---| -| Last session | 2026-05-22 — Executed Phase 2 Plan 02 (auth API endpoints + frontend auth wall) | -| Next action | Run `/gsd:execute-phase 2` to continue Phase 2 (Plan 03: admin endpoints) | +| Last session | 2026-05-22 — Executed Phase 2 Plan 03 (TOTP enrollment + password reset + account management UI) | +| Next action | Run `/gsd:execute-phase 2` to continue Phase 2 (Plan 04: admin endpoints) | | Pending decisions | See Open Questions above | diff --git a/.planning/phases/02-users-authentication/02-03-SUMMARY.md b/.planning/phases/02-users-authentication/02-03-SUMMARY.md new file mode 100644 index 0000000..0d167d1 --- /dev/null +++ b/.planning/phases/02-users-authentication/02-03-SUMMARY.md @@ -0,0 +1,200 @@ +--- +phase: 02-users-authentication +plan: 03 +subsystem: auth +tags: [totp, 2fa, backup-codes, password-reset, rate-limiting, redis-replay-prevention, vue3, pyotp, celery, fastapi] + +# Dependency graph +requires: + - phase: 02-users-authentication + plan: 01 + provides: "services/auth.py: provision_totp, verify_totp, generate_backup_codes, store_backup_codes, verify_backup_code, create_password_reset_token, decode_password_reset_token, revoke_all_refresh_tokens" + - phase: 02-users-authentication + plan: 02 + provides: "backend/api/auth.py (router + limiter), frontend api/client.js stubs (totpSetup, totpEnable, totpDisable, passwordResetRequest, passwordResetConfirm), app.state.redis wiring" +provides: + - "backend/api/auth.py: GET /api/auth/totp/setup, POST /api/auth/totp/enable (rate-limited 10/min), DELETE /api/auth/totp, POST /api/auth/password-reset (rate-limited 5/hr), POST /api/auth/password-reset/confirm" + - "backend/config.py: frontend_url setting for password reset link construction" + - "frontend/src/components/auth/TotpEnrollment.vue: three-step enrollment (setup → verify → backup-codes), emits enrolled" + - "frontend/src/components/auth/BackupCodesDisplay.vue: 2-column grid, copy-all clipboard, acknowledgment checkbox" + - "frontend/src/components/ui/ConfirmBlock.vue: inline confirm/cancel block for destructive actions" + - "frontend/src/views/AccountView.vue: updated with TOTP enrollment section and disable flow" + - "backend/tests/test_auth_totp.py: 11 tests covering all new endpoints" +affects: [02-04, 02-05, 03-user-document-isolation] + +# Tech tracking +tech-stack: + added: [] # No new packages; all dependencies established in Plans 01/02 + patterns: + - "TOTP endpoints append to existing api/auth.py router (no new router file)" + - "Rate limiting: @limiter.limit('10/minute') on /totp/enable, @limiter.limit('5/hour') on /password-reset" + - "Deferred import for send_reset_email.delay inside password-reset endpoint (avoids circular import)" + - "Anti-enumeration: /password-reset always returns 202 regardless of email existence (T-02-22)" + - "No auto-login after reset: /password-reset/confirm returns 200 + message, no tokens (AUTH-05, T-02-21)" + - "TOTP replay: Redis key totp_used:{user_id}:{code} TTL=90s (AUTH-08) — inherited from services/auth.py" + - "BackupCodesDisplay: acknowledged checkbox gates the 'Enable 2FA' CTA" + - "TotpEnrollment: three ref values (step, qrUri, secret) drive conditional template rendering" + - "ConfirmBlock: generic reusable inline confirm/cancel with confirmLabel, cancelLabel, confirmClass props" + +key-files: + created: + - "backend/tests/test_auth_totp.py — 11 tests: TOTP setup, rate limit, password reset (202 anti-enum), confirm (no auto-login), logout-all" + - "frontend/src/components/auth/TotpEnrollment.vue — three-step TOTP enrollment component" + - "frontend/src/components/auth/BackupCodesDisplay.vue — backup codes grid + copy-all + acknowledge" + - "frontend/src/components/ui/ConfirmBlock.vue — reusable inline confirm block" + modified: + - "backend/api/auth.py — appended 5 new endpoints + TotpEnableRequest/PasswordResetRequest/PasswordResetConfirmRequest models" + - "backend/config.py — added frontend_url: str = 'http://localhost:5173'" + - "frontend/src/views/AccountView.vue — added TOTP section with TotpEnrollment + disable flow; improved changePassword error handling (breach vs wrong-pw distinction)" + +key-decisions: + - "Deferred import for Celery task in /password-reset endpoint — send_reset_email.delay called via from tasks.email_tasks import send_reset_email inside handler (matches document_tasks pattern)" + - "QR code rendered as otpauth:// link not as an image — QR library not installed; plan explicitly allows manual secret display for MVP" + - "ConfirmBlock does not include 'I understand' checkbox — plan spec says ConfirmBlock is for sign-out-all (message + buttons only); BackupCodesDisplay has its own acknowledgment checkbox" + - "AccountView changePassword error handling distinguishes 'Current password' errors (shown inline below field) from breach/strength errors (shown as form-level block)" + +patterns-established: + - "TOTP rate limit: @limiter.limit('10/minute') on POST endpoints; Request parameter added first in handler signature" + - "Anti-enumeration pattern: always return success status regardless of whether email/resource exists" + - "Multi-step Vue enrollment: step ref drives v-if/v-else-if template branches; data flows setup→verify→backup-codes→parent via emit" + +requirements-completed: [AUTH-03, AUTH-04, AUTH-05, AUTH-06, AUTH-07, AUTH-08, SEC-06] + +# Metrics +duration: ~6min +completed: 2026-05-22 +--- + +# Phase 2 Plan 03: TOTP Enrollment Flow, Password Reset, and Account Management Summary + +**TOTP enrollment (QR/secret → code verify with Redis replay prevention → 10 backup codes with acknowledgment), password reset (1-hour JWT, Celery dispatch, no auto-login), and account management UI delivered as a complete vertical slice** + +## Performance + +- **Duration:** ~6 min +- **Started:** 2026-05-22T17:49:51Z +- **Completed:** 2026-05-22T17:55:55Z +- **Tasks:** 2 (Task 1 TDD, Task 2 standard) +- **Files created:** 4, Files modified: 3 + +## Accomplishments + +- 5 new backend endpoints in `api/auth.py`: `GET /totp/setup`, `POST /totp/enable` (10/min rate limit, Redis replay prevention), `DELETE /totp`, `POST /password-reset` (5/hr, always-202, anti-enumeration), `POST /password-reset/confirm` (no auto-login) +- `frontend_url` added to `config.py` Settings for building password reset links +- `TotpEnrollment.vue`: three-step flow (setup → verify with code input → backup codes screen), emits `enrolled` to parent +- `BackupCodesDisplay.vue`: 2-column mono grid, copy-all clipboard button with 2s "Copied" flash, acknowledgment checkbox gates "Enable 2FA" CTA +- `ConfirmBlock.vue`: reusable inline confirmation block (props: message, confirmLabel, cancelLabel, confirmClass) +- `AccountView.vue`: TOTP section (show enrollment or disable), improved change-password error handling distinguishing wrong current password (field-level) from breach/strength errors (form-level block) +- 11 TDD tests all passing, full frontend build clean + +## Task Commits + +1. **Task 1 RED — test file:** `d7831e9` (test) +2. **Task 1 GREEN — implementation:** `43e1d01` (feat) +3. **Task 2 — frontend components + AccountView update:** `d73e2f6` (feat) + +## Files Created/Modified + +- `backend/api/auth.py` — 5 new endpoints + 3 request body models + `from sqlalchemy import delete` +- `backend/config.py` — `frontend_url: str = "http://localhost:5173"` added to Settings +- `backend/tests/test_auth_totp.py` — 11 tests: setup returns URI, already-enabled 400, requires-auth, 202 always for reset (non-existent and existing), confirm bad token 400, confirm weak pw 422, confirm valid no access_token, logout-all 200, logout-all requires auth, rate-limit 429 on 11th call +- `frontend/src/components/auth/TotpEnrollment.vue` — steps: setup / verify / backup-codes; emits `enrolled` +- `frontend/src/components/auth/BackupCodesDisplay.vue` — `grid grid-cols-2`, `navigator.clipboard.writeText`, acknowledgment checkbox +- `frontend/src/components/ui/ConfirmBlock.vue` — `confirmed`/`cancelled` emits, configurable label + class +- `frontend/src/views/AccountView.vue` — TOTP section, TotpEnrollment, disable-2FA ConfirmBlock, improved changePassword error handling + +## Decisions Made + +- **Deferred Celery import in endpoint:** `from tasks.email_tasks import send_reset_email` inside the handler body (not module-level) to avoid circular imports — same pattern as document_tasks +- **QR code as otpauth:// link:** No QR rendering library installed; plan explicitly permits manual secret display for MVP. Functional flow works without QR image +- **ConfirmBlock without checkbox:** The plan spec for ConfirmBlock describes message + two buttons only; BackupCodesDisplay has its own separate acknowledgment checkbox. No overlap +- **AccountView changePassword error handling:** Error messages containing "incorrect" or "current" → field-level inline error; breach/strength errors → form-level block per UI-SPEC placement rules + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Test Fix] Test email `nobody@nowhere.invalid` rejected by Pydantic EmailStr** +- **Found during:** Task 1 (GREEN phase first run) +- **Issue:** `pydantic-email-validator` rejects `.invalid` TLD as a reserved/special-use domain +- **Fix:** Changed to `nobody@example.com` (valid format, non-existent user in test DB) +- **Files modified:** backend/tests/test_auth_totp.py +- **Verification:** Test `test_password_reset_always_202_nonexistent` passes + +**2. [Rule 1 - Test Fix] Celery task mock target was wrong (`api.auth.send_reset_email` vs `tasks.email_tasks.send_reset_email`)** +- **Found during:** Task 1 (GREEN phase second run) +- **Issue:** `send_reset_email` is imported inside the handler with a deferred import (`from tasks.email_tasks import ...`), so the mock must target the task's source module, not `api.auth` +- **Fix:** Changed patch target to `tasks.email_tasks.send_reset_email`; also removed the unnecessary mock from `test_password_reset_confirm_valid_no_autologin` (confirm endpoint does not send email) +- **Files modified:** backend/tests/test_auth_totp.py +- **Verification:** Both tests pass + +**3. [Rule 1 - Test Fix] `test_logout_all_revokes_tokens` assertion checked for "revoked" in message but endpoint returns "Signed out of X session(s)"** +- **Found during:** Task 1 (GREEN phase third run) +- **Issue:** The logout-all endpoint was created in Plan 02 with message format "Signed out of X session(s)" — the test expected "revoked" +- **Fix:** Updated assertion to `assert "session" in message or "revoked" in message` +- **Files modified:** backend/tests/test_auth_totp.py +- **Verification:** Test passes + +--- + +**Total deviations:** 3 auto-fixed (all Rule 1 — test precision fixes) +**Impact on plan:** All three fixes corrected test assertions to match actual behavior. No implementation changes needed. No scope creep. + +## Issues Encountered + +None — implementation proceeded cleanly. The deviations were all test-level assertion corrections. + +## Known Stubs + +- `TotpEnrollment.vue` QR code: rendered as an `otpauth://` link ("Open in authenticator app") rather than an actual QR image. The plan explicitly permits this for MVP: "for MVP, render manual secret only — the functional flow works without it." The manual secret is prominently displayed with a copy button. QR image rendering is a visual polish item. + +## Threat Flags + +All STRIDE mitigations from the threat model are implemented: +- T-02-17: TOTP replay — `verify_totp()` in `services/auth.py` sets Redis key with 90s TTL +- T-02-18: Backup code single-use — `BackupCode.used_at` set on first use (from Plan 01) +- T-02-19: Backup codes shown once only — endpoint returns plaintext once; DB stores Argon2 hashes +- T-02-20: Reset token type check — `decode_password_reset_token` validates `typ="password-reset"` +- T-02-21: No auto-login — confirm endpoint returns 200 + message, no tokens +- T-02-22: Anti-enumeration — `/password-reset` always returns 202 +- T-02-24: Sign-out-all confirmation — ConfirmBlock requires explicit click +- T-02-25: TOTP brute force — `@limiter.limit("10/minute")` on `/totp/enable` + +No new threat surface beyond what was planned. + +## Next Phase Readiness + +- TOTP enrollment flow is complete end-to-end: backend endpoints + frontend UI +- Password reset flow is complete: Celery email dispatch + token validation + no auto-login +- `AccountView.vue` is fully functional: TOTP management + change password + sign-out-all +- Plans 02-04 (admin endpoints) and 02-05 can proceed independently +- All 35 auth tests passing (11 new + 17 Plan 02 + 7 Plan 01 auth deps) + +--- + +## Self-Check: PASSED + +**Files verified:** + +- backend/api/auth.py — FOUND, contains `/api/auth/totp/setup` route +- backend/config.py — FOUND, contains `frontend_url` +- backend/tests/test_auth_totp.py — FOUND (11 tests passing) +- frontend/src/components/auth/TotpEnrollment.vue — FOUND +- frontend/src/components/auth/BackupCodesDisplay.vue — FOUND +- frontend/src/components/ui/ConfirmBlock.vue — FOUND +- frontend/src/views/AccountView.vue — FOUND, contains `changePassword` and `Sign out all devices` +- frontend/src/views/auth/PasswordResetView.vue — FOUND, contains "If an account exists" +- frontend/src/views/auth/NewPasswordView.vue — FOUND, no `accessToken` reference + +**Commits verified:** + +- d7831e9 (test: RED phase — test_auth_totp.py) — FOUND +- 43e1d01 (feat: GREEN phase — TOTP endpoints + config.py frontend_url) — FOUND +- d73e2f6 (feat: frontend TOTP components + AccountView update) — FOUND + +**Test results:** 11 passed (test_auth_totp.py), 35 passed (all auth tests combined) +**Build result:** npm run build exits 0, 57 modules transformed + +--- +*Phase: 02-users-authentication* +*Completed: 2026-05-22*