---
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