DevOps平台 --- 第二篇:登录认证与用户角色管理
平台的第一步,不是做花哨的功能,而是把「谁在用」「能做什么」这两个问题回答清楚。这篇记录登录认证、用户管理、角色管理三个模块的设计与实现。
一、整体架构
这三个模块分布在后端的不同层中:
scss
┌──────────────────────────────────────────────────────────┐
│ Controller 层 │
│ AuthController SysUserController SysRoleController
│ (登录/登出/改密/ (用户CRUD/锁定/ (角色CRUD/权限/
│ 企业选择) 禁用/重置密码) 数据范围/默认角色)
└──────┬────────────────────┬──────────────────┬──────────┘
│ │ │
┌──────▼────────────────────▼──────────────────▼──────────┐
│ Service 层 │
│ ISysUserService ISysRoleService
│ (用户业务逻辑) (角色业务逻辑)
└──────┬────────────────────┬──────────────────┬──────────┘
│ │ │
┌──────▼────────────────────▼──────────────────▼──────────┐
│ 基础设施层 │
│ LoginHelper LoginSecurityFacade StpInterfaceImpl
│ (Sa-Token JWT 封装) (登录安全门面) (Sa-Token 权限桥接)
│ │
│ PasswordSecurityService PasswordCredentialService │
│ (密码策略校验/锁定) (密码哈希/历史/强制改密) │
└──────────────────────────────────────────────────────────┘
关键设计点:认证逻辑不写在 Controller 里,而是下沉到基础设施层。 Controller 只做参数接收和结果返回,安全策略由独立的门面(Facade)和端口(Port)编排。
二、登录认证
2.1 认证框架选型:Sa-Token + JWT Simple 模式
我们选择 Sa-Token 而非 Spring Security,原因很简单:
- API 简洁 :
StpUtil.login(userId)一行完成登录,不需要 Filter Chain 那套 - JWT 支持:内置 JWT 模式,不需要额外引依赖
- 会话管理:Simple 模式下 JWT 承载 loginId + extra,同时 Redis 持久化 token 映射,既无状态又支持强制下线
为什么选 Simple 模式而不是 Stateless 模式?
Stateless 模式是纯 JWT,服务端不存任何会话状态,缺点是无法主动让一个 token 失效。Simple 模式在 JWT 之外,用 Redis 维护了一份 token 映射,这意味着:
- 改密后可以强制注销当前 token
- 在线用户列表可以查询
- 管理员可以强制踢人下线
代价是多一次 Redis 查询,但这个代价完全可接受。
2.2 JWT 载荷设计
JWT 中不把整个用户对象塞进去,而是只存必要字段:
userId作为loginId存在 JWT 主体中username、nickname存在 extra 中,避免每次请求都查库companyId、companyCode在用户选择企业后才写入,未选择企业时为 null- 敏感信息(密码、手机号、邮箱等)不进 JWT ,需要时走
/auth/info接口查库
功能截图:

2.3 两阶段登录:先认证,再选企业
我们的系统支持多企业(一个人可以属于多个企业),所以登录流程是两阶段的:
bash
阶段一:POST /auth/login
├─ 校验用户名密码
├─ 签发 JWT(不含企业信息)
└─ 返回 token + 用户关联的企业列表
阶段二:POST /auth/select-enterprise/{companyId}
├─ 校验用户属于该企业
├─ 注销当前 token
├─ 重新签发 JWT(含企业信息)
└─ 返回新 token
为什么不一开始就把企业信息塞进 JWT? 因为登录时还不知道用户要进哪个企业。如果塞所有企业列表,JWT 会膨胀;如果只塞第一个,用户切换企业时又要改 token。两阶段登录是最干净的方案。
切换企业也是同样的逻辑------注销当前 token,签发含新企业信息的新 token。Simple 模式下旧 token 立即失效,不存在多 token 并存的安全问题。
功能截图:

2.4 登录安全:不只是校验密码
密码校验通过不等于登录成功。完整的登录安全流程由 LoginSecurityFacade 编排,共 8 个步骤:
- 断言未被临时锁定 --- 如果账号因多次失败被 Redis 临时锁定,直接拒绝
- 查询用户 --- 用户不存在时也累计失败计数,降低用户名枚举攻击风险
- 校验密码 --- BCrypt 比对,失败则累计失败次数并返回处置结果
- 校验是否禁用 --- 禁用用户不允许登录
- 校验是否锁定 --- 检查
sys_user_lock表是否存在永久锁定记录 - 清理失败计数 --- 登录成功后清除 Redis 中的失败计数和临时锁
- 策略复核 --- 评估密码是否过期、是否需要强制改密
- 签发 JWT --- 登录并记录客户端 IP 和浏览器信息
安全措施一览:
| 安全措施 | 实现方式 |
|---|---|
| 临时锁定 | Redis 累计失败次数,达到阈值后临时锁定(默认 5 次/15 分钟窗口,锁定 30 分钟) |
| 永久锁定 | failLockMinutes=0 时,失败次数达标后写入 sys_user_lock 表,需管理员手动解锁 |
| 防用户名枚举 | 用户不存在时也调用 onBadCredentials,让攻击者无法通过响应差异判断用户是否存在 |
| 密码过期 | 登录后评估密码是否过期(expireDays=90),过期则返回 needChangePwd=true 强制改密 |
| 首次登录强制改密 | 新建用户 forcePwdChange=true,登录后返回 needChangePwd=true |
| 客户端信息记录 | 登录时记录 IP 和浏览器标识到 Token-Session,供在线用户管理展示 |
功能截图:

