diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..38b6291 --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,51 @@ +"""Auth module — bcrypt password verification and session token management.""" + +import os +import secrets + +import bcrypt + +# Module-level defaults (read from env at import time for AUTH_USER/AUTH_PASS_HASH) +AUTH_USER: str = os.environ.get("AUTH_USER", "admin@localhost") +AUTH_PASS_HASH: str = os.environ.get("AUTH_PASS_HASH", "") + +# In-memory session store: token -> email +_sessions: dict[str, str] = {} + + +def verify_password(password: str) -> bool: + """Return True if password matches the bcrypt hash in AUTH_PASS_HASH env var. + + Reads AUTH_PASS_HASH from env at call time so runtime env changes are respected. + Returns False if no hash is configured, if password is empty, or on any error. + """ + if not password: + return False + + hash_str = os.environ.get("AUTH_PASS_HASH", "") + if not hash_str: + return False + + try: + return bcrypt.checkpw(password.encode(), hash_str.encode()) + except (ValueError, TypeError): + return False + + +def create_session_token(email: str) -> str: + """Generate a session token, store it mapped to email, and return the token.""" + token = secrets.token_hex(32) + _sessions[token] = email + return token + + +def validate_session_token(token: str) -> str | None: + """Return the email for a valid token, or None if token is empty/unknown.""" + if not token: + return None + return _sessions.get(token) + + +def invalidate_session_token(token: str) -> None: + """Remove a session token from the store.""" + _sessions.pop(token, None) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..16ef5d3 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,58 @@ +"""Tests for auth module — bcrypt password verification and session tokens.""" + +import sys +import os + +# Add backend to sys.path so we can import auth +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend")) + +import bcrypt +import pytest + +from auth import verify_password, create_session_token, validate_session_token + +TEST_PASSWORD = "test_password_123" +TEST_EMAIL = "test@example.com" + + +@pytest.fixture(autouse=True) +def set_auth_env(monkeypatch): + """Set up AUTH_PASS_HASH env var with a bcrypt-hashed test password.""" + hashed = bcrypt.hashpw(TEST_PASSWORD.encode(), bcrypt.gensalt()).decode() + monkeypatch.setenv("AUTH_PASS_HASH", hashed) + monkeypatch.setenv("AUTH_USER", TEST_EMAIL) + + +def test_verify_password_correct(): + """Correct password returns True.""" + assert verify_password(TEST_PASSWORD) is True + + +def test_verify_password_wrong(): + """Wrong password returns False.""" + assert verify_password("wrong_password_xyz") is False + + +def test_verify_password_empty(): + """Empty password returns False.""" + assert verify_password("") is False + + +def test_create_session_token(): + """create_session_token returns a non-None string of length > 20.""" + token = create_session_token(TEST_EMAIL) + assert token is not None + assert isinstance(token, str) + assert len(token) > 20 + + +def test_validate_session_token_valid(): + """Valid token returns the associated email.""" + token = create_session_token(TEST_EMAIL) + assert validate_session_token(token) == TEST_EMAIL + + +def test_validate_session_token_invalid_and_empty(): + """Invalid/bogus token returns None; empty token returns None.""" + assert validate_session_token("bogus_token_that_does_not_exist") is None + assert validate_session_token("") is None