token无感刷新全流程

demo代码:Arata08/tokenDemo

Java (Spring Boot) 和 Vue (配合 Axios) 前端的无感刷新全流程:

方案:

  1. 双 Token 机制 : 服务器同时签发两个 Token:
    • access_token: 短时效,用于日常接口访问鉴权。
    • refresh_token: 长时效,非常安全,仅用于获取新的 access_token
  2. 前端拦截器 : Vue 前端使用 Axios 拦截器,在每个请求头中自动添加 access_token
  3. 后端校验 : Java 后端拦截器或过滤器校验 access_token 的有效性。
  4. 前端感知与刷新 :
    • 当后端返回特定状态码(如 401 Unauthorized)表示 access_token 过期时。
    • 前端不直接跳转登录页,而是发起一个专用的刷新 Token 接口 请求,并携带 refresh_token
    • 后端验证 refresh_token,如果有效,则生成新的 access_token(有时也更新 refresh_token)并返回给前端。
    • 前端收到新的 access_token 后,更新本地存储,并重试之前失败的请求。

流程总结

  1. 用户登录成功,后端返回 access_tokenrefresh_token,前端存储。
  2. 前端每次 API 请求时,通过 Axios 请求拦截器自动在 Authorization 头中添加 Bearer access_token
  3. 后端通过过滤器验证 access_token
  4. access_token 过期,后端返回 401。
  5. 前端 Axios 响应拦截器捕获 401 错误。
  6. 拦截器发起 /auth/refresh 请求,携带 refresh_token
  7. 后端验证 refresh_token,成功则返回新的 access_token
  8. 前端更新本地 access_token
  9. 拦截器自动重试之前失败的请求(使用新 access_token)。
  10. 如果 refresh_token 也失败或不存在,则清除本地 Token,跳转登录页。

后端 (Java - Spring Boot) 实现

登录接口 : 登录成功后返回 access_tokenrefresh_token

java 复制代码
@RestController
@RequestMapping("/api/auth")
public class AuthController {

    @Autowired
    private JwtUtil jwtUtil;

    // 模拟用户验证逻辑
    private boolean validateUser(String username, String password) {
        // 实际应用中应查询数据库
        return "admin".equals(username) && "password".equals(password);
    }

    @PostMapping("/login")
    public ResponseEntity<Map<String, String>> login(@RequestBody Map<String, String> loginRequest) {
        String username = loginRequest.get("username");
        String password = loginRequest.get("password");

        if (validateUser(username, password)) {
            String accessToken = jwtUtil.generateAccessToken(username);
            String refreshToken = jwtUtil.generateRefreshToken(username);

            Map<String, String> response = new HashMap<>();
            response.put("access_token", accessToken);
            response.put("refresh_token", refreshToken);
            response.put("token_type", "Bearer");

            return ResponseEntity.ok(response);
        } else {
            return ResponseEntity.status(401).body(null); // 或返回错误信息
        }
    }
}

刷新 Token 接口 : 接收 refresh_token,验证后返回新的 access_token

java 复制代码
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        String authHeader = request.getHeader("Authorization");

        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7); // 移除 "Bearer " 前缀
            try {
                Claims claims = jwtUtil.parseAccessToken(token);
                // 可以将用户信息存入 SecurityContext 或 Request Attributes
                String username = claims.getSubject();
                // SecurityContextHolder.getContext().setAuthentication(...);
                request.setAttribute("currentUser", username);
            } catch (RuntimeException e) {
                // Token 无效,返回 401
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                response.getWriter().write("{\"error\":\"Unauthorized\"}");
                return; // 不继续执行后续过滤器/控制器
            }
        }
        // 如果没有 Token 或 Token 有效,继续执行
        filterChain.doFilter(request, response);
    }
}

Token 工具类: 用于生成、解析、验证 JWT

java 复制代码
@Component
public class JwtUtil {

    // 注意:生产中应从配置文件读取,并且密钥要足够复杂且保密
    private final String ACCESS_TOKEN_SECRET = "your_very_strong_access_token_secret_here";
    private final String REFRESH_TOKEN_SECRET = "your_very_strong_refresh_token_secret_here";
    private final long ACCESS_TOKEN_EXPIRATION = 30 * 60 * 1000; // 30分钟
    private final long REFRESH_TOKEN_EXPIRATION = 7 * 24 * 60 * 60 * 1000; // 7天

    private SecretKey getAccessTokenKey() {
        return Keys.hmacShaKeyFor(ACCESS_TOKEN_SECRET.getBytes());
    }

    private SecretKey getRefreshTokenKey() {
        return Keys.hmacShaKeyFor(REFRESH_TOKEN_SECRET.getBytes());
    }

    // 生成 Access Token
    public String generateAccessToken(String username) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + ACCESS_TOKEN_EXPIRATION);

        return Jwts.builder()
                .setSubject(username)
                .setIssuedAt(new Date())
                .setExpiration(expiryDate)
                .signWith(getAccessTokenKey())
                .compact();
    }

    // 生成 Refresh Token
    public String generateRefreshToken(String username) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + REFRESH_TOKEN_EXPIRATION);

        return Jwts.builder()
                .setSubject(username)
                .setIssuedAt(new Date())
                .setExpiration(expiryDate)
                .signWith(getRefreshTokenKey())
                .compact();
    }

    // 解析 Access Token
    public Claims parseAccessToken(String token) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(getAccessTokenKey())
                    .build()
                    .parseClaimsJws(token)
                    .getBody();
        } catch (JwtException | IllegalArgumentException e) {
            // Token 无效或过期
            throw new RuntimeException("Invalid or expired access token", e);
        }
    }

    // 解析 Refresh Token
    public Claims parseRefreshToken(String token) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(getRefreshTokenKey())
                    .build()
                    .parseClaimsJws(token)
                    .getBody();
        } catch (JwtException | IllegalArgumentException e) {
            // Token 无效或过期
            throw new RuntimeException("Invalid or expired refresh token", e);
        }
    }

    // 验证 Access Token 是否过期
    public boolean isAccessTokenExpired(String token) {
        try {
            Claims claims = parseAccessToken(token);
            Date expiration = claims.getExpiration();
            return expiration.before(new Date());
        } catch (JwtException e) {
            return true; // 解析失败也视为过期
        }
    }
}

