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文章
294浏览量
17002 -
Redis
+关注
关注
0文章
371浏览量
10843 -
ChatGPT
+关注
关注
29文章
1548浏览量
7470 -
SpringBoot
+关注
关注
0文章
173浏览量
167
原文标题:三次输错密码后,系统是怎么做到不让我继续尝试的?
文章出处:【微信号:芋道源码,微信公众号:芋道源码】欢迎添加关注!文章转载请注明出处。
发布评论请先 登录
相关推荐
评论