49.1 统一账号体系
一句话
统一账号体系 = 一个不可变的主体 ID + 一个唯一的权威源 + 一部可执行的状态机;难点不在"让 N 个应用共用一张用户表",而在"同一个人在不同系统里必须是同一个主体,离职后必须立刻不是"。
先拆四个概念,别再混着叫"账号"
| 概念 | 回答什么 | 可变性 | 存在哪 | 混用后的翻车现场 |
|---|---|---|---|---|
| 账号(account / subject) | 这个主体是谁 | 不可变 | 权威库 | 用手机号当主键 → 换号即换人,历史订单全部失联 |
| 登录标识(identity) | 用什么登录 | 可变、可多个 | 与账号一对多 | 改绑手机号时"新建一个用户",同一人变成两个 |
| 凭证(credential) | 凭什么证明是他 | 可轮转、可吊销 | 独立凭证库 | 密码哈希与业务数据同库同备份,一次注入全量泄露 |
| 档案(profile) | 叫什么、哪个部门 | 可变 | 业务库 | 把"昵称"当身份展示给审计,改名即无法追责 |
这四个东西的生命周期、权限要求、合规等级都不同。把它们揉进一张 user 表,前三年没事,第四年开始每一次组织调整都要做数据订正。
四个流行误区
| 误区 | 现实 |
|---|---|
| 统一账号 = 统一用户名 | 用户名只是标识之一,手机号 / 邮箱 / 飞书 openid 都能登录,而且都会变。统一的是 UID,不是登录名 |
| 统一账号 = 做一个统一登录页 | 登录只是状态机的一次转移。真正的工作量在开户、改绑、离职、注销这四条路径上 |
| 先上 SSO,账号慢慢统一 | SSO 把 N 个系统的登录合到一处,也把"口径不一致"放大成"全站登录失败"。见 49.2 单点登录 (SSO) |
| 账号就是员工 | 服务账号、设备、API client 同样是主体,凭证策略与员工完全相反(不能强制 MFA、必须自动轮转) |
五类主体,五套生命周期
| 主体 | 典型标识 | 凭证形态 | 归属系统 | 量级 | 生命周期由谁驱动 |
|---|---|---|---|---|---|
| 员工 staff | HR 工号 | 密码 + Passkey + MFA | HR | $10^2$–$10^4$ | 入职 / 离职 / 调岗 |
| C 端用户 consumer | 手机号、第三方 openid | 密码 / 短信 / 社交 | 注册库 | $10^5$–$10^8$ | 注册 / 注销 |
| 服务 workload | 服务名 + SPIFFE ID | 证书 / secret key | 平台 | $10^2$–$10^3$ | 发布 / 下线 |
| 设备 device | 设备指纹 ID | 设备证书 | 设备管理 | $10^3$–$10^6$ | 激活 / 报废 |
| API client | client_id | client_secret | 开放平台 | $10^1$–$10^4$ | 创建 / 轮换 |
🔑 共用一张 account 表(为了 UID 唯一),但绝不共用一套凭证策略。 服务账号的密钥必须能自动轮转、不能"90 天过期"过在凌晨三点的发布窗口;员工的密码必须强制 MFA——把两类主体塞进同一套密码策略,结果一定是运维把策略调到最松。
唯一标识:把"不可变"和"可变"彻底分开
| 方案 | 可变 | 唯一范围 | 可回收 | 泄露后果 | 结论 |
|---|---|---|---|---|---|
| 内部 UID(ULID / UUIDv7) | 否 | 全局 | 否 | 无意义 | 对外只用它 |
| 手机号 | 是(换号、销号回收) | 全局(现实中会重号) | 会 | 高(可撞库、可诈骗) | 只做登录标识 |
| 邮箱 | 是(离职回收) | 全局(企业域名可重发) | 会 | 中高 | 只做登录标识 |
| 用户名 | 是(可改) | 全局 | 不会 | 低 | 只做展示 / 登录标识 |
| 第三方 openid | 否(但来源会变) | 平台内 | 否 | 低 | 只做登录标识,另建绑定表 |
由此推出两条硬约束:
- 对外暴露的一律是 UID(URL、token 的
sub、审计日志、消息体),手机号和邮箱只在登录入口出现。 - 登录标识的"回收"只能是解绑 + 软删。手机号被运营商回收再发给别人,是真实事故源:新机主用短信登录,直接进入前一个人的账号。做法是
identity行标记released_at,并把该号的验证记录清空、旧会话全部失效。
数据模型:五张表,能跑
下面这份 DDL 在 SQLite 上实测通过。搬到 MySQL 8.0.16+ 时要改两处:TEXT PRIMARY KEY 换成 VARCHAR(32),CHECK 要 8.0.16 以后才真正生效(更早版本会静默忽略,等于没约束)。
-- 主体:只有不可变 ID 和状态
CREATE TABLE account (
uid TEXT PRIMARY KEY, -- ULID/UUIDv7,对外只暴露它
kind TEXT NOT NULL CHECK (kind IN ('staff','consumer','service','device','client')),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','active','locked','disabled','deleted')),
created_at TEXT NOT NULL,
deleted_at TEXT
);
-- 登录标识:可增删改,全局唯一
CREATE TABLE identity (
id INTEGER PRIMARY KEY,
uid TEXT NOT NULL REFERENCES account(uid),
type TEXT NOT NULL CHECK (type IN ('email','phone','username','oidc')),
value TEXT NOT NULL,
verified INTEGER NOT NULL DEFAULT 0,
UNIQUE (type, value) -- 同一手机号不能挂在两个账号下
);
-- 凭证:一个账号可多份,可独立吊销 / 过期
CREATE TABLE credential (
id INTEGER PRIMARY KEY,
uid TEXT NOT NULL REFERENCES account(uid),
type TEXT NOT NULL CHECK (type IN ('password','passkey','secret')),
secret TEXT NOT NULL, -- 只存哈希或公钥,绝不存明文
expires INTEGER, -- NULL = 长期(服务账号)
revoked INTEGER NOT NULL DEFAULT 0
);
设计要点只有三条:account 承担身份、identity 承担登录、credential 承担证明。三者分离后,"换手机号"是往 identity 插一行,"换密码"是给 credential 加一行并吊销旧行,uid 永远不动。
模型建好后,日常运维真正要跑的是三条审计查询。灌一批样本数据(一个离职员工、一个只填了手机号没设密码的待激活用户)后实测输出:
-- ① 已离职/禁用但仍有未吊销凭证(最高危:前员工还能登录)
SELECT a.uid, a.status, COUNT(c.id) AS live_cred
FROM account a JOIN credential c ON c.uid = a.uid
WHERE a.status IN ('disabled','deleted') AND c.revoked = 0
GROUP BY a.uid, a.status;
-- ② 有标识但没有任何有效凭证(登录不了,通常是同步残缺)
SELECT i.uid, GROUP_CONCAT(i.type) AS ids
FROM identity i LEFT JOIN credential c ON c.uid = i.uid AND c.revoked = 0
WHERE c.id IS NULL GROUP BY i.uid;
-- ③ pending 超过 7 天未激活(僵尸账号,也是撞库的温床)
SELECT uid, created_at FROM account
WHERE status = 'pending' AND julianday('2026-01-20') - julianday(created_at) > 7;
| ① 已离职/禁用但仍有未吊销凭证(最高危) |
| uid | status | live_cred |
| 01H8B | disabled | 1 |
| ② 有标识但没有任何有效凭证 |
| uid | ids |
| 01H8D | phone |
| ③ pending 超过 7 天未激活 |
| uid | created_at |
| 01H8D | 2026-01-05 |
第一条查出的 01H8B 是最典型的真实事故:员工离职时管理员只吊销了密码,忘了同一账号上还有一把 Passkey。三年后复盘时才发现这个账号一直能登录。
拿 iam 项目当最小实现对照
marmotedu/iam (opens new window)(项目说明为"企业级认证和授权系统",Go 实现)里只有一张 user 表,字段足够说明最小实现长什么样:
| 字段 | 作用 | 扩展时的处理 |
|---|---|---|
name(唯一) | 登录名 + UID 合一 | 拆成 uid + identity(username) |
status(1 可用 / 0 不可用) | 两态开关 | 扩成五态状态机(见下) |
password | 哈希 | 迁到独立 credential 表,支持多凭证 |
email / phone | 登录标识兼档案 | 迁到 identity 表,加 verified |
isAdmin(布尔) | 粗粒度管理员 | 判断:这是权限模型的快捷方式,出现第四种角色时一定会退化,见 49.3 权限中心与权限矩阵 |
loginedAt | 最后登录时间 | 直接驱动"长期未登录自动禁用"任务 |
iam 的 iam-watcher 就周期性扫这张表,把"超过 N 天没登录"的用户禁用,并清理过期的策略审计记录——这是账号生命周期自动化的最小可用形态:不追求实时,用定时任务兜住长尾。
生命周期状态机:五态、十三条边
状态机必须写成数据而不是散落的 if,否则每加一个状态就要全库搜索 status ==。
pending ──激活──▶ active ──风控/失败──▶ locked
│ │ │ │
超期清理 │ └──离职/停用──┐ │
▼ │ ▼ ▼
deleted ◀───────────┴───────────── disabled
▲ │
└──────── 保留期满墓碑化 ◀───────────┘
用 Go 把它跑出来(转移表 + 挂在边上的副作用):
package main
import "fmt"
type State string
const (
Pending State = "pending"
Active State = "active"
Locked State = "locked"
Disabled State = "disabled"
Deleted State = "deleted"
)
var all = []State{Pending, Active, Locked, Disabled, Deleted}
// 转移表是唯一事实来源
var edges = map[State][]State{
Pending: {Active, Deleted}, // 激活 / 超期未激活清理
Active: {Locked, Disabled, Deleted}, // 风控锁定 / 停用 / 注销
Locked: {Active, Disabled}, // 解锁 / 直接停用
Disabled: {Active, Deleted}, // 复职 / 保留期满墓碑化
Deleted: {}, // 终态
}
// 副作用挂在边上:状态变了但令牌没撤,等于没变
var effects = map[[2]State][]string{
{Active, Locked}: {"踢掉全部会话", "记风控事件"},
{Active, Disabled}: {"吊销全部凭证", "撤销全部令牌", "踢会话", "通知 owner"},
{Locked, Disabled}: {"吊销全部凭证", "撤销全部令牌"},
{Disabled, Active}: {"要求重设凭证", "记录复职审批单号"},
{Active, Deleted}: {"撤销令牌", "清空 PII", "保留 uid 墓碑"},
{Disabled, Deleted}: {"清空 PII", "保留 uid 墓碑 + 审计"},
}
func can(from, to State) bool {
for _, s := range edges[from] {
if s == to {
return true
}
}
return false
}
func main() {
fmt.Print(" ")
for _, t := range all {
fmt.Printf("%-9s", t)
}
fmt.Println()
for _, f := range all {
fmt.Printf("%-8s", f)
for _, t := range all {
if can(f, t) {
fmt.Printf("%-9s", "✓")
} else {
fmt.Printf("%-9s", "·")
}
}
fmt.Println()
}
fmt.Println("\nactive → disabled 的副作用:", effects[[2]State{Active, Disabled}])
fmt.Println("locked → deleted 合法吗:", can(Locked, Deleted))
}
输出(本机实测):
pending active locked disabled deleted
pending · ✓ · · ✓
active · · ✓ ✓ ✓
locked · ✓ · ✓ ·
disabled· ✓ · · ✓
deleted · · · · ·
active → disabled 的副作用: [吊销全部凭证 撤销全部令牌 踢会话 通知 owner]
locked → deleted 合法吗: false
"撤销令牌"这一步的具体手段(黑名单、token_version、短 TTL)见 49.5 登录态与令牌生命周期。表里有两处是刻意的:
locked → deleted禁止。锁定态是风控或管理员态,直接删会绕过"停用"这条边上的凭证吊销副作用,留下能登录的幽灵。必须先停用、再删。deleted是终态。恢复只能走数据恢复流程(带审批),不提供deleted → active——否则"注销"就成了一个可逆装饰。
⚠️ 没有"物理删除",只有墓碑 + 保留期:
deleted行保留uid,清空 PII,保留 90–180 天。审计日志、订单、工单全都引用uid,物理删除会让审计链断在中间。合规上"删除权"与"审计留存"的冲突处理见 3.2 个人信息保护与 GDPR。
权威源与对账:把"离职"做成 SLA
统一账号体系里最贵的一句话是"HR 为准"。落成三张表就能执行:
| 事件 | 触发源 | 目标状态 | 时限 | 副作用 |
|---|---|---|---|---|
| 入职 | HR 录用单 | pending → active | T-3 天预开户 | 下发初始凭证、加默认角色 |
| 离职 | HR 离职单 | active → disabled | T+0(当天,按分钟计) | 吊销凭证 + 撤销令牌 + 踢会话 |
| 调岗 | HR 组织变更 | 只改档案与部门 | T+1 | 数据权限随之变化,见 49.4 多租户权限模型 |
| 注销 | 用户申请 | active → deleted | 立即 + 保留期 | 清 PII,保留墓碑 |
同步只能是单向的:HR → 权威账号库 → 各应用。反向写入(应用建了账号再同步回 HR)一定会在半年内制造出两套真相。
对账指标只有三个,但必须每天算、每天报警:
| 指标 | 定义 | 健康值 | 超标的含义 |
|---|---|---|---|
| 孤儿账号数 | 有凭证、HR 里查无此人 | 0 | 手工开户没人管,离职即后门 |
| 离职未停用数 | disabled 却仍有未吊销凭证 | 0 | 就是上面那条 ①,最高危 |
| 僵尸账号数 | 90 天未登录且仍 active | 月增 < 1% 总数 | 撞库目标池(见 38.7 账号安全 (撞库 / 暴力破解 / 防盗号 / 验证码)) |
🔑 统一账号体系的验收标准不是"能不能单点登录",而是"离职当天还能不能登进来"。前者是功能,后者是口径。
落地:指名 / 可抄 / 可验 / 可退
指名:生产首选 Keycloak(Apache-2.0,OIDC / SAML / 用户联邦 / MFA / 管理台齐全,代价是 Java 技术栈与较重的运维);云原生轻量路线选 Ory Kratos(Go,只做 identity,不管授权);自研的最小对照实现就是 shgopher/iam (opens new window)。
可抄(未验证:本机 docker daemon 未启动,命令以 Keycloak 官方 getting-started 为准):
# 单机试跑,仅用于验证概念,不能上生产
docker run -p 8080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.0 start-dev
# 管理台 http://localhost:8080/admin,创建 realm 后用它发 token
可验(零依赖:把上面三段 SQL 分别存成 schema.sql / seed.sql / audit.sql,本机已跑通):
sqlite3 acct.db < schema.sql && sqlite3 acct.db < seed.sql && sqlite3 acct.db < audit.sql
# 预期:① 至少 1 行(离职仍有活凭证)② ③ 各给出待处理账号
# 若无输出:要么数据真的干净,要么 CHECK/FK 没生效——先确认 sqlite3 版本 ≥ 3.37
可退:IdP 一旦成为唯一登录入口,它就是全站单点。必须预置两样东西——① break-glass 本地账号:每个应用保留 1 个不走 IdP 的应急管理员,密码放保险柜、使用即告警;② 熔断开关:IdP 健康检查连续失败 N 次后,允许存量会话继续用到 TTL 到期,而不是把所有人踢下线(新登录仍然失败)。开关状态与触发记录要进配置中心,见 48.1 配置中心。
反模式
| 反模式 | 后果 |
|---|---|
| 用手机号 / 邮箱当主键 | 换号即换人;号码回收后新机主继承旧账号 |
每个应用一张 user 表 + 定时双向同步 | 两套真相,冲突无法仲裁,越同步越乱 |
离职只改 status,不吊销凭证与令牌 | 前员工凭旧 refresh token 继续访问,直到 TTL 到期(可能 30 天) |
| 物理删除用户 | 审计链断裂、外键孤儿、工单指向不存在的 uid |
| 服务账号与员工共用密码策略 | 要么服务账号被强制 90 天轮转导致发布失败,要么员工密码策略被调到最松 |
把权限字段(isAdmin、role)塞进 user 表 | 加角色要改表;权限变更无法审计;越权排查只能读代码 |
| 登录标识不做唯一索引 | 同一手机号挂两个账号,短信登录随机进其中一个,客诉无解 |
| 把"昵称"写进审计日志当主体 | 改名后无法追责,审计报告自相矛盾 |
自检
Q1 一个用户的手机号从 A 换到 B,同时他还有邮箱登录。按这套五表模型,要改几张表、动 uid 吗?
▸ 答案:只动 identity 表两行——旧手机号行标记 released_at(软删,不物理删),新手机号插一行 verified=0、走验证后置 1。account.uid 与 credential 都不动。如果实现里 uid 就是手机号,这次换号等于换人:历史订单、审计日志、消息通知全部指向一个已经不存在的号。
Q2 为什么 locked → deleted 要禁止,而 active → deleted 允许?
▸ 答案:deleted 的副作用是"清 PII + 撤销令牌",但不含"吊销凭证"。active → deleted 是用户主动注销,走的是完整注销流(先撤销全部令牌);locked 是风控态,直接删会跳过"停用"边上的凭证吊销,留下一个凭证仍有效、账号已墓碑的幽灵——登录接口按 uid 查不到人却在凭证表命中,是最难查的一类越权。正确路径是 locked → disabled → deleted。
Q3 iam 项目用 isAdmin 布尔表达管理员,什么规模下必须换掉?换成什么?
▸ 答案:出现第二种角色时就该换。isAdmin 只能表达"全有 / 全无",一旦要区分"只读管理员""运营""审计员""租户管理员",布尔立刻退化成 role 字符串,再退化成多对多表——每次退化都要改代码里的判断。正确做法是把权限做成数据(policy 表),代码只做一次判定,见 49.3 权限中心与权限矩阵。
Q4 统一账号体系上线后,用什么指标证明它有效?给出至少三个可测量。
▸ 答案:① 离职未停用数 = 0(有凭证且状态为 disabled/deleted 的账号数);② 账号口径一致性:同一自然人在两个系统里的 uid 相同比例,目标是 100%,低于 100% 说明还有旁路开户;③ 开通时延:从 HR 入职单到可登录的中位时长(预开户应做到 T 以内);④ 孤儿账号数 = 0(HR 查无此人却有凭证)。前两个是"对不对",后两个是"快不快"。
Q5 为什么"共享一张 user 表"比"共享一套凭证策略"安全得多?
▸ 答案:共享表只引入数据口径风险(字段含义要统一),共享凭证策略引入的是安全策略风险:员工需要 MFA + 90 天改密 + 离职即失效,服务账号需要长期有效 + 自动轮转 + 不能人工登录。两者共用一套策略时,唯一的收敛点是"最松的那套",等于给服务账号开了员工通道。正确切法是:账号表共用(要 UID),凭证表按 kind 分策略,策略引擎按键值分发。