从明文到双重哈希

如何用「SHA-256 + bcrypt」双重哈希加密保护用户密码

plaintext to double hash
Photo by Joshua Hoehne / Unsplash

之前我的一个项目一直用明文存储用户密码——我知道,这很糟糕。最近终于下定决心做了一次彻底的改造,采用了「前端 SHA-256 + 后端 bcrypt」的双重加密方案。这篇文章就来聊聊这套方案的思路、实现和权衡。


为什么明文存储密码是不可接受的?

道理很简单:一旦数据库泄露,所有用户的密码就彻底暴露了。更糟的是,大多数人在多个平台复用同一个密码,这意味着攻击者可以用你泄露的数据库去撞其他平台,造成连锁伤害。

即便你认为自己的数据库"不会被黑",这也不是一个可以接受的赌注。


整体方案:两道防线

这套方案的核心思路是在两个不同的阶段分别做一次哈希:

用户输入明文密码
      │
      ▼
[前端] SHA-256 哈希  ──── 网络传输 ────▶  [后端] bcrypt 哈希
      │                                           │
      ▼                                           ▼
  localStorage                                数据库存储
  (记住密码时)                                pass_hash字段

两道防线各司其职:

  • SHA-256(前端):确保明文密码永远不会出现在网络请求中
  • bcrypt(后端):确保即使数据库泄露,攻击者也极难还原出原始哈希

第一道防线:前端 SHA-256

用户在登录框输入密码后,前端立刻用 crypto-js 对其做 SHA-256 哈希,然后把这个哈希值(一个 64 字符的十六进制字符串)发给后端,明文密码自始至终不离开用户的浏览器

// utils/crypto.ts
import CryptoJS from 'crypto-js';

export function hashPassword(plaintext: string): string {
  return CryptoJS.SHA256(plaintext).toString();
}

登录请求发出的是这样的内容:

{
  "username": "alice",
  "pass_hash": "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92"
}

如果用户勾选了「记住密码」localStorage 里存的也是这个 SHA-256 值,而非明文。即便用户的本地存储被读取,攻击者拿到的也只是一个哈希。


第二道防线:后端 bcrypt

后端(Rust)接收到 SHA-256 字符串后,并不直接存入数据库,而是再用 bcrypt 对它做一次哈希。

// src/main.rs
use bcrypt::{hash, DEFAULT_COST};

let pass_hash = hash(&sha256_from_frontend, DEFAULT_COST)?;
// 生成类似 "$2b$12$KixG.v88VwR..." 的字符串,存入数据库

bcrypt 有两个关键特性让它非常适合存储密码:

  1. 内置随机 Salt:每次哈希结果都不同,天然防止彩虹表攻击。即使两个用户的密码完全一样,数据库里存的 hash 也完全不同。
  2. 计算成本可调DEFAULT_COST(通常为 10~12)意味着每次哈希需要消耗一定的计算资源,让暴力破解的代价极高。

登录时的验证也很直接:

use bcrypt::verify;

let is_valid = verify(&sha256_from_frontend, &stored_bcrypt_hash)?;

一个完整的例子

假设用户密码是 123456,完整流程如下:

阶段 操作 结果
用户输入 在浏览器输入密码 123456
前端哈希 SHA256("123456") 8d969eef...c92(64字符)
网络传输 发送给后端 {"pass_hash": "8d969eef..."}
后端哈希 bcrypt("8d969eef...") $2b$12$KixG.v88VwR...
数据库存储 写入 pass_hash 字段 $2b$12$KixG.v88VwR...
下次登录 bcrypt::verify(前端SHA256, 数据库bcrypt) 匹配 ✓

向后兼容:存量账号怎么处理?

改造前已有的账号,数据库里存的可能是明文或纯 SHA-256。强制要求所有用户重置密码体验很差,所以我在登录逻辑里加了静默升级:

用户登录
   │
   ├─ 是 bcrypt 格式? ──▶ 直接 bcrypt::verify,通过则完成
   │
   ├─ 是旧 SHA-256? ──▶ 对比后通过,立刻重新用 bcrypt 存储,替换旧记录
   │
   └─ 是明文? ──▶ 对比后通过,立刻升级为 bcrypt 格式

用户无感知,每次登录后账号自动升级,存量问题会随着时间自然消解。


这个方案能防住什么?

威胁场景 防御效果
网络抓包/中间人 ✅ 传输的是 SHA-256,明文不暴露
数据库拖库 ✅ 存的是 bcrypt,暴力破解成本极高
彩虹表攻击 ✅ bcrypt 内置 Salt,查表无效
多平台撞库 ✅ 攻击者拿到的 SHA-256 是针对本站的中间值,无法直接用于其他站

有没有不足?

坦白说,这套方案并非无懈可击:

  • 前端 SHA-256 本身不是「加密」,它是确定性哈希。如果攻击者截获了 SHA-256 值,他可以直接用这个值向服务器发起重放攻击(replay attack)。不过因为上了 HTTPS,截获的可能性就极低了。
  • 更严格的方案会在前端加入随机 Salt 或者用 PBKDF2/Argon2 替代 SHA-256,但对于这个项目来说,现有方案的安全性已经够了。
  • 本质上,防御纵深越深,实现复杂度越高。这套「SHA-256 + bcrypt」方案在安全性和实现成本之间取了一个合理的平衡点。

从明文到双重哈希,这次改造带给我最大的感受是:密码安全不需要造轮子,用对工具就够了。SHA-256 负责保护传输,bcrypt 负责保护存储,两者各司其职,组合在一起就能覆盖绝大多数的威胁场景。

如果你的项目还在用明文存密码,希望这篇文章能给你一点动力,早改早安心。