--- 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\\]" --- 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 @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md @.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 Task 1: Create database migration and domain models 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 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. - ./mill summercms.compile succeeds - Migration file exists at summercms/resources/db/migration/V6__backend_auth.sql - All case classes compile with proper Instant types Database schema defined for auth tables, domain models created matching schema, build compiles with new dependencies Task 2: Create PasswordService, SessionService, and LoginService 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 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. - ./mill summercms.compile succeeds - PasswordService.hash returns Argon2id hash (starts with $argon2) - SessionService.createSession returns a JWT string - LoginService has lockout thresholds defined PasswordService hashes with Argon2id, SessionService creates/validates JWT tokens with DB backing, LoginService implements progressive lockout, all repositories created Task 3: Create auth routes and middleware summercms/src/auth/middleware/AuthMiddleware.scala summercms/src/auth/routes/LoginRoutes.scala summercms/src/api/Routes.scala summercms/src/Main.scala 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) - ./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 Login returns JWT token and sets cookie, logout revokes session, session listing works, auth middleware protects routes 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 - 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 After completion, create `.planning/phases/06-backend-authentication/06-01-SUMMARY.md`