阿里面试官问我:如何设计登录接口,十分钟内连续登录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类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
java复制代码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);
}
}
}
}

主类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
java复制代码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设计为:一串字符串 + 用户名(假设具有唯一性)
1
2
3
4
5
java复制代码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 = ":";

用户登录服务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
java复制代码@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";
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
java复制代码@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);
}
}

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

1
2
3
4
5
6
7
8
9
10
java复制代码@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次则禁止登录,只需要设置一个缓存即可:

1
2
3
4
5
java复制代码@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即可:

1
2
3
4
5
6
7
8
9
java复制代码@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;
}

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

1
2
3
4
5
6
7
8
java复制代码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;
}

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%