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:
@@ -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
|
||||
|
||||
99
src/openrouter_monitor/services/password.py
Normal file
99
src/openrouter_monitor/services/password.py
Normal 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
|
||||
274
tests/unit/services/test_password.py
Normal file
274
tests/unit/services/test_password.py
Normal 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
|
||||
Reference in New Issue
Block a user