Api 防重放设计

Api 防重放设计

简介

我们在设计接口的时候,最怕一个接口被用户截取用于重放攻击。重放攻击是什么呢?就是把你的请求原封不动地再发送一次,两次...n次,重放攻击是二次请求,黑客通过抓包获取到了请求的HTTP报文,然后黑客自己编写了一个类似的HTTP请求,发送给服务器。也就是说服务器处理了两个请求,先处理了正常的HTTP请求,然后又处理了黑客发送的篡改过的HTTP请求。

如果这个正常逻辑是插入数据库操作,那么一旦插入数据库的语句写的不好,就有可能出现多条重复的数据。一旦是比较慢的查询操作,就可能导致数据库堵住等情况。

实现流程

  1. 接受请求,判断头信息(timestamp、nonce、signature、key)是否都存在
  2. 判断时间戳timestamp是否已经大于过期时间(60s)
  3. 判断缓存中nonce是否存在,不存在则创建nonce
  4. 将请求数据+nonce+stamp+key使用md5加密,然后和signature比对,判断是否一致
  5. 任一条件不成立,则视为重放请求

使用 timestamp+nonce+signature

每个请求的请求头中,都需要携带timestamp+nonce+signature的信息,否则将验证不通过

timestamp

每次HTTP请求,都需要加上timestamp头信息。因为一次正常的HTTP请求,从发出到达服务器一般都不会超过60s,所以服务器收到HTTP请求之后,首先判断时间戳参数与当前时间相比较,是否超过了60s,如果超过了则认为是非法的请求。

    var requestTime = DateTimeHelper.StampToDateTime(headers["X-CA-TIMESTAMP"].ToString());
    if ((DateTime.Now - requestTime).Seconds >= 60)
        return false;

nonce

nonce是仅一次有效的随机字符串,要求每次请求时,该参数要保证不同,我们将每次请求的nonce参数存储到一个缓存中。 每次处理HTTP请求时,首先判断该请求的nonce参数是否在缓存中,如果存在则认为是非法请求。

    var nonce = cache.Get<string>($"nonce-{requestTime}");
    if (!string.IsNullOrEmpty(nonce) && nonce.Equals(headers["X-CA-NONCE"].ToString()))
        return false;
    cache.Set($"nonce-{requestTime}", nonce, TimeSpan.FromMinutes(10));

nonce参数在首次请求时,已经被存储到了服务器上的缓存中,再次发送请求会被识别并拒绝。
nonce参数作为数字签名的一部分,是无法篡改的,因为别人不清楚token,所以不能生成新的signature。

signature

signature 是将请求的数据、key等信息加密,然后和传入的值比对是否一致,来判断请求是否被篡改或者是重复的请求

创建signature流程

  1. 获取 QueryString 参数,然后将键值对加入到 Dictionary
  2. 获取 Body 中的参数,加入到Dictionary
  3. 然后将key加入到Dictionary
  4. 按照a~z排序
  5. Dictionary转化为keyvalue相连的字符串
  6. 使用MD5加密
    var requestData = new SortedDictionary<string, object>();
    var queryString = request.QueryString;
    if (queryString.HasValue)
    {
        var queryArray = queryString.Value.TrimStart('?').Split('&');
        foreach (var item in queryArray)
        {
            if (!item.Contains('=')) continue;
            var newArray = item.Split('=');
            if (string.IsNullOrEmpty(newArray[1]))
                continue;
            if (requestData.ContainsKey(newArray[0])) continue;
            requestData.Add(newArray[0], newArray[1]);
        }
    }
    requestData.Add("key", request.Headers["X-CA-Key"].ToString());
    requestData.Add("nonce", request.Headers["X-CA-NONCE"].ToString());
    requestData.Add("timestamp", request.Headers["X-CA-TIMESTAMP"].ToString());
    var body = await request.Body.GetStringAsync();
    if (string.IsNullOrEmpty(body)) return requestData;

    var dic = body.ToModel<SortedDictionary<string, object>>();
    foreach (var item in dic)
    {
        if (requestData.ContainsKey(item.Key)) continue;
        requestData.Add(item.Key, item.Value);
    }

