统一认证技术方案

作者:old wang 发布时间: 2025-06-22 阅读量:0 评论数:0

1. 背景与目标

随着业务系统不断增加,用户、账号、登录、组织架构、角色权限、菜单权限、按钮权限、接口权限等基础能力容易在多个系统中重复建设,导致维护成本高、权限不一致、用户状态无法统一控制、审计日志分散、新系统接入成本高等问题。

建设统一用户中心的目标,是将用户体系、认证登录、组织架构、角色权限、权限校验、Token 管理、登录日志、操作审计等基础能力统一收口,为多个业务系统提供统一的身份认证和权限管理能力。

统一用户中心需要解决以下问题:

1. 多个业务系统重复维护用户信息
2. 多个系统登录体系不统一
3. 用户状态无法统一控制
4. 用户离职、禁用、修改密码后 Token 无法及时失效
5. 角色和权限分散在各个系统中
6. 菜单、按钮、接口权限模型不统一
7. 权限变更后业务系统无法及时感知
8. 登录日志、操作日志、审计日志分散
9. 新业务系统接入成本高
10. 系统间用户身份和权限边界不清晰

建设完成后,各业务系统不再单独维护基础用户和基础权限数据,而是统一接入用户中心。

2. 建设范围

2.1 一期建设范围

一期目标是先落地内部系统的统一登录和统一权限,不直接建设完整 OAuth2/OIDC 体系。

一期建设内容:

1. 用户管理
2. 账号管理
3. 组织架构管理
4. 应用管理
5. 角色管理
6. 权限管理
7. 用户角色分配
8. 角色权限分配
9. 统一登录
10. access_token 签发
11. refresh_token 刷新
12. refresh_token rotation
13. refresh_token 重放检测
14. refresh_token 并发刷新处理
15. 用户会话管理
16. 用户禁用即时失效
17. 修改密码后 Token 失效
18. 强制用户下线
19. 菜单权限
20. 按钮权限
21. 接口权限
22. 基础数据权限
23. 登录日志
24. 操作日志
25. 业务系统 SDK 接入
26. 网关 Header 安全处理
27. Redis 异常降级策略
28. RS256 密钥轮换机制

2.2 一期暂不建设内容

一期暂不做以下复杂能力:

1. 完整 OAuth2/OIDC
2. 第三方开放平台
3. 外部应用授权登录
4. 扫码登录
5. 完整 SaaS 多租户
6. 复杂自定义数据权限
7. 多端设备复杂风控
8. 第三方社交账号登录
9. 跨应用授权访问

2.3 二期扩展方向

二期可以在一期基础上升级为标准认证授权中心:

1. Spring Authorization Server
2. OAuth2 Authorization Code
3. OIDC
4. PKCE
5. JWKS
6. scope 权限范围
7. client_id / client_secret
8. redirect_uri 管理
9. 第三方系统接入
10. 开放平台授权
11. 自定义数据权限
12. 多租户模型
13. 多端设备管理

3. 技术选型

模块

技术方案

后端框架

Spring Boot 3.x

安全框架

Spring Security

Token

JWT access_token + 随机 refresh_token

JWT 算法

RS256

密码加密

BCryptPasswordEncoder

refresh_token 哈希

HMAC-SHA256

缓存

Redis

本地缓存

Caffeine

数据库

MySQL

ORM

MyBatis / MyBatis-Plus

网关

Spring Cloud Gateway

注册配置

Nacos

日志

Logback

监控

Prometheus + Grafana

接口文档

OpenAPI / Knife4j

权限控制

Spring Security + 自定义权限注解

业务接入

user-center-spring-boot-starter

一期推荐技术路线:

Spring Boot 3.x
Spring Security
JWT access_token
RS256
Redis
Caffeine 本地缓存
MySQL
refresh_token rotation
refresh_token 重放检测
refresh_token 并发刷新处理
uc_user_session 会话表
uc_refresh_token_record 记录表
用户级 tokenVersion 强制失效
权限 Redis 缓存
业务系统 SDK
Gateway

4. 总体架构

                    ┌────────────────────┐
                    │      前端系统       │
                    │ Admin / H5 / App    │
                    └─────────┬──────────┘
                              │
                              ▼
                    ┌────────────────────┐
                    │      API Gateway    │
                    │  Token 基础校验      │
                    │  清理伪造 Header     │
                    └─────────┬──────────┘
                              │
          ┌───────────────────┼───────────────────┐
          │                   │                   │
          ▼                   ▼                   ▼
┌────────────────┐  ┌────────────────┐  ┌────────────────┐
│  业务系统 A     │  │  业务系统 B     │  │  业务系统 C     │
│ Resource Server│  │ Resource Server│  │ Resource Server│
│ 二次验签 + 鉴权 │  │ 二次验签 + 鉴权 │  │ 二次验签 + 鉴权 │
└────────┬───────┘  └────────┬───────┘  └────────┬───────┘
         │                   │                   │
         └───────────────────┼───────────────────┘
                             ▼
                  ┌────────────────────┐
                  │    统一用户中心     │
                  │ User Center Server │
                  └─────────┬──────────┘
                            │
        ┌───────────────────┼───────────────────┐
        ▼                   ▼                   ▼
   ┌─────────┐         ┌─────────┐         ┌─────────┐
   │  MySQL  │         │  Redis  │         │  MQ可选 │
   │ 用户权限 │         │ Token缓存│         │ 审计事件 │
   └─────────┘         └─────────┘         └─────────┘

5. 系统边界

5.1 用户中心负责

统一用户中心负责基础身份和基础权限能力:

1. 用户是谁
2. 用户有哪些账号
3. 用户是否允许登录
4. 用户能登录哪些系统
5. 用户在某个系统中有哪些角色
6. 用户在某个系统中有哪些权限
7. 用户能看到哪些菜单
8. 用户能点击哪些按钮
9. 用户能访问哪些接口
10. 用户拥有哪种数据权限范围
11. 登录、退出、刷新 Token
12. 用户禁用、强制下线、修改密码后的 Token 失效
13. 登录日志
14. 操作审计

5.2 业务系统负责

业务系统负责具体业务能力:

1. 业务数据
2. 业务流程
3. 业务接口实现
4. 业务表数据过滤
5. 根据用户中心返回的数据权限范围拼接业务查询条件
6. 具体业务操作日志

用户中心不应该承载订单、客户、合同、库存、财务单据等业务逻辑。

一句话边界:

用户中心负责“用户是谁、能登录哪里、拥有什么权限”;
业务系统负责“用户在具体业务里能操作什么数据”。

6. 核心设计原则

1. 用户和账号分离
2. 应用 appCode 隔离
3. 角色和权限按应用隔离
4. JWT 只承载基础身份信息
5. 权限不直接放入 JWT
6. JWT 必须包含 iss、aud、sub、iat、nbf、exp、jti 等标准字段
7. aud 和 appCode 一期保持一致
8. refresh_token 服务端可控
9. refresh_token 只存 HMAC-SHA256 哈希,不存明文
10. access_token 必须包含 jti
11. refresh_token 必须支持 rotation
12. refresh_token 必须支持重放检测
13. refresh_token 刷新必须处理并发场景,避免误判攻击
14. 一期采用用户级 tokenVersion
15. 用户禁用、修改密码、强制下线通过 tokenVersion 实现
16. 权限变更通过缓存清理或权限版本号实现
17. 前端不能自由传 appCode 获取权限
18. 网关 Header 不能被外部伪造
19. Redis 异常时不能默认放行所有请求
20. 数据权限由用户中心返回范围,业务系统执行过滤
21. 一期不做多租户,就不要在模型里伪预留 tenantId
22. 敏感日志必须脱敏
23. 网关校验和业务系统二次校验并存

7. 核心业务模型

7.1 用户与账号模型

用户和账号分离。

一个用户可以绑定多个账号:

用户 User
  └── 账号 Account
        ├── 用户名账号
        ├── 手机号账号
        ├── 邮箱账号
        ├── 微信账号
        ├── 钉钉账号
        └── 企业微信账号

用户表表示自然人或平台用户。

账号表表示登录凭证。

这样可以支持:

1. 一个用户绑定手机号登录
2. 一个用户绑定邮箱登录
3. 一个用户绑定用户名登录
4. 一个用户绑定第三方 openId 登录
5. 修改手机号时不影响用户主身份
6. 后续扩展第三方登录更方便

7.2 应用隔离模型

