告别密码爆破!手把手教你用注解和拦截器实现登录限流

内容分享13小时前发布
0 0 0

在当今的Web应用中,安全永远是第一位的。而登录接口,作为用户进入系统的“大门”,自然成为了黑客攻击的重灾区。其中,暴力破解(或称密码爆破)是最常见的攻击手段之一。如果一个登录接口没有任何防护,攻击者可以通过脚本在短时间内尝试成千上万个用户名和密码组合,后果不堪设想!

为了应对这种情况,限流(Rate Limiting)应运而生。今天,我们就来聊一个既优雅又强劲的实现方式:通过自定义注解 @LimitPoint 和拦截器 LimitInterceptor,为我们的登录接口加上一层坚实的“护盾”!


核心思想:AOP + 拦截器

在开始编码之前,我们先理清一下思路。我们的目标是:

  1. 不侵入业务逻辑:不想在登录的Controller方法里写一大堆限流的判断代码。
  2. 可配置化:能轻松地调整限流的规则(列如1分钟内最多10次)。
  3. 可复用:这个限流功能不仅能用于登录,最好也能用在其他需要限流的接口上。

这完美契合了 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;
    }

}

代码解析:

  1. 类注解配置 添加 @Configuration 注解:将 LimitInterceptor 标识为Spring配置类,确保其能被正确扫描和注册到应用上下文中 保留 @Aspect 注解:维持其作为切面组件的功能,用于定义切点和通知
  2. 环绕通知实现 使用 @Around(“@annotation(limitPointAnnotation)”):实现环绕通知,通过 ProceedingJoinPoint 获取目标方法参数 参数提取:通过 joinPoint.getArgs() 获取方法调用参数,用于后续限流键的生成
  3. 参数处理策略 对象类型判断:针对POST请求中封装的 SmsLoginRequestDTO 对象参数,增加类型检查机制 手机号提取:当检测到目标对象时,从对象中提取 mobile 字段作为限流标识符的一部分
  4. 限流维度支持 枚举配置:通过 LimitTypeEnums 枚举支持三种限流模式: 按用户限流 按手机号限流 按IP地址限流
  5. Redis原子操作 Lua脚本执行:使用预定义的 limitScript 在Redis中进行原子性的计数操作 请求统计:确保在高并发场景下请求计数的准确性
  6. 限流控制机制 阈值比较:将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作为存储天然支持分布式限流。
© 版权声明

相关文章

暂无评论

none
暂无评论...