encrypt.js --- 前端密码哈希工具说明文档
概述
基于浏览器原生 Web Crypto API (window.crypto.subtle)封装的密码哈希工具类,零依赖、无需密钥。
核心思路:密码在前端做 SHA-256 单向哈希,只把哈希值传给后端。后端存储和比对的是同一个哈希值,全程没有明文密码出现在网络中。
类比 MD5 登录,但 SHA-256 远比 MD5 安全。哈希是单向摘要,无法反推出原始密码。
架构流程
用户输入密码 "123456"
│
▼
┌──────────────────────────┐
│ new Encrypt({ ... }) │ ← 创建实例,设置全局配置
└──────────┬───────────────┘
│
▼
┌──────────────────────────┐
│ encrypt.hash(password) │ ← 调用哈希方法
└──────────┬───────────────┘
│
┌─────┴─────┐
│ 哪种模式? │
└─────┬─────┘
│
┌───────┼───────────┐
▼ ▼ ▼
基础模式 加盐模式 挑战-应答
SHA256(p) SHA256(p+s) HMAC(SHA256(p), nonce)
│ │ │
└───────┴───────────┘
│
▼
"8d969eef6ecad3c29a3a629280e686cf..."
│
▼
request.post("/api/login", { password: hash })
│
▼
┌──────────┐
│ 后端 │ 比对数据库中的哈希值
└──────────┘
构造函数
js
new Encrypt(options?)
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
options.salt |
string |
"" |
全局盐值,实例级别默认加盐。调用 hash() 时可局部覆盖 |
options.format |
"hex" | "base64" |
"hex" |
默认输出格式,调用 hash() 时可局部覆盖 |
API 参考
encrypt.hash(password, options?) → Promise<string>
密码哈希 ------ 核心方法,登录/注册/改密场景直接用这个。
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
password |
string |
必填 | 明文密码 |
options.salt |
string |
取构造函数 | 盐值,可与密码拼接后一起哈希 |
options.nonce |
string |
--- | 随机数,挑战-应答模式使用 |
options.format |
"hex" | "base64" |
取构造函数 | 输出格式 |
encrypt.sha256(data) → Promise<string>
通用 SHA-256,返回 64 位十六进制字符串。
encrypt.sha256Base64(data) → Promise<string>
同上,返回 Base64 格式。
encrypt.hmacSha256(data, key) → Promise<string>
HMAC-SHA256,用于挑战-应答签名。
使用示例
0. 创建实例
js
import Encrypt from "@/utils/encrypt";
// 默认配置(hex 输出、无全局盐)
const encrypt = new Encrypt();
// 带全局配置
const encrypt = new Encrypt({ format: "base64", salt: "global_salt" });
1. 基础用法 ------ 直接哈希
js
const encrypt = new Encrypt();
// 登录
async function login(username, password) {
const hashed = await encrypt.hash(password);
await request.post("/api/login", {
username,
password: hashed,
});
}
// 注册
async function register(username, password) {
const hashed = await encrypt.hash(password);
await request.post("/api/register", {
username,
password: hashed,
});
}
2. 加盐 ------ 每个用户唯一盐值
js
const encrypt = new Encrypt();
// 1. 先从后端获取该用户的盐值
const { salt } = await request.get(`/api/user/salt?username=${username}`);
// 2. 加盐后哈希
const hashed = await encrypt.hash(password, { salt });
await request.post("/api/login", { username, password: hashed });
3. 挑战-应答 ------ 防重放攻击
js
const encrypt = new Encrypt();
// 1. 从后端获取一次性 nonce
const { nonce } = await request.get(`/api/login-nonce?username=${username}`);
// 2. 挑战-应答哈希
const hashed = await encrypt.hash(password, { nonce });
await request.post("/api/login", { username, nonce, password: hashed });
4. 在 Vue 组件中使用
js
import Encrypt from "@/utils/encrypt";
import request from "@/utils/request";
export default {
data() {
return {
username: "",
password: "",
encrypt: new Encrypt(),
};
},
methods: {
async handleLogin() {
try {
const hashed = await this.encrypt.hash(this.password);
await request.post("/api/login", {
username: this.username,
password: hashed,
});
this.$message.success("登录成功");
} catch (err) {
this.$message.error(err.message);
}
},
},
};
后端集成
Java / Spring Boot
java
import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;
public class PasswordUtil {
/**
* 对密码做 SHA-256 哈希,与前端保持一致
*/
public static String sha256(String input) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for (byte b : hash) {
hex.append(String.format("%02x", b));
}
return hex.toString();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
// 注册:直接存前端传来的哈希
public void register(String username, String hashedPassword) {
userMapper.insert(username, hashedPassword);
}
// 登录:比对哈希值
public boolean login(String username, String hashedPassword) {
String stored = userMapper.getPassword(username);
return stored != null && stored.equals(hashedPassword);
}
// 加盐模式:SHA256( password + salt )
public boolean loginWithSalt(String username, String hashedPassword, String salt) {
String stored = userMapper.getPassword(username);
String expected = sha256(stored + salt);
return expected.equals(hashedPassword);
}
// 挑战-应答模式:HMAC-SHA256( SHA256(password), nonce )
public boolean loginWithNonce(String username, String hashedPassword, String nonce) {
String stored = userMapper.getPassword(username);
String expected = hmacSha256(stored, nonce);
return expected.equals(hashedPassword);
}
private String hmacSha256(String key, String data) {
try {
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
javax.crypto.spec.SecretKeySpec spec =
new javax.crypto.spec.SecretKeySpec(
hexToBytes(key), "HmacSHA256"
);
mac.init(spec);
byte[] result = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for (byte b : result) hex.append(String.format("%02x", b));
return hex.toString();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static byte[] hexToBytes(String hex) {
int len = hex.length();
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte)
((Character.digit(hex.charAt(i), 16) << 4)
+ Character.digit(hex.charAt(i + 1), 16));
}
return data;
}
}
Node.js (Express)
js
const crypto = require("crypto");
function sha256(data) {
return crypto.createHash("sha256").update(data, "utf8").digest("hex");
}
// 登录接口
app.post("/api/login", async (req, res) => {
const { username, password } = req.body;
// password 已是前端 SHA-256 后的值,直接比对
const user = await db.findUser(username);
if (!user || user.password !== password) {
return res.json({ code: 401, message: "用户名或密码错误" });
}
res.json({ code: 200, data: { token: "xxx" } });
});
Python (Flask)
python
import hashlib
def sha256(data: str) -> str:
return hashlib.sha256(data.encode("utf-8")).hexdigest()
@app.route("/api/login", methods=["POST"])
def login():
data = request.json
username = data.get("username")
hashed_password = data.get("password") # 前端已做 SHA-256
user = db.find_user(username)
if not user or user["password"] != hashed_password:
return {"code": 401, "message": "用户名或密码错误"}
return {"code": 200, "data": {"token": "xxx"}}
安全说明
| 问题 | 解答 |
|---|---|
| 哈希值被截获了怎么办? | 哈希是单向的,无法反推原始密码。攻击者拿到哈希也无法登录其他网站(不同网站哈希不同)。如需更强保护,使用 HTTPS + 挑战-应答模式。 |
| 为什么不用 RSA? | RSA 需要引入 jsencrypt 依赖(~50KB),且需要后端维护密钥对。SHA-256 内置在浏览器中,零依赖、零配置。 |
| 基础模式够安全吗? | 配合 HTTPS 使用是安全的。如果对安全性要求更高,使用加盐或挑战-应答模式。 |
| 加盐的盐值谁生成? | 推荐后端在用户注册时生成随机 salt,存在用户表中。登录时先返回盐值给前端。 |
兼容性
| 浏览器 | 最低版本 |
|---|---|
| Chrome | 37+ |
| Edge | 79+ |
| Firefox | 34+ |
| Safari | 11+ |
| IE | ❌ 不支持 |
Vista / Server 2008 及以下系统、IE 全系列不支持 Web Crypto API。如需兼容,可降级使用 crypto-js 等 polyfill。
从函数式迁移到类
如果你之前用的是函数式导出,迁移只需两步:
js
// 之前
import { hashPassword } from "@/utils/encrypt";
const pwd = await hashPassword("123456");
// 现在
import Encrypt from "@/utils/encrypt";
const encrypt = new Encrypt();
const pwd = await encrypt.hash("123456");
旧的函数式导出已移除,统一使用 class Encrypt 实例化方式。
javascript
/**
* @fileoverview 前端密码哈希工具 ------ 零依赖、纯原生、无需密钥
*
* 使用浏览器内置 Web Crypto API(SubtleCrypto),
* 对密码做 SHA-256 哈希后再传输,杜绝明文密码出现在网络中。
*
* ===== 快速使用 =====
*
* import Encrypt from "@/utils/encrypt";
*
* const encrypt = new Encrypt({ format: "hex" });
*
* // 登录
* const pwd = await encrypt.hash("123456");
* await request.post("/api/login", { username, password: pwd });
*
* // 加盐
* const pwd2 = await encrypt.hash("123456", { salt: "user_salt" });
*
* // 挑战-应答
* const pwd3 = await encrypt.hash("123456", { nonce: "random_from_server" });
*
* ===== 兼容性 =====
*
* Chrome 37+ / Edge 79+ / Firefox 34+ / Safari 11+
* 不兼容 IE 11。
*/
// ============================================================================
// Encrypt 类
// ============================================================================
export default class Encrypt {
/**
* @param {object} [options] - 全局默认配置
* @param {string} [options.salt] - 全局盐值,实例级别的默认加盐
* @param {"hex"|"base64"} [options.format="hex"] - 默认输出格式
*/
constructor(options = {}) {
this.salt = options.salt || "";
this.format = options.format || "hex";
}
// ==========================================================================
// 私有工具方法
// ==========================================================================
/**
* 字符串 → UTF-8 字节数组
* @param {string} str
* @returns {Uint8Array}
*/
_stringToBytes(str) {
return new TextEncoder().encode(str);
}
/**
* ArrayBuffer → 十六进制字符串
* @param {ArrayBuffer} buffer
* @returns {string}
*/
_bufferToHex(buffer) {
const bytes = new Uint8Array(buffer);
let hex = "";
for (let i = 0; i < bytes.length; i++) {
hex += bytes[i].toString(16).padStart(2, "0");
}
return hex;
}
/**
* ArrayBuffer → Base64 字符串
* @param {ArrayBuffer} buffer
* @returns {string}
*/
_bufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* 检查 Web Crypto API 是否可用
* @returns {boolean}
*/
_checkCrypto() {
if (!window.crypto || !window.crypto.subtle) {
console.error("[Encrypt] 当前浏览器不支持 Web Crypto API");
return false;
}
return true;
}
// ==========================================================================
// 公开方法
// ==========================================================================
/**
* SHA-256 哈希(返回十六进制字符串)
*
* @param {string} data - 待哈希的原始数据
* @returns {Promise<string>} 64 位十六进制字符串,失败返回 ""
*
* @example
* const hex = await encrypt.sha256("hello");
* // "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
*/
async sha256(data) {
if (!data) {
console.warn("[Encrypt] sha256 入参为空");
return "";
}
if (!this._checkCrypto()) return "";
try {
const bytes = this._stringToBytes(data);
const buf = await window.crypto.subtle.digest("SHA-256", bytes);
return this._bufferToHex(buf);
} catch (err) {
console.error("[Encrypt] sha256 失败:", err);
return "";
}
}
/**
* SHA-256 哈希(返回 Base64 字符串)
*
* @param {string} data
* @returns {Promise<string>}
*/
async sha256Base64(data) {
if (!data) return "";
if (!this._checkCrypto()) return "";
try {
const bytes = this._stringToBytes(data);
const buf = await window.crypto.subtle.digest("SHA-256", bytes);
return this._bufferToBase64(buf);
} catch (err) {
console.error("[Encrypt] sha256Base64 失败:", err);
return "";
}
}
/**
* HMAC-SHA256 ------ 带密钥的哈希认证码
*
* 防重放攻击场景:后端下发随机 nonce,前端用密码做 key 对 nonce 做 HMAC,
* 每次登录产生的密文都不同。
*
* @param {string} data - 待签名的数据(如后端下发的随机 nonce)
* @param {string} key - 签名密钥(如用户密码的 SHA-256)
* @returns {Promise<string>} 十六进制 HMAC 值
*
* @example
* const hmac = await encrypt.hmacSha256("server_nonce", hashedPassword);
*/
async hmacSha256(data, key) {
if (!data || !key) {
console.warn("[Encrypt] hmacSha256 入参为空");
return "";
}
if (!this._checkCrypto()) return "";
try {
const keyBytes = this._stringToBytes(key);
const dataBytes = this._stringToBytes(data);
const cryptoKey = await window.crypto.subtle.importKey(
"raw",
keyBytes,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await window.crypto.subtle.sign("HMAC", cryptoKey, dataBytes);
return this._bufferToHex(sig);
} catch (err) {
console.error("[Encrypt] hmacSha256 失败:", err);
return "";
}
}
/**
* 密码哈希 ------ 登录 / 注册 / 改密场景的核心方法
*
* @param {string} password - 明文密码
* @param {object} [options] - 可覆盖构造函数中的全局配置
* @param {string} [options.salt] - 盐值(推荐后端为每个用户分配唯一值)
* @param {string} [options.nonce] - 随机数(挑战-应答模式,后端下发)
* @param {"hex"|"base64"} [options.format] - 输出格式,默认取构造函数中的设置
* @returns {Promise<string>} 哈希后的密码
*
* @example
* // 最简用法
* const pwd = await encrypt.hash("123456");
*
* // 加盐(盐值由后端提供,每个用户唯一)
* const pwd = await encrypt.hash("123456", { salt: "user_unique_salt" });
*
* // 挑战-应答(防重放)
* const { nonce } = await request.get("/api/login-nonce");
* const pwd = await encrypt.hash("123456", { nonce });
*/
async hash(password, options = {}) {
if (!password) {
console.warn("[Encrypt] hash 入参为空");
return "";
}
if (!this._checkCrypto()) return "";
const salt = options.salt ?? this.salt;
const nonce = options.nonce;
const format = options.format ?? this.format;
try {
let result;
if (nonce) {
// 挑战-应答:HMAC( SHA256(password), nonce )
const passwordHash = await this.sha256(password);
result = await this.hmacSha256(nonce, passwordHash);
} else if (salt) {
// 加盐:SHA256( password + salt )
result = await this.sha256(password + salt);
} else {
// 基础:SHA256( password )
result = await this.sha256(password);
}
// 格式转换
if (format === "base64" && result) {
return this._bufferToBase64(
new Uint8Array(
result.match(/.{1,2}/g).map(function (b) { return parseInt(b, 16); }),
).buffer,
);
}
return result;
} catch (err) {
console.error("[Encrypt] hash 失败:", err);
return "";
}
}
}