
1. 从登录成功到权限校验Shiro授权链路到底在做什么很多人第一次接触Shiro的权限验证注意力都放在subject.hasRole()或者subject.isPermitted()这两个方法上觉得调用一下就完事了。但真正在项目里落地的时候你会发现事情远没有这么简单——为什么有的接口明明配了权限却拦不住为什么角色改了之后用户要重新登录才生效为什么isPermitted返回false但后台日志里看不到任何异常这些问题的根源都在于没有搞清楚Shiro授权链路的完整走向。Shiro的授权不是孤立的一个方法调用它是一条从Subject到SecurityManager、再到Authorizer、最后落到Realm的完整链路。你调用subject.isPermitted(user:delete)的那一刻Subject会把请求委托给SecurityManagerSecurityManager再交给内部的Authorizer组件默认是ModularRealmAuthorizerAuthorizer会遍历所有配置的Realm逐个调用它们的doGetAuthorizationInfo方法拿到授权信息最后用授权信息里的角色和权限去匹配你传入的字符串。这条链路里有两个关键点容易被忽略。第一doGetAuthorizationInfo的返回值是AuthorizationInfo里面装的是CollectionString roles和CollectionString stringPermissionsShiro拿到这些集合之后做的是字符串匹配不是数据库查询。也就是说你在Realm里查什么、怎么查完全由你自己决定Shiro只负责拿结果做比对。第二ModularRealmAuthorizer在遍历Realm的时候只要有一个Realm返回了匹配结果就会立即返回true不会继续往下查。这个特性在单Realm场景下没什么感觉但多Realm场景下如果没注意很容易出现权限判断不符合预期的情况。理解这条链路的意义在于当权限验证出问题的时候你知道该从哪里下手排查。是Realm没返回正确的权限集合还是Authorizer的匹配逻辑不符合预期还是Subject根本没拿到正确的身份信息导致Realm查询失败每一个环节都有可能出问题而排查的顺序应该沿着链路从后往前推——先确认Realm返回的数据对不对再确认Authorizer的匹配逻辑最后确认Subject的身份信息是否完整。我在实际项目中遇到过这样一个场景用户登录成功之后访问一个需要admin角色的接口返回403。排查了半天发现Realm的doGetAuthorizationInfo方法里查询角色的时候用的是username作为查询条件但登录时存入Subject的Principal是用户ID而不是用户名。结果Realm查不到任何角色返回了一个空的AuthorizationInfoAuthorizer自然匹配不上。这个问题从表面看是权限配置的问题实际上是身份信息传递的问题。如果一开始就理解整条链路排查起来会快很多。2. 自定义Realm的授权实现从数据库到AuthorizationInfo的完整映射2.1 继承AuthorizingRealm还是实现Realm接口Shiro提供了两个层次的Realm抽象最顶层的Realm接口只定义了getAuthenticationInfo和getAuthorizationInfo两个方法而AuthorizingRealm抽象类在实现这两个方法的基础上还提供了缓存管理、凭证匹配器等额外能力。绝大多数场景下我们应该继承AuthorizingRealm而不是直接实现Realm接口。原因很简单AuthorizingRealm帮你处理了授权信息的缓存逻辑。它内部有一个authorizationCachingEnabled开关开启之后第一次调用doGetAuthorizationInfo拿到授权信息后会缓存起来后续的权限判断直接走缓存不会每次都查数据库。这个缓存机制在权限判断频繁的场景下能省掉大量数据库查询。如果你直接实现Realm接口这些都得自己写。继承AuthorizingRealm之后你需要重写的是doGetAuthenticationInfo和doGetAuthorizationInfo这两个protected方法而不是接口里的那两个。这个命名差异是Shiro的一个设计习惯——带do前缀的方法是留给开发者扩展的不带前缀的方法是框架内部调用的入口入口方法里会处理缓存、日志等横切逻辑然后再调用do方法。public class CustomAuthorizingRealm extends AuthorizingRealm { Autowired private UserService userService; Autowired private PermissionService permissionService; // 认证登录时调用 Override protected AuthenticationInfo doGetAuthenticationInfo( AuthenticationToken token) throws AuthenticationException { UsernamePasswordToken upToken (UsernamePasswordToken) token; String username upToken.getUsername(); User user userService.findByUsername(username); if (user null) { throw new UnknownAccountException(用户不存在); } return new SimpleAuthenticationInfo( user.getId(), // principal user.getPassword(), // credentials ByteSource.Util.bytes(user.getSalt()), // 盐值 getName() // realm名称 ); } // 授权权限判断时调用 Override protected AuthorizationInfo doGetAuthorizationInfo( PrincipalCollection principals) { Long userId (Long) principals.getPrimaryPrincipal(); SimpleAuthorizationInfo info new SimpleAuthorizationInfo(); // 查询角色 SetString roles userService.findRoleCodesByUserId(userId); info.setRoles(roles); // 查询权限 SetString permissions permissionService.findPermCodesByUserId(userId); info.setStringPermissions(permissions); return info; } }这段代码里有一个细节值得展开说principals.getPrimaryPrincipal()拿到的是认证时放入的第一个参数。在上面的认证方法里我放的是user.getId()所以授权时拿到的就是用户ID。这个设计的好处是授权查询直接用ID走索引比用用户名查效率更高。但前提是认证和授权两边的类型必须对齐否则会抛类型转换异常。2.2 角色与权限的数据库表设计Shiro本身不关心你的权限数据怎么存它只关心Realm返回的AuthorizationInfo里有没有正确的角色和权限字符串。但在实际项目里权限数据的表结构设计直接影响后续的扩展性和维护成本。经典的RBAC模型通常需要五张表用户表、角色表、权限表、用户角色关联表、角色权限关联表。-- 用户表 CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL UNIQUE, password VARCHAR(128) NOT NULL, salt VARCHAR(64) NOT NULL, status TINYINT DEFAULT 1 ); -- 角色表 CREATE TABLE sys_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_code VARCHAR(64) NOT NULL UNIQUE, role_name VARCHAR(64) NOT NULL ); -- 权限表 CREATE TABLE sys_permission ( id BIGINT PRIMARY KEY AUTO_INCREMENT, perm_code VARCHAR(128) NOT NULL UNIQUE, perm_name VARCHAR(64) NOT NULL, parent_id BIGINT DEFAULT 0 ); -- 用户角色关联 CREATE TABLE sys_user_role ( user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, PRIMARY KEY (user_id, role_id) ); -- 角色权限关联 CREATE TABLE sys_role_permission ( role_id BIGINT NOT NULL, perm_id BIGINT NOT NULL, PRIMARY KEY (role_id, perm_id) );权限字符串的命名规范建议采用资源:操作的格式比如user:create、user:delete、order:view。这种格式的好处是支持通配符匹配Shiro的WildcardPermission默认就支持user:*这样的写法表示用户资源的所有操作。如果你在权限字符串里用了冒号分隔Shiro会自动用通配符逻辑来匹配不需要额外配置。2.3 授权信息缓存的取舍AuthorizingRealm默认开启了授权缓存底层用的是CacheManager。如果你没有显式配置CacheManagerShiro会用MemoryConstrainedCacheManager也就是把授权信息存在内存里。这在单机环境下没问题但在集群环境下会出现一个典型问题用户在A节点登录并触发了权限缓存管理员在B节点修改了该用户的角色A节点的缓存不会自动失效导致权限变更不生效。解决这个问题的常见方案有两种。第一种是关闭授权缓存每次权限判断都查数据库。这种做法实现简单但在权限判断频繁的场景下会给数据库带来压力。第二种是引入分布式缓存比如Redis所有节点共享同一份缓存权限变更时主动清除缓存。这种做法性能好但需要额外维护缓存的清除逻辑。// 关闭授权缓存的方式 Bean public CustomAuthorizingRealm customAuthorizingRealm() { CustomAuthorizingRealm realm new CustomAuthorizingRealm(); realm.setAuthorizationCachingEnabled(false); return realm; }我个人的经验是如果系统的权限变更频率很低比如一个月才调整一次角色关闭缓存是最省事的选择。如果权限变更频繁或者系统对权限判断的性能要求很高那就上分布式缓存并且在角色变更的Service方法里显式调用realm.getAuthorizationCache().remove(principal)来清除缓存。注意这里的principal必须是认证时放入的那个对象通常是用户ID。3. 注解驱动的权限控制从RequiresPermissions到AOP拦截3.1 开启注解支持的正确姿势Shiro的注解权限控制RequiresPermissions、RequiresRoles、RequiresUser等不是默认生效的需要在Spring配置里显式开启。在Spring Boot项目里通常通过一个配置类来完成Configuration public class ShiroConfig { Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor( SecurityManager securityManager) { AuthorizationAttributeSourceAdvisor advisor new AuthorizationAttributeSourceAdvisor(); advisor.setSecurityManager(securityManager); return advisor; } Bean public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() { DefaultAdvisorAutoProxyCreator creator new DefaultAdvisorAutoProxyCreator(); creator.setProxyTargetClass(true); return creator; } }这里有两个容易踩的坑。第一个坑是DefaultAdvisorAutoProxyCreator的proxyTargetClass属性。如果不设置为trueSpring默认使用JDK动态代理而JDK动态代理只能代理接口。如果你的Controller没有实现接口这在Spring Boot项目里很常见注解就不会生效。设置为true之后Spring会使用CGLIB代理即使没有接口也能正常拦截。第二个坑是注解失效的场景。Shiro的注解是通过AOP拦截实现的而AOP拦截只对Spring容器管理的Bean生效。如果你在Service内部直接调用同一个类的另一个方法那个方法上的RequiresPermissions注解不会生效因为调用没有经过代理对象。这个坑和Spring事务注解失效的原理是一样的。Service public class UserService { RequiresPermissions(user:delete) public void deleteUser(Long userId) { // 删除逻辑 } public void batchDelete(ListLong userIds) { for (Long id : userIds) { // 这样调用不会触发权限检查 this.deleteUser(id); } } }正确的做法是把需要权限检查的方法抽到另一个Bean里或者通过AopContext.currentProxy()拿到代理对象再调用。但后者需要开启exposeProxy配置起来比较麻烦推荐用前者。3.2 权限字符串的匹配逻辑与通配符Shiro的权限匹配用的是WildcardPermission它的匹配规则比很多人想象的要灵活。权限字符串用冒号分隔成多个部分每个部分可以用逗号分隔多个值还可以用星号作为通配符。匹配的时候Shiro会逐部分比对只要请求的权限字符串的每一部分都被授予的权限字符串对应部分覆盖就算匹配成功。授予的权限请求的权限匹配结果说明user:*user:delete匹配星号匹配任意操作user:view,edituser:edit匹配逗号分隔多个操作*:viewuser:view匹配星号匹配任意资源user:viewuser:delete不匹配操作不匹配useruser:view不匹配部分数量不一致*user:view匹配全通配符这里有一个反直觉的地方user和user:view是不匹配的。因为user只有一个部分而user:view有两个部分Shiro要求授予的权限部分数量必须大于等于请求的权限部分数量且每一部分都要匹配。所以如果你在数据库里存了一个user权限它不会自动匹配user:view。另一个需要注意的点是RequiresPermissions注解的logical属性默认是AND也就是说多个权限之间是与关系。如果你需要或关系需要显式设置为Logical.ORRequiresPermissions(value {user:create, user:edit}, logical Logical.OR) public void saveOrUpdate(User user) { // 有create或edit权限之一即可访问 }3.3 注解与URL过滤器的配合策略在实际项目里权限控制通常有两层一层是URL级别的过滤器一层是方法级别的注解。URL过滤器负责粗粒度的拦截比如/admin/**需要admin角色方法注解负责细粒度的控制比如某个具体的删除操作需要user:delete权限。Bean public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) { ShiroFilterFactoryBean factory new ShiroFilterFactoryBean(); factory.setSecurityManager(securityManager); MapString, String filterChain new LinkedHashMap(); filterChain.put(/login, anon); filterChain.put(/logout, logout); filterChain.put(/static/**, anon); filterChain.put(/admin/**, roles[admin]); filterChain.put(/api/**, authc); filterChain.put(/**, authc); factory.setFilterChainDefinitionMap(filterChain); return factory; }这里有一个关键细节filterChain用的是LinkedHashMap而不是普通的HashMap。因为Shiro的过滤器链是按顺序匹配的第一个匹配上的过滤器会生效后面的不再检查。如果用HashMap顺序是不确定的可能导致/admin/**的规则被/**的规则覆盖。用LinkedHashMap保证插入顺序把具体的规则放在前面通用的规则放在后面。两层控制的分工建议是URL过滤器管能不能进入这个模块方法注解管能不能执行这个操作。比如/admin/**需要admin角色这是模块级别的准入控制而admin角色下的不同用户可能有不同的操作权限这就需要方法注解来细化。两层配合的好处是即使某个方法忘了加注解URL过滤器还能兜底反过来即使URL过滤器配置有遗漏方法注解也能保护关键操作。4. 权限验证失效的排查链路从现象到根因的完整推演4.1 现象一注解加了但完全不生效这是最常见的问题表现是接口没有任何权限也能访问日志里也看不到Shiro的拦截记录。排查这个问题的第一步是确认AOP代理是否生效。可以在AuthorizationAttributeSourceAdvisor的setSecurityManager方法上打个断点看启动时有没有执行到。如果没有执行到说明配置类没有被加载检查一下包扫描路径是否覆盖了配置类所在的包。如果断点执行到了但注解还是不生效那就检查DefaultAdvisorAutoProxyCreator是否配置了。这个Bean的作用是让Spring自动为匹配的Bean创建代理没有它的话AuthorizationAttributeSourceAdvisor就是一个普通的Bean不会被应用到任何地方。还有一个隐蔽的情况如果你的项目里同时使用了Spring Security和ShiroSpring Security的AOP自动代理配置可能会和Shiro的冲突。这种情况下需要检查EnableGlobalMethodSecurity之类的注解是否被误加了两个权限框架同时存在会导致代理逻辑混乱。4.2 现象二权限字符串明明配了却匹配不上这种问题的排查需要分两步走。第一步是确认Realm返回的权限集合里到底有什么。最直接的方法是在doGetAuthorizationInfo方法里打日志Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { Long userId (Long) principals.getPrimaryPrincipal(); SimpleAuthorizationInfo info new SimpleAuthorizationInfo(); SetString roles userService.findRoleCodesByUserId(userId); SetString permissions permissionService.findPermCodesByUserId(userId); log.info(用户[{}]的角色: {}, 权限: {}, userId, roles, permissions); info.setRoles(roles); info.setStringPermissions(permissions); return info; }如果日志里权限集合是空的那问题出在数据库查询或者数据本身。检查关联表里有没有对应的记录检查查询SQL的关联条件是否正确。如果日志里权限集合有值但匹配还是失败那就对比一下集合里的字符串和注解里写的字符串是否完全一致。常见的不一致包括大小写不同、多了空格、分隔符用了中文冒号而不是英文冒号。第二步是确认缓存是否在作怪。如果之前查询过一次授权信息被缓存了后续修改了数据库但缓存没清除就会出现数据库里明明有但匹配不上的情况。排查方法很简单重启应用之后再试一次。如果重启后正常了那就是缓存问题。4.3 现象三角色判断正常但权限判断异常这种情况通常说明认证和授权的基本链路是通的问题出在权限数据的粒度上。一个典型场景是用户有admin角色admin角色在数据库里关联了user:*权限但代码里判断的是user:delete结果返回false。按理说user:*应该匹配user:delete为什么匹配不上原因可能出在权限字符串的存储格式上。如果数据库里存的是user:*但查询出来之后被某个环节做了处理比如去掉了星号或者转义了那匹配就会失败。检查一下MyBatis的映射文件或者JPA的实体类看看有没有对权限字符串做额外的处理。另一个可能的原因是权限字符串里包含了不可见字符。比如从Excel导入权限数据的时候单元格里可能带了换行符或者制表符。这种问题用肉眼很难发现可以在代码里加一段trim处理或者用permissions.forEach(p - log.info(权限长度: {}, 内容: [{}], p.length(), p))来打印每个权限字符串的长度和内容长度对不上的就是有问题。4.4 现象四集群环境下权限变更不生效这个问题的根因在前面讲缓存的时候已经提到了就是本地缓存不共享。但排查的时候有一个容易混淆的点用户可能会说我重新登录了还是不生效。重新登录会触发认证流程但授权缓存是按principal缓存的如果principal没变还是同一个用户ID重新登录并不会清除授权缓存。验证方法是在doGetAuthorizationInfo方法里打日志然后让用户重新登录再访问。如果日志没有打印说明走了缓存如果日志打印了但权限还是旧的说明数据库里的数据没更新成功。这两种情况的处理方式完全不同所以日志一定要打。解决集群环境下的缓存问题除了前面说的分布式缓存方案还有一个更简单的做法在权限变更的接口里直接调用realm.clearCachedAuthorizationInfo(principal)。但这个方法只能清除当前节点的缓存其他节点的缓存还是旧的。所以如果用了本地缓存集群环境下最稳妥的方案还是关闭授权缓存或者用Redis做集中式缓存。5. 权限数据的动态刷新与实时生效方案5.1 为什么权限变更需要实时生效在后台管理系统中管理员修改了某个角色的权限之后期望的是立即生效而不是等用户重新登录。但Shiro的默认行为是授权信息在第一次权限判断时加载并缓存后续不再查询数据库。这意味着即使管理员改了数据库用户的权限判断结果还是旧的。这个问题的本质是缓存与数据源的一致性问题。解决思路无非三种一是关闭缓存每次查库二是缓存失效策略数据变更时清除缓存三是缩短缓存过期时间让缓存自动失效。三种方案各有取舍实际项目中往往是组合使用。5.2 基于Redis的集中式授权缓存如果系统是集群部署推荐用Redis做集中式授权缓存。Shiro提供了CacheManager接口我们可以实现一个基于Redis的CacheManager替换默认的MemoryConstrainedCacheManager。public class RedisCacheManager implements CacheManager { private RedisTemplateString, Object redisTemplate; public RedisCacheManager(RedisTemplateString, Object redisTemplate) { this.redisTemplate redisTemplate; } Override public K, V CacheK, V getCache(String name) throws CacheException { return new RedisCache(name, redisTemplate); } } public class RedisCacheK, V implements CacheK, V { private String name; private RedisTemplateString, Object redisTemplate; private static final long EXPIRE_SECONDS 1800; // 30分钟过期 public RedisCache(String name, RedisTemplateString, Object redisTemplate) { this.name name; this.redisTemplate redisTemplate; } Override public V get(K key) throws CacheException { return (V) redisTemplate.opsForValue().get(buildKey(key)); } Override public V put(K key, V value) throws CacheException { redisTemplate.opsForValue().set(buildKey(key), value, EXPIRE_SECONDS, TimeUnit.SECONDS); return value; } Override public V remove(K key) throws CacheException { V value get(key); redisTemplate.delete(buildKey(key)); return value; } Override public void clear() throws CacheException { // 按前缀清除实际项目中需要根据name做模式匹配 SetString keys redisTemplate.keys(shiro:cache: name :*); if (keys ! null !keys.isEmpty()) { redisTemplate.delete(keys); } } private String buildKey(K key) { return shiro:cache: name : key.toString(); } }这个实现里有两个设计决策值得说明。第一是设置了30分钟的过期时间这是一个兜底策略——即使缓存清除逻辑出了问题30分钟后缓存也会自动失效不会导致权限永久不一致。第二是key的构造用了shiro:cache:name:key的格式方便按前缀批量清除。5.3 权限变更时的缓存清除时机有了集中式缓存之后还需要在权限变更的时候主动清除缓存。清除的时机很关键必须在数据库事务提交之后清除否则事务还没提交其他节点可能又把旧数据加载进缓存了。Service public class RolePermissionService { Autowired private RolePermissionMapper rolePermissionMapper; Autowired private CacheManager shiroCacheManager; Transactional public void updateRolePermissions(Long roleId, ListLong permIds) { // 1. 删除旧的关联 rolePermissionMapper.deleteByRoleId(roleId); // 2. 插入新的关联 for (Long permId : permIds) { rolePermissionMapper.insert(roleId, permId); } // 3. 事务提交后清除缓存 TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronizationAdapter() { Override public void afterCommit() { clearAuthorizationCache(roleId); } }); } private void clearAuthorizationCache(Long roleId) { // 查出该角色下的所有用户ID ListLong userIds rolePermissionMapper.findUserIdsByRoleId(roleId); CacheObject, Object cache shiroCacheManager.getCache(authorizationCache); for (Long userId : userIds) { cache.remove(userId); } } }这里用到了TransactionSynchronizationManager来注册事务回调确保缓存清除在事务提交之后执行。如果不这样做事务还没提交就清除了缓存其他节点在事务提交前查询数据库拿到的还是旧数据又会把旧数据加载进缓存。5.4 前端权限按钮的同步刷新后端权限生效了前端页面的按钮权限也需要同步刷新。通常的做法是前端在页面加载时调用一个接口获取当前用户的权限列表然后根据权限列表控制按钮的显示隐藏。当管理员修改了权限之后用户刷新页面就能看到最新的按钮状态。但如果用户不刷新页面按钮状态就不会更新。对于权限变更要求实时性很高的场景可以考虑用WebSocket推送权限变更通知前端收到通知后重新拉取权限列表。不过这种方案的复杂度较高大多数后台管理系统用刷新页面生效就足够了。前端权限控制的核心逻辑是后端返回的权限列表是一个字符串数组前端用一个指令或者工具函数来判断某个权限是否在列表中。Vue项目里常见的做法是自定义一个v-permission指令// Vue自定义指令 Vue.directive(permission, { inserted(el, binding) { const requiredPerm binding.value; const userPerms store.getters.permissions; if (!userPerms.includes(requiredPerm)) { el.parentNode el.parentNode.removeChild(el); } } });使用的时候直接在按钮上加v-permissionuser:delete没有权限的按钮会被直接移除。注意这里是移除而不是隐藏因为隐藏的按钮通过开发者工具还是能改出来移除更安全。当然前端权限控制只是体验优化真正的安全边界还是在后端后端必须对每个接口做权限校验。6. 几个容易忽略的权限验证细节与实战建议6.1 权限字符串的大小写敏感性Shiro的权限匹配是大小写敏感的。user:delete和User:Delete是两个不同的权限。这个特性在跨系统对接的时候容易出问题——A系统里权限字符串用的是小写B系统里用的是大写对接的时候匹配不上。建议在项目初期就定好规范统一用小写并且在数据库层面加约束比如用COLLATE设置大小写不敏感或者在插入前统一转小写。6.2 超级管理员的权限处理很多系统都有一个超级管理员拥有所有权限。实现方式通常有两种一种是在数据库里给超级管理员角色关联所有权限记录另一种是在Realm里判断如果是超级管理员直接返回一个包含*:*的权限集合。第二种方式更灵活因为新增权限的时候不需要给超级管理员补记录。但要注意*:*只能匹配两部分组成的权限字符串如果你的权限字符串有三部分比如user:profile:edit*:*是匹配不上的需要用*:*:*。更通用的做法是用一个单独的*它可以匹配任意部分数量的权限字符串。Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { Long userId (Long) principals.getPrimaryPrincipal(); SimpleAuthorizationInfo info new SimpleAuthorizationInfo(); if (userService.isSuperAdmin(userId)) { info.addStringPermission(*); info.addRole(super_admin); return info; } // 普通用户的权限查询 // ... return info; }6.3 权限验证的性能考量每次权限判断都查数据库显然不现实所以缓存是必须的。但缓存也带来了数据一致性的问题。我的经验是根据权限变更的频率来选择合适的方案权限变更频率推荐方案优点缺点极低季度级关闭缓存每次查库实现简单无一致性问题数据库压力大较低月级本地缓存长过期时间性能好实现简单变更后需等待过期中等周级本地缓存手动清除性能好变更即时生效集群环境需额外处理较高日级Redis集中缓存主动清除性能好集群一致实现复杂度高选择方案的时候不要过度设计。如果系统只有几台服务器权限变更也不频繁本地缓存加手动清除就够了。如果系统规模大、权限变更频繁再考虑上Redis。6.4 权限验证的日志记录权限验证失败的时候记录日志非常重要。但Shiro默认的日志只记录了权限不足这个结果没有记录用户是谁、请求了什么权限、拥有什么权限这些上下文信息。这些信息对于排查问题至关重要。建议在自定义Realm或者一个自定义的Authorizer里加上详细的日志Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { Long userId (Long) principals.getPrimaryPrincipal(); SimpleAuthorizationInfo info new SimpleAuthorizationInfo(); SetString roles userService.findRoleCodesByUserId(userId); SetString permissions permissionService.findPermCodesByUserId(userId); if (log.isDebugEnabled()) { log.debug(授权查询 - 用户ID: {}, 角色: {}, 权限数量: {}, userId, roles, permissions.size()); } info.setRoles(roles); info.setStringPermissions(permissions); return info; }然后在权限验证失败的地方比如全局异常处理器里捕获AuthorizationException记录请求的URL、请求的权限字符串、当前用户的权限集合。这样当用户反馈我没有权限访问的时候你能快速定位是权限没配还是配错了。6.5 前后端权限不一致的常见原因最后说一个前后端权限不一致的典型场景前端根据权限列表隐藏了按钮但用户通过直接调用接口的方式绕过了前端限制。这种情况说明后端的权限校验有遗漏。排查方法是把前端所有调用后端的接口列出来逐个确认是否有对应的权限注解或URL过滤器配置。我见过一个项目前端有20多个按钮后端只有5个接口加了权限注解。原因是在开发过程中前端加了按钮就找后端加接口后端开发人员图省事只加了接口没加权限注解。这种问题在测试阶段很难发现因为测试人员通常是用管理员账号测试的管理员有所有权限不会触发权限校验。只有用普通用户账号做权限测试的时候才会暴露。建议在项目里加一个自动化检查扫描所有Controller的公开方法检查是否有权限注解或者是否在URL过滤器的保护范围内。没有保护的接口要么加上保护要么在代码审查时说明为什么不需要保护比如是公开接口。这个检查可以做成一个单元测试每次构建的时候自动运行。