转化为字符串

    string str = "";
    foreach (var item in requestData)
    {
        str += item.Key + item.Value;
    }

注意:要读取请求体Body中的内容,需要在中间件中开启 context.Request.EnableBuffering() 因为.net core在设计时,默认只能读取一次Body的内容,之后将读取不到Body中Stream中的内容,并且在都去前后,都需要将Stream中的Position置为0,否着也读取不到内容

做成过滤器的完整实现

    /// <summary>
    /// 防重放 过滤(验证请求)
    /// </summary>
    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
    public class AntiReplayFilter : ActionFilterAttribute
    {
        private IMemoryCache _cache;
        private ILogger<AntiReplayFilter> _logger;

        public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
        {
            var ignore = IgnoreVerify(context);
            if (!ignore)
            {
                var request = context.HttpContext.Request;
                _cache = context.HttpContext.RequestServices.GetService(typeof(IMemoryCache)) as IMemoryCache;
                _logger = context.HttpContext.RequestServices.GetService(typeof(ILogger<AntiReplayFilter>)) as ILogger<AntiReplayFilter>;

                var verifyHeader = AntiReplayHelper.VerifyHeader(request, _cache);
                if (!verifyHeader)
                {
                    ResponseHandle(context, "Incorrect request header");
                    return;
                }

                var dataDic = await AntiReplayHelper.GetRequestData(request);
                var dataStr = AntiReplayHelper.DicToString(dataDic);
                var encode = HttpUtility.UrlEncode(dataStr)?.ToLower().Replace("+","%20"); // URL Encode
                var sign = SecretHelper.Md5(encode);
                _logger.LogInformation($"request json data:{encode}, generate sign:{sign}, request sign:{request.Headers["X-CA-SIGNATURE"].ToString()}");
                if (!sign.Equals(request.Headers["X-CA-SIGNATURE"].ToString(), StringComparison.OrdinalIgnoreCase))
                {
                    ResponseHandle(context, "Incorrect signature");
                    return;
                }
            }

            await base.OnActionExecutionAsync(context, next);
        }

        private void ResponseHandle(ActionExecutingContext context, string msg)
        {
            var result = new
            {
                success = false,
                message = msg
            };
            context.Result = new BadRequestObjectResult(result);
        }

        private bool IgnoreVerify(ActionExecutingContext context)
        {
            var controllerActionDescriptor = context.ActionDescriptor as ControllerActionDescriptor;
            if (controllerActionDescriptor == null) return true;

            var ignore = controllerActionDescriptor.MethodInfo.GetCustomAttributes(true)
                .Any(p => p.GetType() == typeof(IgnoreAntiReplayAttribute));

            return ignore;
        }
    }

注意:需要将获取到的数据进行UrlEncode处理,Get请求中如果有中文参数之类的,在编码的时候,js和C#大写小不一致,且空格C#会处理成+号,这是一处暗坑

