From cd022b5417932908958d6e40017f23f9d174838f Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 27 May 2026 15:35:55 +0800 Subject: [PATCH 01/30] Refactor GlobalExceptionHandler to improve null exception handling and streamline error responses --- .../theurl/framework/configure/GlobalExceptionHandler.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/framework/src/main/java/io/theurl/framework/configure/GlobalExceptionHandler.java b/framework/src/main/java/io/theurl/framework/configure/GlobalExceptionHandler.java index 47db6e6..d1b277f 100644 --- a/framework/src/main/java/io/theurl/framework/configure/GlobalExceptionHandler.java +++ b/framework/src/main/java/io/theurl/framework/configure/GlobalExceptionHandler.java @@ -83,10 +83,11 @@ public ResponseEntity> handleCompletionException(CompletionE } private ResponseEntity> handleGeneralException(Throwable exception) { - if (exception == null) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); - } + return switch (exception) { + case null -> ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + case CompletionException completionException -> handleGeneralException(completionException.getCause()); + case AggregateException aggregateException -> handleGeneralException(aggregateException.getCause()); case AccountException accountException -> handleAccountException(accountException); case EntityNotFoundException entityNotFoundException -> handleEntityNotFoundException(entityNotFoundException); From 8da1938346917687bd16933804b880c03492a7b4 Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 27 May 2026 15:36:07 +0800 Subject: [PATCH 02/30] Add TokenMapProfile for mapping Token entity to domain model and update TokenDetail with status field --- .../persistence/model/TokenDetail.java | 2 +- .../persistence/profile/TokenMapProfile.java | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 identity/src/main/java/io/theurl/identity/persistence/profile/TokenMapProfile.java diff --git a/identity/src/main/java/io/theurl/identity/persistence/model/TokenDetail.java b/identity/src/main/java/io/theurl/identity/persistence/model/TokenDetail.java index 23e2641..4577afd 100644 --- a/identity/src/main/java/io/theurl/identity/persistence/model/TokenDetail.java +++ b/identity/src/main/java/io/theurl/identity/persistence/model/TokenDetail.java @@ -12,6 +12,6 @@ public class TokenDetail { private Long subject; private LocalDateTime issuedAt; private LocalDateTime expiresAt; - private LocalDateTime refreshAt; private LocalDateTime revokedAt; + private String status; } diff --git a/identity/src/main/java/io/theurl/identity/persistence/profile/TokenMapProfile.java b/identity/src/main/java/io/theurl/identity/persistence/profile/TokenMapProfile.java new file mode 100644 index 0000000..91a83a4 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/profile/TokenMapProfile.java @@ -0,0 +1,31 @@ +package io.theurl.identity.persistence.profile; + +import io.theurl.identity.domain.aggregate.Token; +import jakarta.annotation.PostConstruct; +import org.modelmapper.ModelMapper; +import org.modelmapper.Provider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class TokenMapProfile { + @Autowired + private ModelMapper mapper; + + @PostConstruct + public void configure() { + Provider tokenProvider = request -> { + var source = (io.theurl.identity.persistence.entity.Token) request.getSource(); + return new Token(source.getId(), source.getJti(), source.getContent(), source.getSubject()); + }; + + mapper.createTypeMap(io.theurl.identity.persistence.entity.Token.class, Token.class) + .setProvider(tokenProvider) + .addMappings(expression -> { + expression.map(io.theurl.identity.persistence.entity.Token::getExpiresAt, Token::setExpiresAt); + expression.map(io.theurl.identity.persistence.entity.Token::getIssuedAt, Token::setIssuedAt); + expression.map(io.theurl.identity.persistence.entity.Token::getRevokedAt, Token::setRevokedAt); + expression.map(io.theurl.identity.persistence.entity.Token::getStatus, Token::setStatus); + }); + } +} From 0064ab082ed909eccd05c6506adeb736318c0562 Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 27 May 2026 15:36:28 +0800 Subject: [PATCH 03/30] Update Token class to enforce future issuance date and add revokedAt and status fields --- .../io/theurl/identity/domain/aggregate/Token.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/identity/src/main/java/io/theurl/identity/domain/aggregate/Token.java b/identity/src/main/java/io/theurl/identity/domain/aggregate/Token.java index 9138c7b..35127e4 100644 --- a/identity/src/main/java/io/theurl/identity/domain/aggregate/Token.java +++ b/identity/src/main/java/io/theurl/identity/domain/aggregate/Token.java @@ -57,7 +57,7 @@ public LocalDateTime getRevokedAt() { } public void setIssuedAt(LocalDateTime issuedAt) { - if (issuedAt != null && issuedAt.isBefore(LocalDateTime.now())) { + if (issuedAt != null && issuedAt.isAfter(LocalDateTime.now())) { throw new IllegalArgumentException("issuedAt must be in the future"); } this.issuedAt = issuedAt; @@ -70,6 +70,14 @@ public void setExpiresAt(LocalDateTime expiresAt) { this.expiresAt = expiresAt; } + public void setRevokedAt(LocalDateTime revokedAt) { + this.revokedAt = revokedAt; + } + + public void setStatus(TokenStatus status) { + this.status = status; + } + public void revoke(TokenStatus reason) { this.revokedAt = LocalDateTime.now(); status = reason; From 4593a86b910373a1d9617f13a8df7baea3755d57 Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 27 May 2026 15:51:10 +0800 Subject: [PATCH 04/30] Refactor Token command handlers to use TokenStatus enum and improve error handling --- .../command/TokenRevokeCommand.java | 3 +- .../handler/AuthlogCreateCommandHandler.java | 21 +++++--- .../handler/TokenCreateCommandHandler.java | 32 ++++++++---- .../handler/TokenRevokeCommandHandler.java | 23 ++++++--- .../UserAccessFailureCountCommandHandler.java | 33 +++++++------ .../UserAuthorityCreateCommandHandler.java | 26 ++++++---- .../UserAuthorityRemoveCommandHandler.java | 25 ++++++---- .../handler/UserCreateCommandHandler.java | 28 ++++++----- .../UserPasswordChangeCommandHandler.java | 25 ++++++---- .../handler/UserUpdateCommandHandler.java | 39 +++++++++------ .../implement/AuthApplicationServiceImpl.java | 18 ++++--- .../subscriber/AuthlogEventSubscriber.java | 49 ++++++++++--------- .../subscriber/TokenEventSubscriber.java | 40 +++++++++------ .../subscriber/UserEventSubscriber.java | 43 ++++++++++------ 14 files changed, 249 insertions(+), 156 deletions(-) diff --git a/identity/src/main/java/io/theurl/identity/application/command/TokenRevokeCommand.java b/identity/src/main/java/io/theurl/identity/application/command/TokenRevokeCommand.java index b4238ce..305cf88 100644 --- a/identity/src/main/java/io/theurl/identity/application/command/TokenRevokeCommand.java +++ b/identity/src/main/java/io/theurl/identity/application/command/TokenRevokeCommand.java @@ -1,6 +1,7 @@ package io.theurl.identity.application.command; import com.neroyun.mediator.Command; +import io.theurl.identity.domain.enums.TokenStatus; -public record TokenRevokeCommand(String jti, String reason) implements Command { +public record TokenRevokeCommand(String jti, TokenStatus status) implements Command { } diff --git a/identity/src/main/java/io/theurl/identity/application/handler/AuthlogCreateCommandHandler.java b/identity/src/main/java/io/theurl/identity/application/handler/AuthlogCreateCommandHandler.java index 6c9e733..d3831ef 100644 --- a/identity/src/main/java/io/theurl/identity/application/handler/AuthlogCreateCommandHandler.java +++ b/identity/src/main/java/io/theurl/identity/application/handler/AuthlogCreateCommandHandler.java @@ -6,16 +6,17 @@ import io.theurl.identity.domain.repository.AuthlogRepository; import io.theurl.identity.domain.aggregate.Authlog; import org.modelmapper.ModelMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Scope; -import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.stereotype.Component; import java.util.concurrent.CompletableFuture; @Component -@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(BeanScope.PROTOTYPE) public class AuthlogCreateCommandHandler implements Handler { - + private static final Logger LOGGER = LoggerFactory.getLogger(AuthlogCreateCommandHandler.class); private final AuthlogRepository repository; private final ModelMapper mapper; @@ -26,10 +27,14 @@ public AuthlogCreateCommandHandler(AuthlogRepository repository, ModelMapper map @Override public CompletableFuture handleAsync(AuthlogCreateCommand message) { - - var authlog = Authlog.create(message.getRequestId(), message.getUsername(), message.isSuccess()); - mapper.map(message, authlog); - repository.save(authlog); - return CompletableFuture.completedFuture(null); + try { + var authlog = Authlog.create(message.getRequestId(), message.getUsername(), message.isSuccess()); + mapper.map(message, authlog); + repository.save(authlog); + return CompletableFuture.completedFuture(null); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); + throw exception; + } } } diff --git a/identity/src/main/java/io/theurl/identity/application/handler/TokenCreateCommandHandler.java b/identity/src/main/java/io/theurl/identity/application/handler/TokenCreateCommandHandler.java index 5d89019..19e121d 100644 --- a/identity/src/main/java/io/theurl/identity/application/handler/TokenCreateCommandHandler.java +++ b/identity/src/main/java/io/theurl/identity/application/handler/TokenCreateCommandHandler.java @@ -5,26 +5,38 @@ import io.theurl.identity.application.command.TokenCreateCommand; import io.theurl.identity.domain.aggregate.Token; import io.theurl.identity.domain.repository.TokenRepository; -import jakarta.annotation.Resource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Scope; -import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.stereotype.Component; import java.util.concurrent.CompletableFuture; @Component -@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(BeanScope.PROTOTYPE) public class TokenCreateCommandHandler implements Handler { - @Resource - private TokenRepository tokenRepository; + private static final Logger LOGGER = LoggerFactory.getLogger(TokenCreateCommandHandler.class); + + private final TokenRepository repository; + + public TokenCreateCommandHandler(TokenRepository repository) { + this.repository = repository; + } @Override public CompletableFuture handleAsync(TokenCreateCommand message) { - var token = Token.create(message.getJti(), message.getContent(), message.getSubject()); - token.setExpiresAt(message.getExpiresAt()); - token.setIssuedAt(message.getIssuedAt()); - tokenRepository.save(token); - return CompletableFuture.completedFuture(null); + try { + + + var token = Token.create(message.getJti(), message.getContent(), message.getSubject()); + token.setExpiresAt(message.getExpiresAt()); + token.setIssuedAt(message.getIssuedAt()); + repository.save(token); + return CompletableFuture.completedFuture(null); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); + throw exception; + } } } diff --git a/identity/src/main/java/io/theurl/identity/application/handler/TokenRevokeCommandHandler.java b/identity/src/main/java/io/theurl/identity/application/handler/TokenRevokeCommandHandler.java index 5b5351c..e1d0c66 100644 --- a/identity/src/main/java/io/theurl/identity/application/handler/TokenRevokeCommandHandler.java +++ b/identity/src/main/java/io/theurl/identity/application/handler/TokenRevokeCommandHandler.java @@ -3,17 +3,19 @@ import com.neroyun.mediator.Handler; import io.theurl.framework.core.BeanScope; import io.theurl.identity.application.command.TokenRevokeCommand; -import io.theurl.identity.domain.enums.TokenStatus; import io.theurl.identity.domain.repository.TokenRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Scope; -import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.stereotype.Component; import java.util.concurrent.CompletableFuture; @Component -@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(BeanScope.PROTOTYPE) public class TokenRevokeCommandHandler implements Handler { + private static final Logger LOGGER = LoggerFactory.getLogger(TokenRevokeCommandHandler.class); + private final TokenRepository repository; public TokenRevokeCommandHandler(TokenRepository repository) { @@ -22,11 +24,16 @@ public TokenRevokeCommandHandler(TokenRepository repository) { @Override public CompletableFuture handleAsync(TokenRevokeCommand message) { - var token = repository.findByJti(message.jti()); - if (token != null) { - token.revoke(TokenStatus.valueOf(message.reason().toLowerCase())); - repository.save(token); + try { + var token = repository.findByJti(message.jti()); + if (token != null) { + token.revoke(message.status()); + repository.save(token); + } + return CompletableFuture.completedFuture(null); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); + throw exception; } - return CompletableFuture.completedFuture(null); } } diff --git a/identity/src/main/java/io/theurl/identity/application/handler/UserAccessFailureCountCommandHandler.java b/identity/src/main/java/io/theurl/identity/application/handler/UserAccessFailureCountCommandHandler.java index 21ad01e..b198bf8 100644 --- a/identity/src/main/java/io/theurl/identity/application/handler/UserAccessFailureCountCommandHandler.java +++ b/identity/src/main/java/io/theurl/identity/application/handler/UserAccessFailureCountCommandHandler.java @@ -4,17 +4,17 @@ import io.theurl.framework.core.BeanScope; import io.theurl.identity.application.command.UserAccessFailureCountCommand; import io.theurl.identity.domain.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Scope; -import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.stereotype.Component; -import org.springframework.web.context.WebApplicationContext; import java.util.concurrent.CompletableFuture; @Component -@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(BeanScope.PROTOTYPE) public class UserAccessFailureCountCommandHandler implements Handler { - + private static final Logger LOGGER = LoggerFactory.getLogger(UserAccessFailureCountCommandHandler.class); private final UserRepository repository; public UserAccessFailureCountCommandHandler(UserRepository repository) { @@ -23,17 +23,22 @@ public UserAccessFailureCountCommandHandler(UserRepository repository) { @Override public CompletableFuture handleAsync(UserAccessFailureCountCommand message) { - var user = repository.findById(message.userId()); - if (user == null) { + try { + var user = repository.findById(message.userId()); + if (user == null) { + return CompletableFuture.completedFuture(null); + } + + switch (message.action()) { + case "increase" -> user.increaseAccessFailedCount(); + case "reset" -> user.resetAccessFailedCount(); + } + + repository.save(user); return CompletableFuture.completedFuture(null); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); + throw exception; } - - switch (message.action()) { - case "increase" -> user.increaseAccessFailedCount(); - case "reset" -> user.resetAccessFailedCount(); - } - - repository.save(user); - return CompletableFuture.completedFuture(null); } } diff --git a/identity/src/main/java/io/theurl/identity/application/handler/UserAuthorityCreateCommandHandler.java b/identity/src/main/java/io/theurl/identity/application/handler/UserAuthorityCreateCommandHandler.java index fe82790..3de988a 100644 --- a/identity/src/main/java/io/theurl/identity/application/handler/UserAuthorityCreateCommandHandler.java +++ b/identity/src/main/java/io/theurl/identity/application/handler/UserAuthorityCreateCommandHandler.java @@ -5,16 +5,17 @@ import io.theurl.identity.application.command.UserAuthorityCreateCommand; import io.theurl.identity.domain.repository.UserRepository; import jakarta.persistence.EntityNotFoundException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Scope; -import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.stereotype.Component; import java.util.concurrent.CompletableFuture; @Component -@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(BeanScope.PROTOTYPE) public class UserAuthorityCreateCommandHandler implements Handler { - + private static final Logger LOGGER = LoggerFactory.getLogger(UserAuthorityCreateCommandHandler.class); private final UserRepository repository; public UserAuthorityCreateCommandHandler(UserRepository repository) { @@ -23,13 +24,18 @@ public UserAuthorityCreateCommandHandler(UserRepository repository) { @Override public CompletableFuture handleAsync(UserAuthorityCreateCommand message) { - var user = repository.findById(message.id()); - if (user == null) { - throw new EntityNotFoundException("User not found"); - } + try { + var user = repository.findById(message.id()); + if (user == null) { + throw new EntityNotFoundException("User not found"); + } - user.addAuthority(message.provider(), message.openId(), message.name()); - repository.save(user); - return CompletableFuture.completedFuture(null); + user.addAuthority(message.provider(), message.openId(), message.name()); + repository.save(user); + return CompletableFuture.completedFuture(null); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); + throw exception; + } } } diff --git a/identity/src/main/java/io/theurl/identity/application/handler/UserAuthorityRemoveCommandHandler.java b/identity/src/main/java/io/theurl/identity/application/handler/UserAuthorityRemoveCommandHandler.java index 90e6af6..26e4e16 100644 --- a/identity/src/main/java/io/theurl/identity/application/handler/UserAuthorityRemoveCommandHandler.java +++ b/identity/src/main/java/io/theurl/identity/application/handler/UserAuthorityRemoveCommandHandler.java @@ -5,15 +5,17 @@ import io.theurl.identity.application.command.UserAuthorityRemoveCommand; import io.theurl.identity.domain.repository.UserRepository; import jakarta.persistence.EntityNotFoundException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Scope; -import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.stereotype.Component; import java.util.concurrent.CompletableFuture; @Component -@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(BeanScope.PROTOTYPE) public class UserAuthorityRemoveCommandHandler implements Handler { + private static final Logger LOGGER = LoggerFactory.getLogger(UserAuthorityRemoveCommandHandler.class); private final UserRepository repository; public UserAuthorityRemoveCommandHandler(UserRepository repository) { @@ -22,13 +24,18 @@ public UserAuthorityRemoveCommandHandler(UserRepository repository) { @Override public CompletableFuture handleAsync(UserAuthorityRemoveCommand message) { - var user = repository.findById(message.id()); - if (user == null) { - throw new EntityNotFoundException("User not found"); - } + try { + var user = repository.findById(message.id()); + if (user == null) { + throw new EntityNotFoundException("User not found"); + } - user.removeAuthority(message.provider(), message.openId()); - repository.save(user); - return CompletableFuture.completedFuture(null); + user.removeAuthority(message.provider(), message.openId()); + repository.save(user); + return CompletableFuture.completedFuture(null); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); + throw exception; + } } } diff --git a/identity/src/main/java/io/theurl/identity/application/handler/UserCreateCommandHandler.java b/identity/src/main/java/io/theurl/identity/application/handler/UserCreateCommandHandler.java index ffb9fb8..cca7ba9 100644 --- a/identity/src/main/java/io/theurl/identity/application/handler/UserCreateCommandHandler.java +++ b/identity/src/main/java/io/theurl/identity/application/handler/UserCreateCommandHandler.java @@ -5,18 +5,19 @@ import io.theurl.identity.application.command.UserCreateCommand; import io.theurl.identity.domain.aggregate.User; import io.theurl.identity.domain.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Scope; -import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.context.WebApplicationContext; import java.util.concurrent.CompletableFuture; @Component -@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(BeanScope.PROTOTYPE) public class UserCreateCommandHandler implements Handler { + private static final Logger LOGGER = LoggerFactory.getLogger(UserCreateCommandHandler.class); private final UserRepository repository; public UserCreateCommandHandler(UserRepository repository) { @@ -27,15 +28,20 @@ public UserCreateCommandHandler(UserRepository repository) { @Transactional @Override public CompletableFuture handleAsync(UserCreateCommand message) { - var exists = repository.findByAnyOf(message.getUsername(), message.getEmail(), message.getPhone()); + try { + var exists = repository.findByAnyOf(message.getUsername(), message.getEmail(), message.getPhone()); - if (exists != null) { - throw new IllegalArgumentException("User with the same username, email or phone already exists."); - } + if (exists != null) { + throw new IllegalArgumentException("User with the same username, email or phone already exists."); + } - var user = User.create(message.getUsername(), message.getNickname(), message.getEmail(), message.getPhone()); - user.setPassword(message.getPassword(), "init"); - repository.save(user); - return CompletableFuture.completedFuture(null); + var user = User.create(message.getUsername(), message.getNickname(), message.getEmail(), message.getPhone()); + user.setPassword(message.getPassword(), "init"); + repository.save(user); + return CompletableFuture.completedFuture(null); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); + throw exception; + } } } diff --git a/identity/src/main/java/io/theurl/identity/application/handler/UserPasswordChangeCommandHandler.java b/identity/src/main/java/io/theurl/identity/application/handler/UserPasswordChangeCommandHandler.java index 8571769..2967fd7 100644 --- a/identity/src/main/java/io/theurl/identity/application/handler/UserPasswordChangeCommandHandler.java +++ b/identity/src/main/java/io/theurl/identity/application/handler/UserPasswordChangeCommandHandler.java @@ -4,17 +4,17 @@ import io.theurl.framework.core.BeanScope; import io.theurl.identity.application.command.UserPasswordChangeCommand; import io.theurl.identity.domain.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Scope; -import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.stereotype.Component; -import org.springframework.web.context.WebApplicationContext; import java.util.concurrent.CompletableFuture; @Component -@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(BeanScope.PROTOTYPE) public class UserPasswordChangeCommandHandler implements Handler { - + private static final Logger LOGGER = LoggerFactory.getLogger(UserPasswordChangeCommandHandler.class); private final UserRepository repository; public UserPasswordChangeCommandHandler(UserRepository repository) { @@ -23,13 +23,18 @@ public UserPasswordChangeCommandHandler(UserRepository repository) { @Override public CompletableFuture handleAsync(UserPasswordChangeCommand message) { - var user = repository.findById(message.userId()); - if (user == null) { + try { + var user = repository.findById(message.userId()); + if (user == null) { + return CompletableFuture.completedFuture(null); + } + + user.setPassword(message.password(), message.changeType()); + repository.save(user); return CompletableFuture.completedFuture(null); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); + throw exception; } - - user.setPassword(message.password(), message.changeType()); - repository.save(user); - return CompletableFuture.completedFuture(null); } } diff --git a/identity/src/main/java/io/theurl/identity/application/handler/UserUpdateCommandHandler.java b/identity/src/main/java/io/theurl/identity/application/handler/UserUpdateCommandHandler.java index 3ad2c93..4e6c5c5 100644 --- a/identity/src/main/java/io/theurl/identity/application/handler/UserUpdateCommandHandler.java +++ b/identity/src/main/java/io/theurl/identity/application/handler/UserUpdateCommandHandler.java @@ -5,15 +5,17 @@ import io.theurl.identity.application.command.UserUpdateCommand; import io.theurl.identity.domain.repository.UserRepository; import jakarta.persistence.EntityNotFoundException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Scope; -import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.stereotype.Component; import java.util.concurrent.CompletableFuture; @Component -@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(BeanScope.PROTOTYPE) public class UserUpdateCommandHandler implements Handler { + private static final Logger LOGGER = LoggerFactory.getLogger(UserUpdateCommandHandler.class); private final UserRepository repository; public UserUpdateCommandHandler(UserRepository repository) { @@ -22,21 +24,26 @@ public UserUpdateCommandHandler(UserRepository repository) { @Override public CompletableFuture handleAsync(UserUpdateCommand message) { - var user = repository.findById(message.getId()); + try { + var user = repository.findById(message.getId()); - if (user == null) { - throw new EntityNotFoundException("User with ID " + message.getId() + " not found"); - } - - message.getModifications().forEach((key, value) -> { - switch (key) { - case "email" -> user.setEmail((String) value); - case "phone" -> user.setPhone((String) value); - case "nickname" -> user.setNickname((String) value); - default -> throw new IllegalArgumentException("Unsupported modification key: " + key); + if (user == null) { + throw new EntityNotFoundException("User with ID " + message.getId() + " not found"); } - }); - repository.save(user); - return CompletableFuture.completedFuture(null); + + message.getModifications().forEach((key, value) -> { + switch (key) { + case "email" -> user.setEmail((String) value); + case "phone" -> user.setPhone((String) value); + case "nickname" -> user.setNickname((String) value); + default -> throw new IllegalArgumentException("Unsupported modification key: " + key); + } + }); + repository.save(user); + return CompletableFuture.completedFuture(null); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); + throw exception; + } } } diff --git a/identity/src/main/java/io/theurl/identity/application/implement/AuthApplicationServiceImpl.java b/identity/src/main/java/io/theurl/identity/application/implement/AuthApplicationServiceImpl.java index 7f73c50..eba35f1 100644 --- a/identity/src/main/java/io/theurl/identity/application/implement/AuthApplicationServiceImpl.java +++ b/identity/src/main/java/io/theurl/identity/application/implement/AuthApplicationServiceImpl.java @@ -15,6 +15,7 @@ import io.theurl.identity.application.event.TokenRefreshedEvent; import io.theurl.identity.application.event.UserAuthFailureEvent; import io.theurl.identity.application.event.UserAuthSuccessEvent; +import io.theurl.identity.domain.enums.TokenStatus; import io.theurl.identity.external.ExternalAuthProvider; import io.theurl.identity.external.ExternalAuthResult; import io.theurl.identity.application.dto.TokenGrantRequestDto; @@ -60,12 +61,7 @@ public CompletableFuture grant(TokenGrantRequestDto reque var events = new ArrayList(); try { UserAuthInfoQuery query = switch (request.grantType().toLowerCase()) { - case "password" -> { - if (request.username() == null || request.username().isEmpty()) { - throw new IllegalArgumentException("Username is required for username grant type"); - } - yield new UserAuthInfoQuery("username", request.username()); - } + case "password" -> new UserAuthInfoQuery("username", request.username()); case "email", "phone" -> // For email and phone grant types, we should check OTP or other verification methods before querying user info. checkCodeAsync(request).thenApply(_ -> new UserAuthInfoQuery(request.grantType(), request.username())).join(); @@ -212,6 +208,16 @@ public Map getDetails() { } }; } + + if (TokenStatus.valueOf(tokenDetail.getStatus()) == TokenStatus.REFRESHED) { + throw new CredentialNotFoundException(null, "Refresh token has been revoked.") { + @Override + public Map getDetails() { + return Map.of("jti", jti); + } + }; + } + return tokenDetail.getSubject(); }); } diff --git a/identity/src/main/java/io/theurl/identity/application/subscriber/AuthlogEventSubscriber.java b/identity/src/main/java/io/theurl/identity/application/subscriber/AuthlogEventSubscriber.java index f2bba1c..3ee9c5d 100644 --- a/identity/src/main/java/io/theurl/identity/application/subscriber/AuthlogEventSubscriber.java +++ b/identity/src/main/java/io/theurl/identity/application/subscriber/AuthlogEventSubscriber.java @@ -9,7 +9,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Scope; -import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @@ -17,7 +16,7 @@ import org.springframework.web.context.request.ServletRequestAttributes; @Component -@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(BeanScope.PROTOTYPE) public class AuthlogEventSubscriber { private final Logger LOGGER = LoggerFactory.getLogger(AuthlogEventSubscriber.class); @@ -28,7 +27,7 @@ public AuthlogEventSubscriber(Mediator mediator) { this.mediator = mediator; } - @Async("taskExecutor") + @Async() @EventListener public void handleUserAuthSuccess(UserAuthSuccessEvent event) { try { @@ -60,26 +59,32 @@ public void handleUserAuthSuccess(UserAuthSuccessEvent event) { @Async @EventListener public void handleUserAuthFailure(UserAuthFailureEvent event) { - var request = getRequest(); - var command = new AuthlogCreateCommand(); - command.setUsername(event.getUsername()); - command.setUserId(event.getUserId()); - command.setGrantType(event.getGrantType()); - if (request != null) { - command.setRequestId(request.getRequestId()); - command.setIpAddress(request.getRemoteAddr()); - command.setUserAgent(request.getHeader("User-Agent")); - command.setReferrer(request.getHeader("Referer")); - command.setAppName(request.getHeader("X-App-Name")); - command.setAppVersion(request.getHeader("X-App-Version")); - command.setOsPlatform(request.getHeader("X-OS-Platform")); - command.setSource(request.getHeader("X-Source")); + try { + + + var request = getRequest(); + var command = new AuthlogCreateCommand(); + command.setUsername(event.getUsername()); + command.setUserId(event.getUserId()); + command.setGrantType(event.getGrantType()); + if (request != null) { + command.setRequestId(request.getRequestId()); + command.setIpAddress(request.getRemoteAddr()); + command.setUserAgent(request.getHeader("User-Agent")); + command.setReferrer(request.getHeader("Referer")); + command.setAppName(request.getHeader("X-App-Name")); + command.setAppVersion(request.getHeader("X-App-Version")); + command.setOsPlatform(request.getHeader("X-OS-Platform")); + command.setSource(request.getHeader("X-Source")); + } + command.setSuccess(false); + command.setRemark(event.getError()); + command.setTimestamp(event.getGrantTime()); + mediator.sendAsync(command) + .join(); + } catch (Exception exception) { + LOGGER.error("Failed to log authentication failure event for user: {}, error: {}", event.getUsername(), exception.getMessage(), exception); } - command.setSuccess(false); - command.setRemark(event.getError()); - command.setTimestamp(event.getGrantTime()); - mediator.sendAsync(command) - .join(); } private HttpServletRequest getRequest() { diff --git a/identity/src/main/java/io/theurl/identity/application/subscriber/TokenEventSubscriber.java b/identity/src/main/java/io/theurl/identity/application/subscriber/TokenEventSubscriber.java index 06a5d23..305e8bb 100644 --- a/identity/src/main/java/io/theurl/identity/application/subscriber/TokenEventSubscriber.java +++ b/identity/src/main/java/io/theurl/identity/application/subscriber/TokenEventSubscriber.java @@ -6,15 +6,18 @@ import io.theurl.identity.application.command.TokenRevokeCommand; import io.theurl.identity.application.event.TokenGrantedEvent; import io.theurl.identity.application.event.TokenRefreshedEvent; +import io.theurl.identity.domain.enums.TokenStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Scope; -import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @Component -@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(BeanScope.PROTOTYPE) public class TokenEventSubscriber { + private static final Logger LOGGER = LoggerFactory.getLogger(TokenEventSubscriber.class); private final Mediator mediator; public TokenEventSubscriber(Mediator mediator) { @@ -24,23 +27,30 @@ public TokenEventSubscriber(Mediator mediator) { @Async @EventListener public void handleUserAuthSucceedEvent(TokenGrantedEvent event) { - var command = new TokenCreateCommand() {{ - setJti(event.getJti()); - setContent(event.getContent()); - setSubject(event.getUserId()); - setExpiresAt(event.getExpiresAt()); - setIssuedAt(event.getIssuedAt()); - }}; - - mediator.sendAsync(command) - .join(); + try { + var command = new TokenCreateCommand() {{ + setJti(event.getJti()); + setContent(event.getContent()); + setSubject(event.getUserId()); + setExpiresAt(event.getExpiresAt()); + setIssuedAt(event.getIssuedAt()); + }}; + mediator.sendAsync(command) + .join(); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); + } } @Async @EventListener public void handleTokenRefreshedEvent(TokenRefreshedEvent event) { - var command = new TokenRevokeCommand(event.getJti(), "refreshed"); - mediator.sendAsync(command) - .join(); + try { + var command = new TokenRevokeCommand(event.getJti(), TokenStatus.REFRESHED); + mediator.sendAsync(command) + .join(); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); + } } } diff --git a/identity/src/main/java/io/theurl/identity/application/subscriber/UserEventSubscriber.java b/identity/src/main/java/io/theurl/identity/application/subscriber/UserEventSubscriber.java index 7f4cc1c..1ce45bc 100644 --- a/identity/src/main/java/io/theurl/identity/application/subscriber/UserEventSubscriber.java +++ b/identity/src/main/java/io/theurl/identity/application/subscriber/UserEventSubscriber.java @@ -5,38 +5,49 @@ import io.theurl.identity.application.command.UserAccessFailureCountCommand; import io.theurl.identity.application.event.UserAuthFailureEvent; import io.theurl.identity.application.event.UserAuthSuccessEvent; -import lombok.AllArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Scope; -import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @Component -@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) -@AllArgsConstructor +@Scope(BeanScope.PROTOTYPE) public class UserEventSubscriber { - + private final Logger LOGGER = LoggerFactory.getLogger(UserEventSubscriber.class); private final Mediator mediator; + public UserEventSubscriber(Mediator mediator) { + this.mediator = mediator; + } + @Async @EventListener - public void listen(UserAuthFailureEvent event) { - if (event.getUserId() == null || event.getUserId() <= 0) { - return; - } + public void handleUserAuthFailureEvent(UserAuthFailureEvent event) { + try { + if (event.getUserId() == null || event.getUserId() <= 0) { + return; + } - mediator.sendAsync(new UserAccessFailureCountCommand(event.getUserId(), "increase")) - .join(); + mediator.sendAsync(new UserAccessFailureCountCommand(event.getUserId(), "increase")) + .join(); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); + } } @Async @EventListener - public void listen(UserAuthSuccessEvent event) { - if (event.getUserId() == null || event.getUserId() <= 0) { - return; + public void handleUserAuthSuccessEvent(UserAuthSuccessEvent event) { + try { + if (event.getUserId() == null || event.getUserId() <= 0) { + return; + } + mediator.sendAsync(new UserAccessFailureCountCommand(event.getUserId(), "reset")) + .join(); + } catch (Exception exception) { + LOGGER.error(exception.getLocalizedMessage(), exception); } - mediator.sendAsync(new UserAccessFailureCountCommand(event.getUserId(), "reset")) - .join(); } } From e538130a1309c86c7e9ab1398f5928a7db737934 Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 27 May 2026 17:26:48 +0800 Subject: [PATCH 05/30] Enhance PriorityValueFinder with additional find methods for Supplier and Consumer --- .../framework/core/PriorityValueFinder.java | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/framework/src/main/java/io/theurl/framework/core/PriorityValueFinder.java b/framework/src/main/java/io/theurl/framework/core/PriorityValueFinder.java index e1268a3..1f61400 100644 --- a/framework/src/main/java/io/theurl/framework/core/PriorityValueFinder.java +++ b/framework/src/main/java/io/theurl/framework/core/PriorityValueFinder.java @@ -1,7 +1,9 @@ package io.theurl.framework.core; import java.util.PriorityQueue; +import java.util.function.Consumer; import java.util.function.Predicate; +import java.util.function.Supplier; /** * Utility class for finding a value in a priority queue based on a filter predicate. @@ -22,7 +24,7 @@ public class PriorityValueFinder { */ public static T find(PriorityQueue values, Predicate filter, T defaultValue) { - if (values == null || values.isEmpty()) { + if (values == null) { throw new IllegalArgumentException("Values queue cannot be null or empty"); } @@ -30,6 +32,10 @@ public static T find(PriorityQueue values, Predicate filter, T default throw new IllegalArgumentException("Filter queue cannot be null"); } + if (values.isEmpty()) { + return defaultValue; + } + while (!values.isEmpty()) { T value = values.poll(); if (filter.test(value)) { @@ -38,4 +44,19 @@ public static T find(PriorityQueue values, Predicate filter, T default } return defaultValue; } + + public static T find(Supplier> supplier, Predicate filter, T defaultValue) { + if (supplier == null) { + throw new IllegalArgumentException("Supplier cannot be null"); + } + + PriorityQueue values = supplier.get(); + return find(values, filter, defaultValue); + } + + public static T find(Consumer> queueConsumer, Predicate filter, T defaultValue) { + var queue = new PriorityQueue(); + queueConsumer.accept(queue); + return find(queue, filter, defaultValue); + } } From 0c2126f44c6f3edbf48c82c5640c524275854976 Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 27 May 2026 17:27:05 +0800 Subject: [PATCH 06/30] Enhance AuthlogEventSubscriber to improve IP address retrieval and OS platform resolution --- .../implement/AuthApplicationServiceImpl.java | 1 + .../subscriber/AuthlogEventSubscriber.java | 61 ++++++++++++++++--- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/identity/src/main/java/io/theurl/identity/application/implement/AuthApplicationServiceImpl.java b/identity/src/main/java/io/theurl/identity/application/implement/AuthApplicationServiceImpl.java index eba35f1..da78b38 100644 --- a/identity/src/main/java/io/theurl/identity/application/implement/AuthApplicationServiceImpl.java +++ b/identity/src/main/java/io/theurl/identity/application/implement/AuthApplicationServiceImpl.java @@ -160,6 +160,7 @@ public Map getDetails() { break; } }); + events.add(event); log.error("Error while processing request", e); throw new AggregateException(List.of(e)); } finally { diff --git a/identity/src/main/java/io/theurl/identity/application/subscriber/AuthlogEventSubscriber.java b/identity/src/main/java/io/theurl/identity/application/subscriber/AuthlogEventSubscriber.java index 3ee9c5d..06fd39a 100644 --- a/identity/src/main/java/io/theurl/identity/application/subscriber/AuthlogEventSubscriber.java +++ b/identity/src/main/java/io/theurl/identity/application/subscriber/AuthlogEventSubscriber.java @@ -2,6 +2,7 @@ import com.neroyun.mediator.Mediator; import io.theurl.framework.core.BeanScope; +import io.theurl.framework.core.PriorityValueFinder; import io.theurl.identity.application.command.AuthlogCreateCommand; import io.theurl.identity.application.event.UserAuthFailureEvent; import io.theurl.identity.application.event.UserAuthSuccessEvent; @@ -19,6 +20,14 @@ @Scope(BeanScope.PROTOTYPE) public class AuthlogEventSubscriber { + private static final String[] ipHeaders = { + "X-Forwarded-For", + "Proxy-Client-IP", + "WL-Proxy-Client-IP", + "HTTP_CLIENT_IP", + "HTTP_X_FORWARDED_FOR" + }; + private final Logger LOGGER = LoggerFactory.getLogger(AuthlogEventSubscriber.class); private final Mediator mediator; @@ -38,13 +47,13 @@ public void handleUserAuthSuccess(UserAuthSuccessEvent event) { command.setUserId(event.getUserId()); command.setGrantType(event.getGrantType()); if (request != null) { - command.setRequestId(request.getRequestId()); + command.setRequestId(request.getSession().getId()); command.setIpAddress(request.getRemoteAddr()); command.setUserAgent(request.getHeader("User-Agent")); command.setReferrer(request.getHeader("Referer")); command.setAppName(request.getHeader("X-App-Name")); command.setAppVersion(request.getHeader("X-App-Version")); - command.setOsPlatform(request.getHeader("X-OS-Platform")); + command.setOsPlatform(resolveOsPlatform(request.getHeader("User-Agent"))); command.setSource(request.getHeader("X-Source")); } command.setSuccess(true); @@ -60,21 +69,20 @@ public void handleUserAuthSuccess(UserAuthSuccessEvent event) { @EventListener public void handleUserAuthFailure(UserAuthFailureEvent event) { try { - - var request = getRequest(); + var command = new AuthlogCreateCommand(); command.setUsername(event.getUsername()); command.setUserId(event.getUserId()); command.setGrantType(event.getGrantType()); if (request != null) { - command.setRequestId(request.getRequestId()); - command.setIpAddress(request.getRemoteAddr()); + command.setRequestId(request.getSession().getId()); + command.setIpAddress(getClientIp(request)); command.setUserAgent(request.getHeader("User-Agent")); command.setReferrer(request.getHeader("Referer")); command.setAppName(request.getHeader("X-App-Name")); command.setAppVersion(request.getHeader("X-App-Version")); - command.setOsPlatform(request.getHeader("X-OS-Platform")); + command.setOsPlatform(resolveOsPlatform(request.getHeader("User-Agent"))); command.setSource(request.getHeader("X-Source")); } command.setSuccess(false); @@ -87,6 +95,33 @@ public void handleUserAuthFailure(UserAuthFailureEvent event) { } } + private static String resolveOsPlatform(String userAgent) { + // Implement OS platform resolution logic based on the user agent string + + try { + var os = userAgent.replaceAll(".*(?(?:Windows NT|Mac OS X|Android|iPhone OS|iPad OS|Linux|Ubuntu)[^;)]*).*", "$1"); + if (os.contains("Windows")) { + return os.replace("Windows NT", "Windows"); + } + if (os.contains("Mac OS X")) { + return "macOS"; + } + if (os.contains("Android")) { + return "Android"; + } + if (os.contains("iPad OS") || os.contains("iPhone OS")) { + return "iOS"; + } + if (os.contains("Linux")) { + return "Linux"; + } + + return os; + } catch (Exception exception) { + return "Unknown"; + } + } + private HttpServletRequest getRequest() { var request = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); @@ -96,4 +131,16 @@ private HttpServletRequest getRequest() { return request.getRequest(); } + + public static String getClientIp(HttpServletRequest request) { + return PriorityValueFinder.find(queue -> { + for (String header : ipHeaders) { + var value = request.getHeader(header); + if (value == null) { + continue; + } + queue.offer(value); + } + }, value -> !value.isEmpty(), "127.0.0.1"); + } } From 428b807fe944a0651437534c5354107018befa7a Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 27 May 2026 17:59:36 +0800 Subject: [PATCH 07/30] Enhance authentication and user services with additional methods for token revocation and improved command definitions --- .../command/AuthlogCreateCommand.java | 6 ++- .../UserAccessFailureCountCommand.java | 1 - .../command/UserCreateCommand.java | 2 +- .../command/UserUpdateCommand.java | 4 +- .../contract/AuthApplicationService.java | 17 ++++++ .../contract/UserApplicationService.java | 19 +++++++ .../implement/AuthApplicationServiceImpl.java | 54 ++++++++++++++++--- .../interfaces/controller/AuthController.java | 17 +++++- 8 files changed, 106 insertions(+), 14 deletions(-) diff --git a/identity/src/main/java/io/theurl/identity/application/command/AuthlogCreateCommand.java b/identity/src/main/java/io/theurl/identity/application/command/AuthlogCreateCommand.java index 3df3c09..284ed56 100644 --- a/identity/src/main/java/io/theurl/identity/application/command/AuthlogCreateCommand.java +++ b/identity/src/main/java/io/theurl/identity/application/command/AuthlogCreateCommand.java @@ -5,8 +5,12 @@ import java.time.LocalDateTime; +/** + * Command to create an authentication log entry, capturing details of an authentication attempt. + * This command is typically used after an authentication attempt (successful or failed) to record the event in the system for auditing and monitoring purposes. + */ @Data -public final class AuthlogCreateCommand implements Command { +public class AuthlogCreateCommand implements Command { private Long userId; private String username; private String grantType; diff --git a/identity/src/main/java/io/theurl/identity/application/command/UserAccessFailureCountCommand.java b/identity/src/main/java/io/theurl/identity/application/command/UserAccessFailureCountCommand.java index 15f17a5..73b7007 100644 --- a/identity/src/main/java/io/theurl/identity/application/command/UserAccessFailureCountCommand.java +++ b/identity/src/main/java/io/theurl/identity/application/command/UserAccessFailureCountCommand.java @@ -8,5 +8,4 @@ * The command contains the user ID and the new failure count to be set. */ public record UserAccessFailureCountCommand(Long userId, String action) implements Command { - } diff --git a/identity/src/main/java/io/theurl/identity/application/command/UserCreateCommand.java b/identity/src/main/java/io/theurl/identity/application/command/UserCreateCommand.java index ea6b9aa..d48761a 100644 --- a/identity/src/main/java/io/theurl/identity/application/command/UserCreateCommand.java +++ b/identity/src/main/java/io/theurl/identity/application/command/UserCreateCommand.java @@ -4,7 +4,7 @@ import lombok.Data; @Data -public class UserCreateCommand implements Command { +public final class UserCreateCommand implements Command { private String username; private String password; private String nickname; diff --git a/identity/src/main/java/io/theurl/identity/application/command/UserUpdateCommand.java b/identity/src/main/java/io/theurl/identity/application/command/UserUpdateCommand.java index d65aa82..e6783a3 100644 --- a/identity/src/main/java/io/theurl/identity/application/command/UserUpdateCommand.java +++ b/identity/src/main/java/io/theurl/identity/application/command/UserUpdateCommand.java @@ -6,7 +6,7 @@ import java.util.HashMap; import java.util.Map; -public class UserUpdateCommand implements Command { +public final class UserUpdateCommand implements Command { @Getter private final Long id; @@ -16,6 +16,4 @@ public class UserUpdateCommand implements Command { public UserUpdateCommand(Long id) { this.id = id; } - - } diff --git a/identity/src/main/java/io/theurl/identity/application/contract/AuthApplicationService.java b/identity/src/main/java/io/theurl/identity/application/contract/AuthApplicationService.java index 562a723..4dcaabc 100644 --- a/identity/src/main/java/io/theurl/identity/application/contract/AuthApplicationService.java +++ b/identity/src/main/java/io/theurl/identity/application/contract/AuthApplicationService.java @@ -5,6 +5,23 @@ import java.util.concurrent.CompletableFuture; +/** + * AuthApplicationService defines the contract for authentication-related operations in the application layer. It provides a method to handle token granting requests, which typically involve validating user credentials and issuing access tokens for authenticated users. + */ public interface AuthApplicationService { + /** + * Handle a token granting request asynchronously. + * + * @param request The token grant request data. + * @return A CompletableFuture containing the token grant response data. + */ CompletableFuture grant(TokenGrantRequestDto request); + + /** + * Revoke a token asynchronously. + * + * @param jti The token identifier to revoke. + * @return A CompletableFuture representing the asynchronous operation. + */ + CompletableFuture revoke(String jti); } diff --git a/identity/src/main/java/io/theurl/identity/application/contract/UserApplicationService.java b/identity/src/main/java/io/theurl/identity/application/contract/UserApplicationService.java index 6a9f57e..4411156 100644 --- a/identity/src/main/java/io/theurl/identity/application/contract/UserApplicationService.java +++ b/identity/src/main/java/io/theurl/identity/application/contract/UserApplicationService.java @@ -6,6 +6,11 @@ import java.util.concurrent.CompletableFuture; +/** + * UserApplicationService defines the contract for user-related operations in the application layer. + * It provides asynchronous methods for creating users, retrieving user profiles, and updating user information such as password, email, phone number, and nickname. + * Additionally, it includes methods for connecting and removing authorities associated with the user. + */ public interface UserApplicationService extends ApplicationService { /** @@ -56,7 +61,21 @@ public interface UserApplicationService extends ApplicationService { */ CompletableFuture changeNicknameAsync(String nickname); + /** + * Connect an authority to the currently authenticated user asynchronously. + * + * @param provider The authority provider to connect. + * @param code The authorization code for the authority. + * @return A CompletableFuture representing the asynchronous operation. + */ CompletableFuture connectAuthorityAsync(String provider, String code); + /** + * Remove an authority from the currently authenticated user asynchronously. + * + * @param provider The authority provider to remove. + * @param openId The open ID of the authority to remove. + * @return A CompletableFuture representing the asynchronous operation. + */ CompletableFuture removeAuthorityAsync(String provider, String openId); } diff --git a/identity/src/main/java/io/theurl/identity/application/implement/AuthApplicationServiceImpl.java b/identity/src/main/java/io/theurl/identity/application/implement/AuthApplicationServiceImpl.java index da78b38..31146b5 100644 --- a/identity/src/main/java/io/theurl/identity/application/implement/AuthApplicationServiceImpl.java +++ b/identity/src/main/java/io/theurl/identity/application/implement/AuthApplicationServiceImpl.java @@ -10,6 +10,7 @@ import io.theurl.framework.utility.Cryptography; import io.theurl.framework.utility.DateTimeUtility; import io.theurl.framework.utility.RegexUtility; +import io.theurl.identity.application.command.TokenRevokeCommand; import io.theurl.identity.application.contract.AuthApplicationService; import io.theurl.identity.application.event.TokenGrantedEvent; import io.theurl.identity.application.event.TokenRefreshedEvent; @@ -170,6 +171,19 @@ public Map getDetails() { } } + @Override + public CompletableFuture revoke(String jti) { + var command = new TokenRevokeCommand(jti, TokenStatus.LOGOUT); + return mediator.sendAsync(command); + } + + /** + * Check the one-time password (OTP) for phone or email verification. + * The method retrieves the OTP details using the request ID and validates the OTP against the provided code, recipient, and expiration time. If any validation fails, a CredentialIncorrectException is thrown with an appropriate message. + * + * @param request The token grant request containing the OTP details. + * @return A CompletableFuture that completes when the OTP is successfully validated. + */ CompletableFuture checkCodeAsync(TokenGrantRequestDto request) { return mediator.executeAsync(new OnetimePasswordDetailQuery(request.requestId())).thenAccept(otp -> { if (otp == null) { @@ -190,6 +204,14 @@ CompletableFuture checkCodeAsync(TokenGrantRequestDto request) { }); } + /** + * Authenticate the user with an external provider based on the grant type and username (which could be an OAuth code or other identifier). + * The method retrieves the appropriate ExternalAuthProvider bean from the application context and uses it to authenticate the user asynchronously. The result is expected to contain the user ID, which can then be used to link to an internal user account. + * + * @param grantType The type of grant used for authentication (e.g., OAuth, SAML). + * @param username The username or identifier used for authentication. + * @return A CompletableFuture containing the user ID associated with the authenticated user. + */ CompletableFuture authWithExternalAsync(String grantType, String username) { var provider = applicationContext.getBean(("external-auth-provider-" + grantType).toLowerCase(), ExternalAuthProvider.class); // Here we should check if the external auth result is linked to an internal user account, and return the user ID. @@ -197,6 +219,13 @@ CompletableFuture authWithExternalAsync(String grantType, String usernam return provider.authenticateAsync(username).thenApply(ExternalAuthResult::getId); } + /** + * Refresh the token by validating the provided refresh token (jti) and returning the associated user ID if valid. + * The method checks the token's status, expiration, and existence in the database before allowing a new access token to be issued. + * + * @param jti The unique identifier for the refresh token. + * @return A CompletableFuture containing the user ID associated with the valid refresh token. + */ CompletableFuture refreshToken(String jti) { var query = new TokenDetailQuery(jti); return mediator.executeAsync(query) @@ -211,18 +240,26 @@ public Map getDetails() { } if (TokenStatus.valueOf(tokenDetail.getStatus()) == TokenStatus.REFRESHED) { - throw new CredentialNotFoundException(null, "Refresh token has been revoked.") { - @Override - public Map getDetails() { - return Map.of("jti", jti); - } - }; + throw new CredentialIncorrectException(tokenDetail.getSubject(), "Refresh token has been revoked."); + } + + if (tokenDetail.getIssuedAt().plusDays(30).isBefore(LocalDateTime.now())) { + throw new CredentialExpiredException(tokenDetail.getSubject(), "Invalid refresh token."); } return tokenDetail.getSubject(); }); } + /** + * Generate a JWT token for the authenticated user with the specified claims and signing key. + * + * @param id The unique identifier for the token. + * @param user The authenticated user's information. + * @param issuedAt The token's issuance date. + * @param expiresAt The token's expiration date. + * @return The generated JWT token as a string. + */ private String generateToken(String id, UserAuthInfo user, Date issuedAt, Date expiresAt) { Assert.notNull(user, "user cannot be null"); //var signingKey = environment.getProperty("JwtAuthenticationOptions.SigningKey"); @@ -245,6 +282,11 @@ private String generateToken(String id, UserAuthInfo user, Date issuedAt, Date e return builder.compact(); } + /** + * Validate the token grant request based on the grant type and required fields. + * + * @param request The token grant request to validate. + */ private void checkRequest(TokenGrantRequestDto request) { Assert.notNull(request, "request cannot be null"); Assert.notNull(request.grantType(), "grantType cannot be null"); diff --git a/identity/src/main/java/io/theurl/identity/interfaces/controller/AuthController.java b/identity/src/main/java/io/theurl/identity/interfaces/controller/AuthController.java index 36c342e..d19dbe3 100644 --- a/identity/src/main/java/io/theurl/identity/interfaces/controller/AuthController.java +++ b/identity/src/main/java/io/theurl/identity/interfaces/controller/AuthController.java @@ -26,7 +26,7 @@ public AuthController(AuthApplicationService service) { * @return A response containing the access token and refresh token. */ @PostMapping("token/grant") - @Operation(summary = "Grant access token", security = {}) + @Operation(summary = "Grant access token") public TokenGrantResponseDto grantToken(@RequestBody TokenGrantRequestDto request) { return service.grant(request).join(); } @@ -40,9 +40,22 @@ public TokenGrantResponseDto grantToken(@RequestBody TokenGrantRequestDto reques * @return A response containing the new access token and refresh token. */ @PostMapping("token/refresh") - @Operation(summary = "Refresh access token", security = {}) + @Operation(summary = "Refresh access token") public TokenGrantResponseDto refreshToken(@RequestParam String token) { var request = new TokenGrantRequestDto(token, null, "refresh_token", null); return service.grant(request).join(); } + + /** + * Revoke an access token based on the provided token identifier (jti). + * This endpoint allows clients to revoke an access token, effectively invalidating it and preventing its further use for authentication and authorization. + * The client must provide the token identifier (jti) of the access token to be revoked, and if the token is valid, it will be marked as revoked in the system. + * + * @param jti The token identifier (jti) of the access token to be revoked. + */ + @PostMapping("token/revoke") + @Operation(summary = "Revoke access token") + public void revokeToken(@RequestParam String jti) { + service.revoke(jti).join(); + } } From 629689d4fd0ebd6fd93f1cb0f5e1a1d65c91ce77 Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 27 May 2026 21:27:49 +0800 Subject: [PATCH 08/30] Add OnetimePasswordCreatedEto class and Lombok dependency for OTP management --- shared/pom.xml | 8 ++++++++ .../shared/event/OnetimePasswordCreatedEto.java | 12 ++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 shared/src/main/java/io/theurl/shared/event/OnetimePasswordCreatedEto.java diff --git a/shared/pom.xml b/shared/pom.xml index 4e51e70..38f0d31 100644 --- a/shared/pom.xml +++ b/shared/pom.xml @@ -12,4 +12,12 @@ shared + + + org.projectlombok + lombok + true + + + diff --git a/shared/src/main/java/io/theurl/shared/event/OnetimePasswordCreatedEto.java b/shared/src/main/java/io/theurl/shared/event/OnetimePasswordCreatedEto.java new file mode 100644 index 0000000..82e04c4 --- /dev/null +++ b/shared/src/main/java/io/theurl/shared/event/OnetimePasswordCreatedEto.java @@ -0,0 +1,12 @@ +package io.theurl.shared.event; + +import lombok.Data; + +@Data +public class OnetimePasswordCreatedEto { + private String requestId; + private String recipient; + private String code; + private Integer duration; + private String usage; +} From 306a8e30d8e580eaa097d11d2243d2ca78d12951 Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 27 May 2026 21:31:37 +0800 Subject: [PATCH 09/30] Enhance RandomUtility with flexible random string generation methods and add hasEvents method to AggregateRoot --- .../framework/utility/RandomUtility.java | 74 +++++++++++++++++-- 1 file changed, 68 insertions(+), 6 deletions(-) diff --git a/framework/src/main/java/io/theurl/framework/utility/RandomUtility.java b/framework/src/main/java/io/theurl/framework/utility/RandomUtility.java index 3560d65..25d51c6 100644 --- a/framework/src/main/java/io/theurl/framework/utility/RandomUtility.java +++ b/framework/src/main/java/io/theurl/framework/utility/RandomUtility.java @@ -4,18 +4,80 @@ public class RandomUtility { private static final java.util.Random random = new java.util.Random(); + private static final String ALPHA_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + private static final String NUMERIC_CHARS = "0123456789"; + private static final String MIXED_CHARS = ALPHA_CHARS + NUMERIC_CHARS; + /** - * Generates a random alphanumeric string of the specified length. + * Generates a random string of the specified length using the provided characters. * - * @param length The length of the random string to generate. - * @return A random alphanumeric string of the specified length. + * @param length The length of the random string to generate. + * @param characters The characters to use for generating the random string. + * @return A random string of the specified length using the provided characters. + * @throws IllegalArgumentException if the length is less than or equal to 0 or if the characters string is shorter than the specified length. */ - public static String randomString(int length) { - String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + public static String randomString(int length, String characters) { + + if (length <= 0) { + throw new IllegalArgumentException("Length must be greater than 0"); + } + + if (characters.length() < length) { + throw new IllegalArgumentException("The length of the characters must be at least " + length); + } + StringBuilder sb = new StringBuilder(); for (int i = 0; i < length; i++) { - sb.append(chars.charAt(random.nextInt(chars.length()))); + sb.append(characters.charAt(random.nextInt(characters.length()))); } return sb.toString(); } + + /** + * Generates a random alphanumeric string of the specified length. + * + * @param length The length of the random string to generate. + * @return A random alphanumeric string of the specified length. + */ + public static String randomString(int length) { + return randomString(length, MIXED_CHARS); + } + + /** + * Generates a random string of the specified length and mode. + * + * @param length The length of the random string to generate. + * @param mode The mode of the random string (ALPHA, NUMERIC, or MIXED). + * @return A random string of the specified length and mode. + */ + public static String randomString(int length, RandomUtility.Mode mode) { + + String characters = switch (mode) { + case ALPHA -> ALPHA_CHARS; + case NUMERIC -> NUMERIC_CHARS; + case MIXED -> MIXED_CHARS; + }; + + return randomString(length, characters); + } + + /** + * Enumeration representing the mode of random string generation. + */ + public enum Mode { + /** + * Generates a random string consisting of alphabetic characters (both uppercase and lowercase). + */ + ALPHA, + + /** + * Generates a random string consisting of numeric characters (digits 0-9). + */ + NUMERIC, + + /** + * Generates a random string consisting of both alphabetic and numeric characters. + */ + MIXED + } } From 543996c1ee33f93298e415cb957c08cee86a15b3 Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 27 May 2026 21:32:00 +0800 Subject: [PATCH 10/30] Add hasEvents method to AggregateRoot for event presence checking --- .../main/java/io/theurl/framework/domain/AggregateRoot.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/framework/src/main/java/io/theurl/framework/domain/AggregateRoot.java b/framework/src/main/java/io/theurl/framework/domain/AggregateRoot.java index e7a5f03..498ad9a 100644 --- a/framework/src/main/java/io/theurl/framework/domain/AggregateRoot.java +++ b/framework/src/main/java/io/theurl/framework/domain/AggregateRoot.java @@ -27,6 +27,10 @@ public List getEvents() { return List.copyOf(events); } + public boolean hasEvents() { + return events != null && !events.isEmpty(); + } + @Override public void clearEvents() { events.clear(); From 2d3d83362eeb001eae7107af38d66ba289bb89d2 Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 27 May 2026 21:33:12 +0800 Subject: [PATCH 11/30] Implement one-time password (OTP) functionality with command, service, and controller for sending OTPs --- identity/pom.xml | 42 ++++--- .../command/OnetimePasswordCreateCommand.java | 20 ++++ .../OnetimePasswordApplicationService.java | 20 ++++ .../contract/UserApplicationService.java | 9 +- .../dto/OnetimePasswordSendRequestDto.java | 4 + .../application/dto/UserUpdateRequestDto.java | 2 + .../OnetimePasswordCreateCommandHandler.java | 36 ++++++ ...OnetimePasswordApplicationServiceImpl.java | 44 +++++++ .../implement/UserApplicationServiceImpl.java | 36 +++++- .../subscriber/DistributedEventBus.java | 79 +++++++++++++ .../domain/aggregate/OnetimePassword.java | 110 ++++++++++++++++++ .../event/OnetimePasswordCreatedEvent.java | 16 +++ .../repository/OnetimePasswordRepository.java | 11 ++ .../controller/AccountController.java | 4 +- .../controller/OnetimePasswordController.java | 22 ++++ .../persistence/entity/OnetimePassword.java | 4 +- .../profile/OnetimePasswordMapProfile.java | 24 ++++ .../JpaOnetimePasswordRepository.java | 12 ++ .../OnetimePasswordRepositoryImpl.java | 36 ++++++ identity/src/main/resources/application.yaml | 5 + 20 files changed, 506 insertions(+), 30 deletions(-) create mode 100644 identity/src/main/java/io/theurl/identity/application/command/OnetimePasswordCreateCommand.java create mode 100644 identity/src/main/java/io/theurl/identity/application/contract/OnetimePasswordApplicationService.java create mode 100644 identity/src/main/java/io/theurl/identity/application/dto/OnetimePasswordSendRequestDto.java create mode 100644 identity/src/main/java/io/theurl/identity/application/handler/OnetimePasswordCreateCommandHandler.java create mode 100644 identity/src/main/java/io/theurl/identity/application/implement/OnetimePasswordApplicationServiceImpl.java create mode 100644 identity/src/main/java/io/theurl/identity/domain/aggregate/OnetimePassword.java create mode 100644 identity/src/main/java/io/theurl/identity/domain/event/OnetimePasswordCreatedEvent.java create mode 100644 identity/src/main/java/io/theurl/identity/domain/repository/OnetimePasswordRepository.java create mode 100644 identity/src/main/java/io/theurl/identity/interfaces/controller/OnetimePasswordController.java create mode 100644 identity/src/main/java/io/theurl/identity/persistence/profile/OnetimePasswordMapProfile.java create mode 100644 identity/src/main/java/io/theurl/identity/persistence/repository/JpaOnetimePasswordRepository.java create mode 100644 identity/src/main/java/io/theurl/identity/persistence/repository/OnetimePasswordRepositoryImpl.java diff --git a/identity/pom.xml b/identity/pom.xml index 451c7a5..3354730 100644 --- a/identity/pom.xml +++ b/identity/pom.xml @@ -23,6 +23,12 @@ ${project.version} compile + + io.theurl + shared + ${project.version} + compile + com.neroyun mediator @@ -44,18 +50,18 @@ 0.13.0 runtime - - - - + + org.springframework.boot + spring-boot-starter-amqp + org.springframework.boot spring-boot-starter-data-jpa - - - - + + + + org.springframework.boot spring-boot-starter-web @@ -89,11 +95,11 @@ lombok true - - - - - + + + + + org.springframework.boot spring-boot-starter-data-jpa-test @@ -104,11 +110,11 @@ spring-boot-starter-test test - - - - - + + + + + org.modelmapper modelmapper diff --git a/identity/src/main/java/io/theurl/identity/application/command/OnetimePasswordCreateCommand.java b/identity/src/main/java/io/theurl/identity/application/command/OnetimePasswordCreateCommand.java new file mode 100644 index 0000000..69de824 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/command/OnetimePasswordCreateCommand.java @@ -0,0 +1,20 @@ +package io.theurl.identity.application.command; + +import com.neroyun.mediator.Command; +import lombok.Data; + +@Data +public class OnetimePasswordCreateCommand implements Command { + private final String requestId; + private final String recipient; + private final String code; + + private String usage; + private Integer duration; + + public OnetimePasswordCreateCommand(String requestId, String recipient, String code) { + this.requestId = requestId; + this.recipient = recipient; + this.code = code; + } +} diff --git a/identity/src/main/java/io/theurl/identity/application/contract/OnetimePasswordApplicationService.java b/identity/src/main/java/io/theurl/identity/application/contract/OnetimePasswordApplicationService.java new file mode 100644 index 0000000..68caa69 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/contract/OnetimePasswordApplicationService.java @@ -0,0 +1,20 @@ +package io.theurl.identity.application.contract; + +import io.theurl.framework.application.ApplicationService; +import io.theurl.identity.application.dto.OnetimePasswordSendRequestDto; + +import java.util.concurrent.CompletableFuture; + +/** + * Application service for handling one-time password (OTP) related operations, such as sending OTPs to users for various usages (e.g., authentication, password reset). + */ +public interface OnetimePasswordApplicationService extends ApplicationService { + + /** + * Send a one-time password to the specified recipient for the given usage. + * + * @param request the request containing the usage and recipient information + * @return a CompletableFuture that will complete with the sent OTP + */ + CompletableFuture sendAsync(OnetimePasswordSendRequestDto request); +} diff --git a/identity/src/main/java/io/theurl/identity/application/contract/UserApplicationService.java b/identity/src/main/java/io/theurl/identity/application/contract/UserApplicationService.java index 4411156..1088e67 100644 --- a/identity/src/main/java/io/theurl/identity/application/contract/UserApplicationService.java +++ b/identity/src/main/java/io/theurl/identity/application/contract/UserApplicationService.java @@ -3,6 +3,7 @@ import io.theurl.framework.application.ApplicationService; import io.theurl.identity.application.dto.UserCreateRequestDto; import io.theurl.identity.application.dto.UserProfileResponseDto; +import io.theurl.identity.application.dto.UserUpdateRequestDto; import java.util.concurrent.CompletableFuture; @@ -40,18 +41,18 @@ public interface UserApplicationService extends ApplicationService { /** * Change the email of the currently authenticated user asynchronously. * - * @param email The new email to be set for the user. + * @param data The user update request data containing the new email to be set for the user. * @return A CompletableFuture representing the asynchronous operation. */ - CompletableFuture changeEmailAsync(String email); + CompletableFuture changeEmailAsync(UserUpdateRequestDto data); /** * Change the phone number of the currently authenticated user asynchronously. * - * @param phone The new phone number to be set for the user. + * @param data The user update request data containing the new phone number to be set for the user. * @return A CompletableFuture representing the asynchronous operation. */ - CompletableFuture changePhoneAsync(String phone); + CompletableFuture changePhoneAsync(UserUpdateRequestDto data); /** * Change the nickname of the currently authenticated user asynchronously. diff --git a/identity/src/main/java/io/theurl/identity/application/dto/OnetimePasswordSendRequestDto.java b/identity/src/main/java/io/theurl/identity/application/dto/OnetimePasswordSendRequestDto.java new file mode 100644 index 0000000..ef1c9ba --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/dto/OnetimePasswordSendRequestDto.java @@ -0,0 +1,4 @@ +package io.theurl.identity.application.dto; + +public record OnetimePasswordSendRequestDto(String usage, String recipient) { +} diff --git a/identity/src/main/java/io/theurl/identity/application/dto/UserUpdateRequestDto.java b/identity/src/main/java/io/theurl/identity/application/dto/UserUpdateRequestDto.java index 5cb698f..3c46568 100644 --- a/identity/src/main/java/io/theurl/identity/application/dto/UserUpdateRequestDto.java +++ b/identity/src/main/java/io/theurl/identity/application/dto/UserUpdateRequestDto.java @@ -7,4 +7,6 @@ public class UserUpdateRequestDto { private String email; private String phone; private String nickname; + private String code; + private String requestId; } diff --git a/identity/src/main/java/io/theurl/identity/application/handler/OnetimePasswordCreateCommandHandler.java b/identity/src/main/java/io/theurl/identity/application/handler/OnetimePasswordCreateCommandHandler.java new file mode 100644 index 0000000..2e8f917 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/handler/OnetimePasswordCreateCommandHandler.java @@ -0,0 +1,36 @@ +package io.theurl.identity.application.handler; + +import com.neroyun.mediator.Event; +import com.neroyun.mediator.Handler; +import com.neroyun.mediator.Mediator; +import io.theurl.framework.core.BeanScope; +import io.theurl.identity.application.command.OnetimePasswordCreateCommand; +import io.theurl.identity.domain.aggregate.OnetimePassword; +import io.theurl.identity.domain.repository.OnetimePasswordRepository; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + +import java.util.concurrent.CompletableFuture; + +@Component +@Scope(BeanScope.PROTOTYPE) +public class OnetimePasswordCreateCommandHandler implements Handler { + private final OnetimePasswordRepository repository; + private final Mediator mediator; + + public OnetimePasswordCreateCommandHandler(OnetimePasswordRepository repository, Mediator mediator) { + this.repository = repository; + this.mediator = mediator; + } + + @Override + public CompletableFuture handleAsync(OnetimePasswordCreateCommand message) { + var aggregate = OnetimePassword.create(message.getRequestId(), message.getRecipient(), message.getCode(), message.getDuration()); + aggregate.setUsage(message.getUsage()); + repository.save(aggregate); + if (aggregate.hasEvents()) { + aggregate.getEvents().parallelStream().forEach(event -> mediator.publishAsync((Event) event)); + } + return CompletableFuture.completedFuture(null); + } +} diff --git a/identity/src/main/java/io/theurl/identity/application/implement/OnetimePasswordApplicationServiceImpl.java b/identity/src/main/java/io/theurl/identity/application/implement/OnetimePasswordApplicationServiceImpl.java new file mode 100644 index 0000000..675c537 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/implement/OnetimePasswordApplicationServiceImpl.java @@ -0,0 +1,44 @@ +package io.theurl.identity.application.implement; + +import io.theurl.framework.application.BaseApplicationService; +import io.theurl.framework.core.ObjectId; +import io.theurl.framework.utility.RandomUtility; +import io.theurl.framework.utility.RegexUtility; +import io.theurl.identity.application.command.OnetimePasswordCreateCommand; +import io.theurl.identity.application.contract.OnetimePasswordApplicationService; +import io.theurl.identity.application.dto.OnetimePasswordSendRequestDto; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Service; +import org.springframework.web.context.annotation.RequestScope; + +import java.util.concurrent.CompletableFuture; + +@Service +@RequestScope +public class OnetimePasswordApplicationServiceImpl extends BaseApplicationService implements OnetimePasswordApplicationService { + public OnetimePasswordApplicationServiceImpl(ApplicationContext applicationContext) { + super(applicationContext); + } + + @Override + public CompletableFuture sendAsync(OnetimePasswordSendRequestDto request) { + + if (request == null) { + throw new IllegalArgumentException("Request cannot be null"); + } + + if (request.recipient() == null || request.recipient().isBlank()) { + throw new IllegalArgumentException("Recipient cannot be null or blank"); + } + + if (!RegexUtility.isEmail(request.recipient()) && !RegexUtility.isPhone(request.recipient())) { + throw new IllegalArgumentException("Email or Phone number is invalid"); + } + + var requestId = ObjectId.guid().toString(); + var code = RandomUtility.randomString(6, RandomUtility.Mode.NUMERIC); + + var command = new OnetimePasswordCreateCommand(requestId, request.recipient(), code); + return mediator.sendAsync(command).thenApply(_ -> requestId); + } +} diff --git a/identity/src/main/java/io/theurl/identity/application/implement/UserApplicationServiceImpl.java b/identity/src/main/java/io/theurl/identity/application/implement/UserApplicationServiceImpl.java index 9b7fa3f..a5da9d3 100644 --- a/identity/src/main/java/io/theurl/identity/application/implement/UserApplicationServiceImpl.java +++ b/identity/src/main/java/io/theurl/identity/application/implement/UserApplicationServiceImpl.java @@ -10,7 +10,9 @@ import io.theurl.identity.application.contract.UserApplicationService; import io.theurl.identity.application.dto.UserCreateRequestDto; import io.theurl.identity.application.dto.UserProfileResponseDto; +import io.theurl.identity.application.dto.UserUpdateRequestDto; import io.theurl.identity.external.ExternalAuthProvider; +import io.theurl.identity.persistence.query.OnetimePasswordDetailQuery; import io.theurl.identity.persistence.query.UserAuthInfoQuery; import io.theurl.identity.persistence.query.UserDetailQuery; import org.modelmapper.ModelMapper; @@ -18,6 +20,7 @@ import org.springframework.stereotype.Service; import org.springframework.web.context.annotation.RequestScope; +import java.time.LocalDateTime; import java.util.Locale; import java.util.concurrent.CompletableFuture; @@ -68,16 +71,21 @@ public CompletableFuture changePasswordAsync(String oldPassword, String ne } @Override - public CompletableFuture changeEmailAsync(String email) { + public CompletableFuture changeEmailAsync(UserUpdateRequestDto data) { + checkCodeAsync(data.getRequestId(), data.getPhone(), data.getCode()) + .join(); var command = new UserUpdateCommand(currentUserId()); - command.getModifications().put("email", email); + command.getModifications().put("email", data.getEmail()); return mediator.sendAsync(command); } @Override - public CompletableFuture changePhoneAsync(String phone) { + public CompletableFuture changePhoneAsync(UserUpdateRequestDto data) { + checkCodeAsync(data.getRequestId(), data.getPhone(), data.getCode()) + .join(); + var command = new UserUpdateCommand(currentUserId()); - command.getModifications().put("phone", phone); + command.getModifications().put("phone", data.getPhone()); return mediator.sendAsync(command); } @@ -105,4 +113,24 @@ public CompletableFuture connectAuthorityAsync(String provider, String cod public CompletableFuture removeAuthorityAsync(String provider, String openId) { return null; } + + CompletableFuture checkCodeAsync(String requestId, String recipient, String code) { + return mediator.executeAsync(new OnetimePasswordDetailQuery(requestId)).thenAccept(otp -> { + if (otp == null) { + throw new CredentialIncorrectException("Invalid verify code."); + } + if (otp.getExpiration().isBefore(LocalDateTime.now())) { + throw new CredentialIncorrectException("Verify code has expired."); + } + if (otp.getChecked() != null) { + throw new CredentialIncorrectException("Verify code has already been used."); + } + if (!otp.getRecipient().equals(recipient)) { + throw new CredentialIncorrectException("Invalid verify code recipient."); + } + if (otp.getCode() == null || !otp.getCode().equals(code)) { + throw new CredentialIncorrectException("Invalid verify code."); + } + }); + } } diff --git a/identity/src/main/java/io/theurl/identity/application/subscriber/DistributedEventBus.java b/identity/src/main/java/io/theurl/identity/application/subscriber/DistributedEventBus.java index 3423aaa..ec0cf69 100644 --- a/identity/src/main/java/io/theurl/identity/application/subscriber/DistributedEventBus.java +++ b/identity/src/main/java/io/theurl/identity/application/subscriber/DistributedEventBus.java @@ -1,7 +1,86 @@ package io.theurl.identity.application.subscriber; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.rabbitmq.client.AMQP; +import com.rabbitmq.client.ConnectionFactory; +import io.theurl.identity.domain.event.OnetimePasswordCreatedEvent; +import io.theurl.identity.domain.event.UserEmailChangedEvent; +import io.theurl.identity.domain.event.UserPasswordChangedEvent; +import io.theurl.identity.domain.event.UserPhoneChangedEvent; +import io.theurl.shared.event.OnetimePasswordCreatedEto; +import org.modelmapper.ModelMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @Component public class DistributedEventBus { + private final Logger LOGGER = LoggerFactory.getLogger(DistributedEventBus.class); + + private final ModelMapper mapper; + + @Value("${spring.rabbitmq.host}") + private String host; + @Value("${spring.rabbitmq.port}") + private int port; + @Value("${spring.rabbitmq.username}") + private String username; + @Value("${spring.rabbitmq.password}") + private String password; + + private final ConnectionFactory factory; + + public DistributedEventBus(ModelMapper mapper) { + this.mapper = mapper; + this.factory = new ConnectionFactory(); + this.factory.setHost(host); + this.factory.setPort(port); + this.factory.setUsername(username); + this.factory.setPassword(password); + } + + @Async + @EventListener + public void handleOnetimePasswordCreatedEvent(OnetimePasswordCreatedEvent event) { + try (var connection = factory.newConnection()) { + var eto = mapper.map(event, OnetimePasswordCreatedEto.class); + + var message = new ObjectMapper().writeValueAsString(eto); + + var channel = connection.createChannel(); + channel.exchangeDeclare("io.theurl.identity.otp.created", "fanout", true); + var properties = new AMQP.BasicProperties().builder() + .contentType("application/json") + .build(); + channel.basicPublish("io.theurl.identity.otp.created", "", properties, message.getBytes()); + + } catch (Exception exception) { + LOGGER.error(exception.getMessage(), exception); + } + } + + @Async + @EventListener + public void handleUserEmailChangedEvent(UserEmailChangedEvent event) { + try { + + } catch (Exception exception) { + LOGGER.error(exception.getMessage(), exception); + } + } + + @Async + @EventListener + public void handleUserPasswordChangedEvent(UserPasswordChangedEvent event) { + + } + + @Async + @EventListener + public void handleUserPhoneChangedEvent(UserPhoneChangedEvent event) { + + } } diff --git a/identity/src/main/java/io/theurl/identity/domain/aggregate/OnetimePassword.java b/identity/src/main/java/io/theurl/identity/domain/aggregate/OnetimePassword.java new file mode 100644 index 0000000..b48e753 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/domain/aggregate/OnetimePassword.java @@ -0,0 +1,110 @@ +package io.theurl.identity.domain.aggregate; + +import io.theurl.framework.domain.AggregateRoot; +import io.theurl.framework.utility.SnowflakeId; +import io.theurl.identity.domain.event.OnetimePasswordCreatedEvent; + +import java.time.LocalDateTime; + +@SuppressWarnings({"LombokGetterMayBeUsed", "LombokSetterMayBeUsed"}) +public class OnetimePassword extends AggregateRoot { + + /** + * Initializes the aggregate with the given id. + * + * @param id the identifier of the aggregate + */ + public OnetimePassword(Long id) { + super(id); + } + + public OnetimePassword(Long id, String requestId, String recipient, String code) { + this(id); + this.requestId = requestId; + this.recipient = recipient; + this.code = code; + } + + private String requestId; + private String code; + private String recipient; + private LocalDateTime expiration; + private LocalDateTime checked; + private Integer duration; + private String usage; + + public static OnetimePassword create(String requestId, String recipient, String code, Integer duration) { + OnetimePassword otp = new OnetimePassword(SnowflakeId.getInstance().nextId(), requestId, recipient, code); + otp.duration = duration; + if (duration != null) { + otp.expiration = LocalDateTime.now().plusSeconds(duration); + } + otp.raiseEvent(new OnetimePasswordCreatedEvent() {{ + setRequestId(requestId); + setRecipient(recipient); + setCode(code); + setDuration(duration); + }}); + return otp; + } + + public String getRequestId() { + return requestId; + } + + public void setRequestId(String requestId) { + this.requestId = requestId; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getRecipient() { + return recipient; + } + + public void setRecipient(String recipient) { + this.recipient = recipient; + } + + public LocalDateTime getExpiration() { + return expiration; + } + + public void setExpiration(LocalDateTime expiration) { + this.expiration = expiration; + } + + public LocalDateTime getChecked() { + return checked; + } + + public void setChecked(LocalDateTime checked) { + this.checked = checked; + } + + public Integer getDuration() { + return duration; + } + + public void setDuration(Integer duration) { + this.duration = duration; + } + + public String getUsage() { + return usage; + } + + public void setUsage(String usage) { + this.usage = usage; + } + + public void checkOff() { + setChecked(LocalDateTime.now()); + } +} diff --git a/identity/src/main/java/io/theurl/identity/domain/event/OnetimePasswordCreatedEvent.java b/identity/src/main/java/io/theurl/identity/domain/event/OnetimePasswordCreatedEvent.java new file mode 100644 index 0000000..ca825b4 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/domain/event/OnetimePasswordCreatedEvent.java @@ -0,0 +1,16 @@ +package io.theurl.identity.domain.event; + +import com.neroyun.mediator.Event; +import io.theurl.framework.domain.DomainEvent; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@EqualsAndHashCode(callSuper = true) +@Data +public class OnetimePasswordCreatedEvent extends DomainEvent implements Event { + private String requestId; + private String recipient; + private String code; + private Integer duration; + private String usage; +} diff --git a/identity/src/main/java/io/theurl/identity/domain/repository/OnetimePasswordRepository.java b/identity/src/main/java/io/theurl/identity/domain/repository/OnetimePasswordRepository.java new file mode 100644 index 0000000..61c1564 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/domain/repository/OnetimePasswordRepository.java @@ -0,0 +1,11 @@ +package io.theurl.identity.domain.repository; + +import io.theurl.identity.domain.aggregate.OnetimePassword; + +public interface OnetimePasswordRepository { + void save(OnetimePassword aggregate); + + OnetimePassword findById(Long id); + + OnetimePassword findByRequestId(String requestId); +} diff --git a/identity/src/main/java/io/theurl/identity/interfaces/controller/AccountController.java b/identity/src/main/java/io/theurl/identity/interfaces/controller/AccountController.java index 85d9af9..5a76543 100644 --- a/identity/src/main/java/io/theurl/identity/interfaces/controller/AccountController.java +++ b/identity/src/main/java/io/theurl/identity/interfaces/controller/AccountController.java @@ -46,7 +46,7 @@ public ResponseEntity changePassword(@RequestBody UserPasswordChangeReques @PutMapping("/email") @Operation(summary = "Change user email", security = @SecurityRequirement(name = "bearerAuth")) public ResponseEntity changeEmail(@RequestBody UserUpdateRequestDto data) { - service.changeEmailAsync(data.getEmail()) + service.changeEmailAsync(data) .join(); return ResponseEntity.ok().build(); } @@ -54,7 +54,7 @@ public ResponseEntity changeEmail(@RequestBody UserUpdateRequestDto data) @PutMapping("/phone") @Operation(summary = "Change user phone", security = @SecurityRequirement(name = "bearerAuth")) public ResponseEntity changePhone(@RequestBody UserUpdateRequestDto data) { - service.changePhoneAsync(data.getPhone()) + service.changePhoneAsync(data) .join(); return ResponseEntity.ok().build(); } diff --git a/identity/src/main/java/io/theurl/identity/interfaces/controller/OnetimePasswordController.java b/identity/src/main/java/io/theurl/identity/interfaces/controller/OnetimePasswordController.java new file mode 100644 index 0000000..5405ede --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/interfaces/controller/OnetimePasswordController.java @@ -0,0 +1,22 @@ +package io.theurl.identity.interfaces.controller; + +import io.theurl.identity.application.contract.OnetimePasswordApplicationService; +import io.theurl.identity.application.dto.OnetimePasswordSendRequestDto; +import org.springframework.web.bind.annotation.*; + +import java.util.concurrent.CompletableFuture; + +@RestController +@RequestMapping("otp") +public class OnetimePasswordController { + private final OnetimePasswordApplicationService service; + + public OnetimePasswordController(OnetimePasswordApplicationService service) { + this.service = service; + } + + @PostMapping("send") + public CompletableFuture sendOtp(@RequestBody OnetimePasswordSendRequestDto request) { + return service.sendAsync(request); + } +} diff --git a/identity/src/main/java/io/theurl/identity/persistence/entity/OnetimePassword.java b/identity/src/main/java/io/theurl/identity/persistence/entity/OnetimePassword.java index 9179bda..fdb979c 100644 --- a/identity/src/main/java/io/theurl/identity/persistence/entity/OnetimePassword.java +++ b/identity/src/main/java/io/theurl/identity/persistence/entity/OnetimePassword.java @@ -34,8 +34,8 @@ public class OnetimePassword implements Persistable { @Column(name = "duration") private Integer duration; - @Column(name = "usage") - private int usage; + @Column(name = "usage", length = 20) + private String usage; @Column(name = "created_at") private LocalDateTime createdAt; diff --git a/identity/src/main/java/io/theurl/identity/persistence/profile/OnetimePasswordMapProfile.java b/identity/src/main/java/io/theurl/identity/persistence/profile/OnetimePasswordMapProfile.java new file mode 100644 index 0000000..029db76 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/profile/OnetimePasswordMapProfile.java @@ -0,0 +1,24 @@ +package io.theurl.identity.persistence.profile; + +import jakarta.annotation.PostConstruct; +import org.modelmapper.ModelMapper; +import org.modelmapper.Provider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class OnetimePasswordMapProfile { + @Autowired + private ModelMapper mapper; + + @PostConstruct + public void configure() { + Provider provider = request -> { + var source = (io.theurl.identity.persistence.entity.OnetimePassword) request.getSource(); + return new io.theurl.identity.domain.aggregate.OnetimePassword(source.getId()); + }; + + mapper.createTypeMap(io.theurl.identity.persistence.entity.OnetimePassword.class, io.theurl.identity.domain.aggregate.OnetimePassword.class) + .setProvider(provider); + } +} diff --git a/identity/src/main/java/io/theurl/identity/persistence/repository/JpaOnetimePasswordRepository.java b/identity/src/main/java/io/theurl/identity/persistence/repository/JpaOnetimePasswordRepository.java new file mode 100644 index 0000000..aba59db --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/repository/JpaOnetimePasswordRepository.java @@ -0,0 +1,12 @@ +package io.theurl.identity.persistence.repository; + +import io.theurl.identity.persistence.entity.OnetimePassword; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface JpaOnetimePasswordRepository extends CrudRepository { + Optional findByRequestId(String requestId); +} diff --git a/identity/src/main/java/io/theurl/identity/persistence/repository/OnetimePasswordRepositoryImpl.java b/identity/src/main/java/io/theurl/identity/persistence/repository/OnetimePasswordRepositoryImpl.java new file mode 100644 index 0000000..82735f1 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/repository/OnetimePasswordRepositoryImpl.java @@ -0,0 +1,36 @@ +package io.theurl.identity.persistence.repository; + +import io.theurl.identity.domain.aggregate.OnetimePassword; +import io.theurl.identity.domain.repository.OnetimePasswordRepository; +import org.modelmapper.ModelMapper; +import org.springframework.stereotype.Repository; + +@Repository +public class OnetimePasswordRepositoryImpl implements OnetimePasswordRepository { + + private final JpaOnetimePasswordRepository repository; + private final ModelMapper mapper; + + public OnetimePasswordRepositoryImpl(JpaOnetimePasswordRepository repository, ModelMapper mapper) { + this.repository = repository; + this.mapper = mapper; + } + + @Override + public void save(OnetimePassword aggregate) { + var entity = mapper.map(aggregate, io.theurl.identity.persistence.entity.OnetimePassword.class); + repository.save(entity); + } + + @Override + public OnetimePassword findById(Long id) { + var entity = repository.findById(id).orElse(null); + return mapper.map(entity, OnetimePassword.class); + } + + @Override + public OnetimePassword findByRequestId(String requestId) { + var entity = repository.findByRequestId(requestId).orElse(null); + return mapper.map(entity, OnetimePassword.class); + } +} diff --git a/identity/src/main/resources/application.yaml b/identity/src/main/resources/application.yaml index 46bae52..a20460e 100644 --- a/identity/src/main/resources/application.yaml +++ b/identity/src/main/resources/application.yaml @@ -36,6 +36,11 @@ spring: include-message: ALWAYS include-exception: true include-stacktrace: ALWAYS + rabbitmq: + host: ${RABBITMQ_HOST:localhost} + port: ${RABBITMQ_PORT:5672} + username: ${RABBITMQ_USERNAME:guest} + password: ${RABBITMQ_PASSWORD:guest} external-auth: redirect-uri: "https://theurl.io/auth/callback" From 2d3e109a6c33e7a77bc17de110c36acdd1b5c9b9 Mon Sep 17 00:00:00 2001 From: damon Date: Thu, 28 May 2026 10:48:38 +0800 Subject: [PATCH 12/30] Refactor OTP sending functionality to accept recipient and usage as separate parameters; update controller methods for authentication, email change, and password reset OTP requests --- .../OnetimePasswordApplicationService.java | 6 +-- .../dto/OnetimePasswordSendRequestDto.java | 2 +- ...OnetimePasswordApplicationServiceImpl.java | 36 ++++++++++---- .../subscriber/DistributedEventBus.java | 48 +++++++++++++++---- .../configure/SecurityConfiguration.java | 3 +- .../domain/aggregate/OnetimePassword.java | 2 +- .../controller/OnetimePasswordController.java | 16 +++++-- identity/src/main/resources/application.yaml | 2 +- 8 files changed, 87 insertions(+), 28 deletions(-) diff --git a/identity/src/main/java/io/theurl/identity/application/contract/OnetimePasswordApplicationService.java b/identity/src/main/java/io/theurl/identity/application/contract/OnetimePasswordApplicationService.java index 68caa69..bc03860 100644 --- a/identity/src/main/java/io/theurl/identity/application/contract/OnetimePasswordApplicationService.java +++ b/identity/src/main/java/io/theurl/identity/application/contract/OnetimePasswordApplicationService.java @@ -1,7 +1,6 @@ package io.theurl.identity.application.contract; import io.theurl.framework.application.ApplicationService; -import io.theurl.identity.application.dto.OnetimePasswordSendRequestDto; import java.util.concurrent.CompletableFuture; @@ -13,8 +12,9 @@ public interface OnetimePasswordApplicationService extends ApplicationService { /** * Send a one-time password to the specified recipient for the given usage. * - * @param request the request containing the usage and recipient information + * @param recipient the recipient to whom the one-time password should be sent (e.g., email address, phone number) + * @param usage the intended usage of the one-time password (e.g., "authentication", "password_reset") * @return a CompletableFuture that will complete with the sent OTP */ - CompletableFuture sendAsync(OnetimePasswordSendRequestDto request); + CompletableFuture sendAsync(String recipient, String usage); } diff --git a/identity/src/main/java/io/theurl/identity/application/dto/OnetimePasswordSendRequestDto.java b/identity/src/main/java/io/theurl/identity/application/dto/OnetimePasswordSendRequestDto.java index ef1c9ba..2c00393 100644 --- a/identity/src/main/java/io/theurl/identity/application/dto/OnetimePasswordSendRequestDto.java +++ b/identity/src/main/java/io/theurl/identity/application/dto/OnetimePasswordSendRequestDto.java @@ -1,4 +1,4 @@ package io.theurl.identity.application.dto; -public record OnetimePasswordSendRequestDto(String usage, String recipient) { +public record OnetimePasswordSendRequestDto(String recipient) { } diff --git a/identity/src/main/java/io/theurl/identity/application/implement/OnetimePasswordApplicationServiceImpl.java b/identity/src/main/java/io/theurl/identity/application/implement/OnetimePasswordApplicationServiceImpl.java index 675c537..a4ba2a6 100644 --- a/identity/src/main/java/io/theurl/identity/application/implement/OnetimePasswordApplicationServiceImpl.java +++ b/identity/src/main/java/io/theurl/identity/application/implement/OnetimePasswordApplicationServiceImpl.java @@ -4,13 +4,15 @@ import io.theurl.framework.core.ObjectId; import io.theurl.framework.utility.RandomUtility; import io.theurl.framework.utility.RegexUtility; +import io.theurl.framework.security.CredentialNotFoundException; import io.theurl.identity.application.command.OnetimePasswordCreateCommand; import io.theurl.identity.application.contract.OnetimePasswordApplicationService; -import io.theurl.identity.application.dto.OnetimePasswordSendRequestDto; +import io.theurl.identity.persistence.query.UserAuthInfoQuery; import org.springframework.context.ApplicationContext; import org.springframework.stereotype.Service; import org.springframework.web.context.annotation.RequestScope; +import java.util.Objects; import java.util.concurrent.CompletableFuture; @Service @@ -21,24 +23,38 @@ public OnetimePasswordApplicationServiceImpl(ApplicationContext applicationConte } @Override - public CompletableFuture sendAsync(OnetimePasswordSendRequestDto request) { - - if (request == null) { - throw new IllegalArgumentException("Request cannot be null"); - } - - if (request.recipient() == null || request.recipient().isBlank()) { + public CompletableFuture sendAsync(String recipient, String usage) { + if (recipient == null || recipient.isBlank()) { throw new IllegalArgumentException("Recipient cannot be null or blank"); } - if (!RegexUtility.isEmail(request.recipient()) && !RegexUtility.isPhone(request.recipient())) { + boolean isEmail = RegexUtility.isEmail(recipient); + boolean isPhone = RegexUtility.isPhone(recipient); + + if (!isEmail && !isPhone) { throw new IllegalArgumentException("Email or Phone number is invalid"); } + if (Objects.equals(usage, "authentication")) { + UserAuthInfoQuery query; + if (isEmail) { + query = new UserAuthInfoQuery("email", recipient); + } else { + query = new UserAuthInfoQuery("phone", recipient); + } + + var user = mediator.executeAsync(query).join(); + if (user == null) { + throw new CredentialNotFoundException(recipient, recipient + " not registered."); + } + } + var requestId = ObjectId.guid().toString(); var code = RandomUtility.randomString(6, RandomUtility.Mode.NUMERIC); - var command = new OnetimePasswordCreateCommand(requestId, request.recipient(), code); + var command = new OnetimePasswordCreateCommand(requestId, recipient, code); + command.setUsage(usage); + command.setDuration(15); return mediator.sendAsync(command).thenApply(_ -> requestId); } } diff --git a/identity/src/main/java/io/theurl/identity/application/subscriber/DistributedEventBus.java b/identity/src/main/java/io/theurl/identity/application/subscriber/DistributedEventBus.java index ec0cf69..8267df2 100644 --- a/identity/src/main/java/io/theurl/identity/application/subscriber/DistributedEventBus.java +++ b/identity/src/main/java/io/theurl/identity/application/subscriber/DistributedEventBus.java @@ -3,16 +3,17 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.rabbitmq.client.AMQP; import com.rabbitmq.client.ConnectionFactory; -import io.theurl.identity.domain.event.OnetimePasswordCreatedEvent; -import io.theurl.identity.domain.event.UserEmailChangedEvent; -import io.theurl.identity.domain.event.UserPasswordChangedEvent; -import io.theurl.identity.domain.event.UserPhoneChangedEvent; +import io.theurl.identity.domain.aggregate.User; +import io.theurl.identity.domain.event.*; +import io.theurl.shared.constant.EventConstant; import io.theurl.shared.event.OnetimePasswordCreatedEto; +import io.theurl.shared.event.UserLockedEto; import org.modelmapper.ModelMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.event.EventListener; +import org.springframework.core.env.Environment; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @@ -33,11 +34,15 @@ public class DistributedEventBus { private final ConnectionFactory factory; - public DistributedEventBus(ModelMapper mapper) { + public DistributedEventBus(ModelMapper mapper, Environment environment) { + System.out.println(host + ":" + port); + host = environment.getProperty("spring.rabbitmq.host"); + username = environment.getProperty("spring.rabbitmq.username"); + password = environment.getProperty("spring.rabbitmq.password"); this.mapper = mapper; this.factory = new ConnectionFactory(); this.factory.setHost(host); - this.factory.setPort(port); + //this.factory.setPort(port); this.factory.setUsername(username); this.factory.setPassword(password); } @@ -51,11 +56,11 @@ public void handleOnetimePasswordCreatedEvent(OnetimePasswordCreatedEvent event) var message = new ObjectMapper().writeValueAsString(eto); var channel = connection.createChannel(); - channel.exchangeDeclare("io.theurl.identity.otp.created", "fanout", true); + channel.exchangeDeclare(EventConstant.OTP_CREATED, "fanout", true); var properties = new AMQP.BasicProperties().builder() .contentType("application/json") .build(); - channel.basicPublish("io.theurl.identity.otp.created", "", properties, message.getBytes()); + channel.basicPublish(EventConstant.OTP_CREATED, "", properties, message.getBytes()); } catch (Exception exception) { LOGGER.error(exception.getMessage(), exception); @@ -83,4 +88,31 @@ public void handleUserPasswordChangedEvent(UserPasswordChangedEvent event) { public void handleUserPhoneChangedEvent(UserPhoneChangedEvent event) { } + + @Async + @EventListener + public void handleUserLockedEvent(UserLockedEvent event) { + try (var connection = factory.newConnection()) { + var eto = mapper.map(event, UserLockedEto.class); + + var aggregate = event.getAggregate(User.class); + if (aggregate != null) { + eto.setPhone(aggregate.getPhone()); + eto.setEmail(aggregate.getEmail()); + eto.setUsername(aggregate.getUsername()); + } + + var message = new ObjectMapper().writeValueAsString(eto); + + var channel = connection.createChannel(); + channel.exchangeDeclare(EventConstant.USER_LOCKED, "fanout", true); + var properties = new AMQP.BasicProperties().builder() + .contentType("application/json") + .build(); + channel.basicPublish(EventConstant.USER_LOCKED, "", properties, message.getBytes()); + + } catch (Exception exception) { + LOGGER.error(exception.getMessage(), exception); + } + } } diff --git a/identity/src/main/java/io/theurl/identity/configure/SecurityConfiguration.java b/identity/src/main/java/io/theurl/identity/configure/SecurityConfiguration.java index e9b8e1d..7f9c08b 100644 --- a/identity/src/main/java/io/theurl/identity/configure/SecurityConfiguration.java +++ b/identity/src/main/java/io/theurl/identity/configure/SecurityConfiguration.java @@ -23,7 +23,7 @@ public class SecurityConfiguration { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http, - JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception { + JwtAuthenticationFilter jwtAuthenticationFilter) { http.csrf(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) @@ -34,6 +34,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, "/auth/**", "/account/register", "/account/password/reset", + "/otp/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", diff --git a/identity/src/main/java/io/theurl/identity/domain/aggregate/OnetimePassword.java b/identity/src/main/java/io/theurl/identity/domain/aggregate/OnetimePassword.java index b48e753..daccfad 100644 --- a/identity/src/main/java/io/theurl/identity/domain/aggregate/OnetimePassword.java +++ b/identity/src/main/java/io/theurl/identity/domain/aggregate/OnetimePassword.java @@ -37,7 +37,7 @@ public static OnetimePassword create(String requestId, String recipient, String OnetimePassword otp = new OnetimePassword(SnowflakeId.getInstance().nextId(), requestId, recipient, code); otp.duration = duration; if (duration != null) { - otp.expiration = LocalDateTime.now().plusSeconds(duration); + otp.expiration = LocalDateTime.now().plusMinutes(duration); } otp.raiseEvent(new OnetimePasswordCreatedEvent() {{ setRequestId(requestId); diff --git a/identity/src/main/java/io/theurl/identity/interfaces/controller/OnetimePasswordController.java b/identity/src/main/java/io/theurl/identity/interfaces/controller/OnetimePasswordController.java index 5405ede..29b1ee4 100644 --- a/identity/src/main/java/io/theurl/identity/interfaces/controller/OnetimePasswordController.java +++ b/identity/src/main/java/io/theurl/identity/interfaces/controller/OnetimePasswordController.java @@ -15,8 +15,18 @@ public OnetimePasswordController(OnetimePasswordApplicationService service) { this.service = service; } - @PostMapping("send") - public CompletableFuture sendOtp(@RequestBody OnetimePasswordSendRequestDto request) { - return service.sendAsync(request); + @PostMapping("authentication") + public CompletableFuture sendAuthOtp(@RequestBody OnetimePasswordSendRequestDto request) { + return service.sendAsync(request.recipient(), "authentication"); + } + + @PostMapping("change-email") + public CompletableFuture sendChangeEmailOtp(@RequestBody OnetimePasswordSendRequestDto request) { + return service.sendAsync(request.recipient(), "change-email"); + } + + @PostMapping("reset-password") + public CompletableFuture sendResetPasswordOtp(@RequestBody OnetimePasswordSendRequestDto request) { + return service.sendAsync(request.recipient(), "reset-password"); } } diff --git a/identity/src/main/resources/application.yaml b/identity/src/main/resources/application.yaml index a20460e..fc03505 100644 --- a/identity/src/main/resources/application.yaml +++ b/identity/src/main/resources/application.yaml @@ -37,7 +37,7 @@ spring: include-exception: true include-stacktrace: ALWAYS rabbitmq: - host: ${RABBITMQ_HOST:localhost} + host: ${RABBITMQ_HOST:127.0.0.1} port: ${RABBITMQ_PORT:5672} username: ${RABBITMQ_USERNAME:guest} password: ${RABBITMQ_PASSWORD:guest} From 84651c632432553e3e753be0854b36b81545496e Mon Sep 17 00:00:00 2001 From: damon Date: Thu, 28 May 2026 10:49:00 +0800 Subject: [PATCH 13/30] Add RabbitMQ configuration and event constants for user locking and OTP creation --- .../configure/RabbitMqConfiguration.java | 29 +++++++++++++++++++ message/src/main/resources/application.yaml | 5 ++++ .../theurl/shared/constant/EventConstant.java | 6 ++++ .../io/theurl/shared/event/UserLockedEto.java | 13 +++++++++ 4 files changed, 53 insertions(+) create mode 100644 message/src/main/java/io/theurl/message/configure/RabbitMqConfiguration.java create mode 100644 shared/src/main/java/io/theurl/shared/constant/EventConstant.java create mode 100644 shared/src/main/java/io/theurl/shared/event/UserLockedEto.java diff --git a/message/src/main/java/io/theurl/message/configure/RabbitMqConfiguration.java b/message/src/main/java/io/theurl/message/configure/RabbitMqConfiguration.java new file mode 100644 index 0000000..df83bc6 --- /dev/null +++ b/message/src/main/java/io/theurl/message/configure/RabbitMqConfiguration.java @@ -0,0 +1,29 @@ +package io.theurl.message.configure; + +import com.rabbitmq.client.ConnectionFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RabbitMqConfiguration { + @Value("${spring.rabbitmq.host}") + private String host; + @Value("${spring.rabbitmq.port}") + private int port; + @Value("${spring.rabbitmq.username}") + private String username; + @Value("${spring.rabbitmq.password}") + private String password; + + @Bean + public ConnectionFactory connectionFactory() { + ConnectionFactory factory = new ConnectionFactory(); + factory.setHost(host); + factory.setPort(port); + factory.setUsername(username); + factory.setPassword(password); + factory.setVirtualHost("/"); + return factory; + } +} diff --git a/message/src/main/resources/application.yaml b/message/src/main/resources/application.yaml index cefa816..443e4bc 100644 --- a/message/src/main/resources/application.yaml +++ b/message/src/main/resources/application.yaml @@ -27,3 +27,8 @@ spring: url: ${REDIS_URL:redis://localhost:6379} mongodb: uri: ${MONGO_URI:mongodb://localhost:27017/linkyou} + rabbitmq: + host: ${RABBITMQ_HOST:127.0.0.1} + port: ${RABBITMQ_PORT:5672} + username: ${RABBITMQ_USERNAME:guest} + password: ${RABBITMQ_PASSWORD:guest} diff --git a/shared/src/main/java/io/theurl/shared/constant/EventConstant.java b/shared/src/main/java/io/theurl/shared/constant/EventConstant.java new file mode 100644 index 0000000..6a0a504 --- /dev/null +++ b/shared/src/main/java/io/theurl/shared/constant/EventConstant.java @@ -0,0 +1,6 @@ +package io.theurl.shared.constant; + +public class EventConstant { + public static final String USER_LOCKED = "io.theurl.identity.user.locked"; + public static final String OTP_CREATED = "io.theurl.identity.otp.created"; +} diff --git a/shared/src/main/java/io/theurl/shared/event/UserLockedEto.java b/shared/src/main/java/io/theurl/shared/event/UserLockedEto.java new file mode 100644 index 0000000..028af04 --- /dev/null +++ b/shared/src/main/java/io/theurl/shared/event/UserLockedEto.java @@ -0,0 +1,13 @@ +package io.theurl.shared.event; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class UserLockedEto { + private String username; + private String email; + private String phone; + private LocalDateTime lockedUntil; +} From 6fd79b9c25ac9cc7828ebbe072b48dd3230d102f Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 29 May 2026 10:01:58 +0800 Subject: [PATCH 14/30] Initialize bundle module with Spring Boot application, Maven wrapper, and basic configuration --- .gitignore | 2 + bundle/.gitattributes | 2 + bundle/.gitignore | 33 ++ bundle/.mvn/wrapper/maven-wrapper.properties | 3 + bundle/mvnw | 295 ++++++++++++++++++ bundle/mvnw.cmd | 189 +++++++++++ bundle/pom.xml | 148 +++++++++ .../io/theurl/bundle/BundleApplication.java | 13 + bundle/src/main/resources/application.yaml | 3 + .../theurl/bundle/BundleApplicationTests.java | 13 + .../framework/domain/AggregateRoot.java | 22 ++ pom.xml | 1 + 12 files changed, 724 insertions(+) create mode 100644 bundle/.gitattributes create mode 100644 bundle/.gitignore create mode 100644 bundle/.mvn/wrapper/maven-wrapper.properties create mode 100755 bundle/mvnw create mode 100644 bundle/mvnw.cmd create mode 100644 bundle/pom.xml create mode 100644 bundle/src/main/java/io/theurl/bundle/BundleApplication.java create mode 100644 bundle/src/main/resources/application.yaml create mode 100644 bundle/src/test/java/io/theurl/bundle/BundleApplicationTests.java diff --git a/.gitignore b/.gitignore index e331b61..ad5a90a 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,8 @@ build/ ### VS Code ### .vscode/ +.air/ + .env application-*.properties diff --git a/bundle/.gitattributes b/bundle/.gitattributes new file mode 100644 index 0000000..3b41682 --- /dev/null +++ b/bundle/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/bundle/.gitignore b/bundle/.gitignore new file mode 100644 index 0000000..667aaef --- /dev/null +++ b/bundle/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/bundle/.mvn/wrapper/maven-wrapper.properties b/bundle/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..216df05 --- /dev/null +++ b/bundle/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip diff --git a/bundle/mvnw b/bundle/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/bundle/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/bundle/mvnw.cmd b/bundle/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/bundle/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/bundle/pom.xml b/bundle/pom.xml new file mode 100644 index 0000000..4a1a312 --- /dev/null +++ b/bundle/pom.xml @@ -0,0 +1,148 @@ + + + 4.0.0 + + io.theurl + parent + 1.0 + ../pom.xml + + + bundle + bundle + bundle + + false + + + + + org.springframework.boot + spring-boot-starter-amqp + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-webmvc + + + + + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + com.mysql + mysql-connector-j + runtime + + + org.postgresql + postgresql + runtime + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-amqp-test + test + + + org.springframework.boot + spring-boot-starter-data-jpa-test + test + + + org.springframework.boot + spring-boot-starter-security-test + test + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + default-compile + compile + + compile + + + + + org.projectlombok + lombok + + + + + + default-testCompile + test-compile + + testCompile + + + + + org.projectlombok + lombok + + + + + + + + + + diff --git a/bundle/src/main/java/io/theurl/bundle/BundleApplication.java b/bundle/src/main/java/io/theurl/bundle/BundleApplication.java new file mode 100644 index 0000000..1abc984 --- /dev/null +++ b/bundle/src/main/java/io/theurl/bundle/BundleApplication.java @@ -0,0 +1,13 @@ +package io.theurl.bundle; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class BundleApplication { + + public static void main(String[] args) { + SpringApplication.run(BundleApplication.class, args); + } + +} diff --git a/bundle/src/main/resources/application.yaml b/bundle/src/main/resources/application.yaml new file mode 100644 index 0000000..c5436c3 --- /dev/null +++ b/bundle/src/main/resources/application.yaml @@ -0,0 +1,3 @@ +spring: + application: + name: bundle diff --git a/bundle/src/test/java/io/theurl/bundle/BundleApplicationTests.java b/bundle/src/test/java/io/theurl/bundle/BundleApplicationTests.java new file mode 100644 index 0000000..748603e --- /dev/null +++ b/bundle/src/test/java/io/theurl/bundle/BundleApplicationTests.java @@ -0,0 +1,13 @@ +package io.theurl.bundle; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class BundleApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/framework/src/main/java/io/theurl/framework/domain/AggregateRoot.java b/framework/src/main/java/io/theurl/framework/domain/AggregateRoot.java index 498ad9a..cb9ceaa 100644 --- a/framework/src/main/java/io/theurl/framework/domain/AggregateRoot.java +++ b/framework/src/main/java/io/theurl/framework/domain/AggregateRoot.java @@ -1,5 +1,7 @@ package io.theurl.framework.domain; +import java.beans.PropertyChangeListener; +import java.beans.PropertyChangeSupport; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -12,6 +14,8 @@ public class AggregateRoot> extends Entity impleme private final Map, Consumer> eventHandlers = new HashMap<>(); + protected final PropertyChangeSupport support = new PropertyChangeSupport(this); + /** * Initializes the aggregate with the given id. * @@ -60,4 +64,22 @@ public void attachEvent() { event.attach(this); } } + + /** + * Adds a property change listener to the aggregate root. + * + * @param listener the listener to be added + */ + public void addPropertyChangeListener(PropertyChangeListener listener) { + support.addPropertyChangeListener(listener); + } + + /** + * Removes a property change listener from the aggregate root. + * + * @param listener the listener to be removed + */ + public void removePropertyChangeListener(PropertyChangeListener listener) { + support.removePropertyChangeListener(listener); + } } diff --git a/pom.xml b/pom.xml index 39780c0..540d9dd 100644 --- a/pom.xml +++ b/pom.xml @@ -17,6 +17,7 @@ theurl root pom https://theurl.io + bundle config framework identity From ed018836a8a475f6532aadb4564a175d09d0ba38 Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 29 May 2026 11:21:41 +0800 Subject: [PATCH 15/30] Refactor JwtAuthenticationFilter package structure to align with framework security conventions --- framework/pom.xml | 20 +++++++++++++++++++ .../security}/JwtAuthenticationFilter.java | 10 ++++++---- .../configure/SecurityConfiguration.java | 2 +- 3 files changed, 27 insertions(+), 5 deletions(-) rename {identity/src/main/java/io/theurl/identity/configure => framework/src/main/java/io/theurl/framework/security}/JwtAuthenticationFilter.java (92%) diff --git a/framework/pom.xml b/framework/pom.xml index f8ddb63..ac6df7e 100644 --- a/framework/pom.xml +++ b/framework/pom.xml @@ -22,6 +22,22 @@ mediator ${neroyun.mediator.version} + + io.jsonwebtoken + jjwt-api + 0.13.0 + + + io.jsonwebtoken + jjwt-impl + 0.13.0 + + + io.jsonwebtoken + jjwt-jackson + 0.13.0 + runtime + org.springframework spring-core @@ -46,6 +62,10 @@ org.springframework.boot spring-boot + + org.springframework.boot + spring-boot-security + org.apache.tomcat.embed tomcat-embed-core diff --git a/identity/src/main/java/io/theurl/identity/configure/JwtAuthenticationFilter.java b/framework/src/main/java/io/theurl/framework/security/JwtAuthenticationFilter.java similarity index 92% rename from identity/src/main/java/io/theurl/identity/configure/JwtAuthenticationFilter.java rename to framework/src/main/java/io/theurl/framework/security/JwtAuthenticationFilter.java index bdc8a66..f37e9b8 100644 --- a/identity/src/main/java/io/theurl/identity/configure/JwtAuthenticationFilter.java +++ b/framework/src/main/java/io/theurl/framework/security/JwtAuthenticationFilter.java @@ -1,4 +1,4 @@ -package io.theurl.identity.configure; +package io.theurl.framework.security; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; @@ -6,8 +6,9 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import lombok.extern.slf4j.Slf4j; import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -26,10 +27,11 @@ * If the token is valid, it extracts the user ID from the token claims and creates an authentication object, which is then set in the SecurityContext for downstream processing. * If token parsing fails, it logs the error and allows the request to proceed without authentication, which will be handled by subsequent security filters. */ -@Slf4j @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationFilter.class); + @Value("${jwt.secret}") private String signingKey; @@ -58,7 +60,7 @@ protected void doFilterInternal(HttpServletRequest request, } } catch (Exception e) { // Don't throw an exception when token parsing fails, let the subsequent authentication process handle it and return 401. - log.debug("JWT parse failed: {}", e.getMessage()); + LOGGER.debug("JWT parse failed: {}", e.getMessage()); } } diff --git a/identity/src/main/java/io/theurl/identity/configure/SecurityConfiguration.java b/identity/src/main/java/io/theurl/identity/configure/SecurityConfiguration.java index 7f9c08b..31bd4c0 100644 --- a/identity/src/main/java/io/theurl/identity/configure/SecurityConfiguration.java +++ b/identity/src/main/java/io/theurl/identity/configure/SecurityConfiguration.java @@ -23,7 +23,7 @@ public class SecurityConfiguration { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http, - JwtAuthenticationFilter jwtAuthenticationFilter) { + io.theurl.framework.security.JwtAuthenticationFilter jwtAuthenticationFilter) { http.csrf(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) From 060642979168314567bb82f06e46744768067ce7 Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 29 May 2026 11:33:43 +0800 Subject: [PATCH 16/30] Remove unused JWT dependencies from pom.xml to streamline project configuration --- identity/pom.xml | 21 --------------------- message/pom.xml | 5 ----- 2 files changed, 26 deletions(-) diff --git a/identity/pom.xml b/identity/pom.xml index 3354730..5689fbb 100644 --- a/identity/pom.xml +++ b/identity/pom.xml @@ -29,27 +29,6 @@ ${project.version} compile - - com.neroyun - mediator - ${neroyun.mediator.version} - - - io.jsonwebtoken - jjwt-api - 0.13.0 - - - io.jsonwebtoken - jjwt-impl - 0.13.0 - - - io.jsonwebtoken - jjwt-jackson - 0.13.0 - runtime - org.springframework.boot spring-boot-starter-amqp diff --git a/message/pom.xml b/message/pom.xml index 62db91c..fe0cede 100644 --- a/message/pom.xml +++ b/message/pom.xml @@ -18,11 +18,6 @@ ${project.version} compile - - com.neroyun - mediator - ${neroyun.mediator.version} - org.springframework.boot spring-boot-starter-amqp From b1d9438bec75afe1ca8bfba0a5fd075b457b951f Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 29 May 2026 11:40:32 +0800 Subject: [PATCH 17/30] Add configuration classes for ModelMapper and OpenAPI security; update security settings and enable async processing --- bundle/pom.xml | 25 ++++++++-- .../io/theurl/bundle/BundleApplication.java | 2 + .../configure/ModelMapperConfiguration.java | 24 ++++++++++ .../OpenApiSecurityConfiguration.java | 27 +++++++++++ .../configure/SecurityConfiguration.java | 46 +++++++++++++++++++ 5 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 bundle/src/main/java/io/theurl/bundle/configure/ModelMapperConfiguration.java create mode 100644 bundle/src/main/java/io/theurl/bundle/configure/OpenApiSecurityConfiguration.java create mode 100644 bundle/src/main/java/io/theurl/bundle/configure/SecurityConfiguration.java diff --git a/bundle/pom.xml b/bundle/pom.xml index 4a1a312..511d955 100644 --- a/bundle/pom.xml +++ b/bundle/pom.xml @@ -17,6 +17,18 @@ + + io.theurl + framework + ${project.version} + compile + + + io.theurl + shared + ${project.version} + compile + org.springframework.boot spring-boot-starter-amqp @@ -33,10 +45,15 @@ org.springframework.boot spring-boot-starter-webmvc - - - - + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 3.0.3 + + + org.springframework.cloud + spring-cloud-starter-config + org.springframework.boot diff --git a/bundle/src/main/java/io/theurl/bundle/BundleApplication.java b/bundle/src/main/java/io/theurl/bundle/BundleApplication.java index 1abc984..db134cb 100644 --- a/bundle/src/main/java/io/theurl/bundle/BundleApplication.java +++ b/bundle/src/main/java/io/theurl/bundle/BundleApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; +@EnableAsync @SpringBootApplication public class BundleApplication { diff --git a/bundle/src/main/java/io/theurl/bundle/configure/ModelMapperConfiguration.java b/bundle/src/main/java/io/theurl/bundle/configure/ModelMapperConfiguration.java new file mode 100644 index 0000000..9870694 --- /dev/null +++ b/bundle/src/main/java/io/theurl/bundle/configure/ModelMapperConfiguration.java @@ -0,0 +1,24 @@ +package io.theurl.bundle.configure; + +import org.modelmapper.ModelMapper; +import org.modelmapper.config.Configuration.AccessLevel; +import org.modelmapper.convention.MatchingStrategies; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ModelMapperConfiguration { + @Bean + public ModelMapper modelMapper() { + ModelMapper mapper = new ModelMapper(); + + mapper.getConfiguration() + .setMatchingStrategy(MatchingStrategies.STRICT) + .setFieldMatchingEnabled(true) + .setFieldAccessLevel(AccessLevel.PRIVATE) + .setCollectionsMergeEnabled(true) + .setSkipNullEnabled(true); + + return mapper; + } +} diff --git a/bundle/src/main/java/io/theurl/bundle/configure/OpenApiSecurityConfiguration.java b/bundle/src/main/java/io/theurl/bundle/configure/OpenApiSecurityConfiguration.java new file mode 100644 index 0000000..3b02f52 --- /dev/null +++ b/bundle/src/main/java/io/theurl/bundle/configure/OpenApiSecurityConfiguration.java @@ -0,0 +1,27 @@ +package io.theurl.bundle.configure; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiSecurityConfiguration { + + @Bean + public OpenAPI customOpenAPI() { + final String bearerSchemeName = "bearerAuth"; + + return new OpenAPI() + .components(new Components().addSecuritySchemes( + bearerSchemeName, + new SecurityScheme() + .name(bearerSchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + )); + } +} + diff --git a/bundle/src/main/java/io/theurl/bundle/configure/SecurityConfiguration.java b/bundle/src/main/java/io/theurl/bundle/configure/SecurityConfiguration.java new file mode 100644 index 0000000..85e17e7 --- /dev/null +++ b/bundle/src/main/java/io/theurl/bundle/configure/SecurityConfiguration.java @@ -0,0 +1,46 @@ +package io.theurl.bundle.configure; + +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +/** + * The security configuration for the application, defining the security filter chain and authentication rules. + *

+ * This configuration disables CSRF, form login, and HTTP basic authentication, and sets the session management to stateless. + * It also configures exception handling to return a 401 Unauthorized response for unauthenticated requests. + * The authorization rules allow unauthenticated access to specific endpoints (e.g., authentication and registration endpoints, API documentation) while requiring authentication for all other requests. + * The JwtAuthenticationFilter is added to the filter chain before the UsernamePasswordAuthenticationFilter to handle JWT token parsing and authentication. + *

+ */ +@Configuration +public class SecurityConfiguration { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, + io.theurl.framework.security.JwtAuthenticationFilter jwtAuthenticationFilter) { + http.csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(ex -> ex.authenticationEntryPoint((request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"))) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/error" + ).permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} + From 7a9c884281b663134a67a029d86f283267d56117 Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 29 May 2026 11:51:14 +0800 Subject: [PATCH 18/30] Rename DistributedEventBus to DistributedEventPublisher; refactor constructor to use ConnectionFactory directly and add RabbitMQ configuration class --- ...us.java => DistributedEventPublisher.java} | 37 ++++--------------- .../configure/RabbitMqConfiguration.java | 23 ++++++++++++ .../configure/RabbitMqConfiguration.java | 14 ++----- 3 files changed, 34 insertions(+), 40 deletions(-) rename identity/src/main/java/io/theurl/identity/application/subscriber/{DistributedEventBus.java => DistributedEventPublisher.java} (74%) create mode 100644 identity/src/main/java/io/theurl/identity/configure/RabbitMqConfiguration.java diff --git a/identity/src/main/java/io/theurl/identity/application/subscriber/DistributedEventBus.java b/identity/src/main/java/io/theurl/identity/application/subscriber/DistributedEventPublisher.java similarity index 74% rename from identity/src/main/java/io/theurl/identity/application/subscriber/DistributedEventBus.java rename to identity/src/main/java/io/theurl/identity/application/subscriber/DistributedEventPublisher.java index 8267df2..d2f477d 100644 --- a/identity/src/main/java/io/theurl/identity/application/subscriber/DistributedEventBus.java +++ b/identity/src/main/java/io/theurl/identity/application/subscriber/DistributedEventPublisher.java @@ -11,40 +11,21 @@ import org.modelmapper.ModelMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.event.EventListener; -import org.springframework.core.env.Environment; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @Component -public class DistributedEventBus { - private final Logger LOGGER = LoggerFactory.getLogger(DistributedEventBus.class); +public class DistributedEventPublisher { + private final Logger LOGGER = LoggerFactory.getLogger(DistributedEventPublisher.class); private final ModelMapper mapper; - @Value("${spring.rabbitmq.host}") - private String host; - @Value("${spring.rabbitmq.port}") - private int port; - @Value("${spring.rabbitmq.username}") - private String username; - @Value("${spring.rabbitmq.password}") - private String password; - private final ConnectionFactory factory; - public DistributedEventBus(ModelMapper mapper, Environment environment) { - System.out.println(host + ":" + port); - host = environment.getProperty("spring.rabbitmq.host"); - username = environment.getProperty("spring.rabbitmq.username"); - password = environment.getProperty("spring.rabbitmq.password"); + public DistributedEventPublisher(ModelMapper mapper, ConnectionFactory factory) { this.mapper = mapper; - this.factory = new ConnectionFactory(); - this.factory.setHost(host); - //this.factory.setPort(port); - this.factory.setUsername(username); - this.factory.setPassword(password); + this.factory = factory; } @Async @@ -70,23 +51,19 @@ public void handleOnetimePasswordCreatedEvent(OnetimePasswordCreatedEvent event) @Async @EventListener public void handleUserEmailChangedEvent(UserEmailChangedEvent event) { - try { - - } catch (Exception exception) { - LOGGER.error(exception.getMessage(), exception); - } + LOGGER.debug("收到用户邮箱变更事件,当前尚未接入分布式发布:{}", event.getClass().getSimpleName()); } @Async @EventListener public void handleUserPasswordChangedEvent(UserPasswordChangedEvent event) { - + LOGGER.debug("收到用户密码变更事件,当前尚未接入分布式发布:{}", event.getClass().getSimpleName()); } @Async @EventListener public void handleUserPhoneChangedEvent(UserPhoneChangedEvent event) { - + LOGGER.debug("收到用户手机号变更事件,当前尚未接入分布式发布:{}", event.getClass().getSimpleName()); } @Async diff --git a/identity/src/main/java/io/theurl/identity/configure/RabbitMqConfiguration.java b/identity/src/main/java/io/theurl/identity/configure/RabbitMqConfiguration.java new file mode 100644 index 0000000..0587410 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/configure/RabbitMqConfiguration.java @@ -0,0 +1,23 @@ +package io.theurl.identity.configure; + +import com.rabbitmq.client.ConnectionFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RabbitMqConfiguration { + @Bean + public ConnectionFactory connectionFactory(@Value("${spring.rabbitmq.host}") String host, + @Value("${spring.rabbitmq.port}") int port, + @Value("${spring.rabbitmq.username}") String username, + @Value("${spring.rabbitmq.password}") String password) { + ConnectionFactory factory = new ConnectionFactory(); + factory.setHost(host); + factory.setPort(port); + factory.setUsername(username); + factory.setPassword(password); + factory.setVirtualHost("/"); + return factory; + } +} diff --git a/message/src/main/java/io/theurl/message/configure/RabbitMqConfiguration.java b/message/src/main/java/io/theurl/message/configure/RabbitMqConfiguration.java index df83bc6..b9339d4 100644 --- a/message/src/main/java/io/theurl/message/configure/RabbitMqConfiguration.java +++ b/message/src/main/java/io/theurl/message/configure/RabbitMqConfiguration.java @@ -7,17 +7,11 @@ @Configuration public class RabbitMqConfiguration { - @Value("${spring.rabbitmq.host}") - private String host; - @Value("${spring.rabbitmq.port}") - private int port; - @Value("${spring.rabbitmq.username}") - private String username; - @Value("${spring.rabbitmq.password}") - private String password; - @Bean - public ConnectionFactory connectionFactory() { + public ConnectionFactory connectionFactory(@Value("${spring.rabbitmq.host}") String host, + @Value("${spring.rabbitmq.port}") int port, + @Value("${spring.rabbitmq.username}") String username, + @Value("${spring.rabbitmq.password}") String password) { ConnectionFactory factory = new ConnectionFactory(); factory.setHost(host); factory.setPort(port); From 2ce802a51d3f3047c030729e3f5f0de42042534e Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 29 May 2026 11:51:37 +0800 Subject: [PATCH 19/30] Refactor findByAnyOf method in UserRepositoryImpl to streamline mapping logic --- .../identity/persistence/repository/UserRepositoryImpl.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/identity/src/main/java/io/theurl/identity/persistence/repository/UserRepositoryImpl.java b/identity/src/main/java/io/theurl/identity/persistence/repository/UserRepositoryImpl.java index 83dfca6..fce7577 100644 --- a/identity/src/main/java/io/theurl/identity/persistence/repository/UserRepositoryImpl.java +++ b/identity/src/main/java/io/theurl/identity/persistence/repository/UserRepositoryImpl.java @@ -70,10 +70,7 @@ public User findByPhone(String phone) { @Override public User findByAnyOf(String username, String email, String phone) { return repository.findByAnyOf(username, email, phone) - .map(entity ->{ - System.out.println(entity); - return mapper.map(entity, User.class); - }) + .map(entity -> mapper.map(entity, User.class)) .orElse(null); } } From 9ef193722d4e2a292f81513a391343fe08548551 Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 29 May 2026 13:16:20 +0800 Subject: [PATCH 20/30] Refactor Dockerfile and pom.xml for improved build process and dependency management --- config/Dockerfile | 27 +++++++++++++++++++-------- config/pom.xml | 23 ++++++++++++++++++----- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/config/Dockerfile b/config/Dockerfile index 34f27ba..673d92e 100644 --- a/config/Dockerfile +++ b/config/Dockerfile @@ -1,15 +1,26 @@ -# Stage 1: Build the application +# Stage 1: Build the config module from the multi-module root context. FROM maven:3.9.15-eclipse-temurin-25 AS build -WORKDIR /app -COPY pom.xml . -COPY src ./src -RUN mvn clean package -DskipTests +WORKDIR /workspace + +# Build with repository root as context: docker build -t theurl.io/config -f config/Dockerfile . +COPY pom.xml ./pom.xml +COPY config/pom.xml ./config/pom.xml + +# Resolve dependencies in a separate layer to speed up rebuilds. +RUN mvn -f config/pom.xml -DskipTests dependency:go-offline + +COPY config/src ./config/src + +# Force Spring Boot repackage and override parent skip flag to guarantee fat JAR output. +RUN mvn -f config/pom.xml clean package spring-boot:repackage -DskipTests -Dspring-boot.run.skip=false # Stage 2: Run the application FROM eclipse-temurin:25-jre-alpine WORKDIR /app -COPY --from=build /app/target/*.jar app.jar -EXPOSE 8900 -ENTRYPOINT ["java", "-jar", "app.jar"] + +COPY --from=build /workspace/config/target/config-1.0.jar /app/app.jar LABEL maintainer="damon " + +EXPOSE 8900 +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/config/pom.xml b/config/pom.xml index 285f24f..787b6b3 100644 --- a/config/pom.xml +++ b/config/pom.xml @@ -19,16 +19,16 @@ org.springframework.cloud spring-cloud-config-server
- - com.alibaba.cloud - spring-cloud-starter-alibaba-nacos-discovery - - org.springframework.boot spring-boot-starter-test test + + org.springframework.boot + spring-boot-maven-plugin + 4.0.6 +
@@ -37,6 +37,19 @@ org.springframework.boot spring-boot-maven-plugin + + org.apache.maven.plugins + maven-jar-plugin + 3.5.0 + + + + true + io.theurl.config.ConfigApplication + + + + From c3a914a6ceb45085eb78613adb144d7ab0f96bb0 Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 29 May 2026 13:20:37 +0800 Subject: [PATCH 21/30] Add application configuration and logging setup in YAML and XML files --- bundle/src/main/resources/application.yaml | 71 +++++++++++- bundle/src/main/resources/logback-spring.xml | 107 +++++++++++++++++++ 2 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 bundle/src/main/resources/logback-spring.xml diff --git a/bundle/src/main/resources/application.yaml b/bundle/src/main/resources/application.yaml index c5436c3..962d792 100644 --- a/bundle/src/main/resources/application.yaml +++ b/bundle/src/main/resources/application.yaml @@ -1,3 +1,72 @@ +server: + port: 8903 + spring: + profiles: + active: ${SPRING_PROFILES_ACTIVE:dev} application: - name: bundle + name: identity + config: + import: optional:file:.env[.properties] + cloud: + config: + enabled: false + uri: ${CONFIG_SERVER_URI:http://localhost:8900} + datasource: + url: ${DB_URL:jdbc:postgresql://localhost:5432/linkyou?currentSchema=public} + username: ${DB_USERNAME:postgres} + password: ${DB_PASSWORD:postgres} + driver-class-name: ${DB_DRIVER:org.postgresql.Driver} + jpa: + hibernate: + ddl-auto: update + dialect: ${DB_DIALECT:org.hibernate.dialect.PostgreSQLDialect} + show-sql: true + properties: + hibernate: + multiTenancy: SCHEMA + format_sql: true + data: + redis: + url: ${REDIS_URL:redis://localhost:6379} + mongodb: + uri: ${MONGO_URI:mongodb://localhost:27017/linkyou} + web: + error: + include-message: ALWAYS + include-exception: true + include-stacktrace: ALWAYS + rabbitmq: + host: ${RABBITMQ_HOST:127.0.0.1} + port: ${RABBITMQ_PORT:5672} + username: ${RABBITMQ_USERNAME:guest} + password: ${RABBITMQ_PASSWORD:guest} + +external-auth: + redirect-uri: "https://theurl.io/auth/callback" + google: + client-id: ${GOOGLE_CLIENT_ID:your-google-client-id} + client-secret: ${GOOGLE_CLIENT_SECRET:your-google-client-secret} + github: + client-id: ${GITHUB_CLIENT_ID:your-github-client-id} + client-secret: ${GITHUB_CLIENT_SECRET:your-github-client-secret} + facebook: + client-id: ${FACEBOOK_CLIENT_ID:your-facebook-client-id} + client-secret: ${FACEBOOK_CLIENT_SECRET:your-facebook-client-secret} + microsoft: + client-id: ${MICROSOFT_CLIENT_ID:your-microsoft-client-id} + client-secret: ${MICROSOFT_CLIENT_SECRET:your-microsoft-client-secret} + +jwt: + secret: ${JWT_SECRET:your-jwt-secret} + issuer: theurl.io + expiration: 3600 # in seconds + +logging: + file: + name: #{level}-{T(java.time.LocalDate).now()}.log + path: logs + level: + io.theurl.identity: debug + org.springframework: debug + root: info diff --git a/bundle/src/main/resources/logback-spring.xml b/bundle/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..5306e44 --- /dev/null +++ b/bundle/src/main/resources/logback-spring.xml @@ -0,0 +1,107 @@ + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n + utf8 + + + + + ${LOG_PATH}/bundle/info/current.log + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + ${LOG_PATH}/bundle/info/%d{yyyy-MM-dd}.log + 100MB + 30 + + + + + ${LOG_PATH}/bundle/error/current.log + + ERROR + ACCEPT + DENY + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + ${LOG_PATH}/bundle/error/%d{yyyy-MM-dd}.log + 100MB + 30 + + + + + ${LOG_PATH}/bundle/warn/current.log + + WARN + ACCEPT + DENY + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + ${LOG_PATH}/bundle/warn/%d{yyyy-MM-dd}.log + 100MB + 30 + + + + + ${LOG_PATH}/bundle/debug/current.log + + DEBUG + ACCEPT + DENY + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + ${LOG_PATH}/bundle/debug/%d{yyyy-MM-dd}.log + 100MB + 30 + + + + + ${LOG_PATH}/bundle/sql/current.log + + DEBUG + ACCEPT + DENY + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + ${LOG_PATH}/bundle/sql/%d{yyyy-MM-dd}.log + 100MB + 30 + + + + + + + + + + + + + + + + + + + From 19c4e9b26764d6a6f85f59dd0aff8af5d28be5c2 Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 29 May 2026 13:20:49 +0800 Subject: [PATCH 22/30] Add .dockerignore file to exclude VCS, build outputs, logs, and OS files --- .dockerignore | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2a32a59 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +# VCS and editor +.git +.gitignore +.idea +.vscode + +# Build outputs +**/target/ + +# Local logs and runtime files +logs/ +**/*.log + +# OS files +.DS_Store + From 9f0b3443029df5fd92386461714b3563182d7451 Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 29 May 2026 13:21:08 +0800 Subject: [PATCH 23/30] Add Maven Compiler Plugin configuration for annotation processing with Lombok --- shared/pom.xml | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/shared/pom.xml b/shared/pom.xml index 38f0d31..049686a 100644 --- a/shared/pom.xml +++ b/shared/pom.xml @@ -20,4 +20,48 @@
+ + + + org.apache.maven.plugins + maven-compiler-plugin + + true + + + + default-compile + compile + + compile + + + + + org.projectlombok + lombok + + + + + + default-testCompile + test-compile + + testCompile + + + + + org.projectlombok + lombok + + + + + + + + + From 3d44cea90723e70e5614eaf8c1b7527e4f39fea4 Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 29 May 2026 13:56:45 +0800 Subject: [PATCH 24/30] Update application configuration and Dockerfile for environment variable support --- config/Dockerfile | 4 ++++ config/src/main/resources/application.yaml | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/config/Dockerfile b/config/Dockerfile index 673d92e..96d7a30 100644 --- a/config/Dockerfile +++ b/config/Dockerfile @@ -18,6 +18,10 @@ RUN mvn -f config/pom.xml clean package spring-boot:repackage -DskipTests -Dspri FROM eclipse-temurin:25-jre-alpine WORKDIR /app +ENV JAVA_OPTS="-Xms512m -Xmx1024m" +ENV SPRING_PROFILES_ACTIVE=native +ENV CONFIG_SEARCH_LOCATIONS=file:./config/ + COPY --from=build /workspace/config/target/config-1.0.jar /app/app.jar LABEL maintainer="damon " diff --git a/config/src/main/resources/application.yaml b/config/src/main/resources/application.yaml index cd86cbe..736e9e2 100644 --- a/config/src/main/resources/application.yaml +++ b/config/src/main/resources/application.yaml @@ -5,9 +5,9 @@ spring: application: name: config profiles: - active: native + active: ${SPRING_PROFILES_ACTIVE:native} cloud: config: server: native: - search-locations: file:./config-repo/ + search-locations: ${CONFIG_SEARCH_LOCATIONS:file:./config/} From fb781f1629b5234a5dfb21781b25324386f724e2 Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 29 May 2026 14:06:42 +0800 Subject: [PATCH 25/30] Update application configuration and Dockerfile for CONFIG_SEARCH_LOCATIONS path change --- config/Dockerfile | 2 +- config/src/main/resources/application.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/Dockerfile b/config/Dockerfile index 96d7a30..accf978 100644 --- a/config/Dockerfile +++ b/config/Dockerfile @@ -20,7 +20,7 @@ WORKDIR /app ENV JAVA_OPTS="-Xms512m -Xmx1024m" ENV SPRING_PROFILES_ACTIVE=native -ENV CONFIG_SEARCH_LOCATIONS=file:./config/ +ENV CONFIG_SEARCH_LOCATIONS=file:/config-repo} COPY --from=build /workspace/config/target/config-1.0.jar /app/app.jar diff --git a/config/src/main/resources/application.yaml b/config/src/main/resources/application.yaml index 736e9e2..820599e 100644 --- a/config/src/main/resources/application.yaml +++ b/config/src/main/resources/application.yaml @@ -10,4 +10,4 @@ spring: config: server: native: - search-locations: ${CONFIG_SEARCH_LOCATIONS:file:./config/} + search-locations: ${CONFIG_SEARCH_LOCATIONS:file:/config-repo} From 69573deb4b123f7f94ac1ae45f542b11e6c0b508 Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 29 May 2026 14:15:10 +0800 Subject: [PATCH 26/30] Update application configuration and Dockerfile for CONFIG_SEARCH_LOCATIONS path change and add README instructions for Docker usage --- config/Dockerfile | 4 +++- config/README.md | 11 +++++++++++ config/src/main/resources/application.yaml | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 config/README.md diff --git a/config/Dockerfile b/config/Dockerfile index accf978..9c70d9b 100644 --- a/config/Dockerfile +++ b/config/Dockerfile @@ -18,9 +18,11 @@ RUN mvn -f config/pom.xml clean package spring-boot:repackage -DskipTests -Dspri FROM eclipse-temurin:25-jre-alpine WORKDIR /app +RUN mkdir -p /app/config + ENV JAVA_OPTS="-Xms512m -Xmx1024m" ENV SPRING_PROFILES_ACTIVE=native -ENV CONFIG_SEARCH_LOCATIONS=file:/config-repo} +ENV CONFIG_SEARCH_LOCATIONS=file:./config COPY --from=build /workspace/config/target/config-1.0.jar /app/app.jar diff --git a/config/README.md b/config/README.md new file mode 100644 index 0000000..3f35ddd --- /dev/null +++ b/config/README.md @@ -0,0 +1,11 @@ +## Build Docker image + +```bash +docker build -t theurl.io/config -f config/Dockerfile . +``` + +## Run Docker container + +```bash +docker run -d --restart always --name linkyou-config-server -p 8900:8900 -v $(pwd)/config:/app/config theurl.io/config +``` diff --git a/config/src/main/resources/application.yaml b/config/src/main/resources/application.yaml index 820599e..b75e3d3 100644 --- a/config/src/main/resources/application.yaml +++ b/config/src/main/resources/application.yaml @@ -10,4 +10,4 @@ spring: config: server: native: - search-locations: ${CONFIG_SEARCH_LOCATIONS:file:/config-repo} + search-locations: ${CONFIG_SEARCH_LOCATIONS:file:./config} From 3b9db776e9bdada3c9354a49469860d0b2134199 Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 29 May 2026 14:21:07 +0800 Subject: [PATCH 27/30] Add security configuration with username and password support in application.yaml and update Dockerfile with environment variables --- config/Dockerfile | 2 ++ config/pom.xml | 4 ++++ config/src/main/resources/application.yaml | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/config/Dockerfile b/config/Dockerfile index 9c70d9b..9888131 100644 --- a/config/Dockerfile +++ b/config/Dockerfile @@ -21,6 +21,8 @@ WORKDIR /app RUN mkdir -p /app/config ENV JAVA_OPTS="-Xms512m -Xmx1024m" +ENV CONFIG_SERVER_USERNAME=theurl +ENV CONFIG_SERVER_PASSWORD=Qwer.1234 ENV SPRING_PROFILES_ACTIVE=native ENV CONFIG_SEARCH_LOCATIONS=file:./config diff --git a/config/pom.xml b/config/pom.xml index 787b6b3..c82af21 100644 --- a/config/pom.xml +++ b/config/pom.xml @@ -29,6 +29,10 @@ spring-boot-maven-plugin 4.0.6 + + org.springframework.boot + spring-boot-starter-security + diff --git a/config/src/main/resources/application.yaml b/config/src/main/resources/application.yaml index b75e3d3..2d9490b 100644 --- a/config/src/main/resources/application.yaml +++ b/config/src/main/resources/application.yaml @@ -6,6 +6,10 @@ spring: name: config profiles: active: ${SPRING_PROFILES_ACTIVE:native} + security: + user: + name: ${CONFIG_SERVER_USERNAME:config-user} + password: ${CONFIG_SERVER_PASSWORD:config-password} cloud: config: server: From 6fb65c240697e2b1d0e7e2b720033b0f814599e2 Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 29 May 2026 14:56:26 +0800 Subject: [PATCH 28/30] Add docker-compose configuration for multiple services with environment variables --- docker-compose.yaml | 142 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 docker-compose.yaml diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..c52b171 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,142 @@ +version: "1.0" + +services: + theurl-config-server: + build: -t theurl/config-server -f /config/Dockerfile . + ports: + - "8900" + environment: + - SPRING_PROFILES_ACTIVE=navive + - CONFIG_SERVER_USERNAME=theurl + - CONFIG_SERVER_PASSWORD=Qwer.1234 + - CONFIG_SEARCH_LOCATIONS=file:./config + volumes: + - theurl-config-data:/app/config + + theurl-identity-service: + build: -t theurl/identity-service -f /identity/Dockerfile . + ports: + - "8901:8901" + environment: + - CONFIG_SERVER_URI=http://theurl-config-server:8900 + - CONFIG_SERVER_USERNAME=theurl + - CONFIG_SERVER_PASSWORD=Qwer.1234 + - DB_URL=jdbc:postgresql://theurl-db:5432/linkyou?currentSchema=public + - DB_USERNAME=postgres + - DB_PASSWORD=postgres + - DB_DRIVER=org.postgresql.Driver + - DB_DIALECT=org.hibernate.dialect.PostgreSQLDialect + - REDIS_URL=redis://theurl-redis:6379 + - MONGO_URI=mongodb://theurl-mongo:27017/linkyou + - RABBITMQ_HOST=theurl-rabbitmq + - RABBITMQ_PORT=5672 + - RABBITMQ_USERNAME=guest + - RABBITMQ_PASSWORD=guest + - JWT_SECRET=GImXbOaM2RNkzHhJ7/q3NpYMa1j/xqlahPg9KwX99qI= + - EXTERNAL_AUTH_REDIRECTURI=https://theurl.io/auth/callback + - GOOGLE_CLIENT_ID=your-google-client-id + - GOOGLE_CLIENT_SECRET=your-google-client-secret + - GITHUB_CLIENT_ID=your-github-client-id + - GITHUB_CLIENT_SECRET=your-github-client-secret + - FACEBOOK_CLIENT_ID=your-facebook-client-id + - FACEBOOK_CLIENT_SECRET=your-facebook-client-secret + - MICROSOFT_CLIENT_ID=your-microsoft-client-id + - MICROSOFT_CLIENT_SECRET=your-microsoft-client-secret + depends_on: + - theurl-db + - theurl-redis + - theurl-rabbitmq + + theurl-message-service: + build: -t theurl/message-service -f /message/Dockerfile . + ports: + - "8902:8902" + environment: + - CONFIG_SERVER_URI=http://theurl-config-server:8900 + - CONFIG_SERVER_USERNAME=theurl + - CONFIG_SERVER_PASSWORD=Qwer.1234 + - DB_URL=jdbc:postgresql://theurl-db:5432/linkyou?currentSchema=public + - DB_USERNAME=postgres + - DB_PASSWORD=postgres + - DB_DRIVER=org.postgresql.Driver + - DB_DIALECT=org.hibernate.dialect.PostgreSQLDialect + - REDIS_URL=redis://theurl-redis:6379 + - MONGO_URI=mongodb://theurl-mongo:27017/linkyou + - RABBITMQ_HOST=theurl-rabbitmq + - RABBITMQ_PORT=5672 + - RABBITMQ_USERNAME=guest + - RABBITMQ_PASSWORD=guest + depends_on: + - theurl-db + - theurl-redis + - theurl-rabbitmq + + theurl-bundle-service: + build: -t theurl/bundle-service -f /bundle/Dockerfile . + ports: + - "8903:8903" + environment: + - CONFIG_SERVER_URI=http://theurl-config-server:8900 + - CONFIG_SERVER_USERNAME=theurl + - CONFIG_SERVER_PASSWORD=Qwer.1234 + - DB_URL=jdbc:postgresql://theurl-db:5432/linkyou?currentSchema=public + - DB_USERNAME=postgres + - DB_PASSWORD=postgres + - DB_DRIVER=org.postgresql.Driver + - DB_DIALECT=org.hibernate.dialect.PostgreSQLDialect + - REDIS_URL=redis://theurl-redis:6379 + - MONGO_URI=mongodb://theurl-mongo:27017/linkyou + - RABBITMQ_HOST=theurl-rabbitmq + - RABBITMQ_PORT=5672 + - RABBITMQ_USERNAME=guest + - RABBITMQ_PASSWORD=guest + depends_on: + - theurl-db + - theurl-redis + - theurl-rabbitmq + + theurl-db: + image: postgres:latest + environment: + - POSTGRES_DB=linkyou + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + ports: + - "5432" + + theurl-redis: + image: redis:latest + ports: + - "6379" + + theurl-mongo: + image: mongo:latest + ports: + - "27017" + + theurl-rabbitmq: + image: rabbitmq:management + ports: + - "15671" + - "15672" + - "15691" + - "15692" + - "25672" + - "4369" + - "5671" + - "5672" + +networks: + theurl-network: + driver: bridge + +volumes: + theurl-db-data: + theurl-redis-data: + theurl-mongo-data: + theurl-config-data: + driver: local + driver_opts: + type: none + device: ./theurl/config + o: bind From 3d54d1fc035b5652f4c4da112e645bbbd4391a66 Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 29 May 2026 14:56:45 +0800 Subject: [PATCH 29/30] Enable cloud configuration and add username/password support in application.yaml --- identity/src/main/resources/application.yaml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/identity/src/main/resources/application.yaml b/identity/src/main/resources/application.yaml index fc03505..425f01e 100644 --- a/identity/src/main/resources/application.yaml +++ b/identity/src/main/resources/application.yaml @@ -10,13 +10,15 @@ spring: import: optional:file:.env[.properties] cloud: config: - enabled: false + enabled: true uri: ${CONFIG_SERVER_URI:http://localhost:8900} + password: ${CONFIG_SERVER_PASSWORD:Qwer.1234} + username: ${CONFIG_SERVER_USERNAME:theurl} datasource: url: ${DB_URL:jdbc:postgresql://localhost:5432/linkyou?currentSchema=public} username: ${DB_USERNAME:postgres} password: ${DB_PASSWORD:postgres} - driver-class-name: ${DB_DRIVER:org.postgresql.Driver} + driver-class-name: ${DB_DRIVER:"org.postgresql.Driver"} jpa: hibernate: ddl-auto: update @@ -43,7 +45,7 @@ spring: password: ${RABBITMQ_PASSWORD:guest} external-auth: - redirect-uri: "https://theurl.io/auth/callback" + redirect-uri: ${EXTERNAL_AUTH_REDIRECTURI:https://theurl.io/auth/callback} google: client-id: ${GOOGLE_CLIENT_ID:your-google-client-id} client-secret: ${GOOGLE_CLIENT_SECRET:your-google-client-secret} From 0f071d7f56c38f60524b088f13a9c3a1a189cfc6 Mon Sep 17 00:00:00 2001 From: damon Date: Fri, 29 May 2026 15:08:44 +0800 Subject: [PATCH 30/30] Add RabbitMQ configuration with connection factory bean --- .../configure/RabbitMqConfiguration.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 bundle/src/main/java/io/theurl/bundle/configure/RabbitMqConfiguration.java diff --git a/bundle/src/main/java/io/theurl/bundle/configure/RabbitMqConfiguration.java b/bundle/src/main/java/io/theurl/bundle/configure/RabbitMqConfiguration.java new file mode 100644 index 0000000..2cabb30 --- /dev/null +++ b/bundle/src/main/java/io/theurl/bundle/configure/RabbitMqConfiguration.java @@ -0,0 +1,23 @@ +package io.theurl.bundle.configure; + +import com.rabbitmq.client.ConnectionFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RabbitMqConfiguration { + @Bean + public ConnectionFactory connectionFactory(@Value("${spring.rabbitmq.host}") String host, + @Value("${spring.rabbitmq.port}") int port, + @Value("${spring.rabbitmq.username}") String username, + @Value("${spring.rabbitmq.password}") String password) { + ConnectionFactory factory = new ConnectionFactory(); + factory.setHost(host); + factory.setPort(port); + factory.setUsername(username); + factory.setPassword(password); + factory.setVirtualHost("/"); + return factory; + } +}