JWT 拦截器/过滤器 : 在访问需要认证的接口前,验证 access_token

java 复制代码
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        String authHeader = request.getHeader("Authorization");

        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7); // 移除 "Bearer " 前缀
            try {
                Claims claims = jwtUtil.parseAccessToken(token);
                // 可以将用户信息存入 SecurityContext 或 Request Attributes
                String username = claims.getSubject();
                // SecurityContextHolder.getContext().setAuthentication(...);
                request.setAttribute("currentUser", username);
            } catch (RuntimeException e) {
                // Token 无效,返回 401
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                response.getWriter().write("{\"error\":\"Unauthorized\"}");
                return; // 不继续执行后续过滤器/控制器
            }
        }
        // 如果没有 Token 或 Token 有效,继续执行
        filterChain.doFilter(request, response);
    }
}

前端 (Vue - Axios) 实现

Axios 配置: 创建 Axios 实例,并配置请求和响应拦截器。

javascript 复制代码
import axios from 'axios';

const API_BASE_URL = 'http://localhost:8080/api'; // 替换为你的后端地址

const apiClient = axios.create({
    baseURL: API_BASE_URL,
    timeout: 10000,
});

// 请求拦截器:在每个请求头中添加 access_token
apiClient.interceptors.request.use(
    (config) => {
        const accessToken = localStorage.getItem('access_token'); // 或 sessionStorage
        if (accessToken) {
            config.headers.Authorization = `Bearer ${accessToken}`;
        }
        return config;
    },
    (error) => {
        return Promise.reject(error);
    }
);

let isRefreshing = false;
let failedQueue = [];

const processQueue = (error, token = null) => {
    failedQueue.forEach(prom => {
        if (error) {
            prom.reject(error);
        } else {
            prom.resolve(token);
        }
    });

    failedQueue = [];
};

// 响应拦截器:处理 401 错误并尝试刷新 token
apiClient.interceptors.response.use(
    (response) => {
        // 对响应数据做点什么
        return 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(token => {
                    originalRequest.headers['Authorization'] = 'Bearer ' + token;
                    return apiClient(originalRequest);
                }).catch(err => {
                    return Promise.reject(err);
                });
            }

            originalRequest._retry = true; // 标记为已重试,防止无限循环
            isRefreshing = true;

            const refreshToken = localStorage.getItem('refresh_token'); // 或 sessionStorage

            if (!refreshToken) {
                // 如果没有 refresh_token,直接跳转登录
                // router.push('/login');
                // 或者触发登出逻辑
                localStorage.removeItem('access_token');
                localStorage.removeItem('refresh_token');
                window.location.href = '/login'; // 简单示例
                return Promise.reject(error);
            }

            try {
                const refreshResponse = await apiClient.post('/auth/refresh', {}, {
                    headers: {
                        Authorization: `Bearer ${refreshToken}`,
                    }
                });

                const newAccessToken = refreshResponse.data.access_token;
                // const newRefreshToken = refreshResponse.data.refresh_token; // 如果后端也返回了新的 refresh_token

                // 更新本地存储的 token
                localStorage.setItem('access_token', newAccessToken);
                // localStorage.setItem('refresh_token', newRefreshToken); // 如果更新了

                // 更新正在请求的 axios 实例的 header
                apiClient.defaults.headers.common['Authorization'] = `Bearer ${newAccessToken}`;

                // 重试原始请求
                originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;
                processQueue(null, newAccessToken);
                return apiClient(originalRequest);

            } catch (refreshError) {
                console.error('Token refresh failed:', refreshError);
                processQueue(refreshError, null);
                // Refresh token 也失败了,登出用户
                localStorage.removeItem('access_token');
                localStorage.removeItem('refresh_token');
                window.location.href = '/login'; // 简单示例
                return Promise.reject(refreshError);
            } finally {
                isRefreshing = false;
            }
        }

        return Promise.reject(error);
    }
);

export default apiClient;
相关推荐
QT 小鲜肉2 小时前
【C++基础与提高】第十一章:面向对象编程进阶——继承与多态
java·linux·开发语言·c++·笔记·qt
aerror2 小时前
将sqlite3的表转成excel表
java·sqlite·excel
仟濹2 小时前
IntelliJ IDEA 快捷键 + 实时模板
java·intellij-idea
洛_尘2 小时前
数据结构--6:优先级队列(堆)
java·数据结构
大飞哥~BigFei3 小时前
RabbitMq消费消息遇到的坑
java·rabbitmq·java-rabbitmq
隐形喷火龙3 小时前
Springboot集成OnlyOffice
java·spring boot·后端
5pace3 小时前
【SSM|第一篇】MyBatisPlus
java·spring boot·后端·mybatis
JosieBook3 小时前
【SpringBoot】37 核心功能 - 高级特性- Spring Boot 中的 自定义 Starter 完整教程
java·spring boot·后端