docs(06): create phase plan
Phase 06: Backend Authentication - 3 plan(s) in 2 wave(s) - Wave 1: 06-01 (login/logout foundation) - Wave 2: 06-02 (password reset), 06-03 (RBAC + 2FA) - parallel - Ready for execution
This commit is contained in:
@@ -115,12 +115,12 @@ Plans:
|
|||||||
3. Admin can reset password via email link
|
3. Admin can reset password via email link
|
||||||
4. Roles can be created with specific permissions attached
|
4. Roles can be created with specific permissions attached
|
||||||
5. Controllers and views check permissions before rendering protected content
|
5. Controllers and views check permissions before rendering protected content
|
||||||
**Plans**: TBD
|
**Plans**: 3 plans
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] 06-01: Backend user model and login/logout
|
- [ ] 06-01-PLAN.md - Admin user model, login/logout, sessions with JWT + DB hybrid
|
||||||
- [ ] 06-02: Password reset flow
|
- [ ] 06-02-PLAN.md - Password reset flow with secure tokens and email
|
||||||
- [ ] 06-03: RBAC permissions system
|
- [ ] 06-03-PLAN.md - RBAC permissions system with plugin registry and optional 2FA
|
||||||
|
|
||||||
### Phase 7: Admin Forms & Lists
|
### Phase 7: Admin Forms & Lists
|
||||||
**Goal**: Generate forms and lists from YAML definitions
|
**Goal**: Generate forms and lists from YAML definitions
|
||||||
@@ -211,7 +211,7 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10
|
|||||||
| 3. Component System | 0/2 | Planned | - |
|
| 3. Component System | 0/2 | Planned | - |
|
||||||
| 4. Theme Engine | 0/2 | Planned | - |
|
| 4. Theme Engine | 0/2 | Planned | - |
|
||||||
| 5. CLI Scaffolding | 0/2 | Planned | - |
|
| 5. CLI Scaffolding | 0/2 | Planned | - |
|
||||||
| 6. Backend Authentication | 0/3 | Not started | - |
|
| 6. Backend Authentication | 0/3 | Planned | - |
|
||||||
| 7. Admin Forms & Lists | 0/3 | Not started | - |
|
| 7. Admin Forms & Lists | 0/3 | Not started | - |
|
||||||
| 8. Admin Dashboard | 0/2 | Not started | - |
|
| 8. Admin Dashboard | 0/2 | Not started | - |
|
||||||
| 9. Content Management | 0/5 | Not started | - |
|
| 9. Content Management | 0/5 | Not started | - |
|
||||||
|
|||||||
300
.planning/phases/06-backend-authentication/06-01-PLAN.md
Normal file
300
.planning/phases/06-backend-authentication/06-01-PLAN.md
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
---
|
||||||
|
phase: 06-backend-authentication
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- summercms/resources/db/migration/V6__backend_auth.sql
|
||||||
|
- summercms/src/auth/AdminUser.scala
|
||||||
|
- summercms/src/auth/Role.scala
|
||||||
|
- summercms/src/auth/AdminSession.scala
|
||||||
|
- summercms/src/auth/LoginAttempt.scala
|
||||||
|
- summercms/src/auth/PasswordService.scala
|
||||||
|
- summercms/src/auth/SessionService.scala
|
||||||
|
- summercms/src/auth/LoginService.scala
|
||||||
|
- summercms/src/auth/AuthError.scala
|
||||||
|
- summercms/src/auth/middleware/AuthMiddleware.scala
|
||||||
|
- summercms/src/auth/routes/LoginRoutes.scala
|
||||||
|
- summercms/src/api/Routes.scala
|
||||||
|
- build.mill
|
||||||
|
autonomous: true
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Admin can log in with valid email/password and receive a session token"
|
||||||
|
- "Admin receives generic error message on invalid credentials (no hints about which field is wrong)"
|
||||||
|
- "Admin can log out and the session is revoked"
|
||||||
|
- "Progressive lockout prevents brute force (10 min after 5 failures, etc.)"
|
||||||
|
- "Sessions can be listed and revoked individually"
|
||||||
|
artifacts:
|
||||||
|
- path: "summercms/resources/db/migration/V6__backend_auth.sql"
|
||||||
|
provides: "Database schema for admin_users, roles, admin_sessions, login_attempts"
|
||||||
|
contains: "CREATE TABLE admin_users"
|
||||||
|
- path: "summercms/src/auth/PasswordService.scala"
|
||||||
|
provides: "Argon2id password hashing"
|
||||||
|
exports: ["PasswordService", "hash", "verify"]
|
||||||
|
- path: "summercms/src/auth/SessionService.scala"
|
||||||
|
provides: "JWT + DB hybrid session management"
|
||||||
|
exports: ["SessionService", "createSession", "validateToken", "revokeSession"]
|
||||||
|
- path: "summercms/src/auth/LoginService.scala"
|
||||||
|
provides: "Login logic with progressive lockout"
|
||||||
|
exports: ["LoginService", "authenticate"]
|
||||||
|
- path: "summercms/src/auth/routes/LoginRoutes.scala"
|
||||||
|
provides: "POST /admin/auth/login, POST /admin/auth/logout, GET/DELETE /admin/auth/sessions"
|
||||||
|
min_lines: 50
|
||||||
|
key_links:
|
||||||
|
- from: "LoginRoutes.scala"
|
||||||
|
to: "LoginService"
|
||||||
|
via: "ZIO service dependency"
|
||||||
|
pattern: "ZIO\\.service\\[LoginService\\]"
|
||||||
|
- from: "LoginService"
|
||||||
|
to: "PasswordService"
|
||||||
|
via: "password verification"
|
||||||
|
pattern: "passwordService\\.verify"
|
||||||
|
- from: "SessionService"
|
||||||
|
to: "admin_sessions table"
|
||||||
|
via: "Quill queries"
|
||||||
|
pattern: "querySchema\\[AdminSession\\]"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Create the foundation for admin backend authentication with login/logout functionality.
|
||||||
|
|
||||||
|
Purpose: Enable admin users to authenticate securely with JWT sessions backed by database for revocation support. This is the prerequisite for all protected admin functionality.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- Database migration with admin_users, roles, admin_sessions, login_attempts tables
|
||||||
|
- PasswordService with Argon2id hashing
|
||||||
|
- SessionService with JWT + DB hybrid for revocation support
|
||||||
|
- LoginService with progressive lockout protection
|
||||||
|
- Login/logout HTTP endpoints with auth middleware
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@/home/jin/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/06-backend-authentication/06-CONTEXT.md
|
||||||
|
@.planning/phases/06-backend-authentication/06-RESEARCH.md
|
||||||
|
|
||||||
|
# Existing codebase patterns
|
||||||
|
@summercms/src/repository/UserRepository.scala
|
||||||
|
@summercms/src/repository/RepositoryError.scala
|
||||||
|
@summercms/src/db/QuillContext.scala
|
||||||
|
@summercms/src/api/Routes.scala
|
||||||
|
@build.mill
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Create database migration and domain models</name>
|
||||||
|
<files>
|
||||||
|
summercms/resources/db/migration/V6__backend_auth.sql
|
||||||
|
summercms/src/auth/AdminUser.scala
|
||||||
|
summercms/src/auth/Role.scala
|
||||||
|
summercms/src/auth/AdminSession.scala
|
||||||
|
summercms/src/auth/LoginAttempt.scala
|
||||||
|
summercms/src/auth/AuthError.scala
|
||||||
|
build.mill
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. Add new dependencies to build.mill:
|
||||||
|
- mvn"com.password4j:password4j:1.8.4" (Argon2id hashing)
|
||||||
|
- mvn"com.github.jwt-scala::jwt-zio-json:11.0.3" (JWT with ZIO JSON)
|
||||||
|
|
||||||
|
2. Create V6__backend_auth.sql migration with tables:
|
||||||
|
- roles (id, code, name, permissions TEXT[], is_system, created_at)
|
||||||
|
- admin_users (id, email, password_hash, first_name, last_name, role_id FK, totp_secret, totp_enabled, recovery_codes TEXT[], created_at, updated_at, last_login_at)
|
||||||
|
- admin_sessions (id VARCHAR(64) PK, user_id FK, created_at, expires_at, ip_address INET, user_agent, revoked, revoked_at)
|
||||||
|
- login_attempts (id, email, ip_address INET, attempted_at, successful)
|
||||||
|
- Insert default Super Admin role with permissions = '{"*"}'
|
||||||
|
- Add indexes on admin_sessions(user_id), admin_sessions(expires_at), login_attempts(email, attempted_at)
|
||||||
|
|
||||||
|
3. Create domain models in summercms/src/auth/:
|
||||||
|
- AdminUser case class mapping to admin_users table (use Instant for timestamps)
|
||||||
|
- Role case class mapping to roles table
|
||||||
|
- AdminSession case class mapping to admin_sessions table
|
||||||
|
- LoginAttempt case class mapping to login_attempts table
|
||||||
|
|
||||||
|
4. Create AuthError sealed trait ADT:
|
||||||
|
- InvalidCredentials (generic - no hints)
|
||||||
|
- AccountLocked(until: Instant)
|
||||||
|
- TotpRequired
|
||||||
|
- InvalidTotp
|
||||||
|
- SessionExpired
|
||||||
|
- SessionRevoked
|
||||||
|
- InvalidToken(reason: String)
|
||||||
|
|
||||||
|
Follow existing patterns from RepositoryError.scala and model/User.scala.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
- ./mill summercms.compile succeeds
|
||||||
|
- Migration file exists at summercms/resources/db/migration/V6__backend_auth.sql
|
||||||
|
- All case classes compile with proper Instant types
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
Database schema defined for auth tables, domain models created matching schema, build compiles with new dependencies
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Create PasswordService, SessionService, and LoginService</name>
|
||||||
|
<files>
|
||||||
|
summercms/src/auth/PasswordService.scala
|
||||||
|
summercms/src/auth/SessionService.scala
|
||||||
|
summercms/src/auth/LoginService.scala
|
||||||
|
summercms/src/auth/repository/AdminUserRepository.scala
|
||||||
|
summercms/src/auth/repository/AdminSessionRepository.scala
|
||||||
|
summercms/src/auth/repository/LoginAttemptRepository.scala
|
||||||
|
summercms/src/config/AppConfig.scala
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. Create PasswordService (ZIO service pattern):
|
||||||
|
- trait PasswordService with hash(password: String): Task[String] and verify(password: String, hash: String): Task[Boolean]
|
||||||
|
- Implementation using Password4j with Argon2id (default settings are OWASP compliant)
|
||||||
|
- Use ZIO.attemptBlocking for Password4j calls
|
||||||
|
|
||||||
|
2. Create repository traits following existing UserRepository pattern:
|
||||||
|
- AdminUserRepository: findById, findByEmail, create, update
|
||||||
|
- AdminSessionRepository: create, findById, findByUserId, revoke, revokeAllForUser
|
||||||
|
- LoginAttemptRepository: recordAttempt, countRecentFailures(email, ip, since)
|
||||||
|
All should use RepositoryError for errors.
|
||||||
|
|
||||||
|
3. Add auth config to AppConfig.scala:
|
||||||
|
- jwtSecret: String (env: JWT_SECRET, default: generate-a-secure-secret-in-production)
|
||||||
|
- sessionDuration: Duration (default: 24 hours)
|
||||||
|
- rememberMeDuration: Duration (default: 30 days)
|
||||||
|
|
||||||
|
4. Create SessionService (ZIO service pattern):
|
||||||
|
- createSession(userId, role, rememberMe, ip, userAgent): Task[String] - returns JWT token
|
||||||
|
- validateToken(token: String): IO[AuthError, SessionClaims]
|
||||||
|
- revokeSession(sessionId: String): Task[Unit]
|
||||||
|
- listSessions(userId: Long): Task[List[AdminSession]]
|
||||||
|
- revokeAllSessions(userId: Long): Task[Unit]
|
||||||
|
- Use jwt-zio-json for JWT encoding/decoding with HS256
|
||||||
|
- Store session in DB and include sessionId in JWT claims
|
||||||
|
- On validate: decode JWT, check expiry, check DB for revocation
|
||||||
|
|
||||||
|
5. Create LoginService (ZIO service pattern):
|
||||||
|
- authenticate(email, password, totpCode: Option, ip): IO[AuthError, AdminUser]
|
||||||
|
- Progressive lockout thresholds (per RESEARCH.md):
|
||||||
|
- 5 failures: 10 min lockout
|
||||||
|
- 10 failures: 20 min lockout
|
||||||
|
- 15 failures: 1 hour lockout
|
||||||
|
- 20 failures: 24 hour lockout
|
||||||
|
- IMPORTANT: Always call passwordService.verify even for non-existent users (timing attack prevention)
|
||||||
|
- Record all login attempts (success and failure)
|
||||||
|
- Return generic InvalidCredentials error for both wrong email and wrong password
|
||||||
|
- For now, skip TOTP validation (will be added in plan 06-03)
|
||||||
|
|
||||||
|
Use ZLayer pattern like existing services. Follow _root_.config import pattern to avoid shadowing.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
- ./mill summercms.compile succeeds
|
||||||
|
- PasswordService.hash returns Argon2id hash (starts with $argon2)
|
||||||
|
- SessionService.createSession returns a JWT string
|
||||||
|
- LoginService has lockout thresholds defined
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
PasswordService hashes with Argon2id, SessionService creates/validates JWT tokens with DB backing, LoginService implements progressive lockout, all repositories created
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Create auth routes and middleware</name>
|
||||||
|
<files>
|
||||||
|
summercms/src/auth/middleware/AuthMiddleware.scala
|
||||||
|
summercms/src/auth/routes/LoginRoutes.scala
|
||||||
|
summercms/src/api/Routes.scala
|
||||||
|
summercms/src/Main.scala
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. Create AuthMiddleware.scala:
|
||||||
|
- Extract JWT from Authorization: Bearer header OR __Host-SID cookie
|
||||||
|
- Call SessionService.validateToken
|
||||||
|
- On success, make SessionClaims available to handlers
|
||||||
|
- On failure, return 401 Unauthorized with JSON error
|
||||||
|
|
||||||
|
2. Create LoginRoutes.scala with endpoints:
|
||||||
|
- POST /admin/auth/login
|
||||||
|
- Body: { email, password, totpCode?, rememberMe? }
|
||||||
|
- On success: Return { token, user: { id, email, firstName, lastName } }
|
||||||
|
- Set __Host-SID cookie (httpOnly, secure, sameSite=Strict, path=/admin)
|
||||||
|
- On lockout: Return 429 with Retry-After header
|
||||||
|
- On invalid: Return 401 with generic "Invalid email or password"
|
||||||
|
|
||||||
|
- POST /admin/auth/logout (requires auth)
|
||||||
|
- Revoke current session
|
||||||
|
- Clear __Host-SID cookie
|
||||||
|
- Return 200 OK
|
||||||
|
|
||||||
|
- GET /admin/auth/sessions (requires auth)
|
||||||
|
- List all sessions for current user
|
||||||
|
- Return array with id, createdAt, expiresAt, ipAddress, userAgent, current: boolean
|
||||||
|
|
||||||
|
- DELETE /admin/auth/sessions/:id (requires auth)
|
||||||
|
- Revoke specific session by ID
|
||||||
|
- Only allow revoking own sessions
|
||||||
|
- Return 204 No Content
|
||||||
|
|
||||||
|
3. Update Routes.scala to compose LoginRoutes (public) and protected routes (with AuthMiddleware)
|
||||||
|
|
||||||
|
4. Update Main.scala to provide all new service layers:
|
||||||
|
- PasswordService.live
|
||||||
|
- SessionService.live (needs AdminSessionRepository, AuthConfig)
|
||||||
|
- LoginService.live (needs AdminUserRepository, LoginAttemptRepository, PasswordService)
|
||||||
|
- AdminUserRepository.live, AdminSessionRepository.live, LoginAttemptRepository.live
|
||||||
|
|
||||||
|
Use zio.json for JSON encoding/decoding. Follow existing HealthRoutes.scala pattern for route structure.
|
||||||
|
|
||||||
|
Cookie configuration (per RESEARCH.md):
|
||||||
|
- name: __Host-SID (prefix for additional security)
|
||||||
|
- httpOnly: true
|
||||||
|
- secure: true (in production, allow http in dev)
|
||||||
|
- sameSite: Strict
|
||||||
|
- path: /admin
|
||||||
|
- maxAge: 24 hours (or 30 days with rememberMe)
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
- ./mill summercms.compile succeeds
|
||||||
|
- curl -X POST localhost:8080/admin/auth/login with valid credentials returns token
|
||||||
|
- curl -X POST localhost:8080/admin/auth/logout with valid token returns 200
|
||||||
|
- curl -X GET localhost:8080/admin/auth/sessions with valid token returns session list
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
Login returns JWT token and sets cookie, logout revokes session, session listing works, auth middleware protects routes
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
After all tasks complete:
|
||||||
|
1. Run ./mill summercms.compile - should succeed
|
||||||
|
2. Run migrations against database
|
||||||
|
3. Create a test admin user manually in DB (with hashed password from PasswordService)
|
||||||
|
4. Test login flow: POST /admin/auth/login with credentials
|
||||||
|
5. Test session listing: GET /admin/auth/sessions with token
|
||||||
|
6. Test logout: POST /admin/auth/logout
|
||||||
|
7. Verify token no longer works after logout
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Admin can log in with valid email/password and receive JWT token
|
||||||
|
- Login returns generic error for invalid credentials (no hints)
|
||||||
|
- Logout revokes the session in database
|
||||||
|
- Revoked token returns 401 on subsequent requests
|
||||||
|
- Progressive lockout kicks in after 5 failed attempts
|
||||||
|
- Sessions can be listed and individually revoked
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/06-backend-authentication/06-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
279
.planning/phases/06-backend-authentication/06-02-PLAN.md
Normal file
279
.planning/phases/06-backend-authentication/06-02-PLAN.md
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
---
|
||||||
|
phase: 06-backend-authentication
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["06-01"]
|
||||||
|
files_modified:
|
||||||
|
- summercms/resources/db/migration/V7__password_reset.sql
|
||||||
|
- summercms/src/auth/PasswordResetToken.scala
|
||||||
|
- summercms/src/auth/PasswordResetService.scala
|
||||||
|
- summercms/src/auth/repository/PasswordResetRepository.scala
|
||||||
|
- summercms/src/auth/EmailService.scala
|
||||||
|
- summercms/src/auth/routes/PasswordResetRoutes.scala
|
||||||
|
- summercms/src/api/Routes.scala
|
||||||
|
- summercms/src/config/AppConfig.scala
|
||||||
|
- build.mill
|
||||||
|
autonomous: true
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "User can request password reset and receives email (if account exists)"
|
||||||
|
- "Request always returns success regardless of email existence (timing attack prevention)"
|
||||||
|
- "Reset link expires after 30 minutes"
|
||||||
|
- "After successful reset, redirect to login (no auto-login)"
|
||||||
|
- "All existing sessions are terminated when password changes"
|
||||||
|
artifacts:
|
||||||
|
- path: "summercms/src/auth/PasswordResetService.scala"
|
||||||
|
provides: "Password reset token generation and validation"
|
||||||
|
exports: ["PasswordResetService", "requestReset", "validateToken", "resetPassword"]
|
||||||
|
- path: "summercms/src/auth/EmailService.scala"
|
||||||
|
provides: "Email sending capability"
|
||||||
|
exports: ["EmailService", "sendPasswordReset"]
|
||||||
|
- path: "summercms/src/auth/routes/PasswordResetRoutes.scala"
|
||||||
|
provides: "POST /admin/auth/forgot-password, POST /admin/auth/reset-password"
|
||||||
|
min_lines: 40
|
||||||
|
key_links:
|
||||||
|
- from: "PasswordResetService"
|
||||||
|
to: "EmailService"
|
||||||
|
via: "sending reset email"
|
||||||
|
pattern: "emailService\\.sendPasswordReset"
|
||||||
|
- from: "PasswordResetService.resetPassword"
|
||||||
|
to: "SessionService.revokeAllSessions"
|
||||||
|
via: "terminate sessions on password change"
|
||||||
|
pattern: "sessionService\\.revokeAllSessions"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Implement password reset flow with secure token generation and email delivery.
|
||||||
|
|
||||||
|
Purpose: Allow admin users to recover account access when they forget their password, while maintaining security through time-limited tokens and session invalidation.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- Password reset token generation with SHA-256 hashing for storage
|
||||||
|
- Email sending service (wrapped JavaMail in ZIO)
|
||||||
|
- Password reset HTTP endpoints
|
||||||
|
- Automatic session termination on password change
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@/home/jin/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/phases/06-backend-authentication/06-CONTEXT.md
|
||||||
|
@.planning/phases/06-backend-authentication/06-RESEARCH.md
|
||||||
|
@.planning/phases/06-backend-authentication/06-01-SUMMARY.md
|
||||||
|
|
||||||
|
# Dependencies from 06-01
|
||||||
|
@summercms/src/auth/AdminUser.scala
|
||||||
|
@summercms/src/auth/PasswordService.scala
|
||||||
|
@summercms/src/auth/SessionService.scala
|
||||||
|
@summercms/src/auth/repository/AdminUserRepository.scala
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Create password reset token infrastructure</name>
|
||||||
|
<files>
|
||||||
|
summercms/resources/db/migration/V7__password_reset.sql
|
||||||
|
summercms/src/auth/PasswordResetToken.scala
|
||||||
|
summercms/src/auth/repository/PasswordResetRepository.scala
|
||||||
|
summercms/src/config/AppConfig.scala
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. Create V7__password_reset.sql migration:
|
||||||
|
- CREATE TABLE password_reset_tokens (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
token_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 hex
|
||||||
|
user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
ip_address INET,
|
||||||
|
used BOOLEAN DEFAULT false,
|
||||||
|
used_at TIMESTAMPTZ
|
||||||
|
)
|
||||||
|
- CREATE INDEX idx_password_reset_tokens_user_id ON password_reset_tokens(user_id)
|
||||||
|
- CREATE INDEX idx_password_reset_tokens_expires ON password_reset_tokens(expires_at)
|
||||||
|
|
||||||
|
2. Create PasswordResetToken case class:
|
||||||
|
- id: Long
|
||||||
|
- tokenHash: String
|
||||||
|
- userId: Long
|
||||||
|
- createdAt: Instant
|
||||||
|
- expiresAt: Instant
|
||||||
|
- ipAddress: Option[String]
|
||||||
|
- used: Boolean
|
||||||
|
- usedAt: Option[Instant]
|
||||||
|
|
||||||
|
3. Create PasswordResetRepository (following existing patterns):
|
||||||
|
- create(token: PasswordResetToken): IO[RepositoryError, PasswordResetToken]
|
||||||
|
- findByTokenHash(hash: String): IO[RepositoryError, Option[PasswordResetToken]]
|
||||||
|
- markUsed(id: Long): IO[RepositoryError, Unit]
|
||||||
|
- invalidateAllForUser(userId: Long): IO[RepositoryError, Unit]
|
||||||
|
Use Quill with querySchema mapping.
|
||||||
|
|
||||||
|
4. Add email config to AppConfig.scala:
|
||||||
|
- smtp.host: String (env: SMTP_HOST, default: localhost)
|
||||||
|
- smtp.port: Int (env: SMTP_PORT, default: 587)
|
||||||
|
- smtp.username: Option[String] (env: SMTP_USERNAME)
|
||||||
|
- smtp.password: Option[String] (env: SMTP_PASSWORD)
|
||||||
|
- smtp.from: String (env: SMTP_FROM, default: noreply@summercms.local)
|
||||||
|
- smtp.startTls: Boolean (env: SMTP_STARTTLS, default: true)
|
||||||
|
- passwordReset.tokenExpiry: Duration (default: 30 minutes)
|
||||||
|
- passwordReset.baseUrl: String (env: APP_BASE_URL, default: http://localhost:8080)
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
- ./mill summercms.compile succeeds
|
||||||
|
- Migration file exists at summercms/resources/db/migration/V7__password_reset.sql
|
||||||
|
- PasswordResetRepository compiles with proper Quill queries
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
Database table for password reset tokens created, domain model defined, repository with CRUD operations implemented
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Create EmailService and PasswordResetService</name>
|
||||||
|
<files>
|
||||||
|
summercms/src/auth/EmailService.scala
|
||||||
|
summercms/src/auth/PasswordResetService.scala
|
||||||
|
build.mill
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. Add JavaMail dependency to build.mill:
|
||||||
|
- mvn"com.sun.mail:jakarta.mail:2.0.1"
|
||||||
|
|
||||||
|
2. Create EmailService (ZIO service pattern):
|
||||||
|
- trait EmailService:
|
||||||
|
def sendPasswordReset(to: String, token: String, ipAddress: String, requestedAt: Instant): Task[Unit]
|
||||||
|
- Implementation wraps JavaMail in ZIO.attemptBlocking
|
||||||
|
- Email content (per CONTEXT.md - includes context about the request):
|
||||||
|
- Subject: "SummerCMS Password Reset Request"
|
||||||
|
- Body includes:
|
||||||
|
- Reset link: {baseUrl}/admin/auth/reset-password?token={token}
|
||||||
|
- IP address that requested reset
|
||||||
|
- Request timestamp
|
||||||
|
- "If you didn't request this, ignore this email"
|
||||||
|
- "This link expires in 30 minutes"
|
||||||
|
- Use HTML email format with simple styling
|
||||||
|
|
||||||
|
3. Create PasswordResetService (ZIO service pattern):
|
||||||
|
- requestReset(email: String, ipAddress: String): Task[Unit]
|
||||||
|
- ALWAYS return success (timing attack prevention)
|
||||||
|
- If user exists:
|
||||||
|
- Generate 256-bit random token using SecureRandom (64 chars URL-safe base64)
|
||||||
|
- Hash with SHA-256 before storing
|
||||||
|
- Invalidate any existing tokens for this user
|
||||||
|
- Store hashed token with 30-minute expiry
|
||||||
|
- Send email with plain token
|
||||||
|
- If user doesn't exist:
|
||||||
|
- Do nothing but take same amount of time (simulate DB write + email send)
|
||||||
|
|
||||||
|
- validateToken(token: String): IO[AuthError, Long]
|
||||||
|
- Hash the provided token
|
||||||
|
- Look up in database by hash
|
||||||
|
- Check expiry
|
||||||
|
- Check not already used
|
||||||
|
- Return userId if valid
|
||||||
|
|
||||||
|
- resetPassword(token: String, newPassword: String): Task[Unit]
|
||||||
|
- Validate token (get userId)
|
||||||
|
- Hash new password with PasswordService
|
||||||
|
- Update user's password_hash
|
||||||
|
- Mark token as used
|
||||||
|
- CRITICAL: Call SessionService.revokeAllSessions(userId) to terminate all sessions
|
||||||
|
- Return success
|
||||||
|
|
||||||
|
Token generation (per RESEARCH.md):
|
||||||
|
- Use java.security.SecureRandom for entropy
|
||||||
|
- 48 bytes = 64 URL-safe base64 characters
|
||||||
|
- Hash with SHA-256 before storage
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
- ./mill summercms.compile succeeds
|
||||||
|
- EmailService.sendPasswordReset can be called (logs in dev mode if SMTP not configured)
|
||||||
|
- PasswordResetService.requestReset completes for both existing and non-existing emails
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
EmailService sends password reset emails, PasswordResetService generates secure tokens and handles full reset flow including session termination
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Create password reset routes</name>
|
||||||
|
<files>
|
||||||
|
summercms/src/auth/routes/PasswordResetRoutes.scala
|
||||||
|
summercms/src/api/Routes.scala
|
||||||
|
summercms/src/Main.scala
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. Create PasswordResetRoutes.scala:
|
||||||
|
- POST /admin/auth/forgot-password (public)
|
||||||
|
- Body: { email: String }
|
||||||
|
- Always return 200 with { success: true, message: "If an account exists, a reset email has been sent" }
|
||||||
|
- Call PasswordResetService.requestReset in background (don't block response)
|
||||||
|
- Extract IP from request for logging
|
||||||
|
|
||||||
|
- GET /admin/auth/reset-password?token=xxx (public)
|
||||||
|
- Validate token (don't consume it)
|
||||||
|
- If valid: return 200 with { valid: true }
|
||||||
|
- If invalid/expired: return 400 with { valid: false, error: "Token invalid or expired" }
|
||||||
|
|
||||||
|
- POST /admin/auth/reset-password (public)
|
||||||
|
- Body: { token: String, password: String }
|
||||||
|
- Call PasswordResetService.resetPassword
|
||||||
|
- On success: return 200 with { success: true, message: "Password has been reset. Please log in." }
|
||||||
|
- On failure: return 400 with appropriate error
|
||||||
|
- Per CONTEXT.md: NO auto-login, redirect to login page (client-side)
|
||||||
|
|
||||||
|
2. Update Routes.scala to include PasswordResetRoutes
|
||||||
|
|
||||||
|
3. Update Main.scala to provide:
|
||||||
|
- EmailService.live (needs SmtpConfig)
|
||||||
|
- PasswordResetService.live (needs PasswordResetRepository, AdminUserRepository, PasswordService, SessionService, EmailService)
|
||||||
|
- PasswordResetRepository.live (needs Quill context)
|
||||||
|
|
||||||
|
Response format: Use zio.json for all JSON encoding.
|
||||||
|
All endpoints return JSON with consistent structure.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
- ./mill summercms.compile succeeds
|
||||||
|
- curl -X POST localhost:8080/admin/auth/forgot-password -d '{"email":"test@test.com"}' returns 200
|
||||||
|
- curl -X POST localhost:8080/admin/auth/reset-password with valid token returns 200
|
||||||
|
- After password reset, old sessions are revoked (test by trying to use old token)
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
Password reset flow complete: request generates email, token validation works, password update terminates all sessions
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
After all tasks complete:
|
||||||
|
1. Run ./mill summercms.compile - should succeed
|
||||||
|
2. Run migrations against database
|
||||||
|
3. Create a test admin user with known email
|
||||||
|
4. Test forgot password: POST /admin/auth/forgot-password
|
||||||
|
5. Check that email would be sent (verify in logs or mailhog)
|
||||||
|
6. Test token validation: GET /admin/auth/reset-password?token=xxx
|
||||||
|
7. Test password reset: POST /admin/auth/reset-password
|
||||||
|
8. Verify old session token no longer works after password change
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- POST /admin/auth/forgot-password always returns 200 (no email existence leak)
|
||||||
|
- Reset email contains IP address and timestamp of request
|
||||||
|
- Reset token expires after 30 minutes
|
||||||
|
- Password reset terminates all existing sessions
|
||||||
|
- After reset, user must log in again (no auto-login)
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/06-backend-authentication/06-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
334
.planning/phases/06-backend-authentication/06-03-PLAN.md
Normal file
334
.planning/phases/06-backend-authentication/06-03-PLAN.md
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
---
|
||||||
|
phase: 06-backend-authentication
|
||||||
|
plan: 03
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["06-01"]
|
||||||
|
files_modified:
|
||||||
|
- summercms/src/auth/Permission.scala
|
||||||
|
- summercms/src/auth/PermissionRegistry.scala
|
||||||
|
- summercms/src/auth/PermissionChecker.scala
|
||||||
|
- summercms/src/auth/TotpService.scala
|
||||||
|
- summercms/src/auth/middleware/PermissionMiddleware.scala
|
||||||
|
- summercms/src/auth/routes/TotpRoutes.scala
|
||||||
|
- summercms/src/auth/routes/RoleRoutes.scala
|
||||||
|
- summercms/src/auth/repository/RoleRepository.scala
|
||||||
|
- summercms/src/auth/LoginService.scala
|
||||||
|
- summercms/src/api/Routes.scala
|
||||||
|
- build.mill
|
||||||
|
autonomous: true
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Roles can be created with specific permissions attached"
|
||||||
|
- "Controllers and views check permissions before rendering protected content"
|
||||||
|
- "Wildcard permissions work (e.g., 'blog.*' matches 'blog.posts.create')"
|
||||||
|
- "Super Admin role has '*' permission that matches everything"
|
||||||
|
- "2FA can be enabled optionally by admin users"
|
||||||
|
artifacts:
|
||||||
|
- path: "summercms/src/auth/PermissionRegistry.scala"
|
||||||
|
provides: "Plugin permission registration"
|
||||||
|
exports: ["PermissionRegistry", "register", "getAllPermissions"]
|
||||||
|
- path: "summercms/src/auth/PermissionChecker.scala"
|
||||||
|
provides: "Permission checking with wildcard support"
|
||||||
|
exports: ["PermissionChecker", "hasPermission", "hasAnyPermission"]
|
||||||
|
- path: "summercms/src/auth/middleware/PermissionMiddleware.scala"
|
||||||
|
provides: "Route-level permission enforcement"
|
||||||
|
exports: ["PermissionMiddleware", "require"]
|
||||||
|
- path: "summercms/src/auth/TotpService.scala"
|
||||||
|
provides: "TOTP 2FA setup and verification"
|
||||||
|
exports: ["TotpService", "generateSecret", "verifyCode"]
|
||||||
|
key_links:
|
||||||
|
- from: "PermissionMiddleware"
|
||||||
|
to: "PermissionChecker"
|
||||||
|
via: "permission validation"
|
||||||
|
pattern: "permissionChecker\\.hasPermission"
|
||||||
|
- from: "PermissionChecker"
|
||||||
|
to: "RoleRepository"
|
||||||
|
via: "role lookup"
|
||||||
|
pattern: "roleRepository\\.findById"
|
||||||
|
- from: "LoginService"
|
||||||
|
to: "TotpService"
|
||||||
|
via: "2FA verification"
|
||||||
|
pattern: "totpService\\.verifyCode"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Implement role-based access control (RBAC) with plugin-registered permissions and optional TOTP 2FA.
|
||||||
|
|
||||||
|
Purpose: Enable fine-grained access control where plugins can register their own permissions, roles aggregate permissions, and middleware enforces access rules. Optional 2FA adds an extra security layer for admin accounts.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- Permission registry for plugin-registered permissions
|
||||||
|
- Permission checker with wildcard matching
|
||||||
|
- Permission middleware for route protection
|
||||||
|
- Role CRUD endpoints
|
||||||
|
- TOTP 2FA setup and verification
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@/home/jin/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/phases/06-backend-authentication/06-CONTEXT.md
|
||||||
|
@.planning/phases/06-backend-authentication/06-RESEARCH.md
|
||||||
|
@.planning/phases/06-backend-authentication/06-01-SUMMARY.md
|
||||||
|
@.planning/phases/02-plugin-system/02-RESEARCH.md
|
||||||
|
|
||||||
|
# Dependencies from 06-01
|
||||||
|
@summercms/src/auth/Role.scala
|
||||||
|
@summercms/src/auth/AdminUser.scala
|
||||||
|
@summercms/src/auth/LoginService.scala
|
||||||
|
@summercms/src/auth/middleware/AuthMiddleware.scala
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Create permission system</name>
|
||||||
|
<files>
|
||||||
|
summercms/src/auth/Permission.scala
|
||||||
|
summercms/src/auth/PermissionRegistry.scala
|
||||||
|
summercms/src/auth/PermissionChecker.scala
|
||||||
|
summercms/src/auth/repository/RoleRepository.scala
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. Create Permission.scala:
|
||||||
|
- case class PermissionDef(
|
||||||
|
code: String, // e.g., "golem15.blog.access_posts"
|
||||||
|
label: String, // Human-readable: "Access blog posts"
|
||||||
|
tab: String, // Grouping in UI: "Blog"
|
||||||
|
order: Int = 0
|
||||||
|
)
|
||||||
|
- companion object with helper methods
|
||||||
|
|
||||||
|
2. Create PermissionRegistry (ZIO service with Ref):
|
||||||
|
- trait PermissionRegistry:
|
||||||
|
def register(pluginId: String, permissions: List[PermissionDef]): UIO[Unit]
|
||||||
|
def getAllPermissions: UIO[List[PermissionDef]]
|
||||||
|
def getPermissionsForPlugin(pluginId: String): UIO[List[PermissionDef]]
|
||||||
|
def getPermissionsByTab: UIO[Map[String, List[PermissionDef]]]
|
||||||
|
- Implementation uses ZIO Ref[Map[String, List[PermissionDef]]]
|
||||||
|
- Register some core permissions on initialization:
|
||||||
|
- "backend.access" - "Access admin backend"
|
||||||
|
- "backend.manage_users" - "Manage admin users"
|
||||||
|
- "backend.manage_roles" - "Manage roles"
|
||||||
|
|
||||||
|
3. Create RoleRepository (following existing patterns):
|
||||||
|
- findById(id: Long): IO[RepositoryError, Option[Role]]
|
||||||
|
- findByCode(code: String): IO[RepositoryError, Option[Role]]
|
||||||
|
- findAll: IO[RepositoryError, List[Role]]
|
||||||
|
- create(role: Role): IO[RepositoryError, Role]
|
||||||
|
- update(role: Role): IO[RepositoryError, Role]
|
||||||
|
- delete(id: Long): IO[RepositoryError, Unit]
|
||||||
|
Use Quill with querySchema mapping.
|
||||||
|
|
||||||
|
4. Create PermissionChecker (ZIO service):
|
||||||
|
- trait PermissionChecker:
|
||||||
|
def hasPermission(userId: Long, permission: String): Task[Boolean]
|
||||||
|
def hasAnyPermission(userId: Long, permissions: List[String]): Task[Boolean]
|
||||||
|
def hasAllPermissions(userId: Long, permissions: List[String]): Task[Boolean]
|
||||||
|
|
||||||
|
- Implementation:
|
||||||
|
- Get user's role from AdminUserRepository
|
||||||
|
- Get role's permissions
|
||||||
|
- Match using wildcard logic:
|
||||||
|
- "*" matches everything (Super Admin)
|
||||||
|
- "blog.*" matches "blog.posts.create", "blog.categories.edit", etc.
|
||||||
|
- Exact match: "blog.posts.create" matches only "blog.posts.create"
|
||||||
|
|
||||||
|
- Wildcard matching algorithm:
|
||||||
|
```scala
|
||||||
|
def matchesPermission(granted: String, required: String): Boolean =
|
||||||
|
if granted == "*" then true
|
||||||
|
else if granted.endsWith(".*") then
|
||||||
|
required.startsWith(granted.dropRight(1)) // "blog.*" -> "blog."
|
||||||
|
else
|
||||||
|
granted == required
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
- ./mill summercms.compile succeeds
|
||||||
|
- PermissionChecker.hasPermission returns true for user with matching permission
|
||||||
|
- Wildcard "*" grants all permissions
|
||||||
|
- Wildcard "blog.*" grants "blog.posts.create" but not "users.list"
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
Permission registry stores plugin permissions, permission checker validates with wildcard support, role repository provides CRUD
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Create permission middleware and role management routes</name>
|
||||||
|
<files>
|
||||||
|
summercms/src/auth/middleware/PermissionMiddleware.scala
|
||||||
|
summercms/src/auth/routes/RoleRoutes.scala
|
||||||
|
summercms/src/api/Routes.scala
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. Create PermissionMiddleware.scala:
|
||||||
|
- object PermissionMiddleware:
|
||||||
|
def require(permission: String): Middleware[...] - requires exactly this permission
|
||||||
|
def requireAny(permissions: String*): Middleware[...] - requires any of these
|
||||||
|
def requireAll(permissions: String*): Middleware[...] - requires all of these
|
||||||
|
|
||||||
|
- Implementation pattern (composing with AuthMiddleware):
|
||||||
|
- Get SessionClaims from context (set by AuthMiddleware)
|
||||||
|
- Call PermissionChecker.hasPermission(claims.userId, permission)
|
||||||
|
- If false, return 403 Forbidden with JSON: { error: "Insufficient permissions" }
|
||||||
|
- If true, continue to handler
|
||||||
|
|
||||||
|
- Example usage in routes:
|
||||||
|
```scala
|
||||||
|
val protectedRoute = AuthMiddleware.apply @@
|
||||||
|
PermissionMiddleware.require("blog.posts.create") @@
|
||||||
|
handler { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create RoleRoutes.scala (protected, requires "backend.manage_roles"):
|
||||||
|
- GET /admin/roles
|
||||||
|
- List all roles
|
||||||
|
- Return: [{ id, code, name, permissions: [...], isSystem }]
|
||||||
|
|
||||||
|
- GET /admin/roles/:id
|
||||||
|
- Get single role
|
||||||
|
- Return: { id, code, name, permissions: [...], isSystem }
|
||||||
|
|
||||||
|
- POST /admin/roles (requires "backend.manage_roles")
|
||||||
|
- Body: { code, name, permissions: [...] }
|
||||||
|
- Validate code is unique
|
||||||
|
- Return created role
|
||||||
|
|
||||||
|
- PUT /admin/roles/:id (requires "backend.manage_roles")
|
||||||
|
- Body: { name, permissions: [...] }
|
||||||
|
- Cannot modify code or isSystem roles
|
||||||
|
- Return updated role
|
||||||
|
|
||||||
|
- DELETE /admin/roles/:id (requires "backend.manage_roles")
|
||||||
|
- Cannot delete system roles (is_system = true)
|
||||||
|
- Return 204 No Content
|
||||||
|
|
||||||
|
- GET /admin/permissions
|
||||||
|
- List all registered permissions (for role editor UI)
|
||||||
|
- Return: { tabs: [{ name, permissions: [{code, label}] }] }
|
||||||
|
|
||||||
|
3. Update Routes.scala to include RoleRoutes with proper middleware chain
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
- ./mill summercms.compile succeeds
|
||||||
|
- GET /admin/roles returns role list (with Super Admin having "*")
|
||||||
|
- POST /admin/roles creates new role
|
||||||
|
- User without permission gets 403 Forbidden
|
||||||
|
- User with permission gets 200 OK
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
PermissionMiddleware enforces route-level permissions, role CRUD endpoints available, permission listing available for UI
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Add optional TOTP 2FA</name>
|
||||||
|
<files>
|
||||||
|
summercms/src/auth/TotpService.scala
|
||||||
|
summercms/src/auth/routes/TotpRoutes.scala
|
||||||
|
summercms/src/auth/LoginService.scala
|
||||||
|
summercms/src/api/Routes.scala
|
||||||
|
summercms/src/Main.scala
|
||||||
|
build.mill
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. Add java-totp dependency to build.mill:
|
||||||
|
- mvn"dev.samstevens.totp:totp:1.7.1"
|
||||||
|
|
||||||
|
2. Create TotpService.scala (ZIO service):
|
||||||
|
- trait TotpService:
|
||||||
|
def generateSecret: Task[String]
|
||||||
|
def generateQrCodeDataUri(secret: String, email: String): Task[String]
|
||||||
|
def verifyCode(secret: String, code: String): Task[Boolean]
|
||||||
|
def generateRecoveryCodes: Task[List[String]]
|
||||||
|
|
||||||
|
- Implementation using dev.samstevens.totp:
|
||||||
|
- DefaultSecretGenerator for secret generation
|
||||||
|
- QrData.Builder with issuer "SummerCMS Admin"
|
||||||
|
- ZxingPngQrGenerator for QR code
|
||||||
|
- DefaultCodeVerifier for verification
|
||||||
|
- RecoveryCodeGenerator for backup codes (8 codes)
|
||||||
|
|
||||||
|
- Recovery codes:
|
||||||
|
- Generate 8 random codes
|
||||||
|
- Hash each with SHA-256 before storing (same pattern as password reset tokens)
|
||||||
|
- When verifying, hash input and compare to stored hashes
|
||||||
|
- Each recovery code is single-use
|
||||||
|
|
||||||
|
3. Create TotpRoutes.scala (all require auth):
|
||||||
|
- POST /admin/auth/2fa/setup
|
||||||
|
- Generate new secret
|
||||||
|
- Return: { secret, qrCodeDataUri }
|
||||||
|
- Does NOT enable 2FA yet (user must verify first)
|
||||||
|
|
||||||
|
- POST /admin/auth/2fa/verify-setup
|
||||||
|
- Body: { secret, code }
|
||||||
|
- Verify the code matches the secret
|
||||||
|
- If valid: update user with totp_secret, totp_enabled=true, generate and store recovery codes
|
||||||
|
- Return: { enabled: true, recoveryCodes: [...] } (only time codes are shown!)
|
||||||
|
|
||||||
|
- POST /admin/auth/2fa/disable
|
||||||
|
- Body: { code } (require current TOTP code to disable)
|
||||||
|
- Clear totp_secret, set totp_enabled=false, clear recovery_codes
|
||||||
|
- Return: { enabled: false }
|
||||||
|
|
||||||
|
- GET /admin/auth/2fa/status
|
||||||
|
- Return: { enabled: boolean }
|
||||||
|
|
||||||
|
4. Update LoginService.authenticate:
|
||||||
|
- After password verification, check if user has totp_enabled=true
|
||||||
|
- If yes and totpCode is None: return AuthError.TotpRequired
|
||||||
|
- If yes and totpCode provided:
|
||||||
|
- First try TotpService.verifyCode
|
||||||
|
- If fails, try matching against recovery codes (consume if match)
|
||||||
|
- If no 2FA, proceed normally
|
||||||
|
|
||||||
|
5. Update Main.scala to provide TotpService.live
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
- ./mill summercms.compile succeeds
|
||||||
|
- POST /admin/auth/2fa/setup returns secret and QR code data URI
|
||||||
|
- After enabling 2FA, login requires TOTP code
|
||||||
|
- Recovery code can be used once in place of TOTP
|
||||||
|
- POST /admin/auth/2fa/disable removes 2FA requirement
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
2FA can be enabled/disabled by users, login enforces 2FA when enabled, recovery codes work as backup
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
After all tasks complete:
|
||||||
|
1. Run ./mill summercms.compile - should succeed
|
||||||
|
2. Test role creation: POST /admin/roles with new role
|
||||||
|
3. Test permission check: Access route without permission (should get 403)
|
||||||
|
4. Test wildcard: Give role "blog.*", verify "blog.posts.create" works
|
||||||
|
5. Test 2FA setup: POST /admin/auth/2fa/setup, scan QR, verify setup
|
||||||
|
6. Test 2FA login: Login without code (should fail), login with code (should succeed)
|
||||||
|
7. Test recovery code: Use recovery code instead of TOTP (should work once)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Roles can be created, updated, deleted (except system roles)
|
||||||
|
- Permission middleware returns 403 for unauthorized access
|
||||||
|
- Wildcard permissions work correctly ("*" and "prefix.*")
|
||||||
|
- 2FA can be optionally enabled by users
|
||||||
|
- 2FA login requires valid TOTP code when enabled
|
||||||
|
- Recovery codes work as 2FA backup (single-use)
|
||||||
|
- Super Admin role with "*" can access everything
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/06-backend-authentication/06-03-SUMMARY.md`
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user