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
280 lines
11 KiB
Markdown
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>
|