# M4 — 登录加固（Login Hardening）

> 阅读前提：先读 [`README.md`](./README.md)（协作模型、任务卡格式、校验闸门、数据安全红线）。
> **排期：本里程碑排在 [M5](./m5-iot-energy.md) 之前。** 系统现为公网 + 仅密码 + 无限重试，是现成风险；M4 与 M5 零文件重叠，先堵这个洞再做 IoT。

## 1. 目标

给暴露在公网的单 admin 登录做纵深防御：

1. **防爆破 / 指数退避**：失败登录按指数增长延迟，拖垮暴力枚举；成功即清零。
2. **CLI 逃生通道**：纯命令行重置密码 / 解锁 / 关停 TOTP，**不依赖任何已存恢复凭据**——拿到服务器 CLI 权限的人本来就无所不能，所以这是可接受、且必须存在的最终逃生口（消除"锁死就彻底进不去"的死角）。
3. **TOTP 二次验证（可选配）**：admin 可**自选启用** RFC 6238 TOTP；启用后登录需密码 + 6 位动态码；提供一次性恢复码；CLI 能在恢复码全丢时关掉 TOTP。**非强制**，不启用则维持纯密码登录。

> 三段顺序：A（防爆破，紧急、可独立先发版）→ B（TOTP，可选配）→ 收尾。Phase A 自成可用增量，Phase B 失败不影响 A。

## 2. 现状（实现者可据此工作，不必通读全仓库）

**单 admin + Argon2**
- `app/models/auth.py`：`AuthUser`（`id / username unique / password_hash / is_active / force_password_change / created_at`）、`AuthSession`（`token_hash / csrf_token / expires_at / revoked_at`）。单库 `Base`。
- `app/services/auth.py`：
  - `authenticate_user(session, *, username, password) -> AuthUser | None`：查用户→`verify_password`（Argon2）；失败只 `logger.info`，**无任何节流**。
  - `create_session(session, *, user, settings) -> (AuthSession, raw_token)`：`secrets.token_urlsafe`，SHA256 存 `token_hash`，TTL `auth_session_ttl_hours`（默认 12h）。
  - `change_password(...)`、`get_authenticated_session(...)`、`initialize_auth_schema(session, settings)`（**仅当无任何用户时**用 `AUTH_BOOTSTRAP_USERNAME/PASSWORD` 建初始 admin）。
- `app/api/routes/api/session.py`：
  - `POST /api/auth/login`（body `{username, password}`）：`authenticate_user` → None 则 401；否则 `create_session` + `set_cookie`(HttpOnly/SameSite=Lax/`auth_cookie_secure`) → 返回 `{user, csrf_token}`。
  - `GET /api/session`（401 或 user+csrf）、`POST /api/auth/logout`（CSRF）、`POST /api/auth/password`（CSRF + 校验当前密码）。

**配置**
- `app/config.py`：`auth_bootstrap_username/password`、`auth_session_cookie_name`、`auth_session_ttl_hours`、`auth_cookie_secure_override`（computed `auth_cookie_secure`）。
- 配置系统：扁平 KV（`CONFIG_FIELDS` 注册表 + `Settings` + `_settings_payload`），新增标量配置项前端自动渲染（见 M5 文档 §2）。

**无现成 CLI**
- `scripts/`：`run_migrations.py` / `app_db_adopt.py` / `migrate_legacy_data.py`(argparse) / `export_openapi.py`。**没有**任何 admin/密码/auth 的 CLI。
- `pyproject.toml`：**无 `console_scripts`**。CLI 入口沿用 `python -m scripts.<mod>` 风格（与 `python -m scripts.run_migrations` 一致）。

**前端登录（单步）**
- `frontend/src/pages/LoginPage.tsx`：`POST /api/auth/login {username,password}` → 401 显示通用错误；成功存 csrf、跳转。
- `frontend/src/auth/SessionProvider.tsx`：`GET /api/session` 引导。
- 无"锁定/稍后再试"提示、无二步流程。

