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.
165 lines
6.9 KiB
Python
165 lines
6.9 KiB
Python
"""Примитивы безопасности: проверка почты и паролей, хеширование (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()
|