# 鉴权说明

本文档说明当前已落地的鉴权基座（基础 session 鉴权 + M4 登录加固）。

## 当前 auth 模型

- 认证方式：`username/password`（可选启用 TOTP 二次验证）
- 会话方式：server-side session
- 客户端凭据：session cookie

## 持久化

所有 auth 相关数据存放在单一 App DB（`APP_DATABASE_URL`，默认 `sqlite:///./data/app.db`）中：

- `auth_users`：用户表（含 TOTP 字段 `totp_secret` / `totp_enabled`）
- `auth_sessions`：session token 哈希与过期时间
- `auth_login_throttle`：登录失败退避状态（按 IP / username 双键）
- `auth_recovery_code`：TOTP 恢复码哈希（一次性）
- `app_config`：runtime 配置持久化

## 首次启动与 bootstrap

如果 auth DB 中还没有任何用户，应用启动时会使用：

- `AUTH_BOOTSTRAP_USERNAME`
- `AUTH_BOOTSTRAP_PASSWORD`

创建首个 admin 用户。当前默认 bootstrap 值为：

- username: `admin`
- password: `admin`

首次登录后，系统会强制要求修改密码。

## 基础安全设计

当前这版已经落实的安全点：

- 密码不明文存储，使用 Argon2 哈希
- session cookie 为 `HttpOnly`
- cookie 使用 `SameSite=Lax`
- `Secure` cookie 在非 `development` 环境默认开启
- 写请求（POST/PUT/PATCH/DELETE）需携带 `X-CSRF-Token` header（SameSite=Lax + 自定义 header 纵深防御，无需 per-session token 值比对）
- session token 为随机生成，服务端只持久化 token hash
- session 有过期时间（默认 12 小时）与显式失效机制

---

## M4 登录加固

M4 在基础鉴权之上叠加了三层防御：防爆破/指数退避、CLI 逃生通道、可选 TOTP 二次验证。

### 1. 防爆破 / 指数退避

#### 机制

登录失败按指数增长延迟，目标是拖垮暴力枚举，同时不因此永久锁定账号。

**退避是延迟（429 + Retry-After），不是永久封号**——单 admin 场景下永久锁会被攻击者反向用来故意打锁，所以退避只增加等待时间，CLI 是最终逃生口。

#### 双键计算

每次登录请求同时按 **client IP** 和 **username** 各记一套失败计数，本次需等待时间 = 两者退避的**较大值**。

- 按 IP：堵单点暴力（攻击 IP 越敲越慢，合法用户换 IP 不受影响）
- 按 username：全局兜底（即便攻击者分布式换 IP 也有一层保护）

#### 指数公式

```
N_FREE  = 3       — 前 3 次失败不延迟（免费次数）
BASE    = 1  秒
CAP     = 900 秒（15 分钟）

wait = min(CAP, BASE × 2^(failures - N_FREE))   （failures > N_FREE 时生效）
```

延迟示意：

| 累计失败次数 | 等待时间 |
| --- | --- |
| 1–3 | 0 秒（免费） |
| 4 | 2 秒 |
| 5 | 4 秒 |
| 6 | 8 秒 |
| 7 | 16 秒 |
| … | … |
| 13+ | 900 秒（封顶） |

成功登录后，该 IP 和 username 的退避状态**立即清零**。

#### 登录端点行为（`POST /api/auth/login`）

1. 先查退避（在窗口内直接 `429`，**不验密码**，节省 Argon2 计算并防止枚举）
2. 验密码失败 → 记一次失败（IP + username 各记），返回 `401`
3. 密码正确但 TOTP 启用且缺少 `totp_code` → 返回 `401 {totp_required: true}`，**不记失败**（这是正常两步流程的第一步，不是攻击信号）
4. 密码正确但 TOTP 验证失败 → 记一次失败，返回 `401`
5. 全部通过 → 清零退避状态，发 session cookie

#### 配置项

| 配置项 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `AUTH_LOGIN_THROTTLE_ENABLED` | bool（CONFIG_FIELDS） | `true` | 全局开关，关闭后整个退避机制 no-op |
| `AUTH_TRUST_FORWARDED_FOR` | bool（`.env` 部署级） | `false` | `true` 时取 `X-Forwarded-For` 最左作为 client IP；**反代后必须显式开启**，否则反代 IP 全视为同一 IP，按 IP 退避失效 |

> **反代部署注意**：`AUTH_TRUST_FORWARDED_FOR` 默认 `false`（直接用 socket IP）。部署在 nginx 等反代后面时，应设 `AUTH_TRUST_FORWARDED_FOR=true`，并确保反代正确设置 `X-Forwarded-For`。

---

### 2. CLI 逃生通道

#### 设计原则

CLI 通过 **直连本地 DB**（`get_session_local()`）工作，**无需 HTTP 服务器运行、无需任何已存凭据**（密码、恢复码均不需要）。拿到服务器 CLI 权限本身就意味着对系统有完全控制，因此这是可接受且必须存在的最终逃生口。

CLI 只动 auth 相关行（`auth_users` 的密码/TOTP 字段、`auth_login_throttle`、`auth_recovery_code`），**绝不**触碰用户数据表（`location`、`poo_records`、`public_ip_state` 等）。

