前端与 Spring Boot 后端无感 Token 刷新 – 从原理到全栈实践

前端与 Spring Boot 后端无感 Token 刷新 – 从原理到全栈实践(2026年最佳实践)

在2026年,JWT(JSON Web Token) + Refresh Token 的组合仍是前后端分离架构中最主流的认证方案。其中,“无感 Token 刷新”是指用户在使用过程中,Access Token 过期时,前端自动用 Refresh Token 换取新 Access Token,而不打断用户操作(如跳转登录页)。

这套方案的核心是双 Token 机制 + 前端拦截器,能有效解决 Token 过期问题,同时提升用户体验。下面从原理、后端实现(Spring Boot 3.x)、前端实现(React 示例)、全栈集成实践四个维度系统拆解。所有代码基于2026年主流版本(Spring Boot 3.3+ / React 18+),并注重安全性(如 HTTPS、Secure Cookie)。

一、原理详解(双 Token 机制 + 无感刷新流程)

核心概念

  • Access Token:短期有效(e.g., 15~30分钟),用于 API 认证。包含用户ID、角色等信息(JWT 格式)。
  • Refresh Token:长期有效(e.g., 7~30天),仅用于刷新 Access Token。存储在 HttpOnly Cookie 中(防 XSS),不暴露给 JS。
  • 无感刷新:前端 Axios/Fetch 拦截器检测 401 响应 → 自动用 Refresh Token 请求新 Access Token → 重发原请求。

刷新流程图(文字版)

1. 用户登录 → 后端返回 Access Token (Header) + Refresh Token (HttpOnly Cookie)
2. 前端请求 API → 带 Access Token (Header: Authorization: Bearer xxx)
3. 后端验证 Access Token:
   - 有效 → 处理请求
   - 过期 → 返回 401 Unauthorized
4. 前端拦截 401 → 检查是否有 Refresh Token (Cookie) → 用 Refresh Token 请求 /refresh API
5. 后端验证 Refresh Token:
   - 有效 → 返回新 Access Token + 新 Refresh Token (可选: 滚动刷新)
   - 无效 → 返回 401 → 前端跳转登录
6. 前端拿到新 Access Token → 存储 (localStorage 或内存) → 重发原请求
7. 用户无感知,继续操作

优缺点表格(2026年视角)

维度优点缺点/风险缓解策略(企业实践)
安全性Refresh Token 不暴露 JS,防 XSS/CSRFCSRF 攻击可能(Cookie 自动发送)CSRF Token + SameSite=Strict Cookie
用户体验无需手动登录,操作流畅刷新失败需处理(如网络问题)前端加重试机制 + Loading 提示
性能刷新开销小(O(1) 查询)高并发下 Refresh API 压力大Redis 缓存 Token + 限流 (Sentinel)
兼容性支持移动端/H5/AppCookie 跨域问题CORS 配置 + 域名一致

关键安全点:Refresh Token 存数据库/Redis(带过期时间),Access Token 无状态(JWT 自验证)。避免黑白名单复杂,用 UUID + 用户ID 绑定。

二、后端实现(Spring Boot 3.x + Spring Security 6.x + JWT)

依赖准备(pom.xml 片段)

<dependencies>
    <!-- Spring Security + JWT -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.12.3</version>  <!-- 2026主流版 -->
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.12.3</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.12.3</version>
        <scope>runtime</scope>
    </dependency>
    <!-- Redis(存储 Refresh Token) -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
</dependencies>

配置类(SecurityConfig.java + JwtUtil.java)

// JwtUtil.java - JWT 生成/验证工具
import io.jsonwebtoken.*;
import java.util.Date;
import java.util.UUID;

public class JwtUtil {
    private static final String SECRET_KEY = "your-secret-key-2026";  // 生产用环境变量
    private static final long ACCESS_EXPIRATION = 30 * 60 * 1000;     // 30min
    private static final long REFRESH_EXPIRATION = 7 * 24 * 60 * 60 * 1000;  // 7天

