Spring Security 深度学习(六): RESTful API 安全与 JWT

目录

    • [1. 引言:无状态认证的崛起](#1. 引言:无状态认证的崛起)
    • [2. JWT (JSON Web Token) 核心概念](#2. JWT (JSON Web Token) 核心概念)
      • [2.1 什么是JWT?](#2.1 什么是JWT?)
      • [2.2 JWT的组成:Header, Payload, Signature](#2.2 JWT的组成:Header, Payload, Signature)
      • [2.3 JWT的工作原理](#2.3 JWT的工作原理)
      • [2.4 JWT的优缺点与适用场景](#2.4 JWT的优缺点与适用场景)
    • [3. Spring Security中的JWT集成策略](#3. Spring Security中的JWT集成策略)
      • [3.1 禁用Session管理与CSRF防护](#3.1 禁用Session管理与CSRF防护)
      • [3.2 JWT认证流程概述](#3.2 JWT认证流程概述)
    • [4. 实战演练:构建JWT认证系统](#4. 实战演练:构建JWT认证系统)
      • [4.1 引入JWT库依赖](#4.1 引入JWT库依赖)
      • [4.2 JWT工具类:生成与解析Token](#4.2 JWT工具类:生成与解析Token)
      • [4.3 自定义 JwtAuthenticationToken](#4.3 自定义 JwtAuthenticationToken)
      • [4.4 自定义 JwtAuthenticationConverter (或 AuthenticationProvider)](#4.4 自定义 JwtAuthenticationConverter (或 AuthenticationProvider))
      • [4.5 自定义 JwtAuthenticationFilter](#4.5 自定义 JwtAuthenticationFilter)
      • [4.6 更新 SecurityFilterChain 配置,集成JWT过滤器](#4.6 更新 SecurityFilterChain 配置,集成JWT过滤器)
      • [4.7 改造登录接口,返回JWT](#4.7 改造登录接口,返回JWT)
      • [4.8 认证失败与权限不足的自定义处理](#4.8 认证失败与权限不足的自定义处理)
      • [4.9 测试JWT认证流程](#4.9 测试JWT认证流程)
    • [5. JWT的安全性与挑战](#5. JWT的安全性与挑战)
      • [5.1 Token过期与刷新机制](#5.1 Token过期与刷新机制)
      • [5.2 JWT注销/黑名单机制](#5.2 JWT注销/黑名单机制)
      • [5.3 密钥管理](#5.3 密钥管理)
      • [5.4 防止令牌盗用](#5.4 防止令牌盗用)
    • [6. 常见陷阱与注意事项](#6. 常见陷阱与注意事项)
    • [7. 阶段总结](#7. 阶段总结)

1. 引言:无状态认证的崛起

传统的Web应用通常依赖于服务器端的HTTP Session来维护用户状态。每次用户登录后,服务器会创建一个Session并将其Session ID通过Cookie发送给客户端。客户端在后续请求中携带这个Cookie,服务器通过Session ID查找对应的Session,从而识别用户身份。

然而,这种基于Session的方式在以下场景中面临挑战:

  • 前后端分离: 前端(React, Vue, Angular)和后端(Spring Boot API)是独立的,它们之间可能存在跨域请求。Cookie通常受同源策略限制,且在前端应用中直接操作Cookie不方便。
  • 微服务架构: 用户请求可能需要经过多个微服务,Session的共享和管理(例如使用Sticky Session或Redis共享Session)变得复杂且增加了系统耦合度。
  • 移动应用/第三方应用: 移动客户端不能很好地支持Cookie,更倾向于通过Authorization Header传递凭证。
  • 水平扩展: 当服务器集群需要水平扩展时,Session共享成为瓶颈。

无状态认证 应运而生。它意味着服务器不再存储用户会话信息,每次请求都携带完整的认证凭证。JWT (JSON Web Token) 是实现无状态认证的主流方案之一。

2. JWT (JSON Web Token) 核心概念

2.1 什么是JWT?

JWT是一种开放标准(RFC 7519),它定义了一种紧凑且自包含的方式,用于在各方之间安全地传输信息。这些信息以JSON对象的形式传输,可以被数字签名,从而可以验证其真实性和完整性。

  • 紧凑: JWT的体积很小,可以通过URL、POST参数或HTTP头轻松传输。
  • 自包含: JWT包含了所有必要的用户信息(通常是用户ID、角色、权限等),服务器无需查询数据库即可获取这些信息。
  • 安全: JWT可以通过数字签名进行验证,确保其未被篡改。

2.2 JWT的组成:Header, Payload, Signature

一个JWT通常由三部分组成,用.分隔:Header.Payload.Signature

A. Header (头部)

通常包含两个信息:

  • alg (algorithm):签名算法,如HMAC SHA256 (HS256) 或 RSA (RS256)。
  • typ (type):Token类型,通常是JWT
json 复制代码
{
  "alg": "HS256",
  "typ": "JWT"
}

Header会被Base64Url编码。

B. Payload (载荷)

包含声明 (claims),是关于实体(通常是用户)和附加数据的断言。声明分为三类:

  • Registered claims (注册声明): 预定义的一些声明,非强制,但推荐使用,例如:
    • iss (issuer):颁发者
    • exp (expiration time):过期时间
    • sub (subject):主题(通常是用户ID)
    • aud (audience):受众
    • iat (issued at):签发时间
  • Public claims (公共声明): 可以在JWT中自由定义的声明,但为了避免冲突,应该在IANA JWT Registry中注册,或者将其定义为URI。
  • Private claims (私有声明): 约定俗成的声明,用于在特定方之间共享信息,既不是注册声明也不是公共声明。例如,可以包含用户角色、权限列表等业务信息。
json 复制代码
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022,
  "exp": 1516242622, // 签发时间 + 有效期
  "roles": ["USER", "ADMIN"] // 私有声明
}

Payload也会被Base64Url编码。

C. Signature (签名)

用于验证Token的发送者,并确保Token在传输过程中没有被篡改。

签名是使用Header中指定的算法(例如HS256),将Base64Url编码后的Header、Base64Url编码后的Payload和密钥(secret)进行加密计算得到。
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

2.3 JWT的工作原理

  1. 用户登录: 用户使用用户名和密码向认证服务器(应用后端)发送登录请求。
  2. 生成JWT: 认证服务器验证用户凭证。如果验证成功,根据用户ID、角色、权限等信息生成一个JWT,并用一个密钥进行签名。
  3. 返回JWT: 服务器将生成的JWT返回给客户端(通常在HTTP响应体中)。
  4. 客户端存储JWT: 客户端接收到JWT后,通常将其存储在本地存储(如LocalStorage或SessionStorage)中。
  5. 访问受保护资源: 客户端在后续每次访问受保护的API时,都会在HTTP请求头的Authorization字段中携带JWT,格式为Authorization: Bearer <JWT>
  6. 验证JWT: 资源服务器(应用后端)接收到请求后,从Authorization头中提取JWT。然后,它使用之前用于签名的密钥验证JWT的签名、检查Token是否过期,以及解析其中的声明(如用户ID、权限)。
  7. 授权与响应: 如果JWT有效且用户具有所需权限,服务器处理请求并返回数据。如果JWT无效或过期,或者用户权限不足,则返回错误(如401 Unauthorized或403 Forbidden)。

2.4 JWT的优缺点与适用场景

优点:

  • 无状态: 服务器无需存储Session,易于水平扩展,适用于微服务。
  • 紧凑自包含: 包含了所有必要的用户信息,减少了数据库查询。
  • 跨域友好: 不依赖Cookie,易于跨域请求。
  • 移动兼容性: 广泛应用于移动应用。

缺点:

  • Token无法实时注销: JWT一旦签发,在其有效期内都是有效的,服务器端无法强制使其失效(除非引入黑名单机制)。
  • Token过大: 如果Payload中包含太多信息,Token会变大,增加请求头大小。
  • 安全性考量:
    • 密钥安全: 签名密钥一旦泄露,攻击者可以伪造Token。
    • 传输安全: JWT应始终通过HTTPS传输,防止Token被截获。
    • XSS风险: 如果存储在LocalStorage,容易受到XSS攻击。
    • 无CSRF防护: 因为不依赖Session Cookie,JWT本身不提供CSRF防护,因此无需特别开启CSRF。

适用场景:

  • 前后端分离的Web应用。
  • 微服务架构中的API认证。
  • 移动应用和桌面应用。
  • 第三方OAuth2/OpenID Connect认证。

3. Spring Security中的JWT集成策略

在Spring Security中集成JWT,通常需要进行以下调整:

3.1 禁用Session管理与CSRF防护

由于JWT是无状态的,我们不再需要Spring Security的Session管理和CSRF防护功能。

java 复制代码
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 设置为无状态
            )
            .csrf(csrf -> csrf.disable()) // 禁用CSRF防护

3.2 JWT认证流程概述

  1. JWT生成: 在用户登录成功后,后端生成JWT并返回。
  2. JWT传输: 客户端将JWT存储起来,并在每次请求时通过Authorization: Bearer <JWT>请求头发送。
  3. JWT解析与验证: Spring Security过滤器链中会插入一个自定义的JWT过滤器:
    • 它拦截所有请求,从Authorization头中提取JWT。
    • 使用预设的密钥解析并验证JWT的签名和有效期。
    • 如果验证成功,从JWT中提取用户ID和权限,创建Authentication对象。
    • Authentication对象设置到SecurityContextHolder中。
  4. 授权: 后续的Spring Security授权过滤器(如FilterSecurityInterceptor)会根据SecurityContextHolder中的认证信息进行授权决策。

4. 实战演练:构建JWT认证系统

我们将改造之前的项目,实现JWT认证。

4.1 引入JWT库依赖

我们将使用jjwt库来处理JWT。

xml 复制代码
        <!-- JJWT (JWT Library) -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>0.12.5</version> 
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>0.12.5</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>0.12.5</version>
            <scope>runtime</scope>
        </dependency>

4.2 JWT工具类:生成与解析Token

创建一个工具类来处理JWT的生成、解析和验证。

java 复制代码
package com.example.springsecuritystage1.util;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Component
public class JwtUtil {

    // 密钥。生产环境务必从安全通道获取,不能硬编码。
    @Value("${jwt.secret:thisismyjwtsecretkeythatiwilluseforsigningandvalidatingtokensanditshouldbeverylongandcomplex}")
    private String secret;

    // JWT有效期 (毫秒),这里设置为1小时
    @Value("${jwt.expiration:3600000}")
    private long expiration; // 1 hour

    private SecretKey getSigningKey() {
        // 使用 HS256 算法生成密钥
        return Keys.hmacShaKeyFor(secret.getBytes());
    }

    // 生成Token
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        // 将用户权限添加到claims中
        List<String> authorities = userDetails.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList());
        claims.put("authorities", authorities);
      
        return createToken(claims, userDetails.getUsername());
    }

    private String createToken(Map<String, Object> claims, String subject) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expiration);

        return Jwts.builder()
                .setClaims(claims) // 自定义声明
                .setSubject(subject) // 用户名
                .setIssuedAt(now) // 签发时间
                .setExpiration(expiryDate) // 过期时间
                .signWith(getSigningKey(), SignatureAlgorithm.HS256) // 签名算法和密钥
                .compact();
    }

    // 从Token中获取所有声明
    public Claims extractAllClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    // 从Token中获取用户名
    public String extractUsername(String token) {
        return extractAllClaims(token).getSubject();
    }

    // 从Token中获取过期时间
    public Date extractExpiration(String token) {
        return extractAllClaims(token).getExpiration();
    }

    // 检查Token是否过期
    private Boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    // 验证Token是否有效
    public Boolean validateToken(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }

    // 额外的:从Token中获取权限
    @SuppressWarnings("unchecked")
    public List<String> extractAuthorities(String token) {
        return (List<String>) extractAllClaims(token).get("authorities");
    }
}

application.yml中添加JWT配置:

yaml 复制代码
jwt:
  secret: your_jwt_secret_key_that_is_very_long_and_complex_and_should_be_kept_secure_in_production # 至少32位,生产环境务必使用更长更随机的密钥
  expiration: 3600000 # 1小时,单位毫秒

4.3 自定义 JwtAuthenticationToken

ApiKeyAuthenticationToken类似,我们需要一个Authentication实现来承载从JWT解析出的认证信息。

java 复制代码
package com.example.springsecuritystage1.security.token;

import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;

import java.util.Collection;

public class JwtAuthenticationToken extends AbstractAuthenticationToken {

    private final Object principal; // 用户名或UserDetails对象
    private String credentials; // JWT字符串本身

    public JwtAuthenticationToken(String jwtToken) {
        super(null);
        this.principal = null; // 初始时principal是null
        this.credentials = jwtToken; // JWT Token作为凭证
        setAuthenticated(false);
    }

    public JwtAuthenticationToken(Object principal, String jwtToken, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        this.credentials = jwtToken;
        setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return credentials;
    }

    @Override
    public Object getPrincipal() {
        return principal;
    }
}

4.4 自定义 JwtAuthenticationConverter (或 AuthenticationProvider)

Spring Security 6.x 推荐使用BearerTokenAuthenticationConverterReactiveJwtDecoder等用于OAuth2 Resource Server,但对于自定义的JWT,我们可以继续使用AuthenticationProvider

java 复制代码
package com.example.springsecuritystage1.security.provider;

import com.example.springsecuritystage1.security.token.JwtAuthenticationToken;
import com.example.springsecuritystage1.service.CustomUserDetailsService; // 你的UserDetailsService
import com.example.springsecuritystage1.util.JwtUtil;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureException;
import io.jsonwebtoken.UnsupportedJwtException;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

@Component
public class JwtAuthenticationProvider implements AuthenticationProvider {

    private final JwtUtil jwtUtil;
    private final CustomUserDetailsService userDetailsService; // 用于加载用户详情

    public JwtAuthenticationProvider(JwtUtil jwtUtil, CustomUserDetailsService userDetailsService) {
        this.jwtUtil = jwtUtil;
        this.userDetailsService = userDetailsService;
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        JwtAuthenticationToken jwtAuthenticationToken = (JwtAuthenticationToken) authentication;
        String jwt = (String) jwtAuthenticationToken.getCredentials();

        try {
            String username = jwtUtil.extractUsername(jwt);
            List<String> authoritiesStrings = jwtUtil.extractAuthorities(jwt); // 从JWT中提取权限

            // 可以选择从数据库再次加载UserDetails,以确保用户状态最新
            // 或者仅仅使用JWT中的信息构建User对象
            UserDetails userDetails = userDetailsService.loadUserByUsername(username); 

            if (jwtUtil.validateToken(jwt, userDetails)) {
                Set<SimpleGrantedAuthority> authorities = authoritiesStrings.stream()
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toSet());
              
                return new JwtAuthenticationToken(userDetails, jwt, authorities);
            } else {
                throw new BadCredentialsException("Invalid JWT token");
            }
        } catch (ExpiredJwtException e) {
            throw new BadCredentialsException("JWT Token has expired", e);
        } catch (UnsupportedJwtException | MalformedJwtException | SignatureException | IllegalArgumentException e) {
            throw new BadCredentialsException("Invalid JWT Token", e);
        }
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return JwtAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

注意:JwtAuthenticationProvider中,我们从JWT中提取了权限信息。但为了确保用户状态(如enabledaccountNonLocked)是最新的,我们仍然通过userDetailsService.loadUserByUsername(username)从数据库加载了完整的UserDetails。如果JWT中包含足够的信息且不关心实时状态,可以直接基于JWT信息构建User对象。

4.5 自定义 JwtAuthenticationFilter

这个过滤器负责拦截请求,提取JWT,并将其提交给AuthenticationManager

java 复制代码
package com.example.springsecuritystage1.filter;

import com.example.springsecuritystage1.security.token.JwtAuthenticationToken;
import com.example.springsecuritystage1.util.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

// JWT 认证过滤器
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final AuthenticationManager authenticationManager; // 注入 AuthenticationManager

    public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        // 1. 从 Authorization header 中获取 JWT Token
        String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
        String jwt = null;

        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            jwt = authorizationHeader.substring(7); // 提取Bearer Token
        }

        // 如果没有JWT,或者SecurityContext中已经有认证信息(例如通过Session登录),则跳过
        if (jwt == null || SecurityContextHolder.getContext().getAuthentication() != null) {
            filterChain.doFilter(request, response);
            return;
        }

        try {
            // 2. 创建一个未认证的 JwtAuthenticationToken
            JwtAuthenticationToken authenticationToken = new JwtAuthenticationToken(jwt);
          
            // 3. 将Token提交给 AuthenticationManager 进行认证
            Authentication authentication = authenticationManager.authenticate(authenticationToken);
          
            // 4. 认证成功,将认证信息存入 SecurityContextHolder
            SecurityContextHolder.getContext().setAuthentication(authentication);
            System.out.println("JWT authenticated successfully for: " + authentication.getName());

        } catch (Exception e) {
            // 认证失败,清除SecurityContext,并返回401 Unauthorized
            SecurityContextHolder.clearContext();
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("JWT authentication failed: " + e.getMessage());
            return; // 阻止请求继续往下走
        }
      
        // 继续过滤器链
        filterChain.doFilter(request, response);
    }
}

4.6 更新 SecurityFilterChain 配置,集成JWT过滤器

现在,我们需要在CustomSecurityConfig中添加JwtAuthenticationProviderAuthenticationManager,并将JwtAuthenticationFilter插入到过滤器链中。同时,禁用Session管理和CSRF防护。

java 复制代码
package com.example.springsecuritystage1.config;

// ... 省略其他 imports
import com.example.springsecuritystage1.filter.ApiKeyAuthenticationFilter;
import com.example.springsecuritystage1.filter.JwtAuthenticationFilter; // 导入 JWT 过滤器
import com.example.springsecuritystage1.security.provider.ApiKeyAuthenticationProvider;
import com.example.springsecuritystage1.security.provider.JwtAuthenticationProvider; // 导入 JWT Provider
import com.example.springsecuritystage1.util.JwtUtil; // 导入 JWT 工具类
import org.springframework.http.HttpMethod; // 导入
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.http.SessionCreationPolicy; // 导入

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class CustomSecurityConfig {

    private final DataSource dataSource;
    private final UserDetailsService userDetailsService;
    private final PasswordEncoder passwordEncoder;
    private final ApiKeyAuthenticationProvider apiKeyAuthenticationProvider;
    private final JwtAuthenticationProvider jwtAuthenticationProvider; // 注入 JWT Provider
    private final JwtUtil jwtUtil; // 注入 JWTUtil

    public CustomSecurityConfig(DataSource dataSource,
                                UserDetailsService userDetailsService,
                                PasswordEncoder passwordEncoder,
                                ApiKeyAuthenticationProvider apiKeyAuthenticationProvider,
                                JwtAuthenticationProvider jwtAuthenticationProvider,
                                JwtUtil jwtUtil) {
        this.dataSource = dataSource;
        this.userDetailsService = userDetailsService;
        this.passwordEncoder = passwordEncoder;
        this.apiKeyAuthenticationProvider = apiKeyAuthenticationProvider;
        this.jwtAuthenticationProvider = jwtAuthenticationProvider;
        this.jwtUtil = jwtUtil;
    }

    @Bean
    public PasswordEncoder passwordEncoder() { /* ... */ return new BCryptPasswordEncoder(); }

    @Bean
    public UserDetailsService userDetailsService() { /* ... */ return new CustomUserDetailsService(sysUserMapper); }

    @Bean
    public PersistentTokenRepository persistentTokenRepository() { /* ... */ return tokenRepository; }

    @Bean
    public ProviderManager authenticationManager() {
        DaoAuthenticationProvider daoProvider = new DaoAuthenticationProvider();
        daoProvider.setUserDetailsService(userDetailsService);
        daoProvider.setPasswordEncoder(passwordEncoder);
      
        // ProviderManager 现在包含 DaoAuthenticationProvider, ApiKeyAuthenticationProvider 和 JwtAuthenticationProvider
        return new ProviderManager(daoProvider, apiKeyAuthenticationProvider, jwtAuthenticationProvider);
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable()) // <<-- HERE: 禁用CSRF防护
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // <<-- HERE: 设置为无状态会话策略
            )
            .authorizeHttpRequests(authorize -> authorize
                // 允许所有请求,因为我们现在是无状态API,登录获取Token
                .requestMatchers("/api/auth/**", "/public/**", "/register", "/login").permitAll()
                // 不需要这些Web页面的权限配置了,因为它们现在应该由前端路由控制
                // .requestMatchers("/admin/**").hasAnyAuthority("ROLE_ADMIN", "USER_MANAGE")
                // .requestMatchers("/user/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN", "USER_VIEW")
                .requestMatchers("/api/v2/**").hasAuthority("API_KEY_AUTH")
                .anyRequest().authenticated() // 其他所有 API 请求都需要认证 (JWT 或 API Key)
            )
            // 移除了 formLogin 和 rememberMe, 因为现在是无状态API
            .httpBasic(Customizer.withDefaults()) // 可以在测试阶段保留HTTP Basic
            .addFilterBefore(new ApiKeyAuthenticationFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class)
            // <<-- HERE: 将 JwtAuthenticationFilter 添加到 ApiKeyAuthenticationFilter 之后,UsernamePasswordAuthenticationFilter 之前
            // 但因为我们禁用了 Session,UsernamePasswordAuthenticationFilter 实际上不会被用到,可以考虑移除
            // 这里我们放在 ApiKeyAuthenticationFilter 之后,保证 JWT 认证在 API Key 认证之后尝试
            .addFilterBefore(new JwtAuthenticationFilter(authenticationManager()), ApiKeyAuthenticationFilter.class);


            // TODO: 为JWT认证添加适当的异常处理器,例如 AuthenticationEntryPoint
            // .exceptionHandling(exception -> exception
            //     .authenticationEntryPoint(jwtAuthenticationEntryPoint) // 稍后添加
            // )

        return http.build();
    }

    @Bean
    public HttpSessionEventPublisher httpSessionEventPublisher() {
        return new HttpSessionEventPublisher(); // 即使是 STATELESS,这个Bean本身没有什么副作用,可以保留
    }
}

重要的更新点:

  1. JWT相关注入: JwtAuthenticationProviderJwtUtil被注入,并JwtAuthenticationProvider添加到ProviderManager中。
  2. 禁用CSRF和Session: csrf(csrf -> csrf.disable())sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) 是实现无状态的关键。
  3. 移除Session相关配置: formLogin()rememberMe()配置被移除,因为它们依赖于Session。
  4. JWT过滤器添加: JwtAuthenticationFilter通过 addFilterBefore(new JwtAuthenticationFilter(authenticationManager()), ApiKeyAuthenticationFilter.class) 添加到过滤器链中,它将在ApiKeyAuthenticationFilter之前尝试处理JWT认证。你可以自行调整顺序。
  5. UsernamePasswordAuthenticationFilter的去留: 由于我们禁用了Session和表单登录,UsernamePasswordAuthenticationFilter实际上不再具有作用。此处将其保留在addFilterBefore的参考中,但如果你不打算使用HTTP Basic或传统的表单登录,可以完全移除对它的引用,或者直接将其替换。对于纯API,我们通常不会使用UsernamePasswordAuthenticationFilter
    • 更新: 为了清晰,我们将JWT过滤器放在所有认证过滤器之前,让它优先处理Bearer Token。
    • UsernamePasswordAuthenticationFilter.class 如果不使用表单登录,可以将其作为参考位置,或者使用更通用的过滤器,如 BasicAuthenticationFilter.class。这里,我们将API key认证放在它之前,JWT认证放在API key认证之前,形成优先顺序。

4.7 改造登录接口,返回JWT

我们需要创建一个新的登录Controller,它接收用户名和密码,并在认证成功后返回JWT。

LoginApiController.java

java 复制代码
package com.example.springsecuritystage1.controller;

import com.example.springsecuritystage1.model.LoginRequest;
import com.example.springsecuritystage1.model.LoginResponse;
import com.example.springsecuritystage1.util.JwtUtil;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
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;

// 登录请求体
class LoginRequest {
    private String username;
    private String password;
    // Getters and Setters
    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; }
}

// 登录响应体 (包含JWT)
class LoginResponse {
    private String token;
    private String type = "Bearer";
    private Long id;
    private String username;
    private String email; // 假设有
    private List<String> roles; // 假设有
  
    // Constructors, Getters, Setters
    public LoginResponse(String accessToken, Long id, String username, String email, List<String> roles) {
        this.token = accessToken;
        this.id = id;
        this.username = username;
        this.email = email;
        this.roles = roles;
    }
    public String getToken() { return token; }
    public void setToken(String token) { this.token = token; }
    public String getType() { return type; }
    public void setType(String type) { this.type = type; }
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    public List<String> getRoles() { return roles; }
    public void setRoles(List<String> roles) { this.roles = roles; }
}

@RestController
@RequestMapping("/api/auth")
public class LoginApiController {

    private final AuthenticationManager authenticationManager;
    private final JwtUtil jwtUtil;

    public LoginApiController(AuthenticationManager authenticationManager, JwtUtil jwtUtil) {
        this.authenticationManager = authenticationManager;
        this.jwtUtil = jwtUtil;
    }

    @PostMapping("/login")
    public ResponseEntity<?> authenticateUser(@RequestBody LoginRequest loginRequest) {
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword()));

        // 如果上面认证失败,会抛出 AuthenticationException,不会走到这里
        SecurityContextHolder.getContext().setAuthentication(authentication);

        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        String jwt = jwtUtil.generateToken(userDetails);

        // 这里仅为了演示,id, email, roles可以从 userDetails 中提取或从数据库查询
        List<String> roles = userDetails.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList());

        return ResponseEntity.ok(new LoginResponse(jwt, null, userDetails.getUsername(), null, roles));
    }
}

4.8 认证失败与权限不足的自定义处理

由于我们禁用了Session和表单登录,Spring Security默认的重定向行为将不再适用。对于API,我们应该返回JSON格式的错误响应。

A. 未认证 (AuthenticationEntryPoint)

当用户未提供凭证或凭证无效时,AuthenticationEntryPoint会被触发。

java 复制代码
package com.example.springsecuritystage1.security.handler;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

// 处理未认证的请求,返回401 Unauthorized
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
            throws IOException, ServletException {
        System.out.println("Unauthorized error: " + authException.getMessage());
        response.setContentType("application/json");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.getOutputStream().println("{ \"error\": \"" + authException.getMessage() + "\", \"code\": 401 }");
    }
}

B. 权限不足 (AccessDeniedHandler)

当用户已认证但没有所需权限时,AccessDeniedHandler会被触发。

java 复制代码
package com.example.springsecuritystage1.security.handler;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;

// 处理权限不足的请求,返回403 Forbidden
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException)
            throws IOException, ServletException {
        System.out.println("Access Denied error: " + accessDeniedException.getMessage());
        response.setContentType("application/json");
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.getOutputStream().println("{ \"error\": \"" + accessDeniedException.getMessage() + "\", \"code\": 403 }");
    }
}

C. 更新SecurityFilterChain,集成异常处理器

java 复制代码
            .exceptionHandling(exception -> exception // <<-- HERE: 集成自定义异常处理器
                .authenticationEntryPoint(jwtAuthenticationEntryPoint) // 未认证
                .accessDeniedHandler(customAccessDeniedHandler) // 权限不足
            )

需要注入这两个handler:

java 复制代码
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final CustomAccessDeniedHandler customAccessDeniedHandler;

    public CustomSecurityConfig(
        // ... 其他注入
        JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
        CustomAccessDeniedHandler customAccessDeniedHandler) {
        // ... 初始化
        this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
        this.customAccessDeniedHandler = customAccessDeniedHandler;
    }

4.9 测试JWT认证流程

  1. 启动应用。

  2. 获取JWT: 使用Postman向 http://localhost:8080/api/auth/login 发送POST请求,Content-Type: application/json
    Body:

    json 复制代码
    {
        "username": "user",
        "password": "password"
    }

    成功后,你应该会收到一个包含JWT的JSON响应,例如:

    json 复制代码
    {
        "token": "eyJhbGc...",
        "type": "Bearer",
        "username": "user",
        "roles": ["ROLE_USER", "PRODUCT_READ", "USER_VIEW"]
    }
  3. 使用JWT访问受保护资源:

    • 复制得到的token
    • http://localhost:8080/user/profile 发送GET请求,在请求头中添加 Authorization: Bearer <你的JWT>
    • 你应该会收到 200 OK 响应,表示访问成功。
  4. 访问无权限资源:

    • 继续使用同一个JWT(user用户的),尝试访问 http://localhost:8080/admin/dashboard
    • 你应该收到 403 Forbidden 响应,内容为我们自定义的JSON错误。
  5. 访问需要API Key的资源:

    • 尝试使用JWT访问 http://localhost:8080/api/v2/secret-data
    • 由于这个路径需要API_KEY_AUTH权限,而JWT中可能没有,所以还是会收到403 Forbidden
    • 此时,如果你在请求头中同时提供正确的X-API-KEY,API Key认证会优先触发,导致最终成功。这展示了多认证机制的协同工作。
  6. 无效/过期JWT:

    • 尝试随便修改JWT的某个字符,或者等待JWT过期(如果设置了短有效期)。
    • 再次发送请求,你应该收到 401 Unauthorized 响应。

5. JWT的安全性与挑战

5.1 Token过期与刷新机制

  • 过期目的: JWT的exp声明是其安全性的关键。短有效期可以限制令牌被盗用后的风险。
  • 刷新Token: 通常通过引入Refresh Token机制。
    • 用户登录后,同时获取一个短期的Access Token(JWT)和一个长期的Refresh Token
    • Access Token用于访问资源。
    • Access Token过期时,客户端使用Refresh Token向认证服务器请求新的Access TokenRefresh Token
    • Refresh Token通常存储在更安全的地方(如HttpOnly Cookie),并且只能使用一次,或者有被撤销的机制。

5.2 JWT注销/黑名单机制

JWT无法像Session一样简单地"注销"。一旦签发,只要签名和有效期都没问题,它就是有效的。

为了实现注销功能或禁用被盗用的Token,可以采取:

  • 黑名单机制: 在服务器端维护一个已注销/失效的JWT列表(通常存储在Redis中,设置与JWT有效期相同的过期时间)。每次验证JWT时,除了验证签名和有效期,还需检查其是否在黑名单中。
  • 短有效期结合刷新: 这是更常见的做法。Access Token有效期设置很短,Refresh Token有效期长。当用户登出时,只销毁Refresh Token,Access Token自然很快过期。

5.3 密钥管理

  • 生成与存储: 签名JWT的密钥(secret)至关重要,必须是复杂、随机且妥善保管的。生产环境应通过环境变量、配置文件或密钥管理服务(如Vault)注入,绝不能硬编码。
  • 轮换: 定期轮换密钥是一种良好的安全实践。

5.4 防止令牌盗用

  • Https: 始终通过HTTPS传输JWT,防止中间人攻击窃取Token。
  • HttpOnly: 如果Token存储在Cookie中,应设置为HttpOnly,防止XSS攻击。
  • LocalStorage的风险: 将JWT存储在LocalStorage中虽然方便,但易受XSS攻击。

6. 常见陷阱与注意事项

  • 禁用CSRF与Session的警惕性: 只有当你确定你的应用不再依赖于Session,并且有其他安全措施时,才禁用它们。
  • JWT密钥安全: 生产环境的JWT密钥必须是强随机字符串,且妥善保管。
  • JWT负载信息: 不要在JWT的Payload中存放敏感信息。JWT只是Base64编码,不是加密。
  • JWT有效期: 根据业务需求合理设置JWT有效期。Access Token通常短,Refresh Token长。
  • 异常处理: 务必为AuthenticationEntryPointAccessDeniedHandler提供友好的JSON响应。
  • AuthenticationManager的构建: 确保ProviderManager包含了所有你需要的AuthenticationProvider

7. 阶段总结

至此,你已经完成了Spring Security深度学习的第六阶段!你现在已经能够:

  • 理解JWT的核心概念、组成和工作原理。
  • 使用jjwt库生成、解析和验证JWT。
  • 在Spring Security中禁用Session和CSRF防护,构建一个无状态的API认证系统。
  • 设计JwtAuthenticationTokenJwtAuthenticationProviderJwtAuthenticationFilter,并将其集成到Spring Security过滤器链中。
  • 改造登录接口,使其返回JWT。
  • 定制API认证失败和权限不足的JSON响应。
相关推荐
LiRuiJie5 小时前
深入剖析Spring Boot / Spring 应用中可自定义的扩展点
java·spring boot·spring
茶本无香5 小时前
RequestContextFilter介绍
java·spring·filter·requestcontext
m0_738120726 小时前
CTFshow系列——PHP特性Web93-96
开发语言·安全·web安全·php·ctfshow
Zacks_xdc7 小时前
【前端】使用Vercel部署前端项目,api转发到后端服务器
运维·服务器·前端·安全·react.js
尚学教辅学习资料7 小时前
Ruoyi-vue-plus-5.x第五篇Spring框架核心技术:5.1 Spring Boot自动配置
vue.js·spring boot·spring
A尘埃7 小时前
SpringSecurity版本的不同配置
认证·springsecurity·安全配置·不同版本
晚安里7 小时前
Spring 框架(IoC、AOP、Spring Boot) 的必会知识点汇总
java·spring boot·spring
上官浩仁8 小时前
springboot ioc 控制反转入门与实战
java·spring boot·spring
deepwater_zone8 小时前
Spring 微服务
spring·微服务