--- 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" --- 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 @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md @.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 - 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) After completion, create `.planning/phases/06-backend-authentication/06-02-SUMMARY.md`