需求是要给一个已经存在的项目新增用户登录的限制,如果密码输入错误达到10次,则冻结账户15分钟。被这个问题困扰了两天,最终实现以后回过头来发现其实还是比较简单的。之所以能被难住,其实还是对Shiro的不了解……具体实现方案如下:
1、自定义一个凭证匹配器:
RetryLimitHashedCredentialsMatcher
package com.sh.demo.common.shiro;
import cn.hutool.core.lang.Validator;
import cn.hutool.core.util.StrUtil;
import com.sh.demo.common.util.JedisUtils;
import com.sh.demo.common.util.SHA256Util;
import com.sh.demo.core.entity.SysUserEntity;
import com.sh.demo.core.service.SysUserService;
import org.apache.shiro.authc.*;
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import redis.clients.jedis.JedisCommands;
import javax.annotation.Resource;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @Author zf
* @ClassName RetryLimitHashedCredentialsMatcher.java
* @ProjectName demo
*/
public class RetryLimitHashedCredentialsMatcher extends HashedCredentialsMatcher {
@Resource
SysUserService sysUserService;
//登录操作:密码输入错误达到10次,临时锁定账号15分钟!
//连接Redis
JedisCommands jedisCommands = JedisUtils.getJedisCommands();
@Override
public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) throws AuthenticationException {
String loginName = (String) token.getPrincipal();//获取登录用户名
AtomicInteger errorNum = new AtomicInteger(0);//初始化错误登录次数
if (Validator.isMobile(loginName)) {//手机账号登录
//判断密码错误是否存在
if (jedisCommands.exists("login:error:" + loginName)){
String value = jedisCommands.get("login:error:" + loginName);//获取错误登录的次数
if (StrUtil.isNotBlank(value)) {
errorNum = new AtomicInteger(Integer.parseInt(value));
}
if (errorNum.get() >= 10) { //如果用户错误登录次数超过十次
//临时锁定
throw new AuthenticationException("账户已被临时锁定!");
}
}
}
//获取数据库中的用户信息
SysUserEntity user = getSysUserEntity(loginName);
//获取token中用户输入的密码
String password = new String((char[])token.getCredentials());
//通过salt进行加密
String en_pw = SHA256Util.sha256(password, user.getSALT());
//比较账号密码是否正确
boolean flag = loginName.equals(user.getMOBILE()) && en_pw.equals(user.getPASS_WORD()) ? true : false;
if (flag) {
jedisCommands.del("login:error:" + loginName);//移除缓存中用户的错误登录次数
} else {
//存储错误次数到redis中 i:900秒=15分钟
jedisCommands.setex("login:error:" + loginName, 900, errorNum.incrementAndGet() + "");
}
return flag;
}
/**
* @Description 通过登录账号获取数据库中的用户信息
* @param loginName
*/
private SysUserEntity getSysUserEntity(String loginName) {
//此处省略从数据库中查询用户的操作
return user;
}
}
自定义的凭证匹配器继承了
HashedCredentialsMatcher
,重写doCredentialsMatch
方法。这一步有个大坑,在方法中抛出的自定义异常会被Shiro框架本身的
AuthenticationException
覆盖,并且会修改报错信息导致自定义的'账户已被临时锁定!'
并不会被controller层的catch
捕获。稍后详说!2、ShiroConfig引入凭证匹配器:
ShiroConfig
/*
* @describe 自定义凭证匹配器
* (由于我们的密码校验交给Shiro的SimpleAuthenticationInfo进行处理了所以我们需要修改下doGetAuthenticationInfo中的代码)
* 可以扩展凭证匹配器,实现输入密码错误次数后锁定等功能
* @return org.apache.shiro.authc.credential.HashedCredentialsMatcher
*/
@Bean
public RetryLimitHashedCredentialsMatcher hashedCredentialsMatcher() {
RetryLimitHashedCredentialsMatcher hashedCredentialsMatcher = new RetryLimitHashedCredentialsMatcher();
hashedCredentialsMatcher.setHashAlgorithmName("md5");//散列算法:这里使用MD5算法;
hashedCredentialsMatcher.setHashIterations(2);//散列的次数,比如散列两次,相当于 md5(md5(""));
//storedCredentialsHexEncoded默认是true,此时用的是密码加密用的是Hex编码;false时用Base64编码
// hashedCredentialsMatcher.setStoredCredentialsHexEncoded(true);
return hashedCredentialsMatcher;
}
然后在
ShiroConfig
配置类中引入自定义凭证匹配器,用户登录时执行subject.login(token);
方法时就会运行到我们上面自定义的凭证匹配器里面。3、userLogin加入相关校验:
isTemporaryFreeze
/**
* @Description 校验账户是否被临时冻结
* @Author zf
* @param user
* @return boolean
*/
private boolean isTemporaryFreeze (SysUserEntity user) {
//自定义异常会被框架转为此异常,故此处重新判断!
//连接Redis
JedisCommands jedisCommands = JedisUtils.getJedisCommands();
AtomicInteger errorNum = new AtomicInteger(0);
//判断密码错误是否存在
if (jedisCommands.exists("login:error:" + user.getMOBILE())){
//获取错误登录的次数
String value = jedisCommands.get("login:error:" + user.getMOBILE());
if (StrUtil.isNotBlank(value)) {
errorNum = new AtomicInteger(Integer.parseInt(value));
}
if (errorNum.get() >= 10) { //如果用户错误登录次数超过十次
//临时锁定
return true;
}
}
return false;
}
上面说到在自定义凭证匹配器的
doCredentialsMatch
方法里面抛出的自定义异常会被框架本身的AuthenticationException
异常覆盖,导致自定义的错误信息不能被捕获。所以我们在登录接口userLogin
里面新建一个isTemporaryFreeze
方法,在自定义凭证匹配器判断用户输入密码错误次数大于10次以后,会抛出自定义异常,然后被框架覆盖为AuthenticationException
异常,此时只要捕获AuthenticationException
异常,并在catch
里面引入isTemporaryFreeze
方法,再次获取Redis
中的错误记录次数并返回结果即可。try-catch
try{
//此处省略用户登录流程
} catch (AuthenticationException e) {
if (isTemporaryFreeze(user))
return resultMap.err("输入错误次数过多账户已被锁定,请" + (int) Math.ceil(Double.valueOf(JedisUtils.getJedisCommands().ttl("login:error:" + user.getMOBILE())) / 60) + "分钟后再试!").code(-1).info("");
return resultMap.err("用户不存在或者密码错误!").code(-1).info("");
}