入口：`python -m scripts.admin_cli <command>`

#### 子命令

| 命令 | 用途 | 逃生场景 |
| --- | --- | --- |
| `reset-password <username> [--password <pwd>]` | 重置密码（不带 `--password` 则交互式输入，不回显） | 忘记密码 |
| `unlock [--all \| --ip <ip> \| --username <u>]` | 清 `auth_login_throttle` 行 | 被退避锁住（429）时解锁 |
| `disable-totp <username>` | 关停 TOTP（清 secret + 删全部恢复码），**零凭据可执行** | 恢复码全丢也能进 |
| `reissue-totp <username>` | 生成新 TOTP secret 并打印 `otpauth://` URI | 设备丢失、需重新注册 Authenticator |
| `list-admin` | 列出所有用户与状态列 | 排障用 |

#### 使用示例

```bash
# 重置密码（不带 --password 时交互式 prompt，不回显）
python -m scripts.admin_cli reset-password admin

# 重置密码（非交互，脚本里用）
python -m scripts.admin_cli reset-password admin --password "newpassword"

# 解锁所有退避
python -m scripts.admin_cli unlock --all

# 解锁特定 IP
python -m scripts.admin_cli unlock --ip 1.2.3.4

# 解锁特定 username
python -m scripts.admin_cli unlock --username admin

# 关停 TOTP（零凭据，最终逃生口）
python -m scripts.admin_cli disable-totp admin

# 重新发放 TOTP secret（打印新 otpauth:// URI，扫码重新注册）
python -m scripts.admin_cli reissue-totp admin

# 查看用户列表
python -m scripts.admin_cli list-admin
```

在 Docker 容器内执行：

```bash
docker compose exec app python -m scripts.admin_cli <command>
```

#### `reissue-totp` 语义说明

- 对**已启用** TOTP 的用户：新 secret **立即在登录时生效**，旧 Authenticator 生成的码立即失效；**无需**再走 web `enable` 步骤。
- 现有恢复码**不被删除**——恢复码是独立的随机哈希值，与 TOTP secret 无密码学绑定，reissue 后恢复码依然有效（仍可用于登录）。
- 如果需要完整清理（secret + 所有恢复码），使用 `disable-totp`。

---

### 3. 可选 TOTP 二次验证

#### 设计

- TOTP 遵循 RFC 6238，使用 `pyotp` 库。
- 二维码在**前端**由 `qrcode.react` 渲染（后端只返回 `otpauth://` URI，不引图像依赖）。
- `totp_secret` 明文存库（与项目其他 secret 处理一致，靠数据库文件权限保护）。
- 恢复码以 Argon2 哈希存库，一次性（使用后标记 `used_at`）。

#### 启用流程

1. **setup**（`POST /api/auth/totp/setup`）：生成 pending secret，返回 secret、`otpauth://` URI、10 个明文恢复码（**仅此一次**）；此时 `totp_enabled` 仍为 `false`。
2. **扫码**：用 Authenticator App 扫前端渲染的二维码（或手动输入 secret）。
3. **enable**（`POST /api/auth/totp/enable`）：输入当前 6 位码确认 → `totp_enabled` 变为 `true`。
4. 妥善保存恢复码（不会再次展示）。

#### 停用流程

**web 停用**（`POST /api/auth/totp/disable`）：需提供当前密码或当前 6 位 TOTP 码。成功后清 secret、删全部恢复码，恢复纯密码登录。

**CLI 停用**（逃生口，恢复码全丢时）：`python -m scripts.admin_cli disable-totp admin`，零凭据，立即生效。

#### 登录二步流程

1. 提交 `POST /api/auth/login {username, password}` → 密码正确但 TOTP 已启用 → `401 {totp_required: true}`（不发 session）
2. 前端切到第二屏，提交 `POST /api/auth/login {username, password, totp_code}` → 通过 → 发 session cookie

`totp_code` 可以是 6 位 TOTP 动态码，也可以是 `xxxx-xxxx` 格式恢复码（命中即消费，不可复用）。

#### API 端点

| 端点 | 用途 |
| --- | --- |
| `POST /api/auth/totp/setup` | 生成 pending secret + URI + 恢复码（一次性明文返回） |
| `POST /api/auth/totp/enable` | 带当前 6 位码确认启用 |
| `POST /api/auth/totp/disable` | 带密码或当前码停用 |
| `GET /api/auth/totp` | 返回当前 TOTP 状态（`{enabled: bool}`），不返回 secret/恢复码 |

全部端点需要 session cookie（`GET /api/auth/totp` 不需 CSRF；其余写端点需 `X-CSRF-Token`）。

#### 配置项

| 配置项 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `AUTH_TOTP_ISSUER` | str（`.env` 部署级） | 空（回退 `app_name`） | 显示在 Authenticator App 里的 issuer 标签 |

---

## 受保护范围

当前 JSON API 端点（`/api/*`）需要 session cookie；写端点需额外携带 `X-CSRF-Token` header。

裸 ingestion 端点（`/location/record`、`/poo/record` 等设备调用端点）暂未收口到 session 保护（M3 计划引入 token 鉴权）。

## 下一步（不在当前范围）

- M3：token 鉴权（供脚本 / 设备 / 移动端调用 API），ingestion 端点收口。
