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