若依框架密码安全升级:从MD5到BCrypt的平滑迁移实战
1. 项目概述与背景
最近在帮一个朋友的公司做他们内部管理系统的安全加固,他们用的是国内非常流行的若依(RuoYi)前后端分离框架。在代码审计的时候,我发现了一个不大不小的问题:他们的用户登录密码,用的还是最基础的MD5加密。这让我心里咯噔一下,都202X年了,还在用MD5做密码存储,这安全水位线有点低了。虽然若依框架本身功能强大、生态完善,但默认的加密方式更多是出于历史兼容性和演示目的,直接用在生产环境,尤其是对安全性有要求的企业内部系统,风险不小。MD5作为一种已经被证明存在碰撞漏洞、且计算速度极快的哈希算法,早已不适合用于密码保护。攻击者可以通过彩虹表、GPU暴力破解等手段,相对容易地还原出弱密码的原文。
所以,这个项目的核心目标就很明确了:在不影响若依框架原有登录逻辑和用户体验的前提下,将密码的加密方式从MD5升级到更安全的方案,比如BCrypt、PBKDF2或者Argon2。这不仅仅是改一个加密算法那么简单,它涉及到前后端的协同修改、已有用户数据的平滑迁移、以及未来密码策略的扩展性。整个过程就像给一栋正在住人的大楼更换更坚固的门锁,既要保证新锁安全,又不能把住户关在门外。接下来,我就把这次“换锁”的完整思路、实操步骤以及踩过的坑,详细拆解一遍。
2. 加密方案选型与核心思路拆解
2.1 为什么必须替换MD5?
首先得统一思想:为什么非要换掉MD5?很多老系统觉得“这么多年都没出事,凑合用吧”,这种想法很危险。MD5的主要问题有几个:
- 抗碰撞性已被攻破 :学术界早已找到快速产生MD5碰撞的方法,这意味着攻击者可以伪造出和原密码不同但MD5值相同的字符串,虽然对密码直接碰撞有难度,但已严重动摇其安全性根基。
- 计算速度过快 :这是密码哈希的大忌。现代GPU可以每秒进行数百亿次MD5运算,这使得暴力破解和彩虹表攻击效率极高。密码哈希函数需要“慢”,才能增加攻击者的成本。
- 无盐值(Salt)或盐值固定 :若依默认的MD5加密,虽然代码里可能看到加了盐,但往往是固定盐或用户名作为盐。固定盐意味着攻击者可以针对这个盐预计算彩虹表,一旦数据库泄露(拖库),所有用户的密码都暴露在风险下。
因此,我们的目标不仅仅是换一个算法,而是要采用一种 专门为密码存储设计 的、 自适应慢哈希 的、 每次哈希都使用随机盐 的算法。
2.2 主流密码哈希方案对比
目前业界主流推荐的有三种算法,我们来简单对比一下:
| 算法 | 核心特点 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| BCrypt | 基于Blowfish加密算法,内置盐,通过 work factor (成本因子)控制计算强度和时间。 |
历经考验,安全可靠;设计上就是慢哈希;多数语言有成熟库(如Spring Security的 BCryptPasswordEncoder )。 |
对GPU/ASIC攻击的抵抗性稍弱于后面两者;内存消耗相对固定。 | Java/Spring生态项目的首选 ,尤其是像若依这种基于Spring Boot的,集成起来最顺畅。 |
| PBKDF2 | 通过多次重复的哈希函数(如HMAC-SHA256)来增加计算成本,可配置迭代次数。 | 标准成熟(NIST推荐);可灵活选择底层哈希函数;抵抗GPU攻击能力尚可。 | 主要消耗CPU,对高端GPU/ASIC防御力不如Argon2;需要自己管理盐。 | 需要符合特定标准或兼容旧系统的场景。 |
| Argon2 | 2015年密码哈希竞赛冠军,可配置时间成本、内存成本和并行度,能有效抵抗GPU和ASIC攻击。 | 目前公认最安全的密码哈希算法;可调节内存消耗,使硬件攻击成本极高。 | 相对较新,某些旧环境库支持可能不完善;参数配置稍复杂。 | 对安全性要求极高、且运行环境可控的新项目。 |
选择建议 :对于若依这个典型的Spring Boot项目, BCrypt 是最平衡、最方便的选择。Spring Security原生提供了强大的
BCryptPasswordEncoder,无需引入额外依赖,功能稳定,社区资料丰富。因此,本项目决定采用BCrypt作为新的密码加密标准。
2.3 整体改造思路
改造不能一蹴而就,必须考虑平滑过渡。我们的核心思路是“ 向后兼容,渐进升级 ”:
- 后端核心 :编写一个支持多种算法的密码编码器(例如,兼容MD5和BCrypt),在用户登录时,根据密码字段的特征或用户表的标记,判断使用哪种方式进行验证。
- 数据迁移 :用户首次使用新密码成功登录(或通过后台任务)时,将其密码用BCrypt重新加密并更新数据库,同时打上“已升级”的标记。
- 前端无感 :整个加密、验证过程对前端透明。前端仍然发送明文密码(必须通过HTTPS传输),由后端决定如何处理。
- 最终目标 :所有活跃用户的密码都升级为BCrypt加密,后续新注册用户直接使用BCrypt。
这样,系统在升级过程中,老用户能正常登录,新用户直接享受新安全标准,实现了无缝切换。
3. 后端核心代码改造详解
3.1 引入与配置BCryptPasswordEncoder
若依前后端分离版的后端是基于Spring Boot和Spring Security的。我们首先要在配置类中注入 BCryptPasswordEncoder Bean。
通常,若依的安全配置在 RuoYiFrameworkApplication 或一个独立的 SecurityConfig 类中。我们需要找到配置 PasswordEncoder 的地方。在默认的若依代码中,你可能在 SecurityConfig 里看到类似 new PasswordEncoder() 的匿名内部类,里面实现了MD5逻辑。我们要替换它。
步骤1:修改Security配置类
找到你的安全配置类(例如 com.ruoyi.framework.config.SecurityConfig )。在类中增加一个方法,用于提供 BCryptPasswordEncoder Bean。
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 {
// ... 其他配置代码 ...
/**
* 配置密码编码器。
* 我们使用一个自定义的DelegatingPasswordEncoder,它能够支持多种加密格式。
* 默认编码器是BCrypt,同时兼容原有的MD5格式。
*/
@Bean
public PasswordEncoder passwordEncoder() {
// 定义支持的加密ID前缀映射
String idForEncode = "bcrypt";
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put(idForEncode, new BCryptPasswordEncoder());
// 关键:添加一个自定义的“md5”编码器,用于兼容旧数据
encoders.put("md5", new Md5PasswordEncoder());
// 创建委托编码器,默认使用bcrypt
PasswordEncoder passwordEncoder = new DelegatingPasswordEncoder(idForEncode, encoders);
// 设置默认处理器:当密码字符串没有{id}前缀时,使用默认编码器(bcrypt)进行编码匹配
// 但为了兼容旧版纯MD5,我们可能需要自定义逻辑,这里先这样设置。
// 更精细的控制会在自定义的UserDetailsService中实现。
return passwordEncoder;
}
}
但请注意,上述标准的 DelegatingPasswordEncoder 要求数据库中存储的密码带有 {bcrypt} 或 {md5} 这样的前缀来标识算法。若依原有的数据库密码是纯32位MD5字符串,没有前缀。直接使用上述编码器,在验证旧密码时会失败。
因此,我们需要一个 更灵活的自定义密码验证策略 。
3.2 实现自定义的密码验证服务
更常见的做法是自定义一个 UserDetailsService ,在加载用户和验证密码时,加入我们的兼容逻辑。
步骤2:创建自定义的UserDetailsService实现类
通常若依有自己的 UserDetailsServiceImpl ,它实现了 loadUserByUsername 方法。我们需要修改这个类。
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.framework.web.service.UserDetailsServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class CustomUserDetailsServiceImpl extends UserDetailsServiceImpl {
@Autowired
private ISysUserService userService;
// 注入BCrypt编码器
@Autowired
private BCryptPasswordEncoder bCryptPasswordEncoder;
// 我们保留一个简单的MD5验证方法,用于兼容旧数据
// 注意:这个方法应该和系统原来生成MD5密码的逻辑完全一致!
private String md5EncodeWithSalt(String rawPassword, String salt) {
// 假设原系统密码生成方式是:md5(密码 + 盐)
// 盐通常是用户名或固定值,你需要确认若依原版的加密工具类方法
// 这里调用若依自己的工具类,例如:
return SecurityUtils.md5Encode(rawPassword + salt);
// 或者直接使用:DigestUtils.md5DigestAsHex((rawPassword + salt).getBytes());
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SysUser user = userService.selectUserByUserName(username);
if (user == null) {
throw new UsernameNotFoundException("用户不存在");
}
// ... 其他权限查询等逻辑 ...
// 创建LoginUser对象,这里的关键是密码字段
// 我们不在load阶段处理密码验证,验证工作交给AuthenticationProvider
// 但我们需要把用户对象(包含原始密码哈希值)传递下去
return createLoginUser(user);
}
}
步骤3:核心——自定义AuthenticationProvider
这是最关键的一步。我们需要自定义一个 DaoAuthenticationProvider ,重写其 additionalAuthenticationChecks 方法,在这里实现MD5和BCrypt的双重验证逻辑。
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
@Component
public class CustomAuthenticationProvider extends DaoAuthenticationProvider {
@Autowired
private ISysUserService userService;
@Autowired
private BCryptPasswordEncoder bCryptPasswordEncoder;
public CustomAuthenticationProvider(UserDetailsService userDetailsService) {
setUserDetailsService(userDetailsService);
}
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails,
Authentication authentication) throws AuthenticationException {
if (authentication.getCredentials() == null) {
throw new BadCredentialsException("凭证不能为空");
}
String presentedPassword = authentication.getCredentials().toString();
String storedPassword = userDetails.getPassword(); // 数据库存储的密码哈希值
String username = userDetails.getUsername();
// 逻辑判断:
// 1. 如果存储的密码以“{bcrypt}”开头,直接用BCrypt验证
// 2. 否则,认为是旧的MD5密码(因为旧数据是纯32位MD5字符串)
boolean passwordValid = false;
if (storedPassword.startsWith("{bcrypt}")) {
// 去掉前缀进行BCrypt验证
String bcryptHash = storedPassword.substring(8);
passwordValid = bCryptPasswordEncoder.matches(presentedPassword, bcryptHash);
} else {
// 假设旧密码是 MD5(明文密码 + 盐),盐可能是用户名或固定值
// 你需要根据原若依代码确定盐的生成规则,这里假设盐是用户名
String salt = username; // 或者从用户表某个字段获取
String md5Hash = md5EncodeWithSalt(presentedPassword, salt); // 使用上面定义的MD5方法
passwordValid = md5Hash.equals(storedPassword);
// 如果MD5验证成功,说明用户是首次用旧密码登录,触发密码升级
if (passwordValid) {
upgradePasswordToBCrypt(username, presentedPassword);
}
}
if (!passwordValid) {
throw new BadCredentialsException("用户名或密码错误");
}
}
private void upgradePasswordToBCrypt(String username, String rawPassword) {
// 1. 使用BCrypt加密新密码
String newBcryptPassword = bCryptPasswordEncoder.encode(rawPassword);
// 2. 可以选择加上前缀,也可以不加。如果加,存储为 `{bcrypt}xxxx`
String passwordToStore = "{bcrypt}" + newBcryptPassword;
// 3. 更新数据库对应用户的密码字段
SysUser user = new SysUser();
user.setUserName(username);
user.setPassword(passwordToStore);
userService.updateUserPassword(user);
// 可以在这里记录日志,通知用户密码已安全升级等
log.info("用户 [{}] 的密码已从MD5升级为BCrypt加密。", username);
}
// 简单的MD5加密方法,需与旧系统完全一致
private String md5EncodeWithSalt(String rawPassword, String salt) {
try {
String input = rawPassword + salt;
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MD5算法不支持", e);
}
}
}
步骤4:在SecurityConfig中配置自定义Provider
最后,需要让Spring Security使用我们自定义的 AuthenticationProvider 。
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomAuthenticationProvider customAuthenticationProvider;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 使用我们自定义的认证提供者
auth.authenticationProvider(customAuthenticationProvider);
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
// ... 其他配置,如HTTP安全规则等 ...
}
3.3 用户注册与密码修改适配
登录验证搞定了,别忘了还有用户注册和修改密码的入口也需要统一改用BCrypt加密。
步骤5:修改用户注册和密码更新服务
找到处理用户注册和密码修改的Service方法(通常是 SysUserServiceImpl 中的 registerUser 和 resetUserPwd 等方法)。
@Service
public class SysUserServiceImpl implements ISysUserService {
@Autowired
private BCryptPasswordEncoder bCryptPasswordEncoder;
/**
* 注册用户
*/
@Override
public boolean registerUser(SysUser user) {
// ... 其他校验逻辑 ...
// 对明文密码进行BCrypt加密
String encodedPassword = bCryptPasswordEncoder.encode(user.getPassword());
// 可以选择是否加前缀
user.setPassword("{bcrypt}" + encodedPassword);
// ... 保存用户等后续逻辑 ...
return userMapper.insertUser(user) > 0;
}
/**
* 重置用户密码(管理员操作或用户忘记密码)
*/
@Override
public int resetUserPwd(String userName, String password) {
String encodedPassword = bCryptPasswordEncoder.encode(password);
// 存储时加上前缀,便于识别
return userMapper.resetUserPwd(userName, "{bcrypt}" + encodedPassword);
}
/**
* 用户自己修改密码
*/
@Override
public int updateUserPassword(SysUser user) {
// 前端应传来旧密码和新密码
// 1. 先验证旧密码(会触发我们自定义的验证逻辑,兼容MD5和BCrypt)
// 2. 验证通过后,用BCrypt加密新密码并存储
String newEncodedPwd = bCryptPasswordEncoder.encode(user.getNewPassword());
user.setPassword("{bcrypt}" + newEncodedPwd);
return userMapper.updateUser(user);
}
}
关键点 :在用户自己修改密码的流程中,验证旧密码会走我们之前写的
CustomAuthenticationProvider,因此无论旧密码是MD5还是BCrypt格式,都能正确验证。验证通过后,新密码一律用BCrypt加密存储,并加上{bcrypt}前缀。这样,用户每修改一次密码,其密码存储就自动升级到最新标准。
4. 数据库与数据迁移策略
4.1 数据库字段分析
若依的用户表通常是 sys_user ,密码字段名可能是 password 、 pwd 等,类型为 varchar 。存储MD5时是32位字符串。升级到BCrypt后,BCrypt哈希值长度是固定的60位,加上 {bcrypt} 前缀后总长不足100字符。原 varchar(50) 的字段可能不够,需要 扩字段长度 。
步骤6:检查并修改数据库表结构
-- 查看当前字段定义
DESC sys_user;
-- 通常需要将password字段长度扩大到100或以上
ALTER TABLE sys_user MODIFY COLUMN password VARCHAR(100) COMMENT '密码';
4.2 批量数据迁移方案
对于已有的大量用户,我们不能等他们逐个登录才升级。可以编写一个后台任务或一次性脚本进行批量迁移。
方案一:编写一个临时的数据迁移脚本(Java Service)
@Component
public class PasswordMigrationService {
@Autowired
private BCryptPasswordEncoder bCryptPasswordEncoder;
@Autowired
private SysUserMapper userMapper;
/**
* 迁移所有使用MD5加密的用户密码到BCrypt。
* 注意:此方法需要知道原MD5的盐值生成规则。
* 由于我们不知道用户的明文密码,此方法**不可行**。
* 因此,批量迁移只能在有明文密码的情况下进行,或者等待用户登录时触发升级。
*/
@Deprecated
public void migrateAllUsersWithPlaintext() {
// 除非你有所有用户的明文密码(这本身极不安全),否则无法直接批量转换。
// 所以不推荐此方案。
}
/**
* 更可行的方案:标记并提醒用户。
* 1. 增加一个字段标识密码是否需要升级 (e.g., `pwd_need_upgrade` tinyint default 1)。
* 2. 在登录逻辑中,如果发现是该标记为1且用MD5验证成功,则升级密码并标记为0。
* 3. 管理员可以筛选出标记为1的旧用户,发送系统通知或邮件,提醒他们下次登录时会自动升级密码,无需额外操作。
*/
}
方案二:通过登录触发升级(推荐)
这就是我们在 CustomAuthenticationProvider 的 upgradePasswordToBCrypt 方法中实现的逻辑。这是最安全、最平滑的方式:
- 安全 :无需知道用户明文密码,只在用户主动提供时进行转换。
- 无感 :用户登录过程没有任何变化,后台自动完成升级。
- 渐进 :随着时间推移,活跃用户的密码会自动全部升级。
你可以在用户表中增加一个字段,如 password_version ( char(1) ,默认值 '1' 表示MD5, '2' 表示BCrypt),用于清晰追踪密码状态,方便统计和管理。
ALTER TABLE sys_user ADD COLUMN password_version CHAR(1) DEFAULT '1' COMMENT '密码版本:1-MD5, 2-BCrypt';
然后在升级密码的方法中,同步更新这个字段。
5. 前端注意事项与联调
对于前后端分离项目,前端通常不需要做任何加密修改。前端仍然在登录表单提交时,将用户输入的明文密码通过 HTTPS 通道发送到后端。
绝对禁止在前端进行密码哈希 :这是一个常见误区。在前端用MD5或SHA256哈希一次密码再传输,不仅不能提升安全(因为哈希值变成了新的“密码”),反而会 降低安全性 ,因为它可能绕过服务端的慢哈希机制,并且如果前端哈希算法固定,攻击者可以直接用这个哈希值进行重放攻击。密码哈希必须在服务端进行。
前端需要配合的点可能在于“修改密码”功能 :
- 修改密码时,前端应提供“原密码”和“新密码”两个输入框。
- 提交后,后端会验证原密码(通过我们的自定义逻辑),然后用BCrypt加密新密码保存。
联调时,重点测试以下几个场景:
- 旧用户(MD5密码)登录 :使用原有的MD5密码能否成功登录,登录后查看数据库密码是否已更新为BCrypt格式及版本标记是否变更。
- 新用户注册 :注册新用户,查看数据库存储的密码是否为
{bcrypt}开头的BCrypt哈希。 - 已升级用户(BCrypt密码)登录 :用升级后的密码(或新注册用户)登录,确保正常。
- 修改密码 :分别测试旧密码是MD5和BCrypt的用户修改密码,新密码是否用BCrypt存储。
- 密码错误 :确保错误的密码被正确拒绝。
6. 常见问题与排查技巧实录
在实际改造过程中,我遇到了不少坑,这里记录一下:
问题1:登录时一直报“Bad credentials”,但密码确认没错。
- 排查 :首先在
CustomAuthenticationProvider的additionalAuthenticationChecks方法开始处打日志,打印presentedPassword(前端传来的)、storedPassword(数据库存的)和username。 - 可能原因1:盐值不对 。旧系统MD5加密用的盐可能不是用户名,而是固定字符串(如
“ruoyi”)或者存储在用户表另一个字段(如salt)。你必须 完全复现旧系统的加密逻辑 。去翻看若依原版的SecurityUtils类,找到encryptPassword或md5Encode方法,看它具体是怎么拼接密码和盐的。 - 可能原因2:密码字段有空格 。前端输入或传输时可能在首尾带了空格,后端需做
trim()处理。 - 可能原因3:数据库密码字段值异常 。检查是否有不可见字符,或者密码是否被其他程序修改过。
问题2:升级后,管理员在后台重置用户密码,用户无法用新密码登录。
- 排查 :检查管理员重置密码调用的服务方法(如
resetUserPwd)。确保它使用的是bCryptPasswordEncoder.encode(),并且存储时 格式要统一 。如果登录验证逻辑期望密码带{bcrypt}前缀,那么这里存储时也必须加上。前后格式必须一致。
问题3:BCrypt加密每次结果都不一样,如何验证?
- 这是BCrypt的特性,也是其安全性的体现 。它内部包含了随机盐,所以同一个明文密码每次加密后的哈希值都不同。验证时必须使用
bCryptPasswordEncoder.matches(rawPassword, storedHash)方法,它会从storedHash中提取盐等信息进行计算匹配。 绝对不要自己手动去比较两个BCrypt哈希字符串是否相等 。
问题4:性能问题,BCrypt比MD5慢很多,会影响登录速度吗?
- 会,但这是故意的,也是安全的代价 。BCrypt的
strength参数(默认10)决定了计算成本。一次验证可能需要几十到几百毫秒,这对单个用户登录体验几乎无感,却能极大增加攻击者暴力破解的成本。如果真觉得性能压力大(如在非常高频的接口误用),可以适当调低strength(如调到8),但 不建议低于8 。对于登录这种低频操作,默认值完全可接受。
问题5:如何让系统彻底废弃MD5,只使用BCrypt?
当确认绝大多数活跃用户都已升级后(可以通过 password_version 字段统计),可以分两步走:
- 修改
CustomAuthenticationProvider,移除MD5的验证逻辑,只处理BCrypt格式的密码。对于残留的password_version为‘1’的用户,直接返回“密码已过期,请通过忘记密码功能重置”。 - 清理数据库,将残留的MD5密码哈希清除或重置为一个随机BCrypt哈希(强制用户走找回密码流程)。
一个实用的调试技巧 :在开发阶段,可以写一个简单的测试类,分别用旧方法和新方法生成密码哈希,对比验证逻辑是否正确。
@SpringBootTest
public class PasswordTest {
@Autowired
private BCryptPasswordEncoder encoder;
@Test
public void testPassword() {
String rawPwd = "admin123";
String username = "admin";
// 1. 模拟旧系统MD5加密
String oldMd5Hash = DigestUtils.md5DigestAsHex((rawPwd + username).getBytes());
System.out.println("Old MD5 Hash: " + oldMd5Hash);
// 2. 模拟新系统BCrypt加密
String newBcryptHash = encoder.encode(rawPwd);
System.out.println("New BCrypt Hash: " + newBcryptHash);
System.out.println("With Prefix: {bcrypt}" + newBcryptHash);
// 3. 验证BCrypt
boolean matches = encoder.matches(rawPwd, newBcryptHash);
System.out.println("BCrypt验证结果: " + matches);
// 4. 验证旧MD5 (需用自定义方法)
// ... 调用你的md5EncodeWithSalt方法 ...
}
}
整个改造过程,最需要耐心和细心的地方就是 确保旧密码验证逻辑的百分百还原 ,以及 新旧密码存储格式的清晰管理 。一旦这套机制稳定运行,系统的密码安全等级就上了一个坚实的台阶。看着日志里一条条“用户密码已从MD5升级为BCrypt加密”的记录,感觉这件基础又重要的事儿,总算落地了。
更多推荐



所有评论(0)