M4-T04: add AuthUser TOTP columns and auth_recovery_code table

This commit is contained in:
2026-06-21 21:38:22 +02:00
parent 5481fbc99c
commit 5026967cee
7 changed files with 373 additions and 5 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ 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
+61
View File
@@ -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")
+22
View File
@@ -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")
+1 -1
View File
@@ -188,7 +188,7 @@ Phase BTOTP,可选配)
- **Reviewer checklist**: 密码可交互输入且不回显;只动 auth 行;幂等可重跑。
### M4-T04 — AuthUser TOTP 字段 + `auth_recovery_code` 表 `[schema]`
- **Status**: `todo` · **Depends**: none(与 Phase A 并行的 schema,但 T06 依赖 T02
- **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)。
+1 -1
View File
@@ -15,7 +15,7 @@ if str(PROJECT_ROOT) not in sys.path:
from app.config import get_settings
APP_BASELINE_REVISION = "20260621_07_auth_login_throttle"
APP_BASELINE_REVISION = "20260621_08_totp"
class AppDatabaseAdoptionError(RuntimeError):
+7 -2
View File
@@ -171,12 +171,17 @@ def test_next_allowed_at_persists_when_set(throttle_db):
def test_downgrade_removes_table(tmp_path: Path):
"""downgrade -1 from head must cleanly drop auth_login_throttle."""
"""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, "head")
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()
+280
View File
@@ -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()