Chapter 06

认证与会话安全

密码哈希、MFA 多因素认证、JWT 安全陷阱——构建坚不可摧的身份认证体系

密码安全:正确的哈希方式

为什么不能用 MD5 / SHA-1 / SHA-256 存储密码

普通哈希算法(MD5、SHA 系列)被设计为尽可能快速——这对密码存储是灾难性的。现代 GPU 可以每秒计算数十亿次 MD5,配合彩虹表(预先计算的哈希-明文对应表),破解 MD5 哈希的密码可能只需几秒钟。

彩虹表攻击原理: 预计算阶段(离线,针对无盐哈希): "password123" → MD5 → "482c811da5d5b4bc6d497ffa98491e38" "qwerty" → MD5 → "d8578edf8458ce06fbc5bb76a58c5ca4" ...(数十亿条预计算记录存入数据库) 攻击阶段: 泄露数据库中的哈希值 → 反向查表 → 立即得到明文密码 防御:每个密码加唯一随机 Salt: 密码 "hello" + Salt "xK8m9P" → 哈希 → 每次结果不同 → 彩虹表完全失效(因为攻击者不知道 Salt)

bcrypt:专为密码存储设计

bcrypt 由 Niels Provos 和 David Mazières 于 1999 年设计,专门为密码哈希而生。特点:① 内置随机 Salt;② 计算速度可调(cost factor);③ 设计为"慢速"——即使攻击者获取哈希值,暴力破解成本极高。

import bcrypt

# 注册时:哈希密码
password = "user_password_123".encode('utf-8')
# cost=12 表示 2^12=4096 次计算迭代,在现代硬件上约需 0.3 秒
# 推荐范围:10-14,越高越慢,越慢越安全
hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))
# 存储 hashed 到数据库,bcrypt 自动将 salt 嵌入哈希字符串中

# 登录时:验证密码
def verify_password(input_password: str, stored_hash: bytes) -> bool:
    return bcrypt.checkpw(input_password.encode('utf-8'), stored_hash)
    # 使用恒定时间比较(防止时序攻击),不要用 == 比较哈希值
// Node.js:bcrypt
const bcrypt = require('bcrypt');

// 注册:哈希密码
const saltRounds = 12;
const hashedPassword = await bcrypt.hash(plainPassword, saltRounds);

// 登录:验证密码
const isMatch = await bcrypt.compare(inputPassword, storedHash);
// bcrypt.compare 使用恒定时间比较,安全

Argon2:密码哈希的现代标准

Argon2 赢得了 2015 年密码哈希竞赛(Password Hashing Competition),是 OWASP 目前首推的密码哈希算法。相比 bcrypt,Argon2 还可以配置内存使用量,使 GPU/ASIC 暴力破解成本更高。

from argon2 import PasswordHasher

ph = PasswordHasher(
    time_cost=3,       # 迭代次数
    memory_cost=65536,  # 内存使用:64MB(使 GPU 破解更贵)
    parallelism=2,      # 并行线程数
    hash_len=32
)

# 注册
hashed = ph.hash("user_password")

# 验证
try:
    ph.verify(hashed, "input_password")
    # 验证成功
    if ph.check_needs_rehash(hashed):
        # 如果安全参数已更新,重新哈希密码
        new_hash = ph.hash("input_password")
except argon2.exceptions.VerifyMismatchError:
    # 密码错误
    abort(401)

多因素认证(MFA)

认证的三个因素

你知道的(Knowledge)
密码、PIN、安全问题。最脆弱的因素——可以被猜测、钓鱼、数据泄露。单独使用是不够的。
你拥有的(Possession)
手机(TOTP 验证码)、硬件密钥(YubiKey)、备用码。比密码强,但手机可能被盗,TOTP 可以被钓鱼。
你是什么(Inherence)
指纹、面部识别、声纹。最便捷,但可以被绕过(高质量照片、硅胶手指),通常用于本地解锁而非远程认证。

TOTP:基于时间的一次性密码

