从明文到双重哈希

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

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

Read more

开源的意义

开源的意义

最近在用 Trae SOLO,有一种很奇妙的感觉——想法刚冒出来,服务已经跑起来了。这让我开始认真思考一个问题:开源的意义,到底是什么? 传统意义上,开源解决的是效率问题。代码难写,一个人写不完,所以大家共享代码、集思广益、快速迭代。这是开源存在的经济学基础——通过聚合全球开发者的零散时间,对抗单个组织的能力瓶颈。 但现在,Trae SOLO、Claude Code 这类自主 AI 代理的出现,让这个前提开始动摇。当一个没有工程背景的产品经理也能把想法直接落地成生产服务,"人力不够"这个问题,已经不成立了。 代码生成的边际成本,正在趋近于零。 效率提升的麻烦 按理说,代码生成变快了,开源应该繁荣才对。但现实恰恰相反——大量 AI 生成的低质量 PR 正在淹没开源维护者。研究数据显示,AI 辅助代码产生的缺陷率约为人类代码的 1.

稀缺的执行力

稀缺的执行力

最近看到一个55岁的人写给30、40岁人的话,其中一句: “你越是拖延改变,改变就会变得越痛苦且代价越高。” 我没有特别大的感触——不是因为这句话不对,而是因为我早就活在这句话的另一面了。 我是一个行动力很强的人。有想法,当天就开始动。不确定,就先试。做错了,再调。这件事本身没什么了不起,但在AI这个时代,它突然变成了一种稀缺能力。 想法这个东西,从来不值钱 我身边不缺聪明人。 有人跟我聊过一个方向,我觉得不错,问他打算什么时候开始。他说,再等等,想清楚了再动。三个月后我们再聊,他还在”想”。又过了三个月,这个方向已经有人做出来了,还跑通了。 这不是个例。这几乎是一种普遍现象。 AI出来之后,这个问题被放大了十倍。工具门槛低了,信息差小了,一个普通人能做到的事情多了很多。照理说,应该有更多人去试、去做。但我观察到的恰恰相反——很多人花在”研究怎么用AI”上的时间,远远多于真正用AI做出任何东西的时间。 想法变得更廉价了,行动依然稀缺。