feat(auth): add bcrypt password hasher with strength validator

- Implement PasswordHasher class with bcrypt (cost=12)
- Add PasswordValidator for password strength checking
- Support min length, uppercase, lowercase, digit, special chars
- Add 18 comprehensive unit tests (all passed)
- Install bcrypt dependency

Phase 4 Task 2/68 completed
This commit is contained in:
Xiaoxia AI
2026-06-17 01:08:10 +08:00
parent 2380bd9b92
commit 1d8d7bddb9
4 changed files with 352 additions and 0 deletions
+10
View File
@@ -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",
]
+169
View File
@@ -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,
)
BIN
View File
Binary file not shown.
+173
View File
@@ -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