**测试**
- `tests/test_api_session.py`（凭据/cookie/登出/改密/强制改密）；`tests/conftest.py` 用 env 设 bootstrap 凭据。

## 3. 目标架构

### 3.1 防爆破 / 指数退避

- **状态表 `auth_login_throttle`**：按 key 记失败窗口（`key / scope / failures / first_failed_at / last_failed_at / next_allowed_at`），成功即删该 key 的行。
- **双键**：同时按 **client IP** 与 **username** 计；本次请求需等待 = 两者退避的**较大值**。
  - 按 IP：堵单点暴力（attacker IP 越敲越慢，合法用户换 IP 不受影响）。
  - 按 username：单 admin 的全局兜底（即便分布式换 IP 也有一层）。
  - **DoS 权衡**：单 admin 下纯按 username 硬锁会被攻击者故意失败把你锁死，所以退避是**延迟（429 + Retry-After）而非永久锁定**，且 CLI 是最终逃生口。
- **client IP 来源**：默认用 socket IP；`AUTH_TRUST_FORWARDED_FOR=true` 时取 `X-Forwarded-For` 最左（部署在反代后才开）。**这点必须显式配置**，否则反代后所有请求同一 IP，按 IP 退避失效。
- **指数公式（默认，常量可后调）**：前 `N_free=3` 次不延迟；之后 `wait = min(cap, base * 2^(failures - N_free))`，`base=1s`、`cap=900s`。
- **接入点**：`POST /api/auth/login` 开头先查退避 → 在窗口内直接 429（带 `Retry-After`），**不验密码**（省 Argon2、防枚举）；验密码失败 → 记一次失败；成功 → 清该 IP + username 的退避行。
- **全局开关** `AUTH_LOGIN_THROTTLE_ENABLED`（CONFIG_FIELDS，默认 true）。

### 3.2 CLI 逃生通道

`scripts/admin_cli.py`（`python -m scripts.admin_cli <cmd>`，argparse 子命令；直接连本地 DB，无网络，复用 `get_session_local()` + 模型 + Argon2 hasher）：

| 命令 | 作用 |
| --- | --- |
| `reset-password <username> [--password ...]` | 重置密码（不给则交互式 prompt，不回显）；逃生：忘密码 |
| `unlock [--all | --ip <ip> | --username <u>]` | 清 `auth_login_throttle` 行；逃生：被退避锁住 |
| `disable-totp <username>` | 关 TOTP（清 secret + 恢复码）；逃生：**恢复码全丢也能进**（Phase B 才有意义）|
| `reissue-totp <username>` | 重新发放 TOTP secret（可选）|
| `list-admin` | 列用户/状态（排障）|

- **数据安全**：CLI 只动 auth 状态行（人工执行的 admin 动作），**绝不**触碰用户数据表（location/poo/energy 等），不 drop 有数据的表。

### 3.3 TOTP 二次验证（可选配）

- **库**：`pyotp`（Python 侧）；二维码在**前端**用 JS 库（`qrcode.react`）从 `otpauth://` URI 渲染——后端不引图像依赖。
- **存储**：
  - `AuthUser` 增列 `totp_secret`(nullable)、`totp_enabled`(bool, default false)。secret 落库明文（与现有 secret 处理一致，靠文件权限保护；文档注明）。
  - `auth_recovery_code` 表（`user_id / code_hash / used_at`）：恢复码**哈希**存（复用 Argon2 hasher），一次性。
- **启用流程（opt-in）**：
  1. `POST /api/auth/totp/setup`（已登录）→ 生成 pending secret + `otpauth://` URI + 一组恢复码（**仅此一次明文返回**），尚未启用。
  2. `POST /api/auth/totp/enable`（带一个当前 6 位码确认）→ `totp_enabled=true`，持久化恢复码哈希。
  3. `POST /api/auth/totp/disable`（带密码或当前码）→ 关闭、清 secret/恢复码。
