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