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
+149
View File
@@ -0,0 +1,149 @@
"""Тесты примитивов безопасности: почта, пароли, токены."""
import base64
import threading
import unittest
from unittest import mock
from splitapi import security
class NormalizeEmailTests(unittest.TestCase):
def test_lowercases_and_strips(self):
self.assertEqual(security.normalize_email(" User@Example.COM "), "user@example.com")
def test_accepts_plus_and_dots(self):
self.assertEqual(security.normalize_email("a.b+tag@mail.example.org"), "a.b+tag@mail.example.org")
def test_idn_domain_is_converted_to_punycode(self):
self.assertEqual(security.normalize_email("ivan@почта.рф"), "ivan@xn--80a1acny.xn--p1ai")
def test_rejects_garbage(self):
for bad in ["", "plain", "a@b", "@example.com", "a@@example.com", "a b@example.com",
"a@exa mple.com", "a@example..com", "a@-example.com", "a@example.c", None, 42]:
with self.subTest(bad=bad):
self.assertIsNone(security.normalize_email(bad))
def test_rejects_newline_inside_address(self):
"""re.match с $ пропускал перевод строки перед концом строки: нужен fullmatch."""
for bad in ["a\n@example.com", "user@example\n.com", "a@example.com\n\n", "a@exam\nple.com"]:
with self.subTest(bad=bad):
result = security.normalize_email(bad)
self.assertTrue(result is None or "\n" not in result)
self.assertIsNone(security.normalize_email("a\n@example.com"))
self.assertIsNone(security.normalize_email("user@example\n.com"))
def test_rejects_characters_dangerous_for_legacy_mail_systems(self):
for bad in ["user%evil.com@example.com", "a|cmd@example.com", "a/b@example.com", "a!b@example.com", "a#b@example.com",
"a$b@example.com", "a&b@example.com", "a*b@example.com", "a=b@example.com", "a?b@example.com", "a`b@example.com"]:
with self.subTest(bad=bad):
self.assertIsNone(security.normalize_email(bad))
def test_keeps_apostrophe_in_real_names(self):
self.assertEqual(security.normalize_email("O'Brien@Example.com"), "o'brien@example.com")
def test_rejects_header_injection(self):
for bad in ["a@example.com\r\nBcc: evil@x.org", "a@example.com\nX: y", "a\x00@example.com"]:
with self.subTest(bad=bad):
self.assertIsNone(security.normalize_email(bad))
def test_rejects_too_long(self):
self.assertIsNone(security.normalize_email("a" * 65 + "@example.com"))
self.assertIsNone(security.normalize_email("a@" + "b" * 250 + ".com"))
class PasswordPolicyTests(unittest.TestCase):
def test_accepts_reasonable_password(self):
self.assertIsNone(security.validate_password("correct horse battery", "u@example.com"))
def test_too_short(self):
self.assertEqual(security.validate_password("short1", "u@example.com"), "password_too_short")
def test_too_long_is_rejected_to_bound_hashing_cost(self):
self.assertEqual(security.validate_password("x" * 129, "u@example.com"), "password_too_long")
def test_not_a_string(self):
self.assertEqual(security.validate_password(None, "u@example.com"), "password_too_short")
def test_equal_to_email_or_local_part(self):
self.assertEqual(security.validate_password("someone@example.com", "someone@example.com"), "password_weak")
self.assertEqual(security.validate_password("someoneelse1", "someoneelse1@example.com"), "password_weak")
def test_common_passwords_rejected(self):
for pw in ["password", "12345678", "qwertyuiop", "Password1"]:
with self.subTest(pw=pw):
self.assertEqual(security.validate_password(pw, "u@example.com"), "password_weak")
def test_unicode_password_length_counts_characters(self):
self.assertIsNone(security.validate_password("пароль-на-русском", "u@example.com"))
def test_lone_surrogates_are_rejected_instead_of_crashing_the_hasher(self):
"""json.loads пропускает "\\ud800": такая строка не кодируется в UTF-8 и раньше давала 500."""
for bad in ["\ud800" * 8, "valid-prefix-\udfff", "ok-password-" + "\udc00"]:
with self.subTest(bad=bad):
self.assertEqual(security.validate_password(bad, "u@example.com"), "password_invalid")
class PasswordHashTests(unittest.TestCase):
def test_roundtrip(self):
stored = security.hash_password("s3cret-password")
self.assertTrue(security.verify_password(stored, "s3cret-password"))
self.assertFalse(security.verify_password(stored, "S3cret-password"))
def test_hash_has_parameters_and_is_salted(self):
a, b = security.hash_password("same-password"), security.hash_password("same-password")
self.assertNotEqual(a, b)
self.assertTrue(a.startswith("scrypt$"))
self.assertNotIn("same-password", a)
def test_unicode_normalization_is_consistent(self):
stored = security.hash_password("éclair-1234") # e + combining acute
self.assertTrue(security.verify_password(stored, "éclair-1234")) # precomposed é
def test_malformed_stored_hash_never_verifies(self):
for bad in ["", "garbage", "scrypt$1$2", "scrypt$x$8$1$AAAA$BBBB", "md5$1$2$3$4$5"]:
with self.subTest(bad=bad):
self.assertFalse(security.verify_password(bad, "whatever-password"))
def test_needs_rehash_detects_weaker_parameters(self):
weak = security.hash_password("pw-for-rehash", n=2 ** 12)
self.assertTrue(security.needs_rehash(weak))
self.assertFalse(security.needs_rehash(security.hash_password("pw-for-rehash")))
def test_dummy_verify_takes_same_path_and_returns_false(self):
self.assertFalse(security.verify_dummy("any-password-here"))
def test_waiting_for_a_hash_slot_times_out_instead_of_hanging(self):
stored = "$".join(["scrypt", "1024", "8", "1", base64.b64encode(b"s" * 16).decode(), base64.b64encode(b"d" * 32).decode()])
with mock.patch.object(security, "_slots", threading.BoundedSemaphore(0)), mock.patch.object(security, "SLOT_WAIT", 0.05):
with self.assertRaises(security.HashBusy):
security.hash_password("some-password-1")
with self.assertRaises(security.HashBusy):
security.verify_password(stored, "some-password-1")
def test_hash_slot_is_released_after_use(self):
for _ in range(8): # больше, чем слотов: утечка слота зависла бы
security.hash_password("another-password", n=2**10)
class TokenTests(unittest.TestCase):
def test_tokens_are_random_and_long(self):
raw1, h1 = security.new_token()
raw2, h2 = security.new_token()
self.assertNotEqual(raw1, raw2)
self.assertGreaterEqual(len(raw1), 43)
self.assertNotEqual(h1, h2)
def test_hash_is_sha256_hex_and_deterministic(self):
raw, digest = security.new_token()
self.assertEqual(security.hash_token(raw), digest)
self.assertRegex(digest, r"^[0-9a-f]{64}$")
self.assertNotIn(raw, digest)
def test_hash_token_rejects_non_strings(self):
self.assertIsNone(security.hash_token(None))
self.assertIsNone(security.hash_token(123))
self.assertIsNone(security.hash_token("x" * 300))
if __name__ == "__main__":
unittest.main()