目录
- 前言
- 前后端分离架构的认证挑战
- [传统Session vs Token认证](#传统Session vs Token认证 "#%E4%BC%A0%E7%BB%9Fsession-vs-token%E8%AE%A4%E8%AF%81")
- 双Token机制核心概念
- 完整的认证流程设计
- 技术实现方案
- 安全性考虑
- 最佳实践
- 总结
前言
在现代Web应用开发中,前后端分离架构已成为主流。这种架构模式带来了开发效率的提升和技术栈的解耦,但同时也引入了新的挑战,其中最关键的就是用户认证和会话管理。本文将深入分析如何通过双Token机制(Access Token + Refresh Token)实现前后端分离架构下的无缝用户体验和安全的访问控制。
1. 前后端分离架构的认证挑战
传统架构 vs 前后端分离架构
传统架构的认证方式:
- 基于Cookie和Session的状态管理
- 服务器端维护用户会话状态
- 依赖浏览器的Cookie机制
- 跨域问题相对简单
前后端分离架构面临的挑战:
- 无状态性要求:RESTful API设计原则要求服务无状态
- 跨域问题:前后端部署在不同域名,Cookie传递受限
- 多端支持:需要同时支持Web、移动端、桌面应用
- 微服务架构:多个服务间的认证状态共享
- 安全性要求:防范XSS、CSRF等攻击
为什么需要Token机制?
Token机制相比传统Session具有以下优势:
- 无状态:服务器不需要存储用户会话信息
- 可扩展性:易于实现负载均衡和水平扩展
- 跨域友好:不依赖Cookie,可通过HTTP Header传递
- 移动端友好:原生应用无Cookie概念,Token更适合
- 微服务支持:Token可在多个服务间共享和验证
2. 传统Session vs Token认证
Session认证机制
markdown
用户登录 → 服务器创建Session → 返回SessionID → 客户端存储SessionID
↓
客户端请求 → 携带SessionID → 服务器验证Session → 返回响应
Session机制的特点:
- 服务器端存储用户状态
- 依赖Cookie或URL参数传递SessionID
- 简单直观,但扩展性有限
Token认证机制
markdown
用户登录 → 服务器生成Token → 返回Token → 客户端存储Token
↓
客户端请求 → 携带Token → 服务器验证Token → 返回响应
Token机制的特点:
- 无状态,用户信息编码在Token中
- 通过HTTP Header传递
- 支持分布式部署
3. 双Token机制核心概念
Access Token(访问令牌)
作用和特点:
- 用于访问受保护的API资源
- 生命周期短(通常15-30分钟)
- 包含用户身份和权限信息
- 频繁使用,安全风险相对较高
JWT结构示例:
json
{
"header": {
"alg": "HS256",
"typ": "JWT"
},
"payload": {
"sub": "user123",
"name": "张三",
"roles": ["user", "admin"],
"exp": 1640995200,
"iat": 1640991600
}
}
Refresh Token(刷新令牌)
作用和特点:
- 用于获取新的Access Token
- 生命周期长(通常7-30天)
- 使用频率低,安全风险相对较小
- 可以被撤销,提供更好的安全控制
设计考虑:
- 存储方式:数据库记录,支持撤销
- 使用限制:一次性使用或限制使用次数
- 安全性:加密存储,包含设备指纹等信息
双Token机制的优势
- 安全性平衡:短期Access Token降低泄露风险,长期Refresh Token减少用户重新登录
- 用户体验:无感知的Token续期,避免频繁登录
- 精细控制:可以撤销Refresh Token实现强制登出
- 性能优化:Access Token验证无需数据库查询,Refresh Token验证频率低
4. 完整的认证流程设计
1. 用户登录流程
sequenceDiagram
participant U as 用户
participant F as 前端应用
participant A as 认证服务
participant D as 数据库
U->>F: 输入用户名密码
F->>A: POST /api/auth/login
A->>D: 验证用户凭据
D-->>A: 返回用户信息
A->>A: 生成Access Token
A->>A: 生成Refresh Token
A->>D: 存储Refresh Token
A-->>F: 返回双Token
F->>F: 存储Token
F-->>U: 登录成功
2. API访问流程
sequenceDiagram
participant F as 前端应用
participant A as API服务
participant Auth as 认证服务
F->>A: API请求 + Access Token
A->>Auth: 验证Access Token
Auth-->>A: Token有效
A-->>F: 返回API数据
3. Token刷新流程
sequenceDiagram
participant F as 前端应用
participant A as API服务
participant Auth as 认证服务
participant D as 数据库
F->>A: API请求 + 过期Access Token
A->>Auth: 验证Access Token
Auth-->>A: Token已过期
A-->>F: 401 Unauthorized
F->>Auth: POST /api/auth/refresh + Refresh Token
Auth->>D: 验证Refresh Token
D-->>Auth: Token有效
Auth->>Auth: 生成新Access Token
Auth->>D: 更新/轮换Refresh Token
Auth-->>F: 返回新双Token
F->>A: 重试API请求 + 新Access Token
A-->>F: 返回API数据
5.技术实现方案
技术栈
- 前端: Vue3 + TypeScript + Pinia + Axios
- 后端: Spring Boot 3 + Spring Security 6 + JWT
- 架构模式: 前后端分离 + RESTful API
5.1 后端实现
1. 依赖配置
xml
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<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>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
</dependency>
2. JWT工具类
java
/**
* JWT Token提供者工具类
* 负责生成、验证和解析JWT Token
* 支持Access Token和Refresh Token的双Token机制
*/
@Component
public class JwtTokenProvider {
/**
* JWT签名密钥,从配置文件读取
* 建议使用256位以上的强密钥
*/
@Value("${jwt.secret}")
private String jwtSecret;
/**
* Access Token有效期(毫秒)
* 推荐设置为15-30分钟,平衡安全性和用户体验
*/
@Value("${jwt.access-token-validity}")
private long accessTokenValidity;
/**
* Refresh Token有效期(毫秒)
* 推荐设置为7-30天,用于长期免登录
*/
@Value("${jwt.refresh-token-validity}")
private long refreshTokenValidity;
/**
* 生成Access Token
* Access Token用于API访问,包含用户名和角色信息,有效期较短
*
* @param username 用户名
* @param roles 用户角色列表
* @return 生成的Access Token字符串
*/
public String generateAccessToken(String username, List<String> roles) {
return Jwts.builder()
.setSubject(username) // 设置主题(用户名)
.claim("roles", roles) // 添加角色信息到载荷
.setIssuedAt(new Date()) // 设置签发时间
.setExpiration(new Date(System.currentTimeMillis() + accessTokenValidity)) // 设置过期时间
.signWith(getSigningKey(), SignatureAlgorithm.HS256) // 使用HS256算法签名
.compact(); // 生成最终的JWT字符串
}
/**
* 生成Refresh Token
* Refresh Token用于刷新Access Token,不包含敏感信息,有效期较长
*
* @param username 用户名
* @return 生成的Refresh Token字符串
*/
public String generateRefreshToken(String username) {
return Jwts.builder()
.setSubject(username) // 设置主题(用户名)
.setIssuedAt(new Date()) // 设置签发时间
.setExpiration(new Date(System.currentTimeMillis() + refreshTokenValidity)) // 设置过期时间
.signWith(getSigningKey(), SignatureAlgorithm.HS256) // 使用HS256算法签名
.compact(); // 生成最终的JWT字符串
}
/**
* 验证Token有效性
* 检查Token的签名、格式和过期时间
*
* @param token 待验证的JWT Token
* @return true表示Token有效,false表示无效
*/
public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(getSigningKey()) // 设置验证密钥
.build()
.parseClaimsJws(token); // 解析并验证Token
return true;
} catch (JwtException | IllegalArgumentException e) {
// Token格式错误、签名不匹配或已过期
return false;
}
}
/**
* 从Token中提取用户名
*
* @param token JWT Token
* @return 用户名
*/
public String getUsernameFromToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey()) // 设置验证密钥
.build()
.parseClaimsJws(token) // 解析Token
.getBody() // 获取载荷部分
.getSubject(); // 获取主题(用户名)
}
/**
* 获取签名密钥
* 将Base64编码的密钥转换为HMAC密钥对象
*
* @return HMAC签名密钥
*/
private Key getSigningKey() {
byte[] keyBytes = Decoders.BASE64.decode(jwtSecret); // 解码Base64密钥
return Keys.hmacShaKeyFor(keyBytes); // 创建HMAC密钥
}
}
3. 认证过滤器
java
/**
* JWT认证过滤器
* 继承OncePerRequestFilter确保每个请求只执行一次
* 负责从请求中提取JWT Token并验证用户身份
*/
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
/**
* JWT Token工具类,用于Token的验证和解析
*/
@Autowired
private JwtTokenProvider jwtTokenProvider;
/**
* 用户详情服务,用于加载用户信息和权限
*/
@Autowired
private UserDetailsService userDetailsService;
/**
* 过滤器核心方法
* 在每个HTTP请求处理前执行,验证JWT Token并设置认证上下文
*
* @param request HTTP请求对象
* @param response HTTP响应对象
* @param filterChain 过滤器链,用于传递请求到下一个过滤器
*/
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 从请求头中提取Access Token
String accessToken = getAccessTokenFromRequest(request);
// 验证Token是否存在且有效
if (StringUtils.hasText(accessToken) && jwtTokenProvider.validateToken(accessToken)) {
// 从Token中解析用户名
String username = jwtTokenProvider.getUsernameFromToken(accessToken);
// 根据用户名加载用户详情(包括权限信息)
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 创建认证对象,包含用户信息和权限
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails, // 认证主体(用户信息)
null, // 凭据(密码),JWT认证不需要
userDetails.getAuthorities() // 用户权限列表
);
// 将认证信息设置到Spring Security上下文中
// 后续的请求处理过程中可以通过SecurityContextHolder获取当前用户信息
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// 继续执行过滤器链,处理请求
filterChain.doFilter(request, response);
}
/**
* 从HTTP请求头中提取Access Token
* 标准的JWT Token通过Authorization头传递,格式为"Bearer <token>"
*
* @param request HTTP请求对象
* @return 提取的Token字符串,如果不存在则返回null
*/
private String getAccessTokenFromRequest(HttpServletRequest request) {
// 获取Authorization请求头
String bearerToken = request.getHeader("Authorization");
// 检查请求头是否存在且以"Bearer "开头
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
// 提取Token部分(去掉"Bearer "前缀)
return bearerToken.substring(7);
}
// 请求头格式不正确或不存在
return null;
}
}
4. 认证控制器
java
/**
* 认证控制器
* 处理用户登录、Token刷新、登出等认证相关的API请求
* 实现双Token认证机制的核心业务逻辑
*/
@RestController
@RequestMapping("/api/auth")
@CrossOrigin(origins = "${app.cors.allowed-origins}") // 配置跨域访问
public class AuthController {
/**
* Spring Security认证管理器
* 用于验证用户凭据(用户名和密码)
*/
@Autowired
private AuthenticationManager authenticationManager;
/**
* JWT Token工具类
* 用于生成和验证Access Token和Refresh Token
*/
@Autowired
private JwtTokenProvider jwtTokenProvider;
/**
* Refresh Token服务
* 管理Refresh Token的存储、验证和撤销
*/
@Autowired
private RefreshTokenService refreshTokenService;
/**
* 用户登录接口
* 验证用户凭据并返回双Token
*
* @param loginRequest 登录请求对象,包含用户名和密码
* @return 包含Access Token和Refresh Token的响应
*/
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@RequestBody LoginRequest loginRequest) {
try {
// 使用Spring Security的认证管理器验证用户凭据
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
loginRequest.getUsername(), // 用户名
loginRequest.getPassword() // 密码
)
);
// 将认证信息设置到安全上下文中
SecurityContextHolder.getContext().setAuthentication(authentication);
// 生成Access Token,包含用户名和角色信息
String accessToken = jwtTokenProvider.generateAccessToken(
loginRequest.getUsername(),
getRoles(authentication) // 从认证对象中提取用户角色
);
// 生成Refresh Token,仅包含用户名
String refreshToken = jwtTokenProvider.generateRefreshToken(loginRequest.getUsername());
// 将Refresh Token存储到数据库或Redis中,用于后续验证
// 这样可以实现Refresh Token的撤销功能
refreshTokenService.saveRefreshToken(loginRequest.getUsername(), refreshToken);
// 返回成功响应,包含双Token
return ResponseEntity.ok(new AuthResponse(accessToken, refreshToken));
} catch (AuthenticationException e) {
// 认证失败,返回401未授权状态
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new AuthResponse("Invalid credentials"));
}
}
/**
* Token刷新接口
* 使用Refresh Token获取新的Access Token
*
* @param request 刷新请求对象,包含Refresh Token
* @return 包含新Access Token的响应
*/
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(@RequestBody RefreshTokenRequest request) {
try {
// 验证Refresh Token的格式和有效期
if (jwtTokenProvider.validateToken(request.getRefreshToken())) {
// 从Refresh Token中提取用户名
String username = jwtTokenProvider.getUsernameFromToken(request.getRefreshToken());
// 验证Refresh Token是否在服务端存储中(防止被撤销的Token被重复使用)
if (refreshTokenService.validateRefreshToken(username, request.getRefreshToken())) {
// 生成新的Access Token
String newAccessToken = jwtTokenProvider.generateAccessToken(
username,
getRoles(username) // 重新获取用户最新的角色信息
);
// 返回新的Access Token,不返回新的Refresh Token
return ResponseEntity.ok(new AuthResponse(newAccessToken, null));
}
}
// Refresh Token无效,返回401未授权状态
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new AuthResponse("Invalid refresh token"));
} catch (Exception e) {
// Token刷新过程中发生异常,返回401未授权状态
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new AuthResponse("Token refresh failed"));
}
}
/**
* 用户登出接口
* 撤销用户的Refresh Token,实现安全登出
*
* @param request 登出请求对象,包含用户名
* @return 空响应体,状态码200表示成功
*/
@PostMapping("/logout")
public ResponseEntity<Void> logout(@RequestBody LogoutRequest request) {
// 从服务端存储中撤销Refresh Token
// 这样即使客户端仍然持有Token,也无法再次使用
refreshTokenService.revokeRefreshToken(request.getUsername());
// 返回成功状态
return ResponseEntity.ok().build();
}
/**
* 从认证对象中提取用户角色列表
*
* @param authentication Spring Security认证对象
* @return 角色名称列表
*/
private List<String> getRoles(Authentication authentication) {
return authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority) // 获取权限名称
.collect(Collectors.toList()); // 收集到列表中
}
/**
* 根据用户名获取用户角色列表
* 用于Token刷新时重新获取最新的用户权限
*
* @param username 用户名
* @return 角色名称列表
*/
private List<String> getRoles(String username) {
// 从用户服务获取用户角色
// 这里可以从数据库、缓存或其他用户服务中获取
return userService.getUserRoles(username);
}
}
5. Spring Security配置
java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Autowired
private JwtAuthenticationFilter jwtAuthFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000", "http://localhost:8080"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public AuthenticationManager authenticationManager(
UserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
return new ProviderManager(provider);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
5.2 前端实现
1. 依赖安装
bash
npm install axios pinia @vueuse/core
2. 认证状态管理
typescript
// stores/auth.ts
/**
* 认证状态管理 Store
* 使用 Pinia 管理用户认证状态和双Token机制
* 提供登录、登出、Token刷新等核心功能
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authApi } from '@/api/auth'
import { useRouter } from 'vue-router'
/**
* 用户信息接口定义
*/
export interface User {
id: string // 用户ID
username: string // 用户名
email: string // 邮箱
roles: string[] // 用户角色列表
}
/**
* 认证Token接口定义
*/
export interface AuthTokens {
accessToken: string // 访问令牌,用于API请求认证
refreshToken: string // 刷新令牌,用于获取新的访问令牌
}
/**
* 认证状态管理Store
* 使用 Composition API 风格的 Pinia Store
*/
export const useAuthStore = defineStore('auth', () => {
// ==================== 响应式状态 ====================
/** 当前登录用户信息 */
const user = ref<User | null>(null)
/** Access Token,存储在内存中确保安全性 */
const accessToken = ref<string | null>(null)
/** Refresh Token,可选择存储在localStorage或HttpOnly Cookie */
const refreshToken = ref<string | null>(null)
/** 计算属性:用户是否已认证 */
const isAuthenticated = computed(() => !!accessToken.value)
// ==================== 依赖注入 ====================
/** Vue Router实例,用于页面跳转 */
const router = useRouter()
// ==================== 认证状态管理方法 ====================
/**
* 初始化认证状态
* 应用启动时调用,从本地存储恢复认证状态
* 自动验证Token有效性并在必要时刷新
*/
const initAuth = () => {
// 从本地存储读取Token
const storedAccessToken = localStorage.getItem('accessToken')
const storedRefreshToken = localStorage.getItem('refreshToken')
// 如果存在双Token,恢复认证状态
if (storedAccessToken && storedRefreshToken) {
accessToken.value = storedAccessToken
refreshToken.value = storedRefreshToken
// 验证Token有效性,如果即将过期则自动刷新
validateAndRefreshToken()
}
}
/**
* 用户登录方法
* 发送用户凭据到服务端,获取双Token并保存认证状态
*
* @param username 用户名
* @param password 密码
* @returns 登录结果,包含成功状态和错误信息
*/
const login = async (username: string, password: string) => {
try {
// 调用登录API
const response = await authApi.login(username, password)
const { accessToken: newAccessToken, refreshToken: newRefreshToken } = response.data
// 更新内存中的Token状态
accessToken.value = newAccessToken
refreshToken.value = newRefreshToken
// 持久化存储Token(注意:生产环境建议Refresh Token存储在HttpOnly Cookie中)
localStorage.setItem('accessToken', newAccessToken)
localStorage.setItem('refreshToken', newRefreshToken)
// 获取当前用户详细信息
await fetchUserInfo()
// 登录成功后跳转到仪表板
router.push('/dashboard')
return { success: true }
} catch (error: any) {
// 登录失败,返回错误信息
return {
success: false,
error: error.response?.data?.message || '登录失败'
}
}
}
/**
* 用户登出方法
* 撤销服务端的Refresh Token并清除本地认证状态
*/
const logout = async () => {
try {
// 如果存在Refresh Token,通知服务端撤销
if (refreshToken.value) {
await authApi.logout(user.value?.username || '')
}
} catch (error) {
// 登出API调用失败不影响本地清理
console.error('Logout error:', error)
} finally {
// 无论服务端调用是否成功,都要清除本地认证状态
clearAuth()
// 跳转到登录页面
router.push('/login')
}
}
/**
* 清除本地认证信息
* 重置所有认证相关的状态和本地存储
*/
const clearAuth = () => {
// 清除内存中的用户和Token信息
user.value = null
accessToken.value = null
refreshToken.value = null
// 清除本地存储中的Token
localStorage.removeItem('accessToken')
localStorage.removeItem('refreshToken')
}
/**
* 刷新Access Token
* 使用Refresh Token获取新的Access Token,实现无感知的Token续期
*
* @returns 新的Access Token
* @throws 如果刷新失败则抛出异常并清除认证状态
*/
const refreshAccessToken = async () => {
try {
// 检查是否存在Refresh Token
if (!refreshToken.value) {
throw new Error('No refresh token available')
}
// 调用Token刷新API
const response = await authApi.refresh(refreshToken.value)
const { accessToken: newAccessToken } = response.data
// 更新内存和本地存储中的Access Token
accessToken.value = newAccessToken
localStorage.setItem('accessToken', newAccessToken)
return newAccessToken
} catch (error) {
// Token刷新失败,清除认证状态并跳转登录页
clearAuth()
router.push('/login')
throw error
}
}
/**
* 验证Token有效性并在必要时刷新
* 检查Access Token是否即将过期,如果是则自动刷新
* 实现用户无感知的Token续期机制
*
* @returns 验证结果,true表示Token有效或刷新成功
*/
const validateAndRefreshToken = async () => {
// 如果没有Access Token,直接返回false
if (!accessToken.value) return false
try {
// 解析JWT Token的载荷部分(Base64解码)
const tokenData = JSON.parse(atob(accessToken.value.split('.')[1]))
const expirationTime = tokenData.exp * 1000 // JWT中的exp是秒,转换为毫秒
const currentTime = Date.now()
// 如果Token将在5分钟内过期,提前刷新
// 这样可以避免在用户操作过程中突然失效
if (expirationTime - currentTime < 5 * 60 * 1000) {
await refreshAccessToken()
}
return true
} catch (error) {
// Token解析失败或刷新失败,清除认证状态
clearAuth()
return false
}
}
/**
* 获取当前用户详细信息
* 登录成功后调用,从服务端获取用户的完整信息
*/
const fetchUserInfo = async () => {
try {
// 调用获取用户信息API
const response = await authApi.getUserInfo()
user.value = response.data
} catch (error) {
// 获取用户信息失败,记录错误但不影响认证状态
console.error('Failed to fetch user info:', error)
}
}
return {
user,
accessToken,
refreshToken,
isAuthenticated,
login,
logout,
initAuth,
refreshAccessToken,
validateAndRefreshToken
}
})
3. API配置
typescript
// api/auth.ts
/**
* 认证相关API配置
* 配置axios实例,实现自动Token注入和刷新机制
* 提供完整的双Token认证支持
*/
import axios from 'axios'
import { useAuthStore } from '@/stores/auth'
/**
* 创建专用的axios实例
* 避免与其他API请求产生冲突,提供独立的配置
*/
const api = axios.create({
// API基础URL,优先使用环境变量配置
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api',
// 请求超时时间(10秒)
timeout: 10000,
// 默认请求头
headers: {
'Content-Type': 'application/json'
}
})
/**
* 请求拦截器
* 在每个请求发送前自动添加Authorization头
* 实现Token的自动注入机制
*/
api.interceptors.request.use(
(config) => {
// 获取认证Store实例
const authStore = useAuthStore()
// 如果存在Access Token,自动添加到请求头
if (authStore.accessToken) {
config.headers.Authorization = `Bearer ${authStore.accessToken}`
}
return config
},
(error) => {
// 请求配置错误,直接拒绝
return Promise.reject(error)
}
)
/**
* 响应拦截器
* 处理401未授权响应,实现自动Token刷新机制
* 确保用户在Token过期时能够无感知地继续使用应用
*/
api.interceptors.response.use(
// 响应成功,直接返回
(response) => response,
// 响应错误处理
async (error) => {
const originalRequest = error.config
// 检查是否是401未授权错误,且该请求未曾重试过
if (error.response?.status === 401 && !originalRequest._retry) {
// 标记该请求已重试,避免无限循环
originalRequest._retry = true
const authStore = useAuthStore()
try {
// 尝试刷新Access Token
await authStore.refreshAccessToken()
// Token刷新成功,重新发送原始请求
// 新的Access Token会通过请求拦截器自动添加
return api(originalRequest)
} catch (refreshError) {
// Token刷新失败,说明Refresh Token也已过期
// 清除认证状态并跳转到登录页面
authStore.clearAuth()
window.location.href = '/login'
return Promise.reject(refreshError)
}
}
// 其他错误或已重试过的401错误,直接拒绝
return Promise.reject(error)
}
)
/**
* 认证相关API方法集合
* 提供登录、Token刷新、登出等认证功能的API调用
*/
export const authApi = {
/**
* 用户登录API
* @param username 用户名
* @param password 密码
* @returns Promise<AxiosResponse> 包含双Token的响应
*/
login: (username: string, password: string) =>
api.post('/auth/login', { username, password }),
/**
* 刷新Token API
* @param refreshToken 有效的Refresh Token
* @returns Promise<AxiosResponse> 包含新Access Token的响应
*/
refresh: (refreshToken: string) =>
api.post('/auth/refresh', { refreshToken }),
/**
* 用户登出API
* @param username 用户名
* @returns Promise<AxiosResponse> 登出确认响应
*/
logout: (username: string) =>
api.post('/auth/logout', { username }),
/**
* 获取用户信息API
* @returns Promise<AxiosResponse> 包含用户详细信息的响应
*/
getUserInfo: () => api.get('/auth/user-info')
}
// 导出axios实例,供其他模块使用
export default api
4. 路由守卫
typescript
// router/index.ts
/**
* Vue Router 配置
* 实现基于认证状态的路由守卫机制
* 自动处理Token验证和页面跳转逻辑
*/
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
/**
* 路由配置
* 定义应用的页面路由和访问权限
*/
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { requiresAuth: false } // 登录页不需要认证
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { requiresAuth: true } // 仪表板需要认证
},
{
path: '/',
redirect: '/dashboard' // 根路径重定向到仪表板
}
]
/**
* 创建路由实例
* 使用HTML5 History模式,提供干净的URL
*/
const router = createRouter({
history: createWebHistory(),
routes
})
/**
* 全局前置守卫
* 在每次路由跳转前执行,实现认证检查和自动跳转
*
* @param to 目标路由对象
* @param from 当前路由对象
* @param next 继续执行的回调函数
*/
router.beforeEach(async (to, from, next) => {
// 获取认证状态管理实例
const authStore = useAuthStore()
// 检查目标页面是否需要认证
if (to.meta.requiresAuth) {
// 如果用户未登录,跳转到登录页
if (!authStore.isAuthenticated) {
next('/login')
return
}
// 验证Token有效性,如果即将过期则自动刷新
const isValid = await authStore.validateAndRefreshToken()
if (!isValid) {
// Token无效或刷新失败,跳转到登录页
next('/login')
return
}
}
// 如果用户已登录但访问登录页,重定向到仪表板
if (to.path === '/login' && authStore.isAuthenticated) {
next('/dashboard')
return
}
// 通过所有检查,继续导航
next()
})
export default router
5. 登录组件
vue
<!-- views/Login.vue -->
<template>
<div class="login-container">
<div class="login-card">
<h2>用户登录</h2>
<form @submit.prevent="handleLogin" class="login-form">
<div class="form-group">
<label for="username">用户名</label>
<input
id="username"
v-model="form.username"
type="text"
required
placeholder="请输入用户名"
/>
</div>
<div class="form-group">
<label for="password">密码</label>
<input
id="password"
v-model="form.password"
type="password"
required
placeholder="请输入密码"
/>
</div>
<button type="submit" :disabled="loading" class="login-btn">
{{ loading ? '登录中...' : '登录' }}
</button>
<div v-if="error" class="error-message">
{{ error }}
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 登录组件逻辑
* 实现用户登录功能,集成双Token认证机制
* 提供用户友好的登录界面和错误处理
*/
import { ref, reactive } from 'vue'
import { useAuthStore } from '@/stores/auth'
// 获取认证状态管理实例
const authStore = useAuthStore()
/**
* 登录表单数据
* 使用reactive创建响应式对象,自动跟踪表单变化
*/
const form = reactive({
username: '', // 用户名输入
password: '' // 密码输入
})
/** 登录加载状态,用于显示加载指示器 */
const loading = ref(false)
/** 错误信息,用于显示登录失败的原因 */
const error = ref('')
/**
* 处理登录提交
* 调用认证Store的登录方法,处理成功和失败情况
*/
const handleLogin = async () => {
// 设置加载状态,禁用提交按钮
loading.value = true
// 清除之前的错误信息
error.value = ''
try {
// 调用登录方法,传入用户名和密码
const result = await authStore.login(form.username, form.password)
// 检查登录结果
if (!result.success) {
// 登录失败,显示错误信息
error.value = result.error || '登录失败'
}
// 登录成功的情况由authStore内部处理(自动跳转)
} catch (err) {
// 捕获未预期的错误
error.value = '登录失败,请重试'
console.error('Login error:', err)
} finally {
// 无论成功失败,都要取消加载状态
loading.value = false
}
}
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.login-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-weight: 500;
color: #374151;
}
.form-group input {
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 1rem;
}
.login-btn {
background: #3b82f6;
color: white;
padding: 0.75rem;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
}
.login-btn:hover:not(:disabled) {
background: #2563eb;
}
.login-btn:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.error-message {
color: #dc2626;
text-align: center;
font-size: 0.875rem;
}
</style>
5.3 安全机制
1. Token安全存储
- Access Token: 存储在内存中,避免XSS攻击
- Refresh Token: 存储在HttpOnly Cookie中,防止JavaScript访问
- Token轮换: 每次刷新都生成新的Refresh Token
2. 攻击防护
- XSS防护: 使用HttpOnly Cookie存储Refresh Token
- CSRF防护: 验证Origin和Referer头
- Token劫持: 实现Token黑名单机制
- 重放攻击: 使用JWT的iat和exp字段
3. 安全配置
java
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // 前后端分离,禁用CSRF
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.headers(headers -> headers
.frameOptions().deny() // 防止点击劫持
.contentTypeOptions().and() // 防止MIME类型嗅探
.httpStrictTransportSecurity(hsts -> hsts
.maxAgeInSeconds(31536000)
.includeSubdomains(true)
)
);
return http.build();
}
}
5.4 最佳实践
1. Token生命周期管理
- Access Token: 15-30分钟
- Refresh Token: 7-30天
- 实现Token自动刷新机制
- 支持多设备登录管理
2. 错误处理
- 统一的错误响应格式
- 详细的错误日志记录
- 用户友好的错误提示
3. 性能优化
- 使用Redis缓存Refresh Token
- 实现Token预刷新机制
- 减少不必要的Token验证
4. 监控和日志
- Token使用统计
- 异常登录检测
- 安全事件记录
5.5 完整示例
项目结构
css
src/
├── main/
│ ├── java/
│ │ └── com/example/auth/
│ │ ├── config/
│ │ ├── controller/
│ │ ├── filter/
│ │ ├── service/
│ │ └── util/
│ └── resources/
│ └── application.yml
├── frontend/
│ ├── src/
│ │ ├── api/
│ │ ├── components/
│ │ ├── router/
│ │ ├── stores/
│ │ └── views/
│ ├── package.json
│ └── vite.config.ts
└── README.md
配置文件
yaml
# application.yml
jwt:
secret: your-256-bit-secret-key-here
access-token-validity: 900000 # 15分钟
refresh-token-validity: 604800000 # 7天
app:
cors:
allowed-origins: http://localhost:3000,http://localhost:8080
spring:
security:
user:
name: admin
password: admin
启动说明
- 后端启动:
./mvnw spring-boot:run
- 前端启动:
npm run dev
- 访问:
http://localhost:3000
6.总结
通过本文的深入分析,我们全面了解了前后端分离架构下双Token认证机制的设计和实现。这套方案不仅解决了传统Session机制在分布式环境下的局限性,还通过精心设计的安全策略和最佳实践,确保了用户体验和系统安全的平衡。
关键要点回顾:
- 双Token机制的核心价值:短期Access Token保证安全性,长期Refresh Token保证用户体验
- 完整的实现方案:从后端JWT生成验证到前端自动刷新机制
- 安全性保障:Token轮换、设备指纹、异常监控等多层防护
- 性能优化:预刷新、离线处理、多端同步等用户体验优化
未来发展方向:
- 零信任安全架构:结合设备信任度和行为分析
- 无密码认证:WebAuthn、生物识别等新技术
- 联邦身份认证:SSO和OAuth 2.0扩展
- 边缘计算支持:CDN级别的Token验证
双Token认证机制作为现代Web应用的基础设施,将继续在安全性、可扩展性和用户体验之间寻找最佳平衡点。随着技术的发展,我们也需要持续关注新的安全威胁和解决方案,确保认证系统的持续演进。