- **登录二因子（单端点、无独立 challenge token，保持无状态）**：
  - `POST /api/auth/login` body 增可选 `totp_code`。
  - 密码通过且 `totp_enabled`：无 `totp_code` → 返回 401 + `{totp_required: true}`（**不发 session**）；有 `totp_code` → 校验 TOTP **或**恢复码（命中恢复码则消费它）→ 通过才发 session。
  - 未启用 TOTP：维持现状单步。
  - 退避（§3.1）对两步都生效。
- **issuer 标签**：`AUTH_TOTP_ISSUER`（默认 `app_name`），显示在 Authenticator app 里。

### 3.4 前端

- **登录页两步**：`LoginPage` 在收到 `401 + totp_required` 时切到第二屏（6 位码输入，也接受恢复码），再次 `POST /api/auth/login` 带 `totp_code`。被退避（429）显示"稍后再试"（用 `Retry-After`）。
- **设置页 TOTP 区**：未启用→「启用」走 setup（展示二维码 + 恢复码一次性、要求输入码确认）；已启用→「停用」。恢复码只在生成时展示一次，提示妥善保存。

## 4. API 契约（M4 要落地的端点）

> 全部 `/api`、写端点 session + CSRF 保护、JSON 进出；schema 经 `export_openapi.py` 固化。

| 分组 | 端点 | 用途 |
| --- | --- | --- |
| 登录 | `POST /api/auth/login` | 加退避（429+Retry-After）；body 增可选 `totp_code`；启用 TOTP 且缺码→401 `{totp_required:true}` |
| TOTP | `POST /api/auth/totp/setup` | 生成 pending secret + otpauth URI + 恢复码（一次性）|
| TOTP | `POST /api/auth/totp/enable` | 带当前码确认启用 |
| TOTP | `POST /api/auth/totp/disable` | 关闭 TOTP |
| TOTP | `GET /api/auth/totp` | 返回当前 TOTP 状态（enabled 与否）|

> CLI 不是 HTTP 端点。throttle 开关与 issuer 走现有 `GET/PUT /api/config`（仅加 CONFIG_FIELDS）。

## 5. 已锁定决策（讨论后拍板）

1. **M4 独立里程碑、排在 M5 之前**。
2. **范围**：防爆破 + 指数退避 + CLI 重置（密码/解锁/关 TOTP）+ TOTP（**可选配，非强制**）。
3. **退避双键（IP + username）、延迟非永久锁定、成功清零、CLI 兜底**；反代后需开 `AUTH_TRUST_FORWARDED_FOR`。
4. **CLI 逃生通道不依赖任何已存恢复凭据**；只动 auth 行，不碰用户数据。
5. **TOTP 单端点二因子**（login 加可选 `totp_code`，无独立 challenge token）；恢复码哈希存、一次性；secret 明文存（与现有一致）。
6. **二维码前端渲染**（`qrcode.react`），后端只给 `otpauth://` URI + secret；Python 仅加 `pyotp`。
7. **CLI 入口 = `python -m scripts.admin_cli`**（无 console_scripts）。

> 项目定位：个人自用、单 admin——可按单用户简化，不为多租户/找回邮件等过度设计。

## 6. 任务依赖图

```
Phase A（防爆破，紧急，自成可发版增量）
  M4-T01 [schema] auth_login_throttle 表 + 模型
   └─► M4-T02 退避 service + 接入 login（429/Retry-After/成功清零）
   └─► M4-T03 CLI 骨架 + reset-password + unlock

Phase B（TOTP，可选配）
  M4-T04 [schema] AuthUser TOTP 字段 + auth_recovery_code 表
   ├─► M4-T05 TOTP service + setup/enable/disable/status API
   ├─► M4-T06 登录二因子（login 加 totp_code；依赖 T02 已改过 login）
   └─► M4-T07 CLI disable-totp / reissue-totp
        M4-T08 前端：两步登录 + 设置页 TOTP（依赖 T05+T06；引入 qrcode.react）

收尾
  M4-T09 文档 + OpenAPI + roadmap（依赖全部）
```

