Shiro自定义Realm实战:认证授权链路、密码加密与多Realm配置 2. 为什么需要自定义Realm——先拆解Shiro的认证授权链路先说一个很多初学者容易跳过的认知点Shiro默认自带的IniRealm和SimpleAccountRealm本质上都是基于内存数据的玩具实现。你用它做登录演示没有任何问题但一旦业务落到真实环境用户信息一定在数据库里角色权限一定需要动态查询——这个时候默认Realm就完全不够用了。Shiro的认证链路走到最底层核心只有三件事收集凭证前端把用户名和密码封装成UsernamePasswordToken交给Subject.login()。身份匹配SecurityManager把Token交给Authenticator认证器认证器根据配置找到对应的Realm调用Realm里我们实现的认证方法doGetAuthenticationInfo()。结果处理认证器拿到Realm返回的AuthenticationInfo包含真实密码、盐值等信息把它和Token里的明文密码进行比较匹配则登录成功不匹配则抛出认证异常。看到没有——Realm在整个链路里扮演的角色就是Shiro和你的业务数据之间唯一的桥梁。默认Realm不知道你的用户表在哪张表里、字段叫什么、密码是什么格式所以你必须通过自定义Realm把「从数据库查询用户信息」这个逻辑告诉Shiro。同理在授权一侧也有一套几乎对称的流程Subject.isPermitted()触发Authorizer授权器授权器调用Realm的doGetAuthorizationInfo()从这个方法里拿到当前用户的角色和权限集合再交给Shiro做权限比对。所以自定义Realm要做的事情用一句话概括就是写两个方法回答两个问题——“这个用户是谁”和“这个用户能干什么”这里还有一个特别容易踩的坑Realm和Token类型是强绑定的。你在doGetAuthenticationInfo()里拿到的AuthenticationToken参数通常是UsernamePasswordToken但如果你用了自定义Token比如为了区分前台用户和后台用户就必须同步重写Realm类中的supports()方法。这个方法返回trueShiro才会把Token交给这个Realm去处理。曾见过有人自定义了Token却不重写supports()结果Realm永远不被调用登录请求直接报Unable to find AuthenticationToken in session排查了好久才发现是这里的问题。我自己的经验是先分析清楚数据模型再去写Realm。比如用户表里有状态字段启用/禁用你可以在查不到用户或用户被禁用时抛出LockedAccountException比如你有两个登录入口管理端和用户端可能就需要两个Realm配合不同的Token类型使用。这些都属于“业务和框架对接”的一部分不能只写了两个方法就认为自定义Realm完成了。3. 自定义Realm实现认证与授权的双通道3.1 先从设计数据模型说起自定义Realm的代码本身不难难点在数据模型的设计上。我在实际开发中通常会设计三张核心表sys_user用户表、sys_role角色表、sys_permission权限表然后通过sys_user_role和sys_role_permission两张关联表把它们串起来。为什么要有角色和权限两张概念不同的表因为角色是权限的集合用户是角色的集合。给用户直接分配权限当然也行但一旦用户多了、权限多了管理成本会直线上升。用角色作为中间层你可以做到“改一个角色所有关联用户的权限全部同步更新”这才是RBAC基于角色的访问控制模型的核心价值。用户表里我会要求至少包含这些字段字段名说明备注id用户ID主键自增username登录名唯一索引password密码密文存储加盐哈希后的值salt盐值随机生成注册时写入status状态0禁用1启用create_time创建时间便于排查问题密码这一栏我遇到过很多项目直接把password明文怼在数据库里。这种做法的风险不是“可能被脱库”而是一旦被脱库所有用户的账号直接暴露。密码重用现象在业务系统里很普遍很多用户在不同系统里用同一个密码明文存储等于把这个“连锁炸弹”送给了攻击者。所以密码加密不是可选项而是必选项这一点我会在下一节详细展开。3.2 认证方法doGetAuthenticationInfo实操写法核心逻辑分三步从Token拿到用户名 → 根据用户名查数据库 → 把查到的用户信息包装成AuthenticationInfo返回。直接给代码public class UserRealm extends AuthorizingRealm { Resource private UserService userService; Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { String username (String) token.getPrincipal(); User user userService.getByUsername(username); if (user null) { // 账号不存在抛出异常后由全局异常处理器统一返回提示 throw new UnknownAccountException(账号不存在); } if (user.getStatus() 0) { throw new LockedAccountException(账号已被禁用); } // 入参1传给doGetAuthorizationInfo的身份对象通常是实体类 // 入参2数据库里存储的密文密码 // 入参3盐值用于密码比对时重新计算哈希 // 入参4Realm的名称通常直接写getClass().getName() return new SimpleAuthenticationInfo(user, user.getPassword(), ByteSource.Util.bytes(user.getSalt()), getName()); } }几个细节必须说透token.getPrincipal()拿到的是用户名。UsernamePasswordToken的第一个参数构造进去的是用户名第二个参数是明文密码。所以在认证方法里我们只关注Principal密码则交给Shiro的匹配器去和user.getPassword()对比不需要我们手动比对。盐值必须用ByteSource.Util.bytes()包一层。如果盐值处理不当匹配器那边计算的哈希结果和存储的密文永远对不上你会得到各种莫名其妙的密码错误提示。返回的第一个参数user会在授权方法里通过principals.getPrimaryPrincipal()取出来。我习惯直接存实体类对象这样授权时想用用户ID查角色顺手就能拿到。千万别存一个用户名进去不然授权时还得再查一次数据库。UnknownAccountException和LockedAccountException都是Shiro内置的异常全局异常处理器里接住后可以转换成便于用户理解的信息。Shiro还内置了ExpiredCredentialsException、ExcessiveAttemptsException等场景化异常按需使用即可。3.3 授权方法doGetAuthorizationInfo实操写法授权方法是做两件事查角色集合、查权限集合然后装进SimpleAuthorizationInfo返回。这里有一个性能上非常关键的优化点——给权限查询加缓存。看代码Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { User user (User) principals.getPrimaryPrincipal(); SimpleAuthorizationInfo info new SimpleAuthorizationInfo(); // 允许为空但要确保返回的是Set不要返回null SetString roles userService.getRolesByUserId(user.getId()); SetString permissions userService.getPermissionsByUserId(user.getId()); info.setRoles(roles); info.setStringPermissions(permissions); return info; }就这么几行。但授权方法有一个非常容易被忽视的问题Shiro默认每次校验权限都会调用doGetAuthorizationInfo()。如果你的系统有大量权限校验请求而且每次校验都查数据库数据库扛不扛得住是小事关键是响应延迟会造成很差的用户体验。解决办法有两个层面缓存层面Shiro原生支持Realm层面的缓存配置了CacheManager之后授权信息会自动缓存起来默认缓存键是用户名值就是AuthorizationInfo。用户权限变更时手动调用Subject.logout()或者清理对应缓存即可。逻辑层面在getRolesByUserId()和getPermissionsByUserId()里用一次SQL把关联查询全部查出来而不是角色查一次、权限查一次。用Mapper的联表查询或嵌套结果集都行目标是一次数据库访问搞定所有授权数据。从项目落地角度我还会在doGetAuthorizationInfo()里加一层容错查不到角色或权限时info.setRoles(Collections.emptySet())不要返回null。返回null在某些Shiro版本中会触发NPE而且排查起来极其隐蔽。3.4 别忘了配置Realm到SecurityManager写完了Realm类还必须在配置类里把它注入到SecurityManager中。Spring Boot集成Shiro时最典型的配置是这样Configuration public class ShiroConfig { Bean public UserRealm userRealm() { return new UserRealm(); } Bean public DefaultWebSecurityManager securityManager(UserRealm userRealm) { DefaultWebSecurityManager manager new DefaultWebSecurityManager(); manager.setRealm(userRealm); // 配置会话管理器可设置为Redis共享会话或JDBC持久化 return manager; } Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager) { ShiroFilterFactoryBean bean new ShiroFilterFactoryBean(); bean.setSecurityManager(securityManager); // 设置登录页、未授权页等 return bean; } }注意SecurityManager如果通过setRealms()方式注入多个Realm就必须配合Authenticator的认证策略一起配置这里只设置一个Realm是最简单可靠的起步方式。多个Realm的场景我在后面专门开一节讲。4. 密码加密那些事从MD5加盐到BCrypt4.1 为什么明文密码是个炸弹密码存储这件事我先说结论从Web应用诞生的第一天起明文存储密码就是不安全的。原因不复杂——数据库一旦被拖走不管是SQL注入还是运维误操作还是内部人员泄露所有用户的密码就像写在纸条上一样直接暴露。更麻烦的是用户的密码往往和他邮箱、网上银行、社交账号用的是同一个一串被公开的明文密码可能牵连出大量跨平台盗号事件。所以密码存储的三条铁律不存储明文密码只存储密码的哈希值。两个用户即使明文密码相同存储的哈希值也必须不同这是靠加盐实现的。哈希算法必须抗暴力破解计算速度越慢越好。这点后面详细说。4.2 MD5加盐的原理与Shiro内置方案先看大家最容易上手的方案MD5加盐。原理就是一个公式最终密文 MD5(明文密码 盐值)盐值是一个随机字符串注册时生成并保存到数据库。这样即使两个用户设置了同一个密码因为盐值不同最终存储的哈希值也不同攻击者无法通过预计算的彩虹表直接反查出明文。Shiro对MD5加盐有非常原生的支持需要用到一个配套组件——HashedCredentialsMatcher哈希凭证匹配器。在认证过程中Shiro会把用户输入的明文密码加上认证信息里带的值做一次同样的哈希运算再和密文做比对。配置方式有两种我推荐在ShiroConfig里用Bean方式手动指定Bean public HashedCredentialsMatcher hashedCredentialsMatcher() { HashedCredentialsMatcher matcher new HashedCredentialsMatcher(); matcher.setHashAlgorithmName(MD5); // 哈希算法 matcher.setHashIterations(2); // 哈希迭代次数 return matcher; } Bean public UserRealm userRealm(HashedCredentialsMatcher matcher) { UserRealm realm new UserRealm(); realm.setCredentialsMatcher(matcher); return realm; }注意this.getPassword()在加盐哈希中还有一个连锁坑HashedCredentialsMatcher默认会拿AuthenticationInfo里的credentials做简单的equals比对不会自动加盐。要让加盐生效加密规则的公式必须前后一致比如注册时是MD5(明文salt)迭代2次比对时也必须用同样的规则。Shiro对加盐的自动支持其实是通过SimpleAuthenticationInfo第三个参数盐值传给匹配器然后匹配器内部按hashIterations次数重新计算。如果注册时你用的是DigestUtils.md5Hex(password salt)这种Java自带的工具类拼的字符串而配置里又设了迭代次数为2两边算法牛头不对马嘴密码验证永远失败。所以我在项目里会额外写一个加解密工具类注册和登录校验都从这里走保证两侧算法完全一致public class PasswordUtil { private static final String ALGORITHM MD5; private static final int ITERATIONS 2; public static String encrypt(String plainPassword, String salt) { // 实现逻辑先拼盐值再按迭代次数做哈希 } public static boolean verify(String inputPassword, String salt, String storedPassword) { // 对inputPassword做同样的哈希运算再与storedPassword比较 } }MD5加盐有一个非常现实的前提它只能防彩虹表不能防暴力破解。MD5的运算速度太快了配合现代GPU一秒钟可以尝试数亿次MD5计算。就算用户密码有大小写数字混合在这种算力面前也只是时间问题。所以MD5加盐适合旧系统改造场景老数据已经用MD5算好了替换成本高新系统我强烈建议直接上BCrypt。4.3 BCrypt目前更稳妥的密码哈希方案BCrypt的设计目标就是让暴力破解变得非常昂贵。它内部引入了一个可调的工作因子cost factor工作因子越大计算哈希的时间越长通常设置在10~12之间。在Cost10时单次BCrypt计算大约需要50~100ms听上去很慢但对单个用户的正常登录来说完全无感而攻击者想用暴力破解的方式试探密码计算成本就非常高——同样是试一亿个密码MD5可能只要几秒BCrypt需要几个月。BCrypt还有两个天生的优点自带盐值。每次哈希计算自动混入一个16字节的随机盐而且这个盐会被嵌入到最终的密文字符串中不需要额外字段存储。不存在“撞库”问题。即使两个人的密码完全一样生成的BCrypt密文也是不同的。在Shiro中整合BCrypt需要正确处理一个现实问题Shiro内置的HashedCredentialsMatcher支持MD5、SHA-1等几十种算法但原生不支持BCrypt。网上能找到的做法是在doGetAuthenticationInfo()里自己调用BCrypt的校验方法但我们没必要破坏Shiro原有的认证流程更干净的方案是自定义一个CredentialsMatcherpublic class BCryptCredentialsMatcher implements CredentialsMatcher { Override public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) { UsernamePasswordToken upToken (UsernamePasswordToken) token; String inputPassword new String(upToken.getPassword()); String storedPassword (String) info.getCredentials(); return BCrypt.checkpw(inputPassword, storedPassword); } }然后在Realm里注入这个匹配器Bean public UserRealm userRealm() { UserRealm realm new UserRealm(); realm.setCredentialsMatcher(new BCryptCredentialsMatcher()); return realm; }注册用户时生成密文的代码String encryptedPwd BCrypt.hashpw(plainPassword, BCrypt.gensalt(10));注意使用BCrypt方案时doGetAuthenticationInfo()返回的SimpleAuthenticationInfo不需要再传盐值因为BCrypt的盐已经包含在密文里了。如果你沿用MD5加盐时期的三参数构造器Shiro会尝试用盐值重新计算密文反而导致比对失败。这也是我从MD5方案迁移到BCrypt时踩过的坑务必注意。BCrypt vs MD5加盐的选择我个人的看法新项目无脑选BCrypt如果老系统数据已经被MD5加盐搞定了把用户的密码列升级成两个字段password和salt慢慢地在用户下次登录时用BCrypt重新加密做一个平滑迁移。4.4 注册时的加盐流程实现把注册流程串起来看完整逻辑应该是接收注册请求拿到明文密码。生成随机盐值如果用BCrypt这步自动完成。计算加密后的密码。组装User实体写入数据库。做完整流程时我建议注册接口里还要限制密码强度。最基础的要求至少包含长度不少于8位、必须包含字母和数字。不要嫌麻烦一个复杂的密码配合BCrypt Cost10能很大概率挡住自动化撞库工具。5. 多Realm场景与匹配器配置细节5.1 什么时候需要多个Realm我见过比较典型的多Realm需求有两类不同角色走不同的登录表比如后台管理员在sys_admin表中前台用户在线网站的用户表中两套认证逻辑完全不同Token类型和表结构都不同。支持多种登录方式比如扫码登录和密码登录并存两套凭证验证逻辑没法写在一个doGetAuthenticationInfo()里。遇到这类需求用多个Realm会清晰很多。每个Realm各管各的认证逻辑和Token类型互不干扰。5.2 多Realm的认证策略配置多个Realm注册到SecurityManager之后你必须告诉Shiro到底哪个Realm说了算。Shiro默认提供了三种认证策略策略行为适用场景AtLeastOneSuccessfulStrategy只要有一个Realm认证成功就算通过多登录方式并存的系统默认FirstSuccessfulStrategy只认第一个成功认证的Realm后续不执行避免多次认证副作用AllSuccessfulStrategy所有Realm都认证成功才算通过必须通过所有数据源校验的场景配置多Realm的方式如下Bean public SecurityManager securityManager() { DefaultWebSecurityManager manager new DefaultWebSecurityManager(); ListRealm realms Arrays.asList(adminRealm(), userRealm()); manager.setRealms(realms); ModularRealmAuthenticator authenticator new ModularRealmAuthenticator(); authenticator.setAuthenticationStrategy(new AtLeastOneSuccessfulStrategy()); manager.setAuthenticator(authenticator); return manager; }注意ModularRealmAuthenticator必须手动设置否则Spring Boot默认行为不一定能满足你的预期。认证策略的选择直接影响登录体验——AtLeastOneSuccessfulStrategy在任何一个Realm成功时就算成功但可能出现“前一个Realm用户存在但密码不对后一个Realm用户匹配成功”这种边界情况需要业务层面保证不同Realm的数据源严格区分。5.3 匹配器选型与迭代次数权衡回到匹配器这个话题。很多项目用HashedCredentialsMatcher时纠结迭代次数到底设多少。迭代次数本质上是对哈希函数的重复调用增加计算时间也就增强了抗暴力破解能力。但也不能无脑往上堆因为登录接口每次校验都要算一次迭代次数太多会让合法用户的登录体验变差。我从实际负载角度说说经验单台应用服务器MD5迭代2次单次请求大约多消耗0.1ms可以忽略不计但BCrypt Cost从10涨到12计算时间从80ms涨到300ms以上。如果系统并发登录量在100QPS以上会让CPU有明显压力。所以MD5加盐方案迭代次数建议2~3次没必要更多。BCrypt方案Cost默认10如果并发确实高且用户密码口令强度有保障可以降到8但再低安全性就不够了。6. 高频踩坑点与排查日志快速定位这部分是我很想单独拿出来整理的。Shiro的坑不是“少见”而是“常见但难查”。我把这些年遇到的典型问题做成表格方便各位直接对照排查。现象根本原因排查思路登录一直报密码错误但数据库密文和输入密码没问题HashedCredentialsMatcher的迭代次数或算法名和注册时不一致检查两端的hashAlgorithmName和hashIterations是否完全一致自定义Realm压根没走进去直接抛Unable to find AuthenticationTokenToken类型没有匹配上supports()返回false在supports()里加断点或打印日志确认Token类型认证成功后接口依然返回401Shiro的subject.isAuthenticated()只能识别一次登录会话跨请求的Session认证失败检查SessionManager配置确保Session能正确序列化和持久化授权注解不生效RequiresPermissions等注解所在的类没有被Spring AOP代理确认DefaultAdvisorAutoProxyCreator或Shiro Spring AOP配置已引入修改权限后不生效Realm缓存了AuthorizationInfo清理Shiro缓存或者调用subject.logout()重新登录这里面我要重点展开讲一个特别隐蔽的Session问题。Shiro的默认SessionManager是把Session存在内存中的一旦应用重启所有用户会话全部消失。单机运行没问题但一旦做了负载均衡、多节点部署就全靠Session共享机制支撑。我用Redis存储Session时踩过一个坑Shiro的Session对象需要实现序列化否则存进Redis再读出来直接抛序列化异常。解决办法是配置一个RedisSessionDAO并定制一个合适的Session对象序列化方案。这个配置非常容易遗漏但遗漏之后报错又不直观需要你看到日志里出现SerializationException或ClassNotFoundException时迅速联想到Session序列化问题。用Redis存Session时还有一个注意点默认的DefaultWebSessionManager里Session ID Cookie的Path设置。如果Cookie的Path没设置对会出现在一个域名下登录成功跳到另一个子域名又变成未登录状态的问题。把sessionIdCookie.setPath(/)设置好通常能解决这类问题。授权不生效的问题也很高频。经常有人跑来问“为什么我加了RequiresPermissions(user:add)注解但接口还是能被无权限用户访问”。排除掉权限路径本身的拼写错误之后绝大多数情况是Shiro的Spring AOP没有生效。解决方法是在ShiroConfig中加一个DefaultAdvisorAutoProxyCreator的Bean让它把Shiro的注解和Spring AOP的切面接起来。7. 实际项目中的一点补充经验做了几年权限系统我最后的感想是Shiro的真正威力不在框架本身而在于你怎么围绕业务设计Realm和加密方案。框架只是把认证、授权、加密这些通用能力封装好真正需要考虑的是——你的用户结构长什么样、权限粒度要细化到按钮级还是接口级、密码策略怎么定、会话怎么管理。这些业务层面的设计决定了你的安全体系是不是真的扛得住。分享一个我亲历的项目教训。某个系统原本用的是明文存储密码后来决定升级为加盐哈希。由于历史数据量大我最初想的方案是搞一个一次性脚本来加密所有存量密码。结果脚本写完后在测试环境跑了一段数据发现部分老账号的密码格式很奇怪——有些是纯数字、有些带符号转换后验证总有一小部分失败。后来排查发现历史数据中有不少账号早期是通过数据导入工具进的系统导入时密码字段被处理过有的保留了明文有的本身就是MD5但没加盐。这种情况下一刀切的迁移必须配合登录时的逻辑判断如果发现密码是老的明文或普通MD5就在用户成功登录后自动升级为新的BCrypt密文。我给那段逻辑起名叫“渐进式迁移”后来在好几个项目里都这么用。另外如果你所在的项目是Spring Boot 3.x要注意Shiro的版本兼容问题。Shiro的Spring Boot Starter在Web MVC场景下需要确认是否支持Jakarta命名空间选错版本启动时可能秒挂。遇到这种问题优先去看Shiro官方仓库里的release分支说明。还有一个小建议把密码策略放在配置文件里管理不要把算法名、迭代次数硬编码在Java类中。后续要做安全合规升级或者调整密码过期策略时改配置比改代码要安全得多也不容易引发线上事故。