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文章
301瀏覽量
17032 -
Redis
+關注
關注
0文章
374瀏覽量
10871 -
ChatGPT
+關注
關注
29文章
1558瀏覽量
7596 -
SpringBoot
+關注
關注
0文章
173瀏覽量
177
原文標題:三次輸錯密碼后,系統是怎么做到不讓我繼續嘗試的?
文章出處:【微信號:芋道源碼,微信公眾號:芋道源碼】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
評論