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
+164
View File
@@ -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()