如何對網(wǎng)站建設(shè)和維護(hù)企業(yè)策劃
故事背景
忘記密碼這件事,相信絕大多數(shù)人都遇到過,輸一次錯一次,錯到幾次以上,就不允許你繼續(xù)嘗試了。
但當(dāng)你嘗試重置密碼,又發(fā)現(xiàn)新密碼不能和原密碼重復(fù):
相信此刻心情只能用一張圖形容:
雖然,但是,密碼還是很重要的,順便我有了一個問題:三次輸錯密碼后,系統(tǒng)是怎么做到不讓我繼續(xù)嘗試的?
我想了想,有如下幾個問題需要搞定
- 是只有輸錯密碼才鎖定,還是賬戶名和密碼任何一個輸錯就鎖定?
- 輸錯之后也不是完全凍結(jié),為啥隔了幾分鐘又可以重新輸了?
- 技術(shù)棧到底麻不麻煩?
去網(wǎng)上搜了搜,也問了下ChatGPT,找到一套解決方案:SpringBoot+Redis+Lua腳本。
這套方案也不算新,很早就有人在用了,不過難得是自己想到的問題和解法,就記錄一下吧。
順便回答一下上面的三個問題:
- 鎖定的是IP,不是輸入的賬戶名或者密碼,也就是說任一一個輸錯3次就會被鎖定
- Redis的Lua腳本中實(shí)現(xiàn)了key過期策略,當(dāng)key消失時鎖定自然也就消失了
- 技術(shù)棧同SpringBoot+Redis+Lua腳本
那么自己動手實(shí)現(xiàn)一下
前端部分
首先寫一個賬密輸入頁面,使用很簡單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>
效果如下:
后端部分
技術(shù)選型分析
首先我們畫一個流程圖來分析一下這個登錄限制流程
從流程圖上看,首先訪問次數(shù)的統(tǒng)計(jì)與判斷不是在登錄邏輯執(zhí)行后,而是執(zhí)行前就加1了;
其次登錄邏輯的成功與失敗并不會影響到次數(shù)的統(tǒng)計(jì);
最后還有一點(diǎn)流程圖上沒有體現(xiàn)出來,這個次數(shù)的統(tǒng)計(jì)是有過期時間的,當(dāng)過期之后又可以重新登錄了。
那為什么是Redis+Lua腳本呢?
Redis的選擇不難看出,這個流程比較重要的是存在一個用來計(jì)數(shù)的變量,這個變量既要滿足分布式讀寫需求,還要滿足全局遞增或遞減的需求,那Redis的
incr方法
是最優(yōu)選了。
那為什么需要Lua腳本呢?流程上在驗(yàn)證用戶操作前有些操作,如圖:
這里至少有3步Redis的操作,get、incr、expire,如果全放到應(yīng)用里面來操作,有點(diǎn)慢且浪費(fèi)資源。
Lua腳本的優(yōu)點(diǎn)如下:
- 減少網(wǎng)絡(luò)開銷。可以將多個請求通過腳本的形式一次發(fā)送,減少網(wǎng)絡(luò)時延。
- 原子操作。Redis會將整個腳本作為一個整體執(zhí)行,中間不會被其他請求插入。因此在腳本運(yùn)行過程中無需擔(dān)心會出現(xiàn)競態(tài)條件,無需使用事務(wù)。
- 復(fù)用??蛻舳税l(fā)送的腳本會永久存在redis中,這樣其他客戶端可以復(fù)用這一腳本,而不需要使用代碼完成相同的邏輯。
最后為了增加功能的復(fù)用性,我打算使用Java注解的方式實(shí)現(xiàn)這個功能。
代碼實(shí)現(xiàn)
項(xiàng)目結(jié)構(gòu)如下
配置文件
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;/*** 次數(shù)限制注解* 作用在接口方法上*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LimitCount {/*** 資源名稱,用于描述接口功能*/String name() default "";/*** 資源 key*/String key() default "";/*** key prefix** @return*/String prefix() default "";/*** 時間的,單位秒* 默認(rèn)60s過期*/int period() default 60;/*** 限制訪問次數(shù)* 默認(rèn)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();//過期次數(shù)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 "接口訪問超出頻率限制";}}/*** 限流腳本* 調(diào)用的時候不超過閾值,則直接返回并執(zhí)行計(jì)算器自加。** @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字符串,則為真實(shí)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])
這行表示如果當(dāng)前key 的值大于了limitCount,直接返回;否則調(diào)用incr
方法進(jìn)行累加1,且調(diào)用expire
方法設(shè)置過期時間。
最后就是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);}}
演示一下效果
上面這套限流的邏輯感覺用在小型或中型的項(xiàng)目上應(yīng)該問題不大,不過目前的登錄很少有直接鎖定賬號不能輸入的,一般都是彈出一個驗(yàn)證碼框,讓你輸入驗(yàn)證碼再提交。我覺得用我這套邏輯改改應(yīng)該不成問題,核心還是接口嘗試次數(shù)的限制嘛!剛好我還寫過SpringBoot生成圖形驗(yàn)證碼的文章:SpringBoot整合kaptcha實(shí)現(xiàn)圖片驗(yàn)證碼功能,哪天再來試試這套邏輯~