统一用户中心需要服务多个业务系统,因此必须引入 app_code

示例:

crm
oa
erp
mall-admin
finance

同一个用户在不同系统中的角色可能不同:

张三在 OA 系统是普通员工
张三在 CRM 系统是销售主管
张三在财务系统没有权限

所以用户角色关系必须带 app_code

Token 中的 appCode 表示当前登录应用。

业务系统 SDK 必须校验:

token.aud == 当前业务系统 appCode
token.appCode == 当前业务系统 appCode

如果不一致,直接拒绝访问。

7.3 权限模型

一期采用:

RBAC + 基础数据权限

基础模型:

用户
  └── 用户角色
        └── 角色
              └── 角色权限
                    └── 权限

权限类型:

MENU    菜单权限
BUTTON  按钮权限
API     接口权限

权限编码示例:

user:add
user:update
user:delete
user:query

order:query
order:export
order:audit

customer:assign
customer:follow

菜单权限、按钮权限、接口权限可以有关联,但不能强制绑定。

例如导出接口、异步任务查询接口、文件下载接口可能没有独立菜单,但仍然需要 API 权限控制。

8. 数据库设计

8.1 用户表

CREATE TABLE uc_user (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
    user_no VARCHAR(64) NOT NULL COMMENT '用户编号',
    nickname VARCHAR(64) DEFAULT NULL COMMENT '昵称',
    real_name VARCHAR(64) DEFAULT NULL COMMENT '真实姓名',
    avatar VARCHAR(255) DEFAULT NULL COMMENT '头像',
    gender TINYINT DEFAULT 0 COMMENT '性别:0未知 1男 2女',
    mobile VARCHAR(32) DEFAULT NULL COMMENT '手机号,展示冗余字段',
    email VARCHAR(128) DEFAULT NULL COMMENT '邮箱,展示冗余字段',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1正常 2禁用 3注销',
    token_version INT NOT NULL DEFAULT 0 COMMENT 'Token版本号',
    create_time DATETIME NOT NULL COMMENT '创建时间',
    update_time DATETIME NOT NULL COMMENT '更新时间',
    UNIQUE KEY uk_user_no (user_no),
    KEY idx_mobile (mobile),
    KEY idx_email (email),
    KEY idx_status (status)
) COMMENT='统一用户表';

说明:

1. uc_user 是用户主数据
2. mobile、email 是展示冗余字段
3. 真正的登录账号以 uc_account 为准
4. 修改手机号或邮箱时,需要同时更新 uc_user 和 uc_account
5. token_version 用于用户禁用、修改密码、强制下线后的 Token 失效

8.2 账号表

CREATE TABLE uc_account (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
    user_id BIGINT NOT NULL COMMENT '用户ID',
    account_type VARCHAR(32) NOT NULL COMMENT '账号类型:USERNAME MOBILE EMAIL WECHAT DINGTALK',
    identifier VARCHAR(128) NOT NULL COMMENT '账号标识:用户名/手机号/邮箱/openId',
    credential VARCHAR(255) DEFAULT NULL COMMENT '密码凭证,第三方登录可为空',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1正常 2禁用 3锁定',
    last_login_time DATETIME DEFAULT NULL COMMENT '最近登录时间',
    create_time DATETIME NOT NULL COMMENT '创建时间',
    update_time DATETIME NOT NULL COMMENT '更新时间',
    UNIQUE KEY uk_account_type_identifier (account_type, identifier),
    KEY idx_user_id (user_id),
    KEY idx_status (status)
) COMMENT='统一账号表';

密码存储要求:

1. 不允许明文保存密码
2. 不建议使用 MD5 + salt
3. 推荐使用 BCryptPasswordEncoder
4. 第三方登录账号 credential 可以为空

登录失败锁定分为两类:

1. 临时锁定:Redis 控制,例如 5 分钟失败 5 次,锁定 15 分钟
2. 永久锁定:uc_account.status = 3

8.3 应用表

CREATE TABLE uc_app (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
    app_code VARCHAR(64) NOT NULL COMMENT '应用编码',
    app_name VARCHAR(128) NOT NULL COMMENT '应用名称',
    app_secret_hash VARCHAR(255) DEFAULT NULL COMMENT '应用密钥哈希值',
    app_type VARCHAR(32) NOT NULL DEFAULT 'INTERNAL' COMMENT '应用类型:INTERNAL EXTERNAL',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1正常 2禁用',
    need_role_on_login TINYINT NOT NULL DEFAULT 1 COMMENT '登录时是否要求拥有角色:0否 1是',
    access_token_ttl INT NOT NULL DEFAULT 7200 COMMENT 'access_token有效期,单位秒',
    refresh_token_ttl INT NOT NULL DEFAULT 604800 COMMENT 'refresh_token有效期,单位秒',
    create_time DATETIME NOT NULL COMMENT '创建时间',
    update_time DATETIME NOT NULL COMMENT '更新时间',
    UNIQUE KEY uk_app_code (app_code),
    KEY idx_status (status)
) COMMENT='接入应用表';

说明:

1. app_secret_hash 一期不用于普通用户登录
2. 普通用户名密码登录不要求前端传 app_secret
3. app_secret_hash 一期主要用于服务间调用身份校验
4. need_role_on_login 用于控制登录时是否必须拥有角色
5. 后台管理类系统建议 need_role_on_login = 1
6. 门户、个人中心类系统可设置 need_role_on_login = 0
7. 二期升级 OAuth2/OIDC 后,可扩展为 client_secret 模型

8.4 组织表

CREATE TABLE uc_org (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
    parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父组织ID',
    org_code VARCHAR(64) NOT NULL COMMENT '组织编码',
    org_name VARCHAR(128) NOT NULL COMMENT '组织名称',
    org_type VARCHAR(32) NOT NULL COMMENT '组织类型:COMPANY DEPT TEAM',
    sort INT NOT NULL DEFAULT 0 COMMENT '排序',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1正常 2禁用',
    create_time DATETIME NOT NULL COMMENT '创建时间',
    update_time DATETIME NOT NULL COMMENT '更新时间',
    UNIQUE KEY uk_org_code (org_code),
    KEY idx_parent_id (parent_id),
    KEY idx_status (status)
) COMMENT='组织架构表';

8.5 用户组织关系表

CREATE TABLE uc_user_org (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
    user_id BIGINT NOT NULL COMMENT '用户ID',
    org_id BIGINT NOT NULL COMMENT '组织ID',
    position_name VARCHAR(128) DEFAULT NULL COMMENT '岗位名称',
    is_main TINYINT NOT NULL DEFAULT 0 COMMENT '是否主部门:0否 1是',
    create_time DATETIME NOT NULL COMMENT '创建时间',
    UNIQUE KEY uk_user_org (user_id, org_id),
    KEY idx_org_id (org_id),
    KEY idx_user_id (user_id)
) COMMENT='用户组织关系表';

一期数据权限默认按主部门计算。

如果二期支持多部门数据权限,可以按用户所有部门取并集。

8.6 角色表

CREATE TABLE uc_role (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
    app_code VARCHAR(64) NOT NULL COMMENT '应用编码',
    role_code VARCHAR(64) NOT NULL COMMENT '角色编码',
    role_name VARCHAR(128) NOT NULL COMMENT '角色名称',
    role_type VARCHAR(32) DEFAULT 'CUSTOM' COMMENT '角色类型:SYSTEM CUSTOM',
    data_scope VARCHAR(32) NOT NULL DEFAULT 'SELF' COMMENT '数据权限范围:ALL SELF DEPT DEPT_AND_SUB',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1正常 2禁用',
    create_time DATETIME NOT NULL COMMENT '创建时间',
    update_time DATETIME NOT NULL COMMENT '更新时间',
    UNIQUE KEY uk_app_role_code (app_code, role_code),
    KEY idx_app_code (app_code),
    KEY idx_status (status)
) COMMENT='角色表';

8.7 权限表

