头图

背景

   Leader:现在组内新建系统的API网关,你设计时要充分考虑接口的安全,防止被篡改和暴力攻击。
   Coder:好的,安全方面我们是有充分考虑的,通过验签防止入参被篡改,结合时间戳防止暴力攻击。

知识储备

   基于Token的鉴权方式是无状态的,服务端不再需要存储Session信息,是分布式系统的主要鉴权方案。

   1、开发者认证
   开发者认证也就是登录校验,网关校验用户上送的appId和appSecret,如果跟redis中保存的一致,则生成access_token和refresh_token并返回给用户,用户暂存该token,访问其它API接口时需要上送access_token。

   2、API接口公共请求参数

参数参数名备注
appId应用ID标识调用方的身份
access_tokentoken示例是通过UUID生成
sign签名
timestamp时间戳用于防御重放攻击
nonce随机数用于防御重放攻击

   3、重放攻击
   重放攻击是指攻击者发送目的主机已接收过的数据,以达到欺骗系统的目的,主要用于身份认证过程,破坏认证的正确性。
   我们主要通过验证时间戳和随机数来防御重放攻击。
   1)验证timestamp
   判断时间戳timestamp是否超过nonceTimeout秒,超时则判别为重放攻击。
   2)验证nonce
   验证随机数nonce在redis中是否存在,如果存在,则判别为重放攻击,否则将nonce记录在redis中(示例中key的生成规则是:"NS"+appId+nonce),失效时间为nonceTimeout秒。

   4、鉴权
   验证access_token在redis中是否存在,若已过期,则无权访问API接口。用户可以刷新token,只要refresh_token在redis中存在,则网关重新生成access_token和refresh_token。通常refresh_token保存的时间较access_token久。

   5、验签
   1)将业务参数和timestamp、nonce、appid按键值对字典排序后通过&拼接,例如:appid=appid&key1=value1&key2=value2&nonce=random×atmp=1629777776799,得到stringA;
   2)stringA再拼接appsecret,例如:stringA&appsecret=appsecret,得到stringB;
   3)最后将stringB通过md5加密并转大写,即uppercase(md5(stringB)),得到签名sign,跟用户请求API接口时上送的签名对比,如果相同,则验签通过;也可以采用SHA256WithRSA签名算法,调用方生成一对RSA公私钥,调用方用私钥加签,服务方用公钥验签。

实操

   我们通过实现过滤器接口完成API接口鉴权验签动作。
   过滤器是在请求进入Tomcat容器后,但请求进入servlet之前进行预处理的。请求结束返回也是在servlet处理完后,返回给前端之前。
   进入servlet之前,主要是两个参数:ServletRequest,ServletResponse,我们可以通过ServletRequest得到HttpServletRequest,此时就可以对web服务器管理的所有web资源:例如Jsp, Servlet, 静态图片文件或静态html文件等进行拦截,从而实现一些特殊的功能。例如实现URL级别的权限访问控制、过滤敏感词汇、压缩响应信息、字符集统一等一些高级功能。它主要用于对用户请求进行预处理,也可以对HttpServletResponse进行后处理。使用Filter的完整流程:Filter对用户请求进行预处理,接着将请求交给Servlet进行处理并生成响应,最后Filter再对服务器响应进行后处理。它是随你的web应用启动而启动的,只初始化一次,以后就可以拦截相关请求,只有当你的web应用停止或重新部署的时候才销毁。(每次热部署后,都会销毁)。

@WebFilter(urlPatterns = "/api/*")
public class PreFilter implements Filter {
    private static Logger logger = Logger.getLogger("PreFilter");

    private Long nonceTimeout = 300l;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;


    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        logger.info("进入过滤器处理");
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        RedisUtil redisUtil = new RedisUtil(stringRedisTemplate);
        ServletOutputStream out = servletResponse.getOutputStream();
        String outString = null;

        try {
            String appId = request.getHeader("appId");
            String accessToken = request.getHeader("access_token");
            String sign = request.getHeader("sign");
            String timestamp = request.getHeader("timestamp");
            String nonce = request.getHeader("nonce");

            Map<String, String> paramMap = new HashMap<>();

            InputStream in = request.getInputStream();
            String body = StreamUtils.copyToString(in, Charset.forName("UTF-8"));

            /*
            参数来自请求body
             */
            JSONObject json = JSONObject.parseObject(body);
            if (json != null && !json.isEmpty()) {
                logger.info("body = " + json);
                for (String key : json.keySet()) {
                    paramMap.put(key, json.getString(key));
                }
            }
            /*
            参数来自请求url的QueryString
             */
            String query = request.getQueryString();
            if (query != null) {
                logger.info("queryString = " + URLDecoder.decode(query, "UTF-8"));
                String[] arr = query.split("&");
                for (String pair : arr) {
                    String[] ele = pair.split("=");
                    if (ele.length == 2) {
                        paramMap.put(ele[0], ele[1]);
                    }
                }
            }

            paramMap.put("appid", appId);
            paramMap.put("timestamp", timestamp);
            paramMap.put("nonce", nonce);
            String stringA = AuthUtil.concatParam(paramMap);
            Long now = System.currentTimeMillis();
            /*
            判断时间戳是否超过nonceTimeout秒,超时则判别为重放功击
             */
            if (timestamp != null && (now - Long.parseLong(timestamp) < nonceTimeout * 1000)) {
                /*
                验证nonce在redis中是否存在,如果存在,则判别为重放功击,否则将nonce记录在redis中(key为:"NS"+appId+nonce),失效时间为nonceTimeout秒
                 */
                if (redisUtil.exists("NS" + appId + nonce)) {
                    outString = String.format("{\"code\": 429, \"message\": \"Too Many Requests\"}");
                    out.write(outString.getBytes());
                    out.flush();
                } else {
                    redisUtil.set("NS" + appId + nonce, nonce, nonceTimeout);
                    /*
                    验证access_token是否存在
                     */
                    if (redisUtil.exists(accessToken) && redisUtil.exists(appId)) {
                        String redis_appid = (String) redisUtil.get(accessToken);
                        String redis_appsecret = (String) redisUtil.get(redis_appid);
                        String _sign = AuthUtil.getSign(stringA, redis_appsecret);
                        /*
                        验证签名是否通过,若通过,则开发者认证也会验证通过
                         */
                        if (_sign.equals(sign)) {
                            filterChain.doFilter(servletRequest, servletResponse);
                        } else {
                            outString = String.format("{\"code\": 403, \"message\": \"Forbidden\"}");
                            out.write(outString.getBytes());
                            out.flush();
                        }
                    } else {
                        outString = String.format("{\"code\": 401, \"message\": \"Unauthorized\"}");
                        out.write(outString.getBytes());
                        out.flush();
                    }
                }
            } else {
                outString = String.format("{\"code\": 400, \"message\": \"Bad Request\"}");
                out.write(outString.getBytes());
                out.flush();
            }
        } catch (Exception e) {
            e.printStackTrace();
            outString = String.format("{\"code\": 500, \"message\": \"Internal Server Error\"}");
            out.write(outString.getBytes());
            out.flush();
        }
    }
}

   需要引入以下依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<dependency>
    <groupId>commons-codec</groupId>
    <artifactId>commons-codec</artifactId>
</dependency>

   调用登录接口获取token后,再调用 http://127.0.0.1:8080/api/sayhello?userNo=2,返回结果正常。

   待token失效后,再请求 http://127.0.0.1:8080/api/sayhello?userNo=2,返回结果是未授权。

   在Postman的Pre-request Script中将nonce写死为123456,首次请求成功,再次请求则报错“Too Many Requests”,即多次nonce送值相同时,识别出重放攻击。

   在Postman的Pre-request Script中将签名sign的值变更,请求报错“Forbidden”,即验证签名不通过。


背风
1 声望0 粉丝