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

23,684 阅读5分钟

2022年1月7日更新: 既然大家的讨论热情这么高,我觉得就有必要把这篇文章再好好完善一下,评论区里大家给出的比较一致的实现方式是使用redis,我就把实现的思路也加进来吧。


part 1 面试白板编程实现(原生JDK)

  常言道:字数越短问题越大。

  今天阿里的面试官小哥哥让我实现一个登录接口,同一个用户10分钟内连续登陆5次失败,则需要等到30分钟才能登陆

  当然大佬估计一看到这种题目会很难过,一丁点算法都没有,妙解没意思。我上来就被唬住了。登录接口?10分钟内连续5次??等待30分钟才能登陆???登陆验证????

  问号一下子就冒出来了,当然最开始我想定义一个变量firstFailTime来记录第一次失败的时间,再仔细一想不对啊,firstFailTime是动态的额,要不断变化,单一个变量不好实现啊,第一次登录失败可以记录,但如果出现前十分钟失败了4次,第11分钟又失败了一次的话,firstFailTime应该往后取第二次失败登录的时间啊,我总不能手动定义100个变量吧。。。面试官看到估计脸都绿了。恨不得给我一个Mysql数据表,把每次登陆都给存下来,这样就可以很方便的查出某个时间区间登陆的情况。

  不慌,咱们虽然不是大佬,但一点一点分析还是可以的,沉住气!等等,刚刚说到数据库存所有的登录数据??其实思考到上面已经快接近了,我不能手动创建100个变量,但我可以用一种数据结构依次记录登录失败的时间啊,突然想到LRU算法对不对!!能从数据顺序看出来时间顺序的数据结构不就是链表吗!!!还有登录验证的问题,不如偷个懒,用一个boolean控制。解决,cool~

P.S:我没考虑开多个线程去测试,因为我个人感觉用户登录不会出现在高并发的环境里,几万个人同时登陆同一个账号想想就离谱......但为了保险起见我还是给map加了synchronize关键字。


Person类:

package exam;

import java.util.LinkedList;

/**
 * Created by Enzo Cotter on 2021/3/10.
 */
public class Person {
    /**
     * 重置时间
     */
    private static final int RESET_TIME = 30;

    /**
     * 密码连续输入5次失败的持续时间
     */
    private static final int DURATION = 10;

    /**
     * 最大输入失败次数
     */
    private static final int MAX_TIMES = 5;

    /**
     * 用户id
     */
    private String id;

    /**
     * 登录失败次数
     */
    private int failCount;

    /**
     * 第一次失败的时间
     */
    private long firstFailTime;

    /**
     * 登录失败的时间
     */
    private LinkedList<Long> times;

    private boolean lock;

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public int getFailCount() {
        return failCount;
    }

    public void setFailCount(int failCount) {
        this.failCount = failCount;
    }

    public long getFirstFailTime() {
        return firstFailTime;
    }

    public void setFirstFailTime(long firstFailTime) {
        this.firstFailTime = firstFailTime;
    }

    public LinkedList<Long> getTimes() {
        return times;
    }

    public void setTimes(LinkedList<Long> times) {
        this.times = times;
    }

    public Person() {
    }

    public Person(String id, int failCount, long firstFailTime, LinkedList<Long> times, boolean lock) {
        this.id = id;
        this.failCount = failCount;
        this.firstFailTime = firstFailTime;
        this.times = times;
        this.lock = false;
    }

    /**
     * 密码输错了进入此方法
     */
    public void isValid(){

        long thisTime = System.currentTimeMillis() / 1000;

        System.out.println("第一次登录失败时间" + thisTime);

        // 超过30分钟,重置
        if(thisTime > firstFailTime + RESET_TIME){
            this.failCount = 1;
            firstFailTime = thisTime;
            times = new LinkedList<>();
            times.addLast(thisTime);
            this.lock = false;
            return;
        }else{ // 没有超过30分钟

            if (lock){
                System.out.println("账户锁定,请" + RESET_TIME + "分钟后再来");
                return;
            }

            // 之前记录的第一次登录失败时间在10分钟之前了,要换
            while(!times.isEmpty() && thisTime > times.getFirst() + DURATION){
                times.removeFirst();
                this.failCount --;
                this.firstFailTime = times.isEmpty() ? thisTime : times.getFirst();
            }

            if(this.failCount >= 5 && thisTime < firstFailTime + DURATION){
                System.out.println("10分钟内密码错误大于等于5次,登录失败");
                times.addLast(thisTime);
                this.lock = true;
            }else if(failCount < MAX_TIMES){
                this.failCount ++;
                System.out.println("密码错误" + this.failCount + "次");
                times.addLast(thisTime);
            }
        }
    }
}

主类:

package exam;

import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map;

/**
 * Created by Enzo Cotter on 2021/3/10.
 */
public class FlowLimit {

    private static Map<String, Person> map = new HashMap<>();

