ARTICLE DETAIL

资讯详情

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

Spring Boot整合Shiro实战:认证授权、登录拦截与权限控制全解析

Spring Boot整合Shiro实战:认证授权、登录拦截与权限控制全解析 1. 先搞清楚Shiro到底在帮你管哪三件事很多人第一次接触Shiro看到官网上那几句英文介绍就头大。我当年带团队做第一个企业级后台的时候也一样是懵的。但如果你把它拆开来看Shiro真正替你操心的其实就三件事你是谁、你能干什么、你的状态怎么持续。这三件事对应到专业术语里就是认证、授权、会话管理。先说说认证。认证要回答的问题是你凭什么说你是你。用户在页面上输入用户名和密码系统拿到之后去数据库里比对比对通过了就认定身份。这个过程听起来简单但真正落地的时候会有一堆细节密码不能明文存、登录失败要记录次数、密码过期要不要强制重置、多终端登录怎么处理。Shiro把这套流程抽象成了一个标准管道你不用自己从零写。再说授权。授权要回答的问题是你进来了然后你能碰什么。同样是一个后台系统普通运营和超级管理员能看到的菜单、能点的按钮完全是两套。Shiro用角色和权限两个维度来做控制既有基于角色的粗粒度判断也有基于权限字符串的细粒度判断而且支持在页面、接口、方法三个层面做拦截。最后是会话管理。会话解决的是你登录完之后服务端怎么记住你这次的状态。传统Java Web里用HttpSession但Shiro自己维护了一套Session机制好处是不管你底层用的什么Web容器甚至是不是Web环境它都能管理会话。再配合Cookie和RememberMe就能做到关掉浏览器再打开登录状态还在这种体验。除了这三件大事Shiro还顺手包了加密、单点登录支持、多端会话控制这些能力。所以你去看Shiro的架构图和源码会发现它的核心组件其实非常精简但扩展点特别多。这也是为什么Spring Security那么强势Shiro依然能在很多中小型项目里活得好好的原因——它足够轻学习成本足够低接入一个登录功能往往半天就能搞定。2. 快速搭起一个能跑的项目依赖、配置、第一个登录Demo2.1 别纠结版本先选对整合方式Shiro的整合方式有三种纯Servlet项目手动配置、Spring整合、Spring Boot自动配置。2024年的现实情况是新项目基本全是Spring Boot所以你学Shiro的时候直接学Spring Boot的整合方式是最务实的。老项目维护需要看纯Servlet配置的话网上资料也多但没必要一开始就啃那些XML配置。版本选择上我建议你避开低版本直接上Shiro 1.11或者更新的版本。低版本在JDK 17上跑会有一些反射相关的兼容问题而且有些已知安全漏洞是在高版本才修复的。如果你用Spring Boot 2.x对应Shiro 1.8以上基本没问题如果是Spring Boot 3.x那就要注意了Shiro官方从2.0开始才完整支持Jakarta命名空间所以Spring Boot 3项目必须用Shiro 2.x。这一步踩坑的人特别多经常有人Spring Boot 2的工程半路升级到3结果Shiro相关代码直接编译不过。2.2 依赖引入和基础配置我以一个最普通的Spring Boot 2.7项目为例Maven的依赖长这样dependency groupIdorg.apache.shiro/groupId artifactIdshiro-spring-boot-web-starter/artifactId version1.11.0/version /dependency这个starter会把Shiro的核心包、Web包和Spring整合包全都拉进来你不需要再单独引shiro-core、shiro-web这些。引入之后Spring Boot会自动配置一个SecurityManager但默认情况下没有Realm所以你还得自己定义一个Realm交到Spring容器里。基础配置在application.yml里主要玩的是过滤链规则shiro: loginUrl: /login successUrl: /index unauthorizedUrl: /unauthorized这三个配置项分别对应未登录时跳转到哪里、登录成功后默认去哪里、没有权限时跳转到哪里。不配置也能跑但体验很差因为Shiro默认的跳转地址是根路径自己设置清楚才能配合前端做出正常的跳转逻辑。2.3 自定义Realm数据从哪来Realm是Shiro里最核心的扩展点。说人话就是Shiro把从数据库查用户这件事外包给了Realm你只需要告诉它怎么查。最常见的做法是继承AuthorizingRealm重写两个方法doGetAuthenticationInfo负责根据用户名查用户信息doGetAuthorizationInfo负责查用户的角色和权限。Component public class UserRealm extends AuthorizingRealm { private final UserService userService; public UserRealm(UserService userService) { this.userService userService; } Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String username (String) principals.getPrimaryPrincipal(); SimpleAuthorizationInfo info new SimpleAuthorizationInfo(); info.addRoles(userService.findRolesByUsername(username)); info.addStringPermissions(userService.findPermissionsByUsername(username)); return info; } Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { String username (String) token.getPrincipal(); User user userService.findByUsername(username); if (user null) { return null; } return new SimpleAuthenticationInfo(username, user.getPassword(), getName()); } }注意这里有个关键点doGetAuthenticationInfo里返回的password是数据库里存的密文而不是明文。Shiro拿这个password和用户登录时输入的密码去做比对比对逻辑由CredentialsMatcher决定。你如果在这里返回了明文等于自己把密码脱光了放在门口毫无意义。2.4 跑通第一个认证测试Realm定义好之后我们来一个最原始的测试。用Spring Boot的CommandLineRunner或者写一个单元测试都行SpringBootTest class ShiroQuickStartTest { Test void testLogin() { DefaultSecurityManager securityManager new DefaultSecurityManager(); securityManager.setRealm(new UserRealm()); SecurityUtils.setSecurityManager(securityManager); Subject currentUser SecurityUtils.getSubject(); UsernamePasswordToken token new UsernamePasswordToken(admin, 123456); currentUser.login(token); Assertions.assertTrue(currentUser.isAuthenticated()); currentUser.logout(); } }这段代码走通了说明你的Realm配置、依赖引入、密码比对全都正常。接下来才是在Controller里写真正的登录接口。3. 登录拦截与请求放行的完整链路3.1 过滤器链到底是靠什么工作的Shiro做登录拦截的秘密武器是一套过滤器链机制。它内置了十几个过滤器每个过滤器管一件事比如authc管认证拦截、anon管匿名访问、roles管角色校验、perms管权限校验、logout管退出登录。你配置的每一条规则本质上就是把URL和过滤器们做一次绑定。Spring Boot的starter模式下过滤链规则通常是写在ShiroConfig里的Bean ShiroFilterChainDefinition shiroFilterChainDefinition() { DefaultShiroFilterChainDefinition definition new DefaultShiroFilterChainDefinition(); definition.addPathDefinition(/login, anon); definition.addPathDefinition(/css/**, anon); definition.addPathDefinition(/js/**, anon); definition.addPathDefinition(/captcha, anon); definition.addPathDefinition(/logout, logout); definition.addPathDefinition(/**, authc); return definition; }这段配置的意思是登录接口和静态资源全部放行退出登录走Shiro内置的logout过滤器剩下所有请求都要求先登录。要注意的是规则是有顺序的Shiro按照你添加的先后顺序依次匹配一旦某个规则命中了就不会再往下走。所以anon的放行规则必须写在/**这种兜底规则的前面。3.2 常见场景下的拦截规则组合实际项目里拦截规则远不止上面那几种组合。我随便列几个常见的// 管理员接口需要登录且拥有admin角色 definition.addPathDefinition(/admin/**, authc, roles[admin]); // 敏感操作需要登录且拥有指定权限 definition.addPathDefinition(/order/delete, authc, perms[order:delete]); // 全文放行比如用户注册 definition.addPathDefinition(/register, anon); // 基于HTTP方法的精确控制 definition.addPathDefinition(/api/**, authc, rest[user:read]);对于RESTful接口Shiro内置的rest过滤器支持按HTTP方法区分权限rest[user]表示这个URL下需要user权限其中GET对应read、POST对应create、PUT对应update、DELETE对应delete。这个设计对前后端分离的项目特别友好你不需要为每个方法单独写URL规则。3.3 一个容易翻车的细节未登录的识别方式在纯后端渲染页面的时候Shiro检测到未登录直接重定向到loginUrl这没什么问题。但前后端分离项目里前端希望拿到的是一个JSON状态码而不是一个302跳转。这时候你得自己处理。办法有两种一种是配置统一的异常处理器捕获AuthenticationException然后返回自定义的JSON另一种是自定义Filter把原来的重定向逻辑覆盖掉。我一般更推荐第二种因为自定义Filter可以同时解决未登录和无权限两种情况。常用的做法是重写AuthorizationFilter对应的两个方法public class AjaxPermissionsFilter extends AuthorizationFilter { Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws IOException { HttpServletResponse resp (HttpServletResponse) response; resp.setCharacterEncoding(UTF-8); resp.setContentType(application/json); resp.setStatus(401); resp.getWriter().write({\code\:401,\msg\:\未登录或会话已过期\}); return false; } }然后把它注册到过滤链里替换默认的authc。这样做的好处是后端接口和页面请求可以共用同一套过滤链前端拿到401状态码就知道要跳去登录页。4. 用户认证流程从Controller到SecurityManager一次完整登录的旅程4.1 登录接口里的三行代码我们的Controller里登录逻辑通常长这样PostMapping(/login) public Result doLogin(RequestBody LoginRequest req) { Subject subject SecurityUtils.getSubject(); try { UsernamePasswordToken token new UsernamePasswordToken(req.getUsername(), req.getPassword()); if (req.isRememberMe()) { token.setRememberMe(true); } subject.login(token); return Result.ok(); } catch (UnknownAccountException e) { return Result.error(用户名不存在); } catch (IncorrectCredentialsException e) { return Result.error(密码错误); } catch (LockedAccountException e) { return Result.error(账号被锁定); } catch (AuthenticationException e) { return Result.error(认证失败); } }这三行代码背后Shiro做了什么其实整个流程是Subject.login()把token交给了DelegatingSubjectDelegatingSubject再转给SecurityManager的login方法。然后SecurityManager通过Authenticator去调用我们定义的RealmRealm返回AuthenticationInfoAuthenticator拿Token里的明文和Info里的密文做比对通过之后把用户信息放进Subject的PrincipalCollection里。从此这个Subject就持有用户的身份信息后续每一次权限判断都从里面取。4.2 密码校验中加盐方案的完整写法上面提到密码比对由CredentialsMatcher负责。默认的SimpleCredentialsMatcher做的是最朴素的equals比较但如果数据库里存的是加盐哈希就必须自己写匹配器或者用Shiro提供的HashedCredentialsMatcher配合SimpleHash。我实际项目中用的是MD5加盐虽然现在有更推荐的bcrypt但很多老系统还在用MD5。盐的处理有两种方式盐存在单独的字段或者盐就是用户名。各有利弊存在单独字段更灵活但查询要多取一个字段用用户名做盐简单省事但用户名一旦变更密码校验就会失败。Bean public HashedCredentialsMatcher hashedCredentialsMatcher() { HashedCredentialsMatcher matcher new HashedCredentialsMatcher(); matcher.setHashAlgorithmName(MD5); matcher.setHashIterations(1024); return matcher; }然后在Realm里返回AuthenticationInfo的时候带上盐String salt user.getSalt(); ByteSource saltSource ByteSource.Util.bytes(salt); return new SimpleAuthenticationInfo(username, user.getPassword(), saltSource, getName());HashedCredentialsMatcher拿到AuthenticationInfo里的盐之后会用它去算token里明文密码的哈希值和数据库里的密文比对。这里的迭代次数要和当初加密用户密码时保持一致否则永远比对不上。很多新人踩的第一个坑就是这里注册时用迭代2次登录时Matcher配了1次结果明摆着密码是对的就是登录不上去。4.3 认证异常到底有哪些怎么处理Shiro的认证异常体系是一棵继承树最常见的几个我都列在下面异常类型触发场景UnknownAccountException用户名不存在IncorrectCredentialsException密码错误LockedAccountException账号被锁定DisabledAccountException账号被禁用ExcessiveAttemptsException登录失败次数超限ExpiredCredentialsException凭证过期这里想特别提醒一下异常类型暴露给用户的信息要克制。你在后端日志里可以记详细原因但返回给前端的统一应该是用户名或密码错误这种模糊提示原因是防止攻击者通过接口批量探测哪些用户名是真实存在的。这个算是我个人做安全评估时的一个经验吧很多项目在异常处理这一步做得粗糙直接把UnknownAccountException和IncorrectCredentialsException分开返回等于给攻击者递字典。5. RememberMe记住我功能的实现原理与序列化坑5.1 RememberMe和Session不是一回事很多人在前后端联调的时候会把RememberMe和Session搞混。Session是服务端状态Cookie是客户端的凭证而Shiro的RememberMe机制走的是另一套登录成功后Shiro会把用户身份信息序列化加密后写进Cookie。下次用户再来访问Shiro从Cookie里反序列化出身份就认为这个用户是已记住状态。关键的区别在于isAuthenticated()和isRemembered()是两个不同的概念。RememberMe状态下的用户并不是完整的登录认证状态如果某个接口要求必须真正认证过才让访问那你用authc过滤器就能挡住RememberMe用户。Shiro里有个user过滤器它的要求是认证或记住过就行。这个差别在实际业务中非常重要。5.2 序列化失败是最高频的报错用Spring Boot整Shiro踩得最多的一个坑就是RememberMe报错。报错信息通常类似SerializationException原因基本是你要序列化的那个用户对象没有实现Serializable接口。Shiro默认把AuthenticationInfo中的principal序列化进Cookie如果你的principal是一个自定义User对象而这个User没实现Serializable那登录时开启RememberMe直接抛异常。解决办法有两种。第一种是让User实现Serializable接口注意它的所有属性对象也都要可序列化。第二种也是我更推荐的是只把用户名作为principal存进去不要存整个User对象return new SimpleAuthenticationInfo(username, user.getPassword(), saltSource, getName());这样序列化的就只是一个String没有任何复杂依赖。等需要用户信息的时候再让Realm根据用户名去查数据库。多查一次数据库换来的是稳定性和安全性值。5.3 Cookie的过期时间和安全问题RememberMe功能的Cookie名默认是rememberMe过期时间默认是一年。你在配置里可以调整Bean public CookieRememberMeManager rememberMeManager() { CookieRememberMeManager manager new CookieRememberMeManager(); manager.setCookie(new SimpleCookie(rememberMe)); manager.getCookie().setMaxAge(7 * 24 * 60 * 60); manager.setCipherKey(Base64.decode(你的Base64Key)); return manager; }那个setCipherKey是给Cookie里的序列化数据做加密用的。官方示例里会把一段写死的Base64字符串放上去那是公开的不适用于生产环境。你应该自己生成一段至少16字节的随机密钥。一个人人皆知密钥的RememberMe加密等于给攻击者送了一个可控反序列化入口这类漏洞在安全圈里已经有很多真实案例了。我在自己项目里的做法是把密钥放到配置中心不同环境用不同密钥再配合ACL限制。RememberMe这种能力便利性很高但一旦用不好风险也是同等级的。6. 授权拦截从RequiresPermissions到页面按钮的细粒度控制6.1 注解方式的权限控制除了过滤器链Shiro还提供了注解来声明式地控制方法级别的权限。最常见的是下面这几个RequiresAuthentication public void updateProfile() {} RequiresRoles(admin) public void deleteUser() {} RequiresPermissions(user:add) public void addUser() {}这些注解既可以放在Controller的方法上也可以放在Service的方法上放在哪一层取决于你的控制粒度。我的一贯建议是接口入口做粗粒度拦截服务层做细粒度校验两层配合起来才不容易漏。用注解之前要在配置里开启Spring代理支持。Spring Boot starter默认是打开的但你最好确认一下自己有没有写什么奇怪的AOP配置把注解功能给关掉了。否则注解静静躺在那完全不生效排查起来特别费劲。6.2 权限字符串的规范权限字符串这个东西看起来就是user:addorder:delete:1这种格式但背后其实是有设计约定的。通常用冒号分成三段资源类型、操作、实例ID。资源类型就比如user、order、config操作对应create、read、update、delete或者更细颗粒度的submit、approve。我见过有的项目把权限字符串写得特别随意比如a1b2这样写权限表达式的人是在给自己挖坑。因为你很快就会发现当你想用通配符做一个所有user资源都有读权限的规则时杂乱无章的权限命名会让你完全没法写。Shiro的通配符权限是支持user:*和*:read这种表达式匹配的前提是你的权限字符串本身命名规范。6.3 页面级别的权限判断后端接口肯定要鉴权但前端页面也不能把没权限的菜单渲染出来不然用户虽然点了会报403但体验极差也在某种程度上泄露了系统结构。Shiro在JSP/Thymeleaf时代有一套标签库而现在前后端分离项目里常见的做法是登录的时候把当前用户的角色和权限列表一次性返回给前端由前端代码控制菜单和按钮的显隐。获取当前用户权限的接口逻辑我一般这样写GetMapping(/me/permissions) public Result getMyPermissions() { Subject subject SecurityUtils.getSubject(); String username (String) subject.getPrincipal(); SetString roles userService.findRolesByUsername(username); SetString perms userService.findPermissionsByUsername(username); return Result.ok(new HashMap() {{ put(roles, roles); put(permissions, perms); }}); }前端拿到之后在路由守卫或者菜单渲染函数里比对。这套方案的缺点是用户权限变动后前端要刷新页面或重新拉取才能同步。想解决的话可以做成接口实时拉取。但作为基础版登录时返回权限集合已经完全够用了。7. 实战中那些坑从配置到运行我踩过的和看着别人踩的7.1 过滤器配置不生效的排查思路过滤器配置不生效是Shiro新手最容易遇到的情况。请求明明没登录却还是直接进到了Controller里。这时候我建议按下面的顺序排查确认starter是否真的被引入pom.xml里有没有shiro-spring-boot-web-starter这个依赖。确认ShiroConfig这个类有没有被Spring扫描到。漏掉Configuration注解或者包扫描路径不对配置类根本没加载防火墙等于没装。确认过滤器链配置Bean的返回类型。返回类型写错Spring Boot的自动配置感知不到同样白搭。最后才怀疑代码逻辑问题。这个排查链路里我觉得第2和第3条是最好笑的也是最常发生的。我自己就有一次因为复制配置类的时候把Configuration丢了结果权限全部失效当时还在那怀疑人生查了半天查不到原因最后把包扫描路径打出来一看才反应过来。7.2 前端路由刷新404问题Shiro的拦截规则默认拦的是服务端路径但很多前端项目用的是History模式路由。这时候前端路由的路径是真实URL比如/user/list如果Shiro拦了/**用户在前端页面按F5刷新浏览器会真的去请求/user/list服务端发现没有这个Controller直接404。这个问题不是Shiro独有的是History模式和所有后端拦截的天然冲突。常见的解法是在Shiro里面加一个放行规则把前端的所有静态资源都交给前端框架去兜底。比如definition.addPathDefinition(/, anon); definition.addPathDefinition(/index.html, anon); definition.addPathDefinition(/static/**, anon); definition.addPathDefinition(/favicon.ico, anon); definition.addPathDefinition(/**, authc);同时在后端写一个兜底Controller凡是未匹配到Controller的路径统一转发到index.html让前端路由自己接管。7.3 密码策略别再用明文了我见过不少项目把密码直接明文存数据库每次看到都忍不住多说两句。密码存储的最低标准是加盐哈希Shiro提供了SimpleHash类可以快速生成String salt UUID.randomUUID().toString().replace(-, ); String encoded new SimpleHash(MD5, rawPassword, salt, 1024).toHex();更推荐的做法是用bcrypt或者PBKDF2。Shiro官方在较新版本里也提供了对bcrypt的支持。如果项目能选型我建议直接用bcrypt因为它自带盐和迭代次数每次哈希的结果都不一样撞库成本高得多。7.4 会话并发控制一个账号多地登录的限制默认情况下同一个账号可以在多个浏览器同时登录这对很多内部系统是不能接受的。Shiro提供了一套会话监听器机制可以实现同账号互踢。核心步骤是自定义SessionListenerAware实现SessionListener。在登录成功时把该账号的SessionId记录到一个缓存Map里。检测到同一个账号再次登录时先找到已有SessionId执行session.stop()把旧会话踢掉。要注意的是这个方案依赖Session机制如果项目用的是无状态的JWT方案那套路就完全不同了。实际上在很多新项目里Shiro更多是被用来作为权限框架而不是会话框架会话这一层已经交给JWT处理了。两种方案没有绝对的好坏关键看你的业务是传统的服务端渲染还是前后端分离接口前者用Shiro的完整Session体系后者往往只要它的认证授权加Realm机制。8. 写在最后Shiro之后下一步该往哪走Shiro这东西入门快但想用得好也不简单。它的文档远没有Spring Security那么厚社区也不算特别活跃但在中小型项目的安全模块搭建上它足够用一个周期了。你现在花两三天把Shiro从认证到授权完整跑通相当于学会了所有Java安全框架的通用概念。等你后面真的需要上Spring Security的时候你会发现Subject对应AuthenticationRealm对应UserDetailsService过滤器链对应SecurityFilterChain概念大同小异迁移成本并没有想象中高。我个人在实际项目里的体会是安全框架的选择从来不是技术优劣的较量而是团队维护成本和新手上手速度之间的权衡。Shiro在框架自带的魔法这一点上比Spring Security少很多你能清楚地看到认证的每一个环节在做什么。对新手来说这种透明感比什么都重要。最后分享一个我自己的小习惯无论用什么安全框架我都会在项目里做一个安全自检接口用于手动检查当前请求的登录状态、角色、权限以及当前会话的ID和过期时间。这个接口在开发阶段调权限问题的时候特别好用上线前记得关掉或者用内网隔离限制访问就行。安全框架是盾牌而自检接口是透视镜两者配合你才能既防得住敌人也看得清自己。
返回列表