diff --git a/README.md b/README.md index 4c0902a..eb21a7a 100644 --- a/README.md +++ b/README.md @@ -244,7 +244,7 @@ curl -H "Authorization: Bearer change-me-in-production" \ Очередь модерации доступна по адресу . Администратор вводит `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-схему в ссылку или превью. Администратор может одобрить, отклонить или удалить сообщение. Удаление очищает ник, комментарий, исходную ссылку и объект скриншота, исключает запись из статистики, но сохраняет обезличенный факт действия в журнале аудита. diff --git a/apps/api/alembic/versions/0014_admin_auth_attempt.py b/apps/api/alembic/versions/0014_admin_auth_attempt.py new file mode 100644 index 0000000..4c0c05f --- /dev/null +++ b/apps/api/alembic/versions/0014_admin_auth_attempt.py @@ -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") diff --git a/apps/api/app/admin_security.py b/apps/api/app/admin_security.py new file mode 100644 index 0000000..13b7d65 --- /dev/null +++ b/apps/api/app/admin_security.py @@ -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" diff --git a/apps/api/app/config.py b/apps/api/app/config.py index b31e73d..736ae79 100644 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -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"]) diff --git a/apps/api/app/main.py b/apps/api/app/main.py index b77114f..557cf7b 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -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") diff --git a/apps/api/app/models.py b/apps/api/app/models.py index 4ef0cce..6cba8c8 100644 --- a/apps/api/app/models.py +++ b/apps/api/app/models.py @@ -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) diff --git a/apps/api/app/submission_security.py b/apps/api/app/submission_security.py index e388972..a476f5e 100644 --- a/apps/api/app/submission_security.py +++ b/apps/api/app/submission_security.py @@ -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 diff --git a/apps/api/tests/test_rate_limit.py b/apps/api/tests/test_rate_limit.py index ec65d46..c1b3ff9 100644 --- a/apps/api/tests/test_rate_limit.py +++ b/apps/api/tests/test_rate_limit.py @@ -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: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 50498b2..7111a47 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 журнал модерации и экспорт без персональных данных.