ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

若依权限认证改造实战:从Spring Security到saToken

若依权限认证改造实战:从Spring Security到saToken 1. 为什么要动若依的权限底层一次真实的选型复盘做后端的朋友应该都有这种感觉若依框架好用是好用但它的权限认证模块自带的那套Spring Security JWT方案对于中小型项目来说属实有点重。我最初接手公司一个基于若依Vue版本二次开发的管理后台时第一周几乎就在Security的配置里打转——过滤器链顺序、AuthenticationManager的装配方式、SecurityContextHolder的线程上下文传递、PreAuthorize注解里那串ss.hasPermi(...)的SpEL表达式每一步都能挖出好几个坑。最让人头疼的是当项目需要接入第三方单点登录、需要实现同一账号的强制下线或者想定制一套更灵活的权限模型时Spring Security的扩展点在文档里翻了半天写出来的代码依然又绕又长。相比之下saToken这个国产的开源权限认证框架在我做完技术调研后几乎是一拍即合。它的定位非常明确轻量级、简单、开箱即用。登录就是StpUtil.login(userId)退出就是StpUtil.logout()判断权限就是StpUtil.hasPermission(system:user:list)没有Spring Security那一整条复杂的过滤器链也不强制你继承各种Adapter和Provider。用很直白的话说saToken把“登录认证、权限校验、会话管理”这些事拆成了一个个可理解的模块文档全是中文示例代码贴上去基本就能跑。我写这篇文章就是想完整记录一次把若依框架的认证授权底层从Spring Security替换成saToken的实战过程。这个过程不只是换个依赖那么简单它牵涉到登录逻辑的重写、用户信息的存储、权限注解的替换、前端Token头的适配以及一堆只有真正动手改过才会遇到的边界问题。如果你正打算在新项目里用若依做骨架但又不想背Spring Security的包袱或者你已经在用若依但被它的权限模块折腾得够呛这篇文章应该能帮你省下不少时间。2. 集成前必须摸清的五个若依认证关口动手改造之前我建议你先把若依现有的认证链路从头到尾捋一遍。我自己是在踩了两次“改一半发现还有一处没改干净”的坑之后才学乖了先画了一张调用关系的思维导图。你若依版本在3.8.x左右通常会有下面这几个核心关口。2.1 SecurityConfig与JwtAuthenticationTokenFilter这是若依Spring Security方案的骨架。SecurityConfig继承了WebSecurityConfigurerAdapter里面定义了密码加密器、认证管理器、以及一长串antMatchers().permitAll()的放行路径。JwtAuthenticationTokenFilter则是一个OncePerRequestFilter它负责从请求头里取出Token解析出用户名再从Redis中加载登录用户信息最后放入SecurityContextHolder。这两块是替换的重点也是在删除阶段最容易残留的隐患。我当时的做法是先把SecurityConfig里的代码逐行过一遍搞清楚哪些路径必须放行验证码接口、登录接口、静态资源然后才决定如何在saToken里复刻同样的放行规则。2.2 SysLoginService、LoginUser与UserDetailsServiceImpl若依的登录逻辑集中在SysLoginService.login方法里它内部调用AuthenticationManager.authenticate()本质上是通过UserDetailsServiceImpl.loadUserByUsername从数据库查出用户再由Spring Security内部的DaoAuthenticationProvider做密码校验。校验通过后登录用户会封装成LoginUser对象存到Redis中同时用JWT工具类生成一个token字符串返回给前端。这里的改动在于AuthenticationManager这套机制可以整个去掉密码校验自己动手做就行毕竟无非是查库、比对密码、检查状态这几步。LoginUser这个类本身建议保留因为后续很多业务代码里会用到SecurityUtils.getLoginUser()来拿当前用户的部门、角色、权限集合保留它可以最大限度减少对既有业务代码的冲击。2.3 前端request.js的Token注入方式后端改得再热闹前端不配合也白搭。若依的前端封装了一套axios请求在src/utils/request.js里通过请求拦截器统一给每个请求加上Authorization: Bearer getToken()。这里的Bearer前缀和后端JWT解析逻辑是配套的改造后需要确保saToken也能正确识别这个Header要么给它配置token-prefix: Bearer要么前端去掉Bearer前缀二选一。我在改造前把这三个关口挨个列成清单每完成一步就划掉一项后面全程没有出现“打开一个Controller发现还在用Spring Security的注解”这种尴尬情况。3. 第一步改造依赖替换与Spring Security自动配置排除若依项目的依赖管理比较分散核心的认证相关依赖在ruoyi-framework模块的pom.xml里。替换的第一步就是把这个模块里的spring-boot-starter-security依赖移除换上saToken的starter。这里有一个非常容易踩的坑Spring Boot只要在classpath下检测到Spring Security相关依赖就会自动装配一套默认的安全过滤链。哪怕你只在测试项目里加了一个security依赖什么都没配置启动后所有接口也都会被默认拦截。3.1 引入saToken依赖的具体坐标如果你用的若依是3.8.x系列对应的Spring Boot版本是2.5.x那么依赖坐标是这样的dependency groupIdcn.dev33/groupId artifactIdsa-token-spring-boot-starter/artifactId version1.37.0/version /dependency如果你用的是若依Vue-Puls这类已经把Spring Boot升级到3.x的版本则要换一个artifactIddependency groupIdcn.dev33/groupId artifactIdsa-token-spring-boot3-starter/artifactId version1.37.0/version /dependency3.2 启动类排除安全自动配置的完整写法去掉依赖只是第一步。有时候你项目里其他组件比如某些报表工具、工作流引擎会间接传递依赖引入Spring Security为了防止默认的安全过滤链启动最稳妥的方式是在启动类上用exclude明确排除掉和Security相关的自动配置SpringBootApplication(exclude { org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration.class, org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration.class, org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration.class }) public class RuoYiApplication { public static void main(String[] args) { SpringApplication.run(RuoYiApplication.class, args); } }我在第一次改造时没有排除UserDetailsServiceAutoConfiguration结果启动后Spring Boot还是自动去找UserDetailsService的Bean导致报了一个No qualifying bean of type UserDetailsService的错误。排查了半天才反应过来是排除列表不够完整。3.3 删除旧安全配置类时的连带影响排除自动配置之后还需要把若依自己定义的那些Security配置类处理掉包括但不限于com.ruoyi.framework.config.SecurityConfigcom.ruoyi.framework.security.filter.JwtAuthenticationTokenFiltercom.ruoyi.framework.security.service.UserDetailsServiceImplcom.ruoyi.framework.security.handle.AuthenticationEntryPointImplcom.ruoyi.framework.security.handle.LogoutSuccessHandlerImpl这里有个关键操作如果你直接把spring-boot-starter-security依赖从pom里移除上述类里所有引用Spring Security注解和类的代码都会编译不过。最稳妥的做法是先把SecurityConfig中涉及的依赖梳理清楚逐个注释或删除再全局搜索import org.springframework.security把所有引用了安全包的类找出来统一处理。我保留了SecurityUtils这个类名只是把它的实现全部换成saToken的API这样BaseController和其它业务代码里的SecurityUtils.getUserId()调用都不用改。如果你在IDEA里用Maven导入项目时遇到“error adding module to project: null”这个报错先别慌这通常和代码本身没关系是IDEA导入Maven模块时.idea目录或*.iml文件冲突导致的删掉.idea和所有*.iml文件重新导入一次基本就能解决。4. 第二步改造登录接口与用户信息的saToken化若依的登录接口在SysLoginController里核心逻辑在SysLoginService.login方法。原来它是通过AuthenticationManager完成认证的现在我们要把这套依赖去掉改成最朴素、最可控的手动校验流程。4.1 密码校验逻辑的保留与AuthenticationManager的去除若依数据库里的用户密码是BCrypt加密的校验的时候直接用BCryptPasswordEncoder.matches即可。我改造后的login方法大概是这样的public String login(String username, String password, String code, String uuid) { // 1. 校验验证码这块原逻辑不动 validateCaptcha(username, code, uuid); // 2. 查询用户 SysUser user userService.selectUserByUserName(username); if (user null) { throw new ServiceException(登录用户 username 不存在); } // 3. 校验删除、停用状态 if (UserStatus.DELETE.getCode().equals(user.getDelFlag())) { throw new ServiceException(对不起您的账号 username 已被删除); } if (UserStatus.DISABLE.getCode().equals(user.getStatus())) { throw new ServiceException(对不起您的账号 username 已停用); } // 4. 校验密码 BCryptPasswordEncoder passwordEncoder new BCryptPasswordEncoder(); if (!passwordEncoder.matches(password, user.getPassword())) { throw new ServiceException(用户不存在/密码错误); } // 5. saToken完成登录 StpUtil.login(user.getUserId()); // 6. 保存登录用户信息到会话 LoginUser loginUser buildLoginUser(user); StpUtil.getSession().set(loginUser, loginUser); return StpUtil.getTokenValue(); }这里有一个原则性问题要说明原来的AuthenticationManager会做“用户名不存在”和“密码错误”的区分但从安全角度考虑最终返回给前端的提示最好统一成“用户不存在/密码错误”避免攻击者通过接口返回信息猜测账号是否存在。改造过程中顺手把这个也一并做了。4.2 登录用户写入saToken Session很多第一次用saToken的人会忽略一个细节StpUtil.login()只是完成了用户的身份标记并不会自动把用户详情、权限集合存到Session里。如果后续接口需要StpUtil.getSession().get(loginUser)拿到完整的用户信息必须在登录成功后手动写入。写Session的时候建议存LoginUser对象而不是直接存SysUser。原因有两个一是若依很多业务代码通过SecurityUtils.getLoginUser().getUser()来拿用户信息存成LoginUser可以无缝兼容二是后续如果要扩展权限缓存LoginUser里天然有permissions和roles字段一次Session存储全都搞定。4.3 Token返回格式和前端请求头的适配saToken默认生成的Token是一个UUID字符串通过StpUtil.getTokenValue()取出后返给前端。此时前端往后再请求接口时需要在请求头带上这个Token。最省事的方案是给saToken配置token-prefix: Bearer让它自动识别并剥离Bearer前缀这样前端request.js里的代码完全不用动原来的Authorization: Bearer getToken()照样能跑通sa-token: token-name: Authorization timeout: 7200 active-timeout: -1 is-concurrent: true is-share: true token-style: uuid is-log: false token-prefix: Bearer配置说明配置项值说明token-nameAuthorizationToken放在请求头里的名字和前端保持一致timeout7200Token有效期单位秒2小时active-timeout-1活跃超时-1表示不限制用户一直操作就不过期is-concurrenttrue允许同一账号多地同时登录is-sharetrue同账号多次登录共享同一个Tokentoken-styleuuidToken生成风格token-prefixBearerToken前缀和前端拦截器的Bearer配合这里我特别说明一下is-share和is-concurrent。如果你的业务是强管控型比如一个账号只允许在一个地方登录把is-concurrent设为false即可后登录的会把前面的人挤下线。如果是普通管理后台建议保持true避免运维同事在办公室和服务器上同时登录互相顶掉。5. 第三步改造权限加载与注解鉴权的完整落地登录认证只是上半场权限校验才是重头戏。若依原来的权限校验入口是PreAuthorize(ss.hasPermi(system:user:list))这个ss本质上是Spring的Bean内部调用PermissionContext.hasPermi。改造后Spring Security那套SpEL表达式全部不能用了需要替换成saToken的注解体系。5.1 实现StpInterface完成权限码与角色码的加载saToken的权限校验基于一个核心接口StpInterface你必须自己实现它告诉框架“某个用户的权限码列表是什么、角色码列表是什么”。否则StpUtil.hasPermission()永远返回false。Component public class StpInterfaceImpl implements StpInterface { Autowired private SysMenuService menuService; Autowired private SysRoleService roleService; Override public ListString getPermissionList(Object loginId, String loginType) { Long userId Long.valueOf(loginId.toString()); // 直接从数据库查询菜单权限这里也可以加一层Session缓存 SetString perms menuService.selectMenuPermsByUserId(userId); return new ArrayList(perms); } Override public ListString getRoleList(Object loginId, String loginType) { Long userId Long.valueOf(loginId.toString()); ListSysRole roles roleService.selectRolesByUserId(userId); return roles.stream().map(SysRole::getRoleKey).collect(Collectors.toList()); } }这里有一个细节若依的SysMenuService.selectMenuPermsByUserId在部分版本里接收的是SysUser对象而不是Long userId需要根据你自己拉下来的代码签名灵活调整。还有一点是管理员用户如果在数据库里配置了*:*:*权限saToken是支持通配符权限匹配的所以管理员不需要特殊处理也能通配所有校验。5.2 StpUtil与SaInterceptor的初始化配置SaToken要和Spring MVC整合需要注册一个拦截器。这里有两种玩法一种是只开启注解鉴权功能另一种是开启全局登录校验。我强烈建议用注解方式因为若依的接口里有一部分是不需要登录的比如验证码接口、登录接口、公开的配置查询接口等。全局拦截器还需要逐一排除路径搞起来烦。Configuration public class SaTokenConfigure implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { // 注册Sa-Token拦截器打开注解式鉴权功能 registry.addInterceptor(new SaInterceptor()).addPathPatterns(/**); } }注意new SaInterceptor()这里不要传handle - StpUtil.checkLogin()否则所有接口都会被强制登录到时候连验证码接口都进不去直接形成循环依赖。5.3 将PreAuthorize批量替换为SaCheckPermission的注意点若依的业务代码里散布着大量的PreAuthorize(ss.hasPermi(xxx:xxx:xxx))删掉Spring Security依赖后这些注解会编译报错必须批量处理。最简单的单个权限注解替换格式是// 原来 PreAuthorize(ss.hasPermi(system:user:list)) // 改成 SaCheckPermission(system:user:list)如果是多个权限用||连接需要转换成saToken的SaMode.OR模式// 原来 PreAuthorize(ss.hasPermi(system:user:query) || ss.hasPermi(system:user:edit)) // 改成 SaCheckPermission(value {system:user:query, system:user:edit}, mode SaMode.OR)多个权限用连接的则用默认的SaMode.AND模式两个条件都要满足。我在实际替换时用IDEA的正则全局搜索先把所有PreAuthorize出现的位置列出来逐个判断是单条件还是多条件再手动批量处理。这个步骤最费时但也最保险。如果项目里还有PreAuthorize(ss.hasRole(admin))这类角色校验注解同理替换为SaCheckRole(admin)。6. 获取当前登录用户的兼容层让业务代码少改动改造到这一步最让团队头疼的问题出现了若依整个项目的业务代码里到处都是SecurityUtils.getUserId()、SecurityUtils.getLoginUser()、SecurityUtils.getUsername()。要是逐个替换成saToken的API工作量巨大且容易遗漏。最好的方案是保留SecurityUtils这个类名和公共方法签名内部实现全部改成基于StpUtil。6.1 SecurityUtils.getLoginUser的替代实现改造后的SecurityUtils大致长这样public class SecurityUtils { public static Long getUserId() { try { return StpUtil.getLoginIdAsLong(); } catch (NotLoginException e) { throw new ServiceException(获取用户ID异常, HttpStatus.UNAUTHORIZED); } } public static LoginUser getLoginUser() { try { Session session StpUtil.getSession(); return session.get(loginUser, LoginUser.class); } catch (NotLoginException e) { throw new ServiceException(获取用户信息异常, HttpStatus.UNAUTHORIZED); } } public static String getUsername() { LoginUser loginUser getLoginUser(); return loginUser.getUsername(); } }注意一个最容易踩的坑StpUtil.getSession()在用户没有登录时会抛NotLoginException这个异常要转成若依的ServiceException否则异常信息无法被GlobalExceptionHandler正确包装前端会收到一堆看不懂的堆栈。还有一个问题值得提醒从Session里取对象时如果项目配置了Redis序列化那么取出来的LoginUser在反序列化时可能会因为类型信息丢失而变成LinkedHashMap。我建议在使用LoginUser的字段前先手动判断一下对象类型或者直接使用若依RedisConfig里已有的FastJson2JsonRedisSerializer序列化方式可以让对象类型被完整保存。6.2 登录超时与未登录异常的统一拦截改造之后用户Token过期或者未登录时saToken会抛出NotLoginException这个异常如果不在全局异常处理器里接住返回给前端的会是一段500状态码和异常堆栈前端压根不知道这其实是因为登录失效。我在GlobalExceptionHandler里补了三个异常处理方法ExceptionHandler(NotLoginException.class) public AjaxResult handleNotLoginException(NotLoginException e) { return AjaxResult.error(HttpStatus.UNAUTHORIZED, 认证失败无法访问系统资源); } ExceptionHandler(NotPermissionException.class) public AjaxResult handleNotPermissionException(NotPermissionException e) { return AjaxResult.error(HttpStatus.FORBIDDEN, 没有权限请联系管理员授权); } ExceptionHandler(NotRoleException.class) public AjaxResult handleNotRoleException(NotRoleException e) { return AjaxResult.error(HttpStatus.FORBIDDEN, 没有访问权限请联系管理员授权); }若依前端request.js里的响应拦截器会根据code 401跳转到登录页所以NotLoginException必须映射成401这个很关键。你要是随手返回一个500前端只会弹“系统错误”不会自动跳登录用户就得手动刷新页面才能重新登录。7. 实测中的典型报错与排查链路说实话改造过程最花时间的不是写代码而是排查那些“看起来完全不合理”的问题。我把我在实际项目中遇到的几个典型报错和完整的排查链路写出来方便你遇到类似情况时有个参考方向。7.1 启动直接失败SecurityConfig残留导致过滤器链冲突我第一次启动改造后的项目时直接报了一个BeanCreationException一堆堆栈里能看到FilterChainProxy、DelegatingFilterProxyRegistrationBean这些Spring Security的类。当时我还奇怪明明已经把spring-boot-starter-security依赖移除了怎么还有Security的东西。排查链路是这样的先看异常堆栈发现是SecurityConfig类里有一个Bean方法还在返回SecurityFilterChain这个类本身是一个ConfigurationSpring容器启动时会去加载它。因为spring-boot-starter-security虽然被移除了但我的IDEA还没刷新Maven依赖classpath里依然有老版本的Security jar包。解决方案彻底刷新Maven依赖删除所有引用了Spring Security的配置类。同时全局搜索org.springframework.security把SercutiryConfig、SecurityUtils旧实现、JwtAuthenticationTokenFilter、AuthenticationEntryPointImpl等类一次性清干净。7.2 登录成功但后续请求全部401这个问题特别诡异调用登录接口能正常返回Token但拿着Token去访问业务接口全都返回401。排查其实不复杂我在前端控制台看了实际请求头发现Authorization头上带了Bearer xxxtokenxxx然后去后端saToken配置里翻发现token-prefix没有配置。saToken默认只认token-name指定的header并不会自动去掉Bearer前缀所以它把整个“Bearer xxxtokenxxx”当成Token去解析了自然解析失败。处理方式有两种一是配置token-prefix: Bearer让saToken自行剥离前缀二是前端去掉Bearer。我建议用前者前端代码零改动也保留了请求头符合常见JWT风格的习惯。7.3 SaCheckPermission注解不生效代码里加了SaCheckPermission(system:user:list)但用没有权限的账号访问接口时居然正常通过了完全没有拦截。这个问题几乎都是因为SaInterceptor没有注册。saToken的注解鉴权依赖SaInterceptor的AOP处理如果项目里没注册这个拦截器注解就只是一行注释而已。另外还有一个细节如果你在启动类上写了SaCheckLogin这类注解要注意检查项目是否配置了ComponentScan能扫描到SaTokenConfigure这个配置类。我遇到过把配置类放在了某个不会被主启动类扫描到的子包里导致拦截器压根没注册。7.4 前端按钮全部消失改造完成后登录正常、接口正常但前端页面上的按钮全都不见了。排查到最后发现是getInfo接口返回的permissions数组是空的。若依前端的按钮权限指令v-hasPermi会根据store.getters.permissions判断按钮是否渲染如果权限列表为空自然全都不显示。原因在于我重写了getInfo接口的逻辑但忘了让StpInterfaceImpl中的权限数据通过接口返回给前端。正确的做法是在getInfo接口里从Session中读取登录用户时一并把权限码和角色码组装到返回数据里确保前端拿到的permissions和roles和改造前一致。这个点也提醒了我saToken的权限加载和后端逻辑的权限校验是两条链路一个是给注解判断用的一个是给前端渲染用的两条链路都得跑通。7.5 跨域请求OPTIONS预检被拦截公司项目前后端是分离部署的前端在另一个域。改造后前端请求开始报跨域错误排查发现是SaInterceptor把浏览器的OPTIONS预检请求也拦截了导致预检请求返回401。解决方案是在SaTokenConfigure的拦截器注册里excludePathPatterns中加入/login、/captchaImage、/error等公开路径同时把预检请求也排除掉或者复用若依已有的CorsConfiguration配置在addInterceptors时放行OPTIONS方法。我这里用的是后者在拦截器里判断HttpMethod.OPTIONS就放行问题随之解决。8. 集成完成后的一些建议和扩展方向改造完成只是起点后面的使用和维护才是真正体现saToken价值的地方。我这里再分享几个我实际用下来觉得特别值得关注的点。saToken给我最大的惊喜是“踢人下线”能力。若依原来的JWT方案下你就算在系统管理里把某个用户的状态禁用他手里已有的Token依然还能访问接口直到Token过期。换成saToken后直接在用户管理里调用StpUtil.logoutByLoginId(userId)就能把他踢下线下一次请求立即401。如果项目有“强制退出”之类的运营需求这个能力能省不少事。另外如果你后续要接入单点登录或者多个子系统的统一认证saToken自带SSO和OAuth2模块扩展起来比Spring Security要直白得多。我第一次用saToken的SSO模块时基本是照着官方文档里“同域SSO”的示例敲下来的一个小时就跑通了。如果你和我一样项目已经用了Redis建议给saToken补上sa-token-dao-redis-jackson依赖这样Token和Session会统一落到Redis里多个服务实例之间可以共享登录状态水平扩展的时候不用额外做会话同步。我这边就是加了这个依赖后部署两台应用节点用户在一台上登录在另一台上请求接口也能正常识别身份。最后说一个和若依本身相关的小经验改造过程中我花了不少时间在IDEA的Maven依赖刷新上有时候明明改完pom编译出来的target目录里还是旧的依赖导致出现一些稀奇古怪的报错。遇到这种情况建议先执行mvn clean再重新编译然后重启应用。排查顺序永远是先确认依赖刷新了没有再去看代码报错。这轮改造做完之后我最大的体会是权限认证这件事关键不在于用哪个框架而在于你对自己项目的认证链路有没有完整的掌控力。Spring Security功能强大但它的强大会让中小型项目的开发者产生一种“我不理解但不敢动”的无力感。换成saToken之后登录、鉴权、踢人、会话管理这些操作都在自己手里出了问题翻源码也更有底。如果你的项目也正在被若依的默认权限模块折磨不妨照着这篇文章的思路走一遍大部分坑我在前面已经替你踩过了剩下的路会顺畅得多。
返回列表