feat: auth module with bcrypt password verification and session tokens
This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user