security: rate limit admin authentication
This commit is contained in:
@@ -244,7 +244,7 @@ curl -H "Authorization: Bearer change-me-in-production" \
|
||||
|
||||
Очередь модерации доступна по адресу <http://localhost:4321/admin/moderation>. Администратор вводит `ADMIN_TOKEN`; интерфейс держит его только в памяти открытой страницы и не сохраняет в URL или браузерном хранилище. После 15 минут бездействия или ответа API `401` сессия очищается; также доступен явный выход.
|
||||
|
||||
В production HTML административных страниц дополнительно закрыт Caddy Basic Auth, а API независимо проверяет Bearer-токен. Интерфейс открывает только ссылки со схемой `http` или `https`; данные источника не могут подставить исполняемую URL-схему в ссылку или превью.
|
||||
В production HTML административных страниц дополнительно закрыт Caddy Basic Auth, а API независимо проверяет Bearer-токен. Неуспешные попытки API-входа считаются в БД по HMAC-идентификатору адреса и временно блокируются после десяти ошибок за десять минут; успешная авторизация очищает ошибки клиента. Интерфейс открывает только ссылки со схемой `http` или `https`; данные источника не могут подставить исполняемую URL-схему в ссылку или превью.
|
||||
|
||||
Администратор может одобрить, отклонить или удалить сообщение. Удаление очищает ник, комментарий, исходную ссылку и объект скриншота, исключает запись из статистики, но сохраняет обезличенный факт действия в журнале аудита.
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@
|
||||
|
||||
### Административная панель
|
||||
|
||||
- [ ] **M01 · Усиление административного входа — в работе.** Сохранён двойной барьер Caddy Basic Auth + API Bearer; внешние ссылки ограничены `http/https`, UI завершает сессию после 15 минут бездействия, предоставляет явный выход и возвращает вход после `401`. До перехода к аккаунтам Bearer-токен не сохраняется в URL, cookie или browser storage. Осталось добавить ограничение частоты неуспешной API-авторизации.
|
||||
- [x] **M01 · Усиление административного входа.** Сохранён двойной барьер Caddy Basic Auth + API Bearer; внешние ссылки ограничены `http/https`, UI завершает сессию после 15 минут бездействия, предоставляет явный выход и возвращает вход после `401`. Неуспешная API-авторизация ограничена постоянным счётчиком по HMAC-идентификатору клиента с учётом доверенного proxy; успешный вход очищает ошибки клиента. Bearer-токен не сохраняется в URL, cookie или browser storage.
|
||||
- [ ] **M02 · Единый dashboard.** Добавить `/admin` со счётчиками pending-уловов, staging-наблюдений, ошибками и свежестью источников, последними импортами и быстрыми переходами в очереди. Не выводить секреты, внутренние URL и полные тексты исключений.
|
||||
- [ ] **M03 · Эффективность очередей.** Добавить фильтры по источнику, рыбе, водоёму, полноте и возрасту, сортировку по риску, понятное подтверждение результата, восстановление черновика причины после сетевой ошибки, переход к следующей карточке и доступные горячие клавиши.
|
||||
- [ ] **M04 · Полный provenance и история решений.** Перед публикацией показывать исходные поля, источник, время наблюдения, missing fields и результат сопоставления. Добавить read-only журнал модерации и экспорт без персональных данных.
|
||||
|
||||
Reference in New Issue
Block a user