安全-如何设计一个安全的对外接口

1.  背景

在某些场景(提供第三方API接口的调用)下为了保证数据在传输过程中的安全性,需要对接口进行签名认证。一般而言,如果不是特别需要自己设计,可以直接将接口挂在阿里API网关上(可能会有部分的限制,如流量)。这里也是参照阿里网关签名思路写的。

2.  安全措施

(1)数据加密

我们知道数据在传输过程中是很容易被抓包的,如果直接传输比如通过http协议,那么用户传输的数据可以被任何人获取;所以必须对数据加密,常见的做法对关键字段加密比如用户密码直接通过md5加密;现在主流的做法是使用https协议,在http和tcp之间添加一层加密层(SSL层),这一层负责数据的加密和解密;另外对密码这块可以适用AES、RSA等算法进行文本加密;

(2)数据加签

数据加签就是由发送者产生一段无法伪造的一段数字串,来保证数据在传输过程中不被篡改;你可能会问数据如果已经通过https加密了,还有必要进行加签吗?数据在传输过程中经过加密,理论上就算被抓包,也无法对数据进行篡改;但是我们要知道加密的部分其实只是在外网,现在很多服务在内网中都需要经过很多服务跳转,所以这里的加签可以防止内网中数据被篡改;

(3)时间戳机制

数据是很容易被抓包的,但是经过如上的加密,加签处理,就算拿到数据也不能看到真实的数据;但是有不法者不关心真实的数据,而是直接拿到抓取的数据包进行恶意请求;这时候可以使用时间戳机制,在每次请求中加入当前的时间,服务器端会拿到当前时间和消息中的时间相减,看看是否在一个固定的时间范围内比如5分钟内;这样恶意请求的数据包是无法更改里面时间的,所以5分钟后就视为非法请求了;

(4)AppId机制

大部分网站基本都需要用户名和密码才能登录,并不是谁来能使用我的网站,这其实也是一种安全机制;对应的对外提供的接口其实也需要这么一种机制,并不是谁都可以调用,需要使用接口的用户需要在后台开通appid,提供给用户相关的密钥;在调用的接口中需要提供 appid+密钥,服务器端会进行相关的验证;

(5)限流机制

本来就是真实的用户,并且开通了appid,但是出现频繁调用接口的情况;这种情况需要给相关appid限流处理,常用的限流算法有令牌桶和漏桶算法;

(6)黑名单机制

如果此appid进行过很多非法操作,或者说专门有一个中黑系统,经过分析之后直接将此appid列入黑名单,所有请求直接返回错误码;

(7)数据合法性校验

这个可以说是每个系统都会有的处理机制,只有在数据是合法的情况下才会进行数据处理;每个系统都有自己的验证规则,当然也可能有一些常规性的规则,比如身份证长度和组成,电话号码长度和组成等等;

Q:接口加签对客户端是不是不适用,客户端能直接获取加签方式,也能知道appSecret,这种加签是不是只有服务端之间调用才能保证安全性?

3. 签名生成方式

签名包含内容:请求方法,请求地址,请求头,请求参数,请求Body

(1)系统参数一般设置在请求头中,如时间戳、待加签的请求头;

(2)body内容需要MD5 + Base64(body为空跳过),然后添加换行符;

  (3)  请求头,为了防止部分web容器请求头有大小写区分,因此请求头key统一改成小写。每个请求头后都需要添加换行符;

