Skip to content

38|密码哈希与 JWT 认证

运维平台需要区分用户身份:谁登录了、能做什么操作。认证(Authentication)验证"你是谁",授权(Authorization)决定"你能做什么"。这篇先讲认证的基础:安全存储密码、颁发和验证 JWT Token。

一、密码哈希

绝对不要明文存储密码。即使数据库被攻破,攻击者拿到的是不可逆的哈希值,无法直接获得原始密码。

bcrypt

bash
uv add bcrypt
python
import bcrypt

# 注册时:哈希密码后存储
password = "user_password"
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))
# hashed: b'$2b$12$...'

# 登录时:校验密码
is_valid = bcrypt.checkpw(password.encode(), hashed)
参数作用
bcrypt.gensalt(rounds=12)生成盐值,rounds 是计算强度,越大越慢越安全
bcrypt.hashpw()生成哈希
bcrypt.checkpw()校验密码是否匹配

bcrypt 自带盐值,每次哈希结果都不同,即使两个用户使用相同密码,存储的哈希值也不一样。

为什么不直接用 MD5/SHA256

MD5 和 SHA 系列是快速哈希,设计目的是计算校验和,不是存储密码。攻击者可以用 GPU 每秒计算数十亿次哈希,暴力破解容易。

bcrypt 是慢哈希,故意设计得计算缓慢(可调 rounds),让暴力破解成本极高。

二、JWT(JSON Web Token)

用户登录成功后,服务端颁发一个 JWT Token,客户端后续请求带上这个 Token,服务端验证 Token 的有效性来识别用户身份。

JWT 结构(三部分,用点号分隔):

header.payload.signature
部分内容
Header算法类型(如 HS256)
Payload声明(claims),如用户 ID、角色、过期时间
Signature用密钥对前两部分签名,防止篡改

安装

bash
uv add pyjwt

生成 Token

python
import jwt
from datetime import datetime, timedelta

SECRET_KEY = "your-secret-key-change-in-production"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

def create_access_token(data: dict) -> str:
    to_encode = data.copy()
    expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    to_encode.update({"exp": expire})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

# 使用
token = create_access_token({"sub": "admin", "role": "admin"})
Claim含义
subSubject,通常是用户标识
exp过期时间(Unix 时间戳)
iat签发时间

验证 Token

python
def decode_token(token: str) -> dict:
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        return payload
    except jwt.ExpiredSignatureError:
        raise ValueError("Token 已过期")
    except jwt.InvalidTokenError:
        raise ValueError("Token 无效")

三、FastAPI 集成

OAuth2PasswordBearer

FastAPI 提供 OAuth2PasswordBearer 方便地从请求头中提取 Token:

python
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/login")

async def get_current_user(token: str = Depends(oauth2_scheme)):
    payload = decode_token(token)
    user_id = payload.get("sub")
    if user_id is None:
        raise HTTPException(status_code=401, detail="无效凭证")
    return user_id

@app.get("/api/me")
async def read_me(user_id: str = Depends(get_current_user)):
    return {"user_id": user_id}

OAuth2PasswordBearer 自动从 Authorization: Bearer <token> 请求头中提取 Token。没有 Token 或格式不对时,自动返回 401。

登录接口

python
from fastapi import Form

@app.post("/api/login")
async def login(username: str = Form(...), password: str = Form(...)):
    user = get_user_by_username(username)
    if not user or not bcrypt.checkpw(password.encode(), user.password_hash):
        raise HTTPException(status_code=401, detail="用户名或密码错误")

    token = create_access_token({"sub": user.id, "role": user.role})
    return {"access_token": token, "token_type": "bearer"}

四、安全要点

密钥管理

SECRET_KEY 必须足够随机,生产环境不能硬编码在代码中:

python
import os

SECRET_KEY = os.environ.get("SECRET_KEY")
if not SECRET_KEY:
    raise ValueError("SECRET_KEY 环境变量未设置")

Token 过期时间

Access Token 不宜过长(通常 15~60 分钟),减少 Token 泄露后的风险窗口。需要长期保持登录的,用 Refresh Token 机制:

  1. 登录时颁发短效的 Access Token(30 分钟)和长效的 Refresh Token(7 天)
  2. Access Token 过期后,用 Refresh Token 换取新的 Access Token
  3. Refresh Token 可以撤销(如用户修改密码后使所有 Refresh Token 失效)

HTTPS

Token 在请求头中明文传输,必须通过 HTTPS 发送,否则中间人可以截获 Token。

五、常见错误

明文存储密码

python
# 错误
user.password = request.password   # 明文!

# 正确
user.password_hash = bcrypt.hashpw(request.password.encode(), bcrypt.gensalt())

Token 没有设置过期时间

python
# 错误:永久有效的 Token,泄露后永远可用
jwt.encode({"sub": "admin"}, SECRET_KEY, algorithm="HS256")

# 正确:设置 exp
jwt.encode({"sub": "admin", "exp": datetime.utcnow() + timedelta(minutes=30)}, ...)

在 Token 中存放敏感信息

python
# 错误:JWT 的 payload 只是 base64 编码,没有加密,任何人都能解码读取
jwt.encode({"sub": "admin", "password": "123456"}, ...)

# 正确:只放用户标识和角色
jwt.encode({"sub": "admin", "role": "admin"}, ...)

用对称算法 HS256 但密钥泄露

HS256 用同一个密钥签名和验证。如果密钥泄露,攻击者可以伪造任意 Token。多服务场景考虑用 RS256(非对称算法),服务端用私钥签名,其他服务用公钥验证。