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] 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) - 0/5 completati
|
### 🔐 Servizi di Sicurezza (T12-T16) - 1/5 completati
|
||||||
- [ ] T12: Implementare EncryptionService (AES-256) - 🟡 In progress
|
- [x] T12: Implementare EncryptionService (AES-256) - ✅ Completato (2026-04-07 12:00, commit: 2fdd9d1)
|
||||||
- [ ] T13: Implementare password hashing (bcrypt)
|
- [ ] T13: Implementare password hashing (bcrypt) - 🟡 In progress
|
||||||
- [ ] T14: Implementare JWT utilities
|
- [ ] T14: Implementare JWT utilities
|
||||||
- [ ] T15: Implementare API token generation
|
- [ ] T15: Implementare API token generation
|
||||||
- [ ] T16: Scrivere test per servizi di encryption
|
- [ ] 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