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
+1 -1
View File
@@ -244,7 +244,7 @@ curl -H "Authorization: Bearer change-me-in-production" \
Очередь модерации доступна по адресу <http://localhost:4321/admin/moderation>. Администратор вводит `ADMIN_TOKEN`; интерфейс держит его только в памяти открытой страницы и не сохраняет в URL или браузерном хранилище. После 15 минут бездействия или ответа API `401` сессия очищается; также доступен явный выход. Очередь модерации доступна по адресу <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")
+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" rf4map_point_url: str = "https://rf4map.ru/points/275"
rf4posts_spot_url: str = "https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee" rf4posts_spot_url: str = "https://rf4-posts.com/ru/spots/d0c6d9c6-4ebf-49a7-98a8-9a562553a8ee"
rate_limit_secret: str = "change-rate-limit-secret" 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" log_level: str = "INFO"
cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:4321", "http://127.0.0.1:4321"]) 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"]) 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.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from .admin_security import verify_admin
from .config import settings from .config import settings
from .dependencies import Db from .dependencies import Db
from .community_review import ExternalReviewError, map_observation, publish_observation, reject_observation, suggest_aliases 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) app.include_router(public_data_router)
def _admin(authorization: Annotated[str | None, Header()] = None) -> str: def _admin(request: Request, db: Db, authorization: Annotated[str | None, Header()] = None) -> str:
expected = f"Bearer {settings.admin_token}" return verify_admin(request, db, authorization, settings)
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"
@app.get("/api/v1/admin/diagnostics") @app.get("/api/v1/admin/diagnostics")
+7
View File
@@ -143,6 +143,13 @@ class SubmissionAttempt(Base):
catch_report: Mapped[CatchReport | None] = relationship() 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): class DataSource(Base):
__tablename__ = "data_source" __tablename__ = "data_source"
key: Mapped[str] = mapped_column(String(50), primary_key=True) key: Mapped[str] = mapped_column(String(50), primary_key=True)
+14 -5
View File
@@ -1,7 +1,7 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import hashlib import hashlib
import hmac import hmac
from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_address
from typing import Protocol from typing import Protocol
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
@@ -31,13 +31,22 @@ def is_trusted_proxy(address: str, trusted_cidrs: list[str]) -> bool:
return False 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: def check_rate_limit(request: Request, db: Session, config: RateLimitConfig) -> None:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
cutoff = now - timedelta(minutes=10) cutoff = now - timedelta(minutes=10)
client = request.client.host if request.client else "unknown" client = client_address(request, config.trusted_proxy_cidrs)
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_hash = hmac.new(config.rate_limit_secret.encode(), client.encode(), hashlib.sha256).hexdigest() client_hash = hmac.new(config.rate_limit_secret.encode(), client.encode(), hashlib.sha256).hexdigest()
if db.get_bind().dialect.name == "postgresql": if db.get_bind().dialect.name == "postgresql":
lock_key = int(client_hash[:16], 16) & 0x7FFF_FFFF_FFFF_FFFF 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 sqlalchemy.orm import Session
from app.database import Base from app.database import Base
from app.admin_security import verify_admin
from app.main import _check_rate_limit, _is_trusted_proxy 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: def test_rate_limit_is_persistent_and_does_not_store_raw_client() -> None:
+1 -1
View File
@@ -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 и полные тексты исключений. - [ ] **M02 · Единый dashboard.** Добавить `/admin` со счётчиками pending-уловов, staging-наблюдений, ошибками и свежестью источников, последними импортами и быстрыми переходами в очереди. Не выводить секреты, внутренние URL и полные тексты исключений.
- [ ] **M03 · Эффективность очередей.** Добавить фильтры по источнику, рыбе, водоёму, полноте и возрасту, сортировку по риску, понятное подтверждение результата, восстановление черновика причины после сетевой ошибки, переход к следующей карточке и доступные горячие клавиши. - [ ] **M03 · Эффективность очередей.** Добавить фильтры по источнику, рыбе, водоёму, полноте и возрасту, сортировку по риску, понятное подтверждение результата, восстановление черновика причины после сетевой ошибки, переход к следующей карточке и доступные горячие клавиши.
- [ ] **M04 · Полный provenance и история решений.** Перед публикацией показывать исходные поля, источник, время наблюдения, missing fields и результат сопоставления. Добавить read-only журнал модерации и экспорт без персональных данных. - [ ] **M04 · Полный provenance и история решений.** Перед публикацией показывать исходные поля, источник, время наблюдения, missing fields и результат сопоставления. Добавить read-only журнал модерации и экспорт без персональных данных.