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:
@@ -0,0 +1 @@
|
||||
"""Минимальный API авторизации для Сплит-тренера (только стандартная библиотека Python)."""
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Запуск: python -m splitapi (настройки из переменных окружения, см. docs/DEPLOY.md)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import urllib.request
|
||||
|
||||
from . import security
|
||||
from .config import ConfigError, from_env, is_local_host
|
||||
from .http_api import make_server
|
||||
from .mailer import AsyncMailer, make_mailer
|
||||
from .ratelimit import RateLimiter
|
||||
from .service import Service
|
||||
from .store import Store
|
||||
|
||||
log = logging.getLogger("splitapi")
|
||||
|
||||
|
||||
def healthcheck() -> int:
|
||||
"""Для HEALTHCHECK контейнера: в образе нет curl, поэтому проверяем сами."""
|
||||
port = os.environ.get("PORT") or "8000"
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=3) as response:
|
||||
return 0 if response.status == 200 else 1
|
||||
except OSError:
|
||||
return 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
if "--healthcheck" in sys.argv:
|
||||
return healthcheck()
|
||||
try:
|
||||
cfg = from_env(os.environ)
|
||||
except ConfigError as exc:
|
||||
log.critical("Неверные настройки: %s", exc)
|
||||
return 2
|
||||
|
||||
if cfg.mail_backend == "console":
|
||||
log.warning("SMTP не настроен: письма (со ссылками подтверждения и сброса) будут печататься в этот лог. Только для разработки!")
|
||||
elif cfg.smtp_security == "none" and not is_local_host(cfg.smtp_host):
|
||||
log.warning("SMTP_SECURITY=none: письма и пароль SMTP уходят на %s без шифрования. Используйте starttls или ssl.", cfg.smtp_host)
|
||||
security.verify_dummy("прогрев") # первый вход с несуществующей почтой не должен выдавать себя лишней задержкой
|
||||
mailer = make_mailer(cfg)
|
||||
service = Service(cfg, Store(cfg.db_path), mailer, RateLimiter())
|
||||
server = make_server(cfg, service)
|
||||
|
||||
def stop(signum: int, _frame: object) -> None:
|
||||
log.info("получен сигнал %s, останавливаюсь", signum)
|
||||
threading.Thread(target=server.shutdown, daemon=True).start()
|
||||
|
||||
signal.signal(signal.SIGTERM, stop)
|
||||
signal.signal(signal.SIGINT, stop)
|
||||
log.info("API слушает %s:%s, сайт: %s", cfg.bind, cfg.port, cfg.public_url)
|
||||
try:
|
||||
server.serve_forever()
|
||||
finally:
|
||||
server.server_close()
|
||||
if isinstance(mailer, AsyncMailer):
|
||||
mailer.flush(timeout=5)
|
||||
mailer.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Отправка писем: SMTP, вывод в лог (для разработки), память (для тестов), фоновая очередь."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import queue
|
||||
import smtplib
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formatdate, make_msgid, parseaddr
|
||||
from typing import Protocol
|
||||
|
||||
from .config import Config
|
||||
|
||||
log = logging.getLogger("splitapi.mail")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Mail:
|
||||
to: str
|
||||
subject: str
|
||||
body: str
|
||||
|
||||
|
||||
class Mailer(Protocol):
|
||||
def send(self, mail: Mail) -> None: ...
|
||||
|
||||
|
||||
class MailQueueFull(RuntimeError):
|
||||
"""Очередь отправки переполнена: письмо не принято (API ответит «сервер занят», а не «проверьте почту»)."""
|
||||
|
||||
|
||||
def _plural(n: int, one: str, few: str, many: str) -> str:
|
||||
if 11 <= n % 100 <= 14:
|
||||
return many
|
||||
return one if n % 10 == 1 else few if 2 <= n % 10 <= 4 else many
|
||||
|
||||
|
||||
def human_duration(seconds: int) -> str:
|
||||
"""«24 часа», «3 дня», «30 минут»: срок действия ссылки в тексте письма берётся из настроек."""
|
||||
if seconds >= 2 * 86400 and seconds % 86400 == 0:
|
||||
n = seconds // 86400
|
||||
return f"{n} {_plural(n, 'день', 'дня', 'дней')}"
|
||||
if seconds >= 3600 and seconds % 3600 == 0:
|
||||
n = seconds // 3600
|
||||
return f"{n} {_plural(n, 'час', 'часа', 'часов')}"
|
||||
n = max(1, round(seconds / 60))
|
||||
return f"{n} {_plural(n, 'минута', 'минуты', 'минут')}"
|
||||
|
||||
|
||||
# --- шаблоны писем ---
|
||||
def verify_mail(cfg: Config, to: str, token: str) -> Mail:
|
||||
# Токен во фрагменте (#): он не уходит на сервер, не попадает в логи и заголовок Referer.
|
||||
link = f"{cfg.base_url}#verify={token}"
|
||||
return Mail(
|
||||
to,
|
||||
"Подтвердите почту в Сплит-тренере",
|
||||
"Здравствуйте!\n\nЧтобы завершить регистрацию в Сплит-тренере, подтвердите адрес почты и задайте пароль. "
|
||||
f"Откройте ссылку:\n\n{link}\n\nСсылка действует {human_duration(cfg.verify_ttl)} и работает один раз.\n\n"
|
||||
"Если вы не регистрировались, просто проигнорируйте это письмо: без перехода по ссылке учётная запись не создаётся.\n",
|
||||
)
|
||||
|
||||
|
||||
def reset_mail(cfg: Config, to: str, token: str) -> Mail:
|
||||
link = f"{cfg.base_url}#reset={token}"
|
||||
return Mail(
|
||||
to,
|
||||
"Сброс пароля в Сплит-тренере",
|
||||
"Здравствуйте!\n\nМы получили запрос на сброс пароля. Чтобы задать новый пароль, откройте ссылку:\n\n"
|
||||
f"{link}\n\nСсылка действует {human_duration(cfg.reset_ttl)} и работает один раз.\n\n"
|
||||
"Если вы не запрашивали сброс, проигнорируйте это письмо: пароль останется прежним.\n",
|
||||
)
|
||||
|
||||
|
||||
def already_registered_mail(cfg: Config, to: str) -> Mail:
|
||||
return Mail(
|
||||
to,
|
||||
"Вы уже зарегистрированы в Сплит-тренере",
|
||||
"Здравствуйте!\n\nКто-то (возможно, вы) пытался зарегистрировать этот адрес, но он уже есть в системе.\n\n"
|
||||
f"Если это были вы и вы забыли пароль, на странице входа нажмите «Забыли пароль?»: {cfg.base_url}\n\n"
|
||||
"Если это были не вы, ничего делать не нужно.\n",
|
||||
)
|
||||
|
||||
|
||||
def password_changed_mail(cfg: Config, to: str) -> Mail:
|
||||
return Mail(
|
||||
to,
|
||||
"Пароль в Сплит-тренере изменён",
|
||||
"Здравствуйте!\n\nПароль вашей учётной записи изменён, все прежние входы завершены.\n\n"
|
||||
f"Если это были не вы, срочно запросите сброс пароля: {cfg.base_url}\n",
|
||||
)
|
||||
|
||||
|
||||
def build_message(mail: Mail, from_addr: str) -> EmailMessage:
|
||||
"""Собирает письмо. Переводы строк в заголовках запрещены самим EmailMessage (ValueError): инъекция невозможна."""
|
||||
msg = EmailMessage()
|
||||
msg["From"] = from_addr
|
||||
msg["To"] = mail.to
|
||||
msg["Subject"] = mail.subject
|
||||
msg["Date"] = formatdate()
|
||||
msg["Message-ID"] = make_msgid(domain=parseaddr(from_addr)[1].rpartition("@")[2] or "localhost")
|
||||
msg.set_content(mail.body)
|
||||
return msg
|
||||
|
||||
|
||||
# --- способы доставки ---
|
||||
class MemoryMailer:
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[Mail] = []
|
||||
|
||||
def send(self, mail: Mail) -> None:
|
||||
self.sent.append(mail)
|
||||
|
||||
|
||||
class ConsoleMailer:
|
||||
"""Только для разработки: письмо с ссылкой попадает в лог. Не используйте на боевом сервере."""
|
||||
|
||||
def send(self, mail: Mail) -> None:
|
||||
log.warning("[DEV: SMTP не настроен] письмо для %s\nТема: %s\n%s", mail.to, mail.subject, mail.body)
|
||||
|
||||
|
||||
class SmtpMailer:
|
||||
def __init__(self, cfg: Config) -> None:
|
||||
self._cfg = cfg
|
||||
|
||||
def send(self, mail: Mail) -> None:
|
||||
cfg = self._cfg
|
||||
msg = build_message(mail, cfg.mail_from)
|
||||
if cfg.smtp_security == "ssl":
|
||||
client = smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port, timeout=15, context=ssl.create_default_context())
|
||||
else:
|
||||
client = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=15)
|
||||
with client as server:
|
||||
if cfg.smtp_security == "starttls":
|
||||
server.starttls(context=ssl.create_default_context())
|
||||
if cfg.smtp_user:
|
||||
server.login(cfg.smtp_user, cfg.smtp_password)
|
||||
server.send_message(msg)
|
||||
|
||||
|
||||
class AsyncMailer:
|
||||
"""Отправка в фоне: ответ API не ждёт SMTP, а время ответа не выдаёт, существует ли адрес."""
|
||||
|
||||
def __init__(self, inner: Mailer, maxsize: int = 100) -> None:
|
||||
self._inner = inner
|
||||
self._queue: queue.Queue[Mail | None] = queue.Queue(maxsize=maxsize)
|
||||
self._thread = threading.Thread(target=self._run, name="mailer", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def send(self, mail: Mail) -> None:
|
||||
try:
|
||||
self._queue.put_nowait(mail)
|
||||
except queue.Full:
|
||||
log.error("очередь писем переполнена, письмо не принято (домен получателя: %s)", mail.to.rpartition("@")[2])
|
||||
raise MailQueueFull from None
|
||||
|
||||
def _run(self) -> None:
|
||||
while True:
|
||||
mail = self._queue.get()
|
||||
try:
|
||||
if mail is None:
|
||||
return
|
||||
self._inner.send(mail)
|
||||
except Exception as exc: # одно неудачное письмо не должно останавливать отправку остальных
|
||||
# Текст исключения не пишем: в нём бывает адрес получателя (например, при отказе SMTP)
|
||||
log.error("не удалось отправить письмо (домен получателя: %s): %s", mail.to.rpartition("@")[2], type(exc).__name__)
|
||||
finally:
|
||||
self._queue.task_done()
|
||||
|
||||
def flush(self, timeout: float = 10) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while self._queue.unfinished_tasks and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self._queue.put(None, timeout=1)
|
||||
except queue.Full:
|
||||
return
|
||||
self._thread.join(timeout=5)
|
||||
|
||||
|
||||
def make_mailer(cfg: Config) -> Mailer:
|
||||
if cfg.mail_backend == "smtp":
|
||||
return AsyncMailer(SmtpMailer(cfg))
|
||||
return ConsoleMailer()
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Ограничитель частоты запросов со скользящим окном (в памяти одного процесса)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict, deque
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
def __init__(self, clock: Callable[[], float] = time.monotonic, max_keys: int = 50_000, purge_interval: int = 60) -> None:
|
||||
self._clock = clock
|
||||
self._max_keys = max_keys
|
||||
self._purge_interval = purge_interval
|
||||
self._last_purge = float("-inf")
|
||||
self._lock = threading.Lock()
|
||||
self._hits: OrderedDict[str, tuple[deque[float], int]] = OrderedDict()
|
||||
|
||||
def check(self, key: str, limit: int, window: int) -> tuple[bool, int]:
|
||||
"""Учитывает попытку. Возвращает (разрешено, через сколько секунд повторить)."""
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
entry = self._hits.get(key)
|
||||
hits = entry[0] if entry else deque()
|
||||
while hits and hits[0] <= now - window:
|
||||
hits.popleft()
|
||||
if len(hits) >= limit:
|
||||
self._hits[key] = (hits, window)
|
||||
return False, max(1, math.ceil(hits[0] + window - now))
|
||||
hits.append(now)
|
||||
self._hits[key] = (hits, window)
|
||||
self._hits.move_to_end(key)
|
||||
self._evict(now)
|
||||
return True, 0
|
||||
|
||||
def is_blocked(self, key: str, limit: int, window: int) -> tuple[bool, int]:
|
||||
"""Проверяет лимит, не засчитывая попытку (вместе с record() считает только неудачи)."""
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
entry = self._hits.get(key)
|
||||
hits = entry[0] if entry else deque()
|
||||
while hits and hits[0] <= now - window:
|
||||
hits.popleft()
|
||||
if len(hits) >= limit:
|
||||
return True, max(1, math.ceil(hits[0] + window - now))
|
||||
return False, 0
|
||||
|
||||
def record(self, key: str, window: int) -> None:
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
entry = self._hits.get(key)
|
||||
hits = entry[0] if entry else deque()
|
||||
hits.append(now)
|
||||
self._hits[key] = (hits, window)
|
||||
self._hits.move_to_end(key)
|
||||
self._evict(now)
|
||||
|
||||
def forgive(self, key: str) -> None:
|
||||
"""Возвращает последнюю учтённую попытку (успешный вход не должен приближать блокировку)."""
|
||||
with self._lock:
|
||||
entry = self._hits.get(key)
|
||||
if entry and entry[0]:
|
||||
entry[0].pop()
|
||||
|
||||
def _evict(self, now: float) -> None:
|
||||
if len(self._hits) <= self._max_keys:
|
||||
return
|
||||
# Полный проход по ключам дорог, поэтому не чаще раза в purge_interval; иначе просто вытесняем самые старые.
|
||||
if now - self._last_purge >= self._purge_interval:
|
||||
self._last_purge = now
|
||||
for key in [k for k, (hits, window) in self._hits.items() if not hits or hits[-1] <= now - window]:
|
||||
del self._hits[key]
|
||||
while len(self._hits) > self._max_keys:
|
||||
self._hits.popitem(last=False)
|
||||
|
||||
def size(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._hits)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Примитивы безопасности: проверка почты и паролей, хеширование (scrypt), токены."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import unicodedata
|
||||
|
||||
# Параметры scrypt (OWASP допускает N=2^15, r=8, p=1 как разумный минимум для небольшого сервера).
|
||||
SCRYPT_N = 2**15
|
||||
SCRYPT_R = 8
|
||||
SCRYPT_P = 1
|
||||
_DKLEN = 32
|
||||
_SALT_BYTES = 16
|
||||
_MAX_MEM = 256 * 1024 * 1024
|
||||
|
||||
PASSWORD_MIN = 8
|
||||
PASSWORD_MAX = 128 # верхняя граница ограничивает стоимость хеширования для атакующего
|
||||
_EMAIL_MAX = 254
|
||||
|
||||
# Не больше нескольких одновременных хешей: каждый занимает десятки МБ памяти.
|
||||
SLOT_WAIT = 5.0 # сколько секунд ждать свободный слот, прежде чем ответить «сервер занят»
|
||||
_slots = threading.BoundedSemaphore(4)
|
||||
|
||||
|
||||
class HashBusy(RuntimeError):
|
||||
"""Все слоты хеширования заняты: лучше быстро отказать, чем копить зависшие запросы."""
|
||||
|
||||
# fullmatch, а не match с $: $ срабатывает и перед завершающим переводом строки.
|
||||
# В адресе допустимы только безопасные символы: % | / ! # $ & * = ? ^ ` { } ~ формально валидны по RFC,
|
||||
# но опасны для старых почтовых систем (переадресация через %, выполнение через |).
|
||||
_LOCAL_RE = re.compile(r"[a-z0-9._+'-]{1,64}")
|
||||
_LABEL_RE = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?")
|
||||
_TOKEN_RE = re.compile(r"[A-Za-z0-9_-]{16,100}")
|
||||
|
||||
_COMMON_PASSWORDS = frozenset(
|
||||
{
|
||||
"password", "password1", "password123", "12345678", "123456789", "1234567890", "12341234",
|
||||
"00000000", "11111111", "qwertyui", "qwertyuiop", "qwerty123", "iloveyou", "admin123",
|
||||
"letmein1", "abc12345", "пароль123", "йцукенгш", "qazwsxedc", "1q2w3e4r",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def normalize_email(raw: object) -> str | None:
|
||||
"""Приводит адрес к каноническому виду (нижний регистр, домен в punycode) или возвращает None."""
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
email = raw.strip().lower()
|
||||
if not email or len(email) > _EMAIL_MAX or email.count("@") != 1:
|
||||
return None
|
||||
local, domain = email.split("@")
|
||||
if not _LOCAL_RE.fullmatch(local) or local.startswith(".") or local.endswith(".") or ".." in local:
|
||||
return None
|
||||
try:
|
||||
ascii_domain = domain.encode("idna").decode("ascii")
|
||||
except UnicodeError:
|
||||
return None
|
||||
labels = ascii_domain.split(".")
|
||||
if len(ascii_domain) > 253 or len(labels) < 2 or len(labels[-1]) < 2:
|
||||
return None
|
||||
if not all(_LABEL_RE.fullmatch(label) for label in labels):
|
||||
return None
|
||||
return f"{local}@{ascii_domain}"
|
||||
|
||||
|
||||
def validate_password(password: object, email: str) -> str | None:
|
||||
"""Возвращает код ошибки или None, если пароль допустим."""
|
||||
if not isinstance(password, str) or len(password) < PASSWORD_MIN:
|
||||
return "password_too_short"
|
||||
if len(password) > PASSWORD_MAX:
|
||||
return "password_too_long"
|
||||
try:
|
||||
password.encode("utf-8")
|
||||
except UnicodeEncodeError:
|
||||
return "password_invalid" # одиночный суррогат: json его пропускает, а хешер упал бы
|
||||
lowered = password.lower()
|
||||
local = email.split("@")[0].lower()
|
||||
if lowered in (email.lower(), local) or lowered in _COMMON_PASSWORDS:
|
||||
return "password_weak"
|
||||
return None
|
||||
|
||||
|
||||
def _prepare(password: str) -> bytes:
|
||||
return unicodedata.normalize("NFKC", password).encode("utf-8")
|
||||
|
||||
|
||||
def _derive(password: str, salt: bytes, n: int, r: int, p: int) -> bytes:
|
||||
if not _slots.acquire(timeout=SLOT_WAIT):
|
||||
raise HashBusy("слишком много одновременных проверок пароля")
|
||||
try:
|
||||
return hashlib.scrypt(_prepare(password), salt=salt, n=n, r=r, p=p, dklen=_DKLEN, maxmem=_MAX_MEM)
|
||||
finally:
|
||||
_slots.release()
|
||||
|
||||
|
||||
def hash_password(password: str, n: int | None = None) -> str:
|
||||
"""Формат: scrypt$N$r$p$соль$хеш (параметры внутри позволяют позже усилить хеширование)."""
|
||||
n = n or SCRYPT_N
|
||||
salt = secrets.token_bytes(_SALT_BYTES)
|
||||
digest = _derive(password, salt, n, SCRYPT_R, SCRYPT_P)
|
||||
return "$".join(
|
||||
["scrypt", str(n), str(SCRYPT_R), str(SCRYPT_P), base64.b64encode(salt).decode(), base64.b64encode(digest).decode()]
|
||||
)
|
||||
|
||||
|
||||
def _parse(stored: str) -> tuple[int, int, int, bytes, bytes] | None:
|
||||
try:
|
||||
scheme, n, r, p, salt, digest = stored.split("$")
|
||||
if scheme != "scrypt":
|
||||
return None
|
||||
n_i, r_i, p_i = int(n), int(r), int(p)
|
||||
# Защита от подменённых параметров: не даём заставить сервер считать гигабайтный хеш
|
||||
if not (2**10 <= n_i <= 2**20 and n_i & (n_i - 1) == 0 and 1 <= r_i <= 16 and 1 <= p_i <= 4):
|
||||
return None
|
||||
return n_i, r_i, p_i, base64.b64decode(salt, validate=True), base64.b64decode(digest, validate=True)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def verify_password(stored: str, password: str) -> bool:
|
||||
parsed = _parse(stored) if isinstance(stored, str) else None
|
||||
if parsed is None:
|
||||
return False
|
||||
n, r, p, salt, expected = parsed
|
||||
try:
|
||||
actual = _derive(password, salt, n, r, p)
|
||||
except (ValueError, MemoryError):
|
||||
return False
|
||||
return hmac.compare_digest(actual, expected)
|
||||
|
||||
|
||||
def needs_rehash(stored: str) -> bool:
|
||||
parsed = _parse(stored)
|
||||
return parsed is None or parsed[:3] != (SCRYPT_N, SCRYPT_R, SCRYPT_P)
|
||||
|
||||
|
||||
_dummy_lock = threading.Lock()
|
||||
_dummy_hash: str | None = None
|
||||
|
||||
|
||||
def verify_dummy(password: str) -> bool:
|
||||
"""Тратит столько же времени, как проверка настоящего пароля: ответ на вход не выдаёт, есть ли такая почта."""
|
||||
global _dummy_hash
|
||||
with _dummy_lock:
|
||||
if _dummy_hash is None:
|
||||
_dummy_hash = hash_password(secrets.token_urlsafe(16))
|
||||
verify_password(_dummy_hash, password)
|
||||
return False
|
||||
|
||||
|
||||
def new_token() -> tuple[str, str]:
|
||||
"""Возвращает (токен для пользователя, его SHA-256 для базы): в базе токены в открытом виде не хранятся."""
|
||||
raw = secrets.token_urlsafe(32)
|
||||
return raw, hashlib.sha256(raw.encode("ascii")).hexdigest()
|
||||
|
||||
|
||||
def hash_token(raw: object) -> str | None:
|
||||
if not isinstance(raw, str) or not _TOKEN_RE.fullmatch(raw):
|
||||
return None
|
||||
return hashlib.sha256(raw.encode("ascii")).hexdigest()
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Бизнес-логика: регистрация с подтверждением почты, вход, сброс пароля, удаление аккаунта, хранение прогресса.
|
||||
|
||||
Принципы: ответы для существующих и несуществующих адресов одинаковы, токены одноразовые и хранятся только
|
||||
в виде хеша, пароль хешируется scrypt и задаётся только после подтверждения почты, частота попыток ограничена.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from . import mailer as templates
|
||||
from . import security
|
||||
from .config import Config
|
||||
from .mailer import MailQueueFull, Mailer
|
||||
from .ratelimit import RateLimiter
|
||||
from .statevalid import StateError, clean_state
|
||||
from .store import Store
|
||||
|
||||
log = logging.getLogger("splitapi")
|
||||
|
||||
MAX_STATE_BYTES = 300_000
|
||||
PASSWORD_INPUT_MAX = 1024
|
||||
PURGE_INTERVAL = 300 # как часто чистить просроченные записи (секунды), а не на каждом запросе
|
||||
|
||||
# Ограничения частоты: имя -> (сколько попыток, за сколько секунд)
|
||||
LIMITS: dict[str, tuple[int, int]] = {
|
||||
"register_ip": (5, 3600),
|
||||
"register_email": (3, 3600), # молча: письмо просто не отправляется
|
||||
"verify_ip": (20, 600),
|
||||
"login_ip": (20, 600), # считаются только неудачные входы (успешный вход возвращает попытку)
|
||||
"login_pair": (5, 600), # неудачи для пары «адрес + IP»: чужой IP не блокирует владельца
|
||||
"login_email": (100, 3600), # общий потолок на аккаунт против распределённого подбора (с запасом для владельца)
|
||||
"forgot_ip": (5, 3600),
|
||||
"forgot_email": (3, 3600), # молча
|
||||
"reset_ip": (10, 600),
|
||||
"delete_user": (5, 600),
|
||||
"state_user": (60, 60),
|
||||
}
|
||||
|
||||
MESSAGES = {
|
||||
"invalid_email": "Введите корректный адрес почты.",
|
||||
"password_too_short": "Пароль должен быть не короче 8 символов.",
|
||||
"password_too_long": "Пароль слишком длинный: не более 128 символов.",
|
||||
"password_weak": "Пароль слишком простой: выберите другой.",
|
||||
"password_invalid": "Пароль содержит недопустимые символы.",
|
||||
"rate_limited": "Слишком много попыток. Повторите позже.",
|
||||
"busy": "Сервер сейчас занят. Повторите через несколько секунд.",
|
||||
"registration_closed": "Регистрация на этом сервере закрыта.",
|
||||
"invalid_credentials": "Неверная почта или пароль. Если вы только зарегистрировались, подтвердите почту по ссылке из письма.",
|
||||
"wrong_password": "Неверный пароль.",
|
||||
"invalid_token": "Ссылка недействительна или устарела. Запросите новую.",
|
||||
"unauthorized": "Нужно войти в аккаунт.",
|
||||
"invalid_state": "Некорректные данные прогресса.",
|
||||
"version_conflict": "На сервере более новая версия прогресса.",
|
||||
"account_mismatch": "В другой вкладке выполнен вход в другой аккаунт. Страница будет обновлена.",
|
||||
"csrf": "Запрос отклонён.",
|
||||
"invalid_json": "Некорректный запрос.",
|
||||
"payload_too_large": "Слишком большой запрос.",
|
||||
"unsupported_media_type": "Ожидался JSON.",
|
||||
"not_found": "Не найдено.",
|
||||
"method_not_allowed": "Метод не поддерживается.",
|
||||
"server_error": "Внутренняя ошибка сервера.",
|
||||
}
|
||||
|
||||
REGISTER_OK = {"ok": True, "message": "Проверьте почту: мы отправили письмо со ссылкой. По ней вы подтвердите адрес и задайте пароль. Если письма нет, загляните в «Спам»."}
|
||||
FORGOT_OK = {"ok": True, "message": "Если такой адрес зарегистрирован, мы отправили на него письмо со ссылкой для сброса пароля."}
|
||||
VERIFY_OK = {"ok": True, "message": "Аккаунт создан. Теперь можно войти."}
|
||||
RESET_OK = {"ok": True, "message": "Пароль изменён. Войдите с новым паролем."}
|
||||
DELETE_OK = {"ok": True, "message": "Аккаунт и прогресс на сервере удалены."}
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
def __init__(self, status: int, code: str, retry_after: int | None = None, extra: dict | None = None) -> None:
|
||||
super().__init__(code)
|
||||
self.status = status
|
||||
self.code = code
|
||||
self.message = MESSAGES.get(code, code)
|
||||
self.retry_after = retry_after
|
||||
self.extra = extra or {}
|
||||
|
||||
|
||||
def _key(name: str, value: str) -> str:
|
||||
return f"{name}:{hashlib.sha256(value.encode('utf-8', 'replace')).hexdigest()[:16]}"
|
||||
|
||||
|
||||
class Service:
|
||||
def __init__(self, cfg: Config, store: Store, mailer: Mailer, limiter: RateLimiter, clock: Callable[[], float] = time.time) -> None:
|
||||
self._cfg = cfg
|
||||
self._store = store
|
||||
self._mailer = mailer
|
||||
self._limiter = limiter
|
||||
self._clock = clock
|
||||
self._last_purge = 0.0
|
||||
|
||||
# --- ограничения и обслуживание ---
|
||||
def _reserve(self, *items: tuple[str, str]) -> list[str]:
|
||||
"""Занимает попытку во всех счётчиках ДО дорогой проверки пароля (иначе параллельные запросы обходят лимит).
|
||||
Успех возвращает попытки через _release, поэтому в счётчиках остаются только неудачи."""
|
||||
taken: list[str] = []
|
||||
for name, ident in items:
|
||||
key = _key(name, ident)
|
||||
allowed, retry = self._limiter.check(key, *LIMITS[name])
|
||||
if not allowed:
|
||||
self._release(taken)
|
||||
raise ApiError(429, "rate_limited", retry_after=retry)
|
||||
taken.append(key)
|
||||
return taken
|
||||
|
||||
def _release(self, keys: list[str]) -> None:
|
||||
for key in keys:
|
||||
self._limiter.forgive(key)
|
||||
|
||||
def _quiet_limit(self, name: str, ident: str) -> bool:
|
||||
"""Лимит, о котором пользователь не узнаёт (иначе он раскрывал бы сведения об адресе)."""
|
||||
return self._limiter.check(_key(name, ident), *LIMITS[name])[0]
|
||||
|
||||
def _maybe_purge(self, now: float) -> None:
|
||||
if now - self._last_purge >= PURGE_INTERVAL:
|
||||
self._last_purge = now
|
||||
self._store.purge_expired(now)
|
||||
|
||||
# --- регистрация ---
|
||||
def register(self, email_raw: object, ip: str) -> dict:
|
||||
"""Принимает только адрес: пароль человек задаёт по ссылке из письма, поэтому чужая регистрация ничего не подменит."""
|
||||
if not self._cfg.allow_registration:
|
||||
raise ApiError(403, "registration_closed")
|
||||
self._reserve(("register_ip", ip))
|
||||
email = security.normalize_email(email_raw)
|
||||
if email is None:
|
||||
raise ApiError(400, "invalid_email")
|
||||
now = self._clock()
|
||||
self._maybe_purge(now)
|
||||
if not self._quiet_limit("register_email", email):
|
||||
return REGISTER_OK
|
||||
if self._store.user_by_email(email) is not None:
|
||||
self._mailer.send(templates.already_registered_mail(self._cfg, email))
|
||||
else:
|
||||
raw, token_hash = security.new_token()
|
||||
self._store.save_registration(email, token_hash, now + self._cfg.verify_ttl, now)
|
||||
self._mailer.send(templates.verify_mail(self._cfg, email, raw))
|
||||
return REGISTER_OK
|
||||
|
||||
def verify(self, token_raw: object, password: object, ip: str) -> dict:
|
||||
"""Подтверждение почты и выбор пароля: учётная запись создаётся только здесь."""
|
||||
self._reserve(("verify_ip", ip))
|
||||
token_hash = security.hash_token(token_raw)
|
||||
now = self._clock()
|
||||
email = self._store.peek_registration(token_hash, now) if token_hash else None
|
||||
if email is None:
|
||||
raise ApiError(400, "invalid_token")
|
||||
problem = security.validate_password(password, email)
|
||||
if problem:
|
||||
raise ApiError(400, problem) # ссылка не сгорает: можно выбрать другой пароль
|
||||
pw_hash = security.hash_password(password)
|
||||
if not self._store.activate_registration(token_hash, pw_hash, now):
|
||||
raise ApiError(400, "invalid_token")
|
||||
return VERIFY_OK
|
||||
|
||||
# --- вход и выход ---
|
||||
def login(self, email_raw: object, password: object, ip: str) -> tuple[str, str]:
|
||||
email = security.normalize_email(email_raw)
|
||||
ident = email or str(email_raw)[:254].lower()
|
||||
reserved = self._reserve(("login_ip", ip), ("login_pair", f"{ip}|{ident}"), ("login_email", ident))
|
||||
if not isinstance(password, str) or len(password) > PASSWORD_INPUT_MAX:
|
||||
password = ""
|
||||
user = self._store.user_by_email(email) if email else None
|
||||
valid = security.verify_password(user["pw_hash"], password) if user is not None else security.verify_dummy(password)
|
||||
if not valid:
|
||||
raise ApiError(401, "invalid_credentials")
|
||||
current_hash = user["pw_hash"]
|
||||
if security.needs_rehash(current_hash):
|
||||
upgraded = security.hash_password(password)
|
||||
# Условная запись: если пароль успели сменить (сброс), старый пароль не возвращается, а вход отклоняется ниже
|
||||
if self._store.set_password(user["id"], upgraded, expect_pw_hash=current_hash):
|
||||
current_hash = upgraded
|
||||
now = self._clock()
|
||||
raw, token_hash = security.new_token()
|
||||
if not self._store.create_session(user["id"], token_hash, now + self._cfg.session_seconds, now, expect_pw_hash=current_hash):
|
||||
raise ApiError(401, "invalid_credentials") # пароль сменили, пока шла проверка
|
||||
self._release(reserved)
|
||||
return raw, user["email"]
|
||||
|
||||
def logout(self, session_raw: object) -> None:
|
||||
token_hash = security.hash_token(session_raw)
|
||||
if token_hash:
|
||||
self._store.delete_session(token_hash)
|
||||
|
||||
def me(self, session_raw: object) -> dict | None:
|
||||
token_hash = security.hash_token(session_raw)
|
||||
row = self._store.session_user(token_hash, self._clock()) if token_hash else None
|
||||
return {"id": row["id"], "email": row["email"]} if row else None
|
||||
|
||||
def delete_account(self, session_raw: object, password: object) -> dict:
|
||||
"""Удаляет аккаунт, сессии и прогресс. Нужен пароль: украденная cookie не должна стирать чужие данные."""
|
||||
user = self.me(session_raw)
|
||||
if user is None:
|
||||
raise ApiError(401, "unauthorized")
|
||||
reserved = self._reserve(("delete_user", str(user["id"])))
|
||||
row = self._store.user_by_email(user["email"])
|
||||
if not isinstance(password, str) or len(password) > PASSWORD_INPUT_MAX:
|
||||
password = ""
|
||||
if row is None or not security.verify_password(row["pw_hash"], password):
|
||||
raise ApiError(401, "wrong_password")
|
||||
self._store.delete_user(user["id"])
|
||||
self._release(reserved)
|
||||
return DELETE_OK
|
||||
|
||||
# --- сброс пароля ---
|
||||
def forgot(self, email_raw: object, ip: str) -> dict:
|
||||
self._reserve(("forgot_ip", ip))
|
||||
email = security.normalize_email(email_raw)
|
||||
if email is None:
|
||||
raise ApiError(400, "invalid_email")
|
||||
now = self._clock()
|
||||
self._maybe_purge(now)
|
||||
if not self._quiet_limit("forgot_email", email):
|
||||
return FORGOT_OK
|
||||
user = self._store.user_by_email(email)
|
||||
if user is not None:
|
||||
raw, token_hash = security.new_token()
|
||||
self._store.save_reset_token(user["id"], token_hash, now + self._cfg.reset_ttl)
|
||||
try:
|
||||
self._mailer.send(templates.reset_mail(self._cfg, email, raw))
|
||||
except MailQueueFull:
|
||||
# Ответ обязан быть тем же, что для незарегистрированного адреса, иначе по перегрузке очереди можно
|
||||
# выяснить, какие адреса есть в системе. Сбой уже записан в лог почтовой очереди.
|
||||
pass
|
||||
return FORGOT_OK
|
||||
|
||||
def reset(self, token_raw: object, password: object, ip: str) -> dict:
|
||||
self._reserve(("reset_ip", ip))
|
||||
token_hash = security.hash_token(token_raw)
|
||||
now = self._clock()
|
||||
row = self._store.peek_reset_token(token_hash, now) if token_hash else None
|
||||
if row is None:
|
||||
raise ApiError(400, "invalid_token")
|
||||
problem = security.validate_password(password, row["email"])
|
||||
if problem:
|
||||
raise ApiError(400, problem) # токен не сгорает: можно выбрать другой пароль
|
||||
pw_hash = security.hash_password(password) # считаем до погашения токена: сбой хеширования не сжигает ссылку
|
||||
result = self._store.reset_password(token_hash, pw_hash, now) # токен, пароль и сессии: одна транзакция
|
||||
if result is None:
|
||||
raise ApiError(400, "invalid_token")
|
||||
try:
|
||||
self._mailer.send(templates.password_changed_mail(self._cfg, result["email"]))
|
||||
except MailQueueFull:
|
||||
pass # уведомление не критично: пароль уже изменён
|
||||
return RESET_OK
|
||||
|
||||
# --- прогресс ---
|
||||
def get_state(self, user_id: int) -> dict:
|
||||
row = self._store.get_state(user_id)
|
||||
if row is None:
|
||||
return {"version": 0, "data": clean_state({})}
|
||||
return {"version": row[0], "data": json.loads(row[1])}
|
||||
|
||||
def put_state(self, user_id: int, version: object, data: object, account_email: str, claimed_email: object = None) -> dict:
|
||||
"""claimed_email: от чьего имени клиент сохраняет данные; несовпадение значит, что вход в другой вкладке сменили."""
|
||||
if claimed_email is not None and str(claimed_email).strip().lower() != account_email:
|
||||
raise ApiError(409, "account_mismatch")
|
||||
self._reserve(("state_user", str(user_id)))
|
||||
if isinstance(version, bool) or not isinstance(version, int) or version < 0:
|
||||
raise ApiError(400, "invalid_state")
|
||||
try:
|
||||
cleaned = clean_state(data)
|
||||
except StateError:
|
||||
raise ApiError(400, "invalid_state") from None
|
||||
payload = json.dumps(cleaned, ensure_ascii=False, separators=(",", ":"))
|
||||
if len(payload.encode("utf-8")) > MAX_STATE_BYTES:
|
||||
raise ApiError(400, "invalid_state")
|
||||
new_version = self._store.put_state(user_id, version, payload, self._clock())
|
||||
if new_version is None:
|
||||
raise ApiError(409, "version_conflict", extra=self.get_state(user_id))
|
||||
return {"version": new_version}
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Проверка и очистка состояния прогресса, которое клиент хранит на сервере.
|
||||
|
||||
Сервер не доверяет присланному JSON: оставляем только известные поля, приводим числа к допустимым
|
||||
диапазонам и ограничиваем размеры. Так испорченные данные не попадут ни на другие устройства, ни в интерфейс.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
|
||||
MAX_HISTORY = 600
|
||||
MAX_PROGRESS = 200
|
||||
MAX_KEYS = 200
|
||||
_ID_MAX = 40
|
||||
_ID_RE = re.compile(r"[^A-Za-z0-9_.-]")
|
||||
_LANGS = ("ru", "en")
|
||||
_KINDS = ("lesson", "practice", "weak")
|
||||
|
||||
|
||||
class StateError(ValueError):
|
||||
"""Состояние имеет неверную структуру."""
|
||||
|
||||
|
||||
def _num(value: object, low: float, high: float) -> int | float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return 0
|
||||
if isinstance(value, float) and not math.isfinite(value):
|
||||
return 0
|
||||
clamped = min(max(value, low), high)
|
||||
return clamped
|
||||
|
||||
|
||||
def _ident(value: object) -> str:
|
||||
return _ID_RE.sub("", value)[:_ID_MAX] if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _is_key_char(char: object) -> bool:
|
||||
# одиночные суррогаты не кодируются в UTF-8 и ломали бы сохранение
|
||||
return isinstance(char, str) and len(char) == 1 and not 0xD800 <= ord(char) <= 0xDFFF
|
||||
|
||||
|
||||
def _choice(value: object, allowed: tuple[str, ...]) -> str:
|
||||
return value if isinstance(value, str) and value in allowed else ""
|
||||
|
||||
|
||||
def _history_row(row: dict) -> dict:
|
||||
return {
|
||||
"t": _num(row.get("t"), 0, 4_000_000_000_000),
|
||||
"id": _ident(row.get("id")),
|
||||
"k": _choice(row.get("k"), _KINDS),
|
||||
"lang": _choice(row.get("lang"), _LANGS),
|
||||
"cpm": _num(row.get("cpm"), 0, 3000),
|
||||
"acc": _num(row.get("acc"), 0, 100),
|
||||
"err": _num(row.get("err"), 0, 100_000),
|
||||
"len": _num(row.get("len"), 0, 100_000),
|
||||
"sec": _num(row.get("sec"), 0, 1_000_000),
|
||||
}
|
||||
|
||||
|
||||
def _progress_row(row: dict) -> dict:
|
||||
return {
|
||||
"best": _num(row.get("best"), 0, 3000),
|
||||
"acc": _num(row.get("acc"), 0, 100),
|
||||
"stars": int(_num(row.get("stars"), 0, 3)),
|
||||
"tries": int(_num(row.get("tries"), 0, 100_000)),
|
||||
}
|
||||
|
||||
|
||||
def _key_row(row: dict) -> dict:
|
||||
return {name: _num(row.get(name), 0, 1_000_000_000) for name in ("n", "err", "ms", "ln")}
|
||||
|
||||
|
||||
def _section(data: dict, name: str, kind: type) -> object:
|
||||
value = data.get(name)
|
||||
if value is None:
|
||||
return kind()
|
||||
if not isinstance(value, kind):
|
||||
raise StateError(f"поле {name} имеет неверный тип")
|
||||
return value
|
||||
|
||||
|
||||
def clean_state(data: object) -> dict:
|
||||
"""Возвращает очищенную копию состояния или поднимает StateError."""
|
||||
if not isinstance(data, dict):
|
||||
raise StateError("ожидался объект")
|
||||
progress_in = _section(data, "progress", dict)
|
||||
history_in = _section(data, "history", list)
|
||||
keys_in = _section(data, "keys", dict)
|
||||
|
||||
progress = {}
|
||||
for key, row in sorted(progress_in.items()): # порядок как у клиента: обрезка одинакова на обеих сторонах
|
||||
ident = _ident(key)
|
||||
if ident and isinstance(row, dict) and len(progress) < MAX_PROGRESS:
|
||||
progress[ident] = _progress_row(row)
|
||||
|
||||
history = [_history_row(row) for row in history_in if isinstance(row, dict)][-MAX_HISTORY:]
|
||||
|
||||
keys = {}
|
||||
for lang in _LANGS:
|
||||
per_lang = keys_in.get(lang, {})
|
||||
if not isinstance(per_lang, dict):
|
||||
raise StateError("поле keys имеет неверный тип")
|
||||
cleaned = {}
|
||||
for char, row in per_lang.items():
|
||||
if _is_key_char(char) and isinstance(row, dict) and len(cleaned) < MAX_KEYS:
|
||||
cleaned[char] = _key_row(row)
|
||||
keys[lang] = cleaned
|
||||
|
||||
return {"progress": progress, "history": history, "keys": keys}
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Хранилище SQLite. Все запросы параметризованы; одноразовые токены гасятся атомарно."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator
|
||||
|
||||
SCHEMA_VERSION = 2 # 2: регистрация больше не хранит пароль (его задают после подтверждения почты)
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
pw_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
-- Ожидание подтверждения почты: пользователь появляется только после перехода по ссылке и выбора пароля.
|
||||
CREATE TABLE IF NOT EXISTS registrations (
|
||||
email TEXT PRIMARY KEY,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS reset_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS sessions_user ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS registrations_expires ON registrations(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS reset_tokens_expires ON reset_tokens(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS sessions_expires ON sessions(expires_at);
|
||||
CREATE TABLE IF NOT EXISTS user_state (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
data TEXT NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
_COUNTABLE = frozenset({"users", "registrations", "reset_tokens", "sessions", "user_state"})
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, path: str) -> None:
|
||||
self._path = path
|
||||
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
||||
self._init_schema()
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass # Windows и некоторые файловые системы не поддерживают права
|
||||
|
||||
@contextmanager
|
||||
def _tx(self, immediate: bool = False) -> Iterator[sqlite3.Connection]:
|
||||
conn = sqlite3.connect(self._path, timeout=5, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("BEGIN IMMEDIATE" if immediate else "BEGIN")
|
||||
try:
|
||||
yield conn
|
||||
except BaseException:
|
||||
conn.execute("ROLLBACK")
|
||||
raise
|
||||
conn.execute("COMMIT")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _init_schema(self) -> None:
|
||||
conn = sqlite3.connect(self._path, timeout=5, isolation_level=None)
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
version = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version < 2:
|
||||
# Ожидающие регистрации одноразовы и недолговечны: старые (с паролем) просто отбрасываем
|
||||
conn.execute("DROP TABLE IF EXISTS registrations")
|
||||
conn.executescript(_SCHEMA)
|
||||
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# --- пользователи ---
|
||||
def create_user(self, email: str, pw_hash: str, now: float) -> int | None:
|
||||
try:
|
||||
with self._tx(immediate=True) as conn:
|
||||
return conn.execute(
|
||||
"INSERT INTO users (email, pw_hash, created_at) VALUES (?, ?, ?)", (email, pw_hash, int(now))
|
||||
).lastrowid
|
||||
except sqlite3.IntegrityError:
|
||||
return None
|
||||
|
||||
def user_by_email(self, email: str) -> sqlite3.Row | None:
|
||||
with self._tx() as conn:
|
||||
return conn.execute("SELECT id, email, pw_hash FROM users WHERE email = ?", (email,)).fetchone()
|
||||
|
||||
def set_password(self, user_id: int, pw_hash: str, expect_pw_hash: str | None = None) -> bool:
|
||||
"""С expect_pw_hash меняет пароль, только если он всё ещё прежний (перехеширование не затирает сброс пароля)."""
|
||||
with self._tx(immediate=True) as conn:
|
||||
if expect_pw_hash is None:
|
||||
cursor = conn.execute("UPDATE users SET pw_hash = ? WHERE id = ?", (pw_hash, user_id))
|
||||
else:
|
||||
cursor = conn.execute("UPDATE users SET pw_hash = ? WHERE id = ? AND pw_hash = ?", (pw_hash, user_id, expect_pw_hash))
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def delete_user(self, user_id: int) -> None:
|
||||
"""Удаляет пользователя вместе с сессиями, токенами и прогрессом (каскад внешних ключей)."""
|
||||
with self._tx(immediate=True) as conn:
|
||||
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
|
||||
# --- регистрация ---
|
||||
def save_registration(self, email: str, token_hash: str, expires_at: float, now: float) -> None:
|
||||
with self._tx(immediate=True) as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO registrations (email, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?)",
|
||||
(email, token_hash, int(expires_at), int(now)),
|
||||
)
|
||||
|
||||
def peek_registration(self, token_hash: str, now: float) -> str | None:
|
||||
with self._tx() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT email FROM registrations WHERE token_hash = ? AND expires_at > ?", (token_hash, int(now))
|
||||
).fetchone()
|
||||
return row["email"] if row else None
|
||||
|
||||
def activate_registration(self, token_hash: str, pw_hash: str, now: float) -> bool:
|
||||
"""Гасит ссылку и создаёт пользователя одной транзакцией. False: ссылка недействительна или адрес уже занят."""
|
||||
with self._tx(immediate=True) as conn:
|
||||
row = conn.execute(
|
||||
"DELETE FROM registrations WHERE token_hash = ? AND expires_at > ? RETURNING email", (token_hash, int(now))
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO users (email, pw_hash, created_at) VALUES (?, ?, ?)", (row["email"], pw_hash, int(now))
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
return False # неудавшийся INSERT не отменяет транзакцию: ссылка всё равно погашена
|
||||
return True
|
||||
|
||||
# --- сброс пароля ---
|
||||
def save_reset_token(self, user_id: int, token_hash: str, expires_at: float) -> None:
|
||||
with self._tx(immediate=True) as conn:
|
||||
conn.execute("DELETE FROM reset_tokens WHERE user_id = ?", (user_id,))
|
||||
conn.execute(
|
||||
"INSERT INTO reset_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)",
|
||||
(token_hash, user_id, int(expires_at)),
|
||||
)
|
||||
|
||||
def peek_reset_token(self, token_hash: str, now: float) -> sqlite3.Row | None:
|
||||
with self._tx() as conn:
|
||||
return conn.execute(
|
||||
"SELECT u.id AS id, u.email AS email FROM reset_tokens t JOIN users u ON u.id = t.user_id "
|
||||
"WHERE t.token_hash = ? AND t.expires_at > ?",
|
||||
(token_hash, int(now)),
|
||||
).fetchone()
|
||||
|
||||
def reset_password(self, token_hash: str, pw_hash: str, now: float) -> sqlite3.Row | None:
|
||||
"""Гасит токен, меняет пароль и завершает все сессии одной транзакцией. None: токен недействителен."""
|
||||
with self._tx(immediate=True) as conn:
|
||||
row = conn.execute(
|
||||
"DELETE FROM reset_tokens WHERE token_hash = ? AND expires_at > ? RETURNING user_id", (token_hash, int(now))
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
conn.execute("UPDATE users SET pw_hash = ? WHERE id = ?", (pw_hash, row["user_id"]))
|
||||
conn.execute("DELETE FROM sessions WHERE user_id = ?", (row["user_id"],))
|
||||
return conn.execute("SELECT id, email FROM users WHERE id = ?", (row["user_id"],)).fetchone()
|
||||
|
||||
# --- сессии ---
|
||||
def create_session(self, user_id: int, token_hash: str, expires_at: float, now: float, keep: int = 10,
|
||||
expect_pw_hash: str | None = None) -> bool:
|
||||
"""expect_pw_hash защищает от гонки: если пароль сменили после проверки, сессия не создаётся."""
|
||||
with self._tx(immediate=True) as conn:
|
||||
if expect_pw_hash is not None:
|
||||
current = conn.execute("SELECT pw_hash FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
if current is None or current["pw_hash"] != expect_pw_hash:
|
||||
return False
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (token_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)",
|
||||
(token_hash, user_id, int(expires_at), int(now)),
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM sessions WHERE user_id = ? AND token_hash NOT IN "
|
||||
"(SELECT token_hash FROM sessions WHERE user_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?)",
|
||||
(user_id, user_id, keep),
|
||||
)
|
||||
return True
|
||||
|
||||
def session_user(self, token_hash: str, now: float) -> sqlite3.Row | None:
|
||||
with self._tx() as conn:
|
||||
return conn.execute(
|
||||
"SELECT u.id AS id, u.email AS email FROM sessions s JOIN users u ON u.id = s.user_id "
|
||||
"WHERE s.token_hash = ? AND s.expires_at > ?",
|
||||
(token_hash, int(now)),
|
||||
).fetchone()
|
||||
|
||||
def delete_session(self, token_hash: str) -> None:
|
||||
with self._tx(immediate=True) as conn:
|
||||
conn.execute("DELETE FROM sessions WHERE token_hash = ?", (token_hash,))
|
||||
|
||||
def delete_user_sessions(self, user_id: int) -> None:
|
||||
with self._tx(immediate=True) as conn:
|
||||
conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
|
||||
|
||||
# --- состояние прогресса ---
|
||||
def get_state(self, user_id: int) -> tuple[int, str] | None:
|
||||
with self._tx() as conn:
|
||||
row = conn.execute("SELECT version, data FROM user_state WHERE user_id = ?", (user_id,)).fetchone()
|
||||
return (row["version"], row["data"]) if row else None
|
||||
|
||||
def put_state(self, user_id: int, expected_version: int, data: str, now: float) -> int | None:
|
||||
"""Записывает состояние, если версия совпала (оптимистичная блокировка). None = конфликт."""
|
||||
with self._tx(immediate=True) as conn:
|
||||
row = conn.execute("SELECT version FROM user_state WHERE user_id = ?", (user_id,)).fetchone()
|
||||
current = row["version"] if row else 0
|
||||
if current != expected_version:
|
||||
return None
|
||||
new_version = current + 1
|
||||
conn.execute(
|
||||
"INSERT INTO user_state (user_id, data, version, updated_at) VALUES (?, ?, ?, ?) "
|
||||
"ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, version = excluded.version, updated_at = excluded.updated_at",
|
||||
(user_id, data, new_version, int(now)),
|
||||
)
|
||||
return new_version
|
||||
|
||||
# --- обслуживание ---
|
||||
def purge_expired(self, now: float) -> None:
|
||||
with self._tx(immediate=True) as conn:
|
||||
for table in ("registrations", "reset_tokens", "sessions"):
|
||||
conn.execute(f"DELETE FROM {table} WHERE expires_at <= ?", (int(now),)) # имена таблиц - константы выше
|
||||
|
||||
def count(self, table: str) -> int:
|
||||
if table not in _COUNTABLE:
|
||||
raise ValueError("неизвестная таблица")
|
||||
with self._tx() as conn:
|
||||
return conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
|
||||
Reference in New Issue
Block a user