第五章 RBAC角色权限与账户状态校验
本章继续完善 Spring Security:把数据库中的用户、角色、权限真正接入认证流程,同时补齐账户状态字段(是否过期、是否锁定、凭证是否过期、是否启用),实现"可登录 + 可授权 + 可控状态"的完整认证授权闭环。
在前四章里,我们已经完成了表单登录、验证码、数据库查用户等基础能力。本章开始进入更贴近真实项目的一步:让权限从数据库驱动,而不是写死在代码里。
一、问题切入
只完成"用户名密码能登录"还不够,企业项目通常还需要三个层次:
- 用户是否可登录(禁用、锁定、过期等状态)
- 登录后具备哪些角色(如
ROLE_ADMIN、ROLE_USER) - 角色对应哪些细粒度权限(如
content:moderate)
如果这三块没打通,系统会出现典型问题:
- 用户状态失效:数据库标记禁用,但系统依然放行
- 权限粗糙:所有登录用户权限一致
- 运维成本高:每次改权限都要改代码
二、解决方案概览
本章的做法是:
users表存账号与四个状态字段(enabled、account_non_expired、account_non_locked、credentials_non_expired)。roles表新增role字段,专门存角色编码(如ROLE_ADMIN)。- 通过
user_roles、role_permissions、permissions建立 RBAC 授权关系。 - 在
UserServiceImpl#loadUserByUsername中查询并组装GrantedAuthority。 - 将
Users实体实现UserDetails,把数据库状态直接映射到认证判定。
三、数据库设计(RBAC + 账户状态)
3.1 账户状态字段
users 表中推荐保留以下字段:
enabled:账号是否启用account_non_expired:账号是否未过期account_non_locked:账号是否未锁定credentials_non_expired:凭证(密码)是否未过期
MySQL 中通常用
TINYINT(1)存储,Java 侧映射为Boolean/boolean。
3.2 RBAC 关系
roles:角色定义(包含展示名name与角色编码role)permissions:权限点定义(如perm_key=content:moderate)user_roles:用户-角色关联role_permissions:角色-权限关联
推荐的数据语义是:
roles.name:角色展示名(如"管理员")roles.role:角色编码(如ROLE_ADMIN,用于授权匹配)permissions.perm_key:权限编码(如content:moderate)
roles.role 与 Spring Security 的约定保持一致,例如:
ROLE_ADMINROLE_USERROLE_MODERATOR
3.3 一份最小可用的 RBAC 结构
roles(id, name, role, description)
permissions(id, perm_key, perm_name)
user_roles(id, user_id, role_id)
role_permissions(id, role_id, permission_id)
这样可以同时支持:
- 按角色授权(
hasRole) - 按权限点授权(
hasAuthority) - 后台动态调整权限,无需重启服务
四、代码落地要点
4.1 Users 实现 UserDetails
Users 实体中实现 UserDetails 的四个状态方法,直接返回数据库字段:
@Override
public boolean isAccountNonExpired() {
return this.accountNonExpired;
}
@Override
public boolean isAccountNonLocked() {
return this.accountNonLocked;
}
@Override
public boolean isCredentialsNonExpired() {
return this.credentialsNonExpired;
}
@Override
public boolean isEnabled() {
return this.enabled;
}
Spring Security 在 AbstractUserDetailsAuthenticationProvider 中执行密码校验前,会依次调用这四个方法。只要其中一个返回 false,就立即抛出对应异常(如 DisabledException、LockedException 等),终止登录流程。因此,数据库中的状态字段会直接决定用户能否成功认证。
4.2 动态组装权限集合
在 UserServiceImpl 中,先查用户,再查角色码与权限码,并转换成 SimpleGrantedAuthority,最后回填到 Users 的 authorities 字段:
List<String> roleCodes = usersMapper.selectRoleCodesByUserId(users.getId());
List<String> permissionKeys = usersMapper.selectPermissionKeysByUserId(users.getId());
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
if (roleCodes != null) {
authorities.addAll(roleCodes.stream().map(SimpleGrantedAuthority::new).toList());
}
if (permissionKeys != null) {
authorities.addAll(permissionKeys.stream().map(SimpleGrantedAuthority::new).toList());
}
users.setAuthorities(authorities);
确保
Users实体中有private List<SimpleGrantedAuthority> authorities;字段,并在getAuthorities()方法中返回它。这样
UserDetailsService返回的Users就携带了完整的角色和权限集合,供后续授权阶段使用。
这里有两个实践细节:
- 建议角色和权限都装入
authorities,避免"URL 用角色、方法用权限"时出现缺失。 - 角色建议固定
ROLE_前缀,权限点保持业务风格(如module:action)。
4.3 Mapper 联表 SQL
角色查询:
<select id="selectRoleCodesByUserId" parameterType="java.lang.Long" resultType="java.lang.String">
select r.role
from roles r
inner join user_roles ur on ur.role_id = r.id
where ur.user_id = #{userId,jdbcType=BIGINT}
</select>
权限查询:
<select id="selectPermissionKeysByUserId" parameterType="java.lang.Long" resultType="java.lang.String">
select distinct p.perm_key
from permissions p
inner join role_permissions rp on rp.permission_id = p.id
inner join user_roles ur on ur.role_id = rp.role_id
where ur.user_id = #{userId,jdbcType=BIGINT}
</select>
五、常见坑位与排查
5.1 Invalid bound statement (not found)
如果出现:
Invalid bound statement (not found): xxxMapper.xxxMethod
优先检查:
- Mapper XML 的
namespace是否和接口全限定名一致 - XML 中
id是否和接口方法名一致 mapper-locations是否能扫描到对应 XML
5.2 Collection<? extends GrantedAuthority> 不能 add
错误示例:
Collection<? extends GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
应改为:
List<GrantedAuthority> authorities = new ArrayList<>();
或 List<SimpleGrantedAuthority>,再作为 Collection<? extends GrantedAuthority> 返回。
5.3 rolesList 为空导致 NPE
getAuthorities() 中如果依赖角色列表,务必判空,避免空指针。
5.4 账户状态字段为 null
Users 中四个状态字段如果是 Boolean 包装类型,数据库脏数据可能导致 null。建议:
- 初始化数据时给默认值
1 - 读取后做
Boolean.TRUE.equals(...)风格的安全判定
5.5 只看到登录失败,不知道具体原因
账户状态失败通常对应异常:
DisabledExceptionLockedExceptionAccountExpiredExceptionCredentialsExpiredException
例如,AuthenticationFailureHandler 中可根据异常类型返回不同提示:
if (exception instanceof DisabledException) {
response.getWriter().write("账号已被禁用");
} else if (exception instanceof LockedException) {
response.getWriter().write("账号已被锁定");
}
// ... 其他异常同理
这部分将在统一异常处理章节中详细展开。
六、快速验收清单
可使用以下测试账号验证状态字段是否生效:
admin/admin123:应登录成功disabled/disabled123:应被判定禁用locked/locked123:应被判定锁定expired/expired123:应被判定账号过期credential_expired/credential123:应被判定凭证过期
七、核心概念总结
| 概念 | 说明 |
|---|---|
| UserDetails | Spring Security 的认证用户模型 |
| GrantedAuthority | 权限抽象,角色和权限点最终都可映射为它 |
| RBAC | 用户-角色-权限三层授权模型 |
| roles.role | 角色编码字段,建议使用 ROLE_ 前缀 |
| 四状态字段 | enabled / accountNonExpired / accountNonLocked / credentialsNonExpired |
八、总结
本章完成的核心升级:
- 建立了更规范的 RBAC 数据结构。
- 将账户状态字段真正接入 Spring Security 认证判定。
- 实现从数据库动态装载角色与权限集合。
- 梳理了 Mapper 绑定、泛型集合、空指针等高频问题。
到这里,你的项目已经不再是"演示级登录",而是具备了真实系统常见的认证授权骨架。下一步可以继续做接口级授权规则(hasRole / hasAuthority)与异常返回统一化。
编辑者 :Flittly
更新时间:2026年4月