密码加密

何为密码加密

在 Spring Security 中,密码加密是保障系统安全的核心环节之一。Spring Security 通过PasswordEncoder接口统一规范了密码加密与验证的行为,其核心思想是:存储加密后的密码而非明文,并通过标准化方法验证用户输入的密码与存储的加密密码是否匹配。

明文密码指的是用户输入的原始密码(如abc123password),未经过任何加密或哈希处理,直接以字符串形式存储在数据库中。

但不是你加密了就不能破解,早期系统会使用哈希算法对密码进行处理:将明文密码通过哈希函数转换为一串不可逆的哈希值(如MD5("123456") = e10adc3949ba59abbe56e057f20f883e),然后存储哈希值而非明文。

彩虹表

但是这样,是双向加密,是可以被解密的,彩虹表(Rainbow Table) 就是针对这个缺陷设计的破解工具。

彩虹表的原理:

  • 哈希函数的特性是:相同明文必然产生相同哈希值(确定性)。例如,无论何时计算MD5("123456"),结果都是固定的e10adc3949ba59abbe56e057f20f883e
  • 黑客利用这一特性,预先计算海量常见密码(如字典词、简单数字组合)的哈希值,并将 “明文 - 哈希值” 对应关系存储在一张巨大的表中,这就是彩虹表。
  • 彩虹表的本质是:用存储空间换取破解时间—— 提前花时间计算并存储哈希值,破解时只需查表对比,无需重新计算。

彩虹表的破解流程

假设某系统用 MD5 存储密码,数据库中某用户的哈希值是e10adc3949ba59abbe56e057f20f883e

  1. 黑客获取该哈希值后,查询自己的彩虹表;
  2. 发现表中e10adc3949ba59abbe56e057f20f883e对应明文123456
  3. 直接使用123456登录该用户账户,破解成功。

传统哈希(如 MD5 + 无盐值)容易被彩虹表破解,而现代密码加密方式(如 BCrypt、Argon2)通过两大核心技术解决了这一问题,实现 “难以破译”:

加盐密码

盐值(Salt):让彩虹表失效

盐值是一串随机生成的字符串,在加密时与明文密码混合后再进行哈希处理。加盐这一步就是让哈希算法具有单向性,但是其实是可以验证的,验证的逻辑根据后面不同的算法会详细的说

