红茶的个人站点

  • 首页
  • 专栏
  • 开发工具
  • 其它
  • 隐私政策
Awalon
Talk is cheap,show me the code.
  1. 首页
  2. 其它
  3. 正文

Shiro 学习笔记5:登录控制

2023年9月25日 972点热度 0人点赞 0条评论

本篇介绍 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;
    }
​
    @Override
    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 使用自定义密码匹配器:

@Log4j2
public class MyRealm extends AuthorizingRealm {
    @Autowired
    private UserService userService;
    @Autowired
    private CachedUserService cachedUserService;
    @Autowired
    private RedisConnectionFactory redisConnectionFactory;
    @Autowired
    private ShiroProperties shiroProperties;
​
    @PostConstruct
    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 # 达到最大重试次数后多长时间可以重试,单位秒

由配置类读取:

@Configuration
@PropertySource(value = "classpath:/shiro.yml", factory = YamlPropertySourceFactory.class)
@ConfigurationProperties(prefix = "my.shiro")
@Data
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 {
    @Resource(name = "objectRedisTemplate")
    private RedisTemplate<String, Serializable> sessionIDRedisTemplate;
    @Autowired
    @Qualifier("webSessionManager")
    private SessionManager sessionManager;
    @Autowired
    private SessionDAO sessionDAO;
    @Autowired
    private ShiroProperties shiroProperties;
​
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
        return false;
    }
​
    @Override
    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:

@Configuration
public class RedisConfig extends CachingConfigurerSupport {
    // ...
    @Bean
    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,并且在过滤器中添加:

@Configuration
public class ShiroConfig {
    // ...
    @Bean
    public OnlineLimitFilter onlineLimitFilter(){
        return new OnlineLimitFilter();
    }
​
    /**
     * shiro 过滤器
     *
     * @return
     */
    @Bean
    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/list=authc,onlineLimit, orRoles[sys_manager,dep_manager], perms["brand:view"]
      - /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:add"]
      - /brand/**=authc,onlineLimit, orRoles[sys_manager,dep_manager]
      - /jsp/**=authc,onlineLimit
      - /user/**=authc,onlineLimit

开启两个浏览器,先用其中一个登陆,再用同一个帐号在另一个登陆,就会发现前边的登录失效。

The End,谢谢阅读。

本文的完整示例可以从这里获取。

参考资料

  • 黑马程序员Java高级工程师技术栈-由浅入深掌握Shiro权限框架

  • Spring Data Redis

本作品采用 知识共享署名 4.0 国际许可协议 进行许可
标签: redis shiro
最后更新:2023年9月25日

魔芋红茶

加一点PHP,加一点Go,加一点Python......

点赞
< 上一篇
下一篇 >

文章评论

取消回复

*

code

COPYRIGHT © 2021 icexmoon.cn. ALL RIGHTS RESERVED.
本网站由提供CDN加速/云存储服务

Theme Kratos Made By Seaton Jiang

宁ICP备2021001508号

宁公网安备64040202000141号