2.5 权限桥接:Sa-Token 如何查到用户的角色和权限
Sa-Token 的鉴权接口是 StpInterface,需要实现 getPermissionList 和 getRoleList 两个方法。
这里有一个依赖方向的设计: Sa-Token 模块(modules-satoken)不能反向依赖业务模块(business-system),否则基础组件层会依赖业务层,架构就乱了。
解法是端口-适配器模式 :satoken 模块定义 UserRolePermProvider 接口(端口),business-system 模块实现它(适配器),satoken 通过 ObjectProvider 延迟获取实现。如果业务模块没引入,返回空列表,不报错。
权限查询链路:
markdown
Sa-Token 鉴权
└─ StpInterfaceImpl
└─ UserRolePermProvider(端口接口)
└─ ISysRoleService.selectRoleCodesByUserId() → 角色编码列表
└─ 数据库
权限标识(perms)的查询同样通过端口接口获取,业务模块实现后注入 Sa-Token 鉴权链路。
三、用户管理
3.1 用户实体设计
SysUser 实体不只是一个表映射,它承载了大量的非数据库字段用于业务场景。
数据库字段: 用户名、密码(BCrypt)、昵称、邮箱、手机号、性别、人员身份(内部/外包)、强制改密标记、密码更新时间等。
非数据库字段(@TableField(exist = false)): 角色ID列表、角色列表、团队ID列表、职位ID列表、锁定状态、锁定原因、是否已加入当前企业等,用于编辑时传参和展示时填充。
为什么不拆成多个 VO? 在实际开发中,一个实体承载多种场景的「扩展字段」是 MyBatis-Plus 的常见用法。拆太多 VO 会导致类爆炸,而且大部分字段的组合是固定的。通过 @TableField(exist = false) 明确标记非数据库字段,不会影响 CRUD。
3.2 用户 CRUD:不止增删改查
用户管理接口远超基础 CRUD,完整功能清单:
| 功能 | 接口 | 说明 |
|---|---|---|
| 分页查询 | GET /system/user/list |
支持用户名/姓名模糊搜索 |
| 新增用户 | POST /system/user |
密码经策略校验后 BCrypt 加密 |
| 修改用户 | PUT /system/user |
含操作前后快照,供审计 Diff |
| 删除用户 | DELETE /system/user/{ids} |
逻辑删除(deleted=true) |
| 重置密码 | PUT /system/user/reset-pwd |
管理员重置,触发强制改密 |
| 启用用户 | PUT /system/user/enable/{id} |
清除禁用状态 |
| 禁用用户 | PUT /system/user/disable |
记录禁用原因和时间 |
| 锁定用户 | PUT /system/user/lock |
管理员手动锁定,记录原因 |
| 解锁用户 | PUT /system/user/unlock/{id} |
清除 sys_user_lock 记录 |
| 企业成员管理 | POST/DELETE /system/user/current-company/members |
将已有账号加入/移出当前企业 |
新增用户时的密码处理: 调用 PasswordCredentialService.prepareNewPassword 完成三件事:
- 校验密码是否符合策略(长度、复杂度、弱口令、用户名包含、键盘序列等)
- BCrypt 哈希(随机盐,不可逆)
- 决定是否标记强制改密 (首次设密默认
forcePwdChange=true)
功能截图:


3.3 两种状态控制:禁用 vs 锁定
这两个概念容易混淆,但语义不同:
| 禁用(disabled) | 锁定(locked) | |
|---|---|---|
| 触发方式 | 管理员手动操作 | 登录失败次数达标自动触发,或管理员手动 |
| 存储位置 | sys_user 表的 disabled 字段 |
独立的 sys_user_lock 表 |
| 恢复方式 | 管理员手动启用 | 管理员手动解锁,或临时锁自动过期 |
| 语义 | 账号已停用,不再活跃 | 账号被保护性冻结,可能可恢复 |
为什么锁定用独立表?因为锁定有额外的元数据:锁定原因、锁定来源(manual / password_fail)、锁定时间、预计解锁时间。这些放在 sys_user 表里会让实体越来越臃肿。
功能截图:


