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:
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>
|
||||
Reference in New Issue
Block a user