Spring Security中Token失效与无状态认证实现指南

Spring Security中Token失效与无状态认证实现指南

大家好,今天我们来深入探讨Spring Security中Token失效机制以及如何实现无状态认证。在传统的基于Session的认证方式中,服务端需要维护用户的登录状态,这在高并发和分布式环境下会带来诸多问题。无状态认证通过Token,特别是JWT (JSON Web Token),将用户状态信息存储在客户端,服务端只需验证Token的有效性,从而减轻了服务端的负担,提升了系统的可扩展性。

1. 无状态认证的核心概念

无状态认证的核心在于服务端不再保存用户的登录状态。每次客户端请求时,都会携带Token,服务端根据Token中的信息进行身份验证,而无需查询数据库或其他存储介质。这带来了以下优势:

  • 可扩展性: 服务端无需维护Session,可以轻松扩展到多个节点。
  • 安全性: JWT 可以通过数字签名进行验证,防止篡改。
  • 跨域支持: Token 可以方便地在不同域之间传递。
  • 移动端友好: 非常适合移动应用,因为移动端通常不适合使用 Cookie。

2. JWT (JSON Web Token) 结构与原理

JWT 由三部分组成,通过 . 分隔:

  • Header (头部): 声明Token的类型和使用的签名算法。
  • Payload (载荷): 包含声明(claims),声明是一些关于实体(通常指用户)和其他数据的陈述。
  • Signature (签名): 通过Header中指定的算法对Header和Payload进行签名,防止篡改。

Header: 通常包含 alg (算法) 和 typ (类型) 两个字段。例如:

{
  "alg": "HS256",
  "typ": "JWT"
}

Payload: 包含一系列的声明。可以分为三种类型:

  • Registered claims (注册声明): 预定义的声明,例如 iss (签发者), sub (主题), aud (受众), exp (过期时间), nbf (生效时间), iat (签发时间), jti (JWT ID)。
  • Public claims (公共声明): 由 JWT 的使用者自定义的声明,需要注册以避免冲突。
  • Private claims (私有声明): 仅在生成和消费 JWT 的双方之间使用的声明。

例如:

{
  "sub": "user123",
  "name": "John Doe",
  "admin": true,
  "iat": 1516239022,
  "exp": 1616239022
}

Signature: 通过Header中指定的算法,使用密钥对Header和Payload进行签名。例如,使用 HMAC SHA256 算法:

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret
)

整个 JWT 的结构如下:

base64UrlEncode(header) + "." + base64UrlEncode(payload) + "." + signature

3. Spring Security 集成 JWT

接下来,我们使用 Spring Security 实现基于 JWT 的无状态认证。

3.1 添加依赖

首先,添加 JWT 相关的依赖。这里使用 jjwt 库:

<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-api</artifactId>
  <version>0.11.5</version>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-impl</artifactId>
  <version>0.11.5</version>
  <scope>runtime</scope>
</dependency>
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt-jackson</artifactId>
  <version>0.11.5</version>
  <scope>runtime</scope>
</dependency>

3.2 JWT 工具类

创建一个 JWT 工具类,用于生成和验证 JWT。

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

import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

@Component
public class JwtUtil {

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private long jwtExpirationInMs;

    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    public Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
    }

    private Boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        return createToken(claims, userDetails.getUsername());
    }

    private String createToken(Map<String, Object> claims, String subject) {
        return Jwts.builder().setClaims(claims).setSubject(subject).setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + jwtExpirationInMs))
                .signWith(SignatureAlgorithm.HS256, secret).compact();
    }

    public Boolean validateToken(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }
}

application.properties:

jwt.secret=yourSecretKey
jwt.expiration=3600000 # 1 hour

3.3 Spring Security 配置

配置 Spring Security,使其使用 JWT 进行身份验证。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
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.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private JwtRequestFilter jwtRequestFilter;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeRequests()
                .antMatchers("/authenticate").permitAll()  //允许/authenticate接口匿名访问
                .anyRequest().authenticated()  //其他接口需要认证
                .and().sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);  //设置为无状态
        http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
    }

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

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

3.4 JWT 请求过滤器

创建一个 JWT 请求过滤器,用于在每个请求中验证 JWT。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class JwtRequestFilter extends OncePerRequestFilter {

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private JwtUtil jwtUtil;

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

        final String authorizationHeader = request.getHeader("Authorization");

        String username = null;
        String jwt = null;

        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            jwt = authorizationHeader.substring(7);
            username = jwtUtil.extractUsername(jwt);
        }

        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {

            UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);

            if (jwtUtil.validateToken(jwt, userDetails)) {

                UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                usernamePasswordAuthenticationToken
                        .setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
            }
        }
        chain.doFilter(request, response);
    }

}

3.5 用户认证服务

实现 UserDetailsService 接口,用于从数据库或其他存储介质中加载用户信息。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.ArrayList;

@Service
public class MyUserDetailsService implements UserDetailsService {

