feat(security): T15 implement API token generation

- Add generate_api_token with format 'or_api_' + 48 bytes random
- Implement hash_token with SHA-256
- Add verify_api_token with timing-safe comparison (secrets.compare_digest)
- Only hash stored in DB, plaintext shown once
- 20 comprehensive tests with 100% coverage
- Handle TypeError for non-string inputs
This commit is contained in:
Luca Sacchi Ricciardi
2026-04-07 12:12:39 +02:00
parent 781e564ea0
commit 649ff76d6c
3 changed files with 381 additions and 3 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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)