    public static String generateAccessToken(String userId) {
        return Jwts.builder()
                .subject(userId)
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + ACCESS_EXPIRATION))
                .signWith(Keys.hmacShaKeyFor(SECRET_KEY.getBytes()))
                .compact();
    }

    public static String generateRefreshToken() {
        return UUID.randomUUID().toString();  // 简单UUID,生产加盐
    }

    public static String getUserIdFromToken(String token) {
        return Jwts.parser()
                .verifyWith(Keys.hmacShaKeyFor(SECRET_KEY.getBytes()))
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .getSubject();
    }

    public static boolean validateToken(String token) {
        try {
            getUserIdFromToken(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }
}
// SecurityConfig.java - 核心配置
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())  // 前后分离关闭CSRF,或用CSRF Token
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()  // 登录/刷新公开
                .anyRequest().authenticated()
            )
            .addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);  // 自定义JWT过滤器

        return http.build();
    }
}

自定义JWT过滤器(JwtAuthenticationFilter.java)

import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

public class JwtAuthenticationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws Exception {
        String token = request.getHeader("Authorization");
        if (token != null && token.startsWith("Bearer ")) {
            token = token.substring(7);
            if (JwtUtil.validateToken(token)) {
                String userId = JwtUtil.getUserIdFromToken(token);
                // 设置认证上下文
                UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(userId, null, null);
                SecurityContextHolder.getContext().setAuthentication(auth);
            }
        }
        filterChain.doFilter(request, response);
    }
}

认证控制器(AuthController.java)

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import java.util.concurrent.TimeUnit;

@RestController
public class AuthController {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    // 登录接口
    @PostMapping("/api/auth/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest req, HttpServletResponse res) {
        // 模拟验证用户名密码
        if ("user".equals(req.getUsername()) && "pass".equals(req.getPassword())) {
            String userId = "user-123";  // 从DB取
            String accessToken = JwtUtil.generateAccessToken(userId);
            String refreshToken = JwtUtil.generateRefreshToken();

            // 存 Refresh Token 到 Redis(key: refreshToken, value: userId, 过期7天)
            redisTemplate.opsForValue().set(refreshToken, userId, 7, TimeUnit.DAYS);

            // Set Cookie: HttpOnly + Secure + SameSite
            Cookie cookie = new Cookie("refreshToken", refreshToken);
            cookie.setHttpOnly(true);
            cookie.setSecure(true);  // HTTPS only
            cookie.setPath("/");
            cookie.setMaxAge(7 * 24 * 60 * 60);
            cookie.setAttribute("SameSite", "Strict");
            res.addCookie(cookie);

            return ResponseEntity.ok(new TokenResponse(accessToken));
        }
        return ResponseEntity.status(401).body("Invalid credentials");
    }

    // 刷新接口
    @PostMapping("/api/auth/refresh")
    public ResponseEntity<?> refresh(HttpServletRequest req, HttpServletResponse res) {
        // 从 Cookie 取 Refresh Token
        String refreshToken = null;
        Cookie[] cookies = req.getCookies();
        if (cookies != null) {
            for (Cookie c : cookies) {
                if ("refreshToken".equals(c.getName())) {
                    refreshToken = c.getValue();
                    break;
                }
            }
        }

        if (refreshToken == null) {
            return ResponseEntity.status(401).body("No refresh token");
        }

        // Redis 验证 Refresh Token
        String userId = redisTemplate.opsForValue().get(refreshToken);
        if (userId != null) {
            // 生成新 Access Token
            String newAccessToken = JwtUtil.generateAccessToken(userId);

            // 可选: 滚动刷新 - 生成新 Refresh Token,删除旧的
            String newRefreshToken = JwtUtil.generateRefreshToken();
            redisTemplate.delete(refreshToken);
            redisTemplate.opsForValue().set(newRefreshToken, userId, 7, TimeUnit.DAYS);

            Cookie newCookie = new Cookie("refreshToken", newRefreshToken);
            // 同上设置属性
            newCookie.setHttpOnly(true);
            newCookie.setSecure(true);
            newCookie.setPath("/");
            newCookie.setMaxAge(7 * 24 * 60 * 60);
            newCookie.setAttribute("SameSite", "Strict");
            res.addCookie(newCookie);

            return ResponseEntity.ok(new TokenResponse(newAccessToken));
        }

        return ResponseEntity.status(401).body("Invalid refresh token");
    }
}