`T01` 可先开。Phase A（T01–T03）跑完即可发一版只含防爆破 + CLI 的安全增量。

---

## 7. 原子任务（任务卡）

> 后端沿用校验闸门（`pytest`/`ruff`/改路由或 schema 则 `export_openapi` 重导出入库）。前端闸门见 §8。新增依赖（`pyotp`、`qrcode.react`）须同步 `requirements.in/.txt` 与 `frontend/package.json` 重新锁定。

### M4-T01 — `auth_login_throttle` 表 + 模型 `[schema]`
- **Status**: `done` · **Depends**: none
- **Context**: 存按 IP / username 的失败退避状态。仅建 schema。
- **Files**: `create app/models/auth_throttle.py`（或并入 `app/models/auth.py`）；`create alembic_app/versions/<date>_NN_auth_login_throttle.py`；`modify alembic_app/env.py`、`scripts/app_db_adopt.py`（baseline 常量）；`create tests/test_auth_throttle_model.py`
- **Steps**: 模型 `LoginThrottle`（`id / key str / scope str('ip'|'user') / failures int / first_failed_at / last_failed_at / next_allowed_at`，`(scope,key)` unique 索引）；新 revision 建表 + 索引；更新 baseline 常量。
- **Out of scope / 不要碰**: 不写退避逻辑（T02）；不动登录端点。
- **Acceptance criteria**:
  - [ ] 临时库 upgrade 到 head 含该表与唯一索引；`downgrade -1` 干净。
  - [ ] `APP_BASELINE_REVISION` 更新；`env.py` 已 import。
  - [ ] 校验闸门全绿。
- **Reviewer checklist**: `(scope,key)` 唯一；链上单 head；env.py 注册模型。

### M4-T02 — 退避 service + 接入登录
- **Status**: `done` · **Depends**: M4-T01
- **Context**: 指数退避核心 + 接 `POST /api/auth/login`。
- **Files**: `create app/services/login_throttle.py`；`modify app/api/routes/api/session.py`、`app/services/config_page.py`(+`CONFIG_FIELDS` `AUTH_LOGIN_THROTTLE_ENABLED`)、`app/config.py`(+`auth_login_throttle_enabled`、`auth_trust_forwarded_for`)；`modify tests/test_api_session.py`、`create tests/test_login_throttle.py`
- **Steps**:
  1. `check_and_get_wait(session, *, ip, username) -> int`（秒；>0 表示仍在窗口）；`register_failure(...)`（更新双键 failures/next_allowed_at，按 §3.1 公式）；`clear(session, *, ip, username)`（成功清零）。
  2. 登录端点：取 client IP（按 `auth_trust_forwarded_for` 决定是否信 XFF 最左）；**先**查退避，>0 → 429 + `Retry-After`，不验密码；验密码失败 → `register_failure` 后 401；成功 → `clear` 再发 session。
  3. 开关关闭时整段 no-op。
- **Out of scope / 不要碰**: 不做 TOTP（Phase B）；不改 logout/password 端点。
- **Acceptance criteria**:
  - [ ] 单测：连续失败后 `wait` 按指数增长且封顶；窗口内请求 429 带 `Retry-After` 且不验密码。
  - [ ] 单测：成功登录清零；下次无延迟。
  - [ ] 401 仍为通用文案（不泄露用户是否存在）；现有登录测试仍绿。
  - [ ] 开关 false 时无节流。
  - [ ] 校验闸门全绿（OpenAPI 若变重导出）。
- **Reviewer checklist**: 退避在验密码**之前**生效；双键较大值；XFF 仅在配置开启时信任；无把密码写进日志。

