本文件回答:谁能访问什么、鉴权分几层、会话 / 授信设备 / API Token 各自的规则、授权中间件做什么。 依赖:data-model.md
Haven 的鉴权分三个独立维度,共同决定一次请求能否通过:
visibility 和 user_id 共同决定。三个维度正交,授权中间件依次判断,任一环节失败即拒绝。
登录分两步。第一步校验账密,第二步校验 TOTP。只有两步都通过(或该用户未启用 TOTP、或授信设备有效)才下发正式会话。
username + password;pending_logins 记录,返回临时 token,要求进入第二步。temp_token + code;token_hash 查 pending_logins,校验未过期、属于该用户;users.totp_secret 是否正确;pending_logins 记录,创建正式会话,下发会话 cookie;若第一步请求了 trust_device,同时创建授信设备;pending_logins 记录保留至过期(5 分钟),期间可重试。users.totp_enabled = false 时,登录只走第一步,不要求 TOTP。首次登录即此状态,用户在设置页绑定 TOTP 后才启用。
关键点:
pending_logins 记录不是会话,授权中间件不认,只能用于第二步;用途:维护“已登录”状态,是最短命的凭证。
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
id |
uuid | PK | |
user_id |
uuid | NOT NULL, FK → users(id) ON DELETE CASCADE | |
token_hash |
text | NOT NULL, UNIQUE | 会话 token 的 SHA-256 哈希,明文只存在于 cookie |
expires_at |
timestamptz | NOT NULL | 过期时间,默认创建后 7 天 |
last_used_at |
timestamptz | NOT NULL | 最后使用时间,每次请求更新 |
user_agent |
text | NOT NULL DEFAULT '' | 创建会话时的 UA,仅用于设置页展示 |
created_at |
timestamptz | NOT NULL |
规则:
HttpOnly、Secure(HTTPS 下)、SameSite=Lax、Domain=app.example.com。生产环境若为 HTTP(自用局域网),Secure 可配为 false,但要在 architecture.md 里注明风险。Domain 固定为私有域(app.example.com),不设 .example.com。公开域(example.com)收不到任何 cookie,公开域的请求天然无凭证,减少 CSRF 面。详见 frontend.md 第二节。last_used_at,但不延长 expires_at。pending_logins。授权中间件查询会话时,本表所有记录均视为有效会话(仍要校验 expires_at)。用途:暂存“账密已通过、TOTP 待验证”的登录请求。生命周期极短,验证完成或过期即删除。
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
id |
uuid | PK | |
user_id |
uuid | NOT NULL, FK → users(id) ON DELETE CASCADE | |
token_hash |
text | NOT NULL, UNIQUE | 临时 token 的 SHA-256 哈希 |
expires_at |
timestamptz | NOT NULL | 创建后 5 分钟 |
created_at |
timestamptz | NOT NULL |
规则:
pending_logins。它只能用于 POST /api/auth/login/totp。用途:标记“这台浏览器可信”,用于跳过 TOTP。不代表登录态,不代表免密。
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
id |
uuid | PK | |
user_id |
uuid | NOT NULL, FK → users(id) ON DELETE CASCADE | |
token_hash |
text | NOT NULL, UNIQUE | 设备 token 的 SHA-256 哈希 |
device_name |
text | NOT NULL DEFAULT '' | 用户可命名,如“家里台式机” |
expires_at |
timestamptz | NOT NULL | 默认 30 天 |
last_used_at |
timestamptz | NOT NULL | |
created_at |
timestamptz | NOT NULL |
规则:
haven_trusted_device,Domain 与会话 cookie 一致(app.example.com)。用途:供 TUI / Neovim / 脚本长期使用的凭证,与登录会话完全解耦。
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
id |
uuid | PK | |
user_id |
uuid | NOT NULL, FK → users(id) ON DELETE CASCADE | |
name |
text | NOT NULL | 用户命名,如“TUI @ 家里”“Neovim @ 公司” |
token_hash |
text | NOT NULL, UNIQUE | Token 的 SHA-256 哈希 |
token_prefix |
text | NOT NULL | 明文前 8 位,用于设置页展示与识别,不用于鉴权 |
scope |
text | NOT NULL, CHECK IN ('read','read_write') | 权限档位 |
expires_at |
timestamptz | NULL | NULL 表示永不过期 |
last_used_at |
timestamptz | NULL | 从未使用时为 NULL |
revoked_at |
timestamptz | NULL | 非 NULL 表示已撤销 |
created_at |
timestamptz | NOT NULL |
规则:
token_prefix。这是行业惯例,防止数据库泄露后 Token 被直接使用。haven_<scope>_<32字节随机串base64url>,例如 haven_ro_AbCd...。前缀便于用户和日志识别,但鉴权时不解析前缀,只查哈希。read:只能读,不能写;read_write:读写都可以。
没有更细的权限,也不区分“只能读某类资源”。revoked_at,不是删除记录。保留记录便于审计追溯。已撤销的 Token 鉴权时直接拒绝。name、token_prefix、scope、last_used_at、created_at,并提供撤销按钮。所有 API 请求(除了公开接口白名单)必须经过授权中间件。中间件按以下顺序执行:
身份识别只认 sessions 表与 tokens 表。pending_logins 记录不参与身份识别。
Authorization: Bearer <token>:
tokens 表,按 token_hash 匹配;revoked_at IS NULL)、未过期(expires_at IS NULL OR expires_at > now());TokenUser{user_id, scope};sessions 表,按 token_hash 匹配;SessionUser{user_id},更新 last_used_at;Anonymous。优先级:Authorization header 优先于 cookie。两者同时存在时以 header 为准,便于脚本调用时不受浏览器 cookie 干扰。
根据接口要求,中间件拒绝或放行:
anonymous_ok:任何人可访问,但仍需计算可见性过滤(见 6.3);login_required:必须是 SessionUser 或 TokenUser,Anonymous 返回 401;write:必须是 SessionUser,或 scope = read_write 的 TokenUser。scope = read 的 TokenUser 返回 403。totp_verified:仅 SessionUser 且会话创建时通过过 TOTP 才满足。本期除“关闭 TOTP”“修改密码”“创建 Token”这几个高危操作外,不强制要求。中间件计算出当前请求的数据可见性条件,注入 context,供 handler 使用:
Anonymous:WHERE visibility = 'public' AND published_at IS NOT NULL;SessionUser / TokenUser:WHERE (user_id = current_user_id) OR (visibility = 'public' AND published_at IS NOT NULL)。所有读接口必须使用这个条件,不得自己拼 visibility 判断。 这是 conventions.md 第四节那条禁令的具体落点。
在 6.3 基础上,所有写操作与私有读操作必须额外加 user_id = current_user_id。跨用户访问返回 404,不返回 403(不泄露资源存在性)。
| 操作 | Anonymous | Token (read) | Token (read_write) | SessionUser |
|---|---|---|---|---|
| 浏览公开笔记列表 | ✅ | ✅ | ✅ | ✅ |
| 读公开笔记详情 | ✅ | ✅ | ✅ | ✅ |
| 读私有笔记 | ❌ 401 | ✅(自己的) | ✅(自己的) | ✅(自己的) |
| 创建 / 编辑 / 删除笔记 | ❌ | ❌ 403 | ✅ | ✅ |
| publish / 撤回笔记 | ❌ | ❌ 403 | ✅ | ✅ |
| 搜索笔记与标签 | ✅(仅公开结果) | ✅(自己 + 公开) | ✅(自己 + 公开) | ✅(自己 + 公开) |
| 创建 / 删除标签 | ❌ | ❌ 403 | ✅ | ✅ |
| 查看作品集 | ✅ | ✅ | ✅ | ✅ |
| 管理作品集 | ❌ | ❌ 403 | ✅ | ✅ |
| 创建 / 撤销 Token | ❌ | ❌ 403 | ❌ 403 | ✅(需 TOTP) |
| 查看 / 撤销授信设备 | ❌ | ❌ 403 | ❌ 403 | ✅ |
| 查看 / 撤销会话 | ❌ | ❌ 403 | ❌ 403 | ✅ |
| 绑定 / 关闭 TOTP | ❌ | ❌ 403 | ❌ 403 | ✅(需原 TOTP) |
| 修改密码 | ❌ | ❌ 403 | ❌ 403 | ✅(需原密码 + TOTP) |
说明:Token(包括 read_write)不能管理凭证类资源(Token / 授信设备 / 会话 / TOTP / 密码)。理由:Token 本身是凭证,如果它又能创建新 Token,泄露一次就等于永久后门。凭证管理只允许通过 Web 会话操作。
users.password_hash text NOT NULL。users.totp_secret text NULL(未绑定时为 NULL)。修改密码成功后,在同一个事务内:
sessions 记录;trusted_devices 记录;tokens 记录(见第五节规则 8);audit.md)。users 表的完整字段如下,data-model.md 第九节待补部分以此为准:
| 字段 | 类型 | 约束 | 默认值 | 说明 |
|---|---|---|---|---|
id |
uuid | PRIMARY KEY | gen_random_uuid() |
|
username |
text | NOT NULL, UNIQUE | — | 登录名,本期只有一条记录 |
password_hash |
text | NOT NULL | — | bcrypt 哈希 |
totp_secret |
text | NULL | NULL | TOTP 密钥,未绑定为 NULL |
totp_enabled |
boolean | NOT NULL | false |
是否已启用 TOTP |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
规则:
users 记录,由部署时的初始化脚本创建(见 architecture.md)。username 不区分大小写唯一(用 lower(username) 唯一索引),避免 Admin / admin 混淆。