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/application/subscriber/LoggingEventSubscriber.java b/identity/src/main/java/io/theurl/identity/application/subscriber/LoggingEventSubscriber.java
index cc150e6..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,4 +1,94 @@
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;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+@Component
+@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
public class LoggingEventSubscriber {
+
+ 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) {
+ 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
new file mode 100644
index 0000000..bdc8a66
--- /dev/null
+++ b/identity/src/main/java/io/theurl/identity/configure/JwtAuthenticationFilter.java
@@ -0,0 +1,68 @@
+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.jspecify.annotations.NonNull;
+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;
+
+/**
+ * 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
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+
+ @Value("${jwt.secret}")
+ private String signingKey;
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request,
+ @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();
+
+ 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) {
+ // 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());
+ }
+ }
+
+ filterChain.doFilter(request, response);
+ }
+}
+
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/configure/SecurityConfiguration.java b/identity/src/main/java/io/theurl/identity/configure/SecurityConfiguration.java
new file mode 100644
index 0000000..e9b8e1d
--- /dev/null
+++ b/identity/src/main/java/io/theurl/identity/configure/SecurityConfiguration.java
@@ -0,0 +1,49 @@
+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;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+
+/**
+ * The security configuration for the application, defining the security filter chain and authentication rules.
+ *
+ * This configuration disables CSRF, form login, and HTTP basic authentication, and sets the session management to stateless.
+ * It also configures exception handling to return a 401 Unauthorized response for unauthenticated requests.
+ * The authorization rules allow unauthenticated access to specific endpoints (e.g., authentication and registration endpoints, API documentation) while requiring authentication for all other requests.
+ * The JwtAuthenticationFilter is added to the filter chain before the UsernamePasswordAuthenticationFilter to handle JWT token parsing and authentication.
+ *
+ */
+@Configuration
+public class SecurityConfiguration {
+
+ @Bean
+ public SecurityFilterChain securityFilterChain(HttpSecurity http,
+ JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {
+ http.csrf(AbstractHttpConfigurer::disable)
+ .formLogin(AbstractHttpConfigurer::disable)
+ .httpBasic(AbstractHttpConfigurer::disable)
+ .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .exceptionHandling(ex -> ex.authenticationEntryPoint((request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized")))
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers(
+ "/auth/**",
+ "/account/register",
+ "/account/password/reset",
+ "/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/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/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();
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
new file mode 100644
index 0000000..1068664
--- /dev/null
+++ b/identity/src/main/java/io/theurl/identity/persistence/handler/UserDetailQueryHandler.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.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.context.annotation.ScopedProxyMode;
+import org.springframework.stereotype.Component;
+
+import java.util.concurrent.CompletableFuture;
+
+@Component
+@Scope(value = BeanScope.REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
+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/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 {
+}
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..a6c91b9
--- /dev/null
+++ b/identity/src/test/java/io/theurl/identity/interfaces/controller/AccountControllerSecurityTests.java
@@ -0,0 +1,83 @@
+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 org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+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;
+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;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@TestPropertySource(properties = "jwt.secret=01234567890123456789012345678901")
+class AccountControllerSecurityTests {
+
+ private static final String TEST_SECRET = "01234567890123456789012345678901";
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @MockitoBean
+ 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();
+ }
+}
+