### M4-T03 — CLI 骨架 + reset-password + unlock
- **Status**: `done` · **Depends**: M4-T01
- **Context**: 逃生通道第一批命令。
- **Files**: `create scripts/admin_cli.py`；`create tests/test_admin_cli.py`
- **Steps**: argparse 子命令；`reset-password <username> [--password|prompt(getpass)]` 用 Argon2 hasher 重置 `password_hash`；`unlock [--all|--ip|--username]` 删 `auth_login_throttle` 行；`list-admin`；复用 `get_session_local()` + 模型；无网络。
- **Out of scope / 不要碰**: 不动 HTTP 端点；不碰用户数据表；TOTP 命令在 T07。
- **Acceptance criteria**:
  - [ ] 单测：`reset-password` 后新密码可 `verify_password` 通过、旧密码失败。
  - [ ] 单测：`unlock` 清掉指定/全部退避行。
  - [ ] 不存在的 user 友好报错、非零退出。
  - [ ] `grep` 确认 CLI 不含对用户数据表的删除/drop。
  - [ ] 校验闸门全绿。
- **Reviewer checklist**: 密码可交互输入且不回显；只动 auth 行；幂等可重跑。

### M4-T04 — AuthUser TOTP 字段 + `auth_recovery_code` 表 `[schema]`
- **Status**: `done` · **Depends**: none（与 Phase A 并行的 schema，但 T06 依赖 T02）
- **Files**: `modify app/models/auth.py`（`AuthUser.totp_secret`/`totp_enabled`）；`create` 模型 `RecoveryCode`；`create alembic_app/versions/<date>_NN_totp.py`；`modify alembic_app/env.py`、`scripts/app_db_adopt.py`；`create tests/test_totp_models.py`
- **Steps**: `totp_secret` nullable、`totp_enabled` bool default false；`auth_recovery_code(user_id FK, code_hash, used_at nullable)`；revision 建列 + 表；更新 baseline。
- **Out of scope / 不要碰**: 不写 TOTP 逻辑（T05）。
- **Acceptance criteria**:
  - [ ] upgrade/downgrade 干净；默认 `totp_enabled=false`、`totp_secret=null`。
  - [ ] baseline 常量更新；env.py import。
  - [ ] 校验闸门全绿。
- **Reviewer checklist**: 现有用户迁移后默认未启用 TOTP（不破坏现有登录）。

### M4-T05 — TOTP service + setup/enable/disable/status API
- **Status**: `done` · **Depends**: M4-T04
- **Files**: `create app/services/totp.py`、`app/schemas/totp.py`；`modify app/api/routes/api/session.py`（或新 `auth_totp.py` 路由）、`app/config.py`(+`auth_totp_issuer`)；`modify requirements.in/.txt`(+`pyotp`)；`create tests/test_api_totp.py`
- **Steps**: pyotp 生成 secret + `otpauth://` URI（issuer=`auth_totp_issuer`）；恢复码生成（N=10、`xxxx-xxxx`，Argon2 哈希存、明文仅 setup 返回一次）；`setup`（pending，不启用）/`enable`（带当前码确认）/`disable`（带密码或当前码）/`GET status`；全部 session+CSRF。
- **Out of scope / 不要碰**: 不改 login 校验（T06）。
- **Acceptance criteria**:
  - [ ] `setup` 返回 secret+URI+恢复码（一次性）；未确认前 `totp_enabled` 仍 false。
  - [ ] `enable` 用错码失败、对码成功；恢复码以哈希持久化。
  - [ ] `disable` 关闭并清 secret/恢复码。
  - [ ] schema 经 OpenAPI 固化；secret/恢复码不回显于 `GET status`。
  - [ ] 校验闸门全绿。
- **Reviewer checklist**: 恢复码只存哈希、明文仅一次；secret 不进 OpenAPI 示例/日志；`requirements.txt` 锁定 `pyotp`。

