From a019975c31077bbe97c10c5e9ce6b493b1f8a22c Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 27 May 2026 09:36:18 +0800 Subject: [PATCH 1/8] Add application context support to BaseApplicationService for improved dependency management --- .../theurl/framework/application/BaseApplicationService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/framework/src/main/java/io/theurl/framework/application/BaseApplicationService.java b/framework/src/main/java/io/theurl/framework/application/BaseApplicationService.java index 7b12d6f..be47b5c 100644 --- a/framework/src/main/java/io/theurl/framework/application/BaseApplicationService.java +++ b/framework/src/main/java/io/theurl/framework/application/BaseApplicationService.java @@ -18,10 +18,11 @@ @RequestScope public class BaseApplicationService implements ApplicationService { protected Mediator mediator; - + protected ApplicationContext applicationContext; protected ThreadPoolTaskExecutor mediatorTaskExecutor; protected BaseApplicationService(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; mediator = applicationContext.getBean(Mediator.class); mediatorTaskExecutor = applicationContext.getBean(ThreadPoolTaskExecutor.class); } From eff91aeefab57878d6d60be04cd267ca60700f8d Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 27 May 2026 09:39:04 +0800 Subject: [PATCH 2/8] Add user authority management commands and update user application service for async operations --- .../command/UserAuthorityCreateCommand.java | 14 ++++++ .../command/UserAuthorityRemoveCommand.java | 13 ++++++ .../command/UserUpdateCommand.java | 19 +++++++- .../contract/UserApplicationService.java | 28 ++++++++++++ .../application/dto/UserUpdateRequestDto.java | 10 +++++ .../UserAuthorityCreateCommandHandler.java | 35 +++++++++++++++ .../UserAuthorityRemoveCommandHandler.java | 34 +++++++++++++++ .../handler/UserUpdateCommandHandler.java | 42 ++++++++++++++++++ .../implement/AuthApplicationServiceImpl.java | 15 +------ .../implement/UserApplicationServiceImpl.java | 43 +++++++++++++++++++ ...riber.java => AuthlogEventSubscriber.java} | 6 +-- .../subscriber/DistributedEventBus.java | 7 +++ ...bscriber.java => UserEventSubscriber.java} | 2 +- .../controller/AccountController.java | 42 +++++++++++++++++- 14 files changed, 291 insertions(+), 19 deletions(-) create mode 100644 identity/src/main/java/io/theurl/identity/application/command/UserAuthorityCreateCommand.java create mode 100644 identity/src/main/java/io/theurl/identity/application/command/UserAuthorityRemoveCommand.java create mode 100644 identity/src/main/java/io/theurl/identity/application/dto/UserUpdateRequestDto.java create mode 100644 identity/src/main/java/io/theurl/identity/application/handler/UserAuthorityCreateCommandHandler.java create mode 100644 identity/src/main/java/io/theurl/identity/application/handler/UserAuthorityRemoveCommandHandler.java create mode 100644 identity/src/main/java/io/theurl/identity/application/handler/UserUpdateCommandHandler.java rename identity/src/main/java/io/theurl/identity/application/subscriber/{LoggingEventSubscriber.java => AuthlogEventSubscriber.java} (95%) create mode 100644 identity/src/main/java/io/theurl/identity/application/subscriber/DistributedEventBus.java rename identity/src/main/java/io/theurl/identity/application/subscriber/{UserAccessFailureCountEventSubscriber.java => UserEventSubscriber.java} (96%) diff --git a/identity/src/main/java/io/theurl/identity/application/command/UserAuthorityCreateCommand.java b/identity/src/main/java/io/theurl/identity/application/command/UserAuthorityCreateCommand.java new file mode 100644 index 0000000..3677ed2 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/command/UserAuthorityCreateCommand.java @@ -0,0 +1,14 @@ +package io.theurl.identity.application.command; + +import com.neroyun.mediator.Command; + +/** + * Command to add external authority to a user. + * + * @param id the ID of the user + * @param provider the external authority provider + * @param openId the open ID of the external authority + * @param name the name of the external authority + */ +public record UserAuthorityCreateCommand(Long id, String provider, String openId, String name) implements Command { +} diff --git a/identity/src/main/java/io/theurl/identity/application/command/UserAuthorityRemoveCommand.java b/identity/src/main/java/io/theurl/identity/application/command/UserAuthorityRemoveCommand.java new file mode 100644 index 0000000..8d97456 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/command/UserAuthorityRemoveCommand.java @@ -0,0 +1,13 @@ +package io.theurl.identity.application.command; + +import com.neroyun.mediator.Command; + +/** + * Command to remove external authority from a user. + * + * @param id the ID of the user + * @param provider the external authority provider + * @param openId the open ID of the external authority + */ +public record UserAuthorityRemoveCommand(Long id, String provider, String openId) implements Command { +} 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 41ab369..d65aa82 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 @@ -1,4 +1,21 @@ package io.theurl.identity.application.command; -public class UserUpdateCommand { +import com.neroyun.mediator.Command; +import lombok.Getter; + +import java.util.HashMap; +import java.util.Map; + +public class UserUpdateCommand implements Command { + @Getter + private final Long id; + + @Getter + private final Map modifications = new HashMap<>(); + + public UserUpdateCommand(Long id) { + this.id = id; + } + + } 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 3e42e80..6a9f57e 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 @@ -31,4 +31,32 @@ public interface UserApplicationService extends ApplicationService { * @return A CompletableFuture representing the asynchronous operation. */ CompletableFuture changePasswordAsync(String oldPassword, String newPassword); + + /** + * Change the email of the currently authenticated user asynchronously. + * + * @param email The new email to be set for the user. + * @return A CompletableFuture representing the asynchronous operation. + */ + CompletableFuture changeEmailAsync(String email); + + /** + * Change the phone number of the currently authenticated user asynchronously. + * + * @param phone The new phone number to be set for the user. + * @return A CompletableFuture representing the asynchronous operation. + */ + CompletableFuture changePhoneAsync(String phone); + + /** + * Change the nickname of the currently authenticated user asynchronously. + * + * @param nickname The new nickname to be set for the user. + * @return A CompletableFuture representing the asynchronous operation. + */ + CompletableFuture changeNicknameAsync(String nickname); + + CompletableFuture connectAuthorityAsync(String provider, String code); + + CompletableFuture removeAuthorityAsync(String provider, String openId); } 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 new file mode 100644 index 0000000..5cb698f --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/dto/UserUpdateRequestDto.java @@ -0,0 +1,10 @@ +package io.theurl.identity.application.dto; + +import lombok.Data; + +@Data +public class UserUpdateRequestDto { + private String email; + private String phone; + private String nickname; +} 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 new file mode 100644 index 0000000..fe82790 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/handler/UserAuthorityCreateCommandHandler.java @@ -0,0 +1,35 @@ +package io.theurl.identity.application.handler; + +import com.neroyun.mediator.Handler; +import io.theurl.framework.core.BeanScope; +import io.theurl.identity.application.command.UserAuthorityCreateCommand; +import io.theurl.identity.domain.repository.UserRepository; +import jakarta.persistence.EntityNotFoundException; +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) +public class UserAuthorityCreateCommandHandler implements Handler { + + private final UserRepository repository; + + public UserAuthorityCreateCommandHandler(UserRepository repository) { + this.repository = repository; + } + + @Override + public CompletableFuture handleAsync(UserAuthorityCreateCommand message) { + 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); + } +} 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 new file mode 100644 index 0000000..90e6af6 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/handler/UserAuthorityRemoveCommandHandler.java @@ -0,0 +1,34 @@ +package io.theurl.identity.application.handler; + +import com.neroyun.mediator.Handler; +import io.theurl.framework.core.BeanScope; +import io.theurl.identity.application.command.UserAuthorityRemoveCommand; +import io.theurl.identity.domain.repository.UserRepository; +import jakarta.persistence.EntityNotFoundException; +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) +public class UserAuthorityRemoveCommandHandler implements Handler { + private final UserRepository repository; + + public UserAuthorityRemoveCommandHandler(UserRepository repository) { + this.repository = repository; + } + + @Override + public CompletableFuture handleAsync(UserAuthorityRemoveCommand message) { + 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); + } +} 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 new file mode 100644 index 0000000..3ad2c93 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/handler/UserUpdateCommandHandler.java @@ -0,0 +1,42 @@ +package io.theurl.identity.application.handler; + +import com.neroyun.mediator.Handler; +import io.theurl.framework.core.BeanScope; +import io.theurl.identity.application.command.UserUpdateCommand; +import io.theurl.identity.domain.repository.UserRepository; +import jakarta.persistence.EntityNotFoundException; +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) +public class UserUpdateCommandHandler implements Handler { + private final UserRepository repository; + + public UserUpdateCommandHandler(UserRepository repository) { + this.repository = repository; + } + + @Override + public CompletableFuture handleAsync(UserUpdateCommand message) { + 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); + } + }); + repository.save(user); + return CompletableFuture.completedFuture(null); + } +} 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 25a461d..b187be3 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 @@ -45,12 +45,9 @@ public class AuthApplicationServiceImpl extends BaseApplicationService implement @Value("${jwt.issuer}") private String issuer; - private final ApplicationContext applicationContext; - @Autowired public AuthApplicationServiceImpl(ApplicationContext applicationContext) { super(applicationContext); - this.applicationContext = applicationContext; } @Override @@ -108,41 +105,33 @@ public CompletableFuture grant(TokenGrantRequestDto reque return CompletableFuture.completedFuture(result); } catch (Exception e) { var event = new UserAuthFailureEvent(); + event.setGrantType(request.grantType()); + event.setGrantTime(LocalDateTime.now()); handleException(e, ex -> { switch (ex) { case AccountLockedException exception: event.setUserId((Long) exception.getIdentity()); - event.setGrantType(request.grantType()); - event.setGrantTime(LocalDateTime.now()); event.setError(exception.getLocalizedMessage()); event.setData(Map.of("username", request.username() != null ? request.username() : "", "password", request.password(), "locked", "true")); break; case NoResultException ignored: event.setUsername(request.username()); - event.setGrantType(request.grantType()); - event.setGrantTime(LocalDateTime.now()); event.setError(ex.getLocalizedMessage()); event.setData(Map.of("username", request.username())); break; case EntityNotFoundException ignored: event.setUsername(request.username()); - event.setGrantType(request.grantType()); - event.setGrantTime(LocalDateTime.now()); event.setError(ex.getLocalizedMessage()); event.setData(Map.of("username", request.username())); break; case AccountNotFoundException ignored: event.setUsername(request.username()); - event.setGrantType(request.grantType()); - event.setGrantTime(LocalDateTime.now()); event.setError(ex.getLocalizedMessage()); event.setData(Map.of("username", request.username())); break; case CredentialException exception: event.setUsername(request.username()); - event.setGrantType(request.grantType()); - event.setGrantTime(LocalDateTime.now()); event.setError(exception.getLocalizedMessage()); event.setData(Map.of("username", request.username(), "password", request.password())); break; 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 70a6e66..9b7fa3f 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 @@ -3,11 +3,14 @@ import io.theurl.framework.application.BaseApplicationService; import io.theurl.framework.security.CredentialIncorrectException; import io.theurl.framework.utility.Cryptography; +import io.theurl.identity.application.command.UserAuthorityCreateCommand; import io.theurl.identity.application.command.UserCreateCommand; import io.theurl.identity.application.command.UserPasswordChangeCommand; +import io.theurl.identity.application.command.UserUpdateCommand; 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.external.ExternalAuthProvider; import io.theurl.identity.persistence.query.UserAuthInfoQuery; import io.theurl.identity.persistence.query.UserDetailQuery; import org.modelmapper.ModelMapper; @@ -15,6 +18,7 @@ import org.springframework.stereotype.Service; import org.springframework.web.context.annotation.RequestScope; +import java.util.Locale; import java.util.concurrent.CompletableFuture; @Service @@ -62,4 +66,43 @@ public CompletableFuture changePasswordAsync(String oldPassword, String ne var command = new UserPasswordChangeCommand(currentUserId(), newPassword, "change"); return mediator.sendAsync(command); } + + @Override + public CompletableFuture changeEmailAsync(String email) { + var command = new UserUpdateCommand(currentUserId()); + command.getModifications().put("email", email); + return mediator.sendAsync(command); + } + + @Override + public CompletableFuture changePhoneAsync(String phone) { + var command = new UserUpdateCommand(currentUserId()); + command.getModifications().put("phone", phone); + return mediator.sendAsync(command); + } + + @Override + public CompletableFuture changeNicknameAsync(String nickname) { + var command = new UserUpdateCommand(currentUserId()); + command.getModifications().put("nickname", nickname); + return mediator.sendAsync(command); + } + + @Override + public CompletableFuture connectAuthorityAsync(String provider, String code) { + provider = provider.toLowerCase(Locale.ROOT); + + var authProvider = applicationContext.getBean("external-auth-provider-" + provider, ExternalAuthProvider.class); + + var externalAuthResult = authProvider.authenticateAsync(code) + .join(); + + var command = new UserAuthorityCreateCommand(currentUserId(), provider, externalAuthResult.getId(), externalAuthResult.getNickname()); + return mediator.sendAsync(command); + } + + @Override + public CompletableFuture removeAuthorityAsync(String provider, String openId) { + return null; + } } diff --git a/identity/src/main/java/io/theurl/identity/application/subscriber/LoggingEventSubscriber.java b/identity/src/main/java/io/theurl/identity/application/subscriber/AuthlogEventSubscriber.java similarity index 95% rename from identity/src/main/java/io/theurl/identity/application/subscriber/LoggingEventSubscriber.java rename to identity/src/main/java/io/theurl/identity/application/subscriber/AuthlogEventSubscriber.java index 4e3bc94..f2bba1c 100644 --- a/identity/src/main/java/io/theurl/identity/application/subscriber/LoggingEventSubscriber.java +++ b/identity/src/main/java/io/theurl/identity/application/subscriber/AuthlogEventSubscriber.java @@ -18,13 +18,13 @@ @Component @Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) -public class LoggingEventSubscriber { +public class AuthlogEventSubscriber { - private final Logger LOGGER = LoggerFactory.getLogger(LoggingEventSubscriber.class); + private final Logger LOGGER = LoggerFactory.getLogger(AuthlogEventSubscriber.class); private final Mediator mediator; - public LoggingEventSubscriber(Mediator mediator) { + public AuthlogEventSubscriber(Mediator mediator) { this.mediator = mediator; } 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 new file mode 100644 index 0000000..3423aaa --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/subscriber/DistributedEventBus.java @@ -0,0 +1,7 @@ +package io.theurl.identity.application.subscriber; + +import org.springframework.stereotype.Component; + +@Component +public class DistributedEventBus { +} diff --git a/identity/src/main/java/io/theurl/identity/application/subscriber/UserAccessFailureCountEventSubscriber.java b/identity/src/main/java/io/theurl/identity/application/subscriber/UserEventSubscriber.java similarity index 96% rename from identity/src/main/java/io/theurl/identity/application/subscriber/UserAccessFailureCountEventSubscriber.java rename to identity/src/main/java/io/theurl/identity/application/subscriber/UserEventSubscriber.java index 9280782..7f4cc1c 100644 --- a/identity/src/main/java/io/theurl/identity/application/subscriber/UserAccessFailureCountEventSubscriber.java +++ b/identity/src/main/java/io/theurl/identity/application/subscriber/UserEventSubscriber.java @@ -15,7 +15,7 @@ @Component @Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) @AllArgsConstructor -public class UserAccessFailureCountEventSubscriber { +public class UserEventSubscriber { private final Mediator mediator; 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 5e49e8a..85d9af9 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 @@ -6,6 +6,7 @@ import io.theurl.identity.application.dto.UserProfileResponseDto; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.theurl.identity.application.dto.UserUpdateRequestDto; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -19,7 +20,7 @@ public AccountController(UserApplicationService service) { } @PostMapping("/register") - @Operation(summary = "Register a new user", security = {}) + @Operation(summary = "Register a new user") public ResponseEntity create(@RequestBody UserCreateRequestDto user) { service.createAsync(user) .join(); @@ -42,4 +43,43 @@ public ResponseEntity changePassword(@RequestBody UserPasswordChangeReques return ResponseEntity.ok().build(); } + @PutMapping("/email") + @Operation(summary = "Change user email", security = @SecurityRequirement(name = "bearerAuth")) + public ResponseEntity changeEmail(@RequestBody UserUpdateRequestDto data) { + service.changeEmailAsync(data.getEmail()) + .join(); + return ResponseEntity.ok().build(); + } + + @PutMapping("/phone") + @Operation(summary = "Change user phone", security = @SecurityRequirement(name = "bearerAuth")) + public ResponseEntity changePhone(@RequestBody UserUpdateRequestDto data) { + service.changePhoneAsync(data.getPhone()) + .join(); + return ResponseEntity.ok().build(); + } + + @PutMapping("/nickname") + @Operation(summary = "Change user nickname", security = @SecurityRequirement(name = "bearerAuth")) + public ResponseEntity changeNickname(@RequestBody UserUpdateRequestDto data) { + service.changeNicknameAsync(data.getNickname()) + .join(); + return ResponseEntity.ok().build(); + } + + @PostMapping("/authority/{provider}") + @Operation(summary = "Bind OAuth authority to current user", security = @SecurityRequirement(name = "bearerAuth")) + public ResponseEntity bindAuthority(@PathVariable String provider, @RequestParam String code) { + service.connectAuthorityAsync(provider, code) + .join(); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/authority/{provider}") + @Operation(summary = "Unbind OAuth authority from current user", security = @SecurityRequirement(name = "bearerAuth")) + public ResponseEntity unbindAuthority(@PathVariable String provider, @RequestParam String openId) { + service.removeAuthorityAsync(provider, openId) + .join(); + return ResponseEntity.ok().build(); + } } From f947bec1a92c64c3d53f0fbcf956ba745a1f2317 Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 27 May 2026 10:33:56 +0800 Subject: [PATCH 3/8] Add utility methods for retrieving exception details and enhance regex validation with constants --- .../io/theurl/framework/security/AccountException.java | 4 ++++ .../io/theurl/framework/security/CredentialException.java | 4 ++++ .../java/io/theurl/framework/utility/RegexUtility.java | 7 +++++-- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/framework/src/main/java/io/theurl/framework/security/AccountException.java b/framework/src/main/java/io/theurl/framework/security/AccountException.java index 1be97f5..d5bfc88 100644 --- a/framework/src/main/java/io/theurl/framework/security/AccountException.java +++ b/framework/src/main/java/io/theurl/framework/security/AccountException.java @@ -35,4 +35,8 @@ public Object getIdentity() { public Map getDetails() { return details; } + + public Object get(String key) { + return details.getOrDefault(key, null); + } } diff --git a/framework/src/main/java/io/theurl/framework/security/CredentialException.java b/framework/src/main/java/io/theurl/framework/security/CredentialException.java index 23eb7e3..e98d817 100644 --- a/framework/src/main/java/io/theurl/framework/security/CredentialException.java +++ b/framework/src/main/java/io/theurl/framework/security/CredentialException.java @@ -34,4 +34,8 @@ public Object getCredential() { public Map getDetails() { return details; } + + public Object get(String key) { + return details.getOrDefault(key, null); + } } diff --git a/framework/src/main/java/io/theurl/framework/utility/RegexUtility.java b/framework/src/main/java/io/theurl/framework/utility/RegexUtility.java index a0a397b..001b91b 100644 --- a/framework/src/main/java/io/theurl/framework/utility/RegexUtility.java +++ b/framework/src/main/java/io/theurl/framework/utility/RegexUtility.java @@ -1,11 +1,14 @@ package io.theurl.framework.utility; public class RegexUtility { + public static final String PHONE_REGEX = "^\\+?[0-9]{7,15}$"; + public static final String EMAIL_REGEX = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"; + public static boolean isEmail(String email) { - return email != null && email.matches("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); + return email != null && email.matches(EMAIL_REGEX); } public static boolean isPhone(String phone) { - return phone != null && phone.matches("^\\+?[0-9]{7,15}$"); + return phone != null && phone.matches(PHONE_REGEX); } } From 298a0a91dce54a1582c90394fd2bf60265b59800 Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 27 May 2026 10:34:17 +0800 Subject: [PATCH 4/8] Add TokenDetail model and query handler for asynchronous token detail retrieval --- .../identity/persistence/entity/Token.java | 6 +++ .../handler/TokenDetailQueryHandler.java | 40 +++++++++++++++++++ .../persistence/model/TokenDetail.java | 17 ++++++++ .../persistence/query/TokenDetailQuery.java | 7 ++++ 4 files changed, 70 insertions(+) create mode 100644 identity/src/main/java/io/theurl/identity/persistence/handler/TokenDetailQueryHandler.java create mode 100644 identity/src/main/java/io/theurl/identity/persistence/model/TokenDetail.java create mode 100644 identity/src/main/java/io/theurl/identity/persistence/query/TokenDetailQuery.java diff --git a/identity/src/main/java/io/theurl/identity/persistence/entity/Token.java b/identity/src/main/java/io/theurl/identity/persistence/entity/Token.java index c3687b3..c2f596c 100644 --- a/identity/src/main/java/io/theurl/identity/persistence/entity/Token.java +++ b/identity/src/main/java/io/theurl/identity/persistence/entity/Token.java @@ -31,6 +31,12 @@ public class Token implements Persistable { @Column(name = "expires_at") private LocalDateTime expiresAt; + @Column(name = "refresh_at") + private LocalDateTime refreshAt; + + @Column(name = "revoked_at") + private LocalDateTime revokedAt; + @Override public Long getId() { return id; diff --git a/identity/src/main/java/io/theurl/identity/persistence/handler/TokenDetailQueryHandler.java b/identity/src/main/java/io/theurl/identity/persistence/handler/TokenDetailQueryHandler.java new file mode 100644 index 0000000..03f47e4 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/handler/TokenDetailQueryHandler.java @@ -0,0 +1,40 @@ +package io.theurl.identity.persistence.handler; + +import com.neroyun.mediator.Handler; +import io.theurl.framework.core.BeanScope; +import io.theurl.identity.persistence.model.TokenDetail; +import io.theurl.identity.persistence.query.TokenDetailQuery; +import io.theurl.identity.persistence.repository.JpaTokenRepository; +import org.modelmapper.ModelMapper; +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) +public class TokenDetailQueryHandler implements Handler { + private final JpaTokenRepository repository; + private final ModelMapper mapper; + + public TokenDetailQueryHandler(JpaTokenRepository repository, ModelMapper mapper) { + this.repository = repository; + this.mapper = mapper; + } + + @Override + public CompletableFuture handleAsync(TokenDetailQuery message) { + var entity = repository.findByJti(message.jti()) + .orElse(null); + + TokenDetail detail; + if (entity == null) { + detail = null; + } else { + detail = mapper.map(entity, TokenDetail.class); + } + + return CompletableFuture.completedFuture(detail); + } +} 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 new file mode 100644 index 0000000..23e2641 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/model/TokenDetail.java @@ -0,0 +1,17 @@ +package io.theurl.identity.persistence.model; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class TokenDetail { + private Long id; + private String jti; + private String content; + private Long subject; + private LocalDateTime issuedAt; + private LocalDateTime expiresAt; + private LocalDateTime refreshAt; + private LocalDateTime revokedAt; +} diff --git a/identity/src/main/java/io/theurl/identity/persistence/query/TokenDetailQuery.java b/identity/src/main/java/io/theurl/identity/persistence/query/TokenDetailQuery.java new file mode 100644 index 0000000..a0ab118 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/query/TokenDetailQuery.java @@ -0,0 +1,7 @@ +package io.theurl.identity.persistence.query; + +import com.neroyun.mediator.Query; +import io.theurl.identity.persistence.model.TokenDetail; + +public record TokenDetailQuery(String jti) implements Query { +} From f9fe68e55c6742d77d15be36d5f9fa280f8a91f3 Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 27 May 2026 10:35:24 +0800 Subject: [PATCH 5/8] Add refresh and revoke timestamps to Token model for enhanced state management --- .../identity/domain/aggregate/Token.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 44605bd..56f8453 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 @@ -12,6 +12,8 @@ public class Token extends AggregateRoot { private final Long subject; private LocalDateTime issuedAt; private LocalDateTime expiresAt; + private LocalDateTime refreshAt; + private LocalDateTime revokedAt; /** * Initializes the aggregate with the given id. @@ -45,6 +47,14 @@ public Long getSubject() { return subject; } + public LocalDateTime getRefreshAt() { + return refreshAt; + } + + public LocalDateTime getRevokedAt() { + return revokedAt; + } + public void setIssuedAt(LocalDateTime issuedAt) { if (issuedAt != null && issuedAt.isBefore(LocalDateTime.now())) { throw new IllegalArgumentException("issuedAt must be in the future"); @@ -59,6 +69,14 @@ public void setExpiresAt(LocalDateTime expiresAt) { this.expiresAt = expiresAt; } + public void refresh() { + this.refreshAt = LocalDateTime.now(); + } + + public void revoke() { + this.revokedAt = LocalDateTime.now(); + } + public static Token create(String jti, String content, Long subject) { var id = SnowflakeId.getInstance().nextId(); return new Token(id, jti, content, subject); From 71f4bc80af403412190eac70f7d19239b52625ce Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 27 May 2026 10:35:45 +0800 Subject: [PATCH 6/8] Enhance authentication event handling with improved data management and validation --- .../event/UserAuthFailureEvent.java | 13 +++ .../event/UserAuthSuccessEvent.java | 14 ++- .../implement/AuthApplicationServiceImpl.java | 98 ++++++++++++++++--- .../subscriber/TokenEventSubscriber.java | 8 +- 4 files changed, 114 insertions(+), 19 deletions(-) diff --git a/identity/src/main/java/io/theurl/identity/application/event/UserAuthFailureEvent.java b/identity/src/main/java/io/theurl/identity/application/event/UserAuthFailureEvent.java index 38f12e7..95dddf4 100644 --- a/identity/src/main/java/io/theurl/identity/application/event/UserAuthFailureEvent.java +++ b/identity/src/main/java/io/theurl/identity/application/event/UserAuthFailureEvent.java @@ -6,6 +6,7 @@ import lombok.EqualsAndHashCode; import java.time.LocalDateTime; +import java.util.HashMap; import java.util.Map; @EqualsAndHashCode(callSuper = true) @@ -17,4 +18,16 @@ public class UserAuthFailureEvent extends ApplicationEvent implements Event { private LocalDateTime grantTime; private Map data; private String error; + + public void setData(String key, Object value) { + if (data == null) { + data = new HashMap<>(); + } + + if (value == null) { + return; + } + + this.data.put(key, String.valueOf(value)); + } } diff --git a/identity/src/main/java/io/theurl/identity/application/event/UserAuthSuccessEvent.java b/identity/src/main/java/io/theurl/identity/application/event/UserAuthSuccessEvent.java index 2aeb0df..6f30881 100644 --- a/identity/src/main/java/io/theurl/identity/application/event/UserAuthSuccessEvent.java +++ b/identity/src/main/java/io/theurl/identity/application/event/UserAuthSuccessEvent.java @@ -25,5 +25,17 @@ public UserAuthSuccessEvent(String grantType, Long userId) { private LocalDateTime grantTime; @Getter - private Map data = new HashMap<>(); + private Map data = new HashMap<>(); + + public void setData(String key, Object value) { + if (data == null) { + data = new HashMap<>(); + } + + if (value == null) { + return; + } + + this.data.put(key, value); + } } 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 b187be3..ff95745 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 @@ -8,6 +8,7 @@ import io.theurl.framework.core.ObjectId; import io.theurl.framework.security.*; import io.theurl.framework.utility.Cryptography; +import io.theurl.framework.utility.RegexUtility; import io.theurl.identity.application.contract.AuthApplicationService; import io.theurl.identity.application.event.UserAuthFailureEvent; import io.theurl.identity.application.event.UserAuthSuccessEvent; @@ -17,6 +18,7 @@ import io.theurl.identity.application.dto.TokenGrantResponseDto; import io.theurl.identity.persistence.model.UserAuthInfo; import io.theurl.identity.persistence.query.OnetimePasswordDetailQuery; +import io.theurl.identity.persistence.query.TokenDetailQuery; import io.theurl.identity.persistence.query.UserAuthInfoQuery; import jakarta.persistence.EntityNotFoundException; import jakarta.persistence.NoResultException; @@ -52,40 +54,55 @@ public AuthApplicationServiceImpl(ApplicationContext applicationContext) { @Override public CompletableFuture grant(TokenGrantRequestDto request) { + checkRequest(request); var events = new ArrayList(); try { - UserAuthInfoQuery query = switch (request.grantType().toLowerCase()) { - case null -> throw new IllegalArgumentException("Grant type is required"); - case "" -> throw new IllegalArgumentException("Grant type is required"); 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 "email", "phone" -> - // For email and phone grant types, we should check OTP or other verification methods before querying user info. + 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(); case "github", "microsoft", "google", "facebook" -> authWithExternalAsync(request.grantType(), request.username()).thenApply(userId -> new UserAuthInfoQuery(request.grantType(), userId)).join(); + case "refresh_token" -> + refreshToken(request.username()).thenApply(userId -> new UserAuthInfoQuery("id", String.valueOf(userId))).join(); default -> throw new IllegalArgumentException("Unsupported grant type: " + request.grantType()); }; var userInfo = mediator.executeAsync(query).join(); if (userInfo == null) { - throw new CredentialNotFoundException(request.username(), "Invalid username or password."); + throw new CredentialNotFoundException(null, "Invalid username or password.") { + @Override + public Map getDetails() { + return Map.of("username", request.username() != null ? request.username() : ""); + } + }; } if (userInfo.getLockedUntil() != null && userInfo.getLockedUntil().isAfter(LocalDateTime.now())) { - throw new AccountLockedException(request.username(), "Account is locked until " + userInfo.getLockedUntil()); + throw new AccountLockedException(userInfo.getId(), "Account is locked until " + userInfo.getLockedUntil()) { + @Override + public Map getDetails() { + return Map.of("username", userInfo.getUsername()); + } + }; } if (request.grantType().equals("password")) { var passwordHash = Cryptography.AES.encrypt(request.password(), userInfo.getPasswordSalt()); if (!passwordHash.equals(userInfo.getPasswordHash())) { - throw new CredentialIncorrectException("Invalid username or password."); + throw new CredentialIncorrectException(userInfo.getId(), "Invalid username or password.") { + @Override + public Map getDetails() { + return Map.of("username", userInfo.getUsername()); + } + }; } } @@ -96,8 +113,10 @@ public CompletableFuture grant(TokenGrantRequestDto reque var event = new UserAuthSuccessEvent(request.grantType(), userInfo.getId()); event.setGrantTime(LocalDateTime.now()); - event.getData().put("jti", jwtId); - event.getData().put("jwt", accessToken); + event.setData("jti", jwtId); + event.setData("jwt", accessToken); + event.setData("iat", iat); + event.setData("exp", exp); events.add(event); var result = new TokenGrantResponseDto(accessToken, jwtId, "Bearer", 3600 * 24, iat, userInfo.getUsername(), userInfo.getId()); @@ -112,28 +131,27 @@ public CompletableFuture grant(TokenGrantRequestDto reque switch (ex) { case AccountLockedException exception: event.setUserId((Long) exception.getIdentity()); + event.setUsername((String) exception.getDetails().get("username")); event.setError(exception.getLocalizedMessage()); - event.setData(Map.of("username", request.username() != null ? request.username() : "", "password", request.password(), "locked", "true")); break; case NoResultException ignored: event.setUsername(request.username()); event.setError(ex.getLocalizedMessage()); - event.setData(Map.of("username", request.username())); break; case EntityNotFoundException ignored: event.setUsername(request.username()); event.setError(ex.getLocalizedMessage()); - event.setData(Map.of("username", request.username())); break; case AccountNotFoundException ignored: event.setUsername(request.username()); event.setError(ex.getLocalizedMessage()); - event.setData(Map.of("username", request.username())); break; case CredentialException exception: - event.setUsername(request.username()); + if (exception.getCredential() instanceof Long userId) { + event.setUserId(userId); + } + event.setUsername((String) exception.getDetails().get("username")); event.setError(exception.getLocalizedMessage()); - event.setData(Map.of("username", request.username(), "password", request.password())); break; default: break; @@ -175,6 +193,22 @@ CompletableFuture authWithExternalAsync(String grantType, String usernam return provider.authenticateAsync(username).thenApply(ExternalAuthResult::getId); } + CompletableFuture refreshToken(String jti) { + var query = new TokenDetailQuery(jti); + return mediator.executeAsync(query) + .thenApply(tokenDetail -> { + if (tokenDetail == null) { + throw new CredentialNotFoundException(null, "Invalid refresh token.") { + @Override + public Map getDetails() { + return Map.of("jti", jti); + } + }; + } + return tokenDetail.getSubject(); + }); + } + private String generateToken(String id, UserAuthInfo user, long issuedAt, long expiresAt) { Assert.notNull(user, "user cannot be null"); //var signingKey = environment.getProperty("JwtAuthenticationOptions.SigningKey"); @@ -186,4 +220,36 @@ private String generateToken(String id, UserAuthInfo user, long issuedAt, long e builder.signWith(Keys.hmacShaKeyFor(signingKey.getBytes())); return builder.compact(); } + + private void checkRequest(TokenGrantRequestDto request) { + Assert.notNull(request, "request cannot be null"); + Assert.notNull(request.grantType(), "grantType cannot be null"); + + switch (request.grantType()) { + case "password", "username": + Assert.notNull(request.username(), "Username cannot be null"); + Assert.notNull(request.password(), "Password cannot be null"); + break; + case "phone": + Assert.notNull(request.username(), "Phone number cannot be null"); + Assert.isTrue(request.username().matches(RegexUtility.PHONE_REGEX), "Invalid phone number format"); + Assert.notNull(request.password(), "Onetime password cannot be null"); + Assert.notNull(request.requestId(), "Request ID cannot be null for OTP grant type"); + break; + case "email": + Assert.notNull(request.username(), "Email cannot be null"); + Assert.isTrue(request.username().matches(RegexUtility.EMAIL_REGEX), "Invalid email format"); + Assert.notNull(request.password(), "Onetime password cannot be null"); + Assert.notNull(request.requestId(), "Request ID cannot be null for OTP grant type"); + break; + case "github", "microsoft", "google", "facebook": + Assert.notNull(request.username(), "OAuth code cannot be null"); + break; + case "refresh_token": + Assert.notNull(request.username(), "Refresh token cannot be null"); + break; + default: + throw new IllegalArgumentException("Unsupported grant type: " + request.grantType()); + } + } } 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 6329d79..36d6ace 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 @@ -10,6 +10,8 @@ import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +import java.time.LocalDateTime; + @Component @Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) public class TokenEventSubscriber { @@ -23,9 +25,11 @@ public TokenEventSubscriber(Mediator mediator) { @EventListener public void handleUserAuthSucceedEvent(UserAuthSuccessEvent event) { var command = new TokenCreateCommand() {{ - setJti(event.getData().get("jti")); - setContent(event.getData().get("jwt")); + setJti((String) event.getData().get("jti")); + setContent((String) event.getData().get("jwt")); setSubject(event.getUserId()); + setIssuedAt((LocalDateTime) event.getData().get("iat")); + setExpiresAt((LocalDateTime) event.getData().get("exp")); }}; mediator.sendAsync(command) From a22da16547568e60e5b4f5323259180340e839b3 Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 27 May 2026 11:11:09 +0800 Subject: [PATCH 7/8] Add DateTimeUtility class for Unix timestamp and date conversions --- .../framework/utility/DateTimeUtility.java | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 framework/src/main/java/io/theurl/framework/utility/DateTimeUtility.java diff --git a/framework/src/main/java/io/theurl/framework/utility/DateTimeUtility.java b/framework/src/main/java/io/theurl/framework/utility/DateTimeUtility.java new file mode 100644 index 0000000..9e74615 --- /dev/null +++ b/framework/src/main/java/io/theurl/framework/utility/DateTimeUtility.java @@ -0,0 +1,79 @@ +package io.theurl.framework.utility; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; + +public class DateTimeUtility { + public static long toUnixTimestamp(long epochMilli) { + return epochMilli / 1000; + } + + public static long toUnixTimestamp(long epochMilli, ZoneId zoneId) { + return epochMilli / 1000; + } + + public static long toUnixTimestamp(java.time.LocalDateTime dateTime) { + return dateTime.atZone(ZoneId.systemDefault()).toEpochSecond(); + } + + public static long toUnixTimestamp(java.time.LocalDateTime dateTime, ZoneId zoneId) { + return dateTime.atZone(zoneId).toEpochSecond(); + } + + public static long toUnixTimestamp(java.time.ZonedDateTime dateTime) { + return dateTime.toEpochSecond(); + } + + public static long toUnixTimestamp(java.time.ZonedDateTime dateTime, ZoneId zoneId) { + return dateTime.withZoneSameInstant(zoneId).toEpochSecond(); + } + + public static long toUnixTimestamp(java.time.Instant instant) { + return instant.getEpochSecond(); + } + + public static long toUnixTimestamp(java.time.Instant instant, ZoneId zoneId) { + return instant.atZone(zoneId).toEpochSecond(); + } + + public static long toUnixTimestamp(java.util.Date date) { + return date.getTime() / 1000; + } + + public static Date toDate(long unixTimestamp) { + return new Date(unixTimestamp * 1000); + } + + public static Date toDate(LocalDateTime dateTime) { + return Date.from(dateTime.atZone(ZoneId.systemDefault()).toInstant()); + } + + public static Date toDate(LocalDateTime dateTime, ZoneId zoneId) { + return Date.from(dateTime.atZone(zoneId).toInstant()); + } + + public static Date toDate(java.time.ZonedDateTime dateTime) { + return Date.from(dateTime.toInstant()); + } + + public static Date toDate(java.time.Instant instant) { + return Date.from(instant); + } + + public static LocalDateTime toLocalDateTime(long unixTimestamp) { + return LocalDateTime.ofEpochSecond(unixTimestamp, 0, ZoneId.systemDefault().getRules().getOffset(LocalDateTime.now())); + } + + public static LocalDateTime toLocalDateTime(long unixTimestamp, ZoneId zoneId) { + return LocalDateTime.ofEpochSecond(unixTimestamp, 0, zoneId.getRules().getOffset(LocalDateTime.now())); + } + + public static LocalDateTime toLocalDateTime(java.util.Date date) { + return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(); + } + + public static LocalDateTime toLocalDateTime(java.util.Date date, ZoneId zoneId) { + return date.toInstant().atZone(zoneId).toLocalDateTime(); + } +} From 0cbf43fafde035b468354aac86f7a6d549210424 Mon Sep 17 00:00:00 2001 From: damon Date: Wed, 27 May 2026 11:18:43 +0800 Subject: [PATCH 8/8] Add TokenRevocation functionality with command and event handling --- .../command/TokenRevokeCommand.java | 6 +++ .../application/event/TokenGrantedEvent.java | 23 +++++++++- .../event/TokenRefreshedEvent.java | 15 ++++++- .../event/UserAuthSuccessEvent.java | 2 +- .../handler/TokenRevokeCommandHandler.java | 32 +++++++++++++ .../implement/AuthApplicationServiceImpl.java | 45 +++++++++++++------ .../subscriber/TokenEventSubscriber.java | 24 ++++++---- .../identity/domain/aggregate/Token.java | 14 +++--- .../identity/domain/enums/TokenStatus.java | 17 +++++++ .../identity/persistence/entity/Token.java | 6 +-- 10 files changed, 148 insertions(+), 36 deletions(-) create mode 100644 identity/src/main/java/io/theurl/identity/application/command/TokenRevokeCommand.java create mode 100644 identity/src/main/java/io/theurl/identity/application/handler/TokenRevokeCommandHandler.java create mode 100644 identity/src/main/java/io/theurl/identity/domain/enums/TokenStatus.java 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 new file mode 100644 index 0000000..b4238ce --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/command/TokenRevokeCommand.java @@ -0,0 +1,6 @@ +package io.theurl.identity.application.command; + +import com.neroyun.mediator.Command; + +public record TokenRevokeCommand(String jti, String reason) implements Command { +} diff --git a/identity/src/main/java/io/theurl/identity/application/event/TokenGrantedEvent.java b/identity/src/main/java/io/theurl/identity/application/event/TokenGrantedEvent.java index 2f1b02b..2c912eb 100644 --- a/identity/src/main/java/io/theurl/identity/application/event/TokenGrantedEvent.java +++ b/identity/src/main/java/io/theurl/identity/application/event/TokenGrantedEvent.java @@ -1,6 +1,27 @@ package io.theurl.identity.application.event; +import com.neroyun.mediator.Event; import io.theurl.framework.domain.ApplicationEvent; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +public class TokenGrantedEvent extends ApplicationEvent implements Event { + + private final String jti; + private final Long userId; + private final String content; + private LocalDateTime expiresAt; + private LocalDateTime issuedAt; + + public TokenGrantedEvent(String jti, Long userId, String content) { + this.jti = jti; + this.userId = userId; + this.content = content; + } + -public class TokenGrantedEvent extends ApplicationEvent { } diff --git a/identity/src/main/java/io/theurl/identity/application/event/TokenRefreshedEvent.java b/identity/src/main/java/io/theurl/identity/application/event/TokenRefreshedEvent.java index 2393351..e0bd7dd 100644 --- a/identity/src/main/java/io/theurl/identity/application/event/TokenRefreshedEvent.java +++ b/identity/src/main/java/io/theurl/identity/application/event/TokenRefreshedEvent.java @@ -1,6 +1,19 @@ package io.theurl.identity.application.event; +import com.neroyun.mediator.Event; import io.theurl.framework.domain.ApplicationEvent; +import lombok.Getter; + +/** + * TokenRefreshedEvent is an application event that is published when a JWT token is refreshed. + * It contains the JTI (JWT ID) of the refreshed token, which can be used to track token refresh events and perform actions such as invalidating old tokens or logging refresh activity. + */ +@Getter +public final class TokenRefreshedEvent extends ApplicationEvent implements Event { + private final String jti; + + public TokenRefreshedEvent(String jti) { + this.jti = jti; + } -public class TokenRefreshedEvent extends ApplicationEvent { } diff --git a/identity/src/main/java/io/theurl/identity/application/event/UserAuthSuccessEvent.java b/identity/src/main/java/io/theurl/identity/application/event/UserAuthSuccessEvent.java index 6f30881..8a9c884 100644 --- a/identity/src/main/java/io/theurl/identity/application/event/UserAuthSuccessEvent.java +++ b/identity/src/main/java/io/theurl/identity/application/event/UserAuthSuccessEvent.java @@ -12,7 +12,7 @@ @EqualsAndHashCode(callSuper = true) @Data -public final class UserAuthSuccessEvent extends ApplicationEvent implements Event { +public class UserAuthSuccessEvent extends ApplicationEvent implements Event { private final String grantType; private final Long userId; 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 new file mode 100644 index 0000000..5b5351c --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/handler/TokenRevokeCommandHandler.java @@ -0,0 +1,32 @@ +package io.theurl.identity.application.handler; + +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.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) +public class TokenRevokeCommandHandler implements Handler { + private final TokenRepository repository; + + public TokenRevokeCommandHandler(TokenRepository repository) { + this.repository = 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); + } + return CompletableFuture.completedFuture(null); + } +} 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 ff95745..7f73c50 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 @@ -8,8 +8,11 @@ import io.theurl.framework.core.ObjectId; import io.theurl.framework.security.*; import io.theurl.framework.utility.Cryptography; +import io.theurl.framework.utility.DateTimeUtility; import io.theurl.framework.utility.RegexUtility; import io.theurl.identity.application.contract.AuthApplicationService; +import io.theurl.identity.application.event.TokenGrantedEvent; +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.external.ExternalAuthProvider; @@ -31,7 +34,6 @@ import org.springframework.web.context.annotation.RequestScope; import java.time.LocalDateTime; -import java.time.Instant; import java.time.ZoneOffset; import java.util.*; import java.util.concurrent.CompletableFuture; @@ -107,19 +109,24 @@ public Map getDetails() { } var jwtId = ObjectId.guid().toString(); - var iat = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC); - var exp = LocalDateTime.now().plusHours(24).toEpochSecond(ZoneOffset.UTC); - var accessToken = generateToken(jwtId, userInfo, iat, exp); + var iat = LocalDateTime.now();//.toEpochSecond(ZoneOffset.UTC); + var exp = LocalDateTime.now().plusHours(24);//.toEpochSecond(ZoneOffset.UTC); + var accessToken = generateToken(jwtId, userInfo, DateTimeUtility.toDate(iat), DateTimeUtility.toDate(exp)); - var event = new UserAuthSuccessEvent(request.grantType(), userInfo.getId()); - event.setGrantTime(LocalDateTime.now()); - event.setData("jti", jwtId); - event.setData("jwt", accessToken); - event.setData("iat", iat); - event.setData("exp", exp); - events.add(event); + events.add(new UserAuthSuccessEvent(request.grantType(), userInfo.getId()) {{ + setGrantTime(iat); + }}); + + events.add(new TokenGrantedEvent(jwtId, userInfo.getId(), accessToken) {{ + setIssuedAt(iat); + setExpiresAt(exp); + }}); + + if (request.grantType().equals("refresh_token")) { + events.add(new TokenRefreshedEvent(request.username())); + } - var result = new TokenGrantResponseDto(accessToken, jwtId, "Bearer", 3600 * 24, iat, userInfo.getUsername(), userInfo.getId()); + var result = new TokenGrantResponseDto(accessToken, jwtId, "Bearer", 3600 * 24, iat.toEpochSecond(ZoneOffset.UTC), userInfo.getUsername(), userInfo.getId()); return CompletableFuture.completedFuture(result); } catch (Exception e) { @@ -209,14 +216,24 @@ public Map getDetails() { }); } - private String generateToken(String id, UserAuthInfo user, long issuedAt, long expiresAt) { + private String generateToken(String id, UserAuthInfo user, Date issuedAt, Date expiresAt) { Assert.notNull(user, "user cannot be null"); //var signingKey = environment.getProperty("JwtAuthenticationOptions.SigningKey"); Assert.notNull(signingKey, "SigningKey cannot be null"); var builder = Jwts.builder(); - builder.subject(String.valueOf(user.getId())).id(id).issuer(issuer).issuedAt(Date.from(Instant.ofEpochSecond(issuedAt))).expiration(Date.from(Instant.ofEpochSecond(expiresAt))) // 24小时后过期 + builder.subject(String.valueOf(user.getId())).id(id) + .issuer(issuer) + .issuedAt(issuedAt) + .expiration(expiresAt) .claim("name", user.getUsername()); + + if (user.getRoles() != null) { + for (var role : user.getRoles()) { + builder.claim("role", role); + } + } + builder.signWith(Keys.hmacShaKeyFor(signingKey.getBytes())); return builder.compact(); } 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 36d6ace..06a5d23 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 @@ -3,15 +3,15 @@ import com.neroyun.mediator.Mediator; import io.theurl.framework.core.BeanScope; import io.theurl.identity.application.command.TokenCreateCommand; -import io.theurl.identity.application.event.UserAuthSuccessEvent; +import io.theurl.identity.application.command.TokenRevokeCommand; +import io.theurl.identity.application.event.TokenGrantedEvent; +import io.theurl.identity.application.event.TokenRefreshedEvent; 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; -import java.time.LocalDateTime; - @Component @Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) public class TokenEventSubscriber { @@ -23,16 +23,24 @@ public TokenEventSubscriber(Mediator mediator) { @Async @EventListener - public void handleUserAuthSucceedEvent(UserAuthSuccessEvent event) { + public void handleUserAuthSucceedEvent(TokenGrantedEvent event) { var command = new TokenCreateCommand() {{ - setJti((String) event.getData().get("jti")); - setContent((String) event.getData().get("jwt")); + setJti(event.getJti()); + setContent(event.getContent()); setSubject(event.getUserId()); - setIssuedAt((LocalDateTime) event.getData().get("iat")); - setExpiresAt((LocalDateTime) event.getData().get("exp")); + setExpiresAt(event.getExpiresAt()); + setIssuedAt(event.getIssuedAt()); }}; mediator.sendAsync(command) .join(); } + + @Async + @EventListener + public void handleTokenRefreshedEvent(TokenRefreshedEvent event) { + var command = new TokenRevokeCommand(event.getJti(), "refreshed"); + mediator.sendAsync(command) + .join(); + } } 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 56f8453..9138c7b 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 @@ -2,6 +2,7 @@ import io.theurl.framework.domain.AggregateRoot; import io.theurl.framework.utility.SnowflakeId; +import io.theurl.identity.domain.enums.TokenStatus; import java.time.LocalDateTime; @@ -12,8 +13,8 @@ public class Token extends AggregateRoot { private final Long subject; private LocalDateTime issuedAt; private LocalDateTime expiresAt; - private LocalDateTime refreshAt; private LocalDateTime revokedAt; + private TokenStatus status = TokenStatus.NORMAL; /** * Initializes the aggregate with the given id. @@ -47,8 +48,8 @@ public Long getSubject() { return subject; } - public LocalDateTime getRefreshAt() { - return refreshAt; + public TokenStatus getStatus() { + return status; } public LocalDateTime getRevokedAt() { @@ -69,12 +70,9 @@ public void setExpiresAt(LocalDateTime expiresAt) { this.expiresAt = expiresAt; } - public void refresh() { - this.refreshAt = LocalDateTime.now(); - } - - public void revoke() { + public void revoke(TokenStatus reason) { this.revokedAt = LocalDateTime.now(); + status = reason; } public static Token create(String jti, String content, Long subject) { diff --git a/identity/src/main/java/io/theurl/identity/domain/enums/TokenStatus.java b/identity/src/main/java/io/theurl/identity/domain/enums/TokenStatus.java new file mode 100644 index 0000000..7b6fbb2 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/domain/enums/TokenStatus.java @@ -0,0 +1,17 @@ +package io.theurl.identity.domain.enums; + +public enum TokenStatus { + NORMAL("normal"), + LOGOUT("logout"), + REFRESHED("refreshed"); + private final String value; + + TokenStatus(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + +} diff --git a/identity/src/main/java/io/theurl/identity/persistence/entity/Token.java b/identity/src/main/java/io/theurl/identity/persistence/entity/Token.java index c2f596c..9559eaa 100644 --- a/identity/src/main/java/io/theurl/identity/persistence/entity/Token.java +++ b/identity/src/main/java/io/theurl/identity/persistence/entity/Token.java @@ -31,12 +31,12 @@ public class Token implements Persistable { @Column(name = "expires_at") private LocalDateTime expiresAt; - @Column(name = "refresh_at") - private LocalDateTime refreshAt; - @Column(name = "revoked_at") private LocalDateTime revokedAt; + @Column(length = 20) + private String status; + @Override public Long getId() { return id;