feat: add JWT authentication scaffold

master
chenyuan 4 weeks ago
parent e6381ebd22
commit bfde528aa7

@ -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);
}
}

@ -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) { }
}

@ -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<SecurityContext>(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 + "\"}");
}
}

@ -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; }
}
}

@ -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<LoginResponse> 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));
}
}

@ -0,0 +1,5 @@
package com.yau.digitalrmb.security.interfaces;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(@NotBlank String username, @NotBlank String password) { }

@ -0,0 +1,3 @@
package com.yau.digitalrmb.security.interfaces;
public record LoginResponse(String accessToken, String tokenType, long expiresIn) { }

@ -5,6 +5,8 @@ import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException; import com.yau.digitalrmb.shared.exception.BusinessException;
import jakarta.validation.ConstraintViolationException; import jakarta.validation.ConstraintViolationException;
import org.slf4j.MDC; import org.slf4j.MDC;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
@ -15,6 +17,7 @@ import java.util.UUID;
@RestControllerAdvice @RestControllerAdvice
public class GlobalExceptionHandler { public class GlobalExceptionHandler {
private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler({ConstraintViolationException.class, MethodArgumentNotValidException.class}) @ExceptionHandler({ConstraintViolationException.class, MethodArgumentNotValidException.class})
@ResponseStatus(HttpStatus.BAD_REQUEST) @ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleValidation(Exception exception) { public ApiResponse<Void> handleValidation(Exception exception) {
@ -29,6 +32,7 @@ public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> handleUnexpected(Exception exception) { public ApiResponse<Void> handleUnexpected(Exception exception) {
LOGGER.error("Unhandled request failure", exception);
return ApiResponse.failure(ErrorCode.INTERNAL_ERROR, "系统内部错误", traceId()); return ApiResponse.failure(ErrorCode.INTERNAL_ERROR, "系统内部错误", traceId());
} }

@ -7,3 +7,10 @@ spring:
flyway: flyway:
enabled: true enabled: true
locations: classpath:db/migration locations: classpath:db/migration
security:
jwt:
secret: 0123456789012345678901234567890123456789012345678901234567890123
access-token-ttl: PT30M
bootstrap-admin:
username: admin
password: ChangeMe123!

@ -9,3 +9,10 @@ spring:
flyway: flyway:
enabled: true enabled: true
locations: classpath:db/migration 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}

@ -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());
}
}
Loading…
Cancel
Save