☰
Spring Boot整合Shiro权限认证实战:登录授权与记住我全面落地
2026/10/14 9:55:24 网站建设 项目流程

做后台管理系统,绕不开权限认证。我最近把一个旧项目从零改造成Spring Boot整合Shiro的权限体系,把登录认证、角色授权、记住我全都打通了。这篇就是完整版的落地记录——依赖怎么选、Config怎么配、Realm怎么写、哪几个坑必须躲开,一次性说透。适合刚接触Spring Boot,或者打算从Spring Security迁移过来的开发同学参考。

整个改造工程前后花了不到两天,但排坑的时间占了六成。Shiro的优势在于它轻量、概念少、和Spring容器解耦,看文档半小时能懂核心流程,不像Spring Security那样上手就要面对一堆过滤器链。我先把结论放前面:Spring Boot 2.7.x搭配Shiro 1.12.0是当前测试下来相当稳的组合,JDK8环境下跑起来没什么幺蛾子。下面按项目落地顺序逐层拆解。

1. 项目背景与设计思路

1.1 这个项目解决了什么问题

我当时接手的后台系统是个典型的单体管理端:用户登录、菜单管理、按钮权限控制。原来那套写法是每个接口里手写一段判断当前用户是否登录的脏代码,基本上就是往Session里塞个用户对象,然后Controller里捞出来判空。结果就是权限逻辑散落在各处,换个人接手根本不敢动,加一个新接口忘了加校验就要出安全事故。

用Shiro重构后,核心变化有两个:认证和授权从业务代码里剥离,交给框架统一处理。登录成功之后,Shiro会维护当前用户的身份信息;访问受保护资源时,框架自动检查角色和权限码,业务代码里再也不用写那句重复的if判断。这套体系对于中小型系统的权限需求来说,刚好卡在“够用”和“不复杂”的平衡点上。

1.2 为什么选Shiro而不是Spring Security

这个问题几乎每个团队都要吵一遍。我给的判断标准很简单:如果你只需要登录认证、角色鉴权、请求拦截这几件事,并且团队里没有人深度使用过Spring Security,那就选Shiro。Security的过滤器链虽然强大,但是配置复杂度和概念抽象度明显高一个级别,售后成本不低。

反观Shiro,核心就三个对象:Subject代表当前用户,SecurityManager管理所有Subject,Realm负责从数据源拉取用户和权限信息。这种模型看一眼架构图就能记住,出了问题也容易定位。而且Shiro天然不绑定Spring容器,这意味着以后就算把认证模块单独抽出去做微服务,这套代码也能低成本迁移。实际上我那个项目后来把权限模块拆出去复用,大部分代码直接搬走,这就是当初选的正确性。

1.3 技术栈选型:版本搭配和目录规划

用到的技术栈不算新,但都是经过生产验证的组合。Spring Boot用的是2.7.18,这是2.x系列的长期维护版本;Shiro用1.12.0,兼容性很好,没有升级到2.x的必要,因为2.x到目前为止主要面向Jakarta EE,普通单体项目收益不大。数据库层面用MySQL 8,搭配MyBatis-Plus做CRUD,简单直接。

项目结构上我单独划了一个shiro包,里面放配置类和Realm;controller层只负责接收参数和返回结果;service层处理业务逻辑;权限相关的表和实体单独建了sys_前缀,避免和业务表混在一起。这种分层的好处是,以后腤如果要把认证模块改成JWT模式,动的地方非常集中,不会把项目翻个底朝天。

2. 从零搭建:依赖引入、数据表和基础配置

2.1 依赖坐标与版本坑

第一步是引入依赖。Shiro官方在1.8.0版本之后,把对Spring的整合直接做成了starter,坐标是org.apache.shiro:shiro-spring-boot-web-starter。引入这个starter之后,它会自动注册SpringShiroFilter,不用再手动配置Servlet Filter。

<dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring-boot-web-starter</artifactId> <version>1.12.0</version> </dependency>

这里有一个很典型的坑:网上大量旧帖子让你添加shiro-core和shiro-spring两个坐标,然后手动配置Bean。如果照抄就会出现过滤器重复注册的问题,因为starter已经自动配了一份。我用的时候是踩过的,项目启动后日志里显示两个Shiro Filter,其中一个根本不起作用,路径拦截逻辑全乱套。

另外一个版本层面的坑是Shiro 1.8.0之后不再自动引入Ehcache依赖。如果你要用缓存来存权限信息或者Session,必须手动把Ehcache的坐标加上。我一开始以为升级Shiro就会带出来,结果运行时直接报缓存管理器找不到,排查半天才意识到是新版本改了依赖策略。

2.2 数据库表设计与实体映射

权限模型我采用了经典的RBAC设计,五张表:用户表、角色表、权限表,以及用户角色关联表、角色权限关联表。用户表重点字段包括username、password、salt,以及账号状态status。重点是salt字段,Shiro做密码加密时用随机盐值,登录验证时要靠它重新生成密文比对,所以盐必须数据库里存下来。

权限表的code字段对应Shiro的perms,典型的值就是user:add、user:update这种格式。角色表的code字段对应Shiro的roles,比如admin、operator。这样设计的好处是,授权逻辑里可以直接把数据库中查到的权限码填进Shiro的SimpleAuthorizationInfo,不需要再做额外映射。

建表SQL我用的是标准的五表设计,关联表只存主键ID。实体对象上用了MyBatis-Plus注解,表名字段一一对应。这里补充一个经验:不建议为了图省事把角色和权限直接存进用户表的冗余字段里,后期改一个用户的角色时SQL会写到你怀疑人生,还是老老实实建关联表。

2.3 application.yaml里的关键配置

Shiro的starter会自动注册过滤器,但具体的Session时长、Cookie名称还是要手动配置。我的配置相当精简,大部分逻辑放到Java Config里,yaml只处理数据源和一些基础设置。

spring: datasource: url: jdbc:mysql://localhost:3306/shiro_demo?useUnicode=true&characterEncoding=utf8&useSSL=false username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: configuration: map-underscore-to-camel-case: true type-aliases-package: com.demo.entity

连接MySQL时,useSSL=false必须加上,否则MySQL 8会报SSL连接错误。MyBatis-Plus的map-underscore-to-camel-case开启后,数据库里的create_time能自动映射到实体的createTime字段上,省掉一堆手写映射注解。

3. Shiro三件套:Config、Realm和密码匹配器

3.1 核心配置类:SecurityManager、Filter、Advisor

Shiro的核心装配集中在ShiroConfig里面,这个类就是整篇文章的主心骨。它要创建三个关键Bean:DefaultWebSecurityManager、ShiroFilterFactoryBean、AuthorizationAttributeSourceAdvisor。每个的职责不同,但少了任何一个都不完整。

SecurityManager是Shiro的调度中枢,登录认证、会话管理、记住我,都在它身上挂载。FilterFactoryBean负责定义URL拦截规则,也就是哪个路径要登录、哪个路径可以匿名访问。Advisor则是用来启用Shiro的注解支持,没有它,你在Controller方法上写@RequiresPermissions是不会生效的。

@Configuration public class ShiroConfig { @Bean public UserRealm userRealm() { return new UserRealm(); } @Bean public DefaultWebSecurityManager securityManager() { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); securityManager.setRealm(userRealm()); securityManager.setRememberMeManager(rememberMeManager()); return securityManager; } @Bean public ShiroFilterFactoryBean shiroFilterFactoryBean() { ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager()); factoryBean.setLoginUrl("/login"); factoryBean.setSuccessUrl("/index"); factoryBean.setUnauthorizedUrl("/403"); Map<String, String> filterChainMap = new LinkedHashMap<>(); filterChainMap.put("/static/**", "anon"); filterChainMap.put("/login", "anon"); filterChainMap.put("/logout", "logout"); filterChainMap.put("/admin/**", "authc"); filterChainMap.put("/**", "authc"); factoryBean.setFilterChainDefinitionMap(filterChainMap); return factoryBean; } @Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor() { AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor(); advisor.setSecurityManager(securityManager()); return advisor; } }