(3)请求参数需要排序,按照字母升序,这里有一个细节,就是使用了TreeMap,默认会按照key升序排(http://127.0.0.1/test?a=1&b=2&c=3);

(3)最终签名内容组合的字符串需要MD5 + Base64;

Q:MD5 + Base64组合有什么用?

A:方便表示和存储。

4. 实现

package priv.whh.std.boot.api.sign.strategy;

import com.alibaba.fastjson.JSON;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.io.IOUtils;
import org.apache.logging.log4j.util.Strings;
import org.springframework.http.HttpMethod;
import org.springframework.util.StringUtils;
import priv.whh.std.boot.api.sign.caller.AbstractRemoteCaller;
import priv.whh.std.boot.api.sign.property.ApiSignProperties;
import priv.whh.std.boot.api.sign.wrapper.RequestWrapper;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.http.HttpServletRequest;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;

@Slf4j
public class DefaultSignStrategy extends AbstractSignStrategy {
    private static final String HMAC_SHA256 = "HmacSHA256";
    private static final String MD5 = "MD5";

    private static final String COMMA = ",";
    private static final String COLON = ":";
    private static final String QUESTION_MARK = "?";
    private static final String EQUAL_SIGN = "=";
    private static final String AMPERSAND = "&";
    private static final char LF = '\n';

    private final ApiSignProperties apiSignProperties;
    private final AbstractRemoteCaller remoteCaller;

    public DefaultSignStrategy(ApiSignProperties apiSignProperties,
                               AbstractRemoteCaller remoteCaller) {
        this.apiSignProperties = apiSignProperties;
        this.remoteCaller = remoteCaller;
    }

    @Override
    public Boolean verify(HttpServletRequest request) throws Exception {
        if (apiSignProperties.getAnonymous()) {
            // 签名关闭
            return Boolean.TRUE;
        }
        String method = request.getMethod();
        String uri = request.getRequestURI();
        // 构建待加签的请求头
        Map<String, String> headersMap = new HashMap<>(8);
        for (String headerKey : apiSignProperties.getHeadersToSign().split(COMMA)) {
            headersMap.get(apiSignProperties.getHttpHeaderToLowerCase() ? headerKey.toLowerCase() : headerKey);
        }
        // 根据appId查找appSecret
        headersMap.put(apiSignProperties.getSecretHeaderKey(), apiSignProperties.getAppSecret());
        // 构建待加签的请求参数
        Map<String, Object> paramsMap = new HashMap<>(8);
        String queryString = request.getQueryString();
        if (!Strings.isEmpty(queryString)) {
            for (String param : queryString.split(AMPERSAND)) {
                String[] split = param.split(EQUAL_SIGN);
                if (split.length < 2) {
                    continue;
                }
                paramsMap.put(split[0], split[1]);
            }
        }
        // 构建待加签的body
        String body = ((RequestWrapper) request).getBody();
        String signHeader = request.getHeader(apiSignProperties.getSignHeaderKey());
        String sign = sign(uri, method, headersMap, paramsMap, body.getBytes(StandardCharsets.UTF_8));
        if (!Objects.equals(sign, signHeader)) {
            log.warn("Failed to compare sign by sign: {}, signHeader: {}", sign, signHeader);
            throw new Exception("Sign is not match");
        }

        // 超时判断
        String startTime = request.getHeader(apiSignProperties.getTimestampHeaderKey());
        long endTime = System.currentTimeMillis();
        if (Objects.isNull(startTime)
                || endTime - Long.parseLong(startTime) < apiSignProperties.getTimeout()) {
            log.warn("Failed to compare sign because it is time out, startTime: {}, endTime: {}", startTime, endTime);
            throw new Exception("Time out");
        }
        return Boolean.TRUE;
    }

    @Override
    public <T> T call(String uri, HttpMethod method, Map<String, Object> paramsMap, Object requestBody,
                      Class<T> responseType) throws Exception {
        Map<String, String> headersMap = new HashMap<>(8);
        headersMap.put(apiSignProperties.getHeadersToSign(), apiSignProperties.getHeadersToSign());
        String sign = sign(uri, method.toString(), headersMap, paramsMap, JSON.toJSONString(requestBody)
                .getBytes(StandardCharsets.UTF_8));
        headersMap.put(apiSignProperties.getSignHeaderKey(), sign);
        return remoteCaller.call(uri, method, headersMap, paramsMap, requestBody, responseType);
    }

    /**
     * 计算HTTP请求签名
     *
     * @param uri        原始HTTP请求PATH(不包含Query)
     * @param method     原始HTTP请求方法
     * @param headersMap 原始HTTP请求所有请求头
     * @param paramsMap  原始HTTP请求所有Query+Form参数
     * @param bodyBytes  原始HTTP请求Body体(仅当请求为POST/PUT且非表单请求才需要设置此属性,表单形式的需要将参数放到paramsMap中)
     * @return 签名结果
     * @throws Exception 异常
     */
    public String sign(String uri, String method, Map<String, String> headersMap,
                       Map<String, Object> paramsMap, byte[] bodyBytes) throws Exception {

        Map<String, String> headersToSign = buildHeadersToSign(headersMap);
        String bodyMd5 = buildBodyMd5(method, bodyBytes);
        String resourceToSign = buildResource(uri, paramsMap);
        // 加签:MD5 + base64
        String stringToSign = buildStringToSign(headersToSign, resourceToSign, method, bodyMd5);
        Mac hmacSha256 = Mac.getInstance(HMAC_SHA256);
        String secret = apiSignProperties.getAppSecret();
        byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
        hmacSha256.init(new SecretKeySpec(keyBytes, 0, keyBytes.length, HMAC_SHA256));
        return new String(Base64.encodeBase64(hmacSha256.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8))),
                StandardCharsets.UTF_8);
    }

    /**
     * 构建BodyMd5
     *
     * @param httpMethod       HTTP请求方法
     * @param inputStreamBytes HTTP请求Body体字节数组
     * @return Body Md5值
     */
    private static String buildBodyMd5(String httpMethod, byte[] inputStreamBytes) throws IOException {
        if (inputStreamBytes == null) {
            return null;
        }

        if (!httpMethod.equalsIgnoreCase(HttpMethod.POST.toString())
                && !httpMethod.equalsIgnoreCase(HttpMethod.PUT.toString())) {
            return null;
        }

        InputStream inputStream = new ByteArrayInputStream(inputStreamBytes);
        byte[] bodyBytes = IOUtils.toByteArray(inputStream);
        if (bodyBytes != null && bodyBytes.length > 0) {
            return base64AndMD5(bodyBytes).trim();
        }
        return null;
    }

    /**
     * 将Map转换为用&及=拼接的字符串
     */
    @SuppressWarnings("all")
    private static String buildMapToSign(Map<String, Object> paramMap) {
        StringBuilder builder = new StringBuilder();

        for (Map.Entry<String, Object> e : paramMap.entrySet()) {
            if (builder.length() > 0) {
                builder.append(AMPERSAND);
            }

            String key = e.getKey();
            Object value = e.getValue();

            if (value != null) {
                if (value instanceof List) {
                    List list = (List) value;
                    if (list.size() == 0) {
                        builder.append(key);
                    } else {
                        builder.append(key).append(EQUAL_SIGN).append(list.get(0));
                    }
                } else if (value instanceof Object[]) {
                    Object[] objs = (Object[]) value;
                    if (objs.length == 0) {
                        builder.append(key);
                    } else {
                        builder.append(key).append(EQUAL_SIGN).append(objs[0]);
                    }
                } else {
                    builder.append(key).append(EQUAL_SIGN).append(value);
                }
            }
        }

        return builder.toString();
    }

    /**
     * 构建参与签名的HTTP头
     * <pre>
     * 传入的Headers必须将默认的ISO-8859-1转换为UTF-8以支持中文
     * </pre>
     *
     * @param headers HTTP请求头
     * @return 所有参与签名计算的HTTP请求头
     */
    private Map<String, String> buildHeadersToSign(Map<String, String> headers) {
        Map<String, String> headersToSignMap = new TreeMap<>();
        String headersToSignString = apiSignProperties.getHeadersToSign();
        for (String headerKey : headersToSignString.split(COMMA)) {
            headersToSignMap.put(headerKey, headers.get(apiSignProperties.getHttpHeaderToLowerCase()
                    ? headerKey.toLowerCase() : headerKey));
        }
        return headersToSignMap;
    }

    /**
     * 组织待计算签名字符串
     *
     * @param headers        HTTP请求头
     * @param resourceToSign Uri+请求参数的签名字符串
     * @param method         HTTP方法
     * @param bodyMd5        Body Md5值
     * @return 待计算签名字符串
     */
    private static String buildStringToSign(Map<String, String> headers, String resourceToSign, String method, String bodyMd5) {
        StringBuilder sb = new StringBuilder();
        sb.append(method).append(LF);
        if (StringUtils.isEmpty(bodyMd5)) {
            sb.append(bodyMd5);
        }
        sb.append(LF);
        sb.append(buildHeaders(headers));
        sb.append(resourceToSign);

        return sb.toString();
    }

    /**
     * 组织Headers签名签名字符串
     *
     * @param headers HTTP请求头
     * @return Headers签名签名字符串
     */
    private static String buildHeaders(Map<String, String> headers) {
        StringBuilder sb = new StringBuilder();
        for (Map.Entry<String, String> e : headers.entrySet()) {
            if (e.getValue() != null) {
                sb.append(e.getKey().toLowerCase()).append(COLON).append(e.getValue()).append(LF);
            }
        }
        return sb.toString();
    }

    /**
     * 组织Uri+请求参数的签名字符串
     *
     * @param uri       HTTP请求uri,不包含Query
     * @param paramsMap HTTP请求所有参数(Query+Form参数)
     * @return Uri+请求参数的签名字符串
     */
    private static String buildResource(String uri, Map<String, Object> paramsMap) {
        StringBuilder builder = new StringBuilder();

        // uri
        builder.append(uri);

        // Query+Form
        Map<String, Object> sortMap = new TreeMap<>(paramsMap);


        // 有Query+Form参数
        if (sortMap.size() > 0) {
            builder.append(QUESTION_MARK);
            builder.append(buildMapToSign(sortMap));
        }

        return builder.toString();
    }

    /**
     * 先进行MD5摘要再进行Base64编码获取摘要字符串
     *
     * @param bytes 待计算字节数组
     * @return 密文
     */
    private static String base64AndMD5(byte[] bytes) {
        if (bytes == null) {
            throw new IllegalArgumentException("Bytes can not be null");
        }

        try {
            final MessageDigest md = MessageDigest.getInstance(MD5);
            md.reset();
            md.update(bytes);
            final Base64 base64 = new Base64();

            return new String(base64.encode(md.digest()), StandardCharsets.UTF_8);
        } catch (final NoSuchAlgorithmException e) {
            throw new IllegalArgumentException("Unknown algorithm MD5");
        }
    }
}

5. FAQ

Q:限流、黑白名单如何做?

参考资料:

如何设计一个安全的对外接口?

【接口安全】接口合法性验证加密验签SIGN 签名规则

springboot 统一签名校验的实现过程

阿里网关签名Demo

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值