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
+277
View File
@@ -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}