sprirng Boot 实现token无感刷新

背景

在前后端分离项目中,常用的身份识别认证方案是基于JWT(JSON WEB TOKEN)。在保证安全性的同时,短生命周期Access token 又会带来频繁登录的痛点体验,为了解决这一问题,引入了Refresh Token 并结合 无感刷新机制,让客户端在Access Token 过期时自动刷新,而无需用户手动重新登录,从而提升用户体验感

为什么要无感刷新

在基于Token的用户认证系统中,通常会设计两种Token:

Access Token:用于访问资源,有效期短(通常15-30分钟)
Refresh Token:用于获取新Access Token,有效期长(通常7天)

传统Token机制存在两大痛点:

频繁强制退出Access Token过期时用户需重新登录
安全隐患:延长Access Token有效期会增加安全风险

解决的问题:

用户体验优先:

Access Token 自动过期时间通常设置很短,若不自动刷新,登录状态就会频繁过期,用户被迫"重新登录",体验极差

安全与性能平衡:

短生命周期的 Access Token 能减少被截获滥用的风险
结合 Refresh Token(相对较长有效期),可以在安全与便捷间找到最佳点

前后端解耦:

通过前端拦截器统一处理过期场景,无须在各业务请求中散落重复逻辑
后端专注提供刷新接口与失效策略,无需关心前端实现细节

无感刷新原理

无感刷新流程

在这里插入图片描述

关键技术点

双 Token 机制

  • Access Token:短时有效,携带用户身份和权限

  • Refresh Token:长期有效,专用于换取新的 Access Token

拦截与重试

  • 前端在每次 API 请求中携带 Access Token;
  • 若响应为 401 Unauthorized(或后端自定义过期码),前端拦截器自动调用刷新token接口,用 Refresh Token 获取新一对 Token;
  • 获取成功后,前端重新发起失败的原始请求,用户无感知。

后端安全策略

  • 将 Refresh Token 写入 Redis,并在刷新时做一次性或者滑动过期(可选)校验;
  • 旧 Refresh Token 刷新后失效,防止被盗用。

前端实现

下面以 Axios 为例演示拦截器逻辑。我们将 Tokens 保存在 localStorage 或者更安全的 [HttpOnly Cookie] 中(此处示例用 localStorage 方便演示)

// auth.js
import axios from 'axios';

// Base Axios 实例
const api = axios.create({
  baseURL: '/api',
});

// Token 存取
function getAccessToken() { return localStorage.getItem('access_token'); }
function getRefreshToken() { return localStorage.getItem('refresh_token'); }
function setTokens({ accessToken, refreshToken }) {
  localStorage.setItem('access_token', accessToken);
  localStorage.setItem('refresh_token', refreshToken);
}

// 请求拦截:自动附带 Access Token
api.interceptors.request.use(config => {
  const token = getAccessToken();
  if (token) config.headers['Authorization'] = `Bearer ${token}`;
  return config;
});

// 响应拦截:遇到 401 刷新并重试
let isRefreshing = false;
let subscribers = [];

function onRefreshed(newToken) {
  subscribers.forEach(cb => cb(newToken));
  subscribers = [];
}

function addSubscriber(cb) {
  subscribers.push(cb);
}

api.interceptors.response.use(
  res => res,
  error => {
    const { config, response } = error;
    if (response && response.status === 401 && !config._retry) {
      if (isRefreshing) {
        // 正在刷新,加入队列
        return new Promise(resolve => {
          addSubscriber(token => {
            config.headers['Authorization'] = `Bearer ${token}`;
            resolve(api(config));
          });
        });
      }
      config._retry = true;
      isRefreshing = true;
      // 调用刷新接口
      return api.post('/auth/refresh', { refreshToken: getRefreshToken() })
        .then(res => {
          const { accessToken, refreshToken } = res.data;
          setTokens({ accessToken, refreshToken });
          isRefreshing = false;
          onRefreshed(accessToken);
          // 重试原请求
          config.headers['Authorization'] = `Bearer ${accessToken}`;
          return api(config);
        })
        .catch(err => {
          // 刷新失败,跳转登录
          isRefreshing = false;
          window.location.href = '/login';
          return Promise.reject(err);
        });
    }
    return Promise.reject(error);
  }
);

export default api;

要点说明

isRefreshingsubscribers 用于解决多个并发 401 时只发送一次刷新请求;
_retry 标记避免无限循环;
刷新失败后,需清除本地登录态并跳转到登录页。

