Files
home-automation/docs/design/m4-login-hardening.md
T

23 KiB
Raw Blame History

M4 — 登录加固(Login Hardening

阅读前提:先读 README.md(协作模型、任务卡格式、校验闸门、数据安全红线)。 排期:本里程碑排在 M5 之前。 系统现为公网 + 仅密码 + 无限重试,是现成风险;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.pyAuthUserid / username unique / password_hash / is_active / force_password_change / created_at)、AuthSessiontoken_hash / csrf_token / expires_at / revoked_at)。单库 Base
  • app/services/auth.py
    • authenticate_user(session, *, username, password) -> AuthUser | None:查用户→verify_passwordArgon2);失败只 logger.info无任何节流
    • create_session(session, *, user, settings) -> (AuthSession, raw_token)secrets.token_urlsafeSHA256 存 token_hashTTL 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/loginbody {username, password}):authenticate_user → None 则 401;否则 create_session + set_cookie(HttpOnly/SameSite=Lax/auth_cookie_secure) → 返回 {user, csrf_token}
    • GET /api/session401 或 user+csrf)、POST /api/auth/logoutCSRF)、POST /api/auth/passwordCSRF + 校验当前密码)。

配置

  • app/config.pyauth_bootstrap_username/passwordauth_session_cookie_nameauth_session_ttl_hoursauth_cookie_secure_overridecomputed auth_cookie_secure)。
  • 配置系统:扁平 KVCONFIG_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.tomlconsole_scripts。CLI 入口沿用 python -m scripts.<mod> 风格(与 python -m scripts.run_migrations 一致)。

前端登录(单步)

  • frontend/src/pages/LoginPage.tsxPOST /api/auth/login {username,password} → 401 显示通用错误;成功存 csrf、跳转。
  • frontend/src/auth/SessionProvider.tsxGET /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 IPusername 计;本次请求需等待 = 两者退避的较大值
    • 按 IP:堵单点暴力(attacker IP 越敲越慢,合法用户换 IP 不受影响)。
    • 按 username:单 admin 的全局兜底(即便分布式换 IP 也有一层)。
    • DoS 权衡:单 admin 下纯按 username 硬锁会被攻击者故意失败把你锁死,所以退避是延迟(429 + Retry-After)而非永久锁定,且 CLI 是最终逃生口。
  • client IP 来源:默认用 socket IPAUTH_TRUST_FORWARDED_FOR=true 时取 X-Forwarded-For 最左(部署在反代后才开)。这点必须显式配置,否则反代后所有请求同一 IP,按 IP 退避失效。
  • 指数公式(默认,常量可后调):前 N_free=3 次不延迟;之后 wait = min(cap, base * 2^(failures - N_free))base=1scap=900s
  • 接入点POST /api/auth/login 开头先查退避 → 在窗口内直接 429(带 Retry-After),不验密码(省 Argon2、防枚举);验密码失败 → 记一次失败;成功 → 清该 IP + username 的退避行。
  • 全局开关 AUTH_LOGIN_THROTTLE_ENABLEDCONFIG_FIELDS,默认 true)。

3.2 CLI 逃生通道

scripts/admin_cli.pypython -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 二次验证(可选配)

  • pyotpPython 侧);二维码在前端用 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/logintotp_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 + secretPython 仅加 pyotp
  7. CLI 入口 = python -m scripts.admin_cli(无 console_scripts)。

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

6. 任务依赖图

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

Phase BTOTP,可选配)
  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。新增依赖(pyotpqrcode.react)须同步 requirements.in/.txtfrontend/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.pymodify alembic_app/env.pyscripts/app_db_adopt.pybaseline 常量);create tests/test_auth_throttle_model.py
  • Steps: 模型 LoginThrottleid / 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.pymodify app/api/routes/api/session.pyapp/services/config_page.py(+CONFIG_FIELDS AUTH_LOGIN_THROTTLE_ENABLED)、app/config.py(+auth_login_throttle_enabledauth_trust_forwarded_for)modify tests/test_api_session.pycreate 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 / 不要碰: 不做 TOTPPhase 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.pycreate tests/test_admin_cli.py
  • Steps: argparse 子命令;reset-password <username> [--password|prompt(getpass)] 用 Argon2 hasher 重置 password_hashunlock [--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.pyAuthUser.totp_secret/totp_enabled);create 模型 RecoveryCodecreate alembic_app/versions/<date>_NN_totp.pymodify alembic_app/env.pyscripts/app_db_adopt.pycreate tests/test_totp_models.py
  • Steps: totp_secret nullable、totp_enabled bool default falseauth_recovery_code(user_id FK, code_hash, used_at nullable)revision 建列 + 表;更新 baseline。
  • Out of scope / 不要碰: 不写 TOTP 逻辑(T05)。
  • Acceptance criteria:
    • upgrade/downgrade 干净;默认 totp_enabled=falsetotp_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.pyapp/schemas/totp.pymodify 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:// URIissuer=auth_totp_issuer);恢复码生成(N=10、xxxx-xxxxArgon2 哈希存、明文仅 setup 返回一次);setuppending,不启用)/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.pyapp/schemas/session.pyLoginRequest.totp_code 可选)、app/services/totp.py(校验 + 消费恢复码);modify tests/test_api_session.pytests/test_api_totp.py
  • Steps: 密码通过后:若 totp_enabled 且无 totp_code → 401 {totp_required:true}(不发 session、退避照算);有 totp_code → 校验 TOTP 或恢复码(命中恢复码置 used_at)→ 通过发 session;未启用 → 现状。
  • Out of scope / 不要碰: 不改 setup/enableT05);不动退避公式(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.pymodify 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.tsxcreate frontend/src/pages/.../TotpSettings.tsx(或并入 ConfigPage)、frontend/src/auth/totp.tsmodify frontend/package.json(+qrcode.reactlock 同步)create 对应测试
  • Steps: 登录页:401+totp_required→第二屏 6 位码(也接受恢复码);429→"稍后再试"(读 Retry-After)。设置页:未启用→启用流程(二维码由 otpauth URI 渲染 + 恢复码一次性展示 + 输码确认);已启用→停用。
  • Out of scope / 不要碰: 不改后端;不碰防爆破逻辑。
  • Acceptance criteria:
    • 未启用 TOTP 的登录体验不变;启用后两步可走通;恢复码可登录。
    • 二维码可被 Authenticator 扫描;恢复码仅展示一次。
    • 429 有"稍后再试"提示。
    • 前端闸门全绿。
  • Reviewer checklist: 全走类型化 clientsecret/恢复码不落 localStorage/日志;空/错/加载态有处理。

M4-T09 — 文档 + OpenAPI + roadmap 收尾

  • Status: done · Depends: 全部
  • Files: modify README.mddocs/auth.md(防爆破 + CLI + TOTP 说明、CLI 用法)、docs/architecture-overview.mdmodify 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 在容器内或容器外跑均可。

准备:起服务(任选其一)

  • Dockerdocker 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":"<正确密码>"}'
  • 预期:200Set-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"的排期。