Spring Boot 中JWT登录授权+无感刷新,看这篇就够了!

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 等 。例如:
json 复制代码
{
  "alg": "HS256",
  "typ": "JWT"
}

将这个 JSON 对象进行 Base64Url 编码后,就成为了 JWT 的第一部分。

  • Payload(载荷):存放实际需要传递的声明(claims)信息。这些声明可以分为三类:已注册声明(如 iss 签发者、exp 过期时间、iat 签发时间等)、公共声明(开发者自定义的公开信息,像用户 ID、角色等)、私有声明(应用内自定义的非公开信息)。例如:
json 复制代码
{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true,
  "iat": 1516239022,
  "exp": 1516239022 + 3600 // 假设有效期1小时
}

同样,将这个 JSON 对象 Base64Url 编码后,构成 JWT 的第二部分。需要注意的是,Payload 默认不加密,不要存放敏感信息,如密码等。

  • Signature(签名):用于验证 JWT 的完整性,确保内容未被篡改。生成签名需要用到编码后的 Header、编码后的 Payload、一个只有服务器知道的密钥(secret)以及 Header 中指定的签名算法。以 HS256 算法为例,签名生成公式为:
Plain 复制代码
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret
)

计算出的签名作为 JWT 的第三部分,与前两部分共同组成完整的 JWT,如:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

2.3 认证流程详解

  1. 用户登录:用户在客户端(如浏览器、移动应用)输入用户名和密码,向服务器发起登录请求。

  2. 服务器验证:服务器接收到登录请求后,验证用户名和密码是否正确。若验证通过,根据用户信息生成 JWT。这个过程就像是服务器给用户颁发了一张通行证,通行证里包含了用户的关键信息(如用户 ID、角色等),并使用密钥和特定算法进行了签名。

  3. 返回 JWT:服务器将生成的 JWT 返回给客户端。客户端接收到 JWT 后,可以将其存储在本地,常见的存储方式有 localStorage、sessionStorage 或者 HttpOnly Cookie 等 。

  4. 后续请求 :在后续的每一次请求中,客户端都会将 JWT 携带在 HTTP 请求头中,一般是放在 Authorization 字段,格式为 Authorization: Bearer <JWT> 。Bearer 表示认证方案,告诉服务器使用 JWT 进行认证。

  5. 服务器验证:服务器接收到请求后,从请求头中提取 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文件中添加以下依赖配置:

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.propertiesapplication.yml文件中,需要配置 JWT 的一些关键参数。

比如,设置 JWT 的密钥(jwt.secret),这个密钥是生成和验证 JWT 签名的关键,务必妥善保管,就像保管自己家门的钥匙一样重要,一旦泄露,整个认证体系将面临被攻破的风险;设置访问令牌过期时间(jwt.access-token-expiration),根据业务需求,合理设定访问令牌的有效时长,比如可以设置为 30 分钟,这样既能保证一定的安全性,又不会频繁让用户重新登录;还有刷新令牌过期时间(jwt.refresh-token-expiration),通常刷新令牌的过期时间会比访问令牌长很多,比如设置为 7 天,用于在访问令牌过期时,无需用户重新输入用户名和密码,就能无感刷新获取新的访问令牌。

application.yml为例,配置如下:

yaml 复制代码
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类中几个关键方法的实现及解释:

java 复制代码
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。依次设置claimssubject、签发时间(当前时间)、过期时间,并使用指定的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类,让它继承OncePerRequestFilterOncePerRequestFilter保证每个请求只会被过滤一次,避免重复过滤带来的性能损耗。在这个类中,注入JwtUtils工具类,用于验证 JWT 令牌的有效性,同时注入UserDetailsService,以便在验证令牌通过后,获取用户的详细信息,进行后续的授权操作。

下面是JwtAuthenticationFilter类的核心实现及逻辑解释:

java 复制代码
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创建请求相关的详细信息。最后,将这个认证对象存入SecurityContextHolderSecurityContextHolder是 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 后端实现步骤

  1. 在 JwtUtils 类中添加刷新令牌的方法 :在之前封装的JwtUtils类中,新增一个用于刷新令牌的方法。这个方法的作用是根据传入的旧的 Refresh Token,生成新的 Access Token。首先从旧的 Refresh Token 中提取出用户名,这是因为用户名是生成新的 Access Token 的关键信息,就像重新配钥匙需要知道原来钥匙对应的锁的相关信息一样。然后调用之前的generateAccessToken方法,根据提取的用户名生成新的 Access Token 并返回。
