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

11 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 02 execute 2
06-01
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
true
truths artifacts key_links
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
path provides exports
summercms/src/auth/PasswordResetService.scala Password reset token generation and validation
PasswordResetService
requestReset
validateToken
resetPassword
path provides exports
summercms/src/auth/EmailService.scala Email sending capability
EmailService
sendPasswordReset
path provides min_lines
summercms/src/auth/routes/PasswordResetRoutes.scala POST /admin/auth/forgot-password, POST /admin/auth/reset-password 40
from to via pattern
PasswordResetService EmailService sending reset email emailService.sendPasswordReset
from to via pattern
PasswordResetService.resetPassword SessionService.revokeAllSessions terminate sessions on password change 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

<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

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

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