1 故事背景
忘记密码这件事,相信绝大多数人都遇到过,输一次错一次,错到几次以上,就不允许你继续尝试了。
但当你尝试重置密码,又发现新密码不能和原密码重复:
虽然,但是,密码还是很重要的,顺便我有了一个问题:三次输错密码后,系统是怎么做到不让我继续尝试的?
2 我想了想,有如下几个问题需要搞定
是只有输错密码才锁定,还是账户名和密码任何一个输错就锁定?
输错之后也不是完全冻结,为啥隔了几分钟又可以重新输了?
技术栈到底麻不麻烦?
去网上搜了搜,也问了下ChatGPT,找到一套解决方案:SpringBoot+Redis+Lua脚本。
这套方案也不算新,很早就有人在用了,不过难得是自己想到的问题和解法,就记录一下吧。
顺便回答一下上面的三个问题:
锁定的是IP,不是输入的账户名或者密码,也就是说任一一个输错3次就会被锁定
Redis的Lua脚本中实现了key过期策略,当key消失时锁定自然也就消失了
技术栈同SpringBoot+Redis+Lua脚本
3 那么自己动手实现一下
前端部分
首先写一个账密输入页面,使用很简单HTML加表单提交
登录页面
效果如下:

后端部分
技术选型分析
首先我们画一个流程图来分析一下这个登录限制流程

从流程图上看,首先访问次数的统计与判断不是在登录逻辑执行后,而是执行前就加1了;
其次登录逻辑的成功与失败并不会影响到次数的统计;
最后还有一点流程图上没有体现出来,这个次数的统计是有过期时间的,当过期之后又可以重新登录了。
那为什么是Redis+Lua脚本呢?
Redis的选择不难看出,这个流程比较重要的是存在一个用来计数的变量,这个变量既要满足分布式读写需求,还要满足全局递增或递减的需求,那Redis的incr方法是最优选了。
那为什么需要Lua脚本呢?流程上在验证用户操作前有些操作,如图:

这里至少有3步Redis的操作,get、incr、expire,如果全放到应用里面来操作,有点慢且浪费资源。
Lua脚本的优点如下:
减少网络开销。 可以将多个请求通过脚本的形式一次发送,减少网络时延。
原子操作。 Redis会将整个脚本作为一个整体执行,中间不会被其他请求插入。因此在脚本运行过程中无需担心会出现竞态条件,无需使用事务。
复用。 客户端发送的脚本会永久存在redis中,这样其他客户端可以复用这一脚本,而不需要使用代码完成相同的逻辑。
最后为了增加功能的复用性,我打算使用Java注解的方式实现这个功能。
代码实现
项目结构如下

