1故事背景
忘記密碼這件事,相信絕大多數人都遇到過,輸一次錯一次,錯到幾次以上,就不允許你繼續嘗試了。
但當你嘗試重置密碼,又發現新密碼不能和原密碼重復:
圖片
相信此刻心情只能用一張圖形容:
圖片
雖然,但是,密碼還是很重要的,順便我有了一個問題:三次輸錯密碼后,系統是怎么做到不讓我繼續嘗試的?
2我想了想,有如下幾個問題需要搞定
-
是只有輸錯密碼才鎖定,還是賬戶名和密碼任何一個輸錯就鎖定?
-
輸錯之后也不是完全凍結,為啥隔了幾分鐘又可以重新輸了?
-
技術棧到底麻不麻煩?
去網上搜了搜,也問了下ChatGPT,找到一套解決方案:SpringBoot+Redis+Lua腳本。
這套方案也不算新,很早就有人在用了,不過難得是自己想到的問題和解法,就記錄一下吧。
順便回答一下上面的三個問題:
-
鎖定的是IP,不是輸入的賬戶名或者密碼,也就是說任一一個輸錯3次就會被鎖定
-
Redis的Lua腳本中實現了key過期策略,當key消失時鎖定自然也就消失了
-
技術棧同SpringBoot+Redis+Lua腳本
3那么自己動手實現一下
前端部分
首先寫一個賬密輸入頁面,使用很簡單HTML加表單提交
<!DOCTYPE?html>
<html>
<head><title>登錄頁面</title><style>body?{background-color:?#F5F5F5;}form?{width:?300px;margin:?0?auto;margin-top:?100px;padding:?20px;background-color:?white;border-radius:?5px;box-shadow:?0?0?10px?rgba(0,0,0,0.2);}label?{display:?block;margin-bottom:?10px;}input[type="text"],?input[type="password"]?{border:?none;padding:?10px;margin-bottom:?20px;border-radius:?5px;box-shadow:?0?0?5px?rgba(0,0,0,0.1);width:?100%;box-sizing:?border-box;font-size:?16px;}input[type="submit"]?{background-color:?#30B0F0;color:?white;border:?none;padding:?10px;border-radius:?5px;box-shadow:?0?0?5px?rgba(0,0,0,0.1);width:?100%;font-size:?16px;cursor:?pointer;}input[type="submit"]:hover?{background-color:?#1C90D6;}</style>
</head>
<body><form?action="http://localhost:8080/login"?method="get"><label?for="username">用戶名</label><input?type="text"?id="username"?name="username"?placeholder="請輸入用戶名"?required><label?for="password">密碼</label><input?type="password"?id="password"?name="password"?placeholder="請輸入密碼"?required><input?type="submit"?value="登錄"></form>
</body>
</html>
效果如下:
圖片
后端部分
技術選型分析
首先我們畫一個流程圖來分析一下這個登錄限制流程
圖片
-
從流程圖上看,首先訪問次數的統計與判斷不是在登錄邏輯執行后,而是執行前就加1了;
-
其次登錄邏輯的成功與失敗并不會影響到次數的統計;
-
最后還有一點流程圖上沒有體現出來,這個次數的統計是有過期時間的,當過期之后又可以重新登錄了。
那為什么是Redis+Lua腳本呢?
Redis的選擇不難看出,這個流程比較重要的是存在一個用來計數的變量,這個變量既要滿足分布式讀寫需求,還要滿足全局遞增或遞減的需求,那Redis的incr方法是最優選了。
那為什么需要Lua腳本呢?流程上在驗證用戶操作前有些操作,如圖:
圖片
這里至少有3步Redis的操作,get、incr、expire,如果全放到應用里面來操作,有點慢且浪費資源。
Lua腳本的優點如下:
-
減少網絡開銷。?可以將多個請求通過腳本的形式一次發送,減少網絡時延。
-
原子操作。?Redis會將整個腳本作為一個整體執行,中間不會被其他請求插入。因此在腳本運行過程中無需擔心會出現競態條件,無需使用事務。
-
復用。?客戶端發送的腳本會永久存在redis中,這樣其他客戶端可以復用這一腳本,而不需要使用代碼完成相同的邏輯。
最后為了增加功能的復用性,我打算使用Java注解的方式實現這個功能。
代碼實現
項目結構如下
圖片
配置文件
pom.xml
<?xml?version="1.0"?encoding="UTF-8"?>
<project?xmlns="http://maven.apache.org/POM/4.0.0"?xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0?https://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.7.11</version><relativePath/>?<!--?lookup?parent?from?repository?--></parent><groupId>com.example</groupId><artifactId>LoginLimit</artifactId><version>0.0.1-SNAPSHOT</version><name>LoginLimit</name><description>Demo?project?for?Spring?Boot</description><properties><java.version>1.8</java.version></properties><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency><!--?redis?--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><!--?Jedis?--><dependency><groupId>redis.clients</groupId><artifactId>jedis</artifactId></dependency><!--切面依賴?--><dependency><groupId>org.aspectj</groupId><artifactId>aspectjweaver</artifactId></dependency><!--?commons-lang3?--><dependency><groupId>org.apache.commons</groupId><artifactId>commons-lang3</artifactId></dependency><!--?guava?--><dependency><groupId>com.google.guava</groupId><artifactId>guava</artifactId><version>23.0</version></dependency><!--?lombok?--><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency></dependencies><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build></project>
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
package?com.example.loginlimit.annotation;import?java.lang.annotation.ElementType;
import?java.lang.annotation.Retention;
import?java.lang.annotation.RetentionPolicy;
import?java.lang.annotation.Target;/***?次數限制注解*?作用在接口方法上*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public?@interface?LimitCount?{/***?資源名稱,用于描述接口功能*/String?name()?default?"";/***?資源?key*/String?key()?default?"";/***?key?prefix**?@return*/String?prefix()?default?"";/***?時間的,單位秒*?默認60s過期*/int?period()?default?60;/***?限制訪問次數*?默認3次*/int?count()?default?3;
}
核心處理邏輯類:LimitCountAspect.java
package?com.example.loginlimit.aspect;import?java.io.Serializable;
import?java.lang.reflect.Method;
import?java.util.Objects;import?javax.servlet.http.HttpServletRequest;import?com.example.loginlimit.annotation.LimitCount;
import?com.example.loginlimit.util.IPUtil;
import?com.google.common.collect.ImmutableList;
import?lombok.extern.slf4j.Slf4j;
import?org.apache.commons.lang3.StringUtils;
import?org.aspectj.lang.ProceedingJoinPoint;
import?org.aspectj.lang.annotation.Around;
import?org.aspectj.lang.annotation.Aspect;
import?org.aspectj.lang.annotation.Pointcut;
import?org.aspectj.lang.reflect.MethodSignature;
import?org.springframework.beans.factory.annotation.Autowired;
import?org.springframework.data.redis.core.RedisTemplate;
import?org.springframework.data.redis.core.script.DefaultRedisScript;
import?org.springframework.data.redis.core.script.RedisScript;
import?org.springframework.stereotype.Component;
import?org.springframework.web.context.request.RequestContextHolder;
import?org.springframework.web.context.request.ServletRequestAttributes;@Slf4j
@Aspect
@Component
public?class?LimitCountAspect?{private?final?RedisTemplate<String,?Serializable>?limitRedisTemplate;@Autowiredpublic?LimitCountAspect(RedisTemplate<String,?Serializable>?limitRedisTemplate)?{this.limitRedisTemplate?=?limitRedisTemplate;}@Pointcut("@annotation(com.example.loginlimit.annotation.LimitCount)")public?void?pointcut()?{//?do?nothing}@Around("pointcut()")public?Object?around(ProceedingJoinPoint?point)?throws?Throwable?{HttpServletRequest?request?=?((ServletRequestAttributes)Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();MethodSignature?signature?=?(MethodSignature)point.getSignature();Method?method?=?signature.getMethod();LimitCount?annotation?=?method.getAnnotation(LimitCount.class);//注解名稱String?name?=?annotation.name();//注解keyString?key?=?annotation.key();//訪問IPString?ip?=?IPUtil.getIpAddr(request);//過期時間int?limitPeriod?=?annotation.period();//過期次數int?limitCount?=?annotation.count();ImmutableList<String>?keys?=?ImmutableList.of(StringUtils.join(annotation.prefix()?+?"_",?key,?ip));String?luaScript?=?buildLuaScript();RedisScript<Number>?redisScript?=?new?DefaultRedisScript<>(luaScript,?Number.class);Number?count?=?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"?+"\nc?=?redis.call('get',KEYS[1])"?+"\nif?c?and?tonumber(c)?>?tonumber(ARGV[1])?then"?+"\nreturn?c;"?+"\nend"?+"\nc?=?redis.call('incr',KEYS[1])"?+"\nif?tonumber(c)?==?1?then"?+"\nredis.call('expire',KEYS[1],ARGV[2])"?+"\nend"?+"\nreturn?c;";}}
獲取IP地址的功能我寫了一個工具類IPUtil.java,代碼如下:
package?com.example.loginlimit.util;import?javax.servlet.http.HttpServletRequest;public?class?IPUtil?{private?static?final?String?UNKNOWN?=?"unknown";protected?IPUtil()?{}/***?獲取?IP地址*?使用?Nginx等反向代理軟件,?則不能通過?request.getRemoteAddr()獲取?IP地址*?如果使用了多級反向代理的話,X-Forwarded-For的值并不止一個,而是一串IP地址,*?X-Forwarded-For中第一個非?unknown的有效IP字符串,則為真實IP地址*/public?static?String?getIpAddr(HttpServletRequest?request)?{String?ip?=?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?"0:0:0:0:0:0:0:1".equals(ip)???"127.0.0.1"?:?ip;}}
另外就是Lua限流腳本的說明,腳本代碼如下:
??private?String?buildLuaScript()?{return?"local?c"?+"\nc?=?redis.call('get',KEYS[1])"?+"\nif?c?and?tonumber(c)?>?tonumber(ARGV[1])?then"?+"\nreturn?c;"?+"\nend"?+"\nc?=?redis.call('incr',KEYS[1])"?+"\nif?tonumber(c)?==?1?then"?+"\nredis.call('expire',KEYS[1],ARGV[2])"?+"\nend"?+"\nreturn?c;";}
這段腳本有一個判斷,?tonumber(c) > tonumber(ARGV[1])
這行表示如果當前key 的值大于了limitCount,直接返回;否則調用incr方法進行累加1,且調用expire方法設置過期時間。
最后就是RedisConfig.java,代碼如下:
package?com.example.loginlimit.config;import?java.io.IOException;
import?java.io.Serializable;
import?java.time.Duration;
import?java.util.Arrays;import?com.fasterxml.jackson.core.JsonProcessingException;
import?com.fasterxml.jackson.databind.ObjectMapper;
import?org.apache.commons.lang3.StringUtils;
import?org.springframework.beans.factory.annotation.Value;
import?org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import?org.springframework.cache.CacheManager;
import?org.springframework.cache.annotation.CachingConfigurerSupport;
import?org.springframework.cache.interceptor.KeyGenerator;
import?org.springframework.context.annotation.Bean;
import?org.springframework.context.annotation.Configuration;
import?org.springframework.data.redis.cache.RedisCacheManager;
import?org.springframework.data.redis.connection.RedisConnectionFactory;
import?org.springframework.data.redis.connection.RedisPassword;
import?org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import?org.springframework.data.redis.connection.jedis.JedisClientConfiguration;
import?org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import?org.springframework.data.redis.core.RedisTemplate;
import?org.springframework.data.redis.core.StringRedisTemplate;
import?org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import?org.springframework.data.redis.serializer.RedisSerializer;
import?org.springframework.data.redis.serializer.SerializationException;
import?org.springframework.data.redis.serializer.StringRedisSerializer;
import?redis.clients.jedis.JedisPool;
import?redis.clients.jedis.JedisPoolConfig;@Configuration
public?class?RedisConfig?extends?CachingConfigurerSupport?{@Value("${spring.redis.host}")private?String?host;@Value("${spring.redis.port}")private?int?port;@Value("${spring.redis.password}")private?String?password;@Value("${spring.redis.timeout}")private?int?timeout;@Value("${spring.redis.jedis.pool.max-idle}")private?int?maxIdle;@Value("${spring.redis.jedis.pool.max-wait}")private?long?maxWaitMillis;@Value("${spring.redis.database:0}")private?int?database;@Beanpublic?JedisPool?redisPoolFactory()?{JedisPoolConfig?jedisPoolConfig?=?new?JedisPoolConfig();jedisPoolConfig.setMaxIdle(maxIdle);jedisPoolConfig.setMaxWaitMillis(maxWaitMillis);if?(StringUtils.isNotBlank(password))?{return?new?JedisPool(jedisPoolConfig,?host,?port,?timeout,?password,?database);}?else?{return?new?JedisPool(jedisPoolConfig,?host,?port,?timeout,?null,?database);}}@BeanJedisConnectionFactory?jedisConnectionFactory()?{RedisStandaloneConfiguration?redisStandaloneConfiguration?=?new?RedisStandaloneConfiguration();redisStandaloneConfiguration.setHostName(host);redisStandaloneConfiguration.setPort(port);redisStandaloneConfiguration.setPassword(RedisPassword.of(password));redisStandaloneConfiguration.setDatabase(database);JedisClientConfiguration.JedisClientConfigurationBuilder?jedisClientConfiguration?=?JedisClientConfiguration.builder();jedisClientConfiguration.connectTimeout(Duration.ofMillis(timeout));jedisClientConfiguration.usePooling();return?new?JedisConnectionFactory(redisStandaloneConfiguration,?jedisClientConfiguration.build());}@Bean(name?=?"redisTemplate")@SuppressWarnings({"rawtypes"})@ConditionalOnMissingBean(name?=?"redisTemplate")public?RedisTemplate<Object,?Object>?redisTemplate(RedisConnectionFactory?redisConnectionFactory)?{RedisTemplate<Object,?Object>?template?=?new?RedisTemplate<>();//使用?fastjson?序列化JacksonRedisSerializer?jacksonRedisSerializer?=?new?JacksonRedisSerializer<>(Object.class);//?value?值的序列化采用?fastJsonRedisSerializertemplate.setValueSerializer(jacksonRedisSerializer);template.setHashValueSerializer(jacksonRedisSerializer);//?key?的序列化采用?StringRedisSerializertemplate.setKeySerializer(new?StringRedisSerializer());template.setHashKeySerializer(new?StringRedisSerializer());template.setConnectionFactory(redisConnectionFactory);return?template;}//緩存管理器@Beanpublic?CacheManager?cacheManager(RedisConnectionFactory?redisConnectionFactory)?{RedisCacheManager.RedisCacheManagerBuilder?builder?=?RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(redisConnectionFactory);return?builder.build();}@Bean@ConditionalOnMissingBean(StringRedisTemplate.class)public?StringRedisTemplate?stringRedisTemplate(RedisConnectionFactory?redisConnectionFactory)?{StringRedisTemplate?template?=?new?StringRedisTemplate();template.setConnectionFactory(redisConnectionFactory);return?template;}@Beanpublic?KeyGenerator?wiselyKeyGenerator()?{return?(target,?method,?params)?->?{StringBuilder?sb?=?new?StringBuilder();sb.append(target.getClass().getName());sb.append(method.getName());Arrays.stream(params).map(Object::toString).forEach(sb::append);return?sb.toString();};}@Beanpublic?RedisTemplate<String,?Serializable>?limitRedisTemplate(RedisConnectionFactory?redisConnectionFactory)?{RedisTemplate<String,?Serializable>?template?=?new?RedisTemplate<>();template.setKeySerializer(new?StringRedisSerializer());template.setValueSerializer(new?GenericJackson2JsonRedisSerializer());template.setConnectionFactory(redisConnectionFactory);return?template;}
}class?JacksonRedisSerializer<T>?implements?RedisSerializer<T>?{private?Class<T>?clazz;private?ObjectMapper?mapper;JacksonRedisSerializer(Class<T>?clazz)?{super();this.clazz?=?clazz;this.mapper?=?new?ObjectMapper();mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);}@Overridepublic?byte[]?serialize(T?t)?throws?SerializationException?{try?{return?mapper.writeValueAsBytes(t);}?catch?(JsonProcessingException?e)?{e.printStackTrace();return?null;}}@Overridepublic?T?deserialize(byte[]?bytes)?throws?SerializationException?{if?(bytes.length?<=?0)?{return?null;}try?{return?mapper.readValue(bytes,?clazz);}?catch?(IOException?e)?{e.printStackTrace();return?null;}}
}
LoginController.java
package?com.example.loginlimit.controller;import?javax.servlet.http.HttpServletRequest;import?com.example.loginlimit.annotation.LimitCount;
import?lombok.extern.slf4j.Slf4j;
import?org.apache.commons.lang3.StringUtils;
import?org.springframework.web.bind.annotation.GetMapping;
import?org.springframework.web.bind.annotation.RequestParam;
import?org.springframework.web.bind.annotation.RestController;@Slf4j
@RestController
public?class?LoginController?{@GetMapping("/login")@LimitCount(key?=?"login",?name?=?"登錄接口",?prefix?=?"limit")public?String?login(@RequestParam(required?=?true)?String?username,@RequestParam(required?=?true)?String?password,?HttpServletRequest?request)?throws?Exception?{if?(StringUtils.equals("張三",?username)?&&?StringUtils.equals("123456",?password))?{return?"登錄成功";}return?"賬戶名或密碼錯誤";}}
LoginLimitApplication.java
package?com.example.loginlimit;import?org.springframework.boot.SpringApplication;
import?org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplication
public?class?LoginLimitApplication?{public?static?void?main(String[]?args)?{SpringApplication.run(LoginLimitApplication.class,?args);}}
4演示一下效果
圖片
上面這套限流的邏輯感覺用在小型或中型的項目上應該問題不大,不過目前的登錄很少有直接鎖定賬號不能輸入的,一般都是彈出一個驗證碼框,讓你輸入驗證碼再提交。我覺得用我這套邏輯改改應該不成問題,核心還是接口嘗試次數的限制嘛!