Files
summercms-initial-research/.planning/phases/06-backend-authentication/06-02-PLAN.md
Jakub Zych 1ad1993632 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
2026-02-05 14:35:41 +01:00

280 lines
11 KiB
Markdown

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