Skip to content

多租户系统认证数据模型:User、Identifier、Credential、Passkey 与 SSO 的工程化设计 ​

写作意图:这篇文章不以“当前只需要用户名密码”为目标,也不因为 ORM、数据库产品或查询复杂度而主动简化模型。目标是先把认证领域本身建模正确,让后续 Password、Passkey、WebAuthn、MFA、OIDC、LDAP、SSO、Session、Step-up Authentication 等能力都能在同一套边界内自然扩展。

一、先明确认证系统到底在建模什么 ​

认证系统最容易出现的问题,是把“用户”“登录名”“密码”“租户成员”“角色”“Session”全部压进一张 users 表。

从领域职责看,这些概念并不是一回事。

一个完整的认证模型至少要回答下面几个问题:

概念回答的问题
User系统中的稳定主体是谁
Login Identifier通过什么标识找到这个 User
Credential / AuthenticatorUser 通过什么证明自己
External Identity外部 IdP 认证后的主体如何映射到本地 User
Tenant Member这个 User 在某个租户里是谁
Authentication Policy这次认证需要满足哪些条件
Session一次成功认证关系如何持续

因此最核心的边界应该是:

text
User
├── Login Identifier
├── Credential
├── External Identity
└── Session

User
└── Tenant Member
    └── Role / Permission

Authentication 解决“你是谁、你如何证明”;Authorization 解决“你在当前 Tenant 里能做什么”。二者不能混成同一个模型。

二、User:稳定的认证主体 ​

users 应该代表系统中的全局主体,而不是“用户名密码记录”。

推荐模型:

text
users
────────────────────────────
id
status
created_at
updated_at
deleted_at

它应该尽量稳定,不因为用户修改邮箱、手机号、用户名、密码或绑定新的 SSO 而变化。

因此不要把下面这些字段天然视为 User 本身:

text
email
phone
username
password_hash
provider_subject

这些都属于 User 的认证属性,而不是 User 的主身份。

在多租户系统中,User 通常是全局对象:

text
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。

推荐表:

text
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:

text
username
email
phone

例如:

text
User U001
├── username = xun
├── email    = xun@example.com
└── phone    = +8613800138000

三个 Identifier 都指向同一个 User:

text
xun ------------------┐
xun@example.com ------┼──> User U001
+8613800138000 --------┘

Identifier 为什么必须和 Password 分开 ​

假设系统允许:

text
username + password
email    + password
phone    + password

这并不意味着存在三套密码。

真实模型通常是:

text
3 个 Identifier
      ↓
同一个 User
      ↓
1 个 Password Credential

如果把 Identifier 与密码 Hash 混在同一行,就会变成:

text
xun               -> hash ABC
xun@example.com   -> hash ABC
13800138000       -> hash ABC

修改密码时必须同步修改三行,这会人为制造不变量和安全风险。

因此更合理的职责分离是:

text
Identifier
    ↓
Resolve User
    ↓
Credential
    ↓
Verify

四、Identifier 的租户作用域 ​

多租户系统并不只有一种 Identifier 唯一性模型。

全局 Identifier ​

例如:

text
email = xun@example.com

全平台唯一:

text
UNIQUE(type, normalized_value)

适用于典型 SaaS:用户先完成平台级登录,再选择或切换 Tenant。

Tenant Scoped Identifier ​

例如两个企业都允许存在:

text
Tenant A / username = admin
Tenant B / username = admin

此时唯一约束应该是:

text
UNIQUE(tenant_id, type, normalized_value)

因此最完整的模型应该允许 Identifier 显式表达 scope,而不是把所有 Identifier 强行设计成全局唯一或租户内唯一。

一个常见实现是:

text
scope_type = global | tenant
tenant_id  = nullable

并通过数据库约束保证:

text
scope_type = global  -> tenant_id IS NULL
scope_type = tenant  -> tenant_id IS NOT NULL

五、Credential:统一的认证器实体 ​

Credential 解决的问题是:

User 有哪些可以用于证明自己身份的认证器?

推荐父表:

text
user_credentials
────────────────────────────
id
user_id

type
status
label

created_at
verified_at
last_used_at
expires_at
revoked_at

常见 type:

text
password
passkey
totp
certificate
recovery_code

