fix(auth): require email authentication every login

CRITICAL SECURITY FIX:
- Email code required EVERY login (authentication, not verification)
- DNS TXT check cached separately (domain verification)
- New auth_sessions table for per-login state
- Codes hashed with SHA-256, constant-time comparison
- Max 3 attempts, 10-minute session expiry
- OAuth params stored server-side (security improvement)

New files:
- services/auth_session.py
- migrations 004, 005
- ADR-010: domain verification vs user authentication

312 tests passing, 86.21% coverage

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-22 15:16:26 -07:00
parent 9b50f359a6
commit 9135edfe84
17 changed files with 3457 additions and 529 deletions

View File

@@ -0,0 +1,630 @@
"""
Unit tests for AuthSessionService.
Tests the per-login authentication session management that ensures
email verification is required on EVERY login, never cached.
See ADR-010 for the architectural decision behind this design.
"""
import hashlib
import time
from datetime import datetime, timedelta
from unittest.mock import MagicMock, Mock, patch
import pytest
from gondulf.services.auth_session import (
MAX_CODE_ATTEMPTS,
SESSION_TTL_MINUTES,
AuthSessionError,
AuthSessionService,
CodeVerificationError,
MaxAttemptsExceededError,
SessionExpiredError,
SessionNotFoundError,
)
@pytest.fixture
def mock_database():
"""Create a mock database for testing."""
mock_db = Mock()
mock_engine = MagicMock()
mock_db.get_engine.return_value = mock_engine
return mock_db
@pytest.fixture
def auth_session_service(mock_database):
"""Create AuthSessionService with mock database."""
return AuthSessionService(database=mock_database)
class TestAuthSessionServiceInit:
"""Tests for AuthSessionService initialization."""
def test_initialization(self, mock_database):
"""Test service initializes correctly."""
service = AuthSessionService(database=mock_database)
assert service.database == mock_database
class TestSessionIdGeneration:
"""Tests for session ID generation."""
def test_generate_session_id_is_string(self, auth_session_service):
"""Test session ID is a string."""
session_id = auth_session_service._generate_session_id()
assert isinstance(session_id, str)
def test_generate_session_id_is_unique(self, auth_session_service):
"""Test session IDs are unique."""
ids = [auth_session_service._generate_session_id() for _ in range(100)]
assert len(set(ids)) == 100
def test_generate_session_id_is_long_enough(self, auth_session_service):
"""Test session ID has sufficient entropy."""
session_id = auth_session_service._generate_session_id()
# URL-safe base64 of 32 bytes = ~43 characters
assert len(session_id) >= 40
class TestVerificationCodeGeneration:
"""Tests for verification code generation."""
def test_generate_code_is_6_digits(self, auth_session_service):
"""Test verification code is exactly 6 digits."""
code = auth_session_service._generate_verification_code()
assert len(code) == 6
assert code.isdigit()
def test_generate_code_is_padded(self, auth_session_service):
"""Test verification code is zero-padded."""
# Generate many codes to test padding
for _ in range(100):
code = auth_session_service._generate_verification_code()
assert len(code) == 6
def test_generate_code_varies(self, auth_session_service):
"""Test verification codes are not constant."""
codes = [auth_session_service._generate_verification_code() for _ in range(100)]
# With 6 digits, 100 codes should have significant variation
assert len(set(codes)) > 50
class TestCodeHashing:
"""Tests for code hashing."""
def test_hash_code_produces_sha256(self, auth_session_service):
"""Test code hashing produces SHA-256 hash."""
code = "123456"
hashed = auth_session_service._hash_code(code)
expected = hashlib.sha256(code.encode()).hexdigest()
assert hashed == expected
def test_hash_code_is_deterministic(self, auth_session_service):
"""Test same code produces same hash."""
code = "123456"
hash1 = auth_session_service._hash_code(code)
hash2 = auth_session_service._hash_code(code)
assert hash1 == hash2
def test_different_codes_produce_different_hashes(self, auth_session_service):
"""Test different codes produce different hashes."""
hash1 = auth_session_service._hash_code("123456")
hash2 = auth_session_service._hash_code("654321")
assert hash1 != hash2
class TestCreateSession:
"""Tests for session creation."""
def test_create_session_returns_session_id(self, auth_session_service, mock_database):
"""Test session creation returns session ID."""
# Setup mock to track execute calls
mock_conn = MagicMock()
mock_database.get_engine.return_value.begin.return_value.__enter__ = Mock(return_value=mock_conn)
mock_database.get_engine.return_value.begin.return_value.__exit__ = Mock(return_value=False)
result = auth_session_service.create_session(
me="https://user.example.com",
email="user@example.com",
client_id="https://app.example.com",
redirect_uri="https://app.example.com/callback",
state="xyz123",
code_challenge="challenge123",
code_challenge_method="S256",
scope="",
response_type="id"
)
assert "session_id" in result
assert isinstance(result["session_id"], str)
assert len(result["session_id"]) >= 40
def test_create_session_returns_verification_code(self, auth_session_service, mock_database):
"""Test session creation returns verification code."""
mock_conn = MagicMock()
mock_database.get_engine.return_value.begin.return_value.__enter__ = Mock(return_value=mock_conn)
mock_database.get_engine.return_value.begin.return_value.__exit__ = Mock(return_value=False)
result = auth_session_service.create_session(
me="https://user.example.com",
email="user@example.com",
client_id="https://app.example.com",
redirect_uri="https://app.example.com/callback",
state="xyz123",
code_challenge="challenge123",
code_challenge_method="S256",
scope="",
response_type="id"
)
assert "verification_code" in result
assert len(result["verification_code"]) == 6
assert result["verification_code"].isdigit()
def test_create_session_returns_expiration(self, auth_session_service, mock_database):
"""Test session creation returns expiration time."""
mock_conn = MagicMock()
mock_database.get_engine.return_value.begin.return_value.__enter__ = Mock(return_value=mock_conn)
mock_database.get_engine.return_value.begin.return_value.__exit__ = Mock(return_value=False)
result = auth_session_service.create_session(
me="https://user.example.com",
email="user@example.com",
client_id="https://app.example.com",
redirect_uri="https://app.example.com/callback",
state="xyz123",
code_challenge="challenge123",
code_challenge_method="S256",
scope="",
response_type="id"
)
assert "expires_at" in result
assert isinstance(result["expires_at"], datetime)
# Expiration should be approximately SESSION_TTL_MINUTES from now
expected_expiry = datetime.utcnow() + timedelta(minutes=SESSION_TTL_MINUTES)
assert abs((result["expires_at"] - expected_expiry).total_seconds()) < 5
def test_create_session_stores_hashed_code(self, auth_session_service, mock_database):
"""Test that verification code is stored hashed, not plain."""
mock_conn = MagicMock()
mock_database.get_engine.return_value.begin.return_value.__enter__ = Mock(return_value=mock_conn)
mock_database.get_engine.return_value.begin.return_value.__exit__ = Mock(return_value=False)
result = auth_session_service.create_session(
me="https://user.example.com",
email="user@example.com",
client_id="https://app.example.com",
redirect_uri="https://app.example.com/callback",
state="xyz123",
code_challenge="challenge123",
code_challenge_method="S256",
scope="",
response_type="id"
)
# Verify execute was called
assert mock_conn.execute.called
# Check the parameters passed to execute
call_args = mock_conn.execute.call_args
params = call_args[0][1]
# Code hash should be SHA-256 of the verification code
expected_hash = hashlib.sha256(result["verification_code"].encode()).hexdigest()
assert params["code_hash"] == expected_hash
def test_create_session_handles_database_error(self, auth_session_service, mock_database):
"""Test session creation handles database errors."""
mock_database.get_engine.return_value.begin.side_effect = Exception("Database error")
with pytest.raises(AuthSessionError) as exc_info:
auth_session_service.create_session(
me="https://user.example.com",
email="user@example.com",
client_id="https://app.example.com",
redirect_uri="https://app.example.com/callback",
state="xyz123",
code_challenge="challenge123",
code_challenge_method="S256",
scope="",
response_type="id"
)
assert "Failed to create session" in str(exc_info.value)
class TestGetSession:
"""Tests for session retrieval."""
def test_get_session_not_found(self, auth_session_service, mock_database):
"""Test getting non-existent session raises error."""
mock_conn = MagicMock()
mock_result = MagicMock()
mock_result.fetchone.return_value = None
mock_conn.execute.return_value = mock_result
mock_database.get_engine.return_value.connect.return_value.__enter__ = Mock(return_value=mock_conn)
mock_database.get_engine.return_value.connect.return_value.__exit__ = Mock(return_value=False)
with pytest.raises(SessionNotFoundError):
auth_session_service.get_session("nonexistent_session_id")
def test_get_session_expired(self, auth_session_service, mock_database):
"""Test getting expired session raises error."""
mock_conn = MagicMock()
mock_result = MagicMock()
# Return a session that expired in the past
expired_time = datetime.utcnow() - timedelta(hours=1)
mock_result.fetchone.return_value = (
"session123", "https://user.example.com", "user@example.com",
False, 0, "https://app.example.com", "https://app.example.com/callback",
"xyz", "challenge", "S256", "", "id",
datetime.utcnow() - timedelta(hours=2), expired_time
)
mock_conn.execute.return_value = mock_result
mock_database.get_engine.return_value.connect.return_value.__enter__ = Mock(return_value=mock_conn)
mock_database.get_engine.return_value.connect.return_value.__exit__ = Mock(return_value=False)
# Also mock the delete for cleanup
mock_del_conn = MagicMock()
mock_database.get_engine.return_value.begin.return_value.__enter__ = Mock(return_value=mock_del_conn)
mock_database.get_engine.return_value.begin.return_value.__exit__ = Mock(return_value=False)
with pytest.raises(SessionExpiredError):
auth_session_service.get_session("session123")
def test_get_session_returns_data(self, auth_session_service, mock_database):
"""Test getting valid session returns all data."""
mock_conn = MagicMock()
mock_result = MagicMock()
future_time = datetime.utcnow() + timedelta(minutes=5)
mock_result.fetchone.return_value = (
"session123", "https://user.example.com", "user@example.com",
True, 1, "https://app.example.com", "https://app.example.com/callback",
"xyz", "challenge", "S256", "profile", "code",
datetime.utcnow(), future_time
)
mock_conn.execute.return_value = mock_result
mock_database.get_engine.return_value.connect.return_value.__enter__ = Mock(return_value=mock_conn)
mock_database.get_engine.return_value.connect.return_value.__exit__ = Mock(return_value=False)
result = auth_session_service.get_session("session123")
assert result["session_id"] == "session123"
assert result["me"] == "https://user.example.com"
assert result["email"] == "user@example.com"
assert result["code_verified"] is True
assert result["client_id"] == "https://app.example.com"
assert result["response_type"] == "code"
class TestVerifyCode:
"""Tests for code verification - the core authentication step."""
def test_verify_code_success(self, auth_session_service, mock_database):
"""Test successful code verification."""
code = "123456"
code_hash = hashlib.sha256(code.encode()).hexdigest()
future_time = datetime.utcnow() + timedelta(minutes=5)
# Mock for initial fetch
mock_conn = MagicMock()
mock_result = MagicMock()
mock_result.fetchone.return_value = (
"session123", "https://user.example.com", "user@example.com",
code_hash, False, 0, "https://app.example.com",
"https://app.example.com/callback", "xyz", "challenge", "S256",
"", "id", future_time
)
mock_conn.execute.return_value = mock_result
mock_database.get_engine.return_value.connect.return_value.__enter__ = Mock(return_value=mock_conn)
mock_database.get_engine.return_value.connect.return_value.__exit__ = Mock(return_value=False)
# Mock for update
mock_update_conn = MagicMock()
mock_database.get_engine.return_value.begin.return_value.__enter__ = Mock(return_value=mock_update_conn)
mock_database.get_engine.return_value.begin.return_value.__exit__ = Mock(return_value=False)
result = auth_session_service.verify_code("session123", code)
assert result["code_verified"] is True
assert result["me"] == "https://user.example.com"
def test_verify_code_wrong_code(self, auth_session_service, mock_database):
"""Test code verification with wrong code."""
correct_code = "123456"
wrong_code = "654321"
code_hash = hashlib.sha256(correct_code.encode()).hexdigest()
future_time = datetime.utcnow() + timedelta(minutes=5)
mock_conn = MagicMock()
mock_result = MagicMock()
mock_result.fetchone.return_value = (
"session123", "https://user.example.com", "user@example.com",
code_hash, False, 0, "https://app.example.com",
"https://app.example.com/callback", "xyz", "challenge", "S256",
"", "id", future_time
)
mock_conn.execute.return_value = mock_result
mock_database.get_engine.return_value.connect.return_value.__enter__ = Mock(return_value=mock_conn)
mock_database.get_engine.return_value.connect.return_value.__exit__ = Mock(return_value=False)
# Mock for attempt increment
mock_update_conn = MagicMock()
mock_database.get_engine.return_value.begin.return_value.__enter__ = Mock(return_value=mock_update_conn)
mock_database.get_engine.return_value.begin.return_value.__exit__ = Mock(return_value=False)
with pytest.raises(CodeVerificationError):
auth_session_service.verify_code("session123", wrong_code)
def test_verify_code_max_attempts_exceeded(self, auth_session_service, mock_database):
"""Test code verification fails after max attempts."""
code = "123456"
code_hash = hashlib.sha256(code.encode()).hexdigest()
future_time = datetime.utcnow() + timedelta(minutes=5)
mock_conn = MagicMock()
mock_result = MagicMock()
mock_result.fetchone.return_value = (
"session123", "https://user.example.com", "user@example.com",
code_hash, False, MAX_CODE_ATTEMPTS, "https://app.example.com",
"https://app.example.com/callback", "xyz", "challenge", "S256",
"", "id", future_time
)
mock_conn.execute.return_value = mock_result
mock_database.get_engine.return_value.connect.return_value.__enter__ = Mock(return_value=mock_conn)
mock_database.get_engine.return_value.connect.return_value.__exit__ = Mock(return_value=False)
# Mock for session deletion
mock_del_conn = MagicMock()
mock_database.get_engine.return_value.begin.return_value.__enter__ = Mock(return_value=mock_del_conn)
mock_database.get_engine.return_value.begin.return_value.__exit__ = Mock(return_value=False)
with pytest.raises(MaxAttemptsExceededError):
auth_session_service.verify_code("session123", code)
def test_verify_code_session_not_found(self, auth_session_service, mock_database):
"""Test code verification with non-existent session."""
mock_conn = MagicMock()
mock_result = MagicMock()
mock_result.fetchone.return_value = None
mock_conn.execute.return_value = mock_result
mock_database.get_engine.return_value.connect.return_value.__enter__ = Mock(return_value=mock_conn)
mock_database.get_engine.return_value.connect.return_value.__exit__ = Mock(return_value=False)
with pytest.raises(SessionNotFoundError):
auth_session_service.verify_code("nonexistent", "123456")
def test_verify_code_session_expired(self, auth_session_service, mock_database):
"""Test code verification with expired session."""
code = "123456"
code_hash = hashlib.sha256(code.encode()).hexdigest()
expired_time = datetime.utcnow() - timedelta(hours=1)
mock_conn = MagicMock()
mock_result = MagicMock()
mock_result.fetchone.return_value = (
"session123", "https://user.example.com", "user@example.com",
code_hash, False, 0, "https://app.example.com",
"https://app.example.com/callback", "xyz", "challenge", "S256",
"", "id", expired_time
)
mock_conn.execute.return_value = mock_result
mock_database.get_engine.return_value.connect.return_value.__enter__ = Mock(return_value=mock_conn)
mock_database.get_engine.return_value.connect.return_value.__exit__ = Mock(return_value=False)
# Mock for session deletion
mock_del_conn = MagicMock()
mock_database.get_engine.return_value.begin.return_value.__enter__ = Mock(return_value=mock_del_conn)
mock_database.get_engine.return_value.begin.return_value.__exit__ = Mock(return_value=False)
with pytest.raises(SessionExpiredError):
auth_session_service.verify_code("session123", code)
def test_verify_code_already_verified(self, auth_session_service, mock_database):
"""Test code verification on already verified session returns success."""
code = "123456"
code_hash = hashlib.sha256(code.encode()).hexdigest()
future_time = datetime.utcnow() + timedelta(minutes=5)
mock_conn = MagicMock()
mock_result = MagicMock()
mock_result.fetchone.return_value = (
"session123", "https://user.example.com", "user@example.com",
code_hash, True, 1, "https://app.example.com", # Already verified
"https://app.example.com/callback", "xyz", "challenge", "S256",
"", "id", future_time
)
mock_conn.execute.return_value = mock_result
mock_database.get_engine.return_value.connect.return_value.__enter__ = Mock(return_value=mock_conn)
mock_database.get_engine.return_value.connect.return_value.__exit__ = Mock(return_value=False)
result = auth_session_service.verify_code("session123", code)
assert result["code_verified"] is True
class TestIsSessionVerified:
"""Tests for checking session verification status."""
def test_is_session_verified_true(self, auth_session_service, mock_database):
"""Test is_session_verified returns True for verified session."""
future_time = datetime.utcnow() + timedelta(minutes=5)
mock_conn = MagicMock()
mock_result = MagicMock()
mock_result.fetchone.return_value = (
"session123", "https://user.example.com", "user@example.com",
True, 1, "https://app.example.com", "https://app.example.com/callback",
"xyz", "challenge", "S256", "", "id",
datetime.utcnow(), future_time
)
mock_conn.execute.return_value = mock_result
mock_database.get_engine.return_value.connect.return_value.__enter__ = Mock(return_value=mock_conn)
mock_database.get_engine.return_value.connect.return_value.__exit__ = Mock(return_value=False)
assert auth_session_service.is_session_verified("session123") is True
def test_is_session_verified_false(self, auth_session_service, mock_database):
"""Test is_session_verified returns False for unverified session."""
future_time = datetime.utcnow() + timedelta(minutes=5)
mock_conn = MagicMock()
mock_result = MagicMock()
mock_result.fetchone.return_value = (
"session123", "https://user.example.com", "user@example.com",
False, 0, "https://app.example.com", "https://app.example.com/callback",
"xyz", "challenge", "S256", "", "id",
datetime.utcnow(), future_time
)
mock_conn.execute.return_value = mock_result
mock_database.get_engine.return_value.connect.return_value.__enter__ = Mock(return_value=mock_conn)
mock_database.get_engine.return_value.connect.return_value.__exit__ = Mock(return_value=False)
assert auth_session_service.is_session_verified("session123") is False
def test_is_session_verified_not_found(self, auth_session_service, mock_database):
"""Test is_session_verified returns False for non-existent session."""
mock_conn = MagicMock()
mock_result = MagicMock()
mock_result.fetchone.return_value = None
mock_conn.execute.return_value = mock_result
mock_database.get_engine.return_value.connect.return_value.__enter__ = Mock(return_value=mock_conn)
mock_database.get_engine.return_value.connect.return_value.__exit__ = Mock(return_value=False)
assert auth_session_service.is_session_verified("nonexistent") is False
class TestDeleteSession:
"""Tests for session deletion."""
def test_delete_session(self, auth_session_service, mock_database):
"""Test session deletion."""
mock_conn = MagicMock()
mock_database.get_engine.return_value.begin.return_value.__enter__ = Mock(return_value=mock_conn)
mock_database.get_engine.return_value.begin.return_value.__exit__ = Mock(return_value=False)
# Should not raise
auth_session_service.delete_session("session123")
# Verify execute was called
assert mock_conn.execute.called
class TestCleanupExpiredSessions:
"""Tests for expired session cleanup."""
def test_cleanup_returns_count(self, auth_session_service, mock_database):
"""Test cleanup returns number of deleted sessions."""
mock_conn = MagicMock()
mock_result = MagicMock()
mock_result.rowcount = 5
mock_conn.execute.return_value = mock_result
mock_database.get_engine.return_value.begin.return_value.__enter__ = Mock(return_value=mock_conn)
mock_database.get_engine.return_value.begin.return_value.__exit__ = Mock(return_value=False)
count = auth_session_service.cleanup_expired_sessions()
assert count == 5
def test_cleanup_handles_error(self, auth_session_service, mock_database):
"""Test cleanup handles database errors gracefully."""
mock_database.get_engine.return_value.begin.side_effect = Exception("Database error")
count = auth_session_service.cleanup_expired_sessions()
assert count == 0
class TestSecurityProperties:
"""
Tests verifying security properties of the authentication flow.
These tests ensure the critical security requirements from ADR-010 are met.
"""
def test_code_is_never_stored_in_plain_text(self, auth_session_service, mock_database):
"""
CRITICAL: Verify that verification codes are never stored in plain text.
The verification code should be hashed before storage to prevent
database compromise from exposing valid codes.
"""
mock_conn = MagicMock()
mock_database.get_engine.return_value.begin.return_value.__enter__ = Mock(return_value=mock_conn)
mock_database.get_engine.return_value.begin.return_value.__exit__ = Mock(return_value=False)
result = auth_session_service.create_session(
me="https://user.example.com",
email="user@example.com",
client_id="https://app.example.com",
redirect_uri="https://app.example.com/callback",
state="xyz123",
code_challenge="challenge123",
code_challenge_method="S256",
scope="",
response_type="id"
)
plain_code = result["verification_code"]
call_args = mock_conn.execute.call_args
params = call_args[0][1]
# The plain code should NOT appear in storage
assert params.get("code_hash") != plain_code
# The hash should be a SHA-256 hash (64 hex characters)
assert len(params["code_hash"]) == 64
def test_session_id_has_sufficient_entropy(self, auth_session_service):
"""
CRITICAL: Verify session IDs have sufficient entropy to prevent guessing.
Session IDs must be cryptographically random with enough bits
to prevent brute-force attacks.
"""
session_ids = [auth_session_service._generate_session_id() for _ in range(1000)]
# All should be unique
assert len(set(session_ids)) == 1000
# Should be at least 32 bytes of entropy (256 bits)
# URL-safe base64 of 32 bytes is ~43 characters
for sid in session_ids:
assert len(sid) >= 40
def test_code_verification_uses_constant_time_comparison(self, auth_session_service):
"""
CRITICAL: Verify code comparison uses constant-time algorithm.
This prevents timing attacks that could leak information about
the correct code.
"""
# The implementation uses secrets.compare_digest which is constant-time
# We verify the hash comparison pattern is correct
code1 = "123456"
code2 = "123456"
hash1 = auth_session_service._hash_code(code1)
hash2 = auth_session_service._hash_code(code2)
# Same codes should produce same hashes
assert hash1 == hash2
# Different codes should produce different hashes
hash3 = auth_session_service._hash_code("654321")
assert hash1 != hash3

View File

@@ -175,15 +175,15 @@ class TestDatabaseMigrations:
engine = db.get_engine()
with engine.connect() as conn:
# Check migrations were recorded correctly (001, 002, and 003)
# Check migrations were recorded correctly (001-005)
result = conn.execute(text("SELECT COUNT(*) FROM migrations"))
count = result.fetchone()[0]
assert count == 3
assert count == 5
# Verify all migrations are present
result = conn.execute(text("SELECT version FROM migrations ORDER BY version"))
versions = [row[0] for row in result]
assert versions == [1, 2, 3]
assert versions == [1, 2, 3, 4, 5]
def test_initialize_full_setup(self):
"""Test initialize performs full database setup."""
@@ -261,6 +261,7 @@ class TestMigrationSchemaCorrectness:
"created_at",
"verified_at",
"two_factor",
"last_checked", # Added in migration 005
}
assert columns == expected_columns