diff --git a/export/progress.md b/export/progress.md index a960494..f22dacb 100644 --- a/export/progress.md +++ b/export/progress.md @@ -52,9 +52,9 @@ - [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) - 0/5 completati -- [ ] T12: Implementare EncryptionService (AES-256) - 🟑 In progress -- [ ] T13: Implementare password hashing (bcrypt) +### πŸ” Servizi di Sicurezza (T12-T16) - 1/5 completati +- [x] T12: Implementare EncryptionService (AES-256) - βœ… Completato (2026-04-07 12:00, commit: 2fdd9d1) +- [ ] T13: Implementare password hashing (bcrypt) - 🟑 In progress - [ ] T14: Implementare JWT utilities - [ ] T15: Implementare API token generation - [ ] T16: Scrivere test per servizi di encryption diff --git a/src/openrouter_monitor/services/password.py b/src/openrouter_monitor/services/password.py new file mode 100644 index 0000000..d2f0280 --- /dev/null +++ b/src/openrouter_monitor/services/password.py @@ -0,0 +1,99 @@ +"""Password hashing and validation service. + +This module provides secure password hashing using bcrypt +and password strength validation. +""" + +import re + +from passlib.context import CryptContext + + +# CryptContext with bcrypt scheme +# bcrypt default rounds is 12 which is secure +pwd_context = CryptContext( + schemes=["bcrypt"], + deprecated="auto", + bcrypt__rounds=12, # Explicit for clarity +) + + +def hash_password(password: str) -> str: + """Hash a password using bcrypt. + + Args: + password: The plaintext password to hash. + + Returns: + The bcrypt hashed password. + + Raises: + TypeError: If password is not a string. + """ + if not isinstance(password, str): + raise TypeError("password must be a string") + + return pwd_context.hash(password) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a plaintext password against a hashed password. + + Args: + plain_password: The plaintext password to verify. + hashed_password: The bcrypt hashed password to verify against. + + Returns: + True if the password matches, False otherwise. + + Raises: + TypeError: If either argument is not a string. + """ + if not isinstance(plain_password, str): + raise TypeError("plain_password must be a string") + if not isinstance(hashed_password, str): + raise TypeError("hashed_password must be a string") + + return pwd_context.verify(plain_password, hashed_password) + + +def validate_password_strength(password: str) -> bool: + """Validate password strength. + + Password must meet the following criteria: + - At least 12 characters long + - At least one uppercase letter + - At least one lowercase letter + - At least one digit + - At least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?) + + Args: + password: The password to validate. + + Returns: + True if password meets all criteria, False otherwise. + """ + if not isinstance(password, str): + return False + + # Minimum length: 12 characters + if len(password) < 12: + return False + + # At least one uppercase letter + if not re.search(r"[A-Z]", password): + return False + + # At least one lowercase letter + if not re.search(r"[a-z]", password): + return False + + # At least one digit + if not re.search(r"\d", password): + return False + + # At least one special character + if not re.search(r"[!@#$%^&*()_+\-=\[\]{}|;':\",./<>?]", password): + return False + + return True diff --git a/tests/unit/services/test_password.py b/tests/unit/services/test_password.py new file mode 100644 index 0000000..9b1a368 --- /dev/null +++ b/tests/unit/services/test_password.py @@ -0,0 +1,274 @@ +"""Tests for password hashing service - T13. + +Tests for bcrypt password hashing with strength validation. +""" + +import pytest + + +pytestmark = [pytest.mark.unit, pytest.mark.security, pytest.mark.slow] + + +class TestPasswordHashing: + """Test suite for password hashing service.""" + + def test_hash_password_returns_string(self): + """Test that hash_password returns a string.""" + # Arrange + from src.openrouter_monitor.services.password import hash_password + + password = "SecurePass123!" + + # Act + hashed = hash_password(password) + + # Assert + assert isinstance(hashed, str) + assert len(hashed) > 0 + + def test_hash_password_generates_different_hash_each_time(self): + """Test that hashing same password produces different hashes (due to salt).""" + # Arrange + from src.openrouter_monitor.services.password import hash_password + + password = "SamePassword123!" + + # Act + hash1 = hash_password(password) + hash2 = hash_password(password) + + # Assert + assert hash1 != hash2 + assert hash1.startswith("$2b$") + assert hash2.startswith("$2b$") + + def test_verify_password_correct_returns_true(self): + """Test that verify_password returns True for correct password.""" + # Arrange + from src.openrouter_monitor.services.password import hash_password, verify_password + + password = "MySecurePass123!" + hashed = hash_password(password) + + # Act + result = verify_password(password, hashed) + + # Assert + assert result is True + + def test_verify_password_incorrect_returns_false(self): + """Test that verify_password returns False for incorrect password.""" + # Arrange + from src.openrouter_monitor.services.password import hash_password, verify_password + + password = "CorrectPass123!" + wrong_password = "WrongPass123!" + hashed = hash_password(password) + + # Act + result = verify_password(wrong_password, hashed) + + # Assert + assert result is False + + def test_verify_password_with_different_hash_fails(self): + """Test that verify_password fails with a hash from different password.""" + # Arrange + from src.openrouter_monitor.services.password import hash_password, verify_password + + password1 = "PasswordOne123!" + password2 = "PasswordTwo123!" + hashed1 = hash_password(password1) + + # Act + result = verify_password(password2, hashed1) + + # Assert + assert result is False + + +class TestPasswordStrengthValidation: + """Test suite for password strength validation.""" + + def test_validate_password_strong_returns_true(self): + """Test that strong password passes validation.""" + # Arrange + from src.openrouter_monitor.services.password import validate_password_strength + + passwords = [ + "SecurePass123!", + "MyP@ssw0rd2024", + "C0mpl3x!Pass#", + "Valid-Password-123!", + ] + + # Act & Assert + for password in passwords: + assert validate_password_strength(password) is True, f"Failed for: {password}" + + def test_validate_password_too_short_returns_false(self): + """Test that password less than 12 chars fails validation.""" + # Arrange + from src.openrouter_monitor.services.password import validate_password_strength + + passwords = [ + "Short1!", + "Abc123!", + "NoSpecial1", + "OnlyLower!", + ] + + # Act & Assert + for password in passwords: + assert validate_password_strength(password) is False, f"Should fail for: {password}" + + def test_validate_password_no_uppercase_returns_false(self): + """Test that password without uppercase fails validation.""" + # Arrange + from src.openrouter_monitor.services.password import validate_password_strength + + password = "lowercase123!" + + # Act + result = validate_password_strength(password) + + # Assert + assert result is False + + def test_validate_password_no_lowercase_returns_false(self): + """Test that password without lowercase fails validation.""" + # Arrange + from src.openrouter_monitor.services.password import validate_password_strength + + password = "UPPERCASE123!" + + # Act + result = validate_password_strength(password) + + # Assert + assert result is False + + def test_validate_password_no_digit_returns_false(self): + """Test that password without digit fails validation.""" + # Arrange + from src.openrouter_monitor.services.password import validate_password_strength + + password = "NoDigitsHere!" + + # Act + result = validate_password_strength(password) + + # Assert + assert result is False + + def test_validate_password_no_special_returns_false(self): + """Test that password without special char fails validation.""" + # Arrange + from src.openrouter_monitor.services.password import validate_password_strength + + password = "NoSpecialChar1" + + # Act + result = validate_password_strength(password) + + # Assert + assert result is False + + def test_validate_password_only_special_chars_returns_false(self): + """Test that password with only special chars fails validation.""" + # Arrange + from src.openrouter_monitor.services.password import validate_password_strength + + password = "!@#$%^&*()_+" + + # Act + result = validate_password_strength(password) + + # Assert + assert result is False + + def test_validate_password_empty_returns_false(self): + """Test that empty password fails validation.""" + # Arrange + from src.openrouter_monitor.services.password import validate_password_strength + + password = "" + + # Act + result = validate_password_strength(password) + + # Assert + assert result is False + + def test_validate_password_unicode_handled_correctly(self): + """Test that unicode password is handled correctly.""" + # Arrange + from src.openrouter_monitor.services.password import validate_password_strength + + password = "ζ—₯本θͺžγƒ‘γ‚Ήγƒ―ード123!" + + # Act + result = validate_password_strength(password) + + # Assert - Unicode chars are not special chars in regex sense + # but the password has uppercase/lowercase (in unicode), digits, and special + # This depends on implementation, but should not crash + assert isinstance(result, bool) + + def test_hash_and_verify_integration(self): + """Test full hash and verify workflow.""" + # Arrange + from src.openrouter_monitor.services.password import ( + hash_password, + verify_password, + validate_password_strength, + ) + + password = "Str0ng!Passw0rd" + + # Act & Assert + assert validate_password_strength(password) is True + hashed = hash_password(password) + assert verify_password(password, hashed) is True + assert verify_password("WrongPass", hashed) is False + + +class TestPasswordTypeValidation: + """Test suite for type validation in password functions.""" + + def test_hash_password_non_string_raises_type_error(self): + """Test that hash_password with non-string raises TypeError.""" + # Arrange + from src.openrouter_monitor.services.password import hash_password + + # Act & Assert + with pytest.raises(TypeError, match="password must be a string"): + hash_password(12345) + + def test_verify_password_non_string_plain_raises_type_error(self): + """Test that verify_password with non-string plain raises TypeError.""" + # Arrange + from src.openrouter_monitor.services.password import verify_password + + # Act & Assert + with pytest.raises(TypeError, match="plain_password must be a string"): + verify_password(12345, "hashed_password") + + def test_verify_password_non_string_hash_raises_type_error(self): + """Test that verify_password with non-string hash raises TypeError.""" + # Arrange + from src.openrouter_monitor.services.password import verify_password + + # Act & Assert + with pytest.raises(TypeError, match="hashed_password must be a string"): + verify_password("plain_password", 12345) + + def test_validate_password_strength_non_string_returns_false(self): + """Test that validate_password_strength with non-string returns False.""" + # Arrange + from src.openrouter_monitor.services.password import validate_password_strength + + # Act & Assert + assert validate_password_strength(12345) is False + assert validate_password_strength(None) is False + assert validate_password_strength([]) is False