需求是要给一个已经存在的项目新增用户登录的限制,如果密码输入错误达到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("");
    }

最后修改:2022 年 09 月 20 日
给我一点小钱钱也很高兴啦!o(* ̄▽ ̄*)ブ