WebAPI公開接口請求籤名驗證

前言

現在的系統後端開發的時候,會公開很多API接口
對於要登錄認證後才能訪問的接口,這樣的請求驗證就由身份認證模塊完成
但是也有些接口是對外公開的,沒有身份認證的接口
我們怎麼保證接口的請求是合法的,有效的.
這樣我們一般就是對請求的合法性做簽名驗證.

實現原理

爲保證接口安全,每次請求必帶以下header

| header名 | 類型 | 描述 |
| AppId | string | 應用Id |
| Ticks | string | 時間戳爲1970年1月1日到現在時間的毫秒數(UTC時間) |
| RequestId | string | GUID字符串,作爲請求唯一標誌,防止重複請求 |
| Sign| string | 簽名,簽名算法如下 |

  1. 拼接字符串"{AppId}{Ticks}{RequestId}{AppSecret}"
  2. 把拼接後的字符串計算MD5值,此MD5值爲請求Header的Sign參數傳入
  3. 後端把對應APP配置好(AppId,AppSecret),並提供給客戶端

後端驗證實現

驗證AppId

  1. 先驗證AppId是不是有,沒有就直接返回失敗
  2. 如果有的話,就去緩存裏取AppID對應的配置(如果緩存裏沒有,就去配置文件裏取)
  3. 如果沒有對應AppId的配置,說明不是正確的請求,返回失敗
        model.AppId = context.Request.Headers["AppId"];
        if (String.IsNullOrEmpty(model.AppId))
        {
            await this.ResponseValidFailedAsync(context, 501);
            return;
        }
        var cacheSvc = context.RequestServices.GetRequiredService<IMemoryCache>();
        var cacheAppIdKey = $"RequestValidSign:APPID:{model.AppId}";
        var curConfig = cacheSvc.GetOrCreate<AppConfigModel>(cacheAppIdKey, (e) =>
        {
            e.SlidingExpiration = TimeSpan.FromHours(1);
            var configuration = context.RequestServices.GetRequiredService<IConfiguration>();
            var listAppConfig = configuration.GetSection(AppConfigModel.ConfigSectionKey).Get<AppConfigModel[]>();
            return listAppConfig.SingleOrDefault(x => x.AppId == model.AppId);
        });
        if (curConfig == null)
        {
            await this.ResponseValidFailedAsync(context, 502);
            return;
        }

驗證時間戳

  1. 驗證時間戳是不是有在請求頭裏傳過來,沒有就返回失敗
  2. 驗證時間戳與當前時間比較,如果不在過期時間(5分鐘)之內的請求,就返回失敗
  3. 時間戳爲1970年1月1日到現在時間的毫秒數(UTC時間)
            var ticksString = context.Request.Headers["Ticks"].ToString();
            if (String.IsNullOrEmpty(ticksString))
            {
                await this.ResponseValidFailedAsync(context, 503);
                return;
            }
            model.Ticks = long.Parse(context.Request.Headers["Ticks"].ToString());
            var diffTime = DateTime.UtcNow - (new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddMilliseconds(model.Ticks));
            var expirTime = TimeSpan.FromSeconds(300);//過期時間
            if (diffTime > expirTime)
            {
                await this.ResponseValidFailedAsync(context, 504);
                return;
            }

驗證請求ID

  1. 驗證請求ID是不是有在請求頭裏傳過來,沒有就返回失敗
  2. 驗證請求ID是不是已經在緩存裏存在,如果存在就表示重複請求,那麼就返回失敗
  3. 如果請求ID在緩存中不存在,那麼就表示正常的請求,同時把請求ID添加到緩存
            model.RequestId = context.Request.Headers["RequestId"];
            if (String.IsNullOrEmpty(model.RequestId))
            {
                await this.ResponseValidFailedAsync(context, 505);
                return;
            }
            var cacheKey = $"RequestValidSign:RequestId:{model.AppId}:{model.RequestId}";
            if (cacheSvc.TryGetValue(cacheKey, out _))
            {
                await this.ResponseValidFailedAsync(context, 506);
                return;
            }
            else
                cacheSvc.Set(cacheKey, model.RequestId, expirTime);

