认证数据库设计

要点

  • 在 D1 / SQLite 风格里,这套认证模型建议统一遵守下面的类型约定
  • 认证系统最先要立住的,是用户主体
  • 一个用户未来完全可能有多个邮箱,所以邮箱不应该直接硬塞进 users
  • 密码登录和用户主体是两回事,所以密码也要单独放表
  • GitHub、Google 这类第三方身份,必须单独建绑定表

内容

1. 概述

上一篇已经把 Cloudflare 生态下认证系统的整体分工和核心原则讲清楚了。接下来就该把这些原则真正落到数据库模型上。

这篇文章的目标很单纯:把 D1 里的核心表结构,一张张拆开讲清楚,让新手能理解为什么要这么设计,而不是只看到一堆 SQL 就发懵。

当前这套模型要覆盖的场景包括:

  • admin:邮箱 + 密码登录
  • web:邮箱 + 密码、GitHub 登录
  • 后续可扩 Google 登录
  • 登录后可查看、绑定、解绑第三方账号
  • JWT + refresh token 会话体系

2. 先统一字段类型约定

在 D1 / SQLite 风格里,这套认证模型建议统一遵守下面的类型约定:

  • 主键:TEXT
  • 时间:INTEGER,存毫秒时间戳
  • 布尔:INTEGER + CHECK (IN (0,1))
  • 枚举:TEXT + CHECK
  • JSON:TEXT

这里不用一开始就追求很花哨的类型系统,关键是保持一致。

主键建议在应用层生成,例如:

  • uuidv7
  • ulid

当前更推荐 uuidv7。上一篇已经专门讲了它为什么更适合作为这套系统的默认主键策略。

3. users:用户主体表

认证系统最先要立住的,是用户主体。

// index.sql
CREATE TABLE users (
 
  id TEXT PRIMARY KEY,
 
  status TEXT NOT NULL CHECK (status IN ('active', 'suspended', 'deleted')),
 
  display_name TEXT,
 
  avatar_url TEXT,
 
  primary_email_id TEXT,
 
  created_at_ms INTEGER NOT NULL,
 
  updated_at_ms INTEGER NOT NULL,
 
  last_login_at_ms INTEGER
 
);

这张表最重要的设计原则是:用户主体不能跟密码或 OAuth 绑死。

因为一个真实用户以后可能同时拥有:

  • 邮箱密码登录
  • GitHub 绑定
  • Google 绑定

所以 users 只负责回答一件事:这个用户是谁。

4. user_emails:邮箱表

一个用户未来完全可能有多个邮箱,所以邮箱不应该直接硬塞进 users

// index.sql
CREATE TABLE user_emails (
 
  id TEXT PRIMARY KEY,
 
  user_id TEXT NOT NULL,
 
  email TEXT NOT NULL,
 
  normalized_email TEXT NOT NULL,
 
  is_primary INTEGER NOT NULL DEFAULT 0 CHECK (is_primary IN (0, 1)),
 
  is_verified INTEGER NOT NULL DEFAULT 0 CHECK (is_verified IN (0, 1)),
 
  verified_at_ms INTEGER,
 
  source TEXT NOT NULL CHECK (source IN ('password', 'github', 'google', 'manual')),
 
  created_at_ms INTEGER NOT NULL,
 
  updated_at_ms INTEGER NOT NULL,
 
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
 
);

再加上索引:

// index.sql
CREATE UNIQUE INDEX idx_user_emails_normalized_email_unique
 
ON user_emails(normalized_email);
 
CREATE UNIQUE INDEX idx_user_emails_user_normalized_unique
 
ON user_emails(user_id, normalized_email);
 
CREATE UNIQUE INDEX idx_user_emails_one_primary_per_user
 
ON user_emails(user_id)
 
WHERE is_primary = 1;

这里的关键点是 normalized_email。邮箱唯一性判断应该基于标准化结果,而不是原始输入。

5. password_credentials:本地密码凭证表

密码登录和用户主体是两回事,所以密码也要单独放表。

