首页 / FastAPI 入门教程 / 密码哈希

FastAPI 入门教程

密码哈希

本教程共 50 篇 · 第 27 篇 · 更新于 2026-08-12 · 约 7 分钟阅读

FastAPIFastAPI 入门教程密码哈希bcryptpasslib安全

本节目标:明白为什么绝对不能把密码明文存进数据库,学会用 pwdlib 的 bcrypt 把密码变成哈希,并理解”加盐”为什么让哈希既不可还原又能抗彩虹表。

27-1 明文存密码:最危险的习惯

很多新手写登录功能时,会顺手把用户密码原样存进数据库。这看起来省事,其实是在埋雷。

设想你的数据库被拖库(被黑客整个下载走)。如果密码是明文,黑客立刻拿到所有人的真实密码。更糟的是,很多人所有网站用同一个密码。拿到你这里的密码,黑客就能去试邮箱、试银行、试别的平台。

所以一条铁律:数据库里永远只能存密码的哈希值,绝不能存明文。

Note

“哈希”和”加密”不是一回事。加密是可逆的(有密钥就能解回原文);哈希是单向的,算出来容易,反过来推原文几乎不可能。密码要的是哈希,不是加密。

27-2 哈希到底是什么

哈希(hash)是把任意内容变成一串固定长度字符的过程。对密码来说,就是把 "secret123" 变成像乱码一样的一串。

它有两个关键性质:

  1. 同样的输入,永远得到同样的输出。对于普通哈希(比如 SHA),你第二次算 "secret123",得到的结果和第一次一模一样。后端靠这个性质来核对密码。
  2. 无法从输出反推输入。看到那串乱码,你算不出原密码是什么。这就是”单向”的意义。

所以登录时,后端不存密码,只存哈希。用户再来登录,把输入的密码重新哈希一遍,和库里存的哈希比对,一样就说明密码对。下一节会看到,专门用于密码的 bcrypt 会在哈希时混入随机”盐”,因此同一个密码每次哈希的结果并不相同,但仍然能正确校验——这正是它比普通哈希更安全的地方。

secret123  --哈希-->  $2b$12$EixZaYVK1fsbw1ZfbX3OXe...

27-3 选 bcrypt:成熟又省心

哈希算法不少,比如 MD5、SHA 系列。但它们算得太快,不适合存密码——黑客可以用显卡每秒试几十亿个密码。

bcrypt 专门为密码设计,它故意算得慢,而且能调节”成本”让破解越来越难。它还能自动把”盐”混进去,是存密码的稳妥选择。

过去常用的 passlib 库已经停止维护,而且它和新版 bcrypt(4.1 及以上)存在兼容问题,按旧教程安装后调用会直接报错。FastAPI 官方文档现在改用 pwdlib 来统一管理哈希。pwdlib 支持 bcrypt 和 Argon2 两种算法(官方默认更推荐 Argon2),本教程沿用 bcrypt,便于理解加盐和成本因子。先装好依赖:

pip install "pwdlib[bcrypt]"
Tip

pip install "pwdlib[bcrypt]" 这种写法,方括号里的 bcrypt 是”额外依赖”,它会顺带把 bcrypt 底层库也装上。只装 pwdlib 不装 bcrypt 是不行的。

27-4 创建密码上下文

pwdlib 通过一个 PasswordHash 对象来干活。它像一个小工厂,告诉你”用哪种方案哈希、怎么校验”。

from pwdlib import PasswordHash
from pwdlib.hashers.bcrypt import BcryptHasher

password_hash = PasswordHash((BcryptHasher(),))

注意 PasswordHash 接收一个元组——(BcryptHasher(),) 末尾的逗号不能省,里面列出要启用的哈希器。这里只用 bcrypt。

这个 password_hash 一旦建好,整个项目都能复用。通常放在一个 security.py 之类的小模块里。

27-5 用 hash 把密码变成乱码

注册用户、或修改密码时,调用 password_hash.hash() 把明文密码变成哈希串:

def get_password_hash(password: str) -> str:
    return password_hash.hash(password)


# 使用
hashed = get_password_hash("secret123")
print(hashed)
# 类似:$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW

注意每次运行,hash 出来的字符串都不一样。这很正常,因为里面混了随机盐(下节讲)。但不管串怎么变,它都能正确代表那个密码。

存库时,就存这个 hashed 值。明文 "secret123" 从此只在用户打字的那一瞬间存在于内存,绝不落盘。

27-6 用 verify 校验密码对不对

登录时用户又输入了密码。这时绝不能用 == 比较,而要调用 password_hash.verify()

def verify_password(plain_password: str, hashed_password: str) -> bool:
    return password_hash.verify(plain_password, hashed_password)


# 使用
ok = verify_password("secret123", hashed)
print(ok)  # True

ok2 = verify_password("wrongpass", hashed)
print(ok2)  # False

verify 会拿用户输入的明文,和库里那个哈希做比对,返回 TrueFalse。它内部自动处理了盐,你不用管。

Note

永远不要在代码里写 if password == stored_password。库里存的是哈希不是明文,这样比永远不等;而且就算能比,也意味着你存了明文。用 verify 才对。

27-7 加盐:对抗”彩虹表”的关键

你可能会问:既然普通哈希是”同样的密码得到同样的结果”,黑客不能提前算好一本”密码→哈希”的大字典来反查吗?对普通哈希能,这种字典叫彩虹表(rainbow table)

bcrypt 的解法是加盐(salting)。盐是一段随机字符串,哈希时把它混进密码一起算。于是:

  • 同一个密码 "secret123",每次加盐不同,哈希结果就不同。
  • 黑客没法预先算好字典,因为盐是随机的、千奇百怪。

盐本身不需要保密,它直接写在哈希串开头(就是 $2b$12$... 里那一段)。校验时 verify 会自动从串里取出盐,重新混算再比对。你完全不用手动存盐。

$2b$ 算法标识
$12$ 成本因子(越大越慢)
后面跟着的就是盐 + 哈希结果

这就是 bcrypt 哈希串能”自包含”的原因:盐和结果都在一个字符串里,数据库只存这一列就够了。

27-8 一个可运行的最小示例

把前面的拼起来,就是一个完整可跑的密码哈希小例子。它没接数据库,只用字典演示思路:

from pwdlib import PasswordHash
from pwdlib.hashers.bcrypt import BcryptHasher

password_hash = PasswordHash((BcryptHasher(),))


def get_password_hash(password: str) -> str:
    return password_hash.hash(password)


def verify_password(plain_password: str, hashed_password: str) -> bool:
    return password_hash.verify(plain_password, hashed_password)


# 模拟数据库:用户名 -> 哈希
fake_db = {
    "johndoe": get_password_hash("secret"),
}

# 登录校验
def authenticate(username: str, password: str) -> bool:
    hashed = fake_db.get(username)
    if not hashed:
        return False
    return verify_password(password, hashed)


print(authenticate("johndoe", "secret"))    # True
print(authenticate("johndoe", "bad"))       # False
Tip

真实项目里,这个 fake_db 换成数据库查询即可,哈希逻辑一行都不用改。密码哈希是”写一次,处处用”的典型。

27-9 小结

密码哈希三件事要记牢:

  • 明文密码绝不能进数据库,存哈希。
  • pwdlib[bcrypt]hash 负责把密码变成哈希存储,verify 负责登录校验。
  • 加盐让每个哈希都独一无二,彩虹表失效,且盐自带在哈希串里。

下一章我们就把”校验密码”接进真正的登录接口,并用 JWT 把登录成功的用户变成一张令牌发出去。