CREATE TABLE uc_permission (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
    app_code VARCHAR(64) NOT NULL COMMENT '应用编码',
    parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父权限ID',
    permission_code VARCHAR(128) NOT NULL COMMENT '权限编码',
    permission_name VARCHAR(128) NOT NULL COMMENT '权限名称',
    permission_type VARCHAR(32) NOT NULL COMMENT '权限类型:MENU BUTTON API',
    path VARCHAR(255) DEFAULT NULL COMMENT '前端路由或接口路径',
    method VARCHAR(16) DEFAULT NULL COMMENT '接口请求方法',
    component VARCHAR(255) DEFAULT NULL COMMENT '前端组件路径',
    icon VARCHAR(128) DEFAULT NULL COMMENT '菜单图标',
    sort INT NOT NULL DEFAULT 0 COMMENT '排序',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1正常 2禁用',
    create_time DATETIME NOT NULL COMMENT '创建时间',
    update_time DATETIME NOT NULL COMMENT '更新时间',
    UNIQUE KEY uk_app_permission_code (app_code, permission_code),
    KEY idx_app_parent (app_code, parent_id),
    KEY idx_app_type (app_code, permission_type),
    KEY idx_status (status)
) COMMENT='权限表';

8.8 用户角色关系表

CREATE TABLE uc_user_role (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
    user_id BIGINT NOT NULL COMMENT '用户ID',
    app_code VARCHAR(64) NOT NULL COMMENT '应用编码',
    role_id BIGINT NOT NULL COMMENT '角色ID',
    create_time DATETIME NOT NULL COMMENT '创建时间',
    UNIQUE KEY uk_user_app_role (user_id, app_code, role_id),
    KEY idx_user_app (user_id, app_code),
    KEY idx_role_id (role_id)
) COMMENT='用户角色关联表';

说明:

1. uc_user_role.app_code 是冗余字段,用于提升查询效率
2. 分配角色时,必须校验 uc_user_role.app_code == uc_role.app_code
3. 查询时建议强制关联 role.app_code,避免脏数据导致越权

推荐查询方式:

SELECT ur.*
FROM uc_user_role ur
JOIN uc_role r
  ON ur.role_id = r.id
 AND ur.app_code = r.app_code
WHERE ur.user_id = #{userId}
  AND ur.app_code = #{appCode}
  AND r.status = 1;

8.9 角色权限关系表

CREATE TABLE uc_role_permission (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
    role_id BIGINT NOT NULL COMMENT '角色ID',
    permission_id BIGINT NOT NULL COMMENT '权限ID',
    create_time DATETIME NOT NULL COMMENT '创建时间',
    UNIQUE KEY uk_role_permission (role_id, permission_id),
    KEY idx_permission_id (permission_id)
) COMMENT='角色权限关联表';

说明:

1. 给角色分配权限时,必须校验 role.app_code == permission.app_code
2. 查询权限时,必须关联角色和权限的 app_code,避免跨应用权限污染

8.10 用户会话表

CREATE TABLE uc_user_session (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
    session_no VARCHAR(64) NOT NULL COMMENT '会话编号',
    user_id BIGINT NOT NULL COMMENT '用户ID',
    app_code VARCHAR(64) NOT NULL COMMENT '应用编码',
    device_id VARCHAR(128) DEFAULT NULL COMMENT '设备ID',
    refresh_token_hash VARCHAR(255) NOT NULL COMMENT '当前refresh_token哈希值',
    access_token_jti VARCHAR(128) DEFAULT NULL COMMENT '当前access_token唯一ID',
    login_ip VARCHAR(64) DEFAULT NULL COMMENT '登录IP',
    user_agent VARCHAR(512) DEFAULT NULL COMMENT 'User-Agent',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1正常 2失效',
    expire_time DATETIME NOT NULL COMMENT '过期时间',
    revoked_time DATETIME DEFAULT NULL COMMENT '失效时间',
    last_refresh_time DATETIME DEFAULT NULL COMMENT '最近刷新时间',
    reuse_detected TINYINT NOT NULL DEFAULT 0 COMMENT '是否检测到refresh_token重放:0否 1是',
    create_time DATETIME NOT NULL COMMENT '创建时间',
    update_time DATETIME NOT NULL COMMENT '更新时间',
    UNIQUE KEY uk_session_no (session_no),
    UNIQUE KEY uk_refresh_token_hash (refresh_token_hash),
    KEY idx_user_id (user_id),
    KEY idx_app_user (app_code, user_id),
    KEY idx_expire_time (expire_time)
) COMMENT='用户登录会话表';

说明:

1. uc_user_session 保存当前会话状态
2. refresh_token 明文只返回给客户端,不入库
3. refresh_token_hash 使用 HMAC-SHA256(refresh_token, server_secret)
4. 不使用 BCrypt 存储 refresh_token_hash,因为 BCrypt 不适合作为可查询字段
5. session_no 用于定位会话
6. reuse_detected 用于标记 refresh_token 重放风险

8.11 refresh_token 记录表

为了真正闭环 refresh_token rotation 和重放检测,不能只在 uc_user_session 中保存当前最新的 refresh_token_hash

原因是:每次刷新后,旧的 refresh_token_hash 会被新的值覆盖。如果旧 refresh_token 再次被使用,系统只能发现“查不到有效 session”,但无法准确判断它是否属于某个历史会话,也无法判断这是普通无效 token 还是重放攻击。

因此新增 refresh_token 记录表。

CREATE TABLE uc_refresh_token_record (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
    session_no VARCHAR(64) NOT NULL COMMENT '会话编号',
    token_hash VARCHAR(255) NOT NULL COMMENT 'refresh_token哈希值',
    token_status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1有效 2已使用 3失效',
    expire_time DATETIME NOT NULL COMMENT '过期时间',
    used_time DATETIME DEFAULT NULL COMMENT '使用时间',
    create_time DATETIME NOT NULL COMMENT '创建时间',
    update_time DATETIME NOT NULL COMMENT '更新时间',
    UNIQUE KEY uk_token_hash (token_hash),
    KEY idx_session_no (session_no),
    KEY idx_expire_time (expire_time),
    KEY idx_status (token_status)
) COMMENT='refresh_token记录表';

字段说明:

session_no:归属会话编号,对应 uc_user_session.session_no
token_hash:refresh_token 的 HMAC-SHA256 哈希值
token_status:refresh_token 状态
expire_time:refresh_token 过期时间
used_time:refresh_token 被使用的时间

token_status 状态说明:

1 有效:当前 refresh_token 可以使用
2 已使用:refresh_token 已经被成功刷新过
3 失效:会话退出、强制下线、过期清理等导致失效

8.12 登录日志表

CREATE TABLE uc_login_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
    user_id BIGINT DEFAULT NULL COMMENT '用户ID',
    app_code VARCHAR(64) DEFAULT NULL COMMENT '应用编码',
    account_type VARCHAR(32) DEFAULT NULL COMMENT '账号类型',
    identifier VARCHAR(128) DEFAULT NULL COMMENT '登录账号',
    login_ip VARCHAR(64) DEFAULT NULL COMMENT '登录IP',
    user_agent VARCHAR(512) DEFAULT NULL COMMENT 'User-Agent',
    login_status TINYINT NOT NULL COMMENT '登录状态:1成功 2失败',
    fail_reason VARCHAR(255) DEFAULT NULL COMMENT '失败原因',
    create_time DATETIME NOT NULL COMMENT '创建时间',
    KEY idx_user_id (user_id),
    KEY idx_identifier (identifier),
    KEY idx_create_time (create_time)
) COMMENT='登录日志表';

8.13 操作日志表

CREATE TABLE uc_operation_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
    operator_id BIGINT DEFAULT NULL COMMENT '操作人ID',
    operator_name VARCHAR(128) DEFAULT NULL COMMENT '操作人名称',
    app_code VARCHAR(64) DEFAULT NULL COMMENT '应用编码',
    module_name VARCHAR(128) NOT NULL COMMENT '模块名称',
    operation_type VARCHAR(64) NOT NULL COMMENT '操作类型',
    target_id VARCHAR(128) DEFAULT NULL COMMENT '操作对象ID',
    before_data TEXT COMMENT '操作前数据',
    after_data TEXT COMMENT '操作后数据',
    operation_ip VARCHAR(64) DEFAULT NULL COMMENT '操作IP',
    user_agent VARCHAR(512) DEFAULT NULL COMMENT 'User-Agent',
    create_time DATETIME NOT NULL COMMENT '创建时间',
    KEY idx_operator_id (operator_id),
    KEY idx_app_code (app_code),
    KEY idx_create_time (create_time)
) COMMENT='操作日志表';

操作日志写入前必须脱敏。

需要脱敏的字段包括:

1. 密码
2. 手机号
3. 邮箱
4. app_secret
5. refresh_token
6. 身份证号
7. 银行卡号
8. 其他敏感字段

9. Token 设计