驗證簽名

1.驗證簽名是否正常
2.簽名字符串是$"{AppId}{Ticks}{RequestId}{AppSecret}"組成
3.然後把簽名字符串做MD5,再與請求傳過來的Sign簽名對比
4.如果一至就表示正常請求,請求通過。如果不一至,返回失敗

    public bool Valid()
    {
        var validStr = $"{AppId}{Ticks}{RequestId}{AppSecret}";
        return validStr.ToMD5String() == Sign;
    }

            model.Sign = context.Request.Headers["Sign"];
            if (!model.Valid())
            {
                await this.ResponseValidFailedAsync(context, 507);
                return;
            }

源代碼

我們把所有代碼寫成一個Asp.Net Core的中間件

/// <summary>
/// 請求籤名驗證
/// </summary>
public class RequestValidSignMiddleware
{
    private readonly RequestDelegate _next;

    public RequestValidSignMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var model = new RequestValidSignModel();
        //1.先驗證AppId是不是有,沒有就直接返回失敗
        //2.如果有的話,就去緩存裏取AppID對應的配置(如果緩存裏沒有,就去配置文件裏取)
        //3.如果沒有對應AppId的配置,說明不是正確的請求,返回失敗
        model.AppId = context.Request.Headers["AppId"];
        if (String.IsNullOrEmpty(model.AppId))
        {
            await this.ResponseValidFailedAsync(context, 501);
            return;
        }
        var cacheSvc = context.RequestServices.GetRequiredService<IMemoryCache>();
        var cacheAppIdKey = $"RequestValidSign:APPID:{model.AppId}";
        var curConfig = cacheSvc.GetOrCreate<AppConfigModel>(cacheAppIdKey, (e) =>
        {
            e.SlidingExpiration = TimeSpan.FromHours(1);
            var configuration = context.RequestServices.GetRequiredService<IConfiguration>();
            var listAppConfig = configuration.GetSection(AppConfigModel.ConfigSectionKey).Get<AppConfigModel[]>();
            return listAppConfig.SingleOrDefault(x => x.AppId == model.AppId);
        });
        if (curConfig == null)
        {
            await this.ResponseValidFailedAsync(context, 502);
            return;
        }
        //1.把緩存/配置裏面的APP配置取出來,拿到AppSecret
        //2.如果請求裏附帶了AppSecret(調試用),那麼就只驗證AppSecret是否正確
        //3.傳過來的AppSecret必需是Base64編碼後的
        //4.然後比對傳過來的AppSecret是否與配置的AppSecret一至,如果一至就通過,不一至就返回失敗

