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。
-
流程:
- 客户端使用用户名和密码向服务器请求 Access Token 和 Refresh Token。
- 客户端使用 Access Token 访问 API。
- 如果 Access Token 过期,客户端使用 Refresh Token 向服务器请求新的 Access Token。
- 服务器验证 Refresh Token 的有效性,如果有效,则生成新的 Access Token 和 Refresh Token。
- 客户端使用新的 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 失效策略,并采取必要的安全措施,可以有效地保护系统的安全。
希望今天的讲解对大家有所帮助。谢谢!