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