双Token无感刷新机制实现方式
作者:Hhzzy99
双Token无感刷新机制实现
在现代 Web 应用开发中,前后端分离已经成为一种趋势。Vue.js 作为前端框架,Java作为后端语言的组合被广泛应用。在用户认证方面,JWT因为其无状态、易于扩展等特点也备受青睐。
本文将详细介绍如何在 Vue 前端和 Java后端实现双 Token 的无感刷新机制。
后端依赖
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <!-- 其他依赖 --> </dependencies>
安全配置
配置Jwt过滤器,以及认证失败过滤器。
@Configuration @EnableWebSecurity public class SecurityConfig { /** * 认证失败处理类 */ @Autowired private AuthenticationEntryPointImpl unauthorizedHandler; /** * Jwt过滤器 */ @Autowired private JwtAuthenticationFilter jwtAuthenticationFilter; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf().disable() .cors().and() .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .headers().cacheControl().disable().and() .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() .authorizeRequests() .antMatchers("/user/login", "/user/forgetPassword/**", "/user/sendUpdatePasswordEmailCode/**", "/user/register", "/swagger-ui.html", "/user/sendEmailLoginCode", "/user/verifyEmailLoginCode/**").permitAll() .antMatchers("/admin/**").hasRole("ADMIN") .anyRequest().authenticated() .and() .headers().frameOptions().disable(); return http.build(); } @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); } // 使用BCryptPasswordEncoder作为security默认的passwordEncoder @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
Jwt过滤器 *
这个过滤器是实现token刷新机制的核心,每次前端的请求携带accessToken与refreshToken过来,此过滤器拿到之后,先对accessToken进行解析,如果解析失败(过期),那么接下来会对refreshToken进行解析,解析完成之后,如果没有过期,就会生成新的accessToken与refreshToken返回给前端,并且设置一个新的请求头Token-Refreshed,值可以随便设,前端能拿到就好。
package com.hblog.backend.config; import com.alibaba.fastjson2.JSON; import com.fasterxml.jackson.databind.ObjectMapper; import com.hblog.backend.entity.LoginUser; import com.hblog.backend.entity.User; import com.hblog.backend.exception.BusinessException; import com.hblog.backend.exception.EnumException; import com.hblog.backend.mapper.IUserMapper; import com.hblog.backend.response.CommonResponse; import com.hblog.backend.utli.*; import io.jsonwebtoken.Claims; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; 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.HashMap; import java.util.Map; import java.util.Objects; import java.util.concurrent.TimeUnit; /** * @ClassName: JwtAuthenticationFilter * @author: Hhzzy99 * @date: 2024/3/17 16:09 * description:继承每个请求只会经过一次的过滤器 */ @Component @Slf4j public class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private RedisCache redisCache; @Value("${token.expiration}") private Long expiration; @Autowired private IUserMapper userMapper; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 获取当前请求路径 String requestPath = request.getRequestURI(); // 排除不需要过滤的路径 if (requestPath.equals("/user/login") || requestPath.equals("/user/register")) { filterChain.doFilter(request, response); return; } // 获取token String accessToken = request.getHeader("access_token"); String refreshToken = request.getHeader("refresh_token"); if ("null".equals(accessToken) || "".equals(accessToken) || "undefined".equals(accessToken) || null == accessToken) { // 放行 filterChain.doFilter(request, response); return; } // 解析token String userId = ""; boolean isRefresh = false; try { userId = JwtUtils.parseJWT(accessToken).getSubject(); } catch (Exception e) { isRefresh = true; e.printStackTrace(); } if (isRefresh) { try { userId = JwtUtils.parseJWT(refreshToken).getSubject(); accessToken = JwtUtils.createJWT(userId); refreshToken = JwtUtils.createRefreshToken(userId); User loginUser = userMapper.getUserById(Long.valueOf(userId)); Integer ttl = expiration.intValue() / 1000; log.warn("@@@@@@@@@@@@@@@@@@刷新token@@@@@@@@@@@@@@@@@@@@@"); redisCache.setCacheObject("userInfo:" + userId, loginUser, ttl, TimeUnit.SECONDS); writeTokenResponse(response, accessToken, refreshToken); return; } catch (Exception e1) { throw new BusinessException(EnumException.THE_LOGIN_HAS_EXPIRED); } } // 从redis里面获取用户信息 User loginUser = redisCache.getCacheObject("userInfo:" + userId); if (Objects.isNull(loginUser)) { throw new BusinessException(EnumException.THE_LOGIN_HAS_EXPIRED); } // 存入SecurityContextHolder UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, null); SecurityContextHolder.getContext().setAuthentication(authenticationToken); // 放行 filterChain.doFilter(request, response); } private void writeTokenResponse(HttpServletResponse response, String accessToken, String refreshToken) throws IOException { Map<String, String> tokenMap = new HashMap<>(); tokenMap.put("accessToken", accessToken); tokenMap.put("refreshToken", refreshToken); Map<String, String> headers = new HashMap<>(); headers.put("Token-Refreshed", "true"); CommonResponse<Map<String, String>> commonResponse = new CommonResponse<>(200, "Token refreshed successfully", tokenMap); WebUtil.renderString(response, headers, JSON.toJSONString(commonResponse)); } }
前端的配置
前端对所有的axios请求进行全局配置,先在每次请求的时候设置好请求头accessToken与refreshToken,并且将每次请求都保存起来,如果在请求时后端解析到accessToken失效,并且返回了新的accessToken与refreshToken,在请求头拿到了后端设置好的Token-Refreshed,此时就可以重新将新的accessToken与refreshToken保存在浏览器本地,并且重新发送之前保存好的请求,就可以实现无感刷新。
request.js
import axios from 'axios' import {ref} from "vue"; // create an axios instance const service = axios.create({ baseURL: '/api', // url = base url + request url timeout: 20000 // request timeout }) const retryRequest = ref(null) // request interceptor service.interceptors.request.use( config => { // 加入头信息配置 if (localStorage.getItem("access_token") !== null && localStorage.getItem("access_token") !== undefined){ config.headers['access_token'] = localStorage.getItem("access_token") } if (localStorage.getItem("refresh_token") !== null && localStorage.getItem("refresh_token") !== undefined){ config.headers['refresh_token'] = localStorage.getItem("refresh_token") } retryRequest.value = config return config } ) // response interceptor service.interceptors.response.use( response => { if (response.headers['token-refreshed']) { console.log('Token刷新成功'); // 如果有Token-Refreshed头部,更新本地存储中的Token localStorage.setItem('access_token', response.data.data.accessToken); localStorage.setItem('refresh_token', response.data.data.refreshToken); console.log("继续") // 继续发送原始请求 return axios(retryRequest.value) } return response; }, async error => { const originalRequest = error.config; // 如果是Token过期导致的401错误,并且没有retry标记,尝试刷新Token if (error.response.status === 401 && !originalRequest._retry) { originalRequest._retry = true; const refreshToken = localStorage.getItem('refresh_token'); if (refreshToken) { try { const response = await axios.post('/refresh-token', { refreshToken }); const { accessToken, refreshToken: newRefreshToken } = response.data.data; localStorage.setItem('access_token', accessToken); localStorage.setItem('refresh_token', newRefreshToken); // 更新原始请求的Authorization头部 originalRequest.headers['access_token'] = accessToken; // 重新发送原始请求 return instance(originalRequest); } catch (refreshError) { // 刷新Token失败,跳转到登录页或执行其他处理 console.error('Token刷新失败:', refreshError); // 这里可以跳转到登录页或者执行其他处理 } } } return Promise.reject(error); } ) export default service
通过以上步骤,我们就可以实现双 Token 无感刷新机制。该机制通过短期有效的访问 Token 和长期有效的刷新 Token 相结合,在 Token 过期时自动刷新。
本示例仅展示了基础的实现方式,实际生产环境中还需要考虑更多安全性和健壮性的问题。
总结
以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。