// index.sql
CREATE TABLE password_credentials (
 
  id TEXT PRIMARY KEY,
 
  user_id TEXT NOT NULL,
 
  email_id TEXT NOT NULL,
 
  password_hash TEXT NOT NULL,
 
  password_algo TEXT NOT NULL CHECK (password_algo IN ('argon2id', 'bcrypt')),
 
  password_updated_at_ms INTEGER NOT NULL,
 
  failed_attempts INTEGER NOT NULL DEFAULT 0,
 
  locked_until_ms INTEGER,
 
  must_reset_password INTEGER NOT NULL DEFAULT 0 CHECK (must_reset_password IN (0, 1)),
 
  created_at_ms INTEGER NOT NULL,
 
  updated_at_ms INTEGER NOT NULL,
 
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
 
  FOREIGN KEY (email_id) REFERENCES user_emails(id) ON DELETE CASCADE
 
);

这张表的价值不只是存 hash,而是为后续这些能力预留空间:

  • 错误次数限制
  • 临时锁定
  • 强制改密
  • 算法迁移

6. oauth_identities:第三方身份绑定表

GitHub、Google 这类第三方身份,必须单独建绑定表。

// index.sql
CREATE TABLE oauth_identities (
 
  id TEXT PRIMARY KEY,
 
  user_id TEXT NOT NULL,
 
  provider TEXT NOT NULL CHECK (provider IN ('github', 'google')),
 
  provider_subject TEXT NOT NULL,
 
  email_id TEXT,
 
  provider_username TEXT,
 
  provider_email TEXT,
 
  profile_snapshot TEXT,
 
  linked_at_ms INTEGER NOT NULL,
 
  last_used_at_ms INTEGER,
 
  unlinked_at_ms INTEGER,
 
  created_at_ms INTEGER NOT NULL,
 
  updated_at_ms INTEGER NOT NULL,
 
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
 
  FOREIGN KEY (email_id) REFERENCES user_emails(id) ON DELETE SET NULL
 
);

索引重点在这里:

// index.sql
CREATE UNIQUE INDEX idx_oauth_identities_provider_subject_unique
 
ON oauth_identities(provider, provider_subject);

不要用第三方邮箱作为唯一身份标识,真正稳定的是 (provider, provider_subject)

7. applications 和 application_auth_methods

既然 admin 和 web 的登录方式不同,就需要把“子站定义”和“子站允许的登录方式”拆出来。

applications

// index.sql
CREATE TABLE applications (
 
  id TEXT PRIMARY KEY,
 
  code TEXT NOT NULL,
 
  name TEXT NOT NULL,
 
  status TEXT NOT NULL CHECK (status IN ('active', 'disabled')),
 
  created_at_ms INTEGER NOT NULL
 
);

application_auth_methods

// index.sql
CREATE TABLE application_auth_methods (
 
  id TEXT PRIMARY KEY,
 
  application_id TEXT NOT NULL,
 
  provider TEXT NOT NULL CHECK (provider IN ('password', 'github', 'google')),
 
  enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
 
  created_at_ms INTEGER NOT NULL,
 
  updated_at_ms INTEGER NOT NULL,
 
  FOREIGN KEY (application_id) REFERENCES applications(id) ON DELETE CASCADE
 
);

这两张表立住以后,登录方式就是“子站策略”,不再是硬编码在业务里。

8. roles 和 user_role_bindings

登录成功不等于有权限,尤其是 admin。

roles

// index.sql
CREATE TABLE roles (
 
  id TEXT PRIMARY KEY,
 
  application_id TEXT NOT NULL,
 
  code TEXT NOT NULL,
 
  name TEXT NOT NULL,
 
  created_at_ms INTEGER NOT NULL,
 
  FOREIGN KEY (application_id) REFERENCES applications(id) ON DELETE CASCADE
 
);

user_role_bindings

