diff --git a/export/progress.md b/export/progress.md index 8fd9947..029df2b 100644 --- a/export/progress.md +++ b/export/progress.md @@ -52,11 +52,11 @@ - [x] T10: Creare model ApiToken (SQLAlchemy) - ✅ Completato (2026-04-07 11:15) - [x] T11: Setup Alembic e creare migrazione iniziale - ✅ Completato (2026-04-07 11:20) -### 🔐 Servizi di Sicurezza (T12-T16) - 2/5 completati +### 🔐 Servizi di Sicurezza (T12-T16) - 3/5 completati - [x] T12: Implementare EncryptionService (AES-256) - ✅ Completato (2026-04-07 12:00, commit: 2fdd9d1) - [x] T13: Implementare password hashing (bcrypt) - ✅ Completato (2026-04-07 12:15, commit: 54e8116) -- [ ] T14: Implementare JWT utilities - 🟡 In progress -- [ ] T15: Implementare API token generation +- [x] T14: Implementare JWT utilities - ✅ Completato (2026-04-07 12:30, commit: 781e564) +- [ ] T15: Implementare API token generation - 🟡 In progress - [ ] T16: Scrivere test per servizi di encryption ### 👤 Autenticazione Utenti (T17-T22) - 0/6 completati diff --git a/src/openrouter_monitor/services/token.py b/src/openrouter_monitor/services/token.py new file mode 100644 index 0000000..909ba30 --- /dev/null +++ b/src/openrouter_monitor/services/token.py @@ -0,0 +1,84 @@ +"""API token generation and verification service. + +This module provides secure API token generation using cryptographically +secure random generation and SHA-256 hashing. Only the hash is stored. +""" + +import hashlib +import secrets + + +TOKEN_PREFIX = "or_api_" +TOKEN_ENTROPY_BYTES = 48 # Results in ~64 URL-safe base64 chars + + +def generate_api_token() -> tuple[str, str]: + """Generate a new API token. + + Generates a cryptographically secure random token with format: + 'or_api_' + 48 bytes of URL-safe base64 (~64 chars) + + Returns: + Tuple of (plaintext_token, token_hash) where: + - plaintext_token: The full token to show once to the user + - token_hash: SHA-256 hash to store in database + + Example: + >>> plaintext, hash = generate_api_token() + >>> print(plaintext) + 'or_api_x9QzGv2K...' + >>> # Store hash in DB, show plaintext to user once + """ + # Generate cryptographically secure random token + random_part = secrets.token_urlsafe(TOKEN_ENTROPY_BYTES) + plaintext = f"{TOKEN_PREFIX}{random_part}" + + # Hash the entire token + token_hash = hash_token(plaintext) + + return plaintext, token_hash + + +def hash_token(plaintext: str) -> str: + """Hash a token using SHA-256. + + Args: + plaintext: The plaintext token to hash. + + Returns: + Hexadecimal string of the SHA-256 hash. + + Raises: + TypeError: If plaintext is not a string. + """ + if not isinstance(plaintext, str): + raise TypeError("plaintext must be a string") + + return hashlib.sha256(plaintext.encode("utf-8")).hexdigest() + + +def verify_api_token(plaintext: str, token_hash: str) -> bool: + """Verify an API token against its stored hash. + + Uses timing-safe comparison to prevent timing attacks. + + Args: + plaintext: The plaintext token provided by the user. + token_hash: The SHA-256 hash stored in the database. + + Returns: + True if the token matches the hash, False otherwise. + + Raises: + TypeError: If either argument is not a string. + """ + if not isinstance(plaintext, str): + raise TypeError("plaintext must be a string") + if not isinstance(token_hash, str): + raise TypeError("token_hash must be a string") + + # Compute hash of provided plaintext + computed_hash = hash_token(plaintext) + + # Use timing-safe comparison to prevent timing attacks + return secrets.compare_digest(computed_hash, token_hash) diff --git a/tests/unit/services/test_token.py b/tests/unit/services/test_token.py new file mode 100644 index 0000000..6cf5f5a --- /dev/null +++ b/tests/unit/services/test_token.py @@ -0,0 +1,294 @@ +"""Tests for API token generation service - T15. + +Tests for generating and verifying API tokens with SHA-256 hashing. +""" + +import hashlib +import secrets + +import pytest + + +pytestmark = [pytest.mark.unit, pytest.mark.security] + + +class TestGenerateAPIToken: + """Test suite for generate_api_token function.""" + + def test_generate_api_token_returns_tuple(self): + """Test that generate_api_token returns a tuple.""" + # Arrange + from src.openrouter_monitor.services.token import generate_api_token + + # Act + result = generate_api_token() + + # Assert + assert isinstance(result, tuple) + assert len(result) == 2 + + def test_generate_api_token_returns_plaintext_and_hash(self): + """Test that generate_api_token returns (plaintext, hash).""" + # Arrange + from src.openrouter_monitor.services.token import generate_api_token + + # Act + plaintext, token_hash = generate_api_token() + + # Assert + assert isinstance(plaintext, str) + assert isinstance(token_hash, str) + assert len(plaintext) > 0 + assert len(token_hash) > 0 + + def test_generate_api_token_starts_with_prefix(self): + """Test that plaintext token starts with 'or_api_'.""" + # Arrange + from src.openrouter_monitor.services.token import generate_api_token + + # Act + plaintext, _ = generate_api_token() + + # Assert + assert plaintext.startswith("or_api_") + + def test_generate_api_token_generates_different_tokens(self): + """Test that each call generates a different token.""" + # Arrange + from src.openrouter_monitor.services.token import generate_api_token + + # Act + plaintext1, hash1 = generate_api_token() + plaintext2, hash2 = generate_api_token() + + # Assert + assert plaintext1 != plaintext2 + assert hash1 != hash2 + + def test_generate_api_token_hash_is_sha256(self): + """Test that hash is SHA-256 of the plaintext.""" + # Arrange + from src.openrouter_monitor.services.token import generate_api_token + + # Act + plaintext, token_hash = generate_api_token() + + # Calculate expected hash + expected_hash = hashlib.sha256(plaintext.encode()).hexdigest() + + # Assert + assert token_hash == expected_hash + assert len(token_hash) == 64 # SHA-256 hex is 64 chars + + def test_generate_api_token_plaintext_sufficient_length(self): + """Test that plaintext token has sufficient length.""" + # Arrange + from src.openrouter_monitor.services.token import generate_api_token + + # Act + plaintext, _ = generate_api_token() + + # Assert - or_api_ prefix + 48 chars of token_urlsafe + assert len(plaintext) > 50 # prefix (7) + 48 chars = at least 55 + + +class TestHashToken: + """Test suite for hash_token function.""" + + def test_hash_token_returns_string(self): + """Test that hash_token returns a string.""" + # Arrange + from src.openrouter_monitor.services.token import hash_token + + token = "test-token" + + # Act + result = hash_token(token) + + # Assert + assert isinstance(result, str) + assert len(result) == 64 # SHA-256 hex + + def test_hash_token_is_sha256(self): + """Test that hash_token produces SHA-256 hash.""" + # Arrange + from src.openrouter_monitor.services.token import hash_token + + token = "or_api_test_token" + + # Act + result = hash_token(token) + + # Assert + expected = hashlib.sha256(token.encode()).hexdigest() + assert result == expected + + def test_hash_token_consistent(self): + """Test that hash_token produces consistent results.""" + # Arrange + from src.openrouter_monitor.services.token import hash_token + + token = "consistent-token" + + # Act + hash1 = hash_token(token) + hash2 = hash_token(token) + + # Assert + assert hash1 == hash2 + + +class TestVerifyAPIToken: + """Test suite for verify_api_token function.""" + + def test_verify_api_token_valid_returns_true(self): + """Test that verify_api_token returns True for valid token.""" + # Arrange + from src.openrouter_monitor.services.token import ( + generate_api_token, + verify_api_token, + ) + + plaintext, token_hash = generate_api_token() + + # Act + result = verify_api_token(plaintext, token_hash) + + # Assert + assert result is True + + def test_verify_api_token_invalid_returns_false(self): + """Test that verify_api_token returns False for invalid token.""" + # Arrange + from src.openrouter_monitor.services.token import ( + generate_api_token, + verify_api_token, + ) + + _, token_hash = generate_api_token() + wrong_plaintext = "wrong-token" + + # Act + result = verify_api_token(wrong_plaintext, token_hash) + + # Assert + assert result is False + + def test_verify_api_token_wrong_hash_returns_false(self): + """Test that verify_api_token returns False with wrong hash.""" + # Arrange + from src.openrouter_monitor.services.token import ( + generate_api_token, + verify_api_token, + ) + + plaintext, _ = generate_api_token() + wrong_hash = hashlib.sha256("different".encode()).hexdigest() + + # Act + result = verify_api_token(plaintext, wrong_hash) + + # Assert + assert result is False + + def test_verify_api_token_uses_timing_safe_comparison(self): + """Test that verify_api_token uses timing-safe comparison.""" + # Arrange + from src.openrouter_monitor.services.token import ( + generate_api_token, + verify_api_token, + ) + + plaintext, token_hash = generate_api_token() + + # Act - Should not raise any error and work correctly + result = verify_api_token(plaintext, token_hash) + + # Assert + assert result is True + + def test_verify_api_token_empty_strings(self): + """Test verify_api_token with empty strings.""" + # Arrange + from src.openrouter_monitor.services.token import verify_api_token + + # Act + result = verify_api_token("", "") + + # Assert + assert result is False + + +class TestTokenFormat: + """Test suite for token format validation.""" + + def test_token_contains_only_urlsafe_characters(self): + """Test that token contains only URL-safe characters.""" + # Arrange + from src.openrouter_monitor.services.token import generate_api_token + + # Act + plaintext, _ = generate_api_token() + token_part = plaintext.replace("or_api_", "") + + # Assert - URL-safe base64 chars: A-Z, a-z, 0-9, -, _ + urlsafe_chars = set( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + ) + assert all(c in urlsafe_chars for c in token_part) + + def test_hash_is_hexadecimal(self): + """Test that hash is valid hexadecimal.""" + # Arrange + from src.openrouter_monitor.services.token import generate_api_token + + # Act + _, token_hash = generate_api_token() + + # Assert + try: + int(token_hash, 16) + except ValueError: + pytest.fail("Hash is not valid hexadecimal") + + def test_generated_tokens_are_unique(self): + """Test that generating many tokens produces unique values.""" + # Arrange + from src.openrouter_monitor.services.token import generate_api_token + + # Act + tokens = [generate_api_token()[0] for _ in range(100)] + + # Assert + assert len(set(tokens)) == 100 # All unique + + +class TestTokenTypeValidation: + """Test suite for type validation in token functions.""" + + def test_hash_token_non_string_raises_type_error(self): + """Test that hash_token with non-string raises TypeError.""" + # Arrange + from src.openrouter_monitor.services.token import hash_token + + # Act & Assert + with pytest.raises(TypeError, match="plaintext must be a string"): + hash_token(12345) + + def test_verify_api_token_non_string_plaintext_raises_type_error(self): + """Test that verify_api_token with non-string plaintext raises TypeError.""" + # Arrange + from src.openrouter_monitor.services.token import verify_api_token + + # Act & Assert + with pytest.raises(TypeError, match="plaintext must be a string"): + verify_api_token(12345, "valid_hash") + + def test_verify_api_token_non_string_hash_raises_type_error(self): + """Test that verify_api_token with non-string hash raises TypeError.""" + # Arrange + from src.openrouter_monitor.services.token import verify_api_token + + # Act & Assert + with pytest.raises(TypeError, match="token_hash must be a string"): + verify_api_token("valid_plaintext", 12345)