### M4-T06 — 登录二因子（login 加 `totp_code`）
- **Status**: `done` · **Depends**: M4-T05, M4-T02
- **Files**: `modify app/api/routes/api/session.py`、`app/schemas/session.py`（`LoginRequest.totp_code` 可选）、`app/services/totp.py`（校验 + 消费恢复码）；`modify tests/test_api_session.py`、`tests/test_api_totp.py`
- **Steps**: 密码通过后：若 `totp_enabled` 且无 `totp_code` → 401 `{totp_required:true}`（不发 session、退避照算）；有 `totp_code` → 校验 TOTP 或恢复码（命中恢复码置 `used_at`）→ 通过发 session；未启用 → 现状。
- **Out of scope / 不要碰**: 不改 setup/enable（T05）；不动退避公式（T02）。
- **Acceptance criteria**:
  - [ ] 未启用 TOTP 的用户登录与现状完全一致。
  - [ ] 启用后：缺码→401 totp_required；错码→401；对码→发 session；恢复码可用且一次性。
  - [ ] 退避对二步同样生效。
  - [ ] OpenAPI 重导出入库；校验闸门全绿。
- **Reviewer checklist**: `totp_required` 时确未发 cookie；恢复码消费后不可复用；通用错误不泄露细节。

### M4-T07 — CLI disable-totp / reissue-totp
- **Status**: `done` · **Depends**: M4-T04（逻辑上配合 T03 的 CLI 骨架）
- **Files**: `modify scripts/admin_cli.py`；`modify tests/test_admin_cli.py`
- **Steps**: `disable-totp <username>`：`totp_enabled=false` + 清 secret + 删恢复码（**不需任何恢复码即可执行**）；`reissue-totp <username>`：生成新 secret（可选打印新 URI）。
- **Out of scope / 不要碰**: 不碰用户数据表。
- **Acceptance criteria**:
  - [ ] 单测：启用 TOTP 的用户经 `disable-totp` 后可纯密码登录（结合 T06 行为）。
  - [ ] 不依赖任何恢复码即可关停。
  - [ ] 校验闸门全绿。
- **Reviewer checklist**: 逃生不依赖已存凭据；只动 auth 行。

### M4-T08 — 前端：两步登录 + 设置页 TOTP
- **Status**: `done` · **Depends**: M4-T05, M4-T06
- **Files**: `modify frontend/src/pages/LoginPage.tsx`；`create frontend/src/pages/.../TotpSettings.tsx`（或并入 ConfigPage）、`frontend/src/auth/totp.ts`；`modify frontend/package.json`(+`qrcode.react`，lock 同步)；`create` 对应测试
- **Steps**: 登录页：401+`totp_required`→第二屏 6 位码（也接受恢复码）；429→"稍后再试"（读 `Retry-After`）。设置页：未启用→启用流程（二维码由 `otpauth` URI 渲染 + 恢复码一次性展示 + 输码确认）；已启用→停用。
- **Out of scope / 不要碰**: 不改后端；不碰防爆破逻辑。
- **Acceptance criteria**:
  - [ ] 未启用 TOTP 的登录体验不变；启用后两步可走通；恢复码可登录。
  - [ ] 二维码可被 Authenticator 扫描；恢复码仅展示一次。
  - [ ] 429 有"稍后再试"提示。
  - [ ] 前端闸门全绿。
- **Reviewer checklist**: 全走类型化 client；secret/恢复码不落 localStorage/日志；空/错/加载态有处理。

### M4-T09 — 文档 + OpenAPI + roadmap 收尾
- **Status**: `done` · **Depends**: 全部
- **Files**: `modify README.md`、`docs/auth.md`（防爆破 + CLI + TOTP 说明、CLI 用法）、`docs/architecture-overview.md`；`modify docs/roadmap.md`（把"TOTP 二次验证"与新"登录防爆破"从「下一阶段」毕业、加 M4 行、注明 M4 先于 M5）；`modify docs/design/README.md`（已含 m4）；`run python scripts/export_openapi.py` 提交 `openapi/`
- **Acceptance criteria**:
  - [ ] 文档含 CLI 逃生用法与 TOTP 启停说明；`git diff --exit-code openapi/` 无差异。
  - [ ] 校验闸门全绿。