TOTP(Time-based One-Time Password,RFC 6238)是最普及的 MFA 方案。每 30 秒生成一个 6 位数字,计算方式:HMAC-SHA1(secret_key, floor(time/30))。Google Authenticator、Authy 等应用都实现了 TOTP。

import pyotp
import qrcode

# 为用户生成 TOTP 密钥(注册时)
secret = pyotp.random_base32()  # 生成随机密钥,加密存储到数据库

# 生成二维码 URI(供 Google Authenticator 等扫码)
totp_uri = pyotp.totp.TOTP(secret).provisioning_uri(
    name="user@example.com",
    issuer_name="MyApp"
)
# 生成 QR 码让用户扫描
img = qrcode.make(totp_uri)

# 验证用户输入的 6 位 TOTP 码
def verify_totp(user_secret: str, user_input: str) -> bool:
    totp = pyotp.TOTP(user_secret)
    # valid_window=1 允许前后 30 秒的时间偏差(应对网络延迟)
    return totp.verify(user_input, valid_window=1)

FIDO2 / WebAuthn:抗钓鱼的硬件密钥

TOTP 的弱点:用户可能在钓鱼网站上输入 TOTP 码,攻击者立即用它登录真实网站。FIDO2/WebAuthn 使用公钥密码学,认证过程绑定了域名——密钥对由浏览器/硬件设备生成,签名包含了目标域名,在任何其他域名下都无效,从根本上防止了钓鱼攻击。

// WebAuthn 注册新凭证(前端)
const credential = await navigator.credentials.create({
  publicKey: {
    challenge: serverChallenge,  // 服务器生成的随机挑战值
    rp: { name: "My App", id: "example.com" },
    user: { id: userId, name: email, displayName: name },
    pubKeyCredParams: [
      { type: "public-key", alg: -7 }   // ES256 算法
    ],
    authenticatorSelection: {
      userVerification: "required"  // 要求用户验证(指纹/PIN)
    }
  }
});
// 将 credential 发送到服务器,保存公钥

JWT 安全:常见陷阱与正确实践

JWT 结构

JSON Web Token(JWT)由三部分组成,Base64URL 编码后用 . 连接:Header.Payload.Signature。JWT 的安全完全依赖签名——如果签名验证正确,Payload 中的数据就被信任。

严重漏洞一:alg:none 攻击

JWT Header 中的 alg 字段指定签名算法。某些早期 JWT 库支持 alg: "none"(无签名),且在验证时接受无签名的 Token。攻击者可以将任意 JWT 的 alg 改为 "none",删除签名部分,然后修改 Payload(如将 "role":"user" 改为 "role":"admin"),库会直接接受。

# 危险的 JWT 验证代码
payload = jwt.decode(token, options={"verify_signature": False})  # 完全跳过验证!

# 安全的 JWT 验证(指定算法白名单)
import jwt

def verify_token(token: str, secret: str) -> dict:
    try:
        payload = jwt.decode(
            token,
            secret,
            algorithms=["HS256"],  # 明确指定允许的算法,拒绝 "none"
            options={
                "require": ["exp", "iat", "sub"]  # 强制要求过期时间等字段
            }
        )
        return payload
    except jwt.ExpiredSignatureError:
        raise ValueError("Token 已过期")
    except jwt.InvalidTokenError as e:
        raise ValueError(f"Token 无效: {e}")

严重漏洞二:RS256 → HS256 算法混淆攻击

当系统使用 RS256(非对称加密)时,公钥是公开的。如果 JWT 库接受 HS256 算法,攻击者可以用服务器的公钥作为 HS256 的对称密钥,伪造签名——因为服务器会用"公钥"验证 HS256 签名,而攻击者也知道这个"公钥"。防御:服务端验证时必须明确指定且固定算法。

JWT 最佳实践

