diff --git a/.githooks/pre-push b/.githooks/pre-push index 586f243..b0b96e1 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -6,7 +6,7 @@ echo "๐Ÿš€ Running pre-push checks..." # Run tests echo "๐Ÿงช Running tests..." -./gradlew test --quiet +./gradlew test --quiet --rerun-tasks if [ $? -ne 0 ]; then echo "โŒ Tests failed! Push aborted." diff --git a/.gitignore b/.gitignore index 29e54ab..c7dc590 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,9 @@ -.gradle -.idea -build -target +.gradle/ +.idea/ +build/ +target/ .DS_Store -logs -!auto/build +logs/ # Environment files (contain secrets) .env diff --git a/CLAUDE.md b/CLAUDE.md index 1592590..31ddbb5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,11 +2,21 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Changes Convention + +- Follow functional programming in Java as much as possible: + - Use record, map/flatMap, filter, Optional, functional interface, lambda etc. + - Use Immutable if possible + ## Project Overview -This is a **Java 25 Spring Boot 4.0** modular monolith implementing user authentication and management with both REST and gRPC APIs. The application uses **Spring Modulith** for module boundaries and event-driven communication between modules. The service features JWT authentication with refresh token rotation, token families for multi-device support, role-based access control (ADMIN/MEMBER), and PostgreSQL persistence with Flyway migrations. +This is a **Java 25 Spring Boot 4.0** modular monolith implementing user authentication and management with both REST +and gRPC APIs. The application uses **Spring Modulith** for module boundaries and event-driven communication between +modules. The service features JWT authentication with refresh token rotation, token families for multi-device support, +role-based access control (ADMIN/MEMBER), and PostgreSQL persistence with Flyway migrations. ### Key Technologies + - **Java 25** with virtual threads (Project Loom) - **Spring Boot 4.0** with Spring Framework 7.0 - **Spring Modulith 2.0** for modular architecture @@ -16,6 +26,7 @@ This is a **Java 25 Spring Boot 4.0** modular monolith implementing user authent ## Build & Development Commands ### Building + ```bash # Full build with tests ./gradlew build @@ -28,6 +39,7 @@ This is a **Java 25 Spring Boot 4.0** modular monolith implementing user authent ``` ### Running + ```bash # Run locally (port 3001 REST, 9090 gRPC) ./gradlew bootRun @@ -43,6 +55,7 @@ docker compose up -d ``` ### Testing + ```bash # Run all tests ./gradlew test @@ -64,6 +77,7 @@ docker compose up -d ``` ### Code Formatting + ```bash # Apply Spotless formatting (Palantir Java Format) ./gradlew spotlessApply @@ -76,6 +90,7 @@ docker compose up -d ``` ### gRPC Protobuf Generation + ```bash # Generate Java classes from proto files ./gradlew generateProto @@ -88,7 +103,8 @@ docker compose up -d ### Spring Modulith Structure -The application is organized as a **modular monolith** using Spring Modulith. Each module has clear boundaries and communicates via events. +The application is organized as a **modular monolith** using Spring Modulith. Each module has clear boundaries and +communicates via events. ``` org.nkcoder/ @@ -159,6 +175,7 @@ notification โ”€โ”€โ†’ shared โ†โ”€โ”€ user ### Key Components by Module **User Module** (`org.nkcoder.user`): + - `interfaces/rest/` - AuthController, UserController, AdminUserController - `application/service/` - AuthApplicationService, UserApplicationService - `domain/model/` - User (aggregate), RefreshToken, value objects (Email, UserId, UserRole, etc.) @@ -168,10 +185,12 @@ notification โ”€โ”€โ†’ shared โ†โ”€โ”€ user - `infrastructure/security/` - JwtAuthenticationFilter, SecurityConfig, JwtTokenGeneratorAdapter **Notification Module** (`org.nkcoder.notification`): + - `NotificationService` - Public API for sending notifications - `application/UserEventListener` - Listens to UserRegisteredEvent **Shared Module** (`org.nkcoder.shared`): + - `kernel/domain/event/` - DomainEvent, UserRegisteredEvent, UserProfileUpdatedEvent - `kernel/domain/valueobject/` - AggregateRoot base class - `kernel/exception/` - AuthenticationException, ValidationException, ResourceNotFoundException @@ -179,36 +198,44 @@ notification โ”€โ”€โ†’ shared โ†โ”€โ”€ user - `local/event/` - SpringDomainEventPublisher **Infrastructure Module** (`org.nkcoder.infrastructure`): + - `config/` - CorsProperties, OpenApiConfig, JpaAuditingConfig, WebConfig ### Security & Authentication **JWT Implementation**: Dual-token system with separate HMAC-SHA512 keys: + - **Access tokens**: 15-minute expiration, contains userId, email, role - **Refresh tokens**: 7-day expiration, contains userId, tokenFamily **Token Rotation Mechanism**: + - Each token refresh generates new access + refresh tokens - New refresh token maintains same `tokenFamily` (UUID) for breach detection - Old refresh token is deleted from database (prevents replay attacks) **Token Family Pattern**: + - Each login session creates a new token family - All refresh operations within a session share the same family - If an invalid/expired token from a family is used, entire family is deleted (logout all devices) **Multi-Device Logout**: + - `POST /api/users/auth/logout` - Deletes entire token family (all devices) - `POST /api/users/auth/logout-single` - Deletes only current refresh token (single device) **JWT Authentication Flow**: -1. `JwtAuthenticationFilter` (in `user.infrastructure.security`) extracts token from `Authorization: Bearer {token}` header + +1. `JwtAuthenticationFilter` (in `user.infrastructure.security`) extracts token from `Authorization: Bearer {token}` + header 2. Token validated via `TokenGenerator` port (implemented by `JwtTokenGeneratorAdapter`) 3. `UsernamePasswordAuthenticationToken` created with role authorities (ROLE_MEMBER/ROLE_ADMIN) 4. Context stored in `SecurityContextHolder` 5. Request attributes set: userId, email, role (accessible in controllers) **Security Configuration**: + - Stateless session management (no server-side session) - BCrypt password encoding - CORS enabled for CLIENT_URL (default: http://localhost:3000) @@ -219,19 +246,23 @@ notification โ”€โ”€โ†’ shared โ†โ”€โ”€ user ### Data Access Patterns **Entities**: + - `User`: UUID primary key, unique email index, one-to-many with RefreshToken - `RefreshToken`: UUID primary key, unique token, many-to-one with User, indexed on token/tokenFamily/userId **Audit Timestamps**: + - Entities use `@CreatedDate` and `@LastModifiedDate` from Spring Data JPA auditing - Enabled via `@EnableJpaAuditing` in JpaAuditingConfig **Repository Methods**: + - Custom @Query methods for complex operations (e.g., `updateLastLoginAt`, `findByEmailExcludingId`) - @Modifying queries for deletes/updates (e.g., `deleteByTokenFamily`, `deleteExpiredTokens`) - Boolean existence checks for validation (e.g., `existsByEmail`) **Transaction Management**: + - Service classes use `@Transactional` at class level - Query methods marked with `@Transactional(readOnly=true)` for optimization - Repository operations participate in service-level transactions @@ -239,6 +270,7 @@ notification โ”€โ”€โ†’ shared โ†โ”€โ”€ user ### API Design **REST Endpoints** (port 3001): + ``` POST /api/users/auth/register - User registration POST /api/users/auth/login - User login @@ -254,20 +286,25 @@ PATCH /api/users/{userId}/password - Reset password (admin only) ``` **gRPC Endpoints** (port 9090): + - `AuthService.Register` - User registration - `AuthService.Login` - User login - Proto file: `src/main/proto/auth.proto` **Response Wrapping**: All responses use `ApiResponse` record: + ```json { "message": "Operation successful", - "data": { ... }, + "data": { + ... + }, "timestamp": "2025-11-28T10:30:45.123" } ``` **DTO Patterns**: + - Request DTOs are immutable records with Jakarta validation annotations - Response DTOs are records (UserResponse, AuthResponse, AuthTokens) - Validation errors return field-level details via MethodArgumentNotValidException handling @@ -275,6 +312,7 @@ PATCH /api/users/{userId}/password - Reset password (admin only) ### Error Handling **Global Exception Handler** (`GlobalExceptionHandler.java`): + - Maps custom exceptions to appropriate HTTP status codes - ValidationException โ†’ 400 Bad Request - ResourceNotFoundException โ†’ 404 Not Found @@ -284,6 +322,7 @@ PATCH /api/users/{userId}/password - Reset password (admin only) - Generic exceptions โ†’ 500 Internal Server Error **Custom Exceptions** (all extend RuntimeException): + - `AuthenticationException` - Authentication failures (invalid credentials, expired tokens) - `ValidationException` - Business validation failures (duplicate email, password mismatch) - `ResourceNotFoundException` - Entity not found by ID @@ -293,13 +332,20 @@ PATCH /api/users/{userId}/password - Reset password (admin only) Modules communicate via domain events using Spring Modulith's event infrastructure: **Publishing Events** (in User module): + ```java // In AuthApplicationService after registration -domainEventPublisher.publish(new UserRegisteredEvent(user.getId(), user.getEmail(), user.getName())); +domainEventPublisher.publish(new UserRegisteredEvent(user.getId(),user. + +getEmail(),user. + +getName())); ``` **Listening to Events** (in Notification module): + ```java + @Component public class UserEventListener { @ApplicationModuleListener @@ -309,17 +355,20 @@ public class UserEventListener { } ``` -**Event Publication Table**: Spring Modulith persists events to `event_publication` table for reliable delivery (transactional outbox pattern). +**Event Publication Table**: Spring Modulith persists events to `event_publication` table for reliable delivery ( +transactional outbox pattern). ### Configuration Management **Profiles**: + - `local` - Local development with Docker Compose - `dev` - Development environment with external database - `prod` - Production with environment variables - `test` - Test profile with TestContainers **Environment Variables** (required for production): + ```bash DATABASE_URL=jdbc:postgresql://host:port/database DATABASE_USERNAME=username @@ -332,6 +381,7 @@ CLIENT_URL=http://localhost:3000 ``` **Configuration Binding**: + - JWT settings bound via `@ConfigurationProperties` in JwtProperties.java - Nested structure: jwt.secret.access, jwt.secret.refresh, jwt.expiration.access, etc. - Use `${VARIABLE:defaultValue}` syntax in application.yml @@ -339,12 +389,14 @@ CLIENT_URL=http://localhost:3000 ### Database Migrations **Flyway Migration Files** (in `src/main/resources/db/migration/`): + - `V1.1__create_tables.sql` - Initial schema (users, refresh_tokens tables) - `V1.2__seeding_users.sql` - Seed data (admin@timor.com, demo@timor.com) - `V1.3__update_users_role.sql` - Schema updates - `V1.4__create_event_publication_table.sql` - Spring Modulith event publication table **Migration Best Practices**: + - Never modify existing migrations (create new ones) - Use sequential versioning: V1.1, V1.2, V1.3, etc. (uppercase V required!) - Validate migrations with `validate-on-migrate: true` @@ -353,17 +405,20 @@ CLIENT_URL=http://localhost:3000 ### gRPC Integration **Dual Protocol Support**: + - REST API (port 3001) uses Undertow server - gRPC API (port 9090) uses Netty server - Both protocols share same business logic (AuthService) - Protocol-specific marshaling via GrpcMapper **Proto Files**: + - Located in `src/main/proto/` - Generated classes: `buf-gen/generated/sources/proto/main/java/` - Regenerate after proto changes: `./gradlew generateProto` **gRPC Service Implementation**: + - Classes in `grpc/` package extend generated service base classes - Decorated with `@GrpcService` annotation - Use `StreamObserver` for async responses @@ -372,12 +427,14 @@ CLIENT_URL=http://localhost:3000 ### Testing Infrastructure **Test Types**: + - Unit tests: `@WebMvcTest` for controllers, `@MockBean` for services - Integration tests: `@SpringBootTest` with TestContainers for PostgreSQL - Module tests: `ModulithArchitectureTest` verifies module boundaries - Security tests: Use `@WithMockUser` or custom security setup **Module Verification Test**: + ```java class ModulithArchitectureTest { ApplicationModules modules = ApplicationModules.of(Application.class); @@ -390,22 +447,26 @@ class ModulithArchitectureTest { ``` **Integration Test Setup**: + - Tests in `org.nkcoder.user.integration/` package - Use `@SpringBootTest(classes = Application.class)` to specify bootstrap class - WebTestClient for REST API testing (Spring Boot 4 compatible) **Test Configuration**: + - `TestContainersConfiguration.java` provides PostgreSQL container - `application-test.yml` configures test profile - TestContainers automatically manages PostgreSQL instance **Default Test Users** (seeded in test profile): + - admin@timor.com / Admin12345! (ROLE_ADMIN) - demo@timor.com / Demo12345! (ROLE_MEMBER) ## Code Style & Conventions **Formatting**: + - Palantir Java Format via Spotless plugin - 2-space indentation (no tabs) - Auto-import ordering and unused import removal @@ -414,33 +475,40 @@ class ModulithArchitectureTest { - Files end with newline **Naming Conventions**: + - Classes: PascalCase (e.g., AuthService, UserController) - Methods/Fields: camelCase (e.g., findByEmail, refreshToken) - Constants: UPPER_SNAKE_CASE (e.g., DEFAULT_ROLE) - Packages: lowercase (e.g., org.nkcoder.service) **Package Organization**: + - Modules are direct sub-packages of `org.nkcoder` - Each module follows hexagonal architecture: `interfaces/`, `application/`, `domain/`, `infrastructure/` - Domain events shared across modules go in `shared.kernel.domain.event/` - One controller per resource domain (Auth, User, AdminUser) **Dependency Injection**: + - Prefer constructor injection over field injection - Use `@Autowired` on constructor (optional in single-constructor classes) - Inject interfaces, not implementations (e.g., UserRepository, not JpaRepository) **Validation**: + - Use Jakarta validation annotations on DTOs (@NotBlank, @Email, @Size, @Pattern) - Business validation in service layer (throw ValidationException) - Method-level validation with @Valid on controller parameters **Logging**: -- Use SLF4J with class-level static final logger: `private static final Logger log = LoggerFactory.getLogger(ClassName.class);` + +- Use SLF4J with class-level static final logger: + `private static final Logger log = LoggerFactory.getLogger(ClassName.class);` - Log levels: DEBUG for app code, INFO for Spring/Hibernate, ERROR for exceptions - Log format includes timestamp, thread, level, logger name, message **Error Handling**: + - Throw custom exceptions from services (AuthenticationException, ValidationException, ResourceNotFoundException) - Let GlobalExceptionHandler convert to HTTP responses - Include descriptive error messages @@ -452,25 +520,30 @@ class ModulithArchitectureTest { 2. **Email Normalization**: All emails converted to lowercase before storage/comparison -3. **Token Family Tracking**: Critical for security - same family UUID must persist across refresh operations within a session +3. **Token Family Tracking**: Critical for security - same family UUID must persist across refresh operations within a + session 4. **Lazy Loading**: RefreshToken relationship on User is FetchType.LAZY to prevent N+1 queries -5. **Transaction Boundaries**: Service methods are transactional; repository method execution participates in service transaction +5. **Transaction Boundaries**: Service methods are transactional; repository method execution participates in service + transaction 6. **Password Validation**: Custom @Pattern regex enforces lowercase, uppercase, and digit requirements 7. **Role-Based Access**: Controllers use @PreAuthorize("hasRole('ADMIN')") for admin-only endpoints -8. **Database Indexes**: Critical indexes on users.email, refresh_tokens.token, refresh_tokens.token_family, refresh_tokens.user_id +8. **Database Indexes**: Critical indexes on users.email, refresh_tokens.token, refresh_tokens.token_family, + refresh_tokens.user_id -9. **Actuator Endpoints**: Health checks, metrics, and Prometheus scraping on /actuator/* (configured in application.yml) +9. **Actuator Endpoints**: Health checks, metrics, and Prometheus scraping on /actuator/* (configured in + application.yml) 10. **API Documentation**: Swagger UI available at http://localhost:3001/swagger-ui.html (configured via OpenApiConfig) ## Common Development Tasks **Adding a New Endpoint** (in User module): + 1. Create request DTO in `user/interfaces/rest/request/` 2. Create command DTO in `user/application/dto/command/` 3. Add mapper method in `user/interfaces/rest/mapper/` @@ -480,6 +553,7 @@ class ModulithArchitectureTest { 7. Run `./gradlew test` to verify **Adding a New Module**: + 1. Create package `org.nkcoder.{modulename}/` 2. Create `package-info.java` with `@ApplicationModule(allowedDependencies = {"shared", "infrastructure"})` 3. Create module structure: `interfaces/`, `application/`, `domain/`, `infrastructure/` @@ -487,37 +561,44 @@ class ModulithArchitectureTest { 5. Run `ModulithArchitectureTest` to verify module boundaries **Publishing Domain Events**: -1. Create event record in `shared/kernel/domain/event/` (if cross-module) or `{module}/domain/event/` (if module-internal) + +1. Create event record in `shared/kernel/domain/event/` (if cross-module) or `{module}/domain/event/` (if + module-internal) 2. Inject `DomainEventPublisher` in your service 3. Call `domainEventPublisher.publish(event)` after business logic 4. Create `@ApplicationModuleListener` in consuming module **Database Schema Change**: + 1. Create new migration file: `V{next_version}__{description}.sql` in `src/main/resources/db/migration/` 2. Update JPA entity in `{module}/infrastructure/persistence/entity/` if needed 3. Run `./gradlew bootRun` to apply migration 4. Verify with database client or integration test **Adding gRPC Endpoint**: + 1. Update `src/main/proto/auth.proto` with new RPC method 2. Run `./gradlew generateProto` to regenerate Java classes 3. Implement method in appropriate gRPC service (e.g., AuthGrpcService) 4. Add mapping logic in GrpcMapper if needed 5. Test with gRPC client (e.g., grpcurl) -**Password Requirements**: Must contain at least one lowercase letter, one uppercase letter, and one digit (enforced via @Pattern regex) +**Password Requirements**: Must contain at least one lowercase letter, one uppercase letter, and one digit (enforced via +@Pattern regex) **Role Assignment**: New users default to MEMBER role; ADMIN role must be explicitly assigned or seeded ## Spring Modulith Guidelines **Module Boundaries**: + - Never import internal classes from other modules (only public API) - Use domain events for cross-module communication - Shared code goes in `shared` module (marked as OPEN) - Run `./gradlew test` regularly - `ModulithArchitectureTest` catches violations **Event Best Practices**: + - Events are immutable records - Events should be past-tense (`UserRegistered`, not `RegisterUser`) - Cross-module events go in `shared.kernel.domain.event/` @@ -525,6 +606,7 @@ class ModulithArchitectureTest { **Future Microservice Extraction**: When ready to extract a module as a microservice: + 1. Events become messages (Kafka/RabbitMQ) 2. REST/gRPC calls replace direct method calls 3. Module's `infrastructure/` adapters change, domain stays the same diff --git a/auto/run b/auto/run index f521052..7a23ef5 100755 --- a/auto/run +++ b/auto/run @@ -1,3 +1,6 @@ #!/usr/bin/env sh +# Need to export the mail credentials for local run +#export MAIL_USERNAME= +#export MAIL_PASSWORD= ./gradlew bootRun --args='--spring.profiles.active=local' --no-daemon diff --git a/auto/test b/auto/test index 149dd12..90ccef9 100755 --- a/auto/test +++ b/auto/test @@ -1,4 +1,4 @@ #!/usr/bin/env sh -export SPRING_PROFILES_ACTIVE=test +export SPRING_PROFILES_ACTIVE=dev ./gradlew test jacocoTestCoverageVerification \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 6627fa5..09ca0fa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-jackson") + implementation("org.springframework.boot:spring-boot-starter-mail") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.0") implementation("org.springframework.modulith:spring-modulith-starter-core") implementation("org.springframework.modulith:spring-modulith-starter-jpa") diff --git a/src/main/java/org/nkcoder/Application.java b/src/main/java/org/nkcoder/Application.java index d2ccc55..55c7824 100644 --- a/src/main/java/org/nkcoder/Application.java +++ b/src/main/java/org/nkcoder/Application.java @@ -4,12 +4,14 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.modulith.Modulith; +import org.springframework.scheduling.annotation.EnableScheduling; @Modulith( systemName = "Application", sharedModules = {"shared", "infrastructure"}) @SpringBootApplication @ConfigurationPropertiesScan +@EnableScheduling public class Application { static void main(String[] args) { SpringApplication.run(Application.class, args); diff --git a/src/main/java/org/nkcoder/notification/NotificationService.java b/src/main/java/org/nkcoder/notification/NotificationService.java index a46fa76..039fd5d 100644 --- a/src/main/java/org/nkcoder/notification/NotificationService.java +++ b/src/main/java/org/nkcoder/notification/NotificationService.java @@ -1,20 +1,31 @@ package org.nkcoder.notification; +import org.nkcoder.notification.infrastructure.email.EmailService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; @Service public class NotificationService { + private final Logger logger = LoggerFactory.getLogger(this.getClass()); + private final EmailService emailService; + + public NotificationService(EmailService emailService) { + this.emailService = emailService; + } public void sendWelcomeEmail(String email, String userName) { - // TODO: implement email sending logger.info("Sending Welcome email to {}, for user: {}", email, userName); + emailService.sendWelcomeEmail(email, userName); + } + + public void sendOtpEmail(String email, String userName, String otpCode, int expirationMinutes) { + logger.info("Sending OTP email to {}, for user: {}", email, userName); + emailService.sendOtpEmail(email, userName, otpCode, expirationMinutes); } public void sendPasswordResetEmail(String email, String userName) { - // TODO: implement password reset email logger.info("Sending password reset email to {}, for user: {}", email, userName); } } diff --git a/src/main/java/org/nkcoder/notification/application/OtpEventListener.java b/src/main/java/org/nkcoder/notification/application/OtpEventListener.java new file mode 100644 index 0000000..87c3601 --- /dev/null +++ b/src/main/java/org/nkcoder/notification/application/OtpEventListener.java @@ -0,0 +1,21 @@ +package org.nkcoder.notification.application; + +import org.nkcoder.notification.NotificationService; +import org.nkcoder.shared.kernel.domain.event.OtpRequestedEvent; +import org.springframework.modulith.events.ApplicationModuleListener; +import org.springframework.stereotype.Component; + +@Component +public class OtpEventListener { + + private final NotificationService notificationService; + + public OtpEventListener(NotificationService notificationService) { + this.notificationService = notificationService; + } + + @ApplicationModuleListener + public void onOtpRequested(OtpRequestedEvent event) { + notificationService.sendOtpEmail(event.email(), event.userName(), event.otpCode(), event.expirationMinutes()); + } +} diff --git a/src/main/java/org/nkcoder/notification/infrastructure/config/MailProperties.java b/src/main/java/org/nkcoder/notification/infrastructure/config/MailProperties.java new file mode 100644 index 0000000..80bc05a --- /dev/null +++ b/src/main/java/org/nkcoder/notification/infrastructure/config/MailProperties.java @@ -0,0 +1,6 @@ +package org.nkcoder.notification.infrastructure.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.mail") +public record MailProperties(String from, String fromName) {} diff --git a/src/main/java/org/nkcoder/notification/infrastructure/email/EmailService.java b/src/main/java/org/nkcoder/notification/infrastructure/email/EmailService.java new file mode 100644 index 0000000..1446f86 --- /dev/null +++ b/src/main/java/org/nkcoder/notification/infrastructure/email/EmailService.java @@ -0,0 +1,86 @@ +package org.nkcoder.notification.infrastructure.email; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import java.io.UnsupportedEncodingException; +import org.nkcoder.notification.infrastructure.config.MailProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; + +@Service +public class EmailService { + + private static final Logger logger = LoggerFactory.getLogger(EmailService.class); + + private final JavaMailSender mailSender; + private final MailProperties mailProperties; + + public EmailService(JavaMailSender mailSender, MailProperties mailProperties) { + this.mailSender = mailSender; + this.mailProperties = mailProperties; + } + + public void sendOtpEmail(String to, String userName, String otpCode, int expirationMinutes) { + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setFrom(mailProperties.from(), mailProperties.fromName()); + helper.setTo(to); + helper.setSubject("Your Login Code"); + + String htmlContent = buildOtpEmailContent(userName, otpCode, expirationMinutes); + helper.setText(htmlContent, true); + + mailSender.send(message); + logger.info("OTP email sent successfully to: {}", to); + + } catch (MessagingException | UnsupportedEncodingException e) { + logger.error("Failed to send OTP email to {}: {}", to, e.getMessage()); + throw new RuntimeException("Failed to send email", e); + } + } + + private String buildOtpEmailContent(String userName, String otpCode, int expirationMinutes) { + return """ + + + + + + + +
+

