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
+166
View File
@@ -0,0 +1,166 @@
"""Настройки сервиса из переменных окружения."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Mapping
from urllib.parse import urlsplit
_TRUE = {"1", "true", "yes", "on"}
_FALSE = {"0", "false", "no", "off"}
_SMTP_PORTS = {"starttls": 587, "ssl": 465, "none": 25}
_DEFAULT_PORTS = {"http": 80, "https": 443}
_LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
class ConfigError(ValueError):
"""Настройки заданы неверно: сервис не должен стартовать."""
@dataclass(frozen=True)
class Config:
public_url: str # внешний адрес сайта: из него строятся ссылки в письмах и проверяется Origin
db_path: str = "/data/splitapi.sqlite3"
bind: str = "127.0.0.1"
port: int = 8000
mail_backend: str = "console" # console | smtp
mail_from: str = ""
smtp_host: str = ""
smtp_port: int = 587
smtp_security: str = "starttls" # starttls | ssl | none
smtp_user: str = ""
smtp_password: str = ""
allow_registration: bool = True
trust_proxy: bool = False # доверять X-Real-IP (его выставляет только наш nginx)
session_days: int = 30
verify_ttl: int = 24 * 3600
reset_ttl: int = 3600
@property
def _parts(self):
return urlsplit(self.public_url)
@property
def origin(self) -> str:
return f"{self._parts.scheme}://{self._parts.netloc}"
@property
def base_url(self) -> str:
return self.public_url.rstrip("/") + "/"
@property
def secure_cookie(self) -> bool:
return self._parts.scheme == "https"
@property
def cookie_name(self) -> str:
# Префикс __Host- (только при https): браузер не примет такую cookie с соседнего поддомена и без Secure
return "__Host-session" if self.secure_cookie else "session"
@property
def session_seconds(self) -> int:
return self.session_days * 86400
def _bool(env: Mapping[str, str], key: str, default: bool) -> bool:
raw = (env.get(key) or "").strip().lower()
if not raw:
return default
if raw in _TRUE:
return True
if raw in _FALSE:
return False
raise ConfigError(f"{key}: ожидалось true/false, получено {raw!r}")
def _int(env: Mapping[str, str], key: str, default: int, low: int, high: int) -> int:
raw = (env.get(key) or "").strip()
if not raw:
return default
try:
value = int(raw)
except ValueError:
raise ConfigError(f"{key}: ожидалось целое число") from None
if not low <= value <= high:
raise ConfigError(f"{key}: допустимы значения от {low} до {high}")
return value
def _public_url(raw: str) -> str:
"""Приводит адрес к виду, в котором браузер присылает Origin: схема и хост в нижнем регистре, без порта по умолчанию."""
parts = urlsplit(raw.strip())
if parts.scheme not in ("http", "https") or not parts.hostname or parts.query or parts.fragment or parts.username or parts.password:
raise ConfigError("PUBLIC_URL: нужен адрес вида https://example.com или https://example.com/путь, без ?, # и имени пользователя")
try:
port = parts.port
except ValueError:
raise ConfigError("PUBLIC_URL: некорректный порт") from None
try:
hostname = parts.hostname.encode("idna").decode("ascii") # браузер присылает Origin с punycode
except UnicodeError:
raise ConfigError("PUBLIC_URL: некорректное имя домена") from None
host = f"[{hostname}]" if ":" in hostname else hostname
netloc = host if port in (None, _DEFAULT_PORTS[parts.scheme]) else f"{host}:{port}"
return f"{parts.scheme}://{netloc}{parts.path.rstrip('/')}"
def is_local_host(hostname: str) -> bool:
return hostname in _LOCAL_HOSTS or hostname.endswith(".localhost")
def _mail_backend(env: Mapping[str, str], smtp_host: str, hostname: str) -> str:
explicit = (env.get("MAIL_BACKEND") or "").strip().lower()
if explicit:
if explicit not in ("console", "smtp"):
raise ConfigError("MAIL_BACKEND: допустимы console или smtp")
if explicit == "smtp" and not smtp_host:
raise ConfigError("MAIL_BACKEND=smtp требует SMTP_HOST")
return explicit
if smtp_host:
return "smtp"
if is_local_host(hostname):
return "console" # локальная проверка: письма печатаются в лог
raise ConfigError(
"SMTP не настроен: задайте SMTP_HOST (и SMTP_USER/SMTP_PASSWORD) либо явно MAIL_BACKEND=console. "
"Режим console только для разработки: ссылки подтверждения и сброса пароля тогда печатаются в лог, "
"и любой, кто читает логи, может захватить аккаунт."
)
def _smtp_password(env: Mapping[str, str]) -> str:
path = (env.get("SMTP_PASSWORD_FILE") or "").strip()
if not path:
return env.get("SMTP_PASSWORD", "")
try:
with open(path, encoding="utf-8") as handle:
return handle.read().rstrip("\r\n")
except OSError as exc:
raise ConfigError(f"SMTP_PASSWORD_FILE: не удалось прочитать файл ({exc.strerror})") from None
def from_env(env: Mapping[str, str]) -> Config:
public_url = _public_url(env.get("PUBLIC_URL") or "")
hostname = urlsplit(public_url).hostname or "localhost"
smtp_host = (env.get("SMTP_HOST") or "").strip()
backend = _mail_backend(env, smtp_host, hostname)
security = (env.get("SMTP_SECURITY") or "starttls").strip().lower()
if security not in _SMTP_PORTS:
raise ConfigError("SMTP_SECURITY: допустимы starttls, ssl или none")
return Config(
public_url=public_url,
db_path=env.get("DB_PATH") or "/data/splitapi.sqlite3",
bind=env.get("BIND") or "127.0.0.1",
port=_int(env, "PORT", 8000, 1, 65535),
mail_backend=backend,
mail_from=(env.get("MAIL_FROM") or "").strip() or f"Сплит-тренер <noreply@{hostname}>",
smtp_host=smtp_host,
smtp_port=_int(env, "SMTP_PORT", _SMTP_PORTS[security], 1, 65535),
smtp_security=security,
smtp_user=env.get("SMTP_USER", ""),
smtp_password=_smtp_password(env),
allow_registration=_bool(env, "ALLOW_REGISTRATION", True),
trust_proxy=_bool(env, "TRUST_PROXY", False),
session_days=_int(env, "SESSION_DAYS", 30, 1, 365),
)