feat(security): T13 implement bcrypt password hashing

- Add password hashing with bcrypt (12 rounds)
- Implement verify_password with timing-safe comparison
- Add validate_password_strength with comprehensive rules
  - Min 12 chars, uppercase, lowercase, digit, special char
- 19 comprehensive tests with 100% coverage
- Handle TypeError for non-string inputs
This commit is contained in:
Luca Sacchi Ricciardi
2026-04-07 12:06:38 +02:00
parent 2fdd9d16fd
commit 54e81162df
3 changed files with 376 additions and 3 deletions

View File

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

View File

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

View File

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