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.
278 lines
16 KiB
Python
278 lines
16 KiB
Python
"""Бизнес-логика: регистрация с подтверждением почты, вход, сброс пароля, удаление аккаунта, хранение прогресса.
|
|
|
|
Принципы: ответы для существующих и несуществующих адресов одинаковы, токены одноразовые и хранятся только
|
|
в виде хеша, пароль хешируется 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}
|