310 lines
23 KiB
Markdown
310 lines
23 KiB
Markdown
# 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"的排期。
|