java 复制代码
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;
    }
}
  1. 在 JwtAuthenticationFilter 中添加检查 token 是否即将过期并刷新的逻辑 :在JwtAuthenticationFilter过滤器中,增加对 Access Token 是否即将过期的检查逻辑。当服务器接收到请求时,会先从请求头中获取 Access Token,然后判断该 Token 是否有效且即将过期。这里设置一个阈值,比如当 Token 剩余有效期小于 5 分钟时,认为即将过期 。如果即将过期,调用JwtUtils中的refreshToken方法,生成新的 Access Token,并将新的 Token 添加到响应头中返回给客户端,这样客户端就能及时更新本地的 Access Token,实现无感刷新。
java 复制代码
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 前端配合实现

前端在实现无感刷新机制中起着重要的配合作用,主要涉及以下几个关键步骤:

  1. 保存 token 和过期时间 :当用户登录成功后,前端会从后端返回的响应中获取 Access Token 和 Refresh Token,并将它们存储在本地。通常可以使用localStorage或者sessionStorage来存储这些信息 。同时,为了方便后续判断 Token 是否过期,还需要从 Token 的 Payload 中解析出过期时间并保存。以localStorage为例,假设后端返回的响应数据是一个包含accessTokenrefreshToken的 JSON 对象:
javascript 复制代码
// 假设后端返回的数据
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);
  1. 检查 token 过期时间:在每次前端发送 HTTP 请求前,都需要检查本地存储的 Access Token 是否即将过期。通过获取当前时间,并与之前保存的 Access Token 过期时间进行比较,判断是否需要刷新 Token。这里同样设置一个阈值,比如当距离过期时间小于 1 分钟时,触发刷新操作 。可以使用一个自定义的函数来实现这个检查逻辑:
javascript 复制代码
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;
}
  1. 发送续约请求 :当检查发现 Access Token 即将过期时,前端需要携带旧的 Refresh Token 向服务器发送续约请求,以获取新的 Access Token。通常会有一个专门的后端接口用于处理这个续约请求,比如/api/auth/refresh 。在发送请求时,将 Refresh Token 放在请求头或者请求体中传递给后端。这里以使用axios库发送请求为例:
javascript 复制代码
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';
    }
}
  1. 更新本地存储 :当后端成功返回新的 Access Token 和相关信息(如过期时间)后,前端需要及时更新本地存储的 Token 和过期时间,确保后续请求使用的是最新的有效令牌。在上述renewToken函数中,已经包含了更新本地存储的操作,通过localStorage.setItem方法将新的 Access Token 和过期时间重新保存 。

在实际应用中,为了使代码结构更清晰、逻辑更严谨,可以将上述功能封装成一个独立的模块,并结合前端框架(如 Vue、React 等)的特性,将这些逻辑集成到请求拦截器中,实现对所有请求的统一处理,确保在用户无感知的情况下完成 Token 的刷新,提升用户体验和系统的安全性 。例如,在 Vue 项目中,可以利用axios的拦截器机制,在请求发送前自动检查 Token 并进行刷新操作:

javascript 复制代码
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 的配置:

java 复制代码
@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 中。例如:
java 复制代码
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 代码为例:

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 方法:

java 复制代码
@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 进行验证和刷新处理。例如:

java 复制代码
@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 管理策略,为微服务架构的稳定运行和业务的顺利开展提供了有力保障 。

相关推荐
码农BookSea3 小时前
深度解析Skills:从Prompt到能力复用的技术革命
后端·ai编程
计算机毕设指导63 小时前
基于SpringBoot校园学生健康监测管理系统【源码文末联系】
java·spring boot·后端·spring·tomcat·maven·intellij-idea
希望永不加班4 小时前
SpringBoot 数据库连接池配置(HikariCP)最佳实践
java·数据库·spring boot·后端·spring
夕颜1114 小时前
写 SIP 服务后台前,先把 SIP 和 PSTN 搞清楚
后端
码农BookSea4 小时前
为什么ChatGPT能听懂你说的话?Embedding技术揭秘
后端·openai
黑牛儿4 小时前
MySQL 索引实战详解:从创建到优化,彻底解决查询慢问题
服务器·数据库·后端·mysql
程序员飞哥4 小时前
到底Java 适不适合做 AI 呢?
后端·程序员·全栈
码事漫谈5 小时前
AI提效,到底能强到什么程度?
前端·后端
IT_陈寒5 小时前
React hooks依赖数组这个坑差点把我埋了
前端·人工智能·后端