ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Security密码加盐实战:BCrypt原理与Spring Boot应用

Spring Security密码加盐实战:BCrypt原理与Spring Boot应用 不知道你有没有听过一句话代码里最不能缺的两样东西一个是“盐”一个是“配置”。这里说的“盐”就是信息安全领域里经常提到的 Salt盐值。今天这篇不是讲美食而是带着“要来一口盐巴吗”这个谐音梗把 Spring Security 中密码加盐BCrypt这件事从头到尾讲清楚。如果你正在做用户注册登录模块或者经常被“密码是明文存的吗”“为什么同一个密码每次加密结果不一样”这些问题困扰这篇文章应该能帮你一次性理顺。本文会从“为什么密码不能明文存储”“哈希和加盐到底是什么关系”开始逐步拆解 BCrypt 的核心原理再给出一个可直接运行的 Spring Boot 实战项目包含注册、登录、数据库表设计、常见报错排查以及我在工程中总结出来的一些最佳实践。适合后端开发新手也适合想系统补一补密码存储安全的进阶读者。1. 背景与核心概念1.1 为什么数据库里的密码不能明文保存很多刚入行的同学会把用户密码直接存进数据库比如在一个sys_user表里password字段放的是用户输入的123456。这种做法的风险非常大因为数据库一旦被拖库所有用户的密码都会直接暴露。现实世界中很多用户在不同平台使用同一个密码一个网站的密码泄露可能会导致其他平台的账号也被撞库。正确做法是数据库里存的不应该是原始密码而是密码的“哈希值”。哈希算法是一种单向函数从原始密码可以算出一个固定长度的字符串但从哈希值几乎无法反推出原始密码。也就是说即使数据库泄露攻击者拿到的也是一堆无法直接还原成明文密码的哈希串。这里要区分两个概念哈希Hash不是加密Encryption。加密是可逆的加密后的密文可以通过密钥还原成明文哈希是不可逆的同一个输入对应同一个输出但输出不能还原输入。所以准确地说我们不是给密码“加密”而是给密码做“不可逆哈希”。既然哈希不可逆为什么还要“加盐”因为直接哈希仍然不安全。下面这一节就来解释这个问题。1.2 什么是盐Salt为什么要加盐假设用户密码是123456用 MD5 计算得到的结果是e10adc3949ba59abbe56e057f20f883e这个结果是固定的。攻击者可以提前把常见弱密码全部算出 MD5 值制作成一张“彩虹表”。拿到数据库中的哈希后直接查表就能反推出明文密码。另外如果有两个用户密码相同那么他俩的哈希值也完全相同这等于告诉攻击者这两个人的密码一样进一步放大了风险。解决办法就是加盐。盐Salt是一段随机生成的字符串在计算哈希之前把盐拼接到密码后面再一起做哈希哈希值 Hash(明文密码 随机盐)加了盐之后即使两个用户密码相同只要盐不同哈希结果就不同。攻击者需要针对每个盐重新生成彩虹表攻击成本成倍上升。如果盐足够长、足够随机用彩虹表破解变得不现实。盐需要在验证时被重新使用所以盐一般会和哈希值一起存储或者直接拼接在哈希结果中。这不是秘密盐不需要保密它的作用是增大攻击成本而不是作为密钥存在。下图是一个简化流程注册时 用户输入密码 123456 系统生成随机盐 S 计算 hash BCrypt(123456 S) 存储 hash 中自带盐 登录时 用户输入密码 123456 取出存储 hash解析出其中的盐 S 重新计算 BCrypt(123456 S) 对比是否等于存储 hash1.3 MD5、SHA 与 BCrypt 的本质区别很多旧项目喜欢用MD5或者SHA-256直接处理密码这类算法本质是“快速摘要算法”。计算速度极快在 CPU 上每秒可以完成数亿次计算。攻击者可以用 GPU 暴力穷举所有 8 位数字密码可能只需要几分钟甚至更快。BCrypt 与它们最大的不同在于BCrypt 是一种“慢哈希”算法。它通过反复迭代的方式故意让单次计算变得很慢从而极大提高暴力破解的成本。默认情况下BCrypt 会进行2^10轮迭代计算生成一个 60 字符左右的字符串。虽然单次注册、登录慢几十毫秒用户无感知但攻击者想在相同时间内穷举亿万个密码资源和时间成本都会暴涨。而且 BCrypt 最大的特点是“自带盐”。每次调用encode方法时BCrypt 都会自动生成一个随机盐把盐、算法版本、迭代次数和最终哈希合并成一个字符串返回。所以同一个密码每次生成的哈希结果都不一样但用matches方法依然能够校验通过。下面用一张表格来对比常见的几种处理方式方案是否随机盐是否慢哈希抗彩虹表安全性明文存储无否差不可接受MD5(password)无否差不建议MD5(password 固定盐)否否弱避免使用SHA-256(password 随机盐)是否一般可过渡仍非首选BCrypt(随机盐)是是强推荐Argon2/scrypt是是强安全敏感系统可选2. 环境准备与版本说明本文的实战案例是一个 Spring Boot 项目示例环境如下JDK8 或 11 或 17不同版本都可以跑建议使用项目团队统一的 JDK 版本。Spring Boot本文示例使用2.7.18。如果你的项目已经升级到 Spring Boot 3.x依赖坐标不变但 Spring Security 6.x 中的部分配置写法做了调整文章里会特别说明。Spring SecuritySpring Boot 2.7 默认管理的是 Spring Security 5.8.x。数据库为了演示轻量使用 H2 内存数据库这样不需要额外安装 MySQL。构建工具Maven 3.6。IDEIntelliJ IDEA 或 Eclipse 均可本文示例不依赖 IDE 插件。包管理使用 Maven 的spring-boot-starter-parent统一管理依赖版本。需要说明的是这里的版本只是示例。真实项目里版本需要根据公司的研发规范、基础组件兼容性来定。本文重点是“加盐、哈希、BCrypt 配置”的思路即使版本不同核心 API 仍然一致。项目最终结构如下salt-demo ├── pom.xml ├── src/main/java/com/example/saltdemo/ │ ├── SaltDemoApplication.java │ ├── config/SecurityConfig.java │ ├── controller/AuthController.java │ └── service/UserService.java ├── src/main/resources/ │ ├── application.yml │ └── schema.sql └── src/test/java/com/example/saltdemo/ └── PasswordEncoderTest.java3. 核心原理拆解3.1 PasswordEncoder 接口在 Spring Security 中密码处理的核心接口是PasswordEncoder。它定义了三个关键方法String encode(CharSequence rawPassword)接收明文密码返回编码后的字符串。boolean matches(CharSequence rawPassword, String encodedPassword)校验明文密码与编码后的字符串是否匹配。boolean upgradeEncoding(String encodedPassword)判断是否需要对编码后的字符串进行再次升级一般用于旧哈希迁移。有了这个接口业务代码不需要关心底层是 BCrypt、Argon2 还是自定义算法。项目里通常定义一个PasswordEncoder的 Bean然后注入到 Service 层使用。最简单的用法是这样PasswordEncoder encoder new BCryptPasswordEncoder(); String hash encoder.encode(123456); boolean match encoder.matches(123456, hash);注意matches中传入的第二个参数必须是encode方法生成出来的完整字符串不能是数据库里的原始密码字段也不能是自己拼接的盐。3.2 BCryptPasswordEncoder 关键参数BCryptPasswordEncoder是 Spring Security 对 BCrypt 算法的一个封装使用时一般只需要关注两个点strength迭代参数也叫 cost默认值是10表示进行2^10轮迭代。合法范围一般是4 ~ 31。数值越大计算越慢安全性越高但用户体验和 CPU 开销也会增加。SecureRandom随机源用于生成盐。默认使用SecureRandom安全性足够高。构造方式// 使用默认强度 10 PasswordEncoder encoder new BCryptPasswordEncoder(); // 指定强度为 12 PasswordEncoder encoder new BCryptPasswordEncoder(12);有一点需要提前说明BCrypt 算法对输入密码长度有限制通常只取密码的前 72 字节。如果密码过长部分版本会直接报错部分版本可能会截断。生产环境建议在注册接口加一个密码长度限制比如最长 32 或 64 个字符避免触发这个边界问题。生成的字符串格式大约是$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy拆解一下$2a$ - BCrypt 算法版本号 $10$ - cost即迭代参数 后面 22 个字符左右是盐剩余部分是哈希值所以 BCrypt 的盐是“内嵌”在最终哈希值里的不需要单独在数据库里增加一个salt字段这就是它比“自己拼接盐”更省心的原因。3.3 DelegatingPasswordEncoder 与 {bcrypt} 前缀Spring Security 5 之后默认推荐使用PasswordEncoderFactories.createDelegatingPasswordEncoder()来创建密码编码器。它会返回一个DelegatingPasswordEncoder内部代理多种编码器并根据哈希值的前缀自动选择对应的算法。例如{bcrypt}$2a$10$... {noop}123456这种设计方便旧系统平滑迁移。比如旧的{noop}表示明文密码{md5}表示 MD5 哈希都可以在同一个系统中暂时共存再逐步替换为{bcrypt}。但很多新手在练习时会直接把不带前缀的 BCrypt 哈希存在数据库里然后让 Spring Security 自动帮你校验结果出现下面这个经典报错There is no PasswordEncoder mapped for the id null原因是Spring Security 默认从哈希字符串中解析{id}前缀如果发现没有前缀它就不知道应该用哪个编码器于是直接报错。解决方案有两种在写入门代码时直接使用new BCryptPasswordEncoder()作为PasswordEncoderBean这样 Spring Security 就知道统一使用 BCrypt。如果要支持多算法迁移就用DelegatingPasswordEncoder并且确保数据库里的哈希值都带有{bcrypt}这样的前缀。后面实战案例中我们为了尽量简单直接使用BCryptPasswordEncoder。这样代码清晰也避免了不必要的报错。3.4 先用代码看“盐”到底怎么工作的在进入完整项目之前先看一小段对比代码。下面分别用 MD5 和 BCrypt 对同一个密码编码两次import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.util.DigestUtils; import java.nio.charset.StandardCharsets; public class SaltDemo { public static void main(String[] args) { String rawPassword 123456; // MD5相同密码哈希结果永远相同 String md5_1 DigestUtils.md5DigestAsHex(rawPassword.getBytes(StandardCharsets.UTF_8)); String md5_2 DigestUtils.md5DigestAsHex(rawPassword.getBytes(StandardCharsets.UTF_8)); System.out.println(MD5 第一次: md5_1); System.out.println(MD5 第二次: md5_2); System.out.println(MD5 结果是否一致: md5_1.equals(md5_2)); // BCrypt相同密码每次盐不同所以哈希结果不同 PasswordEncoder encoder new BCryptPasswordEncoder(); String bcrypt_1 encoder.encode(rawPassword); String bcrypt_2 encoder.encode(rawPassword); System.out.println(BCrypt 第一次: bcrypt_1); System.out.println(BCrypt 第二次: bcrypt_2); System.out.println(BCrypt 结果是否一致: bcrypt_1.equals(bcrypt_2)); System.out.println(第一次能匹配: encoder.matches(rawPassword, bcrypt_1)); System.out.println(第二次能匹配: encoder.matches(rawPassword, bcrypt_2)); System.out.println(错误密码能匹配: encoder.matches(wrong, bcrypt_1)); } }这段代码不需要 Web 环境直接运行就可以看到效果。输出中你会发现MD5 两次结果相同而 BCrypt 两次结果完全不同但matches都能通过。“同一个密码生成不同哈希”并不是 bug而是加盐设计的核心优势。4. 完整实战案例接下来我们落地一个最简用户注册登录功能。这是一个非常典型的场景用户注册时把密码用 BCrypt 哈希后入库用户登录时用matches校验。4.1 创建项目并配置 pom.xml新建一个 Maven 项目在pom.xml中引入以下依赖?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent groupIdcom.example/groupId artifactIdsalt-demo/artifactId version1.0.0-SNAPSHOT/version properties java.version11/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdcom.h2database/groupId artifactIdh2/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency /dependencies build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build /project如果使用 Spring Boot 3.x只需要把父版本改为 3.x并把javax相关依赖换成jakarta即可核心编码逻辑不变。4.2 配置 application.yml 与初始化表在src/main/resources/application.yml中配置 H2 数据源spring: datasource: url: jdbc:h2:mem:saltdb;DB_CLOSE_DELAY-1 driver-class-name: org.h2.Driver username: sa password: sql: init: mode: always server: port: 8080然后创建一个src/main/resources/schema.sql项目启动时会自动执行建表语句CREATE TABLE IF NOT EXISTS sys_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password_hash VARCHAR(100) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP );这里把字段命名为password_hash是为了提醒自己和后来的人这个字段存的不是明文密码而是密码哈希值。数据库字段类型建议给VARCHAR(100)因为 BCrypt 结果一般是 60 个字符加上未来可能的算法前缀或者迁移其他哈希算法留足空间更稳妥。4.3 编写 SecurityConfig创建一个配置类src/main/java/com/example/saltdemo/config/SecurityConfig.javapackage com.example.saltdemo.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; Configuration EnableWebSecurity public class SecurityConfig { Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .authorizeHttpRequests(auth - auth .requestMatchers(/api/**).permitAll() .anyRequest().authenticated() ); return http.build(); } }这里有两处要说明我们演示的是“接口注册 登录”的 REST API 场景这种无状态接口通常不依赖浏览器 Cookie所以先关闭 CSRF。如果业务是基于 Session 的浏览器表单登录CSRF 必须保留。requestMatchers(/api/**).permitAll()表示放开所有以/api/开头的接口方便我们用curl测试。真实生产项目中注册接口可以放开但登录后的用户信息接口必须走认证鉴权逻辑不能全部放行。4.4 编写 UserService创建一个 Service 类src/main/java/com/example/saltdemo/service/UserService.java负责注册和登录逻辑package com.example.saltdemo.service; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import java.util.List; import java.util.Map; Service public class UserService { private final JdbcTemplate jdbcTemplate; private final PasswordEncoder passwordEncoder; public UserService(JdbcTemplate jdbcTemplate, PasswordEncoder passwordEncoder) { this.jdbcTemplate jdbcTemplate; this.passwordEncoder passwordEncoder; } public void register(String username, String rawPassword) { if (username null || username.trim().isEmpty()) { throw new IllegalArgumentException(用户名不能为空); } if (rawPassword null || rawPassword.length() 6) { throw new IllegalArgumentException(密码长度不能少于 6 位); } String uname username.trim(); Integer count jdbcTemplate.queryForObject( SELECT COUNT(*) FROM sys_user WHERE username ?, Integer.class, uname ); if (count ! null count 0) { throw new IllegalStateException(用户名已存在); } String encodedPassword passwordEncoder.encode(rawPassword); jdbcTemplate.update( INSERT INTO sys_user(username, password_hash) VALUES (?, ?), uname, encodedPassword ); } public boolean login(String username, String rawPassword) { if (username null || rawPassword null) { return false; } String uname username.trim(); ListMapString, Object users jdbcTemplate.queryForList( SELECT password_hash FROM sys_user WHERE username ?, uname ); if (users.isEmpty()) { return false; } String storedHash (String) users.get(0).get(password_hash); return passwordEncoder.matches(rawPassword, storedHash); } }这里体现了最重要的两个动作passwordEncoder.encode(rawPassword)注册时生成 BCrypt 哈希。passwordEncoder.matches(rawPassword, storedHash)登录时校验密码。注意登录失败时我们没有区分“用户不存在”和“密码错误”而是统一返回失败。这是为了避免攻击者通过接口差异枚举出系统中存在的用户名。4.5 编写 AuthController创建一个 Controller 类src/main/java/com/example/saltdemo/controller/AuthController.javapackage com.example.saltdemo.controller; import com.example.saltdemo.service.UserService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Map; RestController RequestMapping(/api) public class AuthController { private final UserService userService; public AuthController(UserService userService) { this.userService userService; } PostMapping(/register) public ResponseEntity? register(RequestBody MapString, String body) { String username body.get(username); String password body.get(password); try { userService.register(username, password); return ResponseEntity.ok(Map.of(code, 0, message, 注册成功)); } catch (IllegalArgumentException | IllegalStateException e) { return ResponseEntity.badRequest().body(Map.of(code, 1, message, e.getMessage())); } } PostMapping(/login) public ResponseEntity? login(RequestBody MapString, String body) { String username body.get(username); String password body.get(password); boolean success userService.login(username, password); if (success) { return ResponseEntity.ok(Map.of(code, 0, message, 登录成功)); } return ResponseEntity.status(401).body(Map.of(code, 1, message, 用户名或密码错误)); } }这里我故意没有使用复杂的 DTO 类而是用MapString, String接收 JSON目的是减少文件数量让核心逻辑更突出。真实项目中建议定义RegisterRequest、LoginRequest这样的 DTO并做参数校验。最后补上启动类src/main/java/com/example/saltdemo/SaltDemoApplication.javapackage com.example.saltdemo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication public class SaltDemoApplication { public static void main(String[] args) { SpringApplication.run(SaltDemoApplication.class, args); } }4.6 运行与验证在项目根目录执行mvn spring-boot:run启动成功后直接用curl验证接口。先注册一个用户curl -X POST http://localhost:8080/api/register \ -H Content-Type: application/json \ -d {username: zhangsan, password: abc123456}预期输出{code:0,message:注册成功}再调用登录接口curl -X POST http://localhost:8080/api/login \ -H Content-Type: application/json \ -d {username: zhangsan, password: abc123456}预期输出{code:0,message:登录成功}如果用错误密码curl -X POST http://localhost:8080/api/login \ -H Content-Type: application/json \ -d {username: zhangsan, password: wrong-password}预期返回 HTTP 401{code:1,message:用户名或密码错误}如果你想查看数据库里到底存了什么可以把 H2 改为文件模式或者用一个简单的查询接口临时打印。不过从教学角度我更建议在测试代码里验证。4.7 编写一个加盐行为测试创建一个测试类src/test/java/com/example/saltdemo/PasswordEncoderTest.javapackage com.example.saltdemo; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.crypto.password.PasswordEncoder; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; SpringBootTest public class PasswordEncoderTest { Autowired private PasswordEncoder passwordEncoder; Test void samePasswordShouldGenerateDifferentHashes() { String raw abc123456; String hash1 passwordEncoder.encode(raw); String hash2 passwordEncoder.encode(raw); // 盐不同哈希结果应该不同 assertNotEquals(hash1, hash2); // 但都能通过 matches 校验 assertTrue(passwordEncoder.matches(raw, hash1)); assertTrue(passwordEncoder.matches(raw, hash2)); assertFalse(passwordEncoder.matches(abc123457, hash1)); } }运行mvn test如果测试通过说明基本的加盐哈希逻辑没有问题。这个测试也适合作为后续重构的回归测试防止有人把PasswordEncoder换成错误实现。5. 常见问题与排查思路在实际开发中密码加盐这一块最容易出问题的不是概念而是配置和数据处理上的细节。下面整理了几个高频问题。5.1 There is no PasswordEncoder mapped for the id null这个报错在 Spring Security 5 之后非常常见。原因是 Spring Security 默认使用DelegatingPasswordEncoder它会根据存储哈希值的前缀来决定使用哪种编码器。如果你的数据库里存的是不带前缀的原始 BCrypt 字符串比如$2a$10$N9qo8uLOickgx2ZMRZoMye...Spring Security 不知道这个字符串对应的是什么算法所以报错。解决思路如果你确定系统里只用 BCrypt直接把PasswordEncoderBean 定义为new BCryptPasswordEncoder()。如果旧系统有多种密码格式需要保留DelegatingPasswordEncoder并且给数据库里的哈希加上对应前缀比如{bcrypt}或{noop}。5.2 matches 永远返回 falsematches返回 false最常见的原因有几个方向注册和登录时用的密码不一致比如前端对密码做了trim处理而后端没做统一。数据库字段长度不够password_hash被截断导致matches拿到的不是完整哈希值。查询时取错字段比如取成了明文密码字段。自己手写了一套“盐拼接”逻辑登录时盐和注册时不一致导致哈希结果不一致。排查思路先打印出数据库里password_hash的完整值再确认它是encode方法的输出然后用matches单独跑一次。如果单独跑能匹配上说明问题出在数据读取或传入的参数上。5.3 同一个密码每次生成结果不一样认为是 bug这是新手最容易误判的问题。BCrypt 每次encode都会生成新的随机盐所以同一个密码两次结果不一样是正常现象。不要试图把两次结果改成一致也不要因此去掉盐。校验是否一致的唯一方式是通过matches方法而不是直接比较字符串。5.4 密码超过 72 字节报错或部分版本截断BCrypt 算法只使用输入密码的前 72 字节。如果你的业务允许超长密码可能会遇到部分版本抛出异常、部分版本静默截断的情况。最稳妥的解决方案是在注册接口做长度限制。比如限制密码长度不超过 32 或 64 个字符这样既不会触发 BCrypt 的边界问题也能避免超长密码带来的性能开销。如果确实需要支持超长密码可以考虑先对密码做一次 SHA-256 预哈希再把哈希结果交给 BCrypt。但这个过程要非常谨慎因为它会影响老密码的迁移策略千万不要在生产环境直接切换必须先在测试环境验证完整流程。5.5 登录接口响应特别慢BCrypt 默认cost10单次计算可能消耗几十毫秒。如果系统并发量高登录接口变慢是正常现象因为慢哈希本身就是为了拖慢攻击者。优化方向不要把cost调得过高安全需要平衡。一般10~12足够。对登录接口做限流防止攻击者通过并发请求消耗服务器资源。考虑多级缓存策略但要谨慎不要把密码哈希缓存在外部系统避免扩大泄露面。5.6 旧系统 MD5 密码如何平滑迁移如果老系统数据库里存的是 MD5 哈希不能简单地把所有用户密码重设为 BCrypt因为哈希不可逆无法知道原密码。常见迁移策略是用户登录时先用matches判断新旧算法。如果发现用户仍是 MD5 哈希就在校验成功后用 BCrypt 重新生成哈希并更新数据库。这样可以逐步把全量用户迁移到 BCrypt用户无感知。在 Spring Security 中DelegatingPasswordEncoder就是为此设计的。下面用表格汇总一下这些常见问题问题现象常见原因解决思路There is no PasswordEncoder mapped for the id null哈希值缺少{id}前缀且默认编码器无法识别直接使用 BCryptPasswordEncoder 或补前缀matches 始终 false数据被截断、取错字段、明文前后端处理不一致核对存储值和参数单独跑 matches 验证同样密码两次结果不同BCrypt 每次生成随机盐不是 bug用 matches 校验超长密码报错或校验失败BCrypt 只处理前 72 字节限制输入长度或预哈希后再 BCrypt登录接口慢BCrypt 故意慢哈希合理设置 cost给登录接口做限流旧 MD5 密码无法登录算法变更旧哈希无法匹配采用登录时渐进式迁移策略6. 最佳实践与工程建议了解了代码和基本排查之后再来看一些实际项目中更值得注意的原则。6.1 永远不要自己实现密码哈希算法“把密码和盐拼起来再做 SHA-256”确实是一种加盐方案但自己实现容易踩很多坑比如盐的生成不够随机、拼接顺序不一致、迭代次数不足等。专业的密码哈希算法BCrypt、scrypt、Argon2已经经过多年的安全设计并且会自己处理盐、迭代参数和结果格式。直接用成熟库的方案能让代码的可维护性和安全性都更好。6.2 正确选择 cost 值BCrypt 的cost值越高单次计算越慢CPU 消耗越大。这个参数需要根据业务并发和服务器性能来调整。默认10对大多数系统已经够用。安全要求更高的系统可以尝试12但一定要做压测观察登录接口在高峰期的表现。不建议盲目调到15以上否则一次注册或登录请求可能耗时数百毫秒对用户体验影响很大。6.3 数据库表字段设计要留足空间建议把密码字段命名为password_hash而不是password。这样做的好处是语义清晰别人接手代码时不会误以为里面存的是明文。字段长度建议VARCHAR(100)不要用VARCHAR(32)因为 BCrypt 结果通常是 60 个字符将来如果加算法前缀或者换成其他哈希算法长度也足够。6.4 接口层的密码传输必须走 HTTPSBCrypt 解决的是“数据被拖库后密码不被还原”的问题但它不能解决“密码在网络上被截获”的问题。如果注册和登录
返回列表