从明文到双重哈希

如何用「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 负责保护存储,两者各司其职,组合在一起就能覆盖绝大多数的威胁场景。

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

阅读更多

为了平凡的生活

为了平凡的生活

为了翻拣垃圾的孩子,和他们的梦想。 为了那些枯萎的树木, 为了那些如纸牌般倒塌的危楼。 为了那些安眠药, 和那些漫长而失眠的夜晚。 为了那些被困在原地的人, 为了改变那些腐朽而停滞的头脑。 为了那些被加速的人生, 为了恐慌,焦虑,和没有尽头的比较。 为了那些被迫接受的规则, 为了那些被强加的指令, 为了那些看不见的手, 我们成长, 成功, 不断向前。 加速学习, 加速工作, 加速赚钱, 加速消费, 加速体验, 加速衰老。 好像停下来, 就会落在后面。 我想慢下来。 想要一份普通的工作, 不用害怕失去。 想让孩子好好长大, 让老人慢慢变老。 想在傍晚回家, 吃一顿晚饭。 想让夜晚只是夜晚, 而不是焦虑、失眠和安眠药。 想让树长在那里, 而不是倒在推土机经过的地方。 想让房子能够站得住, 住进去,可以安心睡觉。 想让一个人活着, 不需要不停证明自己活得有价值。 我们曾经以为, 生活应该越来越精彩。 后来发现, 真正难得的是它能够保持正常。 没有突然坍塌的房子, 没有突然失去的工作, 没有突然降临的疾病,