这一层只保存所有 Credential 的公共属性:

  • 属于哪个 User;
  • Credential 类型;
  • 当前状态;
  • 创建、验证、最近使用时间;
  • 是否已撤销或过期;
  • 用户可读标签,例如“MacBook Touch ID”。

具体认证类型的数据不应该全部塞进一张大宽表。

六、为什么 Credential 要使用父表 + 类型子表 ​

Password 和 Passkey 的数据结构完全不同。

Password 需要:

text
password_hash
password_version
changed_at

Passkey / WebAuthn 需要:

text
external_credential_id
public_key
sign_count
aaguid
transports
backup_eligible
backup_state

如果把所有字段都塞到 user_credentials:

text
password_hash nullable
public_key nullable
sign_count nullable
aaguid nullable
...

很快就会形成大量互斥 Nullable 字段,而且数据库无法清晰表达某种 Credential 必须拥有哪些字段。

更完整的关系模型应该采用 Class Table Inheritance / Table-per-Type:

text
user_credentials
        │
        ├── password_credentials
        ├── passkey_credentials
        ├── totp_credentials
        └── certificate_credentials

父表表示统一 Credential,子表保存具体类型特有字段。

七、UserCredential 与 PasswordCredential 如何映射 ​

核心采用 Shared Primary Key,也就是共享主键的一对一关系。

父表:

text
user_credentials
────────────────────────────
id              PK
user_id         FK
type
status
...

Password 子表:

text
password_credentials
────────────────────────────
credential_id   PK + FK
password_hash
password_version
changed_at

其中:

text
password_credentials.credential_id
=
user_credentials.id

credential_id 同时是:

  • Password 子表的 Primary Key;
  • 指向 user_credentials.id 的 Foreign Key。

例如:

text
user_credentials

C001 | U001 | password | active
C002 | U001 | passkey  | active
C003 | U001 | passkey  | active

Password 子表:

text
password_credentials

C001 | $argon2id$...

Passkey 子表:

text
passkey_credentials

C002 | credential-A | public-key-A | ...
C003 | credential-B | public-key-B | ...

因此 C001 不是两条不同业务记录之间的关联 ID,而是同一个 Credential 在父表和具体类型表中的同一个身份。

关系基数为:

text
User 1 : N UserCredential

UserCredential 1 : 0..1 PasswordCredential
UserCredential 1 : 0..1 PasskeyCredential

并且必须满足一个核心不变量:

一个 UserCredential 只能落入与自身 type 匹配的一种具体 Credential 子表。

八、Password Credential 的完整模型 ​

推荐:

text
password_credentials
────────────────────────────
credential_id
password_hash
password_version
changed_at
must_change_at nullable

password_hash 应保存完整的 Password Hash Encoding,例如 Argon2id 的 PHC String,而不是保存明文密码或可逆加密密码。

如果系统需要密码复用限制,可以增加:

text
password_credential_history
────────────────────────────
id
credential_id
password_hash
created_at

Password 本身是 Credential,不应该与 Email、Phone 或 Username 一一绑定。

完整登录流程:

text
输入 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 类型。

推荐子表:

text
passkey_credentials
────────────────────────────
credential_id
rp_id
external_credential_id
public_key
sign_count
aaguid
transports
backup_eligible
backup_state
created_at

其中要区分两个 ID:

text
credential_id

是系统内部 user_credentials.id。

而:

text
external_credential_id

是 WebAuthn 协议里的 Credential ID,由认证器生成。

一个用户可以拥有多个 Passkey:

text
User U001
├── Credential C001 / password
├── Credential C002 / passkey / MacBook Touch ID
├── Credential C003 / passkey / iPhone
└── Credential C004 / passkey / Hardware Key

因此 Passkey 天然是:

text
User 1 : N Passkey Credential

十、MFA 不应该再建一个平行的“身份体系” ​

从认证器角度看:

text
Password
Passkey
TOTP
Hardware Key

本质上都属于 Credential / Authenticator。

MFA 描述的不是“这是什么 Credential”,而是:

一次 Authentication Flow 需要组合多少个、什么类型的 Factor 才算满足安全策略。

因此不建议因为某个认证器用于 MFA,就把它放到完全独立的 user_mfa_methods 体系里。