9.1 双 Token 机制

一期采用:

access_token + refresh_token

9.2 access_token

access_token 使用 JWT。

建议:

有效期:30 分钟 - 2 小时
算法:RS256
用途:访问业务接口
存储位置:前端内存或安全 Cookie

JWT Payload 示例:

{
  "iss": "user-center",
  "aud": "crm",
  "sub": "10001",
  "iat": 1782110000,
  "nbf": 1782110000,
  "exp": 1782120000,
  "jti": "access-token-id-xxx",
  "userId": 10001,
  "userNo": "U202606220001",
  "appCode": "crm",
  "tokenVersion": 3
}

JWT Header 示例:

{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "key-2026-01"
}

字段说明:

iss:签发方,固定为用户中心,例如 user-center
aud:接收方,表示该 Token 面向哪个业务系统,例如 crm
sub:用户主体标识,通常为 userId
iat:签发时间
nbf:生效时间,在该时间之前 Token 不可用
exp:过期时间
jti:Token 唯一ID,用于黑名单和审计
userId:用户ID
userNo:用户编号
appCode:当前登录应用
tokenVersion:Token版本号
kid:密钥ID,用于 RS256 密钥轮换

一期规则:

aud = appCode

业务系统 SDK 必须校验:

1. JWT 签名是否合法
2. exp 是否过期
3. nbf 是否已生效
4. iss 是否等于用户中心
5. aud 是否等于当前业务系统 appCode
6. appCode 是否等于当前业务系统 appCode
7. kid 是否能匹配有效公钥
8. jti 是否在黑名单中
9. tokenVersion 是否匹配
10. 用户状态是否正常

不建议把完整权限列表放入 JWT。

原因:

1. Token 会变大
2. 权限变更后旧 Token 无法立即感知
3. 用户角色变更后不好控制
4. 菜单权限、按钮权限变化频繁,不适合固化到 Token

9.3 refresh_token

refresh_token 使用高强度随机字符串。

建议:

有效期:7 天 - 30 天
用途:刷新 access_token
服务端存储:只保存 HMAC-SHA256 哈希
轮换机制:每次刷新都生成新的 refresh_token

refresh_token 不能明文入库。

哈希方式:

refresh_token_hash = HMAC-SHA256(refresh_token, server_secret)

密码和 refresh_token 的处理方式不同:

用户密码:BCrypt
refresh_token:HMAC-SHA256

原因:

1. 密码不需要根据明文反查,适合 BCrypt
2. refresh_token 需要根据客户端提交的 token 计算 hash 后查 session,适合 HMAC-SHA256
3. HMAC 的 server_secret 必须安全保存,不能写死在代码中

10. RS256 密钥管理

一期推荐使用 RS256。

规则:

1. 私钥只保存在用户中心
2. 公钥提供给网关和业务系统 SDK
3. access_token Header 中必须包含 kid
4. 业务系统根据 kid 选择对应公钥验签
5. 支持多公钥并存
6. 密钥轮换期间,旧公钥保留到旧 Token 全部过期
7. 私钥不能写死在代码中
8. 密钥变更需要有操作日志

密钥轮换流程:

1. 生成新密钥对
2. 新公钥发布到公钥列表
3. 新签发的 Token 使用新 kid
4. 老 Token 继续使用旧公钥验签
5. 等待最长 access_token 有效期结束
6. 下线旧公钥

11. 登录流程

11.1 登录请求

POST /auth/login

请求体:

{
  "appCode": "crm",
  "accountType": "MOBILE",
  "identifier": "13800138000",
  "password": "123456",
  "deviceId": "web-browser-001"
}

响应体:

{
  "accessToken": "xxx",
  "refreshToken": "yyy",
  "expiresIn": 7200,
  "tokenType": "Bearer"
}

11.2 登录处理流程

1. 校验 appCode 是否存在
2. 校验应用状态是否正常
3. 根据 accountType + identifier 查询账号
4. 校验账号是否存在
5. 校验账号状态是否正常
6. 校验登录失败临时锁定状态
7. 使用 BCrypt 校验密码
8. 查询用户信息
9. 校验用户状态是否正常
10. 如果应用 need_role_on_login = 1,则查询用户在当前 appCode 下是否有有效角色
11. 如果没有有效角色,拒绝登录当前应用
12. 生成 session_no
13. 生成 access_token jti
14. 生成 access_token
15. 生成 refresh_token
16. refresh_token 做 HMAC-SHA256
17. 写入 uc_user_session
18. 写入 uc_refresh_token_record,状态为有效
19. 更新账号最近登录时间
20. 写入登录成功日志
21. 返回 Token

12. refresh_token 刷新流程

刷新 access_token 时,必须基于 uc_refresh_token_record 做状态校验和状态流转。

刷新流程如下:

1. 前端携带 refresh_token 调用 /auth/refresh-token
2. 用户中心计算 token_hash = HMAC-SHA256(refresh_token, server_secret)
3. 根据 token_hash 查询 uc_refresh_token_record
4. 如果记录不存在,返回 refresh_token 无效
5. 如果 token_status = 3,返回 refresh_token 已失效
6. 如果 token_status = 2,进入重放检测或并发刷新判断逻辑
7. 如果 token_status = 1,继续刷新流程
8. 根据 session_no 查询 uc_user_session
9. 校验 session 是否存在
10. 校验 session 是否过期
11. 校验 session 是否已失效
12. 校验用户状态是否正常
13. 校验 tokenVersion 是否正常
14. 将旧 refresh_token 记录从有效更新为已使用
15. 生成新的 access_token
16. 生成新的 refresh_token
17. 计算新的 refresh_token_hash
18. 插入新的 uc_refresh_token_record,状态为有效
19. 更新 uc_user_session.refresh_token_hash
20. 更新 uc_user_session.access_token_jti
21. 更新 uc_user_session.last_refresh_time
22. 返回新的 access_token 和 refresh_token

关键点:

1. 每次刷新都必须生成新的 refresh_token
2. 旧 refresh_token 成功使用后必须标记为已使用
3. 新 refresh_token 必须插入 uc_refresh_token_record
4. uc_user_session 中只保存当前最新 refresh_token_hash
5. uc_refresh_token_record 负责保留 refresh_token 历史状态

13. refresh_token 并发刷新处理

真实场景中,前端可能出现并发刷新。

例如:

1. access_token 过期
2. 页面同时发出多个请求
3. 多个请求同时触发 refresh_token 刷新
4. 请求 A 刷新成功
5. 请求 B 仍然携带旧 refresh_token 继续刷新

如果没有并发处理,请求 B 可能被误判为 refresh_token 重放攻击。

13.1 处理原则

1. refresh_token 刷新接口必须使用事务
2. 使用 token_status 做乐观更新
3. 只有 token_status = 1 的 refresh_token 才允许被更新为已使用
4. 更新成功说明本次刷新合法
5. 更新失败时,需要进一步判断是并发刷新还是重放攻击

核心 SQL:

UPDATE uc_refresh_token_record
SET token_status = 2,
    used_time = NOW(),
    update_time = NOW()
WHERE token_hash = #{tokenHash}
  AND token_status = 1;

判断逻辑:

1. 如果影响行数 = 1,说明当前请求成功占用该 refresh_token,可以继续刷新
2. 如果影响行数 = 0,重新查询该 token_hash 对应记录
3. 如果记录不存在,返回 refresh_token 无效
4. 如果 token_status = 3,返回 refresh_token 已失效
5. 如果 token_status = 2,则判断是否属于短时间并发刷新

13.2 并发刷新宽限窗口

为了避免正常并发请求被误判为攻击,可以设置一个很短的并发宽限窗口。

建议:

并发宽限窗口:3 - 5 秒

判断规则:

1. token_status = 2
2. used_time 距离当前时间小于等于 3 - 5 秒
3. session_no 相同
4. device_id、IP、User-Agent 等信息基本一致

一期建议采用:

并发刷新时返回 TOKEN_REFRESH_CONCURRENT,由前端使用最新 Token 重试。

原因:

1. 实现简单
2. 不误判正常并发请求
3. 不需要服务端返回已生成的新 refresh_token
4. 前端可以统一处理刷新中的请求队列

13.3 重放攻击判断

如果旧 refresh_token 再次使用,但不满足并发宽限条件,则判定为 refresh_token 重放。

判定条件:

1. token_status = 2
2. used_time 距离当前时间超过并发宽限窗口
3. 或者 device_id / IP / User-Agent 明显不一致
4. 或者 session 已经失效

处理方式:

1. 将 uc_user_session.status 设置为 2
2. 设置 uc_user_session.reuse_detected = 1
3. 设置 uc_user_session.revoked_time = 当前时间
4. 将当前 session 的 access_token_jti 加入黑名单
5. 将该 session 下未过期 refresh_token_record 标记为失效
6. 记录安全日志
7. 要求用户重新登录

14. 请求认证与实时失效

JWT 本地验签只能解决:

1. Token 是否由用户中心签发
2. Token 是否过期

不能解决:

1. 用户是否被禁用
2. 用户是否已经退出登录
3. 用户是否被强制下线
4. 用户是否修改过密码
5. 权限是否发生变化

因此业务系统不能只依赖 JWT 本地验签。

一期采用:

JWT 本地验签 + Redis 状态校验 + 权限缓存

业务系统每次请求校验流程:

1. 获取 Authorization Header
2. 判断是否 Bearer Token
3. 根据 kid 获取公钥
4. 本地校验 JWT 签名
5. 校验 exp 是否过期
6. 校验 nbf 是否已生效
7. 校验 iss 是否为 user-center
8. 校验 aud 是否等于当前业务系统 appCode
9. 从 JWT 中解析 userId、appCode、jti、tokenVersion
10. 校验 token.appCode == 当前业务系统 appCode
11. 校验 jti 是否在 Redis 黑名单
12. 校验 tokenVersion 是否匹配
13. 校验用户状态是否正常
14. 查询权限缓存
15. 执行业务接口权限判断

15. Token 失效机制

15.1 access_token 黑名单

用户退出登录时,将 access_token 的 jti 加入 Redis 黑名单。

Redis Key:

uc:access_token:blacklist:{jti}

过期时间:

access_token 剩余有效期

15.2 用户级 tokenVersion

一期采用用户级 tokenVersion。

字段位于 uc_user 表:

token_version INT NOT NULL DEFAULT 0 COMMENT 'Token版本号'

用户级 tokenVersion 的含义是:

同一个用户在所有应用下共享同一个 tokenVersion。

因此以下操作会导致该用户所有应用的 Token 失效:

1. 用户被禁用
2. 用户修改密码
3. 管理员重置密码
4. 管理员强制用户下线
5. 账号存在安全风险

也就是说,如果用户在 CRM 修改密码,那么该用户在 OA、ERP、Finance 等系统中的 Token 也会失效,需要重新登录。

一期采用用户级 tokenVersion 的原因:

1. 实现简单
2. 安全边界更强
3. 修改密码、禁用用户通常应该影响所有系统
4. 避免多个系统登录态不一致

Redis Key:

uc:user:security:{userId}

内容示例:

{
  "status": 1,
  "tokenVersion": 3,
  "forceLogoutTime": "2026-06-22 10:00:00"
}

业务系统校验规则:

1. 从 JWT 中解析 tokenVersion
2. 从 Redis 查询 uc:user:security:{userId}
3. 如果用户状态不是正常,拒绝访问
4. 如果 JWT 中 tokenVersion 小于 Redis 中 tokenVersion,拒绝访问

15.3 应用级 tokenVersion 扩展

二期如果需要按应用隔离 Token 失效,可以扩展应用级 tokenVersion。

扩展表:

CREATE TABLE uc_user_app_token_version (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
    user_id BIGINT NOT NULL COMMENT '用户ID',
    app_code VARCHAR(64) NOT NULL COMMENT '应用编码',
    token_version INT NOT NULL DEFAULT 0 COMMENT '应用级Token版本号',
    update_time DATETIME NOT NULL COMMENT '更新时间',
    UNIQUE KEY uk_user_app (user_id, app_code)
) COMMENT='用户应用Token版本表';

二期应用级 tokenVersion 适用场景:

1. 只强制用户退出某一个系统
2. 用户在某个 appCode 下存在安全风险
3. 某个业务系统希望独立控制登录态
4. 多应用登录态需要独立管理

一期最终策略:

一期采用用户级 tokenVersion。
修改密码、用户禁用、强制下线、安全风险处理时,用户所有应用 Token 全部失效。

15.4 用户状态缓存

Redis Key:

uc:user:security:{userId}

内容示例:

{
  "status": 1,
  "tokenVersion": 3,
  "forceLogoutTime": null
}

用户被禁用时,需要执行:

1. 更新 uc_user.status
2. uc_user.token_version + 1
3. 更新 Redis 用户安全快照
4. 清理用户权限缓存
5. 将当前活跃会话置为失效
6. 将未过期 refresh_token_record 标记为失效

15.5 access_token 刷新后的旧 Token 策略

一期采用:

刷新 access_token 后,旧 access_token 保持到自然过期。

原因:

1. 避免并发请求误伤
2. 减少黑名单数量
3. 实现简单
4. 用户体验更稳定

但以下场景必须立即失效:

1. 用户退出登录
2. 用户修改密码
3. 用户被禁用
4. 管理员强制下线
5. 检测到 refresh_token 重放

16. 多端登录策略

一期默认采用:

允许多端登录,一个设备一个 session。

规则:

1. 同一用户可以在多个设备登录
2. 每次登录创建一条 uc_user_session
3. deviceId 用于标识设备
4. 退出登录只失效当前 session
5. 强制下线可以失效指定 session
6. 修改密码、用户禁用可以失效该用户所有 session

二期可以扩展:

1. 同一 appCode 只允许一个活跃 session
2. 同一 deviceId 只允许一个活跃 session
3. 后登录踢掉先登录
4. 用户主动管理已登录设备

17. 权限缓存设计

权限不放入 JWT,而是放入 Redis 缓存。

17.1 Redis Key

uc:permission:{appCode}:{userId}
uc:menu:{appCode}:{userId}
uc:role:{appCode}:{userId}
uc:user:info:{userId}
uc:user:security:{userId}

17.2 权限缓存内容

{
  "userId": 10001,
  "appCode": "crm",
  "roles": ["sales_manager"],
  "permissions": [
    "order:query",
    "order:export",
    "customer:assign"
  ]
}

17.3 缓存过期时间

建议:

30 分钟 - 2 小时

17.4 权限变更后的缓存处理

以下操作发生后,需要清理权限缓存:

1. 给用户分配角色
2. 移除用户角色
3. 修改角色权限
4. 禁用角色
5. 禁用权限
6. 禁用用户

如果角色绑定用户数量较少,可以直接批量删除用户权限缓存。

如果角色绑定用户数量很大,不建议批量删除大量 Redis Key,可以使用权限版本号。

权限版本号 Key:

uc:permission:version:{appCode}
uc:user:permission_version:{appCode}:{userId}

一期建议:

1. 用户量不大时,直接清理用户权限缓存
2. 用户量大时,使用角色级或用户级权限版本号,避免全应用缓存同时失效

用户量较大时可扩展:

uc:role:permission_version:{roleId}
uc:user:permission_version:{appCode}:{userId}

18. Redis 异常降级策略

方案中业务系统请求会依赖 Redis 校验:

1. jti 黑名单
2. 用户安全快照
3. tokenVersion
4. 用户状态
5. 权限缓存

因此必须明确 Redis 故障策略。

18.1 基本原则

安全优先,不能因为 Redis 故障就默认放行所有请求。

18.2 降级规则

1. Redis 不可用时,敏感操作默认拒绝
2. 普通查询接口可以使用本地 Caffeine 缓存短暂兜底
3. tokenVersion、用户状态这类安全数据不建议无脑放行
4. 权限缓存可以使用 Caffeine 本地缓存短暂降级
5. 本地缓存 TTL 建议不超过 1 - 5 分钟
6. 本地缓存不能用于授权类接口
7. 本地缓存不能用于修改密码、角色分配、权限修改、导出、审批等敏感操作
8. Redis 恢复后主动清理本地缓存
9. 本地缓存命中降级时必须记录日志

18.3 接口分级

敏感操作:

1. 新增
2. 修改
3. 删除
4. 导出
5. 审批
6. 授权
7. 修改密码
8. 分配角色
9. 修改权限

Redis 异常时,敏感操作默认拒绝。

普通查询接口可以短时间走本地缓存兜底,但需要记录降级日志。

19. 权限校验设计

19.1 菜单权限

前端登录后调用:

GET /permissions/current/menus

说明:

1. 不允许前端自由传 appCode
2. appCode 从 access_token 中解析
3. 只能返回当前登录 appCode 下的菜单

返回示例:

[
  {
    "name": "订单管理",
    "path": "/order",
    "component": "Layout",
    "children": [
      {
        "name": "订单列表",
        "path": "/order/list",
        "component": "order/list/index"
      }
    ]
  }
]

前端根据菜单权限动态生成路由。

19.2 按钮权限

前端调用:

GET /permissions/current/buttons

返回:

[
  "order:add",
  "order:update",
  "order:delete",
  "order:export"
]

前端根据按钮权限控制按钮展示。

注意:

前端按钮控制只用于用户体验,不能作为安全边界。
后端接口仍然必须做权限校验。

19.3 权限码接口

前端或业务系统调用:

GET /permissions/current/codes

返回:

[
  "order:query",
  "order:export",
  "customer:assign"
]

同样,appCode 从 Token 中取,不从前端参数取。

19.4 接口权限

业务系统使用注解控制接口权限。

示例:

@PreAuthorize("@ucPermission.hasPermission('order:export')")
@PostMapping("/orders/export")
public void exportOrders() {
    // 导出逻辑
}

权限判断逻辑:

1. 从 SecurityContext 获取当前用户
2. 获取当前 appCode
3. 查询 Redis 权限缓存
4. 判断是否包含目标权限码
5. 有权限放行
6. 无权限返回 403

20. 数据权限设计

一期支持基础数据权限。

数据权限类型:

ALL           全部数据
SELF          仅本人数据
DEPT          本部门数据
DEPT_AND_SUB  本部门及下级部门数据

角色表通过 data_scope 字段配置数据权限。

20.1 多角色合并规则

一个用户可能拥有多个角色,必须定义合并规则。

一期合并规则:

ALL > DEPT_AND_SUB > DEPT > SELF

多个角色取最大可访问范围。

示例:

角色 A:SELF
角色 B:DEPT
最终数据权限:DEPT

示例:

角色 A:DEPT
角色 B:DEPT_AND_SUB
最终数据权限:DEPT_AND_SUB

示例:

角色 A:ALL
角色 B:SELF
最终数据权限:ALL

20.2 多部门用户规则

一期默认按主部门计算数据权限。

规则:

SELF:本人数据
DEPT:主部门数据
DEPT_AND_SUB:主部门及下级部门数据
ALL:全部数据

如果用户属于多个部门:

1. 一期默认使用 is_main = 1 的主部门
2. 如果没有主部门,需要在保存用户组织关系时保证必选一个主部门
3. 二期如果业务需要支持多部门,则按用户所有部门取并集

20.3 数据权限返回

用户中心只返回数据权限范围,不直接拼接业务 SQL。

返回示例:

{
  "userId": 10001,
  "appCode": "crm",
  "dataScope": "DEPT_AND_SUB",
  "orgIds": [10, 11, 12]
}

业务系统根据自己的业务表进行数据过滤。

订单系统示例:

SELECT *
FROM order_info
WHERE deleted = 0
  AND dept_id IN (...)

客户系统示例:

SELECT *
FROM customer
WHERE deleted = 0
  AND owner_dept_id IN (...)

用户中心不能直接替业务系统过滤订单、客户、合同等业务数据。

21. 网关安全设计

网关可以透传用户上下文,但不能信任外部传入的用户 Header。

21.1 必须清理的 Header

网关进入业务系统前,必须先删除外部请求中的以下 Header:

X-User-Id
X-User-No
X-App-Code
X-Tenant-Id
X-User-Name

然后由网关验签成功后重新生成。

21.2 网关透传 Header

X-User-Id: 10001
X-User-No: U202606220001
X-App-Code: crm

21.3 业务系统安全要求

1. 业务系统不能直接暴露公网
2. 业务系统只接受来自网关或内网服务的请求
3. 业务系统仍然需要校验 Authorization Token
4. X-User-* Header 只作为性能优化和日志上下文
5. 不能完全依赖 Header 做身份认证

21.4 网关和业务系统重复验签说明

网关做 Token 基础校验,业务系统仍然二次验签,这是有意设计。

目的:

1. 网关负责统一入口拦截
2. 业务系统负责零信任二次校验
3. 避免业务系统被绕过网关访问
4. 避免完全依赖 Header 造成越权风险

22. 对外接口设计

22.1 认证接口

POST /auth/login
POST /auth/logout
POST /auth/refresh-token
GET  /auth/userinfo

22.2 用户接口

POST /users
PUT  /users/{userId}
GET  /users/{userId}
GET  /users/page
PUT  /users/{userId}/enable
PUT  /users/{userId}/disable
PUT  /users/{userId}/password/reset
PUT  /users/{userId}/force-logout

22.3 账号接口

POST /accounts
PUT  /accounts/{accountId}
GET  /accounts/{accountId}
GET  /accounts/user/{userId}
PUT  /accounts/{accountId}/enable
PUT  /accounts/{accountId}/disable
PUT  /accounts/{accountId}/lock
PUT  /accounts/{accountId}/unlock

22.4 组织接口

POST /orgs
PUT  /orgs/{orgId}
GET  /orgs/{orgId}
GET  /orgs/tree
GET  /orgs/{orgId}/users
PUT  /orgs/{orgId}/enable
PUT  /orgs/{orgId}/disable

22.5 应用接口

POST /apps
PUT  /apps/{appCode}
GET  /apps/{appCode}
GET  /apps/page
PUT  /apps/{appCode}/enable
PUT  /apps/{appCode}/disable

22.6 角色接口

POST /roles
PUT  /roles/{roleId}
GET  /roles/{roleId}
GET  /roles/page
POST /roles/{roleId}/permissions
POST /roles/{roleId}/users
PUT  /roles/{roleId}/enable
PUT  /roles/{roleId}/disable

22.7 权限接口

POST /permissions
PUT  /permissions/{permissionId}
GET  /permissions/{permissionId}
GET  /permissions/tree
GET  /permissions/current/codes
GET  /permissions/current/menus
GET  /permissions/current/buttons
PUT  /permissions/{permissionId}/enable
PUT  /permissions/{permissionId}/disable

注意:

