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:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Binary file not shown.
@@ -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
|
||||
Reference in New Issue
Block a user