security: rate limit admin authentication
This commit is contained in:
@@ -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"
|
||||
@@ -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"])
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user