【SpringSecurity新手村系列】(5)RBAC角色权限与账户状态校验

0 阅读1分钟

第五章 RBAC角色权限与账户状态校验

本章继续完善 Spring Security:把数据库中的用户、角色、权限真正接入认证流程,同时补齐账户状态字段(是否过期、是否锁定、凭证是否过期、是否启用),实现“可登录 + 可授权 + 可控状态”的完整认证授权闭环。

在前四章里,我们已经完成了表单登录、验证码、数据库查用户等基础能力。本章开始进入更贴近真实项目的一步:让权限从数据库驱动,而不是写死在代码里。

一、问题切入

只完成“用户名密码能登录”还不够,企业项目通常还需要三个层次:

  • 用户是否可登录(禁用、锁定、过期等状态)
  • 登录后具备哪些角色(如 ROLE_ADMINROLE_USER
  • 角色对应哪些细粒度权限(如 content:moderate

如果这三块没打通,系统会出现典型问题:

  • 用户状态失效:数据库标记禁用,但系统依然放行
  • 权限粗糙:所有登录用户权限一致
  • 运维成本高:每次改权限都要改代码

二、解决方案概览

本章的做法是:

  1. users 表存账号与四个状态字段(enabledaccount_non_expiredaccount_non_lockedcredentials_non_expired)。
  2. roles 表新增 role 字段,专门存角色编码(如 ROLE_ADMIN)。
  3. 通过 user_rolesrole_permissionspermissions 建立 RBAC 授权关系。
  4. UserServiceImpl#loadUserByUsername 中查询并组装 GrantedAuthority
  5. 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_ADMIN
  • ROLE_USER
  • ROLE_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,就立即抛出对应异常(如 DisabledExceptionLockedException 等),终止登录流程。因此,数据库中的状态字段会直接决定用户能否成功认证。

4.2 动态组装权限集合

UserServiceImpl 中,先查用户,再查角色码与权限码,并转换成 SimpleGrantedAuthority,最后回填到 Usersauthorities 字段:

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 就携带了完整的角色和权限集合,供后续授权阶段使用。

这里有两个实践细节:

  1. 建议角色和权限都装入 authorities,避免“URL 用角色、方法用权限”时出现缺失。
  2. 角色建议固定 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 不能 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 只看到登录失败,不知道具体原因

账户状态失败通常对应异常:

  • DisabledException
  • LockedException
  • AccountExpiredException
  • CredentialsExpiredException

例如,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

八、总结

本章完成的核心升级:

  1. 建立了更规范的 RBAC 数据结构。
  2. 将账户状态字段真正接入 Spring Security 认证判定。
  3. 实现从数据库动态装载角色与权限集合。
  4. 梳理了 Mapper 绑定、泛型集合、空指针等高频问题。

到这里,你的项目已经不再是“演示级登录”,而是具备了真实系统常见的认证授权骨架。下一步可以继续做接口级授权规则(hasRole / hasAuthority)与异常返回统一化。

编辑者:Flittly
更新时间:2026年4月