    @Autowired
    private PasswordEncoder passwordEncoder; // 注入PasswordEncoder

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 模拟从数据库中查询用户
        if ("foo".equals(username)) {
            // 使用PasswordEncoder加密密码
            return new User("foo", passwordEncoder.encode("foo"), new ArrayList<>());
        } else {
            throw new UsernameNotFoundException("User not found: " + username);
        }
    }
}

3.6 认证接口

创建一个认证接口,用于生成 JWT。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AuthenticationController {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private JwtUtil jwtUtil;

    @PostMapping("/authenticate")
    public ResponseEntity<?> createAuthenticationToken(@RequestBody AuthenticationRequest authenticationRequest) throws Exception {

        try {
            authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(authenticationRequest.getUsername(), authenticationRequest.getPassword())
            );
        } catch (BadCredentialsException e) {
            throw new Exception("Incorrect username or password", e);
        }

        final UserDetails userDetails = userDetailsService
                .loadUserByUsername(authenticationRequest.getUsername());

        final String jwt = jwtUtil.generateToken(userDetails);

        return ResponseEntity.ok(new AuthenticationResponse(jwt));
    }

}

class AuthenticationRequest {

    private String username;
    private String password;

    // Constructors, 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;
    }
}

class AuthenticationResponse {

    private final String jwt;

    public AuthenticationResponse(String jwt) {
        this.jwt = jwt;
    }

    public String getJwt() {
        return jwt;
    }
}

4. Token 失效机制的实现

JWT 本身是无状态的,Token 失效主要依赖于 exp (过期时间) 声明。一旦 Token 过期,服务端就会拒绝访问。但是,仅仅依靠 exp 声明是不够的,因为在 Token 过期之前,仍然可以被使用。我们需要一些额外的机制来实现更灵活的 Token 失效。

4.1 基于黑名单的 Token 失效

维护一个黑名单,记录所有已失效的 Token。每次收到请求时,先检查 Token 是否在黑名单中,如果在,则拒绝访问。

  • 实现方式:

    • 可以使用 Redis 等缓存数据库来存储黑名单。
    • 每次用户注销或修改密码时,将该用户的 Token 加入黑名单。
    • 在 JWT 过滤器中,检查 Token 是否在黑名单中。
  • 代码示例:

    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.data.redis.core.RedisTemplate;
    import org.springframework.stereotype.Component;
    
    import java.util.concurrent.TimeUnit;
    
    @Component
    public class JwtBlacklist {
    
        @Autowired
        private RedisTemplate<String, String> redisTemplate;
    
        private static final String BLACKLIST_PREFIX = "jwt:blacklist:";
    
        public void addTokenToBlacklist(String token, long expirationTimeInSeconds) {
            redisTemplate.opsForValue().set(BLACKLIST_PREFIX + token, "blacklisted", expirationTimeInSeconds, TimeUnit.SECONDS);
        }
    
        public boolean isTokenBlacklisted(String token) {
            return redisTemplate.hasKey(BLACKLIST_PREFIX + token);
        }
    }

    修改 JwtRequestFilter

    @Autowired
    private JwtBlacklist jwtBlacklist;
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
    
        final String authorizationHeader = request.getHeader("Authorization");
    
        String username = null;
        String jwt = null;
    
        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            jwt = authorizationHeader.substring(7);
            username = jwtUtil.extractUsername(jwt);
        }
    
        if (jwt != null && jwtBlacklist.isTokenBlacklisted(jwt)) {
            // Token 已失效
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return;
        }
    
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
    
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
    
            if (jwtUtil.validateToken(jwt, userDetails)) {
    
                UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                usernamePasswordAuthenticationToken
                        .setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
            }
        }
        chain.doFilter(request, response);
    }

4.2 基于刷新 Token 的 Token 失效

