diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 3399c0f..b7c2d23 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -115,12 +115,12 @@ Plans:
3. Admin can reset password via email link
4. Roles can be created with specific permissions attached
5. Controllers and views check permissions before rendering protected content
-**Plans**: TBD
+**Plans**: 3 plans
Plans:
-- [ ] 06-01: Backend user model and login/logout
-- [ ] 06-02: Password reset flow
-- [ ] 06-03: RBAC permissions system
+- [ ] 06-01-PLAN.md - Admin user model, login/logout, sessions with JWT + DB hybrid
+- [ ] 06-02-PLAN.md - Password reset flow with secure tokens and email
+- [ ] 06-03-PLAN.md - RBAC permissions system with plugin registry and optional 2FA
### Phase 7: Admin Forms & Lists
**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 | - |
| 4. Theme Engine | 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 | - |
| 8. Admin Dashboard | 0/2 | Not started | - |
| 9. Content Management | 0/5 | Not started | - |
diff --git a/.planning/phases/06-backend-authentication/06-01-PLAN.md b/.planning/phases/06-backend-authentication/06-01-PLAN.md
new file mode 100644
index 0000000..271ff13
--- /dev/null
+++ b/.planning/phases/06-backend-authentication/06-01-PLAN.md
@@ -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\\]"
+---
+
+
+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
+
+
+
diff --git a/.planning/phases/06-backend-authentication/06-02-PLAN.md b/.planning/phases/06-backend-authentication/06-02-PLAN.md
new file mode 100644
index 0000000..e2ab209
--- /dev/null
+++ b/.planning/phases/06-backend-authentication/06-02-PLAN.md
@@ -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"
+---
+
+
+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
+
+
+
+@/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/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
+
+
+
+
+
+ Task 1: Create password reset token infrastructure
+
+ summercms/resources/db/migration/V7__password_reset.sql
+ summercms/src/auth/PasswordResetToken.scala
+ summercms/src/auth/repository/PasswordResetRepository.scala
+ summercms/src/config/AppConfig.scala
+
+
+ 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)
+
+
+ - ./mill summercms.compile succeeds
+ - Migration file exists at summercms/resources/db/migration/V7__password_reset.sql
+ - PasswordResetRepository compiles with proper Quill queries
+
+
+ Database table for password reset tokens created, domain model defined, repository with CRUD operations implemented
+
+
+
+
+ Task 2: Create EmailService and PasswordResetService
+
+ summercms/src/auth/EmailService.scala
+ summercms/src/auth/PasswordResetService.scala
+ build.mill
+
+
+ 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
+
+
+ - ./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
+
+
+ EmailService sends password reset emails, PasswordResetService generates secure tokens and handles full reset flow including session termination
+
+
+
+
+ Task 3: Create password reset routes
+
+ summercms/src/auth/routes/PasswordResetRoutes.scala
+ summercms/src/api/Routes.scala
+ summercms/src/Main.scala
+
+
+ 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.
+
+
+ - ./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)
+
+
+ Password reset flow complete: request generates email, token validation works, password update terminates all sessions
+
+
+
+
+
+
+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
+
+
+
+- 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)
+
+
+
diff --git a/.planning/phases/06-backend-authentication/06-03-PLAN.md b/.planning/phases/06-backend-authentication/06-03-PLAN.md
new file mode 100644
index 0000000..bb88f15
--- /dev/null
+++ b/.planning/phases/06-backend-authentication/06-03-PLAN.md
@@ -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"
+---
+
+
+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
+
+
+
+@/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/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
+
+
+
+
+
+ Task 1: Create permission system
+
+ summercms/src/auth/Permission.scala
+ summercms/src/auth/PermissionRegistry.scala
+ summercms/src/auth/PermissionChecker.scala
+ summercms/src/auth/repository/RoleRepository.scala
+
+
+ 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
+ ```
+
+
+ - ./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"
+
+
+ Permission registry stores plugin permissions, permission checker validates with wildcard support, role repository provides CRUD
+
+
+
+
+ Task 2: Create permission middleware and role management routes
+
+ summercms/src/auth/middleware/PermissionMiddleware.scala
+ summercms/src/auth/routes/RoleRoutes.scala
+ summercms/src/api/Routes.scala
+
+
+ 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
+
+
+ - ./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
+
+
+ PermissionMiddleware enforces route-level permissions, role CRUD endpoints available, permission listing available for UI
+
+
+
+
+ Task 3: Add optional TOTP 2FA
+
+ 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
+
+
+ 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
+
+
+ - ./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
+
+
+ 2FA can be enabled/disabled by users, login enforces 2FA when enabled, recovery codes work as backup
+
+
+
+
+
+
+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)
+
+
+
+- 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
+
+
+