diff --git a/backend/auth_passwords.py b/backend/auth_passwords.py new file mode 100644 index 0000000..befa92b --- /dev/null +++ b/backend/auth_passwords.py @@ -0,0 +1,27 @@ +"""Password hashing for the client portal — argon2id via argon2-cffi. + +Kept separate from portal_auth (cookie signing) so the future operator auth can +reuse the same hasher. Never store or log raw passwords.""" +import secrets +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError + +_ph = PasswordHasher() + + +def hash_password(raw: str) -> str: + """Return an argon2id hash string for a raw password.""" + return _ph.hash(raw) + + +def verify_password(raw: str, hashed: str) -> bool: + """True iff raw matches the stored hash. Never raises.""" + try: + return _ph.verify(hashed, raw) + except (VerifyMismatchError, VerificationError, InvalidHashError, Exception): + return False + + +def generate_password(n_bytes: int = 12) -> str: + """A strong, URL-safe shareable password (~16 chars for n_bytes=12).""" + return secrets.token_urlsafe(n_bytes) diff --git a/tests/test_auth_passwords.py b/tests/test_auth_passwords.py new file mode 100644 index 0000000..2575a79 --- /dev/null +++ b/tests/test_auth_passwords.py @@ -0,0 +1,24 @@ +import pytest +from backend.auth_passwords import hash_password, verify_password, generate_password + + +def test_hash_is_not_plaintext_and_verifies(): + h = hash_password("hunter2") + assert h != "hunter2" + assert h.startswith("$argon2") + assert verify_password("hunter2", h) is True + + +def test_verify_rejects_wrong_password(): + h = hash_password("hunter2") + assert verify_password("nope", h) is False + + +def test_verify_is_safe_on_garbage_hash(): + assert verify_password("anything", "not-a-real-hash") is False + + +def test_generated_password_is_strong_and_unique(): + a, b = generate_password(), generate_password() + assert a != b + assert len(a) >= 12