- **Reviewer checklist**: roadmap 反映 M4 已落地且先于 M5；无残留旧描述。

---

## 8. 前端校验闸门（前端任务每次结束都要全绿）

在 `frontend/` 下：`npm ci && npm run lint && npm run typecheck && npm run test && npm run build`。新增依赖 `qrcode.react` 须提交 `package.json` + `package-lock.json`。后端同任务改路由/schema 仍需根目录 `export_openapi.py` 并提交 `openapi/`。

## 9. 构建上下文 / 依赖完整性

- 新增 Python 依赖 `pyotp`、前端 `qrcode.react`：同步重新锁定 `requirements.txt` / `package-lock.json`，否则镜像缺包。
- 新文件均在既有 `app/`、`scripts/`、`frontend/` 的 COPY 范围内；`scripts/admin_cli.py` 须在镜像里可 `python -m scripts.admin_cli` 调用（确认 `scripts/` 已进镜像）。
- `tests/test_deployment.py::test_dockerfile_copy_sources_exist` 仍应通过。

## 10. 人工验收 walkthrough（实现完成后）

> 重点：连续输错 → 系统自动退避锁住 → CLI 解锁后恢复登录。可用 `docker compose` 起服务，CLI 在容器内或容器外跑均可。

**准备**：起服务（任选其一）
- Docker：`docker compose up -d`（应用容器名见 `docker-compose.yml`，下文记为 `<app>`）。
- 或本地：`source .venv/bin/activate && uvicorn app.main:app --port 8000`。

**1) 触发退避锁定** —— 用**错误密码**连续登录同一账号：
```bash
for i in $(seq 1 8); do \
  code=$(curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:8000/api/auth/login \
    -H 'Content-Type: application/json' -d '{"username":"admin","password":"wrong"}'); \
  echo "尝试 $i -> HTTP $code"; done
```
- 预期：前几次 `401`，超过免费次数后变 `429`（被锁/退避）。单独跑一次带 `-i` 可看到 `Retry-After` 头逐次增大（指数退避）。
- 在退避窗口内，**即使输正确密码**也应被 `429` 挡住（验证锁定确实生效）。

**2) CLI 解锁**
- 容器内：`docker compose exec <app> python -m scripts.admin_cli unlock --all`
- 或容器外：`python -m scripts.admin_cli unlock --all`
- 预期：打印清除的退避条目数。

**3) 验证恢复** —— 立即用**正确密码**登录：
```bash
curl -i -X POST http://localhost:8000/api/auth/login \
  -H 'Content-Type: application/json' -d '{"username":"admin","password":"<正确密码>"}'
```
- 预期：`200` 且 `Set-Cookie` 下发 session → 解锁成功。

**4)（可选）其它逃生命令**
- 重置密码：`python -m scripts.admin_cli reset-password admin`（交互输入新密码、不回显）。
- 关停 TOTP（若已启用）：`python -m scripts.admin_cli disable-totp admin`，随后纯密码即可登录。

## 11. 里程碑完成定义（DoD）

- 登录失败按指数退避（429 + Retry-After），成功清零；可经 `AUTH_LOGIN_THROTTLE_ENABLED` 开关。
- `python -m scripts.admin_cli` 能在**无任何 Web 访问、无恢复码**的情况下重置密码 / 解锁 / 关停 TOTP。
- TOTP 可由 admin 自选启用：启用后两步登录、恢复码可用且一次性；不启用维持纯密码。
- 后端 `pytest`/`ruff`/`export_openapi` + 前端 `lint/typecheck/test/build` 全绿且 `openapi/` 入库。
- README/auth/architecture/roadmap 反映 M4 现实与"先于 M5"的排期。
