SpringBoot中雙token實現(xiàn)無感刷新
一、方案說明
1. 核心流程
- ?用戶登錄?
- 提交賬號密碼 → 服務(wù)端驗證 → 返回Access Token(前端存儲) + Refresh Token(HttpOnly Cookie)
- ?業(yè)務(wù)請求?
- 請求頭攜帶Access Token → 服務(wù)端驗證有效性 → 有效則返回數(shù)據(jù)
- ?Token過期處理?
- 若Access Token過期 → 前端攔截401錯誤 → 自動用Refresh Token請求新Token → 刷新后重試原請求
- ?Refresh Token失效?
- 清除登錄態(tài) → 跳轉(zhuǎn)登錄頁
2. 安全設(shè)計
- ?Access Token?
- 存儲:前端內(nèi)存(如Vuex/Redux)或
sessionStorage
- 有效期:2小時
- 傳輸:
Authorization: Bearer <token>
- 存儲:前端內(nèi)存(如Vuex/Redux)或
- ?Refresh Token?
- 存儲:
HttpOnly + Secure + SameSite=Strict
Cookie - 有效期:7天
- 刷新機制:單次使用后更新,舊Token立即失效
- 存儲:
二、前端實現(xiàn)(React示例)
1. Axios封裝(src/utils/http.js)
import axios from 'axios'; const http = axios.create({ baseURL: process.env.REACT_APP_API_URL, }); // 請求攔截器:注入Access Token http.interceptors.request.use(config => { const accessToken = sessionStorage.getItem('access_token'); if (accessToken) { config.headers.Authorization = `Bearer ${accessToken}`; } return config; }); // 響應(yīng)攔截器:處理Token過期 http.interceptors.response.use( response => response, async error => { const originalRequest = error.config; // 檢測401錯誤且未重試過 if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; try { // 發(fā)起刷新Token請求 const { accessToken } = await refreshToken(); // 存儲新Token sessionStorage.setItem('access_token', accessToken); // 重試原請求 originalRequest.headers.Authorization = `Bearer ${accessToken}`; return http(originalRequest); } catch (refreshError) { // 刷新失?。呵宄齌oken,跳轉(zhuǎn)登錄 sessionStorage.removeItem('access_token'); window.location.href = '/login'; return Promise.reject(refreshError); } } return Promise.reject(error); } ); // 刷新Token函數(shù) async function refreshToken() { const res = await axios.post( `${process.env.REACT_APP_API_URL}/auth/refresh`, {}, { withCredentials: true } // 自動攜帶Cookie ); return res.data; } export default http;
2. 登錄邏輯(src/pages/Login.js)
const LoginPage = () => { const handleSubmit = async (e) => { e.preventDefault(); try { const res = await axios.post('/auth/login', { username: 'user', password: 'pass' }, { withCredentials: true }); // 存儲Access Token sessionStorage.setItem('access_token', res.data.accessToken); // 跳轉(zhuǎn)主頁 window.location.href = '/'; } catch (err) { alert('登錄失敗'); } }; return ( <form onSubmit={handleSubmit}> {/* 登錄表單 */} </form> ); };
三、后端實現(xiàn)(Spring Boot)
1. JWT工具類(JwtUtil.java)
@Component public class JwtUtil { @Value("${jwt.secret}") private String secret; @Value("${jwt.access.expiration}") private Long accessExpiration; @Value("${jwt.refresh.expiration}") private Long refreshExpiration; // 生成Access Token public String generateAccessToken(UserDetails user) { return buildToken(user, accessExpiration); } // 生成Refresh Token public String generateRefreshToken(UserDetails user) { return buildToken(user, refreshExpiration); } private String buildToken(UserDetails user, Long expiration) { return Jwts.builder() .setSubject(user.getUsername()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expiration)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } // 驗證Token public boolean validateToken(String token) { try { Jwts.parser().setSigningKey(secret).parseClaimsJws(token); return true; } catch (JwtException | IllegalArgumentException e) { throw new JwtException("Token驗證失敗"); } } // 從Token中提取用戶名 public String getUsernameFromToken(String token) { return Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody() .getSubject(); } }
2. 認證接口(AuthController.java)
@RestController @RequestMapping("/auth") public class AuthController { @Autowired private JwtUtil jwtUtil; @Autowired private UserDetailsService userDetailsService; @Autowired private RefreshTokenService refreshTokenService; // 登錄接口 @PostMapping("/login") public ResponseEntity<?> login(@RequestBody LoginRequest request) { UserDetails user = userDetailsService.loadUserByUsername(request.getUsername()); // 密碼驗證 if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { throw new BadCredentialsException("密碼錯誤"); } // 生成Token String accessToken = jwtUtil.generateAccessToken(user); String refreshToken = jwtUtil.generateRefreshToken(user); // 存儲Refresh Token refreshTokenService.saveRefreshToken(user.getUsername(), refreshToken); // 設(shè)置Refresh Token到Cookie ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken) .httpOnly(true) .secure(true) .sameSite("Strict") .maxAge(jwtUtil.getRefreshExpiration() / 1000) .path("/auth/refresh") .build(); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, cookie.toString()) .body(new AuthResponse(accessToken)); } // 刷新Token接口 @PostMapping("/refresh") public ResponseEntity<?> refreshToken(@CookieValue("refreshToken") String refreshToken) { // 驗證Refresh Token if (!jwtUtil.validateToken(refreshToken)) { throw new JwtException("無效Token"); } String username = jwtUtil.getUsernameFromToken(refreshToken); // 檢查是否與存儲的Token一致 if (!refreshTokenService.validateRefreshToken(username, refreshToken)) { throw new JwtException("Token已失效"); } // 生成新Token UserDetails user = userDetailsService.loadUserByUsername(username); String newAccessToken = jwtUtil.generateAccessToken(user); String newRefreshToken = jwtUtil.generateRefreshToken(user); // 更新存儲的Refresh Token refreshTokenService.updateRefreshToken(username, newRefreshToken); // 返回新Token ResponseCookie cookie = ResponseCookie.from("refreshToken", newRefreshToken) .httpOnly(true) .secure(true) .sameSite("Strict") .maxAge(jwtUtil.getRefreshExpiration() / 1000) .path("/auth/refresh") .build(); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, cookie.toString()) .body(new AuthResponse(newAccessToken)); } }
3. Refresh Token服務(wù)(RefreshTokenService.java)
@Service public class RefreshTokenService { @Autowired private RefreshTokenRepository repository; public void saveRefreshToken(String username, String token) { RefreshToken refreshToken = new RefreshToken(); refreshToken.setUsername(username); refreshToken.setToken(token); refreshToken.setExpiryDate(jwtUtil.getExpirationDateFromToken(token)); repository.save(refreshToken); } public boolean validateRefreshToken(String username, String token) { return repository.findByUsernameAndToken(username, token) .map(t -> t.getExpiryDate().after(new Date())) .orElse(false); } public void updateRefreshToken(String username, String newToken) { repository.deleteByUsername(username); saveRefreshToken(username, newToken); } }
四、安全配置(SecurityConfig.java)
@Configuration @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private JwtAuthenticationFilter jwtFilter; @Override protected void configure(HttpSecurity http) throws Exception { http .csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/auth/?**?").permitAll() .anyRequest().authenticated() .and() .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); } } @Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private JwtUtil jwtUtil; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { String header = request.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { String token = header.substring(7); if (jwtUtil.validateToken(token)) { String username = jwtUtil.getUsernameFromToken(token); UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(username, null, new ArrayList<>()); SecurityContextHolder.getContext().setAuthentication(auth); } } chain.doFilter(request, response); } }
五、配置參數(shù)(application.yml)
jwt: secret: "your-256-bit-secret-key-here" # 通過環(huán)境變量注入 access: expiration: 7200000 # 2小時(毫秒) refresh: expiration: 604800000 # 7天(毫秒)
六、數(shù)據(jù)庫表結(jié)構(gòu)(MySQL)
CREATE TABLE refresh_tokens ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(255) NOT NULL, token VARCHAR(512) NOT NULL, expiry_date DATETIME NOT NULL, UNIQUE KEY (username) );
此方案完整實現(xiàn)了雙Token無感刷新機制,具備以下特點:
- 完整的前后端代碼示例,可直接集成到項目中
- 遵循安全最佳實踐(HttpOnly Cookie、短期Token)
- 支持并發(fā)請求處理和Token主動吊銷
- 清晰的模塊劃分,易于擴展維護
到此這篇關(guān)于SpringBoot中雙token實現(xiàn)無感刷新的文章就介紹到這了,更多相關(guān)SpringBoot 雙token無感刷新內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
springboot使用ThreadPoolTaskExecutor多線程批量插入百萬級數(shù)據(jù)的實現(xiàn)方法
這篇文章主要介紹了springboot利用ThreadPoolTaskExecutor多線程批量插入百萬級數(shù)據(jù),本文通過示例代碼給大家介紹的非常詳細,對大家的學習或工作具有一定的參考借鑒價值,需要的朋友可以參考下2023-02-02Spring Security 自動踢掉前一個登錄用戶的實現(xiàn)代碼
這篇文章主要介紹了Spring Security 自動踢掉前一個登錄用戶的實現(xiàn)代碼,本文通過實例代碼給大家介紹的非常詳細,對大家的學習或工作具有一定的參考借鑒價值,需要的朋友可以參考下2020-05-05SpringBoot結(jié)合Redis實現(xiàn)序列化的方法詳解
Spring提供了一個RedisTemplate來進行對Redis的操作,但是RedisTemplate默認配置的是使用Java本機序列化。如果要對對象操作,就不是那么的方便。所以本文為大家介紹了另一種SpringBoot結(jié)合Redis實現(xiàn)序列化的方法,需要的可以參考一下2022-06-06java中List<String>轉(zhuǎn)字符串形式常用方法總結(jié)(非常全!)
這篇文章主要介紹了java中List<String>轉(zhuǎn)字符串形式的相關(guān)資料,文中通過示例總結(jié)了五種字符串連接方法及進階場景、性能優(yōu)化與特殊字符處理技巧,需要的朋友可以參考下2025-05-05Java實用技巧:如何使用String去除開頭的第一個字符?
這篇文章主要介紹了Java實用技巧:如何使用String去除開頭的第一個字符,需要的朋友可以參考下2023-11-11java?io文件操作從文件讀取數(shù)據(jù)的六種方法
這篇文章主要為大家介紹了java?io操作總結(jié)從文件讀取數(shù)據(jù)的六種方法,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步早日升職加薪2022-03-03