使用 Spring Security 和 JWT 保护 REST API
1. 概述
REST API 通常不使用 session。每个请求都必须自己携带认证信息,而 JWT(JSON Web Token)是最常见的方式。
本文将指导您保护一个订单管理 API,使用 Spring Security 6 和 Spring Boot 3。我们将在用户登录时发放 JWT,在每个请求中验证令牌,根据角色分配权限。最后是对主要行为的自动化测试。
我们不会手动编写 JWT 过滤器。相反,本文使用 Spring Security 中内置的 OAuth2 Resource Server。这种方式代码更少,更安全。
2. Maven 依赖
您需要在 pom.xml 中添加以下依赖:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Starter oauth2-resource-server 包含 Nimbus JOSE + JWT 库。该库负责令牌的签名和验证。您不需要添加 jjwt 或任何其他 JWT 库。
3. 配置密钥
为了简单起见,我们使用对称算法 HS256 对令牌进行签名。同一个密钥用于签名和验证签名。
jwt.secret=${JWT_SECRET:将此字符串替换为至少32字节的密钥}
jwt.expiration-minutes=15
表达式 ${JWT_SECRET:...} 从环境变量 JWT_SECRET 读取密钥。如果该变量不存在,Spring 将使用冒号后面的默认值。默认值仅用于开发环境。
注意: HS256 要求密钥长度至少为 256 比特,即 32 字节。如果密钥较短,Nimbus 将在第一次签名令牌时抛出
KeyLengthException。
4. 配置 Spring Security
4.1. JwtEncoder、JwtDecoder 和用户
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Value("${jwt.secret}")
private String secret;
@Bean
SecretKey jwtSecretKey() {
return new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
}
@Bean
JwtEncoder jwtEncoder(SecretKey key) {
return new NimbusJwtEncoder(new ImmutableSecret<>(key));
}
@Bean
JwtDecoder jwtDecoder(SecretKey key) {
return NimbusJwtDecoder.withSecretKey(key)
.macAlgorithm(MacAlgorithm.HS256)
.build();
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
UserDetailsService userDetailsService(PasswordEncoder encoder) {
var customer = User.withUsername("alice")
.password(encoder.encode("alice123")).roles("CUSTOMER").build();
var admin = User.withUsername("bob")
.password(encoder.encode("bob123")).roles("CUSTOMER", "ADMIN").build();
return new InMemoryUserDetailsManager(customer, admin);
}
@Bean
AuthenticationManager authenticationManager(UserDetailsService uds, PasswordEncoder encoder) {
var provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(uds);
provider.setPasswordEncoder(encoder);
return new ProviderManager(provider);
}
}
每个 bean 的作用:
SecretKey将密钥字符串包装为 HMAC-SHA256 密钥。JwtEncoder使用该密钥在用户登录时对令牌进行签名。JwtDecoder验证令牌的签名和过期时间(exp)。Resource Server 会自动使用此 bean。InMemoryUserDetailsManager创建两个示例用户。在生产环境中,您应该将其替换为从数据库读取用户的实现。AuthenticationManager在登录端点处验证用户名和密码。
4.2. SecurityFilterChain
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
return http.build();
}
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
var authorities = new JwtGrantedAuthoritiesConverter();
authorities.setAuthoritiesClaimName("roles");
authorities.setAuthorityPrefix("");
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authorities);
return converter;
}
该 bean 位于相同的 SecurityConfig 类中。每个部分执行以下操作:
csrf.disable():由于 API 不使用 cookie 进行身份验证,不会受到 CSRF 攻击。STATELESS:Spring 不会创建HttpSession。每个请求都通过令牌重新进行身份验证。authorizeHttpRequests:登录端点公开。/api/admin/**仅供ADMIN角色使用。其他所有端点都需要身份验证。oauth2ResourceServer(...jwt...):启用过滤器来读取Authorization: Bearer <token>头并使用JwtDecoder解码令牌。
默认情况下,Spring 从 scope 声明读取权限并添加 SCOPE_ 前缀。我们想使用 hasRole("ADMIN"),所以 JwtAuthenticationConverter 被重新配置为读取 roles 声明。前缀被移除,因为令牌中的值已经是 ROLE_ADMIN 的形式。
5. 登录时发放令牌
5.1. TokenService
@Service
public class TokenService {
private final JwtEncoder encoder;
private final long expirationMinutes;
public TokenService(JwtEncoder encoder,
@Value("${jwt.expiration-minutes}") long expirationMinutes) {
this.encoder = encoder;
this.expirationMinutes = expirationMinutes;
}
public String generateToken(Authentication authentication) {
Instant now = Instant.now();
List<String> roles = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("nonla-shop")
.issuedAt(now)
.expiresAt(now.plus(expirationMinutes, ChronoUnit.MINUTES))
.subject(authentication.getName())
.claim("roles", roles)
.build();
JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
return encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
}
public long getExpirationSeconds() {
return expirationMinutes * 60;
}
}
令牌包含以下声明:发行者(iss)、发行时间(iat)、过期时间(exp)、用户名(sub)和角色列表。
行 JwsHeader.with(MacAlgorithm.HS256) 是强制性的。如果缺少此项,NimbusJwtEncoder 默认会选择 RS256。然后它将找不到合适的密钥并抛出错误 Failed to select a JWK signing key。
5.2. AuthController
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final TokenService tokenService;
public AuthController(AuthenticationManager authenticationManager, TokenService tokenService) {
this.authenticationManager = authenticationManager;
this.tokenService = tokenService;
}
public record LoginRequest(String username, String password) {}
public record TokenResponse(String accessToken, String tokenType, long expiresIn) {}
@PostMapping("/login")
public TokenResponse login(@RequestBody LoginRequest request) {
Authentication auth = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(
request.username(), request.password()));
String token = tokenService.generateToken(auth);
return new TokenResponse(token, "Bearer", tokenService.getExpirationSeconds());
}
}
authenticate() 通过 DaoAuthenticationProvider 验证用户名和密码。如果错误,该方法将抛出 BadCredentialsException。Spring Security 将此错误转换为 HTTP 401。如果正确,控制器会创建令牌并返回给客户端。
用 curl 尝试调用:
curl -s -X POST localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"alice123"}'
{"accessToken":"eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJub25sYS1zaG9wIiwic3ViIjoiYWxpY2UiLC...","tokenType":"Bearer","expiresIn":900}
6. 受保护的端点
@RestController
@RequestMapping("/api")
public class OrderController {
public record Order(long id, String customer, BigDecimal total) {}
private final List<Order> orders = List.of(
new Order(1, "alice", new BigDecimal("250000")),
new Order(2, "bob", new BigDecimal("990000")));
@GetMapping("/orders")
public List<Order> myOrders(@AuthenticationPrincipal Jwt jwt) {
return orders.stream()
.filter(o -> o.customer().equals(jwt.getSubject()))
.toList();
}
@GetMapping("/admin/orders")
public List<Order> allOrders() {
return orders;
}
}
@AuthenticationPrincipal Jwt jwt 将已验证的令牌注入到控制器中。您可以通过 jwt.getSubject() 获取用户名。要读取其他声明,使用 jwt.getClaim("roles")。
尝试用三种不同的情况调用:
# 没有令牌
curl -i localhost:8080/api/orders
# alice 有有效令牌
curl -s localhost:8080/api/orders -H "Authorization: Bearer $TOKEN"
# alice 调用管理员端点
curl -i localhost:8080/api/admin/orders -H "Authorization: Bearer $TOKEN"
HTTP/1.1 401
WWW-Authenticate: Bearer
[{"id":1,"customer":"alice","total":250000}]
HTTP/1.1 403
WWW-Authenticate: Bearer error="insufficient_scope", ...
结果显示两个不同的错误代码。401 表示未认证:缺少令牌、令牌签名错误或令牌已过期。403 表示已认证但权限不足。
7. 编写测试
spring-security-test 提供 post-processor jwt()。它模拟一个已认证的 JWT,而无需对真实令牌进行签名。我们将其与包含真实登录的端到端测试相结合。
@SpringBootTest
@AutoConfigureMockMvc
class OrderSecurityTest {
@Autowired
MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
@Test
void whenNoToken_thenUnauthorized() throws Exception {
MvcResult result = mockMvc.perform(get("/api/orders")).andReturn();
assertThat(result.getResponse().getStatus()).isEqualTo(401);
}
@Test
void whenLoginAndCallWithToken_thenReturnOwnOrders() throws Exception {
String body = mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"username":"alice","password":"alice123"}
"""))
.andReturn().getResponse().getContentAsString();
String token = objectMapper.readTree(body).get("accessToken").asText();
MvcResult result = mockMvc.perform(get("/api/orders")
.header("Authorization", "Bearer " + token))
.andReturn();
assertThat(result.getResponse().getStatus()).isEqualTo(200);
assertThat(result.getResponse().getContentAsString())
.contains("\"customer\":\"alice\"")
.doesNotContain("\"customer\":\"bob\"");
}
@Test
void whenWrongPassword_thenUnauthorized() throws Exception {
MvcResult result = mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"alice\",\"password\":\"sai\"}"))
.andReturn();
assertThat(result.getResponse().getStatus()).isEqualTo(401);
}
@Test
void whenCustomerCallsAdminEndpoint_thenForbidden() throws Exception {
MvcResult result = mockMvc.perform(get("/api/admin/orders")
.with(jwt().jwt(j -> j.subject("alice"))
.authorities(new SimpleGrantedAuthority("ROLE_CUSTOMER"))))
.andReturn();
assertThat(result.getResponse().getStatus()).isEqualTo(403);
}
}
所需的静态导入是 MockMvcRequestBuilders.*、SecurityMockMvcRequestPostProcessors.jwt 和 AssertJ 的 Assertions.assertThat。
第二个测试检查整个流程。它调用登录端点,获取令牌,然后使用令牌调用 API。此测试可以检测编码器和解码器的配置错误,例如忘记 JwtHeader 或密钥过短。
最后一个测试使用 jwt() 单独检查权限部分。请注意,.authorities(...) 会覆盖转换器,所以您必须传入正确的权限名称 ROLE_CUSTOMER。
8. 常见错误和何时不应使用
常见错误:
- 密钥过短: HS256 中少于 32 字节的密钥会导致
KeyLengthException。 - 忘记 HS256 头:
JwtEncoder报告Failed to select a JWK signing key。 - 权限前缀错误: 使用默认转换器时,权限采用
SCOPE_...的形式。然后hasRole("ADMIN")总是返回 403。检查声明和前缀是否匹配。 - 在代码库中硬编码密钥: 始终从环境变量或密钥管理器读取密钥。
- 将敏感数据放入有效负载: JWT 有效负载仅经过 Base64URL 编码,不加密。任何拥有令牌的人都可以读取内容。不要将密码、卡号或个人信息放入令牌中。
注意: 如果不添加诸如黑名单之类的附加机制,JWT 在过期前无法撤销。保持访问令牌有效期较短(5-15 分钟)。如果需要长会话,请添加存储在服务器上的刷新令牌以便可以撤销。
何时不应使用此方法:
- 服务器端渲染的 Web 应用,例如 Thymeleaf:session 加 cookie 更简单、更安全。
- 需要立即生效的注销或账户锁定: session 或带有 introspection 的 opaque 令牌更合适。
- 多服务系统: 与所有服务共享单个 HMAC 密钥风险很大。在这种情况下,您应该使用专用的授权服务器,例如 Spring Authorization Server 或 Keycloak。授权服务器使用 RSA 或 EC 对令牌进行签名,而服务只需配置
spring.security.oauth2.resourceserver.jwt.issuer-uri来通过 JWKS 获取公钥。
9. 结论
本文已建立了一个由 JWT 保护的无状态 REST API,包括以下步骤:
- 使用
NimbusJwtEncoder和NimbusJwtDecoder与 HS256 密钥。 - 通过
AuthenticationManager在/api/auth/login端点发放令牌。 - 让 OAuth2 Resource Server 自动验证
Bearer头。 - 将
roles声明映射到权限以使用hasRole。 - 使用 MockMvc 和 AssertJ 检查 401、403 和成功流程的情况。
从这个基础出发,您可以将 InMemoryUserDetailsManager 替换为数据库中存储的用户、添加刷新令牌,或在系统扩展到多个服务时切换到非对称密钥。