使用一对 Token:Access Token 和 Refresh Token。Access Token 的过期时间较短,用于日常的 API 访问。Refresh Token 的过期时间较长,用于刷新 Access Token。

  • 流程:

    1. 客户端使用用户名和密码向服务器请求 Access Token 和 Refresh Token。
    2. 客户端使用 Access Token 访问 API。
    3. 如果 Access Token 过期,客户端使用 Refresh Token 向服务器请求新的 Access Token。
    4. 服务器验证 Refresh Token 的有效性,如果有效,则生成新的 Access Token 和 Refresh Token。
    5. 客户端使用新的 Access Token 访问 API。
  • 代码示例:

    首先,修改 AuthenticationResponse,包含 Refresh Token:

    class AuthenticationResponse {
    
        private final String jwt;
        private final String refreshToken;
    
        public AuthenticationResponse(String jwt, String refreshToken) {
            this.jwt = jwt;
            this.refreshToken = refreshToken;
        }
    
        public String getJwt() {
            return jwt;
        }
    
        public String getRefreshToken() { return refreshToken; }
    }

    修改 AuthenticationController,生成 Refresh Token:

    @PostMapping("/authenticate")
    public ResponseEntity<?> createAuthenticationToken(@RequestBody AuthenticationRequest authenticationRequest) throws Exception {
    
        try {
            authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(authenticationRequest.getUsername(), authenticationRequest.getPassword())
            );
        } catch (BadCredentialsException e) {
            throw new Exception("Incorrect username or password", e);
        }
    
        final UserDetails userDetails = userDetailsService
                .loadUserByUsername(authenticationRequest.getUsername());
    
        final String jwt = jwtUtil.generateToken(userDetails);
        final String refreshToken = jwtUtil.generateToken(userDetails); // 简单起见,refreshToken也用jwt生成,实际应用中需要更安全的方式
    
        return ResponseEntity.ok(new AuthenticationResponse(jwt, refreshToken));
    }

    添加一个刷新 Token 的接口:

    @PostMapping("/refresh-token")
    public ResponseEntity<?> refreshToken(@RequestBody RefreshTokenRequest refreshTokenRequest) {
        String refreshToken = refreshTokenRequest.getRefreshToken();
    
        // 验证 refreshToken 的有效性 (例如,检查是否在数据库中)
        String username = jwtUtil.extractUsername(refreshToken);
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        if (!jwtUtil.validateToken(refreshToken, userDetails)) {
            return ResponseEntity.status(401).body("Invalid refresh token");
        }
    
        // 生成新的 Access Token 和 Refresh Token
        String newAccessToken = jwtUtil.generateToken(userDetails);
        String newRefreshToken = jwtUtil.generateToken(userDetails);
    
        return ResponseEntity.ok(new AuthenticationResponse(newAccessToken, newRefreshToken));
    }
    
    static class RefreshTokenRequest {
        private String refreshToken;
    
        public String getRefreshToken() {
            return refreshToken;
        }
    
        public void setRefreshToken(String refreshToken) {
            this.refreshToken = refreshToken;
        }
    }

    注意: Refresh Token 的存储和验证需要更加谨慎,通常需要存储在数据库中,并进行更严格的安全控制,以防止 Refresh Token 被盗用。

4.3 基于数据库的 Token 失效

每次用户登录时,生成一个唯一的 Token ID (例如 UUID),将 Token ID 和用户信息存储在数据库中。每次收到请求时,从数据库中查询 Token ID 是否有效。

  • 实现方式:

    • 在数据库中创建一个 Token 表,包含 Token ID, 用户 ID, 过期时间等字段。
    • 每次用户登录时,生成一个新的 Token ID,并将其存储在 Token 表中。
    • 在 JWT 过滤器中,从 JWT 中提取 Token ID,并从数据库中查询该 Token ID 是否有效。
    • 用户注销或修改密码时,删除或更新 Token 表中的记录。
  • 优点: 可以实现更细粒度的 Token 控制,例如可以随时使某个用户的 Token 失效。

  • 缺点: 每次请求都需要查询数据库,会增加系统的负担。

5. 安全性考虑

  • 使用 HTTPS: 确保所有的通信都通过 HTTPS 加密,防止 Token 被窃取。
  • 保护 Secret Key: Secret Key 必须保密,不能泄露给任何人。
  • 使用强密码: 要求用户使用强密码,并定期更换密码。
  • 防止 XSS 和 CSRF 攻击: 采取措施防止 XSS 和 CSRF 攻击,防止 Token 被盗用。
  • Token 加密: 对 Token 进行加密,增加安全性。
  • 限制 Token 的权限: 根据用户的角色和权限,限制 Token 的权限。
  • 定期审计: 定期审计系统的安全性,及时发现和修复安全漏洞。

6. 不同失效策略对比

失效策略 优点 缺点 适用场景 实现复杂度
JWT exp 简单易用 无法主动使 Token 失效 适用于对 Token 失效要求不高的场景
基于黑名单 可以主动使 Token 失效,实现简单 需要维护黑名单,增加存储和查询开销 适用于需要主动使 Token 失效的场景,例如用户注销
基于刷新 Token 可以延长 Token 的有效期,提高用户体验 实现复杂,需要维护 Refresh Token 的安全性 适用于需要频繁访问 API 的场景
基于数据库 可以实现更细粒度的 Token 控制,灵活性高 每次请求都需要查询数据库,增加系统负担 适用于对 Token 控制要求非常高的场景

7. 一些补充说明

  • JWT 的大小: JWT 的大小会影响性能,特别是当 JWT 中包含大量声明时。尽量减少 JWT 的大小,只包含必要的声明。
  • JWT 的存储: 在客户端,JWT 通常存储在 Local Storage 或 Cookie 中。Local Storage 存在 XSS 攻击的风险,Cookie 可以通过设置 httpOnly 属性来防止 XSS 攻击。
  • JWT 的管理: 需要对 JWT 进行管理,例如 Token 的生成、验证、刷新、失效等。可以使用专业的 JWT 管理工具或库来实现这些功能。
  • 单点登录 (SSO): JWT 可以用于实现单点登录,多个应用可以共享同一个 JWT。

8. 理解token失效机制对于安全至关重要

理解 Spring Security 中 Token 失效机制以及如何实现无状态认证,对于构建安全可靠的 API 非常重要。选择合适的 Token 失效策略,并采取必要的安全措施,可以有效地保护系统的安全。

希望今天的讲解对大家有所帮助。谢谢!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注