Ver Fonte

Add authentication and authorization model documentation

This document outlines the authentication and authorization model for the Haven application, detailing login processes, session management, trusted devices, API tokens, and middleware for authorization checks.
Flint há 1 dia atrás
pai
commit
791a117249
1 ficheiros alterados com 221 adições e 0 exclusões
  1. 221 0
      docs/backend/auth.md

+ 221 - 0
docs/backend/auth.md

@@ -0,0 +1,221 @@
+# auth — 鉴权与权限模型
+
+> 本文件回答:谁能访问什么、鉴权分几层、会话 / 授信设备 / API Token 各自的规则、授权中间件做什么。
+> 依赖:data-model.md
+
+## 一、模型概述
+
+Haven 的鉴权分三个独立维度,共同决定一次请求能否通过:
+
+1. **身份**:你是谁。三种可能——未登录、登录用户(通过会话)、API Token 用户。
+2. **凭证层级**:登录用户额外区分是否通过 TOTP、是否来自授信设备。
+3. **资源可见性**:你能看什么。由笔记的 `visibility` 和 `user_id` 共同决定。
+
+三个维度正交,授权中间件依次判断,任一环节失败即拒绝。
+
+## 二、登录流程
+
+### 2.1 普通登录(未授信设备)
+
+1. 用户提交 `username + password`;
+2. 服务端校验密码哈希;
+3. 若该用户已启用 TOTP,要求输入 6 位动态码,校验 TOTP;
+4. 校验通过,创建会话(写 `sessions` 表),下发会话 cookie;
+5. 若用户在登录时勾选“信任此设备”,额外创建一条 `trusted_devices` 记录,下发授信设备 cookie。
+
+### 2.2 授信设备登录(跳过 TOTP)
+
+1. 用户提交 `username + password`;
+2. 服务端校验密码哈希;
+3. 服务端检查请求携带的授信设备 cookie:存在、未过期、哈希匹配、属于该用户;
+4. 匹配成功则跳过 TOTP,直接创建会话;
+5. 匹配失败则回退到普通登录流程(要求 TOTP)。
+
+**关键点**:授信设备**不免除密码**,只免除 TOTP。密码始终要输。授信设备 cookie 单独存放,与会话 cookie 独立。
+
+### 2.3 TOTP 未启用
+
+`users.totp_enabled = false` 时,登录只校验密码,不要求 TOTP。这是首次登录的默认状态——用户登录后在设置页绑定 TOTP,绑定成功后 `totp_enabled` 置为 true。
+
+## 三、会话(sessions)
+
+**用途**:维护“已登录”状态,是最短命的凭证。
+
+| 字段 | 类型 | 约束 | 说明 |
+|---|---|---|---|
+| `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 | |
+
+**规则**:
+
+1. **会话 token 为随机 32 字节**,base64url 编码后下发到 cookie。数据库只存哈希,明文不落库。
+2. **cookie 属性**:`HttpOnly`、`Secure`(HTTPS 下)、`SameSite=Lax`。生产环境若为 HTTP(自用局域网),`Secure` 可配为 false,但要在 `architecture.md` 里注明风险。
+3. **有效期 7 天**,不做滑动续期(避免会话无限延长)。过期后需重新登录。
+4. **每次请求更新 `last_used_at`**,但不延长 `expires_at`。
+5. **用户可在设置页列出自己的所有会话并单独撤销**——这是网吧环境离开后的兜底手段。
+6. **撤销会话 = 删除记录**,硬删除。
+
+## 四、授信设备(trusted_devices)
+
+**用途**:标记“这台浏览器可信”,用于跳过 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 | |
+
+**规则**:
+
+1. **授信设备 token 为随机 32 字节**,存哈希,明文只在 cookie 里。
+2. **cookie 独立于会话 cookie**,名如 `haven_trusted_device`。
+3. **有效期 30 天**,到期不自动续期。
+4. **不做设备指纹**:不用 UA + IP 做绑定校验,避免网吧环境不稳定导致误判。安全性靠“token 随机 + HttpOnly + 可撤销”,不靠指纹。
+5. **用户可在设置页列出所有授信设备并单独撤销**,撤销即删除记录。
+6. **密码修改时,所有授信设备与所有会话一并撤销**(强制重新登录 + 重新授信)。这是安全底线:密码变了的含义是“我怀疑旧凭证泄露”。
+
+## 五、API Token(tokens)
+
+**用途**:供 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 | |
+
+**规则**:
+
+1. **Token 明文只在创建时返回一次**,之后无法再查看,只能看到 `token_prefix`。这是行业惯例,防止数据库泄露后 Token 被直接使用。
+2. **Token 格式**:`haven_<scope>_<32字节随机串base64url>`,例如 `haven_ro_AbCd...`。前缀便于用户和日志识别,但鉴权时不解析前缀,只查哈希。
+3. **Token 存 SHA-256 哈希**,不存明文。
+4. **scope 两档**:
+   - `read`:只能读,不能写;
+   - `read_write`:读写都可以。
+   没有更细的权限,也不区分“只能读某类资源”。
+5. **永不过期是默认值**,但支持设置过期时间。自用场景建议 TUI / Neovim 用永不过期 + 手动撤销。
+6. **撤销 = 写 `revoked_at`**,不是删除记录。保留记录便于审计追溯。已撤销的 Token 鉴权时直接拒绝。
+7. **用户可在设置页列出所有 Token**,显示 `name`、`token_prefix`、`scope`、`last_used_at`、`created_at`,并提供撤销按钮。
+8. **密码修改不撤销 Token**。理由:Token 是独立凭证,可能挂在自动化脚本里,强制撤销会打断工作流。用户需手动撤销。这一条与“密码修改撤销会话 / 授信设备”不同,是刻意为之。
+
+## 六、授权中间件
+
+所有 API 请求(除了公开接口白名单)必须经过授权中间件。中间件按以下顺序执行:
+
+### 6.1 身份识别
+
+1. 若请求携带 `Authorization: Bearer <token>`:
+   - 查 `tokens` 表,按 `token_hash` 匹配;
+   - 校验未撤销(`revoked_at IS NULL`)、未过期(`expires_at IS NULL OR expires_at > now()`);
+   - 匹配成功 → 身份为 `TokenUser{user_id, scope}`;
+   - 匹配失败 → 返回 401。
+2. 否则若请求携带会话 cookie:
+   - 查 `sessions` 表,按 `token_hash` 匹配;
+   - 校验未过期;
+   - 匹配成功 → 身份为 `SessionUser{user_id}`,更新 `last_used_at`;
+   - 匹配失败 → 视为未登录,继续往下走。
+3. 否则 → 身份为 `Anonymous`。
+
+**优先级**:`Authorization` header 优先于 cookie。两者同时存在时以 header 为准,便于脚本调用时不受浏览器 cookie 干扰。
+
+### 6.2 身份与权限判定
+
+根据接口要求,中间件拒绝或放行:
+
+- 接口要求 `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”这几个高危操作外,不强制要求。
+
+### 6.3 可见性过滤条件
+
+中间件计算出当前请求的**数据可见性条件**,注入 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.4 归属过滤
+
+在 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 会话操作。
+
+## 八、密码与 TOTP 存储
+
+### 8.1 密码
+
+- 哈希算法:**bcrypt**,cost = 12。理由:Go 标准库生态成熟、实现简单、抗暴力破解足够;自用场景不需要 argon2id 的极致参数调优。
+- 字段:`users.password_hash text NOT NULL`。
+- 不存明文、不存可逆加密。
+
+### 8.2 TOTP secret
+
+- `users.totp_secret text NULL`(未绑定时为 NULL)。
+- 本期**明文存储**,依赖数据库本身的访问控制。理由:自用、单机部署,数据库访问只有本机;加密存储需要额外的密钥管理,收益低于成本。
+- **风险标注**:如果将来 Haven 部署到多租户或托管环境,必须改为加密存储(用 KMS 或应用层密钥)。这一条写在这里,防止以后忘记。
+
+### 8.3 密码修改后的级联
+
+修改密码成功后,在同一个事务内:
+
+1. 删除该用户所有 `sessions` 记录;
+2. 删除该用户所有 `trusted_devices` 记录;
+3. **不删除** `tokens` 记录(见第五节规则 8);
+4. 写一条审计事件(见 `audit.md`)。
+
+## 九、users 表字段(补充 data-model.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()` | |
+
+**规则**:
+
+1. 本期只有一条 `users` 记录,由部署时的初始化脚本创建(见 `architecture.md`)。
+2. **不提供注册接口**。第二个账号如需创建,只能通过命令行脚本或直接操作数据库,不在 API 层暴露。
+3. `username` 不区分大小写唯一(用 `lower(username)` 唯一索引),避免 `Admin` / `admin` 混淆。

苏ICP备2026082406号
苏公网安备 待审核