diff --git a/build.gradle.kts b/build.gradle.kts index 2dca9f2..5013ff2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -139,11 +139,10 @@ spotless { removeUnusedImports() // Choose one formatters: google or palantir - googleJavaFormat("1.32.0").reflowLongStrings().formatJavadoc(true) - - leadingTabsToSpaces(1) + palantirJavaFormat().formatJavadoc(true) formatAnnotations() trimTrailingWhitespace() + leadingTabsToSpaces(2) endWithNewline() target("src/**/*.java") diff --git a/gradle.properties b/gradle.properties index 9b15dec..fd46128 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,3 +10,4 @@ org.gradle.jvmargs=-Xmx2g -XX:+UseG1GC -Dfile.encoding=UTF-8 --enable-native-acc # Kotlin incremental compilation kotlin.incremental=true kotlin.compiler.execution.strategy=daemon +palantir.native.formatter=true \ No newline at end of file diff --git a/src/main/java/org/nkcoder/UserApplication.java b/src/main/java/org/nkcoder/UserApplication.java index 13034f3..4060f63 100644 --- a/src/main/java/org/nkcoder/UserApplication.java +++ b/src/main/java/org/nkcoder/UserApplication.java @@ -8,7 +8,7 @@ @ConfigurationPropertiesScan public class UserApplication { - public static void main(String[] args) { - SpringApplication.run(UserApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(UserApplication.class, args); + } } diff --git a/src/main/java/org/nkcoder/auth/application/dto/command/RegisterCommand.java b/src/main/java/org/nkcoder/auth/application/dto/command/RegisterCommand.java index 55fff07..23ba04b 100644 --- a/src/main/java/org/nkcoder/auth/application/dto/command/RegisterCommand.java +++ b/src/main/java/org/nkcoder/auth/application/dto/command/RegisterCommand.java @@ -5,7 +5,7 @@ /** Command for user registration. */ public record RegisterCommand(String email, String password, String name, AuthRole role) { - public RegisterCommand(String email, String password, String name) { - this(email, password, name, AuthRole.MEMBER); - } + public RegisterCommand(String email, String password, String name) { + this(email, password, name, AuthRole.MEMBER); + } } diff --git a/src/main/java/org/nkcoder/auth/application/dto/response/AuthResult.java b/src/main/java/org/nkcoder/auth/application/dto/response/AuthResult.java index 7c2379d..88ad3fb 100644 --- a/src/main/java/org/nkcoder/auth/application/dto/response/AuthResult.java +++ b/src/main/java/org/nkcoder/auth/application/dto/response/AuthResult.java @@ -5,10 +5,9 @@ import org.nkcoder.auth.domain.model.TokenPair; /** Result of authentication operations (register, login, refresh). */ -public record AuthResult( - UUID userId, String email, AuthRole role, String accessToken, String refreshToken) { +public record AuthResult(UUID userId, String email, AuthRole role, String accessToken, String refreshToken) { - public static AuthResult of(UUID userId, String email, AuthRole role, TokenPair tokens) { - return new AuthResult(userId, email, role, tokens.accessToken(), tokens.refreshToken()); - } + public static AuthResult of(UUID userId, String email, AuthRole role, TokenPair tokens) { + return new AuthResult(userId, email, role, tokens.accessToken(), tokens.refreshToken()); + } } diff --git a/src/main/java/org/nkcoder/auth/application/service/AuthApplicationService.java b/src/main/java/org/nkcoder/auth/application/service/AuthApplicationService.java index 8bb9a6f..f5eaa52 100644 --- a/src/main/java/org/nkcoder/auth/application/service/AuthApplicationService.java +++ b/src/main/java/org/nkcoder/auth/application/service/AuthApplicationService.java @@ -24,198 +24,179 @@ import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; -/** - * Application service for authentication use cases. Orchestrates domain objects and infrastructure - * services. - */ +/** Application service for authentication use cases. Orchestrates domain objects and infrastructure services. */ @Service @Transactional public class AuthApplicationService { - private static final Logger logger = LoggerFactory.getLogger(AuthApplicationService.class); - - public static final String USER_ALREADY_EXISTS = "User already exists"; - public static final String INVALID_CREDENTIALS = "Invalid email or password"; - public static final String INVALID_REFRESH_TOKEN = "Invalid refresh token"; - public static final String REFRESH_TOKEN_EXPIRED = "Refresh token expired"; - public static final String USER_NOT_FOUND = "User not found"; - - private final AuthUserRepository authUserRepository; - private final RefreshTokenRepository refreshTokenRepository; - private final PasswordEncoder passwordEncoder; - private final TokenGenerator tokenGenerator; - private final DomainEventPublisher eventPublisher; - - public AuthApplicationService( - AuthUserRepository authUserRepository, - RefreshTokenRepository refreshTokenRepository, - PasswordEncoder passwordEncoder, - TokenGenerator tokenGenerator, - DomainEventPublisher eventPublisher) { - this.authUserRepository = authUserRepository; - this.refreshTokenRepository = refreshTokenRepository; - this.passwordEncoder = passwordEncoder; - this.tokenGenerator = tokenGenerator; - this.eventPublisher = eventPublisher; - } - - public AuthResult register(RegisterCommand command) { - logger.debug("Registering new user with email: {}", command.email()); - - Email email = Email.of(command.email()); - - // Check if user already exists - if (authUserRepository.existsByEmail(email)) { - throw new ValidationException(USER_ALREADY_EXISTS); + private static final Logger logger = LoggerFactory.getLogger(AuthApplicationService.class); + + public static final String USER_ALREADY_EXISTS = "User already exists"; + public static final String INVALID_CREDENTIALS = "Invalid email or password"; + public static final String INVALID_REFRESH_TOKEN = "Invalid refresh token"; + public static final String REFRESH_TOKEN_EXPIRED = "Refresh token expired"; + public static final String USER_NOT_FOUND = "User not found"; + + private final AuthUserRepository authUserRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final PasswordEncoder passwordEncoder; + private final TokenGenerator tokenGenerator; + private final DomainEventPublisher eventPublisher; + + public AuthApplicationService( + AuthUserRepository authUserRepository, + RefreshTokenRepository refreshTokenRepository, + PasswordEncoder passwordEncoder, + TokenGenerator tokenGenerator, + DomainEventPublisher eventPublisher) { + this.authUserRepository = authUserRepository; + this.refreshTokenRepository = refreshTokenRepository; + this.passwordEncoder = passwordEncoder; + this.tokenGenerator = tokenGenerator; + this.eventPublisher = eventPublisher; } - // Create auth user - AuthUser authUser = - AuthUser.register( - email, passwordEncoder.encode(command.password()), command.name(), command.role()); + public AuthResult register(RegisterCommand command) { + logger.debug("Registering new user with email: {}", command.email()); - authUser = authUserRepository.save(authUser); - logger.debug("Auth user registered with ID: {}", authUser.getId().value()); + Email email = Email.of(command.email()); - // Publish domain event (replaces direct UserContextPort call for decoupled communication) - eventPublisher.publish( - new UserRegisteredEvent(authUser.getId(), email, command.name(), authUser.getRole())); + // Check if user already exists + if (authUserRepository.existsByEmail(email)) { + throw new ValidationException(USER_ALREADY_EXISTS); + } - // Generate tokens - TokenFamily tokenFamily = TokenFamily.generate(); - TokenPair tokens = - tokenGenerator.generateTokenPair(authUser.getId(), email, authUser.getRole(), tokenFamily); + // Create auth user + AuthUser authUser = + AuthUser.register(email, passwordEncoder.encode(command.password()), command.name(), command.role()); - // Save refresh token - saveRefreshToken(tokens.refreshToken(), authUser, tokenFamily); + authUser = authUserRepository.save(authUser); + logger.debug("Auth user registered with ID: {}", authUser.getId().value()); - return AuthResult.of( - authUser.getId().value(), authUser.getEmail().value(), authUser.getRole(), tokens); - } + // Publish domain event (replaces direct UserContextPort call for decoupled communication) + eventPublisher.publish(new UserRegisteredEvent(authUser.getId(), email, command.name(), authUser.getRole())); - public AuthResult login(LoginCommand command) { - logger.debug("Logging in user with email: {}", command.email()); + // Generate tokens + TokenFamily tokenFamily = TokenFamily.generate(); + TokenPair tokens = tokenGenerator.generateTokenPair(authUser.getId(), email, authUser.getRole(), tokenFamily); - Email email = Email.of(command.email()); + // Save refresh token + saveRefreshToken(tokens.refreshToken(), authUser, tokenFamily); - AuthUser authUser = - authUserRepository - .findByEmail(email) - .orElseThrow(() -> new AuthenticationException(INVALID_CREDENTIALS)); + return AuthResult.of(authUser.getId().value(), authUser.getEmail().value(), authUser.getRole(), tokens); + } + + public AuthResult login(LoginCommand command) { + logger.debug("Logging in user with email: {}", command.email()); + + Email email = Email.of(command.email()); + + AuthUser authUser = authUserRepository + .findByEmail(email) + .orElseThrow(() -> new AuthenticationException(INVALID_CREDENTIALS)); + + // Check password + if (!passwordEncoder.matches(command.password(), authUser.getPassword())) { + throw new AuthenticationException(INVALID_CREDENTIALS); + } + + // Update last login + authUserRepository.updateLastLoginAt(authUser.getId(), java.time.LocalDateTime.now()); + + // Publish domain event + eventPublisher.publish(new UserLoggedInEvent(authUser.getId(), authUser.getEmail())); + + // Generate tokens + TokenFamily tokenFamily = TokenFamily.generate(); + TokenPair tokens = tokenGenerator.generateTokenPair(authUser.getId(), email, authUser.getRole(), tokenFamily); + + // Save refresh token + saveRefreshToken(tokens.refreshToken(), authUser, tokenFamily); + + logger.debug("User logged in successfully: {}", authUser.getId().value()); + return AuthResult.of(authUser.getId().value(), authUser.getEmail().value(), authUser.getRole(), tokens); + } + + @Transactional(isolation = Isolation.SERIALIZABLE) + public AuthResult refreshTokens(RefreshTokenCommand command) { + logger.debug("Refreshing tokens"); + + try { + // Validate refresh token + TokenGenerator.RefreshTokenClaims claims = tokenGenerator.validateRefreshToken(command.refreshToken()); + + // Get stored refresh token with lock + RefreshToken storedToken = refreshTokenRepository + .findByTokenForUpdate(command.refreshToken()) + .orElseThrow(() -> new AuthenticationException(INVALID_REFRESH_TOKEN)); + + // Check if token is expired + if (storedToken.isExpired()) { + refreshTokenRepository.deleteByToken(command.refreshToken()); + throw new AuthenticationException(REFRESH_TOKEN_EXPIRED); + } + + AuthUser authUser = authUserRepository + .findById(claims.userId()) + .orElseThrow(() -> new AuthenticationException(USER_NOT_FOUND)); + + // Delete old token + refreshTokenRepository.deleteByToken(command.refreshToken()); + + // Generate new tokens with same token family + TokenPair tokens = tokenGenerator.generateTokenPair( + authUser.getId(), authUser.getEmail(), authUser.getRole(), claims.tokenFamily()); + + // Save new refresh token + saveRefreshToken(tokens.refreshToken(), authUser, claims.tokenFamily()); + + logger.debug( + "Tokens refreshed successfully for user: {}", + authUser.getId().value()); + return AuthResult.of(authUser.getId().value(), authUser.getEmail().value(), authUser.getRole(), tokens); + + } catch (AuthenticationException e) { + throw e; + } catch (Exception e) { + logger.error("Invalid refresh token: {}", e.getMessage()); + + // If refresh token is invalid, try to delete the token family + refreshTokenRepository + .findByToken(command.refreshToken()) + .ifPresent(storedToken -> refreshTokenRepository.deleteByTokenFamily(storedToken.getTokenFamily())); + + throw new AuthenticationException(INVALID_REFRESH_TOKEN); + } + } + + public void logout(String refreshToken) { + logger.debug("Logging out user (all devices)"); + + refreshTokenRepository.findByToken(refreshToken).ifPresent(storedToken -> { + // Delete entire token family (logout from all devices) + refreshTokenRepository.deleteByTokenFamily(storedToken.getTokenFamily()); + logger.debug( + "Logged out from all devices for token family: {}", + storedToken.getTokenFamily().value()); + }); + } + + public void logoutSingle(String refreshToken) { + logger.debug("Logging out user (single device)"); + + // Delete only this refresh token (logout from current device) + refreshTokenRepository.deleteByToken(refreshToken); + logger.debug("Logged out from current device"); + } - // Check password - if (!passwordEncoder.matches(command.password(), authUser.getPassword())) { - throw new AuthenticationException(INVALID_CREDENTIALS); + public void cleanupExpiredTokens() { + logger.debug("Cleaning up expired refresh tokens"); + refreshTokenRepository.deleteExpiredTokens(java.time.LocalDateTime.now()); } - // Update last login - authUserRepository.updateLastLoginAt(authUser.getId(), java.time.LocalDateTime.now()); - - // Publish domain event - eventPublisher.publish(new UserLoggedInEvent(authUser.getId(), authUser.getEmail())); - - // Generate tokens - TokenFamily tokenFamily = TokenFamily.generate(); - TokenPair tokens = - tokenGenerator.generateTokenPair(authUser.getId(), email, authUser.getRole(), tokenFamily); - - // Save refresh token - saveRefreshToken(tokens.refreshToken(), authUser, tokenFamily); - - logger.debug("User logged in successfully: {}", authUser.getId().value()); - return AuthResult.of( - authUser.getId().value(), authUser.getEmail().value(), authUser.getRole(), tokens); - } - - @Transactional(isolation = Isolation.SERIALIZABLE) - public AuthResult refreshTokens(RefreshTokenCommand command) { - logger.debug("Refreshing tokens"); - - try { - // Validate refresh token - TokenGenerator.RefreshTokenClaims claims = - tokenGenerator.validateRefreshToken(command.refreshToken()); - - // Get stored refresh token with lock - RefreshToken storedToken = - refreshTokenRepository - .findByTokenForUpdate(command.refreshToken()) - .orElseThrow(() -> new AuthenticationException(INVALID_REFRESH_TOKEN)); - - // Check if token is expired - if (storedToken.isExpired()) { - refreshTokenRepository.deleteByToken(command.refreshToken()); - throw new AuthenticationException(REFRESH_TOKEN_EXPIRED); - } - - AuthUser authUser = - authUserRepository - .findById(claims.userId()) - .orElseThrow(() -> new AuthenticationException(USER_NOT_FOUND)); - - // Delete old token - refreshTokenRepository.deleteByToken(command.refreshToken()); - - // Generate new tokens with same token family - TokenPair tokens = - tokenGenerator.generateTokenPair( - authUser.getId(), authUser.getEmail(), authUser.getRole(), claims.tokenFamily()); - - // Save new refresh token - saveRefreshToken(tokens.refreshToken(), authUser, claims.tokenFamily()); - - logger.debug("Tokens refreshed successfully for user: {}", authUser.getId().value()); - return AuthResult.of( - authUser.getId().value(), authUser.getEmail().value(), authUser.getRole(), tokens); - - } catch (AuthenticationException e) { - throw e; - } catch (Exception e) { - logger.error("Invalid refresh token: {}", e.getMessage()); - - // If refresh token is invalid, try to delete the token family - refreshTokenRepository - .findByToken(command.refreshToken()) - .ifPresent( - storedToken -> - refreshTokenRepository.deleteByTokenFamily(storedToken.getTokenFamily())); - - throw new AuthenticationException(INVALID_REFRESH_TOKEN); + private void saveRefreshToken(String token, AuthUser authUser, TokenFamily tokenFamily) { + RefreshToken refreshToken = + RefreshToken.create(token, tokenFamily, authUser.getId(), tokenGenerator.getRefreshTokenExpiry()); + refreshTokenRepository.save(refreshToken); } - } - - public void logout(String refreshToken) { - logger.debug("Logging out user (all devices)"); - - refreshTokenRepository - .findByToken(refreshToken) - .ifPresent( - storedToken -> { - // Delete entire token family (logout from all devices) - refreshTokenRepository.deleteByTokenFamily(storedToken.getTokenFamily()); - logger.debug( - "Logged out from all devices for token family: {}", - storedToken.getTokenFamily().value()); - }); - } - - public void logoutSingle(String refreshToken) { - logger.debug("Logging out user (single device)"); - - // Delete only this refresh token (logout from current device) - refreshTokenRepository.deleteByToken(refreshToken); - logger.debug("Logged out from current device"); - } - - public void cleanupExpiredTokens() { - logger.debug("Cleaning up expired refresh tokens"); - refreshTokenRepository.deleteExpiredTokens(java.time.LocalDateTime.now()); - } - - private void saveRefreshToken(String token, AuthUser authUser, TokenFamily tokenFamily) { - RefreshToken refreshToken = - RefreshToken.create( - token, tokenFamily, authUser.getId(), tokenGenerator.getRefreshTokenExpiry()); - refreshTokenRepository.save(refreshToken); - } } diff --git a/src/main/java/org/nkcoder/auth/domain/event/UserLoggedInEvent.java b/src/main/java/org/nkcoder/auth/domain/event/UserLoggedInEvent.java index 8fb6649..5d25251 100644 --- a/src/main/java/org/nkcoder/auth/domain/event/UserLoggedInEvent.java +++ b/src/main/java/org/nkcoder/auth/domain/event/UserLoggedInEvent.java @@ -6,15 +6,14 @@ import org.nkcoder.shared.kernel.domain.valueobject.Email; /** Domain event published when a user logs in. */ -public record UserLoggedInEvent(AuthUserId userId, Email email, LocalDateTime occurredOn) - implements DomainEvent { +public record UserLoggedInEvent(AuthUserId userId, Email email, LocalDateTime occurredOn) implements DomainEvent { - public UserLoggedInEvent(AuthUserId userId, Email email) { - this(userId, email, LocalDateTime.now()); - } + public UserLoggedInEvent(AuthUserId userId, Email email) { + this(userId, email, LocalDateTime.now()); + } - @Override - public String eventType() { - return "auth.user.logged_in"; - } + @Override + public String eventType() { + return "auth.user.logged_in"; + } } diff --git a/src/main/java/org/nkcoder/auth/domain/event/UserRegisteredEvent.java b/src/main/java/org/nkcoder/auth/domain/event/UserRegisteredEvent.java index efa6139..1fbaa9f 100644 --- a/src/main/java/org/nkcoder/auth/domain/event/UserRegisteredEvent.java +++ b/src/main/java/org/nkcoder/auth/domain/event/UserRegisteredEvent.java @@ -7,16 +7,15 @@ import org.nkcoder.shared.kernel.domain.valueobject.Email; /** Domain event published when a new user registers. */ -public record UserRegisteredEvent( - AuthUserId userId, Email email, String name, AuthRole role, LocalDateTime occurredOn) - implements DomainEvent { +public record UserRegisteredEvent(AuthUserId userId, Email email, String name, AuthRole role, LocalDateTime occurredOn) + implements DomainEvent { - public UserRegisteredEvent(AuthUserId userId, Email email, String name, AuthRole role) { - this(userId, email, name, role, LocalDateTime.now()); - } + public UserRegisteredEvent(AuthUserId userId, Email email, String name, AuthRole role) { + this(userId, email, name, role, LocalDateTime.now()); + } - @Override - public String eventType() { - return "auth.user.registered"; - } + @Override + public String eventType() { + return "auth.user.registered"; + } } diff --git a/src/main/java/org/nkcoder/auth/domain/model/AuthRole.java b/src/main/java/org/nkcoder/auth/domain/model/AuthRole.java index 69aba84..941a254 100644 --- a/src/main/java/org/nkcoder/auth/domain/model/AuthRole.java +++ b/src/main/java/org/nkcoder/auth/domain/model/AuthRole.java @@ -2,6 +2,6 @@ /** User roles for authorization in the Auth context. */ public enum AuthRole { - MEMBER, - ADMIN + MEMBER, + ADMIN } diff --git a/src/main/java/org/nkcoder/auth/domain/model/AuthUser.java b/src/main/java/org/nkcoder/auth/domain/model/AuthUser.java index 0beaa5f..1f1c96d 100644 --- a/src/main/java/org/nkcoder/auth/domain/model/AuthUser.java +++ b/src/main/java/org/nkcoder/auth/domain/model/AuthUser.java @@ -5,83 +5,82 @@ import org.nkcoder.shared.kernel.domain.valueobject.Email; /** - * Auth domain's representation of a user. Contains authentication-related data plus the name - * (required for DB constraint). This is separate from the User domain's richer user model. + * Auth domain's representation of a user. Contains authentication-related data plus the name (required for DB + * constraint). This is separate from the User domain's richer user model. */ public class AuthUser { - private final AuthUserId id; - private final Email email; - private HashedPassword password; - private final String name; - private final AuthRole role; - private LocalDateTime lastLoginAt; + private final AuthUserId id; + private final Email email; + private HashedPassword password; + private final String name; + private final AuthRole role; + private LocalDateTime lastLoginAt; - private AuthUser( - AuthUserId id, - Email email, - HashedPassword password, - String name, - AuthRole role, - LocalDateTime lastLoginAt) { - this.id = Objects.requireNonNull(id, "id cannot be null"); - this.email = Objects.requireNonNull(email, "email cannot be null"); - this.password = Objects.requireNonNull(password, "password cannot be null"); - this.name = Objects.requireNonNull(name, "name cannot be null"); - this.role = role != null ? role : AuthRole.MEMBER; - this.lastLoginAt = lastLoginAt; - } + private AuthUser( + AuthUserId id, + Email email, + HashedPassword password, + String name, + AuthRole role, + LocalDateTime lastLoginAt) { + this.id = Objects.requireNonNull(id, "id cannot be null"); + this.email = Objects.requireNonNull(email, "email cannot be null"); + this.password = Objects.requireNonNull(password, "password cannot be null"); + this.name = Objects.requireNonNull(name, "name cannot be null"); + this.role = role != null ? role : AuthRole.MEMBER; + this.lastLoginAt = lastLoginAt; + } - /** Factory method for creating a new user during registration. */ - public static AuthUser register( - Email email, HashedPassword password, String name, AuthRole role) { - return new AuthUser(AuthUserId.generate(), email, password, name, role, null); - } + /** Factory method for creating a new user during registration. */ + public static AuthUser register(Email email, HashedPassword password, String name, AuthRole role) { + return new AuthUser(AuthUserId.generate(), email, password, name, role, null); + } - /** Factory method for reconstituting from persistence. */ - public static AuthUser reconstitute( - AuthUserId id, - Email email, - HashedPassword password, - String name, - AuthRole role, - LocalDateTime lastLoginAt) { - return new AuthUser(id, email, password, name, role, lastLoginAt); - } + /** Factory method for reconstituting from persistence. */ + public static AuthUser reconstitute( + AuthUserId id, + Email email, + HashedPassword password, + String name, + AuthRole role, + LocalDateTime lastLoginAt) { + return new AuthUser(id, email, password, name, role, lastLoginAt); + } - /** Records the current time as the last login time. */ - public void recordLogin() { - this.lastLoginAt = LocalDateTime.now(); - } + /** Records the current time as the last login time. */ + public void recordLogin() { + this.lastLoginAt = LocalDateTime.now(); + } - /** Changes the password to a new hashed password. */ - public void changePassword(HashedPassword newPassword) { - this.password = Objects.requireNonNull(newPassword, "new password cannot be null"); - } + /** Changes the password to a new hashed password. */ + public void changePassword(HashedPassword newPassword) { + this.password = Objects.requireNonNull(newPassword, "new password cannot be null"); + } - // Getters + // Getters - public AuthUserId getId() { - return id; - } + public AuthUserId getId() { + return id; + } - public Email getEmail() { - return email; - } + public Email getEmail() { + return email; + } - public HashedPassword getPassword() { - return password; - } + public HashedPassword getPassword() { + return password; + } - public String getName() { - return name; - } + public String getName() { + return name; + } - public AuthRole getRole() { - return role; - } + public AuthRole getRole() { + return role; + } - public LocalDateTime getLastLoginAt() { - return lastLoginAt; - } + public LocalDateTime getLastLoginAt() { + return lastLoginAt; + } } diff --git a/src/main/java/org/nkcoder/auth/domain/model/AuthUserId.java b/src/main/java/org/nkcoder/auth/domain/model/AuthUserId.java index 7ed80f8..c2715ac 100644 --- a/src/main/java/org/nkcoder/auth/domain/model/AuthUserId.java +++ b/src/main/java/org/nkcoder/auth/domain/model/AuthUserId.java @@ -6,19 +6,19 @@ /** Value object representing a user's unique identifier in the Auth context. */ public record AuthUserId(UUID value) { - public AuthUserId { - Objects.requireNonNull(value, "AuthUserId value cannot be null"); - } + public AuthUserId { + Objects.requireNonNull(value, "AuthUserId value cannot be null"); + } - public static AuthUserId generate() { - return new AuthUserId(UUID.randomUUID()); - } + public static AuthUserId generate() { + return new AuthUserId(UUID.randomUUID()); + } - public static AuthUserId of(UUID value) { - return new AuthUserId(value); - } + public static AuthUserId of(UUID value) { + return new AuthUserId(value); + } - public static AuthUserId of(String value) { - return new AuthUserId(UUID.fromString(value)); - } + public static AuthUserId of(String value) { + return new AuthUserId(UUID.fromString(value)); + } } diff --git a/src/main/java/org/nkcoder/auth/domain/model/HashedPassword.java b/src/main/java/org/nkcoder/auth/domain/model/HashedPassword.java index 3fed0ea..9d0c951 100644 --- a/src/main/java/org/nkcoder/auth/domain/model/HashedPassword.java +++ b/src/main/java/org/nkcoder/auth/domain/model/HashedPassword.java @@ -5,14 +5,14 @@ /** Value object representing a hashed password. Never contains raw passwords. */ public record HashedPassword(String value) { - public HashedPassword { - Objects.requireNonNull(value, "Hashed password cannot be null"); - if (value.isBlank()) { - throw new IllegalArgumentException("Hashed password cannot be blank"); + public HashedPassword { + Objects.requireNonNull(value, "Hashed password cannot be null"); + if (value.isBlank()) { + throw new IllegalArgumentException("Hashed password cannot be blank"); + } } - } - public static HashedPassword of(String hashedValue) { - return new HashedPassword(hashedValue); - } + public static HashedPassword of(String hashedValue) { + return new HashedPassword(hashedValue); + } } diff --git a/src/main/java/org/nkcoder/auth/domain/model/RefreshToken.java b/src/main/java/org/nkcoder/auth/domain/model/RefreshToken.java index c56931f..bb441ac 100644 --- a/src/main/java/org/nkcoder/auth/domain/model/RefreshToken.java +++ b/src/main/java/org/nkcoder/auth/domain/model/RefreshToken.java @@ -5,79 +5,78 @@ import java.util.UUID; /** - * Entity representing a refresh token. Refresh tokens are used to obtain new access tokens without - * re-authentication. They belong to a token family for multi-device logout support. + * Entity representing a refresh token. Refresh tokens are used to obtain new access tokens without re-authentication. + * They belong to a token family for multi-device logout support. */ public class RefreshToken { - private final UUID id; - private final String token; - private final TokenFamily tokenFamily; - private final AuthUserId userId; - private final LocalDateTime expiresAt; - private final LocalDateTime createdAt; + private final UUID id; + private final String token; + private final TokenFamily tokenFamily; + private final AuthUserId userId; + private final LocalDateTime expiresAt; + private final LocalDateTime createdAt; - private RefreshToken( - UUID id, - String token, - TokenFamily tokenFamily, - AuthUserId userId, - LocalDateTime expiresAt, - LocalDateTime createdAt) { - this.id = Objects.requireNonNull(id, "id cannot be null"); - this.token = Objects.requireNonNull(token, "token cannot be null"); - this.tokenFamily = Objects.requireNonNull(tokenFamily, "tokenFamily cannot be null"); - this.userId = Objects.requireNonNull(userId, "userId cannot be null"); - this.expiresAt = Objects.requireNonNull(expiresAt, "expiresAt cannot be null"); - this.createdAt = Objects.requireNonNull(createdAt, "createdAt cannot be null"); - } + private RefreshToken( + UUID id, + String token, + TokenFamily tokenFamily, + AuthUserId userId, + LocalDateTime expiresAt, + LocalDateTime createdAt) { + this.id = Objects.requireNonNull(id, "id cannot be null"); + this.token = Objects.requireNonNull(token, "token cannot be null"); + this.tokenFamily = Objects.requireNonNull(tokenFamily, "tokenFamily cannot be null"); + this.userId = Objects.requireNonNull(userId, "userId cannot be null"); + this.expiresAt = Objects.requireNonNull(expiresAt, "expiresAt cannot be null"); + this.createdAt = Objects.requireNonNull(createdAt, "createdAt cannot be null"); + } - /** Factory method for creating a new refresh token. */ - public static RefreshToken create( - String token, TokenFamily tokenFamily, AuthUserId userId, LocalDateTime expiresAt) { - return new RefreshToken( - UUID.randomUUID(), token, tokenFamily, userId, expiresAt, LocalDateTime.now()); - } + /** Factory method for creating a new refresh token. */ + public static RefreshToken create( + String token, TokenFamily tokenFamily, AuthUserId userId, LocalDateTime expiresAt) { + return new RefreshToken(UUID.randomUUID(), token, tokenFamily, userId, expiresAt, LocalDateTime.now()); + } - /** Factory method for reconstituting from persistence. */ - public static RefreshToken reconstitute( - UUID id, - String token, - TokenFamily tokenFamily, - AuthUserId userId, - LocalDateTime expiresAt, - LocalDateTime createdAt) { - return new RefreshToken(id, token, tokenFamily, userId, expiresAt, createdAt); - } + /** Factory method for reconstituting from persistence. */ + public static RefreshToken reconstitute( + UUID id, + String token, + TokenFamily tokenFamily, + AuthUserId userId, + LocalDateTime expiresAt, + LocalDateTime createdAt) { + return new RefreshToken(id, token, tokenFamily, userId, expiresAt, createdAt); + } - /** Checks if this token has expired. */ - public boolean isExpired() { - return LocalDateTime.now().isAfter(expiresAt); - } + /** Checks if this token has expired. */ + public boolean isExpired() { + return LocalDateTime.now().isAfter(expiresAt); + } - // Getters + // Getters - public UUID getId() { - return id; - } + public UUID getId() { + return id; + } - public String getToken() { - return token; - } + public String getToken() { + return token; + } - public TokenFamily getTokenFamily() { - return tokenFamily; - } + public TokenFamily getTokenFamily() { + return tokenFamily; + } - public AuthUserId getUserId() { - return userId; - } + public AuthUserId getUserId() { + return userId; + } - public LocalDateTime getExpiresAt() { - return expiresAt; - } + public LocalDateTime getExpiresAt() { + return expiresAt; + } - public LocalDateTime getCreatedAt() { - return createdAt; - } + public LocalDateTime getCreatedAt() { + return createdAt; + } } diff --git a/src/main/java/org/nkcoder/auth/domain/model/TokenFamily.java b/src/main/java/org/nkcoder/auth/domain/model/TokenFamily.java index d45ddb8..de512d2 100644 --- a/src/main/java/org/nkcoder/auth/domain/model/TokenFamily.java +++ b/src/main/java/org/nkcoder/auth/domain/model/TokenFamily.java @@ -4,23 +4,23 @@ import java.util.UUID; /** - * Value object representing a token family. Token families are used to track related refresh tokens - * across rotations, enabling multi-device logout. + * Value object representing a token family. Token families are used to track related refresh tokens across rotations, + * enabling multi-device logout. */ public record TokenFamily(String value) { - public TokenFamily { - Objects.requireNonNull(value, "TokenFamily value cannot be null"); - if (value.isBlank()) { - throw new IllegalArgumentException("TokenFamily value cannot be blank"); + public TokenFamily { + Objects.requireNonNull(value, "TokenFamily value cannot be null"); + if (value.isBlank()) { + throw new IllegalArgumentException("TokenFamily value cannot be blank"); + } } - } - public static TokenFamily generate() { - return new TokenFamily(UUID.randomUUID().toString()); - } + public static TokenFamily generate() { + return new TokenFamily(UUID.randomUUID().toString()); + } - public static TokenFamily of(String value) { - return new TokenFamily(value); - } + public static TokenFamily of(String value) { + return new TokenFamily(value); + } } diff --git a/src/main/java/org/nkcoder/auth/domain/model/TokenPair.java b/src/main/java/org/nkcoder/auth/domain/model/TokenPair.java index ffcb54b..17df1f3 100644 --- a/src/main/java/org/nkcoder/auth/domain/model/TokenPair.java +++ b/src/main/java/org/nkcoder/auth/domain/model/TokenPair.java @@ -5,8 +5,8 @@ /** Value object representing a pair of access and refresh tokens. */ public record TokenPair(String accessToken, String refreshToken) { - public TokenPair { - Objects.requireNonNull(accessToken, "Access token cannot be null"); - Objects.requireNonNull(refreshToken, "Refresh token cannot be null"); - } + public TokenPair { + Objects.requireNonNull(accessToken, "Access token cannot be null"); + Objects.requireNonNull(refreshToken, "Refresh token cannot be null"); + } } diff --git a/src/main/java/org/nkcoder/auth/domain/repository/AuthUserRepository.java b/src/main/java/org/nkcoder/auth/domain/repository/AuthUserRepository.java index a148295..0d5b3f4 100644 --- a/src/main/java/org/nkcoder/auth/domain/repository/AuthUserRepository.java +++ b/src/main/java/org/nkcoder/auth/domain/repository/AuthUserRepository.java @@ -6,19 +6,16 @@ import org.nkcoder.auth.domain.model.AuthUserId; import org.nkcoder.shared.kernel.domain.valueobject.Email; -/** - * Repository interface (port) for AuthUser persistence. Implementations are in the infrastructure - * layer. - */ +/** Repository interface (port) for AuthUser persistence. Implementations are in the infrastructure layer. */ public interface AuthUserRepository { - Optional findById(AuthUserId id); + Optional findById(AuthUserId id); - Optional findByEmail(Email email); + Optional findByEmail(Email email); - boolean existsByEmail(Email email); + boolean existsByEmail(Email email); - AuthUser save(AuthUser authUser); + AuthUser save(AuthUser authUser); - void updateLastLoginAt(AuthUserId id, LocalDateTime lastLoginAt); + void updateLastLoginAt(AuthUserId id, LocalDateTime lastLoginAt); } diff --git a/src/main/java/org/nkcoder/auth/domain/repository/RefreshTokenRepository.java b/src/main/java/org/nkcoder/auth/domain/repository/RefreshTokenRepository.java index 38b2e9e..c488176 100644 --- a/src/main/java/org/nkcoder/auth/domain/repository/RefreshTokenRepository.java +++ b/src/main/java/org/nkcoder/auth/domain/repository/RefreshTokenRepository.java @@ -6,27 +6,24 @@ import org.nkcoder.auth.domain.model.RefreshToken; import org.nkcoder.auth.domain.model.TokenFamily; -/** - * Repository interface (port) for RefreshToken persistence. Implementations are in the - * infrastructure layer. - */ +/** Repository interface (port) for RefreshToken persistence. Implementations are in the infrastructure layer. */ public interface RefreshTokenRepository { - Optional findByToken(String token); + Optional findByToken(String token); - /** - * Finds a refresh token by its value with a pessimistic lock for update. Used during token - * refresh to prevent race conditions. - */ - Optional findByTokenForUpdate(String token); + /** + * Finds a refresh token by its value with a pessimistic lock for update. Used during token refresh to prevent race + * conditions. + */ + Optional findByTokenForUpdate(String token); - RefreshToken save(RefreshToken refreshToken); + RefreshToken save(RefreshToken refreshToken); - void deleteByToken(String token); + void deleteByToken(String token); - void deleteByTokenFamily(TokenFamily tokenFamily); + void deleteByTokenFamily(TokenFamily tokenFamily); - void deleteByUserId(AuthUserId userId); + void deleteByUserId(AuthUserId userId); - void deleteExpiredTokens(LocalDateTime now); + void deleteExpiredTokens(LocalDateTime now); } diff --git a/src/main/java/org/nkcoder/auth/domain/service/PasswordEncoder.java b/src/main/java/org/nkcoder/auth/domain/service/PasswordEncoder.java index d74898e..6ac74c8 100644 --- a/src/main/java/org/nkcoder/auth/domain/service/PasswordEncoder.java +++ b/src/main/java/org/nkcoder/auth/domain/service/PasswordEncoder.java @@ -2,15 +2,12 @@ import org.nkcoder.auth.domain.model.HashedPassword; -/** - * Domain service interface for password encoding and verification. Implementations are in the - * infrastructure layer. - */ +/** Domain service interface for password encoding and verification. Implementations are in the infrastructure layer. */ public interface PasswordEncoder { - /** Encodes a raw password into a hashed password. */ - HashedPassword encode(String rawPassword); + /** Encodes a raw password into a hashed password. */ + HashedPassword encode(String rawPassword); - /** Checks if a raw password matches a hashed password. */ - boolean matches(String rawPassword, HashedPassword hashedPassword); + /** Checks if a raw password matches a hashed password. */ + boolean matches(String rawPassword, HashedPassword hashedPassword); } diff --git a/src/main/java/org/nkcoder/auth/domain/service/TokenGenerator.java b/src/main/java/org/nkcoder/auth/domain/service/TokenGenerator.java index 67c3ab6..9d8be0c 100644 --- a/src/main/java/org/nkcoder/auth/domain/service/TokenGenerator.java +++ b/src/main/java/org/nkcoder/auth/domain/service/TokenGenerator.java @@ -8,21 +8,19 @@ import org.nkcoder.shared.kernel.domain.valueobject.Email; /** - * Domain service interface for JWT token generation and validation. Implementations are in the - * infrastructure layer. + * Domain service interface for JWT token generation and validation. Implementations are in the infrastructure layer. */ public interface TokenGenerator { - /** Generates an access and refresh token pair. */ - TokenPair generateTokenPair( - AuthUserId userId, Email email, AuthRole role, TokenFamily tokenFamily); + /** Generates an access and refresh token pair. */ + TokenPair generateTokenPair(AuthUserId userId, Email email, AuthRole role, TokenFamily tokenFamily); - /** Returns the expiry time for refresh tokens. */ - LocalDateTime getRefreshTokenExpiry(); + /** Returns the expiry time for refresh tokens. */ + LocalDateTime getRefreshTokenExpiry(); - /** Validates a refresh token and returns its claims. */ - RefreshTokenClaims validateRefreshToken(String token); + /** Validates a refresh token and returns its claims. */ + RefreshTokenClaims validateRefreshToken(String token); - /** Claims extracted from a validated refresh token. */ - record RefreshTokenClaims(AuthUserId userId, TokenFamily tokenFamily) {} + /** Claims extracted from a validated refresh token. */ + record RefreshTokenClaims(AuthUserId userId, TokenFamily tokenFamily) {} } diff --git a/src/main/java/org/nkcoder/auth/infrastructure/persistence/entity/AuthUserJpaEntity.java b/src/main/java/org/nkcoder/auth/infrastructure/persistence/entity/AuthUserJpaEntity.java index ac586df..ca3fa5e 100644 --- a/src/main/java/org/nkcoder/auth/infrastructure/persistence/entity/AuthUserJpaEntity.java +++ b/src/main/java/org/nkcoder/auth/infrastructure/persistence/entity/AuthUserJpaEntity.java @@ -17,120 +17,117 @@ import org.springframework.data.jpa.domain.support.AuditingEntityListener; /** - * JPA entity for users table, mapped for Auth context. Contains only authentication-related fields. - * Implements Persistable to control new/existing entity detection since we provide our own UUID. + * JPA entity for users table, mapped for Auth context. Contains only authentication-related fields. Implements + * Persistable to control new/existing entity detection since we provide our own UUID. */ @Entity @Table( - name = "users", - indexes = {@Index(name = "idx_users_email", columnList = "email")}) + name = "users", + indexes = {@Index(name = "idx_users_email", columnList = "email")}) @EntityListeners(AuditingEntityListener.class) public class AuthUserJpaEntity implements Persistable { - @Id private UUID id; + @Id + private UUID id; - @Column(nullable = false, unique = true) - private String email; + @Column(nullable = false, unique = true) + private String email; - @Column(nullable = false) - private String password; + @Column(nullable = false) + private String password; - @Column(nullable = false) - private String name; + @Column(nullable = false) + private String name; - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private AuthRole role; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private AuthRole role; - @Column(name = "last_login_at") - private LocalDateTime lastLoginAt; + @Column(name = "last_login_at") + private LocalDateTime lastLoginAt; - @CreatedDate - @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; - @Transient private boolean isNew = false; + @Transient + private boolean isNew = false; - // Required by JPA - protected AuthUserJpaEntity() {} + // Required by JPA + protected AuthUserJpaEntity() {} - public AuthUserJpaEntity( - UUID id, - String email, - String password, - String name, - AuthRole role, - LocalDateTime lastLoginAt) { - this.id = id; - this.email = email; - this.password = password; - this.name = name; - this.role = role; - this.lastLoginAt = lastLoginAt; - } + public AuthUserJpaEntity( + UUID id, String email, String password, String name, AuthRole role, LocalDateTime lastLoginAt) { + this.id = id; + this.email = email; + this.password = password; + this.name = name; + this.role = role; + this.lastLoginAt = lastLoginAt; + } - // Getters and setters + // Getters and setters - public UUID getId() { - return id; - } + public UUID getId() { + return id; + } - public void setId(UUID id) { - this.id = id; - } + public void setId(UUID id) { + this.id = id; + } - public String getEmail() { - return email; - } + public String getEmail() { + return email; + } - public void setEmail(String email) { - this.email = email; - } + public void setEmail(String email) { + this.email = email; + } - public String getPassword() { - return password; - } + public String getPassword() { + return password; + } - public void setPassword(String password) { - this.password = password; - } + public void setPassword(String password) { + this.password = password; + } - public String getName() { - return name; - } + public String getName() { + return name; + } - public void setName(String name) { - this.name = name; - } + public void setName(String name) { + this.name = name; + } - public AuthRole getRole() { - return role; - } + public AuthRole getRole() { + return role; + } - public void setRole(AuthRole role) { - this.role = role; - } + public void setRole(AuthRole role) { + this.role = role; + } - public LocalDateTime getLastLoginAt() { - return lastLoginAt; - } + public LocalDateTime getLastLoginAt() { + return lastLoginAt; + } - public void setLastLoginAt(LocalDateTime lastLoginAt) { - this.lastLoginAt = lastLoginAt; - } + public void setLastLoginAt(LocalDateTime lastLoginAt) { + this.lastLoginAt = lastLoginAt; + } - public LocalDateTime getCreatedAt() { - return createdAt; - } + public LocalDateTime getCreatedAt() { + return createdAt; + } - // Persistable implementation + // Persistable implementation - @Override - public boolean isNew() { - return isNew; - } - - public void markAsNew() { - this.isNew = true; - } + @Override + public boolean isNew() { + return isNew; + } + + public void markAsNew() { + this.isNew = true; + } } diff --git a/src/main/java/org/nkcoder/auth/infrastructure/persistence/entity/RefreshTokenJpaEntity.java b/src/main/java/org/nkcoder/auth/infrastructure/persistence/entity/RefreshTokenJpaEntity.java index 6d91871..2f775e3 100644 --- a/src/main/java/org/nkcoder/auth/infrastructure/persistence/entity/RefreshTokenJpaEntity.java +++ b/src/main/java/org/nkcoder/auth/infrastructure/persistence/entity/RefreshTokenJpaEntity.java @@ -14,112 +14,109 @@ import org.springframework.data.jpa.domain.support.AuditingEntityListener; /** - * JPA entity for refresh_tokens table. Implements Persistable to control new/existing entity - * detection since we provide our own UUID. + * JPA entity for refresh_tokens table. Implements Persistable to control new/existing entity detection since we provide + * our own UUID. */ @Entity @Table( - name = "refresh_tokens", - indexes = { - @Index(name = "idx_refresh_tokens_token", columnList = "token"), - @Index(name = "idx_refresh_tokens_token_family", columnList = "token_family"), - @Index(name = "idx_refresh_tokens_user_id", columnList = "user_id") - }) + name = "refresh_tokens", + indexes = { + @Index(name = "idx_refresh_tokens_token", columnList = "token"), + @Index(name = "idx_refresh_tokens_token_family", columnList = "token_family"), + @Index(name = "idx_refresh_tokens_user_id", columnList = "user_id") + }) @EntityListeners(AuditingEntityListener.class) public class RefreshTokenJpaEntity implements Persistable { - @Id private UUID id; + @Id + private UUID id; - @Column(nullable = false, unique = true) - private String token; + @Column(nullable = false, unique = true) + private String token; - @Column(name = "token_family", nullable = false) - private String tokenFamily; + @Column(name = "token_family", nullable = false) + private String tokenFamily; - @Column(name = "user_id", nullable = false) - private UUID userId; + @Column(name = "user_id", nullable = false) + private UUID userId; - @Column(name = "expires_at", nullable = false) - private LocalDateTime expiresAt; + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; - @CreatedDate - @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; - @Transient private boolean isNew = false; + @Transient + private boolean isNew = false; - // Required by JPA - protected RefreshTokenJpaEntity() {} + // Required by JPA + protected RefreshTokenJpaEntity() {} - public RefreshTokenJpaEntity( - UUID id, - String token, - String tokenFamily, - UUID userId, - LocalDateTime expiresAt, - LocalDateTime createdAt) { - this.id = id; - this.token = token; - this.tokenFamily = tokenFamily; - this.userId = userId; - this.expiresAt = expiresAt; - this.createdAt = createdAt; - } + public RefreshTokenJpaEntity( + UUID id, String token, String tokenFamily, UUID userId, LocalDateTime expiresAt, LocalDateTime createdAt) { + this.id = id; + this.token = token; + this.tokenFamily = tokenFamily; + this.userId = userId; + this.expiresAt = expiresAt; + this.createdAt = createdAt; + } - // Getters and setters + // Getters and setters - public UUID getId() { - return id; - } + public UUID getId() { + return id; + } - public void setId(UUID id) { - this.id = id; - } + public void setId(UUID id) { + this.id = id; + } - public String getToken() { - return token; - } + public String getToken() { + return token; + } - public void setToken(String token) { - this.token = token; - } + public void setToken(String token) { + this.token = token; + } - public String getTokenFamily() { - return tokenFamily; - } + public String getTokenFamily() { + return tokenFamily; + } - public void setTokenFamily(String tokenFamily) { - this.tokenFamily = tokenFamily; - } + public void setTokenFamily(String tokenFamily) { + this.tokenFamily = tokenFamily; + } - public UUID getUserId() { - return userId; - } + public UUID getUserId() { + return userId; + } - public void setUserId(UUID userId) { - this.userId = userId; - } + public void setUserId(UUID userId) { + this.userId = userId; + } - public LocalDateTime getExpiresAt() { - return expiresAt; - } + public LocalDateTime getExpiresAt() { + return expiresAt; + } - public void setExpiresAt(LocalDateTime expiresAt) { - this.expiresAt = expiresAt; - } + public void setExpiresAt(LocalDateTime expiresAt) { + this.expiresAt = expiresAt; + } - public LocalDateTime getCreatedAt() { - return createdAt; - } + public LocalDateTime getCreatedAt() { + return createdAt; + } - // Persistable implementation + // Persistable implementation - @Override - public boolean isNew() { - return isNew; - } + @Override + public boolean isNew() { + return isNew; + } - public void markAsNew() { - this.isNew = true; - } + public void markAsNew() { + this.isNew = true; + } } diff --git a/src/main/java/org/nkcoder/auth/infrastructure/persistence/mapper/AuthUserPersistenceMapper.java b/src/main/java/org/nkcoder/auth/infrastructure/persistence/mapper/AuthUserPersistenceMapper.java index 96db20c..c38d41c 100644 --- a/src/main/java/org/nkcoder/auth/infrastructure/persistence/mapper/AuthUserPersistenceMapper.java +++ b/src/main/java/org/nkcoder/auth/infrastructure/persistence/mapper/AuthUserPersistenceMapper.java @@ -11,29 +11,29 @@ @Component public class AuthUserPersistenceMapper { - public AuthUser toDomain(AuthUserJpaEntity entity) { - return AuthUser.reconstitute( - AuthUserId.of(entity.getId()), - Email.of(entity.getEmail()), - HashedPassword.of(entity.getPassword()), - entity.getName(), - entity.getRole(), - entity.getLastLoginAt()); - } + public AuthUser toDomain(AuthUserJpaEntity entity) { + return AuthUser.reconstitute( + AuthUserId.of(entity.getId()), + Email.of(entity.getEmail()), + HashedPassword.of(entity.getPassword()), + entity.getName(), + entity.getRole(), + entity.getLastLoginAt()); + } - public AuthUserJpaEntity toEntity(AuthUser domain) { - return new AuthUserJpaEntity( - domain.getId().value(), - domain.getEmail().value(), - domain.getPassword().value(), - domain.getName(), - domain.getRole(), - domain.getLastLoginAt()); - } + public AuthUserJpaEntity toEntity(AuthUser domain) { + return new AuthUserJpaEntity( + domain.getId().value(), + domain.getEmail().value(), + domain.getPassword().value(), + domain.getName(), + domain.getRole(), + domain.getLastLoginAt()); + } - public AuthUserJpaEntity toNewEntity(AuthUser domain) { - AuthUserJpaEntity entity = toEntity(domain); - entity.markAsNew(); - return entity; - } + public AuthUserJpaEntity toNewEntity(AuthUser domain) { + AuthUserJpaEntity entity = toEntity(domain); + entity.markAsNew(); + return entity; + } } diff --git a/src/main/java/org/nkcoder/auth/infrastructure/persistence/mapper/RefreshTokenPersistenceMapper.java b/src/main/java/org/nkcoder/auth/infrastructure/persistence/mapper/RefreshTokenPersistenceMapper.java index 15fe8bc..d3d4474 100644 --- a/src/main/java/org/nkcoder/auth/infrastructure/persistence/mapper/RefreshTokenPersistenceMapper.java +++ b/src/main/java/org/nkcoder/auth/infrastructure/persistence/mapper/RefreshTokenPersistenceMapper.java @@ -10,29 +10,29 @@ @Component public class RefreshTokenPersistenceMapper { - public RefreshToken toDomain(RefreshTokenJpaEntity entity) { - return RefreshToken.reconstitute( - entity.getId(), - entity.getToken(), - TokenFamily.of(entity.getTokenFamily()), - AuthUserId.of(entity.getUserId()), - entity.getExpiresAt(), - entity.getCreatedAt()); - } + public RefreshToken toDomain(RefreshTokenJpaEntity entity) { + return RefreshToken.reconstitute( + entity.getId(), + entity.getToken(), + TokenFamily.of(entity.getTokenFamily()), + AuthUserId.of(entity.getUserId()), + entity.getExpiresAt(), + entity.getCreatedAt()); + } - public RefreshTokenJpaEntity toEntity(RefreshToken domain) { - return new RefreshTokenJpaEntity( - domain.getId(), - domain.getToken(), - domain.getTokenFamily().value(), - domain.getUserId().value(), - domain.getExpiresAt(), - domain.getCreatedAt()); - } + public RefreshTokenJpaEntity toEntity(RefreshToken domain) { + return new RefreshTokenJpaEntity( + domain.getId(), + domain.getToken(), + domain.getTokenFamily().value(), + domain.getUserId().value(), + domain.getExpiresAt(), + domain.getCreatedAt()); + } - public RefreshTokenJpaEntity toNewEntity(RefreshToken domain) { - RefreshTokenJpaEntity entity = toEntity(domain); - entity.markAsNew(); - return entity; - } + public RefreshTokenJpaEntity toNewEntity(RefreshToken domain) { + RefreshTokenJpaEntity entity = toEntity(domain); + entity.markAsNew(); + return entity; + } } diff --git a/src/main/java/org/nkcoder/auth/infrastructure/persistence/repository/AuthUserJpaRepository.java b/src/main/java/org/nkcoder/auth/infrastructure/persistence/repository/AuthUserJpaRepository.java index 8025798..dcbd5ce 100644 --- a/src/main/java/org/nkcoder/auth/infrastructure/persistence/repository/AuthUserJpaRepository.java +++ b/src/main/java/org/nkcoder/auth/infrastructure/persistence/repository/AuthUserJpaRepository.java @@ -14,11 +14,11 @@ @Repository public interface AuthUserJpaRepository extends JpaRepository { - Optional findByEmail(String email); + Optional findByEmail(String email); - boolean existsByEmail(String email); + boolean existsByEmail(String email); - @Modifying - @Query("UPDATE AuthUserJpaEntity u SET u.lastLoginAt = :lastLoginAt WHERE u.id = :id") - void updateLastLoginAt(@Param("id") UUID id, @Param("lastLoginAt") LocalDateTime lastLoginAt); + @Modifying + @Query("UPDATE AuthUserJpaEntity u SET u.lastLoginAt = :lastLoginAt WHERE u.id = :id") + void updateLastLoginAt(@Param("id") UUID id, @Param("lastLoginAt") LocalDateTime lastLoginAt); } diff --git a/src/main/java/org/nkcoder/auth/infrastructure/persistence/repository/AuthUserRepositoryAdapter.java b/src/main/java/org/nkcoder/auth/infrastructure/persistence/repository/AuthUserRepositoryAdapter.java index 3f80ef4..a58d684 100644 --- a/src/main/java/org/nkcoder/auth/infrastructure/persistence/repository/AuthUserRepositoryAdapter.java +++ b/src/main/java/org/nkcoder/auth/infrastructure/persistence/repository/AuthUserRepositoryAdapter.java @@ -13,40 +13,39 @@ @Repository public class AuthUserRepositoryAdapter implements AuthUserRepository { - private final AuthUserJpaRepository jpaRepository; - private final AuthUserPersistenceMapper mapper; - - public AuthUserRepositoryAdapter( - AuthUserJpaRepository jpaRepository, AuthUserPersistenceMapper mapper) { - this.jpaRepository = jpaRepository; - this.mapper = mapper; - } - - @Override - public Optional findById(AuthUserId id) { - return jpaRepository.findById(id.value()).map(mapper::toDomain); - } - - @Override - public Optional findByEmail(Email email) { - return jpaRepository.findByEmail(email.value()).map(mapper::toDomain); - } - - @Override - public boolean existsByEmail(Email email) { - return jpaRepository.existsByEmail(email.value()); - } - - @Override - public AuthUser save(AuthUser authUser) { - boolean exists = jpaRepository.existsById(authUser.getId().value()); - var entity = exists ? mapper.toEntity(authUser) : mapper.toNewEntity(authUser); - var savedEntity = jpaRepository.save(entity); - return mapper.toDomain(savedEntity); - } - - @Override - public void updateLastLoginAt(AuthUserId id, LocalDateTime lastLoginAt) { - jpaRepository.updateLastLoginAt(id.value(), lastLoginAt); - } + private final AuthUserJpaRepository jpaRepository; + private final AuthUserPersistenceMapper mapper; + + public AuthUserRepositoryAdapter(AuthUserJpaRepository jpaRepository, AuthUserPersistenceMapper mapper) { + this.jpaRepository = jpaRepository; + this.mapper = mapper; + } + + @Override + public Optional findById(AuthUserId id) { + return jpaRepository.findById(id.value()).map(mapper::toDomain); + } + + @Override + public Optional findByEmail(Email email) { + return jpaRepository.findByEmail(email.value()).map(mapper::toDomain); + } + + @Override + public boolean existsByEmail(Email email) { + return jpaRepository.existsByEmail(email.value()); + } + + @Override + public AuthUser save(AuthUser authUser) { + boolean exists = jpaRepository.existsById(authUser.getId().value()); + var entity = exists ? mapper.toEntity(authUser) : mapper.toNewEntity(authUser); + var savedEntity = jpaRepository.save(entity); + return mapper.toDomain(savedEntity); + } + + @Override + public void updateLastLoginAt(AuthUserId id, LocalDateTime lastLoginAt) { + jpaRepository.updateLastLoginAt(id.value(), lastLoginAt); + } } diff --git a/src/main/java/org/nkcoder/auth/infrastructure/persistence/repository/RefreshTokenJpaRepository.java b/src/main/java/org/nkcoder/auth/infrastructure/persistence/repository/RefreshTokenJpaRepository.java index 3966fd9..abab3dc 100644 --- a/src/main/java/org/nkcoder/auth/infrastructure/persistence/repository/RefreshTokenJpaRepository.java +++ b/src/main/java/org/nkcoder/auth/infrastructure/persistence/repository/RefreshTokenJpaRepository.java @@ -16,25 +16,25 @@ @Repository public interface RefreshTokenJpaRepository extends JpaRepository { - Optional findByToken(String token); + Optional findByToken(String token); - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT r FROM RefreshTokenJpaEntity r WHERE r.token = :token") - Optional findByTokenForUpdate(@Param("token") String token); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM RefreshTokenJpaEntity r WHERE r.token = :token") + Optional findByTokenForUpdate(@Param("token") String token); - @Modifying - @Query("DELETE FROM RefreshTokenJpaEntity r WHERE r.token = :token") - void deleteByToken(@Param("token") String token); + @Modifying + @Query("DELETE FROM RefreshTokenJpaEntity r WHERE r.token = :token") + void deleteByToken(@Param("token") String token); - @Modifying - @Query("DELETE FROM RefreshTokenJpaEntity r WHERE r.tokenFamily = :tokenFamily") - void deleteByTokenFamily(@Param("tokenFamily") String tokenFamily); + @Modifying + @Query("DELETE FROM RefreshTokenJpaEntity r WHERE r.tokenFamily = :tokenFamily") + void deleteByTokenFamily(@Param("tokenFamily") String tokenFamily); - @Modifying - @Query("DELETE FROM RefreshTokenJpaEntity r WHERE r.userId = :userId") - void deleteByUserId(@Param("userId") UUID userId); + @Modifying + @Query("DELETE FROM RefreshTokenJpaEntity r WHERE r.userId = :userId") + void deleteByUserId(@Param("userId") UUID userId); - @Modifying - @Query("DELETE FROM RefreshTokenJpaEntity r WHERE r.expiresAt < :now") - void deleteExpiredTokens(@Param("now") LocalDateTime now); + @Modifying + @Query("DELETE FROM RefreshTokenJpaEntity r WHERE r.expiresAt < :now") + void deleteExpiredTokens(@Param("now") LocalDateTime now); } diff --git a/src/main/java/org/nkcoder/auth/infrastructure/persistence/repository/RefreshTokenRepositoryAdapter.java b/src/main/java/org/nkcoder/auth/infrastructure/persistence/repository/RefreshTokenRepositoryAdapter.java index 545adb9..f47b959 100644 --- a/src/main/java/org/nkcoder/auth/infrastructure/persistence/repository/RefreshTokenRepositoryAdapter.java +++ b/src/main/java/org/nkcoder/auth/infrastructure/persistence/repository/RefreshTokenRepositoryAdapter.java @@ -13,50 +13,50 @@ @Repository public class RefreshTokenRepositoryAdapter implements RefreshTokenRepository { - private final RefreshTokenJpaRepository jpaRepository; - private final RefreshTokenPersistenceMapper mapper; - - public RefreshTokenRepositoryAdapter( - RefreshTokenJpaRepository jpaRepository, RefreshTokenPersistenceMapper mapper) { - this.jpaRepository = jpaRepository; - this.mapper = mapper; - } - - @Override - public Optional findByToken(String token) { - return jpaRepository.findByToken(token).map(mapper::toDomain); - } - - @Override - public Optional findByTokenForUpdate(String token) { - return jpaRepository.findByTokenForUpdate(token).map(mapper::toDomain); - } - - @Override - public RefreshToken save(RefreshToken refreshToken) { - boolean exists = jpaRepository.existsById(refreshToken.getId()); - var entity = exists ? mapper.toEntity(refreshToken) : mapper.toNewEntity(refreshToken); - var savedEntity = jpaRepository.save(entity); - return mapper.toDomain(savedEntity); - } - - @Override - public void deleteByToken(String token) { - jpaRepository.deleteByToken(token); - } - - @Override - public void deleteByTokenFamily(TokenFamily tokenFamily) { - jpaRepository.deleteByTokenFamily(tokenFamily.value()); - } - - @Override - public void deleteByUserId(AuthUserId userId) { - jpaRepository.deleteByUserId(userId.value()); - } - - @Override - public void deleteExpiredTokens(LocalDateTime now) { - jpaRepository.deleteExpiredTokens(now); - } + private final RefreshTokenJpaRepository jpaRepository; + private final RefreshTokenPersistenceMapper mapper; + + public RefreshTokenRepositoryAdapter( + RefreshTokenJpaRepository jpaRepository, RefreshTokenPersistenceMapper mapper) { + this.jpaRepository = jpaRepository; + this.mapper = mapper; + } + + @Override + public Optional findByToken(String token) { + return jpaRepository.findByToken(token).map(mapper::toDomain); + } + + @Override + public Optional findByTokenForUpdate(String token) { + return jpaRepository.findByTokenForUpdate(token).map(mapper::toDomain); + } + + @Override + public RefreshToken save(RefreshToken refreshToken) { + boolean exists = jpaRepository.existsById(refreshToken.getId()); + var entity = exists ? mapper.toEntity(refreshToken) : mapper.toNewEntity(refreshToken); + var savedEntity = jpaRepository.save(entity); + return mapper.toDomain(savedEntity); + } + + @Override + public void deleteByToken(String token) { + jpaRepository.deleteByToken(token); + } + + @Override + public void deleteByTokenFamily(TokenFamily tokenFamily) { + jpaRepository.deleteByTokenFamily(tokenFamily.value()); + } + + @Override + public void deleteByUserId(AuthUserId userId) { + jpaRepository.deleteByUserId(userId.value()); + } + + @Override + public void deleteExpiredTokens(LocalDateTime now) { + jpaRepository.deleteExpiredTokens(now); + } } diff --git a/src/main/java/org/nkcoder/auth/infrastructure/security/BcryptPasswordEncoderAdapter.java b/src/main/java/org/nkcoder/auth/infrastructure/security/BcryptPasswordEncoderAdapter.java index 9ad834d..6951bb5 100644 --- a/src/main/java/org/nkcoder/auth/infrastructure/security/BcryptPasswordEncoderAdapter.java +++ b/src/main/java/org/nkcoder/auth/infrastructure/security/BcryptPasswordEncoderAdapter.java @@ -9,20 +9,20 @@ @Component public class BcryptPasswordEncoderAdapter implements PasswordEncoder { - private static final int BCRYPT_STRENGTH = 12; - private final BCryptPasswordEncoder bCryptPasswordEncoder; + private static final int BCRYPT_STRENGTH = 12; + private final BCryptPasswordEncoder bCryptPasswordEncoder; - public BcryptPasswordEncoderAdapter() { - this.bCryptPasswordEncoder = new BCryptPasswordEncoder(BCRYPT_STRENGTH); - } + public BcryptPasswordEncoderAdapter() { + this.bCryptPasswordEncoder = new BCryptPasswordEncoder(BCRYPT_STRENGTH); + } - @Override - public HashedPassword encode(String rawPassword) { - return HashedPassword.of(bCryptPasswordEncoder.encode(rawPassword)); - } + @Override + public HashedPassword encode(String rawPassword) { + return HashedPassword.of(bCryptPasswordEncoder.encode(rawPassword)); + } - @Override - public boolean matches(String rawPassword, HashedPassword hashedPassword) { - return bCryptPasswordEncoder.matches(rawPassword, hashedPassword.value()); - } + @Override + public boolean matches(String rawPassword, HashedPassword hashedPassword) { + return bCryptPasswordEncoder.matches(rawPassword, hashedPassword.value()); + } } diff --git a/src/main/java/org/nkcoder/auth/infrastructure/security/JwtTokenGeneratorAdapter.java b/src/main/java/org/nkcoder/auth/infrastructure/security/JwtTokenGeneratorAdapter.java index 8406200..d9837b4 100644 --- a/src/main/java/org/nkcoder/auth/infrastructure/security/JwtTokenGeneratorAdapter.java +++ b/src/main/java/org/nkcoder/auth/infrastructure/security/JwtTokenGeneratorAdapter.java @@ -26,119 +26,117 @@ @Component public class JwtTokenGeneratorAdapter implements TokenGenerator { - private static final Logger logger = LoggerFactory.getLogger(JwtTokenGeneratorAdapter.class); - private static final int MINIMUM_KEY_LENGTH_BYTES = 32; - - private final JwtProperties jwtProperties; - private final SecretKey accessTokenKey; - private final SecretKey refreshTokenKey; - - public JwtTokenGeneratorAdapter(JwtProperties jwtProperties) { - this.jwtProperties = jwtProperties; - this.accessTokenKey = Keys.hmacShaKeyFor(jwtProperties.secret().access().getBytes()); - this.refreshTokenKey = Keys.hmacShaKeyFor(jwtProperties.secret().refresh().getBytes()); - } - - @PostConstruct - public void validateKeyStrength() { - validateSecretKeyStrength(jwtProperties.secret().access(), "access"); - validateSecretKeyStrength(jwtProperties.secret().refresh(), "refresh"); - logger.info("JWT secret key strength validation passed for all tokens"); - } - - private void validateSecretKeyStrength(String secret, String tokenType) { - if (secret == null || secret.getBytes().length < MINIMUM_KEY_LENGTH_BYTES) { - throw new IllegalStateException( - String.format( - "JWT %s secret key must be at least %d bytes. Current length: %d bytes", - tokenType, MINIMUM_KEY_LENGTH_BYTES, secret == null ? 0 : secret.getBytes().length)); + private static final Logger logger = LoggerFactory.getLogger(JwtTokenGeneratorAdapter.class); + private static final int MINIMUM_KEY_LENGTH_BYTES = 32; + + private final JwtProperties jwtProperties; + private final SecretKey accessTokenKey; + private final SecretKey refreshTokenKey; + + public JwtTokenGeneratorAdapter(JwtProperties jwtProperties) { + this.jwtProperties = jwtProperties; + this.accessTokenKey = Keys.hmacShaKeyFor(jwtProperties.secret().access().getBytes()); + this.refreshTokenKey = + Keys.hmacShaKeyFor(jwtProperties.secret().refresh().getBytes()); } - } - - @Override - public TokenPair generateTokenPair( - AuthUserId userId, Email email, AuthRole role, TokenFamily tokenFamily) { - String accessToken = generateAccessToken(userId, email, role); - String refreshToken = generateRefreshToken(userId, tokenFamily); - return new TokenPair(accessToken, refreshToken); - } - - @Override - public LocalDateTime getRefreshTokenExpiry() { - Duration duration = parseDuration(jwtProperties.expiration().refresh()); - return LocalDateTime.now().plus(duration); - } - - @Override - public RefreshTokenClaims validateRefreshToken(String token) { - try { - Claims claims = - Jwts.parser() - .verifyWith(refreshTokenKey) - .requireIssuer(jwtProperties.issuer()) - .build() - .parseSignedClaims(token) - .getPayload(); - - AuthUserId userId = AuthUserId.of(claims.getSubject()); - TokenFamily tokenFamily = TokenFamily.of(claims.get("tokenFamily", String.class)); - - return new RefreshTokenClaims(userId, tokenFamily); - } catch (JwtException e) { - logger.error("Refresh token validation failed: {}", e.getMessage()); - throw new AuthenticationException("Invalid refresh token"); + + @PostConstruct + public void validateKeyStrength() { + validateSecretKeyStrength(jwtProperties.secret().access(), "access"); + validateSecretKeyStrength(jwtProperties.secret().refresh(), "refresh"); + logger.info("JWT secret key strength validation passed for all tokens"); + } + + private void validateSecretKeyStrength(String secret, String tokenType) { + if (secret == null || secret.getBytes().length < MINIMUM_KEY_LENGTH_BYTES) { + throw new IllegalStateException(String.format( + "JWT %s secret key must be at least %d bytes. Current length: %d bytes", + tokenType, MINIMUM_KEY_LENGTH_BYTES, secret == null ? 0 : secret.getBytes().length)); + } + } + + @Override + public TokenPair generateTokenPair(AuthUserId userId, Email email, AuthRole role, TokenFamily tokenFamily) { + String accessToken = generateAccessToken(userId, email, role); + String refreshToken = generateRefreshToken(userId, tokenFamily); + return new TokenPair(accessToken, refreshToken); + } + + @Override + public LocalDateTime getRefreshTokenExpiry() { + Duration duration = parseDuration(jwtProperties.expiration().refresh()); + return LocalDateTime.now().plus(duration); } - } - - private String generateAccessToken(AuthUserId userId, Email email, AuthRole role) { - Date now = new Date(); - Duration duration = parseDuration(jwtProperties.expiration().access()); - Date expiration = new Date(now.getTime() + duration.toMillis()); - - return Jwts.builder() - .subject(userId.value().toString()) - .issuer(jwtProperties.issuer()) - .issuedAt(now) - .expiration(expiration) - .claim("email", email.value()) - .claim("role", role.name()) - .claim("jti", UUID.randomUUID().toString()) - .signWith(accessTokenKey, Jwts.SIG.HS512) - .compact(); - } - - private String generateRefreshToken(AuthUserId userId, TokenFamily tokenFamily) { - Date now = new Date(); - Duration duration = parseDuration(jwtProperties.expiration().refresh()); - Date expiration = new Date(now.getTime() + duration.toMillis()); - - return Jwts.builder() - .subject(userId.value().toString()) - .issuer(jwtProperties.issuer()) - .issuedAt(now) - .expiration(expiration) - .claim("tokenFamily", tokenFamily.value()) - .claim("jti", UUID.randomUUID().toString()) - .signWith(refreshTokenKey, Jwts.SIG.HS512) - .compact(); - } - - private Duration parseDuration(String durationString) { - if (durationString == null || durationString.isEmpty()) { - throw new IllegalArgumentException("Duration string cannot be null or empty"); + + @Override + public RefreshTokenClaims validateRefreshToken(String token) { + try { + Claims claims = Jwts.parser() + .verifyWith(refreshTokenKey) + .requireIssuer(jwtProperties.issuer()) + .build() + .parseSignedClaims(token) + .getPayload(); + + AuthUserId userId = AuthUserId.of(claims.getSubject()); + TokenFamily tokenFamily = TokenFamily.of(claims.get("tokenFamily", String.class)); + + return new RefreshTokenClaims(userId, tokenFamily); + } catch (JwtException e) { + logger.error("Refresh token validation failed: {}", e.getMessage()); + throw new AuthenticationException("Invalid refresh token"); + } + } + + private String generateAccessToken(AuthUserId userId, Email email, AuthRole role) { + Date now = new Date(); + Duration duration = parseDuration(jwtProperties.expiration().access()); + Date expiration = new Date(now.getTime() + duration.toMillis()); + + return Jwts.builder() + .subject(userId.value().toString()) + .issuer(jwtProperties.issuer()) + .issuedAt(now) + .expiration(expiration) + .claim("email", email.value()) + .claim("role", role.name()) + .claim("jti", UUID.randomUUID().toString()) + .signWith(accessTokenKey, Jwts.SIG.HS512) + .compact(); } - String value = durationString.substring(0, durationString.length() - 1); - String unit = durationString.substring(durationString.length() - 1); + private String generateRefreshToken(AuthUserId userId, TokenFamily tokenFamily) { + Date now = new Date(); + Duration duration = parseDuration(jwtProperties.expiration().refresh()); + Date expiration = new Date(now.getTime() + duration.toMillis()); + + return Jwts.builder() + .subject(userId.value().toString()) + .issuer(jwtProperties.issuer()) + .issuedAt(now) + .expiration(expiration) + .claim("tokenFamily", tokenFamily.value()) + .claim("jti", UUID.randomUUID().toString()) + .signWith(refreshTokenKey, Jwts.SIG.HS512) + .compact(); + } - long amount = Long.parseLong(value); + private Duration parseDuration(String durationString) { + if (durationString == null || durationString.isEmpty()) { + throw new IllegalArgumentException("Duration string cannot be null or empty"); + } - return switch (unit) { - case "s" -> Duration.ofSeconds(amount); - case "m" -> Duration.ofMinutes(amount); - case "h" -> Duration.ofHours(amount); - case "d" -> Duration.ofDays(amount); - default -> throw new IllegalArgumentException("Invalid duration unit: " + unit); - }; - } + String value = durationString.substring(0, durationString.length() - 1); + String unit = durationString.substring(durationString.length() - 1); + + long amount = Long.parseLong(value); + + return switch (unit) { + case "s" -> Duration.ofSeconds(amount); + case "m" -> Duration.ofMinutes(amount); + case "h" -> Duration.ofHours(amount); + case "d" -> Duration.ofDays(amount); + default -> throw new IllegalArgumentException("Invalid duration unit: " + unit); + }; + } } diff --git a/src/main/java/org/nkcoder/auth/infrastructure/security/JwtUtil.java b/src/main/java/org/nkcoder/auth/infrastructure/security/JwtUtil.java index 87814d2..da06356 100644 --- a/src/main/java/org/nkcoder/auth/infrastructure/security/JwtUtil.java +++ b/src/main/java/org/nkcoder/auth/infrastructure/security/JwtUtil.java @@ -20,144 +20,147 @@ @Component public class JwtUtil { - private static final Logger logger = LoggerFactory.getLogger(JwtUtil.class); - private static final int MINIMUM_KEY_LENGTH_BYTES = 32; - - private final JwtProperties jwtProperties; - private final SecretKey accessTokenKey; - private final SecretKey refreshTokenKey; - - @Autowired - public JwtUtil(JwtProperties jwtProperties) { - this.jwtProperties = jwtProperties; - this.accessTokenKey = Keys.hmacShaKeyFor(jwtProperties.secret().access().getBytes()); - this.refreshTokenKey = Keys.hmacShaKeyFor(jwtProperties.secret().refresh().getBytes()); - } - - @PostConstruct - public void validateKeyStrength() { - validateSecretKeyStrength(jwtProperties.secret().access(), "access"); - validateSecretKeyStrength(jwtProperties.secret().refresh(), "refresh"); - logger.info("JWT secret key strength validation passed for all tokens"); - } - - private void validateSecretKeyStrength(String secret, String tokenType) { - if (secret == null || secret.getBytes().length < MINIMUM_KEY_LENGTH_BYTES) { - throw new IllegalStateException( - String.format( - "JWT %s secret key must be at least %d bytes. Current length: %d bytes", - tokenType, MINIMUM_KEY_LENGTH_BYTES, secret == null ? 0 : secret.getBytes().length)); + private static final Logger logger = LoggerFactory.getLogger(JwtUtil.class); + private static final int MINIMUM_KEY_LENGTH_BYTES = 32; + + private final JwtProperties jwtProperties; + private final SecretKey accessTokenKey; + private final SecretKey refreshTokenKey; + + @Autowired + public JwtUtil(JwtProperties jwtProperties) { + this.jwtProperties = jwtProperties; + this.accessTokenKey = Keys.hmacShaKeyFor(jwtProperties.secret().access().getBytes()); + this.refreshTokenKey = + Keys.hmacShaKeyFor(jwtProperties.secret().refresh().getBytes()); } - } - - public String generateAccessToken(UUID userId, String email, AuthRole role) { - Date now = new Date(); - Duration duration = parseDuration(jwtProperties.expiration().access()); - Date expiration = new Date(now.getTime() + duration.toMillis()); - - return Jwts.builder() - .subject(userId.toString()) - .issuer(jwtProperties.issuer()) - .issuedAt(now) - .expiration(expiration) - .claim("email", email) - .claim("role", role.name()) - .claim("jti", UUID.randomUUID().toString()) - .signWith(accessTokenKey, Jwts.SIG.HS512) - .compact(); - } - - public String generateRefreshToken(UUID userId, String tokenFamily) { - Date now = new Date(); - Duration duration = parseDuration(jwtProperties.expiration().refresh()); - Date expiration = new Date(now.getTime() + duration.toMillis()); - - return Jwts.builder() - .subject(userId.toString()) - .issuer(jwtProperties.issuer()) - .issuedAt(now) - .expiration(expiration) - .claim("tokenFamily", tokenFamily) - .claim("jti", UUID.randomUUID().toString()) - .signWith(refreshTokenKey, Jwts.SIG.HS512) - .compact(); - } - - public Claims validateAccessToken(String token) { - try { - return Jwts.parser() - .verifyWith(accessTokenKey) - .requireIssuer(jwtProperties.issuer()) - .build() - .parseSignedClaims(token) - .getPayload(); - } catch (JwtException e) { - logger.error("Access token validation failed: {}", e.getMessage()); - throw e; + + @PostConstruct + public void validateKeyStrength() { + validateSecretKeyStrength(jwtProperties.secret().access(), "access"); + validateSecretKeyStrength(jwtProperties.secret().refresh(), "refresh"); + logger.info("JWT secret key strength validation passed for all tokens"); + } + + private void validateSecretKeyStrength(String secret, String tokenType) { + if (secret == null || secret.getBytes().length < MINIMUM_KEY_LENGTH_BYTES) { + throw new IllegalStateException(String.format( + "JWT %s secret key must be at least %d bytes. Current length: %d bytes", + tokenType, MINIMUM_KEY_LENGTH_BYTES, secret == null ? 0 : secret.getBytes().length)); + } + } + + public String generateAccessToken(UUID userId, String email, AuthRole role) { + Date now = new Date(); + Duration duration = parseDuration(jwtProperties.expiration().access()); + Date expiration = new Date(now.getTime() + duration.toMillis()); + + return Jwts.builder() + .subject(userId.toString()) + .issuer(jwtProperties.issuer()) + .issuedAt(now) + .expiration(expiration) + .claim("email", email) + .claim("role", role.name()) + .claim("jti", UUID.randomUUID().toString()) + .signWith(accessTokenKey, Jwts.SIG.HS512) + .compact(); + } + + public String generateRefreshToken(UUID userId, String tokenFamily) { + Date now = new Date(); + Duration duration = parseDuration(jwtProperties.expiration().refresh()); + Date expiration = new Date(now.getTime() + duration.toMillis()); + + return Jwts.builder() + .subject(userId.toString()) + .issuer(jwtProperties.issuer()) + .issuedAt(now) + .expiration(expiration) + .claim("tokenFamily", tokenFamily) + .claim("jti", UUID.randomUUID().toString()) + .signWith(refreshTokenKey, Jwts.SIG.HS512) + .compact(); + } + + public Claims validateAccessToken(String token) { + try { + return Jwts.parser() + .verifyWith(accessTokenKey) + .requireIssuer(jwtProperties.issuer()) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (JwtException e) { + logger.error("Access token validation failed: {}", e.getMessage()); + throw e; + } + } + + public Claims validateRefreshToken(String token) { + try { + return Jwts.parser() + .verifyWith(refreshTokenKey) + .requireIssuer(jwtProperties.issuer()) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (JwtException e) { + logger.error("Refresh token validation failed: {}", e.getMessage()); + throw e; + } + } + + public boolean isTokenExpired(String token) { + try { + Claims claims = Jwts.parser() + .verifyWith(accessTokenKey) + .build() + .parseSignedClaims(token) + .getPayload(); + return claims.getExpiration().before(new Date()); + } catch (Exception e) { + return true; + } + } + + public UUID getUserIdFromToken(String token) { + Claims claims = validateAccessToken(token); + return UUID.fromString(claims.getSubject()); } - } - - public Claims validateRefreshToken(String token) { - try { - return Jwts.parser() - .verifyWith(refreshTokenKey) - .requireIssuer(jwtProperties.issuer()) - .build() - .parseSignedClaims(token) - .getPayload(); - } catch (JwtException e) { - logger.error("Refresh token validation failed: {}", e.getMessage()); - throw e; + + public String getEmailFromToken(String token) { + Claims claims = validateAccessToken(token); + return claims.get("email", String.class); } - } - - public boolean isTokenExpired(String token) { - try { - Claims claims = - Jwts.parser().verifyWith(accessTokenKey).build().parseSignedClaims(token).getPayload(); - return claims.getExpiration().before(new Date()); - } catch (Exception e) { - return true; + + public AuthRole getRoleFromToken(String token) { + Claims claims = validateAccessToken(token); + String roleString = claims.get("role", String.class); + return AuthRole.valueOf(roleString); } - } - - public UUID getUserIdFromToken(String token) { - Claims claims = validateAccessToken(token); - return UUID.fromString(claims.getSubject()); - } - - public String getEmailFromToken(String token) { - Claims claims = validateAccessToken(token); - return claims.get("email", String.class); - } - - public AuthRole getRoleFromToken(String token) { - Claims claims = validateAccessToken(token); - String roleString = claims.get("role", String.class); - return AuthRole.valueOf(roleString); - } - - public LocalDateTime getTokenExpiry(String durationString) { - Duration duration = parseDuration(durationString); - return LocalDateTime.now().plus(duration); - } - - private Duration parseDuration(String durationString) { - if (durationString == null || durationString.isEmpty()) { - throw new IllegalArgumentException("Duration string cannot be null or empty"); + + public LocalDateTime getTokenExpiry(String durationString) { + Duration duration = parseDuration(durationString); + return LocalDateTime.now().plus(duration); } - String value = durationString.substring(0, durationString.length() - 1); - String unit = durationString.substring(durationString.length() - 1); + private Duration parseDuration(String durationString) { + if (durationString == null || durationString.isEmpty()) { + throw new IllegalArgumentException("Duration string cannot be null or empty"); + } - long amount = Long.parseLong(value); + String value = durationString.substring(0, durationString.length() - 1); + String unit = durationString.substring(durationString.length() - 1); - return switch (unit) { - case "s" -> Duration.ofSeconds(amount); - case "m" -> Duration.ofMinutes(amount); - case "h" -> Duration.ofHours(amount); - case "d" -> Duration.ofDays(amount); - default -> throw new IllegalArgumentException("Invalid duration unit: " + unit); - }; - } + long amount = Long.parseLong(value); + + return switch (unit) { + case "s" -> Duration.ofSeconds(amount); + case "m" -> Duration.ofMinutes(amount); + case "h" -> Duration.ofHours(amount); + case "d" -> Duration.ofDays(amount); + default -> throw new IllegalArgumentException("Invalid duration unit: " + unit); + }; + } } diff --git a/src/main/java/org/nkcoder/auth/interfaces/grpc/AuthGrpcService.java b/src/main/java/org/nkcoder/auth/interfaces/grpc/AuthGrpcService.java index b75c995..bbd9966 100644 --- a/src/main/java/org/nkcoder/auth/interfaces/grpc/AuthGrpcService.java +++ b/src/main/java/org/nkcoder/auth/interfaces/grpc/AuthGrpcService.java @@ -19,95 +19,88 @@ @GrpcService public class AuthGrpcService extends AuthServiceGrpc.AuthServiceImplBase { - private static final Logger logger = LoggerFactory.getLogger(AuthGrpcService.class); - - private final AuthApplicationService authService; - private final GrpcAuthMapper mapper; - - public AuthGrpcService(AuthApplicationService authService, GrpcAuthMapper mapper) { - this.authService = authService; - this.mapper = mapper; - } - - @Override - public void register( - AuthProto.RegisterRequest request, StreamObserver responseObserver) { - logger.info("Received gRPC registration request for email: {}", request.getEmail()); - - if (request.getEmail().isEmpty() - || request.getPassword().isEmpty() - || request.getName().isEmpty()) { - logger.error("Invalid registration request: email, password, and name must not be empty"); - responseObserver.onError( - Status.INVALID_ARGUMENT - .withDescription("Email, password, and name must not be empty") - .asRuntimeException()); - return; - } - - try { - RegisterCommand command = - new RegisterCommand( - request.getEmail(), request.getPassword(), request.getName(), AuthRole.MEMBER); - - AuthResult result = authService.register(command); - - AuthProto.ApiResponse apiResponse = - AuthProto.ApiResponse.newBuilder() - .setMessage("User registered successfully") - .setData(mapper.toAuthResponse(result)) - .build(); - - responseObserver.onNext(apiResponse); - responseObserver.onCompleted(); - - } catch (ValidationException e) { - logger.error("Registration validation error: {}", e.getMessage()); - responseObserver.onError( - Status.ALREADY_EXISTS.withDescription(e.getMessage()).asRuntimeException()); - } catch (Exception e) { - logger.error("Registration error: {}", e.getMessage(), e); - responseObserver.onError( - Status.INTERNAL.withDescription("Internal server error").asRuntimeException()); - } - } - - @Override - public void login( - AuthProto.LoginRequest request, StreamObserver responseObserver) { - logger.info("Received gRPC login request for email: {}", request.getEmail()); - - if (request.getEmail().isEmpty() || request.getPassword().isEmpty()) { - logger.error("Invalid login request: email and password must not be empty"); - responseObserver.onError( - Status.INVALID_ARGUMENT - .withDescription("Email and password must not be empty") - .asRuntimeException()); - return; - } + private static final Logger logger = LoggerFactory.getLogger(AuthGrpcService.class); - try { - LoginCommand command = new LoginCommand(request.getEmail(), request.getPassword()); + private final AuthApplicationService authService; + private final GrpcAuthMapper mapper; - AuthResult result = authService.login(command); - - AuthProto.ApiResponse apiResponse = - AuthProto.ApiResponse.newBuilder() - .setMessage("User logged in successfully") - .setData(mapper.toAuthResponse(result)) - .build(); + public AuthGrpcService(AuthApplicationService authService, GrpcAuthMapper mapper) { + this.authService = authService; + this.mapper = mapper; + } - responseObserver.onNext(apiResponse); - responseObserver.onCompleted(); + @Override + public void register(AuthProto.RegisterRequest request, StreamObserver responseObserver) { + logger.info("Received gRPC registration request for email: {}", request.getEmail()); + + if (request.getEmail().isEmpty() + || request.getPassword().isEmpty() + || request.getName().isEmpty()) { + logger.error("Invalid registration request: email, password, and name must not be empty"); + responseObserver.onError(Status.INVALID_ARGUMENT + .withDescription("Email, password, and name must not be empty") + .asRuntimeException()); + return; + } + + try { + RegisterCommand command = + new RegisterCommand(request.getEmail(), request.getPassword(), request.getName(), AuthRole.MEMBER); + + AuthResult result = authService.register(command); + + AuthProto.ApiResponse apiResponse = AuthProto.ApiResponse.newBuilder() + .setMessage("User registered successfully") + .setData(mapper.toAuthResponse(result)) + .build(); + + responseObserver.onNext(apiResponse); + responseObserver.onCompleted(); + + } catch (ValidationException e) { + logger.error("Registration validation error: {}", e.getMessage()); + responseObserver.onError( + Status.ALREADY_EXISTS.withDescription(e.getMessage()).asRuntimeException()); + } catch (Exception e) { + logger.error("Registration error: {}", e.getMessage(), e); + responseObserver.onError( + Status.INTERNAL.withDescription("Internal server error").asRuntimeException()); + } + } - } catch (AuthenticationException e) { - logger.error("Login authentication error: {}", e.getMessage()); - responseObserver.onError( - Status.UNAUTHENTICATED.withDescription(e.getMessage()).asRuntimeException()); - } catch (Exception e) { - logger.error("Login error: {}", e.getMessage(), e); - responseObserver.onError( - Status.INTERNAL.withDescription("Internal server error").asRuntimeException()); + @Override + public void login(AuthProto.LoginRequest request, StreamObserver responseObserver) { + logger.info("Received gRPC login request for email: {}", request.getEmail()); + + if (request.getEmail().isEmpty() || request.getPassword().isEmpty()) { + logger.error("Invalid login request: email and password must not be empty"); + responseObserver.onError(Status.INVALID_ARGUMENT + .withDescription("Email and password must not be empty") + .asRuntimeException()); + return; + } + + try { + LoginCommand command = new LoginCommand(request.getEmail(), request.getPassword()); + + AuthResult result = authService.login(command); + + AuthProto.ApiResponse apiResponse = AuthProto.ApiResponse.newBuilder() + .setMessage("User logged in successfully") + .setData(mapper.toAuthResponse(result)) + .build(); + + responseObserver.onNext(apiResponse); + responseObserver.onCompleted(); + + } catch (AuthenticationException e) { + logger.error("Login authentication error: {}", e.getMessage()); + responseObserver.onError( + Status.UNAUTHENTICATED.withDescription(e.getMessage()).asRuntimeException()); + } catch (Exception e) { + logger.error("Login error: {}", e.getMessage(), e); + responseObserver.onError( + Status.INTERNAL.withDescription("Internal server error").asRuntimeException()); + } } - } } diff --git a/src/main/java/org/nkcoder/auth/interfaces/grpc/GrpcAuthMapper.java b/src/main/java/org/nkcoder/auth/interfaces/grpc/GrpcAuthMapper.java index 635aaee..8b71111 100644 --- a/src/main/java/org/nkcoder/auth/interfaces/grpc/GrpcAuthMapper.java +++ b/src/main/java/org/nkcoder/auth/interfaces/grpc/GrpcAuthMapper.java @@ -8,20 +8,21 @@ @Component public class GrpcAuthMapper { - public AuthProto.AuthResponse toAuthResponse(AuthResult result) { - AuthProto.User grpcUser = - AuthProto.User.newBuilder() - .setId(result.userId().toString()) - .setEmail(result.email()) - .setName("") // Name not available in AuthResult - .build(); + public AuthProto.AuthResponse toAuthResponse(AuthResult result) { + AuthProto.User grpcUser = AuthProto.User.newBuilder() + .setId(result.userId().toString()) + .setEmail(result.email()) + .setName("") // Name not available in AuthResult + .build(); - AuthProto.AuthToken grpcTokens = - AuthProto.AuthToken.newBuilder() - .setAccessToken(result.accessToken()) - .setRefreshToken(result.refreshToken()) - .build(); + AuthProto.AuthToken grpcTokens = AuthProto.AuthToken.newBuilder() + .setAccessToken(result.accessToken()) + .setRefreshToken(result.refreshToken()) + .build(); - return AuthProto.AuthResponse.newBuilder().setUser(grpcUser).setAuthToken(grpcTokens).build(); - } + return AuthProto.AuthResponse.newBuilder() + .setUser(grpcUser) + .setAuthToken(grpcTokens) + .build(); + } } diff --git a/src/main/java/org/nkcoder/auth/interfaces/rest/AuthController.java b/src/main/java/org/nkcoder/auth/interfaces/rest/AuthController.java index 86eee7f..46d7d68 100644 --- a/src/main/java/org/nkcoder/auth/interfaces/rest/AuthController.java +++ b/src/main/java/org/nkcoder/auth/interfaces/rest/AuthController.java @@ -22,62 +22,59 @@ @RequestMapping("/api/auth") public class AuthController { - private static final Logger logger = LoggerFactory.getLogger(AuthController.class); + private static final Logger logger = LoggerFactory.getLogger(AuthController.class); - private final AuthApplicationService authService; - private final AuthRequestMapper requestMapper; + private final AuthApplicationService authService; + private final AuthRequestMapper requestMapper; - public AuthController(AuthApplicationService authService, AuthRequestMapper requestMapper) { - this.authService = authService; - this.requestMapper = requestMapper; - } + public AuthController(AuthApplicationService authService, AuthRequestMapper requestMapper) { + this.authService = authService; + this.requestMapper = requestMapper; + } - @PostMapping("/register") - public ResponseEntity> register( - @Valid @RequestBody RegisterRequest request) { - logger.info("Register request for email: {}", request.email()); + @PostMapping("/register") + public ResponseEntity> register(@Valid @RequestBody RegisterRequest request) { + logger.info("Register request for email: {}", request.email()); - AuthResult result = authService.register(requestMapper.toCommand(request)); + AuthResult result = authService.register(requestMapper.toCommand(request)); - return ResponseEntity.status(HttpStatus.CREATED) - .body(ApiResponse.success("User registered successfully", AuthResponse.from(result))); - } + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success("User registered successfully", AuthResponse.from(result))); + } - @PostMapping("/login") - public ResponseEntity> login(@Valid @RequestBody LoginRequest request) { - logger.info("Login request for email: {}", request.email()); + @PostMapping("/login") + public ResponseEntity> login(@Valid @RequestBody LoginRequest request) { + logger.info("Login request for email: {}", request.email()); - AuthResult result = authService.login(requestMapper.toCommand(request)); + AuthResult result = authService.login(requestMapper.toCommand(request)); - return ResponseEntity.ok(ApiResponse.success("Login successful", AuthResponse.from(result))); - } + return ResponseEntity.ok(ApiResponse.success("Login successful", AuthResponse.from(result))); + } - @PostMapping("/refresh") - public ResponseEntity> refreshTokens( - @Valid @RequestBody RefreshTokenRequest request) { - logger.debug("Token refresh request"); + @PostMapping("/refresh") + public ResponseEntity> refreshTokens(@Valid @RequestBody RefreshTokenRequest request) { + logger.debug("Token refresh request"); - AuthResult result = authService.refreshTokens(requestMapper.toCommand(request)); + AuthResult result = authService.refreshTokens(requestMapper.toCommand(request)); - return ResponseEntity.ok(ApiResponse.success("Tokens refreshed", AuthResponse.from(result))); - } + return ResponseEntity.ok(ApiResponse.success("Tokens refreshed", AuthResponse.from(result))); + } - @PostMapping("/logout") - public ResponseEntity> logout(@Valid @RequestBody RefreshTokenRequest request) { - logger.debug("Logout request (all devices)"); + @PostMapping("/logout") + public ResponseEntity> logout(@Valid @RequestBody RefreshTokenRequest request) { + logger.debug("Logout request (all devices)"); - authService.logout(request.refreshToken()); + authService.logout(request.refreshToken()); - return ResponseEntity.ok(ApiResponse.success("Logged out successfully")); - } + return ResponseEntity.ok(ApiResponse.success("Logged out successfully")); + } - @PostMapping("/logout-single") - public ResponseEntity> logoutSingle( - @Valid @RequestBody RefreshTokenRequest request) { - logger.debug("Logout request (single device)"); + @PostMapping("/logout-single") + public ResponseEntity> logoutSingle(@Valid @RequestBody RefreshTokenRequest request) { + logger.debug("Logout request (single device)"); - authService.logoutSingle(request.refreshToken()); + authService.logoutSingle(request.refreshToken()); - return ResponseEntity.ok(ApiResponse.success("Logged out from current device")); - } + return ResponseEntity.ok(ApiResponse.success("Logged out from current device")); + } } diff --git a/src/main/java/org/nkcoder/auth/interfaces/rest/mapper/AuthRequestMapper.java b/src/main/java/org/nkcoder/auth/interfaces/rest/mapper/AuthRequestMapper.java index c2f0698..ccfe347 100644 --- a/src/main/java/org/nkcoder/auth/interfaces/rest/mapper/AuthRequestMapper.java +++ b/src/main/java/org/nkcoder/auth/interfaces/rest/mapper/AuthRequestMapper.java @@ -12,15 +12,15 @@ @Component public class AuthRequestMapper { - public RegisterCommand toCommand(RegisterRequest request) { - return new RegisterCommand(request.email(), request.password(), request.name(), request.role()); - } + public RegisterCommand toCommand(RegisterRequest request) { + return new RegisterCommand(request.email(), request.password(), request.name(), request.role()); + } - public LoginCommand toCommand(LoginRequest request) { - return new LoginCommand(request.email(), request.password()); - } + public LoginCommand toCommand(LoginRequest request) { + return new LoginCommand(request.email(), request.password()); + } - public RefreshTokenCommand toCommand(RefreshTokenRequest request) { - return new RefreshTokenCommand(request.refreshToken()); - } + public RefreshTokenCommand toCommand(RefreshTokenRequest request) { + return new RefreshTokenCommand(request.refreshToken()); + } } diff --git a/src/main/java/org/nkcoder/auth/interfaces/rest/request/LoginRequest.java b/src/main/java/org/nkcoder/auth/interfaces/rest/request/LoginRequest.java index 7595c73..dd6bfb3 100644 --- a/src/main/java/org/nkcoder/auth/interfaces/rest/request/LoginRequest.java +++ b/src/main/java/org/nkcoder/auth/interfaces/rest/request/LoginRequest.java @@ -4,5 +4,6 @@ import jakarta.validation.constraints.NotBlank; public record LoginRequest( - @NotBlank(message = "Email is required") @Email(message = "Please provide a valid email") String email, - @NotBlank(message = "Password is required") String password) {} + @NotBlank(message = "Email is required") @Email(message = "Please provide a valid email") String email, + + @NotBlank(message = "Password is required") String password) {} diff --git a/src/main/java/org/nkcoder/auth/interfaces/rest/request/RefreshTokenRequest.java b/src/main/java/org/nkcoder/auth/interfaces/rest/request/RefreshTokenRequest.java index 5f38ffc..29467f0 100644 --- a/src/main/java/org/nkcoder/auth/interfaces/rest/request/RefreshTokenRequest.java +++ b/src/main/java/org/nkcoder/auth/interfaces/rest/request/RefreshTokenRequest.java @@ -3,4 +3,4 @@ import jakarta.validation.constraints.NotBlank; public record RefreshTokenRequest( - @NotBlank(message = "Refresh token is required") String refreshToken) {} + @NotBlank(message = "Refresh token is required") String refreshToken) {} diff --git a/src/main/java/org/nkcoder/auth/interfaces/rest/request/RegisterRequest.java b/src/main/java/org/nkcoder/auth/interfaces/rest/request/RegisterRequest.java index 31e1340..9d50039 100644 --- a/src/main/java/org/nkcoder/auth/interfaces/rest/request/RegisterRequest.java +++ b/src/main/java/org/nkcoder/auth/interfaces/rest/request/RegisterRequest.java @@ -7,22 +7,24 @@ import org.nkcoder.auth.domain.model.AuthRole; public record RegisterRequest( - @NotBlank(message = "Email is required") @Email(message = "Please provide a valid email") String email, - @NotBlank(message = "Password is required") @Size(min = 8, message = "Password must be at least 8 characters long") @Pattern( - regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$", - message = - "Password must contain at least one lowercase letter, one uppercase letter, and one" - + " number") + @NotBlank(message = "Email is required") @Email(message = "Please provide a valid email") String email, + + @NotBlank(message = "Password is required") @Size(min = 8, message = "Password must be at least 8 characters long") @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$", + message = "Password must contain at least one lowercase letter, one uppercase letter, and one" + + " number") String password, - @NotBlank(message = "Name is required") @Size(min = 2, max = 50, message = "Name must be between 2 and 50 characters") String name, - AuthRole role) { - public RegisterRequest { - if (email != null) { - email = email.toLowerCase().trim(); - } - if (name != null) { - name = name.trim(); + @NotBlank(message = "Name is required") @Size(min = 2, max = 50, message = "Name must be between 2 and 50 characters") String name, + + AuthRole role) { + + public RegisterRequest { + if (email != null) { + email = email.toLowerCase().trim(); + } + if (name != null) { + name = name.trim(); + } } - } } diff --git a/src/main/java/org/nkcoder/auth/interfaces/rest/response/AuthResponse.java b/src/main/java/org/nkcoder/auth/interfaces/rest/response/AuthResponse.java index 0362675..f77b3f6 100644 --- a/src/main/java/org/nkcoder/auth/interfaces/rest/response/AuthResponse.java +++ b/src/main/java/org/nkcoder/auth/interfaces/rest/response/AuthResponse.java @@ -6,13 +6,13 @@ /** REST API response for authentication operations. */ public record AuthResponse(UserInfo user, TokenInfo tokens) { - public static AuthResponse from(AuthResult result) { - return new AuthResponse( - new UserInfo(result.userId(), result.email(), result.role().name()), - new TokenInfo(result.accessToken(), result.refreshToken())); - } + public static AuthResponse from(AuthResult result) { + return new AuthResponse( + new UserInfo(result.userId(), result.email(), result.role().name()), + new TokenInfo(result.accessToken(), result.refreshToken())); + } - public record UserInfo(UUID id, String email, String role) {} + public record UserInfo(UUID id, String email, String role) {} - public record TokenInfo(String accessToken, String refreshToken) {} + public record TokenInfo(String accessToken, String refreshToken) {} } diff --git a/src/main/java/org/nkcoder/infrastructure/config/CorsProperties.java b/src/main/java/org/nkcoder/infrastructure/config/CorsProperties.java index ccb3257..3618268 100644 --- a/src/main/java/org/nkcoder/infrastructure/config/CorsProperties.java +++ b/src/main/java/org/nkcoder/infrastructure/config/CorsProperties.java @@ -10,28 +10,28 @@ @ConfigurationProperties(prefix = "cors") @Validated public record CorsProperties( - @NotEmpty List allowedOrigins, - @NotEmpty List allowedMethods, - @NotEmpty List allowedHeaders, - @NotNull Boolean allowCredentials, - @Positive Long maxAge) { + @NotEmpty List allowedOrigins, + @NotEmpty List allowedMethods, + @NotEmpty List allowedHeaders, + @NotNull Boolean allowCredentials, + @Positive Long maxAge) { - public CorsProperties { - // Compact constructor with default values - if (allowedOrigins == null || allowedOrigins.isEmpty()) { - allowedOrigins = List.of("http://localhost:3000"); + public CorsProperties { + // Compact constructor with default values + if (allowedOrigins == null || allowedOrigins.isEmpty()) { + allowedOrigins = List.of("http://localhost:3000"); + } + if (allowedMethods == null || allowedMethods.isEmpty()) { + allowedMethods = List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"); + } + if (allowedHeaders == null || allowedHeaders.isEmpty()) { + allowedHeaders = List.of("*"); + } + if (allowCredentials == null) { + allowCredentials = true; + } + if (maxAge == null || maxAge <= 0) { + maxAge = 3600L; + } } - if (allowedMethods == null || allowedMethods.isEmpty()) { - allowedMethods = List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"); - } - if (allowedHeaders == null || allowedHeaders.isEmpty()) { - allowedHeaders = List.of("*"); - } - if (allowCredentials == null) { - allowCredentials = true; - } - if (maxAge == null || maxAge <= 0) { - maxAge = 3600L; - } - } } diff --git a/src/main/java/org/nkcoder/infrastructure/config/JwtProperties.java b/src/main/java/org/nkcoder/infrastructure/config/JwtProperties.java index 50c24ce..3d1ea2e 100644 --- a/src/main/java/org/nkcoder/infrastructure/config/JwtProperties.java +++ b/src/main/java/org/nkcoder/infrastructure/config/JwtProperties.java @@ -10,37 +10,35 @@ @ConfigurationProperties(prefix = "jwt") @Validated public record JwtProperties( - @Valid Secret secret, @Valid Expiration expiration, @NotBlank String issuer) { + @Valid Secret secret, + @Valid Expiration expiration, + @NotBlank String issuer) { - public JwtProperties { - // Compact constructor with default values - if (issuer == null || issuer.isBlank()) { - issuer = "user-service"; + public JwtProperties { + // Compact constructor with default values + if (issuer == null || issuer.isBlank()) { + issuer = "user-service"; + } } - } - - public record Secret( - @NotBlank @Size(min = 32, message = "Access token secret must be at least 32 characters") String access, - @NotBlank @Size(min = 32, message = "Refresh token secret must be at least 32 characters") String refresh) {} - - public record Expiration( - @NotBlank @Pattern( - regexp = "\\d+[smhd]", - message = "Access token expiration must match pattern: [s|m|h|d]") - String access, - @NotBlank @Pattern( - regexp = "\\d+[smhd]", - message = "Refresh token expiration must match pattern: [s|m|h|d]") - String refresh) { - - public Expiration { - // Compact constructor with default values - if (access == null || access.isBlank()) { - access = "15m"; - } - if (refresh == null || refresh.isBlank()) { - refresh = "7d"; - } + + public record Secret( + @NotBlank @Size(min = 32, message = "Access token secret must be at least 32 characters") String access, + + @NotBlank @Size(min = 32, message = "Refresh token secret must be at least 32 characters") String refresh) {} + + public record Expiration( + @NotBlank @Pattern(regexp = "\\d+[smhd]", message = "Access token expiration must match pattern: [s|m|h|d]") String access, + + @NotBlank @Pattern(regexp = "\\d+[smhd]", message = "Refresh token expiration must match pattern: [s|m|h|d]") String refresh) { + + public Expiration { + // Compact constructor with default values + if (access == null || access.isBlank()) { + access = "15m"; + } + if (refresh == null || refresh.isBlank()) { + refresh = "7d"; + } + } } - } } diff --git a/src/main/java/org/nkcoder/infrastructure/config/ObservabilityConfig.java b/src/main/java/org/nkcoder/infrastructure/config/ObservabilityConfig.java index 5111eda..32ce9ed 100644 --- a/src/main/java/org/nkcoder/infrastructure/config/ObservabilityConfig.java +++ b/src/main/java/org/nkcoder/infrastructure/config/ObservabilityConfig.java @@ -10,27 +10,22 @@ @Configuration public class ObservabilityConfig { - @Bean - public TimedAspect timedAspect(MeterRegistry registry) { - return new TimedAspect(registry); - } + @Bean + public TimedAspect timedAspect(MeterRegistry registry) { + return new TimedAspect(registry); + } - @Bean - public MeterRegistryCustomizer metricsCommonTags() { - return registry -> - registry - .config() - .commonTags( - "application", - "user-service", - "environment", - System.getProperty("spring.profiles.active", "unknown")) - .meterFilter( - MeterFilter.deny( - id -> { - String uri = id.getTag("uri"); - return uri != null - && (uri.startsWith("/actuator") || uri.startsWith("/swagger")); - })); - } + @Bean + public MeterRegistryCustomizer metricsCommonTags() { + return registry -> registry.config() + .commonTags( + "application", + "user-service", + "environment", + System.getProperty("spring.profiles.active", "unknown")) + .meterFilter(MeterFilter.deny(id -> { + String uri = id.getTag("uri"); + return uri != null && (uri.startsWith("/actuator") || uri.startsWith("/swagger")); + })); + } } diff --git a/src/main/java/org/nkcoder/infrastructure/config/OpenApiConfig.java b/src/main/java/org/nkcoder/infrastructure/config/OpenApiConfig.java index 5686485..23ce470 100644 --- a/src/main/java/org/nkcoder/infrastructure/config/OpenApiConfig.java +++ b/src/main/java/org/nkcoder/infrastructure/config/OpenApiConfig.java @@ -14,34 +14,29 @@ @Configuration public class OpenApiConfig { - @Bean - public OpenAPI userServiceOpenAPI() { - return new OpenAPI() - .info( - new Info() - .title("User Service API") - .description("User authentication and management service") - .version("v0.1.0") - .contact( - new Contact() - .name("Development Team") - .email("dev@nkcoder.com") - .url("https://github.com/java-springboot")) - .license( - new License().name("ISC License").url("https://opensource.org/licenses/ISC"))) - .servers( - List.of( - new Server().url("http://localhost:3001").description("Development server"), - new Server().url("https://api.nkcoder.com").description("Production server"))) - .addSecurityItem(new SecurityRequirement().addList("Bearer Authentication")) - .components( - new io.swagger.v3.oas.models.Components() - .addSecuritySchemes( - "Bearer Authentication", - new SecurityScheme() - .type(SecurityScheme.Type.HTTP) - .scheme("bearer") - .bearerFormat("JWT") - .description("JWT token for authentication"))); - } + @Bean + public OpenAPI userServiceOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("User Service API") + .description("User authentication and management service") + .version("v0.1.0") + .contact(new Contact() + .name("Development Team") + .email("dev@nkcoder.com") + .url("https://github.com/java-springboot")) + .license(new License().name("ISC License").url("https://opensource.org/licenses/ISC"))) + .servers(List.of( + new Server().url("http://localhost:3001").description("Development server"), + new Server().url("https://api.nkcoder.com").description("Production server"))) + .addSecurityItem(new SecurityRequirement().addList("Bearer Authentication")) + .components(new io.swagger.v3.oas.models.Components() + .addSecuritySchemes( + "Bearer Authentication", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("JWT token for authentication"))); + } } diff --git a/src/main/java/org/nkcoder/infrastructure/config/SecurityConfig.java b/src/main/java/org/nkcoder/infrastructure/config/SecurityConfig.java index 47bd3ab..30922b0 100644 --- a/src/main/java/org/nkcoder/infrastructure/config/SecurityConfig.java +++ b/src/main/java/org/nkcoder/infrastructure/config/SecurityConfig.java @@ -25,87 +25,80 @@ @EnableMethodSecurity(prePostEnabled = true) public class SecurityConfig { - private static final int BCRYPT_STRENGTH = 12; + private static final int BCRYPT_STRENGTH = 12; - private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; - private final JwtAuthenticationFilter jwtAuthenticationFilter; - private final CorsProperties corsProperties; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CorsProperties corsProperties; - @Autowired - public SecurityConfig( - JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, - JwtAuthenticationFilter jwtAuthenticationFilter, - CorsProperties corsProperties) { - this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint; - this.jwtAuthenticationFilter = jwtAuthenticationFilter; - this.corsProperties = corsProperties; - } + @Autowired + public SecurityConfig( + JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, + JwtAuthenticationFilter jwtAuthenticationFilter, + CorsProperties corsProperties) { + this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint; + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + this.corsProperties = corsProperties; + } - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(BCRYPT_STRENGTH); - } + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(BCRYPT_STRENGTH); + } - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http.cors(cors -> cors.configurationSource(corsConfigurationSource())) - .csrf(AbstractHttpConfigurer::disable) - .requestCache(RequestCacheConfigurer::disable) - .sessionManagement( - session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .headers( - headers -> - headers - .contentSecurityPolicy(csp -> csp.policyDirectives("default-src 'self'")) - .frameOptions(HeadersConfigurer.FrameOptionsConfig::deny)) - .authorizeHttpRequests( - auth -> - auth - // Public auth endpoints - .requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/refresh") - .permitAll() - // Actuator and health endpoints - .requestMatchers("/actuator/health", "/actuator/info") - .permitAll() - .requestMatchers("/health") - .permitAll() - // Swagger/OpenAPI endpoints - .requestMatchers( - "/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html", "/api-docs/**") - .permitAll() + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(AbstractHttpConfigurer::disable) + .requestCache(RequestCacheConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .headers(headers -> headers.contentSecurityPolicy(csp -> csp.policyDirectives("default-src 'self'")) + .frameOptions(HeadersConfigurer.FrameOptionsConfig::deny)) + .authorizeHttpRequests(auth -> auth + // Public auth endpoints + .requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/refresh") + .permitAll() + // Actuator and health endpoints + .requestMatchers("/actuator/health", "/actuator/info") + .permitAll() + .requestMatchers("/health") + .permitAll() + // Swagger/OpenAPI endpoints + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html", "/api-docs/**") + .permitAll() - // Authenticated logout endpoints - .requestMatchers("/api/auth/logout", "/api/auth/logout-single") - .authenticated() + // Authenticated logout endpoints + .requestMatchers("/api/auth/logout", "/api/auth/logout-single") + .authenticated() - // Protected user profile endpoints - .requestMatchers("/api/users/me", "/api/users/me/**") - .authenticated() + // Protected user profile endpoints + .requestMatchers("/api/users/me", "/api/users/me/**") + .authenticated() - // Admin endpoints - .requestMatchers("/api/admin/users/**") - .hasRole("ADMIN") + // Admin endpoints + .requestMatchers("/api/admin/users/**") + .hasRole("ADMIN") - // All other requests require authentication - .anyRequest() - .authenticated()) - .exceptionHandling(ex -> ex.authenticationEntryPoint(jwtAuthenticationEntryPoint)) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + // All other requests require authentication + .anyRequest() + .authenticated()) + .exceptionHandling(ex -> ex.authenticationEntryPoint(jwtAuthenticationEntryPoint)) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); - return http.build(); - } + return http.build(); + } - @Bean - public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOriginPatterns(corsProperties.allowedOrigins()); - configuration.setAllowedMethods(corsProperties.allowedMethods()); - configuration.setAllowedHeaders(corsProperties.allowedHeaders()); - configuration.setAllowCredentials(corsProperties.allowCredentials()); - configuration.setMaxAge(corsProperties.maxAge()); + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOriginPatterns(corsProperties.allowedOrigins()); + configuration.setAllowedMethods(corsProperties.allowedMethods()); + configuration.setAllowedHeaders(corsProperties.allowedHeaders()); + configuration.setAllowCredentials(corsProperties.allowCredentials()); + configuration.setMaxAge(corsProperties.maxAge()); - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); - return source; - } + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } } diff --git a/src/main/java/org/nkcoder/infrastructure/config/WebConfig.java b/src/main/java/org/nkcoder/infrastructure/config/WebConfig.java index 8546c25..02940e6 100644 --- a/src/main/java/org/nkcoder/infrastructure/config/WebConfig.java +++ b/src/main/java/org/nkcoder/infrastructure/config/WebConfig.java @@ -8,14 +8,14 @@ @Configuration public class WebConfig implements WebMvcConfigurer { - private final CurrentUserArgumentResolver currentUserArgumentResolver; + private final CurrentUserArgumentResolver currentUserArgumentResolver; - public WebConfig(CurrentUserArgumentResolver currentUserArgumentResolver) { - this.currentUserArgumentResolver = currentUserArgumentResolver; - } + public WebConfig(CurrentUserArgumentResolver currentUserArgumentResolver) { + this.currentUserArgumentResolver = currentUserArgumentResolver; + } - @Override - public void addArgumentResolvers(List resolvers) { - resolvers.add(currentUserArgumentResolver); - } + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(currentUserArgumentResolver); + } } diff --git a/src/main/java/org/nkcoder/infrastructure/resolver/CurrentUserArgumentResolver.java b/src/main/java/org/nkcoder/infrastructure/resolver/CurrentUserArgumentResolver.java index 1e65c02..444a4e4 100644 --- a/src/main/java/org/nkcoder/infrastructure/resolver/CurrentUserArgumentResolver.java +++ b/src/main/java/org/nkcoder/infrastructure/resolver/CurrentUserArgumentResolver.java @@ -12,36 +12,36 @@ import org.springframework.web.method.support.ModelAndViewContainer; /** - * Resolves method parameters annotated with {@link CurrentUser} by extracting the user ID from - * request attributes set by {@link org.nkcoder.infrastructure.security.JwtAuthenticationFilter} + * Resolves method parameters annotated with {@link CurrentUser} by extracting the user ID from request attributes set + * by {@link org.nkcoder.infrastructure.security.JwtAuthenticationFilter} */ @Component public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { - private static final String USER_ID_ATTRIBUTE = "userId"; + private static final String USER_ID_ATTRIBUTE = "userId"; - @Override - public boolean supportsParameter(MethodParameter parameter) { - return parameter.hasParameterAnnotation(CurrentUser.class) - && parameter.getParameterType().equals(UUID.class); - } - - @Override - public Object resolveArgument( - @NotNull MethodParameter parameter, - ModelAndViewContainer mavContainer, - NativeWebRequest webRequest, - WebDataBinderFactory binderFactory) { - HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); - if (request == null) { - return null; + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUser.class) + && parameter.getParameterType().equals(UUID.class); } - Object userId = request.getAttribute(USER_ID_ATTRIBUTE); - if (userId instanceof UUID) { - return userId; - } + @Override + public Object resolveArgument( + @NotNull MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + if (request == null) { + return null; + } - return null; - } + Object userId = request.getAttribute(USER_ID_ATTRIBUTE); + if (userId instanceof UUID) { + return userId; + } + + return null; + } } diff --git a/src/main/java/org/nkcoder/infrastructure/security/JwtAuthenticationEntryPoint.java b/src/main/java/org/nkcoder/infrastructure/security/JwtAuthenticationEntryPoint.java index 771dbe4..8d02ce0 100644 --- a/src/main/java/org/nkcoder/infrastructure/security/JwtAuthenticationEntryPoint.java +++ b/src/main/java/org/nkcoder/infrastructure/security/JwtAuthenticationEntryPoint.java @@ -17,46 +17,44 @@ @Component public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { - private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationEntryPoint.class); + private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationEntryPoint.class); - private static final String CONTENT_TYPE_JSON = "application/json"; + private static final String CONTENT_TYPE_JSON = "application/json"; - private final ObjectMapper objectMapper; + private final ObjectMapper objectMapper; - @Autowired - public JwtAuthenticationEntryPoint(ObjectMapper objectMapper) { - this.objectMapper = objectMapper; - } - - @Override - public void commence( - HttpServletRequest request, - HttpServletResponse response, - AuthenticationException authException) - throws IOException { + @Autowired + public JwtAuthenticationEntryPoint(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } - logger.debug("Unauthorized access attempt to: {}", request.getRequestURI()); + @Override + public void commence( + HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) + throws IOException { - response.setContentType(CONTENT_TYPE_JSON); - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + logger.debug("Unauthorized access attempt to: {}", request.getRequestURI()); - String errorMessage = determineErrorMessage(authException); - ApiResponse apiResponse = ApiResponse.error(errorMessage); + response.setContentType(CONTENT_TYPE_JSON); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - objectMapper.writeValue(response.getOutputStream(), apiResponse); - response.getOutputStream().flush(); - // Do NOT close the stream - let the servlet container manage it - } + String errorMessage = determineErrorMessage(authException); + ApiResponse apiResponse = ApiResponse.error(errorMessage); - private String determineErrorMessage(AuthenticationException authException) { - if (authException.getCause() instanceof ExpiredJwtException) { - return "Token has expired"; + objectMapper.writeValue(response.getOutputStream(), apiResponse); + response.getOutputStream().flush(); + // Do NOT close the stream - let the servlet container manage it } - if (Strings.isNotBlank(authException.getMessage())) { - return "Authentication required: " + authException.getMessage(); - } + private String determineErrorMessage(AuthenticationException authException) { + if (authException.getCause() instanceof ExpiredJwtException) { + return "Token has expired"; + } - return "Authentication required"; - } + if (Strings.isNotBlank(authException.getMessage())) { + return "Authentication required: " + authException.getMessage(); + } + + return "Authentication required"; + } } diff --git a/src/main/java/org/nkcoder/infrastructure/security/JwtAuthenticationFilter.java b/src/main/java/org/nkcoder/infrastructure/security/JwtAuthenticationFilter.java index 7fd753b..a4083a5 100644 --- a/src/main/java/org/nkcoder/infrastructure/security/JwtAuthenticationFilter.java +++ b/src/main/java/org/nkcoder/infrastructure/security/JwtAuthenticationFilter.java @@ -32,33 +32,31 @@ @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { - private static final String AUTHORIZATION_HEADER = "Authorization"; - private static final String BEARER_PREFIX = "Bearer "; - private static final String ATTRIBUTE_USER_ID = "userId"; - private static final String ATTRIBUTE_ROLE = "role"; - private static final String ATTRIBUTE_EMAIL = "email"; - - private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class); - - private final JwtUtil jwtUtil; - - @Autowired - public JwtAuthenticationFilter(JwtUtil jwtUtil) { - this.jwtUtil = jwtUtil; - } - - @Override - protected void doFilterInternal( - @NotNull HttpServletRequest request, - @NotNull HttpServletResponse response, - @NotNull FilterChain filterChain) - throws ServletException, IOException { - logger.debug("Processing authentication for request: {}", request.getRequestURI()); - - extractTokenFromRequest(request) - .ifPresent( - token -> { - try { + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + private static final String ATTRIBUTE_USER_ID = "userId"; + private static final String ATTRIBUTE_ROLE = "role"; + private static final String ATTRIBUTE_EMAIL = "email"; + + private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class); + + private final JwtUtil jwtUtil; + + @Autowired + public JwtAuthenticationFilter(JwtUtil jwtUtil) { + this.jwtUtil = jwtUtil; + } + + @Override + protected void doFilterInternal( + @NotNull HttpServletRequest request, + @NotNull HttpServletResponse response, + @NotNull FilterChain filterChain) + throws ServletException, IOException { + logger.debug("Processing authentication for request: {}", request.getRequestURI()); + + extractTokenFromRequest(request).ifPresent(token -> { + try { Claims claims = jwtUtil.validateAccessToken(token); UUID userId = UUID.fromString(claims.getSubject()); @@ -67,15 +65,13 @@ protected void doFilterInternal( AuthRole role = AuthRole.valueOf(roleString); // Create authorities - List authorities = - List.of(new SimpleGrantedAuthority("ROLE_" + role.name())); + List authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role.name())); UserDetails userDetails = new User(email, "", authorities); UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(userDetails, null, authorities); + new UsernamePasswordAuthenticationToken(userDetails, null, authorities); - authentication.setDetails( - new WebAuthenticationDetailsSource().buildDetails(request)); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); // Set custom attributes request.setAttribute(ATTRIBUTE_USER_ID, userId); @@ -85,26 +81,26 @@ protected void doFilterInternal( SecurityContextHolder.getContext().setAuthentication(authentication); logger.debug("Set authentication for userId: {}", userId); - } catch (ExpiredJwtException e) { + } catch (ExpiredJwtException e) { logger.error("JWT token expired: {}", e.getMessage()); - } catch (MalformedJwtException e) { + } catch (MalformedJwtException e) { logger.error("Malformed JWT token: {}", e.getMessage()); - } catch (UnsupportedJwtException e) { + } catch (UnsupportedJwtException e) { logger.error("Unsupported JWT token: {}", e.getMessage()); - } catch (SecurityException e) { + } catch (SecurityException e) { logger.error("JWT signature validation failed: {}", e.getMessage()); - } catch (IllegalArgumentException e) { + } catch (IllegalArgumentException e) { logger.error("JWT token compact of handler are invalid: {}", e.getMessage()); - } - }); - - filterChain.doFilter(request, response); - } - - private Optional extractTokenFromRequest(HttpServletRequest request) { - return Optional.ofNullable(request.getHeader(AUTHORIZATION_HEADER)) - .filter(StringUtils::hasText) - .filter(token -> token.startsWith(BEARER_PREFIX)) - .map(token -> token.substring(BEARER_PREFIX.length())); - } + } + }); + + filterChain.doFilter(request, response); + } + + private Optional extractTokenFromRequest(HttpServletRequest request) { + return Optional.ofNullable(request.getHeader(AUTHORIZATION_HEADER)) + .filter(StringUtils::hasText) + .filter(token -> token.startsWith(BEARER_PREFIX)) + .map(token -> token.substring(BEARER_PREFIX.length())); + } } diff --git a/src/main/java/org/nkcoder/shared/kernel/domain/event/DomainEvent.java b/src/main/java/org/nkcoder/shared/kernel/domain/event/DomainEvent.java index 136634b..bf81d09 100644 --- a/src/main/java/org/nkcoder/shared/kernel/domain/event/DomainEvent.java +++ b/src/main/java/org/nkcoder/shared/kernel/domain/event/DomainEvent.java @@ -2,15 +2,12 @@ import java.time.LocalDateTime; -/** - * Base interface for all domain events. Domain events represent something significant that happened - * in the domain. - */ +/** Base interface for all domain events. Domain events represent something significant that happened in the domain. */ public interface DomainEvent { - /** Returns the timestamp when the event occurred. */ - LocalDateTime occurredOn(); + /** Returns the timestamp when the event occurred. */ + LocalDateTime occurredOn(); - /** Returns the type identifier for this event. */ - String eventType(); + /** Returns the type identifier for this event. */ + String eventType(); } diff --git a/src/main/java/org/nkcoder/shared/kernel/domain/event/DomainEventPublisher.java b/src/main/java/org/nkcoder/shared/kernel/domain/event/DomainEventPublisher.java index 79036a9..9fdca7d 100644 --- a/src/main/java/org/nkcoder/shared/kernel/domain/event/DomainEventPublisher.java +++ b/src/main/java/org/nkcoder/shared/kernel/domain/event/DomainEventPublisher.java @@ -1,11 +1,11 @@ package org.nkcoder.shared.kernel.domain.event; /** - * Interface for publishing domain events. Implementations may use Spring's - * ApplicationEventPublisher, a message queue, or other mechanisms. + * Interface for publishing domain events. Implementations may use Spring's ApplicationEventPublisher, a message queue, + * or other mechanisms. */ public interface DomainEventPublisher { - /** Publishes a domain event. */ - void publish(DomainEvent event); + /** Publishes a domain event. */ + void publish(DomainEvent event); } diff --git a/src/main/java/org/nkcoder/shared/kernel/domain/valueobject/AggregateRoot.java b/src/main/java/org/nkcoder/shared/kernel/domain/valueobject/AggregateRoot.java index 4bb66ee..0f264a7 100644 --- a/src/main/java/org/nkcoder/shared/kernel/domain/valueobject/AggregateRoot.java +++ b/src/main/java/org/nkcoder/shared/kernel/domain/valueobject/AggregateRoot.java @@ -12,23 +12,23 @@ */ public abstract class AggregateRoot { - private final List domainEvents = new ArrayList<>(); + private final List domainEvents = new ArrayList<>(); - /** Returns the unique identifier of this aggregate root. */ - public abstract ID getId(); + /** Returns the unique identifier of this aggregate root. */ + public abstract ID getId(); - /** Registers a domain event to be published after the aggregate is persisted. */ - protected void registerEvent(DomainEvent event) { - domainEvents.add(event); - } + /** Registers a domain event to be published after the aggregate is persisted. */ + protected void registerEvent(DomainEvent event) { + domainEvents.add(event); + } - /** Returns an unmodifiable view of the registered domain events. */ - public List getDomainEvents() { - return Collections.unmodifiableList(domainEvents); - } + /** Returns an unmodifiable view of the registered domain events. */ + public List getDomainEvents() { + return Collections.unmodifiableList(domainEvents); + } - /** Clears all registered domain events. Should be called after events are published. */ - public void clearDomainEvents() { - domainEvents.clear(); - } + /** Clears all registered domain events. Should be called after events are published. */ + public void clearDomainEvents() { + domainEvents.clear(); + } } diff --git a/src/main/java/org/nkcoder/shared/kernel/domain/valueobject/Email.java b/src/main/java/org/nkcoder/shared/kernel/domain/valueobject/Email.java index 25be919..aabeb18 100644 --- a/src/main/java/org/nkcoder/shared/kernel/domain/valueobject/Email.java +++ b/src/main/java/org/nkcoder/shared/kernel/domain/valueobject/Email.java @@ -6,22 +6,21 @@ /** Email value object with validation. Immutable and self-validating. */ public record Email(String value) { - private static final Pattern EMAIL_PATTERN = - Pattern.compile("^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"); + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"); - public Email { - Objects.requireNonNull(value, "Email cannot be null"); - value = value.toLowerCase().trim(); - if (!isValid(value)) { - throw new IllegalArgumentException("Invalid email format: " + value); + public Email { + Objects.requireNonNull(value, "Email cannot be null"); + value = value.toLowerCase().trim(); + if (!isValid(value)) { + throw new IllegalArgumentException("Invalid email format: " + value); + } } - } - public static Email of(String value) { - return new Email(value); - } + public static Email of(String value) { + return new Email(value); + } - public static boolean isValid(String email) { - return email != null && EMAIL_PATTERN.matcher(email).matches(); - } + public static boolean isValid(String email) { + return email != null && EMAIL_PATTERN.matcher(email).matches(); + } } diff --git a/src/main/java/org/nkcoder/shared/kernel/exception/AuthenticationException.java b/src/main/java/org/nkcoder/shared/kernel/exception/AuthenticationException.java index 66bf261..1af94b1 100644 --- a/src/main/java/org/nkcoder/shared/kernel/exception/AuthenticationException.java +++ b/src/main/java/org/nkcoder/shared/kernel/exception/AuthenticationException.java @@ -1,16 +1,13 @@ package org.nkcoder.shared.kernel.exception; -/** - * Exception thrown when authentication fails. Examples: invalid credentials, expired token, invalid - * token. - */ +/** Exception thrown when authentication fails. Examples: invalid credentials, expired token, invalid token. */ public class AuthenticationException extends DomainException { - public AuthenticationException(String message) { - super(message); - } + public AuthenticationException(String message) { + super(message); + } - public AuthenticationException(String message, Throwable cause) { - super(message, cause); - } + public AuthenticationException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/src/main/java/org/nkcoder/shared/kernel/exception/DomainException.java b/src/main/java/org/nkcoder/shared/kernel/exception/DomainException.java index 048c0f1..91e8324 100644 --- a/src/main/java/org/nkcoder/shared/kernel/exception/DomainException.java +++ b/src/main/java/org/nkcoder/shared/kernel/exception/DomainException.java @@ -1,16 +1,16 @@ package org.nkcoder.shared.kernel.exception; /** - * Base exception for all domain-level exceptions. Provides a common hierarchy for exception - * handling across bounded contexts. + * Base exception for all domain-level exceptions. Provides a common hierarchy for exception handling across bounded + * contexts. */ public abstract class DomainException extends RuntimeException { - protected DomainException(String message) { - super(message); - } + protected DomainException(String message) { + super(message); + } - protected DomainException(String message, Throwable cause) { - super(message, cause); - } + protected DomainException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/src/main/java/org/nkcoder/shared/kernel/exception/ResourceNotFoundException.java b/src/main/java/org/nkcoder/shared/kernel/exception/ResourceNotFoundException.java index 4c612ad..af8ea2e 100644 --- a/src/main/java/org/nkcoder/shared/kernel/exception/ResourceNotFoundException.java +++ b/src/main/java/org/nkcoder/shared/kernel/exception/ResourceNotFoundException.java @@ -1,16 +1,13 @@ package org.nkcoder.shared.kernel.exception; -/** - * Exception thrown when a requested resource cannot be found. Examples: user not found, token not - * found. - */ +/** Exception thrown when a requested resource cannot be found. Examples: user not found, token not found. */ public class ResourceNotFoundException extends DomainException { - public ResourceNotFoundException(String message) { - super(message); - } + public ResourceNotFoundException(String message) { + super(message); + } - public ResourceNotFoundException(String message, Throwable cause) { - super(message, cause); - } + public ResourceNotFoundException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/src/main/java/org/nkcoder/shared/kernel/exception/ValidationException.java b/src/main/java/org/nkcoder/shared/kernel/exception/ValidationException.java index 817bc84..e33b7b5 100644 --- a/src/main/java/org/nkcoder/shared/kernel/exception/ValidationException.java +++ b/src/main/java/org/nkcoder/shared/kernel/exception/ValidationException.java @@ -1,16 +1,16 @@ package org.nkcoder.shared.kernel.exception; /** - * Exception thrown when business validation fails. Examples: duplicate email, password mismatch, - * invalid state transitions. + * Exception thrown when business validation fails. Examples: duplicate email, password mismatch, invalid state + * transitions. */ public class ValidationException extends DomainException { - public ValidationException(String message) { - super(message); - } + public ValidationException(String message) { + super(message); + } - public ValidationException(String message, Throwable cause) { - super(message, cause); - } + public ValidationException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/src/main/java/org/nkcoder/shared/local/annotation/CurrentUser.java b/src/main/java/org/nkcoder/shared/local/annotation/CurrentUser.java index ad953ae..04cbd12 100644 --- a/src/main/java/org/nkcoder/shared/local/annotation/CurrentUser.java +++ b/src/main/java/org/nkcoder/shared/local/annotation/CurrentUser.java @@ -11,9 +11,8 @@ * *

Usage: {@code public ResponseEntity getMe(@CurrentUser UUID userId)} * - *

The userId is extracted from the request attributes set by JwtAuthenticationFilter. If no - * authenticated user is found, the resolver returns null (let Spring Security handle unauthorized - * access via security configuration). + *

The userId is extracted from the request attributes set by JwtAuthenticationFilter. If no authenticated user is + * found, the resolver returns null (let Spring Security handle unauthorized access via security configuration). */ @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) diff --git a/src/main/java/org/nkcoder/shared/local/event/SpringDomainEventPublisher.java b/src/main/java/org/nkcoder/shared/local/event/SpringDomainEventPublisher.java index 6054456..a6bf392 100644 --- a/src/main/java/org/nkcoder/shared/local/event/SpringDomainEventPublisher.java +++ b/src/main/java/org/nkcoder/shared/local/event/SpringDomainEventPublisher.java @@ -6,20 +6,20 @@ import org.springframework.stereotype.Component; /** - * Spring-based implementation of DomainEventPublisher. Uses Spring's ApplicationEventPublisher to - * broadcast domain events within the application. + * Spring-based implementation of DomainEventPublisher. Uses Spring's ApplicationEventPublisher to broadcast domain + * events within the application. */ @Component public class SpringDomainEventPublisher implements DomainEventPublisher { - private final ApplicationEventPublisher applicationEventPublisher; + private final ApplicationEventPublisher applicationEventPublisher; - public SpringDomainEventPublisher(ApplicationEventPublisher applicationEventPublisher) { - this.applicationEventPublisher = applicationEventPublisher; - } + public SpringDomainEventPublisher(ApplicationEventPublisher applicationEventPublisher) { + this.applicationEventPublisher = applicationEventPublisher; + } - @Override - public void publish(DomainEvent event) { - applicationEventPublisher.publishEvent(event); - } + @Override + public void publish(DomainEvent event) { + applicationEventPublisher.publishEvent(event); + } } diff --git a/src/main/java/org/nkcoder/shared/local/rest/ApiResponse.java b/src/main/java/org/nkcoder/shared/local/rest/ApiResponse.java index 0896087..c54f352 100644 --- a/src/main/java/org/nkcoder/shared/local/rest/ApiResponse.java +++ b/src/main/java/org/nkcoder/shared/local/rest/ApiResponse.java @@ -3,16 +3,16 @@ import java.time.LocalDateTime; public record ApiResponse(String message, T data, LocalDateTime timestamp) { - // Static factory methods - public static ApiResponse success(String message) { - return new ApiResponse<>(message, null, LocalDateTime.now()); - } + // Static factory methods + public static ApiResponse success(String message) { + return new ApiResponse<>(message, null, LocalDateTime.now()); + } - public static ApiResponse success(String message, T data) { - return new ApiResponse<>(message, data, LocalDateTime.now()); - } + public static ApiResponse success(String message, T data) { + return new ApiResponse<>(message, data, LocalDateTime.now()); + } - public static ApiResponse error(String message) { - return new ApiResponse<>(message, null, LocalDateTime.now()); - } + public static ApiResponse error(String message) { + return new ApiResponse<>(message, null, LocalDateTime.now()); + } } diff --git a/src/main/java/org/nkcoder/shared/local/rest/GlobalExceptionHandler.java b/src/main/java/org/nkcoder/shared/local/rest/GlobalExceptionHandler.java index 108d00a..af3bfe8 100644 --- a/src/main/java/org/nkcoder/shared/local/rest/GlobalExceptionHandler.java +++ b/src/main/java/org/nkcoder/shared/local/rest/GlobalExceptionHandler.java @@ -19,57 +19,49 @@ @RestControllerAdvice public class GlobalExceptionHandler { - private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); + private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); - @ExceptionHandler(ValidationException.class) - public ResponseEntity> handleValidationException(ValidationException ex) { - logger.debug("Validation error: {}", ex.getMessage()); - return ResponseEntity.badRequest().body(ApiResponse.error(ex.getMessage())); - } + @ExceptionHandler(ValidationException.class) + public ResponseEntity> handleValidationException(ValidationException ex) { + logger.debug("Validation error: {}", ex.getMessage()); + return ResponseEntity.badRequest().body(ApiResponse.error(ex.getMessage())); + } - @ExceptionHandler(AuthenticationException.class) - public ResponseEntity> handleAuthenticationException( - AuthenticationException ex) { - logger.debug("Authentication error: {}", ex.getMessage()); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiResponse.error(ex.getMessage())); - } + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity> handleAuthenticationException(AuthenticationException ex) { + logger.debug("Authentication error: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiResponse.error(ex.getMessage())); + } - @ExceptionHandler(ResourceNotFoundException.class) - public ResponseEntity> handleResourceNotFoundException( - ResourceNotFoundException ex) { - logger.debug("Resource not found: {}", ex.getMessage()); - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.error(ex.getMessage())); - } + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity> handleResourceNotFoundException(ResourceNotFoundException ex) { + logger.debug("Resource not found: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.error(ex.getMessage())); + } - @ExceptionHandler(AccessDeniedException.class) - public ResponseEntity> handleAccessDeniedException(AccessDeniedException ex) { - logger.debug("Access denied: {}", ex.getMessage()); - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error("Access denied")); - } + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDeniedException(AccessDeniedException ex) { + logger.debug("Access denied: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error("Access denied")); + } - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity>> handleMethodArgumentNotValid( - MethodArgumentNotValidException ex) { - logger.debug("Validation failed: {}", ex.getMessage()); - Map errors = new HashMap<>(); - ex.getBindingResult() - .getAllErrors() - .forEach( - error -> { - String fieldName = - error instanceof FieldError - ? ((FieldError) error).getField() - : error.getObjectName(); - String errorMessage = error.getDefaultMessage(); - errors.put(fieldName, errorMessage); - }); - return ResponseEntity.badRequest().body(new ApiResponse<>("Validation failed", errors, null)); - } + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity>> handleMethodArgumentNotValid( + MethodArgumentNotValidException ex) { + logger.debug("Validation failed: {}", ex.getMessage()); + Map errors = new HashMap<>(); + ex.getBindingResult().getAllErrors().forEach(error -> { + String fieldName = error instanceof FieldError ? ((FieldError) error).getField() : error.getObjectName(); + String errorMessage = error.getDefaultMessage(); + errors.put(fieldName, errorMessage); + }); + return ResponseEntity.badRequest().body(new ApiResponse<>("Validation failed", errors, null)); + } - @ExceptionHandler(Exception.class) - public ResponseEntity> handleGenericException(Exception ex) { - logger.error("Unexpected error: {}", ex.getMessage(), ex); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ApiResponse.error("An unexpected error occurred")); - } + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGenericException(Exception ex) { + logger.error("Unexpected error: {}", ex.getMessage(), ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("An unexpected error occurred")); + } } diff --git a/src/main/java/org/nkcoder/shared/local/validation/PasswordMatch.java b/src/main/java/org/nkcoder/shared/local/validation/PasswordMatch.java index a627214..9690d0c 100644 --- a/src/main/java/org/nkcoder/shared/local/validation/PasswordMatch.java +++ b/src/main/java/org/nkcoder/shared/local/validation/PasswordMatch.java @@ -9,13 +9,13 @@ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface PasswordMatch { - String message() default "New password and confirmation password do not match"; + String message() default "New password and confirmation password do not match"; - Class[] groups() default {}; + Class[] groups() default {}; - Class[] payload() default {}; + Class[] payload() default {}; - String passwordField() default "newPassword"; + String passwordField() default "newPassword"; - String confirmField() default "confirmPassword"; + String confirmField() default "confirmPassword"; } diff --git a/src/main/java/org/nkcoder/shared/local/validation/PasswordMatchValidator.java b/src/main/java/org/nkcoder/shared/local/validation/PasswordMatchValidator.java index 057580b..5a755f0 100644 --- a/src/main/java/org/nkcoder/shared/local/validation/PasswordMatchValidator.java +++ b/src/main/java/org/nkcoder/shared/local/validation/PasswordMatchValidator.java @@ -7,49 +7,48 @@ // Generic validator for any Record type public class PasswordMatchValidator implements ConstraintValidator { - private String passwordField; - private String confirmField; - - @Override - public void initialize(PasswordMatch annotation) { - this.passwordField = annotation.passwordField(); - this.confirmField = annotation.confirmField(); - } - - @Override - public boolean isValid(Record record, ConstraintValidatorContext context) { - if (record == null) { - return true; - } + private String passwordField; + private String confirmField; - try { - String password = getFieldValue(record, passwordField); - String confirmPassword = getFieldValue(record, confirmField); + @Override + public void initialize(PasswordMatch annotation) { + this.passwordField = annotation.passwordField(); + this.confirmField = annotation.confirmField(); + } - // Both null is valid (let @NotBlank handle individual field validation) - if (password == null && confirmPassword == null) { - return true; - } + @Override + public boolean isValid(Record record, ConstraintValidatorContext context) { + if (record == null) { + return true; + } + + try { + String password = getFieldValue(record, passwordField); + String confirmPassword = getFieldValue(record, confirmField); + + // Both null is valid (let @NotBlank handle individual field validation) + if (password == null && confirmPassword == null) { + return true; + } + + if (password == null || confirmPassword == null) { + return false; + } + + return password.equals(confirmPassword); + } catch (Exception e) { + // If reflection fails, let validation pass and rely on other constraints + return true; + } + } - if (password == null || confirmPassword == null) { - return false; - } + private String getFieldValue(Record record, String fieldName) throws Exception { + RecordComponent component = Arrays.stream(record.getClass().getRecordComponents()) + .filter(c -> c.getName().equals(fieldName)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Field not found: " + fieldName)); - return password.equals(confirmPassword); - } catch (Exception e) { - // If reflection fails, let validation pass and rely on other constraints - return true; + Object value = component.getAccessor().invoke(record); + return value != null ? value.toString() : null; } - } - - private String getFieldValue(Record record, String fieldName) throws Exception { - RecordComponent component = - Arrays.stream(record.getClass().getRecordComponents()) - .filter(c -> c.getName().equals(fieldName)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Field not found: " + fieldName)); - - Object value = component.getAccessor().invoke(record); - return value != null ? value.toString() : null; - } } diff --git a/src/main/java/org/nkcoder/user/application/dto/response/UserDto.java b/src/main/java/org/nkcoder/user/application/dto/response/UserDto.java index 48c488d..5b0544c 100644 --- a/src/main/java/org/nkcoder/user/application/dto/response/UserDto.java +++ b/src/main/java/org/nkcoder/user/application/dto/response/UserDto.java @@ -6,24 +6,24 @@ /** DTO representing user information. */ public record UserDto( - UUID id, - String email, - String name, - String role, - boolean emailVerified, - LocalDateTime lastLoginAt, - LocalDateTime createdAt, - LocalDateTime updatedAt) { + UUID id, + String email, + String name, + String role, + boolean emailVerified, + LocalDateTime lastLoginAt, + LocalDateTime createdAt, + LocalDateTime updatedAt) { - public static UserDto from(User user) { - return new UserDto( - user.getId().value(), - user.getEmail().value(), - user.getName().value(), - user.getRole().name(), - user.isEmailVerified(), - user.getLastLoginAt(), - user.getCreatedAt(), - user.getUpdatedAt()); - } + public static UserDto from(User user) { + return new UserDto( + user.getId().value(), + user.getEmail().value(), + user.getName().value(), + user.getRole().name(), + user.isEmailVerified(), + user.getLastLoginAt(), + user.getCreatedAt(), + user.getUpdatedAt()); + } } diff --git a/src/main/java/org/nkcoder/user/application/eventhandler/AuthEventHandler.java b/src/main/java/org/nkcoder/user/application/eventhandler/AuthEventHandler.java index 4a01b5b..1585a89 100644 --- a/src/main/java/org/nkcoder/user/application/eventhandler/AuthEventHandler.java +++ b/src/main/java/org/nkcoder/user/application/eventhandler/AuthEventHandler.java @@ -16,69 +16,68 @@ import org.springframework.transaction.event.TransactionalEventListener; /** - * Event handler for events from the Auth bounded context. Creates/updates User records in response - * to Auth domain events. + * Event handler for events from the Auth bounded context. Creates/updates User records in response to Auth domain + * events. */ @Component public class AuthEventHandler { - private static final Logger logger = LoggerFactory.getLogger(AuthEventHandler.class); + private static final Logger logger = LoggerFactory.getLogger(AuthEventHandler.class); - private final UserRepository userRepository; + private final UserRepository userRepository; - public AuthEventHandler(UserRepository userRepository) { - this.userRepository = userRepository; - } + public AuthEventHandler(UserRepository userRepository) { + this.userRepository = userRepository; + } - /** - * Handles user registration events from Auth context. Creates a corresponding User record in the - * User bounded context. Uses AFTER_COMMIT to run in a new transaction after Auth context commits, - * avoiding entity conflicts when both contexts map the same table. - */ - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void handleUserRegistered(UserRegisteredEvent event) { - logger.info("Handling user registered event for user: {}", event.userId().value()); + /** + * Handles user registration events from Auth context. Creates a corresponding User record in the User bounded + * context. Uses AFTER_COMMIT to run in a new transaction after Auth context commits, avoiding entity conflicts when + * both contexts map the same table. + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handleUserRegistered(UserRegisteredEvent event) { + logger.info( + "Handling user registered event for user: {}", event.userId().value()); - UserId userId = UserId.of(event.userId().value()); + UserId userId = UserId.of(event.userId().value()); - if (userRepository.existsById(userId)) { - logger.warn("User already exists, skipping creation: {}", userId); - return; - } + if (userRepository.existsById(userId)) { + logger.warn("User already exists, skipping creation: {}", userId); + return; + } - UserRole role = mapRole(event.role()); - User user = User.create(userId, event.email(), UserName.of(event.name()), role); + UserRole role = mapRole(event.role()); + User user = User.create(userId, event.email(), UserName.of(event.name()), role); - userRepository.save(user); - logger.info("User created in User context: {}", userId); - } + userRepository.save(user); + logger.info("User created in User context: {}", userId); + } - /** - * Handles user login events from Auth context. Updates the last login timestamp in the User - * bounded context. Uses AFTER_COMMIT to run after Auth context commits. - */ - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void handleUserLoggedIn(UserLoggedInEvent event) { - logger.debug("Handling user logged in event for user: {}", event.userId().value()); + /** + * Handles user login events from Auth context. Updates the last login timestamp in the User bounded context. Uses + * AFTER_COMMIT to run after Auth context commits. + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handleUserLoggedIn(UserLoggedInEvent event) { + logger.debug( + "Handling user logged in event for user: {}", event.userId().value()); - UserId userId = UserId.of(event.userId().value()); + UserId userId = UserId.of(event.userId().value()); - userRepository - .findById(userId) - .ifPresent( - user -> { - user.recordLogin(); - userRepository.save(user); - logger.debug("Updated last login for user: {}", userId); - }); - } + userRepository.findById(userId).ifPresent(user -> { + user.recordLogin(); + userRepository.save(user); + logger.debug("Updated last login for user: {}", userId); + }); + } - private UserRole mapRole(org.nkcoder.auth.domain.model.AuthRole authRole) { - return switch (authRole) { - case ADMIN -> UserRole.ADMIN; - case MEMBER -> UserRole.MEMBER; - }; - } + private UserRole mapRole(org.nkcoder.auth.domain.model.AuthRole authRole) { + return switch (authRole) { + case ADMIN -> UserRole.ADMIN; + case MEMBER -> UserRole.MEMBER; + }; + } } diff --git a/src/main/java/org/nkcoder/user/application/port/AuthContextPort.java b/src/main/java/org/nkcoder/user/application/port/AuthContextPort.java index c25e301..aa7f4ba 100644 --- a/src/main/java/org/nkcoder/user/application/port/AuthContextPort.java +++ b/src/main/java/org/nkcoder/user/application/port/AuthContextPort.java @@ -3,25 +3,24 @@ import java.util.UUID; /** - * Port for communicating with the Auth bounded context. Used for password-related operations that - * are owned by Auth. + * Port for communicating with the Auth bounded context. Used for password-related operations that are owned by Auth. */ public interface AuthContextPort { - /** - * Verifies a user's current password. - * - * @param userId the user's ID - * @param password the password to verify - * @return true if the password matches - */ - boolean verifyPassword(UUID userId, String password); + /** + * Verifies a user's current password. + * + * @param userId the user's ID + * @param password the password to verify + * @return true if the password matches + */ + boolean verifyPassword(UUID userId, String password); - /** - * Changes a user's password. - * - * @param userId the user's ID - * @param newPassword the new password to set - */ - void changePassword(UUID userId, String newPassword); + /** + * Changes a user's password. + * + * @param userId the user's ID + * @param newPassword the new password to set + */ + void changePassword(UUID userId, String newPassword); } diff --git a/src/main/java/org/nkcoder/user/application/service/UserCommandService.java b/src/main/java/org/nkcoder/user/application/service/UserCommandService.java index dbad186..4262d97 100644 --- a/src/main/java/org/nkcoder/user/application/service/UserCommandService.java +++ b/src/main/java/org/nkcoder/user/application/service/UserCommandService.java @@ -26,95 +26,93 @@ @Transactional public class UserCommandService { - private static final Logger logger = LoggerFactory.getLogger(UserCommandService.class); + private static final Logger logger = LoggerFactory.getLogger(UserCommandService.class); - private final UserRepository userRepository; - private final AuthContextPort authContextPort; - private final DomainEventPublisher eventPublisher; + private final UserRepository userRepository; + private final AuthContextPort authContextPort; + private final DomainEventPublisher eventPublisher; - public UserCommandService( - UserRepository userRepository, - AuthContextPort authContextPort, - DomainEventPublisher eventPublisher) { - this.userRepository = userRepository; - this.authContextPort = authContextPort; - this.eventPublisher = eventPublisher; - } + public UserCommandService( + UserRepository userRepository, AuthContextPort authContextPort, DomainEventPublisher eventPublisher) { + this.userRepository = userRepository; + this.authContextPort = authContextPort; + this.eventPublisher = eventPublisher; + } - /** Updates a user's profile. */ - public UserDto updateProfile(UpdateProfileCommand command) { - logger.info("Updating profile for user: {}", command.userId()); + /** Updates a user's profile. */ + public UserDto updateProfile(UpdateProfileCommand command) { + logger.info("Updating profile for user: {}", command.userId()); - User user = findUserOrThrow(command.userId()); + User user = findUserOrThrow(command.userId()); - UserProfileUpdatedEvent event = user.updateProfile(UserName.of(command.name())); + UserProfileUpdatedEvent event = user.updateProfile(UserName.of(command.name())); - User savedUser = userRepository.save(user); - eventPublisher.publish(event); + User savedUser = userRepository.save(user); + eventPublisher.publish(event); - logger.info("Profile updated for user: {}", command.userId()); - return UserDto.from(savedUser); - } + logger.info("Profile updated for user: {}", command.userId()); + return UserDto.from(savedUser); + } - /** Changes a user's password. */ - public void changePassword(ChangePasswordCommand command) { - logger.info("Changing password for user: {}", command.userId()); + /** Changes a user's password. */ + public void changePassword(ChangePasswordCommand command) { + logger.info("Changing password for user: {}", command.userId()); - if (!userRepository.existsById(UserId.of(command.userId()))) { - throw new ResourceNotFoundException("User not found: " + command.userId()); - } + if (!userRepository.existsById(UserId.of(command.userId()))) { + throw new ResourceNotFoundException("User not found: " + command.userId()); + } + + if (!authContextPort.verifyPassword(command.userId(), command.currentPassword())) { + throw new ValidationException("Current password is incorrect"); + } + + authContextPort.changePassword(command.userId(), command.newPassword()); - if (!authContextPort.verifyPassword(command.userId(), command.currentPassword())) { - throw new ValidationException("Current password is incorrect"); + logger.info("Password changed for user: {}", command.userId()); } - authContextPort.changePassword(command.userId(), command.newPassword()); + /** Admin operation: Updates a user's information. */ + public UserDto adminUpdateUser(AdminUpdateUserCommand command) { + logger.info("Admin updating user: {}", command.targetUserId()); - logger.info("Password changed for user: {}", command.userId()); - } + User user = findUserOrThrow(command.targetUserId()); - /** Admin operation: Updates a user's information. */ - public UserDto adminUpdateUser(AdminUpdateUserCommand command) { - logger.info("Admin updating user: {}", command.targetUserId()); + if (command.name() != null && !command.name().isBlank()) { + user.updateProfile(UserName.of(command.name())); + } - User user = findUserOrThrow(command.targetUserId()); + if (command.email() != null && !command.email().isBlank()) { + Email newEmail = Email.of(command.email()); - if (command.name() != null && !command.name().isBlank()) { - user.updateProfile(UserName.of(command.name())); - } + if (userRepository.existsByEmailExcludingId(newEmail, user.getId())) { + throw new ValidationException("Email already in use"); + } - if (command.email() != null && !command.email().isBlank()) { - Email newEmail = Email.of(command.email()); + user.updateEmail(newEmail); + } - if (userRepository.existsByEmailExcludingId(newEmail, user.getId())) { - throw new ValidationException("Email already in use"); - } + User savedUser = userRepository.save(user); - user.updateEmail(newEmail); + logger.info("Admin updated user: {}", command.targetUserId()); + return UserDto.from(savedUser); } - User savedUser = userRepository.save(user); + /** Admin operation: Resets a user's password. */ + public void adminResetPassword(AdminResetPasswordCommand command) { + logger.info("Admin resetting password for user: {}", command.targetUserId()); - logger.info("Admin updated user: {}", command.targetUserId()); - return UserDto.from(savedUser); - } + if (!userRepository.existsById(UserId.of(command.targetUserId()))) { + throw new ResourceNotFoundException("User not found: " + command.targetUserId()); + } - /** Admin operation: Resets a user's password. */ - public void adminResetPassword(AdminResetPasswordCommand command) { - logger.info("Admin resetting password for user: {}", command.targetUserId()); + authContextPort.changePassword(command.targetUserId(), command.newPassword()); - if (!userRepository.existsById(UserId.of(command.targetUserId()))) { - throw new ResourceNotFoundException("User not found: " + command.targetUserId()); + logger.info("Admin reset password for user: {}", command.targetUserId()); } - authContextPort.changePassword(command.targetUserId(), command.newPassword()); - - logger.info("Admin reset password for user: {}", command.targetUserId()); - } - - private User findUserOrThrow(UUID userId) { - return userRepository - .findById(UserId.of(userId)) - .orElseThrow(() -> new ResourceNotFoundException("User not found: " + userId)); - } + private User findUserOrThrow(UUID userId) { + return userRepository + .findById(UserId.of(userId)) + .orElseThrow(() -> new ResourceNotFoundException("User not found: " + userId)); + } } diff --git a/src/main/java/org/nkcoder/user/application/service/UserQueryService.java b/src/main/java/org/nkcoder/user/application/service/UserQueryService.java index da5ee03..fb89110 100644 --- a/src/main/java/org/nkcoder/user/application/service/UserQueryService.java +++ b/src/main/java/org/nkcoder/user/application/service/UserQueryService.java @@ -17,35 +17,34 @@ @Transactional(readOnly = true) public class UserQueryService { - private static final Logger logger = LoggerFactory.getLogger(UserQueryService.class); + private static final Logger logger = LoggerFactory.getLogger(UserQueryService.class); - private final UserRepository userRepository; + private final UserRepository userRepository; - public UserQueryService(UserRepository userRepository) { - this.userRepository = userRepository; - } + public UserQueryService(UserRepository userRepository) { + this.userRepository = userRepository; + } - /** Gets a user by their ID. */ - public UserDto getUserById(UUID userId) { - logger.debug("Getting user by ID: {}", userId); + /** Gets a user by their ID. */ + public UserDto getUserById(UUID userId) { + logger.debug("Getting user by ID: {}", userId); - User user = - userRepository - .findById(UserId.of(userId)) - .orElseThrow(() -> new ResourceNotFoundException("User not found: " + userId)); + User user = userRepository + .findById(UserId.of(userId)) + .orElseThrow(() -> new ResourceNotFoundException("User not found: " + userId)); - return UserDto.from(user); - } + return UserDto.from(user); + } - /** Gets all users (admin operation). */ - public List getAllUsers() { - logger.debug("Getting all users"); + /** Gets all users (admin operation). */ + public List getAllUsers() { + logger.debug("Getting all users"); - return userRepository.findAll().stream().map(UserDto::from).toList(); - } + return userRepository.findAll().stream().map(UserDto::from).toList(); + } - /** Checks if a user exists. */ - public boolean userExists(UUID userId) { - return userRepository.existsById(UserId.of(userId)); - } + /** Checks if a user exists. */ + public boolean userExists(UUID userId) { + return userRepository.existsById(UserId.of(userId)); + } } diff --git a/src/main/java/org/nkcoder/user/domain/event/UserProfileUpdatedEvent.java b/src/main/java/org/nkcoder/user/domain/event/UserProfileUpdatedEvent.java index c1a4084..344b3fb 100644 --- a/src/main/java/org/nkcoder/user/domain/event/UserProfileUpdatedEvent.java +++ b/src/main/java/org/nkcoder/user/domain/event/UserProfileUpdatedEvent.java @@ -6,23 +6,22 @@ import org.nkcoder.user.domain.model.UserName; /** Domain event published when a user's profile is updated. */ -public record UserProfileUpdatedEvent( - LocalDateTime occurredOn, UserId userId, UserName oldName, UserName newName) - implements DomainEvent { +public record UserProfileUpdatedEvent(LocalDateTime occurredOn, UserId userId, UserName oldName, UserName newName) + implements DomainEvent { - private static final String EVENT_TYPE = "user.profile.updated"; + private static final String EVENT_TYPE = "user.profile.updated"; - public UserProfileUpdatedEvent(UserId userId, UserName oldName, UserName newName) { - this(LocalDateTime.now(), userId, oldName, newName); - } + public UserProfileUpdatedEvent(UserId userId, UserName oldName, UserName newName) { + this(LocalDateTime.now(), userId, oldName, newName); + } - @Override - public LocalDateTime occurredOn() { - return occurredOn; - } + @Override + public LocalDateTime occurredOn() { + return occurredOn; + } - @Override - public String eventType() { - return EVENT_TYPE; - } + @Override + public String eventType() { + return EVENT_TYPE; + } } 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 b5a6661..3cd6c36 100644 --- a/src/main/java/org/nkcoder/user/domain/model/User.java +++ b/src/main/java/org/nkcoder/user/domain/model/User.java @@ -5,135 +5,132 @@ import org.nkcoder.shared.kernel.domain.valueobject.Email; import org.nkcoder.user.domain.event.UserProfileUpdatedEvent; -/** - * User aggregate root in the User bounded context. Represents a user's profile and identity - * information. - */ +/** User aggregate root in the User bounded context. Represents a user's profile and identity information. */ public class User { - private final UserId id; - private Email email; - private UserName name; - private final UserRole role; - private boolean emailVerified; - private LocalDateTime lastLoginAt; - private final LocalDateTime createdAt; - private LocalDateTime updatedAt; - - private User( - UserId id, - Email email, - UserName name, - UserRole role, - boolean emailVerified, - LocalDateTime lastLoginAt, - LocalDateTime createdAt, - LocalDateTime updatedAt) { - this.id = Objects.requireNonNull(id, "User ID cannot be null"); - this.email = Objects.requireNonNull(email, "Email cannot be null"); - this.name = Objects.requireNonNull(name, "Name cannot be null"); - this.role = Objects.requireNonNull(role, "Role cannot be null"); - this.emailVerified = emailVerified; - this.lastLoginAt = lastLoginAt; - this.createdAt = createdAt; - this.updatedAt = updatedAt; - } - - /** Creates a new user (typically from registration in Auth context). */ - public static User create(UserId id, Email email, UserName name, UserRole role) { - LocalDateTime now = LocalDateTime.now(); - return new User(id, email, name, role, false, null, now, now); - } - - /** Reconstitutes a User from persistence. */ - public static User reconstitute( - UserId id, - Email email, - UserName name, - UserRole role, - boolean emailVerified, - LocalDateTime lastLoginAt, - LocalDateTime createdAt, - LocalDateTime updatedAt) { - return new User(id, email, name, role, emailVerified, lastLoginAt, createdAt, updatedAt); - } - - /** Updates the user's profile information. */ - public UserProfileUpdatedEvent updateProfile(UserName newName) { - UserName oldName = this.name; - this.name = Objects.requireNonNull(newName, "Name cannot be null"); - this.updatedAt = LocalDateTime.now(); - - return new UserProfileUpdatedEvent(this.id, oldName, newName); - } - - /** Updates the user's email address. */ - public void updateEmail(Email newEmail) { - this.email = Objects.requireNonNull(newEmail, "Email cannot be null"); - this.emailVerified = false; - this.updatedAt = LocalDateTime.now(); - } - - /** Marks the email as verified. */ - public void verifyEmail() { - this.emailVerified = true; - this.updatedAt = LocalDateTime.now(); - } - - /** Records a login event (called when Auth context notifies of login). */ - public void recordLogin() { - this.lastLoginAt = LocalDateTime.now(); - this.updatedAt = LocalDateTime.now(); - } - - // Getters - - public UserId getId() { - return id; - } - - public Email getEmail() { - return email; - } - - public UserName getName() { - return name; - } - - public UserRole getRole() { - return role; - } - - public boolean isEmailVerified() { - return emailVerified; - } - - public LocalDateTime getLastLoginAt() { - return lastLoginAt; - } - - public LocalDateTime getCreatedAt() { - return createdAt; - } - - public LocalDateTime getUpdatedAt() { - return updatedAt; - } - - public boolean isAdmin() { - return role == UserRole.ADMIN; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - User user = (User) o; - return Objects.equals(id, user.id); - } - - @Override - public int hashCode() { - return Objects.hash(id); - } + private final UserId id; + private Email email; + private UserName name; + private final UserRole role; + private boolean emailVerified; + private LocalDateTime lastLoginAt; + private final LocalDateTime createdAt; + private LocalDateTime updatedAt; + + private User( + UserId id, + Email email, + UserName name, + UserRole role, + boolean emailVerified, + LocalDateTime lastLoginAt, + LocalDateTime createdAt, + LocalDateTime updatedAt) { + this.id = Objects.requireNonNull(id, "User ID cannot be null"); + this.email = Objects.requireNonNull(email, "Email cannot be null"); + this.name = Objects.requireNonNull(name, "Name cannot be null"); + this.role = Objects.requireNonNull(role, "Role cannot be null"); + this.emailVerified = emailVerified; + this.lastLoginAt = lastLoginAt; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + /** Creates a new user (typically from registration in Auth context). */ + public static User create(UserId id, Email email, UserName name, UserRole role) { + LocalDateTime now = LocalDateTime.now(); + return new User(id, email, name, role, false, null, now, now); + } + + /** Reconstitutes a User from persistence. */ + public static User reconstitute( + UserId id, + Email email, + UserName name, + UserRole role, + boolean emailVerified, + LocalDateTime lastLoginAt, + LocalDateTime createdAt, + LocalDateTime updatedAt) { + return new User(id, email, name, role, emailVerified, lastLoginAt, createdAt, updatedAt); + } + + /** Updates the user's profile information. */ + public UserProfileUpdatedEvent updateProfile(UserName newName) { + UserName oldName = this.name; + this.name = Objects.requireNonNull(newName, "Name cannot be null"); + this.updatedAt = LocalDateTime.now(); + + return new UserProfileUpdatedEvent(this.id, oldName, newName); + } + + /** Updates the user's email address. */ + public void updateEmail(Email newEmail) { + this.email = Objects.requireNonNull(newEmail, "Email cannot be null"); + this.emailVerified = false; + this.updatedAt = LocalDateTime.now(); + } + + /** Marks the email as verified. */ + public void verifyEmail() { + this.emailVerified = true; + this.updatedAt = LocalDateTime.now(); + } + + /** Records a login event (called when Auth context notifies of login). */ + public void recordLogin() { + this.lastLoginAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + // Getters + + public UserId getId() { + return id; + } + + public Email getEmail() { + return email; + } + + public UserName getName() { + return name; + } + + public UserRole getRole() { + return role; + } + + public boolean isEmailVerified() { + return emailVerified; + } + + public LocalDateTime getLastLoginAt() { + return lastLoginAt; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public boolean isAdmin() { + return role == UserRole.ADMIN; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + User user = (User) o; + return Objects.equals(id, user.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } } diff --git a/src/main/java/org/nkcoder/user/domain/model/UserId.java b/src/main/java/org/nkcoder/user/domain/model/UserId.java index 2a9fb33..304d643 100644 --- a/src/main/java/org/nkcoder/user/domain/model/UserId.java +++ b/src/main/java/org/nkcoder/user/domain/model/UserId.java @@ -6,24 +6,24 @@ /** Value object representing a User's unique identifier. */ public record UserId(UUID value) { - public UserId { - Objects.requireNonNull(value, "User ID cannot be null"); - } + public UserId { + Objects.requireNonNull(value, "User ID cannot be null"); + } - public static UserId generate() { - return new UserId(UUID.randomUUID()); - } + public static UserId generate() { + return new UserId(UUID.randomUUID()); + } - public static UserId of(UUID value) { - return new UserId(value); - } + public static UserId of(UUID value) { + return new UserId(value); + } - public static UserId of(String value) { - return new UserId(UUID.fromString(value)); - } + public static UserId of(String value) { + return new UserId(UUID.fromString(value)); + } - @Override - public String toString() { - return value.toString(); - } + @Override + public String toString() { + return value.toString(); + } } diff --git a/src/main/java/org/nkcoder/user/domain/model/UserName.java b/src/main/java/org/nkcoder/user/domain/model/UserName.java index ba16a1a..3d2af28 100644 --- a/src/main/java/org/nkcoder/user/domain/model/UserName.java +++ b/src/main/java/org/nkcoder/user/domain/model/UserName.java @@ -6,29 +6,29 @@ /** Value object representing a user's display name. */ public record UserName(String value) { - private static final int MIN_LENGTH = 1; - private static final int MAX_LENGTH = 100; + private static final int MIN_LENGTH = 1; + private static final int MAX_LENGTH = 100; - public UserName { - Objects.requireNonNull(value, "Name cannot be null"); - String trimmed = value.trim(); - if (trimmed.length() < MIN_LENGTH || trimmed.length() > MAX_LENGTH) { - throw new ValidationException( - String.format("Name must be between %d and %d characters", MIN_LENGTH, MAX_LENGTH)); + public UserName { + Objects.requireNonNull(value, "Name cannot be null"); + String trimmed = value.trim(); + if (trimmed.length() < MIN_LENGTH || trimmed.length() > MAX_LENGTH) { + throw new ValidationException( + String.format("Name must be between %d and %d characters", MIN_LENGTH, MAX_LENGTH)); + } } - } - public static UserName of(String value) { - return new UserName(value.trim()); - } + public static UserName of(String value) { + return new UserName(value.trim()); + } - @Override - public String value() { - return value.trim(); - } + @Override + public String value() { + return value.trim(); + } - @Override - public String toString() { - return value(); - } + @Override + public String toString() { + return value(); + } } diff --git a/src/main/java/org/nkcoder/user/domain/model/UserRole.java b/src/main/java/org/nkcoder/user/domain/model/UserRole.java index c4656cf..3aeedc3 100644 --- a/src/main/java/org/nkcoder/user/domain/model/UserRole.java +++ b/src/main/java/org/nkcoder/user/domain/model/UserRole.java @@ -2,10 +2,10 @@ /** User roles in the User bounded context. */ public enum UserRole { - MEMBER, - ADMIN; + MEMBER, + ADMIN; - public String toAuthority() { - return "ROLE_" + this.name(); - } + public String toAuthority() { + return "ROLE_" + this.name(); + } } diff --git a/src/main/java/org/nkcoder/user/domain/repository/UserRepository.java b/src/main/java/org/nkcoder/user/domain/repository/UserRepository.java index 59e7988..d1d5427 100644 --- a/src/main/java/org/nkcoder/user/domain/repository/UserRepository.java +++ b/src/main/java/org/nkcoder/user/domain/repository/UserRepository.java @@ -9,24 +9,24 @@ /** Repository interface (port) for User aggregate. */ public interface UserRepository { - /** Saves a user (create or update). */ - User save(User user); + /** Saves a user (create or update). */ + User save(User user); - /** Finds a user by their ID. */ - Optional findById(UserId id); + /** Finds a user by their ID. */ + Optional findById(UserId id); - /** Finds a user by their email address. */ - Optional findByEmail(Email email); + /** Finds a user by their email address. */ + Optional findByEmail(Email email); - /** Checks if an email is already in use by another user. */ - boolean existsByEmailExcludingId(Email email, UserId excludeId); + /** Checks if an email is already in use by another user. */ + boolean existsByEmailExcludingId(Email email, UserId excludeId); - /** Finds all users (for admin operations). */ - List findAll(); + /** Finds all users (for admin operations). */ + List findAll(); - /** Deletes a user by ID. */ - void deleteById(UserId id); + /** Deletes a user by ID. */ + void deleteById(UserId id); - /** Checks if a user exists by ID. */ - boolean existsById(UserId id); + /** Checks if a user exists by ID. */ + boolean existsById(UserId id); } diff --git a/src/main/java/org/nkcoder/user/infrastructure/adapter/AuthContextAdapter.java b/src/main/java/org/nkcoder/user/infrastructure/adapter/AuthContextAdapter.java index f4efc9e..a2380a2 100644 --- a/src/main/java/org/nkcoder/user/infrastructure/adapter/AuthContextAdapter.java +++ b/src/main/java/org/nkcoder/user/infrastructure/adapter/AuthContextAdapter.java @@ -15,42 +15,39 @@ @Component public class AuthContextAdapter implements AuthContextPort { - private static final Logger logger = LoggerFactory.getLogger(AuthContextAdapter.class); + private static final Logger logger = LoggerFactory.getLogger(AuthContextAdapter.class); - private final AuthUserRepository authUserRepository; - private final PasswordEncoder passwordEncoder; + private final AuthUserRepository authUserRepository; + private final PasswordEncoder passwordEncoder; - public AuthContextAdapter( - AuthUserRepository authUserRepository, PasswordEncoder passwordEncoder) { - this.authUserRepository = authUserRepository; - this.passwordEncoder = passwordEncoder; - } + public AuthContextAdapter(AuthUserRepository authUserRepository, PasswordEncoder passwordEncoder) { + this.authUserRepository = authUserRepository; + this.passwordEncoder = passwordEncoder; + } - @Override - public boolean verifyPassword(UUID userId, String password) { - logger.debug("Verifying password for user: {}", userId); + @Override + public boolean verifyPassword(UUID userId, String password) { + logger.debug("Verifying password for user: {}", userId); - var authUser = - authUserRepository - .findById(AuthUserId.of(userId)) - .orElseThrow(() -> new ResourceNotFoundException("User not found: " + userId)); + var authUser = authUserRepository + .findById(AuthUserId.of(userId)) + .orElseThrow(() -> new ResourceNotFoundException("User not found: " + userId)); - return passwordEncoder.matches(password, authUser.getPassword()); - } + return passwordEncoder.matches(password, authUser.getPassword()); + } - @Override - public void changePassword(UUID userId, String newPassword) { - logger.debug("Changing password for user: {}", userId); + @Override + public void changePassword(UUID userId, String newPassword) { + logger.debug("Changing password for user: {}", userId); - var authUser = - authUserRepository - .findById(AuthUserId.of(userId)) - .orElseThrow(() -> new ResourceNotFoundException("User not found: " + userId)); + var authUser = authUserRepository + .findById(AuthUserId.of(userId)) + .orElseThrow(() -> new ResourceNotFoundException("User not found: " + userId)); - HashedPassword encodedPassword = passwordEncoder.encode(newPassword); - authUser.changePassword(encodedPassword); + HashedPassword encodedPassword = passwordEncoder.encode(newPassword); + authUser.changePassword(encodedPassword); - authUserRepository.save(authUser); - logger.info("Password changed for user: {}", userId); - } + authUserRepository.save(authUser); + logger.info("Password changed for user: {}", userId); + } } 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 67a7644..7112a64 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 @@ -15,137 +15,139 @@ import org.springframework.data.domain.Persistable; /** - * JPA entity for User in the User bounded context. Maps to the same 'users' table as - * AuthUserJpaEntity but with different field focus. Implements Persistable to control new/existing - * entity detection since the row may already exist (created by Auth context). + * JPA entity for User in the User bounded context. Maps to the same 'users' table as AuthUserJpaEntity but with + * different field focus. Implements Persistable to control new/existing entity detection since the row may already + * exist (created by Auth context). */ @Entity @Table(name = "users") public class UserJpaEntity implements Persistable { - @Id private UUID id; + @Id + private UUID id; + + @Transient + private boolean isNew = false; - @Transient private boolean isNew = false; + @Column(nullable = false, unique = true) + private String email; - @Column(nullable = false, unique = true) - private String email; + @Column(nullable = false) + private String name; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private UserRole role; + + @Column(name = "is_email_verified") + private boolean emailVerified; - @Column(nullable = false) - private String name; + @Column(name = "last_login_at") + private LocalDateTime lastLoginAt; - @Enumerated(EnumType.STRING) - @Column(nullable = false) - private UserRole role; + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; - @Column(name = "is_email_verified") - private boolean emailVerified; - - @Column(name = "last_login_at") - private LocalDateTime lastLoginAt; - - @CreatedDate - @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; - - @LastModifiedDate - @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt; - - protected UserJpaEntity() {} - - public UserJpaEntity( - UUID id, - String email, - String name, - UserRole role, - boolean emailVerified, - LocalDateTime lastLoginAt, - LocalDateTime createdAt, - LocalDateTime updatedAt) { - this.id = id; - this.email = email; - this.name = name; - this.role = role; - this.emailVerified = emailVerified; - this.lastLoginAt = lastLoginAt; - this.createdAt = createdAt; - this.updatedAt = updatedAt; - } - - // Getters and setters - - public UUID getId() { - return id; - } - - public void setId(UUID id) { - this.id = id; - } - - public String getEmail() { - return email; - } - - public void setEmail(String email) { - this.email = email; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public UserRole getRole() { - return role; - } - - public void setRole(UserRole role) { - this.role = role; - } - - public boolean isEmailVerified() { - return emailVerified; - } - - public void setEmailVerified(boolean emailVerified) { - this.emailVerified = emailVerified; - } - - public LocalDateTime getLastLoginAt() { - return lastLoginAt; - } - - public void setLastLoginAt(LocalDateTime lastLoginAt) { - this.lastLoginAt = lastLoginAt; - } - - public LocalDateTime getCreatedAt() { - return createdAt; - } - - public void setCreatedAt(LocalDateTime createdAt) { - this.createdAt = createdAt; - } - - public LocalDateTime getUpdatedAt() { - return updatedAt; - } - - public void setUpdatedAt(LocalDateTime updatedAt) { - this.updatedAt = updatedAt; - } - - // Persistable implementation - - @Override - public boolean isNew() { - return isNew; - } - - public void markAsNew() { - this.isNew = true; - } + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + protected UserJpaEntity() {} + + public UserJpaEntity( + UUID id, + String email, + String name, + UserRole role, + boolean emailVerified, + LocalDateTime lastLoginAt, + LocalDateTime createdAt, + LocalDateTime updatedAt) { + this.id = id; + this.email = email; + this.name = name; + this.role = role; + this.emailVerified = emailVerified; + this.lastLoginAt = lastLoginAt; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + // Getters and setters + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public UserRole getRole() { + return role; + } + + public void setRole(UserRole role) { + this.role = role; + } + + public boolean isEmailVerified() { + return emailVerified; + } + + public void setEmailVerified(boolean emailVerified) { + this.emailVerified = emailVerified; + } + + public LocalDateTime getLastLoginAt() { + return lastLoginAt; + } + + public void setLastLoginAt(LocalDateTime lastLoginAt) { + this.lastLoginAt = lastLoginAt; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + // Persistable implementation + + @Override + public boolean isNew() { + return isNew; + } + + public void markAsNew() { + this.isNew = true; + } } 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 5704526..bcab851 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 @@ -11,33 +11,33 @@ @Component public class UserPersistenceMapper { - public User toDomain(UserJpaEntity entity) { - return User.reconstitute( - UserId.of(entity.getId()), - Email.of(entity.getEmail()), - UserName.of(entity.getName()), - entity.getRole(), - entity.isEmailVerified(), - entity.getLastLoginAt(), - entity.getCreatedAt(), - entity.getUpdatedAt()); - } + public User toDomain(UserJpaEntity entity) { + return User.reconstitute( + UserId.of(entity.getId()), + Email.of(entity.getEmail()), + UserName.of(entity.getName()), + entity.getRole(), + entity.isEmailVerified(), + entity.getLastLoginAt(), + entity.getCreatedAt(), + entity.getUpdatedAt()); + } - public UserJpaEntity toEntity(User user) { - return new UserJpaEntity( - user.getId().value(), - user.getEmail().value(), - user.getName().value(), - user.getRole(), - user.isEmailVerified(), - user.getLastLoginAt(), - user.getCreatedAt(), - user.getUpdatedAt()); - } + public UserJpaEntity toEntity(User user) { + return new UserJpaEntity( + user.getId().value(), + user.getEmail().value(), + user.getName().value(), + user.getRole(), + user.isEmailVerified(), + user.getLastLoginAt(), + user.getCreatedAt(), + user.getUpdatedAt()); + } - public UserJpaEntity toNewEntity(User user) { - UserJpaEntity entity = toEntity(user); - entity.markAsNew(); - return entity; - } + public UserJpaEntity toNewEntity(User user) { + UserJpaEntity entity = toEntity(user); + entity.markAsNew(); + return entity; + } } diff --git a/src/main/java/org/nkcoder/user/infrastructure/persistence/repository/UserJpaRepository.java b/src/main/java/org/nkcoder/user/infrastructure/persistence/repository/UserJpaRepository.java index 6ea4cb8..07609e4 100644 --- a/src/main/java/org/nkcoder/user/infrastructure/persistence/repository/UserJpaRepository.java +++ b/src/main/java/org/nkcoder/user/infrastructure/persistence/repository/UserJpaRepository.java @@ -12,11 +12,10 @@ @Repository public interface UserJpaRepository extends JpaRepository { - Optional findByEmail(String email); + Optional findByEmail(String email); - @Query("SELECT COUNT(u) > 0 FROM UserJpaEntity u WHERE u.email = :email AND u.id != :excludeId") - boolean existsByEmailExcludingId( - @Param("email") String email, @Param("excludeId") UUID excludeId); + @Query("SELECT COUNT(u) > 0 FROM UserJpaEntity u WHERE u.email = :email AND u.id != :excludeId") + boolean existsByEmailExcludingId(@Param("email") String email, @Param("excludeId") UUID excludeId); - boolean existsByEmail(String email); + boolean existsByEmail(String email); } diff --git a/src/main/java/org/nkcoder/user/infrastructure/persistence/repository/UserRepositoryAdapter.java b/src/main/java/org/nkcoder/user/infrastructure/persistence/repository/UserRepositoryAdapter.java index 7403066..1bdd025 100644 --- a/src/main/java/org/nkcoder/user/infrastructure/persistence/repository/UserRepositoryAdapter.java +++ b/src/main/java/org/nkcoder/user/infrastructure/persistence/repository/UserRepositoryAdapter.java @@ -13,49 +13,49 @@ @Repository public class UserRepositoryAdapter implements UserRepository { - private final UserJpaRepository jpaRepository; - private final UserPersistenceMapper mapper; - - public UserRepositoryAdapter(UserJpaRepository jpaRepository, UserPersistenceMapper mapper) { - this.jpaRepository = jpaRepository; - this.mapper = mapper; - } - - @Override - public User save(User user) { - boolean exists = jpaRepository.existsById(user.getId().value()); - var entity = exists ? mapper.toEntity(user) : mapper.toNewEntity(user); - var savedEntity = jpaRepository.save(entity); - return mapper.toDomain(savedEntity); - } - - @Override - public Optional findById(UserId id) { - return jpaRepository.findById(id.value()).map(mapper::toDomain); - } - - @Override - public Optional findByEmail(Email email) { - return jpaRepository.findByEmail(email.value()).map(mapper::toDomain); - } - - @Override - public boolean existsByEmailExcludingId(Email email, UserId excludeId) { - return jpaRepository.existsByEmailExcludingId(email.value(), excludeId.value()); - } - - @Override - public List findAll() { - return jpaRepository.findAll().stream().map(mapper::toDomain).toList(); - } - - @Override - public void deleteById(UserId id) { - jpaRepository.deleteById(id.value()); - } - - @Override - public boolean existsById(UserId id) { - return jpaRepository.existsById(id.value()); - } + private final UserJpaRepository jpaRepository; + private final UserPersistenceMapper mapper; + + public UserRepositoryAdapter(UserJpaRepository jpaRepository, UserPersistenceMapper mapper) { + this.jpaRepository = jpaRepository; + this.mapper = mapper; + } + + @Override + public User save(User user) { + boolean exists = jpaRepository.existsById(user.getId().value()); + var entity = exists ? mapper.toEntity(user) : mapper.toNewEntity(user); + var savedEntity = jpaRepository.save(entity); + return mapper.toDomain(savedEntity); + } + + @Override + public Optional findById(UserId id) { + return jpaRepository.findById(id.value()).map(mapper::toDomain); + } + + @Override + public Optional findByEmail(Email email) { + return jpaRepository.findByEmail(email.value()).map(mapper::toDomain); + } + + @Override + public boolean existsByEmailExcludingId(Email email, UserId excludeId) { + return jpaRepository.existsByEmailExcludingId(email.value(), excludeId.value()); + } + + @Override + public List findAll() { + return jpaRepository.findAll().stream().map(mapper::toDomain).toList(); + } + + @Override + public void deleteById(UserId id) { + jpaRepository.deleteById(id.value()); + } + + @Override + public boolean existsById(UserId id) { + return jpaRepository.existsById(id.value()); + } } diff --git a/src/main/java/org/nkcoder/user/interfaces/rest/AdminUserController.java b/src/main/java/org/nkcoder/user/interfaces/rest/AdminUserController.java index 1804bcf..4f3fac0 100644 --- a/src/main/java/org/nkcoder/user/interfaces/rest/AdminUserController.java +++ b/src/main/java/org/nkcoder/user/interfaces/rest/AdminUserController.java @@ -28,57 +28,55 @@ @PreAuthorize("hasRole('ADMIN')") public class AdminUserController { - private static final Logger logger = LoggerFactory.getLogger(AdminUserController.class); + private static final Logger logger = LoggerFactory.getLogger(AdminUserController.class); - private final UserQueryService queryService; - private final UserCommandService commandService; - private final UserRequestMapper requestMapper; + private final UserQueryService queryService; + private final UserCommandService commandService; + private final UserRequestMapper requestMapper; - public AdminUserController( - UserQueryService queryService, - UserCommandService commandService, - UserRequestMapper requestMapper) { - this.queryService = queryService; - this.commandService = commandService; - this.requestMapper = requestMapper; - } + public AdminUserController( + UserQueryService queryService, UserCommandService commandService, UserRequestMapper requestMapper) { + this.queryService = queryService; + this.commandService = commandService; + this.requestMapper = requestMapper; + } - @GetMapping - public ResponseEntity>> getAllUsers() { - logger.debug("Admin getting all users"); + @GetMapping + public ResponseEntity>> getAllUsers() { + logger.debug("Admin getting all users"); - List users = queryService.getAllUsers().stream().map(UserResponse::from).toList(); + List users = + queryService.getAllUsers().stream().map(UserResponse::from).toList(); - return ResponseEntity.ok(ApiResponse.success("Users retrieved", users)); - } + return ResponseEntity.ok(ApiResponse.success("Users retrieved", users)); + } - @GetMapping("/{userId}") - public ResponseEntity> getUserById(@PathVariable UUID userId) { - logger.debug("Admin getting user: {}", userId); + @GetMapping("/{userId}") + public ResponseEntity> getUserById(@PathVariable UUID userId) { + logger.debug("Admin getting user: {}", userId); - UserDto user = queryService.getUserById(userId); + UserDto user = queryService.getUserById(userId); - return ResponseEntity.ok(ApiResponse.success("User retrieved", UserResponse.from(user))); - } + return ResponseEntity.ok(ApiResponse.success("User retrieved", UserResponse.from(user))); + } - @PatchMapping("/{userId}") - public ResponseEntity> updateUser( - @PathVariable UUID userId, @Valid @RequestBody AdminUpdateUserRequest request) { - logger.info("Admin updating user: {}", userId); + @PatchMapping("/{userId}") + public ResponseEntity> updateUser( + @PathVariable UUID userId, @Valid @RequestBody AdminUpdateUserRequest request) { + logger.info("Admin updating user: {}", userId); - UserDto user = commandService.adminUpdateUser(requestMapper.toCommand(userId, request)); + UserDto user = commandService.adminUpdateUser(requestMapper.toCommand(userId, request)); - return ResponseEntity.ok( - ApiResponse.success("User updated successfully", UserResponse.from(user))); - } + return ResponseEntity.ok(ApiResponse.success("User updated successfully", UserResponse.from(user))); + } - @PatchMapping("/{userId}/password") - public ResponseEntity> resetPassword( - @PathVariable UUID userId, @Valid @RequestBody AdminResetPasswordRequest request) { - logger.info("Admin resetting password for user: {}", userId); + @PatchMapping("/{userId}/password") + public ResponseEntity> resetPassword( + @PathVariable UUID userId, @Valid @RequestBody AdminResetPasswordRequest request) { + logger.info("Admin resetting password for user: {}", userId); - commandService.adminResetPassword(requestMapper.toCommand(userId, request)); + commandService.adminResetPassword(requestMapper.toCommand(userId, request)); - return ResponseEntity.ok(ApiResponse.success("Password reset successfully")); - } + return ResponseEntity.ok(ApiResponse.success("Password reset successfully")); + } } diff --git a/src/main/java/org/nkcoder/user/interfaces/rest/UserController.java b/src/main/java/org/nkcoder/user/interfaces/rest/UserController.java index 9dc08f8..0779267 100644 --- a/src/main/java/org/nkcoder/user/interfaces/rest/UserController.java +++ b/src/main/java/org/nkcoder/user/interfaces/rest/UserController.java @@ -25,50 +25,45 @@ @RequestMapping("/api/users/me") public class UserController { - private static final Logger logger = LoggerFactory.getLogger(UserController.class); + private static final Logger logger = LoggerFactory.getLogger(UserController.class); - private final UserQueryService queryService; - private final UserCommandService commandService; - private final UserRequestMapper requestMapper; + private final UserQueryService queryService; + private final UserCommandService commandService; + private final UserRequestMapper requestMapper; - public UserController( - UserQueryService queryService, - UserCommandService commandService, - UserRequestMapper requestMapper) { - this.queryService = queryService; - this.commandService = commandService; - this.requestMapper = requestMapper; - } + public UserController( + UserQueryService queryService, UserCommandService commandService, UserRequestMapper requestMapper) { + this.queryService = queryService; + this.commandService = commandService; + this.requestMapper = requestMapper; + } - @GetMapping - public ResponseEntity> getCurrentUser( - @RequestAttribute("userId") UUID userId) { - logger.debug("Getting current user profile"); + @GetMapping + public ResponseEntity> getCurrentUser(@RequestAttribute("userId") UUID userId) { + logger.debug("Getting current user profile"); - UserDto user = queryService.getUserById(userId); + UserDto user = queryService.getUserById(userId); - return ResponseEntity.ok( - ApiResponse.success("User profile retrieved", UserResponse.from(user))); - } + return ResponseEntity.ok(ApiResponse.success("User profile retrieved", UserResponse.from(user))); + } - @PatchMapping - public ResponseEntity> updateProfile( - @RequestAttribute("userId") UUID userId, @Valid @RequestBody UpdateProfileRequest request) { - logger.info("Updating profile for user: {}", userId); + @PatchMapping + public ResponseEntity> updateProfile( + @RequestAttribute("userId") UUID userId, @Valid @RequestBody UpdateProfileRequest request) { + logger.info("Updating profile for user: {}", userId); - UserDto user = commandService.updateProfile(requestMapper.toCommand(userId, request)); + UserDto user = commandService.updateProfile(requestMapper.toCommand(userId, request)); - return ResponseEntity.ok( - ApiResponse.success("Profile updated successfully", UserResponse.from(user))); - } + return ResponseEntity.ok(ApiResponse.success("Profile updated successfully", UserResponse.from(user))); + } - @PatchMapping("/password") - public ResponseEntity> changePassword( - @RequestAttribute("userId") UUID userId, @Valid @RequestBody ChangePasswordRequest request) { - logger.info("Changing password for user: {}", userId); + @PatchMapping("/password") + public ResponseEntity> changePassword( + @RequestAttribute("userId") UUID userId, @Valid @RequestBody ChangePasswordRequest request) { + logger.info("Changing password for user: {}", userId); - commandService.changePassword(requestMapper.toCommand(userId, request)); + commandService.changePassword(requestMapper.toCommand(userId, request)); - return ResponseEntity.ok(ApiResponse.success("Password changed successfully")); - } + return ResponseEntity.ok(ApiResponse.success("Password changed successfully")); + } } diff --git a/src/main/java/org/nkcoder/user/interfaces/rest/mapper/UserRequestMapper.java b/src/main/java/org/nkcoder/user/interfaces/rest/mapper/UserRequestMapper.java index 251df47..1b3cb7b 100644 --- a/src/main/java/org/nkcoder/user/interfaces/rest/mapper/UserRequestMapper.java +++ b/src/main/java/org/nkcoder/user/interfaces/rest/mapper/UserRequestMapper.java @@ -15,19 +15,19 @@ @Component public class UserRequestMapper { - public UpdateProfileCommand toCommand(UUID userId, UpdateProfileRequest request) { - return new UpdateProfileCommand(userId, request.name()); - } + public UpdateProfileCommand toCommand(UUID userId, UpdateProfileRequest request) { + return new UpdateProfileCommand(userId, request.name()); + } - public ChangePasswordCommand toCommand(UUID userId, ChangePasswordRequest request) { - return new ChangePasswordCommand(userId, request.currentPassword(), request.newPassword()); - } + public ChangePasswordCommand toCommand(UUID userId, ChangePasswordRequest request) { + return new ChangePasswordCommand(userId, request.currentPassword(), request.newPassword()); + } - public AdminUpdateUserCommand toCommand(UUID targetUserId, AdminUpdateUserRequest request) { - return new AdminUpdateUserCommand(targetUserId, request.name(), request.email()); - } + public AdminUpdateUserCommand toCommand(UUID targetUserId, AdminUpdateUserRequest request) { + return new AdminUpdateUserCommand(targetUserId, request.name(), request.email()); + } - public AdminResetPasswordCommand toCommand(UUID targetUserId, AdminResetPasswordRequest request) { - return new AdminResetPasswordCommand(targetUserId, request.newPassword()); - } + public AdminResetPasswordCommand toCommand(UUID targetUserId, AdminResetPasswordRequest request) { + return new AdminResetPasswordCommand(targetUserId, request.newPassword()); + } } diff --git a/src/main/java/org/nkcoder/user/interfaces/rest/request/AdminResetPasswordRequest.java b/src/main/java/org/nkcoder/user/interfaces/rest/request/AdminResetPasswordRequest.java index 66a3699..ca717a7 100644 --- a/src/main/java/org/nkcoder/user/interfaces/rest/request/AdminResetPasswordRequest.java +++ b/src/main/java/org/nkcoder/user/interfaces/rest/request/AdminResetPasswordRequest.java @@ -6,9 +6,8 @@ /** Request for admin resetting a user's password. */ public record AdminResetPasswordRequest( - @NotBlank(message = "New password is required") @Size(min = 8, max = 100, message = "Password must be between 8 and 100 characters") @Pattern( - regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$", - message = - "Password must contain at least one lowercase letter, one uppercase letter, and one" - + " digit") + @NotBlank(message = "New password is required") @Size(min = 8, max = 100, message = "Password must be between 8 and 100 characters") @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$", + message = + "Password must contain at least one lowercase letter, one uppercase letter, and one" + " digit") String newPassword) {} diff --git a/src/main/java/org/nkcoder/user/interfaces/rest/request/AdminUpdateUserRequest.java b/src/main/java/org/nkcoder/user/interfaces/rest/request/AdminUpdateUserRequest.java index 3ecd924..1eb1195 100644 --- a/src/main/java/org/nkcoder/user/interfaces/rest/request/AdminUpdateUserRequest.java +++ b/src/main/java/org/nkcoder/user/interfaces/rest/request/AdminUpdateUserRequest.java @@ -5,5 +5,6 @@ /** Request for admin updating a user. */ public record AdminUpdateUserRequest( - @Size(min = 1, max = 100, message = "Name must be between 1 and 100 characters") String name, - @Email(message = "Invalid email format") String email) {} + @Size(min = 1, max = 100, message = "Name must be between 1 and 100 characters") String name, + + @Email(message = "Invalid email format") String email) {} diff --git a/src/main/java/org/nkcoder/user/interfaces/rest/request/ChangePasswordRequest.java b/src/main/java/org/nkcoder/user/interfaces/rest/request/ChangePasswordRequest.java index f24e19a..7c10f2e 100644 --- a/src/main/java/org/nkcoder/user/interfaces/rest/request/ChangePasswordRequest.java +++ b/src/main/java/org/nkcoder/user/interfaces/rest/request/ChangePasswordRequest.java @@ -8,11 +8,12 @@ /** Request for changing user's password. */ @PasswordMatch public record ChangePasswordRequest( - @NotBlank(message = "Current password is required") String currentPassword, - @NotBlank(message = "New password is required") @Size(min = 8, max = 100, message = "Password must be between 8 and 100 characters") @Pattern( - regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$", - message = - "Password must contain at least one lowercase letter, one uppercase letter, and one" - + " digit") + @NotBlank(message = "Current password is required") String currentPassword, + + @NotBlank(message = "New password is required") @Size(min = 8, max = 100, message = "Password must be between 8 and 100 characters") @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$", + message = + "Password must contain at least one lowercase letter, one uppercase letter, and one" + " digit") String newPassword, - @NotBlank(message = "Password confirmation is required") String confirmPassword) {} + + @NotBlank(message = "Password confirmation is required") String confirmPassword) {} diff --git a/src/main/java/org/nkcoder/user/interfaces/rest/request/UpdateProfileRequest.java b/src/main/java/org/nkcoder/user/interfaces/rest/request/UpdateProfileRequest.java index a74b54f..bf8da91 100644 --- a/src/main/java/org/nkcoder/user/interfaces/rest/request/UpdateProfileRequest.java +++ b/src/main/java/org/nkcoder/user/interfaces/rest/request/UpdateProfileRequest.java @@ -5,4 +5,4 @@ /** Request for updating user profile. */ public record UpdateProfileRequest( - @NotBlank(message = "Name is required") @Size(min = 1, max = 100, message = "Name must be between 1 and 100 characters") String name) {} + @NotBlank(message = "Name is required") @Size(min = 1, max = 100, message = "Name must be between 1 and 100 characters") String name) {} diff --git a/src/main/java/org/nkcoder/user/interfaces/rest/response/UserResponse.java b/src/main/java/org/nkcoder/user/interfaces/rest/response/UserResponse.java index db7d184..d24aa23 100644 --- a/src/main/java/org/nkcoder/user/interfaces/rest/response/UserResponse.java +++ b/src/main/java/org/nkcoder/user/interfaces/rest/response/UserResponse.java @@ -6,24 +6,24 @@ /** REST API response for user information. */ public record UserResponse( - UUID id, - String email, - String name, - String role, - boolean emailVerified, - LocalDateTime lastLoginAt, - LocalDateTime createdAt, - LocalDateTime updatedAt) { + UUID id, + String email, + String name, + String role, + boolean emailVerified, + LocalDateTime lastLoginAt, + LocalDateTime createdAt, + LocalDateTime updatedAt) { - public static UserResponse from(UserDto dto) { - return new UserResponse( - dto.id(), - dto.email(), - dto.name(), - dto.role(), - dto.emailVerified(), - dto.lastLoginAt(), - dto.createdAt(), - dto.updatedAt()); - } + public static UserResponse from(UserDto dto) { + return new UserResponse( + dto.id(), + dto.email(), + dto.name(), + dto.role(), + dto.emailVerified(), + dto.lastLoginAt(), + dto.createdAt(), + dto.updatedAt()); + } } diff --git a/src/test/java/org/nkcoder/infrastructure/config/IntegrationTest.java b/src/test/java/org/nkcoder/infrastructure/config/IntegrationTest.java index 42a61a2..3171549 100644 --- a/src/test/java/org/nkcoder/infrastructure/config/IntegrationTest.java +++ b/src/test/java/org/nkcoder/infrastructure/config/IntegrationTest.java @@ -11,9 +11,9 @@ import org.springframework.transaction.annotation.Transactional; /** - * Note: @SpringBootTest performs full component scanning and will include the {@link - * JpaAuditingConfig} and enable JPA auditing. So don't add @EnableJPAAuditing on this annotation, - * otherwise it will be registered multiple times during AOT processing. + * Note: @SpringBootTest performs full component scanning and will include the {@link JpaAuditingConfig} and enable JPA + * auditing. So don't add @EnableJPAAuditing on this annotation, otherwise it will be registered multiple times during + * AOT processing. */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) diff --git a/src/test/java/org/nkcoder/infrastructure/config/TestContainersConfiguration.java b/src/test/java/org/nkcoder/infrastructure/config/TestContainersConfiguration.java index 4f2c3d4..0da03ce 100644 --- a/src/test/java/org/nkcoder/infrastructure/config/TestContainersConfiguration.java +++ b/src/test/java/org/nkcoder/infrastructure/config/TestContainersConfiguration.java @@ -6,19 +6,17 @@ import org.testcontainers.containers.PostgreSQLContainer; /** - * The @TestConfiguration with @ServiceConnection and a reusable PostgreSQL container is the modern, - * Spring Boot 3.1+ recommended pattern for integration tests. Centralized Testcontainers - * configuration for all integration tests. + * The @TestConfiguration with @ServiceConnection and a reusable PostgreSQL container is the modern, Spring Boot 3.1+ + * recommended pattern for integration tests. Centralized Testcontainers configuration for all integration tests. * *

Why this approach is recommended: * *

    - *
  • @TestConfiguration: Spring Boot loads this only in test contexts, keeping prod code - * clean. + *
  • @TestConfiguration: Spring Boot loads this only in test contexts, keeping prod code clean. *
  • @ServiceConnection (Spring Boot 3.1+): Auto-wires container connection properties * (spring.datasource.url, username, password) without manual @DynamicPropertySource. - *
  • Singleton Bean: Spring creates a single container instance across all test classes - * that import this config, avoiding repeated startup overhead. + *
  • Singleton Bean: Spring creates a single container instance across all test classes that import this + * config, avoiding repeated startup overhead. *
  • withReuse(true): Testcontainers reuses the container across test runs (requires * testcontainers.reuse.enable=true in ~/.testcontainers.properties). Speeds up local TDD. *
@@ -31,15 +29,15 @@ */ @TestConfiguration(proxyBeanMethods = false) public class TestContainersConfiguration { - private static final String POSTGRES_IMAGE = "postgres:17-alpine"; + private static final String POSTGRES_IMAGE = "postgres:17-alpine"; - @Bean - @ServiceConnection - PostgreSQLContainer postgresContainer() { - return new PostgreSQLContainer<>(POSTGRES_IMAGE) - .withDatabaseName("test-db") - .withUsername("test") - .withPassword("test") - .withReuse(true); - } + @Bean + @ServiceConnection + PostgreSQLContainer postgresContainer() { + return new PostgreSQLContainer<>(POSTGRES_IMAGE) + .withDatabaseName("test-db") + .withUsername("test") + .withPassword("test") + .withReuse(true); + } } diff --git a/src/test/java/org/nkcoder/integration/AuthControllerIntegrationTest.java b/src/test/java/org/nkcoder/integration/AuthControllerIntegrationTest.java index 50b683f..adc65d3 100644 --- a/src/test/java/org/nkcoder/integration/AuthControllerIntegrationTest.java +++ b/src/test/java/org/nkcoder/integration/AuthControllerIntegrationTest.java @@ -25,40 +25,42 @@ import org.springframework.test.web.servlet.MockMvc; /** - * Use @SpringBootTest to load the full context to test the authentication of endpoints. - * Because @WebMvcTest won't load SecurityConfig and JwtAuthenticationEntryPoint. + * Use @SpringBootTest to load the full context to test the authentication of endpoints. Because @WebMvcTest won't load + * SecurityConfig and JwtAuthenticationEntryPoint. */ @AutoConfigureMockMvc @IntegrationTest @DisplayName("AuthController Security Tests") public class AuthControllerIntegrationTest { - @Autowired private MockMvc mockMvc; + @Autowired + private MockMvc mockMvc; - @MockitoBean private AuthApplicationService authService; + @MockitoBean + private AuthApplicationService authService; - @MockitoBean private UserQueryService userQueryService; + @MockitoBean + private UserQueryService userQueryService; - @MockitoBean private UserCommandService userCommandService; + @MockitoBean + private UserCommandService userCommandService; - @MockitoBean private JwtUtil jwtUtil; + @MockitoBean + private JwtUtil jwtUtil; - @Nested - @DisplayName("Public Endpoints") - class PublicEndpoints { + @Nested + @DisplayName("Public Endpoints") + class PublicEndpoints { - @Test - @DisplayName("register endpoint is accessible without authentication") - void registerIsPublic() throws Exception { - AuthResult authResult = createAuthResult(); - given(authService.register(any())).willReturn(authResult); + @Test + @DisplayName("register endpoint is accessible without authentication") + void registerIsPublic() throws Exception { + AuthResult authResult = createAuthResult(); + given(authService.register(any())).willReturn(authResult); - mockMvc - .perform( - post("/api/auth/register") - .contentType(MediaType.APPLICATION_JSON) - .content( - """ + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(""" { "email": "test@example.com", "password": "Password123", @@ -66,121 +68,103 @@ void registerIsPublic() throws Exception { "role": "MEMBER" } """)) - .andExpect(status().isCreated()); - } - - @Test - @DisplayName("login endpoint is accessible without authentication") - void loginIsPublic() throws Exception { - AuthResult authResult = createAuthResult(); - given(authService.login(any())).willReturn(authResult); - - mockMvc - .perform( - post("/api/auth/login") - .contentType(MediaType.APPLICATION_JSON) - .content( - """ + .andExpect(status().isCreated()); + } + + @Test + @DisplayName("login endpoint is accessible without authentication") + void loginIsPublic() throws Exception { + AuthResult authResult = createAuthResult(); + given(authService.login(any())).willReturn(authResult); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" { "email": "test@example.com", "password": "Password123" } """)) - .andExpect(status().isOk()); - } - - @Test - @DisplayName("refresh endpoint is accessible without authentication") - void refreshIsPublic() throws Exception { - AuthResult authResult = createAuthResult(); - given(authService.refreshTokens(any())).willReturn(authResult); - - mockMvc - .perform( - post("/api/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content( - """ + .andExpect(status().isOk()); + } + + @Test + @DisplayName("refresh endpoint is accessible without authentication") + void refreshIsPublic() throws Exception { + AuthResult authResult = createAuthResult(); + given(authService.refreshTokens(any())).willReturn(authResult); + + mockMvc.perform(post("/api/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(""" { "refreshToken": "some-refresh-token" } """)) - .andExpect(status().isOk()); + .andExpect(status().isOk()); + } } - } - - @Nested - @DisplayName("Protected Endpoints") - class ProtectedEndpoints { - @Test - @DisplayName("GET /api/users/me returns 401 without token") - void getMeRequiresAuth() throws Exception { - mockMvc.perform(get("/api/users/me")).andExpect(status().isUnauthorized()); - } - - @Test - @DisplayName("PATCH /api/users/me returns 401 without token") - void updateMeRequiresAuth() throws Exception { - mockMvc - .perform( - patch("/api/users/me") - .contentType(MediaType.APPLICATION_JSON) - .content( - """ + @Nested + @DisplayName("Protected Endpoints") + class ProtectedEndpoints { + + @Test + @DisplayName("GET /api/users/me returns 401 without token") + void getMeRequiresAuth() throws Exception { + mockMvc.perform(get("/api/users/me")).andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("PATCH /api/users/me returns 401 without token") + void updateMeRequiresAuth() throws Exception { + mockMvc.perform(patch("/api/users/me") + .contentType(MediaType.APPLICATION_JSON) + .content(""" {"name": "New Name"} """)) - .andExpect(status().isUnauthorized()); - } - - @Test - @DisplayName("PATCH /api/users/me/password returns 401 without token") - void changePasswordRequiresAuth() throws Exception { - mockMvc - .perform( - patch("/api/users/me/password") - .contentType(MediaType.APPLICATION_JSON) - .content( - """ + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("PATCH /api/users/me/password returns 401 without token") + void changePasswordRequiresAuth() throws Exception { + mockMvc.perform(patch("/api/users/me/password") + .contentType(MediaType.APPLICATION_JSON) + .content(""" { "currentPassword": "Old123", "newPassword": "New123", "confirmPassword": "New123" } """)) - .andExpect(status().isUnauthorized()); - } - } - - @Nested - @DisplayName("Admin Endpoints") - class AdminEndpoints { - - @Test - @DisplayName("GET /api/admin/users/{id} returns 401 without token") - void getUserByIdRequiresAuth() throws Exception { - mockMvc - .perform(get("/api/admin/users/{userId}", UUID.randomUUID())) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()); + } } - @Test - @DisplayName("PATCH /api/admin/users/{id} returns 401 without token") - void updateUserRequiresAuth() throws Exception { - mockMvc - .perform( - patch("/api/admin/users/{userId}", UUID.randomUUID()) - .contentType(MediaType.APPLICATION_JSON) - .content( - """ + @Nested + @DisplayName("Admin Endpoints") + class AdminEndpoints { + + @Test + @DisplayName("GET /api/admin/users/{id} returns 401 without token") + void getUserByIdRequiresAuth() throws Exception { + mockMvc.perform(get("/api/admin/users/{userId}", UUID.randomUUID())).andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("PATCH /api/admin/users/{id} returns 401 without token") + void updateUserRequiresAuth() throws Exception { + mockMvc.perform(patch("/api/admin/users/{userId}", UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" {"name": "Admin Update"} """)) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()); + } } - } - private AuthResult createAuthResult() { - return new AuthResult( - UUID.randomUUID(), "test@example.com", AuthRole.MEMBER, "access-token", "refresh-token"); - } + private AuthResult createAuthResult() { + return new AuthResult(UUID.randomUUID(), "test@example.com", AuthRole.MEMBER, "access-token", "refresh-token"); + } } diff --git a/src/test/java/org/nkcoder/integration/AuthFlowIntegrationTest.java b/src/test/java/org/nkcoder/integration/AuthFlowIntegrationTest.java index 9422c94..c2d35b2 100644 --- a/src/test/java/org/nkcoder/integration/AuthFlowIntegrationTest.java +++ b/src/test/java/org/nkcoder/integration/AuthFlowIntegrationTest.java @@ -23,27 +23,25 @@ @DisplayName("Auth Flow Integration Tests") class AuthFlowIntegrationTest { - @LocalServerPort private int port; - - @BeforeEach - void setupRestAssured() { - RestAssured.port = port; - RestAssured.basePath = ""; - } - - @Nested - @DisplayName("Complete Authentication Flow") - class CompleteAuthFlow { - - @Test - @DisplayName("register → login → access protected → refresh → logout") - void fullAuthenticationFlow() { - // Step 1: Register - Response registerResponse = - given() - .contentType(ContentType.JSON) - .body( - """ + @LocalServerPort + private int port; + + @BeforeEach + void setupRestAssured() { + RestAssured.port = port; + RestAssured.basePath = ""; + } + + @Nested + @DisplayName("Complete Authentication Flow") + class CompleteAuthFlow { + + @Test + @DisplayName("register → login → access protected → refresh → logout") + void fullAuthenticationFlow() { + // Step 1: Register + Response registerResponse = given().contentType(ContentType.JSON) + .body(""" { "email": "flow@example.com", "password": "Password123", @@ -51,119 +49,102 @@ void fullAuthenticationFlow() { "role": "MEMBER" } """) - .when() - .post("/api/auth/register") - .then() - .statusCode(anyOf(is(200), is(201))) - .body("data.user.email", equalTo("flow@example.com")) - .body("data.tokens.accessToken", notNullValue()) - .body("data.tokens.refreshToken", notNullValue()) - .extract() - .response(); - - String accessToken = registerResponse.jsonPath().getString("data.tokens.accessToken"); - String refreshToken = registerResponse.jsonPath().getString("data.tokens.refreshToken"); - - // Step 2: Access protected endpoint - given() - .header("Authorization", "Bearer " + accessToken) - .when() - .get("/api/users/me") - .then() - .statusCode(200) - .body("data.email", equalTo("flow@example.com")) - .body("data.name", equalTo("Flow Test User")); - - // Step 3: Refresh tokens - Response refreshResponse = - given() - .contentType(ContentType.JSON) - .body( - """ + .when() + .post("/api/auth/register") + .then() + .statusCode(anyOf(is(200), is(201))) + .body("data.user.email", equalTo("flow@example.com")) + .body("data.tokens.accessToken", notNullValue()) + .body("data.tokens.refreshToken", notNullValue()) + .extract() + .response(); + + String accessToken = registerResponse.jsonPath().getString("data.tokens.accessToken"); + String refreshToken = registerResponse.jsonPath().getString("data.tokens.refreshToken"); + + // Step 2: Access protected endpoint + given().header("Authorization", "Bearer " + accessToken) + .when() + .get("/api/users/me") + .then() + .statusCode(200) + .body("data.email", equalTo("flow@example.com")) + .body("data.name", equalTo("Flow Test User")); + + // Step 3: Refresh tokens + Response refreshResponse = given().contentType(ContentType.JSON) + .body(""" { "refreshToken": "%s" } - """ - .formatted(refreshToken)) - .when() - .post("/api/auth/refresh") - .then() - .statusCode(200) - .body("data.tokens.accessToken", notNullValue()) - .body("data.tokens.refreshToken", notNullValue()) - .extract() - .response(); - - String newAccessToken = refreshResponse.jsonPath().getString("data.tokens.accessToken"); - String newRefreshToken = refreshResponse.jsonPath().getString("data.tokens.refreshToken"); - - // Step 4: Old refresh token should be invalid - given() - .contentType(ContentType.JSON) - .body( - """ + """.formatted(refreshToken)) + .when() + .post("/api/auth/refresh") + .then() + .statusCode(200) + .body("data.tokens.accessToken", notNullValue()) + .body("data.tokens.refreshToken", notNullValue()) + .extract() + .response(); + + String newAccessToken = refreshResponse.jsonPath().getString("data.tokens.accessToken"); + String newRefreshToken = refreshResponse.jsonPath().getString("data.tokens.refreshToken"); + + // Step 4: Old refresh token should be invalid + given().contentType(ContentType.JSON) + .body(""" { "refreshToken": "%s" } - """ - .formatted(refreshToken)) - .when() - .post("/api/auth/refresh") - .then() - .statusCode(401); - - // Step 5: New access token works - given() - .header("Authorization", "Bearer " + newAccessToken) - .when() - .get("/api/users/me") - .then() - .statusCode(200); - - // Step 6: Logout - given() - .header("Authorization", "Bearer " + newAccessToken) - .contentType(ContentType.JSON) - .body( - """ + """.formatted(refreshToken)) + .when() + .post("/api/auth/refresh") + .then() + .statusCode(401); + + // Step 5: New access token works + given().header("Authorization", "Bearer " + newAccessToken) + .when() + .get("/api/users/me") + .then() + .statusCode(200); + + // Step 6: Logout + given().header("Authorization", "Bearer " + newAccessToken) + .contentType(ContentType.JSON) + .body(""" { "refreshToken": "%s" } - """ - .formatted(newRefreshToken)) - .when() - .post("/api/auth/logout") - .then() - .statusCode(200); - - // Step 7: Refresh token invalid after logout - given() - .contentType(ContentType.JSON) - .body( - """ + """.formatted(newRefreshToken)) + .when() + .post("/api/auth/logout") + .then() + .statusCode(200); + + // Step 7: Refresh token invalid after logout + given().contentType(ContentType.JSON) + .body(""" { "refreshToken": "%s" } - """ - .formatted(newRefreshToken)) - .when() - .post("/api/auth/refresh") - .then() - .statusCode(401); + """.formatted(newRefreshToken)) + .when() + .post("/api/auth/refresh") + .then() + .statusCode(401); + } } - } - - @Nested - @DisplayName("Registration") - class Registration { - - @Test - @DisplayName("registers new user successfully") - void registersNewUser() { - given() - .contentType(ContentType.JSON) - .body( - """ + + @Nested + @DisplayName("Registration") + class Registration { + + @Test + @DisplayName("registers new user successfully") + void registersNewUser() { + given().contentType(ContentType.JSON) + .body(""" { "email": "newuser@example.com", "password": "Password123", @@ -171,23 +152,21 @@ void registersNewUser() { "role": "MEMBER" } """) - .when() - .post("/api/auth/register") - .then() - .statusCode(anyOf(is(200), is(201))) - .body("data.user.email", equalTo("newuser@example.com")) - .body("data.tokens.accessToken", notNullValue()) - .body("data.tokens.refreshToken", notNullValue()); - } - - @Test - @DisplayName("rejects duplicate email") - void rejectsDuplicateEmail() { - // First registration - given() - .contentType(ContentType.JSON) - .body( - """ + .when() + .post("/api/auth/register") + .then() + .statusCode(anyOf(is(200), is(201))) + .body("data.user.email", equalTo("newuser@example.com")) + .body("data.tokens.accessToken", notNullValue()) + .body("data.tokens.refreshToken", notNullValue()); + } + + @Test + @DisplayName("rejects duplicate email") + void rejectsDuplicateEmail() { + // First registration + given().contentType(ContentType.JSON) + .body(""" { "email": "duplicate@example.com", "password": "Password123", @@ -195,16 +174,14 @@ void rejectsDuplicateEmail() { "role": "MEMBER" } """) - .when() - .post("/api/auth/register") - .then() - .statusCode(anyOf(is(200), is(201))); - - // Second registration with same email - given() - .contentType(ContentType.JSON) - .body( - """ + .when() + .post("/api/auth/register") + .then() + .statusCode(anyOf(is(200), is(201))); + + // Second registration with same email + given().contentType(ContentType.JSON) + .body(""" { "email": "duplicate@example.com", "password": "Password123", @@ -212,20 +189,18 @@ void rejectsDuplicateEmail() { "role": "MEMBER" } """) - .when() - .post("/api/auth/register") - .then() - .statusCode(400) - .body("message", equalTo("User already exists")); - } - - @Test - @DisplayName("normalizes email to lowercase") - void normalizesEmail() { - given() - .contentType(ContentType.JSON) - .body( - """ + .when() + .post("/api/auth/register") + .then() + .statusCode(400) + .body("message", equalTo("User already exists")); + } + + @Test + @DisplayName("normalizes email to lowercase") + void normalizesEmail() { + given().contentType(ContentType.JSON) + .body(""" { "email": "UPPERCASE@EXAMPLE.COM", "password": "Password123", @@ -233,227 +208,203 @@ void normalizesEmail() { "role": "MEMBER" } """) - .when() - .post("/api/auth/register") - .then() - .statusCode(anyOf(is(200), is(201))) - .body("data.user.email", equalTo("uppercase@example.com")); + .when() + .post("/api/auth/register") + .then() + .statusCode(anyOf(is(200), is(201))) + .body("data.user.email", equalTo("uppercase@example.com")); + } } - } - - @Nested - @DisplayName("Login") - class Login { - - @Test - @DisplayName("logs in with valid credentials") - void logsInSuccessfully() { - // Register first - registerUser("login@example.com", "Password1234", "Login User"); - - // Login - given() - .contentType(ContentType.JSON) - .body( - """ + + @Nested + @DisplayName("Login") + class Login { + + @Test + @DisplayName("logs in with valid credentials") + void logsInSuccessfully() { + // Register first + registerUser("login@example.com", "Password1234", "Login User"); + + // Login + given().contentType(ContentType.JSON) + .body(""" { "email": "login@example.com", "password": "Password1234" } """) - .when() - .post("/api/auth/login") - .then() - .statusCode(200) - .body("data.user.email", equalTo("login@example.com")) - .body("data.tokens.accessToken", notNullValue()); - } - - @Test - @DisplayName("rejects wrong password") - void rejectsWrongPassword() { - registerUser("wrongpass@example.com", "Password123", "Wrong Pass User"); - - given() - .contentType(ContentType.JSON) - .body( - """ + .when() + .post("/api/auth/login") + .then() + .statusCode(200) + .body("data.user.email", equalTo("login@example.com")) + .body("data.tokens.accessToken", notNullValue()); + } + + @Test + @DisplayName("rejects wrong password") + void rejectsWrongPassword() { + registerUser("wrongpass@example.com", "Password123", "Wrong Pass User"); + + given().contentType(ContentType.JSON) + .body(""" { "email": "wrongpass@example.com", "password": "WrongPassword123" } """) - .when() - .post("/api/auth/login") - .then() - .statusCode(401) - .body("message", equalTo("Invalid email or password")); - } - - @Test - @DisplayName("rejects non-existent email") - void rejectsNonExistentEmail() { - given() - .contentType(ContentType.JSON) - .body( - """ + .when() + .post("/api/auth/login") + .then() + .statusCode(401) + .body("message", equalTo("Invalid email or password")); + } + + @Test + @DisplayName("rejects non-existent email") + void rejectsNonExistentEmail() { + given().contentType(ContentType.JSON) + .body(""" { "email": "nonexistent@example.com", "password": "Password123" } """) - .when() - .post("/api/auth/login") - .then() - .statusCode(401) - .body("message", equalTo("Invalid email or password")); + .when() + .post("/api/auth/login") + .then() + .statusCode(401) + .body("message", equalTo("Invalid email or password")); + } } - } - - @Nested - @DisplayName("Profile Operations") - class ProfileOperations { - - @Test - @DisplayName("updates profile successfully") - void updatesProfile() { - String accessToken = - registerAndGetToken("profile@example.com", "Password123", "Original Name"); - - given() - .header("Authorization", "Bearer " + accessToken) - .contentType(ContentType.JSON) - .body( - """ + + @Nested + @DisplayName("Profile Operations") + class ProfileOperations { + + @Test + @DisplayName("updates profile successfully") + void updatesProfile() { + String accessToken = registerAndGetToken("profile@example.com", "Password123", "Original Name"); + + given().header("Authorization", "Bearer " + accessToken) + .contentType(ContentType.JSON) + .body(""" { "name": "Updated Name" } """) - .when() - .patch("/api/users/me") - .then() - .statusCode(200) - .body("data.name", equalTo("Updated Name")); - } - - @Test - @DisplayName("changes password successfully") - void changesPassword() { - String accessToken = - registerAndGetToken("password@example.com", "OldPassword123", "Password User"); - - // Change password - given() - .header("Authorization", "Bearer " + accessToken) - .contentType(ContentType.JSON) - .body( - """ + .when() + .patch("/api/users/me") + .then() + .statusCode(200) + .body("data.name", equalTo("Updated Name")); + } + + @Test + @DisplayName("changes password successfully") + void changesPassword() { + String accessToken = registerAndGetToken("password@example.com", "OldPassword123", "Password User"); + + // Change password + given().header("Authorization", "Bearer " + accessToken) + .contentType(ContentType.JSON) + .body(""" { "currentPassword": "OldPassword123", "newPassword": "NewPassword123", "confirmPassword": "NewPassword123" } """) - .when() - .patch("/api/users/me/password") - .then() - .statusCode(200); - - // Login with new password works - given() - .contentType(ContentType.JSON) - .body( - """ + .when() + .patch("/api/users/me/password") + .then() + .statusCode(200); + + // Login with new password works + given().contentType(ContentType.JSON) + .body(""" { "email": "password@example.com", "password": "NewPassword123" } """) - .when() - .post("/api/auth/login") - .then() - .statusCode(200); - - // Login with old password fails - given() - .contentType(ContentType.JSON) - .body( - """ + .when() + .post("/api/auth/login") + .then() + .statusCode(200); + + // Login with old password fails + given().contentType(ContentType.JSON) + .body(""" { "email": "password@example.com", "password": "OldPassword123" } """) - .when() - .post("/api/auth/login") - .then() - .statusCode(401); - } - } - - @Nested - @DisplayName("Token Security") - class TokenSecurity { - - @Test - @DisplayName("rejects expired/invalid access token") - void rejectsInvalidToken() { - given() - .header("Authorization", "Bearer invalid.token.here") - .when() - .get("/api/users/me") - .then() - .statusCode(401); + .when() + .post("/api/auth/login") + .then() + .statusCode(401); + } } - @Test - @DisplayName("rejects request without token") - void rejectsNoToken() { - given().when().get("/api/users/me").then().statusCode(401); + @Nested + @DisplayName("Token Security") + class TokenSecurity { + + @Test + @DisplayName("rejects expired/invalid access token") + void rejectsInvalidToken() { + given().header("Authorization", "Bearer invalid.token.here") + .when() + .get("/api/users/me") + .then() + .statusCode(401); + } + + @Test + @DisplayName("rejects request without token") + void rejectsNoToken() { + given().when().get("/api/users/me").then().statusCode(401); + } } - } - - // Helper methods - private void registerUser(String email, String password, String name) { - given() - .contentType(ContentType.JSON) - .body( - """ + + // Helper methods + private void registerUser(String email, String password, String name) { + given().contentType(ContentType.JSON) + .body(""" { "email": "%s", "password": "%s", "name": "%s", "role": "MEMBER" } - """ - .formatted(email, password, name)) - .when() - .post("/api/auth/register") - .then() - .statusCode(anyOf(is(200), is(201))); - } - - private String registerAndGetToken(String email, String password, String name) { - Response response = - given() - .contentType(ContentType.JSON) - .body( - """ + """.formatted(email, password, name)) + .when() + .post("/api/auth/register") + .then() + .statusCode(anyOf(is(200), is(201))); + } + + private String registerAndGetToken(String email, String password, String name) { + Response response = given().contentType(ContentType.JSON) + .body(""" { "email": "%s", "password": "%s", "name": "%s", "role": "MEMBER" } - """ - .formatted(email, password, name)) - .when() - .post("/api/auth/register") - .then() - .statusCode(anyOf(is(200), is(201))) - .extract() - .response(); - - return response.jsonPath().getString("data.tokens.accessToken"); - } + """.formatted(email, password, name)) + .when() + .post("/api/auth/register") + .then() + .statusCode(anyOf(is(200), is(201))) + .extract() + .response(); + + return response.jsonPath().getString("data.tokens.accessToken"); + } } diff --git a/src/test/java/org/nkcoder/user/application/service/UserCommandServiceTest.java b/src/test/java/org/nkcoder/user/application/service/UserCommandServiceTest.java index a7b6087..9ce4132 100644 --- a/src/test/java/org/nkcoder/user/application/service/UserCommandServiceTest.java +++ b/src/test/java/org/nkcoder/user/application/service/UserCommandServiceTest.java @@ -39,236 +39,240 @@ @DisplayName("UserCommandService") class UserCommandServiceTest { - @Mock private UserRepository userRepository; - @Mock private AuthContextPort authContextPort; - @Mock private DomainEventPublisher eventPublisher; - - private UserCommandService userCommandService; - - @BeforeEach - void setUp() { - userCommandService = new UserCommandService(userRepository, authContextPort, eventPublisher); - } - - private User createTestUser(UUID userId, String email, String name) { - return User.reconstitute( - UserId.of(userId), - Email.of(email), - UserName.of(name), - UserRole.MEMBER, - false, - LocalDateTime.now(), - LocalDateTime.now(), - LocalDateTime.now()); - } - - @Nested - @DisplayName("updateProfile") - class UpdateProfile { - - @Test - @DisplayName("updates profile successfully") - void updatesProfileSuccessfully() { - UUID userId = UUID.randomUUID(); - User user = createTestUser(userId, "test@example.com", "Old Name"); - UpdateProfileCommand command = new UpdateProfileCommand(userId, "New Name"); - - given(userRepository.findById(any(UserId.class))).willReturn(Optional.of(user)); - given(userRepository.save(any(User.class))).willReturn(user); - - UserDto result = userCommandService.updateProfile(command); - - assertThat(result.name()).isEqualTo("New Name"); - verify(eventPublisher).publish(any(UserProfileUpdatedEvent.class)); - } + @Mock + private UserRepository userRepository; + + @Mock + private AuthContextPort authContextPort; + + @Mock + private DomainEventPublisher eventPublisher; - @Test - @DisplayName("throws ResourceNotFoundException when user not found") - void throwsWhenUserNotFound() { - UUID userId = UUID.randomUUID(); - UpdateProfileCommand command = new UpdateProfileCommand(userId, "New Name"); + private UserCommandService userCommandService; - given(userRepository.findById(any(UserId.class))).willReturn(Optional.empty()); + @BeforeEach + void setUp() { + userCommandService = new UserCommandService(userRepository, authContextPort, eventPublisher); + } - assertThatThrownBy(() -> userCommandService.updateProfile(command)) - .isInstanceOf(ResourceNotFoundException.class) - .hasMessageContaining("User not found"); + private User createTestUser(UUID userId, String email, String name) { + return User.reconstitute( + UserId.of(userId), + Email.of(email), + UserName.of(name), + UserRole.MEMBER, + false, + LocalDateTime.now(), + LocalDateTime.now(), + LocalDateTime.now()); } - } - @Nested - @DisplayName("changePassword") - class ChangePassword { + @Nested + @DisplayName("updateProfile") + class UpdateProfile { - @Test - @DisplayName("changes password successfully") - void changesPasswordSuccessfully() { - UUID userId = UUID.randomUUID(); - ChangePasswordCommand command = new ChangePasswordCommand(userId, "oldPass", "newPass"); + @Test + @DisplayName("updates profile successfully") + void updatesProfileSuccessfully() { + UUID userId = UUID.randomUUID(); + User user = createTestUser(userId, "test@example.com", "Old Name"); + UpdateProfileCommand command = new UpdateProfileCommand(userId, "New Name"); - given(userRepository.existsById(any(UserId.class))).willReturn(true); - given(authContextPort.verifyPassword(eq(userId), eq("oldPass"))).willReturn(true); + given(userRepository.findById(any(UserId.class))).willReturn(Optional.of(user)); + given(userRepository.save(any(User.class))).willReturn(user); - userCommandService.changePassword(command); + UserDto result = userCommandService.updateProfile(command); - verify(authContextPort).changePassword(eq(userId), eq("newPass")); - } + assertThat(result.name()).isEqualTo("New Name"); + verify(eventPublisher).publish(any(UserProfileUpdatedEvent.class)); + } - @Test - @DisplayName("throws ResourceNotFoundException when user not found") - void throwsWhenUserNotFound() { - UUID userId = UUID.randomUUID(); - ChangePasswordCommand command = new ChangePasswordCommand(userId, "oldPass", "newPass"); + @Test + @DisplayName("throws ResourceNotFoundException when user not found") + void throwsWhenUserNotFound() { + UUID userId = UUID.randomUUID(); + UpdateProfileCommand command = new UpdateProfileCommand(userId, "New Name"); - given(userRepository.existsById(any(UserId.class))).willReturn(false); + given(userRepository.findById(any(UserId.class))).willReturn(Optional.empty()); - assertThatThrownBy(() -> userCommandService.changePassword(command)) - .isInstanceOf(ResourceNotFoundException.class) - .hasMessageContaining("User not found"); + assertThatThrownBy(() -> userCommandService.updateProfile(command)) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("User not found"); + } } - @Test - @DisplayName("throws ValidationException when current password is incorrect") - void throwsWhenCurrentPasswordIncorrect() { - UUID userId = UUID.randomUUID(); - ChangePasswordCommand command = new ChangePasswordCommand(userId, "wrongPass", "newPass"); + @Nested + @DisplayName("changePassword") + class ChangePassword { - given(userRepository.existsById(any(UserId.class))).willReturn(true); - given(authContextPort.verifyPassword(eq(userId), eq("wrongPass"))).willReturn(false); + @Test + @DisplayName("changes password successfully") + void changesPasswordSuccessfully() { + UUID userId = UUID.randomUUID(); + ChangePasswordCommand command = new ChangePasswordCommand(userId, "oldPass", "newPass"); - assertThatThrownBy(() -> userCommandService.changePassword(command)) - .isInstanceOf(ValidationException.class) - .hasMessageContaining("Current password is incorrect"); + given(userRepository.existsById(any(UserId.class))).willReturn(true); + given(authContextPort.verifyPassword(eq(userId), eq("oldPass"))).willReturn(true); - verify(authContextPort, never()).changePassword(any(), any()); - } - } + userCommandService.changePassword(command); - @Nested - @DisplayName("adminUpdateUser") - class AdminUpdateUser { + verify(authContextPort).changePassword(eq(userId), eq("newPass")); + } - @Test - @DisplayName("updates user name successfully") - void updatesUserNameSuccessfully() { - UUID userId = UUID.randomUUID(); - User user = createTestUser(userId, "test@example.com", "Old Name"); - AdminUpdateUserCommand command = new AdminUpdateUserCommand(userId, "New Name", null); + @Test + @DisplayName("throws ResourceNotFoundException when user not found") + void throwsWhenUserNotFound() { + UUID userId = UUID.randomUUID(); + ChangePasswordCommand command = new ChangePasswordCommand(userId, "oldPass", "newPass"); - given(userRepository.findById(any(UserId.class))).willReturn(Optional.of(user)); - given(userRepository.save(any(User.class))).willReturn(user); + given(userRepository.existsById(any(UserId.class))).willReturn(false); - UserDto result = userCommandService.adminUpdateUser(command); + assertThatThrownBy(() -> userCommandService.changePassword(command)) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("User not found"); + } - assertThat(result.name()).isEqualTo("New Name"); + @Test + @DisplayName("throws ValidationException when current password is incorrect") + void throwsWhenCurrentPasswordIncorrect() { + UUID userId = UUID.randomUUID(); + ChangePasswordCommand command = new ChangePasswordCommand(userId, "wrongPass", "newPass"); + + given(userRepository.existsById(any(UserId.class))).willReturn(true); + given(authContextPort.verifyPassword(eq(userId), eq("wrongPass"))).willReturn(false); + + assertThatThrownBy(() -> userCommandService.changePassword(command)) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("Current password is incorrect"); + + verify(authContextPort, never()).changePassword(any(), any()); + } } - @Test - @DisplayName("updates user email successfully") - void updatesUserEmailSuccessfully() { - UUID userId = UUID.randomUUID(); - User user = createTestUser(userId, "old@example.com", "Test User"); - AdminUpdateUserCommand command = new AdminUpdateUserCommand(userId, null, "new@example.com"); + @Nested + @DisplayName("adminUpdateUser") + class AdminUpdateUser { - given(userRepository.findById(any(UserId.class))).willReturn(Optional.of(user)); - given(userRepository.existsByEmailExcludingId(any(Email.class), any(UserId.class))) - .willReturn(false); - given(userRepository.save(any(User.class))).willReturn(user); + @Test + @DisplayName("updates user name successfully") + void updatesUserNameSuccessfully() { + UUID userId = UUID.randomUUID(); + User user = createTestUser(userId, "test@example.com", "Old Name"); + AdminUpdateUserCommand command = new AdminUpdateUserCommand(userId, "New Name", null); - UserDto result = userCommandService.adminUpdateUser(command); + given(userRepository.findById(any(UserId.class))).willReturn(Optional.of(user)); + given(userRepository.save(any(User.class))).willReturn(user); - assertThat(result.email()).isEqualTo("new@example.com"); - } + UserDto result = userCommandService.adminUpdateUser(command); - @Test - @DisplayName("throws ValidationException when email already in use") - void throwsWhenEmailAlreadyInUse() { - UUID userId = UUID.randomUUID(); - User user = createTestUser(userId, "old@example.com", "Test User"); - AdminUpdateUserCommand command = - new AdminUpdateUserCommand(userId, null, "taken@example.com"); - - given(userRepository.findById(any(UserId.class))).willReturn(Optional.of(user)); - given(userRepository.existsByEmailExcludingId(any(Email.class), any(UserId.class))) - .willReturn(true); - - assertThatThrownBy(() -> userCommandService.adminUpdateUser(command)) - .isInstanceOf(ValidationException.class) - .hasMessageContaining("Email already in use"); - } + assertThat(result.name()).isEqualTo("New Name"); + } - @Test - @DisplayName("throws ResourceNotFoundException when user not found") - void throwsWhenUserNotFound() { - UUID userId = UUID.randomUUID(); - AdminUpdateUserCommand command = new AdminUpdateUserCommand(userId, "Name", null); + @Test + @DisplayName("updates user email successfully") + void updatesUserEmailSuccessfully() { + UUID userId = UUID.randomUUID(); + User user = createTestUser(userId, "old@example.com", "Test User"); + AdminUpdateUserCommand command = new AdminUpdateUserCommand(userId, null, "new@example.com"); - given(userRepository.findById(any(UserId.class))).willReturn(Optional.empty()); + given(userRepository.findById(any(UserId.class))).willReturn(Optional.of(user)); + given(userRepository.existsByEmailExcludingId(any(Email.class), any(UserId.class))) + .willReturn(false); + given(userRepository.save(any(User.class))).willReturn(user); - assertThatThrownBy(() -> userCommandService.adminUpdateUser(command)) - .isInstanceOf(ResourceNotFoundException.class) - .hasMessageContaining("User not found"); - } + UserDto result = userCommandService.adminUpdateUser(command); - @Test - @DisplayName("skips name update when name is blank") - void skipsNameUpdateWhenBlank() { - UUID userId = UUID.randomUUID(); - User user = createTestUser(userId, "test@example.com", "Original Name"); - AdminUpdateUserCommand command = new AdminUpdateUserCommand(userId, " ", null); + assertThat(result.email()).isEqualTo("new@example.com"); + } - given(userRepository.findById(any(UserId.class))).willReturn(Optional.of(user)); - given(userRepository.save(any(User.class))).willReturn(user); + @Test + @DisplayName("throws ValidationException when email already in use") + void throwsWhenEmailAlreadyInUse() { + UUID userId = UUID.randomUUID(); + User user = createTestUser(userId, "old@example.com", "Test User"); + AdminUpdateUserCommand command = new AdminUpdateUserCommand(userId, null, "taken@example.com"); - UserDto result = userCommandService.adminUpdateUser(command); + given(userRepository.findById(any(UserId.class))).willReturn(Optional.of(user)); + given(userRepository.existsByEmailExcludingId(any(Email.class), any(UserId.class))) + .willReturn(true); - assertThat(result.name()).isEqualTo("Original Name"); - } + assertThatThrownBy(() -> userCommandService.adminUpdateUser(command)) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("Email already in use"); + } - @Test - @DisplayName("skips email update when email is blank") - void skipsEmailUpdateWhenBlank() { - UUID userId = UUID.randomUUID(); - User user = createTestUser(userId, "original@example.com", "Test User"); - AdminUpdateUserCommand command = new AdminUpdateUserCommand(userId, null, " "); + @Test + @DisplayName("throws ResourceNotFoundException when user not found") + void throwsWhenUserNotFound() { + UUID userId = UUID.randomUUID(); + AdminUpdateUserCommand command = new AdminUpdateUserCommand(userId, "Name", null); - given(userRepository.findById(any(UserId.class))).willReturn(Optional.of(user)); - given(userRepository.save(any(User.class))).willReturn(user); + given(userRepository.findById(any(UserId.class))).willReturn(Optional.empty()); - UserDto result = userCommandService.adminUpdateUser(command); + assertThatThrownBy(() -> userCommandService.adminUpdateUser(command)) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("User not found"); + } - assertThat(result.email()).isEqualTo("original@example.com"); - } - } + @Test + @DisplayName("skips name update when name is blank") + void skipsNameUpdateWhenBlank() { + UUID userId = UUID.randomUUID(); + User user = createTestUser(userId, "test@example.com", "Original Name"); + AdminUpdateUserCommand command = new AdminUpdateUserCommand(userId, " ", null); + + given(userRepository.findById(any(UserId.class))).willReturn(Optional.of(user)); + given(userRepository.save(any(User.class))).willReturn(user); + + UserDto result = userCommandService.adminUpdateUser(command); - @Nested - @DisplayName("adminResetPassword") - class AdminResetPassword { + assertThat(result.name()).isEqualTo("Original Name"); + } - @Test - @DisplayName("resets password successfully") - void resetsPasswordSuccessfully() { - UUID userId = UUID.randomUUID(); - AdminResetPasswordCommand command = new AdminResetPasswordCommand(userId, "newPassword"); + @Test + @DisplayName("skips email update when email is blank") + void skipsEmailUpdateWhenBlank() { + UUID userId = UUID.randomUUID(); + User user = createTestUser(userId, "original@example.com", "Test User"); + AdminUpdateUserCommand command = new AdminUpdateUserCommand(userId, null, " "); - given(userRepository.existsById(any(UserId.class))).willReturn(true); + given(userRepository.findById(any(UserId.class))).willReturn(Optional.of(user)); + given(userRepository.save(any(User.class))).willReturn(user); - userCommandService.adminResetPassword(command); + UserDto result = userCommandService.adminUpdateUser(command); - verify(authContextPort).changePassword(eq(userId), eq("newPassword")); + assertThat(result.email()).isEqualTo("original@example.com"); + } } - @Test - @DisplayName("throws ResourceNotFoundException when user not found") - void throwsWhenUserNotFound() { - UUID userId = UUID.randomUUID(); - AdminResetPasswordCommand command = new AdminResetPasswordCommand(userId, "newPassword"); + @Nested + @DisplayName("adminResetPassword") + class AdminResetPassword { + + @Test + @DisplayName("resets password successfully") + void resetsPasswordSuccessfully() { + UUID userId = UUID.randomUUID(); + AdminResetPasswordCommand command = new AdminResetPasswordCommand(userId, "newPassword"); + + given(userRepository.existsById(any(UserId.class))).willReturn(true); + + userCommandService.adminResetPassword(command); + + verify(authContextPort).changePassword(eq(userId), eq("newPassword")); + } + + @Test + @DisplayName("throws ResourceNotFoundException when user not found") + void throwsWhenUserNotFound() { + UUID userId = UUID.randomUUID(); + AdminResetPasswordCommand command = new AdminResetPasswordCommand(userId, "newPassword"); - given(userRepository.existsById(any(UserId.class))).willReturn(false); + given(userRepository.existsById(any(UserId.class))).willReturn(false); - assertThatThrownBy(() -> userCommandService.adminResetPassword(command)) - .isInstanceOf(ResourceNotFoundException.class) - .hasMessageContaining("User not found"); + assertThatThrownBy(() -> userCommandService.adminResetPassword(command)) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("User not found"); + } } - } } diff --git a/src/test/java/org/nkcoder/user/application/service/UserQueryServiceTest.java b/src/test/java/org/nkcoder/user/application/service/UserQueryServiceTest.java index 7d84793..0a6d132 100644 --- a/src/test/java/org/nkcoder/user/application/service/UserQueryServiceTest.java +++ b/src/test/java/org/nkcoder/user/application/service/UserQueryServiceTest.java @@ -29,117 +29,116 @@ @DisplayName("UserQueryService") class UserQueryServiceTest { - @Mock private UserRepository userRepository; - - private UserQueryService userQueryService; - - @BeforeEach - void setUp() { - userQueryService = new UserQueryService(userRepository); - } - - private User createTestUser(UUID userId, String email, String name) { - return User.reconstitute( - UserId.of(userId), - Email.of(email), - UserName.of(name), - UserRole.MEMBER, - false, - LocalDateTime.now(), - LocalDateTime.now(), - LocalDateTime.now()); - } - - @Nested - @DisplayName("getUserById") - class GetUserById { - - @Test - @DisplayName("returns user when found") - void returnsUserWhenFound() { - UUID userId = UUID.randomUUID(); - User user = createTestUser(userId, "test@example.com", "Test User"); - - given(userRepository.findById(any(UserId.class))).willReturn(Optional.of(user)); - - UserDto result = userQueryService.getUserById(userId); - - assertThat(result.id()).isEqualTo(userId); - assertThat(result.email()).isEqualTo("test@example.com"); - assertThat(result.name()).isEqualTo("Test User"); - assertThat(result.role()).isEqualTo("MEMBER"); - } + @Mock + private UserRepository userRepository; - @Test - @DisplayName("throws ResourceNotFoundException when user not found") - void throwsWhenUserNotFound() { - UUID userId = UUID.randomUUID(); + private UserQueryService userQueryService; - given(userRepository.findById(any(UserId.class))).willReturn(Optional.empty()); + @BeforeEach + void setUp() { + userQueryService = new UserQueryService(userRepository); + } - assertThatThrownBy(() -> userQueryService.getUserById(userId)) - .isInstanceOf(ResourceNotFoundException.class) - .hasMessageContaining("User not found"); + private User createTestUser(UUID userId, String email, String name) { + return User.reconstitute( + UserId.of(userId), + Email.of(email), + UserName.of(name), + UserRole.MEMBER, + false, + LocalDateTime.now(), + LocalDateTime.now(), + LocalDateTime.now()); } - } - @Nested - @DisplayName("getAllUsers") - class GetAllUsers { + @Nested + @DisplayName("getUserById") + class GetUserById { - @Test - @DisplayName("returns all users") - void returnsAllUsers() { - User user1 = createTestUser(UUID.randomUUID(), "user1@example.com", "User One"); - User user2 = createTestUser(UUID.randomUUID(), "user2@example.com", "User Two"); + @Test + @DisplayName("returns user when found") + void returnsUserWhenFound() { + UUID userId = UUID.randomUUID(); + User user = createTestUser(userId, "test@example.com", "Test User"); - given(userRepository.findAll()).willReturn(List.of(user1, user2)); + given(userRepository.findById(any(UserId.class))).willReturn(Optional.of(user)); - List result = userQueryService.getAllUsers(); + UserDto result = userQueryService.getUserById(userId); - assertThat(result).hasSize(2); - assertThat(result) - .extracting(UserDto::email) - .containsExactly("user1@example.com", "user2@example.com"); - } + assertThat(result.id()).isEqualTo(userId); + assertThat(result.email()).isEqualTo("test@example.com"); + assertThat(result.name()).isEqualTo("Test User"); + assertThat(result.role()).isEqualTo("MEMBER"); + } - @Test - @DisplayName("returns empty list when no users") - void returnsEmptyListWhenNoUsers() { - given(userRepository.findAll()).willReturn(List.of()); + @Test + @DisplayName("throws ResourceNotFoundException when user not found") + void throwsWhenUserNotFound() { + UUID userId = UUID.randomUUID(); - List result = userQueryService.getAllUsers(); + given(userRepository.findById(any(UserId.class))).willReturn(Optional.empty()); - assertThat(result).isEmpty(); + assertThatThrownBy(() -> userQueryService.getUserById(userId)) + .isInstanceOf(ResourceNotFoundException.class) + .hasMessageContaining("User not found"); + } } - } - @Nested - @DisplayName("userExists") - class UserExists { + @Nested + @DisplayName("getAllUsers") + class GetAllUsers { + + @Test + @DisplayName("returns all users") + void returnsAllUsers() { + User user1 = createTestUser(UUID.randomUUID(), "user1@example.com", "User One"); + User user2 = createTestUser(UUID.randomUUID(), "user2@example.com", "User Two"); + + given(userRepository.findAll()).willReturn(List.of(user1, user2)); + + List result = userQueryService.getAllUsers(); - @Test - @DisplayName("returns true when user exists") - void returnsTrueWhenUserExists() { - UUID userId = UUID.randomUUID(); + assertThat(result).hasSize(2); + assertThat(result).extracting(UserDto::email).containsExactly("user1@example.com", "user2@example.com"); + } - given(userRepository.existsById(any(UserId.class))).willReturn(true); + @Test + @DisplayName("returns empty list when no users") + void returnsEmptyListWhenNoUsers() { + given(userRepository.findAll()).willReturn(List.of()); - boolean result = userQueryService.userExists(userId); + List result = userQueryService.getAllUsers(); - assertThat(result).isTrue(); + assertThat(result).isEmpty(); + } } - @Test - @DisplayName("returns false when user does not exist") - void returnsFalseWhenUserDoesNotExist() { - UUID userId = UUID.randomUUID(); + @Nested + @DisplayName("userExists") + class UserExists { + + @Test + @DisplayName("returns true when user exists") + void returnsTrueWhenUserExists() { + UUID userId = UUID.randomUUID(); + + given(userRepository.existsById(any(UserId.class))).willReturn(true); + + boolean result = userQueryService.userExists(userId); + + assertThat(result).isTrue(); + } + + @Test + @DisplayName("returns false when user does not exist") + void returnsFalseWhenUserDoesNotExist() { + UUID userId = UUID.randomUUID(); - given(userRepository.existsById(any(UserId.class))).willReturn(false); + given(userRepository.existsById(any(UserId.class))).willReturn(false); - boolean result = userQueryService.userExists(userId); + boolean result = userQueryService.userExists(userId); - assertThat(result).isFalse(); + assertThat(result).isFalse(); + } } - } }