1. 项目背景与技术选型为什么是这套组合先聊聊这个项目的起点。一年多前我在某公司负责一个内部管理系统的重构旧系统用的是单体架构加Session会话管理每次用户登录后服务端存一份Session前端带着Cookie去访问。随着用户量增长和前后端分离的推进这套方案的痛点越来越明显Session在分布式部署下要额外引入共享存储、Cookie跨域处理麻烦、移动端接入困难。于是我们决定用市面上最主流的方案来做——Spring Boot作为后端基础框架Vue 3作为前端渲染层Token机制替代Session形成一套无状态认证体系。选择Spring Boot的原因不用多讲它的自动配置和生态几乎成了Java后端的事实标准配合Spring Security或者Sa-Token框架能快速搭建安全认证。选择Vue 3是因为组合式API的代码组织方式更适合中大型项目配合Pinia状态管理前端存储用户信息和Token状态非常顺手。而Token方案的核心目的就一个让服务端不需要维护用户会话状态认证信息全部由客户端保存服务端只负责验证签名和有效期。这套组合解决了什么问题拿我们实际场景举例一下。当时系统需要支持PC端、手机浏览器和未来的小程序端如果用Session每个端都要处理Cookie跨域问题而Token配合请求头Authorization传递天然支持跨域和多种客户端接入。再加上前端路由守卫和HTTP拦截器能实现页面访问控制跟接口鉴权的双重防护整体安全性比单纯的Session方案要灵活得多。如果你最近也在做前后端分离项目或者打算把老项目从Session换到Token认证体系这篇文章可以当一份踩坑实录看。我会把从零到一搭建权限认证的关键步骤、参数配置细节和排错经验都摊开来讲。2. 安全验证机制的核心逻辑拆解2.1 为什么Token方案能替代Session要理解Token方案先明确SESSION和TOKEN的本质差异。Session的数据存在服务端服务端靠一个随机字符串Session ID去内存或Redis里查用户信息查到了就放行。Token的数据本身就存在令牌里服务端拿到Token后只需要验证这个Token是不是合法签发的、有没有过期验证通过后用户信息是从Token里解析出来的而不是去存储中查出来的。Token的典型实现是JWTJSON Web Token结构分为三部分Header头部、Payload载荷、Signature签名。Header声明了类型和签名算法Payload放用户ID、过期时间、角色等业务声明Signature则通过服务端密钥对前两部分做HMAC-SHA256运算生成。Header: {alg:HS256,typ:JWT} Payload: {userId:1001,username:admin,role:admin,exp:1715000000} Signature: HMACSHA256( base64UrlEncode(Header).base64UrlEncode(Payload), secretKey )三部分用点号拼接成一个完整的Token字符串。由于Payload是Base64编码而非加密绝对不能把密码、手机号等敏感信息写进Payload否则任何拿到Token的人都能用Base64解码看到明文。签名才是安全的核心密钥只要不泄露Token内容就不可伪造。2.2 为什么我们用无状态设计而没选Redis存Token无状态认证最大的优势是后端接口不需要查询存储直接验签即可放行对水平扩容极其友好。但这里有个容易被忽略的痛点——无状态Token一旦签发在有效期之内无法主动使其失效。用户修改密码、管理员封禁账号、用户登出这些场景都需要一个撤销Token的机制。当时我们做了个折中方案短周期Token加上Redis黑名单。Token有效期设置30分钟被撤销的Token的JWT IDjti字段存入Redis黑名单黑名单过期时间与Token剩余有效期一致。Retry一次请求时先查Redis黑名单命中则拒绝访问。这样既保留了无状态验签的高性能又解决了主动失效的问题。我见过一些团队为了省事Token有效期直接设7天Redis都没接结果用户改密码后旧Token还能继续用七天安全风险极高。如果你线上环境已经有Redis多一步查黑名单的代价非常小但安全性提升很明显。2.3 签名算法选择和密钥管理JWT支持的签名算法大致有几种HS256对称密钥、RS256非对称RSA、ES256椭圆曲线。我自己的选择依据很简单——单服务端应用或者API网关统一签发Token用HS256足够了半天内验签性能最好如果涉及第三方系统信任关系需要让他们自己用公钥验签才需要RS256这类非对称算法。HS256的密钥管理是安全核心。网上很多例子直接写死一个字符串常量这在生产环境是绝对要避免的。我们用配置中心保存密钥发布环境隔离开发库、测试库、生产库各用不同密钥。另外密钥定期轮换要提前规划在签发和验签两侧都做好新旧密钥兼容时段否则轮换瞬间所有存量Token全部失效用户被迫全员重新登录。3. 后端核心实现Spring Boot集成Token认证3.1 依赖引入和基础配置后端我们用Spring Boot 2.7 Sa-Token框架。当时我没有直接用Spring Security原因是项目里需要频繁自定义认证逻辑Spring Security的过滤器链对初学者不友好而Sa-Token高度封装了登录、鉴权、注销、踢人下线等常见操作配合注解控权非常省事。如果你习惯Spring Security后面讲的思路一样适用只是API不同。dependency groupIdcn.dev33/groupId artifactIdsa-token-spring-boot-starter/artifactId version1.37.0/version /dependency在application.yml中加入基本配置sa-token: token-name: Authorization timeout: 1800 active-timeout: -1 is-concurrent: false is-share: false token-style: uuid is-log: true几个关键参数的含义我解释下。token-name设置从前端请求头取Token的key默认是satoken。timeout是Token有效期单位秒1800就是30分钟。is-concurrent设为false表示同一个账号只能有一处登录。token-style是Token字符串的生成风格Demo用uuid如果想直接生成JWTSa-Token也提供了集成JWT的插件。3.2 登录接口的签发流程登录接口是认证的第一道门。我们提供用户名密码登录校验通过后调用Sa-Token的登录方法它会自动生成Token并返回给前端public AjaxResult login(LoginDTO dto) { // 1. 校验验证码防止暴力破解 if (!captchaService.verify(dto.getUuid(), dto.getCode())) { return AjaxResult.error(验证码错误); } // 2. 查询用户信息 SysUser user userService.getUserByUsername(dto.getUsername()); if (user null || !passwordEncoder.matches(dto.getPassword(), user.getPassword())) { return AjaxResult.error(用户名或密码错误); } // 3. 检查用户状态 if (user.getStatus() ! 1) { return AjaxResult.error(账号已被禁用); } // 4. 登录成功后生成Token StpUtil.login(user.getId()); String tokenValue StpUtil.getTokenValue(); userService.refreshLoginInfo(user.getId()); return AjaxResult.success(登录成功, tokenValue); }这里我特别想强调密码存储问题。现在很少见有人直接存明文密码了但存的可能是弱哈希比如MD5、SHA1这是重大安全隐患。我们用的是BCrypt加密每次校验时调用Spring Security的BCryptPasswordEncoder去匹配同一密码每次加密结果都不同攻击者没法用彩虹表直接碰撞。3.3 使用注解控制接口权限Sa-Token最方便的地方是各种鉴权注解。登录后才能访问的接口加**SaCheckLogin**需要特定权限的加**SaCheckPermission**按角色控制的加**SaCheckRole**RestController RequestMapping(/api/user) public class UserController { SaCheckLogin GetMapping(/info) public AjaxResult getCurrentUser() { return AjaxResult.success(StpUtil.getLoginId()); } SaCheckPermission(system:user:add) PostMapping(/add) public AjaxResult add(RequestBody SysUser user) { return AjaxResult.success(userService.save(user)); } SaCheckRole(admin) DeleteMapping(/delete/{id}) public AjaxResult delete(PathVariable Long id) { userService.removeById(id); return AjaxResult.success(); } }这样做的价值是权限控制从散落在每个接口的if判断变成了声明式注解。新接手的同事看到注解就知道这个接口有什么限制不需要翻业务代码。权限不足时Sa-Token会抛出NotPermissionException我们注册全局异常处理器统一返回403响应。需要说明的是注解鉴权适合权限点比较固定的模块。如果你的权限模型是动态的比如不同角色能访问不同的数据字典建议把权限校验逻辑下沉到Service层用StpUtil.checkPermission()方法按实际业务条件执行而不是把接口级权限全部写在Controller上。3.4 自定义Token过滤器与跨域配置除了框架自带的拦截器我们还加了一个自定义过滤器做请求日志和基础安全校验。这个过滤器主要做三件事放行预检请求、统一设置响应头、记录耗时。核心代码其实不长Component public class RequestFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; resp.setHeader(Access-Control-Allow-Origin, req.getHeader(Origin)); resp.setHeader(Access-Control-Allow-Methods, GET, POST, PUT, DELETE, OPTIONS); resp.setHeader(Access-Control-Allow-Headers, Authorization, Content-Type, X-Requested-With); resp.setHeader(Access-Control-Allow-Credentials, true); if (OPTIONS.equalsIgnoreCase(req.getMethod())) { resp.setStatus(HttpStatus.SC_OK); return; } long startTime System.currentTimeMillis(); chain.doFilter(request, response); long costTime System.currentTimeMillis() - startTime; log.info(Request: {} {}, Cost: {}ms, req.getMethod(), req.getRequestURI(), costTime); } }跨域配置是前后端分离项目几乎必踩的坑。如果你的接口在域名A前端在域名B不处理跨域浏览器会直接拦截响应。上面的过滤器处理了CORS跨域头CREDENTIALS设置为true表示允许携带Cookie但我们实际主要靠Authorization请求头传TokenCookie用得非常少。4. 前端核心实现Vue 3 Token管理的工程化实践4.1 请求拦截器和响应拦截器的分层设计Vue 3项目里Axios是主流的HTTP客户端。我们在项目中建了一个http模块实例化Axios后做统一拦截处理。这里面有几个细节值得分享——请求拦截器统一加Token响应拦截器统一处理业务码和HTTP异常import axios from axios import { ElMessage } from element-plus import { useUserStore } from /store/user import router from /router const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000 }) // 请求拦截器 service.interceptors.request.use( config { const userStore useUserStore() if (userStore.token) { config.headers[Authorization] userStore.token } return config }, error { return Promise.reject(error) } ) // 响应拦截器 service.interceptors.response.use( response { const res response.data // 后端约定 state 为 200 表示成功 if (res.state ! 200) { ElMessage.error(res.message || 系统错误) if (res.state 401) { handleAuthExpired() } return Promise.reject(new Error(res.message || Error)) } return res }, error { if (error.response error.response.status 401) { handleAuthExpired() } else { ElMessage.error(error.message || 网络异常) } return Promise.reject(error) } ) function handleAuthExpired() { const userStore useUserStore() userStore.resetToken() router.push(/login) } export default service这里有个容易忽视的处理逻辑——后端返回的未登录状态既可能用HTTP状态码401表达也可能用业务状态码表达比如200回包里带state401所以我在两个地方都做了判断。加上请求拦截器统一携带Token业务代码里一个登录态相关的东西都不用管了。4.2 路由守卫如何配合Token做页面访问控制前端的安全防线不能只靠后端接口拦截不然用户在界面操作时会频繁触发401跳转体验很差。更合理的做法是配合Vue Router的路由守卫在路由跳转前判断页面是否需要登录权限。我们用了两级路由守卫。全局前置守卫检查Token存在性和页面白名单动态路由机制根据用户权限过滤出菜单页面。简化后的全局守卫长这样router.beforeEach(async (to, from, next) { document.title getPageTitle(to.meta.title) NProgress.start() const userStore useUserStore() // 免登录页面直接放行 if (to.path /login) { if (userStore.token) { return next(/) } return next() } // 无Token一律去登录页 if (!userStore.token) { return next(/login?redirect${to.fullPath}) } // 有Token但没有用户信息尝试拉取 if (!userStore.userInfo) { try { await userStore.fetchUserInfo() // 动态生成可访问路由 const dynamicRoutes generateRoutes(userStore.permissions) dynamicRoutes.forEach(route router.addRoute(route)) return next({ ...to, replace: true }) } catch (error) { await userStore.resetToken() return next(/login?redirect${to.fullPath}) } } return next() })重点说一下redirect参数的传递用户访问一个受保护页面发现Token过期跳转到登录页时带上当前路径登录成功后还能回到原来的页面。不要小看这个设计大型管理系统里用户收藏了某个详情页长时间后重新打开能直接回到原页面而不是被丢进首页体验差异非常明显。4.3 Pinia状态管理和持久化方案Vue 3项目里我们用Pinia替代Vuex管理全局状态。Token和用户信息放在单独的user store里提供login、logout、fetchUserInfo等action。Pinia没有自动持久化功能Token如果只存在内存刷新页面就没了所以必须配合本地存储。export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , userInfo: null }), actions: { login(token) { this.token token localStorage.setItem(token, token) }, logout() { this.token this.userInfo null localStorage.removeItem(token) }, async fetchUserInfo() { const res await getUserInfo() this.userInfo res.data } } })我个人的建议是核心Token放localStorage用户资料不持久化刷新后重新拉取。之前有团队为了省一次请求把用户角色和权限列表也持久化到localStorage结果管理员给用户改完权限用户浏览器里还是旧数据必须强制刷新甚至清缓存才能生效这种问题排查起来非常痛苦。宁可刷新页面时多一次请求换取数据的强一致性。5. 安全防护细节与常见攻击面5.1 Token注入和XSS攻击的防御Token保存在localStorage有一个天然风险——任何能执行JavaScript的XSS漏洞都能把Token偷走。为了降低这个风险我们做了几层防护第一层后端对所有返回前端的字符串做HTML转义Vue模板默认也是转义输出的写代码时禁用v-html处理用户提交内容。如果有富文本展示需求必须在富文本组件层面做白名单过滤只保留img、a、p等安全标签和属性。第二层关键操作单独校验。比如修改密码、解绑手机这类操作除了登录态验证我们还要求输入原密码或者短信验证码相当于二次校验。即使攻击者通过脚本拿到了Token也操作不了高敏感动作。第三层前端容器层加安全响应头。我们在Nginx配置了CSPContent-Security-Policy禁止加载非白名单来源的脚本极大限制了XSS payload的执行空间。关于localStorage和SessionStorage的选择如果浏览器标签页关闭后希望登录态自动失效可以考虑SessionStorage但用户体验会差一些用户经常抱怨我关个标签页就要重新登录。折中方案是我们目前用的localStorage加短有效期Token30分钟后自动过期配合操作无感刷新机制体验和安全相对平衡。5.2 CSRF和重放攻击的应对方式Token放在Authorization请求头本身能免疫掉大部分CSRF攻击因为跨域请求无法自定义请求头。但我们保留Cookie给部分场景使用时会格外小心设置了SameSiteLax跨站请求默认不携带Cookie。重放攻击是Token方案里容易被忽略的一点。攻击者截获某个合法请求原封不动地再次发送如果后端不做校验请求就会被重复执行。我们的处理方式写操作接口引入时间戳参数服务端判断请求时间与服务器时间差超过5分钟拒绝。更严谨的业务比如支付转账还引入了请求唯一序列号每次请求的nonce值记录下来重复的nonce直接拒绝。5.3 双Token方案Access Token配Refresh Token写到这里必须聊聊Access Token和Refresh Token这套成熟的双Token方案。我们最终的生产架构里没有只靠单Token因为把单Token的有效期做得太长泄露风险大做得太短用户频繁重登体验差。双Token解决了这个矛盾Access Token有效期30分钟用于正常接口访问Refresh Token有效期7天只能用来刷新Access Token刷新时后端校验Refresh Token合法性生成新的Access Token返回Refresh Token必须满足两个条件才安全一是存储比Access Token更严格前端可以放localStorage移动端放在系统安全存储区二是只能换取新的Access Token不能访问业务接口。// 刷新Token逻辑简化版 public AjaxResult refreshToken(String refreshToken) { // 验证Refresh Token SaJwtUtil simple ...; boolean isValid simple.verifyToken(refreshToken); if (!isValid) { return AjaxResult.error(401, 登录已过期请重新登录); } // 黑名单中则拒绝 if (redisTemplate.hasKey(RedisKeys.REFRESH_BLACKLIST refreshToken)) { return AjaxResult.error(401, RefreshToken已失效); } // 签发新的Access Token String newAccessToken createAccessToken(userId); return AjaxResult.success(newAccessToken); }双Token方案上线后用户只要7天内活跃过就不用重新登录而实际访问接口的Token因为有效期短即使被盗攻击窗口也只有30分钟。这是业界比较成熟的做法如果你准备在生产环境长期运行强烈建议采用。6. 踩坑实录常见问题定位与排查技巧6.1 响应头跨域配置不生效初版上线时前端请求总报跨域错误排查半天发现是过滤器里写了CORS头但还是被Nginx拦了。注意一个细节——如果你用Nginx做反向代理且前端页面在另一个域名Nginx需要额外加headerlocation /api/ { add_header Access-Control-Allow-Origin $http_origin; add_header Access-Control-Allow-Methods GET, POST, PUT, DELETE, OPTIONS; add_header Access-Control-Allow-Headers Authorization, Content-Type; if ($request_method OPTIONS) { return 204; } }很多问题的根源是后端设了跨域头但Nginx没透传或自己挡了OPTIONS预检请求。排查时用Chrome开发者工具看网络请求的Response Headers对比是否存在Access-Control-Allow-Origin、Access-Control-Allow-Headers这两个字段一目了然。6.2 Token过期后前端跳转陷入死循环运行一段时间后我们遇到一个隐蔽的Bug——Access Token过期后续接口全部401前端自动跳去登录页。但登录页的路由守卫里写了有Token就重定向到首页于是从登录页又跳回首页首页拉取用户信息又触发401再跳到登录页页面疯狂闪跳无法操作。问题出在跳转登录页前没有清除失效的Token。修复方式是在handleAuthExpired中先调用resetToken清空本地Token再跳转登录页守卫。另外有Token但Token已失效的情况下登录页守卫不应该重定向首页需要额外判断Token是否存在且未失效。类似这种守卫逻辑建议写之前先画一张状态转换图理清有Token无Token和Token有效失效的交叉组合。6.3 用户权限变更不生效动态路由方案里用户登录时生成了可访问路由。如果后台管理员修改了用户角色用户的旧路由权限还会一直存在直到重新登录。针对这个问题我们做了两个动作一是保存用户信息的推送/定时刷新Token刷新时重新拉取用户权限二是提供一个刷新权限接口前端在每次路由跳转时刷新用户信息发现权限版本号变化就重新生成动态路由。这里要提醒一个注意点动态路由的清理比添加更麻烦。Vue Router没有提供removeRoute时我们是记录所有动态路由的name在重新生成前逐个删除// 清理动态路由 dynamicRoutes.forEach(route { if (route.name router.hasRoute(route.name)) { router.removeRoute(route.name) } })6.4 服务端密钥硬编码和日志泄露这是我见过最严重的安全事故隐患。有些同事图方便把JWT签名密钥直接写在代码常量里然后打日志时不小心把Token打出来了。日志系统一般不会做脱敏攻击者拿到日志文件等于拿到了所有签名密钥和Token整个认证体系瞬间崩溃。建议强制要求生产密钥从配置中心或环境变量注入禁止出现在代码和配置仓库所有日志统一加脱敏工具类对Token、密码、身份证等字段进行掩码处理密钥至少半年轮换一次轮换期间兼容新旧Token。6.5 常见问题速查对照现象可能原因排查命令/手段前端请求报跨域缺少CORS响应头或Nginx拦截预检浏览器Network面板检查响应头缺失则补Access-Control-Allow-*接口一直401Token过期、前后端Token字段名不一致、Token未加到请求头后端日志打印收到Token值前端检查拦截器是否有config.headers登录成功后跳回登录页路由守卫死循环、Cookie域问题控制台看跳转路径检查守卫逻辑清除本地Token重试动态路由不显示菜单角色权限码不匹配、后端返回权限树格式错误打印接口返回权限数据核对角色码和前端权限指令用户状态变化不生效权限未重新拉取、路由未重建调刷新权限接口重新生成动态路由后再跳转后端偶发无响应Token验签耗时过长、Redis黑名单查询太慢查看接口耗时日志检查Redis连接和平均响应时间7. 后续演进方向从单体锁到分布式认证项目跑稳定之后我开始思考这套Token认证体系后续怎么演进。现在系统只有一台后端服务Token验签完全没问题。但如果用户量继续涨后端要拆成多个微服务就会面临一个新问题——每个服务都持有相同的签名密钥吗还是有专门的服务统一负责登录目前比较主流的方向是引入网关层统一认证。用户只在网关登录一次网关负责Token验签之后转发到内部微服务时通过请求头透传用户信息内部服务不再关心Token细节。我们正在测试的方案是网关用Sa-Token整合效果不错Sa-Token本身就支持微服务模式下只用一个统一鉴权服务其他服务依赖Redis做会话同步。另一个方向是尝试更细粒度的权限模型。现在我们的权限控制停留在谁能访问这个接口的层面但随着业务深入还要考虑谁能访问这批数据。比如同为销售角色华东区的销售只能看自己区域的数据这就是数据权限。这块配合Spring Boot的AOP自定义注解来做字段校验会比现在复杂不少但价值的提升也相当明显。安全验证从来不是做完一版就结束的工作它更像一场持续的攻防对抗。每次有新漏洞类型出现认证体系都要跟着迭代。好在Spring Boot和Vue 3这套生态足够活跃社区方案更新也快留给我们解决问题的路径始终是清晰的。就我个人经验来说这套架构从设计到落地最值得投入精力的反而不是Token本身的生成和验证而是围绕Token的一系列配套机制——过期处理、跨域配置、动态权限、双Token刷新、黑名单管理、密钥轮换。把每个环节都在项目初期考虑进去后期运维会轻松非常多。如果你也正准备做类似的项目建议不要只搭一个能跑通的Demo多想一想生产环境会遇到什么情况踩过的坑会少很多。
阅读完成 · 觉得有帮助?