encrypt.js — 前端密码哈希工具说明文档

encrypt.js --- 前端密码哈希工具说明文档

概述

基于浏览器原生 Web Crypto APIwindow.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 "";
    }
  }
}
相关推荐
灵析表格1 小时前
灵析表格功能函数深度分析报告
前端·数据库·microsoft
Data_Journal1 小时前
掌握网页抓取中的分页:完整指南
java·服务器·前端
breeze jiang1 小时前
React useRef + Web Worker:避免大计算阻塞页面的通信方案
前端·javascript·react.js
fthux1 小时前
MCP协议开发实战:从零搭建AI Agent工具链
前端·人工智能·ai·开源·github
黄贵根7 小时前
JavaScript实现教培行业意向登记系统
开发语言·javascript·ecmascript
codeGoogle10 小时前
自研 IM 还是选择第三方 SDK?企业开发者应该如何权衡?
前端·后端·程序员
zzzzzz31011 小时前
从 react-bits 看动效组件化:别把视觉效果写成一次性页面代码
javascript·react.js·开源
用户9385156350712 小时前
React Context 与自定义 Hook 从底层到实践:「跨层级通信 + 副作用封装」全解析
前端·javascript·react.js