在当今的Web应用中,安全永远是第一位的。而登录接口,作为用户进入系统的“大门”,自然成为了黑客攻击的重灾区。其中,暴力破解(或称密码爆破)是最常见的攻击手段之一。如果一个登录接口没有任何防护,攻击者可以通过脚本在短时间内尝试成千上万个用户名和密码组合,后果不堪设想!
为了应对这种情况,限流(Rate Limiting)应运而生。今天,我们就来聊一个既优雅又强劲的实现方式:通过自定义注解 @LimitPoint 和拦截器 LimitInterceptor,为我们的登录接口加上一层坚实的“护盾”!
核心思想:AOP + 拦截器
在开始编码之前,我们先理清一下思路。我们的目标是:
- 不侵入业务逻辑:不想在登录的Controller方法里写一大堆限流的判断代码。
- 可配置化:能轻松地调整限流的规则(列如1分钟内最多10次)。
- 可复用:这个限流功能不仅能用于登录,最好也能用在其他需要限流的接口上。
这完美契合了 AOP(面向切面编程) 的思想。在Spring Boot中,拦截器是实现AOP的一种绝佳方式。我们可以创建一个拦截器,让它“盯着”所有被特定注解标记的方法,在方法执行前进行限流检查。
整个流程如下:
第一步:创建自定义注解 @LimitPoint
第一,我们需要一个“标记”,用来告知拦截器:“嘿,这个方法需要限流,快来检查一下!”。这个标记就是我们的自定义注解 @LimitPoint。
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @author: admin
* @description: 接口限流注解
*/
@Target(ElementType.METHOD) // 表明该注解只能用在方法上
@Retention(RetentionPolicy.RUNTIME) // 表明注解在运行时依然存在,JVM可以通过反射读取
public @interface LimitPoint {
```
/**
* 资源的名字 无实际意义,但是可以用于排除异常
*
* @return String
*/
String name() default "";
/**
* 资源的key
* <p>
* 如果下方 limitType 值为自定义,那么全局限流参数来自于此
* 如果limitType 为ip,则限流条件 为 key+ip
* 扩展
* 限流的key,支持占位符
* 例如: "sms_login_{mobile}" 会自动替换为 "sms_login_xxxxxxxxxxx"
* @return String
*/
String key() default "";
/**
* Key的prefix redis前缀,可选
*
* @return String
*/
String prefix() default "";
/**
* 给定的时间段 单位秒
*
* @return int
*/
int period() default 60;
/**
* 最多的访问限制次数
*
* @return int
*/
int limit() default 10;
/**
* 类型 ip限制 还是自定义key值限制
* 提议使用ip,自定义key属于全局限制,ip则是某节点设置,一般情况使用IP
*
* @return LimitType
*/
LimitTypeEnums limitType() default LimitTypeEnums.IP;
}
代码解析:
1.@Target(ElementType.METHOD): 规定了我们的注解只能放在方法上面。 2.@Retention(RetentionPolicy.RUNTIME): 超级重大!它确保了我们的拦截器在程序运行时能够通过反射获取到这个注解。 3.key(): 限流的维度。列如,我们可以用sms_login作为登录接口的key,或者是结合IP或者手机号mobile,列如sms_login_mobile。 4.period()和limit(): 限流的核心规则。period=60, limit=10 就代表“60秒内最多允许10次调用”。
是不是很简单?一个注解就定义了我们所有的限流规则!
第二步:实现拦截器 LimitInterceptor
有了“标记”,接下来就是“执行者”——拦截器。这是整个功能的核心。我们将使用 Redis 作为计数器存储,由于它高性能、支持过期操作,超级适合这种场景。
为了防止并发问题导致计数不准,我们使用 Lua脚本 来保证Redis操作的原子性。
package cn.lili.cache.limit.interceptor;
import cn.lili.cache.limit.annotation.LimitPoint;
import cn.lili.cache.limit.enums.LimitTypeEnums;
import cn.lili.common.enums.ResultCode;
import cn.lili.common.exception.ServiceException;
import cn.lili.common.utils.IpUtils;
import cn.lili.modules.store.entity.dto.SmsLoginRequestDTO;
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.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.io.Serializable;
/**
* 流量拦截
*
* @author admin
*/
@Aspect
@Configuration
@Slf4j
public class LimitInterceptor {
private RedisTemplate<String, Serializable> redisTemplate;
private DefaultRedisScript<Long> limitScript;
@Autowired
public void setRedisTemplate(RedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Autowired
public void setLimitScript(DefaultRedisScript<Long> limitScript) {
this.limitScript = limitScript;
}
@Around("@annotation(limitPointAnnotation)")
public Object interceptor(ProceedingJoinPoint joinPoint, LimitPoint limitPointAnnotation) throws Throwable {
LimitTypeEnums limitTypeEnums = limitPointAnnotation.limitType();
String key;
int limitPeriod = limitPointAnnotation.period();
int limitCount = limitPointAnnotation.limit();
if (limitTypeEnums == LimitTypeEnums.CUSTOMER) {
key = limitPointAnnotation.key();
} else if(limitTypeEnums == LimitTypeEnums.MOBILE){
String keyPattern = limitPointAnnotation.key();
String keyPrefix = limitPointAnnotation.prefix();
//根据限流类型生成唯一key
key = generateMobileKey(joinPoint, keyPattern, keyPrefix);
} else {
key = limitPointAnnotation.key() + IpUtils
.getIpAddress(((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest());
}
ImmutableList<String> keys = ImmutableList.of(StringUtils.join(limitPointAnnotation.prefix(), key));
try {
Number count = redisTemplate.execute(limitScript, keys, limitCount, limitPeriod);
assert count != null;
log.info("限制请求{}, 当前请求{},缓存key{}", limitCount, count.intValue(), key);
//如果缓存里没有值,或者他的值小于限制频率
if (count.intValue() > limitCount) {
throw new ServiceException(ResultCode.LIMIT_ERROR, "60秒内最多登录10次,请稍后再试");
}
}
//如果从redis中执行都值判定为空,则这里跳过
catch (NullPointerException e) {
return null;
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
log.error("限流异常", e);
throw new ServiceException(ResultCode.ERROR);
}
// 5. 未超限,执行原方法
return joinPoint.proceed();
}
/**
* 生成限流唯一key
*/
private String generateMobileKey(ProceedingJoinPoint joinPoint,
String keyPattern, String keyPrefix) {
// 从请求参数中获取手机号,替换key中的{mobile}占位符
String mobile = getMobileFromParams(joinPoint);
if (StringUtils.isBlank(mobile)) {
throw new ServiceException(ResultCode.USER_PHONE_NOT_EXIST);
}
String key = keyPattern.replace("{mobile}", mobile);
// 拼接前缀
return StringUtils.isNotBlank(keyPrefix) ? keyPrefix + key : key;
}
/**
* 从方法参数中获取手机号(兼容多种参数位置)
*/
private String getMobileFromParams(ProceedingJoinPoint joinPoint) {
// 遍历方法参数,查找包含mobile字段的对象
for (Object arg : joinPoint.getArgs()) {
if (arg == null) {
continue;
}
// 检查是否是SmsLoginRequestDTO或其子类
if (arg instanceof SmsLoginRequestDTO) {
return ((SmsLoginRequestDTO) arg).getMobile();
}
}
return null;
}
}
代码解析:
- 类注解配置 添加 @Configuration 注解:将 LimitInterceptor 标识为Spring配置类,确保其能被正确扫描和注册到应用上下文中 保留 @Aspect 注解:维持其作为切面组件的功能,用于定义切点和通知
- 环绕通知实现 使用 @Around(“@annotation(limitPointAnnotation)”):实现环绕通知,通过 ProceedingJoinPoint 获取目标方法参数 参数提取:通过 joinPoint.getArgs() 获取方法调用参数,用于后续限流键的生成
- 参数处理策略 对象类型判断:针对POST请求中封装的 SmsLoginRequestDTO 对象参数,增加类型检查机制 手机号提取:当检测到目标对象时,从对象中提取 mobile 字段作为限流标识符的一部分
- 限流维度支持 枚举配置:通过 LimitTypeEnums 枚举支持三种限流模式: 按用户限流 按手机号限流 按IP地址限流
- Redis原子操作 Lua脚本执行:使用预定义的 limitScript 在Redis中进行原子性的计数操作 请求统计:确保在高并发场景下请求计数的准确性
- 限流控制机制 阈值比较:将Redis返回的请求计数与配置的限流阈值进行比较 异常抛出:当超出限流阈值时,抛出相应的限流异常以阻止请求继续执行 熔断保护:实现服务的自我保护机制,防止系统过载
第三步:配置拦截器
光有拦截器还不够,我们还需要告知Spring Boot,我们创建了这个拦截器,并指定它要拦截哪些URL。
package cn.lili.common.config;
/**
* @author admin
*/
import cn.lili.common.Interceptor.AuthLoginInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final AuthLoginInterceptor authInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/login",
"/public/**",
"/error"
);
}
}
代码解析: 1.@Configuration 表明这是一个配置类。 2.我们实现了 WebMvcConfigurer 接口,并重写了 addInterceptors 方法。 3.registry.addInterceptor(limitInterceptor) 将我们的拦截器注册进去。 4..addPathPatterns(“/**”)指定了拦截的路径。你可以用 /* 或 /** 来匹配更多路径。 5..excludePathPatterns(“/**”)指定了排除的路径。你可以用 /* 或 /** 来匹配更多路径。
第四步:在Controller中使用
万事俱备,只欠东风!目前,我们可以像使用 @Autowired 一样,优雅地在我们的登录方法上加上 @LimitPoint 注解了。
@LimitPoint(name = "sms_login", key = "sms_login_{mobile}", limitType = LimitTypeEnums.MOBILE)
@ApiOperation(value = "短信登录接口")
@ApiImplicitParams({
@ApiImplicitParam(name = "mobile", value = "手机号", required = true, paramType = "query"),
@ApiImplicitParam(name = "code", value = "验证码", required = true, paramType = "query")
})
@PostMapping("/smsLogin")
public ResultMessage<Object> smsLogin(@Valid @RequestBody SmsLoginRequestDTO smsLoginDTO) {
if (smsUtil.verifyCode(smsLoginDTO.getMobile(), VerificationEnums.LOGIN, smsLoginDTO.getUuid(), smsLoginDTO.getCode())) {
return ResultUtil.data(memberService.mobilePhoneStoreLogin(smsLoginDTO.getMobile()));
} else {
throw new ServiceException(ResultCode.VERIFICATION_INCORRECT);
}
}
目前,启动你的项目,用Postman或其他工具在1分钟内连续访问 /smsLogin 接口超过10次,你就会看到我们自定义的限流提示:
{
"success": false,
"message": "访问过于频繁,请稍后再试-60秒内最多登录10次,请稍后再试",
"code": 1003,
"timestamp": 1760600300465,
"result": null
}
大功告成! 我们只用了一个注解,就为登录接口加上了强劲的防爆破功能,代码干净、解耦、易于维护!
总结与展望
今天,我们一起从零到一实现了一个基于注解和拦截器的登录限流功能。
- 优点:
- 解耦:业务逻辑和限流逻辑完全分离。
- 优雅:一个注解即可完成配置,代码可读性高。
- 灵活:通过修改注解参数即可调整限流策略。
- 可复用:可以轻松应用到任何需要限流的接口上。
- 可扩展点:
- 动态Key:可以结合Spring AOP的 JoinPoint 获取方法参数,动态生成更精细的限流Key(如按用户ID限流)。
- 多种限流算法:目前实现的是计数器滑动窗口算法,也可以扩展为令牌桶或漏桶算法。
- 分布式限流:在分布式系统中,使用Redis作为存储天然支持分布式限流。