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:
@@ -52,11 +52,11 @@
|
|||||||
- [x] T10: Creare model ApiToken (SQLAlchemy) - ✅ Completato (2026-04-07 11:15)
|
- [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)
|
- [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] 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)
|
- [x] T13: Implementare password hashing (bcrypt) - ✅ Completato (2026-04-07 12:15, commit: 54e8116)
|
||||||
- [ ] T14: Implementare JWT utilities - 🟡 In progress
|
- [x] T14: Implementare JWT utilities - ✅ Completato (2026-04-07 12:30, commit: 781e564)
|
||||||
- [ ] T15: Implementare API token generation
|
- [ ] T15: Implementare API token generation - 🟡 In progress
|
||||||
- [ ] T16: Scrivere test per servizi di encryption
|
- [ ] T16: Scrivere test per servizi di encryption
|
||||||
|
|
||||||
### 👤 Autenticazione Utenti (T17-T22) - 0/6 completati
|
### 👤 Autenticazione Utenti (T17-T22) - 0/6 completati
|
||||||
|
|||||||
84
src/openrouter_monitor/services/token.py
Normal file
84
src/openrouter_monitor/services/token.py
Normal 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)
|
||||||
294
tests/unit/services/test_token.py
Normal file
294
tests/unit/services/test_token.py
Normal 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)
|
||||||
Reference in New Issue
Block a user