一、基本思路

        不管是单机还是集群,我们都得把用户的登录次数记录下来,放到缓存里面。

        单机使用的是Ehcache缓存,集群使用的是Redis缓存。单机或集群对于缓存来说,只是CacheManager接口的实现方式不同。

        我们可以按照如下的思路来限制登录次数:

        先查看是否系统中是否已有登录次数缓存。缓存对象结构预期为:"用户名--登录次数"。

        如果之前没有登录缓存,则创建一个登录次数缓存。

        将缓存记录的登录次数加1。

        如果缓存次数已经超过限制,则驳回本次登录请求。

        将缓存次数其保存到缓存中。

        验证用户本次输入的帐号密码,如果登录登录成功,则清除掉登录次数的缓存。

        代码只是思路的翻译。我们按照上述思路还编写代码。

        用户名可以从Shiro的token中获取,登录次数可以使用原子类AtomicInteger保证线程安全。

package com.jay.shiro;

import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.ExcessiveAttemptsException;
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheManager;
import org.slf4j.Logger;

import java.util.concurrent.atomic.AtomicInteger;

import static org.slf4j.LoggerFactory.getLogger;

/**
* @author jay.zhou
* @date 2019/1/17
* @time 9:28
*/
public class RetryLimitCredentialsMatcher extends HashedCredentialsMatcher {

/**
* 集群中可能会导致出现验证多过5次的现象,因为AtomicInteger只能保证单节点并发
*/
private Cache<String, AtomicInteger> passwordRetryCache;
private static final Logger LOGGER = getLogger(RetryLimitCredentialsMatcher.class);
private static final String RETRY_CACHE_NAME = "passwordRetryCache";
private static final Integer MAX_RETRY_COUNT = 5;

/**
* cacheManager对象由外部注入
* 可以是Ehcache的CacheManager
* 也可以注入自定义的CacheManager
*
* @param cacheManager cacheManager
*/
private RetryLimitCredentialsMatcher(CacheManager cacheManager) {
/**
* 此处从CacheManager中获取缓存Cache对象
* 本例中获取的缓存对象是从Ehcache.xml配置中获取
* 如果是我们自定义CacheManager的话,
* 可用下面的实现思路:
* 先尝试从缓区池中获取名为RETRY_CACHE_NAME的缓存对象
* 如果缓存池中没有名为RETRY_CACHE_NAME的缓存对象
* 那么则创建名为RETRY_CACHE_NAME的缓存对象,并放入到缓存池中
* 保证本类属性passwordRetryCache不为空
*/
passwordRetryCache = cacheManager.getCache(RETRY_CACHE_NAME);
}

@Override
public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
final String clientUserName = (String) token.getPrincipal();
//先查看是否系统中是否已有登录次数缓存
AtomicInteger retryCount = passwordRetryCache.get(clientUserName);
// 如果之前没有登录缓存,则创建一个登录次数缓存。
if (retryCount == null) {
retryCount = new AtomicInteger(0);
}
//将缓存记录的登录次数加1
retryCount.incrementAndGet();
//如果有且次数已经超过限制,则驳回本次登录请求。
if (retryCount.get() > MAX_RETRY_COUNT) {
LOGGER.error("登录次数超过限制");
throw new ExcessiveAttemptsException("用户:" + clientUserName + "登录次数已经超过限制");
}
//并将其保存到缓存中
passwordRetryCache.put(clientUserName, retryCount);
//debug
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("用户:{},尝试登录次数:{}", clientUserName, retryCount.get());
}
//调用超类验证器,判断是否登录成功
boolean isMatcher = super.doCredentialsMatch(token, info);
//如果成功则清除缓存
if (isMatcher) {
passwordRetryCache.remove(clientUserName);
}
return isMatcher;
}
}

       (1) 在Ehcache中配置名为passwordRetryCache缓存对象的锁定时间。

<ehcache name="shiroCache">
<!-- 磁盘上缓存的位置 -->
<diskStore path="java.io.tmpdir"/>

<defaultCache
maxElementsInMemory="10000"
eternal="false"
overflowToDisk="false"
timeToIdleSeconds="300"
timeToLiveSeconds="300"
diskPersistent="false"
diskExpiryThreadIntervalSeconds="120"
/>

<!-- 登录验证缓存,缓存1分钟 -->
<cache name="passwordRetryCache"
maxElementsInMemory="10000"
eternal="false"
overflowToDisk="false"
timeToIdleSeconds="60"
timeToLiveSeconds="60"
diskPersistent="false"
diskExpiryThreadIntervalSeconds="120"
/>
</ehcache>

         (2)Spring配置CacheManager

<!-- securityManager 对象-->
<bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
...
<!-- 引入UserRealm -->
<property name="realm" ref="userRealm"/>
<!-- 引入ehcache缓存 -->
<property name="cacheManager" ref="cacheManager"/>
</bean>

<!-- shiro的自带 EhCache缓存管理器 -->
<bean id="cacheManager" class="org.apache.shiro.cache.ehcache.EhCacheManager">
<property name="cacheManagerConfigFile" value="classpath:ehcache.xml"/>
</bean>

<!-- 凭证匹配器 -->
<bean id="userCredentialsMatcher" class="com.jay.shiro.RetryLimitCredentialsMatcher">
<!-- 为自定义Matcher注入缓存管理器-->
<constructor-arg ref="cacheManager"/>
<!-- 我们继承的HashedCredentialsMatcher类的构造函数中没有指明加密算法
因此我们得手动配置,使用md5算法循环加密一次即可-->
<property name="hashAlgorithmName" value="md5"/>
<property name="hashIterations" value="1"/>
<property name="storedCredentialsHexEncoded" value="true"/>
</bean>

<!-- 自定义Realm -->
<bean id="userRealm" class="com.jay.shiro.UserRealm">
<property name="cachingEnabled" value="true"/>
<!-- 为自定义Realm注入密码匹配器-->
<property name="credentialsMatcher" ref="userCredentialsMatcher"/>
...
</bean>

 (3)自定义Realm中的配置

@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token)
throws AuthenticationException {
//还记得吗,token封装了客户端的帐号密码,由Subject拉客并最终带到此处
String clientUsername = (String) token.getPrincipal();
//从数据库中查询帐号密码
String passwordFromDB = userService.findPasswordByName(clientUsername);
if (passwordFromDB == null) {
//如果根据用户输入的用户名,去数据库中没有查询到相关的密码
throw new UnknownAccountException();
}

//使用相同的加密算法,md5加密,默认加密一次
Md5Hash md5Hash = new Md5Hash(passwordFromDB);

return new SimpleAuthenticationInfo(clientUsername, md5Hash.toString(), "UserRealm");
}

        数据库中存放的密码是123456,通过MD5加密循环加密1次后为:e10adc3949ba59abbe56e057f20f883e。并将此密文那过去与密码凭证器中的解析出来的密文进行对比,看是否一致。本例仅为实例项目,在实际项目中数据库的密码是加密后的密文。

第十六节 Shiro限制密码重试次数限制_java

          更多关于Shiro加密的操作可参考:​​第三节 Shiro对加密的支持​

二、测试

输入超过五次错误密码后,限制再次登录,并提示用户等待一段时间后重试。

        

第十六节 Shiro限制密码重试次数限制_自定义_02