过滤链配置有一个必须刻在脑子里的原则:顺序相关,谁先声明谁先生效,LinkedHashMap保证按插入顺序匹配。所以/static/**和/login这种匿名规则必须放在最前面。我踩过一次顺序反了的坑,把/**=authc写在最前面,结果请求/login也被拦截,登录页都打不开,直接搞成死循环跳转。

3.2 自定义Realm:认证和授权的数据接入点

Realm是Shiro和应用数据之间的一座桥。我自定义的UserRealm继承了AuthorizingRealm,重写两个方法:doGetAuthenticationInfo负责认定用户身份,doGetAuthorizationInfo负责查询权限。

认证方法里,我先根据Token携带的用户名去数据库查用户,查不到直接返回null,Shiro会抛UnknownAccountException;查到了就把数据库里存好的密码密文、盐值一并返回给SecurityManager,由它调用密码匹配器做最终比对。

授权方法里,我从PrincipalCollection中取出登录时保存的用户对象,然后查角色码和权限码集合,分别塞进SimpleAuthorizationInfo。这里有个经验:权限查询尽量走缓存,否则每次请求触发鉴权都会查一次数据库,压力不小。我后来给Realm加了getCacheManager的配置,使用自带的Ehcache缓存,授权信息十分钟内不会重复查库。

public class UserRealm extends AuthorizingRealm { @Autowired private SysUserService userService; @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { SysUser user = (SysUser) principals.getPrimaryPrincipal(); Set<String> roles = userService.findRoleCodesByUserId(user.getId()); Set<String> perms = userService.findPermCodesByUserId(user.getId()); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); info.setRoles(roles); info.setStringPermissions(perms); return info; } @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { String username = (String) token.getPrincipal(); SysUser user = userService.findByUsername(username); if (user == null) { return null; } if (user.getStatus() == 0) { throw new LockedAccountException("账号已被锁定"); } return new SimpleAuthenticationInfo(user, user.getPassword(), ByteSource.Util.bytes(user.getSalt()), getName()); } }

这里有一个很容易被忽略的细节:SimpleAuthenticationInfo的第一个参数传的是整个用户对象,不只是用户名。这么做是因为后面在Controller里要频繁获取当前登录用户的信息,直接从Subject.getPrincipal()里强转出来就行,省得再查一次数据库。如果把第一个参数传成字符串用户名,后面就要多一次查询,性能和代码可读性都吃亏。

3.3 密码加密和盐值匹配器

密码明文入库是绝对的红线。Shiro提供HashedCredentialsMatcher,支持MD5、SHA等算法混合盐值迭代加密。我在系统初始化时,给每个用户的密码做同样的加密逻辑:MD5加用户专属盐值迭代两次。

用户表的salt字段就是为了这一刻准备的。登录时,Realm返回数据库里的盐值,匹配器拿到Token明文密码,用这个盐值做同样的MD5运算,和数据库密文比对。两个密文一致,说明密码正确。

@Bean public HashedCredentialsMatcher hashedCredentialsMatcher() { HashedCredentialsMatcher matcher = new HashedCredentialsMatcher(); matcher.setHashAlgorithmName("MD5"); matcher.setHashIterations(2); return matcher; }

创建账号时加密的代码和这个逻辑必须完全一致,包括算法名和迭代次数。我在测试时遇到一个诡异的问题:管理员创建的账号密码,明明输对了却一直报密码错误。最后排查发现创建账号的工具类里迭代次数写的是1,和登录匹配器的2不一致,导致两个密文永远对不上。所以加密参数一定要统一管理,建议专门写一个PasswordUtils工具类,所有涉及密码的地方都调它,别到处复制代码。

4. 登录认证和授权落地实操

4.1 登录接口:从参数到Token再到Subject

登录接口是认证的入口,逻辑不算复杂,但异常处理必须写完整。前端传用户名、密码和一个记住我的布尔值,Controller拿到后组装UsernamePasswordToken,调用SecurityUtils.getSubject().login(token)触发认证流程。

我之前看到不少新手把subject.login()包在try块里,然后catch异常把错误信息直接返回给前端。这样做本身没问题,但要注意异常时的状态码,比如HTTP 200返回一个业务错误码。我实际用的是统一返回体R对象,内部有code和msg字段。前端根据状态码判断是弹出“用户名不存在”还是“密码错误”。

@PostMapping("/login") @ResponseBody public R login(String username, String password, Boolean rememberMe) { Subject subject = SecurityUtils.getSubject(); UsernamePasswordToken token = new UsernamePasswordToken(username, password, rememberMe); try { subject.login(token); return R.ok("登录成功"); } catch (UnknownAccountException e) { return R.error("用户不存在"); } catch (IncorrectCredentialsException e) { return R.error("密码错误"); } catch (LockedAccountException e) { return R.error("账号被锁定"); } catch (AuthenticationException e) { return R.error("认证失败"); } }

登录成功后,前端不需要自己存用户信息,直接下一步跳转就行。当前登录用户的信息由Shiro的Session维护,后端任何地方需要拿到当前用户,都通过SecurityUtils.getSubject().getPrincipal()获取,这个模式要在团队里养成习惯,别再用Session里塞对象的老套路。

4.2 过滤链与页面级安全控制

配置里的过滤链承担了页面级权限控制。anon代表匿名可访问,authc代表必须登录,roles代表必须拥有指定角色,perms代表必须拥有指定权限码。

我这里给/logout配了logout过滤器,访问这个路径时Shiro会自动退出登录并清理Session。这是框架自带的功能,不要自己写一个logout接口来手动清理,因为你容易忘记清除记住我的Cookie,导致退出后Cookie还残留,下次打开浏览器依然自动登录。

Map<String, String> filterChainMap = new LinkedHashMap<>(); filterChainMap.put("/static/**", "anon"); filterChainMap.put("/login", "anon"); filterChainMap.put("/logout", "logout"); filterChainMap.put("/admin/**", "roles[admin]"); filterChainMap.put("/user/**", "perms[user:add]"); filterChainMap.put("/**", "authc");

这里有一个设计取舍:URL级别的角色或权限校验比较粗粒度,适合做菜单栏级别的控制;更细的方法级权限控制放在Controller注解里。我把这两者分开使用,URL级别管页面能不能访问,注解级别管接口能不能调用。双管齐下,权限没有死角。

4.3 注解级权限与前端标签控制

方法级权限控制靠Shiro的注解,最常用的三个:@RequiresRoles校验角色,@RequiresPermissions校验权限码,@RequiresAuthentication校验已认证。我一般在新增或删除接口上加上@RequiresPermissions,比如用户管理模块的删除接口写@RequiresPermissions("user:delete"),这样就算有人绕过页面直接调接口,也会被Shiro拦截下来。

@PostMapping("/user/delete") @RequiresPermissions("user:delete") @ResponseBody public R deleteUser(@RequestParam Long id) { userService.removeById(id); return R.ok("删除成功"); }

注解不生效时,优先检查AuthorizationAttributeSourceAdvisor这个Bean有没有注册。我遇到过一次项目里引了一个自定义配置类把ShiroConfig覆盖掉了,结果所有注解全部失效,权限完全裸奔。排查思路很直接:写一个测试接口打上注解,看是否返回403,不返回就说明Advisor链路断了。

前端控制我使用Shiro提供的标签库,在页面上根据用户权限决定按钮是否渲染。shiro:hasPermission标签可以嵌入name属性,权限不通过就显示按钮。这样做的前端控制是体验层面的,不是一个安全屏障,真正的安全屏障必须放在后端接口上,两件事别搞混。

5. 常见问题与排查速查手册

5.1 问题对照表:症状、原因、处置方式

我把自己踩过的、以及帮别人排查过的典型问题整理成一张表,基本覆盖了SpringBoot整合Shiro的大多数疑难杂症。

现象可能原因处置方式
访问登录页死循环跳转过滤链中/**=authc写在anon规则之前调整LinkedHashMap中规则顺序,anon放前面
密码正确却提示密码错误创建账号和登录匹配器的加密算法或迭代次数不一致用同一个PasswordUtils工具类统一加密参数
注解@RequiresPermissions不生效缺少AuthorizationAttributeSourceAdvisorBean在ShiroConfig中注册该Advisor并注入SecurityManager
退出登录后Cookie仍能自动登录logout接口是自己写的,没有走Shiro的logout过滤器配置/logout=logout,清掉RememberMe Cookie
静态资源全部404过滤链把/static/**拦截了,导致资源未加载加filterChainMap.put("/static/**", "anon")
RememberMe设置七天后失效Cookie的setMaxAge没有生效检查SimpleCookie是否被SecurityManager正确注入
启动报缓存管理器不存在Shiro 1.8.0之后不再自动带Ehcache依赖手动引入shiro-ehcache依赖
访问页面总是301重定向到login后又回原页面登录成功后没有跳转到successUrl,而是继续进入原URL检查Controller登录成功后是否调用了subject.login(),以及/login路径是否被某个过滤器拦截

第一行、第二行和第四行是我见过最高频的三大问题。尤其是第一行,几乎每个月都能在交流群里看到有人发截图说“为什么我的Shiro一直跳login”,九成都是过滤链顺序问题。框架本身没问题,是你声明顺序有问题。

5.2 吃了最多亏的3个细节

先说第一个:缓存引发的权限更新延迟。Realm开了缓存之后,如果管理员修改了一个用户的角色,用户不重新登录,新角色不会生效。这在生产环境会引发“权限明明改了,但用户还能访问旧接口”的怪问题。我的处理办法是给管理端的角色变更接口加一个清缓存逻辑,主动调用subject.getSession().removeAttribute(...)或者直接让用户重新登录强制刷新。

第二个细节是统一异常处理。Shiro的AuthorizationException默认会被Spring转成500,前端拿到的是一堆堆栈信息,体验很差。我写了一个@RestControllerAdvice全局异常类,专门捕获AuthorizationException返回403 JSON,前端就能根据状态码统一弹出“无权操作”提示。这一步虽然简单,但能省掉很多后端人肉排查的时间。

第三个细节是关于HashedCredentialsMatcher和salt的合作关系。如果用户表里历史数据没有salt字段,直接给所有人随机生成盐值会导致老用户全部无法登录。我在做数据库改造时特意兼容了这种场景:salt字段为空时走明文MD5比对,同时标记用户账号为“待强制改密”,登录成功后跳转修改密码页,再更新salt和密文。过渡期不卡用户,安全性也能逐步提升。

前前后后做下来,最大的体会是Shiro本身并不复杂,出问题往往出在项目集成层面。比如Spring Boot版本升级带来的路径匹配器不兼容、starter自动配置和手动配置冲突、缓存依赖变化,这些才是真正消耗时间的地方。所以建议第一次整合的时候,严格按当前文档的版本组合来,不要一上来就追最新版。后面环境稳了,再考虑是否要更换认证框架或者引入前后端分离的Token方案。至少在我这边的项目里,这套组合跑了小半年,没有出过权限相关的线上故障。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询