AntiReplayHelper 文件

    /// <summary>
    /// 防重放 辅助类
    /// </summary>
    internal class AntiReplayHelper
    {
        /// <summary>
        /// 验证请求头参数
        /// </summary>
        /// <param name="request"></param>
        /// <param name="cache"></param>
        /// <returns></returns>
        internal static bool VerifyHeader(HttpRequest request, IMemoryCache cache)
        {
            var headers = request.Headers;
            if (string.IsNullOrEmpty(headers["X-CA-Key"]))
                return false;

            if (string.IsNullOrEmpty(headers["X-CA-NONCE"]))
                return false;

            if (string.IsNullOrEmpty(headers["X-CA-SIGNATURE"]))
                return false;

            if (string.IsNullOrEmpty(headers["X-CA-TIMESTAMP"]))
                return false;

            var requestTime = DateTimeHelper.StampToDateTime(headers["X-CA-TIMESTAMP"].ToString());
            if ((DateTime.Now - requestTime).Minutes >= 1)
                return false;

            var nonce = cache.Get<string>($"nonce-{requestTime}");
            if (!string.IsNullOrEmpty(nonce) && nonce.Equals(headers["X-CA-NONCE"].ToString()))
                return false;
            cache.Set($"nonce-{requestTime}", nonce, TimeSpan.FromMinutes(5));

            return true;
        }

        /// <summary>
        /// 获取请求参数,并根据字母(a~z)排序
        /// </summary>
        /// <param name="request"></param>
        /// <returns></returns>
        internal static async Task<SortedDictionary<string, object>> GetRequestData(HttpRequest request)
        {
            var requestData = GetRequestQueryData(request);

            if (request.HasFormContentType)
            {
                GetRequestFormData(request, requestData);
            }
            else
            {
                await GetRequestBodyData(request, requestData);
            }
            
            return requestData;
        }

        /// <summary>
        /// 将Dictionary拼接成字符串
        /// </summary>
        /// <param name="data"></param>
        /// <returns></returns>
        internal static string DicToString(SortedDictionary<string, object> data)
        {
            string str = "";
            foreach (var item in data)
            {
                str += item.Key + item.Value;
            }

            return str;
        }

        #region Utils

        /// <summary>
        /// 获取 QueryString
        /// </summary>
        /// <param name="request"></param>
        /// <returns></returns>
        private static SortedDictionary<string, object> GetRequestQueryData(HttpRequest request)
        {
            var requestData = new SortedDictionary<string, object>();
            var queryString = request.QueryString;
            if (queryString.HasValue)
            {
                var queryArray = queryString.Value.TrimStart('?').Split('&');
                foreach (var item in queryArray)
                {
                    if (!item.Contains('=')) continue;
                    var newArray = item.Split('=');
                    if (string.IsNullOrEmpty(newArray[1]))
                        continue;
                    if (requestData.ContainsKey(newArray[0])) continue;
                    requestData.Add(newArray[0], newArray[1]);
                }
            }
            requestData.Add("key", request.Headers["X-CA-Key"].ToString());
            requestData.Add("nonce", request.Headers["X-CA-NONCE"].ToString());
            requestData.Add("timestamp", request.Headers["X-CA-TIMESTAMP"].ToString());
            return requestData;
        }

        /// <summary>
        /// 获取Request Body中的数据
        /// </summary>
        /// <param name="request"></param>
        /// <param name="requestData"></param>
        /// <returns></returns>
        private static async Task GetRequestBodyData(HttpRequest request, SortedDictionary<string, object> requestData)
        {
            try
            {
                // 请求流内容只能读取一次,配置 PostBodyHandlerMiddleware 中间件后可以多次读取,
                // 但Position必须为0才能读取到内容,读取完成之后,从新将 Position 设置为 0,便于之后的中间件读取或模型绑定
                if (request.Body.Position > 0) request.Body.Position = 0;
                var readerStream = new StreamReader(request.Body);
                var body = await readerStream.ReadToEndAsync();
                request.Body.Position = 0;

                if (string.IsNullOrEmpty(body)) return;

                var dic = body.ToModel<SortedDictionary<string, object>>();
                foreach (var item in dic)
                {
                    if (requestData.ContainsKey(item.Key)) continue;
                    requestData.Add(item.Key, item.Value);
                }
            }
            catch
            {
                throw new CustomException("Incorrect Request Body");
            }
        }

        /// <summary>
        /// 获取 Request Form中的数据
        /// </summary>
        /// <param name="request"></param>
        /// <param name="requestData"></param>
        private static void GetRequestFormData(HttpRequest request, SortedDictionary<string, object> requestData)
        {
            if (request.Form == null) return;
            foreach (var item in request.Form)
            {
                requestData.Add(item.Key, item.Value);
            }
        }

PostBodyHandlerMiddleware 中间件:

    /// <summary>
    /// 开启 重复读取 请求流内容
    /// </summary>
    public class PostBodyHandlerMiddleware
    {
        private readonly RequestDelegate _next;

        public PostBodyHandlerMiddleware(RequestDelegate next) => _next = next;

        public async Task Invoke(HttpContext context)
        {
            context.Request.EnableBuffering();
            await _next(context);
        }
    }

在Vue的axios请求中的实现

import md5 from 'js-md5'
//生成随机字符串(uuid)
const generateNonce = () => {
    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);
    });
}

