ARTICLE DETAIL

资讯详情

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

密码存储不再裸奔:从MD5到BCrypt加盐哈希的Spring Boot实战

密码存储不再裸奔:从MD5到BCrypt加盐哈希的Spring Boot实战 做后端开发几乎每个项目都绕不开用户密码存储这个环节。经常能看到一些系统把用户密码直接做一次 MD5 后存进数据库同一个密码在不同用户下摘要完全相同一旦数据库泄露攻击者通过彩虹表就能快速还原一大批明文密码。本文以“巴嘎海贼团”项目中的账号模块为例完整记录密码存储的一次重构实践围绕“盐Salt”讲清楚加盐哈希的原理、Spring Security 中 BCrypt 的落地方式以及生产环境中常见的问题和应对方案。适合刚接触 Spring Boot 安全模块的开发者也适合正在做密码存储改造的团队参考。1. 盐值Salt到底是什么为什么不能只做哈希1.1 从一次密码泄露场景说起假设一张用户表里存了下面这样的数据用户名 密码 admin 5f4dcc3b5aa765d61d8327deb882cf99 zhangsan 5f4dcc3b5aa765d61d8327deb882cf99 lisi 7c6a5d6ad9e7a4f2b7f0b2b1d9a4f3c1第一个和第二个用户的“密码”字段完全一样。熟悉哈希算法的人会立刻意识到这两个用户的真实密码大概率也是相同的。5f4dcc...这个 MD5 值对应的是 “password”这类常见弱密码的哈希值在公开的彩虹表里一查就能命中。这里的问题并不是“用哈希对不对”而是“只做一次哈希且不加盐”导致同一个明文密码永远得到同一个摘要值。攻击者可以预先计算海量常见密码的哈希值再和数据库里的摘要做比对成本低、命中率高。1.2 盐值是什么盐值Salt是一段随机生成的字符串在计算密码哈希之前先拼接到原始密码上然后再进行哈希运算存储结果 哈希(原始密码 盐值)因为每个用户都使用不同的随机盐值所以即使两个用户密码相同最终数据库里的哈希值也不一样。攻击者无法用一张彩虹表覆盖所有可能性只能针对每个盐值单独做暴力破解成本大幅上升。1.3 加盐之后的完整流程用户注册时系统做三件事生成一个随机盐值将盐值与明文密码拼接对拼接后的字符串计算哈希并把“哈希值 盐值 算法参数”一起保存。用户登录时系统做三件事从数据库查出该用户保存的哈希值和盐值将用户输入的明文密码与盐值拼接计算哈希并与数据库中的哈希值比对相等则通过。这里有一个很容易混淆的点盐值并不是用来隐藏的它可以直接和哈希值保存在一起甚至不需要单独保密。盐值的核心作用是“让不同用户的哈希值尽量随机化”从而打破预计算表攻击。1.4 常见的密码哈希算法对比算法是否建议用于密码存储原因MD5 / SHA-1 / SHA-256不建议计算速度快容易被暴力破解bcrypt推荐内置盐值支持 cost 参数计算成本可调scrypt可选需要控制内存占用安全性较强Argon2推荐近年主流方案抗 GPU 暴力破解能力强SHA-256 虽然也是安全哈希算法但它不是专门的密码哈希算法。原因是它计算速度太快现代 GPU 可以每秒尝试数十亿次。bcrypt、scrypt、Argon2 这类算法在设计上就刻意放慢了计算速度并且自带盐值处理逻辑更适合密码存储场景。2. 环境准备与版本说明在实际动手写代码之前先明确运行环境。本文示例使用 Java Spring Boot Spring Security 这套后端组合这是当前企业中非常常见的认证授权技术栈。说明JDK 与 Spring Boot 版本变化较快本文不会把版本号写死。示例代码以常见稳定版本为主如果你使用的是 Spring Boot 3.x注意需要把javax.persistence替换为jakarta.persistence如果你使用的是 Spring Boot 2.x下面代码基本可以直接运行。推荐环境清单JDK 8 或更高版本Maven 3.6 或 Gradle 6.8Spring Boot 2.7.x / 3.x 均可Spring Security 5.x / 6.x演示数据库用 H2 内存数据库不依赖外部服务IDE 推荐 IntelliJ IDEA 或 Eclipse。版本需要根据你的项目实际情况调整本文重点演示配置思路和核心代码不纠结于某一个小版本号的差异。3. 从手动加盐到 BCrypt核心原理拆解3.1 先看一个手动加盐示例为了帮助理解盐值的作用先用 JDK 自带的MessageDigest写一个手动 MD5 加盐示例。注意这个代码仅用于理解原理不要直接用在生产环境。import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.HexFormat; public class ManualSaltDemo { public static String md5WithSalt(String password, String salt) throws NoSuchAlgorithmException { MessageDigest md MessageDigest.getInstance(MD5); md.update(salt.getBytes(StandardCharsets.UTF_8)); byte[] digest md.digest(password.getBytes(StandardCharsets.UTF_8)); return HexFormat.of().formatHex(digest); } public static void main(String[] args) throws NoSuchAlgorithmException { String password admin123; String saltA 3d7f9c21; String saltB 8a1b4e67; System.out.println(使用盐 A md5WithSalt(password, saltA)); System.out.println(使用盐 B md5WithSalt(password, saltB)); System.out.println(使用盐 A md5WithSalt(password, saltA)); } }运行结果类似下面这样注意同一密码在盐 A 和盐 B 下结果是不同的而在同一个盐下结果相同使用盐 A2c1a2f9c4b8e5d0a3f6e7b8c9d0e1f2a 使用盐 B7b3a9d1e4f2c8a6b0d3e5f7a8b9c1d2e 使用盐 A2c1a2f9c4b8e5d0a3f6e7b8c9d0e1f2a从这个示例可以直观看到盐值对哈希结果的影响。但这里仍有一个隐患盐值是我们自己写死的字符串如果真的放到项目里需要为每个用户生成独立随机盐并且要在数据库里保存盐值整体逻辑会比较繁琐容易出错。3.2 手动加盐容易出现的问题实际项目里自己写加盐逻辑往往会出现下面几类问题盐值生成不够随机使用了固定盐或时间戳作为盐值盐值长度不足安全性没有保障拼接方式五花八门有的拼在前面有的拼在后面盐值存储与哈希值存储分散后续维护混乱没有考虑算法升级将来换算法时需要大改代码。所以更推荐的做法是直接用成熟库。Spring Security提供的BCryptPasswordEncoder把盐值生成、拼接、哈希计算全部封装好了而且最终生成的字符串里已经包含了盐值和 cost 参数校验时直接调用matches方法即可。3.3 BCrypt 的核心设计BCrypt 是一种基于 Blowfish 加密算法设计的密码哈希算法它有几个关键特点内置盐值每次encode时自动生成 16 字节随机盐输出字符串自带盐值和 cost 参数校验时不需要额外存储盐值可通过 cost 参数调整计算强度cost 越大越慢越抗暴力破解同一密码每次加密结果不同数据库里看不到重复摘要。一个典型的 BCrypt 哈希值长这样$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy这段字符串以$分成了四个部分片段含义2a算法版本标识10cost 参数表示 2 的 10 次方次迭代N9qo8uLOickgx2ZMRZoMye22 位盐值IjZAgcfl7p92ldGxad68LJZdL17lhWy实际哈希值正因为盐值已经包含在结果里校验时只需要拿到完整的哈希字符串算法会自动从中提取盐值再用用户输入的密码重新计算并比对。这个设计非常巧妙省去了单独维护盐值字段的麻烦。3.4 Spring Security 中的 PasswordEncoderSpring Security 从 5.0 开始推荐使用PasswordEncoder接口默认推荐BCryptPasswordEncoder。在项目里先定义一个配置类把PasswordEncoder注入到 Spring 容器中package com.example.saltdemo.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; Configuration public class SecurityConfig { Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }如果你希望未来能够在不同算法之间平滑切换可以改用DelegatingPasswordEncoder它会在存储的哈希值前保留{bcrypt}这样的前缀标记。这个策略在企业生产环境中非常实用后面第 6 章会专门展开。4. 完整实战Spring Boot 用户注册与登录中的盐值存储下面用 Spring Boot Spring Security JPA H2 实现一个最简注册登录示例。目标是验证两件事注册时密码经过BCryptPasswordEncoder加盐哈希后写入数据库登录时matches方法能够正确校验明文密码与数据库哈希值。4.1 创建项目结构项目结构如下salt-demo/ ├── pom.xml └── src/main/java/com/example/saltdemo/ ├── SaltDemoApplication.java ├── config/ │ └── SecurityConfig.java ├── controller/ │ └── AuthController.java ├── entity/ │ └── User.java ├── repository/ │ └── UserRepository.java └── service/ └── UserService.java4.2 添加 Maven 依赖在pom.xml中加入以下依赖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-data-jpa/artifactId /dependency dependency groupIdcom.h2database/groupId artifactIdh2/artifactId scoperuntime/scope /dependency4.3 编写配置文件在src/main/resources/application.yml中配置数据源和 JPAspring: datasource: url: jdbc:h2:mem:saltdb driver-class-name: org.h2.Driver username: sa password: jpa: hibernate: ddl-auto: update show-sql: true h2: console: enabled: trueH2 使用内存数据库方便本地演示。ddl-auto: update会让 JPA 自动创建表结构不用手写建表语句。4.4 编写实体类package com.example.saltdemo.entity; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; Entity public class User { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String username; private String password; public User() { } public User(String username, String password) { this.username username; this.password password; } public Long getId() { return id; } public void setId(Long id) { this.id id; } public String getUsername() { return username; } public void setUsername(String username) { this.username username; } public String getPassword() { return password; } public void setPassword(String password) { this.password password; } }提示如果是 Spring Boot 2.x请把jakarta.persistence改成javax.persistence。这里password字段保存的是 BCrypt 加盐哈希后的字符串长度约 60 位。注意不要把字段长度限制得太小否则插入时会报“Data too long”之类的错误。4.5 编写 Repositorypackage com.example.saltdemo.repository; import com.example.saltdemo.entity.User; import org.springframework.data.jpa.repository.JpaRepository; public interface UserRepository extends JpaRepositoryUser, Long { User findByUsername(String username); }findByUsername是 Spring Data JPA 根据方法名自动生成的查询登录时用来按用户名查找用户。4.6 编写 Service 层package com.example.saltdemo.service; import com.example.saltdemo.entity.User; import com.example.saltdemo.repository.UserRepository; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; Service public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) { this.userRepository userRepository; this.passwordEncoder passwordEncoder; } public User register(String username, String rawPassword) { if (userRepository.findByUsername(username) ! null) { throw new RuntimeException(用户名已存在); } String encodedPassword passwordEncoder.encode(rawPassword); User user new User(username, encodedPassword); return userRepository.save(user); } public boolean login(String username, String rawPassword) { User user userRepository.findByUsername(username); if (user null) { return false; } return passwordEncoder.matches(rawPassword, user.getPassword()); } }这段代码有几个关键点passwordEncoder.encode(rawPassword)完成了“随机盐生成 密码拼接 bcrypt 计算”整个过程数据库最终保存的是$2a$10$...这样一段完整字符串不需要单独加盐值字段passwordEncoder.matches(rawPassword, user.getPassword())会自动从存储哈希中取出盐值再对输入密码重新计算并比对。4.7 编写 Controllerpackage com.example.saltdemo.controller; import com.example.saltdemo.entity.User; import com.example.saltdemo.service.UserService; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; RestController RequestMapping(/auth) public class AuthController { private final UserService userService; public AuthController(UserService userService) { this.userService userService; } PostMapping(/register) public String register(RequestParam String username, RequestParam String password) { User user userService.register(username, password); return 注册成功 user.getUsername(); } PostMapping(/login) public String login(RequestParam String username, RequestParam String password) { boolean success userService.login(username, password); return success ? 登录成功 : 用户名或密码错误; } }4.8 启动类package 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.9 运行与验证启动SaltDemoApplication后用 curl 分别测试注册和登录curl -X POST http://localhost:8080/auth/register?usernameadminpasswordadmin123预期输出注册成功admin此时在日志中可以看到 H2 执行的 INSERT 语句。如果打开 H2 控制台或添加一个查询接口会发现数据库里admin的password字段是一段$2a$10$开头的字符串而不是原始的admin123。然后测试登录curl -X POST http://localhost:8080/auth/login?usernameadminpasswordadmin123预期输出登录成功再测试错误密码curl -X POST http://localhost:8080/auth/login?usernameadminpasswordwrong预期输出用户名或密码错误这里有一个值得注意的现象如果连续调用注册接口注册同一个用户名每次生成的password哈希字符串是不同的。这正是 BCrypt 随机盐的作用也是加盐哈希和普通 SHA-256 的最大差别。5. 常见问题与排查思路5.1 问题一同一条密码每次加密结果不同是不是程序出错了这是新手最常问的问题。BCrypt 每次执行encode都会重新生成随机盐所以同一明文密码的加密结果确实不同。这不是 Bug而是设计如此。校验时不能直接比较两个哈希字符串必须用matches方法。5.2 问题二登录时matches永远返回 false常见原因有三个密码字段长度太短注册时哈希值被截断数据库中被误存成了明文密码用户在注册和登录时用了不同的字符编码或前后空格。排查时先检查数据库中的哈希字符串是否完整再检查登录输入的密码前后是否有空格。5.3 问题三旧系统用的是 MD5 哈希如何迁移到 BCrypt老系统升级时数据库里已经有大量 MD5 哈希值不能直接要求所有用户重新注册。比较稳妥的做法是采用“双重校验 懒迁移”用户登录时先用 BCrypt 的matches校验如果失败再用旧算法如 MD5计算一次哈希与数据库比对如果旧算法校验通过立即用 BCrypt 重新encode并更新数据库后续登录就走 BCrypt 校验路径。这样用户无感知地完成了哈希算法迁移。关键点是在迁移到 BCrypt 后删除旧算法的校验代码避免留下后门。5.4 问题四BCrypt 计算太慢并发请求时 CPU 升高BCrypt 的 cost 参数决定了计算强度。cost 值越高单次计算越慢暴力破解成本越高但系统响应也会变慢。生产环境需要结合服务器性能选择一个合适的 cost 值通常 10 到 12 比较常见。如果并发量很大可以考虑引入 Argon2并在压测下确定参数。5.5 问题五数据库密码字段报长度不足BCrypt 输出字符串长度为 60所以数据库字段建议使用VARCHAR(60)或更大的长度。如果使用旧表结构原本字段是VARCHAR(32)插入时就会失败。修改表结构后再次测试即可。问题现象常见原因解决思路加密结果每次都不同BCrypt 随机盐机制属正常现象用 matches 校验matches 返回 false哈希被截断、字段类型不符检查字段长度和哈希完整性旧 MD5 密码无法登录未做旧算法兼容实现懒迁移逻辑登录接口响应慢cost 值过高压测后调整 cost数据插入报字段长度不足字段设置过短改为 VARCHAR(60) 以上6. 最佳实践与工程建议6.1 使用成熟的密码哈希库不自行实现密码学是非常容易出现“看似正确、实则不安全”的领域。手动拼接字符串、自己生成盐值、选择不合适的哈希算法都可能在真实攻击下崩溃。优先选择业界认可的算法库Spring Security 已经提供了很好的封装。6.2 使用 DelegatingPasswordEncoder 预留算法升级空间Spring Security 5 之后支持DelegatingPasswordEncoder。它允许你在同一个系统中兼容多种密码编码器并给哈希值加前缀标记。例如package com.example.saltdemo.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.DelegatingPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import java.util.HashMap; import java.util.Map; Configuration public class PasswordConfig { Bean public PasswordEncoder passwordEncoder() { String idForEncode bcrypt; MapString, PasswordEncoder encoders new HashMap(); encoders.put(idForEncode, new BCryptPasswordEncoder()); encoders.put(pbkdf2, org.springframework.security.crypto.password.Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_5()); return new DelegatingPasswordEncoder(idForEncode, encoders); } }这样存储格式会变成{bcrypt}$2a$10$...。将来如果要升级到新的算法只需要新加一个 encoder旧数据依然可以通过前缀识别并校验新数据使用新算法编码。6.3 正确设置 cost 参数BCryptPasswordEncoder的构造函数可以传入 strength 参数例如new BCryptPasswordEncoder(12)表示 cost 为 12。一般建议从 10 开始结合服务器性能压测后调整。生产环境不要使用默认构造函数直接跑而不做任何评估尤其不能把 cost 设得过高导致登录接口成为攻击者的“性能攻击入口”。6.4 密码字段禁止输出到日志在打印用户信息、请求参数、SQL 日志时最容易不小心把明文密码或哈希值打出来。一定要配置日志脱敏策略对敏感字段做正则替换或使用脱敏工具。特别是 Spring Boot 开启show-sql: true后INSERT 语句中会显示完整密码哈希这虽然不像明文风险那么高但也属于敏感信息生产环境建议关闭 SQL 日志。6.5 强制 HTTPS 传输密码加盐哈希只能保护“存下来”的密码无法保护传输过程中的明文密码。登录注册接口必须要走 HTTPS否则密码可能在网络中直接暴露。生产环境中应通过网关或负载均衡强制 HTTPS并配置 HSTS。6.6 避免内存中大量明文密码对象在 Service 层处理密码时尽量使用char[]而不是String虽然 Java 中这种优化并不容易做到绝对安全但这是一个较好的习惯。另外不要在页面、接口返回中回显密码字段DTO 中要单独做字段过滤。6.7 配合账号风控措施加盐哈希解决的是密码泄露后的还原难度但不能阻止“撞库”和“暴力破解”。生产环境还应配合登录失败次数限制、验证码、设备风控等手段。Spring Security 中可以很方便地添加登录失败监听和限流逻辑。7. 总结与下一步本文围绕“盐Salt”讲解了密码存储安全的核心问题。你应当已经掌握了三个关键点为什么密码不能只做一次普通哈希因为彩虹表攻击会让哈希失去意义盐值如何工作以及 BCrypt 如何把“盐值 哈希 参数”封装成一段完整字符串如何在 Spring Boot 项目中用 Spring Security 的PasswordEncoder完成注册密码加盐存储和登录校验。如果你要继续深入可以从下面几个方向入手学习DelegatingPasswordEncoder的算法迁移机制做一个完整的多算法兼容实战调研 Argon2 在 Spring Security 中的集成方式对比它与 BCrypt 在性能、内存消耗方面的差异结合 Spring Security 的认证流程把密码校验接入到表单登录或 JWT 登录中研究 OWASP 的密码存储速查表了解最新的攻击方法和推荐参数。在实际项目中优先关注三类风险一是不要自己发明或组装密码哈希方案二是在算法升级时做好旧数据兼容避免用户被迫重置密码三是密码安全的完整链路不只是存储还包含传输、日志、风控和账号锁定策略。希望这篇笔记能给你一个清晰的落地路径也欢迎在评论区交流你的踩坑经验。
返回列表