多租户系统认证数据模型:User、Identifier、Credential、Passkey 与 SSO 的工程化设计
写作意图:这篇文章不以“当前只需要用户名密码”为目标,也不因为 ORM、数据库产品或查询复杂度而主动简化模型。目标是先把认证领域本身建模正确,让后续 Password、Passkey、WebAuthn、MFA、OIDC、LDAP、SSO、Session、Step-up Authentication 等能力都能在同一套边界内自然扩展。
一、先明确认证系统到底在建模什么
认证系统最容易出现的问题,是把“用户”“登录名”“密码”“租户成员”“角色”“Session”全部压进一张 users 表。
从领域职责看,这些概念并不是一回事。
一个完整的认证模型至少要回答下面几个问题:
| 概念 | 回答的问题 |
|---|---|
| User | 系统中的稳定主体是谁 |
| Login Identifier | 通过什么标识找到这个 User |
| Credential / Authenticator | User 通过什么证明自己 |
| External Identity | 外部 IdP 认证后的主体如何映射到本地 User |
| Tenant Member | 这个 User 在某个租户里是谁 |
| Authentication Policy | 这次认证需要满足哪些条件 |
| Session | 一次成功认证关系如何持续 |
因此最核心的边界应该是:
User
├── Login Identifier
├── Credential
├── External Identity
└── Session
User
└── Tenant Member
└── Role / PermissionAuthentication 解决“你是谁、你如何证明”;Authorization 解决“你在当前 Tenant 里能做什么”。二者不能混成同一个模型。
二、User:稳定的认证主体
users 应该代表系统中的全局主体,而不是“用户名密码记录”。
推荐模型:
users
────────────────────────────
id
status
created_at
updated_at
deleted_at它应该尽量稳定,不因为用户修改邮箱、手机号、用户名、密码或绑定新的 SSO 而变化。
因此不要把下面这些字段天然视为 User 本身:
email
phone
username
password_hash
provider_subject这些都属于 User 的认证属性,而不是 User 的主身份。
在多租户系统中,User 通常是全局对象:
User U001
├── Tenant A / Member M001
├── Tenant B / Member M002
└── Tenant C / Member M003这样 MFA、Passkey、Session、账号冻结、外部身份绑定都可以以 User 为统一主体管理,而租户内角色和组织关系则由 tenant_members 管理。
三、Login Identifier:负责“找到 User”
用户名、邮箱、手机号的本质都是 Identifier。
它们的职责只有一个:
根据用户输入的登录标识,解析出一个稳定的
user_id。
推荐表:
user_login_identifiers
────────────────────────────
id
user_id
scope_type
tenant_id nullable
type
value
normalized_value
status
is_primary
verified_at
created_at
updated_at常见 type:
username
email
phone例如:
User U001
├── username = xun
├── email = xun@example.com
└── phone = +8613800138000三个 Identifier 都指向同一个 User:
xun ------------------┐
xun@example.com ------┼──> User U001
+8613800138000 --------┘Identifier 为什么必须和 Password 分开
假设系统允许:
username + password
email + password
phone + password这并不意味着存在三套密码。
真实模型通常是:
3 个 Identifier
↓
同一个 User
↓
1 个 Password Credential如果把 Identifier 与密码 Hash 混在同一行,就会变成:
xun -> hash ABC
xun@example.com -> hash ABC
13800138000 -> hash ABC修改密码时必须同步修改三行,这会人为制造不变量和安全风险。
因此更合理的职责分离是:
Identifier
↓
Resolve User
↓
Credential
↓
Verify四、Identifier 的租户作用域
多租户系统并不只有一种 Identifier 唯一性模型。
全局 Identifier
例如:
email = xun@example.com全平台唯一:
UNIQUE(type, normalized_value)适用于典型 SaaS:用户先完成平台级登录,再选择或切换 Tenant。
Tenant Scoped Identifier
例如两个企业都允许存在:
Tenant A / username = admin
Tenant B / username = admin此时唯一约束应该是:
UNIQUE(tenant_id, type, normalized_value)因此最完整的模型应该允许 Identifier 显式表达 scope,而不是把所有 Identifier 强行设计成全局唯一或租户内唯一。
一个常见实现是:
scope_type = global | tenant
tenant_id = nullable并通过数据库约束保证:
scope_type = global -> tenant_id IS NULL
scope_type = tenant -> tenant_id IS NOT NULL五、Credential:统一的认证器实体
Credential 解决的问题是:
User 有哪些可以用于证明自己身份的认证器?
推荐父表:
user_credentials
────────────────────────────
id
user_id
type
status
label
created_at
verified_at
last_used_at
expires_at
revoked_at常见 type:
password
passkey
totp
certificate
recovery_code这一层只保存所有 Credential 的公共属性:
- 属于哪个 User;
- Credential 类型;
- 当前状态;
- 创建、验证、最近使用时间;
- 是否已撤销或过期;
- 用户可读标签,例如“MacBook Touch ID”。
具体认证类型的数据不应该全部塞进一张大宽表。
六、为什么 Credential 要使用父表 + 类型子表
Password 和 Passkey 的数据结构完全不同。
Password 需要:
password_hash
password_version
changed_atPasskey / WebAuthn 需要:
external_credential_id
public_key
sign_count
aaguid
transports
backup_eligible
backup_state如果把所有字段都塞到 user_credentials:
password_hash nullable
public_key nullable
sign_count nullable
aaguid nullable
...很快就会形成大量互斥 Nullable 字段,而且数据库无法清晰表达某种 Credential 必须拥有哪些字段。
更完整的关系模型应该采用 Class Table Inheritance / Table-per-Type:
user_credentials
│
├── password_credentials
├── passkey_credentials
├── totp_credentials
└── certificate_credentials父表表示统一 Credential,子表保存具体类型特有字段。
七、UserCredential 与 PasswordCredential 如何映射
核心采用 Shared Primary Key,也就是共享主键的一对一关系。
父表:
user_credentials
────────────────────────────
id PK
user_id FK
type
status
...Password 子表:
password_credentials
────────────────────────────
credential_id PK + FK
password_hash
password_version
changed_at其中:
password_credentials.credential_id
=
user_credentials.idcredential_id 同时是:
- Password 子表的 Primary Key;
- 指向
user_credentials.id的 Foreign Key。
例如:
user_credentials
C001 | U001 | password | active
C002 | U001 | passkey | active
C003 | U001 | passkey | activePassword 子表:
password_credentials
C001 | $argon2id$...Passkey 子表:
passkey_credentials
C002 | credential-A | public-key-A | ...
C003 | credential-B | public-key-B | ...因此 C001 不是两条不同业务记录之间的关联 ID,而是同一个 Credential 在父表和具体类型表中的同一个身份。
关系基数为:
User 1 : N UserCredential
UserCredential 1 : 0..1 PasswordCredential
UserCredential 1 : 0..1 PasskeyCredential并且必须满足一个核心不变量:
一个 UserCredential 只能落入与自身
type匹配的一种具体 Credential 子表。
八、Password Credential 的完整模型
推荐:
password_credentials
────────────────────────────
credential_id
password_hash
password_version
changed_at
must_change_at nullablepassword_hash 应保存完整的 Password Hash Encoding,例如 Argon2id 的 PHC String,而不是保存明文密码或可逆加密密码。
如果系统需要密码复用限制,可以增加:
password_credential_history
────────────────────────────
id
credential_id
password_hash
created_atPassword 本身是 Credential,不应该与 Email、Phone 或 Username 一一绑定。
完整登录流程:
输入 email / phone / username
↓
user_login_identifiers
↓
解析 user_id
↓
user_credentials(type=password)
↓
password_credentials
↓
verify password hash这样多个 Identifier 可以共享同一 Password Credential。
九、Passkey / WebAuthn Credential
Passkey 不是“另一种密码字段”,而是独立 Credential 类型。
推荐子表:
passkey_credentials
────────────────────────────
credential_id
rp_id
external_credential_id
public_key
sign_count
aaguid
transports
backup_eligible
backup_state
created_at其中要区分两个 ID:
credential_id是系统内部 user_credentials.id。
而:
external_credential_id是 WebAuthn 协议里的 Credential ID,由认证器生成。
一个用户可以拥有多个 Passkey:
User U001
├── Credential C001 / password
├── Credential C002 / passkey / MacBook Touch ID
├── Credential C003 / passkey / iPhone
└── Credential C004 / passkey / Hardware Key因此 Passkey 天然是:
User 1 : N Passkey Credential十、MFA 不应该再建一个平行的“身份体系”
从认证器角度看:
Password
Passkey
TOTP
Hardware Key本质上都属于 Credential / Authenticator。
MFA 描述的不是“这是什么 Credential”,而是:
一次 Authentication Flow 需要组合多少个、什么类型的 Factor 才算满足安全策略。
因此不建议因为某个认证器用于 MFA,就把它放到完全独立的 user_mfa_methods 体系里。
更清晰的模型是:
User
└── Credentials
├── Password
├── Passkey
├── TOTP
└── Hardware Key然后由 Authentication Policy 决定:
password
+
totp或者:
passkey是否已经达到当前所需的 Authentication Assurance Level。
十一、Authentication Policy 必须与 Credential 分离
Credential 描述:
用户拥有什么认证器。
Authentication Policy 描述:
当前系统、Tenant 或具体高风险操作要求使用哪些认证器。
例如:
tenant_auth_policies
────────────────────────────
id
tenant_id
password_enabled
passkey_enabled
mfa_required
required_aal
session_max_age
step_up_max_age
created_at
updated_at实际项目通常还会把 Password Policy 独立出来:
password_policies
────────────────────────────
tenant_id
min_length
history_count
max_age_days
lockout_threshold
lockout_duration这里的关键原则是:
不要把“这个 Credential 是不是第二因素”固化成 Credential 自身属性;同一个 Passkey 在不同策略下可能作为单因素强认证,也可能参与 Step-up Authentication。
十二、External Identity:OIDC / SSO 不等于本地 Credential
OIDC、SAML、企业 SSO 与 Password/Passkey 有一个本质区别:
本地系统不持有用户用于外部 IdP 登录的 Credential。
例如 Microsoft Entra ID 已经完成了密码、MFA、风险控制等认证,本地系统验证的是 IdP 签发的 Assertion / Token。
因此推荐单独建模 Identity Provider:
identity_providers
────────────────────────────
id
tenant_id nullable
protocol
name
issuer
client_id
client_secret_encrypted
metadata_url
status
created_at
updated_at用户和外部主体的映射:
user_external_identities
────────────────────────────
id
user_id
provider_id
subject
email_snapshot nullable
created_at
last_login_at核心唯一约束:
UNIQUE(provider_id, subject)其中:
provider + subject才是外部身份真正稳定的标识。
不要仅通过外部 IdP 返回的 Email 直接长期绑定用户,因为 Email 可能变化、复用,甚至不同 Provider 对 Email 的可信语义不同。
登录流程:
Tenant / Domain Discovery
↓
选择 Identity Provider
↓
OIDC / SAML Authentication
↓
验证 Token / Assertion
↓
(provider_id, subject)
↓
user_external_identities
↓
User十三、多租户下认证和 TenantMember 的边界
推荐始终保持:
User = 全局认证主体
TenantMember = User 在 Tenant 内的成员身份表关系:
users
│
└── tenant_members
├── tenant_id
├── user_id
└── status认证成功只意味着:
User U001 authenticated它不自动意味着:
U001 可以访问 Tenant A进入 Tenant 时还必须校验:
TenantMember exists
status = active这样可以自然表达:
User U001 = active
Tenant A member = suspended
Tenant B member = active此时用户仍能登录平台,也能访问 Tenant B,但不能进入 Tenant A。
十四、Tenant 如何参与 Authentication
“User 是全局的”并不意味着 Tenant 永远不参与登录流程。
Tenant 仍然可能参与:
- Tenant Scoped Username;
- 企业专属 OIDC / SAML;
- 不同 Tenant 的 MFA Policy;
- 不同 Password Policy;
- Domain Discovery;
- Step-up Authentication 要求。
因此完整认证流程可以是:
输入登录上下文
↓
解析 Tenant(可选)
↓
解析 Login Identifier 或 IdP
↓
找到 User
↓
验证 Credential / External Identity
↓
执行 Tenant Authentication Policy
↓
Authentication Success
↓
建立 SessionTenant 参与的是认证上下文和策略,而不是取代 User 成为认证主体。
十五、Session:认证成功后的独立实体
Credential 完成的是“证明身份”,Session 描述的是“一次认证成功关系”。
推荐:
auth_sessions
────────────────────────────
id
user_id
current_tenant_id nullable
current_member_id nullable
authentication_level
created_at
last_seen_at
idle_expires_at
absolute_expires_at
revoked_at
status如果系统需要精确记录一次 Session 使用了哪些 Factor,可以增加:
auth_session_factors
────────────────────────────
session_id
credential_id nullable
method_type
verified_at
authentication_context例如:
Session S001
├── Password Credential C001 / 10:00 verified
└── Passkey Credential C003 / 10:01 verified这对 Step-up Authentication 很重要。
例如用户在 10:00 已通过 Password 登录,但 10:30 发起资金划转时要求:
AAL2
且第二因素认证时间 < 5 min系统就可以判断是否需要重新挑战 Passkey / TOTP。
Access Token、Refresh Token 与 Session 的生命周期设计可继续沿用已有文章《Access Token 与 Refresh Token 的认证架构设计》中的分层模型。
十六、临时认证流程不要污染 Credential 主表
以下数据通常是临时状态:
Email verification token
Password reset token
WebAuthn registration challenge
WebAuthn authentication challenge
OTP challenge
Account recovery challenge它们不是长期 Credential,应该单独放在 Challenge / Verification 体系中。
例如:
auth_challenges
────────────────────────────
id
user_id nullable
identifier_id nullable
session_id nullable
type
challenge_hash
attempt_count
expires_at
consumed_at
created_at它们的生命周期通常是:
created
↓
verified / consumed
↓
expired / deleted而不是像 Credential 一样长期挂在 User 下。
十七、认证尝试与安全审计
认证系统需要同时区分业务状态和安全事件。
推荐单独记录:
authentication_attempts
────────────────────────────
id
user_id nullable
identifier_type
identifier_fingerprint
provider_id nullable
method_type
result
failure_reason
ip
user_agent
created_at以及更高层的:
security_events
────────────────────────────
id
user_id nullable
tenant_id nullable
session_id nullable
credential_id nullable
event_type
risk_level
metadata
created_at事件包括:
LOGIN_SUCCESS
LOGIN_FAILED
CREDENTIAL_CREATED
CREDENTIAL_REVOKED
PASSWORD_CHANGED
PASSKEY_REGISTERED
MFA_STEP_UP
SESSION_REVOKED
EXTERNAL_IDENTITY_LINKEDCredential 本身只保存当前状态,不承担完整审计历史。
十八、核心数据库不变量
认证表设计真正重要的不是“表有几张”,而是数据库能否持续维持下面这些不变量。
1. Identifier 唯一性
全局 Identifier:
(type, normalized_value)必须唯一。
Tenant Scoped Identifier:
(tenant_id, type, normalized_value)必须唯一。
2. External Identity 唯一性
(provider_id, subject)必须唯一映射到一个 User。
3. Password 数量约束
如果业务定义一个 User 只有一套本地密码,则必须保证:
每个 user_id 最多一个 active password Credential不要只靠应用层约定。
4. Credential 子类型一致性
如果:
user_credentials.type = password则只能存在对应的:
password_credentials不能同时存在:
passkey_credentials5. Credential 子表不能脱离父表存在
共享主键 Foreign Key 必须保证:
password_credentials.credential_id对应的:
user_credentials.id一定存在。
6. Credential 创建必须原子化
创建 Password 时应视为一个 Domain Transaction:
BEGIN
INSERT user_credentials(type=password)
INSERT password_credentials(...)
COMMIT如果任一步失败,则整个 Credential 不应处于半创建状态。
十九、推荐的最终关系模型
完整主干可以压缩成:
User
│
├── Login Identifier
│ ├── Username
│ ├── Email
│ └── Phone
│
├── Credential
│ ├── Password Credential
│ ├── Passkey Credential
│ ├── TOTP Credential
│ └── Certificate Credential
│
├── External Identity
│ └── Identity Provider
│
├── Auth Session
│ └── Session Factors
│
└── Tenant Member
└── Tenant对应 ER:
二十、最终推荐表清单
如果以完整工程化认证体系为目标,推荐至少保留下面这些边界。
Principal
usersIdentifier
user_login_identifiersCredential
user_credentials
password_credentials
passkey_credentials
totp_credentials
certificate_credentials
password_credential_historyFederation / SSO
identity_providers
user_external_identitiesTenant Authentication
tenant_auth_policies
password_policiesSession
auth_sessions
auth_session_factors
refresh_token_familiesTemporary Authentication State
auth_challengesAudit / Security
authentication_attempts
security_eventsAuthorization 边界
tenants
tenant_members
roles
permissions其中 tenant_members 之后已经进入 Authorization / Organization Domain,不应重新塞回 Authentication Aggregate。
二十一、正常认证路径
Password 登录
Login Identifier
↓
Resolve User
↓
Load Password Credential
↓
Verify Password
↓
Evaluate Authentication Policy
↓
必要时执行 Additional Factor
↓
Create Auth Session
↓
Issue AT / RTPasskey 登录
Resolve User / Discoverable Credential
↓
Create WebAuthn Challenge
↓
Authenticator Assertion
↓
Resolve Passkey Credential
↓
Verify Signature + Counter + RP Context
↓
Evaluate Authentication Policy
↓
Create Auth SessionOIDC / SSO 登录
Resolve Tenant / Provider
↓
Redirect to IdP
↓
Receive Authorization Response
↓
Validate ID Token / Assertion
↓
(provider_id, subject)
↓
Resolve External Identity
↓
Resolve User
↓
Evaluate Local Authentication Policy
↓
Create Auth Session三个流程最终都收敛到同一个稳定主体:
User并最终产生同一种结果:
Auth Session这就是统一认证模型真正的价值。
二十二、不要让 ORM 或数据库产品反向决定领域模型
这套模型的原则是:
先确定领域边界和数据不变量
↓
再选择 ORM / Database Adapter 的实现方式而不是:
因为 ORM Join 写起来麻烦
↓
把 Identifier、Credential、Password 全塞回 usersAI 编程已经显著降低了 Schema、Migration、Repository、DTO、查询和测试代码的实现成本,但并没有降低领域模型错误带来的长期成本。
认证系统最应该避免的不是“表多”,而是:
- 同一个概念承担多个职责;
- Identifier 和 Credential 强耦合;
- Tenant 和 User 身份混淆;
- Password、Passkey、SSO 被塞进一套互相冲突的字段;
- Credential 状态和 Authentication Policy 混在一起;
- Session、Token 和 Credential 生命周期没有边界;
- 关键不变量只能依赖调用方“记得这么写”。
因此在长期工程化设计中,优先保证模型正确,再考虑查询优化、缓存、ORM 映射和物理存储优化。
总结
多租户认证系统可以用下面几句话建立稳定心智模型:
User 是稳定的全局认证主体。
Login Identifier 负责找到 User。
Credential 负责证明 User。
Password、Passkey、TOTP 是 Credential 的具体类型。
External Identity 负责把外部 IdP 主体映射到本地 User。
Authentication Policy 决定一次登录必须满足哪些 Factor。
Session 是认证成功后持续存在的一次登录关系。
TenantMember 描述 User 在 Tenant 中的身份,属于认证之后的租户授权边界。
最终核心模型不是一张越来越大的 users 表,而是围绕稳定 User 建立几组职责明确、生命周期独立的数据:
User
├── Identifiers
├── Credentials
├── External Identities
├── Sessions
└── Tenant Memberships其中 Credential 再使用共享主键的一对一子类型模型表达 Password、Passkey 等具体认证器。这套结构可以在不推翻核心边界的情况下持续增加新的认证方式、企业 SSO、MFA、Step-up Authentication 和更严格的安全策略。