配置文件
pom.xml
4.0.0 org.springframework.boot spring-boot-starter-parent 2.7.11 com.example LoginLimit 0.0.1-SNAPSHOT LoginLimit DemoprojectforSpringBoot 1.8 org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-test test org.springframework.boot spring-boot-starter-data-redis redis.clients jedis org.aspectj aspectjweaver org.apache.commons commons-lang3 com.google.guava guava 23.0 org.projectlombok lombok true org.springframework.boot spring-boot-maven-plugin
application.properties
#Redis配置 spring.redis.host=127.0.0.1 spring.redis.port=6379 spring.redis.password= spring.redis.timeout=1000 #Jedis配置 spring.redis.jedis.pool.min-idle=0 spring.redis.jedis.pool.max-idle=500 spring.redis.jedis.pool.max-active=2000 spring.redis.jedis.pool.max-wait=10000
注解部分
LimitCount.java
packagecom.example.loginlimit.annotation; importjava.lang.annotation.ElementType; importjava.lang.annotation.Retention; importjava.lang.annotation.RetentionPolicy; importjava.lang.annotation.Target; /** *次数限制注解 *作用在接口方法上 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public@interfaceLimitCount{ /** *资源名称,用于描述接口功能 */ Stringname()default""; /** *资源key */ Stringkey()default""; /** *keyprefix * *@return */ Stringprefix()default""; /** *时间的,单位秒 *默认60s过期 */ intperiod()default60; /** *限制访问次数 *默认3次 */ intcount()default3; }
核心处理逻辑类:LimitCountAspect.java
packagecom.example.loginlimit.aspect; importjava.io.Serializable; importjava.lang.reflect.Method; importjava.util.Objects; importjavax.servlet.http.HttpServletRequest; importcom.example.loginlimit.annotation.LimitCount; importcom.example.loginlimit.util.IPUtil; importcom.google.common.collect.ImmutableList; importlombok.extern.slf4j.Slf4j; importorg.apache.commons.lang3.StringUtils; importorg.aspectj.lang.ProceedingJoinPoint; importorg.aspectj.lang.annotation.Around; importorg.aspectj.lang.annotation.Aspect; importorg.aspectj.lang.annotation.Pointcut; importorg.aspectj.lang.reflect.MethodSignature; importorg.springframework.beans.factory.annotation.Autowired; importorg.springframework.data.redis.core.RedisTemplate; importorg.springframework.data.redis.core.script.DefaultRedisScript; importorg.springframework.data.redis.core.script.RedisScript; importorg.springframework.stereotype.Component; importorg.springframework.web.context.request.RequestContextHolder; importorg.springframework.web.context.request.ServletRequestAttributes; @Slf4j @Aspect @Component publicclassLimitCountAspect{ privatefinalRedisTemplatelimitRedisTemplate; @Autowired publicLimitCountAspect(RedisTemplate limitRedisTemplate){ this.limitRedisTemplate=limitRedisTemplate; } @Pointcut("@annotation(com.example.loginlimit.annotation.LimitCount)") publicvoidpointcut(){ //donothing } @Around("pointcut()") publicObjectaround(ProceedingJoinPointpoint)throwsThrowable{ HttpServletRequestrequest=((ServletRequestAttributes)Objects.requireNonNull( RequestContextHolder.getRequestAttributes())).getRequest(); MethodSignaturesignature=(MethodSignature)point.getSignature(); Methodmethod=signature.getMethod(); LimitCountannotation=method.getAnnotation(LimitCount.class); //注解名称 Stringname=annotation.name(); //注解key Stringkey=annotation.key(); //访问IP Stringip=IPUtil.getIpAddr(request); //过期时间 intlimitPeriod=annotation.period(); //过期次数 intlimitCount=annotation.count(); ImmutableList keys=ImmutableList.of(StringUtils.join(annotation.prefix()+"_",key,ip)); StringluaScript=buildLuaScript(); RedisScript redisScript=newDefaultRedisScript<>(luaScript,Number.class); Numbercount=limitRedisTemplate.execute(redisScript,keys,limitCount,limitPeriod); log.info("IP:{}第{}次访问key为{},描述为[{}]的接口",ip,count,keys,name); if(count!=null&&count.intValue()<= limitCount) { return point.proceed(); } else { return "接口访问超出频率限制"; } } /** * 限流脚本 * 调用的时候不超过阈值,则直接返回并执行计算器自加。 * * @return lua脚本 */ private String buildLuaScript() { return "local c" + " c = redis.call('get',KEYS[1])" + " if c and tonumber(c) >tonumber(ARGV[1])then"+ " returnc;"+ " end"+ " c=redis.call('incr',KEYS[1])"+ " iftonumber(c)==1then"+ " redis.call('expire',KEYS[1],ARGV[2])"+ " end"+ " returnc;"; } }
获取IP地址的功能我写了一个工具类IPUtil.java,代码如下:
packagecom.example.loginlimit.util;
importjavax.servlet.http.HttpServletRequest;
publicclassIPUtil{
privatestaticfinalStringUNKNOWN="unknown";
protectedIPUtil(){
}
/**
*获取IP地址
*使用Nginx等反向代理软件,则不能通过request.getRemoteAddr()获取IP地址
*如果使用了多级反向代理的话,X-Forwarded-For的值并不止一个,而是一串IP地址,
*X-Forwarded-For中第一个非unknown的有效IP字符串,则为真实IP地址
*/
publicstaticStringgetIpAddr(HttpServletRequestrequest){
Stringip=request.getHeader("x-forwarded-for");
if(ip==null||ip.length()==0||UNKNOWN.equalsIgnoreCase(ip)){
ip=request.getHeader("Proxy-Client-IP");
}
if(ip==null||ip.length()==0||UNKNOWN.equalsIgnoreCase(ip)){
ip=request.getHeader("WL-Proxy-Client-IP");
}
if(ip==null||ip.length()==0||UNKNOWN.equalsIgnoreCase(ip)){
ip=request.getRemoteAddr();
}
return"0000:1".equals(ip)?"127.0.0.1":ip;
}
}
另外就是Lua限流脚本的说明,脚本代码如下:
privateStringbuildLuaScript(){
return"localc"+
"
c=redis.call('get',KEYS[1])"+
"
ifcandtonumber(c)>tonumber(ARGV[1])then"+
"
returnc;"+
"
end"+
"
c=redis.call('incr',KEYS[1])"+
"
iftonumber(c)==1then"+
"
redis.call('expire',KEYS[1],ARGV[2])"+
"
end"+
"
returnc;";
}
这段脚本有一个判断, tonumber(c) > tonumber(ARGV[1])这行表示如果当前key 的值大于了limitCount,直接返回;否则调用incr方法进行累加1,且调用expire方法设置过期时间。
最后就是RedisConfig.java,代码如下:
packagecom.example.loginlimit.config;
importjava.io.IOException;
importjava.io.Serializable;
importjava.time.Duration;
importjava.util.Arrays;
importcom.fasterxml.jackson.core.JsonProcessingException;
importcom.fasterxml.jackson.databind.ObjectMapper;
importorg.apache.commons.lang3.StringUtils;
importorg.springframework.beans.factory.annotation.Value;
importorg.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
importorg.springframework.cache.CacheManager;
importorg.springframework.cache.annotation.CachingConfigurerSupport;
importorg.springframework.cache.interceptor.KeyGenerator;
importorg.springframework.context.annotation.Bean;
importorg.springframework.context.annotation.Configuration;
importorg.springframework.data.redis.cache.RedisCacheManager;
importorg.springframework.data.redis.connection.RedisConnectionFactory;
importorg.springframework.data.redis.connection.RedisPassword;
importorg.springframework.data.redis.connection.RedisStandaloneConfiguration;
importorg.springframework.data.redis.connection.jedis.JedisClientConfiguration;
importorg.springframework.data.redis.connection.jedis.JedisConnectionFactory;
importorg.springframework.data.redis.core.RedisTemplate;
importorg.springframework.data.redis.core.StringRedisTemplate;
importorg.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
importorg.springframework.data.redis.serializer.RedisSerializer;
importorg.springframework.data.redis.serializer.SerializationException;
importorg.springframework.data.redis.serializer.StringRedisSerializer;
importredis.clients.jedis.JedisPool;
importredis.clients.jedis.JedisPoolConfig;
@Configuration
publicclassRedisConfigextendsCachingConfigurerSupport{
@Value("${spring.redis.host}")
privateStringhost;
@Value("${spring.redis.port}")
privateintport;
@Value("${spring.redis.password}")
privateStringpassword;
@Value("${spring.redis.timeout}")
privateinttimeout;
@Value("${spring.redis.jedis.pool.max-idle}")
privateintmaxIdle;
@Value("${spring.redis.jedis.pool.max-wait}")
privatelongmaxWaitMillis;
@Value("${spring.redis.database:0}")
privateintdatabase;
@Bean
publicJedisPoolredisPoolFactory(){
JedisPoolConfigjedisPoolConfig=newJedisPoolConfig();
jedisPoolConfig.setMaxIdle(maxIdle);
jedisPoolConfig.setMaxWaitMillis(maxWaitMillis);
if(StringUtils.isNotBlank(password)){
returnnewJedisPool(jedisPoolConfig,host,port,timeout,password,database);
}else{
returnnewJedisPool(jedisPoolConfig,host,port,timeout,null,database);
}
}
@Bean
JedisConnectionFactoryjedisConnectionFactory(){
RedisStandaloneConfigurationredisStandaloneConfiguration=newRedisStandaloneConfiguration();
redisStandaloneConfiguration.setHostName(host);
redisStandaloneConfiguration.setPort(port);
redisStandaloneConfiguration.setPassword(RedisPassword.of(password));
redisStandaloneConfiguration.setDatabase(database);
JedisClientConfiguration.JedisClientConfigurationBuilderjedisClientConfiguration=JedisClientConfiguration
.builder();
jedisClientConfiguration.connectTimeout(Duration.ofMillis(timeout));
jedisClientConfiguration.usePooling();
returnnewJedisConnectionFactory(redisStandaloneConfiguration,jedisClientConfiguration.build());
}
@Bean(name="redisTemplate")
@SuppressWarnings({"rawtypes"})
@ConditionalOnMissingBean(name="redisTemplate")
publicRedisTemplate
LoginController.java
packagecom.example.loginlimit.controller;
importjavax.servlet.http.HttpServletRequest;
importcom.example.loginlimit.annotation.LimitCount;
importlombok.extern.slf4j.Slf4j;
importorg.apache.commons.lang3.StringUtils;
importorg.springframework.web.bind.annotation.GetMapping;
importorg.springframework.web.bind.annotation.RequestParam;
importorg.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
publicclassLoginController{
@GetMapping("/login")
@LimitCount(key="login",name="登录接口",prefix="limit")
publicStringlogin(
@RequestParam(required=true)Stringusername,
@RequestParam(required=true)Stringpassword,HttpServletRequestrequest)throwsException{
if(StringUtils.equals("张三",username)&&StringUtils.equals("123456",password)){
return"登录成功";
}
return"账户名或密码错误";
}
}
LoginLimitApplication.java
packagecom.example.loginlimit;
importorg.springframework.boot.SpringApplication;
importorg.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
publicclassLoginLimitApplication{
publicstaticvoidmain(String[]args){
SpringApplication.run(LoginLimitApplication.class,args);
}
}
4 演示一下效果

上面这套限流的逻辑感觉用在小型或中型的项目上应该问题不大,不过目前的登录很少有直接锁定账号不能输入的,一般都是弹出一个验证码框,让你输入验证码再提交。我觉得用我这套逻辑改改应该不成问题,核心还是接口尝试次数的限制嘛!
审核编辑:黄飞
-
ip地址
+关注
关注
0文章
308浏览量
18831 -
Redis
+关注
关注
0文章
390浏览量
12042 -
ChatGPT
+关注
关注
30文章
1596浏览量
10065 -
SpringBoot
+关注
关注
0文章
177浏览量
625
原文标题:三次输错密码后,系统是怎么做到不让我继续尝试的?
文章出处:【微信号:芋道源码,微信公众号:芋道源码】欢迎添加关注!文章转载请注明出处。
发布评论请先 登录
手机密码锁忘了怎么办?教你解手机密码锁
电脑密码忘记了怎么办_win7系统电脑忘记密码的处理方法
苹果iphone7plus的下载密码忘了怎么办?
苹果iPad忘记了Apple ID密码该怎么办?
三次谐波是什么,三次谐波会造成哪些影响

三次输入密码错误怎么办?
评论