/permissions/current/* 接口不允许前端自由传 appCode。
appCode 必须从当前 access_token 中解析。

23. Java 项目结构

推荐先采用单服务模块化结构,不要一开始拆太细。

user-center
├── user-center-server
├── user-center-api
├── user-center-sdk
└── user-center-common

user-center-server 包结构:

com.xxx.usercenter
├── auth
│   ├── controller
│   ├── service
│   ├── token
│   └── session
├── user
│   ├── controller
│   ├── service
│   ├── mapper
│   └── entity
├── account
├── org
├── app
├── role
├── permission
├── security
├── log
└── common

24. 核心 Java 实现示例

24.1 登录请求对象

@Data
public class LoginRequest {

    private String appCode;

    private String accountType;

    private String identifier;

    private String password;

    private String deviceId;
}

24.2 登录响应对象

@Data
@AllArgsConstructor
public class LoginResponse {

    private String accessToken;

    private String refreshToken;

    private Long expiresIn;

    private String tokenType;
}

24.3 当前登录用户

@Data
public class LoginUser {

    private Long userId;

    private String userNo;

    private String realName;

    private String appCode;

    private String audience;

    private String issuer;

    private String jti;

    private Integer tokenVersion;
}

24.4 登录核心流程

@Service
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {

    private final UcAppMapper appMapper;
    private final UcAccountMapper accountMapper;
    private final UcUserMapper userMapper;
    private final UcUserRoleMapper userRoleMapper;
    private final UcUserSessionMapper sessionMapper;
    private final UcRefreshTokenRecordMapper refreshTokenRecordMapper;
    private final PasswordEncoder passwordEncoder;
    private final TokenService tokenService;
    private final LoginLogService loginLogService;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public LoginResponse login(LoginRequest request) {
        UcApp app = appMapper.selectByAppCode(request.getAppCode());
        if (app == null || app.getStatus() != 1) {
            throw new BizException("应用不存在或已禁用");
        }

        UcAccount account = accountMapper.selectByTypeAndIdentifier(
                request.getAccountType(),
                request.getIdentifier()
        );

        if (account == null) {
            loginLogService.recordFail(request, "账号不存在");
            throw new BizException("账号或密码错误");
        }

        if (account.getStatus() != 1) {
            loginLogService.recordFail(request, "账号状态异常");
            throw new BizException("账号状态异常");
        }

        if (!passwordEncoder.matches(request.getPassword(), account.getCredential())) {
            loginLogService.recordFail(request, "密码错误");
            throw new BizException("账号或密码错误");
        }

        UcUser user = userMapper.selectById(account.getUserId());
        if (user == null || user.getStatus() != 1) {
            loginLogService.recordFail(request, "用户不存在或已禁用");
            throw new BizException("用户不存在或已禁用");
        }

        if (app.getNeedRoleOnLogin() == 1) {
            boolean hasValidRole = userRoleMapper.existsValidRole(user.getId(), request.getAppCode());
            if (!hasValidRole) {
                loginLogService.recordFail(request, "用户无当前应用访问权限");
                throw new BizException("用户无当前应用访问权限");
            }
        }

        String sessionNo = tokenService.generateSessionNo();
        String jti = tokenService.generateJti();

        LoginUser loginUser = new LoginUser();
        loginUser.setUserId(user.getId());
        loginUser.setUserNo(user.getUserNo());
        loginUser.setRealName(user.getRealName());
        loginUser.setAppCode(request.getAppCode());
        loginUser.setAudience(request.getAppCode());
        loginUser.setIssuer("user-center");
        loginUser.setJti(jti);
        loginUser.setTokenVersion(user.getTokenVersion());

        String accessToken = tokenService.createAccessToken(loginUser, app.getAccessTokenTtl());
        String refreshToken = tokenService.generateRefreshToken();
        String refreshTokenHash = tokenService.hashRefreshToken(refreshToken);

        UcUserSession session = new UcUserSession();
        session.setSessionNo(sessionNo);
        session.setUserId(user.getId());
        session.setAppCode(request.getAppCode());
        session.setDeviceId(request.getDeviceId());
        session.setRefreshTokenHash(refreshTokenHash);
        session.setAccessTokenJti(jti);
        session.setStatus(1);
        session.setExpireTime(LocalDateTime.now().plusSeconds(app.getRefreshTokenTtl()));
        sessionMapper.insert(session);

        UcRefreshTokenRecord record = new UcRefreshTokenRecord();
        record.setSessionNo(sessionNo);
        record.setTokenHash(refreshTokenHash);
        record.setTokenStatus(1);
        record.setExpireTime(session.getExpireTime());
        refreshTokenRecordMapper.insert(record);

        loginLogService.recordSuccess(user.getId(), request);

        return new LoginResponse(
                accessToken,
                refreshToken,
                Long.valueOf(app.getAccessTokenTtl()),
                "Bearer"
        );
    }
}

24.5 refresh_token 刷新伪代码

@Transactional(rollbackFor = Exception.class)
public RefreshTokenResponse refreshToken(String refreshToken, RefreshContext context) {
    String tokenHash = tokenService.hashRefreshToken(refreshToken);

    UcRefreshTokenRecord record = refreshTokenRecordMapper.selectByTokenHash(tokenHash);
    if (record == null) {
        throw new BizException("refresh_token无效");
    }

    if (record.getTokenStatus() == 3) {
        throw new BizException("refresh_token已失效");
    }

    if (record.getTokenStatus() == 2) {
        if (isConcurrentRefresh(record, context)) {
            throw new BizException("TOKEN_REFRESH_CONCURRENT");
        }

        handleRefreshTokenReuse(record, context);
        throw new BizException("refresh_token存在重放风险,请重新登录");
    }

    int updated = refreshTokenRecordMapper.markUsed(tokenHash, LocalDateTime.now());

    if (updated == 0) {
        UcRefreshTokenRecord latestRecord = refreshTokenRecordMapper.selectByTokenHash(tokenHash);

        if (latestRecord != null
                && latestRecord.getTokenStatus() == 2
                && isConcurrentRefresh(latestRecord, context)) {
            throw new BizException("TOKEN_REFRESH_CONCURRENT");
        }

        handleRefreshTokenReuse(latestRecord, context);
        throw new BizException("refresh_token存在重放风险,请重新登录");
    }

    UcUserSession session = sessionMapper.selectBySessionNo(record.getSessionNo());
    if (session == null || session.getStatus() != 1) {
        throw new BizException("登录会话已失效");
    }

    if (session.getExpireTime().isBefore(LocalDateTime.now())) {
        throw new BizException("登录会话已过期");
    }

    UcUser user = userMapper.selectById(session.getUserId());
    if (user == null || user.getStatus() != 1) {
        throw new BizException("用户状态异常");
    }

    String newJti = tokenService.generateJti();
    String newAccessToken = tokenService.createAccessToken(user, session.getAppCode(), newJti);
    String newRefreshToken = tokenService.generateRefreshToken();
    String newRefreshTokenHash = tokenService.hashRefreshToken(newRefreshToken);

    UcRefreshTokenRecord newRecord = new UcRefreshTokenRecord();
    newRecord.setSessionNo(session.getSessionNo());
    newRecord.setTokenHash(newRefreshTokenHash);
    newRecord.setTokenStatus(1);
    newRecord.setExpireTime(session.getExpireTime());
    refreshTokenRecordMapper.insert(newRecord);

    session.setRefreshTokenHash(newRefreshTokenHash);
    session.setAccessTokenJti(newJti);
    session.setLastRefreshTime(LocalDateTime.now());
    sessionMapper.updateById(session);

    return new RefreshTokenResponse(newAccessToken, newRefreshToken);
}

25. 业务系统 SDK 设计

业务系统不应该重复实现 Token 解析和权限判断逻辑。

提供统一 SDK:

user-center-spring-boot-starter

业务系统引入:

<dependency>
    <groupId>com.xxx</groupId>
    <artifactId>user-center-spring-boot-starter</artifactId>
    <version>1.0.0</version>
</dependency>

配置:

user-center:
  app-code: crm
  auth-server: http://user-center
  public-key-url: http://user-center/auth/public-keys
  permission-cache-seconds: 3600
  local-cache-seconds: 300

SDK 提供能力:

1. Token 校验过滤器
2. 当前登录用户上下文
3. iss / aud / appCode 一致性校验
4. 权限校验工具
5. 菜单权限客户端
6. 用户信息客户端
7. 数据权限工具
8. Redis 异常本地缓存兜底

业务系统使用:

Long userId = LoginUserContext.getUserId();

接口权限:

@PreAuthorize("@ucPermission.hasPermission('order:export')")
@PostMapping("/orders/export")
public void exportOrders() {
    // 导出逻辑
}

26. 安全设计

26.1 密码安全

1. 密码使用 BCrypt
2. 不允许明文保存密码
3. 不建议使用 MD5 + salt
4. 修改密码后 tokenVersion + 1
5. 重置密码后 tokenVersion + 1

26.2 登录失败限制

Redis Key:

uc:login:fail:{accountType}:{identifier}

规则:

5 分钟内失败 5 次,锁定 15 分钟

这是 Redis 临时锁定。

账号永久锁定使用:

uc_account.status = 3

26.3 Token 安全

1. access_token 有效期不能太长
2. access_token 必须包含 iss、aud、sub、iat、nbf、exp、jti
3. refresh_token 必须服务端可控
4. refresh_token 必须 rotation
5. refresh_token 只存 HMAC-SHA256 哈希
6. refresh_token 必须有历史记录表支持重放检测
7. refresh_token 刷新必须处理并发误判
8. access_token 必须包含 jti
9. logout 时 jti 加黑名单
10. 用户禁用时 tokenVersion + 1
11. 修改密码时 tokenVersion + 1
12. refresh_token 重放时失效 session

26.4 接口安全

1. 管理接口必须鉴权
2. 敏感操作必须记录操作日志
3. 应用密钥不允许明文存储
4. 业务系统不允许直接暴露公网
5. 网关必须清理外部伪造的 X-User-* Header
6. 业务系统必须二次校验 Token
7. 业务系统必须校验 token.aud == 当前系统 appCode
8. 业务系统必须校验 token.appCode == 当前系统 appCode

27. 日志与审计

27.1 登录日志

记录内容:

用户ID
账号
应用编码
登录IP
User-Agent
登录状态
失败原因
登录时间

27.2 操作日志

重点记录:

1. 新增用户
2. 修改用户
3. 禁用用户
4. 启用用户
5. 重置密码
6. 分配角色
7. 修改角色权限
8. 新增应用
9. 禁用应用
10. 强制用户下线
11. refresh_token 重放检测
12. refresh_token 并发刷新异常
13. 密钥轮换

操作日志建议记录操作前和操作后的数据,但写入前必须脱敏。

27.3 日志异步化

登录日志、操作日志、安全日志可能影响核心链路性能,建议:

1. 登录成功日志可以同步写
2. 操作日志、安全日志建议异步写
3. 可以通过 MQ 或本地事件队列异步落库
4. 异步失败需要有补偿或降级日志

28. 监控与告警

28.1 核心监控指标

1. 登录成功次数
2. 登录失败次数
3. 登录失败率
4. Token 刷新次数
5. refresh_token 重放次数
6. refresh_token 并发刷新次数
7. 403 权限拒绝次数
8. 用户中心接口响应时间
9. Redis 权限缓存命中率
10. Caffeine 本地缓存命中率
11. 用户中心服务错误率
12. 用户中心服务实例存活状态
13. Redis 异常次数
14. tokenVersion 校验失败次数
15. appCode 不一致拒绝次数
16. aud 校验失败次数
17. iss 校验失败次数

28.2 告警规则

建议配置:

1. 登录失败率异常升高
2. refresh_token 重放次数大于 0
3. 用户中心 5xx 错误率过高
4. Redis 不可用
5. MySQL 连接异常
6. Token 刷新接口异常
7. 权限校验接口耗时过高
8. appCode 不一致拒绝次数异常升高
9. aud 校验失败次数异常升高
10. 403 权限拒绝次数异常升高

29. 数据归档与清理

以下表会持续增长:

uc_login_log
uc_operation_log
uc_user_session
uc_refresh_token_record

建议策略:

1. 登录日志保留 6 - 12 个月
2. 操作日志按审计要求保留
3. 过期 session 定期清理
4. 过期 refresh_token_record 定期清理
5. 大表按 create_time 或 expire_time 建索引
6. 数据量大时考虑按月归档或分区

30. 高可用设计

统一用户中心属于基础服务,必须保证高可用。

建议:

1. user-center-server 至少部署 2 个实例
2. Redis 使用主从或集群
3. MySQL 使用主从或高可用方案
4. JWT 使用公私钥,本地验签减少远程调用
5. 权限数据使用 Redis 缓存
6. 业务系统使用 Caffeine 做短时间本地缓存兜底
7. 登录接口增加限流
8. 用户中心接口设置超时时间
9. 网关增加熔断和限流

关键原则:

业务系统不能每次请求都远程调用用户中心。

推荐方式:

JWT 本地验签
Redis 校验 Token 状态
Redis 获取权限缓存
必要时才回源用户中心

31. 部署方案

部署结构:

Nginx
  └── Gateway
        ├── user-center-server-1
        ├── user-center-server-2
        ├── crm-service
        ├── oa-service
        ├── erp-service
        └── mall-admin-service

依赖组件:

MySQL
Redis
Nacos
Prometheus
Grafana
日志系统

32. 一期落地步骤

阶段一:基础用户与账号

1. 建立用户表
2. 建立账号表
3. 实现用户新增、修改、禁用
4. 实现账号新增、锁定、解锁
5. 实现密码加密
6. 实现登录日志

阶段二:认证与 Token

1. 实现统一登录接口
2. 实现 JWT access_token
3. 实现 JWT iss / aud / iat / nbf / exp / jti 标准字段
4. 实现 RS256 密钥管理
5. 实现 refresh_token
6. 实现 uc_user_session
7. 实现 uc_refresh_token_record
8. 实现 refresh_token HMAC-SHA256 存储
9. 实现 refresh_token rotation
10. 实现 refresh_token 重放检测
11. 实现 refresh_token 并发刷新处理
12. 实现 logout
13. 实现 jti 黑名单
14. 实现用户级 tokenVersion

阶段三:角色权限

1. 建立应用表
2. 建立角色表
3. 建立权限表
4. 建立用户角色表
5. 建立角色权限表
6. 实现角色分配
7. 实现 appCode 一致性校验
8. 实现权限分配
9. 实现菜单权限
10. 实现按钮权限
11. 实现接口权限

阶段四:业务系统接入

1. 开发 user-center-spring-boot-starter
2. 业务系统引入 SDK
3. 接入 Token 校验过滤器
4. 接入 iss / aud / appCode 校验
5. 接入权限注解
6. 接入 Redis 权限缓存
7. 接入 Caffeine 本地缓存降级
8. 前端接入菜单权限
9. 前端接入按钮权限

阶段五:安全和稳定性

1. 登录失败限制
2. 用户禁用即时失效
3. 修改密码 Token 失效
4. 权限缓存清理
5. 操作日志
6. 敏感字段脱敏
7. 监控告警
8. 网关 Header 安全处理
9. 限流熔断
10. Redis 异常降级策略
11. 数据归档清理任务

33. 关键风险与解决方案

风险

说明

解决方案

JWT 无法实时失效

JWT 本地验签无法感知用户禁用

jti 黑名单 + 用户级 tokenVersion

JWT 跨系统误用

A 系统 Token 被用于 B 系统

校验 aud + appCode

refresh_token 泄露

泄露后可长期刷新 Token

refresh_token rotation + HMAC 哈希存储

refresh_token 重放

旧 refresh_token 被再次使用

uc_refresh_token_record + reuse_detected + session 失效

refresh_token 并发刷新误判

多请求同时刷新导致误判攻击

token_status 乐观更新 + 并发宽限窗口

权限变更不生效

权限缓存可能滞后

清理缓存或使用权限版本号

appCode 越权

用户拿 A 系统 Token 查询 B 系统权限

appCode 从 Token 取,SDK 校验当前系统 appCode

Header 被伪造

外部请求伪造 X-User-Id

网关清理 Header,业务系统仍校验 Token

Redis 故障

鉴权依赖 Redis

敏感操作拒绝,普通查询短时本地缓存兜底

数据权限混乱

多角色数据权限冲突

明确合并规则

多部门数据权限不清晰

用户属于多个部门时规则不明确

一期默认按主部门计算

多租户不闭环

只在 Token 放 tenantId 没有意义

一期不做多租户则不引入 tenantId

用户中心单点

用户中心挂掉影响所有系统

多实例部署,本地验签,缓存权限

操作日志泄露敏感信息

before_data / after_data 记录敏感字段

日志写入前统一脱敏

密钥泄露或轮换困难

RS256 私钥管理不当

kid + 多公钥并存 + 密钥轮换

日志表持续膨胀

登录日志、操作日志不断增长

定期归档和清理

34. 最终方案总结

统一用户中心一期采用:

Spring Boot 3.x
Spring Security
JWT access_token
RS256
Redis
Caffeine
MySQL
refresh_token rotation
refresh_token 重放检测
refresh_token 并发刷新处理
uc_user_session
uc_refresh_token_record
用户级 tokenVersion
权限缓存
业务系统 SDK
Gateway

核心能力:

1. 统一用户
2. 统一账号
3. 统一登录
4. 统一 Token
5. 统一角色
6. 统一权限
7. 统一菜单
8. 统一按钮
9. 统一接口鉴权
10. 基础数据权限
11. 登录日志
12. 操作审计
13. Token 实时失效
14. 权限变更感知
15. 网关 Header 安全
16. Redis 异常降级
17. refresh_token 安全刷新
18. refresh_token 重放检测

核心原则:

1. 用户中心负责“用户是谁、能登录哪里、拥有什么权限”
2. 业务系统负责“用户在具体业务中能操作什么数据”
3. JWT 只做身份凭证,不承载完整权限
4. JWT 必须校验 iss、aud、exp、nbf、jti
5. refresh_token 必须服务端可控
6. refresh_token 必须 rotation
7. refresh_token 必须支持重放检测
8. refresh_token 并发刷新必须避免误判攻击
9. 一期采用用户级 tokenVersion
10. 用户禁用、修改密码、强制下线必须能让 Token 失效
11. 权限变更必须能被业务系统及时感知
12. appCode 不能由前端自由传入
13. 网关 Header 不能被外部伪造
14. Redis 异常时不能默认放行所有请求
15. 一期不做完整 OAuth2/OIDC,避免过度设计
16. 后续需要第三方接入时,再升级为标准 OAuth2/OIDC 认证授权中心

统一用户中心负责统一身份、统一登录、统一权限和统一审计;业务系统通过 SDK 接入用户中心,只关心自己的业务逻辑和业务数据过滤。

评论