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:
Jakub Zych
2026-02-05 14:35:41 +01:00
parent 10cdd3f638
commit 1ad1993632
4 changed files with 918 additions and 5 deletions

View File

@@ -115,12 +115,12 @@ Plans:
3. Admin can reset password via email link 3. Admin can reset password via email link
4. Roles can be created with specific permissions attached 4. Roles can be created with specific permissions attached
5. Controllers and views check permissions before rendering protected content 5. Controllers and views check permissions before rendering protected content
**Plans**: TBD **Plans**: 3 plans
Plans: Plans:
- [ ] 06-01: Backend user model and login/logout - [ ] 06-01-PLAN.md - Admin user model, login/logout, sessions with JWT + DB hybrid
- [ ] 06-02: Password reset flow - [ ] 06-02-PLAN.md - Password reset flow with secure tokens and email
- [ ] 06-03: RBAC permissions system - [ ] 06-03-PLAN.md - RBAC permissions system with plugin registry and optional 2FA
### Phase 7: Admin Forms & Lists ### Phase 7: Admin Forms & Lists
**Goal**: Generate forms and lists from YAML definitions **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 | - | | 3. Component System | 0/2 | Planned | - |
| 4. Theme Engine | 0/2 | Planned | - | | 4. Theme Engine | 0/2 | Planned | - |
| 5. CLI Scaffolding | 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 | - | | 7. Admin Forms & Lists | 0/3 | Not started | - |
| 8. Admin Dashboard | 0/2 | Not started | - | | 8. Admin Dashboard | 0/2 | Not started | - |
| 9. Content Management | 0/5 | Not started | - | | 9. Content Management | 0/5 | Not started | - |

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

View File

@@ -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"
---
<objective>
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
</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/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
</context>
<tasks>
<task type="auto">
<name>Task 1: Create password reset token infrastructure</name>
<files>
summercms/resources/db/migration/V7__password_reset.sql
summercms/src/auth/PasswordResetToken.scala
summercms/src/auth/repository/PasswordResetRepository.scala
summercms/src/config/AppConfig.scala
</files>
<action>
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)
</action>
<verify>
- ./mill summercms.compile succeeds
- Migration file exists at summercms/resources/db/migration/V7__password_reset.sql
- PasswordResetRepository compiles with proper Quill queries
</verify>
<done>
Database table for password reset tokens created, domain model defined, repository with CRUD operations implemented
</done>
</task>
<task type="auto">
<name>Task 2: Create EmailService and PasswordResetService</name>
<files>
summercms/src/auth/EmailService.scala
summercms/src/auth/PasswordResetService.scala
build.mill
</files>
<action>
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
</action>
<verify>
- ./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
</verify>
<done>
EmailService sends password reset emails, PasswordResetService generates secure tokens and handles full reset flow including session termination
</done>
</task>
<task type="auto">
<name>Task 3: Create password reset routes</name>
<files>
summercms/src/auth/routes/PasswordResetRoutes.scala
summercms/src/api/Routes.scala
summercms/src/Main.scala
</files>
<action>
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.
</action>
<verify>
- ./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)
</verify>
<done>
Password reset flow complete: request generates email, token validation works, password update terminates all sessions
</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 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
</verification>
<success_criteria>
- 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)
</success_criteria>
<output>
After completion, create `.planning/phases/06-backend-authentication/06-02-SUMMARY.md`
</output>

View File

@@ -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"
---
<objective>
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
</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/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
</context>
<tasks>
<task type="auto">
<name>Task 1: Create permission system</name>
<files>
summercms/src/auth/Permission.scala
summercms/src/auth/PermissionRegistry.scala
summercms/src/auth/PermissionChecker.scala
summercms/src/auth/repository/RoleRepository.scala
</files>
<action>
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
```
</action>
<verify>
- ./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"
</verify>
<done>
Permission registry stores plugin permissions, permission checker validates with wildcard support, role repository provides CRUD
</done>
</task>
<task type="auto">
<name>Task 2: Create permission middleware and role management routes</name>
<files>
summercms/src/auth/middleware/PermissionMiddleware.scala
summercms/src/auth/routes/RoleRoutes.scala
summercms/src/api/Routes.scala
</files>
<action>
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
</action>
<verify>
- ./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
</verify>
<done>
PermissionMiddleware enforces route-level permissions, role CRUD endpoints available, permission listing available for UI
</done>
</task>
<task type="auto">
<name>Task 3: Add optional TOTP 2FA</name>
<files>
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
</files>
<action>
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
</action>
<verify>
- ./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
</verify>
<done>
2FA can be enabled/disabled by users, login enforces 2FA when enabled, recovery codes work as backup
</done>
</task>
</tasks>
<verification>
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)
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/06-backend-authentication/06-03-SUMMARY.md`
</output>