// LoginRequest & TokenResponse 类(简单DTO)
class LoginRequest {
    private String username;
    private String password;
    // getter/setter
}

class TokenResponse {
    private String accessToken;
    public TokenResponse(String accessToken) { this.accessToken = accessToken; }
    // getter
}

后端注意:生产加异常处理、日志、限流。Redis Key 用前缀如 “refresh:” + token。

三、前端实现(React + Axios 示例)

依赖准备(package.json 片段)

"dependencies": {
  "axios": "^1.6.0",  // 2026主流
  "js-cookie": "^3.0.5"  // 操作Cookie(但Refresh Token HttpOnly,JS不可读,只需发请求)
}

Axios 拦截器(api.js)

import axios from 'axios';
import Cookies from 'js-cookie';

const api = axios.create({
  baseURL: '/api',
  withCredentials: true,  // 携带Cookie
});

let isRefreshing = false;  // 防止并发刷新
let failedQueue = [];      // 失败请求队列

const processQueue = (error) => {
  failedQueue.forEach((prom) => {
    if (error) {
      prom.reject(error);
    } else {
      prom.resolve();
    }
  });
  failedQueue = [];
};

// 请求拦截器:加 Access Token
api.interceptors.request.use((config) => {
  const token = localStorage.getItem('accessToken');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// 响应拦截器:处理 401 无感刷新
api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        // 已在刷新,加入队列等待
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        })
          .then(() => api(originalRequest))
          .catch((err) => Promise.reject(err));
      }

      originalRequest._retry = true;
      isRefreshing = true;

      try {
        // 请求刷新(自动带 Cookie 中的 Refresh Token)
        const { data } = await api.post('/auth/refresh');
        localStorage.setItem('accessToken', data.accessToken);

        // 重发队列中所有请求
        processQueue(null);

        return api(originalRequest);
      } catch (refreshError) {
        processQueue(refreshError);
        localStorage.removeItem('accessToken');
        // 跳转登录
        window.location.href = '/login';
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

export default api;

登录组件(Login.jsx 示例)

import React, { useState } from 'react';
import api from './api';

function Login() {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');

  const handleLogin = async () => {
    try {
      const { data } = await api.post('/auth/login', { username, password });
      localStorage.setItem('accessToken', data.accessToken);
      // 跳转首页
      window.location.href = '/home';
    } catch (err) {
      console.error('登录失败');
    }
  };

  return (
    <div>
      <input value={username} onChange={(e) => setUsername(e.target.value)} />
      <input value={password} onChange={(e) => setPassword(e.target.value)} type="password" />
      <button onClick={handleLogin}>登录</button>
    </div>
  );
}

export default Login;

前端注意:Access Token 存 localStorage(易 XSS),生产可存内存或 IndexedDB。Refresh Token HttpOnly,JS 无需操作。

四、全栈实践 & 测试指南

  1. 启动后端:Spring Boot 运行,配置 Redis(localhost:6379)。
  2. 启动前端:React App,代理到后端端口(vite.config.js 加 proxy)。
  3. 测试流程
  • 登录:POST /api/auth/login → 得 Access Token + Cookie(Refresh Token)
  • 访问保护 API:GET /api/protected → 如过期,自动刷新
  • 模拟过期:等30min,或手动改 Token
  1. 常见坑 & 优化
  • 并发刷新:用 isRefreshing + 队列防重复请求。
  • 注销:删除 Redis Refresh Token + 清 localStorage + 删 Cookie。
  • 移动端:App 用 Secure Storage 存 Token。
  • 高级:集成 OAuth2 或 Spring Security OAuth2 Client。

这套方案在2026年已非常成熟,适用于中大型 Web/App 项目。如果你用 Vue/Angular,前端拦截器类似(Vue 用 Axios,Angular 用 HttpInterceptor)。

有具体问题(如代码报错、Vue 版本)?或想加功能(如滑动过期)?告诉我,我继续细化~

文章已创建 3806

发表回复

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

相关文章

开始在上面输入您的搜索词,然后按回车进行搜索。按ESC取消。

返回顶部