    /**
     * 登录
     * @param id
     * @param flag  是否成功
     */
    public static void login(String id, boolean flag){
        if (flag){
            // 登陆成功
            return;
        }else{
            Person p = null;
            // 登录失败
            synchronized (map) {
                p = map.get(id);
                if (p == null){
                    p = new Person(id, 0, System.currentTimeMillis() / 1000,
                            new LinkedList<>(), false);
                    map.put(id, p);
                    return;
                }
                p.isValid();
            }
        }
    }

    public static void main(String[] args) {
        for(int i = 0; i < 20; i ++){
            login("aaa", false);
        }
    }
}

part 2 使用缓存来实现

实现步骤:

  1. 用户发起登录请求

  2. 后台验证是否失败次数过多,账户没有锁定的话就进入下面的步骤;否则直接返回

  3. 验证用户的账号 + 密码

    3.1 验证成功:删除缓存 3.2 验证失败:统计最近10分钟时间窗口内的失败次数,如果达到5次则设置锁定缓存,返回

图解实现步骤:

image.png

代码实现细节:

  • 登录失败计数器的key设计为:一串字符串 + 用户名(假设具有唯一性)+ 登录失败的时间

  • 锁定登录操作的key设计为:一串字符串 + 用户名(假设具有唯一性)

private static final String FAIL_COUNT_REDIS_KEY = "login_fail_count";

private static final String LOCK_REDIS_KEY = "login_lock";

private static final String SEPARATOR = ":";

用户登录服务:

@Override
public String login(String username, String password) {
    // 验证用户是否被登录锁定
    boolean lock = isForbidden(username);
    if (lock) {
        return "Login authentication failed too many times. Please try again after " + unLockTime(username) + " minutes.";
    }
    // 验证用户名 + 密码
    boolean isLogin = userRepository.checkUsernameAndPassword(username, password);
    if (!isLogin) {
        // 登录失败
        setFailCounter(username);
        return "login fail";
    }
    // 登录成功 移除失败计数器
    deleteFilCounter(username);
    return "login success";
}

登陆失败的话,就给登录失败次数加一:

@Override
public void setFailCounter(String username) {
    // 获取当前时间
    Calendar cal = Calendar.getInstance();
    String minute = fastDateFormat.format(cal);

    // 登录失败次数 + 1
    String key = String.join(SEPARATOR, FAIL_COUNT_REDIS_KEY, username, minute);
    Integer count = redisTemplate.opsForValue().get(key);
    redisTemplate.opsForValue().increment(key, 1); // 如果key不存在的话就会以增量形式存储进来

    if (count == null) {
        redisTemplate.expire(key, 10, TimeUnit.MINUTES);
    } 

    // 如果失败次数大于5次,锁定账户
    List<String> windowsKeys = new ArrayList<>();
    for (int i = 0; i < 10; i ++) {
        windowsKeys.add(String.join(SEPARATOR, FAIL_COUNT_REDIS_KEY, username, fastDateFormat.format(cal)));
        cal.add(Calendar.MINUTE, -1);
    }
    List<Integer> countList = redisTemplate.opsForValue().multiGet(windowsKeys);

    assert countList != null;

    int total = 0;
    for (Integer c : countList) {
        total += c;
    }
    if (total >= maxFailTimes) {
        forbidden(username);
    }
}

如果登录成功,则删除失败次数计数器:

@Override
public void deleteFilCounter(String username) {
    Calendar cal = Calendar.getInstance();
    List<String> windowKeys = new ArrayList<>();
    for (int i = 0; i < 10; i ++) {
        windowKeys.add(String.join(SEPARATOR, FAIL_COUNT_REDIS_KEY, username, fastDateFormat.format(cal)));
        cal.add(Calendar.MINUTE, -1);
    }
    redisTemplate.delete(windowKeys);
}

失败次数超过5次则禁止登录,只需要设置一个缓存即可:

@Override
public void forbidden(String username) {
    String key = String.join(SEPARATOR, FAIL_COUNT_REDIS_KEY, username);
    redisTemplate.opsForValue().set(key, 1, 30, TimeUnit.MINUTES);
}

判断是否被禁止登录,只需要判断是否有上面方法设置的key即可:

@Override
public boolean isForbidden(String username) {
    try{
        return Boolean.TRUE.equals(redisTemplate.hasKey(String.join(SEPARATOR, FAIL_COUNT_REDIS_KEY, username)));
    }catch (Exception e){
        e.printStackTrace();
    }
    return false;
}

如果想要获取到用户具体需要几分钟才能解锁(用于提示信息),只需要查询缓存的过期时间:

private Long unLockTime(String username){
    String key = String.join(SEPARATOR, LOCK_REDIS_KEY, username);
    Long expireTime = redisTemplate.opsForValue().getOperations().getExpire(key, TimeUnit.MINUTES);
    if (expireTime == null){
        throw new RuntimeException("there is no unlock time");
    }
    return expireTime;
}