如今大多数应用采用前后端分离的形式,后端服务提供api接口,前端(APP、WEB等)调用接口,这样应用架构会比较灵活,同时也有一个问题就是api接口安全的问题。

一、api主要的要全问题

  • 授权认证:确认访问者的身份,控制应用接口访问,控制用户数据访问。
  • 网络攻击:防止Dos攻击,防止网络重放攻击
  • 数据安全:防止数据泄露、防止数据被截取、篡改、伪造(包括请求的数据和返回的结果数据)。

二、解决方案

  • 授权认证:可使用appid+appsecret签名认证、用户登录验证及JWT Token验证
  • 网络攻击:每个请求带上时间参数,进行过期判断,防止Dos攻击;带上一个随机数,可以防止网络重放攻击
  • 数据安全:使用https协议,或者使用其他加密方案

三、具体技术实现

1、接口appid、appsecret签名认证

所有接口请求都必须通过appid、appsecret签名验证。
pc端、web端、移动端、小程序、以及各第三方应用都当作是一个独立的应用,每个应用都会分配一个appid和appsecret,每个appid对应分配不同的接口,以实现接口的授权控制。
appid:应用id,用于标识是哪个应用,
appsecret:接口密钥,用于签名。密钥须保证安全,一般保存于服务端,不参与传输。由于web端无法保证appsecret的安全,所以web端必须通过JWT Token登录验证。
为了识别请求应用,以及防止请求参数被篡改伪造,所有请求参数必须通过签名认证,具体规则如下:
所有接口必须包含以下公共参数,为了防止公共参数和其他参数有冲突,可以加一个前缀,比如:lh

参数名 参数说明
lhappid 即:appid。用于标识引用
lhtime 当前时间戳(13位毫秒数)。用于期限限制等
lhnonce 请求随机数。用于防止网络重放攻击,每次请求必须重新赋值(一次性使用),建议使用uuid/guid
lhsign 签名。签名生成规则:
(1)所有请求参数(排除lhsign),根据参数key排序(升序)拼接成键值连接的字符串;
(2)最后把appsecret加在前面,得到待加密字符串;
(3)再进行md5加密转大写,得到签名。MD5(appsecret+请求参数字符串)转大写

签名说明

(1)假设:appid=111111,appsecret=eeeeee
(2)请求参数为:k=1&b=2&a=3&lhappid=111111&lhtime=1583069221120
(3)排序后为:a=3,b=2,k=1,lhappid=111111,lhtime=1583069221120
(4)然后将参数键值拼接得到参数字符串:a3b2k1lhappid111111lhtime1583069221120
(5)把appsecret加在头部,得到加密字符串:eeeeeea3b2k1lhappid111111lhtime1583069221120
(6)MD5加密并转大写,得到签名:464CD7E4075B8484FBE348689173CEF3
签名代码参考:

:::code-tabs#shell
@tab java

import java.security.MessageDigest;
import java.util.HashMap;
import java.util.Map;
import java.util.TreeMap;

/**
 * 签名生成工具类
 * 请求公共参数,为防止参数冲突以lh为前缀
 * lhappid     (公共参数)应用id,用于定位是哪个应用
 * lhtime     (公共参数)当前时间毫秒数。用于防止网络重放攻击,期限限制等
 * lhsign     (公共参数)签名
 */
public class SignUtil {
    /**
     * 签名生成规则
     * 1、所有请求参数(排除lhsign),根据参数key排序(升序)拼接成键值连接的字符串
     * 2、最后把appsecret加在前面,得到待加密字符串
     * 3、再进行md5加密转大写,得到签名
     *
     * @param params    请求参数map,带有lhappid,lhtime
     * @param appsecret app密钥
     * @return
     */
    public static String sign(Map<String, String> params, String appsecret) {
        //如果有,去掉签名字段
        if (params.containsKey("lhsign"))
            params.remove("lhsign");

        //根据参数Key排序(升序),TreeMap有排序功能
        TreeMap<String, String> treeMap = new TreeMap<>();
        treeMap.putAll(params);

        //构造待签名的请求串
        StringBuilder sb = new StringBuilder();
        //把密钥加载最前面
        sb.append(appsecret);
        for (Map.Entry<String, String> entry : treeMap.entrySet()) {
            sb.append(entry.getKey()).append(entry.getValue());
        }
        //生成md5值,转大写
        return md5(sb.toString()).toUpperCase();
    }

