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

150 lines
7.4 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.
"""Тесты примитивов безопасности: почта, пароли, токены."""
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()