Files
summercms-initial-research/.planning/phases/06-backend-authentication/06-01-PLAN.md
Jakub Zych 1ad1993632 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
2026-02-05 14:35:41 +01:00

12 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
phase plan type wave depends_on files_modified autonomous must_haves
06-backend-authentication 01 execute 1
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
true
truths artifacts key_links
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
path provides contains
summercms/resources/db/migration/V6__backend_auth.sql Database schema for admin_users, roles, admin_sessions, login_attempts CREATE TABLE admin_users
path provides exports
summercms/src/auth/PasswordService.scala Argon2id password hashing
PasswordService
hash
verify
path provides exports
summercms/src/auth/SessionService.scala JWT + DB hybrid session management
SessionService
createSession
validateToken
revokeSession
path provides exports
summercms/src/auth/LoginService.scala Login logic with progressive lockout
LoginService
authenticate
path provides min_lines
summercms/src/auth/routes/LoginRoutes.scala POST /admin/auth/login, POST /admin/auth/logout, GET/DELETE /admin/auth/sessions 50
from to via pattern
LoginRoutes.scala LoginService ZIO service dependency ZIO.service[LoginService]
from to via pattern
LoginService PasswordService password verification passwordService.verify
from to via pattern
SessionService admin_sessions table Quill queries 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

<execution_context> @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md </execution_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

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

<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>
After completion, create `.planning/phases/06-backend-authentication/06-01-SUMMARY.md`