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
13 KiB
13 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 06-backend-authentication | 03 | execute | 2 |
|
|
true |
|
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
<execution_context> @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/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.mdDependencies 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 methods2. 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)
<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>