Files
darko fcafe1ae15 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.
2026-10-16 11:25:09 +03:00

165 lines
6.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Примитивы безопасности: проверка почты и паролей, хеширование (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()