23 KiB
M4 — 登录加固(Login Hardening)
阅读前提:先读
README.md(协作模型、任务卡格式、校验闸门、数据安全红线)。 排期:本里程碑排在 M5 之前。 系统现为公网 + 仅密码 + 无限重试,是现成风险;M4 与 M5 零文件重叠,先堵这个洞再做 IoT。
1. 目标
给暴露在公网的单 admin 登录做纵深防御:
- 防爆破 / 指数退避:失败登录按指数增长延迟,拖垮暴力枚举;成功即清零。
- CLI 逃生通道:纯命令行重置密码 / 解锁 / 关停 TOTP,不依赖任何已存恢复凭据——拿到服务器 CLI 权限的人本来就无所不能,所以这是可接受、且必须存在的最终逃生口(消除"锁死就彻底进不去"的死角)。
- 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,TTLauth_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(computedauth_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 |
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):
POST /api/auth/totp/setup(已登录)→ 生成 pending secret +otpauth://URI + 一组恢复码(仅此一次明文返回),尚未启用。POST /api/auth/totp/enable(带一个当前 6 位码确认)→totp_enabled=true,持久化恢复码哈希。POST /api/auth/totp/disable(带密码或当前码)→ 关闭、清 secret/恢复码。
- 登录二因子(单端点、无独立 challenge token,保持无状态):
POST /api/auth/loginbody 增可选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. 已锁定决策(讨论后拍板)
- M4 独立里程碑、排在 M5 之前。
- 范围:防爆破 + 指数退避 + CLI 重置(密码/解锁/关 TOTP)+ TOTP(可选配,非强制)。
- 退避双键(IP + username)、延迟非永久锁定、成功清零、CLI 兜底;反代后需开
AUTH_TRUST_FORWARDED_FOR。 - CLI 逃生通道不依赖任何已存恢复凭据;只动 auth 行,不碰用户数据。
- TOTP 单端点二因子(login 加可选
totp_code,无独立 challenge token);恢复码哈希存、一次性;secret 明文存(与现有一致)。 - 二维码前端渲染(
qrcode.react),后端只给otpauth://URI + secret;Python 仅加pyotp。 - 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。- 校验闸门全绿。
- 临时库 upgrade 到 head 含该表与唯一索引;
- Reviewer checklist:
(scope,key)唯一;链上单 head;env.py 注册模型。
M4-T02 — 退避 service + 接入登录
- Status:
todo· 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_FIELDSAUTH_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:
check_and_get_wait(session, *, ip, username) -> int(秒;>0 表示仍在窗口);register_failure(...)(更新双键 failures/next_allowed_at,按 §3.1 公式);clear(session, *, ip, username)(成功清零)。- 登录端点:取 client IP(按
auth_trust_forwarded_for决定是否信 XFF 最左);先查退避,>0 → 429 +Retry-After,不验密码;验密码失败 →register_failure后 401;成功 →clear再发 session。 - 开关关闭时整段 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:
todo· 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:
todo· 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_secretnullable、totp_enabledbool 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。
- 校验闸门全绿。
- upgrade/downgrade 干净;默认
- Reviewer checklist: 现有用户迁移后默认未启用 TOTP(不破坏现有登录)。
M4-T05 — TOTP service + setup/enable/disable/status API
- Status:
todo· 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:
todo· 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:
todo· 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 行为)。 - 不依赖任何恢复码即可关停。
- 校验闸门全绿。
- 单测:启用 TOTP 的用户经
- Reviewer checklist: 逃生不依赖已存凭据;只动 auth 行。
M4-T08 — 前端:两步登录 + 设置页 TOTP
- Status:
todo· 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)。设置页:未启用→启用流程(二维码由otpauthURI 渲染 + 恢复码一次性展示 + 输码确认);已启用→停用。 - 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/无差异。 - 校验闸门全绿。
- 文档含 CLI 逃生用法与 TOTP 启停说明;
- 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) 触发退避锁定 —— 用错误密码连续登录同一账号:
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) 验证恢复 —— 立即用正确密码登录:
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"的排期。