        //5.如果請求裏沒有附帶AppSecret,那麼走其它驗證邏輯.
        model.AppSecret = curConfig.AppSecret;
        var headerSecret = context.Request.Headers["AppSecret"].ToString();
        if (!String.IsNullOrEmpty(headerSecret))
        {
            var secretBuffer = new byte[1024];
            var secretIsBase64 = Convert.TryFromBase64String(headerSecret, new Span<byte>(secretBuffer), out var bytesWritten);
            if (secretIsBase64 && Encoding.UTF8.GetString(secretBuffer, 0, bytesWritten) == curConfig.AppSecret)
                await _next(context);
            else
            {
                await this.ResponseValidFailedAsync(context, 508);
                return;
            }
        }
        else
        {
            //1.驗證時間戳是不是有在請求頭裏傳過來,沒有就返回失敗
            //2.驗證時間戳與當前時間比較,如果不在過期時間(5分鐘)之內的請求,就返回失敗
            //時間戳爲1970年1月1日到現在時間的毫秒數(UTC時間)
            var ticksString = context.Request.Headers["Ticks"].ToString();
            if (String.IsNullOrEmpty(ticksString))
            {
                await this.ResponseValidFailedAsync(context, 503);
                return;
            }
            model.Ticks = long.Parse(context.Request.Headers["Ticks"].ToString());
            var diffTime = DateTime.UtcNow - (new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddMilliseconds(model.Ticks));
            var expirTime = TimeSpan.FromSeconds(300);//過期時間
            if (diffTime > expirTime)
            {
                await this.ResponseValidFailedAsync(context, 504);
                return;
            }
            //1.驗證請求ID是不是有在請求頭裏傳過來,沒有就返回失敗
            //2.驗證請求ID是不是已經在緩存裏存在,如果存在就表示重複請求,那麼就返回失敗
            //3.如果請求ID在緩存中不存在,那麼就表示正常的請求,同時把請求ID添加到緩存
            model.RequestId = context.Request.Headers["RequestId"];
            if (String.IsNullOrEmpty(model.RequestId))
            {
                await this.ResponseValidFailedAsync(context, 505);
                return;
            }
            var cacheKey = $"RequestValidSign:RequestId:{model.AppId}:{model.RequestId}";
            if (cacheSvc.TryGetValue(cacheKey, out _))
            {
                await this.ResponseValidFailedAsync(context, 506);
                return;
            }
            else
                cacheSvc.Set(cacheKey, model.RequestId, expirTime);
            //1.驗證簽名是否正常
            //2.簽名字符串是$"{AppId}{Ticks}{RequestId}{AppSecret}"組成
            //3.然後把簽名字符串做MD5,再與請求傳過來的Sign簽名對比
            //4.如果一至就表示正常請求,請求通過。如果不一至,返回失敗
            model.Sign = context.Request.Headers["Sign"];
            if (!model.Valid())
            {
                await this.ResponseValidFailedAsync(context, 507);
                return;
            }
            await _next(context);
        }
    }
    /// <summary>
    /// 返回驗證失敗
    /// </summary>
    /// <param name="context"></param>
    /// <param name="status"></param>
    /// <returns></returns>
    public async Task ResponseValidFailedAsync(HttpContext context, int status)
    {
        context.Response.StatusCode = 500;
        await context.Response.WriteAsJsonAsync(new ComResult() { Success = false, Status = status, Msg = "請求籤名驗證失敗" }, Extention.DefaultJsonSerializerOptions, context.RequestAborted);
    }
}
public class AppConfigModel
{
    public const string ConfigSectionKey = "AppConfig";
    /// <summary>
    /// 應用Id
    /// </summary>
    public string AppId { get; set; }
    /// <summary>
    /// 應用密鑰
    /// </summary>
    public string AppSecret { get; set; }
}
public class RequestValidSignModel : AppConfigModel
{
    /// <summary>
    /// 前端時間戳
    /// Date.now()
    /// 1970 年 1 月 1 日 00:00:00 (UTC) 到當前時間的毫秒數
    /// </summary>
    public long Ticks { get; set; }
    /// <summary>
    /// 請求ID
    /// </summary>
    public string RequestId { get; set; }
    /// <summary>
    /// 簽名
    /// </summary>
    public string Sign { get; set; }
    public bool Valid()
    {
        var validStr = $"{AppId}{Ticks}{RequestId}{AppSecret}";
        return validStr.ToMD5String() == Sign;
    }
}

中間件註冊擴展

寫一箇中間件的擴展,這樣我們在Program裏可以方便的使用/停用中間件

/// <summary>
/// 中間件註冊擴展
/// </summary>
public static class RequestValidSignMiddlewareExtensions
{
    public static IApplicationBuilder UseRequestValidSign(this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<RequestValidSignMiddleware>();
    }
}

///Program.cs
app.UseRequestValidSign();

與Swagger結合

