# Email Verification - Implementation Task ## Task Overview Implement email verification for new user registrations. Users receive a verification link after registration and must click it before they can login. Includes token management, verification endpoints, and admin bypass functionality. Reference: `specs/requirements/email-verification.md` ## Application Context ### Current Registration Flow - `AuthController.register()` → `AuthenticationService.register()` - Creates user with `enabled = true` - Sends welcome email immediately - Returns JWT token immediately (user can login right away) ### User Entity - Location: `core/model/User.java` - Has `enabled` field (for admin disable) - Missing: `emailVerified` field ### Authentication Flow - `UserService.loadUserByUsername()` checks `enabled` field - Throws `DisabledException` if disabled - Returns authorities based on role ### Email Infrastructure - `EmailService` with Freemarker templates - `LogsEmailSender` for local development - Templates in `src/main/resources/templates/email/` ### Existing Patterns - Secure random: Use `java.security.SecureRandom` - Hashing: Use existing `PasswordEncoder` for token hashing - Config: Use `@Value` or `@ConfigurationProperties` ## Architecture Design Guidelines ### Token Security 1. Generate 32 bytes using `SecureRandom` 2. Encode as URL-safe Base64 (44 characters) 3. Hash token before storing (use SHA-256, not bcrypt - need exact match) 4. Store hash in database, send raw token in email ### Token Hashing ```java // Use SHA-256 for token hashing (need exact match, not bcrypt) MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hash = digest.digest(token.getBytes(StandardCharsets.UTF_8)); String tokenHash = Base64.getEncoder().encodeToString(hash); ``` ### Registration Response DTO ```java @Data @Builder public class RegisterResponse { private String message; private String email; // masked: j***@example.com } ``` ### Configuration Properties ```java @ConfigurationProperties(prefix = "application.email.verification") public class EmailVerificationProperties { private boolean enabled = true; private int expirationHours = 24; } ``` ## Implementation Plan ### Phase 1: Database Schema **Files to create:** - `src/main/resources/db/migrations/V3__Add_email_verification.sql` **Migration content:** ```sql -- Add emailVerified to users ALTER TABLE users ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT FALSE; -- Update existing users to verified (backward compatibility) UPDATE users SET email_verified = TRUE; -- Create verification tokens table CREATE TABLE email_verification_tokens ( id UUID PRIMARY KEY, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash VARCHAR(255) NOT NULL, expires_at TIMESTAMP(6) NOT NULL, created_at TIMESTAMP(6) NOT NULL, CONSTRAINT uk_email_verification_token_hash UNIQUE (token_hash) ); CREATE INDEX idx_email_verification_tokens_user_id ON email_verification_tokens(user_id); CREATE INDEX idx_email_verification_tokens_expires_at ON email_verification_tokens(expires_at); ``` --- ### Phase 2: Domain Model Updates **Files to create:** - `src/main/java/be/transcode/barebone/server/core/model/EmailVerificationToken.java` **Files to modify:** - `src/main/java/be/transcode/barebone/server/core/model/User.java` **User entity changes:** ```java @Builder.Default @Column(name = "email_verified", nullable = false) private boolean emailVerified = false; ``` **EmailVerificationToken entity:** ```java @Entity @Table(name = "email_verification_tokens") @Data @NoArgsConstructor @AllArgsConstructor @Builder public class EmailVerificationToken { @Id @GeneratedValue(strategy = GenerationType.UUID) private UUID id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private User user; @Column(name = "token_hash", nullable = false, unique = true) private String tokenHash; @Column(name = "expires_at", nullable = false) private LocalDateTime expiresAt; @Column(name = "created_at", nullable = false) private LocalDateTime createdAt; @PrePersist protected void onCreate() { createdAt = LocalDateTime.now(); } public boolean isExpired() { return LocalDateTime.now().isAfter(expiresAt); } } ``` --- ### Phase 3: Configuration **Files to create:** - `src/main/java/be/transcode/barebone/server/config/EmailVerificationProperties.java` **Files to modify:** - `src/main/resources/application.properties` - `src/main/resources/application-local.properties` **Properties class:** ```java @Configuration @ConfigurationProperties(prefix = "application.email.verification") @Data public class EmailVerificationProperties { private boolean enabled = true; private int expirationHours = 24; } ``` **application.properties additions:** ```properties # Email verification application.email.verification.enabled=true application.email.verification.expiration-hours=24 ``` **application-local.properties:** ```properties # Disable email verification for local development application.email.verification.enabled=false ``` --- ### Phase 4: Repository **Files to create:** - `src/main/java/be/transcode/barebone/server/core/repository/EmailVerificationTokenRepository.java` ```java public interface EmailVerificationTokenRepository extends JpaRepository { Optional findByTokenHash(String tokenHash); void deleteByUser(User user); void deleteByExpiresAtBefore(LocalDateTime dateTime); int countByUserAndCreatedAtAfter(User user, LocalDateTime dateTime); } ``` --- ### Phase 5: DTOs **Files to create:** - `src/main/java/be/transcode/barebone/server/core/dto/RegisterResponse.java` - `src/main/java/be/transcode/barebone/server/core/dto/ResendVerificationRequest.java` - `src/main/java/be/transcode/barebone/server/core/dto/VerificationResponse.java` **RegisterResponse:** ```java @Data @Builder public class RegisterResponse { private String message; private String email; } ``` **ResendVerificationRequest:** ```java @Data public class ResendVerificationRequest { @NotBlank @Email private String email; } ``` **VerificationResponse:** ```java @Data @Builder public class VerificationResponse { private boolean success; private String message; } ``` --- ### Phase 6: Email Verification Service **Files to create:** - `src/main/java/be/transcode/barebone/server/core/service/EmailVerificationService.java` **Methods:** ```java @Service @RequiredArgsConstructor @Slf4j public class EmailVerificationService { private final EmailVerificationTokenRepository tokenRepository; private final UserRepository userRepository; private final EmailService emailService; private final EmailVerificationProperties properties; private final AppConfig appConfig; // Generate token and send verification email @Transactional public void createAndSendVerification(User user); // Verify token and activate user @Transactional public VerificationResponse verifyEmail(String token); // Resend verification email (with rate limiting) @Transactional public void resendVerification(String email); // Admin manual verification @Transactional public void adminVerifyEmail(UUID adminId, UUID userId); // Check if verification is enabled public boolean isVerificationEnabled(); // Generate secure random token private String generateToken(); // Hash token for storage private String hashToken(String token); // Mask email for display (j***@example.com) private String maskEmail(String email); // Cleanup expired tokens (scheduled) @Scheduled(cron = "0 0 * * * *") public void cleanupExpiredTokens(); } ``` **Token generation:** ```java private String generateToken() { SecureRandom random = new SecureRandom(); byte[] bytes = new byte[32]; random.nextBytes(bytes); return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); } private String hashToken(String token) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hash = digest.digest(token.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(hash); } catch (NoSuchAlgorithmException e) { throw new RuntimeException("SHA-256 not available", e); } } ``` **Rate limiting for resend:** ```java // Check rate limit: max 3 per hour int recentCount = tokenRepository.countByUserAndCreatedAtAfter( user, LocalDateTime.now().minusHours(1)); if (recentCount >= 3) { throw new BadRequestException("Too many verification requests. Try again later."); } ``` --- ### Phase 7: Email Template **Files to create:** - `src/main/resources/templates/email/email_verification.ftl` **Template variables:** - `fullName` - `verificationLink` - `expirationHours` - `domain` **EmailService additions:** ```java public void sendVerificationEmail(String to, String fullName, String verificationLink, int expirationHours, String domain); ``` --- ### Phase 8: Authentication Service Updates **Files to modify:** - `src/main/java/be/transcode/barebone/server/core/service/AuthenticationService.java` - `src/main/java/be/transcode/barebone/server/core/service/UserService.java` **AuthenticationService.register() changes:** - If verification enabled: - Create user with `emailVerified = false` - Don't send welcome email yet - Create verification token and send verification email - Return `RegisterResponse` with message and masked email - If verification disabled: - Keep current behavior (return JWT) **UserService.loadUserByUsername() changes:** ```java if (!user.isEnabled()) { throw new DisabledException("User account is disabled"); } if (!user.isEmailVerified()) { throw new DisabledException("Email not verified"); } ``` --- ### Phase 9: Auth Controller Updates **Files to modify:** - `src/main/java/be/transcode/barebone/server/core/controller/AuthController.java` **New/modified endpoints:** ```java @PostMapping("/register") public ResponseEntity register(@RequestBody @Valid RegisterRequest request) { // Returns AuthResponse or RegisterResponse based on verification config } @GetMapping("/verify-email") public ResponseEntity verifyEmail(@RequestParam String token) { return ResponseEntity.ok(emailVerificationService.verifyEmail(token)); } @PostMapping("/resend-verification") public ResponseEntity resendVerification( @RequestBody @Valid ResendVerificationRequest request) { emailVerificationService.resendVerification(request.getEmail()); return ResponseEntity.ok().build(); } ``` --- ### Phase 10: Admin Endpoint **Files to modify:** - `src/main/java/be/transcode/barebone/server/core/controller/AdminController.java` - `src/main/java/be/transcode/barebone/server/core/service/AdminService.java` **New endpoint:** ```java @PutMapping("/users/{id}/verify-email") public ResponseEntity verifyUserEmail( @AuthenticationPrincipal UserDetails userDetails, @PathVariable UUID id) { UUID adminId = getAdminId(userDetails); emailVerificationService.adminVerifyEmail(adminId, id); return ResponseEntity.ok().build(); } ``` --- ### Phase 11: Update Admin User Detail DTO **Files to modify:** - `src/main/java/be/transcode/barebone/server/core/dto/AdminUserDetailDTO.java` - `src/main/java/be/transcode/barebone/server/core/dto/AdminUserListItemDTO.java` - `src/main/java/be/transcode/barebone/server/core/service/AdminService.java` Add `emailVerified` field to admin DTOs and mappings. --- ### Phase 12: Exception Handling **Files to modify:** - `src/main/java/be/transcode/barebone/server/core/exception/GlobalExceptionHandler.java` Consider adding specific exception for unverified email if needed for distinct error messages. --- ### Phase 13: Integration Tests **Files to create:** - `src/test/java/be/transcode/barebone/server/core/controller/EmailVerificationIT.java` **Test scenarios:** 1. Registration sends verification email (when enabled) 2. Registration returns JWT (when disabled) 3. Verify email with valid token 4. Verify email with expired token 5. Verify email with invalid token 6. Resend verification email 7. Rate limiting on resend 8. Unverified user cannot login 9. Verified user can login 10. Admin can manually verify user 11. Welcome email sent after verification --- ### Phase 14: Update Existing Tests **Files to modify:** - Existing auth tests need to handle verification flow - Either disable verification in test profile or verify users in setup **Test configuration:** ```properties # application-test.properties application.email.verification.enabled=false ``` --- ## Implementation Order Execute phases sequentially: 1. Phase 1 (Database) - Required for all subsequent phases 2. Phase 2 (Domain Model) - Required for repository and service 3. Phase 3 (Configuration) - Required for conditional logic 4. Phase 4 (Repository) - Required for service 5. Phase 5 (DTOs) - Required for controller and service 6. Phase 6 (Service) - Core verification logic 7. Phase 7 (Email Template) - Required for sending emails 8. Phase 8 (Auth Service) - Update registration flow 9. Phase 9 (Auth Controller) - New endpoints 10. Phase 10 (Admin) - Admin bypass functionality 11. Phase 11 (Admin DTOs) - Add emailVerified to admin views 12. Phase 12 (Exceptions) - Polish error handling 13. Phase 13 (Tests) - New integration tests 14. Phase 14 (Update tests) - Ensure existing tests pass