# 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.` 风格(与 `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 `,argparse 子命令;直接连本地 DB,无网络,复用 `get_session_local()` + 模型 + Argon2 hasher): | 命令 | 作用 | | --- | --- | | `reset-password [--password ...]` | 重置密码(不给则交互式 prompt,不回显);逃生:忘密码 | | `unlock [--all | --ip | --username ]` | 清 `auth_login_throttle` 行;逃生:被退避锁住 | | `disable-totp ` | 关 TOTP(清 secret + 恢复码);逃生:**恢复码全丢也能进**(Phase B 才有意义)| | `reissue-totp ` | 重新发放 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/_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 [--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/_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 `:`totp_enabled=false` + 清 secret + 删恢复码(**不需任何恢复码即可执行**);`reissue-totp `:生成新 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**: `todo` · **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`,下文记为 ``)。 - 或本地:`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 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"的排期。