Shiro+Redis实现登录次数冻结的示例
一个JavaBean 人气:0概述
假设我们需要有这样一个场景:如果用户连续输错5次密码,那可能说明有人在搞事情,所以需要暂时冻结该账户的登录功能
关于Shiro整合JWT,可以看这里:Springboot实现Shiro+JWT认证
假设我们的项目中用到了shiro,因为Shiro是建立在完善的接口驱动设计和面向对象原则之上的,支持各种自定义行为,所以我们可以结合Shiro框架的认证模块和redis来实现这个功能。
思路
我们大体的思路如下:
- 用户登录
- Shiro去Redis检查账户的登录错误次数是否超过规定范围(超过了就是所谓的冻结)
- Shiro进行密码比对
- 如果登录失败,则去Redis里记录:登录错误次数+1
- 如果密码正确,则登录成功,删除Redis里的登录错误记录
前期准备
除了需要用到Shiro以外,我们也需要用到Redis,这里需要先配置好RedisTemplate,(由于这个不是重点,我就把代码和配置方法贴在文章的最后了),另外,在Controller层,登录接口的异常处理除了之前的登录错误,还需要新增一个账户冻结类的异常,代码如下:
@PostMapping(value = "/login") public AccountVO login(String userName, String password){ //尝试登录 Subject subject = SecurityUtils.getSubject(); try { //通过shiro提供的安全接口来进行认证 subject.login(new UsernamePasswordToken(userName, password)); } catch (ExcessiveAttemptsException e1) { //新增一个账户锁定类错误 throw new AccountLockedException(); } catch (Exception e) { //其他的错误判定 throw new LoginFailed(); } //聚合登录信息 AccountVO account = accountService.getAccountByUserName(userName); //返回正确登录的结果 return account; }
自定义Shiro认证管理器
HashedCredentialsMatcher
当你在上面的Controller层调用subject.login方法后,会进入到自定义的Realm里去,然后慢慢进入到Shiro当前的Security Manager里定义的HashedCredentialsMatcher认证管理器的doCredentialsMatch方法,进行密码匹配,原版代码如下:
/** * This implementation first hashes the {@code token}'s credentials, potentially using a * {@code salt} if the {@code info} argument is a * {@link org.apache.shiro.authc.SaltedAuthenticationInfo SaltedAuthenticationInfo}. It then compares the hash * against the {@code AuthenticationInfo}'s * {@link #getCredentials(org.apache.shiro.authc.AuthenticationInfo) already-hashed credentials}. This method * returns {@code true} if those two values are {@link #equals(Object, Object) equal}, {@code false} otherwise. * * @param token the {@code AuthenticationToken} submitted during the authentication attempt. * @param info the {@code AuthenticationInfo} stored in the system matching the token principal * @return {@code true} if the provided token credentials hash match to the stored account credentials hash, * {@code false} otherwise * @since 1.1 */ @Override public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) { Object tokenHashedCredentials = hashProvidedCredentials(token, info); Object accountCredentials = getCredentials(info); return equals(tokenHashedCredentials, accountCredentials); }
可以发现,原版的逻辑很简单,就做了两件事,获取密码,比对密码。
由于我们需要联动Redis,在每次登录前都做一次冻结检查,每次遇到登录失败之后还需要实现对redis的写操作,所以现在需要重写一个认证管理器去配置到Security Manager里。
CustomMatcher
我们自定义一个CustomMatcher,这个类继承了HashedCredentialsMatcher,唯独重写了doCredentialsMatch方法,在这里面加入了我们自己的逻辑,代码如下:
import com.imlehr.internship.redis.RedisStringService; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.ExcessiveAttemptsException; import org.apache.shiro.authc.UsernamePasswordToken; import org.apache.shiro.authc.credential.HashedCredentialsMatcher; import org.springframework.beans.factory.annotation.Autowired; /** * @author Lehr * @create: 2020-02-25 */ public class CustomMatcher extends HashedCredentialsMatcher { //这个是redis里的key的统一前缀 private static final String PREFIX = "USER_LOGIN_FAIL:"; @Autowired RedisStringService redisUtils; @Override public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) { //检查本账号是否被冻结 //先获取用户的登录名字 UsernamePasswordToken myToken = (UsernamePasswordToken) token; String userName = myToken.getUsername(); //初始化错误登录次数 Integer errorNum = 0; //从数据库里获取错误次数 String errorTimes = (String)redisUtils.get(PREFIX+userName); if(errorTimes!=null && errorTimes.trim().length()>0) { //如果得到的字符串不为空不为空 errorNum = Integer.parseInt(errorTimes); } //如果用户错误登录次数超过十次 if (errorNum >= 10) { //抛出账号锁定异常类 throw new ExcessiveAttemptsException(); } //先按照父类的规则来比对密码 boolean matched = super.doCredentialsMatch(token, info); if(matched) { //清空错误次数 redisUtils.remove(PREFIX+userName); } else{ //添加一次错误次数 秒为单位 redisUtils.set(PREFIX+userName,String.valueOf(++errorNum),60*30L); } return matched; } }
首先,我们从AuthenticationToken里面拿到之前存入的用户的登录信息,这个对象其实就是你在Controller层
subject.login(new UsernamePasswordToken(userName, password));
这一步里面你实例化的对象
然后,通过用户的登录名加上固定前缀(为了防止防止userName和其他主键冲突)去Redis里获取到错误次数。判断账户是否被冻结的逻辑其实就是看当前用户的错误登录次数是否超过某个规定值,这里我们定为5次。
接下来,说明用户没有被冻结,可以执行登录操作,所以我们就直接调用父类的验证方法来进行密码比对(就是之前提到的那三行代码),得到密码的比对结果
如果比对一致,那么就成功登录,返回true即可,也可以选择一旦登录成功,就消除所有错误次数记录,上面的代码就是这样做的。
如果对比结果不一样,那就再添加一次错误记录,然后返回false
测试
第一次登录:页面结果:
Redis中:
然后连续错误10次:
页面结果:
Redis中:
然后等待了半小时之后(其实我调成了5分钟)
再次尝试错误密码登录:
再次报错,此时Redis里由于之前的记录到期了,自动销毁了,所以再次触发错误又会添加一次错误记录
现在尝试一次正确登录:
成功登录
查看Redis:
🎉Done!
附RedisTemplate代码
配置类
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) { //我就用的默认的序列化处理器 StringRedisSerializer stringRedisSerializer = new StringRedisSerializer(); JdkSerializationRedisSerializer ser = new JdkSerializationRedisSerializer(); RedisTemplate<String, Object> template = new RedisTemplate<String, Object>(); template.setConnectionFactory(redisConnectionFactory); template.setKeySerializer(stringRedisSerializer); template.setValueSerializer(ser); return template; } @Bean public RedisStringService myStringRedisTemplate() { return new RedisStringService(); } }
工具类RedisStringService
一个只能用来处理Value是String的工具类,就是我在CustomMatcher里Autowired的这个类
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.stereotype.Service; import java.util.concurrent.TimeUnit; public class RedisStringService { @Autowired protected StringRedisTemplate redisTemplate; /** * 写入redis缓存(不设置expire存活时间) * @param key * @param value * @return */ public boolean set(final String key, String value){ boolean result = false; try { ValueOperations operations = redisTemplate.opsForValue(); operations.set(key, value); result = true; } catch (Exception e) { e.getMessage(); } return result; } /** * 写入redis缓存(设置expire存活时间) * @param key * @param value * @param expire * @return */ public boolean set(final String key, String value, Long expire){ boolean result = false; try { ValueOperations operations = redisTemplate.opsForValue(); operations.set(key, value); redisTemplate.expire(key, expire, TimeUnit.SECONDS); result = true; } catch (Exception e) { e.getMessage(); } return result; } /** * 读取redis缓存 * @param key * @return */ public Object get(final String key){ Object result = null; try { ValueOperations operations = redisTemplate.opsForValue(); result = operations.get(key); } catch (Exception e) { e.getMessage(); } return result; } /** * 判断redis缓存中是否有对应的key * @param key * @return */ public boolean exists(final String key){ boolean result = false; try { result = redisTemplate.hasKey(key); } catch (Exception e) { e.getMessage(); } return result; } /** * redis根据key删除对应的value * @param key * @return */ public boolean remove(final String key){ boolean result = false; try { if(exists(key)){ redisTemplate.delete(key); } result = true; } catch (Exception e) { e.getMessage(); } return result; } /** * redis根据keys批量删除对应的value * @param keys * @return */ public void remove(final String... keys){ for(String key : keys){ remove(key); } } }
加载全部内容