    /**
     * 验证签名
     *
     * @param params    参数
     * @param appsecret app密钥
     * @return
     */
    public static boolean verify(Map<String, String> params, String appsecret) {
        String sign = params.get("lhsign");
        String tmpsign = sign(params, appsecret);
        return sign.equals(tmpsign);
    }

    /**
     * 获取sha1值
     *
     * @param str
     * @return
     */
    public static String sha1(String str) {
        return encode("sha1", str);
    }

    /**
     * 计算并获取md5值
     *
     * @param str
     * @return
     */
    public static String md5(String str) {
        return encode("md5", str);
    }

    /**
     * 编码加密
     *
     * @param algorithm 加密算法
     * @param value     加密字符串
     * @return
     */
    private static String encode(String algorithm, String value) {
        if (value == null) {
            return null;
        }
        try {
            MessageDigest messageDigest = MessageDigest.getInstance(algorithm);
            messageDigest.update(value.getBytes());
            return getFormattedText(messageDigest.digest());
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * 转成16进制
     *
     * @param bytes
     * @return
     */
    private static String getFormattedText(byte[] bytes) {
        int len = bytes.length;
        StringBuilder buf = new StringBuilder(len * 2);
        for (int j = 0; j < len; j++) {
            buf.append(HEX_DIGITS[(bytes[j] >> 4) & 0x0f]);
            buf.append(HEX_DIGITS[bytes[j] & 0x0f]);
        }
        return buf.toString();
    }

    private static final char[] HEX_DIGITS = {'0', '1', '2', '3', '4', '5',
            '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};


    /**
     * 测试示例
     *
     * @param args
     */
    public static void main(String[] args) {
        String url = "http://localhost/webmvc/v2/patrol/findGpsrealList";
        String appid = "5e58bc4452a36d76ed455bc8";
        String appsecret = "56d6b50c7c49927ab4d92bdbea0f6b0ffa1ad70f";

        Map<String, String> params = new HashMap<>();
        params.put("projectid", "slfhtest");
        params.put("adcd", "530000000000000");
        params.put("gpstype", "0");
        params.put("next", "0");
        params.put("lhappid", appid);
        params.put("lhtime", String.valueOf(System.currentTimeMillis()));
        params.put("lhnonce", UUID.randomUUID().toString());
        //生成签名
        String sign = sign(params, appsecret);
        params.put("lhsign", sign);

        Map<String, Object> qarams = new HashMap<>(params);
        //发出http请求
        //String res= HttpUtil.post(url,qarams);

    }

}

@tab javascript

//MD5库:https://github.com/blueimp/JavaScript-MD5

/**生成签名
 * @param params    请求参数对象
 * @param appsecret 密钥
 * @returns {string}
 */
function sign(params,appsecret){
    //如果存在lhsign,去掉此属性
    if(params.hasOwnProperty("lhsign")){
        delete params.lhsign;
    }
    //key排序
    var keys=Object.keys(params).sort();
    var str=appsecret;
    //封装请求字符串
    for(var i=0;i<keys.length;i++){
        str=str+keys[i]+params[keys[i]];
    }
    //MD5加密并转大写
    return md5(str).toUpperCase();
}

/**
 *验证签名
 * @param params    请求参数对象
 * @param appsecret 密钥
 * @returns {boolean}
 */
function verify(params,appsecret){
    var sign=params["lhsign"];
    var tmpsign=sign(params,appsecret);
    return sign==tmpsign;
}

/**
 * 生成uuid
 * @returns {string}
 */
function uuid() {
    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
        var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8);
        return v.toString(16);
    });
}

//测试
var url="http://task.lonhcloud.cn/webmvc/v2/elementquery/findAdcdChildren"
var appid="5e58bc4452a36d76ed455bc8";
var appsecret="56d6b50c7c49927ab4d92bdbea0f6b0ffa1ad70f";
var params={
    lhappid:appid,
    lhtime:Date.now(),
    lhnonce:uuid(),
    adcd:'530000000000000',
}
var sign=sign(params,appsecret);
params["lhsign"]=sign;

//get请求
$.get(url,params,function(data,status,request){
    console.log(data);
});

@tab csharp

