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:
@@ -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 | - |
|
||||
|
||||
300
.planning/phases/06-backend-authentication/06-01-PLAN.md
Normal file
300
.planning/phases/06-backend-authentication/06-01-PLAN.md
Normal 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>
|
||||
279
.planning/phases/06-backend-authentication/06-02-PLAN.md
Normal file
279
.planning/phases/06-backend-authentication/06-02-PLAN.md
Normal 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>
|
||||
334
.planning/phases/06-backend-authentication/06-03-PLAN.md
Normal file
334
.planning/phases/06-backend-authentication/06-03-PLAN.md
Normal 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>
|
||||
Reference in New Issue
Block a user