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,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}
|
||||
Reference in New Issue
Block a user