本篇介绍 Shiro 在登录控制相关的实际应用。
面对暴力破解密码方式的进行登录的攻击,比较有效的应对手段除了使用验证码,对一定时间内登录尝试次数进行限制同样是行之有效的。
在这里可以用 Redis 记录某段时间内的登录失败尝试次数,如果超过某个阈值,就不检查密码是否正确,直接返回错误信息。若干时间后再次允许对该账号的登录尝试。
Shiro 对密码进行比较的工作是由 Realm 的CredentialsMatcher
完成的,因此我们可以自定义一个CredentialsMatcher
,并且在进行密码匹配工作前实现登录尝试次数检查的工作。
public class RetryLimitCredentialsMatcher extends HashedCredentialsMatcher {
/**
* 最大重试次数
*/
private final int maxRetryTimes;
/**
* 多长时间后可以再次重试,单位秒
*/
private final long retryDelay;
private RedisConnectionFactory redisConnectionFactory;
public RetryLimitCredentialsMatcher(int maxRetryTimes,
long retryDelay,
String hashAlgorithmName,
RedisConnectionFactory redisConnectionFactory) {
super(hashAlgorithmName);
this.maxRetryTimes = maxRetryTimes;
this.retryDelay = retryDelay;
this.redisConnectionFactory = redisConnectionFactory;
}
public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
String username = (String) token.getPrincipal();
String key = getCounterKey(username);
RedisAtomicInteger retryCounter = new RedisAtomicInteger(key, redisConnectionFactory);
int retryTimes = retryCounter.get();
if (retryTimes >= maxRetryTimes){
// 如果登录次数超过限制
throw new ExcessiveAttemptsException("登录过于频繁,请稍后再试");
}
// 如果登录次数没有超过限制,计数器+1
retryCounter.incrementAndGet();
// 重置有效时长
retryCounter.expire(retryDelay, TimeUnit.SECONDS);
// 检查密码是否正确,并返回结果
boolean matchResult = super.doCredentialsMatch(token, info);
if (matchResult){
// 如果密码正确,清除计数器
retryCounter.expire(0, TimeUnit.SECONDS);
}
return matchResult;
}
private String getCounterKey(String username) {
return "login-retry:" + username;
}
}
这里为了在 Redis 中记录尝试次数,使用了 spring-data-redis 的 RedisAtomicInteger
,这是一个基于 Java 原子类的 Redis 数据操作的抽象,可以像使用 Java 原子类那样当做一个支持并发的计数器使用(所有操作都是原子性的)。
让 Realm 使用自定义密码匹配器:
public class MyRealm extends AuthorizingRealm {
private UserService userService;
private CachedUserService cachedUserService;
private RedisConnectionFactory redisConnectionFactory;
private ShiroProperties shiroProperties;
public void initMyRealm(){
// 设置密码匹配器
// 设置密码匹配方式为 SHA1 加密后匹配
RetryLimitCredentialsMatcher matcher = new RetryLimitCredentialsMatcher(shiroProperties.getRetryMax(),
shiroProperties.getRetryDelay(),
EncryptorUtil.ALGORITHM_NAME,
redisConnectionFactory);
// 设置加密次数
matcher.setHashIterations(EncryptorUtil.TIMES);
setCredentialsMatcher(matcher);
}
// ...
}
具体的重试次数等参数放在配置文件 shiro.yml 中:
my
shiro
retry-max 5 # 最大登录重试次数
retry-delay 300 # 达到最大重试次数后多长时间可以重试,单位秒
由配置类读取:
value = "classpath:/shiro.yml", factory = YamlPropertySourceFactory.class)
(prefix = "my.shiro")
(
public class ShiroProperties {
private List<String> urls;
/**
* 最大尝试次数
*/
private Integer retryMax;
/**
* 达到最大尝试次数后多长时间可以重试
*/
private Long retryDelay;
}
程序运行后的最终效果是,如果某个用户短期内密码尝试次数超过5次,就会无法登录,需要等待5分钟后才能再次尝试登录。
限制同时在线设备数
还可以通过 Shiro 限制同时在线的设备数目。
如果是多个设备登录同一个帐号,虽然使用的帐号是同一个,但是 Shiro 会生成不同的 Session。所以这里的实现思路是在 Redis 中维护一个基于用户名的 SessionID 队列,有一个该用户的新 Session 产生,就将对应的 SessionID 加入这个队列,我们只需要检查队列的长度是否满足要求,如果超过了允许范围,就从队列头部删除若干 SessionID 和对应的 Session,这样相应设备的登录状态自然也就失效了。
具体这里使用 Shiro 过滤器实现:
public class OnlineLimitFilter extends AccessControlFilter {
name = "objectRedisTemplate")
( private RedisTemplate<String, Serializable> sessionIDRedisTemplate;
"webSessionManager")
( private SessionManager sessionManager;
private SessionDAO sessionDAO;
private ShiroProperties shiroProperties;
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
return false;
}
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
// 检查是否登录
Subject subject = SecurityUtils.getSubject();
if (!subject.isAuthenticated()) {
// 没有登录,不进行处理
return true;
}
String username = (String) subject.getPrincipal();
Serializable sessionId = subject.getSession().getId();
// 获取用户对应的 session 队列
DefaultRedisList<Serializable> sessionQueue = new DefaultRedisList("session-queue:" + username, sessionIDRedisTemplate);
if (!sessionQueue.contains(sessionId)) {
// 如果 session 队列中没有当前 SessionID,加入
sessionQueue.addLast(sessionId);
}
if (sessionQueue.size() > shiroProperties.getOnlineDevices()) {
// 如果 Session 队列中的 SessionID 数量大于1,从头部删除一个
Serializable removedSessionID = sessionQueue.removeFirst();
// 将 SessionID 对应的 Session 作废
Session session;
try {
session = sessionManager.getSession(new DefaultSessionKey(removedSessionID));
} catch (SessionException e) {
// Session 已经作废或过期,不处理
return true;
}
if (session != null) {
sessionDAO.delete(session);
}
}
return true;
}
}
spring-data-redis 提供的基于 Java 队列的实现是DefaultRedisList
,它需要一个RedisTemplate
。
因为 Shiro 的 SessionID 是Serializable
类型,这里定义了一个使用 JDK 序列化的 RedisTemplate:
public class RedisConfig extends CachingConfigurerSupport {
// ...
public RedisTemplate objectRedisTemplate(RedisConnectionFactory redisConnectionFactory){
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
template.setKeySerializer(stringRedisSerializer);
template.setValueSerializer(new JdkSerializationRedisSerializer());
template.afterPropertiesSet();
return template;
}
}
定义好过滤器还需要将其添加为 SpringBean,并且在过滤器中添加:
public class ShiroConfig {
// ...
public OnlineLimitFilter onlineLimitFilter(){
return new OnlineLimitFilter();
}
/**
* shiro 过滤器
*
* @return
*/
public ShiroFilterFactoryBean shiroFilterFactoryBean() {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
// 设置过滤器关联的安全管理器
shiroFilterFactoryBean.setSecurityManager(webSecurityManager());
Map<String, Filter> filters = new HashMap<>();
// 添加自定义过滤器
filters.put("orRoles", new OrRolesAuthorizationFilter());
filters.put("onlineLimit", onlineLimitFilter());
shiroFilterFactoryBean.setFilters(filters);
// ...
}
}
新添加的过滤器可以在过滤器链中使用:
my
shiro
urls
/jsp/user/login.jsp=anon
/user/login=anon"brand:view"
/brand/list=authc,onlineLimit, orRoles sys_manager dep_manager , perms"brand:add"
/brand/add=authc,onlineLimit, orRoles sys_manager dep_manager , perms"brand:add"
/jsp/brand/add.jsp=authc,onlineLimit, orRoles sys_manager dep_manager , perms
/brand/**=authc,onlineLimit, orRoles sys_manager dep_manager
/jsp/**=authc,onlineLimit /user/**=authc,onlineLimit
开启两个浏览器,先用其中一个登陆,再用同一个帐号在另一个登陆,就会发现前边的登录失效。
The End,谢谢阅读。
本文的完整示例可以从获取。
文章评论