diff --git a/containers/clearview/src/clearview_app/auth/security.py b/containers/clearview/src/clearview_app/auth/security.py new file mode 100644 index 0000000..7c0dd7f --- /dev/null +++ b/containers/clearview/src/clearview_app/auth/security.py @@ -0,0 +1,44 @@ +"""Password hashing, password-policy validation, and session-id generation.""" +from __future__ import annotations + +import uuid + +from argon2 import PasswordHasher +from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError + + +class PasswordPolicyError(ValueError): + """Raised when a candidate password does not meet the policy.""" + + +_hasher = PasswordHasher() + +MIN_LENGTH = 12 + + +def validate_password(pw: str) -> None: + """Enforce: length >= 12, at least one letter and one digit.""" + if len(pw) < MIN_LENGTH: + raise PasswordPolicyError(f"Password must be at least {MIN_LENGTH} characters.") + if not any(c.isalpha() for c in pw): + raise PasswordPolicyError("Password must contain at least one letter.") + if not any(c.isdigit() for c in pw): + raise PasswordPolicyError("Password must contain at least one digit.") + + +def hash_password(pw: str) -> str: + return _hasher.hash(pw) + + +def verify_password(pw: str, encoded: str) -> bool: + try: + return _hasher.verify(encoded, pw) + except (VerifyMismatchError, InvalidHashError, VerificationError): + return False + except Exception: + return False + + +def new_session_id() -> str: + """Opaque 128-bit session identifier rendered as 32 hex chars.""" + return uuid.uuid4().hex diff --git a/containers/clearview/tests/test_security.py b/containers/clearview/tests/test_security.py new file mode 100644 index 0000000..ee61323 --- /dev/null +++ b/containers/clearview/tests/test_security.py @@ -0,0 +1,37 @@ +import pytest + +from clearview_app.auth.security import ( + PasswordPolicyError, + hash_password, + new_session_id, + validate_password, + verify_password, +) + + +def test_hash_and_verify_roundtrip(): + h = hash_password("CorrectHorse42") + assert verify_password("CorrectHorse42", h) is True + assert verify_password("wrong", h) is False + + +def test_verify_returns_false_on_garbage_hash(): + assert verify_password("anything", "not-a-real-hash") is False + + +@pytest.mark.parametrize("pw", ["short1A", "alllowercase", "ALLUPPERCASE", "12345678901234"]) +def test_policy_rejects(pw): + with pytest.raises(PasswordPolicyError): + validate_password(pw) + + +@pytest.mark.parametrize("pw", ["CorrectHorse42", "abcdefghij12"]) +def test_policy_accepts(pw): + validate_password(pw) + + +def test_new_session_id_unique_and_hex(): + a = new_session_id() + b = new_session_id() + assert a != b + assert len(a) == 32 and all(c in "0123456789abcdef" for c in a)