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.
This commit is contained in:
darko
2026-10-16 11:25:09 +03:00
parent e897a6c5eb
commit fcafe1ae15
36 changed files with 4520 additions and 22 deletions
+241
View File
@@ -0,0 +1,241 @@
"""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