例如,对明文123456加密时:

  • 生成随机盐值(如x9#k2p);
  • 计算哈希(明文 + 盐值)(如哈希("123456x9#k2p"));
  • 最终存储的加密串包含盐值 + 哈希结果(如x9#k2p$abc123...)。

为什么能抵御彩虹表?

彩虹表是预计算 “明文→哈希值” 的对应关系,而加入盐值后,相同明文的加密结果完全不同(因盐值随机)。例如:

  • 明文123456+ 盐值x9#k2p → 哈希结果 A;
  • 明文123456+ 盐值y7!m5q → 哈希结果 B;

黑客无法预先计算所有可能的 “明文 + 盐值” 组合(盐值随机且长度可变,组合量趋近无穷),彩虹表彻底失效。

自适应单向函数

自适应哈希:让暴力破解成本极高

即使彩虹表失效,黑客仍可能尝试暴力破解:枚举所有可能的明文(如从azzzzzzzz),对每个明文加入盐值后计算哈希,与存储的加密串对比。

现代加密算法通过自适应哈希(调整计算复杂度)让暴力破解变得 “不现实”:

什么是自适应哈希?

算法允许通过参数控制哈希计算的 “难度”,例如:

  • 迭代次数:对哈希结果重复哈希 N 次(如 PBKDF2 的默认 65536 次迭代);
  • 内存成本:计算过程中消耗大量内存(如 Argon2 的内存成本参数);
  • 并行度:控制计算时的并行处理量(增加 GPU 破解难度)。

这些参数越大,计算一个哈希值的时间越长(例如从 1ms 增加到 100ms)。

为何能抵御暴力破解?

假设黑客要尝试 10 亿个可能的密码:

  • 若每个密码计算耗时 1ms,总时间约 115 天;
  • 若通过参数将耗时提升到 100ms,总时间则增至 31 年。

对黑客而言,这样的时间成本和硬件资源消耗(如 GPU 集群运行数年)完全不可接受,从而放弃破解。

单向性:无法从密文反推明文

现代加密算法基于单向哈希函数:从明文→密文的过程不可逆,无法通过密文直接计算出明文。

黑客只能通过 “正向枚举 + 对比” 的方式尝试破解,而结合盐值和自适应哈希后,这种尝试的成本已远超收益,因此加密后的密码在实际场景中可视为 “不可破译”。

密码加密方式

其中,PasswordEncoder是 Spring Security 密码加密的核心接口,定义了两个关键方法:

  • String encode(CharSequence rawPassword):对原始密码进行加密,返回加密后的字符串(通常包含算法标识、盐值、哈希结果等信息)。
  • boolean matches(CharSequence rawPassword, String encodedPassword):验证原始密码与加密密码是否匹配(无需手动提取盐值或解析加密串,接口内部实现自动处理)。

而 Spring Security 提供了多种PasswordEncoder的实现类,对应不同的加密算法或策略,安全性和适用场景各有不同。以下是常用的实现类及对应的加密方式:

  • 不加密NoOpPasswordEncoder不对密码进行任何加密,直接存储明文,仅通过字符串相等性验证密码。明文存储极度危险,一旦数据库泄露,所有用户密码直接暴露。貌似Spring Security 5.0 后标记为废弃,好像实际上你想用肯定也能用
  • 基于哈希算法MessageDigestPasswordEncoder
    • 支持算法:MD5、SHA-1、SHA-256、SHA-512 等传统哈希算法。
    • 原理:通过哈希函数对原始密码进行单向哈希(如MD5("123456")),存储哈希结果。验证时对输入密码再次哈希,对比结果是否一致。
    • 问题
      • 无盐值(salt):相同密码的哈希结果完全相同,容易通过 “彩虹表”(预计算的哈希值字典)破解。安全性极低,Spring Security 已不推荐使用
      • 计算速度快:哈希算法设计初衷是快速计算,导致暴力破解(枚举密码 + 哈希对比)成本低。
  • 带盐值的哈希算法:为解决无盐值哈希的缺陷,现代密码加密算法引入盐值(随机字符串)和自适应哈希(通过增加计算复杂度抵抗暴力破解)。Spring Security 提供了多种实现:
    • BCrypt 加密BCryptPasswordEncoder
      • 算法:基于 BCrypt 算法(一种自适应密码哈希函数)。
      • 特点:
        • 自动生成盐值:每次加密时随机生成盐值(无需手动管理),并将盐值嵌入加密结果中(格式如$2a$10$N9qo8uLOickgx2ZMRZo5MeVQ82i0t8w0)。
        • 自适应哈希:通过 “工作因子”(work factor,默认 10)控制计算复杂度,值越大,哈希计算时间越长(可抵御 GPU 暴力破解)。
        • 单向不可逆:无法从加密结果反推原始密码。
      • 使用在了绝大多数 Web 应用,平衡了安全性和实现复杂度。
    • PBKDF2 加密Pbkdf2PasswordEncoder
      • 算法:基于 PBKDF2(Password-Based Key Derivation Function 2)算法,通过迭代哈希 + 盐值增强安全性。
      • 特点
        • 手动配置参数:需指定哈希算法(如 HMAC-SHA256)、迭代次数(默认 65536)、盐值长度(默认 16 字节)、密钥长度(默认 256 位)。
        • 标准化算法:被 NIST(美国国家标准与技术研究院)推荐,安全性有权威背书。
        • 盐值管理:加密结果中包含盐值,验证时自动提取。
      • 使用在对安全性要求较高且需要符合行业标准的场景(如金融、政务系统)。
    • Argon2 加密Argon2PasswordEncoder
      • 算法:基于 Argon2 算法(2015 年密码哈希竞赛冠军),专为抵抗 GPU/ASIC 暴力破解设计。
      • 特点
        • 多维度复杂度控制:通过 “内存成本”“时间成本”“并行度” 三个参数调整计算难度(内存消耗 + 计算时间 + 并行处理),大幅提高暴力破解成本。
        • 现代设计:针对密码哈希的最新攻击方式(如侧信道攻击)进行了优化。
        • 盐值自动生成:加密结果包含盐值和参数信息,验证时自动解析。
      • 使用场景:对安全性要求极高的系统(如支付、敏感数据存储),但实现复杂度略高于 BCrypt。
    • SCrypt 加密SCryptPasswordEncoder
      • 算法:基于 SCrypt 算法(一种密钥派生函数),结合了内存硬消耗和计算硬消耗。
      • 特点
        • 内存密集型:通过消耗大量内存增加 GPU/ASIC 破解的难度(普通哈希算法主要消耗 CPU)。
        • 参数可配置:需指定 CPU 成本、内存成本、并行度等参数,平衡安全性和性能。
      • 使用场景:需要抵抗大规模并行破解的场景,适合高安全性需求。

详细讲解密码加密算法

BCrypt 加密源码分析

BCryptPasswordEncoder 的核心逻辑集中在 encode(加密)和 matches(验证)方法,对应 BCrypt 算法的两个核心阶段:

加密阶段(encode 方法)

加密的目标是将明文密码转换为包含版本、盐值、哈希结果的字符串,流程如下:

  • 参数初始化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public BCryptPasswordEncoder(BCryptVersion version, int strength, SecureRandom random) {
    // 校验工作因子有效性(4-31之间,默认10)
    if (strength == -1 || strength >= 4 && strength <= 31) {
    this.version = version; // 版本(如$2a、$2b)
    this.strength = strength == -1 ? 10 : strength; // 工作因子,默认10
    this.random = random; // 随机数生成器(用于生成盐值)
    } else {
    throw new IllegalArgumentException("Bad strength");
    }
    }
    • 工作因子(strength):决定加密复杂度的核心参数,计算次数为 2^strength(如 strength=10 对应 1024 次迭代)。
    • 版本(BCryptVersion):源码中定义了 $2A$2Y$2B 三个版本,用于修复早期实现的漏洞(如 $2B 修复了一个小的安全缺陷,推荐优先使用)。
  • 生成盐值(getSalt 方法)

    盐值是一串随机字符串,用于与明文密码混合后哈希,确保相同密码加密结果不同

    1
    2
    3
    4
    5
    6
    private String getSalt() {
    // 调用BCrypt工具类生成盐值,包含版本、工作因子和随机字节
    return this.random != null ?
    BCrypt.gensalt(this.version.getVersion(), this.strength, this.random) :
    BCrypt.gensalt(this.version.getVersion(), this.strength);
    }

    盐值生成的具体细节:

    • 生成 16 字节(128 位)的随机数(通过 SecureRandom 保证随机性);
    • 用 BCrypt 自定义的 Base64 编码(字符集:./0-9A-Za-z,共 64 个字符)将 16 字节随机数转换为 22 个字符
    • 盐值字符串格式:$<version>$<strength>$<encoded-salt>(如 $2b$10$N9qo8uLOickgx2ZMRZo5Me)。
  • 哈希计算(BCrypt.hashpw 方法)

    将明文密码与盐值混合后,通过 BCrypt 核心算法计算哈希,核心是 EksBlowfish 密钥扩展算法

    1
    2
    3
    4
    public String encode(CharSequence rawPassword) {
    String salt = this.getSalt(); // 获取盐值
    return BCrypt.hashpw(rawPassword.toString(), salt); // 混合密码与盐值,计算哈希
    }

    其中,hashpw 走到底就是直接涉及 EksBlowfish 算法的核心流程,EksBlowfish(Extended Key Schedule Blowfish,扩展密钥调度的 Blowfish)是 BCrypt 算法的底层核心,负责通过 “密钥扩展” 和 “加密迭代” 实现密码的高强度哈希。

    hashpw 方法的主要作用是解析盐值参数、准备输入数据,并最终调用 EksBlowfish 算法的核心实现(B.crypt_raw 方法)

    • 解析盐值,提取 EksBlowfish 所需的核心参数

      盐值是 EksBlowfish 算法的关键输入之一,BCrypt 的盐值格式包含算法版本、工作因子(迭代次数)和随机盐字节,这段代码首先解析这些参数:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      // 盐值格式示例:$2b$10$N9qo8uLOickgx2ZMRZo5Me(版本$2b,工作因子10,盐字节N9qo8uLOickgx2ZMRZo5Me)
      int off;
      char minor = 0;
      if (salt.charAt(2) == '$') {
      off = 3; // 处理无次要版本的盐值(如$2$)
      } else {
      minor = salt.charAt(2); // 提取次要版本(如$2b$中的'b')
      off = 4; // 偏移到工作因子位置
      }
      int rounds = Integer.parseInt(salt.substring(off, off + 2)); // 提取工作因子(如10)
      String real_salt = salt.substring(off + 3, off + 25); // 提取实际盐值字符串(22字符)
      byte[] saltb = decode_base64(real_salt, 16); // 将盐值字符串解码为16字节的原始盐值(EksBlowfish的盐输入)
      • 工作因子(rounds):EksBlowfish 算法的迭代次数由 2^rounds 决定(如 rounds=10 对应 1024 次迭代),这是 “自适应复杂度” 的核心参数。
      • 盐值字节(saltb):16 字节的随机盐值,是 EksBlowfish 密钥扩展的关键输入之一,用于确保相同密码的哈希结果不同。
    • 处理密码字节,适配 EksBlowfish 的输入要求

      EksBlowfish 对密码的处理有特定要求(如某些版本需要额外填充),代码中针对不同版本(minor)调整密码字节数组:

      1
      2
      3
      if (minor >= 'a') {
      passwordb = Arrays.copyOf(passwordb, passwordb.length + 1); // 对特定版本(如$2a$)的密码进行额外填充
      }

      这一步是为了适配 EksBlowfish 算法在不同版本中的实现细节(如早期版本的密码长度处理逻辑)。

    • 调用 crypt_raw 方法,执行 EksBlowfish 核心流程

      代码的核心是通过 B.crypt_raw(...) 调用 EksBlowfish 算法的具体实现:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      BCrypt B = new BCrypt();
      byte[] hashed = B.crypt_raw(
      passwordb, // 密码字节数组(原始密码)
      saltb, // 16字节盐值
      rounds, // 工作因子(决定迭代次数)
      minor == 'x', // 兼容旧版本的标志(与密钥扩展逻辑相关)
      minor == 'a' ? 65536 : 0, // 特定版本的密码长度限制(EksBlowfish的参数)
      for_check // 是否为验证模式(影响加密流程的细节)
      );

      crypt_raw 方法是 EksBlowfish 算法的 “实现载体”,内部包含 EksBlowfish 的两大核心步骤:

      • 密钥扩展(Key Expansion)

        以密码和盐值为输入,通过 2^rounds 次迭代扩展生成 Blowfish 加密算法的子密钥。迭代次数越多,密钥扩展过程越耗时,这正是 BCrypt 抵御暴力破解的核心(EksBlowfish 的 “扩展” 特性由此体现)。

      • 加密固定明文

        用扩展后的子密钥对一个固定的明文("OrpheanBeholderScryDoubt")执行 Blowfish 加密,得到的密文经过处理后即为最终哈希结果的核心部分。

    • 组装最终哈希串(包含 EksBlowfish 的输入参数)

      EksBlowfish 的输入参数(版本、工作因子、盐值)会被嵌入最终的哈希串,以便验证时能重新执行相同的 EksBlowfish 流程:

      这也是 BCrypt 无需单独存储盐值的原因 —— 所有 EksBlowfish 所需的参数都已包含在哈希串中。

      1
      2
      3
      4
      5
      6
      7
      rs.append("$2"); // 主版本
      if (minor >= 'a') rs.append(minor); // 次要版本
      rs.append("$");
      rs.append(rounds); // 工作因子(EksBlowfish的迭代次数参数)
      rs.append("$");
      encode_base64(saltb, saltb.length, rs); // 盐值(EksBlowfish的盐输入)
      encode_base64(hashed, ..., rs); // EksBlowfish加密后的结果
  • EksBlowfish 算法的核心实现——crypt_raw 方法

    这个方法负责将密码、盐值通过多轮密钥扩展和加密迭代,生成最终的哈希字节数组。其流程可拆解为 参数初始化、密钥扩展、迭代强化、固定明文加密、结果转换 五个核心阶段,每个阶段都服务于 “增强密码哈希安全性” 的目标。

    • 参数初始化与校验

      没啥多说的,该有的流程要有

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      // 1. 初始化固定明文(用于最终加密的基准数据)
      int[] cdata = (int[])bf_crypt_ciphertext.clone();
      int clen = cdata.length;

      // 2. 计算实际迭代次数(rounds = 2^log_rounds)
      long rounds;
      if (log_rounds >= 4 && log_rounds <= 31) {
      rounds = roundsForLogRounds(log_rounds); // 核心:rounds = 2^log_rounds(如log_rounds=10 → 1024)
      if (rounds < 16L || rounds > 2147483648L) {
      throw new IllegalArgumentException("Bad number of rounds");
      }
      } else {
      // 验证模式下的特殊处理(略)
      }

      // 3. 校验盐值长度(必须16字节,EksBlowfish的硬性要求)
      if (salt.length != 16) {
      throw new IllegalArgumentException("Bad salt length");
      }
      • bf_crypt_ciphertext:这是一个固定的整数数组(源码中预定义为 {0x4f727068, 0x65616e42, 0x65686f6c, 0x64657253, 0x63727944, 0x6f756274}),对应明文字符串 "OrpheanBeholderScryDoubt"。EksBlowfish 最终会用扩展后的密钥加密这个固定明文,确保哈希结果的一致性基准。
      • rounds 计算log_rounds 即 “工作因子”,rounds 是实际迭代次数(2^log_rounds),这是 “自适应复杂度” 的核心 —— 次数越多,计算越耗时,暴力破解成本越高。
      • 盐值校验:强制盐值为 16 字节(128 位),确保随机性足够,避免相同密码产生相同哈希。
    • 密钥初始化与扩展:

      EksBlowfish(扩展密钥调度的 Blowfish)的核心是 “通过盐值和密码生成强密钥”,这一过程由 init_keyekskey 完成

      1
      2
      this.init_key(); // 初始化Blowfish算法的S盒和P数组(初始密钥状态)
      this.ekskey(salt, password, sign_ext_bug, safety); // 扩展密钥调度:结合盐和密码生成初始密钥
      • init_key():初始化 Blowfish 算法的内部状态(包括 8 个 S 盒和 18 个 P 数组),这些是对称加密的核心参数(类似 “密码本”)。
      • ekskey(...):EksBlowfish 的关键步骤(“Eks” 即 Extended Key Schedule),作用是:用盐值(salt)和密码(password)共同 “扰乱” 初始的 S 盒和 P 数组,生成与密码、盐值强关联的初始密钥。这一步确保密钥不仅依赖密码,还依赖随机盐值,避免相同密码在不同盐值下产生相同密钥。
    • 迭代强化:

      为进一步增强密钥安全性,算法会执行 rounds 次(2^log_rounds)迭代,每次迭代用密码和盐值再次更新密钥:

      1
      2
      3
      4
      for(int i = 0; (long)i < rounds; ++i) {
      this.key(password, sign_ext_bug, safety); // 用密码更新密钥
      this.key(salt, false, safety); // 用盐值更新密钥
      }
      • this.key(...):Blowfish 算法的标准密钥调度方法,作用是用输入的字节数组(密码或盐值)进一步 “扰乱” 当前的 S 盒和 P 数组(即更新密钥)。
    • 固定明文加密:生成哈希核心数据

      完成密钥强化后,算法用最终生成的密钥对固定明文(cdata)执行多轮加密,生成哈希的核心部分:

      1
      2
      3
      4
      5
      for(int i = 0; i < 64; ++i) { // 固定执行64轮加密
      for(int j = 0; j < clen >> 1; ++j) { // 对cdata中的每对整数加密
      this.encipher(cdata, j << 1); // Blowfish加密操作
      }
      }
      • encipher(...):Blowfish 算法的加密函数,接收整数数组和索引,对指定位置的两个整数(64 位数据)执行加密(Blowfish 是 64 位块加密算法)。
    • 最后,将加密后的 cdata 整数数组转换为字节数组,作为哈希结果返回:

      加密后的字符串将盐值和哈希结果合并,这一步是在上面的 hashpw 中尾部完成,组装最终哈希串,格式为:

      总长度固定为 60 字符(版本 3 字符 + 强度 2 字符 + 盐 22 字符 + 哈希 31 字符);

      1
      $<version>$<strength>$<encoded-salt><encoded-hash>
      1
      2
      3
      4
      5
      6
      7
      8
      9
      byte[] ret = new byte[clen * 4]; // clen是cdata的长度,每个int占4字节
      int i = 0;
      for(int j = 0; i < clen; ++i) {
      ret[j++] = (byte)(cdata[i] >> 24 & 255); // 提取int的高8位
      ret[j++] = (byte)(cdata[i] >> 16 & 255);
      ret[j++] = (byte)(cdata[i] >> 8 & 255);
      ret[j++] = (byte)(cdata[i] & 255); // 提取int的低8位
      }
      return ret;

验证阶段(matches 方法)

验证的目标是判断用户输入的明文密码与存储的加密串是否匹配,核心是 “用相同盐值和算法重新计算哈希并对比”:

1
2
3
4
5
6
7
8
9
public boolean matches(CharSequence rawPassword, String encodedPassword) {
// 校验加密串格式是否符合BCrypt规范(通过正则表达式)
if (!this.BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
logger.warn("Encoded password does not look like BCrypt");
return false;
}
// 调用BCrypt工具类验证:用加密串中的盐值重新计算哈希,对比结果
return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
}

BCrypt.checkpw 内部逻辑只在这里简单说明:

  1. 从加密串中解析出版本、工作因子和盐值(如从 $2b$10$N9qo8uLOickgx2ZMRZo5MeVQ82i0t8w0 提取盐值 $2b$10$N9qo8uLOickgx2ZMRZo5Me);
  2. 用解析出的盐值对用户输入的明文密码重新计算哈希;
  3. 对比新计算的哈希结果与加密串中的哈希部分是否一致,一致则验证通过。

PBKDF2 加密源码流程分析

PBKDF2 的核心目标是通过 “增强计算复杂度” 和 “随机盐值” 提升密码哈希的安全性,和 Bcrypt 略有区别,但是流程较为相似,核心逻辑集中在 参数初始化、encode(加密)、matches(验证) 三个部分。

参数初始化

PBKDF2 依赖 5 个核心参数(也是 Pbkdf2PasswordEncoder 的核心配置项):

  • Password(原始密码):用户输入的明文密码(如 123456)。
  • Salt(盐值):随机生成的字节数组(默认 16 字节),确保相同密码生成不同哈希。
  • Iterations(迭代次数):哈希函数的执行次数(是 Spring Security 5.8+ 的优化值 310000 次),次数越多,计算越耗时,暴力破解成本越高。
  • PRF(伪随机函数):通常基于 HMAC 哈希算法(如 HMAC-SHA256、HMAC-SHA512),作为迭代计算的 “基础哈希工具”。Spring Security 默认使用的是PBKDF2WithHmacSHA256
  • Key Length(密钥长度):最终生成的哈希(或密钥)的字节长度(如 256 位 = 32 字节)。

源码首先通过构造函数和静态变量定义 PBKDF2 的默认参数,确保算法安全性的 “基础配置”:

1
2
3
4
5
6
7
8
9
private static final int DEFAULT_SALT_LENGTH = 16; // 默认盐值长度:16字节(128位)
private static final SecretKeyFactoryAlgorithm DEFAULT_ALGORITHM; // 默认PRF:HMAC-SHA256
private static final int DEFAULT_HASH_WIDTH = 256; // 默认密钥长度:256位(32字节)
private static final int DEFAULT_ITERATIONS = 310000; // 默认迭代次数:31万次

// 静态代码块初始化默认算法
static {
DEFAULT_ALGORITHM = Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA256;
}

源码提供多个构造函数,支持自定义参数,以最常用的构造函数为例:

1
2
3
4
5
6
7
8
9
10
public Pbkdf2PasswordEncoder(CharSequence secret, int saltLength, int iterations, SecretKeyFactoryAlgorithm secretKeyFactoryAlgorithm) {
this.algorithm = DEFAULT_ALGORITHM.name(); // 初始算法为默认值
this.hashWidth = 256; // 初始密钥长度为256位
this.overrideHashWidth = true; // 标记是否自动匹配算法的密钥长度

this.secret = Utf8.encode(secret); // 额外的“秘密值”(可选,增强密钥多样性)
this.saltGenerator = KeyGenerators.secureRandom(saltLength); // 盐值生成器(基于SecureRandom)加密级随机盐值,确保盐值不可预测。
this.iterations = iterations; // 自定义迭代次数
this.setAlgorithm(secretKeyFactoryAlgorithm); // 设置PRF算法
}
  • secret 参数:可选的 “额外秘密值”(如系统全局密钥),会与盐值拼接后参与计算,进一步增强哈希的唯一性(即使盐值和密码相同,secret 不同则哈希不同)。

setAlgorithm 方法就是对PBK2K算法进行一些设置,其中注意的就是

1
2
3
4
5
6
7
8
// 自动匹配密钥长度(overrideHashWidth为true时)
if (this.overrideHashWidth) {
this.hashWidth = switch (secretKeyFactoryAlgorithm) {
case PBKDF2WithHmacSHA1 -> 160; // SHA1输出160位
case PBKDF2WithHmacSHA256 -> 256; // SHA256输出256位
case PBKDF2WithHmacSHA512 -> 512; // SHA512输出512位
};
}

加密流程

encode 方法是 PBKDF2 算法的核心实现,对应 “生成盐值→派生密钥→组装结果” 的完整流程:

入口 encode 方法(生成盐值 + 调用核心加密)

1
2
3
4
5
public String encode(CharSequence rawPassword) {
byte[] salt = this.saltGenerator.generateKey(); // 1. 生成随机盐值(16字节)
byte[] encoded = this.encode(rawPassword, salt); // 2. 核心加密:生成“盐值+派生密钥”
return this.encode(encoded); // 3. 编码:Base64或Hex(可配置)
}

核心加密 encode(rawPassword, salt) 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private byte[] encode(CharSequence rawPassword, byte[] salt) {
try {
// 1. 构建PBEKeySpec(PBKDF2的参数封装)
PBEKeySpec spec = new PBEKeySpec(
rawPassword.toString().toCharArray(), // 原始密码(转为char数组,安全处理)
EncodingUtils.concatenate(new byte[][]{salt, this.secret}), // 盐值 + 额外secret(拼接后作为PBKDF2的盐)
this.iterations, // 迭代次数(如310000)
this.hashWidth // 密钥长度(如256位)
);

// 2. 获取SecretKeyFactory(基于配置的PRF算法)
SecretKeyFactory skf = SecretKeyFactory.getInstance(this.algorithm);

// 3. 执行PBKDF2密钥派生:生成派生密钥
byte[] derivedKey = skf.generateSecret(spec).getEncoded();

// 4. 组装结果:盐值 + 派生密钥(方便验证时提取盐值)
return EncodingUtils.concatenate(new byte[][]{salt, derivedKey});
} catch (GeneralSecurityException ex) {
throw new IllegalStateException("Could not create hash", ex);
}
}

其中的 encode 方法是三层调用关系的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 1. 入口:生成盐值 + 调用核心加密 + 编码结果
public String encode(CharSequence rawPassword) {
byte[] salt = this.saltGenerator.generateKey(); // 生成随机盐值(默认16字节)
byte[] encoded = this.encode(rawPassword, salt); // 核心:盐值+密码→加密字节数组
return this.encode(encoded); // 编码:Base64/Hex(可配置)
}

// 2. 核心:参数封装 + 底层加密 + 结果拼接(重点分析)
private String encode(byte[] bytes) {
return this.encodeHashAsBase64 ? Base64.getEncoder().encodeToString(bytes) : String.valueOf(Hex.encode(bytes));
}

// 3. 辅助:字节数组→字符串(编码)
private String encode(byte[] bytes) { ... }

第二步真正实现了加密,该方法的作用是:将 “原始密码 + 盐值 + 系统 secret” 封装为安全参数,调用 JCE 底层 PBKDF2 实现生成派生密钥,最终拼接 “盐值 + 派生密钥” 返回

验证流程

主要核心是 matches 方法,验证的核心逻辑是 “用存储的盐值重新计算哈希,对比结果是否一致”,源码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
public boolean matches(CharSequence rawPassword, String encodedPassword) {
// 1. 解码存储的哈希串:Base64或Hex → 字节数组(盐值+派生密钥)
byte[] digested = this.decode(encodedPassword);

// 2. 提取盐值:从字节数组的前 N 字节(N = 盐值长度,默认16)
byte[] salt = EncodingUtils.subArray(digested, 0, this.saltGenerator.getKeyLength());

// 3. 重新计算哈希:用提取的盐值对输入密码加密
byte[] newDigested = this.encode(rawPassword, salt);

// 4. 安全对比:用MessageDigest.isEqual避免时序攻击
return MessageDigest.isEqual(digested, newDigested);
}

密码编码器PasswordEncoder

使用密码编码器

最简单的方式就是修改你的 Security 配置类

1
2
3
4
5
6
7
/**
* 密码编码器
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
image-20250925174708815

那么你其实也可以自己写一个密码编码器,自定义一些密码的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
package hbnu.project.databasesecurity.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.crypto.password.Pbkdf2PasswordEncoder;
import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder;

import java.util.HashMap;
import java.util.Map;

/**
* 密码编码器配置类
* 演示Spring Security中各种密码编码器的使用
*/
@Configuration
public class PasswordEncoderConfig {

/**
* 主要的密码编码器 - BCrypt
* BCrypt是Spring Security推荐的密码编码器
* 特点:
* - 基于Blowfish加密算法
* - 自适应哈希函数,可以随着硬件性能提升调整复杂度
* - 每次编码结果都不同(内置随机盐)
* - 安全性高,抗彩虹表攻击
*/
@Bean("bcryptEncoder")
@Primary
public PasswordEncoder bcryptPasswordEncoder() {
// 强度参数:4-31,默认10。数值越高越安全但速度越慢
return new BCryptPasswordEncoder(12);
}

/**
* Argon2密码编码器
* Argon2是2015年密码哈希竞赛的获胜者
* 特点:
* - 内存困难函数,抗ASIC攻击
* - 有三个变种:Argon2d、Argon2i、Argon2id
* - 安全性极高,但消耗更多CPU和内存
*/
@Bean("argon2Encoder")
public PasswordEncoder argon2PasswordEncoder() {
return new Argon2PasswordEncoder(16, 32, 1, 65536, 3);
// 参数说明:
// saltLength: 盐长度(字节)
// hashLength: 哈希长度(字节)
// parallelism: 并行度
// memory: 内存使用量(KB)
// iterations: 迭代次数
}

/**
* PBKDF2密码编码器
* PBKDF2 (Password-Based Key Derivation Function 2)
* 特点:
* - PKCS #5标准
* - 基于HMAC的密钥派生函数
* - 通过迭代增加计算复杂度
*/
@Bean("pbkdf2Encoder")
public PasswordEncoder pbkdf2PasswordEncoder() {
return new Pbkdf2PasswordEncoder("mySecret", 16, 185000,
Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm.PBKDF2WithHmacSHA256);
// 参数说明:
// secret: 密钥
// saltLength: 盐长度
// iterations: 迭代次数
// algorithm: 算法类型
}

/**
* SCrypt密码编码器
* SCrypt是内存困难函数
* 特点:
* - 需要大量内存,抗ASIC攻击
* - 比PBKDF2更安全
* - 计算和内存开销都较大
*/
@Bean("scryptEncoder")
public PasswordEncoder scryptPasswordEncoder() {
return new SCryptPasswordEncoder(16384, 8, 1, 32, 64);
// 参数说明:
// cpuCost: CPU开销参数
// memoryCost: 内存开销参数
// parallelization: 并行化参数
// keyLength: 密钥长度
// saltLength: 盐长度
}

/**
* 委托密码编码器
* DelegatingPasswordEncoder支持多种编码器
* 特点:
* - 可以同时支持多种编码算法
* - 向后兼容性好
* - 可以平滑升级密码编码算法
* - 密码格式:{算法ID}编码后的密码
*/
@Bean("delegatingEncoder")
public PasswordEncoder delegatingPasswordEncoder() {
String defaultEncoderId = "bcrypt";
Map<String, PasswordEncoder> encoders = new HashMap<>();

// 添加各种编码器
encoders.put("bcrypt", new BCryptPasswordEncoder());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("argon2", new Argon2PasswordEncoder());

// 创建委托编码器
DelegatingPasswordEncoder delegatingEncoder =
new DelegatingPasswordEncoder(defaultEncoderId, encoders);

// 设置默认编码器(用于新密码编码)
delegatingEncoder.setDefaultPasswordEncoderForMatches(new BCryptPasswordEncoder());

return delegatingEncoder;
}

/**
* 获取编码器强度说明
*/
public static class EncoderStrengthInfo {
public static final String BCRYPT_INFO =
"BCrypt强度10: ~100ms, 强度12: ~400ms, 强度15: ~3s";
public static final String ARGON2_INFO =
"Argon2内存使用64MB,3次迭代,安全性最高但资源消耗大";
public static final String PBKDF2_INFO =
"PBKDF2迭代185000次,适合资源受限环境";
public static final String SCRYPT_INFO =
"SCrypt内存困难函数,抗ASIC攻击,内存消耗16MB";
}
}

密码编码器的工作流程

  1. 存储密码时
    • 接收用户输入的明文密码
    • 通过encode()方法生成加密后的密码
    • 将加密后的密码存储到用户存储系统(内存 / 数据库)
  2. 验证密码时
    • 用户登录时输入明文密码
    • 系统获取存储的加密密码
    • 通过matches()方法验证两者是否匹配
    • 匹配成功则认证通过

当然这只是简单的流程概述,实际上 Spring Security 通过 PasswordEncoder 接口统一规范所有密码编码器的行为,接口定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
public interface PasswordEncoder {
// 对原始密码进行加密,返回加密后的字符串(包含算法信息、盐值等)
String encode(CharSequence rawPassword);

// 验证原始密码与加密密码是否匹配
boolean matches(CharSequence rawPassword, String encodedPassword);

// 判断加密密码是否需要重新加密(如迭代次数过低)
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}

所有具体编码器(如 BCryptPasswordEncoderPbkdf2PasswordEncoder)均实现此接口,确保加密与验证逻辑的一致性。

密码编码器的工作流程贯穿用户 “注册” 和 “登录” 两个核心场景,具体步骤如下:

注册阶段:密码加密与存储

当用户注册时,系统需要将原始密码加密后存储到数据库,流程如下:

生成随机盐值(部分算法)

  • 现代加密算法(如 BCrypt、PBKDF2)会自动生成随机盐值(Salt),例如:
    • BCryptPasswordEncoder:生成 16 字节随机盐值,通过 BCrypt.gensalt() 实现;
    • Pbkdf2PasswordEncoder:通过 KeyGenerators.secureRandom(16) 生成 16 字节盐值。
  • 盐值作用:确保相同密码加密结果不同,抵御彩虹表攻击。

执行加密算法

编码器使用指定算法(如 BCrypt、PBKDF2)对 “原始密码 + 盐值” 进行加密,核心逻辑包括:

  • BCrypt:通过 EksBlowfish 算法执行 2^strength 次迭代(默认 1024 次),生成哈希结果;
  • PBKDF2:基于 HMAC 哈希(如 SHA256)执行指定次数迭代(默认 310000 次),生成派生密钥。

组装加密串

加密后的结果会包含 算法标识、盐值、哈希结果 等信息,例如:

  • BCrypt 加密串:$2b$10$N9qo8uLOickgx2ZMRZo5MeVQ82i0t8w0(版本 + 工作因子 + 盐值 + 哈希);
  • PBKDF2 加密串(Hex 编码):5f4dcc3b5aa765d61d8327deb882cf99...(盐值 + 派生密钥)。

优势:无需单独存储盐值和算法参数,加密串本身包含验证所需的全部信息。

存储加密串

将组装后的加密串存入数据库(如用户表的 password 字段),替代明文密码。

登录阶段:密码验证

当用户登录时,系统需要验证输入密码与存储的加密串是否匹配,流程如下:

  • 提取加密串信息

    编码器从存储的加密串中解析出 盐值、算法参数 等信息,例如:

    • BCrypt:通过正则表达式 \A\$2(a|y|b)?\$(\d\d)\$[./0-9A-Za-z]{53} 提取版本、工作因子和盐值;
    • PBKDF2:通过 EncodingUtils.subArray 从加密串头部提取盐值(默认前 16 字节)。
  • 重新加密输入密码

    使用解析出的盐值和算法参数,对用户输入的原始密码执行 与注册时相同的加密流程,生成临时加密结果。

  • 对比验证

    将临时加密结果与存储的加密串进行对比,一致则验证通过,否则失败。

安全细节:对比时使用 MessageDigest.isEqual 等方法,避免时序攻击(Timing Attack)。

可选阶段:密码升级

当加密算法参数(如迭代次数)需要更新时,通过 upgradeEncoding 方法判断是否需要重新加密:

  • 例如,若存储的 BCrypt 加密串工作因子为 10,而当前配置为 12,则 upgradeEncoding 返回 true
  • 系统可在用户登录成功后,使用新参数重新加密密码并更新数据库,实现平滑升级。

DelegatingPasswordEncoder

当系统从旧的密码加密方式升级到新方式时,DelegatingPasswordEncoder 是 Spring Security 提供的优雅解决方案。它解决了一个核心问题:如何在不强制所有用户立即修改密码的情况下,平滑过渡到新的加密算法

假设你的系统原本使用:

  • 明文存储密码(极不安全)
  • 或 MD5 加密(已被破解)
  • 或 SHA-1 加密(安全性不足)

现在要升级到 BCrypt 加密,但不能要求所有用户重新注册或立即修改密码,这时候就需要一种机制:

  • 能验证旧算法加密的密码
  • 新密码或修改后的密码使用新算法加密
  • 逐步完成所有密码的算法升级

那么我们就可以这样使用 DelegatingPasswordEncoder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Bean
public PasswordEncoder passwordEncoder() {
// 1. 定义默认使用的新算法(这里选择 bcrypt)
String idForEncode = "bcrypt";

// 2. 配置支持的所有加密算法(包括旧算法)
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put(idForEncode, new BCryptPasswordEncoder()); // 新算法
encoders.put("md5", new MessageDigestPasswordEncoder("MD5")); // 旧算法1
encoders.put("sha-1", new MessageDigestPasswordEncoder("SHA-1")); // 旧算法2
encoders.put("noop", NoOpPasswordEncoder.getInstance()); // 明文(仅用于迁移)

// 3. 创建 DelegatingPasswordEncoder
return new DelegatingPasswordEncoder(idForEncode, encoders);
}

它的核心思想是在加密后的密码前添加算法标识,格式如下:

1
{加密算法ID}加密后的密码

例如:

  • {noop}123456 - 明文密码(noop 表示无加密)
  • {md5}e10adc3949ba59abbe56e057f20f883e - MD5 加密
  • {bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW - BCrypt 加密

这样,Spring Security 就能根据前缀自动选择对应的编码器进行验证。

那么我们知道了,DelegatingPasswordEncoder 的核心设计是 “前缀标识算法”(比如 {bcrypt}$2a$10$xxx 表示用 BCrypt 加密,{md5}e10adc39xxx 表示用 MD5 加密)。它通过解析密码字符串的前缀,来决定用哪种具体的 PasswordEncoder 去验证密码。

但问题在于:迁移前的旧密码,是没有这个前缀的(比如数据库中存的是 $2a$10$xxx,而不是 {bcrypt}$2a$10$xxx;或者存的是 e10adc39xxx,而不是 {md5}e10adc39xxx)。

那很厉害了,DelegatingPasswordEncoder 提供了一个关键特性:如果密码字符串没有前缀,会自动使用你所配置的 “默认算法” 去验证。这正是迁移的核心 —— 你不需要手动修改旧密码或者手动加前缀,只需要告诉 DelegatingPasswordEncoder:“所有没有前缀的密码,都默认是用【旧算法】加密的”。他就会去做好匹配。

举个具体迁移场景的例子:

假设你的系统迁移前:

  • BCrypt 加密密码(无前缀),数据库中存的是 $2a$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxx(纯 BCrypt 哈希,无 {bcrypt} 前缀);
  • 现在要迁移到 DelegatingPasswordEncoder,且新密码希望用 更强的 BCrypt 版本(或 Argon2) 存储(带前缀)。

迁移配置步骤(无需手动加前缀):

  1. 定义所有需要支持的算法(包括旧算法 BCrypt、新算法 BCrypt/Argon2);
  2. 指定 “默认算法” 为旧算法(BCrypt)—— 让无前缀的旧密码能被正确验证;
  3. 指定 “新密码加密算法” 为目标新算法—— 新用户注册 / 密码重置时,自动用新算法 + 前缀存储。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
@Configuration
@EnableWebSecurity
public class SecurityConfig {

// 1. 配置 DelegatingPasswordEncoder(核心迁移配置)
@Bean
public PasswordEncoder passwordEncoder() {
// 步骤1:定义需要支持的算法映射(key=前缀,value=对应的PasswordEncoder)
Map<String, PasswordEncoder> encoderMap = new HashMap<>();
// 旧算法:BCrypt(用于验证无前缀的旧密码)
encoderMap.put("bcrypt", new BCryptPasswordEncoder());
// 新算法:更强的BCrypt版本(或Argon2,这里用BCrypt示例)
encoderMap.put("bcrypt-new", new BCryptPasswordEncoder(12)); // 工作因子12,比旧版更安全

// 步骤2:创建 DelegatingPasswordEncoder
// 参数1:默认算法的前缀(无前缀密码会用这个算法验证)
// 参数2:算法映射表
DelegatingPasswordEncoder delegatingEncoder = new DelegatingPasswordEncoder(
"bcrypt", // 无前缀的旧密码 → 默认用 "bcrypt" 算法验证
encoderMap
);

// 步骤3:指定新密码的加密算法(可选,默认用“默认算法”,这里显式指定用新算法)
delegatingEncoder.setDefaultPasswordEncoderForMatches(
encoderMap.get("bcrypt-new") // 新用户注册/改密码时,自动用 "bcrypt-new" 加密
);

return delegatingEncoder;
}

// 2. 配置用户信息(用 DelegatingPasswordEncoder 加密密码)
@Bean
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
// 旧用户:密码是迁移前存储的“无前缀BCrypt哈希”(直接从数据库读,无需手动加前缀)
UserDetails oldUser = User.builder()
.username("oldUser")
// 数据库中实际存储的是:$2a$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxx(无前缀)
.password("$2a$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxx")
.roles("USER")
.build();

// 新用户:密码会自动用 "bcrypt-new" 加密,存储为 {bcrypt-new}$2a$12$yyyyyyyyyyyyyyyyyyyyyyyyy
UserDetails newUser = User.builder()
.username("newUser")
.password(passwordEncoder.encode("newPassword123")) // 自动加前缀
.roles("USER")
.build();

return new InMemoryUserDetailsManager(oldUser, newUser);
}

// 3. 安全策略配置(省略,同之前逻辑)
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
)
.formLogin(withDefaults())
.logout(withDefaults());
return http.build();
}
}

