ARTICLE DETAIL

资讯详情

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

SpringBoot+Shiro+JWT+Dubbo+Redis统一权限系统实践

SpringBoot+Shiro+JWT+Dubbo+Redis统一权限系统实践 项目标题是“SpringBoot-shiro-jwt-dubbo-redis分布式统一权限系统完结”看到“完结”两个字我第一反应是这哥们终于把整套权限体系跑通了。做分布式权限的人都知道这个组合踩坑的地方不在某一个技术点而在它们之间的衔接缝隙里。我也是从单体架构一路改过来的深深理解为什么最后会沉淀出这样一套模板。这篇文章我打算完整拆开这套组合的每一个关键节点从架构设计到代码落点从踩坑记录到排查思路全部摊开来说。如果你正准备搭一套分布式统一权限系统或者已经在改微服务的路上这篇应该能帮你省掉好几周的调试时间。1. 架构思路拆解为什么需要这套组合拳1.1 单体时代的权限系统搬到微服务之后哪里不对传统单体项目里Shiro的用法很固定登录成功后把用户信息塞进Session后续每次请求通过Session拿到当前用户身份再做权限校验。这套机制在单体架构里跑得很顺因为Session天然存在同一个应用进程里不存在“找不到人”的问题。但到了分布式环境服务被拆成多个独立进程用户请求先打到网关再路由到订单服务、用户服务、商品服务。这时候Session就尴尬了——用户明明在支付服务登录成功了结果请求转到售后服务时那个服务根本不知道Session是什么东西。最常见的老办法是配置Session共享比如通过Spring Session把Session存进Redis。我在早期项目里就这么干过但实际体验很别扭每次请求都要带着同一个SessionID要么用Cookie要么重写URL扩展性很差。服务间互相调用时Session和Cookie的传递链路特别脆弱稍不加小心就丢了身份。Session这种服务端状态存储天然和“无状态微服务”的伸缩弹性格格不入。所以到微服务阶段权限认证的思考方式必须换一个角度不想在服务端保存状态那就把身份信息做成一份“可以自己证明自己”的令牌让要校验的一方只验证令牌、不查会话。1.2 技术选型背后的取舍逻辑这套方案选了四个核心组件每个承担的角色都不一样我把它们拆开看组件在系统中的角色解决的核心问题SpringBoot基础设施整合框架快速组装项目自动化配置让其他组件能快速接入Shiro认证授权框架提供过滤器链、Subject抽象、权限检查APIJWT无状态令牌把用户身份、过期时间、签名做进一个自包含密文DubboRPC通讯框架多服务间远程调用统一鉴权入口Redis缓存与共享存储权限数据缓存、令牌黑名单、分布式锁、防止并发重复刷新选型的时候其实有不少人纠结过用Shiro还是Spring Security。我自己的感受是如果项目里已经有一堆老接口是Shiro体系写好的强行切Spring Security改造成本极高而且Spring Security的门槛确实比Shiro高不少。Shiro的过滤器链机制非常直观自定义一个过滤器加进去就能完成Token解析和身份组装调试起来也清晰。另外一个很关键的点是JWT和Session的取舍。JWT最大的优势就在于它是无状态的服务端不保存任何会话信息验证起来只需要三样东西密钥、签名算法、载荷数据。但无状态也带来一个“副作用”——token发出去之后服务端没办法主动让它失效。举一个场景用户修改了密码正常情况下应该所有旧令牌立即失效但JWT做不到除非你在Redis里维护一份黑名单或版本号。这也是为什么这套方案里Redis必须存在它不只是缓存更是弥补JWT无状态缺陷的“后门”。1.3 一条完整的请求链路长什么样理论上用户从前端发起一次请求走的完整路径是这样的前端传入用户名密码请求登录接口。认证服务校验用户信息生成JWT令牌并把用户基本信息同步写入Redis缓存。前端拿到令牌后后续每次请求都在请求头里带Authorization: Bearer token。网关或统一入口的JWT过滤器先校验令牌签名、解析用户身份、检查Redis黑名单。身份合法后Shiro的Subject被填充带有当前用户信息。请求被路由到具体业务服务时Dubbo调用通过隐式参数传递当前用户信息。目标服务利用Shiro注解如RequiresPermissions做二次权限校验。这一条链路就是这套系统的整体骨架后面所有细节都是围绕这条链路去填肉。我建议初学者先把这条链路画在纸上再开始看代码否则很容易被各种配置绕晕。2. 认证模块落地Shiro与JWT的整合细节2.1 Shiro过滤器链的核心工作机制Shiro的核心抽象是SecurityManager它管理着Subject、Authenticator、Authorizer三大部分。在SpringBoot整合Shiro时最关键的配置文件是ShiroConfig里面要定义一套过滤器链。这套过滤器链本质上是Java里的Filter责任链模式和Servlet过滤器的执行方式一样每个URL在进入业务代码之前会依次经过自定义过滤器。我见过很多新手配置Shiro时报错最典型的问题就是过滤器链顺序写错。比如MapString, String filterChainDefinitionMap new LinkedHashMap(); filterChainDefinitionMap.put(/login, anon); filterChainDefinitionMap.put(/logout, logout); filterChainDefinitionMap.put(/**, jwt);这里有个细节容易被忽略Map的遍历顺序是不可靠的必须用LinkedHashMap保证定义顺序。Shiro匹配URL是按声明顺序从上往下来匹配的先声明愿每个人都可以访问的路由最后再声明需要认证的路由。如果你把/**写在最前面后面的/login就永远匹配不到了所有接口都会要求登录。另外Shiro自身自带一些过滤器比如anon匿名可访问、authc需要登录、logout退出针对JWT方案需要自己写一个过滤器把JWT的解析、校验收进Shiro的过滤链路里。我自己是继承了BasicHttpAuthenticationFilter这个类后来发现更清爽的做法是直接继承AuthenticatingFilter因为它在判断“有没有带token”和“token是否有效”这两个环节已经给你留好了扩展点。2.2 自定义JWT过滤器到底要重写哪些方法自定义认证过滤器是整套系统的“大门”核心是重写两个方法Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { // 判断请求头是否携带token如果没带就直接返回false进入onAccessDenied流程 String token getTokenFromRequest(request); if (StringUtils.isBlank(token)) { return false; } return null ! JwtUtils.parseToken(token); } Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { // 主要做两件事取出token构造Subject交给Shiro去登录校验 String token getTokenFromRequest(request); try { getSubject(request, response).login(new JwtToken(token)); return true; } catch (Exception e) { // 返回401状态码前端拿到后做跳转 return false; } }这里面有一个极其关键的细节isAccessAllowed里如果直接返回false请求会进入onAccessDenied去走登录逻辑。而自定义的JwtToken是什么它本质上是一个携带token字符串的认证令牌对应Shiro里的AuthenticationToken接口。因为Shiro默认只认UsernamePasswordToken我们要让它认JWT就得自己实现一个AuthenticationToken并在Realm里写相应的校验逻辑。还有一个坑是跨域相关的如果你加了CORS配置要注意OPTIONS预检请求的处理。很多项目里JWT过滤器把OPTIONS请求直接拦截了导致前端报跨域错误。解决办法很简单在isAccessAllowed里加一个判断如果请求方法是OPTIONS直接返回true。2.3 自定义Realm把JWT解析出来的用户身份交给Shiro管理Realm是Shiro和业务数据之间的桥它负责告诉Shiro“这个token对应的是谁他有什么权限”。JWT方案里Realm的逻辑很轻因为用户身份已经从JWT里解析出来了不需要再查数据库Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { JwtToken jwtToken (JwtToken) token; String userId JwtUtils.getUserId(jwtToken.getToken()); if (userId null) { throw new AuthenticationException(token invalid); } // 从Redis里取用户信息登录时已经缓存好 UserInfo userInfo RedisUtils.get(user: userId); if (userInfo null) { throw new AuthenticationException(user not found); } // 返回给Shiro后续可以从Subject获取当前用户 return new SimpleAuthenticationInfo(userInfo, jwtToken.getToken(), myRealm); } Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { // 从Redis获取该用户角色和权限集合封装成SimpleAuthorizationInfo UserInfo userInfo (UserInfo) principals.getPrimaryPrincipal(); SimpleAuthorizationInfo info new SimpleAuthorizationInfo(); info.addRoles(userInfo.getRoles()); info.addStringPermissions(userInfo.getPermissions()); return info; }这套逻辑在单服务模式下很顺但到分布式环境就有个问题用户信息存Redis的key要怎么设计才能让所有服务都读得到我的经验是统一约定好key前缀和序列化方式比如存用户对象用JSON字符串而不是Java原生的序列化这样其他语言写的服务也能读。如果不统一Dubbo服务用默认的JdkSerializer写进去的对象别的服务读出来就是一片乱码。2.4 无状态会话下的rememberMe该怎么做Shiro原生的rememberMe机制是存Cookie下次请求自动带上Cookie完成登录。但在JWT方案里这招不好使了因为JWT本身已经包含了身份信息你再额外开一个Session干嘛我在项目里处理的方式是把“记住我”这个语义转换成“签发一个长期有效的JWT令牌”也就是把token的有效期从30分钟升级到7天。但长期令牌的风险也在于“泄露出去了就完蛋”所以我在长期令牌里额外塞了一个设备信息、浏览器指纹之类的参数登录校验时比对一下发现异常就强制重新登录。另外一个经验是长期令牌不要存浏览器localStorage存到HttpOnly的Cookie里更安全一些虽然牺牲了一点XSS防御便利性但CSRF该防还是要防。3. 分布式会话与权限缓存Redis的真实角色3.1 为什么说“Redis存token”不是最优解网上很多博客写的方案是把JWT本身存一份到Redis但这在我看来是没想清楚JWT的设计目的。JWT的核心价值就是“无状态”你把它存进去等于又开始做有状态会话Redis一旦崩了所有令牌立刻失效分布式下更明显——有的服务连得上Redis有的连不上就会出现“这个节点说登录失败、那个节点说登录成功”的怪现象。那Redis到底存什么我总结了三类东西用户信息缓存登录成功后把用户基础信息、角色集合、权限集合写一份到Rediskey设为user:userId设置过期时间和token保持一致。token黑名单登出、改密、被封禁时把当前token的jtiJWT ID写进黑名单写入的过期时间正好是token剩余的有效期黑名单自动过期不用手动清理。分布式锁token并发刷新、多终端踢人下线这种场景用Redis的SETNX命令做分布式锁保证同一用户在同一时刻只有一个实例在刷新token。我踩过一个大坑一开始我把权限集合直接序列化成Java对象存RedisRedis的value用JdkSerializationRedisSerializer后来发现服务A写入的权限服务B读出来反序列化报错原因是两个服务引用的实体类包路径一模一样类加载器对不上也会出问题。最后我统一改用Jackson序列化权限集合存成JSON数组读出来再手动转成ListString这才彻底解决。如果你也在做分布式强烈建议Redis的key和value都统一用字符串序列化别图省事用默认的JDK序列化。3.2 权限数据为什么要缓存而不是每次查库在一个分布式多服务的环境里权限校验的调用频率比想象中高得多。每个请求进来Shiro都要调一次doGetAuthorizationInfo拉取角色和权限。如果你这段逻辑是查数据库那高并发下数据库压力瞬间拉满纯粹是自找麻烦。我的方案是用户在登录成功后一次性把权限集合从数据库查出来按服务维度拆分存好。比如用户访问订单服务就从Redis的userPermissions:userId:orderService这个key里面取权限取不到再走一遍数据库查询并回填缓存同时设置一个合理的过期时间。这样做的好处是第一业务服务不直接查库第二不同服务间权限数据独立某个服务的权限变更不会影响其他服务的缓存。权限修改后的缓存同步是另一个重要问题。我这边做法是在权限管理后台修改了某个用户的角色或权限时主动删除Redis里对应用户的权限缓存key或者调用一个统一的“刷新用户权限”Dubbo接口把所有服务节点都通知一遍。这里如果偷懒不清缓存会出现用户已经解除权限但还能继续访问接口的尴尬情况。3.3 Redis连接池与连接超时的坑高并发下Redis问题会放大。我最开始用Lettuce客户端老遇到连接争抢超时日志里全是RedisCommandTimeoutException后来排查原因发现是没有正确配置连接池参数默认的池子太小并发一上来就排队等着。得改一下配置spring: redis: lettuce: pool: max-active: 50 max-idle: 20 min-idle: 5 max-wait: 3000ms另外一个问题是Redis主从切换时的缓存穿透现象。如果主节点挂了从节点顶上那一瞬间大量未命中的请求会全打到数据库。我的缓解方案是权限缓存不只是Redis一层再加一层本地缓存比如Caffeine把常访问的用户权限放本地过期时间极短60秒。这算是一个用空间换稳定性的取舍。不过要注意本地缓存和Redis之间的一致性会比较弱如果业务上对权限实时性要求苛刻必须权衡好这两者。4. Dubbo服务间的身份透传与统一鉴权4.1 服务A调服务B时怎么把用户身份带过去这是整套分布式权限方案里最容易被忽略的一环。网关层做了JWT认证但内部服务之间走的是Dubbo RPC不会经过HTTP请求头。如果不做特殊处理服务B根本不知道“这个请求的用户是谁”更没法做权限校验。Dubbo提供了一个隐式参数的机制专门干这个事。利用RpcContext的attachment可以在发起远端调用时给请求附加信息RpcContext.getContext().setAttachment(userId, userId); RpcContext.getContext().setAttachment(username, username); // 然后发起调用 userService.getUserDetail(userId);在服务提供方你再通过RpcContext.getContext().getAttachment(userId)取回这些参数。这里我强烈建议用一个统一的Filter来处理Dubbo支持自定义Filter通过Activate(group {PROVIDER, CONSUMER})注解注册这样就不用在每个业务方法里手动塞参数、取参数了。我踩过一个特别经典的坑Dubbo的RpcContext不是线程安全的异步调用情况下attachment会丢。我在项目里一开始用CompletableFuture异步调用结果发现服务B接收的attachment经常为空找了好久才定位到是RpcContext的线程上下文被切换了。解决方式是如果必须用异步则把需要在attachment传递的用户信息作为业务方法的显式参数传进去宁可方法签名多一个参数也不要依赖隐式传递的上下文。4.2 服务提供方要不要重复做权限校验做过分布式的人应该都有体会网关校验过的权限到服务内部就不校验了这种行为极其危险。因为Dubbo服务可能不止通过网关调用还可能是别的服务调用、调度任务调用、MQ消费者调用这些入口都没经过JWT过滤器。换句话说如果一个服务错误地把Dubbo端口暴露出去而自身不做任何权限校验那这个服务就裸奔了。我的策略是“网关做粗粒度校验、服务做细粒度校验”。网关只管“这个用户是否合法、token是否有效”不做具体权限判断。到了具体业务服务再用Shiro的RequiresPermissions(order:create)注解做细粒度授权配合前面提到的Dubbo RpcContext里透传的身份信息。需要注意的是服务B里要有一个realm能识别从RpcContext里拿到的用户信息并完成Subject的构建。这些代码要抽成一个公共的权限starter让每个服务都引用避免每个服务各写一套。4.3 统一权限starter的模块划分做完整套系统后我最大的建议是不要把这些代码零散地放在各个服务里一开始就要抽成公共模块。我自己最终分的模块是这样的模块名内容common-core统一返回结构、异常定义、常量、工具类security-coreShiro配置、JWT工具、filter、realm抽象security-starter自动配置让每个服务引入后即生效platform-commonDubbo隐式参数过滤器、RpcContext工具这样设计的好处是一个新服务上线时只要引入security-core和security-starter加上一个简单的配置类指定自己的Redis和密钥就能拥有完整的JWT校验、Shiro权限注解、Dubbo身份透传能力。不需要再写那几千行各种重复配置。我在实际项目中把这套starter推到私服后新服务从零到接入完整权限体系不到一个小时这才是分布式系统该有的开发效率。5. 令牌生命周期管理续签、黑名单与并发5.1 JWT过期了怎么办统一续签方案JWT的过期时间设多长一直是争论点。设太短比如30分钟用户用着用着突然弹回登录页体验很差设太长比如7天令牌泄漏后的风险窗口很大。我最后采用的方案是“短期令牌 无感续签”。具体逻辑主令牌有效期设30分钟同时给一个refreshToken有效期7天。用户在访问过程中每次JWT过滤器发现主令牌快过期比如剩余有效时间小于5分钟就在响应头里返回一个新的token前端拦截器检测到新token就更新本地存储。这样对用户完全透明没有任何感知。如果你觉得refreshToken太重也可以走“每N分钟内自动续期”的轻量方案网关层判断如果token剩余时长小于阈值直接重新签发一个token并在响应头返回。要注意频繁续签会带来两个问题一个是Redis黑名单里旧token过期时间不好对齐另一个是同一用户多个并发请求同时触发续签会签发一堆新token。5.2 多端登录、踢人下线怎么实现JWT无状态服务端没有会话列表想“踢人下线”就麻烦了。我实现的方式很简单Redis里以用户为维度维护一个token版本号。用户登录后Redis存userTokenVersion:userId v1JWT的payload里也带一个version字段。每次鉴权时对比JWT里的version和Redis里的version不一致就拒绝。这样踢人下线就退化成一步操作修改Redis里version值。比如用户在后台点了“踢出其他设备”系统只要把Redis的version加1老token立刻全部失效。同一用户最多允许N个设备在线时靠version和deviceId配合维护一个设备列表就行。这套方案在部署多实例时依然有效因为Redis是共享的各实例拿到的版本号是一致的。5.3 用Redis分布式锁拦住并发续签续签操作如果并发发生会出现同一用户签发多个新token的问题更严重的是多个写操作同时生成token导致后续鉴权出现“新旧token交替”的混乱。我在这块用Redis的SETNX做一个简单分布式锁String lockKey lock:refresh:token: userId; boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 3, TimeUnit.SECONDS); try { if (locked) { // 执行刷新逻辑 String newToken jwtUtils.generateToken(userInfo); // 写入Redis缓存 } } finally { redisTemplate.delete(lockKey); }这里的核心点在于同一时间只能有一个线程为同一个用户续签其他线程必须等锁释放后直接拿到最新的token。如果不用这个锁并发下会产生一堆旧token并存而且黑名单的冗余切换会让排查变得异常痛苦。热词里有“redis分布式锁”这里就是一个非常契合的实际应用场景。用分布式锁的目的不是花哨而是让并发下的一致性问题控制在可控范围。6. 常见问题与排查实录6.1 过滤器链顺序导致接口一直401现象登录接口正常其他接口全部401Swagger也访问不了。排查半天发现是filterChainDefinitionMap声明顺序不对/**被写在前面了。Shiro的过滤链匹配规则是“先匹配先生效”/**拦截了所有路径前面的规则根本没机会执行。解决办法上面提过使用LinkedHashMap把最具体的URL放在最前面。6.2 Shiro注解直接失效在SpringBoot整合Shiro时RequiresPermissions注解不生效是高频问题。本质原因通常是忘了开启注解支持Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) { AuthorizationAttributeSourceAdvisor advisor new AuthorizationAttributeSourceAdvisor(); advisor.setSecurityManager(securityManager); return advisor; }这个方法才是让Shiro注解生效的关键。另外注意如果你的controller在Dubbo服务里Shiro管理的容器是SpringMVC的容器和Dubbo的服务暴露是两码事。Dubbo服务里的RequiresPermissions注解不一定能被Shiro识别此时你需要在服务接口里显式做权限校验或者自定义Dubbo Filter调用Shiro的Subject.checkPermission()。6.3 Redis集群模式下权限缓存乱套了如果你的Redis是集群部署比如三主三从有一个隐藏问题多个key使用不同分区槽会导致批量操作失败。我遇到过用mget批量获取用户权限时直接报错CROSSSLOT原因是用花括号拼key老拼错Redis Cluster的分区规则。解决办法是key设计时把同一用户的多个权限key落到同一个槽位上比如key统一为{user.123}:order:permissions、{user.123}:goods:permissions集群环境下{}中的部分会被用来计算槽这样批量操作就不会跨slot。6.4 一升级SpringBoot版本整套权限体系直接崩了热词里有人提到“springboot版本太高”问题。我遇到过最惨的一次项目从SpringBoot 2.3升到2.7Shiro starter没升级结果过滤器的初始化顺序变了导致JWT过滤器还没注册就被执行控制台疯狂报NullPointerException。解决办法是检查依赖版本兼容性。我自己实测比较稳的组合是组件版本建议SpringBoot2.6.x 或 2.7.xshiro-spring-boot-starter1.8.0 及以上jjwt0.9.1 或 0.11.5dubbo-spring-boot-starter2.7.15 或 3.0系spring-boot-starter-data-redis随SpringBoot版本一致6.5 Dubbo调用链路权限全部丢失的案例下面这个case让我记忆犹新服务A调用服务B服务B报“用户未登录”。逐步排查一开始怀疑是RpcContext的attachment没传对后来发现其实是服务A那边用了ExecutorService做异步调用attachment在子线程里根本读不到。解决方式是把userId作为业务方法的参数量显式传递而不是依赖Dubbo的隐式传参。另外一个注意事项是如果服务B是多副本部署各副本必须连同一个Redis否则从服务A传过来的userId在B的Redis里查不到用户信息一样会失败。我在部署的时候专门给每个环境的Redis分配了独立库database 0/1/2防止开发环境和测试环境的用户缓存互相串了。这一点在多人协作时特别重要不然你会看到莫名其妙“另一个人的用户信息出现在我的token里”这种玄学问题。7. 关于做这套系统的几句实在话前后花了大概三周时间把这套系统打磨到“完结”状态之后我最大的感受是权限系统不是一个能用“某某框架”一步到位的功能点它是整个微服务架构里最容易出幺蛾子的一层。真正核心的价值不在那些框架本身而在于你做出了什么样的设计决策token生命周期怎么管理、黑白名单放哪里、服务间身份怎么传递、缓存一致性怎么保证这些策略层面的东西才是最难抄作业的地方。我在项目完成后把核心的代码抽成了安全starter放进了私有仓库。后来几个新服务接入基本上就是引入依赖、配置redis地址和密钥两件事那一刻才真正感觉自己当初折腾那么多天总算没有白费。最后再分享一个小技巧如果你也刚开始做这套方案先别急着写代码先画一遍“请求调用全链路图”把网关校验、服务内校验、Dubbo透传、缓存失效这几个节点标注清楚再对照着写一定会顺畅很多。
返回列表