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:
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>
|
||||
Reference in New Issue
Block a user