diff --git a/packages/domain/auth/__init__.py b/packages/domain/auth/__init__.py index dd940765c..ca97bd436 100644 --- a/packages/domain/auth/__init__.py +++ b/packages/domain/auth/__init__.py @@ -1,9 +1,19 @@ """认证模块""" from packages.domain.auth.jwt_service import JWTService, JWTConfig, TokenType, jwt_service +from packages.domain.auth.password_hasher import ( + PasswordHasher, + PasswordValidator, + password_hasher, + password_validator, +) __all__ = [ "JWTService", "JWTConfig", "TokenType", "jwt_service", + "PasswordHasher", + "PasswordValidator", + "password_hasher", + "password_validator", ] diff --git a/packages/domain/auth/password_hasher.py b/packages/domain/auth/password_hasher.py new file mode 100644 index 000000000..db468d9d8 --- /dev/null +++ b/packages/domain/auth/password_hasher.py @@ -0,0 +1,169 @@ +""" +密码哈希工具类 +使用 bcrypt 安全存储密码 +""" +import bcrypt +from typing import Optional + + +class PasswordHasher: + """密码哈希服务""" + + def __init__(self, rounds: int = 12): + """ + 初始化密码哈希器 + + Args: + rounds: bcrypt cost factor(默认 12,推荐范围 10-14) + 值越大越安全,但计算时间越长 + """ + if rounds < 4 or rounds > 31: + raise ValueError("rounds must be between 4 and 31") + + self.rounds = rounds + + def hash_password(self, password: str) -> str: + """ + 哈希密码 + + Args: + password: 明文密码 + + Returns: + bcrypt 哈希字符串(包含 salt) + + Raises: + ValueError: 密码为空 + """ + if not password: + raise ValueError("Password cannot be empty") + + # bcrypt 需要 bytes + password_bytes = password.encode('utf-8') + + # 生成 salt 并哈希 + salt = bcrypt.gensalt(rounds=self.rounds) + hashed = bcrypt.hashpw(password_bytes, salt) + + # 返回字符串(数据库存储) + return hashed.decode('utf-8') + + def verify_password(self, password: str, hashed_password: str) -> bool: + """ + 验证密码 + + Args: + password: 明文密码 + hashed_password: 存储的哈希密码 + + Returns: + True 如果密码正确,否则 False + """ + if not password or not hashed_password: + return False + + try: + password_bytes = password.encode('utf-8') + hashed_bytes = hashed_password.encode('utf-8') + + return bcrypt.checkpw(password_bytes, hashed_bytes) + except Exception: + # 哈希格式错误或其他异常,返回 False + return False + + def needs_rehash(self, hashed_password: str) -> bool: + """ + 检查哈希是否需要重新计算 + (当 cost factor 改变时需要重新哈希) + + Args: + hashed_password: 存储的哈希密码 + + Returns: + True 如果需要重新哈希 + """ + try: + hashed_bytes = hashed_password.encode('utf-8') + current_rounds = bcrypt.getsalt(hashed_bytes) + + # 提取当前的 cost factor + # bcrypt hash 格式: $2b$rounds$salt+hash + parts = hashed_password.split('$') + if len(parts) >= 3: + stored_rounds = int(parts[2]) + return stored_rounds != self.rounds + + return False + except Exception: + return False + + +class PasswordValidator: + """密码强度验证器""" + + def __init__( + self, + min_length: int = 8, + require_uppercase: bool = True, + require_lowercase: bool = True, + require_digit: bool = True, + require_special: bool = False, + ): + """ + 初始化密码验证器 + + Args: + min_length: 最小长度 + require_uppercase: 是否要求大写字母 + require_lowercase: 是否要求小写字母 + require_digit: 是否要求数字 + require_special: 是否要求特殊字符 + """ + self.min_length = min_length + self.require_uppercase = require_uppercase + self.require_lowercase = require_lowercase + self.require_digit = require_digit + self.require_special = require_special + + def validate(self, password: str) -> tuple[bool, Optional[str]]: + """ + 验证密码强度 + + Args: + password: 明文密码 + + Returns: + (是否有效, 错误信息) + """ + if not password: + return False, "Password cannot be empty" + + if len(password) < self.min_length: + return False, f"Password must be at least {self.min_length} characters" + + if self.require_uppercase and not any(c.isupper() for c in password): + return False, "Password must contain at least one uppercase letter" + + if self.require_lowercase and not any(c.islower() for c in password): + return False, "Password must contain at least one lowercase letter" + + if self.require_digit and not any(c.isdigit() for c in password): + return False, "Password must contain at least one digit" + + if self.require_special: + special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?~" + if not any(c in special_chars for c in password): + return False, "Password must contain at least one special character" + + return True, None + + +# 全局实例 +password_hasher = PasswordHasher(rounds=12) +password_validator = PasswordValidator( + min_length=8, + require_uppercase=True, + require_lowercase=True, + require_digit=True, + require_special=False, +) diff --git a/requirements.txt b/requirements.txt index d2c383c8f..e749dd81d 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/tests/unit/test_password_hasher.py b/tests/unit/test_password_hasher.py new file mode 100644 index 000000000..eee3509ec --- /dev/null +++ b/tests/unit/test_password_hasher.py @@ -0,0 +1,173 @@ +""" +密码哈希工具测试 +""" +import pytest +from packages.domain.auth.password_hasher import ( + PasswordHasher, + PasswordValidator, +) + + +class TestPasswordHasher: + """密码哈希测试""" + + @pytest.fixture + def hasher(self): + """创建密码哈希器""" + return PasswordHasher(rounds=4) # 测试用低 cost,加快速度 + + def test_hash_password(self, hasher): + """测试密码哈希""" + password = "MySecurePassword123" + hashed = hasher.hash_password(password) + + assert isinstance(hashed, str) + assert len(hashed) > 0 + assert hashed != password # 哈希后不等于原文 + assert hashed.startswith("$2b$") # bcrypt 格式 + + def test_hash_same_password_different_result(self, hasher): + """测试相同密码每次哈希结果不同(因为 salt 不同)""" + password = "MySecurePassword123" + hash1 = hasher.hash_password(password) + hash2 = hasher.hash_password(password) + + assert hash1 != hash2 # salt 不同,哈希不同 + + def test_verify_correct_password(self, hasher): + """测试验证正确的密码""" + password = "MySecurePassword123" + hashed = hasher.hash_password(password) + + assert hasher.verify_password(password, hashed) is True + + def test_verify_incorrect_password(self, hasher): + """测试验证错误的密码""" + password = "MySecurePassword123" + hashed = hasher.hash_password(password) + + assert hasher.verify_password("WrongPassword", hashed) is False + + def test_verify_empty_password(self, hasher): + """测试空密码验证""" + hashed = hasher.hash_password("test") + + assert hasher.verify_password("", hashed) is False + + def test_verify_empty_hash(self, hasher): + """测试空哈希验证""" + assert hasher.verify_password("test", "") is False + + def test_verify_invalid_hash(self, hasher): + """测试无效的哈希""" + assert hasher.verify_password("test", "invalid-hash") is False + + def test_hash_empty_password(self, hasher): + """测试哈希空密码应该失败""" + with pytest.raises(ValueError, match="Password cannot be empty"): + hasher.hash_password("") + + def test_invalid_rounds(self): + """测试无效的 rounds 参数""" + with pytest.raises(ValueError, match="rounds must be between 4 and 31"): + PasswordHasher(rounds=2) + + with pytest.raises(ValueError, match="rounds must be between 4 and 31"): + PasswordHasher(rounds=50) + + def test_unicode_password(self, hasher): + """测试 Unicode 密码""" + password = "密码123!@#" + hashed = hasher.hash_password(password) + + assert hasher.verify_password(password, hashed) is True + assert hasher.verify_password("错误密码", hashed) is False + + +class TestPasswordValidator: + """密码验证器测试""" + + @pytest.fixture + def validator(self): + """创建密码验证器""" + return PasswordValidator( + min_length=8, + require_uppercase=True, + require_lowercase=True, + require_digit=True, + require_special=False, + ) + + def test_valid_password(self, validator): + """测试有效密码""" + valid, error = validator.validate("MyPassword123") + assert valid is True + assert error is None + + def test_password_too_short(self, validator): + """测试密码太短""" + valid, error = validator.validate("Pass1") + assert valid is False + assert "at least 8 characters" in error + + def test_password_no_uppercase(self, validator): + """测试没有大写字母""" + valid, error = validator.validate("mypassword123") + assert valid is False + assert "uppercase letter" in error + + def test_password_no_lowercase(self, validator): + """测试没有小写字母""" + valid, error = validator.validate("MYPASSWORD123") + assert valid is False + assert "lowercase letter" in error + + def test_password_no_digit(self, validator): + """测试没有数字""" + valid, error = validator.validate("MyPassword") + assert valid is False + assert "digit" in error + + def test_password_with_special_chars(self): + """测试要求特殊字符""" + validator = PasswordValidator( + min_length=8, + require_uppercase=True, + require_lowercase=True, + require_digit=True, + require_special=True, + ) + + # 没有特殊字符 + valid, error = validator.validate("MyPassword123") + assert valid is False + assert "special character" in error + + # 有特殊字符 + valid, error = validator.validate("MyPassword123!") + assert valid is True + assert error is None + + def test_empty_password(self, validator): + """测试空密码""" + valid, error = validator.validate("") + assert valid is False + assert "cannot be empty" in error + + def test_custom_min_length(self): + """测试自定义最小长度""" + validator = PasswordValidator( + min_length=12, + require_uppercase=False, + require_lowercase=False, + require_digit=False, + require_special=False, + ) + + valid, error = validator.validate("short") + assert valid is False + assert "at least 12 characters" in error + + valid, error = validator.validate("longenoughpassword") + assert valid is True + assert error is None