更清晰的模型是:

text
User
└── Credentials
    ├── Password
    ├── Passkey
    ├── TOTP
    └── Hardware Key

然后由 Authentication Policy 决定:

text
password
+
totp

或者:

text
passkey

是否已经达到当前所需的 Authentication Assurance Level。

十一、Authentication Policy 必须与 Credential 分离 ​

Credential 描述:

用户拥有什么认证器。

Authentication Policy 描述:

当前系统、Tenant 或具体高风险操作要求使用哪些认证器。

例如:

text
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 独立出来:

text
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:

text
identity_providers
────────────────────────────
id
tenant_id nullable
protocol
name
issuer
client_id
client_secret_encrypted
metadata_url
status
created_at
updated_at

用户和外部主体的映射:

text
user_external_identities
────────────────────────────
id
user_id
provider_id
subject
email_snapshot nullable
created_at
last_login_at

核心唯一约束:

text
UNIQUE(provider_id, subject)

其中:

text
provider + subject

才是外部身份真正稳定的标识。

不要仅通过外部 IdP 返回的 Email 直接长期绑定用户,因为 Email 可能变化、复用,甚至不同 Provider 对 Email 的可信语义不同。

登录流程:

text
Tenant / Domain Discovery
        ↓
选择 Identity Provider
        ↓
OIDC / SAML Authentication
        ↓
验证 Token / Assertion
        ↓
(provider_id, subject)
        ↓
user_external_identities
        ↓
User

十三、多租户下认证和 TenantMember 的边界 ​

推荐始终保持:

text
User = 全局认证主体
TenantMember = User 在 Tenant 内的成员身份

表关系:

text
users
  │
  └── tenant_members
        ├── tenant_id
        ├── user_id
        └── status

认证成功只意味着:

text
User U001 authenticated

它不自动意味着:

text
U001 可以访问 Tenant A

进入 Tenant 时还必须校验:

text
TenantMember exists
status = active

这样可以自然表达:

text
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 要求。

因此完整认证流程可以是:

text
输入登录上下文
        ↓
解析 Tenant(可选)
        ↓
解析 Login Identifier 或 IdP
        ↓
找到 User
        ↓
验证 Credential / External Identity
        ↓
执行 Tenant Authentication Policy
        ↓
Authentication Success
        ↓
建立 Session

Tenant 参与的是认证上下文和策略,而不是取代 User 成为认证主体。

十五、Session:认证成功后的独立实体 ​

Credential 完成的是“证明身份”,Session 描述的是“一次认证成功关系”。

推荐:

text
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,可以增加:

text
auth_session_factors
────────────────────────────
session_id
credential_id nullable
method_type
verified_at
authentication_context

例如:

text
Session S001
├── Password Credential C001 / 10:00 verified
└── Passkey Credential C003 / 10:01 verified

这对 Step-up Authentication 很重要。

例如用户在 10:00 已通过 Password 登录,但 10:30 发起资金划转时要求:

text
AAL2
且第二因素认证时间 < 5 min

系统就可以判断是否需要重新挑战 Passkey / TOTP。

Access Token、Refresh Token 与 Session 的生命周期设计可继续沿用已有文章《Access Token 与 Refresh Token 的认证架构设计》中的分层模型。

十六、临时认证流程不要污染 Credential 主表 ​

以下数据通常是临时状态:

text
Email verification token
Password reset token
WebAuthn registration challenge
WebAuthn authentication challenge
OTP challenge
Account recovery challenge

它们不是长期 Credential,应该单独放在 Challenge / Verification 体系中。

例如:

text
auth_challenges
────────────────────────────
id
user_id nullable
identifier_id nullable
session_id nullable

type
challenge_hash
attempt_count
expires_at
consumed_at
created_at

它们的生命周期通常是:

text
created
↓
verified / consumed
↓
expired / deleted

而不是像 Credential 一样长期挂在 User 下。

十七、认证尝试与安全审计 ​

认证系统需要同时区分业务状态和安全事件。

推荐单独记录:

text
authentication_attempts
────────────────────────────
id
user_id nullable
identifier_type
identifier_fingerprint
provider_id nullable
method_type
result
failure_reason
ip
user_agent
created_at

以及更高层的:

