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
+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