Web安全之防止短信炸弹

虽然标题名为防止短信炸弹,但其实这就是个限流的问题——也就是限制某个接口的时间窗口内的请求数。

1. 概述

本人所在的公司属于传统的国土行业,技术以求稳为主,而且为了兼顾实施人员的开发水平,至今连MQ都不敢往系统里集成(有人能拉一曲《二泉映月》吗?)。

抱怨到此为止,接下来我们就看看实现思路。

2. 思路

我们先来看看目前比较流行的思路:
1. 限制每个手机号的每日发送次数,超过次数则拒发送,提示超过当日次数。
2. 每个ip限制最大限制次数。超过次数则拒发送,提示超过ip当日发送最大次数。
3. 限制每个手机号发送的时间间隔,比如两分钟,没超过2分钟,不允许发送,提示操作频繁。
4. 发送短信增加图片验证码,服务端和输入验证码对比,不一致则拒绝发送。

思路基本上就是这么多了,现在唯一的问题就是挑选出更适合业务场景的。

3. 实现

我们选择使用Servlet Filter机制来完成限流功能,注意该Filter需要注册到web.xml中,且作为第一个Filter。

/**
 * 限流
 * @author LQ
 *
 */
public final class RateLimitFilter implements Filter {

    private static final Logger LOG = Logger.getLogger(LoginFilter.class);

    private int contextPathLength;
    // Spring提供的缓存接口
    private Cache rateLimitCache;
    // 时间窗口内的限制访问次数; 也应该外置化
    private final int LIMIT = 2;

    @Override
    public void destroy() {

    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
            FilterChain filterchain) throws IOException, ServletException {
        final HttpServletRequest request = (HttpServletRequest) servletRequest;
        final HttpServletResponse response = (HttpServletResponse) servletResponse;

        String requestUri = request.getRequestURI();
        if (contextPathLength != 0) {
            requestUri = requestUri.substring(contextPathLength);
        }

            // 这里应该使用配置化
            if (!"/xx/yy/zzz.do".equalsIgnoreCase(requestUri)) {
                filterchain.doFilter(servletRequest, servletResponse);
                return;
            }

            makesureCacheExist();

            final String sjh = WebUtils.findParameterValue(request, "SJH");
            // CacheKey就是关键所在
            final CacheKey cacheKey = new CacheKey.Builder(sjh).ip(HtmlUtil.getClientIp(request))
                    .build();

            // putIfAbsent方法报错
            if (ObjectUtil.isNull(rateLimitCache.get(cacheKey, AtomicLong.class))) {
                LOG.debug(StringUtil.format("手机号[ {} ]在时间点[ {} ]第一次访问.", sjh, DateTime.now()
                        .toString("yyyy-MM-dd HH:mm:ss")));
                rateLimitCache.put(cacheKey, new AtomicLong(0));
            }

            final AtomicLong countPerOneMin = rateLimitCache.get(cacheKey, AtomicLong.class);
            if (countPerOneMin.incrementAndGet() > LIMIT) {
                LOG.debug(StringUtil.format("手机号[ {} ]被限流, 当前时间 : [ {} ]", sjh, DateTime.now()
                        .toString("yyyy-MM-dd HH:mm:ss")));
                HtmlUtil.writerJson(response, Collections.singletonMap("BL", false));
            } else {
                filterchain.doFilter(servletRequest, servletResponse);
            }


    }

    private void makesureCacheExist() {
        if (ObjectUtil.isNull(rateLimitCache)) {
            try {
                rateLimitCache = ((CacheManager) SpringBeanFactory.getBean("cacheManager"))
                        .getCache("rateLimitCache");
            } catch (Exception e) {
                throw ExceptionUtil.wrapRuntime(e);
            }
        }
    }

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        String contextPath = filterConfig.getServletContext().getContextPath();
        contextPathLength = (contextPath == null || "/".equals(contextPath) ? 0 : contextPath
                .length());
    }

}

附上CacheKey的相关代码

/**
 * <p> 限流缓存的Key
 * <p> 以手机号和当前分钟数为检索Key, 加入时间是为了出现不断访问缓存导致一直刷新的问题
 * <p> 目前在时间刻度里的一分钟内只允许访问固定的次数, 即在上一分钟的最后一秒访问后, 跳到下一分钟后重新开始计算次数(依然是两次限制)
 * <p> 注意其equal和hashcode方法, 里面的逻辑正是关键
 * @author LQ
 *
 */
final class CacheKey implements Serializable {
    private static final long serialVersionUID = -1;
    private String ip;
    private String phoneNum;
    private long currentMinute;

    public static class Builder {
        private final CacheKey key = new CacheKey();

        public Builder(final String phoneNum) {
            key.phoneNum = phoneNum;
            key.currentMinute = System.currentTimeMillis() / 1000 / 60;
        }

        public Builder ip(final String ip) {
            key.ip = ip;
            return this;
        }

        public CacheKey build() {
            return key;
        }
    }

    public String getIp() {
        return ip;
    }

    public String getPhoneNum() {
        return phoneNum;
    }

    /**
     * @return 获取生成该Cache时的分钟数
     */
    public long getCurrentMinute() {
        return currentMinute;
    }

    @Override
    public boolean equals(Object other) {
        if (this == other) {
            return true;
        }

        if (other == null || other.getClass() != this.getClass()) {
            return false;
        }

        CacheKey that = (CacheKey) other;
        // custom logic -- self property; 比较自身的特有属性;父类的就交给父类.
        // 核心逻辑就是这里了
        // 目前的逻辑是相同手机号, 分钟数一样; 可依据自身业务需求进行自定义更改
        if (this.phoneNum.equalsIgnoreCase(that.phoneNum)
                && Long.compare(this.currentMinute, that.currentMinute) == 0) {
            return true;
        }

        // -------------------------------- 2018/9/14补充(start)
        if (this.ip.equalsIgnoreCase(that.ip)
                && Long.compare(this.currentMinute, that.currentMinute) == 0) {
            return true;
        }
        // -------------------------------- 2018/9/14补充(end)

        //其它的比较交给父类
        return super.equals(other);
    }

    @Override
    public int hashCode() {
        return HashCodeBuilder.reflectionHashCode(this);
    }

}

4. 补充(2018/9/14)

本以为这件事就告一段落了,没想到最近又接到“还需要针对IP进行限制”的新需求,其实在这个需求最开始的时候就考虑到了这种可能性,所以预想很轻松就能解决——只需要在CacheKey已覆写的equal方法中添加相应的判等逻辑即可满足需求,但最终发现只是自己的一厢情愿。

基础扎实的读者应该已经猜根源所在了;在上面的CacheKey的实现中,hashCode方法的实现我们直接借用了Apache提供的HashCodeBuilder.reflectionHashCode(this)以简化代码,在新的需求下,我们就需要稍微思考下关于这个方法的实现了。

这个问题归根到底就是 “哈希表是一个链表的数组”,其中hashcode()的返回值作为数组的索引,而equal()方法则是作为链表中取值的依据。 所以这里我们修改了equal里的判等逻辑之后,一定要去hashcode()中进行相应的修改以适配equal()中的逻辑变化(最暴力的方式就是return 1;,当然这就完全失去了hash的优势。)

另外通过观察ehcache的底层源码SimpleBackend.get(),内部就是通过ConcurrentHashMap支撑的。而相应的Guava中的LocalCache.get()实现中也是类似的逻辑。

5. 总结

  1. 其实没啥值得总结的,唯一注意的就是需要清楚基类Object里提供的equals的作用。
  2. 越来越觉得“用到再去学”这句话的漏洞太大了,你都不知道有这么个东西,你怎么会知道它的好处,进而去学习它呢?
  1. 《亿级流量网站架构核心技术》 P70
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值