API аккаунтов на чистой стандартной библиотеке Python (server/splitapi): - регистрация по почте: пароль задаётся по ссылке из письма, аккаунт создаётся только после подтверждения - вход, выход, сброс пароля по почте (токены одноразовые, в базе только SHA-256), удаление аккаунта - пароли scrypt, лимиты попыток по паре «адрес + IP», IP и аккаунту, защита от CSRF - хранение прогресса пользователя с оптимистичной блокировкой версии - SMTP через переменные окружения; без SMTP на публичном адресе API не стартует Клиент: public/auth.js (вход, регистрация, сброс, синхронизация), public/sync.js (чистое слияние прогресса). Без API сайт работает как раньше. Развёртывание: Dockerfile.api, сервис splitapi в docker-compose, проксирование /api/ в nginx с ограничением частоты, deploy/install-api-debian.sh (systemd), .env.example. Тесты: 216 для бэкенда (unittest, покрытие около 97%), 16 для клиентского слияния. Документация: раздел «Аккаунты и почта» в docs/DEPLOY.md, раздел 9 в docs/ИНСТРУКЦИЯ.md.
242 lines
11 KiB
Python
242 lines
11 KiB
Python
"""HTTP-слой: маршруты /api/*, JSON, cookie сессии, защита от CSRF. Только стандартная библиотека."""
|
||
from __future__ import annotations
|
||
|
||
import ipaddress
|
||
import json
|
||
import logging
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from urllib.parse import urlsplit
|
||
|
||
from . import security
|
||
from .config import Config
|
||
from .mailer import MailQueueFull
|
||
from .service import MESSAGES, ApiError, Service
|
||
|
||
log = logging.getLogger("splitapi")
|
||
access = logging.getLogger("splitapi.access")
|
||
|
||
MAX_BODY = 300_000
|
||
CSRF_HEADER = "X-Requested-With"
|
||
CSRF_VALUE = "split-trainer"
|
||
BUSY_RETRY_SECONDS = 5
|
||
|
||
# путь -> {метод: имя обработчика}
|
||
ROUTES: dict[str, dict[str, str]] = {
|
||
"/api/health": {"GET": "health"},
|
||
"/api/register": {"POST": "register"},
|
||
"/api/verify": {"POST": "verify"},
|
||
"/api/login": {"POST": "login"},
|
||
"/api/logout": {"POST": "logout"},
|
||
"/api/me": {"GET": "me"},
|
||
"/api/forgot": {"POST": "forgot"},
|
||
"/api/reset": {"POST": "reset"},
|
||
"/api/delete-account": {"POST": "delete_account"},
|
||
"/api/state": {"GET": "get_state", "PUT": "put_state"},
|
||
}
|
||
|
||
# Откуда принимаем X-Real-IP: тот же список, что в set_real_ip_from у nginx (частные сети и localhost)
|
||
_TRUSTED_PEERS = tuple(
|
||
ipaddress.ip_network(net)
|
||
for net in ("127.0.0.0/8", "::1/128", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16", "fc00::/7", "fe80::/10")
|
||
)
|
||
|
||
|
||
def _parse_ip(raw: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
|
||
try:
|
||
ip = ipaddress.ip_address(raw)
|
||
except ValueError:
|
||
return None
|
||
return ip.ipv4_mapped if ip.version == 6 and ip.ipv4_mapped else ip
|
||
|
||
|
||
def is_trusted_proxy_peer(address: str) -> bool:
|
||
ip = _parse_ip(address)
|
||
return ip is not None and any(ip in net for net in _TRUSTED_PEERS)
|
||
|
||
|
||
def normalize_ip(raw: str) -> str | None:
|
||
"""IPv4 как есть; IPv6 сводим к сети /64: у одного абонента их миллиарды, поэтому счётчик общий."""
|
||
ip = _parse_ip(raw)
|
||
if ip is None:
|
||
return None
|
||
if ip.version == 6:
|
||
return str(ipaddress.ip_network(f"{ip}/64", strict=False))
|
||
return str(ip)
|
||
|
||
|
||
def _forbid_constant(name: str) -> None:
|
||
raise ValueError(f"недопустимая константа {name}")
|
||
|
||
|
||
def make_handler(cfg: Config, service: Service) -> type[BaseHTTPRequestHandler]:
|
||
class Handler(BaseHTTPRequestHandler):
|
||
server_version = "splitapi"
|
||
sys_version = ""
|
||
timeout = 10 # защита от медленных соединений (основную работу делает nginx)
|
||
|
||
# --- вспомогательное ---
|
||
def log_message(self, format: str, *args: object) -> None: # noqa: A002 - сигнатура базового класса
|
||
pass
|
||
|
||
def client_ip(self) -> str:
|
||
peer = self.client_address[0]
|
||
chosen = peer
|
||
if cfg.trust_proxy and is_trusted_proxy_peer(peer): # заголовку верим только от своего прокси
|
||
candidate = (self.headers.get("X-Real-IP") or "").strip()
|
||
if normalize_ip(candidate):
|
||
chosen = candidate
|
||
return normalize_ip(chosen) or peer
|
||
|
||
def session_token(self) -> str | None:
|
||
"""Токен из cookie. Если cookie с таким именем несколько, не верим ни одной (подброс cookie с поддомена)."""
|
||
values = []
|
||
for part in (self.headers.get("Cookie") or "").split(";"):
|
||
name, _, value = part.strip().partition("=")
|
||
if name == cfg.cookie_name:
|
||
values.append(value.strip())
|
||
return values[0] if len(values) == 1 else None
|
||
|
||
def cookie_header(self, token: str, max_age: int) -> str:
|
||
parts = [f"{cfg.cookie_name}={token}", f"Max-Age={max_age}", "Path=/", "HttpOnly", "SameSite=Lax"]
|
||
if cfg.secure_cookie:
|
||
parts.append("Secure")
|
||
return "; ".join(parts)
|
||
|
||
def send_json(self, status: int, body: dict, headers: dict[str, str] | None = None, cookies: list[str] | None = None) -> None:
|
||
payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||
try:
|
||
self.send_response(status)
|
||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||
self.send_header("Content-Length", str(len(payload)))
|
||
self.send_header("Cache-Control", "no-store")
|
||
self.send_header("X-Content-Type-Options", "nosniff")
|
||
self.send_header("Referrer-Policy", "no-referrer")
|
||
for name, value in (headers or {}).items():
|
||
self.send_header(name, value)
|
||
for cookie in cookies or []:
|
||
self.send_header("Set-Cookie", cookie)
|
||
self.end_headers()
|
||
self.wfile.write(payload)
|
||
except OSError:
|
||
self.close_connection = True # клиент ушёл, не дождавшись ответа: это не ошибка сервера
|
||
return
|
||
access.info("%s %s %r -> %s", self.client_ip(), self.command, urlsplit(self.path).path, status)
|
||
|
||
def error(self, err: ApiError) -> None:
|
||
headers = {"Retry-After": str(err.retry_after)} if err.retry_after else None
|
||
self.send_json(err.status, {"error": err.code, "message": err.message, **err.extra}, headers)
|
||
|
||
# --- проверки запроса ---
|
||
def check_csrf(self) -> None:
|
||
origin = self.headers.get("Origin")
|
||
if origin is not None and origin != cfg.origin:
|
||
log.warning("запрос отклонён: Origin %r, ожидался %r (проверьте PUBLIC_URL)", origin, cfg.origin)
|
||
raise ApiError(403, "csrf")
|
||
if self.headers.get(CSRF_HEADER) != CSRF_VALUE:
|
||
raise ApiError(403, "csrf")
|
||
|
||
def read_json(self) -> dict:
|
||
content_type = (self.headers.get("Content-Type") or "").split(";")[0].strip().lower()
|
||
if content_type != "application/json":
|
||
raise ApiError(415, "unsupported_media_type")
|
||
try:
|
||
length = int(self.headers.get("Content-Length") or 0)
|
||
except ValueError:
|
||
raise ApiError(400, "invalid_json") from None
|
||
if length < 0:
|
||
raise ApiError(400, "invalid_json")
|
||
if length > MAX_BODY:
|
||
raise ApiError(413, "payload_too_large")
|
||
raw = self.rfile.read(length) if length else b""
|
||
try:
|
||
data = json.loads(raw.decode("utf-8"), parse_constant=_forbid_constant)
|
||
except (ValueError, RecursionError):
|
||
raise ApiError(400, "invalid_json") from None
|
||
if not isinstance(data, dict):
|
||
raise ApiError(400, "invalid_json")
|
||
return data
|
||
|
||
def require_user(self) -> dict:
|
||
user = service.me(self.session_token())
|
||
if user is None:
|
||
raise ApiError(401, "unauthorized")
|
||
return user
|
||
|
||
# --- обработчики маршрутов ---
|
||
def h_health(self, body: dict | None) -> None:
|
||
self.send_json(200, {"ok": True})
|
||
|
||
def h_register(self, body: dict) -> None:
|
||
self.send_json(200, service.register(body.get("email"), self.client_ip()))
|
||
|
||
def h_verify(self, body: dict) -> None:
|
||
self.send_json(200, service.verify(body.get("token"), body.get("password"), self.client_ip()))
|
||
|
||
def h_login(self, body: dict) -> None:
|
||
token, email = service.login(body.get("email"), body.get("password"), self.client_ip())
|
||
self.send_json(200, {"ok": True, "email": email}, cookies=[self.cookie_header(token, cfg.session_seconds)])
|
||
|
||
def h_logout(self, body: dict) -> None:
|
||
service.logout(self.session_token())
|
||
self.send_json(200, {"ok": True}, cookies=[self.cookie_header("", 0)])
|
||
|
||
def h_me(self, body: dict | None) -> None:
|
||
# Гостю тоже 200 (email: null): иначе каждый заход анонима оставляет «ошибку» в консоли браузера
|
||
user = service.me(self.session_token())
|
||
self.send_json(200, {"email": user["email"] if user else None})
|
||
|
||
def h_forgot(self, body: dict) -> None:
|
||
self.send_json(200, service.forgot(body.get("email"), self.client_ip()))
|
||
|
||
def h_reset(self, body: dict) -> None:
|
||
self.send_json(200, service.reset(body.get("token"), body.get("password"), self.client_ip()))
|
||
|
||
def h_delete_account(self, body: dict) -> None:
|
||
result = service.delete_account(self.session_token(), body.get("password"))
|
||
self.send_json(200, result, cookies=[self.cookie_header("", 0)])
|
||
|
||
def h_get_state(self, body: dict | None) -> None:
|
||
user = self.require_user()
|
||
self.send_json(200, {**service.get_state(user["id"]), "email": user["email"]})
|
||
|
||
def h_put_state(self, body: dict) -> None:
|
||
user = self.require_user()
|
||
self.send_json(200, service.put_state(user["id"], body.get("version"), body.get("data"), user["email"], body.get("email")))
|
||
|
||
# --- маршрутизация ---
|
||
def dispatch(self) -> None:
|
||
self.close_connection = True
|
||
path = urlsplit(self.path).path
|
||
try:
|
||
methods = ROUTES.get(path)
|
||
if methods is None:
|
||
raise ApiError(404, "not_found")
|
||
if self.command not in methods:
|
||
self.send_json(405, {"error": "method_not_allowed", "message": MESSAGES["method_not_allowed"]},
|
||
{"Allow": ", ".join(methods)})
|
||
return
|
||
body = None
|
||
if self.command != "GET":
|
||
self.check_csrf()
|
||
body = self.read_json()
|
||
getattr(self, "h_" + methods[self.command])(body)
|
||
except ApiError as err:
|
||
self.error(err)
|
||
except (security.HashBusy, MailQueueFull):
|
||
self.error(ApiError(503, "busy", retry_after=BUSY_RETRY_SECONDS))
|
||
except (TimeoutError, ConnectionError):
|
||
log.debug("соединение оборвано клиентом: %s %r", self.command, path)
|
||
except Exception:
|
||
log.exception("необработанная ошибка в %s %r", self.command, path)
|
||
self.send_json(500, {"error": "server_error", "message": MESSAGES["server_error"]})
|
||
|
||
do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = do_HEAD = do_OPTIONS = dispatch
|
||
|
||
return Handler
|
||
|
||
|
||
def make_server(cfg: Config, service: Service, host: str | None = None, port: int | None = None) -> ThreadingHTTPServer:
|
||
server = ThreadingHTTPServer((host or cfg.bind, cfg.port if port is None else port), make_handler(cfg, service))
|
||
server.daemon_threads = True # не держим процесс из-за зависших соединений при остановке
|
||
return server
|