Hello %s,

+

You requested a login code for your account. Use the code below:

+
%s
+

This code will expire in %d minutes.

+

If you didn't request this code, please ignore this email.

+
+ + + """.formatted(userName, otpCode, expirationMinutes); + } + + public void sendWelcomeEmail(String to, String userName) { + logger.info("Welcome email sent to: {} for user: {}", to, userName); + } +} diff --git a/src/main/java/org/nkcoder/shared/kernel/domain/event/OtpRequestedEvent.java b/src/main/java/org/nkcoder/shared/kernel/domain/event/OtpRequestedEvent.java new file mode 100644 index 0000000..6044523 --- /dev/null +++ b/src/main/java/org/nkcoder/shared/kernel/domain/event/OtpRequestedEvent.java @@ -0,0 +1,22 @@ +package org.nkcoder.shared.kernel.domain.event; + +import java.time.LocalDateTime; + +public record OtpRequestedEvent( + String email, String userName, String otpCode, int expirationMinutes, LocalDateTime occurredOn) + implements DomainEvent { + + public OtpRequestedEvent(String email, String userName, String otpCode, int expirationMinutes) { + this(email, userName, otpCode, expirationMinutes, LocalDateTime.now()); + } + + @Override + public String eventType() { + return "otp.requested"; + } + + @Override + public LocalDateTime occurredOn() { + return occurredOn; + } +} diff --git a/src/main/java/org/nkcoder/user/application/dto/command/RequestOtpCommand.java b/src/main/java/org/nkcoder/user/application/dto/command/RequestOtpCommand.java new file mode 100644 index 0000000..79a66f7 --- /dev/null +++ b/src/main/java/org/nkcoder/user/application/dto/command/RequestOtpCommand.java @@ -0,0 +1,3 @@ +package org.nkcoder.user.application.dto.command; + +public record RequestOtpCommand(String email) {} diff --git a/src/main/java/org/nkcoder/user/application/dto/command/VerifyOtpCommand.java b/src/main/java/org/nkcoder/user/application/dto/command/VerifyOtpCommand.java new file mode 100644 index 0000000..81da265 --- /dev/null +++ b/src/main/java/org/nkcoder/user/application/dto/command/VerifyOtpCommand.java @@ -0,0 +1,3 @@ +package org.nkcoder.user.application.dto.command; + +public record VerifyOtpCommand(String email, String otp) {} diff --git a/src/main/java/org/nkcoder/user/application/service/OtpApplicationService.java b/src/main/java/org/nkcoder/user/application/service/OtpApplicationService.java new file mode 100644 index 0000000..8d503c0 --- /dev/null +++ b/src/main/java/org/nkcoder/user/application/service/OtpApplicationService.java @@ -0,0 +1,183 @@ +package org.nkcoder.user.application.service; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Optional; +import java.util.stream.Collectors; +import org.nkcoder.shared.kernel.domain.event.DomainEventPublisher; +import org.nkcoder.shared.kernel.domain.event.OtpRequestedEvent; +import org.nkcoder.shared.kernel.domain.event.UserRegisteredEvent; +import org.nkcoder.shared.kernel.exception.AuthenticationException; +import org.nkcoder.shared.kernel.exception.ValidationException; +import org.nkcoder.user.application.dto.command.RequestOtpCommand; +import org.nkcoder.user.application.dto.command.VerifyOtpCommand; +import org.nkcoder.user.application.dto.response.AuthResult; +import org.nkcoder.user.domain.model.Email; +import org.nkcoder.user.domain.model.OtpCode; +import org.nkcoder.user.domain.model.OtpToken; +import org.nkcoder.user.domain.model.RefreshToken; +import org.nkcoder.user.domain.model.TokenFamily; +import org.nkcoder.user.domain.model.TokenPair; +import org.nkcoder.user.domain.model.User; +import org.nkcoder.user.domain.model.UserName; +import org.nkcoder.user.domain.repository.OtpTokenRepository; +import org.nkcoder.user.domain.repository.RefreshTokenRepository; +import org.nkcoder.user.domain.repository.UserRepository; +import org.nkcoder.user.domain.service.OtpHasher; +import org.nkcoder.user.domain.service.TokenGenerator; +import org.nkcoder.user.domain.service.TokenRotationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class OtpApplicationService { + + private static final Logger logger = LoggerFactory.getLogger(OtpApplicationService.class); + + private static final String USER_NOT_FOUND = "User not found"; + private static final String INVALID_OTP = "Invalid or expired OTP"; + private static final String TOO_MANY_REQUESTS = "Too many OTP requests. Please try again later."; + private static final String MAX_ATTEMPTS_EXCEEDED = "Maximum verification attempts exceeded"; + + private static final int RATE_LIMIT_WINDOW_MINUTES = 15; + private static final int MAX_REQUESTS_PER_WINDOW = 3; + + private final UserRepository userRepository; + private final OtpTokenRepository otpTokenRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final OtpHasher otpHasher; + private final TokenGenerator tokenGenerator; + private final TokenRotationService tokenRotationService; + private final DomainEventPublisher eventPublisher; + + public OtpApplicationService( + UserRepository userRepository, + OtpTokenRepository otpTokenRepository, + RefreshTokenRepository refreshTokenRepository, + OtpHasher otpHasher, + TokenGenerator tokenGenerator, + TokenRotationService tokenRotationService, + DomainEventPublisher eventPublisher) { + this.userRepository = userRepository; + this.otpTokenRepository = otpTokenRepository; + this.refreshTokenRepository = refreshTokenRepository; + this.otpHasher = otpHasher; + this.tokenGenerator = tokenGenerator; + this.tokenRotationService = tokenRotationService; + this.eventPublisher = eventPublisher; + } + + @Transactional + public void requestOtp(RequestOtpCommand command) { + logger.debug("OTP request for email: {}", command.email()); + + Email email = Email.of(command.email()); + + validateRateLimit(email); + + User user = userRepository.findByEmail(email).orElseGet(() -> registerNewUser(email)); + + otpTokenRepository.deleteByEmail(email); + + OtpCode rawOtp = OtpCode.generate(); + String hashedOtp = otpHasher.hash(rawOtp.value()); + + OtpToken otpToken = OtpToken.create(email, hashedOtp); + otpTokenRepository.save(otpToken); + + eventPublisher.publish(new OtpRequestedEvent( + email.value(), user.getName().value(), rawOtp.value(), OtpToken.EXPIRATION_MINUTES)); + + logger.debug("OTP requested successfully for email: {}", email.value()); + } + + private void validateRateLimit(Email email) { + LocalDateTime windowStart = LocalDateTime.now().minusMinutes(RATE_LIMIT_WINDOW_MINUTES); + int recentRequests = otpTokenRepository.countRecentRequests(email, windowStart); + if (recentRequests >= MAX_REQUESTS_PER_WINDOW) { + throw new ValidationException(TOO_MANY_REQUESTS); + } + } + + private User registerNewUser(Email email) { + UserName name = generateNameFromEmail(email); + User newUser = User.registerWithOtp(email, name); + User savedUser = userRepository.save(newUser); + + eventPublisher.publish(new UserRegisteredEvent( + savedUser.getId().value(), + savedUser.getEmail().value(), + savedUser.getName().value())); + + logger.debug("Auto-registered new user with email: {}", email.value()); + return savedUser; + } + + private UserName generateNameFromEmail(Email email) { + return Optional.of(email.value()) + .map(e -> e.split("@")[0]) + .map(localPart -> localPart.replace(".", " ").replace("_", " ").replace("-", " ")) + .map(this::capitalizeWords) + .map(UserName::of) + .orElseGet(() -> UserName.of("User")); + } + + private String capitalizeWords(String input) { + return Arrays.stream(input.split("\\s+")) + .filter(part -> !part.isEmpty()) + .map(part -> + part.substring(0, 1).toUpperCase() + part.substring(1).toLowerCase()) + .collect(Collectors.joining(" ")); + } + + @Transactional + public AuthResult verifyOtp(VerifyOtpCommand command) { + logger.debug("OTP verification for email: {}", command.email()); + + Email email = Email.of(command.email()); + + OtpToken otpToken = + otpTokenRepository.findByEmail(email).orElseThrow(() -> new AuthenticationException(INVALID_OTP)); + + if (!otpToken.isValid()) { + otpTokenRepository.deleteByEmail(email); + String message = otpToken.isMaxAttemptsExceeded() ? MAX_ATTEMPTS_EXCEEDED : INVALID_OTP; + throw new AuthenticationException(message); + } + + if (!otpHasher.verify(command.otp(), otpToken.getHashedOtp())) { + otpToken.incrementAttempts(); + otpTokenRepository.updateAttempts(email, otpToken.getAttempts()); + + if (otpToken.isMaxAttemptsExceeded()) { + otpTokenRepository.deleteByEmail(email); + throw new AuthenticationException(MAX_ATTEMPTS_EXCEEDED); + } + throw new AuthenticationException(INVALID_OTP); + } + + otpTokenRepository.deleteByEmail(email); + + User user = userRepository.findByEmail(email).orElseThrow(() -> new AuthenticationException(USER_NOT_FOUND)); + + userRepository.updateLastLoginAt(user.getId(), LocalDateTime.now()); + + TokenFamily tokenFamily = TokenFamily.generate(); + TokenPair tokens = tokenRotationService.generateTokens(user, tokenFamily); + + RefreshToken refreshToken = RefreshToken.create( + tokens.refreshToken(), tokenFamily, user.getId(), tokenGenerator.getRefreshTokenExpiry()); + refreshTokenRepository.save(refreshToken); + + logger.debug("OTP verified successfully for email: {}", email.value()); + return AuthResult.of(user.getId().value(), user.getEmail().value(), user.getRole(), tokens); + } + + @Transactional + public void cleanupExpiredTokens() { + logger.debug("Cleaning up expired OTP tokens"); + otpTokenRepository.deleteExpiredTokens(LocalDateTime.now()); + } +} diff --git a/src/main/java/org/nkcoder/user/domain/model/OtpCode.java b/src/main/java/org/nkcoder/user/domain/model/OtpCode.java new file mode 100644 index 0000000..5d4263b --- /dev/null +++ b/src/main/java/org/nkcoder/user/domain/model/OtpCode.java @@ -0,0 +1,25 @@ +package org.nkcoder.user.domain.model; + +import java.security.SecureRandom; +import java.util.Objects; + +public record OtpCode(String value) { + private static final SecureRandom RANDOM = new SecureRandom(); + private static final int OTP_LENGTH = 6; + + public OtpCode { + Objects.requireNonNull(value, "OTP code cannot be null"); + if (value.length() != OTP_LENGTH || !value.matches("\\d{6}")) { + throw new IllegalArgumentException("OTP must be exactly 6 digits"); + } + } + + public static OtpCode generate() { + int otp = RANDOM.nextInt(900000) + 100000; + return new OtpCode(String.valueOf(otp)); + } + + public static OtpCode of(String value) { + return new OtpCode(value); + } +} diff --git a/src/main/java/org/nkcoder/user/domain/model/OtpToken.java b/src/main/java/org/nkcoder/user/domain/model/OtpToken.java new file mode 100644 index 0000000..81b02ba --- /dev/null +++ b/src/main/java/org/nkcoder/user/domain/model/OtpToken.java @@ -0,0 +1,78 @@ +package org.nkcoder.user.domain.model; + +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.UUID; + +public class OtpToken { + + public static final int MAX_ATTEMPTS = 3; + public static final int EXPIRATION_MINUTES = 5; + + private final UUID id; + private final Email email; + private final String hashedOtp; + private int attempts; + private final LocalDateTime expiresAt; + private final LocalDateTime createdAt; + + private OtpToken( + UUID id, Email email, String hashedOtp, int attempts, LocalDateTime expiresAt, LocalDateTime createdAt) { + this.id = Objects.requireNonNull(id, "id cannot be null"); + this.email = Objects.requireNonNull(email, "email cannot be null"); + this.hashedOtp = Objects.requireNonNull(hashedOtp, "hashedOtp cannot be null"); + this.attempts = attempts; + this.expiresAt = Objects.requireNonNull(expiresAt, "expiresAt cannot be null"); + this.createdAt = Objects.requireNonNull(createdAt, "createdAt cannot be null"); + } + + public static OtpToken create(Email email, String hashedOtp) { + LocalDateTime now = LocalDateTime.now(); + return new OtpToken(UUID.randomUUID(), email, hashedOtp, 0, now.plusMinutes(EXPIRATION_MINUTES), now); + } + + public static OtpToken reconstitute( + UUID id, Email email, String hashedOtp, int attempts, LocalDateTime expiresAt, LocalDateTime createdAt) { + return new OtpToken(id, email, hashedOtp, attempts, expiresAt, createdAt); + } + + public boolean isExpired() { + return LocalDateTime.now().isAfter(expiresAt); + } + + public boolean isMaxAttemptsExceeded() { + return attempts >= MAX_ATTEMPTS; + } + + public boolean isValid() { + return !isExpired() && !isMaxAttemptsExceeded(); + } + + public void incrementAttempts() { + this.attempts++; + } + + public UUID getId() { + return id; + } + + public Email getEmail() { + return email; + } + + public String getHashedOtp() { + return hashedOtp; + } + + public int getAttempts() { + return attempts; + } + + public LocalDateTime getExpiresAt() { + return expiresAt; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/src/main/java/org/nkcoder/user/domain/model/User.java b/src/main/java/org/nkcoder/user/domain/model/User.java index dc1cb34..7c278b4 100644 --- a/src/main/java/org/nkcoder/user/domain/model/User.java +++ b/src/main/java/org/nkcoder/user/domain/model/User.java @@ -33,7 +33,7 @@ private User( LocalDateTime updatedAt) { this.id = Objects.requireNonNull(id, "User ID cannot be null"); this.email = Objects.requireNonNull(email, "Email cannot be null"); - this.password = Objects.requireNonNull(password, "Password cannot be null"); + this.password = password; // Nullable for OTP-only users this.name = Objects.requireNonNull(name, "Name cannot be null"); this.role = role != null ? role : UserRole.MEMBER; this.emailVerified = emailVerified; @@ -42,12 +42,18 @@ private User( this.updatedAt = updatedAt; } - /** Factory method for creating a new user during registration. */ + /** Factory method for creating a new user during registration with password. */ public static User register(Email email, HashedPassword password, UserName name, UserRole role) { + Objects.requireNonNull(password, "Password cannot be null"); LocalDateTime now = LocalDateTime.now(); return new User(UserId.generate(), email, password, name, role, false, null, now, now); } + public static User registerWithOtp(Email email, UserName name) { + LocalDateTime now = LocalDateTime.now(); + return new User(UserId.generate(), email, null, name, UserRole.MEMBER, false, null, now, now); + } + /** Factory method for reconstituting from persistence. */ public static User reconstitute( UserId id, diff --git a/src/main/java/org/nkcoder/user/domain/repository/OtpTokenRepository.java b/src/main/java/org/nkcoder/user/domain/repository/OtpTokenRepository.java new file mode 100644 index 0000000..08848b3 --- /dev/null +++ b/src/main/java/org/nkcoder/user/domain/repository/OtpTokenRepository.java @@ -0,0 +1,21 @@ +package org.nkcoder.user.domain.repository; + +import java.time.LocalDateTime; +import java.util.Optional; +import org.nkcoder.user.domain.model.Email; +import org.nkcoder.user.domain.model.OtpToken; + +public interface OtpTokenRepository { + + Optional findByEmail(Email email); + + OtpToken save(OtpToken otpToken); + + void deleteByEmail(Email email); + + void deleteExpiredTokens(LocalDateTime now); + + int countRecentRequests(Email email, LocalDateTime since); + + void updateAttempts(Email email, int attempts); +} diff --git a/src/main/java/org/nkcoder/user/domain/service/OtpHasher.java b/src/main/java/org/nkcoder/user/domain/service/OtpHasher.java new file mode 100644 index 0000000..5dbb856 --- /dev/null +++ b/src/main/java/org/nkcoder/user/domain/service/OtpHasher.java @@ -0,0 +1,8 @@ +package org.nkcoder.user.domain.service; + +public interface OtpHasher { + + String hash(String rawOtp); + + boolean verify(String rawOtp, String hashedOtp); +} diff --git a/src/main/java/org/nkcoder/user/infrastructure/persistence/entity/OtpTokenJpaEntity.java b/src/main/java/org/nkcoder/user/infrastructure/persistence/entity/OtpTokenJpaEntity.java new file mode 100644 index 0000000..471eca3 --- /dev/null +++ b/src/main/java/org/nkcoder/user/infrastructure/persistence/entity/OtpTokenJpaEntity.java @@ -0,0 +1,91 @@ +package org.nkcoder.user.infrastructure.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; +import java.time.LocalDateTime; +import java.util.UUID; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.domain.Persistable; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Entity +@Table(name = "otp_tokens") +@EntityListeners(AuditingEntityListener.class) +public class OtpTokenJpaEntity implements Persistable { + + @Id + private UUID id; + + @Column(nullable = false, unique = true) + private String email; + + @Column(name = "otp_code", nullable = false) + private String otpCode; + + @Column(nullable = false) + private int attempts = 0; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Transient + private boolean isNew = false; + + protected OtpTokenJpaEntity() {} + + public OtpTokenJpaEntity( + UUID id, String email, String otpCode, int attempts, LocalDateTime expiresAt, LocalDateTime createdAt) { + this.id = id; + this.email = email; + this.otpCode = otpCode; + this.attempts = attempts; + this.expiresAt = expiresAt; + this.createdAt = createdAt; + } + + @Override + public UUID getId() { + return id; + } + + public String getEmail() { + return email; + } + + public String getOtpCode() { + return otpCode; + } + + public int getAttempts() { + return attempts; + } + + public void setAttempts(int attempts) { + this.attempts = attempts; + } + + public LocalDateTime getExpiresAt() { + return expiresAt; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + @Override + public boolean isNew() { + return isNew; + } + + public void markAsNew() { + this.isNew = true; + } +} diff --git a/src/main/java/org/nkcoder/user/infrastructure/persistence/entity/UserJpaEntity.java b/src/main/java/org/nkcoder/user/infrastructure/persistence/entity/UserJpaEntity.java index 2b8fab4..9a60f4d 100644 --- a/src/main/java/org/nkcoder/user/infrastructure/persistence/entity/UserJpaEntity.java +++ b/src/main/java/org/nkcoder/user/infrastructure/persistence/entity/UserJpaEntity.java @@ -37,7 +37,7 @@ public class UserJpaEntity implements Persistable { @Column(nullable = false, unique = true) private String email; - @Column(nullable = false) + @Column(nullable = true) private String password; @Column(nullable = false) diff --git a/src/main/java/org/nkcoder/user/infrastructure/persistence/mapper/OtpTokenPersistenceMapper.java b/src/main/java/org/nkcoder/user/infrastructure/persistence/mapper/OtpTokenPersistenceMapper.java new file mode 100644 index 0000000..d516d8b --- /dev/null +++ b/src/main/java/org/nkcoder/user/infrastructure/persistence/mapper/OtpTokenPersistenceMapper.java @@ -0,0 +1,36 @@ +package org.nkcoder.user.infrastructure.persistence.mapper; + +import org.nkcoder.user.domain.model.Email; +import org.nkcoder.user.domain.model.OtpToken; +import org.nkcoder.user.infrastructure.persistence.entity.OtpTokenJpaEntity; +import org.springframework.stereotype.Component; + +@Component +public class OtpTokenPersistenceMapper { + + public OtpToken toDomain(OtpTokenJpaEntity entity) { + return OtpToken.reconstitute( + entity.getId(), + Email.of(entity.getEmail()), + entity.getOtpCode(), + entity.getAttempts(), + entity.getExpiresAt(), + entity.getCreatedAt()); + } + + public OtpTokenJpaEntity toEntity(OtpToken domain) { + return new OtpTokenJpaEntity( + domain.getId(), + domain.getEmail().value(), + domain.getHashedOtp(), + domain.getAttempts(), + domain.getExpiresAt(), + domain.getCreatedAt()); + } + + public OtpTokenJpaEntity toNewEntity(OtpToken domain) { + OtpTokenJpaEntity entity = toEntity(domain); + entity.markAsNew(); + return entity; + } +} diff --git a/src/main/java/org/nkcoder/user/infrastructure/persistence/mapper/UserPersistenceMapper.java b/src/main/java/org/nkcoder/user/infrastructure/persistence/mapper/UserPersistenceMapper.java index e18229a..f5e1378 100644 --- a/src/main/java/org/nkcoder/user/infrastructure/persistence/mapper/UserPersistenceMapper.java +++ b/src/main/java/org/nkcoder/user/infrastructure/persistence/mapper/UserPersistenceMapper.java @@ -16,7 +16,7 @@ public User toDomain(UserJpaEntity entity) { return User.reconstitute( UserId.of(entity.getId()), Email.of(entity.getEmail()), - HashedPassword.of(entity.getPassword()), + entity.getPassword() != null ? HashedPassword.of(entity.getPassword()) : null, UserName.of(entity.getName()), entity.getRole(), entity.isEmailVerified(), @@ -29,7 +29,7 @@ public UserJpaEntity toEntity(User user) { return new UserJpaEntity( user.getId().value(), user.getEmail().value(), - user.getPassword().value(), + user.getPassword() != null ? user.getPassword().value() : null, user.getName().value(), user.getRole(), user.isEmailVerified(), diff --git a/src/main/java/org/nkcoder/user/infrastructure/persistence/repository/OtpTokenJpaRepository.java b/src/main/java/org/nkcoder/user/infrastructure/persistence/repository/OtpTokenJpaRepository.java new file mode 100644 index 0000000..63696f1 --- /dev/null +++ b/src/main/java/org/nkcoder/user/infrastructure/persistence/repository/OtpTokenJpaRepository.java @@ -0,0 +1,32 @@ +package org.nkcoder.user.infrastructure.persistence.repository; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; +import org.nkcoder.user.infrastructure.persistence.entity.OtpTokenJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface OtpTokenJpaRepository extends JpaRepository { + + Optional findByEmail(String email); + + @Modifying + @Query("DELETE FROM OtpTokenJpaEntity o WHERE o.email = :email") + void deleteByEmail(@Param("email") String email); + + @Modifying + @Query("DELETE FROM OtpTokenJpaEntity o WHERE o.expiresAt < :now") + void deleteExpiredTokens(@Param("now") LocalDateTime now); + + @Query("SELECT COUNT(o) FROM OtpTokenJpaEntity o WHERE o.email = :email AND o.createdAt > :since") + int countByEmailAndCreatedAtAfter(@Param("email") String email, @Param("since") LocalDateTime since); + + @Modifying + @Query("UPDATE OtpTokenJpaEntity o SET o.attempts = :attempts WHERE o.email = :email") + void updateAttempts(@Param("email") String email, @Param("attempts") int attempts); +} diff --git a/src/main/java/org/nkcoder/user/infrastructure/persistence/repository/OtpTokenRepositoryAdapter.java b/src/main/java/org/nkcoder/user/infrastructure/persistence/repository/OtpTokenRepositoryAdapter.java new file mode 100644 index 0000000..97118e1 --- /dev/null +++ b/src/main/java/org/nkcoder/user/infrastructure/persistence/repository/OtpTokenRepositoryAdapter.java @@ -0,0 +1,59 @@ +package org.nkcoder.user.infrastructure.persistence.repository; + +import java.time.LocalDateTime; +import java.util.Optional; +import org.nkcoder.user.domain.model.Email; +import org.nkcoder.user.domain.model.OtpToken; +import org.nkcoder.user.domain.repository.OtpTokenRepository; +import org.nkcoder.user.infrastructure.persistence.mapper.OtpTokenPersistenceMapper; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +public class OtpTokenRepositoryAdapter implements OtpTokenRepository { + + private final OtpTokenJpaRepository jpaRepository; + private final OtpTokenPersistenceMapper mapper; + + public OtpTokenRepositoryAdapter(OtpTokenJpaRepository jpaRepository, OtpTokenPersistenceMapper mapper) { + this.jpaRepository = jpaRepository; + this.mapper = mapper; + } + + @Override + public Optional findByEmail(Email email) { + return jpaRepository.findByEmail(email.value()).map(mapper::toDomain); + } + + @Override + @Transactional + public OtpToken save(OtpToken otpToken) { + jpaRepository.deleteByEmail(otpToken.getEmail().value()); + var entity = mapper.toNewEntity(otpToken); + var savedEntity = jpaRepository.save(entity); + return mapper.toDomain(savedEntity); + } + + @Override + @Transactional + public void deleteByEmail(Email email) { + jpaRepository.deleteByEmail(email.value()); + } + + @Override + @Transactional + public void deleteExpiredTokens(LocalDateTime now) { + jpaRepository.deleteExpiredTokens(now); + } + + @Override + public int countRecentRequests(Email email, LocalDateTime since) { + return jpaRepository.countByEmailAndCreatedAtAfter(email.value(), since); + } + + @Override + @Transactional + public void updateAttempts(Email email, int attempts) { + jpaRepository.updateAttempts(email.value(), attempts); + } +} diff --git a/src/main/java/org/nkcoder/user/infrastructure/scheduler/OtpCleanupScheduler.java b/src/main/java/org/nkcoder/user/infrastructure/scheduler/OtpCleanupScheduler.java new file mode 100644 index 0000000..c8f9d85 --- /dev/null +++ b/src/main/java/org/nkcoder/user/infrastructure/scheduler/OtpCleanupScheduler.java @@ -0,0 +1,25 @@ +package org.nkcoder.user.infrastructure.scheduler; + +import org.nkcoder.user.application.service.OtpApplicationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +public class OtpCleanupScheduler { + + private static final Logger logger = LoggerFactory.getLogger(OtpCleanupScheduler.class); + + private final OtpApplicationService otpService; + + public OtpCleanupScheduler(OtpApplicationService otpService) { + this.otpService = otpService; + } + + @Scheduled(fixedRate = 600000) + public void cleanupExpiredOtpTokens() { + logger.debug("Running scheduled OTP cleanup"); + otpService.cleanupExpiredTokens(); + } +} diff --git a/src/main/java/org/nkcoder/user/infrastructure/security/BcryptOtpHasherAdapter.java b/src/main/java/org/nkcoder/user/infrastructure/security/BcryptOtpHasherAdapter.java new file mode 100644 index 0000000..871e457 --- /dev/null +++ b/src/main/java/org/nkcoder/user/infrastructure/security/BcryptOtpHasherAdapter.java @@ -0,0 +1,21 @@ +package org.nkcoder.user.infrastructure.security; + +import org.nkcoder.user.domain.service.OtpHasher; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +public class BcryptOtpHasherAdapter implements OtpHasher { + + private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(4); + + @Override + public String hash(String rawOtp) { + return encoder.encode(rawOtp); + } + + @Override + public boolean verify(String rawOtp, String hashedOtp) { + return encoder.matches(rawOtp, hashedOtp); + } +} diff --git a/src/main/java/org/nkcoder/user/infrastructure/security/SecurityConfig.java b/src/main/java/org/nkcoder/user/infrastructure/security/SecurityConfig.java index a3e4796..eb72da6 100644 --- a/src/main/java/org/nkcoder/user/infrastructure/security/SecurityConfig.java +++ b/src/main/java/org/nkcoder/user/infrastructure/security/SecurityConfig.java @@ -55,7 +55,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .frameOptions(HeadersConfigurer.FrameOptionsConfig::deny)) .authorizeHttpRequests(auth -> auth // Public auth endpoints - .requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/refresh") + .requestMatchers( + "/api/auth/register", "/api/auth/login", "/api/auth/refresh", "/api/auth/otp/**") .permitAll() // Actuator and health endpoints .requestMatchers("/actuator/health", "/actuator/info") diff --git a/src/main/java/org/nkcoder/user/interfaces/rest/AuthController.java b/src/main/java/org/nkcoder/user/interfaces/rest/AuthController.java index 684c6ca..4dd36e4 100644 --- a/src/main/java/org/nkcoder/user/interfaces/rest/AuthController.java +++ b/src/main/java/org/nkcoder/user/interfaces/rest/AuthController.java @@ -4,10 +4,13 @@ import org.nkcoder.shared.local.rest.ApiResponse; import org.nkcoder.user.application.dto.response.AuthResult; import org.nkcoder.user.application.service.AuthApplicationService; +import org.nkcoder.user.application.service.OtpApplicationService; import org.nkcoder.user.interfaces.rest.mapper.AuthRequestMapper; import org.nkcoder.user.interfaces.rest.request.LoginRequest; import org.nkcoder.user.interfaces.rest.request.RefreshTokenRequest; import org.nkcoder.user.interfaces.rest.request.RegisterRequest; +import org.nkcoder.user.interfaces.rest.request.RequestOtpRequest; +import org.nkcoder.user.interfaces.rest.request.VerifyOtpRequest; import org.nkcoder.user.interfaces.rest.response.AuthResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -25,10 +28,13 @@ public class AuthController { private static final Logger logger = LoggerFactory.getLogger(AuthController.class); private final AuthApplicationService authService; + private final OtpApplicationService otpService; private final AuthRequestMapper requestMapper; - public AuthController(AuthApplicationService authService, AuthRequestMapper requestMapper) { + public AuthController( + AuthApplicationService authService, OtpApplicationService otpService, AuthRequestMapper requestMapper) { this.authService = authService; + this.otpService = otpService; this.requestMapper = requestMapper; } @@ -77,4 +83,22 @@ public ResponseEntity> logoutSingle(@Valid @RequestBody Refres return ResponseEntity.ok(ApiResponse.success("Logged out from current device")); } + + @PostMapping("/otp/request") + public ResponseEntity> requestOtp(@Valid @RequestBody RequestOtpRequest request) { + logger.info("OTP request for email: {}", request.email()); + + otpService.requestOtp(requestMapper.toCommand(request)); + + return ResponseEntity.ok(ApiResponse.success("If an account exists with this email, an OTP has been sent")); + } + + @PostMapping("/otp/verify") + public ResponseEntity> verifyOtp(@Valid @RequestBody VerifyOtpRequest request) { + logger.info("OTP verification for email: {}", request.email()); + + AuthResult result = otpService.verifyOtp(requestMapper.toCommand(request)); + + return ResponseEntity.ok(ApiResponse.success("Login successful", AuthResponse.from(result))); + } } diff --git a/src/main/java/org/nkcoder/user/interfaces/rest/mapper/AuthRequestMapper.java b/src/main/java/org/nkcoder/user/interfaces/rest/mapper/AuthRequestMapper.java index 227581e..3245375 100644 --- a/src/main/java/org/nkcoder/user/interfaces/rest/mapper/AuthRequestMapper.java +++ b/src/main/java/org/nkcoder/user/interfaces/rest/mapper/AuthRequestMapper.java @@ -3,9 +3,13 @@ import org.nkcoder.user.application.dto.command.LoginCommand; import org.nkcoder.user.application.dto.command.RefreshTokenCommand; import org.nkcoder.user.application.dto.command.RegisterCommand; +import org.nkcoder.user.application.dto.command.RequestOtpCommand; +import org.nkcoder.user.application.dto.command.VerifyOtpCommand; import org.nkcoder.user.interfaces.rest.request.LoginRequest; import org.nkcoder.user.interfaces.rest.request.RefreshTokenRequest; import org.nkcoder.user.interfaces.rest.request.RegisterRequest; +import org.nkcoder.user.interfaces.rest.request.RequestOtpRequest; +import org.nkcoder.user.interfaces.rest.request.VerifyOtpRequest; import org.springframework.stereotype.Component; /** Mapper for converting REST requests to application commands. */ @@ -23,4 +27,12 @@ public LoginCommand toCommand(LoginRequest request) { public RefreshTokenCommand toCommand(RefreshTokenRequest request) { return new RefreshTokenCommand(request.refreshToken()); } + + public RequestOtpCommand toCommand(RequestOtpRequest request) { + return new RequestOtpCommand(request.email()); + } + + public VerifyOtpCommand toCommand(VerifyOtpRequest request) { + return new VerifyOtpCommand(request.email(), request.otp()); + } } diff --git a/src/main/java/org/nkcoder/user/interfaces/rest/request/RequestOtpRequest.java b/src/main/java/org/nkcoder/user/interfaces/rest/request/RequestOtpRequest.java new file mode 100644 index 0000000..e9fc8c6 --- /dev/null +++ b/src/main/java/org/nkcoder/user/interfaces/rest/request/RequestOtpRequest.java @@ -0,0 +1,14 @@ +package org.nkcoder.user.interfaces.rest.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record RequestOtpRequest( + @NotBlank(message = "Email is required") @Email(message = "Please provide a valid email") String email) { + + public RequestOtpRequest { + if (email != null) { + email = email.toLowerCase().trim(); + } + } +} diff --git a/src/main/java/org/nkcoder/user/interfaces/rest/request/VerifyOtpRequest.java b/src/main/java/org/nkcoder/user/interfaces/rest/request/VerifyOtpRequest.java new file mode 100644 index 0000000..6e52898 --- /dev/null +++ b/src/main/java/org/nkcoder/user/interfaces/rest/request/VerifyOtpRequest.java @@ -0,0 +1,17 @@ +package org.nkcoder.user.interfaces.rest.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record VerifyOtpRequest( + @NotBlank(message = "Email is required") @Email(message = "Please provide a valid email") String email, + + @NotBlank(message = "OTP is required") @Pattern(regexp = "\\d{6}", message = "OTP must be exactly 6 digits") String otp) { + + public VerifyOtpRequest { + if (email != null) { + email = email.toLowerCase().trim(); + } + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 2ae3c70..d4bde96 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -31,6 +31,12 @@ spring: max-lifetime: 1800000 connection-timeout: 30000 + mail: + host: email-smtp.ap-southeast-2.amazonaws.com + port: 587 + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} + # gRPC reflection enabled for dev debugging grpc: server: diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 673c466..bbca487 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -26,6 +26,16 @@ spring: reflection: enabled: true + mail: + host: email-smtp.ap-southeast-2.amazonaws.com + port: 587 + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} + +app: + mail: + from: noreply@daniel-guo.com + # Enable Swagger UI for local development springdoc: api-docs: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 82ee6ad..4c57152 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -58,6 +58,23 @@ spring: reflection: enabled: true + # Mail Configuration + mail: + host: ${MAIL_HOST:smtp.gmail.com} + port: ${MAIL_PORT:587} + username: ${MAIL_USERNAME:} + password: ${MAIL_PASSWORD:} + properties: + mail: + smtp: + auth: true + starttls: + enabled: true + required: true + connectiontimeout: 5000 + timeout: 5000 + writetimeout: 5000 + # ----------------------------------------------------------------------------- # JWT Configuration # ----------------------------------------------------------------------------- @@ -72,6 +89,14 @@ jwt: refresh: ${JWT_REFRESH_EXPIRES_IN:7d} issuer: ${JWT_ISSUER:user-service} +# ----------------------------------------------------------------------------- +# Application Mail Settings +# ----------------------------------------------------------------------------- +app: + mail: + from: ${MAIL_FROM:noreply@example.com} + from-name: ${MAIL_FROM_NAME:User Service} + # ----------------------------------------------------------------------------- # CORS Configuration # ----------------------------------------------------------------------------- diff --git a/src/main/resources/db/migration/V1.5__create_otp_table.sql b/src/main/resources/db/migration/V1.5__create_otp_table.sql new file mode 100644 index 0000000..c895e24 --- /dev/null +++ b/src/main/resources/db/migration/V1.5__create_otp_table.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS otp_tokens +( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email VARCHAR(255) NOT NULL, + otp_code VARCHAR(60) NOT NULL, -- BCrypt hashed + attempts INTEGER NOT NULL DEFAULT 0, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_otp_tokens_email UNIQUE (email) +); + +CREATE INDEX IF NOT EXISTS idx_otp_tokens_expires_at ON otp_tokens (expires_at); \ No newline at end of file diff --git a/src/main/resources/db/migration/V1.6__make_password_nullable.sql b/src/main/resources/db/migration/V1.6__make_password_nullable.sql new file mode 100644 index 0000000..2555092 --- /dev/null +++ b/src/main/resources/db/migration/V1.6__make_password_nullable.sql @@ -0,0 +1,2 @@ +-- Make password column nullable to support OTP-only registration +ALTER TABLE users ALTER COLUMN password DROP NOT NULL; diff --git a/src/test/java/org/nkcoder/user/application/service/OtpApplicationServiceTest.java b/src/test/java/org/nkcoder/user/application/service/OtpApplicationServiceTest.java new file mode 100644 index 0000000..f882523 --- /dev/null +++ b/src/test/java/org/nkcoder/user/application/service/OtpApplicationServiceTest.java @@ -0,0 +1,397 @@ +package org.nkcoder.user.application.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.nkcoder.shared.kernel.domain.event.DomainEventPublisher; +import org.nkcoder.shared.kernel.domain.event.OtpRequestedEvent; +import org.nkcoder.shared.kernel.domain.event.UserRegisteredEvent; +import org.nkcoder.shared.kernel.exception.AuthenticationException; +import org.nkcoder.shared.kernel.exception.ValidationException; +import org.nkcoder.user.application.dto.command.RequestOtpCommand; +import org.nkcoder.user.application.dto.command.VerifyOtpCommand; +import org.nkcoder.user.application.dto.response.AuthResult; +import org.nkcoder.user.domain.model.Email; +import org.nkcoder.user.domain.model.HashedPassword; +import org.nkcoder.user.domain.model.OtpToken; +import org.nkcoder.user.domain.model.RefreshToken; +import org.nkcoder.user.domain.model.TokenFamily; +import org.nkcoder.user.domain.model.TokenPair; +import org.nkcoder.user.domain.model.User; +import org.nkcoder.user.domain.model.UserId; +import org.nkcoder.user.domain.model.UserName; +import org.nkcoder.user.domain.model.UserRole; +import org.nkcoder.user.domain.repository.OtpTokenRepository; +import org.nkcoder.user.domain.repository.RefreshTokenRepository; +import org.nkcoder.user.domain.repository.UserRepository; +import org.nkcoder.user.domain.service.OtpHasher; +import org.nkcoder.user.domain.service.TokenGenerator; +import org.nkcoder.user.domain.service.TokenRotationService; + +@ExtendWith(MockitoExtension.class) +@DisplayName("OtpApplicationService") +class OtpApplicationServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private OtpTokenRepository otpTokenRepository; + + @Mock + private RefreshTokenRepository refreshTokenRepository; + + @Mock + private OtpHasher otpHasher; + + @Mock + private TokenGenerator tokenGenerator; + + @Mock + private TokenRotationService tokenRotationService; + + @Mock + private DomainEventPublisher eventPublisher; + + private OtpApplicationService otpApplicationService; + + @BeforeEach + void setUp() { + otpApplicationService = new OtpApplicationService( + userRepository, + otpTokenRepository, + refreshTokenRepository, + otpHasher, + tokenGenerator, + tokenRotationService, + eventPublisher); + } + + @Nested + @DisplayName("requestOtp") + class RequestOtp { + + @Test + @DisplayName("requests OTP for existing user successfully") + void requestsOtpForExistingUserSuccessfully() { + RequestOtpCommand command = new RequestOtpCommand("user@example.com"); + User user = createTestUser(); + + given(otpTokenRepository.countRecentRequests(any(Email.class), any(LocalDateTime.class))) + .willReturn(0); + given(userRepository.findByEmail(any(Email.class))).willReturn(Optional.of(user)); + given(otpHasher.hash(any())).willReturn("hashed-otp"); + + otpApplicationService.requestOtp(command); + + verify(otpTokenRepository).save(any(OtpToken.class)); + verify(eventPublisher).publish(any(OtpRequestedEvent.class)); + } + + @Test + @DisplayName("auto-registers new user when email does not exist") + void autoRegistersNewUserWhenEmailDoesNotExist() { + RequestOtpCommand command = new RequestOtpCommand("newuser@example.com"); + + given(otpTokenRepository.countRecentRequests(any(Email.class), any(LocalDateTime.class))) + .willReturn(0); + given(userRepository.findByEmail(any(Email.class))).willReturn(Optional.empty()); + given(userRepository.save(any(User.class))).willAnswer(inv -> inv.getArgument(0)); + given(otpHasher.hash(any())).willReturn("hashed-otp"); + + otpApplicationService.requestOtp(command); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(userCaptor.capture()); + assertThat(userCaptor.getValue().getEmail().value()).isEqualTo("newuser@example.com"); + assertThat(userCaptor.getValue().getPassword()).isNull(); + + verify(eventPublisher).publish(any(UserRegisteredEvent.class)); + verify(eventPublisher).publish(any(OtpRequestedEvent.class)); + } + + @Test + @DisplayName("generates name from email correctly") + void generatesNameFromEmailCorrectly() { + RequestOtpCommand command = new RequestOtpCommand("john.doe@example.com"); + + given(otpTokenRepository.countRecentRequests(any(Email.class), any(LocalDateTime.class))) + .willReturn(0); + given(userRepository.findByEmail(any(Email.class))).willReturn(Optional.empty()); + given(userRepository.save(any(User.class))).willAnswer(inv -> inv.getArgument(0)); + given(otpHasher.hash(any())).willReturn("hashed-otp"); + + otpApplicationService.requestOtp(command); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(userCaptor.capture()); + assertThat(userCaptor.getValue().getName().value()).isEqualTo("John Doe"); + } + + @Test + @DisplayName("generates name from email with underscores") + void generatesNameFromEmailWithUnderscores() { + RequestOtpCommand command = new RequestOtpCommand("jane_smith@example.com"); + + given(otpTokenRepository.countRecentRequests(any(Email.class), any(LocalDateTime.class))) + .willReturn(0); + given(userRepository.findByEmail(any(Email.class))).willReturn(Optional.empty()); + given(userRepository.save(any(User.class))).willAnswer(inv -> inv.getArgument(0)); + given(otpHasher.hash(any())).willReturn("hashed-otp"); + + otpApplicationService.requestOtp(command); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(userCaptor.capture()); + assertThat(userCaptor.getValue().getName().value()).isEqualTo("Jane Smith"); + } + + @Test + @DisplayName("throws ValidationException when rate limit exceeded") + void throwsWhenRateLimitExceeded() { + RequestOtpCommand command = new RequestOtpCommand("user@example.com"); + + given(otpTokenRepository.countRecentRequests(any(Email.class), any(LocalDateTime.class))) + .willReturn(3); + + assertThatThrownBy(() -> otpApplicationService.requestOtp(command)) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("Too many OTP requests"); + + verify(userRepository, never()).findByEmail(any()); + verify(otpTokenRepository, never()).save(any()); + } + + @Test + @DisplayName("publishes OtpRequestedEvent with correct data") + void publishesOtpRequestedEventWithCorrectData() { + RequestOtpCommand command = new RequestOtpCommand("user@example.com"); + User user = createTestUser(); + + given(otpTokenRepository.countRecentRequests(any(Email.class), any(LocalDateTime.class))) + .willReturn(0); + given(userRepository.findByEmail(any(Email.class))).willReturn(Optional.of(user)); + given(otpHasher.hash(any())).willReturn("hashed-otp"); + + otpApplicationService.requestOtp(command); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(OtpRequestedEvent.class); + verify(eventPublisher).publish(eventCaptor.capture()); + assertThat(eventCaptor.getValue().email()).isEqualTo("user@example.com"); + assertThat(eventCaptor.getValue().userName()).isEqualTo("Test User"); + assertThat(eventCaptor.getValue().expirationMinutes()).isEqualTo(OtpToken.EXPIRATION_MINUTES); + } + } + + @Nested + @DisplayName("verifyOtp") + class VerifyOtp { + + @Test + @DisplayName("verifies OTP successfully and returns AuthResult") + void verifiesOtpSuccessfully() { + VerifyOtpCommand command = new VerifyOtpCommand("user@example.com", "123456"); + User user = createTestUser(); + OtpToken otpToken = OtpToken.create(Email.of("user@example.com"), "hashed-otp"); + TokenPair tokenPair = new TokenPair("access-token", "refresh-token"); + + given(otpTokenRepository.findByEmail(any(Email.class))).willReturn(Optional.of(otpToken)); + given(otpHasher.verify(eq("123456"), eq("hashed-otp"))).willReturn(true); + given(userRepository.findByEmail(any(Email.class))).willReturn(Optional.of(user)); + given(tokenRotationService.generateTokens(any(User.class), any(TokenFamily.class))) + .willReturn(tokenPair); + given(tokenGenerator.getRefreshTokenExpiry()) + .willReturn(LocalDateTime.now().plusDays(7)); + + AuthResult result = otpApplicationService.verifyOtp(command); + + assertThat(result.email()).isEqualTo("user@example.com"); + assertThat(result.accessToken()).isEqualTo("access-token"); + assertThat(result.refreshToken()).isEqualTo("refresh-token"); + verify(otpTokenRepository).deleteByEmail(any(Email.class)); + verify(refreshTokenRepository).save(any(RefreshToken.class)); + } + + @Test + @DisplayName("throws AuthenticationException when OTP token not found") + void throwsWhenOtpTokenNotFound() { + VerifyOtpCommand command = new VerifyOtpCommand("user@example.com", "123456"); + + given(otpTokenRepository.findByEmail(any(Email.class))).willReturn(Optional.empty()); + + assertThatThrownBy(() -> otpApplicationService.verifyOtp(command)) + .isInstanceOf(AuthenticationException.class) + .hasMessageContaining("Invalid or expired OTP"); + } + + @Test + @DisplayName("throws AuthenticationException when OTP is expired") + void throwsWhenOtpIsExpired() { + VerifyOtpCommand command = new VerifyOtpCommand("user@example.com", "123456"); + OtpToken expiredToken = createExpiredOtpToken(); + + given(otpTokenRepository.findByEmail(any(Email.class))).willReturn(Optional.of(expiredToken)); + + assertThatThrownBy(() -> otpApplicationService.verifyOtp(command)) + .isInstanceOf(AuthenticationException.class) + .hasMessageContaining("Invalid or expired OTP"); + + verify(otpTokenRepository).deleteByEmail(any(Email.class)); + } + + @Test + @DisplayName("throws AuthenticationException when OTP is incorrect") + void throwsWhenOtpIsIncorrect() { + VerifyOtpCommand command = new VerifyOtpCommand("user@example.com", "wrong-otp"); + OtpToken otpToken = OtpToken.create(Email.of("user@example.com"), "hashed-otp"); + + given(otpTokenRepository.findByEmail(any(Email.class))).willReturn(Optional.of(otpToken)); + given(otpHasher.verify(eq("wrong-otp"), eq("hashed-otp"))).willReturn(false); + + assertThatThrownBy(() -> otpApplicationService.verifyOtp(command)) + .isInstanceOf(AuthenticationException.class) + .hasMessageContaining("Invalid or expired OTP"); + + verify(otpTokenRepository).updateAttempts(any(Email.class), eq(1)); + } + + @Test + @DisplayName("throws AuthenticationException when max attempts exceeded") + void throwsWhenMaxAttemptsExceeded() { + VerifyOtpCommand command = new VerifyOtpCommand("user@example.com", "wrong-otp"); + OtpToken otpToken = createOtpTokenWithMaxAttempts(); + + given(otpTokenRepository.findByEmail(any(Email.class))).willReturn(Optional.of(otpToken)); + + assertThatThrownBy(() -> otpApplicationService.verifyOtp(command)) + .isInstanceOf(AuthenticationException.class) + .hasMessageContaining("Maximum verification attempts exceeded"); + + verify(otpTokenRepository).deleteByEmail(any(Email.class)); + } + + @Test + @DisplayName("deletes OTP after reaching max attempts on wrong verification") + void deletesOtpAfterMaxAttemptsOnWrongVerification() { + VerifyOtpCommand command = new VerifyOtpCommand("user@example.com", "wrong-otp"); + OtpToken otpToken = createOtpTokenNearMaxAttempts(); + + given(otpTokenRepository.findByEmail(any(Email.class))).willReturn(Optional.of(otpToken)); + given(otpHasher.verify(eq("wrong-otp"), any())).willReturn(false); + + assertThatThrownBy(() -> otpApplicationService.verifyOtp(command)) + .isInstanceOf(AuthenticationException.class) + .hasMessageContaining("Maximum verification attempts exceeded"); + + verify(otpTokenRepository).deleteByEmail(any(Email.class)); + } + + @Test + @DisplayName("throws AuthenticationException when user not found after OTP verification") + void throwsWhenUserNotFoundAfterOtpVerification() { + VerifyOtpCommand command = new VerifyOtpCommand("user@example.com", "123456"); + OtpToken otpToken = OtpToken.create(Email.of("user@example.com"), "hashed-otp"); + + given(otpTokenRepository.findByEmail(any(Email.class))).willReturn(Optional.of(otpToken)); + given(otpHasher.verify(eq("123456"), eq("hashed-otp"))).willReturn(true); + given(userRepository.findByEmail(any(Email.class))).willReturn(Optional.empty()); + + assertThatThrownBy(() -> otpApplicationService.verifyOtp(command)) + .isInstanceOf(AuthenticationException.class) + .hasMessageContaining("User not found"); + } + + @Test + @DisplayName("updates last login time on successful verification") + void updatesLastLoginTimeOnSuccessfulVerification() { + VerifyOtpCommand command = new VerifyOtpCommand("user@example.com", "123456"); + User user = createTestUser(); + OtpToken otpToken = OtpToken.create(Email.of("user@example.com"), "hashed-otp"); + TokenPair tokenPair = new TokenPair("access-token", "refresh-token"); + + given(otpTokenRepository.findByEmail(any(Email.class))).willReturn(Optional.of(otpToken)); + given(otpHasher.verify(eq("123456"), eq("hashed-otp"))).willReturn(true); + given(userRepository.findByEmail(any(Email.class))).willReturn(Optional.of(user)); + given(tokenRotationService.generateTokens(any(User.class), any(TokenFamily.class))) + .willReturn(tokenPair); + given(tokenGenerator.getRefreshTokenExpiry()) + .willReturn(LocalDateTime.now().plusDays(7)); + + otpApplicationService.verifyOtp(command); + + verify(userRepository).updateLastLoginAt(eq(user.getId()), any(LocalDateTime.class)); + } + } + + @Nested + @DisplayName("cleanupExpiredTokens") + class CleanupExpiredTokens { + + @Test + @DisplayName("deletes expired OTP tokens") + void deletesExpiredOtpTokens() { + otpApplicationService.cleanupExpiredTokens(); + + verify(otpTokenRepository).deleteExpiredTokens(any(LocalDateTime.class)); + } + } + + private User createTestUser() { + return User.reconstitute( + UserId.generate(), + Email.of("user@example.com"), + HashedPassword.of("hashed"), + UserName.of("Test User"), + UserRole.MEMBER, + false, + null, + LocalDateTime.now(), + LocalDateTime.now()); + } + + private OtpToken createExpiredOtpToken() { + return OtpToken.reconstitute( + UUID.randomUUID(), + Email.of("user@example.com"), + "hashed-otp", + 0, + LocalDateTime.now().minusMinutes(10), + LocalDateTime.now().minusMinutes(15)); + } + + private OtpToken createOtpTokenWithMaxAttempts() { + return OtpToken.reconstitute( + UUID.randomUUID(), + Email.of("user@example.com"), + "hashed-otp", + OtpToken.MAX_ATTEMPTS, + LocalDateTime.now().plusMinutes(5), + LocalDateTime.now()); + } + + private OtpToken createOtpTokenNearMaxAttempts() { + return OtpToken.reconstitute( + UUID.randomUUID(), + Email.of("user@example.com"), + "hashed-otp", + OtpToken.MAX_ATTEMPTS - 1, + LocalDateTime.now().plusMinutes(5), + LocalDateTime.now()); + } +} diff --git a/src/test/java/org/nkcoder/user/integration/OtpFlowIntegrationTest.java b/src/test/java/org/nkcoder/user/integration/OtpFlowIntegrationTest.java new file mode 100644 index 0000000..ac838d9 --- /dev/null +++ b/src/test/java/org/nkcoder/user/integration/OtpFlowIntegrationTest.java @@ -0,0 +1,560 @@ +package org.nkcoder.user.integration; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.nkcoder.infrastructure.config.TestContainersConfiguration; +import org.nkcoder.user.domain.service.OtpHasher; +import org.nkcoder.user.infrastructure.persistence.entity.OtpTokenJpaEntity; +import org.nkcoder.user.infrastructure.persistence.repository.OtpTokenJpaRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.reactive.server.WebTestClient; + +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) +@AutoConfigureWebTestClient +@Import(TestContainersConfiguration.class) +@ActiveProfiles("test") +@DisplayName("OTP Flow Integration Tests") +class OtpFlowIntegrationTest { + + @Autowired + private WebTestClient webTestClient; + + @Autowired + private OtpTokenJpaRepository otpTokenRepository; + + @Autowired + private OtpHasher otpHasher; + + @BeforeEach + void setUp() { + otpTokenRepository.deleteAll(); + } + + @Nested + @DisplayName("Request OTP") + class RequestOtp { + + @Test + @DisplayName("requests OTP for new user and auto-registers") + void requestsOtpForNewUser() { + webTestClient + .post() + .uri("/api/auth/otp/request") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "newotpuser@example.com" + } + """) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.message") + .isEqualTo("If an account exists with this email, an OTP has been sent"); + + assertThat(otpTokenRepository.findByEmail("newotpuser@example.com")).isPresent(); + } + + @Test + @DisplayName("requests OTP for existing user") + void requestsOtpForExistingUser() { + registerUser("existinguser@example.com", "Password123", "Existing User"); + + webTestClient + .post() + .uri("/api/auth/otp/request") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "existinguser@example.com" + } + """) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.message") + .isEqualTo("If an account exists with this email, an OTP has been sent"); + + assertThat(otpTokenRepository.findByEmail("existinguser@example.com")) + .isPresent(); + } + + @Test + @DisplayName("rejects invalid email format") + void rejectsInvalidEmail() { + webTestClient + .post() + .uri("/api/auth/otp/request") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "invalid-email" + } + """) + .exchange() + .expectStatus() + .isBadRequest(); + } + + @Test + @DisplayName("replaces existing OTP token on subsequent requests") + void replacesExistingOtpToken() { + String email = "replaceotp@example.com"; + + webTestClient + .post() + .uri("/api/auth/otp/request") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "%s" + } + """.formatted(email)) + .exchange() + .expectStatus() + .isOk(); + + var firstToken = otpTokenRepository.findByEmail(email); + assertThat(firstToken).isPresent(); + var firstTokenId = firstToken.get().getId(); + + webTestClient + .post() + .uri("/api/auth/otp/request") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "%s" + } + """.formatted(email)) + .exchange() + .expectStatus() + .isOk(); + + var secondToken = otpTokenRepository.findByEmail(email); + assertThat(secondToken).isPresent(); + assertThat(secondToken.get().getId()).isNotEqualTo(firstTokenId); + } + + @Test + @DisplayName("generates name from email for new users") + void generatesNameFromEmail() { + String email = "john.doe.test@example.com"; + + webTestClient + .post() + .uri("/api/auth/otp/request") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "%s" + } + """.formatted(email)) + .exchange() + .expectStatus() + .isOk(); + + replaceOtpToken(email, "123456"); + + var response = webTestClient + .post() + .uri("/api/auth/otp/verify") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "%s", + "otp": "123456" + } + """.formatted(email)) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .returnResult(); + + String responseBody = new String(response.getResponseBody()); + String accessToken = extractJsonValue(responseBody, "data.tokens.accessToken"); + + webTestClient + .get() + .uri("/api/users/me") + .header("Authorization", "Bearer " + accessToken) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.data.name") + .isEqualTo("John Doe Test"); + } + } + + @Nested + @DisplayName("Verify OTP") + class VerifyOtp { + + @Test + @DisplayName("verifies OTP and returns tokens") + void verifiesOtpSuccessfully() { + webTestClient + .post() + .uri("/api/auth/otp/request") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "verify@example.com" + } + """) + .exchange() + .expectStatus() + .isOk(); + + replaceOtpToken("verify@example.com", "654321"); + + webTestClient + .post() + .uri("/api/auth/otp/verify") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "verify@example.com", + "otp": "654321" + } + """) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.data.user.email") + .isEqualTo("verify@example.com") + .jsonPath("$.data.tokens.accessToken") + .isNotEmpty() + .jsonPath("$.data.tokens.refreshToken") + .isNotEmpty(); + + assertThat(otpTokenRepository.findByEmail("verify@example.com")).isEmpty(); + } + + @Test + @DisplayName("rejects invalid OTP") + void rejectsInvalidOtp() { + webTestClient + .post() + .uri("/api/auth/otp/request") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "invalidotp@example.com" + } + """) + .exchange() + .expectStatus() + .isOk(); + + webTestClient + .post() + .uri("/api/auth/otp/verify") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "invalidotp@example.com", + "otp": "000000" + } + """) + .exchange() + .expectStatus() + .isUnauthorized() + .expectBody() + .jsonPath("$.message") + .isEqualTo("Invalid or expired OTP"); + } + + @Test + @DisplayName("rejects OTP for non-existent token") + void rejectsNonExistentToken() { + webTestClient + .post() + .uri("/api/auth/otp/verify") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "notoken@example.com", + "otp": "123456" + } + """) + .exchange() + .expectStatus() + .isUnauthorized() + .expectBody() + .jsonPath("$.message") + .isEqualTo("Invalid or expired OTP"); + } + + @Test + @DisplayName("keeps token after wrong OTP attempt") + void keepsTokenAfterWrongOtpAttempt() { + webTestClient + .post() + .uri("/api/auth/otp/request") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "attempts@example.com" + } + """) + .exchange() + .expectStatus() + .isOk(); + + webTestClient + .post() + .uri("/api/auth/otp/verify") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "attempts@example.com", + "otp": "000000" + } + """) + .exchange() + .expectStatus() + .isUnauthorized(); + + assertThat(otpTokenRepository.findByEmail("attempts@example.com")).isPresent(); + } + + @Test + @DisplayName("allows access to protected endpoints after OTP verification") + void allowsAccessAfterVerification() { + webTestClient + .post() + .uri("/api/auth/otp/request") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "protected@example.com" + } + """) + .exchange() + .expectStatus() + .isOk(); + + replaceOtpToken("protected@example.com", "123456"); + + var response = webTestClient + .post() + .uri("/api/auth/otp/verify") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "protected@example.com", + "otp": "123456" + } + """) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .returnResult(); + + String responseBody = new String(response.getResponseBody()); + String accessToken = extractJsonValue(responseBody, "data.tokens.accessToken"); + + webTestClient + .get() + .uri("/api/users/me") + .header("Authorization", "Bearer " + accessToken) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.data.email") + .isEqualTo("protected@example.com"); + } + } + + @Nested + @DisplayName("Complete OTP Flow") + class CompleteOtpFlow { + + @Test + @DisplayName("request OTP โ†’ verify โ†’ access protected โ†’ refresh โ†’ logout") + void fullOtpAuthenticationFlow() { + String email = "otpflow@example.com"; + + webTestClient + .post() + .uri("/api/auth/otp/request") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "%s" + } + """.formatted(email)) + .exchange() + .expectStatus() + .isOk(); + + replaceOtpToken(email, "999999"); + + var verifyResponse = webTestClient + .post() + .uri("/api/auth/otp/verify") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "%s", + "otp": "999999" + } + """.formatted(email)) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.data.user.email") + .isEqualTo(email) + .returnResult(); + + String responseBody = new String(verifyResponse.getResponseBody()); + String accessToken = extractJsonValue(responseBody, "data.tokens.accessToken"); + String refreshToken = extractJsonValue(responseBody, "data.tokens.refreshToken"); + + webTestClient + .get() + .uri("/api/users/me") + .header("Authorization", "Bearer " + accessToken) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.data.email") + .isEqualTo(email); + + var refreshResponse = webTestClient + .post() + .uri("/api/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "refreshToken": "%s" + } + """.formatted(refreshToken)) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .returnResult(); + + String refreshResponseBody = new String(refreshResponse.getResponseBody()); + String newAccessToken = extractJsonValue(refreshResponseBody, "data.tokens.accessToken"); + String newRefreshToken = extractJsonValue(refreshResponseBody, "data.tokens.refreshToken"); + + webTestClient + .post() + .uri("/api/auth/logout") + .header("Authorization", "Bearer " + newAccessToken) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "refreshToken": "%s" + } + """.formatted(newRefreshToken)) + .exchange() + .expectStatus() + .isOk(); + + webTestClient + .post() + .uri("/api/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "refreshToken": "%s" + } + """.formatted(newRefreshToken)) + .exchange() + .expectStatus() + .isUnauthorized(); + } + } + + private void registerUser(String email, String password, String name) { + webTestClient + .post() + .uri("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "email": "%s", + "password": "%s", + "name": "%s", + "role": "MEMBER" + } + """.formatted(email, password, name)) + .exchange() + .expectStatus() + .is2xxSuccessful(); + } + + private void createOtpTokenDirectly(String email, String otp) { + String hashedOtp = otpHasher.hash(otp); + OtpTokenJpaEntity entity = new OtpTokenJpaEntity( + UUID.randomUUID(), email, hashedOtp, 0, LocalDateTime.now().plusMinutes(5), LocalDateTime.now()); + entity.markAsNew(); + otpTokenRepository.saveAndFlush(entity); + } + + private void replaceOtpToken(String email, String otp) { + otpTokenRepository.findByEmail(email).ifPresent(e -> { + otpTokenRepository.delete(e); + otpTokenRepository.flush(); + }); + + String hashedOtp = otpHasher.hash(otp); + OtpTokenJpaEntity entity = new OtpTokenJpaEntity( + UUID.randomUUID(), email, hashedOtp, 0, LocalDateTime.now().plusMinutes(5), LocalDateTime.now()); + entity.markAsNew(); + otpTokenRepository.saveAndFlush(entity); + } + + private String extractJsonValue(String json, String path) { + String[] parts = path.split("\\."); + String current = json; + + for (String part : parts) { + int keyIndex = current.indexOf("\"" + part + "\""); + if (keyIndex == -1) { + return null; + } + current = current.substring(keyIndex + part.length() + 2); + int colonIndex = current.indexOf(":"); + current = current.substring(colonIndex + 1).trim(); + + if (current.startsWith("\"")) { + int endQuote = current.indexOf("\"", 1); + if (endQuote == -1) { + return null; + } + if (parts[parts.length - 1].equals(part)) { + return current.substring(1, endQuote); + } + } else if (current.startsWith("{")) { + continue; + } + } + return null; + } +}