我們一般對外提供在線的Swagger文檔
如果我們增加了請求驗證的Header,那麼所有接口文檔裏面都要把驗證的Header添加到在線文檔裏面

/// <summary>
/// 請求籤名驗證添加Swagger請求頭
/// </summary>
public class RequestValidSignSwaggerOperationFilter : IOperationFilter
{
    public void Apply(OpenApiOperation operation, OperationFilterContext context)
    {
        if (operation.Parameters == null)
            operation.Parameters = new List<OpenApiParameter>();

        operation.Parameters.Add(new OpenApiParameter
        {
            Name = "AppId",
            In = ParameterLocation.Header,
            Required = true,
            Description = "應用ID",
            Schema = new OpenApiSchema
            {
                Type = "string"
            }
        });
        operation.Parameters.Add(new OpenApiParameter
        {
            Name = "Ticks",
            In = ParameterLocation.Header,
            Required = true,
            Description = "時間戳",
            Example = new OpenApiString(((long)(DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds).ToString()),
            Schema = new OpenApiSchema
            {
                Type = "string"
            }
        });
        operation.Parameters.Add(new OpenApiParameter
        {
            Name = "RequestId",
            In = ParameterLocation.Header,
            Required = true,
            Description = "請求ID",
            Example = new OpenApiString(Guid.NewGuid().ToString()),
            Schema = new OpenApiSchema
            {
                Type = "string"
            }
        });
        operation.Parameters.Add(new OpenApiParameter
        {
            Name = "Sign",
            In = ParameterLocation.Header,
            Required = true,
            Description = "請求籤名",
            //{AppId}{Ticks}{RequestId}{AppSecret}
            Example = new OpenApiString("MD5({AppId}{Ticks}{RequestId}{AppSecret})"),
            Schema = new OpenApiSchema
            {
                Type = "string"
            }
        });
        operation.Parameters.Add(new OpenApiParameter
        {
            Name = "AppSecret",
            In = ParameterLocation.Header,
            Description = "應用密鑰(調試用)",
            Example = new OpenApiString("BASE64({AppSecret})"),
            Schema = new OpenApiSchema
            {
                Type = "string"
            }
        });
    }
}

///在Program.cs裏添加Swagger請求驗證Header
builder.Services.AddSwaggerGen(c =>
{
    c.OperationFilter<RequestValidSignSwaggerOperationFilter>();
});

客戶端調用實現

我們如果用HttpClient調用的話,就要在調用請求前
設置後請求頭,AppId,Ticks,RequestId,Sign

        public async Task<string> GetIPAsync(CancellationToken token)
        {
            this.SetSignHeader();
            var result = await Client.GetStringAsync("/Get", token);
            return result;
        }
        public void SetSignHeader()
        {
            this.Client.DefaultRequestHeaders.Clear();
            var ticks = ((long)(DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds).ToString();
            var requestId = Guid.NewGuid().ToString();
            var signString = $"{this.Config.AppId}{ticks}{requestId}{this.Config.AppSecret}";
            var sign = this.GetMD5(signString);
            this.Client.DefaultRequestHeaders.Add("AppId", this.Config.AppId);
            this.Client.DefaultRequestHeaders.Add("Ticks", ticks);
            this.Client.DefaultRequestHeaders.Add("RequestId", requestId);
            this.Client.DefaultRequestHeaders.Add("Sign", sign);
        }
        public string GetMD5(string value)
        {
            using (MD5 md5 = MD5.Create())
            {
                byte[] inputBytes = Encoding.UTF8.GetBytes(value);
                byte[] hashBytes = md5.ComputeHash(inputBytes);

                StringBuilder sb = new StringBuilder();
                for (int i = 0; i < hashBytes.Length; i++)
                {
                    sb.Append(hashBytes[i].ToString("x2"));
                }
                return sb.ToString();
            }
        }

最終效果

當我們沒有傳簽名參數的時候,返回失敗

當我們把簽名參數都傳正確後,返回正確

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章