前言
不知道大家有没有遇到过这种情况:登录某个网站或app的时候,输入密码,提示“密码错误”,然后点击“忘记密码”,重新设置新密码,结果提示“新密码不可与旧密码相同”???嗯?这什么情况?不信邪返回登录页面,再次输入密码,还是提示“密码错误”???不是,不是“新密码不可与旧密码相同”嘛,怎么用旧密码登录还是提示错误??这什么情况??
我之前就遇到过好几次这种情况,我一直想不太明白这到底怎么做到 “新密码不可与旧密码相同,旧密码错误” 的?
后面直到我自己做项目,遇到了需要 “给用户设置历史密码”
这种功能,我才想明白这是怎么一回事了。
因为之前用户密码一直都是直接放在用户信息表的,存的都是用户当前使用的密码,也想不到还有历史密码这回事,所以一直想不通怎么做到的。
其实啊,只要把“旧密码”换成“历史密码”
看,就很容易理解了,这里的“旧密码”不是指前一次的密码,而是指前面几次用过的历史密码(比如前三次、前五次)
。因为这个系统有存用户每一次用过的密码,如果新密码跟前面几次的历史密码中的任意一个相同,就会提示“新密码不可与旧密码相同”
。
假如系统的规则是新密码不能和前五次密码中的任意一个相同。这个系统的用户1更改过五次密码,每一次更改后的密码都被记录了,当第六次需要再次更改密码时,忘记了之前用过哪些密码,就输入了一个之前用过的密码,比如输入的是第二次的密码,就会出现“新密码不可与旧密码相同”的错误提示
。
所以这个“旧密码”并不一定是你最近一次用过的密码,而是近几次用过的历史密码(这里近几次包括了最近一次)
。
现在,我们就来看看这个困扰我n久的问题是怎么实现的。
开始
需求
记录2次用户历史密码(可根据自己需要设置次数)。
思路
密码采用的是盐值加密,用户信息表存储密码有这两个字段:password(密码加密密文)、salt(盐值)。
历史密码我们需要单独的一张表存储,必须要有这几个字段:用户id、创建时间、password(密码加密密文)、salt(盐值)。因为用户每次修改密码,都将密码记录到这张表中,然后我们校验历史密码时,就需要按照创建时间倒叙。比如我的需求是校验两次历史密码,那就是 创建时间倒叙,取最新的两条,也就是 limit 2
。
有了历史密码表,用户信息表的 password、salt 这两个字段也可以去掉,登录时校验密码是否正确,改成从历史密码表拿登录用户最新的那个密码和盐值进行校验。然后我这里的话选择了保留这两个字段,登录那一块的逻辑就不用变,只需要在新增用户、修改密码这两个地方需要改一下。
校验新密码和设置历史密码流程:用户每次修改密码,先校验新密码(校验格式、密码是否正确、是否和历史密码相同),校验通过,将新密码设置到历史密码表中(同时更新用户信息表的密码和盐值);新增用户时,先校验密码格式,校验通过,新增用户,同时将密码设置到历史密码表中。
历史密码表
用户信息表我选择保留 password(密码加密密文)、salt(盐值)这两个字段,所以这个表不做更改。
历史密码表如下:
DROP TABLE IF EXISTS `sys_history_pwd`;
CREATE TABLE `sys_history_pwd` (
`id` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'ID编号',
`create_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`user_id` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '用户id',
`password` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '密码',
`salt` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '盐值',
PRIMARY KEY (`id`) USING BTREE,
INDEX `user_id`(`user_id` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '存储用户历史密码' ROW_FORMAT = Dynamic;
新增用户
/**
* 新增
*/
@PostMapping("/insert")
public ResultUtil insert(@RequestBody SysUser user) throws NoSuchAlgorithmException, InvalidKeySpecException {
// 省略其他代码......
// 注意密码传输应该进行加密,而不能直接用明文传输,这里我省略了密码解密这一步,项目中要注意
// 校验用户名和密码不可相同
if(user.getUserName().equals(user.getPassword())){
return ResultUtil.error("密码不可与用户名称相同");
}
String salt = EncryptionUtil.generateSalt();
// 根据明文密码和盐值进行加密,得到密文
String password = EncryptionUtil.getEncryptedPassword(password, salt);
user.setId(IdUtil.fastSimpleUUID());
user.setSalt(salt);
user.setPassword(password);
sysUserService.save(user);
// 记录历史密码
sysHistoryPwdService.insertHistoryPwd(user.getId(),password,salt);
return ResultUtil.success();
}
修改密码
/**
* 修改密码
*/
@PostMapping("/updatePassword")
public ResultUtil updatePassword(String oldPassword,String newPassword,String userId) throws InvalidKeySpecException, NoSuchAlgorithmException {
// 省略其他代码......
// 注意密码传输应该进行加密,而不能直接用明文传输
SysUser user = sysUserService.getById(userId);
//对旧密码进行加密对比:先将用户输入的旧密码(明文)根据盐值加密,对比用户输入的旧密码的密文是否和数据库中的密文一致
boolean authenticate = EncryptionUtil.authenticate(oldPassword, user.getPassword(), user.getSalt());
if (!authenticate){
return ResultUtil.error("旧密码错误");
}
// 校验历史密码
if (sysHistoryPwdService.checkNewPwd(userId, newPassword)){
return ResultUtil.error("新密码不可与旧密码相同");
}
String salt = EncryptionUtil.generateSalt();
// 根据明文密码和盐值进行加密,得到密文
String password = EncryptionUtil.getEncryptedPassword(newPassword, salt);
UpdateWrapper<SysUser> updateWrapper = new UpdateWrapper<>();
updateWrapper.set("password", password);
updateWrapper.set("salt", salt);
updateWrapper.eq("id",user.getId());
sysUserService.update(updateWrapper);
// 记录历史密码
sysHistoryPwdService.insertHistoryPwd(userId,password,salt);
return ResultUtil.success();
}
修改密码这里,我只做了在记得旧密码的情况下修改密码,没有做忘记密码的功能,不过“校验新密码和历史密码是否相同”都是一样的。
SysHistoryPwdService:
/**
* 根据用户id获取该用户的历史密码
*/
public List<SysHistoryPwd> historyPwdByUserId(String userId){
QueryWrapper<SysHistoryPwd> query = new QueryWrapper<>();
query.eq("user_id",userId);
query.orderByDesc("create_time"); // 创建时间倒叙
query.last("limit 2"); // 只取最近的两个密码
return baseMapper.selectList(query);
}
/**
* 校验新密码是否和前两次历史密码相同
*/
public boolean checkNewPwd(String userId,String newPwd) throws NoSuchAlgorithmException, InvalidKeySpecException {
List<SysHistoryPwd> historys = historyPwdByUserId(userId);
boolean flag = false; // false 新密码与历史密码不同 true 新密码与历史密码相同
for (SysHistoryPwd item : historys) {
boolean authenticate = EncryptionUtil.authenticate(newPwd, item.getPassword(), item.getSalt());
if (authenticate){
flag = true;
break;
}
}
return flag;
}
/**
* 新增一条历史密码数据
*/
public void insertHistoryPwd(String userId,String password,String salt) throws NoSuchAlgorithmException, InvalidKeySpecException {
SysHistoryPwd historyPwd = new SysHistoryPwd();
if (StrUtil.isEmpty(salt)){
salt = EncryptionUtil.generateSalt();
password = EncryptionUtil.getEncryptedPassword(password, salt);
}
historyPwd.setId(IdUtil.fastSimpleUUID());
historyPwd.setUserId(userId);
historyPwd.setSalt(salt);
historyPwd.setPassword(password);
baseMapper.insert(historyPwd);
}
EncryptionUtil密码盐值加密工具类:
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.math.BigInteger;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.KeySpec;
import java.util.Random;
/**
* 密码盐值加密工具类
*/
public class EncryptionUtil {
private EncryptionUtil(){}
public static final String PBKDF2_ALGORITHM = "PBKDF2WithHmacSHA256";
/**
* 盐的长度
*/
public static final int SALT_BYTE_SIZE = 32;
/**
* 生成密文的长度
*/
public static final int HASH_BIT_SIZE = 128;
/**
* 迭代次数
*/
public static final int PBKDF2_ITERATIONS = 2000;
/**
* 验证输入的password是否正确
* @param attemptedPassword 待验证的password
* @param encryptedPassword 密文
* @param salt 盐值
*/
public static boolean authenticate(String attemptedPassword, String encryptedPassword, String salt) throws NoSuchAlgorithmException, InvalidKeySpecException {
// 用同样的盐值对用户输入的password进行加密
String encryptedAttemptedPassword = getEncryptedPassword(attemptedPassword, salt);
// 把加密后的密文和原密文进行比較,同样则验证成功。否则失败
return encryptedAttemptedPassword.equals(encryptedPassword);
}
/**
* 生成密文
* @param password 明文password
* @param salt 盐值
*/
public static String getEncryptedPassword(String password, String salt) throws NoSuchAlgorithmException, InvalidKeySpecException {
KeySpec spec = new PBEKeySpec(password.toCharArray(), fromHex(salt), PBKDF2_ITERATIONS, HASH_BIT_SIZE);
SecretKeyFactory f = SecretKeyFactory.getInstance(PBKDF2_ALGORITHM);
return toHex(f.generateSecret(spec).getEncoded());
}
/**
* 通过提供加密的强随机数生成器 生成盐
*/
public static String generateSalt() throws NoSuchAlgorithmException {
SecureRandom random = SecureRandom.getInstance("SHA1PRNG");
byte[] salt = new byte[SALT_BYTE_SIZE];
random.nextBytes(salt);
return toHex(salt);
}
/**
* 十六进制字符串转二进制字符串
* @param hex the hex string
*/
private static byte[] fromHex(String hex) {
byte[] binary = new byte[hex.length() / 2];
for (int i = 0; i < binary.length; i++) {
binary[i] = (byte) Integer.parseInt(hex.substring(2 * i, 2 * i + 2), 16);
}
return binary;
}
/**
* 二进制字符串转十六进制字符串
* @param array the byte array to convert
*/
private static String toHex(byte[] array) {
BigInteger bi = new BigInteger(1, array);
String hex = bi.toString(16);
int paddingLength = (array.length * 2) - hex.length();
if (paddingLength > 0) return String.format("%0" + paddingLength + "d", 0) + hex;
else return hex;
}
}
最后
密码加密、校验是否正确这些,根据自己的加密方式调整,反正道理就是这么个道理。
这样我们就实现了历史密码,快点把这个功能加进自己的项目里吧,这样说不定以后别人用你的系统也会有我开头的那种疑问呢😂
(欢迎大家多多给博主点赞或关注~)