feat: add JWT authentication scaffold
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) { }
|
||||
@ -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…
Reference in New Issue