越秀网站建设策划,oa办公系统网址,做数据统计的网站,欧美个人网站1故事背景
忘记密码这件事#xff0c;相信绝大多数人都遇到过#xff0c;输一次错一次#xff0c;错到几次以上#xff0c;就不允许你继续尝试了。
但当你尝试重置密码#xff0c;又发现新密码不能和原密码重复#xff1a; 图片
相信此刻心情只能用一张图形容#xf…1故事背景
忘记密码这件事相信绝大多数人都遇到过输一次错一次错到几次以上就不允许你继续尝试了。
但当你尝试重置密码又发现新密码不能和原密码重复 图片
相信此刻心情只能用一张图形容 图片
虽然但是密码还是很重要的顺便我有了一个问题三次输错密码后系统是怎么做到不让我继续尝试的
2我想了想有如下几个问题需要搞定 是只有输错密码才锁定还是账户名和密码任何一个输错就锁定 输错之后也不是完全冻结为啥隔了几分钟又可以重新输了 技术栈到底麻不麻烦
去网上搜了搜也问了下ChatGPT找到一套解决方案SpringBootRedisLua脚本。
这套方案也不算新很早就有人在用了不过难得是自己想到的问题和解法就记录一下吧。
顺便回答一下上面的三个问题 锁定的是IP不是输入的账户名或者密码也就是说任一一个输错3次就会被锁定 Redis的Lua脚本中实现了key过期策略当key消失时锁定自然也就消失了 技术栈同SpringBootRedisLua脚本
3那么自己动手实现一下
前端部分
首先写一个账密输入页面使用很简单HTML加表单提交
!DOCTYPE html
html
headtitle登录页面/titlestylebody {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[typetext], input[typepassword] {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[typesubmit] {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[typesubmit]:hover {background-color: #1C90D6;}/style
/head
bodyform actionhttp://localhost:8080/login methodgetlabel forusername用户名/labelinput typetext idusername nameusername placeholder请输入用户名 requiredlabel forpassword密码/labelinput typepassword idpassword namepassword placeholder请输入密码 requiredinput typesubmit value登录/form
/body
/html效果如下: 图片
后端部分
技术选型分析
首先我们画一个流程图来分析一下这个登录限制流程 图片 从流程图上看首先访问次数的统计与判断不是在登录逻辑执行后而是执行前就加1了 其次登录逻辑的成功与失败并不会影响到次数的统计 最后还有一点流程图上没有体现出来这个次数的统计是有过期时间的当过期之后又可以重新登录了。
那为什么是RedisLua脚本呢
Redis的选择不难看出这个流程比较重要的是存在一个用来计数的变量这个变量既要满足分布式读写需求还要满足全局递增或递减的需求那Redis的incr方法是最优选了。
那为什么需要Lua脚本呢流程上在验证用户操作前有些操作如图 图片
这里至少有3步Redis的操作get、incr、expire如果全放到应用里面来操作有点慢且浪费资源。
Lua脚本的优点如下 减少网络开销。 可以将多个请求通过脚本的形式一次发送减少网络时延。 原子操作。 Redis会将整个脚本作为一个整体执行中间不会被其他请求插入。因此在脚本运行过程中无需担心会出现竞态条件无需使用事务。 复用。 客户端发送的脚本会永久存在redis中这样其他客户端可以复用这一脚本而不需要使用代码完成相同的逻辑。
最后为了增加功能的复用性我打算使用Java注解的方式实现这个功能。
代码实现
项目结构如下 图片
配置文件
pom.xml
?xml version1.0 encodingUTF-8?
project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instancexsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsdmodelVersion4.0.0/modelVersionparentgroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-parent/artifactIdversion2.7.11/versionrelativePath/ !-- lookup parent from repository --/parentgroupIdcom.example/groupIdartifactIdLoginLimit/artifactIdversion0.0.1-SNAPSHOT/versionnameLoginLimit/namedescriptionDemo project for Spring Boot/descriptionpropertiesjava.version1.8/java.version/propertiesdependenciesdependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-web/artifactId/dependencydependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-test/artifactIdscopetest/scope/dependency!-- redis --dependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-data-redis/artifactId/dependency!-- Jedis --dependencygroupIdredis.clients/groupIdartifactIdjedis/artifactId/dependency!--切面依赖 --dependencygroupIdorg.aspectj/groupIdartifactIdaspectjweaver/artifactId/dependency!-- commons-lang3 --dependencygroupIdorg.apache.commons/groupIdartifactIdcommons-lang3/artifactId/dependency!-- guava --dependencygroupIdcom.google.guava/groupIdartifactIdguava/artifactIdversion23.0/version/dependency!-- lombok --dependencygroupIdorg.projectlombok/groupIdartifactIdlombok/artifactIdoptionaltrue/optional/dependency/dependenciesbuildpluginsplugingroupIdorg.springframework.boot/groupIdartifactIdspring-boot-maven-plugin/artifactId/plugin/plugins/build/projectapplication.properties
## Redis配置
spring.redis.host127.0.0.1
spring.redis.port6379
spring.redis.password
spring.redis.timeout1000
## Jedis配置
spring.redis.jedis.pool.min-idle0
spring.redis.jedis.pool.max-idle500
spring.redis.jedis.pool.max-active2000
spring.redis.jedis.pool.max-wait10000注解部分
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 RedisTemplateString, Serializable limitRedisTemplate;Autowiredpublic LimitCountAspect(RedisTemplateString, 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();ImmutableListString keys ImmutableList.of(StringUtils.join(annotation.prefix() _, key, ip));String luaScript buildLuaScript();RedisScriptNumber 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 RedisTemplateObject, Object redisTemplate(RedisConnectionFactory redisConnectionFactory) {RedisTemplateObject, 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();}BeanConditionalOnMissingBean(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 RedisTemplateString, Serializable limitRedisTemplate(RedisConnectionFactory redisConnectionFactory) {RedisTemplateString, Serializable template new RedisTemplate();template.setKeySerializer(new StringRedisSerializer());template.setValueSerializer(new GenericJackson2JsonRedisSerializer());template.setConnectionFactory(redisConnectionFactory);return template;}
}class JacksonRedisSerializerT implements RedisSerializerT {private ClassT clazz;private ObjectMapper mapper;JacksonRedisSerializer(ClassT 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演示一下效果 图片
上面这套限流的逻辑感觉用在小型或中型的项目上应该问题不大不过目前的登录很少有直接锁定账号不能输入的一般都是弹出一个验证码框让你输入验证码再提交。我觉得用我这套逻辑改改应该不成问题核心还是接口尝试次数的限制嘛