那么假如你的旧密码是明文存储?

  • 只需将 DelegatingPasswordEncoder 的 “默认算法” 配置为 NoOpPasswordEncoder(明文验证器)
  • 旧用户登录时,DelegatingPasswordEncoder 会用 NoOpPasswordEncoder 验证明文密码,新用户 / 改密码时自动用新算法加密并加前缀。

注意,使用DelegatingPasswordEncoder迁移完成后,务必删除旧算法配置(尤其是 NoOpPasswordEncoder 这类不安全的实现),避免安全风险。

对了,还可以添加一步,使得在用户成功登录进行验证通过之后,可以选择自动将密码更新为新算法,步骤也很简单,就是在用户登录成功后,检测其密码是否使用旧算法,若是则自动升级:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Service
public class UserService {

@Autowired
private UserDetailsManager userDetailsManager;

@Autowired
private PasswordEncoder passwordEncoder;

// 用户登录成功后调用此方法
public void upgradePasswordIfNeeded(String username, String rawPassword) {
UserDetails user = userDetailsManager.loadUserByUsername(username);
String currentEncodedPassword = user.getPassword();

// 检查是否需要升级(如果不是默认算法则需要升级)
if (!currentEncodedPassword.startsWith("{" + passwordEncoder.getClass().getSimpleName() + "}")) {
// 使用新算法重新加密密码
String newEncodedPassword = passwordEncoder.encode(rawPassword);
// 更新用户密码
userDetailsManager.changePassword(currentEncodedPassword, newEncodedPassword);
}
}
}