第 88 课:密码安全:哈希、加盐与 bcrypt
学习目标
完成本课学习后,你将能够:
- 理解为什么绝对不能将用户密码以明文形式存储在数据库中。
- 掌握使用哈希函数对密码进行单向加密的基本原理。
- 认识加盐(Salt) 技术如何有效防御"彩虹表攻击"。
- 学会使用 Python 的
bcrypt库,对密码进行安全地哈希和验证。 - 了解处理用户密码时的一些核心安全最佳实践。
核心概念
1. 为什么需要密码哈希?
想象一下,如果你的网站数据库被黑客攻破了,里面存的都是"123456"、"password"这样的原始密码。那不仅是你的网站,用户在其他地方使用相同密码的账户也将面临风险。因此,我们永远不应存储密码本身,而应存储一个密码的"指纹"——即密码的哈希值。
哈希是一个单向函数,它将任意长度的输入(比如你的密码"mysecretpassword")转换成一个固定长度、看似杂乱的字符串(哈希值)。它有两个关键特性:
- 单向性:无法从哈希值反推出原始密码。
- 确定性:相同的输入永远产生相同的输出。
2. 哈希的弱点:彩虹表
早期的密码哈希方案(如直接使用 MD5 或 SHA1)存在一个致命缺陷。黑客可以预先计算出海量常见密码(如字典单词、数字组合)的哈希值,并存储成一个巨大的"彩虹表"。当他们获得你数据库中的哈希值后,只需查表就能快速找到对应的原始密码。
3. 加盐:对抗彩虹表的武器
加盐(Salt) 是在进行哈希之前,向原始密码中插入一段随机生成的、独一无二的字符串。例如,用户密码 "p@ssw0rd" 加盐 "x7j2#kL" 后,实际被哈希的是 "p@ssw0rdx7j2#kL"。
这带来了两个巨大好处:
- 防御彩虹表:每个用户的盐都不同,即使两个用户密码相同,哈希后的结果也完全不同。黑客无法再使用通用彩虹表进行查表攻击。
- 隐藏相同密码:无法通过哈希值看出哪些用户使用了相同的密码。
4. bcrypt:专为密码而生的哈希函数
bcrypt 是一个现代密码哈希函数,它天生就支持加盐,并且通过可调节的工作因子(Cost Factor) 来控制哈希计算的速度。这一点至关重要:对于验证登录的用户来说,0.5秒的延迟可以接受;但对于试图暴力破解数百万密码的黑客来说,这将是灾难性的。成本因子可以随着计算机速度的提升而增加,从而对抗未来的破解能力。
代码示例
我们将使用 bcrypt 库来演示完整的密码存储和验证流程。
首先,请确保已安装该库:
pip install bcrypt
import bcrypt
import getpass # 用于安全地从终端获取密码,避免回显
def hash_password(plain_text_password: str) -> bytes:
"""
将明文密码哈希化。
bcrypt 会自动生成一个随机的盐,并将其嵌入到返回的哈希值中。
"""
# 1. 将字符串密码编码为字节
password_bytes = plain_text_password.encode('utf-8')
# 2. 生成哈希。`gensalt()` 会生成一个随机的盐,其参数 `rounds` 控制成本因子(默认为12)。
# 这个过程会有些慢,这正是其安全性的来源。
hashed = bcrypt.hashpw(password_bytes, bcrypt.gensalt())
# 3. 返回的 hashed 是一个字节串,其中包含了盐和算法信息。
# 例如:b'$2b$12$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
return hashed
def check_password(plain_text_password: str, hashed_password: bytes) -> bool:
"""
验证明文密码与存储的哈希密码是否匹配。
"""
# 1. 编码明文密码
password_bytes = plain_text_password.encode('utf-8')
# 2. 使用 `checkpw` 进行验证。它会从 `hashed_password` 中提取盐,
# 对输入的密码进行同样的哈希计算,然后比较结果。
# 这个函数包含了用于时序攻击保护的逻辑。
return bcrypt.checkpw(password_bytes, hashed_password)
# === 模拟用户注册与登录 ===
if __name__ == "__main__":
print("--- 用户注册 ---")
# 模拟从表单获取密码(安全起见,使用 getpass)
user_password = getpass.getpass("请设置您的密码:")
# 哈希并存储密码(通常存入数据库)
stored_hash = hash_password(user_password)
print(f"密码哈希值已生成并存储: {stored_hash}")
# 真实应用中,你会把 `stored_hash` 存入数据库的 `password_hash` 字段。
print("\n--- 用户登录验证 ---")
# 模拟用户登录时输入密码
login_password = getpass.getpass("请输入您的密码:")
# 用存储的哈希值验证输入的密码
if check_password(login_password, stored_hash):
print("✅ 验证成功!欢迎回来。")
else:
print("❌ 验证失败,密码错误!")
# 演示加盐的效果
print("\n--- 加盐演示 ---")
same_password = "UserPass123!"
hash1 = hash_password(same_password)
hash2 = hash_password(same_password)
print(f"对相同密码 '{same_password}' 进行两次哈希:")
print(f"哈希值1: {hash1}")
print(f"哈希值2: {hash2}")
print(f"结果相同? {hash1 == hash2}") # 输出:False。因为盐是随机的。
运行说明:上述代码在终端运行时,getpass 模块会隐藏你的密码输入,更贴近实际应用。
实践练习
练习 1(基础):哈希比较
编写一个函数 compare_hashes(password1, password2),它接收两个明文密码,分别对它们进行哈希,然后打印出两个哈希值是否相同。通过这个实验,理解哈希的确定性。
练习 2(进阶):实现加盐哈希
不使用 bcrypt,尝试用 hashlib 和 os.urandom 手动实现一个简单的加盐哈希过程。
- 生成一个 16 字节的随机盐 (
salt = os.urandom(16))。 - 将密码和盐拼接起来 (
salted = salt + password.encode())。 - 使用
hashlib.sha256(salted).hexdigest()进行哈希。 - 将盐和哈希值一起存储(例如,用
$分隔:salt_hex + '$' + hash_hex)。 - 编写一个对应的验证函数。
提示:这仅仅用于教学,生产环境请务必使用 bcrypt 或类似的专用库。
练习 3(综合):模拟注册登录系统
创建一个简单的字典 user_db 来模拟数据库。
- 实现一个
register函数,接收用户名和密码,将用户名和密码的 bcrypt 哈希值 存入字典。 - 实现一个
login函数,接收用户名和密码,根据字典中存储的哈希值进行验证,并返回成功或失败。 - 编写主程序,允许用户进行多次注册和登录测试。
常见错误
- 使用过时或不安全的哈希算法:切勿使用
MD5、SHA1甚至SHA256直接对密码哈希。它们计算速度太快,且不支持内置加盐。 - 忘记或错误地加盐:盐必须是每个用户唯一且密码学安全随机生成的。不要使用用户ID、创建时间等可预测的值作为盐。
- 将盐与哈希分开存储:使用
bcrypt,盐和哈希值是捆绑在一起的($2b$...字符串的一部分),必须整体存储和读取,以确保验证正确。 - 硬编码成本因子:
bcrypt的成本因子(如bcrypt.gensalt(rounds=12))应作为配置项,以便未来可以轻松增加计算成本。不要将其硬编码在代码中。 - 时序攻击:虽然
bcrypt.checkpw已内置防护,但自己实现时,永远不要使用==直接比较两个哈希字节串,因为这可能泄露信息。应使用hmac.compare_digest。
小结
本节课我们深入探讨了密码安全的核心实践:
- 根本原则:永远不要存储明文密码。
- 哈希:使用单向函数生成密码"指纹",但单纯的哈希易受彩虹表攻击。
- 加盐:为每个密码添加随机数据,使相同的密码产生不同的哈希值,彻底挫败彩虹表。
- bcrypt:它是当前的行业标准。它内建了加盐,并具有可调节的工作因子,能有效抵抗暴力破解。
- 关键实践:在 Python 中,始终使用像
bcrypt这样经过严格审查的库来处理密码哈希,而不是自己"造轮子"。
掌握了密码哈希的安全存储,是构建可信赖应用程序的基石。在下一课中,我们将学习如何通过环境变量和配置管理,进一步保护应用中的其他敏感信息(如数据库连接密钥、API密钥等)。