// index.sql
CREATE TABLE user_role_bindings (
 
  id TEXT PRIMARY KEY,
 
  user_id TEXT NOT NULL,
 
  role_id TEXT NOT NULL,
 
  status TEXT NOT NULL CHECK (status IN ('active', 'revoked')),
 
  granted_at_ms INTEGER NOT NULL,
 
  revoked_at_ms INTEGER,
 
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
 
  FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
 
);

这一层的作用是:把“这个用户是谁”和“这个用户在当前子站拥有怎样的权限”分开。

9. auth_sessions 和 refresh_tokens

这两张表是 JWT 方案能不能真正落地的关键。

auth_sessions

// index.sql
CREATE TABLE auth_sessions (
 
  id TEXT PRIMARY KEY,
 
  user_id TEXT NOT NULL,
 
  application_id TEXT NOT NULL,
 
  session_type TEXT NOT NULL CHECK (session_type IN ('web', 'admin')),
 
  device_name TEXT,
 
  user_agent TEXT,
 
  ip TEXT,
 
  last_seen_at_ms INTEGER,
 
  created_at_ms INTEGER NOT NULL,
 
  expires_at_ms INTEGER NOT NULL,
 
  revoked_at_ms INTEGER,
 
  revoke_reason TEXT,
 
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
 
  FOREIGN KEY (application_id) REFERENCES applications(id) ON DELETE CASCADE
 
);

refresh_tokens

// index.sql
CREATE TABLE refresh_tokens (
 
  id TEXT PRIMARY KEY,
 
  session_id TEXT NOT NULL,
 
  jti_hash TEXT NOT NULL,
 
  parent_token_id TEXT,
 
  issued_at_ms INTEGER NOT NULL,
 
  expires_at_ms INTEGER NOT NULL,
 
  used_at_ms INTEGER,
 
  revoked_at_ms INTEGER,
 
  replaced_by_token_id TEXT,
 
  FOREIGN KEY (session_id) REFERENCES auth_sessions(id) ON DELETE CASCADE,
 
  FOREIGN KEY (parent_token_id) REFERENCES refresh_tokens(id) ON DELETE SET NULL,
 
  FOREIGN KEY (replaced_by_token_id) REFERENCES refresh_tokens(id) ON DELETE SET NULL
 
);

access token 不落库,但会话和 refresh token 必须落库。否则:

  • 无法做注销
  • 无法做单设备下线
  • 无法做 token rotation
  • 无法做 replay 检测

10. 辅助表和迁移顺序

后续还会有两类辅助能力:

  • 邮箱验证
  • 密码重置

它们可以继续拆成独立 token 表,例如:

  • email_verification_tokens
  • password_reset_tokens
  • 二期再补 oauth_tokens

迁移顺序也建议分批做。

第一批先上核心表:

  1. users
  2. user_emails
  3. password_credentials
  4. oauth_identities
  5. applications
  6. application_auth_methods
  7. roles
  8. user_role_bindings
  9. auth_sessions
  10. refresh_tokens

第二批再补辅助表:

  1. email_verification_tokens
  2. password_reset_tokens
  3. oauth_tokens

11. 初始化数据怎么准备

数据库建完还不够,初始化数据也要跟着落。

首先是 applications,至少插入两条:

  • web
  • admin

然后是 application_auth_methods

admin:

  • password = 1
  • github = 0
  • google = 0

web:

  • password = 1
  • github = 1
  • google = 0

最后是 roles,至少初始化:

admin:

  • admin_owner
  • admin_operator

web:

  • web_user

这些初始化数据决定了后面每条登录链路的边界。

12. 这一篇的落点是什么

这篇最重要的不是让你背下每一张表,而是理解这套 D1 模型的拆分原则:

  • 用户主体独立
  • 登录方式独立
  • 子站策略独立
  • 角色权限独立
  • 会话与 refresh token 独立

只要这几个边界立住,后面再扩 GitHub、Google、邮箱验证、重置密码、设备会话管理时,数据库结构都不会乱。

下一篇会继续往下,把 JWT、refresh token、OAuth 绑定、Durable Objects 的适用时机、接口设计和最终落地顺序系统串起来。