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