text
security_events
────────────────────────────
id
user_id nullable
tenant_id nullable
session_id nullable
credential_id nullable

event_type
risk_level
metadata
created_at

事件包括:

text
LOGIN_SUCCESS
LOGIN_FAILED
CREDENTIAL_CREATED
CREDENTIAL_REVOKED
PASSWORD_CHANGED
PASSKEY_REGISTERED
MFA_STEP_UP
SESSION_REVOKED
EXTERNAL_IDENTITY_LINKED

Credential 本身只保存当前状态,不承担完整审计历史。

十八、核心数据库不变量 ​

认证表设计真正重要的不是“表有几张”,而是数据库能否持续维持下面这些不变量。

1. Identifier 唯一性 ​

全局 Identifier:

text
(type, normalized_value)

必须唯一。

Tenant Scoped Identifier:

text
(tenant_id, type, normalized_value)

必须唯一。

2. External Identity 唯一性 ​

text
(provider_id, subject)

必须唯一映射到一个 User。

3. Password 数量约束 ​

如果业务定义一个 User 只有一套本地密码,则必须保证:

text
每个 user_id 最多一个 active password Credential

不要只靠应用层约定。

4. Credential 子类型一致性 ​

如果:

text
user_credentials.type = password

则只能存在对应的:

text
password_credentials

不能同时存在:

text
passkey_credentials

5. Credential 子表不能脱离父表存在 ​

共享主键 Foreign Key 必须保证:

text
password_credentials.credential_id

对应的:

text
user_credentials.id

一定存在。

6. Credential 创建必须原子化 ​

创建 Password 时应视为一个 Domain Transaction:

text
BEGIN

INSERT user_credentials(type=password)
INSERT password_credentials(...)

COMMIT

如果任一步失败,则整个 Credential 不应处于半创建状态。

十九、推荐的最终关系模型 ​

完整主干可以压缩成:

text
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 ​

text
users

Identifier ​

text
user_login_identifiers

Credential ​

text
user_credentials
password_credentials
passkey_credentials
totp_credentials
certificate_credentials
password_credential_history

Federation / SSO ​

text
identity_providers
user_external_identities

Tenant Authentication ​

text
tenant_auth_policies
password_policies

Session ​

text
auth_sessions
auth_session_factors
refresh_token_families

Temporary Authentication State ​

text
auth_challenges

Audit / Security ​

text
authentication_attempts
security_events

Authorization 边界 ​

text
tenants
tenant_members
roles
permissions

其中 tenant_members 之后已经进入 Authorization / Organization Domain,不应重新塞回 Authentication Aggregate。

二十一、正常认证路径 ​

Password 登录 ​

text
Login Identifier
↓
Resolve User
↓
Load Password Credential
↓
Verify Password
↓
Evaluate Authentication Policy
↓
必要时执行 Additional Factor
↓
Create Auth Session
↓
Issue AT / RT

Passkey 登录 ​

text
Resolve User / Discoverable Credential
↓
Create WebAuthn Challenge
↓
Authenticator Assertion
↓
Resolve Passkey Credential
↓
Verify Signature + Counter + RP Context
↓
Evaluate Authentication Policy
↓
Create Auth Session

OIDC / SSO 登录 ​

text
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

三个流程最终都收敛到同一个稳定主体:

text
User

并最终产生同一种结果:

text
Auth Session

这就是统一认证模型真正的价值。

二十二、不要让 ORM 或数据库产品反向决定领域模型 ​

这套模型的原则是:

text
先确定领域边界和数据不变量
↓
再选择 ORM / Database Adapter 的实现方式

而不是:

text
因为 ORM Join 写起来麻烦
↓
把 Identifier、Credential、Password 全塞回 users

AI 编程已经显著降低了 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 建立几组职责明确、生命周期独立的数据:

text
User
├── Identifiers
├── Credentials
├── External Identities
├── Sessions
└── Tenant Memberships

其中 Credential 再使用共享主键的一对一子类型模型表达 Password、Passkey 等具体认证器。这套结构可以在不推翻核心边界的情况下持续增加新的认证方式、企业 SSO、MFA、Step-up Authentication 和更严格的安全策略。

Last updated:

基于 VitePress 构建 · 工程、交易与系统研究日志