diff --git a/src/main/java/com/yau/digitalrmb/security/application/BootstrapAdminAuthenticator.java b/src/main/java/com/yau/digitalrmb/security/application/BootstrapAdminAuthenticator.java new file mode 100644 index 0000000..c3f2fa1 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/security/application/BootstrapAdminAuthenticator.java @@ -0,0 +1,22 @@ +package com.yau.digitalrmb.security.application; + +import com.yau.digitalrmb.security.config.SecurityProperties; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +public class BootstrapAdminAuthenticator { + private final SecurityProperties properties; + private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + private final String passwordHash; + + public BootstrapAdminAuthenticator(SecurityProperties properties) { + this.properties = properties; + this.passwordHash = passwordEncoder.encode(properties.getBootstrapAdmin().getPassword()); + } + + public boolean matches(String username, String password) { + return properties.getBootstrapAdmin().getUsername().equals(username) + && passwordEncoder.matches(password, passwordHash); + } +} diff --git a/src/main/java/com/yau/digitalrmb/security/application/JwtTokenService.java b/src/main/java/com/yau/digitalrmb/security/application/JwtTokenService.java new file mode 100644 index 0000000..cef391f --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/security/application/JwtTokenService.java @@ -0,0 +1,35 @@ +package com.yau.digitalrmb.security.application; + +import com.yau.digitalrmb.security.config.SecurityProperties; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jwt.JwsHeader; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; + +@Service +public class JwtTokenService { + private final JwtEncoder jwtEncoder; + private final SecurityProperties properties; + + public JwtTokenService(JwtEncoder jwtEncoder, SecurityProperties properties) { + this.jwtEncoder = jwtEncoder; + this.properties = properties; + } + + public Token issueFor(String username) { + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plus(properties.getJwt().getAccessTokenTtl()); + JwtClaimsSet claims = JwtClaimsSet.builder().subject(username).issuedAt(issuedAt).expiresAt(expiresAt) + .claim("roles", List.of("ROLE_ADMIN")).build(); + Jwt jwt = jwtEncoder.encode(JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(), claims)); + return new Token(jwt.getTokenValue(), properties.getJwt().getAccessTokenTtl().toSeconds()); + } + + public record Token(String accessToken, long expiresIn) { } +} diff --git a/src/main/java/com/yau/digitalrmb/security/config/SecurityConfig.java b/src/main/java/com/yau/digitalrmb/security/config/SecurityConfig.java new file mode 100644 index 0000000..9108f9a --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/security/config/SecurityConfig.java @@ -0,0 +1,69 @@ +package com.yau.digitalrmb.security.config; + +import com.nimbusds.jose.jwk.source.ImmutableSecret; +import com.nimbusds.jose.proc.SecurityContext; +import com.yau.digitalrmb.shared.api.ApiResponse; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.web.TraceIdFilter; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; +import org.springframework.security.web.SecurityFilterChain; + +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +@Configuration +@EnableWebSecurity +@EnableConfigurationProperties(SecurityProperties.class) +public class SecurityConfig { + @Bean + public JwtEncoder jwtEncoder(SecurityProperties properties) { + return new NimbusJwtEncoder(new ImmutableSecret(secretKey(properties))); + } + + @Bean + public JwtDecoder jwtDecoder(SecurityProperties properties) { + return NimbusJwtDecoder.withSecretKey(secretKey(properties)).macAlgorithm(MacAlgorithm.HS256).build(); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http.csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/actuator/health", "/api/v1/auth/login", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html") + .permitAll() + .anyRequest().authenticated()) + .oauth2ResourceServer(resourceServer -> resourceServer.jwt(jwt -> { })) + .exceptionHandling(exceptions -> exceptions + .authenticationEntryPoint((request, response, exception) -> writeError(response, ErrorCode.UNAUTHORIZED, 401)) + .accessDeniedHandler((request, response, exception) -> writeError(response, ErrorCode.FORBIDDEN, 403))) + .build(); + } + + private static SecretKeySpec secretKey(SecurityProperties properties) { + return new SecretKeySpec(properties.getJwt().getSecret().getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + } + + private static void writeError(jakarta.servlet.http.HttpServletResponse response, ErrorCode code, int status) + throws java.io.IOException { + response.setStatus(status); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + String traceId = response.getHeader(TraceIdFilter.HEADER_NAME); + if (traceId == null) traceId = UUID.randomUUID().toString(); + response.getWriter().write("{\"code\":\"" + code.name() + "\",\"message\":\"" + code.name() + + "\",\"data\":null,\"traceId\":\"" + traceId + "\"}"); + } +} diff --git a/src/main/java/com/yau/digitalrmb/security/config/SecurityProperties.java b/src/main/java/com/yau/digitalrmb/security/config/SecurityProperties.java new file mode 100644 index 0000000..cb5d626 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/security/config/SecurityProperties.java @@ -0,0 +1,46 @@ +package com.yau.digitalrmb.security.config; + +import jakarta.annotation.PostConstruct; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +import java.time.Duration; + +@Validated +@ConfigurationProperties(prefix = "security") +public class SecurityProperties { + private final Jwt jwt = new Jwt(); + private final BootstrapAdmin bootstrapAdmin = new BootstrapAdmin(); + + public Jwt getJwt() { return jwt; } + public BootstrapAdmin getBootstrapAdmin() { return bootstrapAdmin; } + + @PostConstruct + void validate() { + if (jwt.secret == null || jwt.secret.length() < 64) { + throw new IllegalStateException("security.jwt.secret must contain at least 64 characters"); + } + if (bootstrapAdmin.username == null || bootstrapAdmin.username.isBlank() + || bootstrapAdmin.password == null || bootstrapAdmin.password.isBlank()) { + throw new IllegalStateException("bootstrap administrator credentials must not be blank"); + } + } + + public static class Jwt { + private String secret; + private Duration accessTokenTtl = Duration.ofMinutes(30); + public String getSecret() { return secret; } + public void setSecret(String secret) { this.secret = secret; } + public Duration getAccessTokenTtl() { return accessTokenTtl; } + public void setAccessTokenTtl(Duration accessTokenTtl) { this.accessTokenTtl = accessTokenTtl; } + } + + public static class BootstrapAdmin { + private String username; + private String password; + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + } +} diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java b/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java new file mode 100644 index 0000000..412aa6c --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java @@ -0,0 +1,36 @@ +package com.yau.digitalrmb.security.interfaces; + +import com.yau.digitalrmb.security.application.BootstrapAdminAuthenticator; +import com.yau.digitalrmb.security.application.JwtTokenService; +import com.yau.digitalrmb.shared.api.ApiResponse; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; +import com.yau.digitalrmb.shared.web.TraceIdFilter; +import jakarta.validation.Valid; +import org.slf4j.MDC; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/auth") +public class AuthController { + private final BootstrapAdminAuthenticator authenticator; + private final JwtTokenService tokenService; + + public AuthController(BootstrapAdminAuthenticator authenticator, JwtTokenService tokenService) { + this.authenticator = authenticator; + this.tokenService = tokenService; + } + + @PostMapping("/login") + public ApiResponse login(@Valid @RequestBody LoginRequest request) { + if (!authenticator.matches(request.username(), request.password())) { + throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户名或密码错误"); + } + JwtTokenService.Token token = tokenService.issueFor(request.username()); + return ApiResponse.success(new LoginResponse(token.accessToken(), "Bearer", token.expiresIn()), + MDC.get(TraceIdFilter.MDC_KEY)); + } +} diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/LoginRequest.java b/src/main/java/com/yau/digitalrmb/security/interfaces/LoginRequest.java new file mode 100644 index 0000000..a72ae92 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/security/interfaces/LoginRequest.java @@ -0,0 +1,5 @@ +package com.yau.digitalrmb.security.interfaces; + +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest(@NotBlank String username, @NotBlank String password) { } diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/LoginResponse.java b/src/main/java/com/yau/digitalrmb/security/interfaces/LoginResponse.java new file mode 100644 index 0000000..a5aef5f --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/security/interfaces/LoginResponse.java @@ -0,0 +1,3 @@ +package com.yau.digitalrmb.security.interfaces; + +public record LoginResponse(String accessToken, String tokenType, long expiresIn) { } diff --git a/src/main/java/com/yau/digitalrmb/shared/web/GlobalExceptionHandler.java b/src/main/java/com/yau/digitalrmb/shared/web/GlobalExceptionHandler.java index 5f49348..e4dae3c 100644 --- a/src/main/java/com/yau/digitalrmb/shared/web/GlobalExceptionHandler.java +++ b/src/main/java/com/yau/digitalrmb/shared/web/GlobalExceptionHandler.java @@ -5,6 +5,8 @@ import com.yau.digitalrmb.shared.api.ErrorCode; import com.yau.digitalrmb.shared.exception.BusinessException; import jakarta.validation.ConstraintViolationException; import org.slf4j.MDC; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -15,6 +17,7 @@ import java.util.UUID; @RestControllerAdvice public class GlobalExceptionHandler { + private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class); @ExceptionHandler({ConstraintViolationException.class, MethodArgumentNotValidException.class}) @ResponseStatus(HttpStatus.BAD_REQUEST) public ApiResponse handleValidation(Exception exception) { @@ -29,6 +32,7 @@ public class GlobalExceptionHandler { @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ApiResponse handleUnexpected(Exception exception) { + LOGGER.error("Unhandled request failure", exception); return ApiResponse.failure(ErrorCode.INTERNAL_ERROR, "系统内部错误", traceId()); } diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 40dd36c..dcabcd0 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -7,3 +7,10 @@ spring: flyway: enabled: true locations: classpath:db/migration +security: + jwt: + secret: 0123456789012345678901234567890123456789012345678901234567890123 + access-token-ttl: PT30M + bootstrap-admin: + username: admin + password: ChangeMe123! diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index cdcf2c1..7de7061 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -9,3 +9,10 @@ spring: flyway: enabled: true locations: classpath:db/migration +security: + jwt: + secret: ${DIGITAL_RMB_JWT_SECRET} + access-token-ttl: PT30M + bootstrap-admin: + username: ${DIGITAL_RMB_BOOTSTRAP_ADMIN_USERNAME} + password: ${DIGITAL_RMB_BOOTSTRAP_ADMIN_PASSWORD} diff --git a/src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java b/src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java new file mode 100644 index 0000000..cf4dbff --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java @@ -0,0 +1,50 @@ +package com.yau.digitalrmb.security; + +import com.jayway.jsonpath.JsonPath; +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.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +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.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(properties = { + "security.jwt.secret=0123456789012345678901234567890123456789012345678901234567890123", + "security.bootstrap-admin.username=admin", + "security.bootstrap-admin.password=ChangeMe123!" +}) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class AuthControllerTest { + @Autowired + private MockMvc mvc; + + @Test + void loginIssuesTokenAndTokenProtectsEndpoint() throws Exception { + String body = "{\"username\":\"admin\",\"password\":\"ChangeMe123!\"}"; + String response = mvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").isNotEmpty()) + .andReturn().getResponse().getContentAsString(); + String token = JsonPath.read(response, "$.data.accessToken"); + + mvc.perform(get("/api/v1/diagnostics/validation") + .param("value", "ok") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()); + } + + @Test + void protectedEndpointRejectsMissingToken() throws Exception { + mvc.perform(get("/api/v1/diagnostics/validation").param("value", "ok")) + .andExpect(status().isUnauthorized()); + } +}