Vue3+SpringBoot前后端分离项目JWT登录权限完整实战指南
1. 项目概述与核心价值
最近在重构一个内部管理系统,前端从Vue2升级到了Vue3,后端依然是SpringBoot。在这个过程中,我重新梳理了整个登录和权限体系,决定采用JWT(JSON Web Token)方案。这几乎是现代前后端分离项目的标配了,但网上很多教程要么只讲后端,要么只讲前端,要么就是版本老旧,用着过时的API。今天,我就把这次从零到一、前后端打通的完整实现过程,以及我踩过的那些坑,毫无保留地分享出来。
这个方案能解决什么问题?简单说,就是用户登录后,后端生成一个“令牌”(Token)给前端,前端后续的每次请求都带着这个令牌。后端验证令牌的合法性,并从中解析出用户身份和权限信息,从而决定是否允许访问某个接口或看到某个菜单。整个过程是无状态的,服务器不需要保存会话,非常适合分布式和微服务架构。对于刚接触前后端分离开发,或者想从Session/Cookie模式切换到Token模式的朋友来说,这是一个非常实用的实战案例。无论你是全栈开发者,还是专注于前端或后端,都能从中找到清晰的实现路径和避坑指南。
2. 技术选型与架构设计思路
2.1 为什么是JWT,而不是Session?
在项目初期,我们面临一个选择:沿用传统的Session-Cookie,还是拥抱JWT?我最终选择了JWT,主要基于以下几点考量:
- 无状态与扩展性 :Session需要服务器端存储(内存、Redis等),在集群环境下需要做Session共享,增加了复杂度和维护成本。JWT是无状态的,令牌本身包含了所有必要信息,任何一台持有密钥的服务都可以验证它,天然适合水平扩展。
- 跨域与多端支持 :现代应用往往是前后端分离部署,域名不同,Session-Cookie在跨域场景下处理起来比较麻烦(需要配置CORS和
withCredentials)。JWT通常放在HTTP请求的Authorization头里,不受同源策略限制,对移动端App、第三方调用也更友好。 - 灵活性 :JWT的Payload可以自定义存放用户ID、角色、权限等信息,一次解析即可获取,无需多次查询数据库。虽然这带来了令牌无法主动失效的问题(后面会讲解决方案),但其灵活性在很多场景下优势明显。
注意 :JWT并非银弹。如果你的应用有极强的“即时踢人下线”需求,或者令牌Payload信息量巨大,需要谨慎评估。对于后台管理系统这类场景,JWT的优势非常突出。
2.2 整体流程与组件职责
整个登录权限流程,我把它拆解为以下几个核心环节,并明确了前后端的职责:
- 登录认证 :
- 前端 :收集用户账号密码,调用后端登录接口。
- 后端 :校验账号密码,成功后,生成JWT令牌(包含用户ID、角色等),返回给前端。
- 令牌存储与携带 :
- 前端 :收到令牌后,存储在
localStorage或sessionStorage(根据安全需求选择),并在后续所有请求的Authorization头中携带(格式:Bearer <token>)。
- 前端 :收到令牌后,存储在
- 请求拦截与验证 :
- 后端 :配置一个拦截器(Interceptor)或过滤器(Filter),对需要保护的接口进行拦截。从
Authorization头中取出令牌,进行签名验证、过期时间校验,并解析出用户信息,存入本次请求的上下文(如SecurityContextHolder或Request属性中)。
- 后端 :配置一个拦截器(Interceptor)或过滤器(Filter),对需要保护的接口进行拦截。从
- 权限控制 :
- 后端 :在业务接口或更细的粒度上,通过注解(如
@PreAuthorize(“hasRole(‘ADMIN’)”))或手动判断,校验当前用户是否有权限执行操作。 - 前端 :根据用户角色或权限列表,动态渲染侧边栏菜单、页面按钮等,实现前端路由守卫和界面元素权限控制。
- 后端 :在业务接口或更细的粒度上,通过注解(如
技术栈版本 :
- 后端 :Spring Boot 2.7.x / 3.x, Java 8+,
jjwt库(处理JWT) - 前端 :Vue 3.x, Composition API (
<script setup>), Vite, Axios, Vue Router 4, Pinia(状态管理)
3. 后端SpringBoot实现详解
3.1 引入依赖与基础配置
首先,创建一个SpringBoot项目,并引入必要的依赖。核心是 jjwt ,我们选用目前维护活跃的 jjwt-api , jjwt-impl , jjwt-jackson 组合。
<!-- pom.xml 关键依赖 -->
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- JWT 核心库 -->
<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>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<!-- 其他可能需要的:Lombok, Spring Security (如需更复杂权限), MyBatis-Plus等 -->
</dependencies>
接下来,在 application.yml 中配置JWT相关参数。这些参数最好放在配置文件中,方便不同环境(开发、生产)调整。
# application.yml
jwt:
secret: your-256-bit-secret-key-here-must-be-very-long-and-secure # 密钥,务必足够长且复杂!
expiration: 7200 # 令牌过期时间,单位:秒 (2小时)
token-header: Authorization # 前端传递令牌的请求头名称
token-prefix: Bearer # 令牌前缀,规范写法
这里有个 大坑 : jwt.secret 。千万不要用简单的单词或短字符串。它用于签名,一旦泄露,攻击者可以伪造任意令牌。建议使用一个长度至少32位的随机字符串,可以通过命令行工具生成,并妥善保管,生产环境务必使用环境变量注入,而不是硬编码在配置文件中。
3.2 核心工具类:JwtUtils
创建一个 JwtUtils 工具类,封装JWT的生成、解析和验证逻辑。这是后端的核心。
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.Map;
@Component
public class JwtUtils {
// 从配置文件中注入
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
// 生成密钥
private SecretKey getSigningKey() {
// 将配置的字符串密钥转换为安全的SecretKey对象
return Keys.hmacShaKeyFor(secret.getBytes());
}
/**
* 生成JWT令牌
* @param claims 自定义声明,通常放用户ID、用户名、角色等
* @return JWT字符串
*/
public String generateToken(Map<String, Object> claims) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration * 1000);
return Jwts.builder()
.setClaims(claims) // 设置自定义负载
.setIssuedAt(now) // 签发时间
.setExpiration(expiryDate) // 过期时间
.signWith(getSigningKey(), SignatureAlgorithm.HS256) // 使用HS256算法和密钥签名
.compact();
}
/**
* 从令牌中解析Claims(负载)
*/
public Claims parseToken(String token) {
// 移除可能携带的“Bearer ”前缀
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
}
return Jwts.parserBuilder()
.setSigningKey(getSigningKey()) // 设置签名密钥
.build()
.parseClaimsJws(token)
.getBody();
}
/**
* 验证令牌是否有效(未过期且签名正确)
*/
public boolean validateToken(String token) {
try {
parseToken(token); // 如果能成功解析,说明签名有效且未过期(过期会抛出ExpiredJwtException)
return true;
} catch (JwtException | IllegalArgumentException e) {
// 捕获签名无效、令牌过期、令牌格式错误等异常
// 生产环境可以在这里记录日志
return false;
}
}
/**
* 从令牌中获取指定Claim
*/
public <T> T getClaimFromToken(String token, String claimName, Class<T> requiredType) {
Claims claims = parseToken(token);
return claims.get(claimName, requiredType);
}
}
实操心得 :
signWith方法使用的算法HS256是HMAC + SHA256,对称加密,需要同一个密钥。还有RS256(非对称,公私钥),更适合多服务场景,但配置稍复杂。对于单服务后台系统,HS256足够。parseToken方法里处理了Bearer前缀,这是一个好习惯,让工具类更健壮。validateToken方法通过捕获异常来判断有效性,这是标准做法。千万不要自己手动去判断时间,因为签名验证是更根本的检查。
3.3 登录接口与令牌发放
创建一个 AuthController 来处理登录请求。
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private UserService userService; // 假设你有一个用户服务
@Autowired
private JwtUtils jwtUtils;
@PostMapping("/login")
public Result login(@RequestBody LoginRequest loginRequest) {
// 1. 验证用户名密码
User user = userService.authenticate(loginRequest.getUsername(), loginRequest.getPassword());
if (user == null) {
return Result.error("用户名或密码错误");
}
// 2. 构造JWT的Claims(负载)
Map<String, Object> claims = new HashMap<>();
claims.put("userId", user.getId());
claims.put("username", user.getUsername());
claims.put("roles", user.getRoles()); // 假设用户有角色列表,如 ["ADMIN", "USER"]
// 3. 生成令牌
String token = jwtUtils.generateToken(claims);
// 4. 构造返回体(通常包含令牌和用户基础信息)
LoginResponse response = new LoginResponse();
response.setToken(token);
response.setUserInfo(new UserInfoVo(user)); // 用户信息视图对象
return Result.success("登录成功", response);
}
}
这里 Result 和 LoginResponse 是自定义的通用返回对象和登录响应对象。注意, 敏感信息如密码哈希千万不要放在JWT的Payload里 。
3.4 请求拦截与身份认证
我们需要一个拦截器,在请求到达Controller之前,验证JWT令牌,并将用户信息设置到当前请求上下文中。这里使用Spring的 HandlerInterceptor 。
@Component
public class JwtAuthenticationInterceptor implements HandlerInterceptor {
@Autowired
private JwtUtils jwtUtils;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 检查是否是公开接口(如登录、注册),可以通过注解或配置排除路径
String requestURI = request.getRequestURI();
if (“/api/auth/login”.equals(requestURI)) {
return true; // 放行登录接口
}
// 2. 从请求头获取令牌
String authHeader = request.getHeader(“Authorization”);
if (authHeader == null || !authHeader.startsWith(“Bearer ”)) {
// 没有令牌或格式错误,返回401未授权
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(“application/json;charset=UTF-8”);
response.getWriter().write(JSON.toJSONString(Result.error(“未提供有效的认证令牌”)));
return false;
}
String token = authHeader.substring(7); // 去掉“Bearer ”前缀
// 3. 验证令牌
if (!jwtUtils.validateToken(token)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(“application/json;charset=UTF-8”);
response.getWriter().write(JSON.toJSONString(Result.error(“令牌已过期或无效”)));
return false;
}
// 4. 解析令牌,获取用户信息
Claims claims = jwtUtils.parseToken(token);
String userId = claims.get(“userId”, String.class);
String username = claims.get(“username”, String.class);
List<String> roles = claims.get(“roles”, List.class);
// 5. 将用户信息存入请求上下文,供后续Controller使用
// 方式一:存入Request Attribute
request.setAttribute(“currentUserId”, userId);
request.setAttribute(“currentUsername”, username);
request.setAttribute(“currentUserRoles”, roles);
// 方式二:使用Spring Security的SecurityContextHolder(如果集成了Spring Security)
// UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(username, null, authorities);
// SecurityContextHolder.getContext().setAuthentication(authentication);
return true; // 验证通过,放行
}
}
然后,需要将这个拦截器注册到Spring MVC中。
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private JwtAuthenticationInterceptor jwtAuthenticationInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtAuthenticationInterceptor)
.addPathPatterns(“/api/**”) // 拦截所有/api开头的请求
.excludePathPatterns(“/api/auth/login”, “/api/auth/register”); // 排除登录注册等公开接口
}
}
3.5 细粒度权限控制
身份认证(Authentication)解决了“你是谁”的问题,权限控制(Authorization)则要解决“你能干什么”。我们可以在拦截器里做简单的角色判断,但更优雅的方式是使用注解。
方案一:自定义注解 + 拦截器增强
- 定义一个权限注解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequiresPermissions { String[] value(); // 需要的权限标识符,如 “user:add”, “sys:config:view” } - 在拦截器的
preHandle方法中,不仅验证JWT,在验证通过后,获取当前请求对应的方法上的@RequiresPermissions注解。 - 将注解中定义的权限标识符与当前用户拥有的权限(可以从JWT的
claims或根据userId实时查询数据库获得)进行比对。如果用户不具备所需权限,则返回403 Forbidden。
方案二:集成Spring Security(推荐用于复杂系统) 对于权限模型复杂(RBAC、ABAC等)的系统,强烈建议直接集成Spring Security。它可以与JWT完美结合。
- 引入
spring-boot-starter-security依赖。 - 配置一个继承
WebSecurityConfigurerAdapter(Spring Boot 2.x)或使用SecurityFilterChainBean(Spring Boot 3.x)的配置类。 - 实现一个
JwtAuthenticationFilter,继承OncePerRequestFilter,在其中完成JWT验证,并构建Authentication对象设置到SecurityContextHolder。 - 使用
@PreAuthorize,@PostAuthorize,@Secured等注解在Controller方法上进行声明式权限控制,例如@PreAuthorize(“hasRole(‘ADMIN’) or hasAuthority(‘user:delete’)”)。
实操心得 :
- 对于中小型后台管理系统, 方案一(自定义拦截器) 足够轻量、直观,耦合度低,容易理解和定制。
- 如果你的系统权限模型复杂,未来可能涉及方法级、数据级权限,或者需要OAuth2等高级特性,那么 方案二(Spring Security) 是更专业、更强大的选择,虽然学习曲线稍陡。我当前的项目权限比较简单,所以选择了方案一,在拦截器里根据角色判断,清晰可控。
4. 前端Vue3实现详解
4.1 项目初始化与依赖安装
使用Vite快速创建一个Vue3项目,并安装必要的依赖。
npm create vue@latest my-vue-app # 按提示选择需要的特性(Router, Pinia)
cd my-vue-app
npm install
npm install axios pinia # 如果创建时没选,手动安装
4.2 状态管理:用户信息与令牌存储
我们使用Pinia来全局管理用户登录状态和令牌。创建一个 auth store。
// stores/auth.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import axios from 'axios'
export const useAuthStore = defineStore('auth', () => {
// 状态
const token = ref(localStorage.getItem('token') || '')
const userInfo = ref(JSON.parse(localStorage.getItem('userInfo') || 'null'))
// Getter
const isAuthenticated = computed(() => !!token.value)
// Actions
function login(loginData) {
return new Promise((resolve, reject) => {
axios.post('/api/auth/login', loginData)
.then(response => {
const { token: newToken, userInfo: newUserInfo } = response.data.data
// 更新状态
token.value = newToken
userInfo.value = newUserInfo
// 持久化到本地存储
localStorage.setItem('token', newToken)
localStorage.setItem('userInfo', JSON.stringify(newUserInfo))
// 设置axios默认请求头
axios.defaults.headers.common['Authorization'] = `Bearer ${newToken}`
resolve(response)
})
.catch(error => {
// 清除可能存在的旧状态
logout()
reject(error)
})
})
}
function logout() {
token.value = ''
userInfo.value = null
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
delete axios.defaults.headers.common['Authorization']
// 跳转到登录页
router.push('/login')
}
// 初始化时,如果本地有token,则设置到axios头部
if (token.value) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token.value}`
}
return { token, userInfo, isAuthenticated, login, logout }
})
关键点 :
localStoragevssessionStorage:localStorage关闭浏览器后依然存在,用户体验好,但有一定安全风险(XSS攻击可能窃取)。sessionStorage关闭标签页即清除,更安全。根据项目安全要求选择。对于内部管理系统,我通常用localStorage,并确保做好XSS防护。- 令牌存储位置 :除了Web Storage,还可以考虑
Vuex/Pinia内存、Cookie(需设置httpOnly和secure防XSS,但前端JS无法直接读取,适用于纯后端Session场景,与JWT无状态理念稍悖)。这里是最常见的localStorage方案。 - 设置axios默认头 :登录成功后,立即将令牌设置到axios的公共请求头中,这样后续所有请求都会自动携带。
4.3 请求拦截器与响应拦截器
我们需要在axios的拦截器中统一处理请求头的添加和响应的错误(如401令牌过期)。
// utils/request.js
import axios from 'axios'
import { useAuthStore } from '@/stores/auth'
import router from '@/router'
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL, // 从环境变量读取API基础地址
timeout: 10000
})
// 请求拦截器
service.interceptors.request.use(
config => {
// 从store获取token(如果store已初始化)
const authStore = useAuthStore()
if (authStore.token) {
config.headers['Authorization'] = `Bearer ${authStore.token}`
}
return config
},
error => {
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
response => {
// 对响应数据做点什么
const res = response.data
// 假设后端统一返回格式为 { code: 200, message: ‘success’, data: ... }
if (res.code === 200) {
return res.data // 直接返回业务数据
} else {
// 业务逻辑错误,例如密码错误
ElMessage.error(res.message || ‘Error’)
return Promise.reject(new Error(res.message || ‘Error’))
}
},
error => {
// 对响应错误做点什么
if (error.response) {
const { status } = error.response
const authStore = useAuthStore()
switch (status) {
case 401:
ElMessage.error(‘用户未认证或令牌已过期’)
// 清除用户状态,跳转到登录页
authStore.logout()
router.push(‘/login’)
break
case 403:
ElMessage.error(‘用户没有权限访问此资源’)
break
case 500:
ElMessage.error(‘服务器内部错误’)
break
default:
ElMessage.error(error.response.data?.message || `请求错误 ${status}`)
}
} else if (error.request) {
// 请求发送了但没有收到响应
ElMessage.error(‘网络错误,请检查您的网络连接’)
} else {
// 请求配置出错
ElMessage.error(‘请求配置错误:’ + error.message)
}
return Promise.reject(error)
}
)
export default service
然后在项目的 main.js 或入口文件中导入这个 service ,并挂载到全局,或者在各处直接导入使用。
4.4 路由守卫与页面权限
使用Vue Router的导航守卫,在用户进入需要认证的页面之前进行检查。
// router/index.js
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, roles: [‘ADMIN’, ‘USER’] } // 需要认证,且角色为ADMIN或USER
},
{
path: ‘/admin’,
name: ‘Admin’,
component: () => import(‘@/views/Admin.vue’),
meta: { requiresAuth: true, roles: [‘ADMIN’] } // 需要认证,且角色必须为ADMIN
},
// … 其他路由
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 全局前置守卫
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
const isAuthenticated = authStore.isAuthenticated
// 判断该路由是否需要认证
if (to.meta.requiresAuth !== false) { // 默认需要认证,除非显式设置为false
if (!isAuthenticated) {
// 未登录,重定向到登录页,并携带目标路径,以便登录后回跳
next({ path: ‘/login’, query: { redirect: to.fullPath } })
return
}
// 已登录,检查角色权限
const requiredRoles = to.meta.roles
if (requiredRoles && requiredRoles.length > 0) {
const userRoles = authStore.userInfo?.roles || [] // 从store中获取用户角色
const hasRole = requiredRoles.some(role => userRoles.includes(role))
if (!hasRole) {
// 角色不符,跳转到无权限页面或首页
ElMessage.warning(‘您没有权限访问该页面’)
next(‘/403’) // 假设有一个403页面
return
}
}
}
// 如果要去登录页且已登录,则跳转到首页
if (to.path === ‘/login’ && isAuthenticated) {
next(‘/’)
return
}
next() // 放行
})
export default router
实操心得 :
meta字段是路由元信息,非常适合用来存放权限相关的数据。- 角色检查的逻辑可以更复杂,比如支持权限字符串数组。这里为了演示,用了简单的角色数组包含判断。
- 对于更细粒度的 按钮级权限 ,可以写一个全局的指令或一个工具函数,在组件内根据用户权限
v-if控制按钮的显示隐藏。例如:<template> <button v-if=“hasPermission(‘user:delete’)” @click=“handleDelete”>删除用户</button> </template> <script setup> import { useAuthStore } from ‘@/stores/auth’ const authStore = useAuthStore() const hasPermission = (permission) => { return authStore.userInfo?.permissions?.includes(permission) } </script>
4.5 登录页面与状态联动
最后,实现一个简单的登录页面,将上述所有部分串联起来。
<!-- views/Login.vue -->
<template>
<div class=“login-container”>
<el-form :model=“loginForm” :rules=“rules” ref=“formRef”>
<el-form-item prop=“username”>
<el-input v-model=“loginForm.username” placeholder=“请输入用户名” />
</el-form-item>
<el-form-item prop=“password”>
<el-input v-model=“loginForm.password” type=“password” placeholder=“请输入密码” />
</el-form-item>
<el-form-item>
<el-button type=“primary” @click=“handleLogin” :loading=“loading”>登录</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup>
import { ref, reactive } from ‘vue’
import { useRouter, useRoute } from ‘vue-router’
import { useAuthStore } from ‘@/stores/auth’
import { ElMessage } from ‘element-plus’ // 假设使用Element Plus
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const formRef = ref()
const loading = ref(false)
const loginForm = reactive({
username: ‘’,
password: ‘’
})
const rules = {
username: [{ required: true, message: ‘请输入用户名’, trigger: ‘blur’ }],
password: [{ required: true, message: ‘请输入密码’, trigger: ‘blur’ }]
}
const handleLogin = () => {
formRef.value.validate((valid) => {
if (!valid) return
loading.value = true
authStore.login(loginForm)
.then(() => {
ElMessage.success(‘登录成功’)
// 登录成功后,跳转到之前想访问的页面或首页
const redirectPath = route.query.redirect || ‘/’
router.push(redirectPath)
})
.catch(err => {
// 错误信息已在axios拦截器或login action中统一处理,这里可以不再显示
console.error(‘登录失败:’, err)
})
.finally(() => {
loading.value = false
})
})
}
</script>
至此,一个完整的、前后端分离的JWT登录权限流程就实现了。用户登录后,令牌被存储并自动附加到请求中,后端验证令牌并控制权限,前端根据权限动态展示界面和路由。
5. 进阶优化与安全考量
基础功能跑通后,我们还需要考虑一些进阶问题和安全加固措施。
5.1 令牌刷新机制
JWT的一个固有缺点是过期后无法主动失效。通常我们设置一个较短的过期时间(如2小时)来降低风险。但用户体验会变差,用户每2小时就要重新登录。为此,需要引入 刷新令牌(Refresh Token) 机制。
流程 :
- 登录成功后,后端返回两个令牌:
- Access Token (AT) : 短期有效(如2小时),用于访问业务接口。
- Refresh Token (RT) : 长期有效(如7天),仅用于获取新的AT,不能访问业务接口。
- RT需要安全存储(后端数据库或Redis,关联用户ID),AT可以放在内存或
localStorage。 - 前端在请求接口时,如果收到401错误(AT过期),则自动发起一个使用RT换取新AT的请求。
- 后端验证RT的有效性和关联性,如果有效,则颁发新的AT和RT(可选,可以RT不变),并返回给前端。
- 前端用新的AT重试刚才失败的请求。
实现要点 :
- RT的存储和验证必须在服务端,这是安全的关键。
- 前端需要封装一个“带自动刷新重试”的请求函数,或在axios响应拦截器中实现刷新逻辑,注意处理多个并发请求同时触发刷新的问题(需要锁机制)。
- 用户主动登出或管理员禁用用户时,后端应立即使该用户的RT失效。
5.2 防止CSRF与XSS
- CSRF(跨站请求伪造) :由于我们使用
Authorization头携带Token,而浏览器不会自动在跨站请求中附加自定义头,因此 我们的JWT方案天然对CSRF免疫 。这是相比Cookie方案的一大优势。 - XSS(跨站脚本攻击) :这是Token存储在
localStorage时的主要威胁。如果网站存在XSS漏洞,攻击者可以窃取Token。防御措施包括:- 对输出进行编码/转义 ,防止恶意脚本注入。
- 设置Cookie的
HttpOnly属性(但我们的Token不存Cookie)。 - 使用 内容安全策略(CSP) 头部,限制页面可以加载和执行哪些资源。
- 考虑将Token存储在
sessionStorage中,关闭标签页即失效,但牺牲了用户体验。 - 为敏感操作(如修改密码、支付)增加二次验证。
5.3 分布式部署与令牌黑名单
在单服务下,JWT无状态是优点。但在分布式或微服务下,如果你想实现“即时踢人下线”功能,就需要引入一个中心化的状态存储(如Redis),这与无状态理念相悖,但有时是业务刚需。
方案 :维护一个 令牌黑名单 或 令牌白名单版本号 。
- 黑名单 :用户登出或管理员禁用用户时,将该用户的AT(虽然快过期)或RT的
jti(JWT ID)存入Redis,并设置一个稍长于AT有效期的TTL。在所有服务的拦截器中,除了验证JWT本身,还要查询这个黑名单。这会增加一次Redis查询,但实现了主动失效。 - 版本号 :在用户表中增加一个
tokenVersion字段。JWT的Payload中包含这个版本号。当用户登出或修改密码时,递增这个版本号。拦截器验证JWT时,不仅验证签名和过期时间,还要从数据库或缓存中取出用户最新的tokenVersion进行比对,不一致则拒绝。这比黑名单查询压力小,但每次验证都需要查用户信息。
实操心得 :对于大多数后台管理系统,“即时踢人”并非强需求。可以折中:设置较短的AT过期时间(如30分钟),并提示用户“长时间无操作,请重新登录”。如果必须实现,我倾向于使用“版本号”方案,因为它更优雅,对缓存友好。
6. 常见问题排查与调试技巧
在实际开发中,你肯定会遇到各种问题。这里记录几个我踩过的坑和解决方法。
6.1 前端常见问题
问题1:登录成功,但后续请求返回401。
- 排查 :打开浏览器开发者工具的
Network选项卡。- 检查登录请求的响应中是否确实包含了
token字段。 - 检查后续请求的
Request Headers中,Authorization头是否存在且格式正确(Bearer <token>)。 - 检查
token是否被正确存入localStorage和Pinia store。
- 检查登录请求的响应中是否确实包含了
- 可能原因 :
- axios默认头未设置 :确保在登录成功的action里,设置了
axios.defaults.headers.common[‘Authorization’]。 - 请求拦截器逻辑错误 :检查请求拦截器是否从store正确获取了token。注意Pinia store在拦截器中的使用方式,可能需要
pinia实例已创建。 - 跨域问题(CORS) :后端需要正确配置CORS,允许前端域名的请求,并暴露
Authorization头。Spring Boot中需要配置CorsFilter或@CrossOrigin。
- axios默认头未设置 :确保在登录成功的action里,设置了
问题2:页面刷新后,用户登录状态丢失。
- 排查 :检查
auth store的初始化逻辑。在created或onMounted中,是否从localStorage读取了token和userInfo并还原到store状态中?Pinia store通常是响应式的,但刷新页面会重置,所以 持久化是关键 。
问题3:路由守卫无限循环。
- 现象 :页面在登录页和首页之间疯狂跳转。
- 排查 :检查
router.beforeEach逻辑。- 确保对
/login这类公开路由,正确设置了meta: { requiresAuth: false }或在守卫中做了排除。 - 检查
isAuthenticated的计算逻辑是否正确。有时store还未初始化完成,判断可能出错。可以加一个loading状态,或者确保store初始化在路由守卫之前。
- 确保对
6.2 后端常见问题
问题1:签名验证失败(SignatureException)。
- 排查 :
- 密钥不一致 :这是最常见的原因。确保生成Token和验证Token使用的是 完全相同的
jwt.secret字符串。检查生产环境和开发环境的配置文件。 - 算法不匹配 :生成Token时用的
HS256,验证时也必须用HS256。检查JwtUtils中的SignatureAlgorithm。 - Token被篡改 :如果密钥没问题,那可能就是Token在传输过程中被修改了。对比前端发送的Token和后端收到的Token是否完全一致(注意空格、换行符)。
- 密钥不一致 :这是最常见的原因。确保生成Token和验证Token使用的是 完全相同的
问题2:令牌过期(ExpiredJwtException)。
- 现象 :接口返回401,后端日志打印Token过期。
- 解决 :这就是引入刷新令牌机制的场景。短期内,可以先在前端拦截401响应,提示用户“登录已过期,请重新登录”并跳转登录页。
问题3:拦截器放行了OPTIONS预检请求。
- 现象 :前端POST请求失败,但Network里看到先发了一个
OPTIONS请求,这个请求没有带Authorization头,被拦截器拦截返回401,导致真正的POST请求发不出去。 - 解决 :在拦截器的
preHandle方法中,对OPTIONS请求方法直接放行。if (HttpMethod.OPTIONS.toString().equals(request.getMethod())) { return true; }
问题4:获取不到请求头中的Token。
- 排查 :
- 前端是否正确设置了头?名称是否是
Authorization? - 后端是否因为CORS配置,没有接收到这个头?检查CORS配置是否包含了
Authorization头。// Spring Boot CORS配置示例 @Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(“/**“) .allowedOriginPatterns(“*“) // 生产环境应指定具体域名 .allowedMethods(“GET”, “POST”, “PUT”, “DELETE”, “OPTIONS”) .allowedHeaders(“*“) .exposedHeaders(“Authorization”) // 暴露Authorization头,某些浏览器需要 .allowCredentials(true); // 如果前端需要传Cookie,设为true } }
- 前端是否正确设置了头?名称是否是
6.3 联调工具与技巧
- 使用Postman或ApiFox测试后端接口 :先绕过前端,直接用工具测试登录接口和受保护的接口,确保后端逻辑正确。在工具的
Authorization选项卡中选择Bearer Token类型填入Token。 - 查看浏览器Application面板 :查看
Local Storage里存储的Token和用户信息是否正确。 - 查看浏览器Network面板 :这是最重要的调试工具,查看每一个请求的请求头、响应状态码和响应体。
- 后端开启详细日志 :在
application.yml中设置logging.level.com.yourpackage: DEBUG,查看拦截器、JWT解析的详细日志。 - 手动构造和解析JWT :遇到JWT格式问题时,可以到 jwt.io 这个网站,粘贴你的Token和Secret,手动解码和验证,非常直观。
整个实现过程就像搭积木,前后端各司其职,通过一个简单的字符串(Token)和一套约定好的规则进行通信。关键在于理解每个环节的目的和可能出现的问题点。希望这篇超详细的总结,能帮你一次性打通任督二脉,少走弯路。在实际项目中,根据你的具体业务需求,再对权限模型、令牌管理策略进行微调即可。
更多推荐



所有评论(0)