// 生成时间戳
const generateStamp = () => {
    return Math.round(new Date() / 1000);
}

// 获取body中的数据,并去掉值为""和null的项
const getBody = (data) => {
    if (!data) return data;

    if (data instanceof FormData) {
        let entries = data.entries();
        let item = null;
        let formData = {};
        while(item = entries.next()){
            if(item.done) break;
            formData[item.value[0]] = item.value[1];
        }
        return formData;
    } 

    for (var key in data) {
        if (!data[key]) {
            delete data[key]
        }
    }

    return data;
}

// 获取url中的数据,并去掉值为""和null的项
const getQuery = (url) => {
    let data = {};
    if (url.indexOf('?') < 0) return data;
    let queryString = url.split('?')[1];
    let array = queryString.split('&');
    for (let i = 0; i < array.length; i++) {
        let newArr = array[i].split('=');
        if (!newArr[1]) continue;
        data[newArr[0]] = newArr[1];
    }
    return data;
}

//a-z排序,并化作string
const objectToString = (obj) => {
    let str = '';
    var newkey = Object.keys(obj).sort();
    for (var i = 0; i < newkey.length; i++) {
        str += newkey[i] + obj[newkey[i]];
    }
    return str;
}

// 生成签名
const generateSignature = (config, key, nonce, timestamp) => {
    let data = { key: key, nonce: nonce, timestamp: timestamp };
    let body = Object.assign(data, getBody(config.data), getBody(config.params), getQuery(config.url));
    let str = encodeURIComponent(objectToString(body)).toLowerCase();
    return md5(str);
}

const antiReplay = {
    nonce: () => generateNonce(),
    stamp: () => generateStamp(),
    signature: (config, key, nonce, timestamp) => generateSignature(config, key, nonce, timestamp)
}

export default antiReplay;

axios 拦截器中如下

//请求拦截器
instance.interceptors.request.use(
    config => {
        let key = "clientId";
        let nonce = antiReplay.nonce();
        let stamp = antiReplay.stamp();
        config.headers.common['X-CA-Key'] = key;
        config.headers.common['X-CA-NONCE'] = nonce;
        config.headers.common['X-CA-TIMESTAMP'] = stamp;
        config.headers.common['X-CA-SIGNATURE'] = antiReplay.signature(config, key, nonce, stamp);
        return config;
    },
    error => {
        console.log(error);
        return Promise.reject(error);
    })

示例

  1. 请求url: http://api.com?param_a=1&param_e=2&param_b=
  2. Method: post
  3. Body
{
    "param_z":"123",
    "param_d":"bbb",
    "param_c":""
}
  1. key、nonce、timestamp
key=f76fa058-c89b-47ba-a833-7a818ebe00c6
nonce=d89a8784-d04b-46c1-b9c3-7afee74a20bd
timestamp=1589089870
  1. 计算 signature
转化为字符串为:keyf76fa058-c89b-47ba-a833-7a818ebe00c6nonced89a8784-d04b-46c1-b9c3-7afee74a20bdparam_a1param_dbbbparam_e2param_z123timestamp1589089870
通过md5计算得到的signature为:09b2e54a923a0b259b31addfae90ca8a