3.4 企业隔离:@CompanyIsolated
用户管理有两套接口:全局接口和企业隔离接口。区别在于是否标注 @CompanyIsolated 注解。
@CompanyIsolated 做了什么?它通过拦截器校验请求头中的 X-Company-Code 与 JWT 中的企业编码一致后,开启 CompanyScope,后续查询自动加上 WHERE companyId = ? 条件。
设计意图: 全局接口给超级管理员用(能看到所有企业的用户),企业隔离接口给普通管理员用(只能看到当前企业的成员)。同一套 Service 逻辑,通过注解控制数据范围。
四、角色管理
4.1 RBAC 模型
平台采用标准 RBAC(Role-Based Access Control)模型:
用户 ──多对多──> 角色 ──多对多──> 菜单(目录/页面/按钮)
两张关联表:
sys_user_role:用户-角色关联sys_role_menu:角色-权限关联
4.2 角色实体与核心字段
角色实体 SysRole 包含以下核心字段:
- 角色名称:展示用,可修改
- 角色编码 :如
admin、developer,一旦创建不可修改,因为编码是权限判断的依据 - 数据范围:控制角色能看到哪些数据(ALL/DEPT/PROJECT/SELF)
- 排序:控制角色在列表中的展示顺序
- 默认角色:全局唯一,新建用户时自动分配
功能截图:

4.3 数据范围控制
角色的 dataScope 字段控制该角色能看到哪些数据:
| 值 | 含义 | 场景 |
|---|---|---|
ALL |
全局数据 | 超级管理员 |
DEPT |
本部门及下级 | 部门主管 |
PROJECT |
本项目 | 项目成员(默认值) |
SELF |
仅本人 | 普通开发 |
数据范围的批量更新通过 PUT /system/role/data-scope 接口,接收含多个角色的数据范围配置,一次性批量更新。更新时携带 version 字段做乐观锁控制,防止并发修改覆盖。
功能截图:

4.4 默认角色:全局唯一
系统支持设置一个默认角色,新建用户时自动分配。约束是全局只能有一个默认角色。设置新默认角色时,先将所有已有的默认角色置为 false,再将目标角色置为 true,整个过程在事务中完成。
4.5 角色删除保护
删除角色前会检查是否有关联用户------如果有用户正在使用该角色,拒绝删除并提示「请先解除用户关联后再删除」。删除成功后同步清理角色与权限的关联数据,避免产生孤儿记录。
先校验再删除,删除时同步清理关联数据,这是所有涉及关联关系的删除操作的基本原则。
五、操作审计:@LogRecord
上述所有写操作都带有 @LogRecord 注解,实现了操作日志的自动记录。注解中定义了成功/失败文案模板、操作类型、子类型和业务编号,支持 SpEL 表达式动态填充操作人、操作对象等信息。
对于修改操作,还会在更新前后分别落库快照,通过 LogRecordContext 传入旧值和新值,框架自动计算 Diff,记录「改了哪些字段,从什么值改成了什么值」。
这样操作日志不仅记录了「谁做了什么」,还能看到具体变更内容,满足安全审计需求。
功能截图:
此处放置操作日志详情页面截图


六、总结与思考
做对了什么
-
端口-适配器模式解耦了认证框架和业务模块。satoken 模块不依赖任何业务代码,业务模块通过实现接口注入权限数据。如果未来换权限框架,业务代码不用动。
-
两阶段登录解决了多企业问题。不把企业信息塞死在 JWT 里,而是通过「选择企业 → 重新签发 token」的方式实现动态切换。
-
禁用与锁定分离。看似多了一个概念,实际上让管理逻辑更清晰------禁用是业务决策,锁定是安全保护。
-
密码安全下沉到基础设施层 。Controller 不感知密码策略细节,只调
prepareNewPassword拿到哈希结果。策略变更(比如把最小长度从 8 改到 12)不需要改任何业务代码。
可以改进的地方
-
权限查询没有缓存 。每次请求鉴权都查一次数据库(
selectPermsByUserId),高并发场景下会成为瓶颈。后续可以引入 Redis 缓存,角色权限变更时主动失效。 -
角色权限分配是先删后插。如果中途异常,虽然事务会回滚,但先删后插的方式在数据量大时不够优雅。可以考虑 Diff 计算,只增删差异部分。
-
用户实体承载了太多非数据库字段 。虽然
@TableField(exist = false)标记了,但 SysUser 类已经有 20+ 个非持久化字段。后续考虑按场景拆分 VO。
2026 年 8 月 · 第二篇