Files
darko fcafe1ae15 feat: аккаунты по почте с подтверждением, сбросом пароля и синхронизацией прогресса
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.
2026-10-16 11:25:09 +03:00

242 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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