后端实现

  1. 添加依赖

    <dependencies>
      <!-- Spring Boot Starter Web -->
      <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
      </dependency>
      <!-- MyBatis-Plus -->
      <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.5.3.1</version>
      </dependency>
      <!-- MySQL 驱动 -->
      <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
      </dependency>
      <!-- Redis -->
      <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
      </dependency>
      <!-- JWT -->
      <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>
    </dependencies>
    
  2. 数据库与实体(存储用户可选)

    这里就简单模拟用户,仅有用户名和密码为例

    -- 用户表(简化)
    CREATE TABLE user_account (
      id BIGINT PRIMARY KEY AUTO_INCREMENT,
      username VARCHAR(50) UNIQUE NOT NULL,
      password VARCHAR(255) NOT NULL
    );
    
  3. Redis 存储 Refresh Token

    我们用 ·Redis· 的 String,Key 为 refresh:{userId},Value 存 JSON { token, expireTime }

  4. JWT工具类

    // JwtUtil.java
    @Component
    public class JwtUtil {
        @Value("${jwt.secret}") private String secret;
        @Value("${jwt.access.expire}") private long accessExpire;   // ms
        @Value("${jwt.refresh.expire}") private long refreshExpire; // ms
    
        // 生成 Access Token(短期)
        public String generateAccessToken(Long userId) {
            return Jwts.builder()
                .setSubject(userId.toString())
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + accessExpire))
                .signWith(Keys.hmacShaKeyFor(secret.getBytes()))
                .compact();
        }
        // 生成 Refresh Token(长期)
        public String generateRefreshToken(Long userId) {
            return Jwts.builder()
                .setSubject(userId.toString())
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + refreshExpire))
                .signWith(Keys.hmacShaKeyFor(secret.getBytes()))
                .compact();
        }
        // 解析 Token
        public Claims parseToken(String token) {
            return Jwts.parserBuilder()
                .setSigningKey(secret.getBytes())
                .build()
                .parseClaimsJws(token)
                .getBody();
        }
    }
    
  5. 刷新服务

    // AuthService.java
    @Service
    public class AuthService {
        @Autowired private JwtUtil jwtUtil;
        @Autowired private StringRedisTemplate redis;
    
        public Tokens login(String username, String password) {
            // 1. 验证用户名密码(略,用 MyBatis-Plus 查询)
            Long userId = /* ... */;
    
            // 2. 生成双 Token
            String accessToken  = jwtUtil.generateAccessToken(userId);
            String refreshToken = jwtUtil.generateRefreshToken(userId);
    
            // 3. 保存到 Redis
            String key = "refresh:" + userId;
            redis.opsForValue().set(key, refreshToken, jwtUtil.getRefreshExpire(), TimeUnit.MILLISECONDS);
    
            return new Tokens(accessToken, refreshToken);
        }
    
        public Tokens refresh(String refreshToken) {
            // 1. 解析
            Claims claims = jwtUtil.parseToken(refreshToken);
            Long userId = Long.parseLong(claims.getSubject());
    
            // 2. Redis 校验
            String key = "refresh:" + userId;
            String cached = redis.opsForValue().get(key);
            if (cached == null || !cached.equals(refreshToken)) {
                throw new RuntimeException("Refresh Token 无效或已过期");
            }
    
            // 3. 生成新 Token
            String newAccess  = jwtUtil.generateAccessToken(userId);
            String newRefresh = jwtUtil.generateRefreshToken(userId);
    
            // 4. 覆盖 Redis
            redis.opsForValue().set(key, newRefresh, jwtUtil.getRefreshExpire(), TimeUnit.MILLISECONDS);
    
            return new Tokens(newAccess, newRefresh);
        }
    }
    
  6. 控制器Controller

    // AuthController.java
    @RestController
    @RequestMapping("/api/auth")
    public class AuthController {
        @Autowired private AuthService authService;
    
        @PostMapping("/login")
        public Tokens login(@RequestBody LoginReq req) {
            return authService.login(req.getUsername(), req.getPassword());
        }
    
        @PostMapping("/refresh")
        public Tokens refresh(@RequestBody Map<String,String> body) {
            return authService.refresh(body.get("refreshToken"));
        }
    }
    
    // DTOs
    @Data
    class LoginReq { private String username, password; }
    
    @Data
    @AllArgsConstructor
    class Tokens { 
        private String accessToken; 
        private String refreshToken; 
    }
    
  7. JWT 验证过滤器

    由于验证并非本文的重点,小伙伴们可以参考博主的 《Spring Security》专栏学习,这里仅提供思路:
    在每次请求拦截中,解析 Access Token 并将用户信息放入 SecurityContext,若过期则交由前端刷新逻辑处理。

posted @ 2025-09-22 22:11  小郑[努力版]  阅读(10)  评论(0)    收藏  举报