使用 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 替换为数据库中存储的用户、添加刷新令牌,或在系统扩展到多个服务时切换到非对称密钥。

spring-bootspring-securityjwtrest-apijava