0赞
赞赏
更多好文
Spring Boot 中JWT登录授权+无感刷新,看这篇就够了!
一、引言
在当今的分布式系统和前后端分离架构盛行的时代,传统的基于 Session 的认证方式逐渐暴露出诸多弊端。想象一下,在一个大型电商系统中,用户的操作频繁涉及多个服务模块,且前端可能是网页端、移动端等多种类型。若采用传统 Session 认证,当用户从网页端切换到移动端继续操作时,由于跨域问题,Session 信息难以有效传递 ,导致用户需要重新登录。同时,随着用户数量的急剧增加,服务器需要存储大量的 Session 信息,这无疑给服务器带来了沉重的存储压力,就像一间小仓库要存放海量的货物,空间迟早会被耗尽。
而 JWT(JSON Web Token)的出现,犹如一道曙光,为这些问题提供了完美的解决方案。JWT 是一种轻量级的身份认证与授权方案,具有无状态的特性,这意味着服务器无需存储用户的会话信息,大大减轻了服务器的负担,如同给服务器卸下了沉重的包袱。它在跨域场景下表现出色,能够轻松地在不同的前端应用和后端服务之间传递,为前后端分离架构的发展提供了有力支持。并且,JWT 易于扩展,方便与各种系统集成,无论是小型项目还是大型企业级应用,都能发挥其优势。
本文将详细地为大家讲解 Spring Boot 整合 JWT 实现登录认证与接口授权的全流程,从最基础的环境搭建,到核心功能的实现,再到进阶优化,每一步都有详细的代码示例和解释,让你轻松掌握这一关键技术,为你的项目开发保驾护航。
二、JWT 基础扫盲
2.1 JWT 是什么
JWT,即 JSON Web Token,是一种基于 JSON 的开放标准(RFC 7519) ,用于在网络应用间安全地传输声明。简单来说,它是一种轻量级的身份认证和授权方案,以 JSON 格式组织和传输信息。相较于传统的认证方式,JWT 具有无状态、自包含的特性,这意味着服务器无需存储用户的会话信息,减轻了服务器的负载,同时也方便在不同的服务和系统之间传递身份验证信息,就像一个小巧且功能强大的通行证,在分布式系统和前后端分离的架构中被广泛应用。
2.2 JWT 结构剖析
JWT 看起来是一个很长的字符串,实际上它由三部分组成,每部分之间用英文句点 “.” 分隔,即 Header.Payload.Signature。
- Header(头部):主要存储两方面信息,一是令牌的类型,通常就是 “JWT”;二是签名算法,常见的如 HMAC SHA256、RSA 等 。例如:
{
"alg": "HS256",
"typ": "JWT"
}
将这个 JSON 对象进行 Base64Url 编码后,就成为了 JWT 的第一部分。
- Payload(载荷):存放实际需要传递的声明(claims)信息。这些声明可以分为三类:已注册声明(如 iss 签发者、exp 过期时间、iat 签发时间等)、公共声明(开发者自定义的公开信息,像用户 ID、角色等)、私有声明(应用内自定义的非公开信息)。例如:
{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"iat": 1516239022,
"exp": 1516239022 + 3600 // 假设有效期1小时
}
同样,将这个 JSON 对象 Base64Url 编码后,构成 JWT 的第二部分。需要注意的是,Payload 默认不加密,不要存放敏感信息,如密码等。
- Signature(签名):用于验证 JWT 的完整性,确保内容未被篡改。生成签名需要用到编码后的 Header、编码后的 Payload、一个只有服务器知道的密钥(secret)以及 Header 中指定的签名算法。以 HS256 算法为例,签名生成公式为:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret
)
计算出的签名作为 JWT 的第三部分,与前两部分共同组成完整的 JWT,如:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c 。
2.3 认证流程详解
-
用户登录:用户在客户端(如浏览器、移动应用)输入用户名和密码,向服务器发起登录请求。
-
服务器验证:服务器接收到登录请求后,验证用户名和密码是否正确。若验证通过,根据用户信息生成 JWT。这个过程就像是服务器给用户颁发了一张通行证,通行证里包含了用户的关键信息(如用户 ID、角色等),并使用密钥和特定算法进行了签名。
-
返回 JWT:服务器将生成的 JWT 返回给客户端。客户端接收到 JWT 后,可以将其存储在本地,常见的存储方式有 localStorage、sessionStorage 或者 HttpOnly Cookie 等 。
-
后续请求:在后续的每一次请求中,客户端都会将 JWT 携带在 HTTP 请求头中,一般是放在 Authorization 字段,格式为
Authorization: Bearer <JWT>。Bearer 表示认证方案,告诉服务器使用 JWT 进行认证。 -
服务器验证:服务器接收到请求后,从请求头中提取 JWT,然后使用相同的密钥和签名算法对 JWT 进行验证。验证内容包括签名是否正确、JWT 是否过期等。如果验证通过,服务器就认为该请求是合法的,并且可以从 JWT 的 Payload 中获取用户相关信息,从而进行相应的授权操作,返回请求的资源;若验证失败,则返回 401 Unauthorized 错误,拒绝访问 。
三、Spring Boot 环境搭建
3.1 核心依赖引入
在 Spring Boot 项目中,首先要引入关键依赖,为后续使用 JWT 进行登录授权奠定基础。这些依赖就像是搭建房屋的基石,缺一不可。我们需要添加 Spring Security,它是 Spring 生态中提供强大认证授权框架的组件,能为应用程序保驾护航,确保只有合法用户才能访问特定资源,就像小区门口严格的保安,阻挡外来人员随意进入;JJWT 是 Java 领域主流的 JWT 工具库,专门用于生成、解析和验证 JWT,为我们处理 JWT 相关操作提供了便捷的方法;Spring Web 则用于编写接口,方便我们测试整个认证流程,让我们能直观地看到认证授权在实际接口调用中的效果。
如果你的项目使用 Maven 构建,在pom.xml文件中添加以下依赖配置:
<dependencies>
<!-- Spring Security依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JJWT依赖 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<!-- Spring Web依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
3.2 配置文件设置
配置文件在整个项目中起着至关重要的作用,它就像是项目的 “指挥中心”,通过设置各种参数,我们可以灵活地调整项目的运行方式。在application.properties或application.yml文件中,需要配置 JWT 的一些关键参数。
比如,设置 JWT 的密钥(jwt.secret),这个密钥是生成和验证 JWT 签名的关键,务必妥善保管,就像保管自己家门的钥匙一样重要,一旦泄露,整个认证体系将面临被攻破的风险;设置访问令牌过期时间(jwt.access-token-expiration),根据业务需求,合理设定访问令牌的有效时长,比如可以设置为 30 分钟,这样既能保证一定的安全性,又不会频繁让用户重新登录;还有刷新令牌过期时间(jwt.refresh-token-expiration),通常刷新令牌的过期时间会比访问令牌长很多,比如设置为 7 天,用于在访问令牌过期时,无需用户重新输入用户名和密码,就能无感刷新获取新的访问令牌。
以application.yml为例,配置如下:
jwt:
secret: your-secret-key
access-token-expiration: 1800000 # 30分钟,单位毫秒
refresh-token-expiration: 604800000 # 7天,单位毫秒
通过上述环境搭建步骤,我们的 Spring Boot 项目已经具备了使用 JWT 进行登录授权的基本条件,接下来就可以着手实现核心的登录认证和接口授权功能了。
四、核心功能实现
4.1 JWT 工具类封装
在 Spring Boot 项目中,为了方便地处理 JWT 相关操作,我们需要将常用的 JWT 操作封装成一个工具类,就像把各种工具整理到一个工具箱里,使用时随手可拿。这里创建一个JwtUtils类,利用 Spring 的依赖注入机制,将 JWT 的密钥和过期时间等配置信息注入到类中,这样我们就能灵活地根据配置生成和验证 JWT,而无需在代码中硬编码这些关键信息。
首先,在类中使用@Component注解,将JwtUtils标记为 Spring 组件,这样 Spring 容器在启动时会自动扫描并实例化这个类,使其成为容器管理的 Bean,方便在其他组件中通过依赖注入的方式使用。接着,通过@Value注解从配置文件中读取 JWT 的密钥(jwt.secret)、访问令牌过期时间(jwt.access-token-expiration)和刷新令牌过期时间(jwt.refresh-token-expiration) ,并将这些值赋给相应的成员变量。
下面是JwtUtils类中几个关键方法的实现及解释:
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.stereotype.Component;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Component
public class JwtUtils {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.access-token-expiration}")
private long accessTokenExpiration;
@Value("${jwt.refresh-token-expiration}")
private long refreshTokenExpiration;
/**
* 生成访问令牌
* @param username 用户名
* @return 生成的访问令牌
*/
public String generateAccessToken(String username) {
Map<String, Object> claims = new HashMap<>();
return generateToken(claims, username, accessTokenExpiration);
}
/**
* 生成刷新令牌
* @param username 用户名
* @return 生成的刷新令牌
*/
public String generateRefreshToken(String username) {
Map<String, Object> claims = new HashMap<>();
return generateToken(claims, username, refreshTokenExpiration);
}
/**
* 生成JWT令牌
* @param claims 负载信息
* @param subject 主题,一般为用户名
* @param expireTime 过期时间(毫秒)
* @return 生成的JWT令牌
*/
private String generateToken(Map<String, Object> claims, String subject, long expireTime) {
Key key = Keys.hmacShaKeyFor(secret.getBytes());
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expireTime))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
/**
* 从令牌中获取用户名
* @param token JWT令牌
* @return 用户名
*/
public String getUsernameFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims.getSubject();
}
/**
* 验证令牌是否有效
* @param token JWT令牌
* @return 是否有效
*/
public boolean validateToken(String token) {
try {
Key key = Keys.hmacShaKeyFor(secret.getBytes());
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (Exception e) {
return false;
}
}
/**
* 判断令牌是否过期
* @param token JWT令牌
* @return 是否过期
*/
public boolean isTokenExpired(String token) {
Claims claims = getClaimsFromToken(token);
Date expiration = claims.getExpiration();
return expiration.before(new Date());
}
/**
* 从令牌中获取负载信息
* @param token JWT令牌
* @return 负载信息
*/
private Claims getClaimsFromToken(String token) {
Key key = Keys.hmacShaKeyFor(secret.getBytes());
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
}
-
生成访问令牌(
generateAccessToken):该方法接收用户名作为参数,创建一个空的claims(载荷)Map,然后调用generateToken方法,传入claims、用户名和访问令牌过期时间,生成访问令牌。这里空的claims可以在后续根据业务需求添加更多用户相关信息,比如用户角色、用户 ID 等 ,使令牌携带更丰富的用户身份信息。 -
生成刷新令牌(
generateRefreshToken):与生成访问令牌类似,只是传入的过期时间是刷新令牌的过期时间,用于生成长期有效的刷新令牌,以便在访问令牌过期时,用户无需重新输入用户名和密码,就能获取新的访问令牌,提升用户体验。 -
生成 JWT 令牌(
generateToken):这是一个私有的核心方法,用于实际生成 JWT 令牌。它接收claims(载荷信息)、subject(主题,通常是用户名)和expireTime(过期时间,单位毫秒)作为参数。首先根据密钥生成一个Key对象,然后使用Jwts.builder构建 JWT。依次设置claims、subject、签发时间(当前时间)、过期时间,并使用指定的SignatureAlgorithm.HS256算法和密钥进行签名,最后调用compact方法生成紧凑的 JWT 字符串 。 -
从令牌中获取用户名(
getUsernameFromToken):该方法从 JWT 令牌中提取出用户名。首先调用getClaimsFromToken方法获取令牌的claims(载荷),然后通过claims.getSubject()获取主题,即用户名,这样在验证令牌后,我们就能方便地获取到令牌对应的用户身份。 -
验证令牌是否有效(
validateToken):尝试使用密钥和签名算法解析 JWT 令牌,如果解析成功,说明令牌有效,返回true;如果在解析过程中出现异常,如签名验证失败、令牌格式错误、令牌已过期等,说明令牌无效,返回false,确保只有合法有效的令牌才能通过验证,保障系统安全。 -
判断令牌是否过期(
isTokenExpired):从令牌中获取claims(载荷),提取其中的过期时间expiration,然后与当前时间进行比较,如果过期时间早于当前时间,说明令牌已过期,返回true,否则返回false,这在处理令牌过期逻辑时非常重要,比如决定是否需要刷新令牌。 -
从令牌中获取负载信息(
getClaimsFromToken):使用密钥和签名算法解析 JWT 令牌,返回解析后的claims(载荷),其中包含了用户相关的各种信息,如用户名、角色、过期时间等,为后续根据令牌获取用户信息提供了基础。
通过以上封装,JwtUtils类提供了一套完整且便捷的 JWT 操作方法,在整个项目中,无论是生成令牌、验证令牌还是从令牌中提取信息,都可以通过调用这个工具类的方法轻松实现,大大提高了代码的复用性和可维护性 。
4.2 实现认证过滤器
在 Spring Boot 项目中,为了对每个请求进行 JWT 认证,我们需要创建一个认证过滤器。这个过滤器就像是一个严格的保安,站在请求进入系统的入口,对每个请求进行检查,只有持有合法 JWT 令牌的请求才能放行进入系统。
这里创建一个JwtAuthenticationFilter类,让它继承OncePerRequestFilter,OncePerRequestFilter保证每个请求只会被过滤一次,避免重复过滤带来的性能损耗。在这个类中,注入JwtUtils工具类,用于验证 JWT 令牌的有效性,同时注入UserDetailsService,以便在验证令牌通过后,获取用户的详细信息,进行后续的授权操作。
下面是JwtAuthenticationFilter类的核心实现及逻辑解释:
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.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 JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtUtils jwtUtils;
@Autowired
private UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
// 跳过登录和刷新令牌的接口
String requestURI = request.getRequestURI();
if (requestURI.equals("/api/auth/login") || requestURI.equals("/api/auth/refresh")) {
chain.doFilter(request, response);
return;
}
// 从请求头获取token
String token = getTokenFromRequest(request);
if (token != null && jwtUtils.validateToken(token)) {
// 从token中获取用户名
String username = jwtUtils.getUsernameFromToken(token);
// 从UserDetailsService中获取用户详细信息
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 创建认证对象
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 将认证对象存入SecurityContextHolder
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
chain.doFilter(request, response);
}
private String getTokenFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
-
跳过特定接口:在
doFilterInternal方法中,首先获取当前请求的 URI,判断是否是登录接口(/api/auth/login)或刷新令牌接口(/api/auth/refresh) 。如果是这两个接口之一,直接调用chain.doFilter(request, response)放行请求,因为登录接口用于用户获取 JWT 令牌,刷新令牌接口用于在访问令牌过期时获取新的访问令牌,这两个接口在请求时不需要进行 JWT 认证,避免循环认证问题。 -
从请求头获取 token:调用
getTokenFromRequest方法从请求头中提取 JWT 令牌。该方法先获取请求头中的Authorization字段,这是约定俗成用于传递认证信息的字段。如果Authorization字段存在且以Bearer开头(Bearer 是一种常见的认证方案前缀,表示使用令牌认证),则截取Bearer后面的字符串,即 JWT 令牌,返回给调用者;如果不符合条件,返回null。 -
验证 token 并设置认证信息:当获取到 JWT 令牌后,调用
jwtUtils.validateToken(token)方法验证令牌的有效性。如果令牌有效,通过jwtUtils.getUsernameFromToken(token)从令牌中提取用户名。接着,利用注入的UserDetailsService,调用loadUserByUsername(username)方法,根据用户名从数据库或其他数据源中加载用户的详细信息,包括用户名、密码(在验证过程中可能用到)、用户权限等 。然后,创建一个UsernamePasswordAuthenticationToken认证对象,将用户详细信息、null(密码在验证令牌后不再需要传递,这里设为null)和用户权限传入构造函数。再调用setDetails方法,设置认证对象的详细信息,这里使用WebAuthenticationDetailsSource创建请求相关的详细信息。最后,将这个认证对象存入SecurityContextHolder,SecurityContextHolder是 Spring Security 用于存储当前认证信息的地方,这样在后续的请求处理过程中,其他组件就可以从这里获取到当前用户的认证信息,进行相应的授权操作 。 -
放行请求:在完成上述处理后,无论是否成功验证令牌,都调用
chain.doFilter(request, response)将请求传递给下一个过滤器或处理器,继续处理请求。如果令牌验证成功,后续组件可以基于已设置的认证信息进行授权操作;如果令牌验证失败,由于没有在SecurityContextHolder中设置有效的认证信息,后续的授权操作会因为认证失败而拒绝请求 。
通过JwtAuthenticationFilter的实现,我们在 Spring Boot 项目中建立了一个有效的 JWT 认证机制,对每个进入系统的请求进行严格的身份验证,确保只有合法用户的请求才能访问受保护的资源,大大提高了系统的安全性和可靠性 。
五、无感刷新机制实现
5.1 双 Token 机制原理
在实际应用中,为了提升用户体验,同时保障系统安全性,我们引入双 Token 机制,即同时使用 Access Token(访问令牌)和 Refresh Token(刷新令牌)。Access Token 主要用于用户在正常操作过程中,每次请求时携带进行身份验证,它包含了用户的关键信息,如用户名、用户 ID、角色等,这些信息会在服务器验证令牌时被提取和使用,以确认请求的合法性和用户的权限。但由于其在网络传输中频繁使用,一旦泄露,可能导致用户身份被冒用,所以通常设置较短的有效期,比如 30 分钟 ,这就像一把有效期很短的临时钥匙,即使丢失,被他人利用的时间也有限。
而 Refresh Token 则是专门用于在 Access Token 过期时,获取新的 Access Token,它就像是一把备用钥匙,有效期相对较长,例如可以设置为 7 天。因为它不直接参与业务接口的访问认证,使用频率较低,所以泄露的风险相对较小,一般会存储在相对安全的地方,如 HttpOnly Cookie 中,防止前端 JavaScript 代码直接访问,避免被 XSS 攻击窃取 。
当用户登录成功后,服务器会同时生成 Access Token 和 Refresh Token 并返回给客户端。客户端在后续请求时,会将 Access Token 携带在 HTTP 请求头中发送给服务器。服务器在接收到请求后,首先验证 Access Token 的有效性。如果 Access Token 有效,正常处理请求;若 Access Token 过期,但此时客户端还持有有效的 Refresh Token,客户端就会携带 Refresh Token 向服务器发送获取新 Access Token 的请求。服务器验证 Refresh Token 通过后,会生成新的 Access Token 返回给客户端,客户端更新本地存储的 Access Token,然后继续后续操作,整个过程对用户来说是无感知的,极大地提升了用户体验,同时也保障了系统的安全性 。
5.2 后端实现步骤
- 在 JwtUtils 类中添加刷新令牌的方法:在之前封装的
JwtUtils类中,新增一个用于刷新令牌的方法。这个方法的作用是根据传入的旧的 Refresh Token,生成新的 Access Token。首先从旧的 Refresh Token 中提取出用户名,这是因为用户名是生成新的 Access Token 的关键信息,就像重新配钥匙需要知道原来钥匙对应的锁的相关信息一样。然后调用之前的generateAccessToken方法,根据提取的用户名生成新的 Access Token 并返回。
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.stereotype.Component;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Component
public class JwtUtils {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.access-token-expiration}")
private long accessTokenExpiration;
@Value("${jwt.refresh-token-expiration}")
private long refreshTokenExpiration;
// 其他方法...
/**
* 刷新令牌,根据Refresh Token生成新的Access Token
* @param refreshToken 旧的Refresh Token
* @return 新的Access Token
*/
public String refreshToken(String refreshToken) {
if (validateToken(refreshToken)) {
String username = getUsernameFromToken(refreshToken);
return generateAccessToken(username);
}
return null;
}
}
- 在 JwtAuthenticationFilter 中添加检查 token 是否即将过期并刷新的逻辑:在
JwtAuthenticationFilter过滤器中,增加对 Access Token 是否即将过期的检查逻辑。当服务器接收到请求时,会先从请求头中获取 Access Token,然后判断该 Token 是否有效且即将过期。这里设置一个阈值,比如当 Token 剩余有效期小于 5 分钟时,认为即将过期 。如果即将过期,调用JwtUtils中的refreshToken方法,生成新的 Access Token,并将新的 Token 添加到响应头中返回给客户端,这样客户端就能及时更新本地的 Access Token,实现无感刷新。
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.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;
import java.util.Date;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtUtils jwtUtils;
@Autowired
private UserDetailsService userDetailsService;
private static final long REFRESH_THRESHOLD = 5 * 60 * 1000; // 5分钟,即将过期的阈值,单位毫秒
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
// 跳过登录和刷新令牌的接口
String requestURI = request.getRequestURI();
if (requestURI.equals("/api/auth/login") || requestURI.equals("/api/auth/refresh")) {
chain.doFilter(request, response);
return;
}
// 从请求头获取token
String token = getTokenFromRequest(request);
if (token != null && jwtUtils.validateToken(token)) {
// 从token中获取用户名
String username = jwtUtils.getUsernameFromToken(token);
// 判断token是否即将过期
Date expiration = jwtUtils.getClaimsFromToken(token).getExpiration();
long remainingTime = expiration.getTime() - System.currentTimeMillis();
if (remainingTime < REFRESH_THRESHOLD) {
// 生成新的accessToken
String newAccessToken = jwtUtils.refreshToken(token);
if (newAccessToken != null) {
response.setHeader("Authorization", "Bearer " + newAccessToken);
}
}
// 从UserDetailsService中获取用户详细信息
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 创建认证对象
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 将认证对象存入SecurityContextHolder
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
chain.doFilter(request, response);
}
private String getTokenFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
5.3 前端配合实现
前端在实现无感刷新机制中起着重要的配合作用,主要涉及以下几个关键步骤:
- 保存 token 和过期时间:当用户登录成功后,前端会从后端返回的响应中获取 Access Token 和 Refresh Token,并将它们存储在本地。通常可以使用
localStorage或者sessionStorage来存储这些信息 。同时,为了方便后续判断 Token 是否过期,还需要从 Token 的 Payload 中解析出过期时间并保存。以localStorage为例,假设后端返回的响应数据是一个包含accessToken和refreshToken的 JSON 对象:
// 假设后端返回的数据
const responseData = {
accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c',
refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c',
// 假设后端同时返回过期时间(毫秒时间戳)
accessTokenExpiration: 1616239022000
};
// 保存token和过期时间
localStorage.setItem('accessToken', responseData.accessToken);
localStorage.setItem('refreshToken', responseData.refreshToken);
localStorage.setItem('accessTokenExpiration', responseData.accessTokenExpiration);
- 检查 token 过期时间:在每次前端发送 HTTP 请求前,都需要检查本地存储的 Access Token 是否即将过期。通过获取当前时间,并与之前保存的 Access Token 过期时间进行比较,判断是否需要刷新 Token。这里同样设置一个阈值,比如当距离过期时间小于 1 分钟时,触发刷新操作 。可以使用一个自定义的函数来实现这个检查逻辑:
function checkTokenExpiration() {
const accessToken = localStorage.getItem('accessToken');
const accessTokenExpiration = parseInt(localStorage.getItem('accessTokenExpiration'));
const currentTime = Date.now();
// 设置阈值,距离过期时间小于1分钟时触发刷新
const threshold = 60 * 1000;
if (accessToken && accessTokenExpiration && (accessTokenExpiration - currentTime) < threshold) {
return true;
}
return false;
}
- 发送续约请求:当检查发现 Access Token 即将过期时,前端需要携带旧的 Refresh Token 向服务器发送续约请求,以获取新的 Access Token。通常会有一个专门的后端接口用于处理这个续约请求,比如
/api/auth/refresh。在发送请求时,将 Refresh Token 放在请求头或者请求体中传递给后端。这里以使用axios库发送请求为例:
import axios from 'axios';
async function renewToken() {
const refreshToken = localStorage.getItem('refreshToken');
try {
const response = await axios.post('/api/auth/refresh', { refreshToken }, {
headers: {
'Content-Type': 'application/json'
}
});
const newAccessToken = response.data.accessToken;
const newAccessTokenExpiration = response.data.accessTokenExpiration;
// 更新本地存储的token和过期时间
localStorage.setItem('accessToken', newAccessToken);
localStorage.setItem('accessTokenExpiration', newAccessTokenExpiration);
return newAccessToken;
} catch (error) {
console.error('Token续约失败', error);
// 处理续约失败的情况,比如跳转到登录页面
window.location.href = '/login';
}
}
- 更新本地存储:当后端成功返回新的 Access Token 和相关信息(如过期时间)后,前端需要及时更新本地存储的 Token 和过期时间,确保后续请求使用的是最新的有效令牌。在上述
renewToken函数中,已经包含了更新本地存储的操作,通过localStorage.setItem方法将新的 Access Token 和过期时间重新保存 。
在实际应用中,为了使代码结构更清晰、逻辑更严谨,可以将上述功能封装成一个独立的模块,并结合前端框架(如 Vue、React 等)的特性,将这些逻辑集成到请求拦截器中,实现对所有请求的统一处理,确保在用户无感知的情况下完成 Token 的刷新,提升用户体验和系统的安全性 。例如,在 Vue 项目中,可以利用axios的拦截器机制,在请求发送前自动检查 Token 并进行刷新操作:
import axios from 'axios';
// 创建axios实例
const service = axios.create({
baseURL: process.env.VUE_APP_BASE_API, // api的base_url
timeout: 5000 // 请求超时时间
});
// 请求拦截器
service.interceptors.request.use(config => {
if (checkTokenExpiration()) {
return renewToken().then(newAccessToken => {
config.headers['Authorization'] = 'Bearer'+ newAccessToken;
return config;
});
} else {
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
config.headers['Authorization'] = 'Bearer'+ accessToken;
}
return config;
}
}, error => {
console.log(error); // for debug
Promise.reject(error);
});
export default service;
通过上述前端配合实现的步骤,与后端的无感刷新机制相结合,形成了一个完整的、用户无感知的 Token 刷新流程,有效提升了应用的用户体验和安全性,确保用户在使用应用过程中,不会因为 Token 过期而频繁中断操作,需要重新登录 。
六、安全与优化考量
6.1 Refresh Token 安全措施
Refresh Token 作为获取新 Access Token 的关键凭证,其安全性至关重要,直接关系到用户身份的持续有效性和系统的整体安全性。为了切实保障 Refresh Token 的安全,我们可以采取以下多种有效措施:
-
限制生命周期:为 Refresh Token 设置合理的有效期,避免其长期有效。虽然 Refresh Token 相较于 Access Token 有效期更长,但如果无限期有效,一旦泄露,就会给恶意攻击者提供长时间冒用用户身份的机会。例如,将 Refresh Token 的有效期设置为 7 天,这样即使 Refresh Token 不幸泄露,攻击者能利用的时间也被限制在 7 天内,大大降低了安全风险。在
JwtUtils类中设置过期时间时,通过@Value注解从配置文件读取过期时间参数,如@Value("${jwt.refresh-token-expiration}") private long refreshTokenExpiration;,在生成刷新令牌的方法中使用该参数return generateToken(claims, username, refreshTokenExpiration);,确保按照配置的有效期生成刷新令牌。 -
禁止跨域使用:通过设置 HTTP 响应头,如
Access-Control-Allow-Origin,严格限制 Refresh Token 只能在同域请求中使用。这是因为跨域请求容易受到跨站请求伪造(CSRF)等攻击,若 Refresh Token 在跨域场景下被滥用,攻击者可能会借助用户的身份在其他域中进行非法操作。在 Spring Boot 项目中,可以使用过滤器或者 Spring Security 的配置来实现这一限制。例如,使用 Spring Security 的配置:
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.cors().configurationSource(request -> {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(Collections.singletonList("http://your-allowed-origin.com"));
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type"));
return config;
})
.and()
// 其他配置...
}
}
这样就限制了只有http://your-allowed-origin.com这个域的请求才能携带 Refresh Token 进行访问,有效防止了跨域攻击。
- 考虑绑定设备:将 Refresh Token 与用户设备信息进行绑定,比如设备的唯一标识(如 IMEI、MAC 地址等) 。这样一来,即使 Refresh Token 被泄露,由于与原设备信息不匹配,攻击者也无法在其他设备上成功使用。实现设备绑定可以在生成 Refresh Token 时,将设备标识作为自定义声明(claim)添加到 JWT 的 Payload 中。例如:
public String generateRefreshToken(String username, String deviceId) {
Map<String, Object> claims = new HashMap<>();
claims.put("deviceId", deviceId);
return generateToken(claims, username, refreshTokenExpiration);
}
在验证 Refresh Token 时,从 Token 的 Payload 中提取设备标识,并与当前请求设备的标识进行比对,若不一致则拒绝请求,从而增强了 Refresh Token 的安全性。
6.2 离线刷新策略
离线刷新 Token,简单来说,就是在客户端检测到 Token 即将过期时,即便此时没有新的请求发生,客户端也会主动向服务器发起请求,获取新的 Token,以确保用户在下次使用应用时,Token 仍然有效,整个过程无需用户手动干预,真正实现了无感知的 Token 更新。
在实际应用中,离线刷新策略具有重要的意义和广泛的应用场景。比如在一些需要长时间运行的应用程序中,如在线文档编辑工具,用户可能会长时间打开文档进行编辑,期间并没有频繁的网络请求。但随着时间的推移,Token 可能会过期,如果没有离线刷新策略,当用户完成编辑想要保存文档时,由于 Token 过期,保存操作就会失败,用户需要重新登录,这无疑会给用户带来极大的困扰,影响用户体验。
实现离线刷新策略,在前端可以利用定时器机制,定时检查本地存储的 Token 过期时间。例如,使用setInterval函数,每隔一段时间(如 10 分钟)检查一次 Token 是否即将过期。当检测到 Token 即将过期(如距离过期时间小于 1 分钟)时,触发刷新操作。以 JavaScript 代码为例:
// 假设已经定义了checkTokenExpiration和renewToken函数
const checkInterval = setInterval(() => {
if (checkTokenExpiration()) {
renewToken().then(() => {
console.log('Token已成功刷新');
}).catch((error) => {
console.error('Token刷新失败', error);
// 处理刷新失败的情况,如跳转到登录页面
window.location.href = '/login';
});
}
}, 10 * 60 * 1000); // 每隔10分钟检查一次
在后端,需要提供相应的接口来处理前端发送的刷新请求。这个接口与之前实现的刷新接口类似,接收前端传递的 Refresh Token,验证其有效性后,生成新的 Access Token 并返回给前端。例如,在 Spring Boot 中,可以定义如下 Controller 方法:
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private JwtUtils jwtUtils;
@PostMapping("/offline-refresh")
public ResponseEntity<String> offlineRefreshToken(@RequestBody String refreshToken) {
if (jwtUtils.validateToken(refreshToken)) {
String newAccessToken = jwtUtils.refreshToken(refreshToken);
if (newAccessToken != null) {
return ResponseEntity.ok(newAccessToken);
}
}
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid refresh token");
}
}
通过前端和后端的协同工作,实现了离线刷新 Token 的功能,有效提升了应用的稳定性和用户体验,确保用户在使用应用过程中不会因为 Token 过期而中断操作 。
6.3 API Gateway 集成优势
在微服务架构中,API Gateway 作为整个系统的统一入口,扮演着至关重要的角色。将 Token 刷新功能集成到 API Gateway 中,具有诸多显著的优势,能够极大地提升系统的性能和灵活性。
首先,API Gateway 可以集中处理 Token 的刷新逻辑,减轻各个微服务的负担。在传统的架构中,每个微服务都需要自行处理 Token 的验证和刷新,这无疑会导致代码的重复编写,增加开发和维护的成本。而通过 API Gateway 统一处理 Token 刷新,各个微服务只需专注于自身的业务逻辑,无需再关心复杂的认证和授权流程,就像将繁琐的安保工作统一交给专业的安保公司,各个部门就能更专注于自己的核心业务。
其次,API Gateway 能够实现更灵活的刷新策略。它可以根据不同的业务需求和场景,制定个性化的 Token 刷新规则。比如,对于一些对安全性要求极高的业务请求,可以设置更短的 Token 有效期和更频繁的刷新机制;而对于一些普通的业务请求,则可以适当放宽刷新条件,减少不必要的刷新操作,提高系统的性能和响应速度。这种差异化的刷新策略,能够更好地满足多样化的业务需求,提升系统的整体适应性。
以 Spring Cloud Gateway 为例,实现 Token 刷新功能的集成。首先,在 Spring Cloud Gateway 的配置文件中,定义全局过滤器,用于拦截所有的请求,并对 Token 进行验证和刷新处理。例如:
@Configuration
public class GatewayConfig {
@Autowired
private JwtUtils jwtUtils;
@Bean
public GlobalFilter jwtFilter() {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
String token = request.getHeaders().getFirst("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
if (jwtUtils.validateToken(token)) {
// 判断Token是否即将过期
boolean isTokenAboutToExpire = jwtUtils.isTokenAboutToExpire(token);
if (isTokenAboutToExpire) {
// 尝试刷新Token
String newToken = jwtUtils.refreshToken(token);
if (newToken != null) {
ServerHttpRequest newRequest = request.mutate()
.headers(httpHeaders -> httpHeaders.set("Authorization", "Bearer " + newToken))
.build();
return chain.filter(exchange.mutate().request(newRequest).build());
}
}
}
}
return chain.filter(exchange);
};
}
}
在上述代码中,通过定义jwtFilter全局过滤器,对每个进入系统的请求进行拦截。首先从请求头中提取 Token,验证其有效性。如果 Token 有效且即将过期,调用JwtUtils中的refreshToken方法尝试刷新 Token。若刷新成功,将新的 Token 添加到请求头中,继续处理请求;若刷新失败或 Token 无效,则按照正常流程继续处理请求,由后续的业务逻辑来决定是否拒绝访问 。
通过将 Token 刷新功能集成到 API Gateway 中,不仅减轻了微服务的压力,提高了系统的可维护性,还实现了更灵活、高效的 Token 管理策略,为微服务架构的稳定运行和业务的顺利开展提供了有力保障 。
