From 2901bedb16e3a9a6d8219907d0ef59631d0157cb Mon Sep 17 00:00:00 2001 From: damon Date: Tue, 26 May 2026 12:08:47 +0800 Subject: [PATCH 1/7] Add detailed documentation for BeanScope constants in Spring IoC container --- .../io/theurl/framework/core/BeanScope.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/framework/src/main/java/io/theurl/framework/core/BeanScope.java b/framework/src/main/java/io/theurl/framework/core/BeanScope.java index 04ce303..1885fbd 100644 --- a/framework/src/main/java/io/theurl/framework/core/BeanScope.java +++ b/framework/src/main/java/io/theurl/framework/core/BeanScope.java @@ -1,11 +1,54 @@ package io.theurl.framework.core; +/** + * Defines the scopes of a bean in the Spring IoC container. + *

> + * The scopes are defined as constants in this class, and can be used to specify the scope of a bean when defining it in the Spring configuration. + *

> + * The scopes defined in this class are: + *

+ */ public class BeanScope { + /** + * Scopes a single bean definition to the lifecycle of a ServletContext. + * Only valid in the context of a web-aware Spring ApplicationContext. + */ public final static String APPLICATION = "application"; + + /** + * Scopes a single bean definition to any number of object instances. + */ public final static String PROTOTYPE = "prototype"; + + /** + * Scopes a single bean definition to the lifecycle of a single HTTP request. + * That is, each HTTP request has its own instance of a bean created off the back of a single bean definition. + * Only valid in the context of a web-aware Spring ApplicationContext. + */ public final static String REQUEST = "request"; + + /** + * Scopes a single bean definition to the lifecycle of an HTTP Session. + * Only valid in the context of a web-aware Spring ApplicationContext. + */ public final static String SESSION = "session"; + + /** + * Scopes a single bean definition to a single object instance for each Spring IoC container. + */ public final static String SINGLETON = "singleton"; + + /** + * Scopes a single bean definition to the lifecycle of a WebSocket. + * Only valid in the context of a web-aware Spring ApplicationContext. + */ public final static String WEB_SOCKET = "websocket"; // APPLICATION("application"), // PROTOTYPE("prototype"), From 6b07de4667f1c7e59c6ba4a8d5269f1798c6876b Mon Sep 17 00:00:00 2001 From: damon Date: Tue, 26 May 2026 14:31:35 +0800 Subject: [PATCH 2/7] Implement user context retrieval and logging enhancements in authentication events --- framework/pom.xml | 4 ++++ .../application/BaseApplicationService.java | 16 +++++++++++++ .../command/AuthlogCreateCommand.java | 7 ++++++ .../implement/UserApplicationServiceImpl.java | 3 ++- .../subscriber/LoggingEventSubscriber.java | 24 +++++++++++++++++++ 5 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 identity/src/main/java/io/theurl/identity/application/command/AuthlogCreateCommand.java diff --git a/framework/pom.xml b/framework/pom.xml index 0fb73e7..76a9fbc 100644 --- a/framework/pom.xml +++ b/framework/pom.xml @@ -42,6 +42,10 @@ spring-web ${spring-framework.version} + + org.apache.tomcat.embed + tomcat-embed-core + org.bouncycastle bcprov-jdk18on 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 c1342ff..18656b6 100644 --- a/framework/src/main/java/io/theurl/framework/application/BaseApplicationService.java +++ b/framework/src/main/java/io/theurl/framework/application/BaseApplicationService.java @@ -5,7 +5,11 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.stereotype.Service; import org.springframework.web.context.annotation.RequestScope; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import jakarta.servlet.http.HttpServletRequest; +import java.security.Principal; import java.util.concurrent.CompletionException; import java.util.function.Consumer; @@ -28,4 +32,16 @@ protected void handleException(Throwable throwable, Consumer consumer consumer.accept(throwable); } } + + protected HttpServletRequest getRequest() { + return ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest(); + } + + protected Principal currentUser() { + var request = getRequest(); + if (request == null) { + return null; + } + return request.getUserPrincipal(); + } } 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 new file mode 100644 index 0000000..de62075 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/command/AuthlogCreateCommand.java @@ -0,0 +1,7 @@ +package io.theurl.identity.application.command; + +import com.neroyun.mediator.Command; + +public class AuthlogCreateCommand implements Command { + +} 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 574851d..300118e 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 @@ -36,6 +36,7 @@ public CompletableFuture createAsync(UserCreateRequestDto user) { @Override public CompletableFuture getProfileAsync() { + var user = currentUser(); var query = new UserDetailQuery(1L); return mediator.executeAsync(query) .thenApply(userDetail -> mapper.map(userDetail, UserProfileResponseDto.class)); @@ -43,7 +44,7 @@ public CompletableFuture getProfileAsync() { @Override public CompletableFuture changePasswordAsync(String oldPassword, String newPassword) { - mediator.executeAsync(new UserAuthInfoQuery("id", "")) + mediator.executeAsync(new UserAuthInfoQuery("id", currentUser().getName())) .thenAccept(userDetail -> { if (userDetail == null) { throw new IllegalStateException("User not found."); diff --git a/identity/src/main/java/io/theurl/identity/application/subscriber/LoggingEventSubscriber.java b/identity/src/main/java/io/theurl/identity/application/subscriber/LoggingEventSubscriber.java index cc150e6..8c69a70 100644 --- a/identity/src/main/java/io/theurl/identity/application/subscriber/LoggingEventSubscriber.java +++ b/identity/src/main/java/io/theurl/identity/application/subscriber/LoggingEventSubscriber.java @@ -1,4 +1,28 @@ package io.theurl.identity.application.subscriber; +import io.theurl.framework.core.BeanScope; +import io.theurl.identity.application.event.UserAuthFailureEvent; +import io.theurl.identity.application.event.UserAuthSuccessEvent; +import org.springframework.context.annotation.Scope; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +@Component +@Scope(BeanScope.PROTOTYPE) public class LoggingEventSubscriber { + + @Async + @EventListener + public void handleUserAuthSuccess(UserAuthSuccessEvent event) { + ((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getRequest().getUserPrincipal(); + } + + @Async + @EventListener + public void handleUserAuthFailure(UserAuthFailureEvent event) { + + } } From 062ac9661877f076076b4f9785f97a072b694c4c Mon Sep 17 00:00:00 2001 From: damon Date: Tue, 26 May 2026 14:45:36 +0800 Subject: [PATCH 3/7] Add OpenAPI security configuration and enhance API documentation with security requirements --- .../OpenApiSecurityConfiguration.java | 27 +++++++++++++++++++ .../controller/AccountController.java | 5 ++++ .../interfaces/controller/AuthController.java | 3 +++ 3 files changed, 35 insertions(+) create mode 100644 identity/src/main/java/io/theurl/identity/configure/OpenApiSecurityConfiguration.java diff --git a/identity/src/main/java/io/theurl/identity/configure/OpenApiSecurityConfiguration.java b/identity/src/main/java/io/theurl/identity/configure/OpenApiSecurityConfiguration.java new file mode 100644 index 0000000..cdccfd7 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/configure/OpenApiSecurityConfiguration.java @@ -0,0 +1,27 @@ +package io.theurl.identity.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/identity/src/main/java/io/theurl/identity/interfaces/controller/AccountController.java b/identity/src/main/java/io/theurl/identity/interfaces/controller/AccountController.java index 7758c3d..5e49e8a 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 @@ -4,6 +4,8 @@ import io.theurl.identity.application.dto.UserCreateRequestDto; import io.theurl.identity.application.dto.UserPasswordChangeRequestDto; import io.theurl.identity.application.dto.UserProfileResponseDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -17,6 +19,7 @@ public AccountController(UserApplicationService service) { } @PostMapping("/register") + @Operation(summary = "Register a new user", security = {}) public ResponseEntity create(@RequestBody UserCreateRequestDto user) { service.createAsync(user) .join(); @@ -24,6 +27,7 @@ public ResponseEntity create(@RequestBody UserCreateRequestDto user) { } @GetMapping("/profile") + @Operation(summary = "Get current profile", security = @SecurityRequirement(name = "bearerAuth")) public ResponseEntity getProfile() { var profileFuture = service.getProfileAsync(); var profile = profileFuture.join(); @@ -31,6 +35,7 @@ public ResponseEntity getProfile() { } @PostMapping("/password/change") + @Operation(summary = "Change current password", security = @SecurityRequirement(name = "bearerAuth")) public ResponseEntity changePassword(@RequestBody UserPasswordChangeRequestDto user) { service.changePasswordAsync(user.getOldPassword(), user.getNewPassword()) .join(); 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 37661f9..36c342e 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 @@ -1,5 +1,6 @@ package io.theurl.identity.interfaces.controller; +import io.swagger.v3.oas.annotations.Operation; import io.theurl.identity.application.contract.AuthApplicationService; import io.theurl.identity.application.dto.TokenGrantRequestDto; import io.theurl.identity.application.dto.TokenGrantResponseDto; @@ -25,6 +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 = {}) public TokenGrantResponseDto grantToken(@RequestBody TokenGrantRequestDto request) { return service.grant(request).join(); } @@ -38,6 +40,7 @@ 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 = {}) public TokenGrantResponseDto refreshToken(@RequestParam String token) { var request = new TokenGrantRequestDto(token, null, "refresh_token", null); return service.grant(request).join(); From 1dc5f89fd256df898cc0340658b5b8a922e0cec2 Mon Sep 17 00:00:00 2001 From: damon Date: Tue, 26 May 2026 16:14:53 +0800 Subject: [PATCH 4/7] Implement JWT authentication and user profile retrieval with enhanced error handling --- .../application/BaseApplicationService.java | 9 ++ identity/pom.xml | 4 + .../dto/UserProfileResponseDto.java | 11 +++ .../implement/AuthApplicationServiceImpl.java | 3 +- .../implement/UserApplicationServiceImpl.java | 7 +- .../configure/JwtAuthenticationFilter.java | 62 ++++++++++++++ .../configure/SecurityConfiguration.java | 40 +++++++++ .../handler/UserDetailQueryHandler.java | 39 +++++++++ .../persistence/model/UserDetail.java | 11 +++ .../persistence/profile/UserMapProfile.java | 8 ++ .../AccountControllerSecurityTests.java | 85 +++++++++++++++++++ 11 files changed, 274 insertions(+), 5 deletions(-) create mode 100644 identity/src/main/java/io/theurl/identity/configure/JwtAuthenticationFilter.java create mode 100644 identity/src/main/java/io/theurl/identity/configure/SecurityConfiguration.java create mode 100644 identity/src/main/java/io/theurl/identity/persistence/handler/UserDetailQueryHandler.java create mode 100644 identity/src/test/java/io/theurl/identity/interfaces/controller/AccountControllerSecurityTests.java 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 18656b6..7b12d6f 100644 --- a/framework/src/main/java/io/theurl/framework/application/BaseApplicationService.java +++ b/framework/src/main/java/io/theurl/framework/application/BaseApplicationService.java @@ -1,6 +1,7 @@ package io.theurl.framework.application; import com.neroyun.mediator.Mediator; +import io.theurl.framework.security.CredentialExpiredException; import org.springframework.context.ApplicationContext; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.stereotype.Service; @@ -44,4 +45,12 @@ protected Principal currentUser() { } return request.getUserPrincipal(); } + + protected Long currentUserId() { + var principal = currentUser(); + if (principal == null || principal.getName() == null || principal.getName().isBlank()) { + throw new CredentialExpiredException(null, "Unauthenticated request."); + } + return Long.parseLong(principal.getName()); + } } diff --git a/identity/pom.xml b/identity/pom.xml index dd8eb5c..6551f49 100644 --- a/identity/pom.xml +++ b/identity/pom.xml @@ -60,6 +60,10 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-security + org.springdoc springdoc-openapi-starter-webmvc-ui diff --git a/identity/src/main/java/io/theurl/identity/application/dto/UserProfileResponseDto.java b/identity/src/main/java/io/theurl/identity/application/dto/UserProfileResponseDto.java index 2b4a0c7..7e8999f 100644 --- a/identity/src/main/java/io/theurl/identity/application/dto/UserProfileResponseDto.java +++ b/identity/src/main/java/io/theurl/identity/application/dto/UserProfileResponseDto.java @@ -1,4 +1,15 @@ package io.theurl.identity.application.dto; +import lombok.Data; + +import java.util.Collection; + +@Data public class UserProfileResponseDto { + private Long id; + private String username; + private String nickname; + private String email; + private String phone; + private Collection roles; } 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 ecc742f..c7888b3 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 @@ -29,6 +29,7 @@ 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; @@ -192,7 +193,7 @@ private String generateToken(String id, UserAuthInfo user, long issuedAt, long e Assert.notNull(signingKey, "SigningKey cannot be null"); var builder = Jwts.builder(); - builder.subject(String.valueOf(user.getId())).id(id).issuer(issuer).issuedAt(new Date(issuedAt)).expiration(new Date(expiresAt)) // 24小时后过期 + builder.subject(String.valueOf(user.getId())).id(id).issuer(issuer).issuedAt(Date.from(Instant.ofEpochSecond(issuedAt))).expiration(Date.from(Instant.ofEpochSecond(expiresAt))) // 24小时后过期 .claim("name", user.getUsername()); builder.signWith(Keys.hmacShaKeyFor(signingKey.getBytes())); return builder.compact(); 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 300118e..70a6e66 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 @@ -36,15 +36,14 @@ public CompletableFuture createAsync(UserCreateRequestDto user) { @Override public CompletableFuture getProfileAsync() { - var user = currentUser(); - var query = new UserDetailQuery(1L); + var query = new UserDetailQuery(currentUserId()); return mediator.executeAsync(query) .thenApply(userDetail -> mapper.map(userDetail, UserProfileResponseDto.class)); } @Override public CompletableFuture changePasswordAsync(String oldPassword, String newPassword) { - mediator.executeAsync(new UserAuthInfoQuery("id", currentUser().getName())) + mediator.executeAsync(new UserAuthInfoQuery("id", String.valueOf(currentUserId()))) .thenAccept(userDetail -> { if (userDetail == null) { throw new IllegalStateException("User not found."); @@ -60,7 +59,7 @@ public CompletableFuture changePasswordAsync(String oldPassword, String ne }) .join(); - var command = new UserPasswordChangeCommand(1L, newPassword, "change"); + var command = new UserPasswordChangeCommand(currentUserId(), newPassword, "change"); return mediator.sendAsync(command); } } diff --git a/identity/src/main/java/io/theurl/identity/configure/JwtAuthenticationFilter.java b/identity/src/main/java/io/theurl/identity/configure/JwtAuthenticationFilter.java new file mode 100644 index 0000000..b2e10ea --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/configure/JwtAuthenticationFilter.java @@ -0,0 +1,62 @@ +package io.theurl.identity.configure; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Collections; + +/** + * JWT认证过滤器:从Authorization头中解析Bearer Token并写入SecurityContext。 + */ +@Slf4j +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + @Value("${jwt.secret}") + private String signingKey; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + + if (authHeader != null && authHeader.startsWith("Bearer ") && SecurityContextHolder.getContext().getAuthentication() == null) { + String token = authHeader.substring(7); + try { + var claims = Jwts.parser() + .verifyWith(Keys.hmacShaKeyFor(signingKey.getBytes(StandardCharsets.UTF_8))) + .build() + .parseSignedClaims(token) + .getPayload(); + + String userId = claims.getSubject(); + if (userId != null && !userId.isBlank()) { + var authentication = new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception e) { + // Token解析失败时不抛出异常,交由后续鉴权流程统一返回401。 + log.debug("JWT parse failed: {}", e.getMessage()); + } + } + + filterChain.doFilter(request, response); + } +} + diff --git a/identity/src/main/java/io/theurl/identity/configure/SecurityConfiguration.java b/identity/src/main/java/io/theurl/identity/configure/SecurityConfiguration.java new file mode 100644 index 0000000..c63b1f2 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/configure/SecurityConfiguration.java @@ -0,0 +1,40 @@ +package io.theurl.identity.configure; + +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; + +/** + * Security配置:开启JWT无状态鉴权,并放行登录注册与Swagger相关端点。 + */ +@Configuration +public class SecurityConfiguration { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, + JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception { + http.csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/auth/**", + "/account/register", + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/error" + ).permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} + diff --git a/identity/src/main/java/io/theurl/identity/persistence/handler/UserDetailQueryHandler.java b/identity/src/main/java/io/theurl/identity/persistence/handler/UserDetailQueryHandler.java new file mode 100644 index 0000000..49295dc --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/handler/UserDetailQueryHandler.java @@ -0,0 +1,39 @@ +package io.theurl.identity.persistence.handler; + +import com.neroyun.mediator.Handler; +import io.theurl.framework.core.BeanScope; +import io.theurl.identity.persistence.model.UserDetail; +import io.theurl.identity.persistence.query.UserDetailQuery; +import io.theurl.identity.persistence.repository.JpaUserRepository; +import org.modelmapper.ModelMapper; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Component; + +import java.util.concurrent.CompletableFuture; + +@Component +@Scope(BeanScope.PROTOTYPE) +public class UserDetailQueryHandler implements Handler { + private final JpaUserRepository repository; + private final ModelMapper mapper; + + public UserDetailQueryHandler(JpaUserRepository repository, ModelMapper mapper) { + this.repository = repository; + this.mapper = mapper; + } + + @Override + public CompletableFuture handleAsync(UserDetailQuery message) { + var user = repository.findById(message.id()).orElse(null); + + UserDetail detail; + + if (user == null) { + detail = null; + } else { + detail = mapper.map(user, UserDetail.class); + } + + return CompletableFuture.completedFuture(detail); + } +} diff --git a/identity/src/main/java/io/theurl/identity/persistence/model/UserDetail.java b/identity/src/main/java/io/theurl/identity/persistence/model/UserDetail.java index 1b9f890..0f48a56 100644 --- a/identity/src/main/java/io/theurl/identity/persistence/model/UserDetail.java +++ b/identity/src/main/java/io/theurl/identity/persistence/model/UserDetail.java @@ -1,4 +1,15 @@ package io.theurl.identity.persistence.model; +import lombok.Data; + +import java.util.Collection; + +@Data public class UserDetail { + private Long id; + private String username; + private String nickname; + private String email; + private String phone; + private Collection roles; } diff --git a/identity/src/main/java/io/theurl/identity/persistence/profile/UserMapProfile.java b/identity/src/main/java/io/theurl/identity/persistence/profile/UserMapProfile.java index d71cb78..a1cf761 100644 --- a/identity/src/main/java/io/theurl/identity/persistence/profile/UserMapProfile.java +++ b/identity/src/main/java/io/theurl/identity/persistence/profile/UserMapProfile.java @@ -6,6 +6,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; +import java.util.List; + @Component public class UserMapProfile { @Autowired @@ -74,5 +76,11 @@ public void configure() { expression.map(io.theurl.identity.domain.aggregate.User::getLockedUntil, io.theurl.identity.persistence.entity.User::setLockedUntil); expression.map(io.theurl.identity.domain.aggregate.User::getRoles, io.theurl.identity.persistence.entity.User::setRoles); }); + + mapper.createTypeMap(io.theurl.identity.persistence.entity.User.class, io.theurl.identity.persistence.model.UserDetail.class) + .addMappings(expression -> { + expression.map(src -> src.getRoles() == null ? List.of() : src.getRoles().stream().map(io.theurl.identity.persistence.entity.UserRole::getName).toList(), io.theurl.identity.persistence.model.UserDetail::setRoles); + }); + } } diff --git a/identity/src/test/java/io/theurl/identity/interfaces/controller/AccountControllerSecurityTests.java b/identity/src/test/java/io/theurl/identity/interfaces/controller/AccountControllerSecurityTests.java new file mode 100644 index 0000000..bd6519b --- /dev/null +++ b/identity/src/test/java/io/theurl/identity/interfaces/controller/AccountControllerSecurityTests.java @@ -0,0 +1,85 @@ +package io.theurl.identity.interfaces.controller; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import io.theurl.identity.application.contract.UserApplicationService; +import io.theurl.identity.application.dto.UserProfileResponseDto; +import io.theurl.identity.configure.JwtAuthenticationFilter; +import io.theurl.identity.configure.SecurityConfiguration; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; +import java.util.concurrent.CompletableFuture; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = AccountController.class) +@Import({SecurityConfiguration.class, JwtAuthenticationFilter.class}) +@TestPropertySource(properties = "jwt.secret=01234567890123456789012345678901") +class AccountControllerSecurityTests { + + private static final String TEST_SECRET = "01234567890123456789012345678901"; + + @Autowired + private MockMvc mockMvc; + + @MockBean + private UserApplicationService userApplicationService; + + @Test + void shouldReturn401WhenProfileRequestHasNoJwt() throws Exception { + mockMvc.perform(get("/account/profile")) + .andExpect(status().isUnauthorized()); + } + + @Test + void shouldReturn200WhenProfileRequestHasValidJwt() throws Exception { + when(userApplicationService.getProfileAsync()).thenReturn(CompletableFuture.completedFuture(new UserProfileResponseDto())); + + mockMvc.perform(get("/account/profile") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + createToken("1"))) + .andExpect(status().isOk()); + + verify(userApplicationService).getProfileAsync(); + } + + @Test + void shouldAllowRegisterWithoutJwt() throws Exception { + when(userApplicationService.createAsync(any())).thenReturn(CompletableFuture.completedFuture(null)); + + mockMvc.perform(post("/account/register") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"demo\",\"password\":\"pwd\"}")) + .andExpect(status().isOk()); + + verify(userApplicationService).createAsync(any()); + } + + /** + * 生成用于测试鉴权流程的短时JWT。 + */ + private String createToken(String subject) { + return Jwts.builder() + .subject(subject) + .issuedAt(new Date()) + .expiration(Date.from(Instant.now().plusSeconds(300))) + .signWith(Keys.hmacShaKeyFor(TEST_SECRET.getBytes(StandardCharsets.UTF_8))) + .compact(); + } +} + From 82b19887dcba27e41398636e4fc98128a23356b9 Mon Sep 17 00:00:00 2001 From: damon Date: Tue, 26 May 2026 16:28:19 +0800 Subject: [PATCH 5/7] Enhance security configuration with custom unauthorized response and update test annotations --- identity/pom.xml | 5 +++++ .../identity/configure/SecurityConfiguration.java | 3 +++ .../controller/AccountControllerSecurityTests.java | 14 ++++++-------- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/identity/pom.xml b/identity/pom.xml index 6551f49..451c7a5 100644 --- a/identity/pom.xml +++ b/identity/pom.xml @@ -99,6 +99,11 @@ spring-boot-starter-data-jpa-test test + + org.springframework.boot + spring-boot-starter-test + test + 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 c63b1f2..fc81be4 100644 --- a/identity/src/main/java/io/theurl/identity/configure/SecurityConfiguration.java +++ b/identity/src/main/java/io/theurl/identity/configure/SecurityConfiguration.java @@ -1,5 +1,6 @@ package io.theurl.identity.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; @@ -21,10 +22,12 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, .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( "/auth/**", "/account/register", + "/account/password/reset", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", diff --git a/identity/src/test/java/io/theurl/identity/interfaces/controller/AccountControllerSecurityTests.java b/identity/src/test/java/io/theurl/identity/interfaces/controller/AccountControllerSecurityTests.java index bd6519b..a6c91b9 100644 --- a/identity/src/test/java/io/theurl/identity/interfaces/controller/AccountControllerSecurityTests.java +++ b/identity/src/test/java/io/theurl/identity/interfaces/controller/AccountControllerSecurityTests.java @@ -4,16 +4,14 @@ import io.jsonwebtoken.security.Keys; import io.theurl.identity.application.contract.UserApplicationService; import io.theurl.identity.application.dto.UserProfileResponseDto; -import io.theurl.identity.configure.JwtAuthenticationFilter; -import io.theurl.identity.configure.SecurityConfiguration; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.Import; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import java.nio.charset.StandardCharsets; @@ -28,8 +26,8 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@WebMvcTest(controllers = AccountController.class) -@Import({SecurityConfiguration.class, JwtAuthenticationFilter.class}) +@SpringBootTest +@AutoConfigureMockMvc @TestPropertySource(properties = "jwt.secret=01234567890123456789012345678901") class AccountControllerSecurityTests { @@ -38,7 +36,7 @@ class AccountControllerSecurityTests { @Autowired private MockMvc mockMvc; - @MockBean + @MockitoBean private UserApplicationService userApplicationService; @Test From b3e9fdf823c3641c34ba0525bff0399af160135d Mon Sep 17 00:00:00 2001 From: damon Date: Tue, 26 May 2026 23:51:01 +0800 Subject: [PATCH 6/7] Add context copying decorator and update task executor configuration for async request handling --- framework/pom.xml | 4 +++ .../configure/ContextCopyingDecorator.java | 22 ++++++++++++++ .../configure/MediatorConfiguration.java | 24 ++++----------- .../framework/configure/WebConfiguration.java | 30 +++++++++++++++++++ 4 files changed, 62 insertions(+), 18 deletions(-) create mode 100644 framework/src/main/java/io/theurl/framework/configure/ContextCopyingDecorator.java create mode 100644 framework/src/main/java/io/theurl/framework/configure/WebConfiguration.java diff --git a/framework/pom.xml b/framework/pom.xml index 76a9fbc..f8ddb63 100644 --- a/framework/pom.xml +++ b/framework/pom.xml @@ -42,6 +42,10 @@ spring-web ${spring-framework.version} + + org.springframework.boot + spring-boot + org.apache.tomcat.embed tomcat-embed-core diff --git a/framework/src/main/java/io/theurl/framework/configure/ContextCopyingDecorator.java b/framework/src/main/java/io/theurl/framework/configure/ContextCopyingDecorator.java new file mode 100644 index 0000000..41617de --- /dev/null +++ b/framework/src/main/java/io/theurl/framework/configure/ContextCopyingDecorator.java @@ -0,0 +1,22 @@ +package io.theurl.framework.configure; + +import org.springframework.core.task.TaskDecorator; +import org.springframework.web.context.request.RequestContextHolder; + +@SuppressWarnings("NullableProblems") +public class ContextCopyingDecorator implements TaskDecorator { + + @Override + public Runnable decorate(Runnable runnable) { + var attributes = RequestContextHolder.getRequestAttributes(); + + return () -> { + try { + RequestContextHolder.setRequestAttributes(attributes); + runnable.run(); + } finally { + RequestContextHolder.resetRequestAttributes(); + } + }; + } +} diff --git a/framework/src/main/java/io/theurl/framework/configure/MediatorConfiguration.java b/framework/src/main/java/io/theurl/framework/configure/MediatorConfiguration.java index a573727..225c069 100644 --- a/framework/src/main/java/io/theurl/framework/configure/MediatorConfiguration.java +++ b/framework/src/main/java/io/theurl/framework/configure/MediatorConfiguration.java @@ -1,6 +1,7 @@ package io.theurl.framework.configure; import com.neroyun.mediator.*; +import org.slf4j.MDC; import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; @@ -12,7 +13,9 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.RequestContextListener; +import java.util.Map; import java.util.concurrent.CompletableFuture; @SuppressWarnings("rawtypes") @@ -34,31 +37,16 @@ public ThreadPoolTaskExecutor taskExecutor() { executor.setCorePoolSize(8); executor.setMaxPoolSize(32); executor.setQueueCapacity(200); - executor.setTaskDecorator(copyRequestContextDecorator()); + executor.setTaskDecorator(new ContextCopyingDecorator()); executor.initialize(); return executor; } - private TaskDecorator copyRequestContextDecorator() { - return runnable -> { - RequestAttributes context = RequestContextHolder.getRequestAttributes(); - return () -> { - try { - if (context != null) { - RequestContextHolder.setRequestAttributes(context); - } - runnable.run(); - } finally { - RequestContextHolder.resetRequestAttributes(); - } - }; - }; - } - @Bean(name = "applicationEventMulticaster") public ApplicationEventMulticaster simpleApplicationEventMulticaster() { SimpleApplicationEventMulticaster multicaster = new SimpleApplicationEventMulticaster(); - multicaster.setTaskExecutor(new SimpleAsyncTaskExecutor()); + multicaster.setTaskExecutor(taskExecutor()); + //multicaster.setTaskExecutor(new SimpleAsyncTaskExecutor()); return multicaster; } } diff --git a/framework/src/main/java/io/theurl/framework/configure/WebConfiguration.java b/framework/src/main/java/io/theurl/framework/configure/WebConfiguration.java new file mode 100644 index 0000000..6dff801 --- /dev/null +++ b/framework/src/main/java/io/theurl/framework/configure/WebConfiguration.java @@ -0,0 +1,30 @@ +package io.theurl.framework.configure; + +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.boot.web.servlet.ServletListenerRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.web.context.request.RequestContextListener; +import org.springframework.web.filter.RequestContextFilter; + +@Configuration +public class WebConfiguration { + @Bean + public ServletListenerRegistrationBean requestContextListenerRegistration() { + ServletListenerRegistrationBean registrationBean = new ServletListenerRegistrationBean<>(); + registrationBean.setListener(new RequestContextListener()); + return registrationBean; + } + + @Bean + public FilterRegistrationBean requestContextFilterRegistration() { + RequestContextFilter filter = new RequestContextFilter(); + // Force the request context to pass down to asynchronous child threads + filter.setThreadContextInheritable(true); + + FilterRegistrationBean registration = new FilterRegistrationBean<>(filter); + registration.setOrder(Ordered.HIGHEST_PRECEDENCE); + return registration; + } +} From a50692d7ef7aee32a5c6cbf5891d07a6c9041eff Mon Sep 17 00:00:00 2001 From: damon Date: Tue, 26 May 2026 23:53:54 +0800 Subject: [PATCH 7/7] Implement authentication logging with Authlog entity and command pattern --- .../command/AuthlogCreateCommand.java | 21 +- .../handler/AuthlogCreateCommandHandler.java | 35 ++++ .../handler/TokenCreateCommandHandler.java | 4 +- .../UserAccessFailureCountCommandHandler.java | 3 +- .../handler/UserCreateCommandHandler.java | 3 +- .../UserPasswordChangeCommandHandler.java | 3 +- .../implement/AuthApplicationServiceImpl.java | 189 +++++++++--------- .../subscriber/LoggingEventSubscriber.java | 72 ++++++- .../subscriber/TokenEventSubscriber.java | 3 +- ...UserAccessFailureCountEventSubscriber.java | 4 +- .../configure/JwtAuthenticationFilter.java | 22 +- .../configure/SecurityConfiguration.java | 8 +- .../identity/domain/aggregate/Authlog.java | 45 +++++ .../domain/repository/AuthlogRepository.java | 8 + .../OnetimePasswordDetailQueryHandler.java | 3 +- .../handler/UserAuthInfoQueryHandler.java | 3 +- .../handler/UserDetailQueryHandler.java | 3 +- .../repository/AuthlogRepositoryImpl.java | 24 +++ .../repository/JpaAuthlogRepository.java | 9 + 19 files changed, 343 insertions(+), 119 deletions(-) create mode 100644 identity/src/main/java/io/theurl/identity/application/handler/AuthlogCreateCommandHandler.java create mode 100644 identity/src/main/java/io/theurl/identity/domain/aggregate/Authlog.java create mode 100644 identity/src/main/java/io/theurl/identity/domain/repository/AuthlogRepository.java create mode 100644 identity/src/main/java/io/theurl/identity/persistence/repository/AuthlogRepositoryImpl.java create mode 100644 identity/src/main/java/io/theurl/identity/persistence/repository/JpaAuthlogRepository.java 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 de62075..3df3c09 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 @@ -1,7 +1,24 @@ package io.theurl.identity.application.command; import com.neroyun.mediator.Command; +import lombok.Data; -public class AuthlogCreateCommand implements Command { - +import java.time.LocalDateTime; + +@Data +public final class AuthlogCreateCommand implements Command { + private Long userId; + private String username; + private String grantType; + private String requestId; + private String ipAddress; + private String userAgent; + private String referrer; + private String appName; + private String appVersion; + private String osPlatform; + private String source; + private boolean success; + private String remark; + private LocalDateTime timestamp; } 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 new file mode 100644 index 0000000..6c9e733 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/handler/AuthlogCreateCommandHandler.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.AuthlogCreateCommand; +import io.theurl.identity.domain.repository.AuthlogRepository; +import io.theurl.identity.domain.aggregate.Authlog; +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 AuthlogCreateCommandHandler implements Handler { + + private final AuthlogRepository repository; + private final ModelMapper mapper; + + public AuthlogCreateCommandHandler(AuthlogRepository repository, ModelMapper mapper) { + this.repository = repository; + this.mapper = mapper; + } + + @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); + } +} 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 f21688d..5d89019 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 @@ -7,13 +7,13 @@ import io.theurl.identity.domain.repository.TokenRepository; import jakarta.annotation.Resource; import org.springframework.context.annotation.Scope; +import org.springframework.context.annotation.ScopedProxyMode; import org.springframework.stereotype.Component; import java.util.concurrent.CompletableFuture; @Component -@Scope(BeanScope.PROTOTYPE) -//@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) public class TokenCreateCommandHandler implements Handler { @Resource 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 215e65e..21ad01e 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 @@ -1,6 +1,7 @@ package io.theurl.identity.application.handler; import com.neroyun.mediator.Handler; +import io.theurl.framework.core.BeanScope; import io.theurl.identity.application.command.UserAccessFailureCountCommand; import io.theurl.identity.domain.repository.UserRepository; import org.springframework.context.annotation.Scope; @@ -11,7 +12,7 @@ import java.util.concurrent.CompletableFuture; @Component -@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) public class UserAccessFailureCountCommandHandler implements Handler { private final UserRepository repository; 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 307b409..ffb9fb8 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 @@ -1,6 +1,7 @@ package io.theurl.identity.application.handler; import com.neroyun.mediator.Handler; +import io.theurl.framework.core.BeanScope; import io.theurl.identity.application.command.UserCreateCommand; import io.theurl.identity.domain.aggregate.User; import io.theurl.identity.domain.repository.UserRepository; @@ -14,7 +15,7 @@ import java.util.concurrent.CompletableFuture; @Component -@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) public class UserCreateCommandHandler implements Handler { private final UserRepository repository; 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 8b93bc3..8571769 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 @@ -1,6 +1,7 @@ package io.theurl.identity.application.handler; import com.neroyun.mediator.Handler; +import io.theurl.framework.core.BeanScope; import io.theurl.identity.application.command.UserPasswordChangeCommand; import io.theurl.identity.domain.repository.UserRepository; import org.springframework.context.annotation.Scope; @@ -11,7 +12,7 @@ import java.util.concurrent.CompletableFuture; @Component -@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) public class UserPasswordChangeCommandHandler implements Handler { private final UserRepository repository; 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 c7888b3..25a461d 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 @@ -55,109 +55,108 @@ public AuthApplicationServiceImpl(ApplicationContext applicationContext) { @Override public CompletableFuture grant(TokenGrantRequestDto request) { - return CompletableFuture.supplyAsync(() -> { - 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()); + 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"); } - 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(); - 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."); + 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. + 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(); + 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."); + } - if (userInfo.getLockedUntil() != null && userInfo.getLockedUntil().isAfter(LocalDateTime.now())) { - throw new AccountLockedException(request.username(), "Account is locked until " + userInfo.getLockedUntil()); - } + if (userInfo.getLockedUntil() != null && userInfo.getLockedUntil().isAfter(LocalDateTime.now())) { + throw new AccountLockedException(request.username(), "Account is locked until " + userInfo.getLockedUntil()); + } - 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."); - } + 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."); } + } - 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 event = new UserAuthSuccessEvent(request.grantType(), userInfo.getId()); - event.setGrantTime(LocalDateTime.now()); - event.getData().put("jti", jwtId); - event.getData().put("jwt", accessToken); - events.add(event); - - return new TokenGrantResponseDto(accessToken, jwtId, "Bearer", 3600 * 24, iat, userInfo.getUsername(), userInfo.getId()); - - } catch (Exception e) { - var event = new UserAuthFailureEvent(); - - 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; - default: - break; - } - }); - log.error("Error while processing request", e); - throw new AggregateException(List.of(e)); - } finally { - if (!events.isEmpty()) { - events.parallelStream().forEach(mediator::publishAsync); + 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 event = new UserAuthSuccessEvent(request.grantType(), userInfo.getId()); + event.setGrantTime(LocalDateTime.now()); + event.getData().put("jti", jwtId); + event.getData().put("jwt", accessToken); + events.add(event); + + var result = new TokenGrantResponseDto(accessToken, jwtId, "Bearer", 3600 * 24, iat, userInfo.getUsername(), userInfo.getId()); + + return CompletableFuture.completedFuture(result); + } catch (Exception e) { + var event = new UserAuthFailureEvent(); + + 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; + default: + break; } + }); + log.error("Error while processing request", e); + throw new AggregateException(List.of(e)); + } finally { + if (!events.isEmpty()) { + events.parallelStream().forEach(mediator::publishAsync); } - }, mediatorTaskExecutor.getThreadPoolExecutor()); + } } CompletableFuture checkCodeAsync(TokenGrantRequestDto request) { diff --git a/identity/src/main/java/io/theurl/identity/application/subscriber/LoggingEventSubscriber.java b/identity/src/main/java/io/theurl/identity/application/subscriber/LoggingEventSubscriber.java index 8c69a70..4e3bc94 100644 --- a/identity/src/main/java/io/theurl/identity/application/subscriber/LoggingEventSubscriber.java +++ b/identity/src/main/java/io/theurl/identity/application/subscriber/LoggingEventSubscriber.java @@ -1,9 +1,15 @@ package io.theurl.identity.application.subscriber; +import com.neroyun.mediator.Mediator; import io.theurl.framework.core.BeanScope; +import io.theurl.identity.application.command.AuthlogCreateCommand; import io.theurl.identity.application.event.UserAuthFailureEvent; import io.theurl.identity.application.event.UserAuthSuccessEvent; +import jakarta.servlet.http.HttpServletRequest; +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; @@ -11,18 +17,78 @@ import org.springframework.web.context.request.ServletRequestAttributes; @Component -@Scope(BeanScope.PROTOTYPE) +@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) public class LoggingEventSubscriber { - @Async + private final Logger LOGGER = LoggerFactory.getLogger(LoggingEventSubscriber.class); + + private final Mediator mediator; + + public LoggingEventSubscriber(Mediator mediator) { + this.mediator = mediator; + } + + @Async("taskExecutor") @EventListener public void handleUserAuthSuccess(UserAuthSuccessEvent event) { - ((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getRequest().getUserPrincipal(); + 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(true); + command.setTimestamp(event.getGrantTime()); + mediator.sendAsync(command) + .join(); + } catch (Exception exception) { + LOGGER.error("Failed to log authentication success event for user: {}, error: {}", event.getUsername(), exception.getMessage(), exception); + } } @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")); + } + command.setSuccess(false); + command.setRemark(event.getError()); + command.setTimestamp(event.getGrantTime()); + mediator.sendAsync(command) + .join(); + } + + private HttpServletRequest getRequest() { + var request = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + + if (request == null) { + return null; + } + return request.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 de939ed..6329d79 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 @@ -5,12 +5,13 @@ import io.theurl.identity.application.command.TokenCreateCommand; import io.theurl.identity.application.event.UserAuthSuccessEvent; 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(BeanScope.PROTOTYPE) +@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) public class TokenEventSubscriber { private final Mediator mediator; diff --git a/identity/src/main/java/io/theurl/identity/application/subscriber/UserAccessFailureCountEventSubscriber.java b/identity/src/main/java/io/theurl/identity/application/subscriber/UserAccessFailureCountEventSubscriber.java index 6520c7c..9280782 100644 --- a/identity/src/main/java/io/theurl/identity/application/subscriber/UserAccessFailureCountEventSubscriber.java +++ b/identity/src/main/java/io/theurl/identity/application/subscriber/UserAccessFailureCountEventSubscriber.java @@ -1,17 +1,19 @@ package io.theurl.identity.application.subscriber; import com.neroyun.mediator.Mediator; +import io.theurl.framework.core.BeanScope; 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.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("prototype") +@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) @AllArgsConstructor public class UserAccessFailureCountEventSubscriber { diff --git a/identity/src/main/java/io/theurl/identity/configure/JwtAuthenticationFilter.java b/identity/src/main/java/io/theurl/identity/configure/JwtAuthenticationFilter.java index b2e10ea..bdc8a66 100644 --- a/identity/src/main/java/io/theurl/identity/configure/JwtAuthenticationFilter.java +++ b/identity/src/main/java/io/theurl/identity/configure/JwtAuthenticationFilter.java @@ -7,6 +7,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.NonNull; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -20,7 +21,10 @@ import java.util.Collections; /** - * JWT认证过滤器:从Authorization头中解析Bearer Token并写入SecurityContext。 + * The JWT Authentication Filter is responsible for parsing the JWT token from the Authorization header of incoming HTTP requests and setting the authentication information in the SecurityContext. + * It extends OncePerRequestFilter to ensure that it is executed once per request. The filter checks if the Authorization header contains a Bearer token, and if so, it attempts to parse the token using the configured signing key. + * 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 @@ -31,18 +35,20 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) + throws ServletException, IOException { + String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); if (authHeader != null && authHeader.startsWith("Bearer ") && SecurityContextHolder.getContext().getAuthentication() == null) { String token = authHeader.substring(7); try { var claims = Jwts.parser() - .verifyWith(Keys.hmacShaKeyFor(signingKey.getBytes(StandardCharsets.UTF_8))) - .build() - .parseSignedClaims(token) - .getPayload(); + .verifyWith(Keys.hmacShaKeyFor(signingKey.getBytes(StandardCharsets.UTF_8))) + .build() + .parseSignedClaims(token) + .getPayload(); String userId = claims.getSubject(); if (userId != null && !userId.isBlank()) { @@ -51,7 +57,7 @@ protected void doFilterInternal(HttpServletRequest request, SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (Exception e) { - // Token解析失败时不抛出异常,交由后续鉴权流程统一返回401。 + // 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()); } } 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 fc81be4..e9b8e1d 100644 --- a/identity/src/main/java/io/theurl/identity/configure/SecurityConfiguration.java +++ b/identity/src/main/java/io/theurl/identity/configure/SecurityConfiguration.java @@ -10,7 +10,13 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; /** - * Security配置:开启JWT无状态鉴权,并放行登录注册与Swagger相关端点。 + * 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 { diff --git a/identity/src/main/java/io/theurl/identity/domain/aggregate/Authlog.java b/identity/src/main/java/io/theurl/identity/domain/aggregate/Authlog.java new file mode 100644 index 0000000..3a797af --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/domain/aggregate/Authlog.java @@ -0,0 +1,45 @@ +package io.theurl.identity.domain.aggregate; + +import io.theurl.framework.domain.AggregateRoot; +import io.theurl.framework.utility.SnowflakeId; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +@EqualsAndHashCode(callSuper = true) +@Data +public class Authlog extends AggregateRoot { + /** + * Initializes the aggregate with the given id. + * + * @param id the identifier of the aggregate + */ + public Authlog(Long id) { + super(id); + } + + private Long userId; + private String username; + private String grantType; + private String requestId; + private String ipAddress; + private String userAgent; + private String referrer; + private String appName; + private String appVersion; + private String osPlatform; + private String source; + private boolean success; + private String remark; + private LocalDateTime timestamp; + + public static Authlog create(String requestId, String username, boolean success) { + var aggregate = new Authlog(SnowflakeId.getInstance().nextId()); + aggregate.setRequestId(requestId); + aggregate.setUsername(username); + aggregate.setSuccess(success); + aggregate.setTimestamp(LocalDateTime.now()); + return aggregate; + } +} diff --git a/identity/src/main/java/io/theurl/identity/domain/repository/AuthlogRepository.java b/identity/src/main/java/io/theurl/identity/domain/repository/AuthlogRepository.java new file mode 100644 index 0000000..c76152d --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/domain/repository/AuthlogRepository.java @@ -0,0 +1,8 @@ +package io.theurl.identity.domain.repository; + + +import io.theurl.identity.domain.aggregate.Authlog; + +public interface AuthlogRepository { + void save(Authlog authlog); +} diff --git a/identity/src/main/java/io/theurl/identity/persistence/handler/OnetimePasswordDetailQueryHandler.java b/identity/src/main/java/io/theurl/identity/persistence/handler/OnetimePasswordDetailQueryHandler.java index f77bbb7..c427bdc 100644 --- a/identity/src/main/java/io/theurl/identity/persistence/handler/OnetimePasswordDetailQueryHandler.java +++ b/identity/src/main/java/io/theurl/identity/persistence/handler/OnetimePasswordDetailQueryHandler.java @@ -1,6 +1,7 @@ package io.theurl.identity.persistence.handler; import com.neroyun.mediator.Handler; +import io.theurl.framework.core.BeanScope; import io.theurl.identity.persistence.model.OnetimePasswordDetail; import io.theurl.identity.persistence.query.OnetimePasswordDetailQuery; import jakarta.persistence.EntityManager; @@ -14,7 +15,7 @@ import java.util.concurrent.CompletableFuture; @Component -@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) public class OnetimePasswordDetailQueryHandler implements Handler { @PersistenceContext diff --git a/identity/src/main/java/io/theurl/identity/persistence/handler/UserAuthInfoQueryHandler.java b/identity/src/main/java/io/theurl/identity/persistence/handler/UserAuthInfoQueryHandler.java index 6a4ff49..b47dd68 100644 --- a/identity/src/main/java/io/theurl/identity/persistence/handler/UserAuthInfoQueryHandler.java +++ b/identity/src/main/java/io/theurl/identity/persistence/handler/UserAuthInfoQueryHandler.java @@ -1,6 +1,7 @@ package io.theurl.identity.persistence.handler; import com.neroyun.mediator.Handler; +import io.theurl.framework.core.BeanScope; import io.theurl.identity.persistence.entity.User; import io.theurl.identity.persistence.entity.UserRole; import io.theurl.identity.persistence.model.UserAuthInfo; @@ -22,7 +23,7 @@ import static java.util.concurrent.CompletableFuture.supplyAsync; @Component -@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) public class UserAuthInfoQueryHandler implements Handler { @PersistenceContext diff --git a/identity/src/main/java/io/theurl/identity/persistence/handler/UserDetailQueryHandler.java b/identity/src/main/java/io/theurl/identity/persistence/handler/UserDetailQueryHandler.java index 49295dc..1068664 100644 --- a/identity/src/main/java/io/theurl/identity/persistence/handler/UserDetailQueryHandler.java +++ b/identity/src/main/java/io/theurl/identity/persistence/handler/UserDetailQueryHandler.java @@ -7,12 +7,13 @@ import io.theurl.identity.persistence.repository.JpaUserRepository; 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(BeanScope.PROTOTYPE) +@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) public class UserDetailQueryHandler implements Handler { private final JpaUserRepository repository; private final ModelMapper mapper; diff --git a/identity/src/main/java/io/theurl/identity/persistence/repository/AuthlogRepositoryImpl.java b/identity/src/main/java/io/theurl/identity/persistence/repository/AuthlogRepositoryImpl.java new file mode 100644 index 0000000..ac8ec0c --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/repository/AuthlogRepositoryImpl.java @@ -0,0 +1,24 @@ +package io.theurl.identity.persistence.repository; + +import io.theurl.identity.domain.aggregate.Authlog; +import io.theurl.identity.domain.repository.AuthlogRepository; +import org.modelmapper.ModelMapper; +import org.springframework.stereotype.Repository; + +@Repository +public class AuthlogRepositoryImpl implements AuthlogRepository { + + private final JpaAuthlogRepository repository; + private final ModelMapper mapper; + + public AuthlogRepositoryImpl(JpaAuthlogRepository repository, ModelMapper mapper) { + this.repository = repository; + this.mapper = mapper; + } + + @Override + public void save(Authlog authlog) { + var entity = mapper.map(authlog, io.theurl.identity.persistence.entity.Authlog.class); + repository.save(entity); + } +} diff --git a/identity/src/main/java/io/theurl/identity/persistence/repository/JpaAuthlogRepository.java b/identity/src/main/java/io/theurl/identity/persistence/repository/JpaAuthlogRepository.java new file mode 100644 index 0000000..847f438 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/repository/JpaAuthlogRepository.java @@ -0,0 +1,9 @@ +package io.theurl.identity.persistence.repository; + +import io.theurl.identity.persistence.entity.Authlog; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface JpaAuthlogRepository extends CrudRepository { +}