security: rate limit admin authentication

This commit is contained in:
ik
2026-09-12 21:33:26 +07:00
parent ff8ea40d40
commit 7c28a0c36a
9 changed files with 158 additions and 13 deletions
@@ -0,0 +1,27 @@
"""Add persistent administrative authentication rate limit."""
from alembic import op
import sqlalchemy as sa
revision = "0014"
down_revision = "20260910_recovery"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"admin_auth_attempt",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("client_hash", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_admin_auth_attempt_client_hash", "admin_auth_attempt", ["client_hash"])
op.create_index("ix_admin_auth_attempt_created_at", "admin_auth_attempt", ["created_at"])
def downgrade() -> None:
op.drop_index("ix_admin_auth_attempt_created_at", table_name="admin_auth_attempt")
op.drop_index("ix_admin_auth_attempt_client_hash", table_name="admin_auth_attempt")
op.drop_table("admin_auth_attempt")
+59
View File
@@ -0,0 +1,59 @@
from datetime import datetime, timedelta, timezone
import hashlib
import hmac
from fastapi import HTTPException, Request
from sqlalchemy import delete, func, select, text
from sqlalchemy.orm import Session
from .models import AdminAuthAttempt
from .submission_security import RateLimitConfig, client_address
class AdminAuthConfig(RateLimitConfig):
admin_token: str
admin_auth_attempt_limit: int
admin_auth_window_seconds: int
def verify_admin(
request: Request,
db: Session,
authorization: str | None,
config: AdminAuthConfig,
) -> str:
now = datetime.now(timezone.utc)
cutoff = now - timedelta(seconds=config.admin_auth_window_seconds)
client = client_address(request, config.trusted_proxy_cidrs)
client_hash = hmac.new(
config.rate_limit_secret.encode(), f"admin:{client}".encode(), hashlib.sha256
).hexdigest()
if db.get_bind().dialect.name == "postgresql":
lock_key = int(client_hash[:16], 16) & 0x7FFF_FFFF_FFFF_FFFF
db.execute(text("SELECT pg_advisory_xact_lock(:lock_key)"), {"lock_key": lock_key})
db.execute(delete(AdminAuthAttempt).where(AdminAuthAttempt.created_at < now - timedelta(days=1)))
failures = db.scalar(
select(func.count()).select_from(AdminAuthAttempt).where(
AdminAuthAttempt.client_hash == client_hash,
AdminAuthAttempt.created_at >= cutoff,
)
) or 0
if failures >= config.admin_auth_attempt_limit:
db.commit()
raise HTTPException(
status_code=429,
detail="too many admin authentication attempts",
headers={"Retry-After": str(config.admin_auth_window_seconds)},
)
expected = f"Bearer {config.admin_token}"
if not authorization or not hmac.compare_digest(authorization, expected):
db.add(AdminAuthAttempt(client_hash=client_hash, created_at=now))
db.commit()
raise HTTPException(
status_code=401,
detail="invalid admin token",
headers={"WWW-Authenticate": "Bearer"},
)
db.execute(delete(AdminAuthAttempt).where(AdminAuthAttempt.client_hash == client_hash))
db.commit()
return "admin"
+2
View File
@@ -31,6 +31,8 @@ class Settings(BaseSettings):
rf4map_point_url: str = "https://rf4map.ru/points/275"
rf4posts_spot_url: str = "https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee"
rate_limit_secret: str = "change-rate-limit-secret"
admin_auth_attempt_limit: int = Field(default=10, ge=3, le=100)
admin_auth_window_seconds: int = Field(default=600, ge=60, le=3600)
log_level: str = "INFO"
cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:4321", "http://127.0.0.1:4321"])
trusted_proxy_cidrs: list[str] = Field(default_factory=lambda: ["127.0.0.1/32", "::1/128"])
+3 -5
View File
@@ -19,6 +19,7 @@ from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload
from .admin_security import verify_admin
from .config import settings
from .dependencies import Db
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation, suggest_aliases
@@ -105,11 +106,8 @@ app.include_router(activity_router)
app.include_router(public_data_router)
def _admin(authorization: Annotated[str | None, Header()] = None) -> str:
expected = f"Bearer {settings.admin_token}"
if not authorization or not hmac.compare_digest(authorization, expected):
raise HTTPException(status_code=401, detail="invalid admin token", headers={"WWW-Authenticate": "Bearer"})
return "admin"
def _admin(request: Request, db: Db, authorization: Annotated[str | None, Header()] = None) -> str:
return verify_admin(request, db, authorization, settings)
@app.get("/api/v1/admin/diagnostics")
+7
View File
@@ -143,6 +143,13 @@ class SubmissionAttempt(Base):
catch_report: Mapped[CatchReport | None] = relationship()
class AdminAuthAttempt(Base):
__tablename__ = "admin_auth_attempt"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
client_hash: Mapped[str] = mapped_column(String(64), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
class DataSource(Base):
__tablename__ = "data_source"
key: Mapped[str] = mapped_column(String(50), primary_key=True)
+14 -5
View File
@@ -1,7 +1,7 @@
from datetime import datetime, timedelta, timezone
import hashlib
import hmac
from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network
from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_address
from typing import Protocol
from fastapi import HTTPException, Request
@@ -31,13 +31,22 @@ def is_trusted_proxy(address: str, trusted_cidrs: list[str]) -> bool:
return False
def client_address(request: Request, trusted_cidrs: list[str]) -> str:
client = request.client.host if request.client else "unknown"
forwarded = request.headers.get("x-forwarded-for")
if forwarded and request.client and is_trusted_proxy(request.client.host, trusted_cidrs):
candidate = forwarded.split(",")[0].strip()
try:
return str(ip_address(candidate))
except ValueError:
return client
return client
def check_rate_limit(request: Request, db: Session, config: RateLimitConfig) -> None:
now = datetime.now(timezone.utc)
cutoff = now - timedelta(minutes=10)
client = request.client.host if request.client else "unknown"
forwarded = request.headers.get("x-forwarded-for")
if forwarded and request.client and is_trusted_proxy(request.client.host, config.trusted_proxy_cidrs):
client = forwarded.split(",")[0].strip()
client = client_address(request, config.trusted_proxy_cidrs)
client_hash = hmac.new(config.rate_limit_secret.encode(), client.encode(), hashlib.sha256).hexdigest()
if db.get_bind().dialect.name == "postgresql":
lock_key = int(client_hash[:16], 16) & 0x7FFF_FFFF_FFFF_FFFF
+44 -1
View File
@@ -9,8 +9,51 @@ from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from app.database import Base
from app.admin_security import verify_admin
from app.main import _check_rate_limit, _is_trusted_proxy
from app.models import SubmissionAttempt
from app.models import AdminAuthAttempt, SubmissionAttempt
def _admin_config() -> MagicMock:
config = MagicMock()
config.admin_token = "correct-token"
config.admin_auth_attempt_limit = 3
config.admin_auth_window_seconds = 600
config.rate_limit_secret = "test-secret-for-testing"
config.trusted_proxy_cidrs = ["127.0.0.1/32"]
return config
def test_admin_auth_failures_are_hashed_and_limited() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
request = MagicMock()
request.client.host = "203.0.113.42"
request.headers.get.return_value = None
with Session(engine) as db:
for _ in range(3):
with pytest.raises(HTTPException) as denied:
verify_admin(request, db, "Bearer wrong", _admin_config())
assert denied.value.status_code == 401
with pytest.raises(HTTPException) as limited:
verify_admin(request, db, "Bearer correct-token", _admin_config())
assert limited.value.status_code == 429
attempts = list(db.scalars(select(AdminAuthAttempt)))
assert len(attempts) == 3
assert all(item.client_hash != "203.0.113.42" and len(item.client_hash) == 64 for item in attempts)
def test_successful_admin_auth_clears_previous_failures() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
request = MagicMock()
request.client.host = "203.0.113.43"
request.headers.get.return_value = None
with Session(engine) as db:
with pytest.raises(HTTPException):
verify_admin(request, db, "Bearer wrong", _admin_config())
assert verify_admin(request, db, "Bearer correct-token", _admin_config()) == "admin"
assert list(db.scalars(select(AdminAuthAttempt))) == []
def test_rate_limit_is_persistent_and_does_not_store_raw_client() -> None: