1. 程式人生 > >Java分散式IP限流和防止惡意IP攻擊方案

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])\n" +
            " if tonumber(my_limit) == 1 then\n" +
            "   redis.call('expire', KEYS[1], ARGV[1])\n" +
            "   return 1\n" +
            " elseif tonumber(my_limit) > tonumber(ARGV[2]) then\n" +
            "   return 0\n" +
            " else\n" +
            "   return 1\n" +
            " end\n";

這裡為啥時候用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])\n" +
                    " if tonumber(ip_rate) == 1 then\n" +
                    "   redis.call('expire', KEYS[1], ARGV[1])\n" +
                    "   return 1\n" +
                    " elseif tonumber(ip_rate) > tonumber(ARGV[2]) then\n" +
                    "   return 0\n" +
                    " else\n" +
                    "   return 1\n" +
                    " end\n";

    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的有效期)。本文的設計方案僅供參考,並不能應用於所有的業務場