M4-T01: add auth_login_throttle table + LoginThrottle model
This commit is contained in:
@@ -7,6 +7,7 @@ from app.config import get_settings
|
|||||||
from app.db import Base
|
from app.db import Base
|
||||||
from app.models.config import AppConfigEntry # noqa: F401
|
from app.models.config import AppConfigEntry # noqa: F401
|
||||||
from app.models.auth import AuthSession, AuthUser # noqa: F401
|
from app.models.auth import AuthSession, AuthUser # noqa: F401
|
||||||
|
from app.models.auth_throttle import LoginThrottle # noqa: F401
|
||||||
from app.models.public_ip import PublicIPHistory, PublicIPState # noqa: F401
|
from app.models.public_ip import PublicIPHistory, PublicIPState # noqa: F401
|
||||||
from app.models.location import Location # noqa: F401
|
from app.models.location import Location # noqa: F401
|
||||||
from app.models.poo import PooRecord # 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,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
|
||||||
|
)
|
||||||
@@ -145,7 +145,7 @@ Phase B(TOTP,可选配)
|
|||||||
> 后端沿用校验闸门(`pytest`/`ruff`/改路由或 schema 则 `export_openapi` 重导出入库)。前端闸门见 §8。新增依赖(`pyotp`、`qrcode.react`)须同步 `requirements.in/.txt` 与 `frontend/package.json` 重新锁定。
|
> 后端沿用校验闸门(`pytest`/`ruff`/改路由或 schema 则 `export_openapi` 重导出入库)。前端闸门见 §8。新增依赖(`pyotp`、`qrcode.react`)须同步 `requirements.in/.txt` 与 `frontend/package.json` 重新锁定。
|
||||||
|
|
||||||
### M4-T01 — `auth_login_throttle` 表 + 模型 `[schema]`
|
### M4-T01 — `auth_login_throttle` 表 + 模型 `[schema]`
|
||||||
- **Status**: `todo` · **Depends**: none
|
- **Status**: `done` · **Depends**: none
|
||||||
- **Context**: 存按 IP / username 的失败退避状态。仅建 schema。
|
- **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`
|
- **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 常量。
|
- **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 常量。
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
|||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|
||||||
APP_BASELINE_REVISION = "20260611_06_merge_location_poo_tables"
|
APP_BASELINE_REVISION = "20260621_07_auth_login_throttle"
|
||||||
|
|
||||||
|
|
||||||
class AppDatabaseAdoptionError(RuntimeError):
|
class AppDatabaseAdoptionError(RuntimeError):
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""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):
|
||||||
|
"""downgrade -1 from head must cleanly drop auth_login_throttle."""
|
||||||
|
db_path = tmp_path / "down_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})
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user