短过期时间
Access Token 设置较短的过期时间(15分钟-1小时),配合 Refresh Token 机制(7-30天,但可撤销)。短期 Access Token 即使泄露,危害窗口有限。
足够强的 Secret
HS256 的 Secret 必须足够长(至少 256 位 = 32 字节),使用 secrets.token_hex(32) 生成。弱 Secret(如 "secret"、"password")可被字典攻击破解。
不存储敏感数据
JWT Payload 只是 Base64 编码,不是加密——任何人都可以解码读取内容。不要在 JWT 中存储密码、信用卡号、个人隐私数据。
Token 吊销难题
JWT 是无状态的,无法像 Session 一样"立即失效"。解决方案:① 短过期时间;② 维护吊销列表(黑名单,牺牲无状态性);③ 版本号(用户数据中存储 token_version,每次登出递增)。

Session 安全

Session ID 的安全要求

# Flask Session 安全配置
from flask import Flask
import secrets

app = Flask(__name__)
app.secret_key = secrets.token_hex(32)  # 32 字节随机密钥,不能硬编码!

app.config.update(
    SESSION_COOKIE_HTTPONLY=True,    # JS 无法读取 Session Cookie
    SESSION_COOKIE_SECURE=True,      # 只通过 HTTPS 传输
    SESSION_COOKIE_SAMESITE='Lax',   # 防 CSRF
    PERMANENT_SESSION_LIFETIME=3600   # 1小时过期
)

Session 固定攻击与修复

Session 固定(Session Fixation)攻击:攻击者让受害者使用攻击者已知的 Session ID 登录,登录后攻击者可以用同一 Session ID 冒充受害者。防御:用户登录成功后,立即生成新的 Session ID(Session 重生成)。

# 登录成功后重生成 Session ID(防止 Session 固定攻击)
@app.route('/login', methods=['POST'])
def login():
    user = authenticate(request.form['username'], request.form['password'])
    if not user:
        return abort(401)

    # 保存登录前的 Session 数据
    old_data = dict(session)

    # 清除旧 Session(使旧 Session ID 失效)
    session.clear()

    # 生成新 Session(Flask 自动分配新 Session ID)
    session['user_id'] = user.id
    session['login_time'] = datetime.utcnow().isoformat()

    return redirect('/')

登录安全防御

账号枚举防护

# 危险:不同的错误消息泄露账号是否存在
if not user:
    return "该邮箱未注册"  # 泄露账号不存在!
if not check_password(user, password):
    return "密码错误"       # 泄露账号存在但密码错误!

# 安全:统一的错误消息
if not user or not check_password(user, password):
    return "邮箱或密码错误"  # 无法推断是哪个错了

# 即使账号不存在,也应该执行哈希计算(防止时序攻击)
dummy_hash = "$2b$12$dummy_hash_for_timing_attack_prevention"
bcrypt.checkpw(password.encode(), dummy_hash.encode())  # 消耗相同时间

登录限速与账号锁定

from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
import redis

limiter = Limiter(
    app=app,
    key_func=get_remote_address,
    default_limits=["200 per day", "50 per hour"]
)

redis_client = redis.Redis()

@app.route('/login', methods=['POST'])
@limiter.limit("5 per minute")  # 每个 IP 每分钟最多 5 次尝试
def login():
    username = request.form['username']
    key = f"failed_login:{username}"
    failed = int(redis_client.get(key) or 0)

    if failed >= 10:
        return "账号已临时锁定,请 15 分钟后重试或通过邮件解锁", 429

    user = authenticate(username, request.form['password'])
    if not user:
        redis_client.incr(key)
        redis_client.expire(key, 900)  # 15 分钟后自动解锁
        return "邮箱或密码错误", 401

    redis_client.delete(key)  # 登录成功,清除失败计数
    return "登录成功"
认证安全最佳实践清单
本章小结

认证安全包含密码存储、多因素认证、Token 安全、Session 管理四个维度。密码必须用 bcrypt 或 Argon2 哈希(不是加密,不是普通哈希);MFA 大幅提升账号安全,FIDO2/WebAuthn 是抗钓鱼的最强 MFA;JWT 的常见漏洞(alg:none、算法混淆)源于错误的验证配置,务必指定算法白名单;Session 在登录后必须重生成,防止固定攻击;登录接口必须有限速机制,防止暴力破解和账号枚举。