阿里需求挑战-十分钟内连续登录5次失败,需要等待30分钟才能登录【附图】

·  阅读 9712
阿里需求挑战-十分钟内连续登录5次失败,需要等待30分钟才能登录【附图】

前言

今天在掘金学习(moyu),看到一个文章,‘阿里面试官问我:十分钟内连续登录5次失败,需要等待30分钟才能登录’,觉得很有意思,虽然是一个普遍需求,但是解决方案非常多,于是自己半个小时画了个图,写了几行伪代码,来解一下这个需求,毕竟天天CRUD也挺无聊的,非最佳解决方案,仅供互相讨论学习,希望各位轻喷。

原文链接

阿里面试官问我:如何设计登录接口,十分钟内连续登录5次失败,需要等待30分钟才能登录 - 掘金 (juejin.cn)

直接上图

image.png

图解

  • 登录请求
  • 验证锁定缓存,锁定直接返回登录次数过多已锁定
  • 未锁定 验证账号密码
  • 验证失败 最近10分钟失败次数计数 次数>=5,设置锁定缓存,返回失败
  • 验证成功 删除计数缓存,返回成功

代码实现

package com.demo.service;

import com.google.common.collect.Lists;
import org.apache.commons.lang3.time.FastDateFormat;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.Calendar;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;

@Service
public class LoginService {

    @Autowired
    private RedisTemplate<String, Integer> redisTemplate;

    private static final String FAIL_COUNTER = "user_login_fail_counter";

    private static final String FAIL_LOCK = "user_login_fail_lock";

    /**
     * 登录操作
     *
     * @param username
     * @param password
     * @return
     */
    public String login(String username, String password) {
        // 1.验证用户是否被登录锁定
        boolean lock = isLock(username);
        if (lock) {
            // 获取过期时间
            long t = unlockTime(username);
            return "登录验证失败次数过多,请" + t + "分钟后再试!";
        }
        boolean check = loginCheck(username, password);
        if (!check) {
            setCheckFailCounter(username);
            return "登录失败!";
        }
        // 登录成功 移除失败计数器
        deleteLoginFailCounter(username);
        return "登录成功";

    }

    /**
     * 登录失败计数器
     * 缓存key user_login_fail_counter:username:yyyyMMddHHmm
     *
     * @param username
     */
    public void setCheckFailCounter(String username) {
        Calendar cal = Calendar.getInstance();
        FastDateFormat fastDateFormat = FastDateFormat.getInstance("yyyyMMddHHmm");
        String minute = fastDateFormat.format(cal);
        String key = String.join(":", FAIL_COUNTER, username, minute);
        Integer count = redisTemplate.opsForValue().get(key);
        redisTemplate.opsForValue().increment(key);
        if (count == null) {
            redisTemplate.expire(key, 10, TimeUnit.MINUTES);
        }
        // 往前检查10分钟 统计失败次数
        List<String> tenMinuteKeys = Lists.newArrayList();
        for (int i = 0; i < 10; i++) {
            tenMinuteKeys.add(String.join(":", FAIL_COUNTER, username, fastDateFormat.format(cal)));
            cal.add(Calendar.MINUTE, -1);
        }
        List<Integer> countList = redisTemplate.opsForValue().multiGet(tenMinuteKeys);
        int total = countList.stream().filter(Objects::nonNull).mapToInt(e -> e).sum();
        if (total >= 5) {
            lock(username);
            // 因为锁定半小时,所以十分钟内的计数器都可以主动删除了
            redisTemplate.delete(tenMinuteKeys);
        }
    }

    /**
     * 移除最近十分钟计数器
     *
     * @param username
     */
    public void deleteLoginFailCounter(String username) {
        Calendar cal = Calendar.getInstance();
        List<String> tenMinuteKeys = Lists.newArrayList();
        for (int i = 0; i < 10; i++) {
            tenMinuteKeys.add(String.join(":", FAIL_COUNTER, username, fastDateFormat.format(cal)));
            cal.add(Calendar.MINUTE, -1);
        }
        redisTemplate.delete(tenMinuteKeys);
    }


    /**
     * 失败达到一定一定次数 锁定30分钟
     *
     * @param username
     */
    public void lock(String username) {
        String key = String.join(":", FAIL_LOCK, username);
        redisTemplate.opsForValue().set(key, 1, 30, TimeUnit.MINUTES);
    }

    /**
     * 是否被登录锁定
     *
     * @param username
     * @return
     */
    public boolean isLock(String username) {
        return redisTemplate.hasKey(String.join(":", FAIL_LOCK, username));
    }

    /**
     * 获取解锁的时间
     *
     * @param username
     * @return
     */
    public long unlockTime(String username) {
        String key = String.join(":", FAIL_LOCK, username);
        return redisTemplate.opsForValue().getOperations().getExpire(key, TimeUnit.MINUTES);
    }

    /**
     * 验证登录
     *
     * @param username
     * @param password
     * @return
     */
    public boolean loginCheck(String username, String password) {
        // 验证账号密码是否正确 省略...
        return false;
    }
}
复制代码

失败计数器(更新setCheckFailCounter方法)

第一版本代码

public void setCheckFailCounter(String username) {
    String key = String.join(":", FAIL_COUNTER, username);
    Integer count = redisTemplate.opsForValue().get(key);
    redisTemplate.opsForValue().increment(key);
    if (count == null) {
        // 第一次操作设置过期时间
        redisTemplate.expire(key, 10, TimeUnit.MINUTES);
    }
    if (count.intValue() == 5) {
        // 失败达到五次 设置锁定缓存
        lock(username);
    }
}
复制代码

第一版本计数器的问题

时间滑动性的问题,比如10分钟内只失败了4次 然后又可以进行请求了,又来了一次验证失败的请求,这个请求与前面的后三次失败也在10分钟内,但是这时候失败总次数只是1,其实相对于最新的一次登录失败,十分钟内的失败次数也是4

新版本的解决思路

每分钟的操作失败进行单独的统计,每次用系统时间向前检查10分钟总共失败次数,大于5就进行锁定,做到了,解决了第一版本的问题,新版本的代码已经在上面更新了

总结

核心点在于每次都是最近10分钟的总失败次数,而不是失败了一次开始10分钟过期做自增,这也是自己再初版代码出现的问题,已经解决了

我写得其它登录相关文章

一文搞懂用户登录验证流程(附图) - 掘金 (juejin.cn)

快速搭建一个网关服务,动态路由、鉴权看完就会(含流程图) - 掘金 (juejin.cn)

分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改