1. 项目概述与核心价值最近在做一个内部管理系统登录和权限这块儿团队里讨论了好几次。一开始想直接用Spring Security但考虑到项目体量不大团队成员对Security的学习曲线有点犯怵配置起来也相对繁琐。后来有人提到了sa-token一个轻量级的Java权限认证框架我花了一个周末研究了一下发现它确实把登录、鉴权、会话管理这些事儿变得异常简单。结合JWT做无状态令牌再用拦截器统一处理权限校验整个流程清晰又高效。这个Demo就是我当时搭建的一个原型跑通了从用户登录、签发JWT令牌、到接口权限拦截的全过程。如果你也在寻找一个比Spring Security更轻快、比手动撸JWT更规范的权限解决方案特别是对于中小型SpringBoot项目这套组合拳值得你花时间了解一下。它能让你的认证授权代码变得非常整洁把精力更多地聚焦在业务逻辑本身。2. 技术栈选型与设计思路拆解2.1 为什么是 sa-token JWT在开始敲代码之前得先想清楚为什么选这两个技术。权限框架的核心诉求无非几点易于集成、功能完备、性能可靠、学习成本低。首先看sa-token。它不像Spring Security那样“重”不需要你理解一大堆过滤器链、投票器、决策管理器等复杂概念。sa-token的核心抽象非常直接一个StpUtil工具类提供了login、checkLogin、hasRole、hasPermission等开箱即用的方法几乎是对业务逻辑最直观的映射。对于大部分后台管理系统需要的登录、踢人、权限校验、会话管理等功能它都封装好了。它的轻量体现在“约定大于配置”很多行为通过注解和简单配置就能搞定这大大降低了初学者的心智负担。再看JWTJSON Web Token。在分布式或前后端分离架构中传统的Session模式会遇到扩展性和跨域问题。JWT是一种无状态的令牌机制它将用户信息、有效期等数据通过签名直接编码在令牌字符串里服务端无需存储会话状态仅靠验证签名和令牌内容即可完成认证。这非常适合RESTful API。但纯JWT也有痛点比如令牌无法主动失效只能等过期、权限信息更新延迟等。而sa-token的巧妙之处在于它可以用JWT作为令牌的“载体”同时自身维护一套轻量的“逻辑会话”从而弥补了JWT的不足实现了诸如强制下线、账号封禁等动态控制能力。所以这个组合的设计思路是利用JWT作为跨服务、无状态的身份凭证传递标准利用sa-token作为服务端统一、强大的会话与权限管理内核。拦截器则作为桥梁在请求到达Controller之前统一完成令牌的解析、校验和权限信息的注入。2.2 整体架构与数据流设计整个Demo的运行时数据流可以这样理解用户登录客户端提交用户名密码。服务端验证通过后sa-token会以用户ID为key在内存或Redis如果集成的话中创建一个逻辑会话并生成一个对应的JWT字符串作为这个会话的“令牌凭证”返回给客户端。令牌携带客户端如Vue/React前端在后续请求的HTTP Header通常是Authorization: Bearer xxxx中携带此JWT。拦截器鉴权自定义的Spring MVC拦截器会拦截所有或指定路径请求。它从Header中取出JWT交给sa-token进行校验。校验包括JWT签名是否有效、是否过期、对应的逻辑会话是否有效是否被踢下线等。权限校验如果令牌有效拦截器或通过sa-token的注解如SaCheckPermission(“user:add”)进行细粒度的权限点校验。请求放行所有校验通过后请求才会到达真正的业务Controller。此时你可以通过StpUtil.getLoginId()安全地获取当前登录用户的ID。这个架构保证了安全校验逻辑与业务逻辑的高内聚、低耦合。所有和安全相关的脏活累活都被拦截器和sa-token在边界处处理干净了。3. 核心依赖引入与基础配置3.1 Maven依赖配置项目基于SpringBoot 2.7.x同样适用于3.x注意部分依赖groupId变化。在pom.xml中需要引入以下核心依赖dependencies !-- SpringBoot Web 基础 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- Sa-Token 核心包 -- dependency groupIdcn.dev33/groupId artifactIdsa-token-spring-boot-starter/artifactId version1.37.0/version !-- 请使用最新稳定版 -- /dependency !-- Sa-Token 集成 JWT -- dependency groupIdcn.dev33/groupId artifactIdsa-token-jwt/artifactId version1.37.0/version scopecompile/scope /dependency !-- 常用工具包如 Lombok -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies注意sa-token-jwt模块包含了JWT生成和解析的逻辑它依赖于一个JWT实现如jjwt。直接引入这个starter即可无需再单独引入其他JWT包避免了版本冲突。3.2 核心配置文件详解接下来是application.yml或application.properties的配置。这里面的每一项都关系到框架的行为模式。server: port: 8080 spring: application: name: sa-token-demo # Sa-Token 配置 sa-token: # 令牌名称也是前端提交令牌时使用的 header 字段名 token-name: Authorization # 令牌有效期单位秒默认30天这里设为2小时方便测试 timeout: 7200 # 是否允许同一账号并发登录为true时同一账号可在不同设备同时登录 is-concurrent: true # 在多人登录同一账号时是否共用一个token为true时所有登录共用一个token is-share: false # 令牌风格此处使用 uuid 风格但我们会用 JWT 覆盖输出 token-style: uuid # 是否输出操作日志 is-log: true # JWT 专用配置 jwt: # JWT 秘钥非常重要生产环境务必使用长且复杂的随机字符串并从安全渠道获取 secret-key: aVeryLongAndComplexSecretKeyThatYouShouldChangeInProduction # 自定义参与签名的字段可选默认包含所有字段 # include-fields: loginId, device配置项深度解析token-name: Authorization这个配置至关重要。它告诉sa-token前端传来的令牌放在HTTP Header的Authorization字段里。这符合RESTful API的常见约定Bearer Token方案。拦截器会主动从这个字段读取值。timeout与JWT过期时间这里配置的timeout是sa-token逻辑会话的有效期。而JWT自身的过期时间(expclaim)是由sa-token-jwt模块在创建令牌时自动根据这个timeout值设置的。两者保持同步确保逻辑状态和令牌凭证同时失效。is-concurrent和is-share这两个配置管理了多端登录的行为。is-concurrent: true允许同一账号在手机、电脑同时在线每个登录会生成独立的token和会话。is-share: false意味着这些会话不共享token一个设备下线不影响另一个。根据你的业务安全要求如银行APP通常不允许并发登录来调整。jwt.secret-key这是签名JWT的密钥。生产环境绝对不要使用示例中的简单字符串必须使用足够长如32位以上、足够随机的密钥并像保护数据库密码一样保护它最好从环境变量或配置中心读取。4. 核心组件实现登录、JWT与拦截器4.1 用户登录与JWT签发接口首先我们创建一个简单的AuthController来处理登录登出。这里模拟用户数据真实项目应连接数据库。RestController RequestMapping(/auth) public class AuthController { /** * 模拟用户查询真实项目应从数据库查询 */ private UserEntity findUserByUsername(String username) { // 模拟一个用户id10001密码是加密后的“123456”明文密码不要存储 if (zhangsan.equals(username)) { UserEntity user new UserEntity(); user.setId(10001L); user.setUsername(zhangsan); user.setPassword($2a$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx); // BCrypt加密后的密码 // 模拟用户权限 user.setPermissions(Arrays.asList(user:view, user:add, article:edit)); return user; } return null; } PostMapping(/login) public ApiResponse login(RequestBody LoginRequest request) { // 1. 查询用户 UserEntity user findUserByUsername(request.getUsername()); if (user null) { return ApiResponse.fail(用户不存在); } // 2. 校验密码真实项目应使用BCrypt等加密算法对比 // 此处为演示简化密码校验。实际应为BCrypt.checkpw(request.getPassword(), user.getPassword()) if (!123456.equals(request.getPassword())) { return ApiResponse.fail(密码错误); } // 3. 密码校验成功后使用Sa-Token进行登录 // 此操作会 // a. 以用户ID为key在当前服务器或Redis创建一个逻辑会话。 // b. 生成一个Token根据配置此时会生成JWT格式的Token。 // c. 将这个Token返回给前端。 StpUtil.login(user.getId()); // 4. 获取登录成功后生成的Token此时已经是JWT字符串 String token StpUtil.getTokenValue(); // 5. 构造返回结果通常将Token放在响应体或Header中这里放在响应体。 LoginResponse response new LoginResponse(); response.setUserId(user.getId()); response.setToken(token); // 可以返回一些额外信息如用户名、权限列表注意敏感信息 response.setPermissions(user.getPermissions()); return ApiResponse.ok(登录成功, response); } PostMapping(/logout) public ApiResponse logout() { // 调用此方法会使当前客户端的登录状态失效。 // 对于JWT这意味着服务端会删除对应的逻辑会话即使JWT本身未过期也已无法通过校验。 StpUtil.logout(); return ApiResponse.ok(登出成功); } }关键点解析StpUtil.login(user.getId())这是sa-token登录的核心。参数user.getId()是这个会话的“账号ID”它必须是唯一标识。登录成功后框架会生成Token我们配置了JWT所以是JWT格式并自动通过StpUtil.getTokenValue()获取。无状态与有状态的结合虽然我们返回了JWT但StpUtil.login()同时在服务端创建了一个逻辑会话默认内存存储。这个会话存储了诸如loginId、登录设备、登录时间等元信息。当拦截器校验JWT时sa-token不仅验证JWT签名和过期时间还会检查这个JWT对应的逻辑会话是否存在、是否有效。这就实现了“有状态”的管理能力如强制下线。密码处理示例中简化了密码校验。实战中必须使用BCrypt、SCrypt等自适应哈希算法存储和校验密码绝对不要明文存储或比较。4.2 自定义JWT生成策略可选但重要默认情况下sa-token-jwt生成的JWT负载Payload包含的是sa-token内部会话的tokenValue。有时我们希望JWT里直接携带一些业务字段如userId,username减少后续查询。我们可以通过自定义SaJwtTemplate来实现。Component public class CustomJwtTemplate extends SaJwtTemplate { /** * 重写生成Token的逻辑 */ Override public String generateToken(Object loginId, String device, long timeout) { // 1. 先调用父类方法生成基础的JWT Map包含了框架必需的字段如 loginId, device, timeout 等 MapString, Object payload generatePayload(loginId, device, timeout); // 2. 在此Map中添加自定义的业务字段 // 例如根据loginId查询用户信息这里模拟 if (loginId.equals(10001L)) { payload.put(userId, loginId); payload.put(username, zhangsan); payload.put(role, admin); } // 3. 使用配置的密钥将整个Map签名并生成最终的JWT字符串 return createToken(payload); } /** * 重写解析Token的逻辑如果需要从自定义字段反解析信息 */ Override public Object parseToken(String token) { // 解析JWT获取负载Map MapString, Object payload parseTokenToMap(token); // 你可以在这里从payload中取出自定义字段进行一些额外校验或设置到上下文 // 例如String username (String) payload.get(“username”); // 框架主要关心的是loginId它会自动从payload的默认字段中获取。 return payload; } }注意JWT负载不宜过大因为每个请求都要携带。通常只放频繁使用、非敏感的用户标识信息。权限列表等变化频繁或数据量大的信息不适合放在JWT里仍建议通过会话或每次查询获取。4.3 全局鉴权拦截器实现这是将sa-token鉴权能力接入SpringBoot Web请求链的关键。我们创建一个实现HandlerInterceptor的拦截器。Component public class SaTokenInterceptor implements HandlerInterceptor { /** * 在Controller方法执行前调用 */ Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1. 如果是静态资源或OPTIONS预检请求直接放行 if (handler instanceof ResourceHttpRequestHandler) { return true; } if (HttpMethod.OPTIONS.toString().equals(request.getMethod())) { return true; } // 2. 执行Sa-Token的全局登录校验 // 该方法会 // a. 从配置的token-name对应的Header即Authorization中读取Token。 // b. 校验Token有效性格式、签名、过期时间。 // c. 根据Token找到对应的逻辑会话校验会话是否有效。 // d. 如果全部通过将登录ID存储到当前请求的上下文中。 try { SaManager.getStpLogic().checkLogin(); } catch (NotLoginException e) { // 3. 如果校验失败未登录、Token无效、会话失效则统一返回401状态码和错误信息 response.setContentType(application/json;charsetUTF-8); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); ApiResponse errorResp ApiResponse.fail(e.getMessage()); response.getWriter().write(JSON.toJSONString(errorResp)); return false; } // 4. 校验通过放行请求 return true; } }拦截器注册创建完拦截器后需要将其注册到Spring MVC的拦截器链中。Configuration public class WebMvcConfig implements WebMvcConfigurer { Autowired private SaTokenInterceptor saTokenInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(saTokenInterceptor) .addPathPatterns(/api/**) // 拦截所有/api开头的请求 .excludePathPatterns(/auth/login, /auth/logout) // 排除登录登出接口 .excludePathPatterns(/swagger**/**, /webjars/**, /v3/api-docs/**) // 排除Swagger .excludePathPatterns(/error); // 排除错误页面 } }拦截器工作流程深度解析请求拦截所有匹配/api/**的请求都会先经过这个拦截器。Token提取SaManager.getStpLogic().checkLogin()内部会调用StpUtil.getTokenValue()而该方法会按照配置的token-name即Authorization从HttpServletRequest中读取Token值。它支持从Header、Cookie、请求体参数等多处读取优先级可配置。综合校验这是最关键的一步。校验不是简单的JWT验签。其内部流程是解析JWT使用secret-key验证签名并解析出Payload中的核心字段如loginId。查询会话用解析出的tokenValue或loginId作为key去查找sa-token维护的逻辑会话默认在内存的HashMap里集成Redis后则在Redis中。会话状态检查检查会话是否存在、是否过期timeout、是否被标记为disable禁用或已被踢下线。上下文注入如果所有检查通过sa-token会将当前loginId绑定到当前线程的上下文中通过ThreadLocal这样在后续的Controller或Service里就能通过StpUtil.getLoginId()直接获取。异常处理如果任何一步失败如Token缺失、签名错误、会话不存在checkLogin()会抛出特定的NotLoginException异常。我们在拦截器中捕获它并返回标准的401 Unauthorized响应告知前端“身份认证失败”。这比在每个Controller里写校验代码要优雅和统一得多。5. 精细化权限校验与注解应用全局拦截器保证了用户是登录状态但很多场景下我们还需要更细粒度的权限控制比如“用户管理”接口只允许有user:write权限的人访问。sa-token提供了极其方便的注解式权限校验。5.1 权限注解使用示例假设我们有一个用户管理的UserControllerRestController RequestMapping(/api/user) public class UserController { GetMapping(/profile) SaCheckLogin // 校验当前请求是否已登录等同于在方法内调用 StpUtil.checkLogin() public ApiResponse getProfile() { Long userId StpUtil.getLoginIdAsLong(); // 安全地获取当前登录用户ID // ... 查询用户资料逻辑 return ApiResponse.ok(获取成功, userProfile); } PostMapping SaCheckPermission(user:add) // 校验当前登录用户是否拥有user:add这个权限码 public ApiResponse addUser(RequestBody UserDto userDto) { // 如果用户没有user:add权限请求根本不会进入这个方法 // ... 添加用户逻辑 return ApiResponse.ok(用户添加成功); } DeleteMapping(/{id}) SaCheckPermission(user:delete) SaCheckRole(admin) // 同时要求拥有admin角色 public ApiResponse deleteUser(PathVariable Long id) { // 必须同时拥有user:delete权限和admin角色才能执行删除 // ... 删除用户逻辑 return ApiResponse.ok(用户删除成功); } GetMapping(/list) SaCheckPermission(value {user:view, user:audit}, mode SaMode.OR) // 拥有user:view或user:audit任一权限即可 public ApiResponse getUserList() { // ... 获取用户列表逻辑 return ApiResponse.ok(查询成功, userList); } }注解原理这些注解SaCheckLogin,SaCheckPermission,SaCheckRole是通过Spring AOP实现的。sa-token提供了一个拦截器会扫描这些注解并在方法执行前进行相应的校验。校验逻辑依赖于当前线程上下文中已绑定的登录ID这正是我们全局拦截器preHandle方法所做的工作。5.2 权限数据从哪里来注解校验时sa-token需要知道当前登录用户拥有哪些权限和角色。这需要通过实现StpInterface接口来告诉框架。Component public class StpInterfaceImpl implements StpInterface { /** * 返回一个账号所拥有的权限码集合 * 这个权限码集合就是 SaCheckPermission(user:add) 中校验的“user:add” */ Override public ListString getPermissionList(Object loginId, String loginType) { // 根据loginId从数据库或缓存中查询该用户的权限列表 // 这里为了演示返回一个静态列表。真实项目应从数据库查询。 ListString permissionList new ArrayList(); if (loginId.equals(10001L)) { permissionList.add(user:view); permissionList.add(user:add); permissionList.add(user:edit); permissionList.add(user:delete); permissionList.add(article:edit); } else if (loginId.equals(10002L)) { permissionList.add(user:view); permissionList.add(article:edit); } return permissionList; } /** * 返回一个账号所拥有的角色标识集合 * 这个角色标识就是 SaCheckRole(admin) 中校验的“admin” */ Override public ListString getRoleList(Object loginId, String loginType) { // 根据loginId从数据库或缓存中查询该用户的角色列表 ListString roleList new ArrayList(); if (loginId.equals(10001L)) { roleList.add(admin); roleList.add(supervisor); } else if (loginId.equals(10002L)) { roleList.add(user); } return roleList; } }关键点这个接口的实现是按需加载的。即只有当第一次进行权限或角色校验时框架才会调用对应的方法。你可以在这里执行数据库查询建议配合缓存使用如Redis避免每次校验都查库。loginType参数用于多账号体系鉴权如后台用户和APP用户分开单系统可以忽略。6. 常见问题排查与实战技巧在实际集成和开发过程中你可能会遇到一些典型问题。下面是我踩过的一些坑和解决方案。6.1 问题排查清单问题现象可能原因排查步骤与解决方案登录成功但访问接口返回401 NotLogin1. 前端未正确携带Token。2. 拦截器路径配置错误请求未被拦截校验。3. Token在传输中被修改或损坏。4. 服务器时间不同步导致JWT过期判断异常。1. 检查浏览器开发者工具Network面板确认请求Header中是否有Authorization: Bearer xxx。2. 检查WebMvcConfig中addPathPatterns和excludePathPatterns确保目标接口路径被正确包含或排除。3. 将收到的Token复制到 jwt.io 等调试网站检查是否能正确解析内容是否包含loginId和正确的exp。4. 检查服务器系统时间确保与标准时间同步。拥有权限但注解校验不通过 (NotPermissionException)1.StpInterfaceImpl实现类未正确返回权限列表。2. 权限码大小写或拼写不一致。3. 用户登录后权限信息发生了变更但缓存未更新。1. 在getPermissionList方法中打日志或调试确认传入的loginId和返回的列表是否正确。2. 仔细对比注解中的字符串如user:add和getPermissionList返回的字符串确保完全一致。3. 在修改用户权限后调用StpUtil.getSessionByLoginId(loginId).delete()清除该用户的sa-token会话缓存强制下次校验时重新加载权限。集成Redis后登录状态丢失1. Redis连接失败或配置错误。2. sa-token的Redis序列化方式与存进去的数据不匹配。3. Redis中key的命名空间冲突。1. 检查Redis服务是否正常application.yml中Redis连接配置host, port, password, database是否正确。2. 确保sa-token配置了正确的序列化方式默认Jackson。检查Redis里存储的值是否为JSON格式。3. 检查sa-token的token-prefix配置确保不同应用或环境下的key前缀是唯一的。踢人下线功能无效1. 踢人时使用的loginId不对。2. 被踢用户使用的是旧Token但客户端缓存未更新。3. 未正确集成Redis多服务实例下踢人信息不同步。1. 确认踢人操作StpUtil.kickout(loginId)传入的loginId是目标用户的登录ID。2. 踢人操作会使服务端会话失效但客户端持有的JWT在物理上依然有效直到过期。前端需要在收到特定状态码如401后主动跳转登录页。3. 在集群部署时必须集成Redis等集中式存储确保所有服务实例共享同一会话存储踢人操作才能全局生效。6.2 实战技巧与心得Token存储与传输前端最好将Token存储在localStorage或sessionStorage中并在每次请求时通过AuthorizationHeader携带。避免放在Cookie中以减少CSRF风险。设置Axios等HTTP客户端的请求拦截器来自动添加这个Header。续签与滑动过期对于需要长时间操作的应用如后台管理系统可以开启sa-token的活跃续签功能。在配置文件中设置sa-token.active-timeout为一个大于0的值如1800秒用户在过期时间内有任何操作Token有效期就会自动续期。这比固定过期体验更好。sa-token: # 令牌总有效期默认30天 timeout: 2592000 # 活跃续签有效期单位秒在timeout时间内每次访问都会重置此时间为active-timeout active-timeout: 1800集成Redis实现分布式会话生产环境单机内存存储显然不行。集成Redis非常简单只需引入一个依赖并配置连接。dependency groupIdcn.dev33/groupId artifactIdsa-token-dao-redis/artifactId version1.37.0/version /dependency !-- 需要对应的Redis客户端如Jedis或Lettuce -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency在application.yml中配置Redis连接信息即可。sa-token会自动将会话存储切换到Redis。权限模型的建议对于复杂的权限系统建议使用RBAC角色-权限模型。即用户关联角色角色关联权限。在StpInterfaceImpl的getPermissionList方法中根据用户ID查询其所有角色再聚合这些角色下的所有权限码返回。这样在管理后台调整角色权限时用户权限会自动更新下次登录或清除缓存后生效。接口放行的最佳实践在WebMvcConfig中配置拦截器放行路径时不要图省事用/**然后排除一大堆。应该采用“黑名单”思想即只拦截需要保护的API路径如/api/**而将登录、公开文档、静态资源等明确排除。这样更安全逻辑也更清晰。对于SpringBoot静态资源路径/static/,/public/等和错误路径/error通常也需要排除。这套组合在我经历的几个项目中表现非常稳定它极大地简化了权限管理的开发复杂度让代码更专注于业务。特别是注解式权限校验让Controller层的代码看起来干净利落。如果你正在为SpringBoot项目的权限模块选型不妨试试这个方案。