class SignUtil
{
    /// <summary>
    /// 生成签名
    /// </summary>
    /// <param name="dict">请求参数</param>
    /// <param name="appsecret">密钥</param>
    /// <returns></returns>
    public static String sign(Dictionary<String,String> dict,String appsecret)
    {
        if (dict.ContainsKey("lhsign"))
            dict.Remove("lhsign");
        SortedDictionary<String, String> sortDict = new SortedDictionary<string, string>(dict);
        StringBuilder sb = new StringBuilder();
        sb.Append(appsecret);
        foreach (var item in sortDict)
        {
            sb.Append(item.Key).Append(item.Value);
        }

        return md5(sb.ToString()).ToUpper();
    }
    /// <summary>
    /// 验证签名
    /// </summary>
    /// <param name="dict">请求参数</param>
    /// <param name="appsecret">密钥</param>
    /// <returns></returns>
    public static bool verify(Dictionary<String, String> dict, String appsecret)
    {
        String lhsign = dict["lhsign"];
        String tmpsign = sign(dict, appsecret);
        return lhsign.Equals(tmpsign);
    }
    /// <summary>
    /// md5加密
    /// </summary>
    /// <param name="str">加密字符串</param>
    /// <returns></returns>
    public static String md5(String str)
    {
        MD5 md5 = MD5.Create();

        Byte[] bytes=  md5.ComputeHash(Encoding.UTF8.GetBytes(str));
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < bytes.Length; i++)
        {
            sb.Append(bytes[i].ToString("x2"));
        }
        return sb.ToString();
    }

    /// <summary>
    /// 获取时间戳,13位,毫秒
    /// </summary>
    /// <returns></returns>
    public static string GetTimeStamps()
    {
        TimeSpan ts = DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, 0);
        return Convert.ToInt64(ts.TotalMilliseconds).ToString();
    }
    /// <summary>
    /// 测试示例
    /// </summary>
    public static void test()
    {
        String url = "http://task.lonhcloud.cn/webmvc/v2/elementquery/findAdcdChildren";
        String appid = "5e58bc4452a36d76ed455bc8";
        String appsecret = "56d6b50c7c49927ab4d92bdbea0f6b0ffa1ad70f";

        Dictionary<String,String> dict =new Dictionary<string, string>();

        dict["lhappid"]=appid;
        dict["lhtime"] = GetTimeStamps();
        dict["lhnonce"] = Guid.NewGuid().ToString();
        dict["adcd"] = "530000000000000";
        //生成签名
        String lhsign = sign(dict, appsecret);
        dict["lhsign"]= lhsign;

        //http请求
        //String res=HttpUtil.HttpPost(url, dict);

    }
}

:::

2、用户登录验证及JWT Token验证

对于web项目或者更加严格的安全验证时,可以加上一层身份登录验证,一般使用用户登录+JWT Token验证。规则如下:
(1)客户端调用登录接口,登录成功后,服务端会返回JWT Token,位于返回对象的header中的lhtoken字段, 即: token=response.header("lhtoken")
(2)之后客户端每次请求都要在请求对象的header中带上上一次请求得到的lhtoken,即:request.header("lhtoken",token)
(3)每个token都有一定的期限,一般为15分钟,请求没有token或者token过期,都将导致请求失败
(3)可将lhtoken在本地存成公共变量,客户端每次请求带上lhtoken,返回成功后更新本地lhtoken

JWT Token 参考:https://www.cnblogs.com/aaron911/p/11300062.html

3、数据安全加密

为了防止数据被拦截抓取,导致泄露,所有请求参数和返回结果都要进行加密。
经过考虑,数据加密使用RSA(非对称加密)和AES(对称加密)相结合的方案。

AES 对称加密:

(1)加密方和解密方适用同一个秘钥
(2)加密解密的速度快,适合数据比较长时使用
(3)秘钥传输过程不安全,而且秘钥管理也麻烦

RSA 非对称加密

(1)有一对密钥,公钥和私钥。公钥加密只能私钥解密,私钥加密只能公钥解密
(2)算法强度复杂,其安全性依赖于算法与秘钥
(3)加密解密的速度慢,不适用于数据量较大的情况

具体实现

(1)服务端生成RSA密钥对,保存私钥,公开公钥
(2)客户端获取服务端RSA公钥。每次请求,成一个AES的密钥,请求的数据拼成json字符串后使用AES加密,放到请求body里,AES的密钥使用RSA公钥加密,放到请求头的lhkey里,并使用POST发出请求。
(3)服务端接收数据后,先取出lhkey,使用RSA私钥解密出AES的密钥,再使用AES解密出请求数据的json字符串,此时解析json字符串就可以得到请求内容。
(4)服务端返回结果时,使用AES进行加密返回,客户端再进行AES解密,就可以得到明文数据

完成流程示意图

rsa_aes.png