zoukankan      html  css  js  c++  java
  • Java分布式IP限流和防止恶意IP攻击方案

    一、前言

    限流是分布式系统设计中经常提到的概念,在某些要求不严格的场景下,使用Guava RateLimiter就可以满足。但是Guava RateLimiter只能应用于单进程,多进程间协同控制便无能为力。本文介绍一种简单的处理方式,用于分布式环境下接口调用频次管控。

    如何防止恶意IP攻击某些暴露的接口呢(比如某些场景下短信验证码服务)?本文介绍一种本地缓存和分布式缓存集成方式判断远程IP是否为恶意调用接口的IP。

    二、分布式IP限流

    思路是使用redis incr命令,完成一段时间内接口请求次数的统计,以此来完成限流相关逻辑。

    private static final String LIMIT_LUA =
        "local my_limit = redis.call('incr', KEYS[1])
    " +
                " if tonumber(my_limit) == 1 then
    " +
                "   redis.call('expire', KEYS[1], ARGV[1])
    " +
                "   return 1
    " +
                " elseif tonumber(my_limit) > tonumber(ARGV[2]) then
    " +
                "   return 0
    " +
                " else
    " +
                "   return 1
    " +
                " end
    ";

    这里为啥时候用lua脚本来实现呢?因为要保证incr命令和expire命令的原子性操作。KEYS[1]代表自增key值, ARGV[1]代表过期时间,ARGV[2]代表最大频次,明白了这些参数的含义,整个lua脚本逻辑也就不言而喻了。

    /**
     * @param limitKey 限制Key值
     * @param maxRate  最大速率
     * @param expire   Key过期时间
     */
    public boolean access(String limitKey, int maxRate, int expire) {
        if (StringUtils.isBlank(limitKey)) {
            return true;
        }
    
        String cacheKey = LIMIT_KEY_PREFIX + limitKey;
    
        return REDIS_SUCCESS_STATUS.equals(
                this.cacheService.eval(
                        LIMIT_LUA
                        , Arrays.asList(cacheKey)
                        , Arrays.asList(String.valueOf(expire), String.valueOf(maxRate))
                ).toString()
        );
    }
    
    public void unlimit(String limitKey) {
        if (StringUtils.isBlank(limitKey)) {
            return;
        }
        String cacheKey = LIMIT_KEY_PREFIX + limitKey;
        this.cacheService.decr(cacheKey);
    }

    access方法用来判断 limitKey 是否超过了最大访问频次。缓存服务对象(cacheService)的eval方法参数分别是lua脚本、key list、value list。

    unlimit方法其实就是执行redis decr操作,在某些业务场景可以回退访问频次统计。

    三、防止恶意IP攻击

    由于某些对外暴露的接口很容易被恶意用户攻击,必须做好防范措施。最近我就遇到了这么一种情况,我们一个快应用产品,短信验证码服务被恶意调用了。通过后台的日志发现,IP固定,接口调用时间间隔固定,明显是被人利用了。虽然我们针对每个手机号每天发送短信验证码的次数限制在5次以内。但是短信验证码服务每天这样被重复调用,会打扰用户并产生投诉。针对这种现象,简单的做了一个方案,可以自动识别恶意攻击的IP并加入黑名单。

    思路是这样的,针对某些业务场景,约定在一段时间内同一个IP访问最大频次,如果超过了这个最大频次,那么就认为是非法IP。识别了非法IP后,把IP同时放入本地缓存和分布式缓存中。非法IP再次访问的时候,拦截器发现本地缓存(没有则去分布式缓存)有记录这个IP,直接返回异常状态,不会继续执行正常业务逻辑。

    Guava本地缓存集成Redis分布式缓存

    public abstract class AbstractCombineCache<K, V> {
        private static Logger LOGGER = LoggerFactory.getLogger(AbstractCombineCache.class);
    
        protected Cache<K, V> localCache;
    
        protected ICacheService cacheService;
    
        public AbstractCombineCache(Cache<K, V> localCache, ICacheService cacheService) {
            this.localCache = localCache;
            this.cacheService = cacheService;
        }
    
        public Cache<K, V> getLocalCache() {
            return localCache;
        }
    
        public ICacheService getCacheService() {
            return cacheService;
        }
    
        public V get(K key) {
           //只有LoadingCache对象才有get方法,如果本地缓存不存在key值, 会执行CacheLoader的load方法,从分布式缓存中加载。
            if (localCache instanceof LoadingCache) {
                try {
                    return ((LoadingCache<K, V>) localCache).get(key);
                } catch (ExecutionException e) {
                    LOGGER.error(String.format("cache key=%s loading error...", key), e);
                    return null;
                } catch (CacheLoader.InvalidCacheLoadException e) {
                    //分布式缓存中不存在这个key
                    LOGGER.error(String.format("cache key=%s loading fail...", key));
                    return null;
                }
            } else {
                return localCache.getIfPresent(key);
            }
        }
    
        public void put(K key, V value, int expire) {
            this.localCache.put(key, value);
            String cacheKey = key instanceof String ? (String) key : key.toString();
            if (value instanceof String) {
                this.cacheService.setex(cacheKey, (String) value, expire);
            } else {
                this.cacheService.setexObject(cacheKey, value, expire);
            }
        }
    }

    AbstractCombineCache这个抽象类封装了guava本地缓存和redis分布式缓存操作,可以降低分布式缓存压力。

    防止恶意IP攻击缓存服务

    public class IPBlackCache extends AbstractCombineCache<String, Object> {
        private static Logger LOGGER = LoggerFactory.getLogger(IPBlackCache.class);
    
        private static final String IP_BLACK_KEY_PREFIX = "wmhipblack_";
    
        private static final String REDIS_SUCCESS_STATUS = "1";
    
        private static final String IP_RATE_LUA =
                "local ip_rate = redis.call('incr', KEYS[1])
    " +
                        " if tonumber(ip_rate) == 1 then
    " +
                        "   redis.call('expire', KEYS[1], ARGV[1])
    " +
                        "   return 1
    " +
                        " elseif tonumber(ip_rate) > tonumber(ARGV[2]) then
    " +
                        "   return 0
    " +
                        " else
    " +
                        "   return 1
    " +
                        " end
    ";
    
        public IPBlackCache(Cache<String, Object> localCache, ICacheService cacheService) {
            super(localCache, cacheService);
        }
    
        /**
         * @param ipKey   IP
         * @param maxRate 最大速率
         * @param expire  过期时间
         */
        public boolean ipAccess(String ipKey, int maxRate, int expire) {
            if (StringUtils.isBlank(ipKey)) {
                return true;
            }
    
            String cacheKey = IP_BLACK_KEY_PREFIX + ipKey;
    
            return REDIS_SUCCESS_STATUS.equals(
                    this.cacheService.eval(
                            IP_RATE_LUA
                            , Arrays.asList(cacheKey)
                            , Arrays.asList(String.valueOf(expire), String.valueOf(maxRate))
                    ).toString()
            );
        }
    
        /**
         * @param ipKey IP
         */
        public void removeIpAccess(String ipKey) {
            if (StringUtils.isBlank(ipKey)) {
                return;
            }
            String cacheKey = IP_BLACK_KEY_PREFIX + ipKey;
            try {
                this.cacheService.del(cacheKey);
            } catch (Exception e) {
                LOGGER.error(String.format("%s, ip access remove error...", ipKey), e);
            }
        }
    }

    没有错,IP_RATE_LUA 这个lua脚本和上面说的限流方案对应的lua脚本是一样的。

    IPBlackCache继承了AbstractCombineCache,构造函数需要guava的本地Cache对象和redis分布式缓存服务ICacheService 对象。

    ipAccess方法用来判断当前ip访问次数是否在一定时间内已经达到了最大访问频次。

    removeIpAccess方法是直接移除当前ip访问频次统计的key值。

    防止恶意IP攻击缓存配置类

    @Configuration
    public class IPBlackCacheConfig {
        private static final String IPBLACK_LOCAL_CACHE_NAME = "ip-black-cache";
        private static Logger LOGGER = LoggerFactory.getLogger(IPBlackCacheConfig.class);
    
        @Autowired
        private LimitConstants limitConstants;
    
        @Bean
        public IPBlackCache ipBlackCache(@Autowired ICacheService cacheService) {
            GuavaCacheBuilder cacheBuilder = new GuavaCacheBuilder<String, Object>(IPBLACK_LOCAL_CACHE_NAME);
            cacheBuilder.setCacheBuilder(
                    CacheBuilder.newBuilder()
                            .initialCapacity(100)
                            .maximumSize(10000)
                            .concurrencyLevel(10)
                            .expireAfterWrite(limitConstants.getIpBlackExpire(), TimeUnit.SECONDS)
                            .removalListener((RemovalListener<String, Object>) notification -> {
                                String curTime = LocalDateTime.now().toString();
                                LOGGER.info(notification.getKey() + " 本地缓存移除时间:" + curTime);
                                try {
                                    cacheService.del(notification.getKey());
                                    LOGGER.info(notification.getKey() + " 分布式缓存移除时间:" + curTime);
                                } catch (Exception e) {
                                    LOGGER.error(notification.getKey() + " 分布式缓存移除异常...", e);
                                }
                            })
            );
            cacheBuilder.setCacheLoader(new CacheLoader<String, Object>() {
                @Override
                public Object load(String key) {
                    try {
                        Object obj = cacheService.getString(key);
                        LOGGER.info(String.format("从分布式缓存中加载key=%s, value=%s", key, obj));
                        return obj;
                    } catch (Exception e) {
                        LOGGER.error(key + " 从分布式缓存加载异常...", e);
                        return null;
                    }
                }
            });
    
            Cache<String, Object> localCache = cacheBuilder.build();
            IPBlackCache ipBlackCache = new IPBlackCache(localCache, cacheService);
            return ipBlackCache;
        }
    }

    注入redis分布式缓存服务ICacheService对象。

    通过GuavaCacheBuilder构建guava本地Cache对象,指定初始容量(initialCapacity)、最大容量(maximumSize)、并发级别、key过期时间、key移除监听器。最终要的是CacheLoader这个参数,是干什么用的呢?如果GuavaCacheBuilder指定了CacheLoader对象,那么最终创建的guava本地Cache对象是LoadingCache类型(参考AbstractCombineCache类的get方法),LoadingCache对象的get方法首先从内存中获取key对应的value,如果内存中不存在这个key则调用CacheLoader对象的load方法加载key对应的value值,加载成功后放入内存中。

    最后通过ICacheService对象和guava本地Cache对象创建IPBlackCache(防止恶意IP攻击缓存服务)对象。

    拦截器里恶意IP校验

    定义一个注解,标注在指定方法上,拦截器里会识别这个注解。

    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface IPBlackLimit {
        //统计时间内最大速率
        int maxRate();
    
        //频次统计时间
        int duration();
    
        //方法名称
        String method() default StringUtils.EMPTY;
    }

    拦截器里加入ipAccess方法,校验远程IP是否为恶意攻击的IP。

    /**
    * @param method 需要校验的方法
    * @param remoteAddr 远程IP
    */
    private boolean ipAccess(Method method, String remoteAddr) {
        if (StringUtils.isBlank(remoteAddr) || !AnnotatedElementUtils.isAnnotated(method, IPBlackLimit.class)) {
            return true;
        }
        IPBlackLimit ipBlackLimit = AnnotatedElementUtils.getMergedAnnotation(method, IPBlackLimit.class);
        try {
            String ip = remoteAddr.split(",")[0].trim();
            String cacheKey = "cipb_" + (StringUtils.isBlank(ipBlackLimit.method()) ? ip : String.format("%s_%s", ip, ipBlackLimit.method()));
    
            String beginAccessTime = (String) ipBlackCache.get(cacheKey);
            if (StringUtils.isNotBlank(beginAccessTime)) {
                LocalDateTime beginTime = LocalDateTime.parse(beginAccessTime, DateTimeFormatter.ISO_LOCAL_DATE_TIME), endTime = LocalDateTime.now();
                Duration duration = Duration.between(beginTime, endTime);
                if (duration.getSeconds() >= limitConstants.getIpBlackExpire()) {
                    ipBlackCache.getLocalCache().invalidate(cacheKey);
                    return true;
                } else {
                    return false;
                }
            }
    
            boolean access = ipBlackCache.ipAccess(cacheKey, ipBlackLimit.maxRate(), ipBlackLimit.duration());
            if (!access) {
                ipBlackCache.removeIpAccess(cacheKey);
                String curTime = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
                ipBlackCache.put(cacheKey, curTime, limitConstants.getIpBlackExpire());
            }
            return access;
        } catch (Exception e) {
            LOGGER.error(String.format("method=%sï¼remoteAddr=%s, ip access check error.", method.getName(), remoteAddr), e);
            return true;
        }
    }

    remoteAddr取的是X-Forwarded-For对应的值。利用remoteAddr构造cacheKey参数,通过IPBlackCache判断cacheKey是否存在。

    如果是cacheKey存在的请求,判断黑名单IP限制是否已经到达有效期,如果已经超过有效期则清除本地缓存和分布式缓存的cacheKey,请求合法;如果没有超过有效期则请求非法。

    否则是cacheKey不存在的请求,使用IPBlackCache对象的ipAccess方法统计一定时间内的访问频次,如果频次超过最大限制,表明是非法请求IP,需要往IPBlackCache对象写入“cacheKey=当前时间”。

    四、总结

    本文的两种方案都使用redis incr命令,如果不是特殊业务场景,redis的key要指定过期时间,严格来讲需要保证incr和expire两个命令的原子性,所以使用lua脚本方式。如果没有那么严格,完全可以先setex(设置key,value,过期时间),然后再incr(注:incr不会更新key的有效期)。本文的设计方案仅供参考,并不能应用于所有的业务场景。

  • 相关阅读:
    jquery选择器
    js中的闭包技术
    idea创建servlet不能创建:
    JSP页面不解析EL表达式的原因
    大对象数据LoB的应用
    缓冲流、转换流、序列化流相关流知识点
    jdk5.0新特性(注解)
    EKT相关知识(Class类对象的方法补充)
    java中调用存储过程或函数
    Java 缓冲流
  • 原文地址:https://www.cnblogs.com/SmallStrange/p/13813594.html
Copyright © 2011-2022 走看看