Compare commits
14
Commits
v1.2.0
...
e3c47b2762
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3c47b2762 | ||
|
|
dbf17f3592 | ||
|
|
ae75e4582d | ||
|
|
ee1264b66b | ||
|
|
51da8c5716 | ||
|
|
81bd32f613 | ||
|
|
ee3031013e | ||
|
|
5026967cee | ||
|
|
5481fbc99c | ||
|
|
e480a84a44 | ||
|
|
64d3882c16 | ||
|
|
6b67b8e0f8 | ||
|
|
0cb94d85ec | ||
|
|
9da88db221 |
@@ -36,14 +36,29 @@
|
||||
|
||||
- **默认逐步**:给一个 milestone 文档,按其中原子任务**一步一步**实现。
|
||||
- **(a) 只实现一步**:用户说"只实现一步 / 这一个任务"时,**只做那一个任务卡**,跑完校验闸门后停下,等用户确认,不要顺手往下做。
|
||||
- **(b) 完成整个 milestone**:仅当用户在提示词里**显式要求启用 sub-agent 并指定模型**时,才用指定模型起 implementer sub-agent,按任务依赖顺序跑完整条链。
|
||||
- **Sub-agent 纪律**:只在用户显式要求时才 spawn sub-agent;单步/小改动在主线内联完成。起 sub-agent 时用用户**指定的模型**(Agent 工具的 `model` 覆盖)。
|
||||
- **(b) 完成整个 milestone**:仅当用户在提示词里**显式要求启用 sub-agent**时,才起 implementer / reviewer / fixer sub-agent(模型用下方**『默认模型档位』**,用户人工指定则覆盖),按任务依赖顺序跑完整条链。
|
||||
- **Sub-agent 纪律**:只在用户显式要求时才 spawn sub-agent;单步/小改动在主线内联完成。起 sub-agent 时按下方**『默认模型档位』**选择模型(用户人工指定则以人工指定为准),用 Agent 工具的 `model` 字段落实。
|
||||
|
||||
### 角色(Orchestrator → Implementer → Reviewer)
|
||||
### 默认模型档位(实现模式 sub-agent;可被人工指定覆盖)
|
||||
|
||||
起 implementer / reviewer / fixer sub-agent 时,**默认**用下列模型档位,无需用户每次人工指定:
|
||||
|
||||
| 角色 | 默认模型 | 推理档位 |
|
||||
| --- | --- | --- |
|
||||
| **Implementer** | **Sonnet** | high reasoning effort |
|
||||
| **Fixer**(返工) | **Sonnet** | high reasoning effort |
|
||||
| **Reviewer** | **Opus** | extra-high reasoning effort |
|
||||
|
||||
- **人工指定覆盖**:若用户在提示词里**显式指定了其他模型**(针对任一角色),则**以用户人工指定为准**,覆盖上述默认。
|
||||
- 用 Agent 工具的 `model` 字段落实模型选择;该字段当前仅支持 `sonnet` / `opus` / `haiku` / `fable`。
|
||||
- **推理档位说明**:Agent 工具未暴露独立的 reasoning-effort 旋钮,"high / extra-high reasoning" 通过 spawn prompt 里的显式指令传达(要求 implementer/fixer 动手前充分推理边界条件;要求 reviewer 以对抗性外部审计心态最高强度复核)。若所在 harness 提供真正的 effort 设置,则一并按此档位设置。
|
||||
|
||||
### 角色(Orchestrator → Implementer → Reviewer → Fixer)
|
||||
|
||||
- 我(主线)= **Orchestrator**:挑依赖已满足的下一个任务、派发、转述结果、维护任务 `Status`。
|
||||
- **Implementer**(便宜模型,用户指定):一次一个任务,严格按任务卡,不扩范围。
|
||||
- **Reviewer**(强模型,用户指定):实现完成后起 Reviewer sub-agent,按任务卡 `Acceptance criteria` + `Reviewer checklist` 复核、**独立重跑校验闸门**,驱动 implementer 返工直到本轮 PASS。
|
||||
- **Implementer**(默认 **Sonnet**,high reasoning;见上方『默认模型档位』):一次一个任务,严格按任务卡,不扩范围。
|
||||
- **Reviewer**(默认 **Opus**,extra-high reasoning):实现完成后起 Reviewer sub-agent,按任务卡 `Acceptance criteria` + `Reviewer checklist` 复核、**独立重跑校验闸门**,驱动返工直到本轮 PASS。
|
||||
- **Fixer**(默认 **Sonnet**,high reasoning):按 reviewer 的编号返工清单返工;**每轮返工起一个干净的 Fixer**(与首次实现的 Implementer 分开冷启动),先读对应 `review-notes/<task>-review-<n>.md` 再改。
|
||||
|
||||
#### Reviewer 盲审纪律(M1 教训)
|
||||
|
||||
@@ -101,7 +116,9 @@ python scripts/export_openapi.py && git diff --exit-code openapi/ # 改了路
|
||||
## Commit 规范(重点)
|
||||
|
||||
### 分支
|
||||
- 每个 milestone/feature 一个分支(如 `feature/m1-db-consolidation`),**不在 `main` 上直接提交**。
|
||||
- **本仓库是个人单用户项目:默认直接在 `main` 上开发**,不强制 feature 分支,**直接提交并 push 到 `main` 是允许的(无需开 PR)**。
|
||||
- 仍保持**每个任务一个干净 commit**(message 前缀任务/里程碑 ID)。改动较大想隔离时可临时开分支,用完**快进合并**回 `main`(保持线性历史),非必需。
|
||||
- 历史改写类操作(`rebase` / `--amend` / auto-squash)只在**尚未 push 的本地 commit** 上做;**已 push 到 `main` 的历史不要重写**(确需 force-push 时先确认,见「一般约束」)。
|
||||
|
||||
### 一轮实现完成(用户确认「实现完成」后)
|
||||
- 准备好**这一轮的 commit message** 并提交,作为本轮的 **base commit**。
|
||||
@@ -130,7 +147,8 @@ python scripts/export_openapi.py && git diff --exit-code openapi/ # 改了路
|
||||
- autosquash **改写历史**:仅在 push / 开 PR **之前**做。若该分支已 push,需要 force-push——属对外操作,**先取得用户确认再做**。
|
||||
|
||||
### 一般约束
|
||||
- commit / push 只在用户要求时进行;push、force-push、开/改 PR 等对外操作先确认。
|
||||
- **个人单用户仓库:直接 commit 并 push 到 `main` 已获授权**——在校验闸门全绿、一轮工作完成时即可提交 / 推送,无需逐次征求同意。
|
||||
- 仍需**先取得用户确认**的操作:**force-push / 改写已推送历史**,以及**打 tag**(会触发镜像 CI / 对外发布;且打 tag 前须按下方「发版前置走查」真跑一次 `docker build`)。
|
||||
|
||||
## 发版前置走查(打 tag 前必做)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
- FastAPI Web 应用(React SPA 前端 + JSON API)
|
||||
- SQLite + SQLAlchemy + Alembic 的单库结构
|
||||
- username/password + server-side session 鉴权
|
||||
- username/password + server-side session 鉴权(含登录加固,见下文)
|
||||
- runtime config 页面与 app DB 持久化
|
||||
- public IPv4 monitor、历史持久化与定时检查
|
||||
- SMTP 配置、测试发信与 public IPv4 changed 邮件通知
|
||||
@@ -228,6 +228,59 @@ React SPA 主要页面路由(客户端路由,均由 FastAPI fallback 到 `in
|
||||
|
||||
无论是本地 `host:port` 还是反向代理后的域名访问,登录成功后进入 SPA 首页(`/`)。
|
||||
|
||||
## M4 登录加固
|
||||
|
||||
M4 在基础鉴权之上叠加了三层防御,详细说明见 [`docs/auth.md`](./docs/auth.md)。
|
||||
|
||||
### 防爆破 / 指数退避
|
||||
|
||||
登录失败超过 3 次后进入指数退避(`wait = min(900s, 1s × 2^(failures-3))`),期间请求返回 `429 Too Many Requests`(含 `Retry-After` 响应头);成功登录后自动清零。退避按 **client IP** 与 **username** 双键取较大值,不会因此永久锁定账号(只是延迟,不是封号)。
|
||||
|
||||
- 全局开关:`AUTH_LOGIN_THROTTLE_ENABLED`(CONFIG_FIELDS,默认 `true`)
|
||||
- 反代后需要 `AUTH_TRUST_FORWARDED_FOR=true` 才会读 `X-Forwarded-For`(`.env` 部署级配置,默认 `false`)
|
||||
|
||||
### CLI 逃生通道
|
||||
|
||||
拿到服务器 CLI 权限时,可以在**不依赖任何已存凭据**(无需密码、恢复码)的情况下重置密码、解锁退避、关停 TOTP:
|
||||
|
||||
```bash
|
||||
# 重置密码(不加 --password 则交互式输入,不回显)
|
||||
python -m scripts.admin_cli reset-password admin
|
||||
|
||||
# 解锁退避(被 429 挡住时使用)
|
||||
python -m scripts.admin_cli unlock --all # 清所有退避行
|
||||
python -m scripts.admin_cli unlock --ip 1.2.3.4 # 按 IP 清
|
||||
python -m scripts.admin_cli unlock --username admin # 按 username 清
|
||||
|
||||
# TOTP 相关(需要先启用,见下文)
|
||||
python -m scripts.admin_cli disable-totp admin # 关停 TOTP(零凭据,逃生口)
|
||||
python -m scripts.admin_cli reissue-totp admin # 重新发放 TOTP secret(打印新 URI)
|
||||
|
||||
# 查看用户列表
|
||||
python -m scripts.admin_cli list-admin
|
||||
```
|
||||
|
||||
在 Docker 容器内执行时:
|
||||
|
||||
```bash
|
||||
docker compose exec app python -m scripts.admin_cli <command>
|
||||
```
|
||||
|
||||
### 可选 TOTP 二次验证
|
||||
|
||||
admin 可在 React SPA 设置页(`/config`)自选启用 RFC 6238 TOTP:
|
||||
|
||||
1. 设置页点「启用 TOTP」→ 后端生成 `otpauth://` URI,前端渲染二维码(`qrcode.react`)
|
||||
2. 用 Authenticator App(如 Google Authenticator、Authy)扫码
|
||||
3. 输入当前 6 位动态码确认 → TOTP 启用
|
||||
4. 妥善保存一次性展示的 10 个恢复码(格式 `xxxx-xxxx`)
|
||||
|
||||
启用后,登录需要两步:密码 → 6 位动态码(或恢复码,一次性)。不启用则维持纯密码登录,行为不变。
|
||||
|
||||
恢复码丢失时,可用 CLI 逃生:`python -m scripts.admin_cli disable-totp admin`,随后即可纯密码登录。
|
||||
|
||||
TOTP issuer 标签(显示在 Authenticator 里)通过 `AUTH_TOTP_ISSUER` 环境变量配置(`.env` 部署级),默认回退 `app_name`。
|
||||
|
||||
## Config 持久化
|
||||
|
||||
当前 config 页面不会把修改写回 `.env`。
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@ from sqlalchemy import engine_from_config, pool
|
||||
from app.config import get_settings
|
||||
from app.db import Base
|
||||
from app.models.config import AppConfigEntry # noqa: F401
|
||||
from app.models.auth import AuthSession, AuthUser # noqa: F401
|
||||
from app.models.auth import AuthSession, AuthUser, RecoveryCode # noqa: F401
|
||||
from app.models.auth_throttle import LoginThrottle # noqa: F401
|
||||
from app.models.public_ip import PublicIPHistory, PublicIPState # noqa: F401
|
||||
from app.models.location import Location # noqa: F401
|
||||
from app.models.poo import PooRecord # noqa: F401
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""add auth_login_throttle table for exponential back-off throttling
|
||||
|
||||
Revision ID: 20260621_07_auth_login_throttle
|
||||
Revises: 20260611_06_merge_location_poo_tables
|
||||
Create Date: 2026-06-21 00:00:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260621_07_auth_login_throttle"
|
||||
down_revision: Union[str, None] = "20260611_06_merge_location_poo_tables"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"auth_login_throttle",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("key", sa.String(length=255), nullable=False),
|
||||
sa.Column("scope", sa.String(length=16), nullable=False),
|
||||
sa.Column("failures", sa.Integer(), nullable=False),
|
||||
sa.Column("first_failed_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("last_failed_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("next_allowed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("scope", "key", name="uq_auth_login_throttle_scope_key"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_auth_login_throttle_scope_key",
|
||||
"auth_login_throttle",
|
||||
["scope", "key"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_auth_login_throttle_scope_key", table_name="auth_login_throttle")
|
||||
op.drop_table("auth_login_throttle")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""add TOTP fields to auth_users and create auth_recovery_code table
|
||||
|
||||
Revision ID: 20260621_08_totp
|
||||
Revises: 20260621_07_auth_login_throttle
|
||||
Create Date: 2026-06-21 00:00:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "20260621_08_totp"
|
||||
down_revision: Union[str, None] = "20260621_07_auth_login_throttle"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add totp_secret (nullable) to auth_users — existing rows get NULL, which is correct.
|
||||
op.add_column("auth_users", sa.Column("totp_secret", sa.String(length=64), nullable=True))
|
||||
# Add totp_enabled (NOT NULL) with server_default="0" so existing rows default to false.
|
||||
op.add_column(
|
||||
"auth_users",
|
||||
sa.Column(
|
||||
"totp_enabled",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
)
|
||||
|
||||
# Create auth_recovery_code table for one-time TOTP recovery codes.
|
||||
op.create_table(
|
||||
"auth_recovery_code",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("code_hash", sa.String(length=255), nullable=False),
|
||||
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["auth_users.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_auth_recovery_code_user_id"),
|
||||
"auth_recovery_code",
|
||||
["user_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop auth_recovery_code table first (has FK to auth_users).
|
||||
op.drop_index(op.f("ix_auth_recovery_code_user_id"), table_name="auth_recovery_code")
|
||||
op.drop_table("auth_recovery_code")
|
||||
|
||||
# Drop the two TOTP columns from auth_users.
|
||||
# Use batch_alter_table for SQLite compatibility (alembic's portable column-drop path).
|
||||
with op.batch_alter_table("auth_users") as batch_op:
|
||||
batch_op.drop_column("totp_enabled")
|
||||
batch_op.drop_column("totp_secret")
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.routes.api.deps import require_csrf, require_session
|
||||
@@ -14,6 +14,12 @@ from app.schemas.session import (
|
||||
SessionResponse,
|
||||
SessionUser,
|
||||
)
|
||||
from app.schemas.totp import (
|
||||
TotpDisableRequest,
|
||||
TotpEnableRequest,
|
||||
TotpSetupResponse,
|
||||
TotpStatusResponse,
|
||||
)
|
||||
from app.services.auth import (
|
||||
AuthPasswordChangeError,
|
||||
AuthenticatedSession,
|
||||
@@ -22,6 +28,8 @@ from app.services.auth import (
|
||||
create_session,
|
||||
revoke_session,
|
||||
)
|
||||
from app.services import login_throttle
|
||||
from app.services import totp as totp_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,9 +54,26 @@ def get_session(
|
||||
return _build_session_response(auth)
|
||||
|
||||
|
||||
def _get_client_ip(request: Request, *, trust_forwarded_for: bool) -> str:
|
||||
"""Extract the client IP address from the request.
|
||||
|
||||
When ``trust_forwarded_for`` is True (reverse-proxy deployments) the
|
||||
left-most value from the ``X-Forwarded-For`` header is used. Otherwise the
|
||||
direct socket IP (``request.client.host``) is used.
|
||||
"""
|
||||
if trust_forwarded_for:
|
||||
xff = request.headers.get("X-Forwarded-For", "")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
if request.client is not None:
|
||||
return request.client.host
|
||||
return "unknown"
|
||||
|
||||
|
||||
@router.post("/auth/login", response_model=SessionResponse)
|
||||
def post_login(
|
||||
body: LoginRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
@@ -58,15 +83,67 @@ def post_login(
|
||||
|
||||
On success, sets an HttpOnly session cookie and returns the session user + CSRF token.
|
||||
On failure, returns 401 with no cookie set.
|
||||
Repeated failures trigger exponential back-off (429 + Retry-After).
|
||||
No X-CSRF-Token required (unauthenticated endpoint).
|
||||
"""
|
||||
client_ip = _get_client_ip(request, trust_forwarded_for=settings.auth_trust_forwarded_for)
|
||||
|
||||
# --- Throttle check (before any password verification) ---
|
||||
if settings.auth_login_throttle_enabled:
|
||||
wait_seconds = login_throttle.check_and_get_wait(
|
||||
db, ip=client_ip, username=body.username
|
||||
)
|
||||
if wait_seconds > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="too many failed login attempts; please try again later",
|
||||
headers={"Retry-After": str(wait_seconds)},
|
||||
)
|
||||
|
||||
# --- Password verification ---
|
||||
user = authenticate_user(db, username=body.username, password=body.password)
|
||||
if user is None:
|
||||
if settings.auth_login_throttle_enabled:
|
||||
login_throttle.register_failure(db, ip=client_ip, username=body.username)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid username or password",
|
||||
)
|
||||
|
||||
# --- TOTP second-factor check (only when TOTP is enabled for the user) ---
|
||||
if user.totp_enabled:
|
||||
if not body.totp_code:
|
||||
# Password correct but no TOTP code supplied: signal the front-end to
|
||||
# prompt for the second factor. Do NOT issue a session.
|
||||
# Deliberate: this is a normal two-step protocol step from a legitimate
|
||||
# user — we do NOT register a throttle failure here. The attacker must
|
||||
# already have the correct password to reach this branch, and counting
|
||||
# each normal first-step as a failure would risk locking out the real
|
||||
# admin on every login attempt.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"totp_required": True},
|
||||
)
|
||||
|
||||
# TOTP code supplied — verify against time-based code or a recovery code.
|
||||
totp_ok = totp_service.verify_totp_code(user, body.totp_code)
|
||||
if not totp_ok:
|
||||
totp_ok = totp_service.verify_recovery_code(db, user=user, code=body.totp_code)
|
||||
|
||||
if not totp_ok:
|
||||
# Second-factor failure is an active attack signal — register failure
|
||||
# so that repeated wrong TOTP/recovery-code guesses trigger back-off.
|
||||
if settings.auth_login_throttle_enabled:
|
||||
login_throttle.register_failure(db, ip=client_ip, username=body.username)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid username or password",
|
||||
)
|
||||
|
||||
# --- Success: clear back-off state and issue session ---
|
||||
if settings.auth_login_throttle_enabled:
|
||||
login_throttle.clear(db, ip=client_ip, username=body.username)
|
||||
|
||||
auth_session, raw_token = create_session(db, user=user, settings=settings)
|
||||
logger.info("Created API authenticated session for user '%s'", user.username)
|
||||
|
||||
@@ -139,3 +216,109 @@ def post_change_password(
|
||||
|
||||
logger.info("Password updated for user '%s'", auth.user.username)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TOTP endpoints (M4-T05)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/auth/totp/setup", response_model=TotpSetupResponse)
|
||||
def post_totp_setup(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> TotpSetupResponse:
|
||||
"""
|
||||
Generate a new pending TOTP secret, otpauth URI, and one-time recovery codes.
|
||||
|
||||
The secret is stored in the DB but TOTP is NOT yet enabled (totp_enabled stays
|
||||
False until the user confirms with POST /api/auth/totp/enable).
|
||||
|
||||
Recovery codes are returned here as plaintext exactly once; their Argon2 hashes
|
||||
are persisted immediately so enable only needs to flip the enabled flag.
|
||||
|
||||
Repeating this call replaces any prior pending secret and regenerates codes.
|
||||
|
||||
Requires: session cookie + X-CSRF-Token.
|
||||
"""
|
||||
secret, otpauth_uri, recovery_codes = totp_service.setup(
|
||||
db,
|
||||
user=auth.user,
|
||||
issuer=settings.effective_totp_issuer,
|
||||
)
|
||||
return TotpSetupResponse(
|
||||
secret=secret,
|
||||
otpauth_uri=otpauth_uri,
|
||||
recovery_codes=recovery_codes,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/auth/totp/enable", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def post_totp_enable(
|
||||
body: TotpEnableRequest,
|
||||
db: Session = Depends(get_db),
|
||||
auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> Response:
|
||||
"""
|
||||
Enable TOTP by confirming with the current 6-digit code from the authenticator app.
|
||||
|
||||
Requires a prior call to POST /api/auth/totp/setup (so that a pending secret
|
||||
exists). On success, totp_enabled becomes True.
|
||||
|
||||
Returns 400 if the code is wrong or there is no pending secret.
|
||||
Requires: session cookie + X-CSRF-Token.
|
||||
"""
|
||||
ok = totp_service.enable(db, user=auth.user, code=body.code)
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="invalid TOTP code or no pending setup",
|
||||
)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post("/auth/totp/disable", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def post_totp_disable(
|
||||
body: TotpDisableRequest,
|
||||
db: Session = Depends(get_db),
|
||||
auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> Response:
|
||||
"""
|
||||
Disable TOTP. The caller must provide exactly one of:
|
||||
- ``password``: the user's current login password, OR
|
||||
- ``code``: the current 6-digit TOTP code.
|
||||
|
||||
On success: totp_enabled=False, totp_secret cleared, all recovery codes deleted.
|
||||
Returns 400 if neither credential matches or neither is provided.
|
||||
Requires: session cookie + X-CSRF-Token.
|
||||
"""
|
||||
ok = totp_service.disable(
|
||||
db,
|
||||
user=auth.user,
|
||||
password=body.password,
|
||||
code=body.code,
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="invalid credential; provide a valid password or TOTP code",
|
||||
)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get("/auth/totp", response_model=TotpStatusResponse)
|
||||
def get_totp_status(
|
||||
auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> TotpStatusResponse:
|
||||
"""
|
||||
Return the current TOTP status for the authenticated user.
|
||||
|
||||
Response contains only ``{"enabled": bool}``.
|
||||
Secret and recovery codes are NEVER returned here.
|
||||
Requires: session cookie only (no CSRF — read-only).
|
||||
"""
|
||||
return TotpStatusResponse(enabled=auth.user.totp_enabled)
|
||||
|
||||
@@ -37,6 +37,9 @@ class Settings(BaseSettings):
|
||||
auth_session_cookie_name: str = "home_automation_session"
|
||||
auth_session_ttl_hours: int = 12
|
||||
auth_cookie_secure_override: bool | None = True
|
||||
auth_login_throttle_enabled: bool = True
|
||||
auth_trust_forwarded_for: bool = False
|
||||
auth_totp_issuer: str = "" # defaults to app_name when empty
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
@@ -86,6 +89,12 @@ class Settings(BaseSettings):
|
||||
return self.auth_cookie_secure_override
|
||||
return not self.is_development
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def effective_totp_issuer(self) -> str:
|
||||
"""The issuer label shown in Authenticator apps. Falls back to app_name."""
|
||||
return self.auth_totp_issuer.strip() or self.app_name
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -15,8 +15,12 @@ class AuthUser(Base):
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
force_password_change: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
# TOTP fields (Phase B, M4-T04) — nullable/false by default so existing users are unaffected
|
||||
totp_secret: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
totp_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
sessions: Mapped[list["AuthSession"]] = relationship(back_populates="user")
|
||||
recovery_codes: Mapped[list["RecoveryCode"]] = relationship(back_populates="user")
|
||||
|
||||
|
||||
class AuthSession(Base):
|
||||
@@ -31,3 +35,21 @@ class AuthSession(Base):
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
user: Mapped[AuthUser] = relationship(back_populates="sessions")
|
||||
|
||||
|
||||
class RecoveryCode(Base):
|
||||
"""One-time TOTP recovery codes for AuthUser.
|
||||
|
||||
``code_hash`` stores the Argon2 hash of the plaintext code (plaintext is
|
||||
returned only at setup time and never stored). ``used_at`` is NULL while
|
||||
the code is still valid; set to the consumption timestamp when consumed.
|
||||
"""
|
||||
|
||||
__tablename__ = "auth_recovery_code"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("auth_users.id"), nullable=False, index=True)
|
||||
code_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
user: Mapped[AuthUser] = relationship(back_populates="recovery_codes")
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Index, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class LoginThrottle(Base):
|
||||
"""Tracks per-key login failure state for exponential back-off throttling.
|
||||
|
||||
``scope`` is either ``'ip'`` or ``'user'``; ``key`` is the IP address or
|
||||
username string respectively. The pair ``(scope, key)`` is unique — one
|
||||
row per tracked entity. A row is deleted on successful login (clear).
|
||||
"""
|
||||
|
||||
__tablename__ = "auth_login_throttle"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("scope", "key", name="uq_auth_login_throttle_scope_key"),
|
||||
Index("ix_auth_login_throttle_scope_key", "scope", "key"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
scope: Mapped[str] = mapped_column(String(16), nullable=False) # 'ip' | 'user'
|
||||
failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
first_failed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
last_failed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
next_allowed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
@@ -16,6 +16,7 @@ class SessionResponse(BaseModel):
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
totp_code: str | None = None
|
||||
|
||||
|
||||
class PasswordChangeRequest(BaseModel):
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Pydantic schemas for TOTP setup / enable / disable / status endpoints (M4-T05)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Setup (POST /api/auth/totp/setup) — response
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TotpSetupResponse(BaseModel):
|
||||
"""Returned once after a setup call.
|
||||
|
||||
``secret`` and ``recovery_codes`` are **one-time plaintext values**.
|
||||
They are never returned again by any subsequent API call.
|
||||
The frontend must display and instruct the user to save them before confirming.
|
||||
"""
|
||||
|
||||
secret: str
|
||||
otpauth_uri: str
|
||||
recovery_codes: list[str]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enable (POST /api/auth/totp/enable) — request
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TotpEnableRequest(BaseModel):
|
||||
"""The user confirms setup by providing the 6-digit TOTP code."""
|
||||
|
||||
code: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Disable (POST /api/auth/totp/disable) — request
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TotpDisableRequest(BaseModel):
|
||||
"""Disable TOTP by proving identity.
|
||||
|
||||
Exactly one of ``password`` or ``code`` must be provided.
|
||||
"""
|
||||
|
||||
password: str | None = None
|
||||
code: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status (GET /api/auth/totp) — response
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TotpStatusResponse(BaseModel):
|
||||
"""Minimal status response — never exposes secret or recovery codes."""
|
||||
|
||||
enabled: bool
|
||||
@@ -49,6 +49,12 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = (
|
||||
"auth_cookie_secure_override",
|
||||
"Cookie Secure Override",
|
||||
),
|
||||
ConfigField(
|
||||
"Authentication",
|
||||
"AUTH_LOGIN_THROTTLE_ENABLED",
|
||||
"auth_login_throttle_enabled",
|
||||
"Login Throttle Enabled",
|
||||
),
|
||||
ConfigField("Poo", "POO_WEBHOOK_ID", "poo_webhook_id", "Poo Webhook ID", secret=True),
|
||||
ConfigField(
|
||||
"Poo",
|
||||
@@ -284,4 +290,6 @@ def _settings_payload(settings: Settings) -> dict[str, Any]:
|
||||
"auth_session_cookie_name": settings.auth_session_cookie_name,
|
||||
"auth_session_ttl_hours": settings.auth_session_ttl_hours,
|
||||
"auth_cookie_secure_override": settings.auth_cookie_secure_override,
|
||||
"auth_login_throttle_enabled": settings.auth_login_throttle_enabled,
|
||||
"auth_trust_forwarded_for": settings.auth_trust_forwarded_for,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Exponential back-off throttle service for the login endpoint.
|
||||
|
||||
Design
|
||||
------
|
||||
Failures are tracked per (scope, key) in the ``auth_login_throttle`` table:
|
||||
|
||||
* scope ``'ip'`` → key is the client IP address
|
||||
* scope ``'user'`` → key is the username
|
||||
|
||||
On each login attempt the endpoint checks **both** keys and takes the **maximum**
|
||||
remaining wait (in seconds). A wait > 0 means the caller is still inside a
|
||||
back-off window and should receive 429.
|
||||
|
||||
Exponential formula
|
||||
-------------------
|
||||
The first ``N_FREE`` failures are free (no delay). After that::
|
||||
|
||||
wait = min(BACKOFF_CAP_S, BACKOFF_BASE_S * 2 ** (failures - N_FREE))
|
||||
|
||||
Constants (module-level so they can be tuned without touching the algorithm):
|
||||
|
||||
* ``N_FREE`` = 3 — failures before any delay starts
|
||||
* ``BACKOFF_BASE_S`` = 1 — base wait in seconds (1st delayed attempt → 2 s)
|
||||
* ``BACKOFF_CAP_S`` = 900 — hard cap (15 min)
|
||||
|
||||
Example delay schedule:
|
||||
failures 1, 2, 3 → 0 s (free)
|
||||
failures 4 → 2 s
|
||||
failures 5 → 4 s
|
||||
failures 6 → 8 s
|
||||
failures 7 → 16 s
|
||||
…
|
||||
failures 13+ → 900 s (capped)
|
||||
|
||||
Timezone notes
|
||||
--------------
|
||||
The model uses ``DateTime(timezone=True)``. SQLite stores timestamps without
|
||||
timezone info, so when rows are read back the column value may be timezone-naive.
|
||||
We normalise every stored value to UTC-aware via ``_as_utc`` before comparing
|
||||
against ``datetime.now(UTC)``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.auth_throttle import LoginThrottle
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tunable constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
N_FREE: int = 3 # failures before any backoff starts
|
||||
BACKOFF_BASE_S: int = 1 # seconds — wait after the first delayed failure
|
||||
BACKOFF_CAP_S: int = 900 # seconds — maximum wait (15 min)
|
||||
|
||||
# Internal scope labels (keep in sync with model constraint)
|
||||
_SCOPE_IP = "ip"
|
||||
_SCOPE_USER = "user"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def check_and_get_wait(session: Session, *, ip: str, username: str) -> int:
|
||||
"""Return the number of seconds the caller must still wait, or 0 if allowed.
|
||||
|
||||
Checks both the IP row and the username row; returns the *larger* remaining
|
||||
wait so that both keys must have their back-off window expire before the
|
||||
caller is permitted to try again.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
wait_ip = _remaining_wait(session, scope=_SCOPE_IP, key=ip, now=now)
|
||||
wait_user = _remaining_wait(session, scope=_SCOPE_USER, key=username, now=now)
|
||||
return max(wait_ip, wait_user)
|
||||
|
||||
|
||||
def register_failure(session: Session, *, ip: str, username: str) -> None:
|
||||
"""Record one login failure for both the IP and the username keys.
|
||||
|
||||
Updates (or creates) the throttle row for each key, increments the failure
|
||||
counter, and recomputes ``next_allowed_at`` using the exponential formula.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
_upsert_failure(session, scope=_SCOPE_IP, key=ip, now=now)
|
||||
_upsert_failure(session, scope=_SCOPE_USER, key=username, now=now)
|
||||
session.commit()
|
||||
|
||||
|
||||
def clear(session: Session, *, ip: str, username: str) -> None:
|
||||
"""Delete the throttle rows for the given IP and username (successful login).
|
||||
|
||||
It is safe to call this even if no rows exist for the given keys.
|
||||
"""
|
||||
session.execute(
|
||||
delete(LoginThrottle).where(
|
||||
(LoginThrottle.scope == _SCOPE_IP) & (LoginThrottle.key == ip)
|
||||
| (LoginThrottle.scope == _SCOPE_USER) & (LoginThrottle.key == username)
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _remaining_wait(session: Session, *, scope: str, key: str, now: datetime) -> int:
|
||||
"""Return the number of seconds that ``key`` in ``scope`` must still wait.
|
||||
|
||||
Returns 0 if the key has no throttle row or if the back-off window has
|
||||
already expired.
|
||||
"""
|
||||
row = session.scalar(
|
||||
select(LoginThrottle)
|
||||
.where(LoginThrottle.scope == scope, LoginThrottle.key == key)
|
||||
.limit(1)
|
||||
)
|
||||
if row is None or row.next_allowed_at is None:
|
||||
return 0
|
||||
|
||||
next_allowed = _as_utc(row.next_allowed_at)
|
||||
if next_allowed <= now:
|
||||
return 0
|
||||
|
||||
remaining = (next_allowed - now).total_seconds()
|
||||
# Round up so callers are never told "0 seconds" while still blocked.
|
||||
return math.ceil(remaining)
|
||||
|
||||
|
||||
def _compute_next_allowed_at(failures: int, now: datetime) -> datetime | None:
|
||||
"""Compute the earliest time the key should be allowed to try again.
|
||||
|
||||
Returns ``None`` for the first ``N_FREE`` failures (no delay).
|
||||
"""
|
||||
if failures <= N_FREE:
|
||||
return None
|
||||
wait_s = min(BACKOFF_CAP_S, BACKOFF_BASE_S * (2 ** (failures - N_FREE)))
|
||||
return now + timedelta(seconds=wait_s)
|
||||
|
||||
|
||||
def _upsert_failure(session: Session, *, scope: str, key: str, now: datetime) -> None:
|
||||
"""Create or update the throttle row for (scope, key) with one more failure."""
|
||||
row = session.scalar(
|
||||
select(LoginThrottle)
|
||||
.where(LoginThrottle.scope == scope, LoginThrottle.key == key)
|
||||
.limit(1)
|
||||
)
|
||||
if row is None:
|
||||
new_failures = 1
|
||||
row = LoginThrottle(
|
||||
scope=scope,
|
||||
key=key,
|
||||
failures=new_failures,
|
||||
first_failed_at=now,
|
||||
last_failed_at=now,
|
||||
next_allowed_at=_compute_next_allowed_at(new_failures, now),
|
||||
)
|
||||
session.add(row)
|
||||
else:
|
||||
row.failures += 1
|
||||
row.last_failed_at = now
|
||||
row.next_allowed_at = _compute_next_allowed_at(row.failures, now)
|
||||
|
||||
|
||||
def _as_utc(value: datetime | None) -> datetime | None:
|
||||
"""Normalise a possibly-naive datetime to UTC-aware."""
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
@@ -0,0 +1,234 @@
|
||||
"""TOTP service: secret management, recovery-code lifecycle, setup/enable/disable (M4-T05).
|
||||
|
||||
State-machine summary
|
||||
---------------------
|
||||
DISABLED (default)
|
||||
totp_secret=None, totp_enabled=False, no recovery codes
|
||||
|
||||
PENDING (after setup, before enable)
|
||||
totp_secret=<base32>, totp_enabled=False, recovery codes stored as hashes
|
||||
- Re-calling setup replaces the pending secret and regenerates recovery codes.
|
||||
|
||||
ENABLED (after enable)
|
||||
totp_secret=<base32>, totp_enabled=True, recovery codes stored as hashes
|
||||
|
||||
After disable:
|
||||
totp_secret=None, totp_enabled=False, recovery codes deleted → back to DISABLED
|
||||
|
||||
|
||||
Recovery-code timing
|
||||
--------------------
|
||||
1. setup → generate 10 plaintext codes, persist their Argon2 hashes immediately,
|
||||
return plaintext to caller (ONLY time).
|
||||
2. enable → verify 6-digit TOTP code; if ok, flip totp_enabled=True.
|
||||
Recovery codes are already in the DB from step 1.
|
||||
3. disable → clear secret, clear enabled flag, delete all recovery codes.
|
||||
|
||||
Security note: ``totp_secret`` is stored as plaintext (same posture as other
|
||||
secrets in this project: protected by file-system permissions on the SQLite
|
||||
database). Recovery codes are stored as Argon2 hashes only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
|
||||
import pyotp
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.auth import AuthUser, RecoveryCode
|
||||
from app.services.auth import hash_password, verify_password
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Number of recovery codes to generate per setup
|
||||
_RECOVERY_CODE_COUNT = 10
|
||||
# Alphabet for each 4-character segment: lowercase letters + digits, no ambiguous chars
|
||||
_CODE_ALPHABET = string.ascii_lowercase + string.digits
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _generate_recovery_code() -> str:
|
||||
"""Return a single recovery code in ``xxxx-xxxx`` format."""
|
||||
segment = lambda: "".join(secrets.choice(_CODE_ALPHABET) for _ in range(4)) # noqa: E731
|
||||
return f"{segment()}-{segment()}"
|
||||
|
||||
|
||||
def _verify_totp_code(secret: str, code: str) -> bool:
|
||||
"""Verify a 6-digit TOTP code against the given base-32 secret (±1 window)."""
|
||||
return pyotp.TOTP(secret).verify(code, valid_window=1)
|
||||
|
||||
|
||||
def _delete_recovery_codes(db: Session, *, user_id: int) -> None:
|
||||
"""Delete ALL recovery codes for a user (called on setup re-run and disable)."""
|
||||
db.execute(delete(RecoveryCode).where(RecoveryCode.user_id == user_id))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def setup(
|
||||
db: Session,
|
||||
*,
|
||||
user: AuthUser,
|
||||
issuer: str,
|
||||
) -> tuple[str, str, list[str]]:
|
||||
"""Generate a new pending TOTP secret and recovery codes.
|
||||
|
||||
The secret is stored in ``user.totp_secret`` (``totp_enabled`` stays
|
||||
``False``). Any pre-existing pending secret and recovery codes are
|
||||
replaced atomically.
|
||||
|
||||
Returns
|
||||
-------
|
||||
(secret, otpauth_uri, plaintext_recovery_codes)
|
||||
|
||||
``plaintext_recovery_codes`` are returned **once** here and MUST NOT be
|
||||
stored or returned elsewhere.
|
||||
"""
|
||||
# Generate new TOTP secret
|
||||
new_secret = pyotp.random_base32()
|
||||
otpauth_uri = pyotp.TOTP(new_secret).provisioning_uri(
|
||||
name=user.username,
|
||||
issuer_name=issuer,
|
||||
)
|
||||
|
||||
# Generate plaintext recovery codes
|
||||
plaintext_codes = [_generate_recovery_code() for _ in range(_RECOVERY_CODE_COUNT)]
|
||||
|
||||
# --- Persist atomically ---
|
||||
# 1. Delete any old pending recovery codes (idempotent on re-setup)
|
||||
assert user.id is not None # mypy guard; always set after DB insertion
|
||||
_delete_recovery_codes(db, user_id=user.id)
|
||||
|
||||
# 2. Update the secret (pending — totp_enabled stays False)
|
||||
user.totp_secret = new_secret
|
||||
|
||||
# 3. Persist new recovery codes as Argon2 hashes
|
||||
for plaintext in plaintext_codes:
|
||||
db.add(RecoveryCode(user_id=user.id, code_hash=hash_password(plaintext)))
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
logger.info("TOTP setup (pending) for user '%s'; %d recovery codes generated.", user.username, _RECOVERY_CODE_COUNT)
|
||||
return new_secret, otpauth_uri, plaintext_codes
|
||||
|
||||
|
||||
def enable(db: Session, *, user: AuthUser, code: str) -> bool:
|
||||
"""Enable TOTP after the user proves they can generate the correct code.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
code:
|
||||
The current 6-digit TOTP code from the user's authenticator app.
|
||||
|
||||
Returns
|
||||
-------
|
||||
True on success; False if the code is invalid or no secret is pending.
|
||||
"""
|
||||
if not user.totp_secret:
|
||||
logger.info("TOTP enable rejected for '%s': no pending secret.", user.username)
|
||||
return False
|
||||
|
||||
if not _verify_totp_code(user.totp_secret, code):
|
||||
logger.info("TOTP enable rejected for '%s': wrong code.", user.username)
|
||||
return False
|
||||
|
||||
user.totp_enabled = True
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
logger.info("TOTP enabled for user '%s'.", user.username)
|
||||
return True
|
||||
|
||||
|
||||
def disable(
|
||||
db: Session,
|
||||
*,
|
||||
user: AuthUser,
|
||||
password: str | None = None,
|
||||
code: str | None = None,
|
||||
) -> bool:
|
||||
"""Disable TOTP. Caller must supply exactly one of ``password`` or ``code``.
|
||||
|
||||
On success: ``totp_enabled=False``, ``totp_secret=None``, all recovery
|
||||
codes deleted.
|
||||
|
||||
Returns
|
||||
-------
|
||||
True on success; False if neither credential is valid.
|
||||
"""
|
||||
if not password and not code:
|
||||
logger.info("TOTP disable rejected for '%s': no credential provided.", user.username)
|
||||
return False
|
||||
|
||||
if password:
|
||||
if not verify_password(password, user.password_hash):
|
||||
logger.info("TOTP disable rejected for '%s': wrong password.", user.username)
|
||||
return False
|
||||
elif code:
|
||||
if not user.totp_secret or not _verify_totp_code(user.totp_secret, code):
|
||||
logger.info("TOTP disable rejected for '%s': wrong TOTP code.", user.username)
|
||||
return False
|
||||
|
||||
# Clear TOTP state
|
||||
assert user.id is not None
|
||||
_delete_recovery_codes(db, user_id=user.id)
|
||||
user.totp_enabled = False
|
||||
user.totp_secret = None
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
logger.info("TOTP disabled for user '%s'.", user.username)
|
||||
return True
|
||||
|
||||
|
||||
def verify_totp_code(user: AuthUser, code: str) -> bool:
|
||||
"""Verify a 6-digit TOTP code for a user.
|
||||
|
||||
Returns True if the code is valid (±1 time window), False otherwise.
|
||||
The user must have a ``totp_secret`` set; returns False if not.
|
||||
|
||||
This is the public entry-point for T06 login two-factor verification.
|
||||
"""
|
||||
if not user.totp_secret:
|
||||
return False
|
||||
return _verify_totp_code(user.totp_secret, code)
|
||||
|
||||
|
||||
def verify_recovery_code(db: Session, *, user: AuthUser, code: str) -> bool:
|
||||
"""Verify and consume a one-time recovery code.
|
||||
|
||||
Finds the first unused recovery code whose hash matches ``code``, marks it
|
||||
as consumed (sets ``used_at``), and returns ``True``. Returns ``False`` if
|
||||
no matching unused code exists.
|
||||
|
||||
This function is provided for T06 (login two-factor) but lives here so the
|
||||
TOTP service owns all recovery-code logic.
|
||||
"""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
unused = db.execute(
|
||||
select(RecoveryCode).where(
|
||||
RecoveryCode.user_id == user.id,
|
||||
RecoveryCode.used_at.is_(None),
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
for rc in unused:
|
||||
if verify_password(code, rc.code_hash):
|
||||
rc.used_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
logger.info(
|
||||
"Recovery code consumed for user '%s' (id=%d).", user.username, rc.id
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -20,9 +20,9 @@ services:
|
||||
build: .
|
||||
image: home-automation:dev
|
||||
container_name: home-automation-app-dev
|
||||
# Publish on 8001 for dev. `!override` REPLACES the base ports list instead of
|
||||
# Publish on 8002 for dev. `!override` REPLACES the base ports list instead of
|
||||
# appending to it, so the dev stack does NOT also bind the production 8881.
|
||||
ports: !override
|
||||
- "127.0.0.1:8001:8000"
|
||||
- "127.0.0.1:8002:8000"
|
||||
environment:
|
||||
APP_DATABASE_URL: "sqlite:////app/data/app.db"
|
||||
|
||||
@@ -69,12 +69,30 @@ React SPA 前端(M2 引入)。Vite + React + TypeScript + Mantine,由 Fast
|
||||
|
||||
### `scripts/`
|
||||
|
||||
辅助脚本目录。当前包含 OpenAPI 导出脚本(`export_openapi.py`)与数据层辅助脚本。
|
||||
辅助脚本目录。当前包含:
|
||||
|
||||
- `export_openapi.py`:导出 OpenAPI schema 静态产物
|
||||
- `run_migrations.py`:运行 Alembic migration
|
||||
- `app_db_adopt.py`:App DB 接管 / 初始化
|
||||
- `migrate_legacy_data.py`:一次性历史数据搬迁脚本
|
||||
- `admin_cli.py`:Admin CLI 逃生通道(M4),见下方"登录加固"说明
|
||||
|
||||
### `openapi/`
|
||||
|
||||
OpenAPI schema 静态产物(`openapi.json` / `openapi.yaml`),由 `python scripts/export_openapi.py` 生成,纳入版本控制。前端 codegen 以此为契约源。
|
||||
|
||||
## 登录加固(M4)
|
||||
|
||||
M4 在基础 Argon2 + server-side session 鉴权之上叠加了三层防御:
|
||||
|
||||
**防爆破 / 指数退避**:`app/services/login_throttle.py` 按 client IP 与 username 双键记失败计数,失败超过 3 次后指数增长等待时间(最长 15 分钟),`POST /api/auth/login` 在退避窗口内直接返回 `429 + Retry-After`,不执行 Argon2 验证。成功登录后清零。退避是延迟而非永久封号;全局开关 `AUTH_LOGIN_THROTTLE_ENABLED`(CONFIG_FIELDS,默认开);反代后需设 `AUTH_TRUST_FORWARDED_FOR=true`(`.env` 部署级,默认 false)。
|
||||
|
||||
**CLI 逃生通道**:`scripts/admin_cli.py`(入口 `python -m scripts.admin_cli`)直连本地 DB,**无需 HTTP 服务运行、无需任何已存凭据**,支持:重置密码(`reset-password`)、解锁退避(`unlock`)、关停 TOTP(`disable-totp`,零凭据最终逃生)、重新发放 TOTP secret(`reissue-totp`)、查看用户列表(`list-admin`)。CLI 只动 auth 行,不触碰用户数据表。
|
||||
|
||||
**可选 TOTP 二次验证**:admin 可在设置页自选启用 RFC 6238 TOTP。启用后登录为两步(密码 → 6 位动态码或一次性恢复码);不启用维持纯密码。TOTP secret 明文存库(与其他 secret 一致,靠文件权限保护);恢复码以 Argon2 哈希存储,使用后消费(一次性)。后端使用 `pyotp`,二维码由前端 `qrcode.react` 渲染。issuer 标签由 `AUTH_TOTP_ISSUER`(`.env` 部署级)配置,默认回退 `app_name`。
|
||||
|
||||
详细说明:[`docs/auth.md`](./auth.md)
|
||||
|
||||
## 当前约束
|
||||
|
||||
- 当前数据库继续使用 SQLite
|
||||
|
||||
+191
-82
@@ -1,120 +1,229 @@
|
||||
# 基础鉴权说明
|
||||
# 鉴权说明
|
||||
|
||||
本文档说明当前 Python 重构项目里已经落地的第一版鉴权基座。
|
||||
|
||||
这一轮只解决:
|
||||
|
||||
- 登录页
|
||||
- 登录 / 登出流程
|
||||
- server-side session
|
||||
- 一个最小受保护页面
|
||||
|
||||
这一轮明确不解决:
|
||||
|
||||
- 完整 config persistence
|
||||
- 完整 config CRUD
|
||||
- 多用户权限系统
|
||||
- OAuth / SSO / RBAC
|
||||
本文档说明当前已落地的鉴权基座(基础 session 鉴权 + M4 登录加固)。
|
||||
|
||||
## 当前 auth 模型
|
||||
|
||||
- 认证方式:`username/password`
|
||||
- 认证方式:`username/password`(可选启用 TOTP 二次验证)
|
||||
- 会话方式:server-side session
|
||||
- 客户端凭据:session cookie
|
||||
- 页面形态:Jinja server-side template
|
||||
|
||||
## 当前持久化
|
||||
## 持久化
|
||||
|
||||
当前新增一个共享 App DB:
|
||||
所有 auth 相关数据存放在单一 App DB(`APP_DATABASE_URL`,默认 `sqlite:///./data/app.db`)中:
|
||||
|
||||
- `APP_DATABASE_URL`
|
||||
- 默认值:`sqlite:///./data/app.db`
|
||||
|
||||
当前 auth 相关数据存放在这个 DB 中:
|
||||
|
||||
- `auth_users`
|
||||
- `auth_sessions`
|
||||
- `app_config`
|
||||
|
||||
当前没有把 auth 数据和 `location` / `poo` DB 混放。
|
||||
|
||||
当前这部分现在也走 Alembic 管理:
|
||||
|
||||
- Alembic 环境:`alembic_app.ini` + `alembic_app/`
|
||||
- 初始化脚本:`python scripts/app_db_adopt.py`
|
||||
|
||||
当前没有 legacy app DB,所以这一版脚本只负责初始化新库,不负责 legacy adoption。
|
||||
|
||||
`app_config` 现在承接运行时配置持久化。
|
||||
|
||||
其中:
|
||||
|
||||
- `.env` 负责 bootstrap / fallback
|
||||
- `app_config` 表负责运行时配置覆盖
|
||||
- 登录密码仍然属于认证数据,使用 Argon2 哈希,不存进 `app_config`
|
||||
- `auth_users`:用户表(含 TOTP 字段 `totp_secret` / `totp_enabled`)
|
||||
- `auth_sessions`:session token 哈希与过期时间
|
||||
- `auth_login_throttle`:登录失败退避状态(按 IP / username 双键)
|
||||
- `auth_recovery_code`:TOTP 恢复码哈希(一次性)
|
||||
- `app_config`:runtime 配置持久化
|
||||
|
||||
## 首次启动与 bootstrap
|
||||
|
||||
如果 auth DB 中还没有任何用户,应用启动时会要求:
|
||||
如果 auth DB 中还没有任何用户,应用启动时会使用:
|
||||
|
||||
- `AUTH_BOOTSTRAP_USERNAME`
|
||||
- `AUTH_BOOTSTRAP_PASSWORD`
|
||||
|
||||
并创建首个 admin 用户。
|
||||
|
||||
当前默认 bootstrap 值就是:
|
||||
创建首个 admin 用户。当前默认 bootstrap 值为:
|
||||
|
||||
- username: `admin`
|
||||
- password: `admin`
|
||||
|
||||
首次登录后,系统会强制要求修改密码。
|
||||
|
||||
如果你希望在首次启动前就覆盖默认值,可以直接设置环境变量:
|
||||
## 基础安全设计
|
||||
|
||||
- `AUTH_BOOTSTRAP_USERNAME`
|
||||
- `AUTH_BOOTSTRAP_PASSWORD`
|
||||
|
||||
建议流程是:
|
||||
|
||||
1. 配好 `.env`
|
||||
2. 运行 `python scripts/app_db_adopt.py`
|
||||
3. 启动应用
|
||||
4. 用 `admin / admin` 首次登录
|
||||
5. 立即修改密码
|
||||
|
||||
## 安全设计
|
||||
|
||||
当前这版已经落实的基础安全点:
|
||||
当前这版已经落实的安全点:
|
||||
|
||||
- 密码不明文存储,使用 Argon2 哈希
|
||||
- session cookie 为 `HttpOnly`
|
||||
- cookie 使用 `SameSite=Lax`
|
||||
- `Secure` cookie 在非 `development` 环境默认开启
|
||||
- 登录表单与登出表单都有基础 CSRF 校验
|
||||
- 写请求(POST/PUT/PATCH/DELETE)需携带 `X-CSRF-Token` header(SameSite=Lax + 自定义 header 纵深防御,无需 per-session token 值比对)
|
||||
- session token 为随机生成,服务端只持久化 token hash
|
||||
- session 有过期时间与显式失效机制
|
||||
- session 有过期时间(默认 12 小时)与显式失效机制
|
||||
|
||||
## 当前受保护范围
|
||||
---
|
||||
|
||||
当前这轮只保护了页面入口:
|
||||
## M4 登录加固
|
||||
|
||||
- `GET /config`
|
||||
- `POST /config`
|
||||
- `POST /config/change-password`
|
||||
- `POST /logout`
|
||||
M4 在基础鉴权之上叠加了三层防御:防爆破/指数退避、CLI 逃生通道、可选 TOTP 二次验证。
|
||||
|
||||
相关流程:
|
||||
### 1. 防爆破 / 指数退避
|
||||
|
||||
- `GET /login`
|
||||
- `POST /login`
|
||||
#### 机制
|
||||
|
||||
未登录访问 `/config` 时会被重定向到 `/login`。
|
||||
登录失败按指数增长延迟,目标是拖垮暴力枚举,同时不因此永久锁定账号。
|
||||
|
||||
## 下一步不在本轮内
|
||||
**退避是延迟(429 + Retry-After),不是永久封号**——单 admin 场景下永久锁会被攻击者反向用来故意打锁,所以退避只增加等待时间,CLI 是最终逃生口。
|
||||
|
||||
后续可以在这个基座上继续做:
|
||||
#### 双键计算
|
||||
|
||||
- 配置页面接入
|
||||
- config persistence
|
||||
- 更细的受保护路由范围
|
||||
- 用户初始化 / 密码轮换的更正式 runbook
|
||||
每次登录请求同时按 **client IP** 和 **username** 各记一套失败计数,本次需等待时间 = 两者退避的**较大值**。
|
||||
|
||||
- 按 IP:堵单点暴力(攻击 IP 越敲越慢,合法用户换 IP 不受影响)
|
||||
- 按 username:全局兜底(即便攻击者分布式换 IP 也有一层保护)
|
||||
|
||||
#### 指数公式
|
||||
|
||||
```
|
||||
N_FREE = 3 — 前 3 次失败不延迟(免费次数)
|
||||
BASE = 1 秒
|
||||
CAP = 900 秒(15 分钟)
|
||||
|
||||
wait = min(CAP, BASE × 2^(failures - N_FREE)) (failures > N_FREE 时生效)
|
||||
```
|
||||
|
||||
延迟示意:
|
||||
|
||||
| 累计失败次数 | 等待时间 |
|
||||
| --- | --- |
|
||||
| 1–3 | 0 秒(免费) |
|
||||
| 4 | 2 秒 |
|
||||
| 5 | 4 秒 |
|
||||
| 6 | 8 秒 |
|
||||
| 7 | 16 秒 |
|
||||
| … | … |
|
||||
| 13+ | 900 秒(封顶) |
|
||||
|
||||
成功登录后,该 IP 和 username 的退避状态**立即清零**。
|
||||
|
||||
#### 登录端点行为(`POST /api/auth/login`)
|
||||
|
||||
1. 先查退避(在窗口内直接 `429`,**不验密码**,节省 Argon2 计算并防止枚举)
|
||||
2. 验密码失败 → 记一次失败(IP + username 各记),返回 `401`
|
||||
3. 密码正确但 TOTP 启用且缺少 `totp_code` → 返回 `401 {totp_required: true}`,**不记失败**(这是正常两步流程的第一步,不是攻击信号)
|
||||
4. 密码正确但 TOTP 验证失败 → 记一次失败,返回 `401`
|
||||
5. 全部通过 → 清零退避状态,发 session cookie
|
||||
|
||||
#### 配置项
|
||||
|
||||
| 配置项 | 类型 | 默认 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `AUTH_LOGIN_THROTTLE_ENABLED` | bool(CONFIG_FIELDS) | `true` | 全局开关,关闭后整个退避机制 no-op |
|
||||
| `AUTH_TRUST_FORWARDED_FOR` | bool(`.env` 部署级) | `false` | `true` 时取 `X-Forwarded-For` 最左作为 client IP;**反代后必须显式开启**,否则反代 IP 全视为同一 IP,按 IP 退避失效 |
|
||||
|
||||
> **反代部署注意**:`AUTH_TRUST_FORWARDED_FOR` 默认 `false`(直接用 socket IP)。部署在 nginx 等反代后面时,应设 `AUTH_TRUST_FORWARDED_FOR=true`,并确保反代正确设置 `X-Forwarded-For`。
|
||||
|
||||
---
|
||||
|
||||
### 2. CLI 逃生通道
|
||||
|
||||
#### 设计原则
|
||||
|
||||
CLI 通过 **直连本地 DB**(`get_session_local()`)工作,**无需 HTTP 服务器运行、无需任何已存凭据**(密码、恢复码均不需要)。拿到服务器 CLI 权限本身就意味着对系统有完全控制,因此这是可接受且必须存在的最终逃生口。
|
||||
|
||||
CLI 只动 auth 相关行(`auth_users` 的密码/TOTP 字段、`auth_login_throttle`、`auth_recovery_code`),**绝不**触碰用户数据表(`location`、`poo_records`、`public_ip_state` 等)。
|
||||
|
||||
入口:`python -m scripts.admin_cli <command>`
|
||||
|
||||
#### 子命令
|
||||
|
||||
| 命令 | 用途 | 逃生场景 |
|
||||
| --- | --- | --- |
|
||||
| `reset-password <username> [--password <pwd>]` | 重置密码(不带 `--password` 则交互式输入,不回显) | 忘记密码 |
|
||||
| `unlock [--all \| --ip <ip> \| --username <u>]` | 清 `auth_login_throttle` 行 | 被退避锁住(429)时解锁 |
|
||||
| `disable-totp <username>` | 关停 TOTP(清 secret + 删全部恢复码),**零凭据可执行** | 恢复码全丢也能进 |
|
||||
| `reissue-totp <username>` | 生成新 TOTP secret 并打印 `otpauth://` URI | 设备丢失、需重新注册 Authenticator |
|
||||
| `list-admin` | 列出所有用户与状态列 | 排障用 |
|
||||
|
||||
#### 使用示例
|
||||
|
||||
```bash
|
||||
# 重置密码(不带 --password 时交互式 prompt,不回显)
|
||||
python -m scripts.admin_cli reset-password admin
|
||||
|
||||
# 重置密码(非交互,脚本里用)
|
||||
python -m scripts.admin_cli reset-password admin --password "newpassword"
|
||||
|
||||
# 解锁所有退避
|
||||
python -m scripts.admin_cli unlock --all
|
||||
|
||||
# 解锁特定 IP
|
||||
python -m scripts.admin_cli unlock --ip 1.2.3.4
|
||||
|
||||
# 解锁特定 username
|
||||
python -m scripts.admin_cli unlock --username admin
|
||||
|
||||
# 关停 TOTP(零凭据,最终逃生口)
|
||||
python -m scripts.admin_cli disable-totp admin
|
||||
|
||||
# 重新发放 TOTP secret(打印新 otpauth:// URI,扫码重新注册)
|
||||
python -m scripts.admin_cli reissue-totp admin
|
||||
|
||||
# 查看用户列表
|
||||
python -m scripts.admin_cli list-admin
|
||||
```
|
||||
|
||||
在 Docker 容器内执行:
|
||||
|
||||
```bash
|
||||
docker compose exec app python -m scripts.admin_cli <command>
|
||||
```
|
||||
|
||||
#### `reissue-totp` 语义说明
|
||||
|
||||
- 对**已启用** TOTP 的用户:新 secret **立即在登录时生效**,旧 Authenticator 生成的码立即失效;**无需**再走 web `enable` 步骤。
|
||||
- 现有恢复码**不被删除**——恢复码是独立的随机哈希值,与 TOTP secret 无密码学绑定,reissue 后恢复码依然有效(仍可用于登录)。
|
||||
- 如果需要完整清理(secret + 所有恢复码),使用 `disable-totp`。
|
||||
|
||||
---
|
||||
|
||||
### 3. 可选 TOTP 二次验证
|
||||
|
||||
#### 设计
|
||||
|
||||
- TOTP 遵循 RFC 6238,使用 `pyotp` 库。
|
||||
- 二维码在**前端**由 `qrcode.react` 渲染(后端只返回 `otpauth://` URI,不引图像依赖)。
|
||||
- `totp_secret` 明文存库(与项目其他 secret 处理一致,靠数据库文件权限保护)。
|
||||
- 恢复码以 Argon2 哈希存库,一次性(使用后标记 `used_at`)。
|
||||
|
||||
#### 启用流程
|
||||
|
||||
1. **setup**(`POST /api/auth/totp/setup`):生成 pending secret,返回 secret、`otpauth://` URI、10 个明文恢复码(**仅此一次**);此时 `totp_enabled` 仍为 `false`。
|
||||
2. **扫码**:用 Authenticator App 扫前端渲染的二维码(或手动输入 secret)。
|
||||
3. **enable**(`POST /api/auth/totp/enable`):输入当前 6 位码确认 → `totp_enabled` 变为 `true`。
|
||||
4. 妥善保存恢复码(不会再次展示)。
|
||||
|
||||
#### 停用流程
|
||||
|
||||
**web 停用**(`POST /api/auth/totp/disable`):需提供当前密码或当前 6 位 TOTP 码。成功后清 secret、删全部恢复码,恢复纯密码登录。
|
||||
|
||||
**CLI 停用**(逃生口,恢复码全丢时):`python -m scripts.admin_cli disable-totp admin`,零凭据,立即生效。
|
||||
|
||||
#### 登录二步流程
|
||||
|
||||
1. 提交 `POST /api/auth/login {username, password}` → 密码正确但 TOTP 已启用 → `401 {totp_required: true}`(不发 session)
|
||||
2. 前端切到第二屏,提交 `POST /api/auth/login {username, password, totp_code}` → 通过 → 发 session cookie
|
||||
|
||||
`totp_code` 可以是 6 位 TOTP 动态码,也可以是 `xxxx-xxxx` 格式恢复码(命中即消费,不可复用)。
|
||||
|
||||
#### API 端点
|
||||
|
||||
| 端点 | 用途 |
|
||||
| --- | --- |
|
||||
| `POST /api/auth/totp/setup` | 生成 pending secret + URI + 恢复码(一次性明文返回) |
|
||||
| `POST /api/auth/totp/enable` | 带当前 6 位码确认启用 |
|
||||
| `POST /api/auth/totp/disable` | 带密码或当前码停用 |
|
||||
| `GET /api/auth/totp` | 返回当前 TOTP 状态(`{enabled: bool}`),不返回 secret/恢复码 |
|
||||
|
||||
全部端点需要 session cookie(`GET /api/auth/totp` 不需 CSRF;其余写端点需 `X-CSRF-Token`)。
|
||||
|
||||
#### 配置项
|
||||
|
||||
| 配置项 | 类型 | 默认 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `AUTH_TOTP_ISSUER` | str(`.env` 部署级) | 空(回退 `app_name`) | 显示在 Authenticator App 里的 issuer 标签 |
|
||||
|
||||
---
|
||||
|
||||
## 受保护范围
|
||||
|
||||
当前 JSON API 端点(`/api/*`)需要 session cookie;写端点需额外携带 `X-CSRF-Token` header。
|
||||
|
||||
裸 ingestion 端点(`/location/record`、`/poo/record` 等设备调用端点)暂未收口到 session 保护(M3 计划引入 token 鉴权)。
|
||||
|
||||
## 下一步(不在当前范围)
|
||||
|
||||
- M3:token 鉴权(供脚本 / 设备 / 移动端调用 API),ingestion 端点收口。
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
- [`m1-db-consolidation.md`](./m1-db-consolidation.md) — 单库化地基
|
||||
- [`m2-frontend-v2.md`](./m2-frontend-v2.md) — React SPA 前端 v2
|
||||
- [`m3-token-mobile.md`](./m3-token-mobile.md) — token 鉴权与移动端(远期)
|
||||
- [`m4-login-hardening.md`](./m4-login-hardening.md) — 登录加固(防爆破/指数退避 + CLI 逃生 + 可选 TOTP)**先做**
|
||||
- [`m5-iot-energy.md`](./m5-iot-energy.md) — IoT 集成与能耗采集(Modbus/Energy + MQTT/HA Discovery + 前端侧边栏)
|
||||
|
||||
本文件定义**所有任务共用的格式与协作规则**,三个里程碑文档不再重复这些约定。
|
||||
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
# 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"的排期。
|
||||
@@ -0,0 +1,441 @@
|
||||
# M5 — IoT 集成与能耗采集(Modbus/Energy + MQTT/HA Discovery + 前端侧边栏)
|
||||
|
||||
> 阅读前提:先读 [`README.md`](./README.md)(协作模型、任务卡格式、校验闸门、数据安全红线)。本里程碑建立在 M1 单库 + M2 React SPA 之上。
|
||||
> 配套参考:电表协议见 [`../references/SDM120-Modbus-Protocol.md`](../references/SDM120-Modbus-Protocol.md)。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
给后端接入家庭 IoT 生态,并新增一条能耗采集链路:
|
||||
|
||||
1. **Modbus/Energy 采集**:通过 Modbus-TCP 网关周期读取电表(首个 profile 为 SDM120 单相),解码为工程量,存入单库的能耗表。后台静默轮询,支持多电表。
|
||||
2. **MQTT + Home Assistant Discovery**:后端作为 MQTT 发布方,按"**可勾选暴露**"的方式把数据以 HA Discovery 自动注册成 device/entity(不止 sensor)。
|
||||
3. **前端侧边栏**:把现有顶栏改成侧边导航,承载新的 Energy 视图(电表管理 + 最新读数 + 走势图)。
|
||||
|
||||
> 三段有依赖关系,按 §6 的 `Depends` 顺序推进:A(侧边栏,独立)→ B(Energy 后端 + 前端)→ C(MQTT/Discovery,消费 B 的数据)。
|
||||
|
||||
## 2. 现状(实现者可据此工作,不必通读全仓库)
|
||||
|
||||
**单库数据层**(M1 完成态)
|
||||
- `app/db.py`:`class Base(DeclarativeBase)`,绑 `settings.app_database_url` 的 cached engine(WAL 已开),`get_engine` / `get_session_local` / `reset_db_caches` / `get_db_session`。
|
||||
- 模型都继承同一 `Base`:`app/models/{auth,config,public_ip,location,poo}.py`。
|
||||
- 单 Alembic 链 `alembic_app/`,head = `20260611_06_merge_location_poo_tables`(见 `alembic_app/versions/`);`alembic_app/env.py` 逐个 import 所有模型。
|
||||
- 迁移命名惯例:`YYYYMMDD_NN_<desc>.py`,`revision` / `down_revision` 串链。
|
||||
- `scripts/app_db_adopt.py` 常量 `APP_BASELINE_REVISION` 指向当前 head;`scripts/run_migrations.py` 负责把 app 库升到 head。
|
||||
|
||||
**配置系统(扁平 KV,自动渲染)**
|
||||
- `app/config.py`:`class Settings(BaseSettings)`,每个配置项一个带类型的字段 + 默认值。
|
||||
- `app/services/config_page.py`:`CONFIG_FIELDS: tuple[ConfigField, ...]` 是**注册表**(`section / env_name / setting_attr / label / secret / input_type`);`build_config_sections`(读,secret 回空串)、`save_config_updates`(写,空 secret 保留旧值)、`build_runtime_settings`(DB override 合并进 Settings)、`_settings_payload`(把 Settings 摊平成 dict,新字段要在此补一行)。
|
||||
- `app/api/routes/api/config.py`:`GET/PUT /api/config`,session + CSRF 保护;非法值 422 且不写库。
|
||||
- 前端 `frontend/src/pages/ConfigPage.tsx`:**通用渲染**——按 section 分组、按 `input_type`/`secret` 渲染输入框。**新增标量配置项零前端改动**(追加 `CONFIG_FIELDS` + `Settings` 字段 + `_settings_payload` 一行即可)。
|
||||
- ⚠️ 扁平 KV **装不下"电表列表"和"逐实体勾选"**——这两者走专用表 + 专用 API + 自定义 UI(见 §3.2 / §3.4)。
|
||||
|
||||
**后台调度(APScheduler)**
|
||||
- `app/main.py` lifespan:`BackgroundScheduler(timezone="UTC")`,`scheduler.add_job(_run_scheduled_public_ip_check, IntervalTrigger(hours=4), id=..., max_instances=1, coalesce=True)`,`scheduler.start()`;`yield` 后 `scheduler.shutdown(wait=False)`。
|
||||
- 周期任务惯例:一个**同步** wrapper 自己开/关 session:`session = get_session_local()(); try: service(session, ...) finally: session.close()`,service 内部不抛崩溃。
|
||||
|
||||
**Home Assistant 现状(REST,不碰 MQTT)**
|
||||
- `app/integrations/homeassistant.py`:`HomeAssistantClient.publish_sensor()`(`POST /api/states/{entity}`)、`trigger_webhook()`。
|
||||
- 入站 webhook `app/api/routes/homeassistant.py`:`POST /homeassistant/publish`(envelope `target/action/content`)。
|
||||
- 新 MQTT Discovery 与此**并行、不冲突**,是第二条独立通道。
|
||||
|
||||
**前端(M2)**
|
||||
- React + react-router v6 + Mantine + TanStack Query + `openapi-fetch` 生成的类型化 client(`frontend/src/api/client.ts` + `schema.d.ts`)。
|
||||
- `frontend/src/App.tsx`:`AppLayout`(当前是**顶栏**),包住所有受保护页;路由 `/`(HomePage 地图)、`/config`、`/records`;`/login`、`/change-password` 不带 layout。
|
||||
- 数据请求惯例:`useQuery`/`useMutation` + `apiClient.GET/POST/...`(见 `frontend/src/records/hooks.ts`)。
|
||||
- **无图表库**(只有 Leaflet 地图);走势图需新引入 **Recharts**。
|
||||
|
||||
## 3. 目标架构
|
||||
|
||||
### 3.1 Modbus / Energy 采集
|
||||
|
||||
- **传输:仅 Modbus-TCP**(用户的 Waveshare RTU↔TCP 网关,RJ45 以太网;服务器无串口)。用 **pymodbus** 的 `ModbusTcpClient`,由它处理封帧 / CRC / 超时重试 / float 解码。
|
||||
- 网关若开"Modbus TCP"协议转换 → pymodbus 默认 framer 直连。
|
||||
- 网关若是透传(RTU-over-TCP,裸 RTU 帧含 CRC)→ pymodbus 用 RTU framer over TCP。
|
||||
- **二者实现时对一次即可确定**(连上读 Voltage 寄存器验证),不影响表结构与上层。
|
||||
- **协议知识在代码(profile/driver),部署信息在 DB(meters 表)**:
|
||||
- `app/integrations/modbus.py`:薄封装 pymodbus 的连接 + 块读 + 大端 float32 解码(word/byte 都大端,高寄存器在前)。
|
||||
- `app/integrations/energy_profiles.py`:device profile 定义"读哪些寄存器、解码成哪个工程量"。首个 profile = `sdm120`(寄存器地址见参考文档 §4)。
|
||||
- profile 输出统一的 `dict[metric_name -> value]`,由 service 落到读数表对应列;profile 不认识的冷门量丢进 `extra` JSON。
|
||||
- **只读**:不写电表配置寄存器(改 Meter ID/波特率有通信中断风险)。
|
||||
- **轮询**:每个电表一个 `poll_interval_s`(默认 5s),APScheduler 一个 job 扫所有 `enabled` 电表,逐表块读→解码→落库。9600 总线上多表串行,5s/几个表余量充足。
|
||||
- 全局开关 `ENERGY_POLLING_ENABLED`(CONFIG_FIELDS)可一键停采。
|
||||
- **两级周期 / 降采样 / 保留是后续杠杆**(见 §10),本里程碑用单周期读全。
|
||||
|
||||
### 3.2 数据模型(新增两张表,单库 app 链)
|
||||
|
||||
**`energy_meters`**(电表定义,CRUD 管理)
|
||||
|
||||
| 列 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| id | int PK | |
|
||||
| slug | str unique | 稳定标识(用于 MQTT entity key / HA device identifier)|
|
||||
| name | str | 显示名 |
|
||||
| transport | str | 现仅 `'tcp'` |
|
||||
| host | str | 网关 IP |
|
||||
| port | int | 网关端口(默认 502)|
|
||||
| unit_id | int | Modbus 从机地址 = 电表 Meter ID(默认 1)|
|
||||
| profile | str | 设备 profile(首个 `'sdm120'`)|
|
||||
| poll_interval_s | int | 采样周期(默认 5)|
|
||||
| enabled | bool | 是否轮询 |
|
||||
| created_at / updated_at | datetime | |
|
||||
|
||||
**`energy_readings`**(一张相位感知宽表,一行 = 一个电表的一次采样)
|
||||
|
||||
| 列 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| id | int PK | |
|
||||
| meter_id | int FK→energy_meters.id | **ON DELETE RESTRICT**(见 §4 删除语义)|
|
||||
| recorded_at | datetime (UTC) | 采样时刻,索引 |
|
||||
| voltage_l1 / l2 / l3 | float null | 单相电表只填 l1 |
|
||||
| current_l1 / l2 / l3 | float null | 单相电表只填 l1 |
|
||||
| active_power_l1 / l2 / l3 | float null | 三相按相填;单相留空(用 total)|
|
||||
| total_active_power | float null | W(单相=该电表有功功率)|
|
||||
| apparent_power | float null | VA |
|
||||
| reactive_power | float null | var |
|
||||
| power_factor | float null | |
|
||||
| frequency | float null | Hz |
|
||||
| import_active_energy | float null | kWh(累计)|
|
||||
| export_active_energy | float null | kWh(累计)|
|
||||
| total_active_energy | float null | kWh(累计)|
|
||||
| extra | JSON null | 冷门/型号特有量(demands、maxima、无功电能、line-to-line 等)|
|
||||
|
||||
- 索引:`(meter_id, recorded_at)`。
|
||||
- **SDM120 单相映射**:`voltage_l1`←电压、`current_l1`←电流、`total_active_power`←有功功率、`apparent_power`/`reactive_power`/`power_factor`/`frequency`、`import/export/total_active_energy`←对应电能寄存器;l2/l3 与 `active_power_l1..l3` 留空。
|
||||
- 三相 profile 以后填 l1/l2/l3 + total,**无需改 schema**。
|
||||
|
||||
### 3.3 MQTT + HA Discovery(通用 expose 框架)
|
||||
|
||||
HA MQTT Discovery 模型 = **device → entities**:往 `<prefix>/<component>/<node>/<object>/config` 发 retained 消息定义一个 entity;config 内 `device.identifiers` 相同的 entity 归到同一个 HA device 卡片下;之后往 `state_topic` 推值。
|
||||
|
||||
- **可暴露实体目录由 provider 动态产出**:
|
||||
- `app/integrations/expose.py`:定义 `ExposableEntity`(`key`(稳定)、`component`(sensor/binary_sensor/switch…)、`device`(归属,决定 HA device 分组)、`device_class`、`unit`、取值来源)+ 一个 provider 注册表。
|
||||
- **Energy provider**:每个 `enabled` 电表 = 一个 HA **device**,其各工程量 = 一组 sensor entity(带 `device_class=voltage/current/power/energy/frequency` 与单位),外加一个 `binary_sensor`「meter online」(轮询成功/失败)——这就是"**不止 sensor**"的体现。
|
||||
- 其它 provider(如 public-ip、poo)可后续挂入,本里程碑至少接 energy + 一个示例。
|
||||
- **`exposed_entities` 表**:只存"逐 key 的开关"(`key` unique + `enabled` + `updated_at`)。目录本身由 provider 计算,表只记被勾选的状态(默认未勾 = 不暴露)。
|
||||
- **MQTT 客户端**:`app/integrations/mqtt.py` 用 **paho-mqtt**,`loop_start()` 后台线程;在 lifespan 起/停;支持配置变更后**重连 + 重发 discovery**。
|
||||
- **发布时机**:
|
||||
- discovery config(retained):连接成功时、目录/勾选变更时全量发;取消勾选时发空 payload 清除该 entity。
|
||||
- state:energy 在每次轮询后推最新值;另有一个周期 job 兜底重发所有 enabled 实体的 state + availability(在线)topic。
|
||||
- **配置**(走现有扁平 CONFIG_FIELDS):`MQTT_ENABLED`、`MQTT_BROKER_HOST/PORT/USERNAME/PASSWORD(secret)`、`MQTT_TLS_ENABLED`、`HA_DISCOVERY_ENABLED`、`HA_DISCOVERY_PREFIX`(默认 `homeassistant`)。
|
||||
|
||||
### 3.4 前端
|
||||
|
||||
- **侧边栏**:把 `AppLayout` 从顶栏重构为侧边导航(Mantine `AppShell` 或 flex sidebar),导航项:Home / Records / Energy / Config + 主题切换 + 注销;当前路由高亮;移动端可折叠。仅改 `App.tsx`(+ 可抽 `AppSidebar`/`NavItem` 组件),各页面主体不动。
|
||||
- **Energy 视图**(新页 `/energy`):
|
||||
- 电表管理:列表 + 新建/编辑/删除(删除有二次确认;后端对有读数的电表拒删,引导改用"禁用")。
|
||||
- 最新读数卡片(每电表当前各工程量)。
|
||||
- 走势图:用 **Recharts** 画时间序列(电压/电流/功率/电能),时间范围选择,取数走 readings API(窗口 + 上限)。
|
||||
- **Expose 设置**:设置页内一块「Home Assistant Expose」——列出可暴露实体目录、逐项勾选、显示 MQTT/Discovery 连接状态、一个"重新发布 discovery"按钮。
|
||||
|
||||
## 4. API 契约(M5 要落地的端点)
|
||||
|
||||
> 全部 `/api` 前缀、session + CSRF(写)保护、JSON 进出。schema 经 `export_openapi.py` 固化入库。
|
||||
|
||||
| 分组 | 端点 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| Energy | `GET /api/energy/meters` | 列出电表 |
|
||||
| Energy | `POST /api/energy/meters` | 新建电表 |
|
||||
| Energy | `GET /api/energy/meters/{id}` | 单个电表 |
|
||||
| Energy | `PATCH /api/energy/meters/{id}` | 修改电表(含 enable/disable)|
|
||||
| Energy | `DELETE /api/energy/meters/{id}` | 删除电表;**有读数时 409**,引导改 disable |
|
||||
| Energy | `GET /api/energy/meters/{id}/latest` | 该电表最新一条读数 |
|
||||
| Energy | `GET /api/energy/meters/{id}/readings` | 时间范围读数(`start/end/limit`,limit 有上限),供走势图 |
|
||||
| Energy | `POST /api/energy/meters/{id}/test` | 即时试读一次(验证网关连通/地址),不落库 |
|
||||
| Expose | `GET /api/expose` | 返回可暴露实体目录 + 勾选状态 + MQTT/Discovery 状态 |
|
||||
| Expose | `PUT /api/expose` | 设置逐 key 勾选(map key→bool)|
|
||||
| Expose | `POST /api/expose/republish` | 手动重发 discovery |
|
||||
| 配置 | `POST /api/config/mqtt/test` | 测试 broker 连接(仿 SMTP 测试三态)|
|
||||
|
||||
> MQTT broker / discovery 的**标量配置**复用现有 `GET/PUT /api/config`(只新增 CONFIG_FIELDS,不新增端点)。
|
||||
|
||||
## 5. 已锁定决策(讨论后拍板)
|
||||
|
||||
1. **里程碑编排**:一个 M5 文档分三段,`Depends` 串顺序(A 侧边栏 → B Energy → C MQTT)。
|
||||
2. **电表定义 = 专用 `energy_meters` 表 + CRUD API/UI**(扁平 KV 装不下多电表)。
|
||||
3. **读数 = 一张相位感知宽表 `energy_readings`,单 per-meter 周期(默认 5s)每 tick 读全部**,`extra` JSON 兜底;为三相预留 l1/l2/l3 列。两级周期/降采样为后续杠杆。
|
||||
4. **Modbus 仅 TCP**,pymodbus,framer 配网关模式,**只读**采集。
|
||||
5. **协议知识在代码 profile,部署信息在 DB**;首个 profile `sdm120`。
|
||||
6. **MQTT = 通用 expose 框架**:provider 动态产出可暴露实体目录,`exposed_entities` 只存逐 key 开关;支持 sensor/binary_sensor/switch 等多 component;电表自动注册(每表一 device、各量为 entity + 一个 online binary_sensor)。
|
||||
7. **MQTT 库 = paho-mqtt**,lifespan 长连接,配置变更后重连 + 重发 discovery。
|
||||
8. **MQTT broker/discovery 标量配置走现有扁平 CONFIG_FIELDS**(自动渲染);电表清单与 expose 勾选走专用表 + 自定义 UI。
|
||||
9. **图表库 = Recharts**(封在自包含组件后,仿 M2 对 Leaflet 的隔离)。
|
||||
10. **删除电表安全**:FK `ON DELETE RESTRICT`,有读数拒删(避免一键删表丢历史);"停用"用 `enabled=false`。
|
||||
|
||||
> 项目定位:个人自用、家庭特化、不开源——可按单用户场景简化,不过度抽象。
|
||||
|
||||
## 6. 任务依赖图
|
||||
|
||||
```
|
||||
Phase A(独立,可最先做)
|
||||
M5-T01 [structural] 侧边栏布局重构
|
||||
|
||||
Phase B(Energy)
|
||||
M5-T02 [schema] energy_meters + energy_readings 表 + 模型
|
||||
├─► M5-T03 Modbus 驱动 + sdm120 profile(pymodbus,纯模块)
|
||||
│ └─► M5-T04 Energy service + APScheduler 轮询(接 lifespan)
|
||||
└─► M5-T05 Energy JSON API(meters CRUD + readings 查询 + test)
|
||||
└─► M5-T06 前端:电表管理 UI(依赖 T01 侧栏 + T05 API)
|
||||
└─► M5-T07 前端:读数展示 + Recharts 走势图(依赖 T05;引入 recharts)
|
||||
|
||||
Phase C(MQTT / Discovery,依赖 B 的 energy 数据与 provider 接口)
|
||||
M5-T08 MQTT/Discovery 配置项(CONFIG_FIELDS)
|
||||
M5-T09 [schema] exposed_entities 表 + ExposableEntity/provider 框架(energy provider)
|
||||
├─► M5-T10 MQTT 客户端(paho,lifespan 连接 + 重连 + config/mqtt/test)
|
||||
│ └─► M5-T11 Discovery 发布 + state 发布(连 T04 轮询推 state)
|
||||
└─► M5-T12 前端:Expose 勾选 UI + /api/expose API
|
||||
|
||||
收尾
|
||||
M5-T13 文档 + OpenAPI + roadmap 收尾(依赖全部)
|
||||
```
|
||||
|
||||
`T01`、`T02`、`T08` 无前置可先开。
|
||||
|
||||
---
|
||||
|
||||
## 7. 原子任务(任务卡)
|
||||
|
||||
> 后端任务沿用校验闸门(`pytest` / `ruff` / 改路由或 schema 则 `export_openapi` 重导出入库)。前端任务闸门见 §8。新增依赖(`pymodbus`、`paho-mqtt`、`recharts`)须在对应任务里同步 `requirements.in/.txt` 或 `frontend/package.json` 并重新锁定。
|
||||
|
||||
### M5-T01 — 侧边栏布局重构 `[structural]`
|
||||
- **Status**: `todo` · **Depends**: none
|
||||
- **Context**: 把 `AppLayout` 从顶栏改为侧边导航,给后续 Energy 等视图腾入口。纯前端,不碰各页主体。
|
||||
- **Files**: `modify frontend/src/App.tsx`;`create frontend/src/components/AppSidebar.tsx`、`frontend/src/components/NavItem.tsx`(可选);`modify` 受影响的 `frontend/src/pages/*.test.tsx`(导航断言)
|
||||
- **Steps**:
|
||||
1. 用 Mantine `AppShell`(或 flex sidebar)重构 `AppLayout`:左侧竖直导航(Home/Records/Config + 主题切换 + 注销),`<Outlet/>` 在右。
|
||||
2. 当前路由高亮(`useLocation` 比对 `pathname`);移动端可折叠(burger)。
|
||||
3. 导航项图标沿用 `react-feather`;样式走 Mantine(暗色模式自动适配)。
|
||||
4. 不在本任务加 Energy 项(页面还不存在,T06 加),保持导航无死链。
|
||||
- **Out of scope / 不要碰**: 不改各页面主体;不动鉴权(SessionProvider/ProtectedRoute);不引入图表库。
|
||||
- **Acceptance criteria**:
|
||||
- [ ] 受保护页都在侧边栏布局内;`/login`、`/change-password` 不带布局(与现状一致)。
|
||||
- [ ] 当前路由在侧栏高亮;移动端宽度下可折叠/展开。
|
||||
- [ ] 前端闸门全绿(`lint`/`typecheck`/`test`/`build`)。
|
||||
- **Reviewer checklist**: 布局只在 `App.tsx`/新组件内变动,未误改页面或鉴权;无死链导航项。
|
||||
|
||||
### M5-T02 — `energy_meters` + `energy_readings` 表与模型 `[schema]`
|
||||
- **Status**: `todo` · **Depends**: none
|
||||
- **Context**: 单库 app 链新增两表,建出 §3.2 结构。本任务只建 schema + 模型,不写采集/接口。
|
||||
- **Files**: `create app/models/energy.py`(`EnergyMeter`、`EnergyReading`,继承 `app.db.Base`);`create alembic_app/versions/<date>_07_energy_tables.py`;`modify alembic_app/env.py`(import 新模型);`modify scripts/app_db_adopt.py`(`APP_BASELINE_REVISION` → 新 head);`create tests/test_energy_models.py`
|
||||
- **Steps**:
|
||||
1. 模型按 §3.2 列定义(`Mapped[...]` 2.0 风格,`extra` 用 JSON 列且 nullable);`EnergyReading.meter_id` FK→`energy_meters.id`,**`ondelete="RESTRICT"`**;`slug` unique。
|
||||
2. 新 revision:`down_revision` = 当前 head;`upgrade()` 用 `op.create_table` 建两表 + 索引 `(meter_id, recorded_at)`;`downgrade()` 反向 drop。
|
||||
3. 更新 `APP_BASELINE_REVISION`。
|
||||
- **Out of scope / 不要碰**: 不写 pymodbus/采集(T03/T04);不加路由(T05);不动其它模型。
|
||||
- **Acceptance criteria**:
|
||||
- [ ] 全新临时 app 库 upgrade 到 head 后含 `energy_meters`、`energy_readings` 及索引;`downgrade -1` 干净回滚。
|
||||
- [ ] `Base.metadata.tables` 含两新表;FK 为 RESTRICT。
|
||||
- [ ] `APP_BASELINE_REVISION` == 新 head。
|
||||
- [ ] 校验闸门全绿。
|
||||
- **Reviewer checklist**: 列/约束与 §3.2 一致;`extra` 为 JSON nullable;链上单 head;`env.py` 已 import 新模型(否则 autogenerate/建表漏表)。
|
||||
|
||||
### M5-T03 — Modbus 驱动 + `sdm120` profile
|
||||
- **Status**: `todo` · **Depends**: M5-T02
|
||||
- **Context**: 薄封装 pymodbus 的连接/块读/大端 float 解码,加 SDM120 寄存器 profile。纯模块,mock client 单测。
|
||||
- **Files**: `create app/integrations/modbus.py`、`app/integrations/energy_profiles.py`、`scripts/energy_cli.py`;`modify requirements.in`/`requirements.txt`(加 `pymodbus`,重新锁定);`create tests/test_modbus_driver.py`、`tests/test_energy_profiles.py`、`tests/test_energy_cli.py`
|
||||
- **Steps**:
|
||||
1. `modbus.py`:`read_meter(host, port, unit_id, blocks) -> dict[int,int]`(块读 input registers,FC04),用 `ModbusTcpClient`;超时/连接失败抛明确异常;大端 float32 解码 helper(`registers_to_float`,高寄存器在前)。framer 选择留可配置/可探测。
|
||||
2. `energy_profiles.py`:`SDM120_PROFILE` 描述要读的寄存器块(参考文档 §4 地址)+ 每个工程量如何从寄存器对解码并映射到 `EnergyReading` 列名;`decode(raw_registers) -> dict[col -> value]`,未映射的丢 `extra`。
|
||||
3. profile 注册表 `PROFILES = {"sdm120": SDM120_PROFILE}`。
|
||||
4. `scripts/energy_cli.py`(`python -m scripts.energy_cli read --host H --port P --unit U --profile sdm120`):连网关、按 profile 读一次、解码后**把各工程量打印成可读结果**(表格/JSON);纯命令行、**不依赖 DB / 不需先配电表**,用于现场验证网关连通与读数。连接失败给清晰报错 + 非零退出。
|
||||
- **Out of scope / 不要碰**: 不连真实硬件(单测用 mock/fake 返回已知寄存器字节);不写调度(T04);不写电表配置寄存器。
|
||||
- **Acceptance criteria**:
|
||||
- [ ] 单测:给定 `0x4366,0x3334` 解码为 `230.2`(参考文档实例);字序/字节序正确。
|
||||
- [ ] 单测:`SDM120_PROFILE.decode(...)` 把已知寄存器映射到正确列名与值;冷门量进 `extra`。
|
||||
- [ ] 连接失败/超时抛可识别异常,不静默返回错值。
|
||||
- [ ] `python -m scripts.energy_cli read ...` 能(对 mock/真实网关)打印解码后的各工程量;连接失败非零退出。
|
||||
- [ ] 校验闸门全绿。
|
||||
- **Reviewer checklist**: 解码确为大端、高寄存器在前;地址与参考文档一致;无任何写寄存器路径;`requirements.txt` 已同步锁定 `pymodbus`。
|
||||
|
||||
### M5-T04 — Energy service + APScheduler 轮询
|
||||
- **Status**: `todo` · **Depends**: M5-T03
|
||||
- **Context**: 周期扫所有 enabled 电表,调用 driver 读+解码,落 `energy_readings`。仿 public-ip 的同步 job 模式。
|
||||
- **Files**: `create app/services/energy.py`;`modify app/main.py`(lifespan 注册 job);`create tests/test_energy_poll.py`
|
||||
- **Steps**:
|
||||
1. `energy.py`:`poll_meter(session, meter) -> EnergyReading | None`(按 profile 读+解码+插入一行,记录成功/失败用于 online 状态);`poll_all_enabled_meters(session)` 遍历 enabled 电表;service 内吞异常并日志,不让 job 崩。
|
||||
2. `main.py`:加同步 wrapper `_run_scheduled_energy_poll`(自管 session),`add_job(IntervalTrigger(seconds=...), id="energy-poll", max_instances=1, coalesce=True)`。周期取**最小 per-meter interval 或一个基础 tick**(实现可用单一基础 tick + 各表按自身 interval 取模决定本 tick 是否读,保持 job 简单);受全局 `ENERGY_POLLING_ENABLED` 控制。
|
||||
3. 失败的电表记录 online=false(供 T11 暴露),不影响其它电表。
|
||||
- **Out of scope / 不要碰**: 不发 MQTT(T11);不加 HTTP 路由(T05);不引入两级周期(后续杠杆)。
|
||||
- **Acceptance criteria**:
|
||||
- [ ] 单测:mock driver 返回已知值,`poll_all_enabled_meters` 后 `energy_readings` 精确 +N 行、列值正确。
|
||||
- [ ] 单测:某电表读失败时其它电表仍正常落库,job 不抛。
|
||||
- [ ] `ENERGY_POLLING_ENABLED=false` 时不轮询。
|
||||
- [ ] 校验闸门全绿。
|
||||
- **Reviewer checklist**: session 在 wrapper 内开关、try/finally 关闭;job `max_instances=1` 防叠加;无 N+1/每行单独 connect 的明显低效;异常不外泄崩 job。
|
||||
|
||||
### M5-T05 — Energy JSON API(meters CRUD + readings + test)
|
||||
- **Status**: `todo` · **Depends**: M5-T02
|
||||
- **Context**: 给前端提供电表 CRUD、最新读数、时间范围读数、即时试读。
|
||||
- **Files**: `create app/api/routes/api/energy.py`、`app/schemas/energy.py`;`modify app/main.py`(注册路由);`create tests/test_api_energy.py`
|
||||
- **Steps**:
|
||||
1. meters:`GET`(list)/`POST`/`GET{id}`/`PATCH{id}`/`DELETE{id}`;session+CSRF;`slug` 唯一校验;`DELETE` 有读数 → 409。
|
||||
2. readings:`GET {id}/latest`、`GET {id}/readings`(`start/end/limit`,limit 有上限防全表导出,按 `recorded_at` 升序)。
|
||||
3. `POST {id}/test`:用 driver 即时读一次返回解码值(或错误),**不落库**。
|
||||
- **Out of scope / 不要碰**: 不在此处发 MQTT;不写采集逻辑(复用 T03/T04 的 driver/service)。
|
||||
- **Acceptance criteria**:
|
||||
- [ ] CRUD 行为正确:创建/改/删行数精确;删有读数的电表返回 409;未登录 401、缺 CSRF 403。
|
||||
- [ ] readings 时间范围 + limit 上限生效;latest 返回最新一条。
|
||||
- [ ] schema 经 OpenAPI 固化入库。
|
||||
- [ ] 校验闸门全绿(含 `openapi/` 重导出)。
|
||||
- **Reviewer checklist**: 删除受 RESTRICT 保护、无批量删/清表路径;查询走 `(meter_id, recorded_at)` 索引;test 端点确不落库。
|
||||
|
||||
### M5-T06 — 前端:电表管理 UI
|
||||
- **Status**: `todo` · **Depends**: M5-T01, M5-T05
|
||||
- **Context**: 在侧栏加 Energy 入口与 `/energy` 路由;电表增删改 + 试读。
|
||||
- **Files**: `create frontend/src/pages/EnergyPage.tsx`、`frontend/src/energy/MeterForm.tsx`、`frontend/src/energy/hooks.ts`;`modify frontend/src/App.tsx`(路由)、`frontend/src/components/AppSidebar.tsx`(Energy 项);`create` 对应 `*.test.tsx`
|
||||
- **Steps**: `useQuery`/`useMutation` 接 meters API;列表 + 新建/编辑表单 + 删除二次确认(删失败 409 提示改用禁用);"试读"按钮调 `POST {id}/test` 显示结果。
|
||||
- **Out of scope / 不要碰**: 走势图在 T07;不碰其它页面。
|
||||
- **Acceptance criteria**:
|
||||
- [ ] 能增/改/删电表并即时刷新;删除有二次确认;409 有友好提示。
|
||||
- [ ] 侧栏出现 Energy 入口、`/energy` 可达。
|
||||
- [ ] 前端闸门全绿。
|
||||
- **Reviewer checklist**: 全部走生成的类型化 client;删除走确认;无与契约不符的手写请求。
|
||||
|
||||
### M5-T07 — 前端:读数展示 + Recharts 走势图
|
||||
- **Status**: `todo` · **Depends**: M5-T05(数据), M5-T06(页面壳)
|
||||
- **Context**: 在 Energy 页展示每电表最新读数 + 时间序列走势。
|
||||
- **Files**: `modify frontend/src/pages/EnergyPage.tsx`;`create frontend/src/energy/EnergyCharts.tsx`(封装 Recharts);`modify frontend/package.json`(加 `recharts`,`package-lock.json` 同步);`create` 对应测试
|
||||
- **Steps**: 最新读数卡片(接 `latest`);时间范围选择 + 折线图(电压/电流/功率/电能),接 `readings`(窗口 + limit);图表封在 `EnergyCharts` 内(仿 Leaflet 隔离,便于将来换库)。
|
||||
- **Out of scope / 不要碰**: 不做服务端降采样(后续);不改后端。
|
||||
- **Acceptance criteria**:
|
||||
- [ ] 最新读数与走势图渲染正确;时间范围只取窗口数据(不拉全量)。
|
||||
- [ ] Recharts 封装自包含、仅此处 import。
|
||||
- [ ] 前端闸门全绿(`build` 通过,注意 chunk 体积提示)。
|
||||
- **Reviewer checklist**: 图表组件隔离;查询有窗口/上限;空数据/加载/错误态有处理。
|
||||
|
||||
### M5-T08 — MQTT / Discovery 配置项(CONFIG_FIELDS)
|
||||
- **Status**: `todo` · **Depends**: none
|
||||
- **Context**: 把 MQTT broker 与 discovery 的标量配置接入扁平配置系统(前端自动渲染)。
|
||||
- **Files**: `modify app/config.py`(新增 Settings 字段)、`app/services/config_page.py`(追加 CONFIG_FIELDS + `_settings_payload`);`modify .env.example`;`modify tests/test_api_config.py`
|
||||
- **Steps**: 加字段 `mqtt_enabled`、`mqtt_broker_host/port/username/password`(secret)、`mqtt_tls_enabled`、`ha_discovery_enabled`、`ha_discovery_prefix`(默认 `homeassistant`)、`energy_polling_enabled`;CONFIG_FIELDS 归入「MQTT」「Home Assistant Discovery」「Energy」section;`_settings_payload` 补齐对应行。
|
||||
- **Out of scope / 不要碰**: 不建 MQTT 客户端(T10);不动 expose 表(T09)。
|
||||
- **Acceptance criteria**:
|
||||
- [ ] 新配置项在 `GET /api/config` 出现且分 section;password 为 secret(回空、留空保留);port 为 number。
|
||||
- [ ] 非法值(端口非数字)422 不写库。
|
||||
- [ ] 校验闸门全绿(OpenAPI 若变化则重导出)。
|
||||
- **Reviewer checklist**: secret 不回显/不入 OpenAPI 示例;`_settings_payload` 未漏字段(否则运行期 override 丢失)。
|
||||
|
||||
### M5-T09 — `exposed_entities` 表 + ExposableEntity/provider 框架 `[schema]`
|
||||
- **Status**: `todo` · **Depends**: M5-T02
|
||||
- **Context**: 建"可暴露实体目录"的抽象与开关存储;energy provider 把电表映射成 device/entities。
|
||||
- **Files**: `create app/integrations/expose.py`(`ExposableEntity`、provider 协议、注册表、energy provider);`create app/models/expose.py`(`ExposedEntityToggle`:`key` unique + `enabled` + `updated_at`);`create alembic_app/versions/<date>_08_exposed_entities.py`;`modify alembic_app/env.py`、`scripts/app_db_adopt.py`;`create tests/test_expose_catalog.py`
|
||||
- **Steps**:
|
||||
1. `ExposableEntity`(`key/component/device/device_class/unit/value_getter`)+ provider 接口 `enumerate(session) -> list[ExposableEntity]`。
|
||||
2. energy provider:每个电表 → 一个 device(identifier=slug),各工程量 → sensor entity(带 device_class/unit),加一个 `binary_sensor` online。
|
||||
3. `build_catalog(session)` 合并所有 provider 的实体 + 各自 `enabled`(来自 toggle 表,缺省 false)。
|
||||
4. migration 建 toggle 表;更新 baseline 常量。
|
||||
- **Out of scope / 不要碰**: 不发 MQTT(T11);不加 HTTP(T12)。
|
||||
- **Acceptance criteria**:
|
||||
- [ ] 单测:建若干电表后 `build_catalog` 产出每表对应 entity(含 online binary_sensor)+ 正确 device 分组、device_class、unit。
|
||||
- [ ] toggle 表 migration 可升/降;缺省 enabled=false。
|
||||
- [ ] 至少含一个非 sensor component(online binary_sensor)。
|
||||
- [ ] 校验闸门全绿。
|
||||
- **Reviewer checklist**: `key` 稳定(电表用 slug,不用自增 id,避免重建漂移);component 支持多类型;目录由 provider 计算而非写死。
|
||||
|
||||
### M5-T10 — MQTT 客户端(paho,lifespan 连接 + 重连)
|
||||
- **Status**: `todo` · **Depends**: M5-T08
|
||||
- **Context**: 长连接 MQTT 客户端,配置变更可重连;含连接测试端点。
|
||||
- **Files**: `create app/integrations/mqtt.py`;`modify app/main.py`(lifespan 起/停)、`app/api/routes/api/config.py`(`POST /api/config/mqtt/test`);`modify requirements.in`/`requirements.txt`(加 `paho-mqtt` 重新锁定);`create tests/test_mqtt_client.py`
|
||||
- **Steps**:
|
||||
1. `MqttManager`:`is_configured()`、`connect()/disconnect()/reconnect(settings)`、`publish(topic, payload, retain)`;paho `loop_start()` 后台线程;未配置/未启用则 no-op。
|
||||
2. lifespan:启用则 connect;shutdown disconnect。
|
||||
3. 配置保存后若 MQTT 设置变化 → 触发 manager 重连(在 config 保存路径加 hook 或保存后比对)。
|
||||
4. `POST /api/config/mqtt/test`:用提交/现存配置试连,返回三态(success/config-error/failed),仿 SMTP 测试。
|
||||
- **Out of scope / 不要碰**: 不构建 discovery/state 消息(T11)。
|
||||
- **Acceptance criteria**:
|
||||
- [ ] 单测(fake broker/paho mock):configured 时 connect 调用正确;未配置 no-op;publish 透传 topic/payload/retain。
|
||||
- [ ] `POST /api/config/mqtt/test` 三态有明确返回;session+CSRF 保护。
|
||||
- [ ] 校验闸门全绿。
|
||||
- **Reviewer checklist**: 断网/连接失败不崩主进程;线程在 shutdown 正确停止;`requirements.txt` 同步锁定 `paho-mqtt`;密码不进日志。
|
||||
|
||||
### M5-T11 — Discovery 发布 + state 发布
|
||||
- **Status**: `todo` · **Depends**: M5-T09, M5-T10
|
||||
- **Context**: 把 enabled 实体发成 HA discovery config(retained)并周期推 state;energy 轮询后推最新值。
|
||||
- **Files**: `create app/services/ha_discovery.py`;`modify app/services/energy.py`(轮询后推 state)、`app/main.py`(state 周期 job + 连接后/勾选变更后发 discovery)、`app/api/routes/api/...`(`/api/expose/republish` 在 T12 接,本任务提供 service);`create tests/test_ha_discovery.py`
|
||||
- **Steps**:
|
||||
1. `build_discovery_payload(entity)` → HA 规范 config(`<prefix>/<component>/<node>/<object>/config`,含 `device` 块、`state_topic`、`device_class`、`unit_of_measurement`、`availability`)。
|
||||
2. `publish_discovery(session)`:对 enabled 实体发 retained config;对取消勾选的发空 payload 清除。
|
||||
3. `publish_states(session)`:取各实体当前值发 state;energy 在 `poll_meter` 成功后顺带推该表实体 state + online。
|
||||
4. lifespan:连接成功 / 目录或勾选变更后 `publish_discovery`;周期 job 兜底 `publish_states` + availability。
|
||||
- **Out of scope / 不要碰**: 不做前端(T12);不改采集解码逻辑。
|
||||
- **Acceptance criteria**:
|
||||
- [ ] 单测:discovery payload 符合 HA 结构(device 分组正确、topic/ device_class/unit 正确);取消勾选发空 payload。
|
||||
- [ ] 单测:energy 轮询成功后推对应 state topic;失败推 online=false。
|
||||
- [ ] discovery 用 retained。
|
||||
- [ ] 校验闸门全绿。
|
||||
- **Reviewer checklist**: 仅发 enabled 实体;entity 唯一标识稳定;MQTT 未启用时整链 no-op;不阻塞轮询。
|
||||
|
||||
### M5-T12 — 前端:Expose 勾选 UI + `/api/expose`
|
||||
- **Status**: `todo` · **Depends**: M5-T09, M5-T11
|
||||
- **Context**: 后端 expose 读写端点 + 设置页勾选界面。
|
||||
- **Files**: `create app/api/routes/api/expose.py`、`app/schemas/expose.py`;`modify app/main.py`;`create tests/test_api_expose.py`;前端 `create frontend/src/pages/.../ExposeSettings.tsx`(或并入 ConfigPage)、`modify` 路由/设置入口;`create` 前端测试
|
||||
- **Steps**: `GET /api/expose`(目录 + 勾选 + MQTT/Discovery 状态)、`PUT /api/expose`(key→bool)、`POST /api/expose/republish`(调 T11 service);前端列出目录、按 device 分组、逐项开关、显示连接状态、"重新发布"按钮。
|
||||
- **Out of scope / 不要碰**: 不改采集/发布逻辑(T11)。
|
||||
- **Acceptance criteria**:
|
||||
- [ ] `GET/PUT /api/expose` 正确读写勾选;session+CSRF;OpenAPI 固化。
|
||||
- [ ] 勾选变更后(或点重新发布)触发 discovery 重发。
|
||||
- [ ] 前端能勾选并显示状态;前后端闸门全绿。
|
||||
- **Reviewer checklist**: PUT 只改 toggle 不误碰其它配置;republish 真触发 T11;类型化 client。
|
||||
|
||||
### M5-T13 — 文档 + OpenAPI + roadmap 收尾
|
||||
- **Status**: `todo` · **Depends**: 全部
|
||||
- **Files**: `modify README.md`(Energy/MQTT 段、新依赖)、`docs/roadmap.md`(M5 行 + 把"MQTT/IoT"从"下一阶段"毕业、新增 Modbus/Energy 方向)、`docs/architecture-overview.md`(新增 MQTT 通道与能耗采集);`modify docs/design/README.md`(列入 m5);`run python scripts/export_openapi.py` 并提交 `openapi/`
|
||||
- **Acceptance criteria**:
|
||||
- [ ] 文档反映新链路;`git diff --exit-code openapi/` 无未提交差异。
|
||||
- [ ] 校验闸门全绿。
|
||||
- **Reviewer checklist**: 无残留旧描述;OpenAPI 已入库。
|
||||
|
||||
---
|
||||
|
||||
## 8. 前端校验闸门(前端任务每次结束都要全绿)
|
||||
|
||||
在 `frontend/` 下:
|
||||
```bash
|
||||
npm ci
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm run test
|
||||
npm run build # 必须产出 dist;留意 chunk 体积告警
|
||||
```
|
||||
- 后端若同任务改了路由/schema,仍需根目录 `python scripts/export_openapi.py` 并提交 `openapi/`。
|
||||
- 新增前端依赖(recharts)须提交 `package.json` + `package-lock.json`。
|
||||
|
||||
## 9. 构建上下文完整性(M1 教训)
|
||||
|
||||
- 本里程碑**不删/移文件**,但新增了 Python 依赖(`pymodbus`、`paho-mqtt`)与前端依赖(`recharts`):必须同步 `requirements.in/.txt` 的重新锁定与 `package-lock.json`,否则镜像构建会缺包。
|
||||
- 新增源文件都在 `app/`、`scripts/` 与 `frontend/` 既有 COPY 范围内,无需改 `Dockerfile` 的 `COPY`;`scripts/energy_cli.py` 须在镜像里可 `python -m scripts.energy_cli` 调用(确认 `scripts/` 已进镜像);`tests/test_deployment.py::test_dockerfile_copy_sources_exist` 仍应通过。
|
||||
- 发版前置走查(见 CLAUDE.md):真起 app 跑一次轮询、真连一次 broker、前端 Energy 视图人工瞄一眼渲染,再打 tag。
|
||||
|
||||
## 10. 后续杠杆(本里程碑不做,文档留痕)
|
||||
|
||||
- **两级采样周期**:瞬时量(V/I/P/PF/Hz)快、累计电能慢——9600 总线吃紧或要把功率压到 5s 以下时再开(meters 表加 `energy_interval_s`,读数表已含全部列,非破坏性变更)。
|
||||
- **保留 / 降采样**:5s 采样长期行数大(≈630 万行/年/表),加定期降采样或保留窗口任务(独立于本里程碑)。
|
||||
- **更多 device profile**:三相电表(如 SDM630)按 §3.2 填 l1/l2/l3 + total,新增 profile 即可。
|
||||
- **更多 expose provider**:public-ip / poo 等挂入 expose 框架。
|
||||
- **写电表配置**:当前只读;如需经 MQTT/UI 控制设备(switch 类),再单独评估安全边界。
|
||||
|
||||
## 11. 人工验收 walkthrough(实现完成后)
|
||||
|
||||
> 重点:用一条命令行命令读到电表数据并展示结果。可用 `docker compose` 起环境,命令在容器内或容器外跑均可。
|
||||
|
||||
**前提**:网关(Waveshare RTU↔TCP)已上电接入网络,电表 Meter ID 已知(默认 1)。
|
||||
|
||||
**1) 命令行直接试读(不依赖 DB,最快验证)**
|
||||
- 容器内:`docker compose exec <app> python -m scripts.energy_cli read --host <网关IP> --port 502 --unit 1 --profile sdm120`
|
||||
- 或容器外:`source .venv/bin/activate && python -m scripts.energy_cli read --host <网关IP> --port 502 --unit 1 --profile sdm120`
|
||||
- 预期:打印解码后的工程量(电压/电流/有功功率/功率因数/频率/导入导出电能等),数值合理;连不上则清晰报错。
|
||||
|
||||
**2)(可选)经 API 试读已配置的电表**
|
||||
- 先在前端 Energy 页或 `POST /api/energy/meters` 建一个电表;
|
||||
- `POST /api/energy/meters/{id}/test` 即时试读,返回解码值(不落库)。
|
||||
|
||||
**3)(可选)验证后台轮询落库**
|
||||
- 确认 `ENERGY_POLLING_ENABLED=true` 且电表 `enabled`;等一个采样周期;
|
||||
- 看前端 Energy 视图的最新读数/走势图,或查 `GET /api/energy/meters/{id}/readings` 有新行。
|
||||
|
||||
## 12. 里程碑完成定义(DoD)
|
||||
|
||||
- 后端能按 per-meter 周期静默轮询 Modbus-TCP 电表、解码落 `energy_readings`,支持多电表 CRUD。
|
||||
- MQTT 启用时,勾选的实体以 HA Discovery 注册成 device/entities(含非 sensor),state 周期发布;配置变更可重连重发。
|
||||
- 前端侧边栏可切换功能;Energy 视图能管理电表、看最新读数与走势图;设置页可勾选 expose。
|
||||
- 后端 `pytest`/`ruff`/`export_openapi` + 前端 `lint/typecheck/test/build` 全绿且 `openapi/` 已入库。
|
||||
- README / architecture / roadmap / design 索引反映 M5 现实。
|
||||
Binary file not shown.
@@ -0,0 +1,134 @@
|
||||
# SDM120 Modbus 协议(从官方 PDF 提取)
|
||||
|
||||
> 来源:`docs/references/SDM120-MODBUS_Protocol.pdf`
|
||||
> (Eastron SDM120 Modbus Smart Meter Modbus Protocol Implementation **V2.4**)
|
||||
> 本文件是 PDF 的可读化提取,供本项目的 Modbus 采集驱动设计参考。**以官方 PDF 为准**,本文件如有出入以 PDF 为准。
|
||||
|
||||
## 0. 本项目的接入方式(重要)
|
||||
|
||||
SDM120 物理层是 **Modbus RTU(RS-485 串口)**。本项目通过一个 **Modbus-TCP 网关**接入:
|
||||
|
||||
- 后端用 **Modbus TCP**(`IP:port`)连到网关,网关在串口侧转成 RTU 与电表通信。
|
||||
- TCP 帧用 MBAP header、**无 CRC**(CRC/Error Check 由网关在 RTU 侧处理)。本文档里 RTU 帧的 `Error Check (Lo/Hi)` 字段在 TCP 模式下不需要我们关心。
|
||||
- **Slave Address / Unit ID = 电表的 Meter ID**(默认 `1`,范围 1–247),在 TCP 请求里作为 unit id 传入。
|
||||
- 若以后直连串口(RTU),才需要管波特率 / 校验位 / CRC(见 §5 holding 寄存器)。
|
||||
|
||||
## 1. 协议帧格式
|
||||
|
||||
MODBUS 定义 master 查询 / slave 响应的格式。Eastron 电表用 16-bit 寄存器在主从间传值,**但实际数据是 32-bit IEEE-754 浮点**,因此每个测量参数占**两个相邻的 16-bit 寄存器**。
|
||||
|
||||
### Query(master → slave,RTU 帧)
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| Slave Address | 8-bit,目标从机地址 1–247(0=广播,Eastron 不支持广播) |
|
||||
| Function Code | 8-bit,功能码(Eastron 支持 **03 / 04 / 08 / 16(=0x10)**) |
|
||||
| Start Address (Hi/Lo) | 16-bit 起始寄存器地址;**寄存器成对使用、从 0 开始,所以起始地址必须是偶数** |
|
||||
| Number of Points (Hi/Lo) | 16-bit 请求的寄存器数量;**也必须是偶数**(成对读浮点) |
|
||||
| Error Check (Lo/Hi) | 16-bit CRC(RTU 模式;TCP 网关模式无此字段) |
|
||||
|
||||
### Response(slave → master)
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| Slave Address | 响应从机地址 |
|
||||
| Function Code | 与查询相同的功能码(表示识别并已响应) |
|
||||
| Byte Count | 8-bit,本次返回的数据字节数 |
|
||||
| Data (寄存器对) | 每个寄存器 Hi byte / Lo byte,按"高寄存器在前"排列 |
|
||||
| Error Check (Lo/Hi) | 16-bit CRC(RTU 模式) |
|
||||
|
||||
### Exception Response(异常响应)
|
||||
|
||||
- 异常响应的 Function Code = 查询功能码 **OR 0x80**(最高位置 1)。
|
||||
- 数据是单字节 Error Code(异常码)。
|
||||
- PDF 正文写了"见后文 Table Of Exception Codes",但提取的 8 页里**没有附上该表**。标准 Modbus 异常码(供参考,非本 PDF 内容):`01` 非法功能、`02` 非法数据地址、`03` 非法数据值、`04` 从机设备故障。
|
||||
|
||||
## 2. 功能码
|
||||
|
||||
| 功能码 | 作用 | 寄存器区 |
|
||||
| --- | --- | --- |
|
||||
| **04** | Read Input Registers(读输入寄存器,3X)—— **所有测量值都在这里** | 30001+ |
|
||||
| **03** | Read Holding Registers(读保持寄存器,4X)—— 配置项 | 40001+ |
|
||||
| **16 / 0x10** | Write Holding Registers(写保持寄存器,4X)—— 改配置 | 40001+ |
|
||||
| 08 | Diagnostics(诊断) | — |
|
||||
|
||||
> ⚠️ **测量值用 FC 04(输入寄存器),不是 FC 03。** 这是最常见的踩坑点。
|
||||
|
||||
## 3. 浮点数据编码
|
||||
|
||||
- 每个参数 = **32-bit IEEE-754 float**,占两个相邻 16-bit 寄存器。
|
||||
- **字序(word order)= 大端:高寄存器在前。**
|
||||
- **字节序(byte order)= 大端:寄存器内高字节在前。**
|
||||
- 即整体就是标准大端 float(`>f`),4 字节顺序 = `[Reg1 Hi][Reg1 Lo][Reg2 Hi][Reg2 Lo]`。
|
||||
|
||||
**实例(来自 PDF)**
|
||||
|
||||
| 含义 | 原始 4 字节 (hex) | 解码值 |
|
||||
| --- | --- | --- |
|
||||
| Volts 1 | `43 66 33 34` | `230.2` V |
|
||||
| Demand Time | `3F 80 00 00` | `1.0` |
|
||||
| Network Node | `42 70 00 00` | `60.0` |
|
||||
|
||||
> Python 解码:`struct.unpack('>f', bytes([0x43,0x66,0x33,0x34]))[0]` → `230.2`。
|
||||
> pymodbus 用 `BinaryPayloadDecoder.fromRegisters(regs, byteorder=Endian.BIG, wordorder=Endian.BIG)`。
|
||||
|
||||
## 4. 输入寄存器表(FC 04 读测量值)
|
||||
|
||||
全部为 `Float`,长度 4 字节,每项占 2 个寄存器。"Hex 起始"是 Modbus 协议起始地址(即 Start Address Hi/Lo)。
|
||||
|
||||
| 寄存器 | 参数 | 单位 | Hex 起始 |
|
||||
| --- | --- | --- | --- |
|
||||
| 30001 | Voltage(电压) | Volts | `0000` |
|
||||
| 30007 | Current(电流) | Amps | `0006` |
|
||||
| 30013 | Active power(有功功率) | Watts | `000C` |
|
||||
| 30019 | Apparent power(视在功率) | VA | `0012` |
|
||||
| 30025 | Reactive power(无功功率) | VAr | `0018` |
|
||||
| 30031 | Power factor(功率因数) | — | `001E` |
|
||||
| 30071 | Frequency(频率) | Hz | `0046` |
|
||||
| 30073 | Import active energy(导入有功电能) | kWh | `0048` |
|
||||
| 30075 | Export active energy(导出有功电能) | kWh | `004A` |
|
||||
| 30077 | Import reactive energy(导入无功电能) | kvarh | `004C` |
|
||||
| 30079 | Export reactive energy(导出无功电能) | kvarh | `004E` |
|
||||
| 30085 | Total system power demand | W | `0054` |
|
||||
| 30087 | Maximum total system power demand | W | `0056` |
|
||||
| 30089 | Import system power demand | W | `0058` |
|
||||
| 30091 | Maximum import system power demand | W | `005A` |
|
||||
| 30093 | Export system power demand | W | `005C` |
|
||||
| 30095 | Maximum export system power demand | W | `005E` |
|
||||
| 30259 | Current demand | Amps | `0102` |
|
||||
| 30265 | Maximum current demand | Amps | `0108` |
|
||||
| 30343 | Total active energy(总有功电能) | kWh | `0156` |
|
||||
| 30345 | Total reactive energy(总无功电能) | kvarh | `0158` |
|
||||
|
||||
**读取分块建议**:`0x0000–0x005E`(30001–30095)地址连续,可一次块读;`0x0102/0x0108`、`0x0156/0x0158` 各为独立小块。整表用 2–3 次块读即可覆盖,减少 Modbus 事务数。
|
||||
|
||||
### 常用核心子集(日常监控够用)
|
||||
|
||||
电压 `0000`、电流 `0006`、有功功率 `000C`、功率因数 `001E`、频率 `0046`、导入有功电能 `0048`、导出有功电能 `004A`、总有功电能 `0156`。
|
||||
|
||||
## 5. 保持寄存器表(FC 03 读 / FC 16 写配置)
|
||||
|
||||
| 寄存器 | 参数 | Hex 起始 | 格式 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 40013 | Relay Pulse Width | `000C` | Float | 继电器脉宽 60/100/200 ms,默认 100ms |
|
||||
| 40019 | Network Parity Stop | `0012` | Float | 0=1停止位无校验(默认),1=1停止位偶校验,2=1停止位奇校验,3=2停止位无校验;**改后需重启生效** |
|
||||
| 40021 | Meter ID | `0014` | Float | 从机地址 1–247,默认 1 |
|
||||
| 40029 | Baud rate | `001C` | Float | 0=2400(默认),1=4800,2=9600,5=1200 |
|
||||
| 40087 | Pulse 1 output mode | `0056` | Float | 0001 导入有功,0002 导入+导出有功,0004 导出有功(默认),0005 导入无功,0006 导入+导出无功,0008 导出无功 |
|
||||
| 463745 | Time of scroll display | `F900` | HEX 2字节 | 滚动显示时间 0–30s,默认 0(不滚动) |
|
||||
| 463761 | Pulse 1 output | `F910` | HEX 2字节 | 0000:0.001kWh/imp(默认),0001:0.01,0002:0.1,0003:1 kWh/imp |
|
||||
| 463777 | Measurement mode | `F920` | HEX 2字节 | 1:total=import,2:total=import+export(默认),3:total=import-export |
|
||||
| 464513 | Serial number | `FC00` | uint32 4字节 | 序列号,**只读** |
|
||||
| 464515 | Meter code | `FC02` | Hex 2字节 | 设备码=`0020`,**只读** |
|
||||
| 464516 | Software version | `FC03` | Hex 2字节 | 软件版本,**只读** |
|
||||
|
||||
> ⚠️ 写保持寄存器(改 Meter ID / 波特率 / 校验位等)会改变电表通信参数,配错可能导致**通信中断**。本项目默认**只读采集**,不建议在自动化链路里写电表配置。
|
||||
|
||||
## 6. 给本项目采集驱动的要点小结
|
||||
|
||||
1. 走 **Modbus TCP 网关**:`ModbusTcpClient(host, port)`,`slave=<Meter ID>`。
|
||||
2. 测量值用 **FC 04 / 输入寄存器**,按 §4 地址读。
|
||||
3. 解码 **大端 float32**(word & byte 都大端,高寄存器在前)。
|
||||
4. 起始地址与数量**都用偶数**(成对读)。
|
||||
5. 默认**只读**,不写电表配置寄存器。
|
||||
6. 不同型号电表 → 不同"寄存器 profile"。本表是 `sdm120` 这一个 profile 的定义。
|
||||
+36
-12
@@ -2,7 +2,7 @@
|
||||
|
||||
本文档记录 `home-automation` 在 `v1.0.3` 之后的下一阶段规划。这一阶段不是小修补,而是几次较大的结构性改动:单库化、前端重写、以及远期的移动端试水。
|
||||
|
||||
> 每个里程碑的**可执行原子任务**展开在 [`docs/design/`](./design/README.md):M1 [`m1-db-consolidation.md`](./design/m1-db-consolidation.md)、M2 [`m2-frontend-v2.md`](./design/m2-frontend-v2.md)、M3 [`m3-token-mobile.md`](./design/m3-token-mobile.md)。这些文档为 Orchestrator→Implementer→Reviewer 的多模型流水线设计。
|
||||
> 每个里程碑的**可执行原子任务**展开在 [`docs/design/`](./design/README.md):M1 [`m1-db-consolidation.md`](./design/m1-db-consolidation.md)、M2 [`m2-frontend-v2.md`](./design/m2-frontend-v2.md)、M3 [`m3-token-mobile.md`](./design/m3-token-mobile.md)、M4 [`m4-login-hardening.md`](./design/m4-login-hardening.md)。这些文档为 Orchestrator→Implementer→Reviewer 的多模型流水线设计。
|
||||
|
||||
## 当前基线(v1.0.3)
|
||||
|
||||
@@ -36,9 +36,11 @@
|
||||
| --- | --- | --- |
|
||||
| **M1** ✅ | 单库化地基 | 把三库合并成单一 `app.db`,清理散落数据层,删掉 Grafana |
|
||||
| **M2** ✅ | 前端 v2 | React SPA 取代 Jinja,承载 config + 可视化 + 记录增删改 |
|
||||
| **M4** ✅ | 登录加固 | 防爆破/指数退避 + CLI 逃生通道 + 可选 TOTP 二次验证(**先于 M5**) |
|
||||
| **M5** | IoT / 能耗采集 | Modbus/Energy + MQTT/HA Discovery + 前端侧边栏 |
|
||||
| **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 |
|
||||
|
||||
排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上,否则就是在准备推倒的旧数据层上盖新楼、之后回头返工。
|
||||
排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上;M4 是公网安全加固,在 M5 IoT 集成之前先堵住裸密码这个洞;M5 在安全基座就绪后再做 IoT 接入。
|
||||
|
||||
---
|
||||
|
||||
@@ -132,6 +134,22 @@
|
||||
|
||||
---
|
||||
|
||||
## M4 — 登录加固(✅ 已完成,排在 M5 之前)
|
||||
|
||||
### 目标
|
||||
|
||||
给暴露在公网的单 admin 登录做纵深防御,先于 M5 IoT 集成关闭暴力枚举和单因子风险。
|
||||
|
||||
### 范围
|
||||
|
||||
- **防爆破 / 指数退避**:失败登录按双键(IP + username)指数增长延迟,成功即清零。退避是延迟(429 + Retry-After),不是永久封号。全局开关 `AUTH_LOGIN_THROTTLE_ENABLED`(CONFIG_FIELDS);反代后需开 `AUTH_TRUST_FORWARDED_FOR`(`.env` 部署级)。
|
||||
- **CLI 逃生通道**:`python -m scripts.admin_cli` 直连本地 DB,无需 HTTP 服务、无需任何已存凭据;支持重置密码、解锁退避、关停/重发 TOTP、查看用户。
|
||||
- **可选 TOTP 二次验证**:admin 可自选启用;启用后两步登录(密码 + 6 位动态码或一次性恢复码);不启用维持纯密码。CLI `disable-totp` 是连恢复码都丢了时的最终逃生口。
|
||||
|
||||
> 详细设计与任务卡:[`docs/design/m4-login-hardening.md`](./design/m4-login-hardening.md)
|
||||
|
||||
---
|
||||
|
||||
## M3 — 开放与移动端(远期试水)
|
||||
|
||||
### 目标
|
||||
@@ -149,22 +167,28 @@
|
||||
- 移动端是这一阶段最远期、最不确定的部分。
|
||||
- token 主要是移动端的前置条件;Web 端 React 用现有 session cookie 即可,不需要为它提前引入 token。
|
||||
|
||||
## Future Ideas(暂不排期,想到先记下)
|
||||
## 下一阶段:已确定要做(尚未拆解为任务卡)
|
||||
|
||||
> 这里收集**还没排进里程碑**的想法。不是承诺、也没有先后顺序;想做时再从这里捞出来细化成 `docs/design/` 的任务卡。**明确不开 M2.5**——下列条目一律先躺在 Future Ideas,之后再说。
|
||||
> 这些是 M4 之后**已经定下来要做**的方向——区别于下面的 Future Ideas(仅备忘、未必做)。这里只记到 roadmap 粒度:确定**做什么、为什么**;具体排期、依赖与原子任务,等动手时再展开成 `docs/design/` 的任务卡。**先后顺序未定**,具体排期等动手时再定。
|
||||
|
||||
### TOTP 二次验证(Dashboard 加固)
|
||||
### 1. 前端优化
|
||||
|
||||
**动机**:M2 之后多了一个 Web Dashboard。它虽有单 admin 密码保护,但**大概率会暴露在公网**上,只靠密码这一层不够。给登录再叠一层 **TOTP(基于时间的一次性密码,RFC 6238)** 作为第二因子,做纵深防御。
|
||||
**动机**:M2 的 React SPA 先把功能跑通,性能 / 体验层面的打磨还没做。这一项**确定要做,但具体优化什么还没定**。
|
||||
|
||||
**范围(待定)**:方向先留空,想清楚再细化。可能的候选(仅占位、非承诺):打包体积与代码分割(M2 构建已提示存在 > 500 kB 的单 chunk)、首屏加载、热力图 / 地图的渲染性能、移动端适配、可访问性等。等确定具体目标后再拆任务卡。
|
||||
|
||||
### 2. 设置页生成 Long-lived Token(供 API 调用)
|
||||
|
||||
**动机**:浏览器端走 session cookie 即可,但**脚本 / 设备 / 外部程序调用 API** 需要一种长期有效、可随身携带的凭据。在设置页加一组功能,由 admin **手动签发 long-lived token**,之后用它来调 API。
|
||||
|
||||
**范围(粗略,待细化)**:
|
||||
|
||||
- 在现有单 admin(Argon2 + server-side session)登录之上,叠加 TOTP 第二步:密码校验通过后再验 6 位动态码,通过才发 session cookie。
|
||||
- 首次启用时生成 TOTP secret,给出可导入 Authenticator 的二维码 / 可手输密钥;同时生成一组一次性**恢复码(recovery codes)**。
|
||||
- 设置页新增「API Token」区:生成 / 命名 / 吊销 long-lived token;明文只在**生成时展示一次**,此后只存哈希。
|
||||
- 后端支持用该 token 鉴权访问 API(与现有 session cookie 并存,互不影响)。
|
||||
- 与 [M3](#m3--开放与移动端远期试水) 的 token 主题相关,但**这条是 Web 设置页手动签发的 PAT 风格**,不依赖移动端 OAuth 流程;两者实现时可复用同一套 token 存储 / 校验。
|
||||
|
||||
**运维 / 命令行要求(关键,实现时必须满足)**:
|
||||
## Future Ideas(暂不排期,想到先记下)
|
||||
|
||||
1. **忘记密码**:不需要任何 Web 端“找回密码”流程——直接在命令行里重置 admin 密码即可(沿用现有 CLI 思路)。
|
||||
2. **TOTP 重置 / 恢复**:必须提供**命令行重置入口**。要覆盖最坏情况——**连恢复码(restore key)都丢了**,也能纯靠 CLI 把 TOTP 关掉 / 重新发放新的 secret,从而恢复登录。即:**CLI 是不依赖任何已存恢复凭据的最终逃生通道**,不能出现“密钥丢了就彻底锁死”的死角。
|
||||
> 这里收集**还没排进里程碑、也还没决定要不要做**的想法。不是承诺、也没有先后顺序;想做时再从这里捞出来——先升进上面的「下一阶段」,再细化成 `docs/design/` 的任务卡。
|
||||
|
||||
**先不做**:本条仅记入 Future Ideas,不进 M2.5、不排期;之后再细化为 design 任务卡。
|
||||
_(暂无条目。)_
|
||||
|
||||
Generated
+10
@@ -17,6 +17,7 @@
|
||||
"leaflet.heat": "^0.2.0",
|
||||
"leaflet.markercluster": "^1.5.3",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-feather": "^2.0.10",
|
||||
@@ -5661,6 +5662,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode.react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"leaflet.heat": "^0.2.0",
|
||||
"leaflet.markercluster": "^1.5.3",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-feather": "^2.0.10",
|
||||
|
||||
Vendored
+270
@@ -245,6 +245,7 @@ export interface paths {
|
||||
*
|
||||
* On success, sets an HttpOnly session cookie and returns the session user + CSRF token.
|
||||
* On failure, returns 401 with no cookie set.
|
||||
* Repeated failures trigger exponential back-off (429 + Retry-After).
|
||||
* No X-CSRF-Token required (unauthenticated endpoint).
|
||||
*/
|
||||
post: operations["post_login_api_auth_login_post"];
|
||||
@@ -300,6 +301,112 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/auth/totp/setup": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Post Totp Setup
|
||||
* @description Generate a new pending TOTP secret, otpauth URI, and one-time recovery codes.
|
||||
*
|
||||
* The secret is stored in the DB but TOTP is NOT yet enabled (totp_enabled stays
|
||||
* False until the user confirms with POST /api/auth/totp/enable).
|
||||
*
|
||||
* Recovery codes are returned here as plaintext exactly once; their Argon2 hashes
|
||||
* are persisted immediately so enable only needs to flip the enabled flag.
|
||||
*
|
||||
* Repeating this call replaces any prior pending secret and regenerates codes.
|
||||
*
|
||||
* Requires: session cookie + X-CSRF-Token.
|
||||
*/
|
||||
post: operations["post_totp_setup_api_auth_totp_setup_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/auth/totp/enable": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Post Totp Enable
|
||||
* @description Enable TOTP by confirming with the current 6-digit code from the authenticator app.
|
||||
*
|
||||
* Requires a prior call to POST /api/auth/totp/setup (so that a pending secret
|
||||
* exists). On success, totp_enabled becomes True.
|
||||
*
|
||||
* Returns 400 if the code is wrong or there is no pending secret.
|
||||
* Requires: session cookie + X-CSRF-Token.
|
||||
*/
|
||||
post: operations["post_totp_enable_api_auth_totp_enable_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/auth/totp/disable": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Post Totp Disable
|
||||
* @description Disable TOTP. The caller must provide exactly one of:
|
||||
* - ``password``: the user's current login password, OR
|
||||
* - ``code``: the current 6-digit TOTP code.
|
||||
*
|
||||
* On success: totp_enabled=False, totp_secret cleared, all recovery codes deleted.
|
||||
* Returns 400 if neither credential matches or neither is provided.
|
||||
* Requires: session cookie + X-CSRF-Token.
|
||||
*/
|
||||
post: operations["post_totp_disable_api_auth_totp_disable_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/auth/totp": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Totp Status
|
||||
* @description Return the current TOTP status for the authenticated user.
|
||||
*
|
||||
* Response contains only ``{"enabled": bool}``.
|
||||
* Secret and recovery codes are NEVER returned here.
|
||||
* Requires: session cookie only (no CSRF — read-only).
|
||||
*/
|
||||
get: operations["get_totp_status_api_auth_totp_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/homeassistant/publish": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -510,6 +617,8 @@ export interface components {
|
||||
username: string;
|
||||
/** Password */
|
||||
password: string;
|
||||
/** Totp Code */
|
||||
totp_code?: string | null;
|
||||
};
|
||||
/** PasswordChangeRequest */
|
||||
PasswordChangeRequest: {
|
||||
@@ -647,6 +756,50 @@ export interface components {
|
||||
/** Status */
|
||||
status: string;
|
||||
};
|
||||
/**
|
||||
* TotpDisableRequest
|
||||
* @description Disable TOTP by proving identity.
|
||||
*
|
||||
* Exactly one of ``password`` or ``code`` must be provided.
|
||||
*/
|
||||
TotpDisableRequest: {
|
||||
/** Password */
|
||||
password?: string | null;
|
||||
/** Code */
|
||||
code?: string | null;
|
||||
};
|
||||
/**
|
||||
* TotpEnableRequest
|
||||
* @description The user confirms setup by providing the 6-digit TOTP code.
|
||||
*/
|
||||
TotpEnableRequest: {
|
||||
/** Code */
|
||||
code: string;
|
||||
};
|
||||
/**
|
||||
* TotpSetupResponse
|
||||
* @description Returned once after a setup call.
|
||||
*
|
||||
* ``secret`` and ``recovery_codes`` are **one-time plaintext values**.
|
||||
* They are never returned again by any subsequent API call.
|
||||
* The frontend must display and instruct the user to save them before confirming.
|
||||
*/
|
||||
TotpSetupResponse: {
|
||||
/** Secret */
|
||||
secret: string;
|
||||
/** Otpauth Uri */
|
||||
otpauth_uri: string;
|
||||
/** Recovery Codes */
|
||||
recovery_codes: string[];
|
||||
};
|
||||
/**
|
||||
* TotpStatusResponse
|
||||
* @description Minimal status response — never exposes secret or recovery codes.
|
||||
*/
|
||||
TotpStatusResponse: {
|
||||
/** Enabled */
|
||||
enabled: boolean;
|
||||
};
|
||||
/** ValidationError */
|
||||
ValidationError: {
|
||||
/** Location */
|
||||
@@ -1143,6 +1296,123 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
post_totp_setup_api_auth_totp_setup_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: {
|
||||
"X-CSRF-Token"?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["TotpSetupResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
post_totp_enable_api_auth_totp_enable_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: {
|
||||
"X-CSRF-Token"?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["TotpEnableRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
post_totp_disable_api_auth_totp_disable_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: {
|
||||
"X-CSRF-Token"?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["TotpDisableRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_totp_status_api_auth_totp_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["TotpStatusResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
publish_from_homeassistant_homeassistant_publish_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* TOTP API helpers (M4-T08).
|
||||
*
|
||||
* All calls go through the typed apiClient (openapi-fetch + generated schema).
|
||||
* CSRF tokens are injected automatically by the client middleware for write
|
||||
* endpoints; GET /api/auth/totp needs no CSRF (read-only).
|
||||
*
|
||||
* Security notes:
|
||||
* - The TotpSetupResponse contains secret + recovery_codes in plaintext.
|
||||
* These are kept only in component state; callers must NOT write them to
|
||||
* localStorage, sessionStorage, or log them.
|
||||
* - After the setup phase is over the data leaves memory when the component
|
||||
* re-renders (one-time display only).
|
||||
*/
|
||||
|
||||
import apiClient, { ApiError } from '../api/client'
|
||||
import type { components } from '../api/schema.d.ts'
|
||||
|
||||
export type TotpSetupResponse = components['schemas']['TotpSetupResponse']
|
||||
export type TotpStatusResponse = components['schemas']['TotpStatusResponse']
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getTotpStatus — GET /api/auth/totp
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Return the current TOTP enabled state for the authenticated user. */
|
||||
export async function getTotpStatus(): Promise<TotpStatusResponse> {
|
||||
const res = await apiClient.GET('/api/auth/totp')
|
||||
if (!res.data) {
|
||||
throw new ApiError(res.response.status, null)
|
||||
}
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setupTotp — POST /api/auth/totp/setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a new pending TOTP secret + recovery codes.
|
||||
* Returns the one-time plaintext values; caller is responsible for displaying
|
||||
* them exactly once and never persisting them outside component state.
|
||||
*/
|
||||
export async function setupTotp(): Promise<TotpSetupResponse> {
|
||||
const res = await apiClient.POST('/api/auth/totp/setup')
|
||||
if (!res.data) {
|
||||
throw new ApiError(res.response.status, null)
|
||||
}
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// enableTotp — POST /api/auth/totp/enable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Confirm TOTP setup with the current 6-digit code from the authenticator app. */
|
||||
export async function enableTotp(code: string): Promise<void> {
|
||||
// The endpoint returns 204 on success; openapi-fetch yields { data: undefined }.
|
||||
await apiClient.POST('/api/auth/totp/enable', { body: { code } })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// disableTotp — POST /api/auth/totp/disable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Disable TOTP. Exactly one of password or code must be provided.
|
||||
* On success the backend clears the secret and all recovery codes.
|
||||
*/
|
||||
export async function disableTotp(opts: {
|
||||
password?: string | null
|
||||
code?: string | null
|
||||
}): Promise<void> {
|
||||
await apiClient.POST('/api/auth/totp/disable', {
|
||||
body: { password: opts.password ?? null, code: opts.code ?? null },
|
||||
})
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from '@mantine/core'
|
||||
import apiClient, { ApiError } from '../api/client'
|
||||
import type { components } from '../api/schema.d.ts'
|
||||
import { TotpSettings } from './TotpSettings'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -393,6 +394,11 @@ export function ConfigPage() {
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* TOTP two-factor auth management */}
|
||||
<Stack mt="xl">
|
||||
<TotpSettings />
|
||||
</Stack>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
/**
|
||||
* Tests for LoginPage (M2-T07).
|
||||
* Tests for LoginPage (M2-T07, extended M4-T08).
|
||||
*
|
||||
* Strategy: vi.mock the apiClient module so we can control POST /api/auth/login
|
||||
* responses without a real server. We also mock useSession so tests can control
|
||||
* the authentication state.
|
||||
*
|
||||
* Coverage:
|
||||
* 1. Renders the login form.
|
||||
* 2. Successful login → invalidates session query + navigates.
|
||||
* 1. Renders the login form (step 1).
|
||||
* 2. Successful login (step 1) → invalidates session query + navigates.
|
||||
* 3. 401 bad credentials → shows inline error, does not navigate.
|
||||
* 4. Already-authenticated users visiting /login → redirected to '/'.
|
||||
* 5. Unexpected error → shows generic error message.
|
||||
* 6. (M4-T08) 401 with totp_required → switches to step 2 TOTP form.
|
||||
* 7. (M4-T08) Step 2 submit with correct code → success + navigate.
|
||||
* 8. (M4-T08) Step 2 submit with wrong code → shows error, stays on step 2.
|
||||
* 9. (M4-T08) Step 2 "Back to login" button → returns to step 1.
|
||||
* 10. (M4-T08) 429 response → shows "too many attempts" message.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
@@ -86,6 +91,10 @@ describe('LoginPage', () => {
|
||||
mockUseSession.mockReturnValue({ status: 'unauthenticated', user: null })
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Renders the form
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('renders the login form with username and password fields', () => {
|
||||
renderLogin()
|
||||
expect(screen.getByTestId('login-form')).toBeInTheDocument()
|
||||
@@ -99,8 +108,11 @@ describe('LoginPage', () => {
|
||||
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Successful login
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('navigates to "/" on successful login', async () => {
|
||||
// Simulate a successful POST /api/auth/login response
|
||||
mockPost.mockResolvedValueOnce({
|
||||
data: { user: { username: 'admin', force_password_change: false }, csrf_token: 'tok123' },
|
||||
response: { status: 200, ok: true },
|
||||
@@ -130,10 +142,14 @@ describe('LoginPage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3. 401 bad credentials
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows inline error on 401 and does NOT navigate', async () => {
|
||||
// Simulate 401: openapi-fetch returns { data: undefined, response: { status: 401 } }
|
||||
mockPost.mockResolvedValueOnce({
|
||||
data: undefined,
|
||||
error: { detail: 'invalid username or password' },
|
||||
response: { status: 401, ok: false },
|
||||
})
|
||||
|
||||
@@ -154,6 +170,7 @@ describe('LoginPage', () => {
|
||||
it('does not include the password in the error message', async () => {
|
||||
mockPost.mockResolvedValueOnce({
|
||||
data: undefined,
|
||||
error: { detail: 'invalid username or password' },
|
||||
response: { status: 401, ok: false },
|
||||
})
|
||||
|
||||
@@ -167,6 +184,10 @@ describe('LoginPage', () => {
|
||||
expect(screen.getByTestId('login-error')).not.toHaveTextContent('super-secret-password')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5. Unexpected error
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows generic error on unexpected network failure', async () => {
|
||||
mockPost.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
@@ -180,6 +201,10 @@ describe('LoginPage', () => {
|
||||
expect(screen.getByTestId('login-error')).toHaveTextContent(/login failed/i)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4. Already authenticated
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('redirects already-authenticated users to "/"', async () => {
|
||||
mockUseSession.mockReturnValue({
|
||||
status: 'authenticated',
|
||||
@@ -192,4 +217,148 @@ describe('LoginPage', () => {
|
||||
expect(screen.getByTestId('home-page')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 6. (M4-T08) 401 with totp_required → step 2
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('switches to TOTP step on 401 with totp_required=true', async () => {
|
||||
mockPost.mockResolvedValueOnce({
|
||||
data: undefined,
|
||||
error: { detail: { totp_required: true } },
|
||||
response: { status: 401, ok: false },
|
||||
})
|
||||
|
||||
renderLogin()
|
||||
fillAndSubmit('admin', 'correct-password')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('login-totp-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Step 1 form should be gone
|
||||
expect(screen.queryByTestId('login-form')).not.toBeInTheDocument()
|
||||
// TOTP code input should be visible
|
||||
expect(screen.getByTestId('totp-code-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 7. (M4-T08) Step 2 correct code → success
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('completes login on correct TOTP code (step 2)', async () => {
|
||||
// Step 1: trigger totp_required
|
||||
mockPost.mockResolvedValueOnce({
|
||||
data: undefined,
|
||||
error: { detail: { totp_required: true } },
|
||||
response: { status: 401, ok: false },
|
||||
})
|
||||
// Step 2: success
|
||||
mockPost.mockResolvedValueOnce({
|
||||
data: { user: { username: 'admin', force_password_change: false }, csrf_token: 'tok456' },
|
||||
response: { status: 200, ok: true },
|
||||
})
|
||||
|
||||
renderLogin()
|
||||
fillAndSubmit('admin', 'correct-password')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('login-totp-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Enter the TOTP code and submit step 2
|
||||
fireEvent.change(screen.getByTestId('totp-code-input'), { target: { value: '123456' } })
|
||||
fireEvent.submit(screen.getByTestId('login-totp-form'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('home-page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Check step 2 request included totp_code
|
||||
expect(mockPost).toHaveBeenCalledWith('/api/auth/login', {
|
||||
body: { username: 'admin', password: 'correct-password', totp_code: '123456' },
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 8. (M4-T08) Step 2 wrong code → error, stays on step 2
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows error on wrong TOTP code and stays on step 2', async () => {
|
||||
// Step 1: totp_required
|
||||
mockPost.mockResolvedValueOnce({
|
||||
data: undefined,
|
||||
error: { detail: { totp_required: true } },
|
||||
response: { status: 401, ok: false },
|
||||
})
|
||||
// Step 2: wrong code → 401
|
||||
mockPost.mockResolvedValueOnce({
|
||||
data: undefined,
|
||||
error: { detail: 'invalid totp code' },
|
||||
response: { status: 401, ok: false },
|
||||
})
|
||||
|
||||
renderLogin()
|
||||
fillAndSubmit('admin', 'correct-password')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('login-totp-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByTestId('totp-code-input'), { target: { value: '000000' } })
|
||||
fireEvent.submit(screen.getByTestId('login-totp-form'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('login-error')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should still be on TOTP form
|
||||
expect(screen.getByTestId('login-totp-form')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('login-error')).toHaveTextContent(/incorrect verification code/i)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 9. (M4-T08) Step 2 "Back to login" → returns to step 1
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('returns to step 1 when "Back to login" is clicked', async () => {
|
||||
mockPost.mockResolvedValueOnce({
|
||||
data: undefined,
|
||||
error: { detail: { totp_required: true } },
|
||||
response: { status: 401, ok: false },
|
||||
})
|
||||
|
||||
renderLogin()
|
||||
fillAndSubmit('admin', 'correct-password')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('login-totp-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('totp-back'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('login-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('login-totp-form')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 10. (M4-T08) 429 → "too many attempts" message
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows "too many attempts" message on 429', async () => {
|
||||
const { ApiError } = await import('../api/client')
|
||||
mockPost.mockRejectedValueOnce(new ApiError(429, { detail: 'Too Many Requests' }))
|
||||
|
||||
renderLogin()
|
||||
fillAndSubmit('admin', 'password')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('login-error')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('login-error')).toHaveTextContent(/too many login attempts/i)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
/**
|
||||
* LoginPage — real login form (M2-T07).
|
||||
* LoginPage — real login form (M2-T07, extended M4-T08).
|
||||
*
|
||||
* Behaviours:
|
||||
* - Renders a Mantine form with username + password fields.
|
||||
* - Renders a Mantine form with username + password fields (step 1).
|
||||
* - On submit → POST /api/auth/login via apiClient (no CSRF needed; unauthenticated endpoint).
|
||||
* - On success → invalidate ['session'] so SessionProvider re-fetches, then navigate to the
|
||||
* originally-requested route (from location.state.from) or fall back to '/'.
|
||||
* - On 401 (bad credentials) → show an inline error without leaking the password.
|
||||
* - On 401 with totp_required=true → switch to step 2 (TOTP code entry).
|
||||
* - On step 2 submit → POST /api/auth/login with {username, password, totp_code}.
|
||||
* - On 401 plain (bad credentials / wrong TOTP code) → show inline error.
|
||||
* - On 429 (rate-limited) → show "too many attempts, please try again later".
|
||||
* - Already-authenticated users visiting /login → redirect to '/'.
|
||||
*
|
||||
* Security:
|
||||
* - Password is retained in state for the step-2 re-submission but is NEVER
|
||||
* written to localStorage, sessionStorage, or console.
|
||||
* - totp_code / recovery codes are NEVER logged or persisted.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
@@ -23,9 +31,10 @@ import {
|
||||
Alert,
|
||||
Stack,
|
||||
Center,
|
||||
Text,
|
||||
} from '@mantine/core'
|
||||
import { useSession } from '../auth/SessionProvider'
|
||||
import apiClient from '../api/client'
|
||||
import apiClient, { ApiError } from '../api/client'
|
||||
import { setCsrfToken } from '../api/csrf'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -36,6 +45,9 @@ interface LocationState {
|
||||
from?: { pathname: string }
|
||||
}
|
||||
|
||||
/** The two UI states for the login flow. */
|
||||
type LoginStep = 'credentials' | 'totp'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -48,6 +60,8 @@ export function LoginPage() {
|
||||
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [step, setStep] = useState<LoginStep>('credentials')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
@@ -57,7 +71,11 @@ export function LoginPage() {
|
||||
return <Navigate to={from} replace />
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// handleCredentialsSubmit — step 1
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleCredentialsSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
@@ -68,31 +86,84 @@ export function LoginPage() {
|
||||
})
|
||||
|
||||
if (res.response.status === 401 || !res.data) {
|
||||
// Bad credentials — do not leak the password in the message.
|
||||
// Check if the backend wants a TOTP code.
|
||||
// openapi-fetch puts 401 body in res.error (not res.data).
|
||||
// The shape is { detail: { totp_required: true } } for TOTP-required,
|
||||
// or { detail: "invalid username or password" } for bad credentials.
|
||||
const detail = (res.error as { detail?: unknown } | undefined)?.detail
|
||||
if (detail && typeof detail === 'object' && (detail as { totp_required?: boolean }).totp_required === true) {
|
||||
// Transition to step 2 — TOTP entry. Password stays in state for re-send.
|
||||
setStep('totp')
|
||||
setTotpCode('')
|
||||
return
|
||||
}
|
||||
// Ordinary bad credentials.
|
||||
setError('Incorrect username or password.')
|
||||
return
|
||||
}
|
||||
|
||||
// Success: store the CSRF token returned by login (same shape as session response).
|
||||
// Success: store the CSRF token returned by login.
|
||||
if (res.data.csrf_token) {
|
||||
setCsrfToken(res.data.csrf_token)
|
||||
}
|
||||
|
||||
// Refresh session state: invalidate the ['session'] query so SessionProvider
|
||||
// picks up the new authenticated state (which may include force_password_change).
|
||||
// Refresh session state.
|
||||
await queryClient.invalidateQueries({ queryKey: ['session'] })
|
||||
|
||||
// Navigate to the originally-requested route or home.
|
||||
const from = (location.state as LocationState)?.from?.pathname ?? '/'
|
||||
navigate(from, { replace: true })
|
||||
} catch {
|
||||
// Any unexpected error (network, 5xx, etc.)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 429) {
|
||||
setError('Too many login attempts. Please try again later.')
|
||||
} else {
|
||||
setError('Login failed. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handleTotpSubmit — step 2
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleTotpSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await apiClient.POST('/api/auth/login', {
|
||||
body: { username, password, totp_code: totpCode },
|
||||
})
|
||||
|
||||
if (res.response.status === 401 || !res.data) {
|
||||
// Wrong code (or expired) — stay on step 2 and show error.
|
||||
setError('Incorrect verification code. Please try again.')
|
||||
return
|
||||
}
|
||||
|
||||
// Success.
|
||||
if (res.data.csrf_token) {
|
||||
setCsrfToken(res.data.csrf_token)
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: ['session'] })
|
||||
const from = (location.state as LocationState)?.from?.pathname ?? '/'
|
||||
navigate(from, { replace: true })
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 429) {
|
||||
setError('Too many login attempts. Please try again later.')
|
||||
} else {
|
||||
setError('Login failed. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<Center mih="100vh">
|
||||
<Container size="xs" w="100%">
|
||||
@@ -107,7 +178,8 @@ export function LoginPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} data-testid="login-form">
|
||||
{step === 'credentials' && (
|
||||
<form onSubmit={handleCredentialsSubmit} data-testid="login-form">
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Username"
|
||||
@@ -140,6 +212,51 @@ export function LoginPage() {
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 'totp' && (
|
||||
<form onSubmit={handleTotpSubmit} data-testid="login-totp-form">
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Enter the 6-digit code from your authenticator app, or a recovery code.
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
label="Verification Code"
|
||||
placeholder="6-digit code or recovery code"
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.currentTarget.value)}
|
||||
required
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
data-testid="totp-code-input"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
loading={loading}
|
||||
mt="sm"
|
||||
data-testid="totp-submit"
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStep('credentials')
|
||||
setError(null)
|
||||
setTotpCode('')
|
||||
}}
|
||||
data-testid="totp-back"
|
||||
>
|
||||
Back to login
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
)}
|
||||
</Paper>
|
||||
</Container>
|
||||
</Center>
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* Tests for TotpSettings (M4-T08).
|
||||
*
|
||||
* Strategy: vi.mock the auth/totp module so we can control API responses
|
||||
* without a real server. We also mock the api/client module for ApiError.
|
||||
*
|
||||
* Coverage:
|
||||
* 1. Shows loading spinner while fetching TOTP status.
|
||||
* 2. Shows error when TOTP status fetch fails.
|
||||
* 3. Shows "Disabled" badge and enable button when TOTP is disabled.
|
||||
* 4. Enable flow: clicking setup → shows QR code + recovery codes + confirm form.
|
||||
* 5. Confirm with correct code → TOTP enabled (status refetched).
|
||||
* 6. Confirm with wrong code → shows error, stays on confirm form.
|
||||
* 7. Shows "Enabled" badge and disable form when TOTP is enabled.
|
||||
* 8. Disable with password → TOTP disabled.
|
||||
* 9. Disable with TOTP code → TOTP disabled.
|
||||
* 10. Disable without providing password or code → shows validation error.
|
||||
* 11. Setup error shows error message.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
||||
import { renderWithProviders } from '../test-utils'
|
||||
import { TotpSettings } from './TotpSettings'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock auth/totp
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockGetTotpStatus = vi.fn()
|
||||
const mockSetupTotp = vi.fn()
|
||||
const mockEnableTotp = vi.fn()
|
||||
const mockDisableTotp = vi.fn()
|
||||
|
||||
vi.mock('../auth/totp', () => ({
|
||||
getTotpStatus: (...args: unknown[]) => mockGetTotpStatus(...args),
|
||||
setupTotp: (...args: unknown[]) => mockSetupTotp(...args),
|
||||
enableTotp: (...args: unknown[]) => mockEnableTotp(...args),
|
||||
disableTotp: (...args: unknown[]) => mockDisableTotp(...args),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock api/client (for ApiError)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
default: {
|
||||
GET: vi.fn(),
|
||||
POST: vi.fn(),
|
||||
PUT: vi.fn(),
|
||||
},
|
||||
ApiError: class ApiError extends Error {
|
||||
status: number
|
||||
body: unknown
|
||||
constructor(status: number, body: unknown) {
|
||||
super(`API error ${status}`)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
},
|
||||
registerLoginRedirect: vi.fn(),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MOCK_SETUP_DATA = {
|
||||
secret: 'JBSWY3DPEHPK3PXP',
|
||||
otpauth_uri: 'otpauth://totp/TestApp:admin?secret=JBSWY3DPEHPK3PXP&issuer=TestApp',
|
||||
recovery_codes: ['abcd-1234', 'efgh-5678', 'ijkl-9012'],
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderTotp() {
|
||||
return renderWithProviders(<TotpSettings />, { initialPath: '/config' })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('TotpSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Loading state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows loading spinner while fetching TOTP status', async () => {
|
||||
// Never resolves during this test
|
||||
mockGetTotpStatus.mockReturnValue(new Promise(() => {}))
|
||||
renderTotp()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-status-loading')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Error state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows error when TOTP status fetch fails', async () => {
|
||||
mockGetTotpStatus.mockRejectedValue(new Error('Network error'))
|
||||
renderTotp()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-status-error')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3. Disabled state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows Disabled badge and enable button when TOTP is disabled', async () => {
|
||||
mockGetTotpStatus.mockResolvedValue({ enabled: false })
|
||||
renderTotp()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-status-badge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('totp-status-badge')).toHaveTextContent(/disabled/i)
|
||||
expect(screen.getByTestId('totp-setup-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4. Enable flow: setup shows QR + recovery codes + confirm form
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows QR code, secret, and recovery codes after clicking setup', async () => {
|
||||
mockGetTotpStatus.mockResolvedValue({ enabled: false })
|
||||
mockSetupTotp.mockResolvedValue(MOCK_SETUP_DATA)
|
||||
|
||||
renderTotp()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-setup-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('totp-setup-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-qr-code')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('totp-secret')).toHaveTextContent('JBSWY3DPEHPK3PXP')
|
||||
expect(screen.getByTestId('totp-recovery-codes')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('totp-recovery-codes')).toHaveTextContent('abcd-1234')
|
||||
expect(screen.getByTestId('totp-confirm-form')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('totp-confirm-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5. Confirm with correct code → enabled
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('enables TOTP after confirming with correct code', async () => {
|
||||
mockGetTotpStatus
|
||||
.mockResolvedValueOnce({ enabled: false })
|
||||
.mockResolvedValueOnce({ enabled: true })
|
||||
mockSetupTotp.mockResolvedValue(MOCK_SETUP_DATA)
|
||||
mockEnableTotp.mockResolvedValue(undefined)
|
||||
|
||||
renderTotp()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-setup-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('totp-setup-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-confirm-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByTestId('totp-confirm-input'), { target: { value: '123456' } })
|
||||
fireEvent.submit(screen.getByTestId('totp-confirm-form'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockEnableTotp).toHaveBeenCalledWith('123456')
|
||||
})
|
||||
|
||||
// After enable the status refetches → shows enabled
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-status-badge')).toHaveTextContent(/enabled/i)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 6. Confirm with wrong code → error
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows error on wrong TOTP code during enable', async () => {
|
||||
mockGetTotpStatus.mockResolvedValue({ enabled: false })
|
||||
mockSetupTotp.mockResolvedValue(MOCK_SETUP_DATA)
|
||||
|
||||
const { ApiError } = await import('../api/client')
|
||||
mockEnableTotp.mockRejectedValue(new ApiError(400, { detail: 'invalid code' }))
|
||||
|
||||
renderTotp()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-setup-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('totp-setup-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-confirm-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByTestId('totp-confirm-input'), { target: { value: '000000' } })
|
||||
fireEvent.submit(screen.getByTestId('totp-confirm-form'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-confirm-error')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should still show confirm form (not enabled)
|
||||
expect(screen.getByTestId('totp-confirm-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 7. Enabled state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows Enabled badge and disable form when TOTP is enabled', async () => {
|
||||
mockGetTotpStatus.mockResolvedValue({ enabled: true })
|
||||
renderTotp()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-status-badge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('totp-status-badge')).toHaveTextContent(/enabled/i)
|
||||
expect(screen.getByTestId('totp-disable-form')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('totp-disable-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 8. Disable with password
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('disables TOTP when correct password is provided', async () => {
|
||||
mockGetTotpStatus
|
||||
.mockResolvedValueOnce({ enabled: true })
|
||||
.mockResolvedValueOnce({ enabled: false })
|
||||
mockDisableTotp.mockResolvedValue(undefined)
|
||||
|
||||
renderTotp()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-disable-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByTestId('totp-disable-password'), {
|
||||
target: { value: 'my-password' },
|
||||
})
|
||||
fireEvent.submit(screen.getByTestId('totp-disable-form'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDisableTotp).toHaveBeenCalledWith({ password: 'my-password', code: null })
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-status-badge')).toHaveTextContent(/disabled/i)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 9. Disable with TOTP code
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('disables TOTP when correct TOTP code is provided', async () => {
|
||||
mockGetTotpStatus
|
||||
.mockResolvedValueOnce({ enabled: true })
|
||||
.mockResolvedValueOnce({ enabled: false })
|
||||
mockDisableTotp.mockResolvedValue(undefined)
|
||||
|
||||
renderTotp()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-disable-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByTestId('totp-disable-code'), { target: { value: '654321' } })
|
||||
fireEvent.submit(screen.getByTestId('totp-disable-form'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDisableTotp).toHaveBeenCalledWith({ password: null, code: '654321' })
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 10. Disable without credentials → validation error
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows validation error when disable submitted without password or code', async () => {
|
||||
mockGetTotpStatus.mockResolvedValue({ enabled: true })
|
||||
|
||||
renderTotp()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-disable-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Submit without filling in anything
|
||||
fireEvent.submit(screen.getByTestId('totp-disable-form'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-disable-error')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(mockDisableTotp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 11. Setup error
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows error when TOTP setup request fails', async () => {
|
||||
mockGetTotpStatus.mockResolvedValue({ enabled: false })
|
||||
mockSetupTotp.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
renderTotp()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-setup-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('totp-setup-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('totp-enable-error')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* TotpSettings — TOTP two-factor auth management panel (M4-T08).
|
||||
*
|
||||
* Intended to be embedded in the settings/config area of the app.
|
||||
* Handles the full TOTP lifecycle:
|
||||
* - Status probe : GET /api/auth/totp
|
||||
* - Enable flow : setup → show QR code + one-time recovery codes → confirm with code
|
||||
* - Disable flow : prompt for current password or TOTP code → disable
|
||||
*
|
||||
* Security notes:
|
||||
* - secret and recovery_codes from TotpSetupResponse are held ONLY in component
|
||||
* state; they are NEVER written to localStorage, sessionStorage, or console.
|
||||
* - Recovery codes are displayed exactly once (during the setup flow); after the
|
||||
* user confirms with a code and TOTP is enabled the data is discarded.
|
||||
* - Passwords typed in the disable form are NEVER logged or persisted.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Paper,
|
||||
Title,
|
||||
Text,
|
||||
Button,
|
||||
Alert,
|
||||
Stack,
|
||||
Group,
|
||||
TextInput,
|
||||
PasswordInput,
|
||||
Divider,
|
||||
Badge,
|
||||
Loader,
|
||||
Center,
|
||||
Code,
|
||||
List,
|
||||
} from '@mantine/core'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { getTotpStatus, setupTotp, enableTotp, disableTotp } from '../auth/totp'
|
||||
import { ApiError } from '../api/client'
|
||||
import type { TotpSetupResponse } from '../auth/totp'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-panel: Enable flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EnablePanelProps {
|
||||
onEnabled: () => void
|
||||
}
|
||||
|
||||
function EnablePanel({ onEnabled }: EnablePanelProps) {
|
||||
const [setupData, setSetupData] = useState<TotpSetupResponse | null>(null)
|
||||
const [confirmCode, setConfirmCode] = useState('')
|
||||
const [status, setStatus] = useState<'idle' | 'setup-loading' | 'confirm' | 'confirming' | 'error'>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||
|
||||
async function handleSetup() {
|
||||
setStatus('setup-loading')
|
||||
setErrorMsg(null)
|
||||
try {
|
||||
const data = await setupTotp()
|
||||
// data contains one-time secret + recovery_codes — keep only in state.
|
||||
setSetupData(data)
|
||||
setStatus('confirm')
|
||||
} catch {
|
||||
setErrorMsg('Failed to start TOTP setup. Please try again.')
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEnable(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setErrorMsg(null)
|
||||
setStatus('confirming')
|
||||
try {
|
||||
await enableTotp(confirmCode)
|
||||
// Discard secret + recovery codes from memory now that TOTP is enabled.
|
||||
setSetupData(null)
|
||||
setConfirmCode('')
|
||||
onEnabled()
|
||||
} catch (err) {
|
||||
let msg = 'Failed to enable TOTP. Check the code and try again.'
|
||||
if (err instanceof ApiError && err.status === 400) {
|
||||
msg = 'Incorrect code or no pending setup. Please start the setup again.'
|
||||
}
|
||||
setErrorMsg(msg)
|
||||
setStatus('confirm')
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: idle — show "Start Setup" button
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (status === 'idle' || status === 'error') {
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">
|
||||
Two-factor authentication is not enabled. Enable it to require a verification code
|
||||
each time you sign in.
|
||||
</Text>
|
||||
{errorMsg && (
|
||||
<Alert color="red" data-testid="totp-enable-error">
|
||||
{errorMsg}
|
||||
</Alert>
|
||||
)}
|
||||
<Button onClick={handleSetup} data-testid="totp-setup-button">
|
||||
Enable Two-Factor Authentication
|
||||
</Button>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: setup-loading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (status === 'setup-loading') {
|
||||
return (
|
||||
<Center>
|
||||
<Loader data-testid="totp-setup-loading" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: confirm — show QR code, secret, recovery codes, confirmation form
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if ((status === 'confirm' || status === 'confirming') && setupData) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Alert color="yellow" data-testid="totp-setup-warning">
|
||||
Save your recovery codes now — they will only be shown once.
|
||||
</Alert>
|
||||
|
||||
<Text size="sm" fw={500}>
|
||||
1. Scan this QR code with your authenticator app (e.g. Google Authenticator, Authy):
|
||||
</Text>
|
||||
|
||||
<Center data-testid="totp-qr-code">
|
||||
<QRCodeSVG value={setupData.otpauth_uri} size={180} />
|
||||
</Center>
|
||||
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Or enter the key manually:
|
||||
</Text>
|
||||
<Code block data-testid="totp-secret">
|
||||
{setupData.secret}
|
||||
</Code>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text size="sm" fw={500}>
|
||||
2. Save these recovery codes in a secure place. Each code can be used once
|
||||
if you lose access to your authenticator:
|
||||
</Text>
|
||||
<Paper withBorder p="sm" data-testid="totp-recovery-codes">
|
||||
<List spacing="xs" size="sm">
|
||||
{setupData.recovery_codes.map((code) => (
|
||||
<List.Item key={code}>
|
||||
<Code>{code}</Code>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text size="sm" fw={500}>
|
||||
3. Enter the 6-digit code from your authenticator app to confirm setup:
|
||||
</Text>
|
||||
|
||||
{errorMsg && (
|
||||
<Alert color="red" data-testid="totp-confirm-error">
|
||||
{errorMsg}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleEnable} data-testid="totp-confirm-form">
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Verification Code"
|
||||
placeholder="6-digit code"
|
||||
value={confirmCode}
|
||||
onChange={(e) => setConfirmCode(e.currentTarget.value)}
|
||||
required
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
data-testid="totp-confirm-input"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={status === 'confirming'}
|
||||
data-testid="totp-confirm-button"
|
||||
>
|
||||
Confirm and Enable
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-panel: Disable flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DisablePanelProps {
|
||||
onDisabled: () => void
|
||||
}
|
||||
|
||||
function DisablePanel({ onDisabled }: DisablePanelProps) {
|
||||
const [disablePassword, setDisablePassword] = useState('')
|
||||
const [disableCode, setDisableCode] = useState('')
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
async function handleDisable(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setErrorMsg(null)
|
||||
|
||||
// At least one of password or code must be provided.
|
||||
if (!disablePassword && !disableCode) {
|
||||
setErrorMsg('Please provide your current password or a TOTP code.')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await disableTotp({
|
||||
password: disablePassword || null,
|
||||
code: disableCode || null,
|
||||
})
|
||||
setDisablePassword('')
|
||||
setDisableCode('')
|
||||
onDisabled()
|
||||
} catch (err) {
|
||||
let msg = 'Failed to disable TOTP. Please try again.'
|
||||
if (err instanceof ApiError && err.status === 400) {
|
||||
msg = 'Incorrect password or code. Please try again.'
|
||||
}
|
||||
setErrorMsg(msg)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Alert color="blue" data-testid="totp-enabled-notice">
|
||||
Two-factor authentication is enabled. To disable it, enter your current password
|
||||
or a valid verification code.
|
||||
</Alert>
|
||||
|
||||
{errorMsg && (
|
||||
<Alert color="red" data-testid="totp-disable-error">
|
||||
{errorMsg}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleDisable} data-testid="totp-disable-form">
|
||||
<Stack gap="sm">
|
||||
<PasswordInput
|
||||
label="Current Password"
|
||||
placeholder="Enter your password (or use a TOTP code below)"
|
||||
value={disablePassword}
|
||||
onChange={(e) => setDisablePassword(e.currentTarget.value)}
|
||||
autoComplete="current-password"
|
||||
data-testid="totp-disable-password"
|
||||
/>
|
||||
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
— or —
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
label="TOTP Code"
|
||||
placeholder="6-digit code from your authenticator"
|
||||
value={disableCode}
|
||||
onChange={(e) => setDisableCode(e.currentTarget.value)}
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
data-testid="totp-disable-code"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
color="red"
|
||||
variant="outline"
|
||||
loading={isSubmitting}
|
||||
data-testid="totp-disable-button"
|
||||
>
|
||||
Disable Two-Factor Authentication
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TotpSettings — main exported component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function TotpSettings() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const {
|
||||
data: totpStatus,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ['totp-status'],
|
||||
queryFn: getTotpStatus,
|
||||
// No automatic retries — the test QueryClient is configured with retry: false.
|
||||
// Network errors are shown immediately via the error state.
|
||||
retry: false,
|
||||
})
|
||||
|
||||
// Invalidate the TOTP status query to refetch after enable/disable.
|
||||
const refetchStatus = useCallback(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['totp-status'] })
|
||||
}, [queryClient])
|
||||
|
||||
return (
|
||||
<Paper withBorder p="md" radius="md" data-testid="totp-settings">
|
||||
<Group justify="space-between" mb="md" wrap="nowrap">
|
||||
<Title order={4}>Two-Factor Authentication (TOTP)</Title>
|
||||
{totpStatus && (
|
||||
<Badge
|
||||
color={totpStatus.enabled ? 'green' : 'gray'}
|
||||
variant="outline"
|
||||
data-testid="totp-status-badge"
|
||||
>
|
||||
{totpStatus.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{isLoading && (
|
||||
<Center>
|
||||
<Loader data-testid="totp-status-loading" />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<Alert color="red" data-testid="totp-status-error">
|
||||
Failed to load TOTP status. Please refresh the page.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{totpStatus && !totpStatus.enabled && <EnablePanel onEnabled={refetchStatus} />}
|
||||
|
||||
{totpStatus && totpStatus.enabled && <DisablePanel onDisabled={refetchStatus} />}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
+274
-1
@@ -653,7 +653,7 @@
|
||||
"api-session"
|
||||
],
|
||||
"summary": "Post Login",
|
||||
"description": "Authenticate with username and password.\n\nOn success, sets an HttpOnly session cookie and returns the session user + CSRF token.\nOn failure, returns 401 with no cookie set.\nNo X-CSRF-Token required (unauthenticated endpoint).",
|
||||
"description": "Authenticate with username and password.\n\nOn success, sets an HttpOnly session cookie and returns the session user + CSRF token.\nOn failure, returns 401 with no cookie set.\nRepeated failures trigger exponential back-off (429 + Retry-After).\nNo X-CSRF-Token required (unauthenticated endpoint).",
|
||||
"operationId": "post_login_api_auth_login_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -795,6 +795,184 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/totp/setup": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"api-session"
|
||||
],
|
||||
"summary": "Post Totp Setup",
|
||||
"description": "Generate a new pending TOTP secret, otpauth URI, and one-time recovery codes.\n\nThe secret is stored in the DB but TOTP is NOT yet enabled (totp_enabled stays\nFalse until the user confirms with POST /api/auth/totp/enable).\n\nRecovery codes are returned here as plaintext exactly once; their Argon2 hashes\nare persisted immediately so enable only needs to flip the enabled flag.\n\nRepeating this call replaces any prior pending secret and regenerates codes.\n\nRequires: session cookie + X-CSRF-Token.",
|
||||
"operationId": "post_totp_setup_api_auth_totp_setup_post",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "X-CSRF-Token",
|
||||
"in": "header",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "X-Csrf-Token"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TotpSetupResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/totp/enable": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"api-session"
|
||||
],
|
||||
"summary": "Post Totp Enable",
|
||||
"description": "Enable TOTP by confirming with the current 6-digit code from the authenticator app.\n\nRequires a prior call to POST /api/auth/totp/setup (so that a pending secret\nexists). On success, totp_enabled becomes True.\n\nReturns 400 if the code is wrong or there is no pending secret.\nRequires: session cookie + X-CSRF-Token.",
|
||||
"operationId": "post_totp_enable_api_auth_totp_enable_post",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "X-CSRF-Token",
|
||||
"in": "header",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "X-Csrf-Token"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TotpEnableRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/totp/disable": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"api-session"
|
||||
],
|
||||
"summary": "Post Totp Disable",
|
||||
"description": "Disable TOTP. The caller must provide exactly one of:\n- ``password``: the user's current login password, OR\n- ``code``: the current 6-digit TOTP code.\n\nOn success: totp_enabled=False, totp_secret cleared, all recovery codes deleted.\nReturns 400 if neither credential matches or neither is provided.\nRequires: session cookie + X-CSRF-Token.",
|
||||
"operationId": "post_totp_disable_api_auth_totp_disable_post",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "X-CSRF-Token",
|
||||
"in": "header",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "X-Csrf-Token"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TotpDisableRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/totp": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"api-session"
|
||||
],
|
||||
"summary": "Get Totp Status",
|
||||
"description": "Return the current TOTP status for the authenticated user.\n\nResponse contains only ``{\"enabled\": bool}``.\nSecret and recovery codes are NEVER returned here.\nRequires: session cookie only (no CSRF — read-only).",
|
||||
"operationId": "get_totp_status_api_auth_totp_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TotpStatusResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/homeassistant/publish": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -1169,6 +1347,17 @@
|
||||
"password": {
|
||||
"type": "string",
|
||||
"title": "Password"
|
||||
},
|
||||
"totp_code": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Totp Code"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -1549,6 +1738,90 @@
|
||||
],
|
||||
"title": "StatusResponse"
|
||||
},
|
||||
"TotpDisableRequest": {
|
||||
"properties": {
|
||||
"password": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Password"
|
||||
},
|
||||
"code": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Code"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"title": "TotpDisableRequest",
|
||||
"description": "Disable TOTP by proving identity.\n\nExactly one of ``password`` or ``code`` must be provided."
|
||||
},
|
||||
"TotpEnableRequest": {
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"title": "Code"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"code"
|
||||
],
|
||||
"title": "TotpEnableRequest",
|
||||
"description": "The user confirms setup by providing the 6-digit TOTP code."
|
||||
},
|
||||
"TotpSetupResponse": {
|
||||
"properties": {
|
||||
"secret": {
|
||||
"type": "string",
|
||||
"title": "Secret"
|
||||
},
|
||||
"otpauth_uri": {
|
||||
"type": "string",
|
||||
"title": "Otpauth Uri"
|
||||
},
|
||||
"recovery_codes": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Recovery Codes"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"secret",
|
||||
"otpauth_uri",
|
||||
"recovery_codes"
|
||||
],
|
||||
"title": "TotpSetupResponse",
|
||||
"description": "Returned once after a setup call.\n\n``secret`` and ``recovery_codes`` are **one-time plaintext values**.\nThey are never returned again by any subsequent API call.\nThe frontend must display and instruct the user to save them before confirming."
|
||||
},
|
||||
"TotpStatusResponse": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"title": "Enabled"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"enabled"
|
||||
],
|
||||
"title": "TotpStatusResponse",
|
||||
"description": "Minimal status response — never exposes secret or recovery codes."
|
||||
},
|
||||
"ValidationError": {
|
||||
"properties": {
|
||||
"loc": {
|
||||
|
||||
@@ -480,6 +480,8 @@ paths:
|
||||
|
||||
On failure, returns 401 with no cookie set.
|
||||
|
||||
Repeated failures trigger exponential back-off (429 + Retry-After).
|
||||
|
||||
No X-CSRF-Token required (unauthenticated endpoint).'
|
||||
operationId: post_login_api_auth_login_post
|
||||
requestBody:
|
||||
@@ -575,6 +577,157 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
/api/auth/totp/setup:
|
||||
post:
|
||||
tags:
|
||||
- api-session
|
||||
summary: Post Totp Setup
|
||||
description: 'Generate a new pending TOTP secret, otpauth URI, and one-time
|
||||
recovery codes.
|
||||
|
||||
|
||||
The secret is stored in the DB but TOTP is NOT yet enabled (totp_enabled stays
|
||||
|
||||
False until the user confirms with POST /api/auth/totp/enable).
|
||||
|
||||
|
||||
Recovery codes are returned here as plaintext exactly once; their Argon2 hashes
|
||||
|
||||
are persisted immediately so enable only needs to flip the enabled flag.
|
||||
|
||||
|
||||
Repeating this call replaces any prior pending secret and regenerates codes.
|
||||
|
||||
|
||||
Requires: session cookie + X-CSRF-Token.'
|
||||
operationId: post_totp_setup_api_auth_totp_setup_post
|
||||
parameters:
|
||||
- name: X-CSRF-Token
|
||||
in: header
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: X-Csrf-Token
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TotpSetupResponse'
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
/api/auth/totp/enable:
|
||||
post:
|
||||
tags:
|
||||
- api-session
|
||||
summary: Post Totp Enable
|
||||
description: 'Enable TOTP by confirming with the current 6-digit code from the
|
||||
authenticator app.
|
||||
|
||||
|
||||
Requires a prior call to POST /api/auth/totp/setup (so that a pending secret
|
||||
|
||||
exists). On success, totp_enabled becomes True.
|
||||
|
||||
|
||||
Returns 400 if the code is wrong or there is no pending secret.
|
||||
|
||||
Requires: session cookie + X-CSRF-Token.'
|
||||
operationId: post_totp_enable_api_auth_totp_enable_post
|
||||
parameters:
|
||||
- name: X-CSRF-Token
|
||||
in: header
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: X-Csrf-Token
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TotpEnableRequest'
|
||||
responses:
|
||||
'204':
|
||||
description: Successful Response
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
/api/auth/totp/disable:
|
||||
post:
|
||||
tags:
|
||||
- api-session
|
||||
summary: Post Totp Disable
|
||||
description: 'Disable TOTP. The caller must provide exactly one of:
|
||||
|
||||
- ``password``: the user''s current login password, OR
|
||||
|
||||
- ``code``: the current 6-digit TOTP code.
|
||||
|
||||
|
||||
On success: totp_enabled=False, totp_secret cleared, all recovery codes deleted.
|
||||
|
||||
Returns 400 if neither credential matches or neither is provided.
|
||||
|
||||
Requires: session cookie + X-CSRF-Token.'
|
||||
operationId: post_totp_disable_api_auth_totp_disable_post
|
||||
parameters:
|
||||
- name: X-CSRF-Token
|
||||
in: header
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: X-Csrf-Token
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TotpDisableRequest'
|
||||
responses:
|
||||
'204':
|
||||
description: Successful Response
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
/api/auth/totp:
|
||||
get:
|
||||
tags:
|
||||
- api-session
|
||||
summary: Get Totp Status
|
||||
description: 'Return the current TOTP status for the authenticated user.
|
||||
|
||||
|
||||
Response contains only ``{"enabled": bool}``.
|
||||
|
||||
Secret and recovery codes are NEVER returned here.
|
||||
|
||||
Requires: session cookie only (no CSRF — read-only).'
|
||||
operationId: get_totp_status_api_auth_totp_get
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TotpStatusResponse'
|
||||
/homeassistant/publish:
|
||||
post:
|
||||
tags:
|
||||
@@ -824,6 +977,11 @@ components:
|
||||
password:
|
||||
type: string
|
||||
title: Password
|
||||
totp_code:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Totp Code
|
||||
type: object
|
||||
required:
|
||||
- username
|
||||
@@ -1077,6 +1235,71 @@ components:
|
||||
required:
|
||||
- status
|
||||
title: StatusResponse
|
||||
TotpDisableRequest:
|
||||
properties:
|
||||
password:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Password
|
||||
code:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Code
|
||||
type: object
|
||||
title: TotpDisableRequest
|
||||
description: 'Disable TOTP by proving identity.
|
||||
|
||||
|
||||
Exactly one of ``password`` or ``code`` must be provided.'
|
||||
TotpEnableRequest:
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
title: Code
|
||||
type: object
|
||||
required:
|
||||
- code
|
||||
title: TotpEnableRequest
|
||||
description: The user confirms setup by providing the 6-digit TOTP code.
|
||||
TotpSetupResponse:
|
||||
properties:
|
||||
secret:
|
||||
type: string
|
||||
title: Secret
|
||||
otpauth_uri:
|
||||
type: string
|
||||
title: Otpauth Uri
|
||||
recovery_codes:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
title: Recovery Codes
|
||||
type: object
|
||||
required:
|
||||
- secret
|
||||
- otpauth_uri
|
||||
- recovery_codes
|
||||
title: TotpSetupResponse
|
||||
description: 'Returned once after a setup call.
|
||||
|
||||
|
||||
``secret`` and ``recovery_codes`` are **one-time plaintext values**.
|
||||
|
||||
They are never returned again by any subsequent API call.
|
||||
|
||||
The frontend must display and instruct the user to save them before confirming.'
|
||||
TotpStatusResponse:
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
title: Enabled
|
||||
type: object
|
||||
required:
|
||||
- enabled
|
||||
title: TotpStatusResponse
|
||||
description: Minimal status response — never exposes secret or recovery codes.
|
||||
ValidationError:
|
||||
properties:
|
||||
loc:
|
||||
|
||||
@@ -4,6 +4,7 @@ argon2-cffi>=25.1,<26.0
|
||||
fastapi>=0.115,<0.116
|
||||
httpx>=0.28,<1.0
|
||||
pydantic-settings>=2.6,<3.0
|
||||
pyotp>=2.9,<3.0
|
||||
python-multipart>=0.0.12,<1.0
|
||||
pyyaml>=6.0,<7.0
|
||||
sqlalchemy>=2.0,<3.0
|
||||
|
||||
@@ -59,6 +59,8 @@ pydantic-core==2.46.2
|
||||
# via pydantic
|
||||
pydantic-settings==2.13.1
|
||||
# via -r requirements.in
|
||||
pyotp==2.10.0
|
||||
# via -r requirements.in
|
||||
python-dotenv==1.2.2
|
||||
# via
|
||||
# pydantic-settings
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Admin CLI — escape hatch for local server administration.
|
||||
|
||||
Entry point::
|
||||
|
||||
python -m scripts.admin_cli <command> [args]
|
||||
|
||||
Commands
|
||||
--------
|
||||
reset-password <username> [--password <pwd>]
|
||||
Reset the Argon2 password hash for the given admin user.
|
||||
If ``--password`` is omitted the new password is read interactively
|
||||
with ``getpass.getpass`` (no echo). Prompts twice for confirmation.
|
||||
|
||||
unlock [--all | --ip <ip> | --username <u>]
|
||||
Delete rows from ``auth_login_throttle``.
|
||||
--all clear every row (all IPs and usernames)
|
||||
--ip clear the row for a specific IP address (scope='ip')
|
||||
--username clear the row for a specific username (scope='user')
|
||||
Prints the number of rows deleted.
|
||||
|
||||
disable-totp <username>
|
||||
Disable TOTP for the given user without requiring any existing credential
|
||||
(recovery codes, current TOTP code, or password). Sets totp_enabled=False,
|
||||
clears totp_secret, and deletes ALL recovery codes for that user.
|
||||
This is the final escape hatch when all recovery codes are lost.
|
||||
|
||||
reissue-totp <username>
|
||||
Generate a fresh TOTP secret for the given user and print the new
|
||||
otpauth:// URI so the user can re-enroll in their Authenticator app.
|
||||
totp_enabled is LEFT UNCHANGED (caller must re-run enable flow if needed).
|
||||
Existing recovery codes are NOT deleted. Recovery codes are independent
|
||||
random values with no cryptographic binding to the TOTP secret, so they
|
||||
remain valid after reissue and continue to serve as backup login credentials.
|
||||
Silently removing them would unexpectedly strip the user of that backup;
|
||||
use disable-totp for a full cleanup (secret + recovery codes).
|
||||
|
||||
list-admin
|
||||
Print all users with their auth status columns (id, username,
|
||||
is_active, force_password_change).
|
||||
|
||||
Design contract
|
||||
---------------
|
||||
- Direct DB access via ``get_session_local()`` — no HTTP, no network.
|
||||
- Only touches ``auth_users`` (totp_secret/totp_enabled/password_hash columns)
|
||||
and ``auth_login_throttle`` / ``auth_recovery_code`` (delete rows).
|
||||
Never touches any user-data table (location, poo, public_ip, app_config, etc.).
|
||||
- Non-zero exit code on user-facing errors (missing user, bad args, …).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure project root is on sys.path when invoked as `python -m scripts.admin_cli`
|
||||
# or directly as a script.
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
import pyotp
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import get_session_local
|
||||
from app.models.auth import AuthUser, RecoveryCode
|
||||
from app.models.auth_throttle import LoginThrottle
|
||||
from app.services.auth import hash_password, verify_password # noqa: F401 (used below)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_session() -> Session:
|
||||
"""Return a new SQLAlchemy session for the configured app database."""
|
||||
SessionLocal = get_session_local()
|
||||
return SessionLocal()
|
||||
|
||||
|
||||
def _find_user(session: Session, username: str) -> AuthUser | None:
|
||||
return session.scalar(select(AuthUser).where(AuthUser.username == username).limit(1))
|
||||
|
||||
|
||||
def _die(message: str, code: int = 1) -> None:
|
||||
print(f"Error: {message}", file=sys.stderr)
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sub-command implementations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_reset_password(args: argparse.Namespace) -> None:
|
||||
"""Reset the password for <username>."""
|
||||
username: str = args.username
|
||||
|
||||
if args.password is not None:
|
||||
new_password: str = args.password
|
||||
else:
|
||||
# Interactive prompt — getpass suppresses echo.
|
||||
new_password = getpass.getpass(f"New password for '{username}': ")
|
||||
confirm = getpass.getpass("Confirm new password: ")
|
||||
if new_password != confirm:
|
||||
_die("Passwords do not match.")
|
||||
|
||||
if not new_password:
|
||||
_die("Password must not be empty.")
|
||||
|
||||
session = _get_session()
|
||||
try:
|
||||
user = _find_user(session, username)
|
||||
if user is None:
|
||||
_die(f"User '{username}' not found.")
|
||||
|
||||
user.password_hash = hash_password(new_password)
|
||||
session.commit()
|
||||
print(f"Password for '{username}' has been reset.")
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def cmd_unlock(args: argparse.Namespace) -> None:
|
||||
"""Delete rows from auth_login_throttle according to the given filter."""
|
||||
use_all: bool = args.all
|
||||
ip: str | None = args.ip
|
||||
username: str | None = args.username
|
||||
|
||||
# Validate: exactly one of --all / --ip / --username must be provided.
|
||||
flags = [use_all, ip is not None, username is not None]
|
||||
if sum(flags) == 0:
|
||||
_die("One of --all, --ip, or --username is required.")
|
||||
if sum(flags) > 1:
|
||||
_die("Only one of --all, --ip, or --username may be specified at a time.")
|
||||
|
||||
session = _get_session()
|
||||
try:
|
||||
if use_all:
|
||||
stmt = delete(LoginThrottle)
|
||||
result = session.execute(stmt)
|
||||
count = result.rowcount
|
||||
elif ip is not None:
|
||||
stmt = delete(LoginThrottle).where(
|
||||
LoginThrottle.scope == "ip",
|
||||
LoginThrottle.key == ip,
|
||||
)
|
||||
result = session.execute(stmt)
|
||||
count = result.rowcount
|
||||
else: # username is not None
|
||||
stmt = delete(LoginThrottle).where(
|
||||
LoginThrottle.scope == "user",
|
||||
LoginThrottle.key == username,
|
||||
)
|
||||
result = session.execute(stmt)
|
||||
count = result.rowcount
|
||||
|
||||
session.commit()
|
||||
noun = "row" if count == 1 else "rows"
|
||||
print(f"Cleared {count} throttle {noun}.")
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def cmd_disable_totp(args: argparse.Namespace) -> None:
|
||||
"""Disable TOTP for <username> — no credential required (escape hatch).
|
||||
|
||||
Sets totp_enabled=False, clears totp_secret, and deletes ALL recovery
|
||||
codes for this user. This is the final escape route when all recovery
|
||||
codes have been lost: whoever holds server CLI access can always turn off
|
||||
TOTP, restoring plain-password login.
|
||||
"""
|
||||
username: str = args.username
|
||||
|
||||
session = _get_session()
|
||||
try:
|
||||
user = _find_user(session, username)
|
||||
if user is None:
|
||||
_die(f"User '{username}' not found.")
|
||||
|
||||
assert user.id is not None
|
||||
# Delete all recovery codes for this user.
|
||||
deleted = session.execute(
|
||||
delete(RecoveryCode).where(RecoveryCode.user_id == user.id)
|
||||
)
|
||||
rc_count = deleted.rowcount
|
||||
|
||||
# Clear TOTP state.
|
||||
user.totp_enabled = False
|
||||
user.totp_secret = None
|
||||
session.commit()
|
||||
|
||||
print(f"TOTP disabled for '{username}'. {rc_count} recovery code(s) deleted.")
|
||||
print("The user can now log in with password only.")
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def cmd_reissue_totp(args: argparse.Namespace) -> None:
|
||||
"""Generate a fresh TOTP secret for <username> and print the otpauth URI.
|
||||
|
||||
Design choices:
|
||||
- ``totp_enabled`` is LEFT UNCHANGED. If the user currently has TOTP
|
||||
enabled, the new secret takes effect immediately on the next login
|
||||
(old code invalid, new code accepted) — no web enable step required.
|
||||
If TOTP was disabled, the secret is updated but login remains
|
||||
password-only until the web setup/enable flow is completed.
|
||||
- Existing recovery codes are NOT deleted here. Recovery codes are
|
||||
independent random values stored as Argon2 hashes in
|
||||
``auth_recovery_code.code_hash``; login verification compares only those
|
||||
hashes and has no dependency on ``totp_secret``. Reissuing the secret
|
||||
therefore leaves all existing recovery codes fully valid — they remain
|
||||
the user's backup login path. Silently deleting them would
|
||||
unexpectedly strip that backup; ``disable-totp`` is the explicit full
|
||||
cleanup path (secret + all recovery codes).
|
||||
- This command's purpose is to hand the new secret URI to the admin so
|
||||
they can re-enroll their Authenticator — useful when the device is lost
|
||||
but the account credentials are still intact.
|
||||
"""
|
||||
username: str = args.username
|
||||
|
||||
session = _get_session()
|
||||
try:
|
||||
user = _find_user(session, username)
|
||||
if user is None:
|
||||
_die(f"User '{username}' not found.")
|
||||
|
||||
# Generate a new base-32 secret using pyotp (same library as the web TOTP flow).
|
||||
new_secret = pyotp.random_base32()
|
||||
|
||||
# Build the provisioning URI using the same issuer logic as the web service.
|
||||
settings = get_settings()
|
||||
issuer = settings.effective_totp_issuer
|
||||
otpauth_uri = pyotp.TOTP(new_secret).provisioning_uri(
|
||||
name=user.username,
|
||||
issuer_name=issuer,
|
||||
)
|
||||
|
||||
user.totp_secret = new_secret
|
||||
session.commit()
|
||||
|
||||
print(f"New TOTP secret issued for '{username}'.")
|
||||
print(f"Secret : {new_secret}")
|
||||
print(f"URI : {otpauth_uri}")
|
||||
print()
|
||||
if user.totp_enabled:
|
||||
print(
|
||||
"NOTE: TOTP is still ENABLED. Scan the URI above in your Authenticator app;"
|
||||
" the new code is active immediately on next login — the old code is now invalid."
|
||||
" To fully disable TOTP instead, use 'disable-totp'."
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"NOTE: TOTP is currently DISABLED for this user — login remains password-only."
|
||||
" The new secret has been saved, but will only take effect once TOTP is"
|
||||
" enabled via the web settings page."
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def cmd_list_admin(args: argparse.Namespace) -> None: # noqa: ARG001
|
||||
"""List all admin users with their status columns."""
|
||||
session = _get_session()
|
||||
try:
|
||||
users = session.scalars(select(AuthUser).order_by(AuthUser.id)).all()
|
||||
if not users:
|
||||
print("No users found.")
|
||||
return
|
||||
|
||||
# Header
|
||||
print(f"{'ID':<5} {'USERNAME':<30} {'IS_ACTIVE':<10} {'FORCE_PW_CHANGE':<16}")
|
||||
print("-" * 65)
|
||||
for u in users:
|
||||
print(
|
||||
f"{u.id:<5} {u.username:<30} {str(u.is_active):<10} "
|
||||
f"{str(u.force_password_change):<16}"
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argument parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="python -m scripts.admin_cli",
|
||||
description=(
|
||||
"Admin CLI — local escape hatch for password reset, throttle unlock, "
|
||||
"and user inspection. Connects directly to the configured app DB; "
|
||||
"no HTTP server required."
|
||||
),
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", metavar="<command>")
|
||||
subparsers.required = True
|
||||
|
||||
# -- reset-password --
|
||||
rp = subparsers.add_parser(
|
||||
"reset-password",
|
||||
help="Reset a user's password (prompts interactively if --password is omitted).",
|
||||
)
|
||||
rp.add_argument("username", help="Username whose password to reset.")
|
||||
rp.add_argument(
|
||||
"--password",
|
||||
default=None,
|
||||
help="New password (omit to be prompted interactively with no echo).",
|
||||
)
|
||||
rp.set_defaults(func=cmd_reset_password)
|
||||
|
||||
# -- unlock --
|
||||
ul = subparsers.add_parser(
|
||||
"unlock",
|
||||
help="Clear login-throttle rows (escape when locked out by exponential back-off).",
|
||||
)
|
||||
unlock_group = ul.add_mutually_exclusive_group(required=True)
|
||||
unlock_group.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Clear ALL throttle rows.",
|
||||
)
|
||||
unlock_group.add_argument(
|
||||
"--ip",
|
||||
metavar="<ip>",
|
||||
default=None,
|
||||
help="Clear the throttle row for this specific IP address.",
|
||||
)
|
||||
unlock_group.add_argument(
|
||||
"--username",
|
||||
metavar="<username>",
|
||||
default=None,
|
||||
help="Clear the throttle row for this specific username.",
|
||||
)
|
||||
ul.set_defaults(func=cmd_unlock)
|
||||
|
||||
# -- disable-totp --
|
||||
dt = subparsers.add_parser(
|
||||
"disable-totp",
|
||||
help=(
|
||||
"Disable TOTP for a user (escape hatch — no credential required). "
|
||||
"Clears totp_secret, sets totp_enabled=False, deletes all recovery codes."
|
||||
),
|
||||
)
|
||||
dt.add_argument("username", help="Username whose TOTP to disable.")
|
||||
dt.set_defaults(func=cmd_disable_totp)
|
||||
|
||||
# -- reissue-totp --
|
||||
rt = subparsers.add_parser(
|
||||
"reissue-totp",
|
||||
help=(
|
||||
"Generate a new TOTP secret for a user and print the otpauth:// URI. "
|
||||
"totp_enabled is left unchanged; re-enroll via the web UI."
|
||||
),
|
||||
)
|
||||
rt.add_argument("username", help="Username for whom to reissue the TOTP secret.")
|
||||
rt.set_defaults(func=cmd_reissue_totp)
|
||||
|
||||
# -- list-admin --
|
||||
la = subparsers.add_parser(
|
||||
"list-admin",
|
||||
help="List all users and their auth status (for diagnostics).",
|
||||
)
|
||||
la.set_defaults(func=cmd_list_admin)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -15,7 +15,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
APP_BASELINE_REVISION = "20260611_06_merge_location_poo_tables"
|
||||
APP_BASELINE_REVISION = "20260621_08_totp"
|
||||
|
||||
|
||||
class AppDatabaseAdoptionError(RuntimeError):
|
||||
|
||||
@@ -26,6 +26,9 @@ def test_database_urls(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password")
|
||||
monkeypatch.setenv("AUTH_COOKIE_SECURE_OVERRIDE", "false")
|
||||
# Disable throttle by default so existing tests are unaffected; throttle
|
||||
# tests opt in explicitly via their own monkeypatch / env override.
|
||||
monkeypatch.setenv("AUTH_LOGIN_THROTTLE_ENABLED", "false")
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
"""Tests for M4-T03 + M4-T07: scripts/admin_cli.py — CLI escape hatch.
|
||||
|
||||
Covers:
|
||||
- reset-password: new password verifies, old password fails.
|
||||
- unlock: clears specified/all throttle rows; prints count; idempotent.
|
||||
- disable-totp: clears TOTP state + recovery codes without requiring any credential.
|
||||
- reissue-totp: writes new secret; prints otpauth URI; secret changes.
|
||||
- list-admin: prints user table without crashing.
|
||||
- Non-existent user → friendly error + SystemExit with non-zero code.
|
||||
- Data-safety: CLI must never reference user-data tables in delete/drop.
|
||||
|
||||
DB isolation: each test gets a fresh temporary SQLite database via
|
||||
``cli_db`` fixture (mirrors the pattern in test_login_throttle.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.db import reset_db_caches
|
||||
from app.config import get_settings
|
||||
from app.models.auth import AuthUser, RecoveryCode
|
||||
from app.models.auth_throttle import LoginThrottle
|
||||
from app.services.auth import hash_password, verify_password
|
||||
from scripts.admin_cli import (
|
||||
build_parser,
|
||||
cmd_disable_totp,
|
||||
cmd_list_admin,
|
||||
cmd_reset_password,
|
||||
cmd_reissue_totp,
|
||||
cmd_unlock,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_app_alembic_config(database_url: str) -> Config:
|
||||
config = Config("alembic_app.ini")
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def cli_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Fresh migrated SQLite DB wired as the active app DB for the CLI.
|
||||
|
||||
Monkeypatches APP_DATABASE_URL so that ``get_session_local()`` inside
|
||||
admin_cli reaches this temp file, not the real data store.
|
||||
"""
|
||||
db_path = tmp_path / "cli_test.db"
|
||||
db_url = f"sqlite:///{db_path}"
|
||||
|
||||
monkeypatch.setenv("APP_DATABASE_URL", db_url)
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "original-password")
|
||||
monkeypatch.setenv("AUTH_COOKIE_SECURE_OVERRIDE", "false")
|
||||
monkeypatch.setenv("AUTH_LOGIN_THROTTLE_ENABLED", "false")
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
|
||||
command.upgrade(_make_app_alembic_config(db_url), "head")
|
||||
reset_db_caches()
|
||||
|
||||
# Bootstrap the admin user manually so the test controls the initial hash.
|
||||
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
|
||||
with SessionLocal() as session:
|
||||
# The Alembic upgrade does NOT create a user (that is app startup logic).
|
||||
# Insert the initial admin user here for tests that need one.
|
||||
existing = session.scalar(select(AuthUser).limit(1))
|
||||
if existing is None:
|
||||
user = AuthUser(
|
||||
username="admin",
|
||||
password_hash=hash_password("original-password"),
|
||||
is_active=True,
|
||||
force_password_change=False,
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
engine.dispose()
|
||||
reset_db_caches()
|
||||
|
||||
yield db_url
|
||||
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def cli_session(cli_db: str) -> Session:
|
||||
"""SQLAlchemy session bound to the CLI test DB."""
|
||||
engine = create_engine(cli_db, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _add_throttle_rows(session: Session, rows: list[tuple[str, str]]) -> None:
|
||||
"""Helper: insert (scope, key) throttle rows into the test DB."""
|
||||
now = datetime.now(UTC)
|
||||
for scope, key in rows:
|
||||
session.add(
|
||||
LoginThrottle(
|
||||
scope=scope,
|
||||
key=key,
|
||||
failures=5,
|
||||
first_failed_at=now,
|
||||
last_failed_at=now,
|
||||
next_allowed_at=None,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _throttle_count(session: Session) -> int:
|
||||
return session.query(LoginThrottle).count()
|
||||
|
||||
|
||||
def _enable_totp_for_user(session: Session, username: str, secret: str = "JBSWY3DPEHPK3PXP") -> None:
|
||||
"""Helper: set TOTP enabled + secret for a user and add some recovery codes."""
|
||||
user = session.scalar(select(AuthUser).where(AuthUser.username == username))
|
||||
assert user is not None, f"User '{username}' not found in _enable_totp_for_user"
|
||||
user.totp_secret = secret
|
||||
user.totp_enabled = True
|
||||
# Add two fake recovery codes so we can verify they get deleted.
|
||||
session.add(RecoveryCode(user_id=user.id, code_hash=hash_password("aaaa-bbbb")))
|
||||
session.add(RecoveryCode(user_id=user.id, code_hash=hash_password("cccc-dddd")))
|
||||
session.commit()
|
||||
session.expire_all()
|
||||
|
||||
|
||||
def _recovery_code_count(session: Session, username: str) -> int:
|
||||
user = session.scalar(select(AuthUser).where(AuthUser.username == username))
|
||||
assert user is not None
|
||||
return session.query(RecoveryCode).filter(RecoveryCode.user_id == user.id).count()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# reset-password tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResetPassword:
|
||||
"""reset-password sub-command."""
|
||||
|
||||
def test_new_password_verifies(self, cli_db: str, cli_session: Session):
|
||||
"""After reset, new password passes verify_password; old one fails."""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["reset-password", "admin", "--password", "new-secret-123"])
|
||||
cmd_reset_password(args)
|
||||
|
||||
# Re-query from the session (committed by CLI, so flush needed).
|
||||
cli_session.expire_all()
|
||||
user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||
assert user is not None
|
||||
assert verify_password("new-secret-123", user.password_hash), (
|
||||
"New password should verify against stored hash"
|
||||
)
|
||||
assert not verify_password("original-password", user.password_hash), (
|
||||
"Old password must no longer verify"
|
||||
)
|
||||
|
||||
def test_nonexistent_user_exits_nonzero(self, cli_db: str):
|
||||
"""reset-password for an unknown username must SystemExit with code != 0."""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(
|
||||
["reset-password", "ghost-user", "--password", "doesnt-matter"]
|
||||
)
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
cmd_reset_password(args)
|
||||
assert exc_info.value.code != 0
|
||||
|
||||
def test_empty_password_exits_nonzero(self, cli_db: str):
|
||||
"""Passing an empty --password string must fail with non-zero exit."""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["reset-password", "admin", "--password", ""])
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
cmd_reset_password(args)
|
||||
assert exc_info.value.code != 0
|
||||
|
||||
def test_multiple_resets_are_idempotent(self, cli_db: str, cli_session: Session):
|
||||
"""Calling reset-password multiple times leaves the last password active."""
|
||||
parser = build_parser()
|
||||
|
||||
args1 = parser.parse_args(["reset-password", "admin", "--password", "first-pass"])
|
||||
cmd_reset_password(args1)
|
||||
|
||||
args2 = parser.parse_args(["reset-password", "admin", "--password", "second-pass"])
|
||||
cmd_reset_password(args2)
|
||||
|
||||
cli_session.expire_all()
|
||||
user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||
assert user is not None
|
||||
assert verify_password("second-pass", user.password_hash)
|
||||
assert not verify_password("first-pass", user.password_hash)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# unlock tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUnlock:
|
||||
"""unlock sub-command."""
|
||||
|
||||
def test_unlock_all_clears_all_rows(self, cli_db: str, cli_session: Session):
|
||||
"""unlock --all should delete every row in auth_login_throttle."""
|
||||
_add_throttle_rows(
|
||||
cli_session,
|
||||
[("ip", "1.2.3.4"), ("ip", "5.6.7.8"), ("user", "admin")],
|
||||
)
|
||||
assert _throttle_count(cli_session) == 3
|
||||
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["unlock", "--all"])
|
||||
cmd_unlock(args)
|
||||
|
||||
cli_session.expire_all()
|
||||
assert _throttle_count(cli_session) == 0
|
||||
|
||||
def test_unlock_ip_clears_only_target_ip(self, cli_db: str, cli_session: Session):
|
||||
"""unlock --ip should remove only the matching scope='ip' row."""
|
||||
_add_throttle_rows(
|
||||
cli_session,
|
||||
[("ip", "10.0.0.1"), ("ip", "10.0.0.2"), ("user", "admin")],
|
||||
)
|
||||
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["unlock", "--ip", "10.0.0.1"])
|
||||
cmd_unlock(args)
|
||||
|
||||
cli_session.expire_all()
|
||||
assert _throttle_count(cli_session) == 2
|
||||
remaining = cli_session.scalars(select(LoginThrottle)).all()
|
||||
keys = [(r.scope, r.key) for r in remaining]
|
||||
assert ("ip", "10.0.0.1") not in keys
|
||||
assert ("ip", "10.0.0.2") in keys
|
||||
assert ("user", "admin") in keys
|
||||
|
||||
def test_unlock_username_clears_only_target_user(self, cli_db: str, cli_session: Session):
|
||||
"""unlock --username should remove only the matching scope='user' row."""
|
||||
_add_throttle_rows(
|
||||
cli_session,
|
||||
[("ip", "1.2.3.4"), ("user", "admin"), ("user", "other")],
|
||||
)
|
||||
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["unlock", "--username", "admin"])
|
||||
cmd_unlock(args)
|
||||
|
||||
cli_session.expire_all()
|
||||
assert _throttle_count(cli_session) == 2
|
||||
remaining = cli_session.scalars(select(LoginThrottle)).all()
|
||||
keys = [(r.scope, r.key) for r in remaining]
|
||||
assert ("user", "admin") not in keys
|
||||
assert ("ip", "1.2.3.4") in keys
|
||||
assert ("user", "other") in keys
|
||||
|
||||
def test_unlock_all_idempotent(self, cli_db: str, cli_session: Session):
|
||||
"""Calling unlock --all when no rows exist must not raise."""
|
||||
assert _throttle_count(cli_session) == 0
|
||||
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["unlock", "--all"])
|
||||
cmd_unlock(args) # must not raise
|
||||
|
||||
cli_session.expire_all()
|
||||
assert _throttle_count(cli_session) == 0
|
||||
|
||||
def test_unlock_ip_idempotent_when_absent(self, cli_db: str, cli_session: Session):
|
||||
"""Calling unlock --ip for a non-existent IP is a no-op (0 rows deleted)."""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["unlock", "--ip", "99.99.99.99"])
|
||||
cmd_unlock(args) # must not raise; 0 rows deleted is fine
|
||||
|
||||
def test_unlock_username_idempotent_when_absent(self, cli_db: str, cli_session: Session):
|
||||
"""Calling unlock --username for a non-existent username is a no-op."""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["unlock", "--username", "nobody"])
|
||||
cmd_unlock(args) # must not raise
|
||||
|
||||
def test_unlock_all_prints_row_count(self, cli_db: str, cli_session: Session, capsys):
|
||||
"""unlock --all must print the number of rows deleted."""
|
||||
_add_throttle_rows(cli_session, [("ip", "1.2.3.4"), ("user", "admin")])
|
||||
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["unlock", "--all"])
|
||||
cmd_unlock(args)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "2" in captured.out
|
||||
|
||||
def test_unlock_does_not_touch_auth_users(self, cli_db: str, cli_session: Session):
|
||||
"""unlock must not delete or alter auth_users rows."""
|
||||
_add_throttle_rows(cli_session, [("ip", "1.2.3.4")])
|
||||
user_count_before = cli_session.query(AuthUser).count()
|
||||
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["unlock", "--all"])
|
||||
cmd_unlock(args)
|
||||
|
||||
cli_session.expire_all()
|
||||
assert cli_session.query(AuthUser).count() == user_count_before
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list-admin tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListAdmin:
|
||||
"""list-admin sub-command."""
|
||||
|
||||
def test_list_admin_prints_header_and_user(self, cli_db: str, capsys):
|
||||
"""list-admin must print the admin user row."""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["list-admin"])
|
||||
cmd_list_admin(args)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "admin" in captured.out
|
||||
|
||||
def test_list_admin_no_users(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys
|
||||
):
|
||||
"""list-admin on an empty user table should say 'No users found.'"""
|
||||
db_path = tmp_path / "empty_test.db"
|
||||
db_url = f"sqlite:///{db_path}"
|
||||
|
||||
monkeypatch.setenv("APP_DATABASE_URL", db_url)
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "x")
|
||||
monkeypatch.setenv("AUTH_COOKIE_SECURE_OVERRIDE", "false")
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
command.upgrade(_make_app_alembic_config(db_url), "head")
|
||||
reset_db_caches()
|
||||
|
||||
# Do NOT bootstrap a user — table is empty.
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["list-admin"])
|
||||
cmd_list_admin(args)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "No users found" in captured.out
|
||||
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# disable-totp tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDisableTotp:
|
||||
"""disable-totp sub-command (M4-T07)."""
|
||||
|
||||
def test_disable_totp_clears_state(self, cli_db: str, cli_session: Session):
|
||||
"""After disable-totp: totp_enabled=False, totp_secret=None, recovery codes gone."""
|
||||
_enable_totp_for_user(cli_session, "admin")
|
||||
|
||||
# Verify preconditions.
|
||||
user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||
assert user is not None
|
||||
assert user.totp_enabled is True
|
||||
assert user.totp_secret is not None
|
||||
assert _recovery_code_count(cli_session, "admin") == 2
|
||||
|
||||
# Run disable-totp — NO credential supplied (escape hatch semantics).
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["disable-totp", "admin"])
|
||||
cmd_disable_totp(args)
|
||||
|
||||
# Verify state.
|
||||
cli_session.expire_all()
|
||||
user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||
assert user is not None
|
||||
assert user.totp_enabled is False
|
||||
assert user.totp_secret is None
|
||||
assert _recovery_code_count(cli_session, "admin") == 0
|
||||
|
||||
def test_disable_totp_no_credential_required(self, cli_db: str, cli_session: Session):
|
||||
"""disable-totp must succeed without any recovery code or password argument."""
|
||||
_enable_totp_for_user(cli_session, "admin")
|
||||
|
||||
# The argparse spec for disable-totp takes only username — no credential flags.
|
||||
# Parsing must succeed with just the username.
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["disable-totp", "admin"])
|
||||
# No exception means no credential was needed.
|
||||
cmd_disable_totp(args) # must not raise
|
||||
|
||||
def test_disable_totp_already_disabled_is_noop(self, cli_db: str, cli_session: Session):
|
||||
"""disable-totp on a user who never had TOTP is a harmless no-op."""
|
||||
user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||
assert user is not None
|
||||
assert user.totp_enabled is False
|
||||
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["disable-totp", "admin"])
|
||||
cmd_disable_totp(args) # must not raise
|
||||
|
||||
cli_session.expire_all()
|
||||
user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||
assert user is not None
|
||||
assert user.totp_enabled is False
|
||||
assert user.totp_secret is None
|
||||
|
||||
def test_disable_totp_nonexistent_user_exits_nonzero(self, cli_db: str):
|
||||
"""disable-totp for an unknown user must SystemExit with code != 0."""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["disable-totp", "ghost-user"])
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
cmd_disable_totp(args)
|
||||
assert exc_info.value.code != 0
|
||||
|
||||
def test_disable_totp_prints_confirmation(
|
||||
self, cli_db: str, cli_session: Session, capsys
|
||||
):
|
||||
"""disable-totp must print a confirmation message mentioning the user."""
|
||||
_enable_totp_for_user(cli_session, "admin")
|
||||
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["disable-totp", "admin"])
|
||||
cmd_disable_totp(args)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "admin" in captured.out
|
||||
|
||||
def test_disable_totp_does_not_touch_password(
|
||||
self, cli_db: str, cli_session: Session
|
||||
):
|
||||
"""disable-totp must not alter the user's password_hash."""
|
||||
_enable_totp_for_user(cli_session, "admin")
|
||||
|
||||
user_before = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||
assert user_before is not None
|
||||
pw_hash_before = user_before.password_hash
|
||||
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["disable-totp", "admin"])
|
||||
cmd_disable_totp(args)
|
||||
|
||||
cli_session.expire_all()
|
||||
user_after = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||
assert user_after is not None
|
||||
assert user_after.password_hash == pw_hash_before
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# reissue-totp tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReissueTotp:
|
||||
"""reissue-totp sub-command (M4-T07)."""
|
||||
|
||||
def test_reissue_totp_changes_secret(self, cli_db: str, cli_session: Session):
|
||||
"""After reissue-totp, totp_secret must be a new non-empty value."""
|
||||
old_secret = "JBSWY3DPEHPK3PXP"
|
||||
_enable_totp_for_user(cli_session, "admin", secret=old_secret)
|
||||
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["reissue-totp", "admin"])
|
||||
cmd_reissue_totp(args)
|
||||
|
||||
cli_session.expire_all()
|
||||
user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||
assert user is not None
|
||||
assert user.totp_secret is not None
|
||||
assert user.totp_secret != old_secret, "Secret must be regenerated"
|
||||
|
||||
def test_reissue_totp_prints_uri(self, cli_db: str, cli_session: Session, capsys):
|
||||
"""reissue-totp must print an otpauth:// URI and the secret."""
|
||||
_enable_totp_for_user(cli_session, "admin")
|
||||
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["reissue-totp", "admin"])
|
||||
cmd_reissue_totp(args)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "otpauth://" in captured.out
|
||||
assert "Secret" in captured.out
|
||||
|
||||
def test_reissue_totp_totp_enabled_unchanged(self, cli_db: str, cli_session: Session):
|
||||
"""reissue-totp must leave totp_enabled in its current state (True stays True)."""
|
||||
_enable_totp_for_user(cli_session, "admin")
|
||||
|
||||
user_before = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||
assert user_before is not None
|
||||
assert user_before.totp_enabled is True
|
||||
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["reissue-totp", "admin"])
|
||||
cmd_reissue_totp(args)
|
||||
|
||||
cli_session.expire_all()
|
||||
user_after = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||
assert user_after is not None
|
||||
assert user_after.totp_enabled is True, "totp_enabled must remain unchanged"
|
||||
|
||||
def test_reissue_totp_disabled_user_stays_disabled(
|
||||
self, cli_db: str, cli_session: Session
|
||||
):
|
||||
"""reissue-totp on a user with totp_enabled=False leaves it False."""
|
||||
# Don't enable TOTP — user is in disabled state.
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["reissue-totp", "admin"])
|
||||
cmd_reissue_totp(args)
|
||||
|
||||
cli_session.expire_all()
|
||||
user = cli_session.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||
assert user is not None
|
||||
assert user.totp_enabled is False, "totp_enabled must remain False"
|
||||
assert user.totp_secret is not None, "A new secret should have been written"
|
||||
|
||||
def test_reissue_totp_nonexistent_user_exits_nonzero(self, cli_db: str):
|
||||
"""reissue-totp for an unknown user must SystemExit with code != 0."""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(["reissue-totp", "ghost-user"])
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
cmd_reissue_totp(args)
|
||||
assert exc_info.value.code != 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Argument-parser edge-cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParserEdgeCases:
|
||||
"""Ensure argparse flags behave as expected."""
|
||||
|
||||
def test_unlock_requires_flag(self):
|
||||
"""unlock without any flag should exit with a parse error (non-zero)."""
|
||||
parser = build_parser()
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
parser.parse_args(["unlock"])
|
||||
assert exc_info.value.code != 0
|
||||
|
||||
def test_unlock_ip_and_all_mutually_exclusive(self):
|
||||
"""--ip and --all together should produce a parse error."""
|
||||
parser = build_parser()
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
parser.parse_args(["unlock", "--all", "--ip", "1.2.3.4"])
|
||||
assert exc_info.value.code != 0
|
||||
|
||||
def test_unknown_command_exits_nonzero(self):
|
||||
"""An unknown sub-command must exit non-zero."""
|
||||
parser = build_parser()
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
parser.parse_args(["nonexistent-command"])
|
||||
assert exc_info.value.code != 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level smoke: python -m scripts.admin_cli --help
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_help_flag_exits_zero():
|
||||
"""python -m scripts.admin_cli --help must exit 0."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "scripts.admin_cli", "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "reset-password" in result.stdout
|
||||
assert "unlock" in result.stdout
|
||||
assert "disable-totp" in result.stdout
|
||||
assert "reissue-totp" in result.stdout
|
||||
assert "list-admin" in result.stdout
|
||||
+442
-1
@@ -1,7 +1,24 @@
|
||||
"""Tests for M2-T02: GET /api/session, POST /api/auth/login, /logout, /password."""
|
||||
"""Tests for M2-T02: GET /api/session, POST /api/auth/login, /logout, /password.
|
||||
|
||||
Also covers M4-T06: two-factor login regression tests (TOTP not enabled → same
|
||||
as before; TOTP enabled → two-step protocol).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pyotp
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import reset_db_caches
|
||||
from app.config import get_settings
|
||||
from app.main import create_app
|
||||
from app.services.login_throttle import N_FREE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -330,3 +347,427 @@ def test_session_response_has_no_secret_fields(client: TestClient) -> None:
|
||||
assert set(body.keys()) == {"user", "csrf_token"}
|
||||
# user must have exactly these keys
|
||||
assert set(body["user"].keys()) == {"username", "force_password_change"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# M4-T06 — Two-factor login (TOTP second step)
|
||||
# Fixtures & helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_app_alembic_config(database_url: str) -> Config:
|
||||
cfg = Config("alembic_app.ini")
|
||||
cfg.set_main_option("sqlalchemy.url", database_url)
|
||||
return cfg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def totp_login_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Full client with TOTP-capable DB (throttle disabled for simplicity)."""
|
||||
db_path = tmp_path / "totp_login_test.db"
|
||||
db_url = f"sqlite:///{db_path}"
|
||||
|
||||
monkeypatch.setenv("APP_DATABASE_URL", db_url)
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password")
|
||||
monkeypatch.setenv("AUTH_COOKIE_SECURE_OVERRIDE", "false")
|
||||
monkeypatch.setenv("AUTH_LOGIN_THROTTLE_ENABLED", "false")
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
|
||||
command.upgrade(_make_app_alembic_config(db_url), "head")
|
||||
reset_db_caches()
|
||||
|
||||
app = create_app()
|
||||
with TestClient(app) as tc:
|
||||
yield tc
|
||||
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def totp_login_throttle_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Full client with TOTP + throttle ENABLED (for throttle-interaction tests)."""
|
||||
db_path = tmp_path / "totp_throttle_test.db"
|
||||
db_url = f"sqlite:///{db_path}"
|
||||
|
||||
monkeypatch.setenv("APP_DATABASE_URL", db_url)
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password")
|
||||
monkeypatch.setenv("AUTH_COOKIE_SECURE_OVERRIDE", "false")
|
||||
monkeypatch.setenv("AUTH_LOGIN_THROTTLE_ENABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
|
||||
command.upgrade(_make_app_alembic_config(db_url), "head")
|
||||
reset_db_caches()
|
||||
|
||||
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||
app = create_app()
|
||||
with TestClient(app) as tc:
|
||||
yield tc, engine
|
||||
engine.dispose()
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
|
||||
|
||||
def _enable_totp(client: TestClient) -> tuple[str, list[str]]:
|
||||
"""Log in, setup TOTP, enable it; return (secret, recovery_codes)."""
|
||||
login_resp = client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "test-password"},
|
||||
)
|
||||
assert login_resp.status_code == 200, login_resp.text
|
||||
csrf = login_resp.json()["csrf_token"]
|
||||
|
||||
setup_resp = client.post("/api/auth/totp/setup", headers={"X-CSRF-Token": csrf})
|
||||
assert setup_resp.status_code == 200, setup_resp.text
|
||||
setup_body = setup_resp.json()
|
||||
secret = setup_body["secret"]
|
||||
recovery_codes = setup_body["recovery_codes"]
|
||||
|
||||
enable_resp = client.post(
|
||||
"/api/auth/totp/enable",
|
||||
json={"code": pyotp.TOTP(secret).now()},
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
)
|
||||
assert enable_resp.status_code == 204, enable_resp.text
|
||||
|
||||
# Log out so subsequent login tests start fresh
|
||||
client.post("/api/auth/logout", headers={"X-CSRF-Token": csrf})
|
||||
return secret, recovery_codes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# M4-T06 — Regression: TOTP not enabled → login unchanged
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoginWithoutTotpEnabled:
|
||||
"""TOTP not enabled: login behaves exactly as before T06."""
|
||||
|
||||
def test_login_without_totp_code_succeeds(self, totp_login_client: TestClient) -> None:
|
||||
"""Standard login (no totp_code field at all) still works when TOTP is off."""
|
||||
resp = totp_login_client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "test-password"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "user" in body
|
||||
assert "csrf_token" in body
|
||||
|
||||
def test_login_with_null_totp_code_succeeds(self, totp_login_client: TestClient) -> None:
|
||||
"""Explicitly sending totp_code=null works fine when TOTP is off."""
|
||||
resp = totp_login_client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "test-password", "totp_code": None},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_login_sets_session_cookie(self, totp_login_client: TestClient) -> None:
|
||||
resp = totp_login_client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "test-password"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "home_automation_session=" in resp.headers.get("set-cookie", "").lower()
|
||||
|
||||
def test_wrong_password_still_401(self, totp_login_client: TestClient) -> None:
|
||||
resp = totp_login_client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "wrong"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
# Must be the standard generic message
|
||||
assert resp.json()["detail"] == "invalid username or password"
|
||||
|
||||
def test_wrong_password_no_cookie(self, totp_login_client: TestClient) -> None:
|
||||
resp = totp_login_client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "wrong"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "home_automation_session=" not in resp.headers.get("set-cookie", "").lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# M4-T06 — TOTP enabled: two-step protocol
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoginWithTotpEnabled:
|
||||
"""TOTP enabled: missing/wrong/correct code semantics."""
|
||||
|
||||
def test_missing_totp_code_returns_401_with_totp_required(
|
||||
self, totp_login_client: TestClient
|
||||
) -> None:
|
||||
"""Correct password but no totp_code → 401 with totp_required signal."""
|
||||
_enable_totp(totp_login_client)
|
||||
resp = totp_login_client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "test-password"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
body = resp.json()
|
||||
# detail must be a dict with totp_required=true
|
||||
assert isinstance(body["detail"], dict)
|
||||
assert body["detail"].get("totp_required") is True
|
||||
|
||||
def test_totp_required_response_has_no_session_cookie(
|
||||
self, totp_login_client: TestClient
|
||||
) -> None:
|
||||
"""totp_required 401 must NOT set a session cookie."""
|
||||
_enable_totp(totp_login_client)
|
||||
resp = totp_login_client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "test-password"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "home_automation_session=" not in resp.headers.get("set-cookie", "").lower()
|
||||
|
||||
def test_wrong_totp_code_returns_generic_401(
|
||||
self, totp_login_client: TestClient
|
||||
) -> None:
|
||||
"""Wrong TOTP code → generic 401 (no totp_required signal)."""
|
||||
_enable_totp(totp_login_client)
|
||||
resp = totp_login_client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "test-password", "totp_code": "000000"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
detail = resp.json()["detail"]
|
||||
# Must be a plain string (not dict with totp_required)
|
||||
assert isinstance(detail, str)
|
||||
assert detail == "invalid username or password"
|
||||
|
||||
def test_wrong_totp_code_no_cookie(self, totp_login_client: TestClient) -> None:
|
||||
"""Wrong TOTP → no cookie."""
|
||||
_enable_totp(totp_login_client)
|
||||
resp = totp_login_client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "test-password", "totp_code": "000000"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert "home_automation_session=" not in resp.headers.get("set-cookie", "").lower()
|
||||
|
||||
def test_correct_totp_code_issues_session(self, totp_login_client: TestClient) -> None:
|
||||
"""Correct TOTP code → 200 + cookie + csrf."""
|
||||
secret, _ = _enable_totp(totp_login_client)
|
||||
resp = totp_login_client.post(
|
||||
"/api/auth/login",
|
||||
json={
|
||||
"username": "admin",
|
||||
"password": "test-password",
|
||||
"totp_code": pyotp.TOTP(secret).now(),
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "user" in body
|
||||
assert "csrf_token" in body
|
||||
assert "home_automation_session=" in resp.headers.get("set-cookie", "").lower()
|
||||
|
||||
def test_totp_required_detail_not_on_wrong_password(
|
||||
self, totp_login_client: TestClient
|
||||
) -> None:
|
||||
"""Wrong password must NOT carry totp_required (cannot reveal TOTP state)."""
|
||||
_enable_totp(totp_login_client)
|
||||
resp = totp_login_client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "wrong-password"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
detail = resp.json()["detail"]
|
||||
assert isinstance(detail, str), "Wrong-password 401 must not expose totp_required"
|
||||
if isinstance(detail, dict):
|
||||
assert "totp_required" not in detail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# M4-T06 — Recovery codes can be used for login (one-time)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRecoveryCodeLogin:
|
||||
"""Recovery codes work as second factor and are consumed after use."""
|
||||
|
||||
def test_recovery_code_login_succeeds(self, totp_login_client: TestClient) -> None:
|
||||
_, recovery_codes = _enable_totp(totp_login_client)
|
||||
code = recovery_codes[0]
|
||||
resp = totp_login_client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "test-password", "totp_code": code},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
def test_recovery_code_is_one_time_only(self, totp_login_client: TestClient) -> None:
|
||||
"""Using the same recovery code twice: second use must fail (401)."""
|
||||
_, recovery_codes = _enable_totp(totp_login_client)
|
||||
code = recovery_codes[0]
|
||||
|
||||
# First use: success
|
||||
resp1 = totp_login_client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "test-password", "totp_code": code},
|
||||
)
|
||||
assert resp1.status_code == 200, resp1.text
|
||||
|
||||
# Log out
|
||||
csrf = resp1.json()["csrf_token"]
|
||||
totp_login_client.post("/api/auth/logout", headers={"X-CSRF-Token": csrf})
|
||||
|
||||
# Second use: must fail
|
||||
resp2 = totp_login_client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "test-password", "totp_code": code},
|
||||
)
|
||||
assert resp2.status_code == 401, (
|
||||
"Recovery code must be rejected on second use"
|
||||
)
|
||||
|
||||
def test_recovery_code_used_at_set_in_db(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""After consuming a recovery code, its used_at is set in the DB."""
|
||||
from app.models.auth import RecoveryCode
|
||||
from sqlalchemy import select
|
||||
|
||||
db_path = tmp_path / "rc_used_test.db"
|
||||
db_url = f"sqlite:///{db_path}"
|
||||
|
||||
monkeypatch.setenv("APP_DATABASE_URL", db_url)
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password")
|
||||
monkeypatch.setenv("AUTH_COOKIE_SECURE_OVERRIDE", "false")
|
||||
monkeypatch.setenv("AUTH_LOGIN_THROTTLE_ENABLED", "false")
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
command.upgrade(_make_app_alembic_config(db_url), "head")
|
||||
reset_db_caches()
|
||||
|
||||
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||
app = create_app()
|
||||
with TestClient(app) as tc:
|
||||
secret, recovery_codes = _enable_totp(tc)
|
||||
code = recovery_codes[0]
|
||||
|
||||
tc.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "test-password", "totp_code": code},
|
||||
)
|
||||
|
||||
with Session(engine) as db:
|
||||
used_codes = db.execute(
|
||||
select(RecoveryCode).where(RecoveryCode.used_at.isnot(None))
|
||||
).scalars().all()
|
||||
assert len(used_codes) >= 1, "At least one recovery code should have used_at set"
|
||||
|
||||
engine.dispose()
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
|
||||
def test_other_recovery_codes_still_work_after_one_consumed(
|
||||
self, totp_login_client: TestClient
|
||||
) -> None:
|
||||
"""Consuming one recovery code leaves the others intact."""
|
||||
_, recovery_codes = _enable_totp(totp_login_client)
|
||||
|
||||
# Use first code
|
||||
resp1 = totp_login_client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "test-password", "totp_code": recovery_codes[0]},
|
||||
)
|
||||
assert resp1.status_code == 200
|
||||
csrf = resp1.json()["csrf_token"]
|
||||
totp_login_client.post("/api/auth/logout", headers={"X-CSRF-Token": csrf})
|
||||
|
||||
# Second code must still work
|
||||
resp2 = totp_login_client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "test-password", "totp_code": recovery_codes[1]},
|
||||
)
|
||||
assert resp2.status_code == 200, (
|
||||
"Second recovery code should still be valid after first is consumed"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# M4-T06 — Throttle interaction with TOTP second step
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestThrottleWithTotpEnabled:
|
||||
"""Verify throttle semantics when TOTP is enabled:
|
||||
- Missing code (totp_required) does NOT count as a throttle failure.
|
||||
- Wrong TOTP/recovery code DOES count as a throttle failure.
|
||||
- Throttle check at step 1 still gates step 2.
|
||||
"""
|
||||
|
||||
def test_missing_code_does_not_register_throttle_failure(
|
||||
self, totp_login_throttle_client
|
||||
) -> None:
|
||||
"""Sending correct password without totp_code must not count as a failure.
|
||||
|
||||
If this test sends N_FREE+1 such requests without accumulating throttle
|
||||
failures, the next request will still get 401 (totp_required) not 429.
|
||||
"""
|
||||
client, engine = totp_login_throttle_client
|
||||
_enable_totp(client)
|
||||
|
||||
# Send N_FREE + 2 requests with correct password but no totp_code.
|
||||
for i in range(N_FREE + 2):
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "test-password"},
|
||||
)
|
||||
# Must never become 429 — each is a legitimate first-step
|
||||
assert resp.status_code == 401, (
|
||||
f"Iteration {i}: expected 401 (totp_required), got {resp.status_code}"
|
||||
)
|
||||
body = resp.json()
|
||||
assert isinstance(body["detail"], dict)
|
||||
assert body["detail"].get("totp_required") is True
|
||||
|
||||
def test_wrong_totp_code_accumulates_throttle_failures(
|
||||
self, totp_login_throttle_client
|
||||
) -> None:
|
||||
"""Wrong TOTP codes should eventually trigger 429."""
|
||||
client, engine = totp_login_throttle_client
|
||||
_enable_totp(client)
|
||||
|
||||
# Send N_FREE + 1 wrong-code requests — each should register a failure
|
||||
for _ in range(N_FREE + 1):
|
||||
client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "test-password", "totp_code": "000000"},
|
||||
)
|
||||
|
||||
# Next attempt (even with correct password + no code) should hit 429
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "test-password"},
|
||||
)
|
||||
assert resp.status_code == 429, (
|
||||
"After N_FREE+1 wrong TOTP codes the throttle should kick in"
|
||||
)
|
||||
|
||||
def test_throttle_blocks_step_one_when_in_window(
|
||||
self, totp_login_throttle_client
|
||||
) -> None:
|
||||
"""Once throttled (by wrong codes), even step-1 attempt is rejected 429."""
|
||||
client, engine = totp_login_throttle_client
|
||||
_enable_totp(client)
|
||||
|
||||
# Accumulate failures via wrong TOTP codes
|
||||
for _ in range(N_FREE + 1):
|
||||
client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "test-password", "totp_code": "000000"},
|
||||
)
|
||||
|
||||
# Throttle check is before password — even wrong password should get 429
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "wrong"},
|
||||
)
|
||||
assert resp.status_code == 429
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
"""Tests for M4-T05: TOTP service + setup/enable/disable/status API."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pyotp
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import reset_db_caches
|
||||
from app.models.auth import AuthUser, RecoveryCode
|
||||
from app.services.auth import verify_password
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _login(client: TestClient) -> str:
|
||||
"""Log in and return the CSRF token from the response."""
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "test-password"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["csrf_token"]
|
||||
|
||||
|
||||
def _setup(client: TestClient, csrf: str) -> dict:
|
||||
resp = client.post(
|
||||
"/api/auth/totp/setup",
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
def _enable(client: TestClient, csrf: str, code: str): # noqa: ANN201
|
||||
return client.post(
|
||||
"/api/auth/totp/enable",
|
||||
json={"code": code},
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
)
|
||||
|
||||
|
||||
def _disable(client: TestClient, csrf: str, *, password: str | None = None, code: str | None = None):
|
||||
body: dict = {}
|
||||
if password is not None:
|
||||
body["password"] = password
|
||||
if code is not None:
|
||||
body["code"] = code
|
||||
return client.post(
|
||||
"/api/auth/totp/disable",
|
||||
json=body,
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
)
|
||||
|
||||
|
||||
def _status(client: TestClient) -> dict:
|
||||
resp = client.get("/api/auth/totp")
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures — reuse conftest app/client via the session-aware totp_client fixture
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def totp_client(auth_database):
|
||||
"""TestClient + a direct SQLAlchemy engine so tests can inspect DB state."""
|
||||
from app.main import create_app
|
||||
|
||||
app_url = auth_database["app_url"]
|
||||
engine = create_engine(app_url, connect_args={"check_same_thread": False})
|
||||
fastapi_app = create_app()
|
||||
with TestClient(fastapi_app) as tc:
|
||||
yield tc, engine
|
||||
engine.dispose()
|
||||
reset_db_caches()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/auth/totp/setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_setup_returns_secret_uri_and_recovery_codes(totp_client):
|
||||
client, engine = totp_client
|
||||
csrf = _login(client)
|
||||
|
||||
resp = _setup(client, csrf)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert "secret" in body
|
||||
assert "otpauth_uri" in body
|
||||
assert "recovery_codes" in body
|
||||
assert len(body["recovery_codes"]) == 10
|
||||
# Each code must match xxxx-xxxx pattern
|
||||
for rc in body["recovery_codes"]:
|
||||
parts = rc.split("-")
|
||||
assert len(parts) == 2
|
||||
assert all(len(p) == 4 for p in parts)
|
||||
# URI must start with otpauth://totp/
|
||||
assert body["otpauth_uri"].startswith("otpauth://totp/")
|
||||
# Secret must be a valid base32 string (pyotp will accept it)
|
||||
assert len(body["secret"]) >= 16
|
||||
|
||||
|
||||
def test_setup_does_not_enable_totp(totp_client):
|
||||
"""After setup, totp_enabled must still be False."""
|
||||
client, engine = totp_client
|
||||
csrf = _login(client)
|
||||
|
||||
_setup(client, csrf)
|
||||
|
||||
status_body = _status(client)
|
||||
assert status_body["enabled"] is False
|
||||
|
||||
|
||||
def test_setup_persists_secret_and_code_hashes(totp_client):
|
||||
"""After setup, DB has totp_secret set and recovery code hashes stored."""
|
||||
client, engine = totp_client
|
||||
csrf = _login(client)
|
||||
|
||||
resp = _setup(client, csrf)
|
||||
body = resp.json()
|
||||
|
||||
with Session(engine) as db:
|
||||
user = db.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||
assert user is not None
|
||||
# Secret is stored
|
||||
assert user.totp_secret == body["secret"]
|
||||
# Enabled is still False
|
||||
assert user.totp_enabled is False
|
||||
# Recovery codes are stored as hashes
|
||||
codes = db.execute(select(RecoveryCode).where(RecoveryCode.user_id == user.id)).scalars().all()
|
||||
assert len(codes) == 10
|
||||
# Hashes are not the same as plaintext
|
||||
for i, rc in enumerate(codes):
|
||||
assert rc.code_hash != body["recovery_codes"][i]
|
||||
# But they must verify correctly with Argon2
|
||||
assert verify_password(body["recovery_codes"][i], rc.code_hash)
|
||||
|
||||
|
||||
def test_setup_twice_replaces_pending_state(totp_client):
|
||||
"""Re-running setup clears old pending secret + codes and issues new ones."""
|
||||
client, engine = totp_client
|
||||
csrf = _login(client)
|
||||
|
||||
first = _setup(client, csrf).json()
|
||||
second = _setup(client, csrf).json()
|
||||
|
||||
# Different secret each time
|
||||
assert first["secret"] != second["secret"]
|
||||
# Different codes
|
||||
assert first["recovery_codes"] != second["recovery_codes"]
|
||||
|
||||
# DB has exactly 10 codes (not 20)
|
||||
with Session(engine) as db:
|
||||
user = db.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||
codes = db.execute(
|
||||
select(RecoveryCode).where(RecoveryCode.user_id == user.id)
|
||||
).scalars().all()
|
||||
assert len(codes) == 10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/auth/totp/enable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_enable_with_wrong_code_fails(totp_client):
|
||||
client, engine = totp_client
|
||||
csrf = _login(client)
|
||||
|
||||
_setup(client, csrf)
|
||||
|
||||
resp = _enable(client, csrf, "000000")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_enable_with_wrong_code_does_not_enable(totp_client):
|
||||
client, engine = totp_client
|
||||
csrf = _login(client)
|
||||
|
||||
_setup(client, csrf)
|
||||
_enable(client, csrf, "000000")
|
||||
|
||||
assert _status(client)["enabled"] is False
|
||||
|
||||
|
||||
def test_enable_with_correct_code_succeeds(totp_client):
|
||||
"""Using pyotp.TOTP(secret).now() to generate the correct code."""
|
||||
client, engine = totp_client
|
||||
csrf = _login(client)
|
||||
|
||||
setup_body = _setup(client, csrf).json()
|
||||
secret = setup_body["secret"]
|
||||
|
||||
correct_code = pyotp.TOTP(secret).now()
|
||||
resp = _enable(client, csrf, correct_code)
|
||||
assert resp.status_code == 204, resp.text
|
||||
|
||||
|
||||
def test_enable_with_correct_code_sets_totp_enabled(totp_client):
|
||||
client, engine = totp_client
|
||||
csrf = _login(client)
|
||||
|
||||
setup_body = _setup(client, csrf).json()
|
||||
secret = setup_body["secret"]
|
||||
|
||||
_enable(client, csrf, pyotp.TOTP(secret).now())
|
||||
|
||||
assert _status(client)["enabled"] is True
|
||||
|
||||
|
||||
def test_enable_persists_recovery_code_hashes(totp_client):
|
||||
"""Recovery codes are already hashed in DB after enable (not plaintext)."""
|
||||
client, engine = totp_client
|
||||
csrf = _login(client)
|
||||
|
||||
setup_body = _setup(client, csrf).json()
|
||||
secret = setup_body["secret"]
|
||||
|
||||
_enable(client, csrf, pyotp.TOTP(secret).now())
|
||||
|
||||
with Session(engine) as db:
|
||||
user = db.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||
codes = db.execute(select(RecoveryCode).where(RecoveryCode.user_id == user.id)).scalars().all()
|
||||
assert len(codes) == 10
|
||||
for i, rc in enumerate(codes):
|
||||
plaintext = setup_body["recovery_codes"][i]
|
||||
# code_hash must NOT be the plaintext
|
||||
assert rc.code_hash != plaintext
|
||||
# But verify_password must return True (Argon2 hash matches)
|
||||
assert verify_password(plaintext, rc.code_hash)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/auth/totp/disable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _do_full_enable(client: TestClient, csrf: str) -> str:
|
||||
"""Perform setup + enable and return the secret."""
|
||||
setup_body = _setup(client, csrf).json()
|
||||
secret = setup_body["secret"]
|
||||
_enable(client, csrf, pyotp.TOTP(secret).now())
|
||||
return secret
|
||||
|
||||
|
||||
def test_disable_with_password_succeeds(totp_client):
|
||||
client, engine = totp_client
|
||||
csrf = _login(client)
|
||||
|
||||
_do_full_enable(client, csrf)
|
||||
assert _status(client)["enabled"] is True
|
||||
|
||||
resp = _disable(client, csrf, password="test-password")
|
||||
assert resp.status_code == 204, resp.text
|
||||
|
||||
|
||||
def test_disable_clears_enabled_flag(totp_client):
|
||||
client, engine = totp_client
|
||||
csrf = _login(client)
|
||||
|
||||
_do_full_enable(client, csrf)
|
||||
_disable(client, csrf, password="test-password")
|
||||
|
||||
assert _status(client)["enabled"] is False
|
||||
|
||||
|
||||
def test_disable_clears_secret_and_recovery_codes(totp_client):
|
||||
client, engine = totp_client
|
||||
csrf = _login(client)
|
||||
|
||||
_do_full_enable(client, csrf)
|
||||
_disable(client, csrf, password="test-password")
|
||||
|
||||
with Session(engine) as db:
|
||||
user = db.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||
assert user is not None
|
||||
assert user.totp_secret is None
|
||||
assert user.totp_enabled is False
|
||||
codes = db.execute(select(RecoveryCode).where(RecoveryCode.user_id == user.id)).scalars().all()
|
||||
assert len(codes) == 0
|
||||
|
||||
|
||||
def test_disable_with_totp_code_succeeds(totp_client):
|
||||
client, engine = totp_client
|
||||
csrf = _login(client)
|
||||
|
||||
secret = _do_full_enable(client, csrf)
|
||||
current_code = pyotp.TOTP(secret).now()
|
||||
|
||||
resp = _disable(client, csrf, code=current_code)
|
||||
assert resp.status_code == 204, resp.text
|
||||
assert _status(client)["enabled"] is False
|
||||
|
||||
|
||||
def test_disable_with_wrong_password_fails(totp_client):
|
||||
client, engine = totp_client
|
||||
csrf = _login(client)
|
||||
|
||||
_do_full_enable(client, csrf)
|
||||
|
||||
resp = _disable(client, csrf, password="wrong-password")
|
||||
assert resp.status_code == 400
|
||||
assert _status(client)["enabled"] is True
|
||||
|
||||
|
||||
def test_disable_with_no_credential_fails(totp_client):
|
||||
client, engine = totp_client
|
||||
csrf = _login(client)
|
||||
|
||||
_do_full_enable(client, csrf)
|
||||
|
||||
resp = _disable(client, csrf) # neither password nor code
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/auth/totp — status endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_status_does_not_return_secret(totp_client):
|
||||
client, engine = totp_client
|
||||
csrf = _login(client)
|
||||
|
||||
setup_body = _setup(client, csrf).json()
|
||||
secret = setup_body["secret"]
|
||||
_enable(client, csrf, pyotp.TOTP(secret).now())
|
||||
|
||||
status_resp = client.get("/api/auth/totp")
|
||||
assert status_resp.status_code == 200
|
||||
body = status_resp.json()
|
||||
# Must contain ONLY enabled
|
||||
assert set(body.keys()) == {"enabled"}
|
||||
assert "secret" not in str(body)
|
||||
assert "recovery_codes" not in str(body)
|
||||
assert "otpauth" not in str(body)
|
||||
|
||||
|
||||
def test_status_initially_disabled(totp_client):
|
||||
client, _ = totp_client
|
||||
_login(client)
|
||||
|
||||
body = _status(client)
|
||||
assert body["enabled"] is False
|
||||
|
||||
|
||||
def test_status_after_enable(totp_client):
|
||||
client, _ = totp_client
|
||||
csrf = _login(client)
|
||||
|
||||
setup_body = _setup(client, csrf).json()
|
||||
_enable(client, csrf, pyotp.TOTP(setup_body["secret"]).now())
|
||||
|
||||
body = _status(client)
|
||||
assert body["enabled"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Security: unauthenticated / missing CSRF
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_setup_unauthenticated_returns_401(client: TestClient):
|
||||
resp = client.post("/api/auth/totp/setup", headers={"X-CSRF-Token": "x"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_setup_missing_csrf_returns_403(totp_client):
|
||||
client, _ = totp_client
|
||||
_login(client)
|
||||
# No X-CSRF-Token header
|
||||
resp = client.post("/api/auth/totp/setup")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_enable_unauthenticated_returns_401(client: TestClient):
|
||||
resp = client.post(
|
||||
"/api/auth/totp/enable",
|
||||
json={"code": "123456"},
|
||||
headers={"X-CSRF-Token": "x"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_enable_missing_csrf_returns_403(totp_client):
|
||||
client, _ = totp_client
|
||||
_login(client)
|
||||
resp = client.post("/api/auth/totp/enable", json={"code": "123456"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_disable_unauthenticated_returns_401(client: TestClient):
|
||||
resp = client.post(
|
||||
"/api/auth/totp/disable",
|
||||
json={"password": "test-password"},
|
||||
headers={"X-CSRF-Token": "x"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_disable_missing_csrf_returns_403(totp_client):
|
||||
client, _ = totp_client
|
||||
_login(client)
|
||||
resp = client.post("/api/auth/totp/disable", json={"password": "test-password"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_status_unauthenticated_returns_401(client: TestClient):
|
||||
resp = client.get("/api/auth/totp")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# M4-T06 — verify_recovery_code service-level tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _full_setup_and_enable(client: TestClient) -> tuple[str, list[str]]:
|
||||
"""Log in, run setup, enable TOTP, return (secret, plaintext_recovery_codes)."""
|
||||
csrf = _login(client)
|
||||
setup_body = _setup(client, csrf).json()
|
||||
secret = setup_body["secret"]
|
||||
recovery_codes = setup_body["recovery_codes"]
|
||||
_enable(client, csrf, pyotp.TOTP(secret).now())
|
||||
return secret, recovery_codes
|
||||
|
||||
|
||||
class TestVerifyRecoveryCodeService:
|
||||
"""Unit-level checks for totp_service.verify_recovery_code (used in T06 login)."""
|
||||
|
||||
def test_verify_recovery_code_returns_true_on_valid_code(self, totp_client):
|
||||
"""verify_recovery_code returns True for an unused code."""
|
||||
from app.models.auth import AuthUser
|
||||
from sqlalchemy import select
|
||||
from app.services import totp as totp_svc
|
||||
|
||||
client, engine = totp_client
|
||||
_, recovery_codes = _full_setup_and_enable(client)
|
||||
code = recovery_codes[0]
|
||||
|
||||
with Session(engine) as db:
|
||||
user = db.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||
result = totp_svc.verify_recovery_code(db, user=user, code=code)
|
||||
assert result is True
|
||||
|
||||
def test_verify_recovery_code_returns_false_on_used_code(self, totp_client):
|
||||
"""verify_recovery_code returns False when the code was already consumed."""
|
||||
from app.models.auth import AuthUser
|
||||
from sqlalchemy import select
|
||||
from app.services import totp as totp_svc
|
||||
|
||||
client, engine = totp_client
|
||||
_, recovery_codes = _full_setup_and_enable(client)
|
||||
code = recovery_codes[0]
|
||||
|
||||
with Session(engine) as db:
|
||||
user = db.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||
# First call consumes
|
||||
assert totp_svc.verify_recovery_code(db, user=user, code=code) is True
|
||||
# Reload user after commit
|
||||
db.expire_all()
|
||||
user = db.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||
# Second call must fail
|
||||
assert totp_svc.verify_recovery_code(db, user=user, code=code) is False
|
||||
|
||||
def test_verify_recovery_code_returns_false_on_invalid_code(self, totp_client):
|
||||
"""verify_recovery_code returns False for a completely wrong code."""
|
||||
from app.models.auth import AuthUser
|
||||
from sqlalchemy import select
|
||||
from app.services import totp as totp_svc
|
||||
|
||||
client, engine = totp_client
|
||||
_full_setup_and_enable(client)
|
||||
|
||||
with Session(engine) as db:
|
||||
user = db.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||
result = totp_svc.verify_recovery_code(db, user=user, code="xxxx-xxxx")
|
||||
assert result is False
|
||||
|
||||
def test_verify_totp_code_correct(self, totp_client):
|
||||
"""verify_totp_code returns True for the current TOTP code."""
|
||||
from app.models.auth import AuthUser
|
||||
from sqlalchemy import select
|
||||
from app.services import totp as totp_svc
|
||||
|
||||
client, engine = totp_client
|
||||
secret, _ = _full_setup_and_enable(client)
|
||||
|
||||
with Session(engine) as db:
|
||||
user = db.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||
result = totp_svc.verify_totp_code(user, pyotp.TOTP(secret).now())
|
||||
assert result is True
|
||||
|
||||
def test_verify_totp_code_wrong(self, totp_client):
|
||||
"""verify_totp_code returns False for a wrong code."""
|
||||
from app.models.auth import AuthUser
|
||||
from sqlalchemy import select
|
||||
from app.services import totp as totp_svc
|
||||
|
||||
client, engine = totp_client
|
||||
_full_setup_and_enable(client)
|
||||
|
||||
with Session(engine) as db:
|
||||
user = db.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||
result = totp_svc.verify_totp_code(user, "000000")
|
||||
assert result is False
|
||||
|
||||
def test_verify_totp_code_no_secret(self, totp_client):
|
||||
"""verify_totp_code returns False if user has no secret (TOTP not set up)."""
|
||||
from app.models.auth import AuthUser
|
||||
from sqlalchemy import select
|
||||
from app.services import totp as totp_svc
|
||||
|
||||
client, engine = totp_client
|
||||
|
||||
with Session(engine) as db:
|
||||
user = db.scalar(select(AuthUser).where(AuthUser.username == "admin"))
|
||||
assert user.totp_secret is None
|
||||
result = totp_svc.verify_totp_code(user, "123456")
|
||||
assert result is False
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Tests for M4-T01: LoginThrottle model and auth_login_throttle migration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.auth_throttle import LoginThrottle
|
||||
|
||||
|
||||
def _make_app_alembic_config(database_url: str) -> Config:
|
||||
config = Config("alembic_app.ini")
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
return config
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def throttle_db(tmp_path: Path):
|
||||
"""Temporary SQLite DB upgraded to the current Alembic head."""
|
||||
db_path = tmp_path / "throttle_test.db"
|
||||
db_url = f"sqlite:///{db_path}"
|
||||
alembic_cfg = _make_app_alembic_config(db_url)
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||
yield engine
|
||||
engine.dispose()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Migration shape tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_table_exists_after_upgrade(throttle_db):
|
||||
"""auth_login_throttle table must be present after upgrade to head."""
|
||||
inspector = inspect(throttle_db)
|
||||
assert "auth_login_throttle" in inspector.get_table_names()
|
||||
|
||||
|
||||
def test_table_has_expected_columns(throttle_db):
|
||||
"""All required columns must be present with correct nullability."""
|
||||
inspector = inspect(throttle_db)
|
||||
columns = {col["name"]: col for col in inspector.get_columns("auth_login_throttle")}
|
||||
|
||||
expected_non_nullable = {"id", "key", "scope", "failures", "first_failed_at", "last_failed_at"}
|
||||
expected_nullable = {"next_allowed_at"}
|
||||
|
||||
for col_name in expected_non_nullable:
|
||||
assert col_name in columns, f"Missing column: {col_name}"
|
||||
assert not columns[col_name]["nullable"], f"Column {col_name} should be NOT NULL"
|
||||
|
||||
for col_name in expected_nullable:
|
||||
assert col_name in columns, f"Missing column: {col_name}"
|
||||
assert columns[col_name]["nullable"], f"Column {col_name} should be nullable"
|
||||
|
||||
|
||||
def test_unique_constraint_scope_key(throttle_db):
|
||||
"""(scope, key) pair must be unique — inserting a duplicate must raise."""
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
with Session(throttle_db) as session:
|
||||
session.add(
|
||||
LoginThrottle(
|
||||
key="192.168.1.1",
|
||||
scope="ip",
|
||||
failures=1,
|
||||
first_failed_at=now,
|
||||
last_failed_at=now,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
with Session(throttle_db) as session:
|
||||
session.add(
|
||||
LoginThrottle(
|
||||
key="192.168.1.1",
|
||||
scope="ip",
|
||||
failures=2,
|
||||
first_failed_at=now,
|
||||
last_failed_at=now,
|
||||
)
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_same_key_different_scope_allowed(throttle_db):
|
||||
"""Same key string but different scopes are independent rows — both should insert."""
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
with Session(throttle_db) as session:
|
||||
session.add(
|
||||
LoginThrottle(
|
||||
key="admin",
|
||||
scope="ip",
|
||||
failures=1,
|
||||
first_failed_at=now,
|
||||
last_failed_at=now,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
LoginThrottle(
|
||||
key="admin",
|
||||
scope="user",
|
||||
failures=3,
|
||||
first_failed_at=now,
|
||||
last_failed_at=now,
|
||||
)
|
||||
)
|
||||
session.commit() # must not raise
|
||||
|
||||
with Session(throttle_db) as session:
|
||||
rows = session.query(LoginThrottle).filter_by(key="admin").all()
|
||||
assert len(rows) == 2
|
||||
scopes = {r.scope for r in rows}
|
||||
assert scopes == {"ip", "user"}
|
||||
|
||||
|
||||
def test_next_allowed_at_nullable(throttle_db):
|
||||
"""next_allowed_at is nullable — a row without it must persist fine."""
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
with Session(throttle_db) as session:
|
||||
row = LoginThrottle(
|
||||
key="10.0.0.1",
|
||||
scope="ip",
|
||||
failures=1,
|
||||
first_failed_at=now,
|
||||
last_failed_at=now,
|
||||
next_allowed_at=None,
|
||||
)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
assert row.next_allowed_at is None
|
||||
|
||||
|
||||
def test_next_allowed_at_persists_when_set(throttle_db):
|
||||
"""next_allowed_at can be set and retrieved correctly."""
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
with Session(throttle_db) as session:
|
||||
row = LoginThrottle(
|
||||
key="10.0.0.2",
|
||||
scope="ip",
|
||||
failures=5,
|
||||
first_failed_at=now,
|
||||
last_failed_at=now,
|
||||
next_allowed_at=now,
|
||||
)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
row_id = row.id
|
||||
|
||||
with Session(throttle_db) as session:
|
||||
fetched = session.get(LoginThrottle, row_id)
|
||||
assert fetched is not None
|
||||
assert fetched.next_allowed_at is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Downgrade / upgrade reversibility test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_downgrade_removes_table(tmp_path: Path):
|
||||
"""Downgrading the auth_login_throttle revision must cleanly drop the table.
|
||||
|
||||
We upgrade to the specific throttle revision (not necessarily head, since later
|
||||
revisions may have been added), then downgrade one step to verify the table is gone.
|
||||
"""
|
||||
db_path = tmp_path / "down_test.db"
|
||||
db_url = f"sqlite:///{db_path}"
|
||||
alembic_cfg = _make_app_alembic_config(db_url)
|
||||
throttle_revision = "20260621_07_auth_login_throttle"
|
||||
|
||||
command.upgrade(alembic_cfg, throttle_revision)
|
||||
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||
assert "auth_login_throttle" in inspect(engine).get_table_names()
|
||||
engine.dispose()
|
||||
|
||||
command.downgrade(alembic_cfg, "-1")
|
||||
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||
assert "auth_login_throttle" not in inspect(engine).get_table_names()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_index_exists_on_scope_key(throttle_db):
|
||||
"""ix_auth_login_throttle_scope_key composite index must exist."""
|
||||
inspector = inspect(throttle_db)
|
||||
indexes = inspector.get_indexes("auth_login_throttle")
|
||||
index_names = {idx["name"] for idx in indexes}
|
||||
assert "ix_auth_login_throttle_scope_key" in index_names
|
||||
@@ -0,0 +1,417 @@
|
||||
"""Tests for M4-T02: login throttle service and endpoint integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
|
||||
from app.db import reset_db_caches
|
||||
from app.config import get_settings
|
||||
from app.main import create_app
|
||||
from app.models.auth_throttle import LoginThrottle
|
||||
from app.services import login_throttle as svc
|
||||
from app.services.login_throttle import (
|
||||
N_FREE,
|
||||
BACKOFF_BASE_S,
|
||||
BACKOFF_CAP_S,
|
||||
_compute_next_allowed_at,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures — throttle-aware variants (throttle enabled)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_app_alembic_config(database_url: str) -> Config:
|
||||
config = Config("alembic_app.ini")
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def throttle_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Fresh migrated DB with throttle enabled (AUTH_LOGIN_THROTTLE_ENABLED=true)."""
|
||||
app_database_path = tmp_path / "throttle_test.db"
|
||||
app_database_url = f"sqlite:///{app_database_path}"
|
||||
|
||||
monkeypatch.setenv("APP_DATABASE_URL", app_database_url)
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password")
|
||||
monkeypatch.setenv("AUTH_COOKIE_SECURE_OVERRIDE", "false")
|
||||
monkeypatch.setenv("AUTH_LOGIN_THROTTLE_ENABLED", "true")
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
|
||||
command.upgrade(_make_app_alembic_config(app_database_url), "head")
|
||||
reset_db_caches()
|
||||
|
||||
yield app_database_url
|
||||
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def throttle_session(throttle_db: str) -> Session:
|
||||
"""SQLAlchemy session for the throttle-enabled test DB."""
|
||||
engine = create_engine(throttle_db, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def throttle_client(throttle_db: str):
|
||||
"""TestClient wired to a throttle-enabled DB."""
|
||||
app = create_app()
|
||||
with TestClient(app) as client:
|
||||
yield client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests — service internals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestComputeNextAllowedAt:
|
||||
"""Verify the exponential formula independently of DB I/O."""
|
||||
|
||||
def test_no_delay_for_first_n_free_failures(self):
|
||||
now = datetime.now(UTC)
|
||||
for f in range(1, N_FREE + 1):
|
||||
result = _compute_next_allowed_at(f, now)
|
||||
assert result is None, f"Expected None for failures={f}"
|
||||
|
||||
def test_delay_starts_after_n_free(self):
|
||||
now = datetime.now(UTC)
|
||||
result = _compute_next_allowed_at(N_FREE + 1, now)
|
||||
assert result is not None
|
||||
expected_wait = BACKOFF_BASE_S * (2 ** (N_FREE + 1 - N_FREE)) # base * 2^1
|
||||
assert abs((result - now).total_seconds() - expected_wait) < 0.1
|
||||
|
||||
def test_exponential_growth(self):
|
||||
now = datetime.now(UTC)
|
||||
prev_wait = 0.0
|
||||
for failures in range(N_FREE + 1, N_FREE + 8):
|
||||
result = _compute_next_allowed_at(failures, now)
|
||||
assert result is not None
|
||||
wait = (result - now).total_seconds()
|
||||
if prev_wait > 0:
|
||||
# Each step should double (until cap)
|
||||
if wait < BACKOFF_CAP_S:
|
||||
assert abs(wait - prev_wait * 2) < 0.1, (
|
||||
f"Expected doubling at failures={failures}: {prev_wait} → {wait}"
|
||||
)
|
||||
prev_wait = wait
|
||||
|
||||
def test_cap_is_respected(self):
|
||||
now = datetime.now(UTC)
|
||||
# Use a very high failure count to guarantee we're past the cap threshold.
|
||||
result = _compute_next_allowed_at(N_FREE + 20, now)
|
||||
assert result is not None
|
||||
wait = (result - now).total_seconds()
|
||||
assert wait <= BACKOFF_CAP_S + 0.1
|
||||
|
||||
|
||||
class TestRegisterFailureAndCheck:
|
||||
"""Service functions against an in-memory DB."""
|
||||
|
||||
def test_single_failure_within_free_window_no_wait(self, throttle_session: Session):
|
||||
svc.register_failure(throttle_session, ip="1.2.3.4", username="alice")
|
||||
wait = svc.check_and_get_wait(throttle_session, ip="1.2.3.4", username="alice")
|
||||
assert wait == 0, "First failure should not produce any wait"
|
||||
|
||||
def test_free_failures_all_zero_wait(self, throttle_session: Session):
|
||||
for _ in range(N_FREE):
|
||||
svc.register_failure(throttle_session, ip="10.0.0.1", username="bob")
|
||||
wait = svc.check_and_get_wait(throttle_session, ip="10.0.0.1", username="bob")
|
||||
assert wait == 0
|
||||
|
||||
def test_fourth_failure_produces_wait(self, throttle_session: Session):
|
||||
"""failures = N_FREE + 1 should produce a non-zero wait."""
|
||||
for _ in range(N_FREE + 1):
|
||||
svc.register_failure(throttle_session, ip="10.0.0.2", username="carol")
|
||||
wait = svc.check_and_get_wait(throttle_session, ip="10.0.0.2", username="carol")
|
||||
assert wait > 0
|
||||
|
||||
def test_wait_grows_with_more_failures(self, throttle_session: Session):
|
||||
ip, user = "10.0.0.3", "dave"
|
||||
# Accumulate past the free window
|
||||
for _ in range(N_FREE + 1):
|
||||
svc.register_failure(throttle_session, ip=ip, username=user)
|
||||
wait1 = svc.check_and_get_wait(throttle_session, ip=ip, username=user)
|
||||
|
||||
svc.register_failure(throttle_session, ip=ip, username=user)
|
||||
wait2 = svc.check_and_get_wait(throttle_session, ip=ip, username=user)
|
||||
|
||||
assert wait2 > wait1, "Wait should increase with more failures"
|
||||
|
||||
def test_wait_is_capped(self, throttle_session: Session):
|
||||
ip, user = "10.0.0.4", "eve"
|
||||
# Simulate a high failure count by calling register_failure many times.
|
||||
for _ in range(N_FREE + 15):
|
||||
svc.register_failure(throttle_session, ip=ip, username=user)
|
||||
wait = svc.check_and_get_wait(throttle_session, ip=ip, username=user)
|
||||
assert wait <= BACKOFF_CAP_S, f"Wait {wait} exceeds cap {BACKOFF_CAP_S}"
|
||||
|
||||
def test_clear_removes_rows(self, throttle_session: Session):
|
||||
ip, user = "10.0.0.5", "frank"
|
||||
for _ in range(N_FREE + 2):
|
||||
svc.register_failure(throttle_session, ip=ip, username=user)
|
||||
assert svc.check_and_get_wait(throttle_session, ip=ip, username=user) > 0
|
||||
|
||||
svc.clear(throttle_session, ip=ip, username=user)
|
||||
assert svc.check_and_get_wait(throttle_session, ip=ip, username=user) == 0
|
||||
|
||||
def test_clear_is_idempotent(self, throttle_session: Session):
|
||||
"""Calling clear when no rows exist must not raise."""
|
||||
svc.clear(throttle_session, ip="192.168.1.1", username="nobody")
|
||||
assert svc.check_and_get_wait(throttle_session, ip="192.168.1.1", username="nobody") == 0
|
||||
|
||||
def test_dual_key_max_is_used(self, throttle_session: Session):
|
||||
"""If only the username key is in backoff, check returns its wait time."""
|
||||
# Register enough failures for username but use a fresh IP.
|
||||
user = "grace"
|
||||
for _ in range(N_FREE + 1):
|
||||
svc.register_failure(throttle_session, ip="172.16.0.1", username=user)
|
||||
|
||||
# Fresh IP, same username — username key should dominate.
|
||||
wait = svc.check_and_get_wait(throttle_session, ip="172.16.0.2", username=user)
|
||||
assert wait > 0, "Username key should dominate even with a fresh IP"
|
||||
|
||||
def test_expired_window_is_zero(self, throttle_session: Session):
|
||||
"""A row whose next_allowed_at is in the past should yield 0."""
|
||||
now = datetime.now(UTC)
|
||||
past = now - timedelta(seconds=10)
|
||||
row = LoginThrottle(
|
||||
scope="ip",
|
||||
key="192.168.99.1",
|
||||
failures=N_FREE + 2,
|
||||
first_failed_at=past,
|
||||
last_failed_at=past,
|
||||
next_allowed_at=past,
|
||||
)
|
||||
throttle_session.add(row)
|
||||
throttle_session.commit()
|
||||
|
||||
wait = svc.check_and_get_wait(throttle_session, ip="192.168.99.1", username="nobody")
|
||||
assert wait == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests — POST /api/auth/login endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _bad_login(client: TestClient, *, username: str = "admin", password: str = "wrong") -> int:
|
||||
resp = client.post("/api/auth/login", json={"username": username, "password": password})
|
||||
return resp.status_code
|
||||
|
||||
|
||||
def _good_login(client: TestClient) -> int:
|
||||
resp = client.post(
|
||||
"/api/auth/login", json={"username": "admin", "password": "test-password"}
|
||||
)
|
||||
return resp.status_code
|
||||
|
||||
|
||||
class TestLoginEndpointThrottle:
|
||||
def test_first_few_failures_return_401(self, throttle_client: TestClient):
|
||||
"""First N_FREE failures should still return 401, not 429."""
|
||||
for _ in range(N_FREE):
|
||||
code = _bad_login(throttle_client)
|
||||
assert code == 401, f"Expected 401 within free window, got {code}"
|
||||
|
||||
def test_429_after_enough_failures(self, throttle_client: TestClient):
|
||||
"""After N_FREE+1 failures the endpoint must return 429."""
|
||||
for _ in range(N_FREE + 1):
|
||||
_bad_login(throttle_client)
|
||||
resp = throttle_client.post(
|
||||
"/api/auth/login", json={"username": "admin", "password": "wrong"}
|
||||
)
|
||||
assert resp.status_code == 429
|
||||
|
||||
def test_429_includes_retry_after_header(self, throttle_client: TestClient):
|
||||
"""429 response must carry a Retry-After header with a positive integer."""
|
||||
for _ in range(N_FREE + 1):
|
||||
_bad_login(throttle_client)
|
||||
resp = throttle_client.post(
|
||||
"/api/auth/login", json={"username": "admin", "password": "wrong"}
|
||||
)
|
||||
assert resp.status_code == 429
|
||||
retry_after = resp.headers.get("retry-after")
|
||||
assert retry_after is not None, "Retry-After header missing"
|
||||
assert int(retry_after) > 0, "Retry-After must be positive"
|
||||
|
||||
def test_correct_password_blocked_in_window(self, throttle_client: TestClient):
|
||||
"""Even a correct password must be rejected (429) while inside backoff window."""
|
||||
for _ in range(N_FREE + 1):
|
||||
_bad_login(throttle_client)
|
||||
resp = throttle_client.post(
|
||||
"/api/auth/login", json={"username": "admin", "password": "test-password"}
|
||||
)
|
||||
assert resp.status_code == 429, (
|
||||
"Correct password should still be blocked while in backoff window"
|
||||
)
|
||||
|
||||
def test_success_clears_throttle(self, throttle_client: TestClient):
|
||||
"""A successful login must clear the backoff so the next attempt is free."""
|
||||
# Register some failures (but not enough to trigger 429).
|
||||
for _ in range(N_FREE):
|
||||
_bad_login(throttle_client)
|
||||
|
||||
# Successful login clears state.
|
||||
assert _good_login(throttle_client) == 200
|
||||
|
||||
# Subsequent bad attempts should be counted fresh (no residual backoff).
|
||||
code = _bad_login(throttle_client)
|
||||
assert code == 401, "After clear, first new failure should be 401 not 429"
|
||||
|
||||
def test_401_uses_generic_message(self, throttle_client: TestClient):
|
||||
"""401 detail must be generic — must not reveal whether the user exists."""
|
||||
resp = throttle_client.post(
|
||||
"/api/auth/login", json={"username": "nonexistent", "password": "wrong"}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
detail = resp.json().get("detail", "")
|
||||
assert "invalid username or password" == detail
|
||||
assert "user" not in detail.lower() or "username" in detail.lower()
|
||||
# Must NOT say things like "user not found" or "user does not exist"
|
||||
assert "not found" not in detail
|
||||
assert "does not exist" not in detail
|
||||
|
||||
def test_unknown_user_401_has_same_message(self, throttle_client: TestClient):
|
||||
"""Unknown username and wrong password for known user give identical 401 detail."""
|
||||
resp_known = throttle_client.post(
|
||||
"/api/auth/login", json={"username": "admin", "password": "wrong"}
|
||||
)
|
||||
resp_unknown = throttle_client.post(
|
||||
"/api/auth/login", json={"username": "nobody", "password": "wrong"}
|
||||
)
|
||||
assert resp_known.status_code == 401
|
||||
assert resp_unknown.status_code == 401
|
||||
assert resp_known.json()["detail"] == resp_unknown.json()["detail"]
|
||||
|
||||
def test_retry_after_grows_with_more_failures(self, throttle_client: TestClient):
|
||||
"""Retry-After should be strictly larger after more failures (exponential)."""
|
||||
# Accumulate past the free window.
|
||||
for _ in range(N_FREE + 1):
|
||||
_bad_login(throttle_client)
|
||||
resp1 = throttle_client.post(
|
||||
"/api/auth/login", json={"username": "admin", "password": "wrong"}
|
||||
)
|
||||
assert resp1.status_code == 429
|
||||
wait1 = int(resp1.headers["retry-after"])
|
||||
|
||||
# One more failure → longer wait.
|
||||
_bad_login(throttle_client)
|
||||
resp2 = throttle_client.post(
|
||||
"/api/auth/login", json={"username": "admin", "password": "wrong"}
|
||||
)
|
||||
assert resp2.status_code == 429
|
||||
wait2 = int(resp2.headers["retry-after"])
|
||||
|
||||
assert wait2 >= wait1, "Retry-After should not decrease with more failures"
|
||||
|
||||
def test_xff_header_used_when_trust_enabled(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""When AUTH_TRUST_FORWARDED_FOR=true the leftmost XFF IP is used as the IP key."""
|
||||
app_database_path = tmp_path / "xff_test.db"
|
||||
app_database_url = f"sqlite:///{app_database_path}"
|
||||
|
||||
monkeypatch.setenv("APP_DATABASE_URL", app_database_url)
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin")
|
||||
monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password")
|
||||
monkeypatch.setenv("AUTH_COOKIE_SECURE_OVERRIDE", "false")
|
||||
monkeypatch.setenv("AUTH_LOGIN_THROTTLE_ENABLED", "true")
|
||||
monkeypatch.setenv("AUTH_TRUST_FORWARDED_FOR", "true")
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
|
||||
command.upgrade(_make_app_alembic_config(app_database_url), "head")
|
||||
reset_db_caches()
|
||||
|
||||
app = create_app()
|
||||
with TestClient(app) as client:
|
||||
# Accumulate failures from XFF IP "5.5.5.5".
|
||||
for _ in range(N_FREE + 1):
|
||||
client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "wrong"},
|
||||
headers={"X-Forwarded-For": "5.5.5.5, 10.0.0.1"},
|
||||
)
|
||||
|
||||
# Same XFF IP should now be in backoff.
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "wrong"},
|
||||
headers={"X-Forwarded-For": "5.5.5.5, 10.0.0.1"},
|
||||
)
|
||||
assert resp.status_code == 429
|
||||
|
||||
# Different XFF IP should NOT be in backoff (username might be, so use fresh one).
|
||||
resp2 = client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "wrong"},
|
||||
headers={"X-Forwarded-For": "6.6.6.6"},
|
||||
)
|
||||
# This IP has no failures, but username "admin" DOES have N_FREE+1 failures.
|
||||
# The username key is in backoff, so this should still 429.
|
||||
# To cleanly test IP isolation we'd need a different username — but single admin
|
||||
# means we can only confirm the XFF IP is being read (not defaulting to socket).
|
||||
# The important thing: the request was processed with XFF logic (no crash).
|
||||
assert resp2.status_code in (401, 429)
|
||||
|
||||
get_settings.cache_clear()
|
||||
reset_db_caches()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Throttle disabled — no-op behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestThrottleDisabled:
|
||||
"""When AUTH_LOGIN_THROTTLE_ENABLED=false the endpoint behaves exactly as before."""
|
||||
|
||||
def test_many_failures_still_return_401_not_429(self, client: TestClient):
|
||||
"""With throttle off, no number of failures should produce 429."""
|
||||
# The default `client` fixture has throttle disabled (AUTH_LOGIN_THROTTLE_ENABLED=false).
|
||||
for _ in range(N_FREE + 5):
|
||||
resp = client.post(
|
||||
"/api/auth/login", json={"username": "admin", "password": "wrong"}
|
||||
)
|
||||
assert resp.status_code == 401, f"Expected 401 with throttle off, got {resp.status_code}"
|
||||
|
||||
def test_no_throttle_rows_written_when_disabled(
|
||||
self, auth_database, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""With throttle disabled, no LoginThrottle rows are created."""
|
||||
db_url = auth_database["app_url"]
|
||||
app = create_app()
|
||||
with TestClient(app) as client:
|
||||
for _ in range(N_FREE + 2):
|
||||
client.post(
|
||||
"/api/auth/login", json={"username": "admin", "password": "wrong"}
|
||||
)
|
||||
|
||||
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(
|
||||
bind=engine, autoflush=False, autocommit=False, class_=Session
|
||||
)
|
||||
with SessionLocal() as session:
|
||||
count = session.query(LoginThrottle).count()
|
||||
engine.dispose()
|
||||
assert count == 0, f"Expected 0 throttle rows with throttle disabled, found {count}"
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Tests for M4-T04: AuthUser TOTP fields and auth_recovery_code migration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.auth import AuthUser, RecoveryCode
|
||||
|
||||
|
||||
def _make_app_alembic_config(database_url: str) -> Config:
|
||||
config = Config("alembic_app.ini")
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
return config
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def totp_db(tmp_path: Path):
|
||||
"""Temporary SQLite DB upgraded to the current Alembic head."""
|
||||
db_path = tmp_path / "totp_test.db"
|
||||
db_url = f"sqlite:///{db_path}"
|
||||
alembic_cfg = _make_app_alembic_config(db_url)
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||
yield engine
|
||||
engine.dispose()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Migration shape tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_auth_users_has_totp_columns(totp_db):
|
||||
"""After upgrade to head, auth_users must have totp_secret and totp_enabled columns."""
|
||||
inspector = inspect(totp_db)
|
||||
columns = {col["name"]: col for col in inspector.get_columns("auth_users")}
|
||||
|
||||
assert "totp_secret" in columns, "totp_secret column missing from auth_users"
|
||||
assert "totp_enabled" in columns, "totp_enabled column missing from auth_users"
|
||||
|
||||
# totp_secret must be nullable
|
||||
assert columns["totp_secret"]["nullable"], "totp_secret should be nullable"
|
||||
# totp_enabled must be NOT NULL
|
||||
assert not columns["totp_enabled"]["nullable"], "totp_enabled should be NOT NULL"
|
||||
|
||||
|
||||
def test_auth_recovery_code_table_exists(totp_db):
|
||||
"""auth_recovery_code table must exist after upgrade to head."""
|
||||
inspector = inspect(totp_db)
|
||||
assert "auth_recovery_code" in inspector.get_table_names()
|
||||
|
||||
|
||||
def test_auth_recovery_code_has_expected_columns(totp_db):
|
||||
"""auth_recovery_code must have id, user_id, code_hash, used_at with correct nullability."""
|
||||
inspector = inspect(totp_db)
|
||||
columns = {col["name"]: col for col in inspector.get_columns("auth_recovery_code")}
|
||||
|
||||
expected_non_nullable = {"id", "user_id", "code_hash"}
|
||||
expected_nullable = {"used_at"}
|
||||
|
||||
for col_name in expected_non_nullable:
|
||||
assert col_name in columns, f"Missing column: {col_name}"
|
||||
assert not columns[col_name]["nullable"], f"Column {col_name} should be NOT NULL"
|
||||
|
||||
for col_name in expected_nullable:
|
||||
assert col_name in columns, f"Missing column: {col_name}"
|
||||
assert columns[col_name]["nullable"], f"Column {col_name} should be nullable"
|
||||
|
||||
|
||||
def test_auth_recovery_code_has_fk_to_auth_users(totp_db):
|
||||
"""auth_recovery_code.user_id must have a FK constraint pointing to auth_users.id."""
|
||||
inspector = inspect(totp_db)
|
||||
fks = inspector.get_foreign_keys("auth_recovery_code")
|
||||
assert len(fks) == 1, f"Expected 1 FK on auth_recovery_code, got {len(fks)}"
|
||||
fk = fks[0]
|
||||
assert fk["referred_table"] == "auth_users"
|
||||
assert "user_id" in fk["constrained_columns"]
|
||||
assert "id" in fk["referred_columns"]
|
||||
|
||||
|
||||
def test_auth_recovery_code_user_id_index_exists(totp_db):
|
||||
"""ix_auth_recovery_code_user_id index must exist."""
|
||||
inspector = inspect(totp_db)
|
||||
indexes = inspector.get_indexes("auth_recovery_code")
|
||||
index_names = {idx["name"] for idx in indexes}
|
||||
assert "ix_auth_recovery_code_user_id" in index_names
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default value tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_totp_enabled_defaults_to_false(totp_db):
|
||||
"""A newly inserted AuthUser must have totp_enabled=False and totp_secret=None."""
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
with Session(totp_db) as session:
|
||||
user = AuthUser(
|
||||
username="testuser",
|
||||
password_hash="$argon2id$v=19$m=65536,t=3,p=4$fakehash",
|
||||
is_active=True,
|
||||
force_password_change=False,
|
||||
created_at=now,
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
user_id = user.id
|
||||
|
||||
with Session(totp_db) as session:
|
||||
fetched = session.get(AuthUser, user_id)
|
||||
assert fetched is not None
|
||||
assert fetched.totp_enabled is False, "totp_enabled should default to False"
|
||||
assert fetched.totp_secret is None, "totp_secret should default to None"
|
||||
|
||||
|
||||
def test_totp_secret_can_be_set(totp_db):
|
||||
"""totp_secret can be set to a base32 string and retrieved correctly."""
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
secret = "JBSWY3DPEHPK3PXP"
|
||||
with Session(totp_db) as session:
|
||||
user = AuthUser(
|
||||
username="totp_user",
|
||||
password_hash="$argon2id$v=19$m=65536,t=3,p=4$fakehash",
|
||||
is_active=True,
|
||||
force_password_change=False,
|
||||
created_at=now,
|
||||
totp_secret=secret,
|
||||
totp_enabled=True,
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
user_id = user.id
|
||||
|
||||
with Session(totp_db) as session:
|
||||
fetched = session.get(AuthUser, user_id)
|
||||
assert fetched is not None
|
||||
assert fetched.totp_secret == secret
|
||||
assert fetched.totp_enabled is True
|
||||
|
||||
|
||||
def test_recovery_code_insert_and_used_at_nullable(totp_db):
|
||||
"""A RecoveryCode can be inserted with used_at=None and later marked consumed."""
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
with Session(totp_db) as session:
|
||||
user = AuthUser(
|
||||
username="rc_user",
|
||||
password_hash="$argon2id$v=19$m=65536,t=3,p=4$fakehash",
|
||||
is_active=True,
|
||||
force_password_change=False,
|
||||
created_at=now,
|
||||
)
|
||||
session.add(user)
|
||||
session.flush()
|
||||
|
||||
code = RecoveryCode(
|
||||
user_id=user.id,
|
||||
code_hash="$argon2id$v=19$m=65536,t=3,p=4$rcfakehash",
|
||||
used_at=None,
|
||||
)
|
||||
session.add(code)
|
||||
session.commit()
|
||||
code_id = code.id
|
||||
|
||||
with Session(totp_db) as session:
|
||||
fetched = session.get(RecoveryCode, code_id)
|
||||
assert fetched is not None
|
||||
assert fetched.used_at is None
|
||||
|
||||
# Mark as consumed
|
||||
fetched.used_at = now
|
||||
session.commit()
|
||||
|
||||
with Session(totp_db) as session:
|
||||
fetched = session.get(RecoveryCode, code_id)
|
||||
assert fetched is not None
|
||||
assert fetched.used_at is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Existing user migration test (not-break-login guarantee)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_existing_user_defaults_to_totp_disabled_after_upgrade(tmp_path: Path):
|
||||
"""An existing user row inserted before the TOTP migration must have totp_enabled=0 after upgrade.
|
||||
|
||||
Simulates the real-world scenario: a pre-existing row in auth_users gets migrated
|
||||
to the TOTP revision and the server_default ensures totp_enabled=false (0) for it.
|
||||
"""
|
||||
db_path = tmp_path / "existing_user_test.db"
|
||||
db_url = f"sqlite:///{db_path}"
|
||||
alembic_cfg = _make_app_alembic_config(db_url)
|
||||
|
||||
# Upgrade to the TOTP revision's predecessor (the throttle revision).
|
||||
command.upgrade(alembic_cfg, "20260621_07_auth_login_throttle")
|
||||
|
||||
# Insert a user row directly (simulates a user that existed before the TOTP migration).
|
||||
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||
with engine.connect() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO auth_users (username, password_hash, is_active, force_password_change, created_at)"
|
||||
" VALUES ('legacy_admin', 'hash', 1, 0, '2026-01-01T00:00:00+00:00')"
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute(
|
||||
text("SELECT id FROM auth_users WHERE username = 'legacy_admin'")
|
||||
).fetchone()
|
||||
legacy_user_id = row[0]
|
||||
engine.dispose()
|
||||
|
||||
# Now upgrade to head (applies the TOTP migration).
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
|
||||
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||
with engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT totp_enabled, totp_secret FROM auth_users WHERE id = :uid"
|
||||
),
|
||||
{"uid": legacy_user_id},
|
||||
).fetchone()
|
||||
engine.dispose()
|
||||
|
||||
assert row is not None
|
||||
# totp_enabled should be 0 (false) for the legacy row — server_default="0" ensures this.
|
||||
assert row[0] == 0, f"Legacy user should have totp_enabled=0 after migration, got {row[0]}"
|
||||
# totp_secret should be NULL for the legacy row.
|
||||
assert row[1] is None, f"Legacy user should have totp_secret=NULL after migration, got {row[1]}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Downgrade / upgrade reversibility tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_downgrade_removes_totp_columns_and_table(tmp_path: Path):
|
||||
"""downgrade -1 from head must drop auth_recovery_code and remove TOTP columns from auth_users."""
|
||||
db_path = tmp_path / "down_totp_test.db"
|
||||
db_url = f"sqlite:///{db_path}"
|
||||
alembic_cfg = _make_app_alembic_config(db_url)
|
||||
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
|
||||
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||
inspector = inspect(engine)
|
||||
assert "auth_recovery_code" in inspector.get_table_names()
|
||||
auth_user_cols = {col["name"] for col in inspector.get_columns("auth_users")}
|
||||
assert "totp_secret" in auth_user_cols
|
||||
assert "totp_enabled" in auth_user_cols
|
||||
engine.dispose()
|
||||
|
||||
# Downgrade one step (removes TOTP migration).
|
||||
command.downgrade(alembic_cfg, "-1")
|
||||
|
||||
engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||
inspector = inspect(engine)
|
||||
assert "auth_recovery_code" not in inspector.get_table_names(), (
|
||||
"auth_recovery_code table should be gone after downgrade"
|
||||
)
|
||||
auth_user_cols_after = {col["name"] for col in inspector.get_columns("auth_users")}
|
||||
assert "totp_secret" not in auth_user_cols_after, (
|
||||
"totp_secret column should be removed after downgrade"
|
||||
)
|
||||
assert "totp_enabled" not in auth_user_cols_after, (
|
||||
"totp_enabled column should be removed after downgrade"
|
||||
)
|
||||
engine.dispose()
|
||||
Reference in New Issue
Block a user