DevOps平台 — 第二篇:登录认证与用户角色管理

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 主体中
  • usernamenickname 存在 extra 中,避免每次请求都查库
  • companyIdcompanyCode 在用户选择企业后才写入,未选择企业时为 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 个步骤:

  1. 断言未被临时锁定 --- 如果账号因多次失败被 Redis 临时锁定,直接拒绝
  2. 查询用户 --- 用户不存在时也累计失败计数,降低用户名枚举攻击风险
  3. 校验密码 --- BCrypt 比对,失败则累计失败次数并返回处置结果
  4. 校验是否禁用 --- 禁用用户不允许登录
  5. 校验是否锁定 --- 检查 sys_user_lock 表是否存在永久锁定记录
  6. 清理失败计数 --- 登录成功后清除 Redis 中的失败计数和临时锁
  7. 策略复核 --- 评估密码是否过期、是否需要强制改密
  8. 签发 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,需要实现 getPermissionListgetRoleList 两个方法。

这里有一个依赖方向的设计: 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 完成三件事:

  1. 校验密码是否符合策略(长度、复杂度、弱口令、用户名包含、键盘序列等)
  2. BCrypt 哈希(随机盐,不可逆)
  3. 决定是否标记强制改密 (首次设密默认 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 包含以下核心字段:

  • 角色名称:展示用,可修改
  • 角色编码 :如 admindeveloper一旦创建不可修改,因为编码是权限判断的依据
  • 数据范围:控制角色能看到哪些数据(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,记录「改了哪些字段,从什么值改成了什么值」。

这样操作日志不仅记录了「谁做了什么」,还能看到具体变更内容,满足安全审计需求。

功能截图:

此处放置操作日志详情页面截图

六、总结与思考

做对了什么

  1. 端口-适配器模式解耦了认证框架和业务模块。satoken 模块不依赖任何业务代码,业务模块通过实现接口注入权限数据。如果未来换权限框架,业务代码不用动。

  2. 两阶段登录解决了多企业问题。不把企业信息塞死在 JWT 里,而是通过「选择企业 → 重新签发 token」的方式实现动态切换。

  3. 禁用与锁定分离。看似多了一个概念,实际上让管理逻辑更清晰------禁用是业务决策,锁定是安全保护。

  4. 密码安全下沉到基础设施层 。Controller 不感知密码策略细节,只调 prepareNewPassword 拿到哈希结果。策略变更(比如把最小长度从 8 改到 12)不需要改任何业务代码。

可以改进的地方

  1. 权限查询没有缓存 。每次请求鉴权都查一次数据库(selectPermsByUserId),高并发场景下会成为瓶颈。后续可以引入 Redis 缓存,角色权限变更时主动失效。

  2. 角色权限分配是先删后插。如果中途异常,虽然事务会回滚,但先删后插的方式在数据量大时不够优雅。可以考虑 Diff 计算,只增删差异部分。

  3. 用户实体承载了太多非数据库字段 。虽然 @TableField(exist = false) 标记了,但 SysUser 类已经有 20+ 个非持久化字段。后续考虑按场景拆分 VO。


2026 年 8 月 · 第二篇

相关推荐
终末圆3 小时前
GitHub Actions 实现 CI/CD
运维·ci/cd·github·运维开发·devops
运维开发那些事3 天前
GitOps最佳实践 (gitlab ci + ArgoCD)
ci/cd·devops
可乐ea3 天前
Anthropic 的 CI/CD 值班智能体:Claude Tag 当一线响应者的架构拆解与踩坑复盘
ci/cd·架构·claude·devops·ai智能体·mcp
Zadig4 天前
Zadig 全面支持 CRD,至此所有 K8s 资源类型均可一键发布!
后端·devops
NineData9 天前
DTCC 2026 预告|NineData CEO& 创始人叶正盛:面向 AI Agent 的数据库 DevOps 与数据复制实践
数据库·人工智能·数据库开发·devops·ninedata·数据库技术·dtcc
7177779 天前
如何搭建信创研发交付体系?Gitee DevOps 国产化适配、安全与流水线能力解析
安全·gitee·devops
m0_5257247210 天前
AIOps全链路智能运维架构揭秘
云原生·devops
cakeism82511 天前
Gitee DevOps 平台定位、核心能力与选型指南
运维·gitee·devops