.Net Web API 引數驗籤

chenxizhaolu發表於2024-10-25

前言

  在開放的api介面中,我們透過http Post或者Get請求伺服器的時候,會面臨著許多的安全性問題。為了保證資料在通訊時的安全性,我們可以採用TOKEN+引數簽名的方式來進行相關驗證。

  •   Token(本文使用jwt)來保證訪問介面的使用者身份合法。這種場景主要用於前端(網頁、移動端)的介面訪問身份驗證方式。前面一篇已經做了介紹:.net core web api授權、鑑權、API保護 - chenxizhaolu - 部落格園
  •   用Sign引數簽名的方式來防止被篡改和請求的唯一性,是本篇介紹的重點。這種場景主要使用者給第三方提供API介面的身份驗證。

  本文所有的實現程式碼可以參考:https://gitee.com/xiaoqingyao/web-app-identity.git

工作流程說明

  1、作為服務的提供方,會在達成合作後為第三方提供一個AppId和AppSecret。

  2、第三方透過這AppId和AppSecret請求某web介面拿到動態token。

  3、第三方在請求其他業務介面時用AppId、時間戳、業務引數key和value按照一定規則拼裝字串、隨機數、token 拼接字串後計算md5值作為signKey,signKey跟appid、時間戳、隨機數一起放到Header中。(注:Token不傳)

  3、伺服器在收到請求時,按照客戶端相同的規則計算SignKey,與客戶端Header傳來的值做比對,如果一致則認為驗籤透過。

  接下來每一步驟進行詳細說明並提供關鍵程式碼。

1、AppId和AppSecret的維護

  這個就很簡單了,就維護一個資料庫表進行維護AppId和AppSecret。新增一個合作伙伴的時候新增一條記錄即可。

2、獲取動態Token

/// <summary>
/// 透過appkey與appSecret獲取token
/// </summary>
/// <param name="thirdClient"></param>
/// <returns></returns>
[HttpPost("GetToken")]
[AllowAnonymous]
public async Task<string> GetToken(ThirdClient thirdClient)
{
    return await _thirdClientService.GetToken(thirdClient);
}
public async Task<string> GetToken(ThirdClient thirdClient)
{
    var cacheKey = "thirdClient_" + thirdClient.AppKey;
    if (_memoryCache.TryGetValue(cacheKey, out var cacheToken))
    {
        return cacheToken?.ToString() ?? "";
    }
    var exist = await _appDbContext.ThirdClients.AnyAsync(p => p.AppKey == thirdClient.AppKey && p.AppSecret == thirdClient.AppSecret);
    if (!exist)
    {
        return string.Empty;
    }
    var token = Guid.NewGuid().ToString().Replace("-", "").ToUpper();
    _memoryCache.Set(cacheKey, token, TimeSpan.FromMinutes(10));
    return token;
}

3、客戶端請求及新增引數簽名、

internal class Program
{
    static async Task Main(string[] args)
    {
        var token = await GetToken();
        var getResult = await Get(token);
        Console.WriteLine("get請求結果:" + getResult);
        Thread.Sleep(1000);
        var postResult = await Post(token);
        Console.WriteLine("post請求結果:" + postResult);
        Thread.Sleep(1000);
        getResult = await Get(token);
        Console.WriteLine("get請求結果:" + getResult);
        Thread.Sleep(1000);
        postResult = await Post(token);
        Console.WriteLine("post請求結果:" + postResult);
        Console.WriteLine("Hello, World!");
    }

    static async Task<string> Post(string token)
    {
        HttpClient client = new HttpClient();
        var nonce = new Random().Next().ToString();
        var appKey = "etcp";
        var timeStamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); // Unix 時間戳(秒)

        client.DefaultRequestHeaders.Add("AppKey", appKey);
        client.DefaultRequestHeaders.Add("TimeStamp", timeStamp.ToString());
        client.DefaultRequestHeaders.Add("Nonce", nonce);

        IDictionary<string, string> sortedParams = new SortedDictionary<string, string>
        {
            { "id", "1" },
            { "name", "張三" }
        };

        IEnumerator<KeyValuePair<string, string>> dem = sortedParams.GetEnumerator();
        StringBuilder query = new StringBuilder();
        while (dem.MoveNext())
        {
            string key = dem.Current.Key;
            string value = dem.Current.Value;
            if (!string.IsNullOrEmpty(key))
            {
                query.Append(key).Append(value);
            }
        }
        string requestDataStr = query.ToString();

        var sign = Md5Helper.ComputeMd5Hash(timeStamp.ToString() + nonce.ToString() + appKey.ToString()
            + token.ToString() + requestDataStr);
        client.DefaultRequestHeaders.Add("Sign", sign);

        var jsonContent = JsonSerializer.Serialize(sortedParams);
        var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");

        var response = await client.PostAsync($"http://localhost:5203/SignProtectedAPI/PostApi", content);
        // 確保 HTTP 響應狀態是成功的
        response.EnsureSuccessStatusCode();
        // 讀取響應內容
        var responseBody = await response.Content.ReadAsStringAsync();
        return responseBody;

    }

    static async Task<string> Get(string token)
    {
        HttpClient client = new HttpClient();
        var nonce = 5;
        var appKey = "etcp";
        var timeStamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); // Unix 時間戳(秒)

        client.DefaultRequestHeaders.Add("AppKey", appKey);
        client.DefaultRequestHeaders.Add("TimeStamp", timeStamp.ToString());
        client.DefaultRequestHeaders.Add("Nonce", nonce.ToString());

        IDictionary<string, string> sortedParams = new SortedDictionary<string, string>
        {
            { "id", "1" },
            { "name", "張三" }
        };

        IEnumerator<KeyValuePair<string, string>> dem = sortedParams.GetEnumerator();
        StringBuilder query = new StringBuilder();
        while (dem.MoveNext())
        {
            string key = dem.Current.Key;
            string value = dem.Current.Value;
            if (!string.IsNullOrEmpty(key))
            {
                query.Append(key).Append(value);
            }
        }
        string requestDataStr = query.ToString();

        var sign = Md5Helper.ComputeMd5Hash(timeStamp.ToString() + nonce.ToString() + appKey.ToString()
            + token.ToString() + requestDataStr);
        client.DefaultRequestHeaders.Add("Sign", sign);

        var response = await client.GetAsync($"http://localhost:5203/SignProtectedAPI/List?{BuildQueryString(sortedParams.ToDictionary())}");
        // 確保 HTTP 響應狀態是成功的
        response.EnsureSuccessStatusCode();
        // 讀取響應內容
        var responseBody = await response.Content.ReadAsStringAsync();
        return responseBody;

    }

    private static string BuildQueryString(Dictionary<string, string> parameters)
    {
        return string.Join("&", parameters.Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}"));
    }

    static async Task<string> GetToken()
    {
        HttpClient client = new HttpClient();
        //獲取token
        var jsonContent = "{\"appKey\":\"etcp\", \"appSecret\":\"34853399624948488e1b3cb63ea5e578\"}"; // key 與 secret提前提供
        var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
        var response = await client.PostAsync("http://localhost:5203/ThirdClient/GetToken", content);

        // 確保 HTTP 響應狀態是成功的
        response.EnsureSuccessStatusCode();
        var token = await response.Content.ReadAsStringAsync();
        return token;
    }
}

4、伺服器端驗證

  新增並註冊一箇中介軟體SignValidateMiddleware。

public class SignValidateMiddleware
{
    private readonly RequestDelegate _next;

    private readonly IServiceScopeFactory _scopeFactory;

    private readonly IMemoryCache _memoryCache;

    public SignValidateMiddleware(RequestDelegate next, IServiceScopeFactory scopeFactory, IMemoryCache memoryCache1)
    {
        _next = next;
        _scopeFactory = scopeFactory;
        _memoryCache = memoryCache1;
    }

    public async Task InvokeAsync(HttpContext context,IServiceProvider serviceProvider)
    {
        var requestPath = context.Request.Path;
        var exceptUrls = new string[] { "/User/", "/WeatherForecast/", "/swagger/", "/ThirdClient/" };
        if (requestPath.HasValue && exceptUrls.Any(p => requestPath.Value.IndexOf(p) > -1))
        {
            await _next(context);
            return;
        }
        
        if (!context.Request.Headers.TryGetValue("AppKey", out var appKey))
        {
            context.Response.StatusCode = StatusCodes.Status406NotAcceptable;
            await context.Response.WriteAsync("驗籤不透過-appKey");
            return;
        }
        if (!context.Request.Headers.TryGetValue("TimeStamp", out var timeStamp) || string.IsNullOrEmpty(timeStamp))
        {
            context.Response.StatusCode = StatusCodes.Status406NotAcceptable;
            await context.Response.WriteAsync("驗籤不透過-TimeStamp");
            return;
        }
        if (!context.Request.Headers.TryGetValue("Nonce", out var nonce))
        {
            context.Response.StatusCode = StatusCodes.Status406NotAcceptable;
            await context.Response.WriteAsync("驗籤不透過-Nonce");
            return;
        }
        if (!context.Request.Headers.TryGetValue("Sign", out var sign))
        {
            context.Response.StatusCode = StatusCodes.Status406NotAcceptable;
            await context.Response.WriteAsync("驗籤不透過-Sign");
            return;
        }

        bool timespanvalidate = double.TryParse(timeStamp.ToString(), out double ts1);
        double ts2 = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        double ts = ts2 - ts1;
        bool flag = ts > 1000 * 30;//30秒內有效
        if (flag || !timespanvalidate)
        {
            context.Response.StatusCode = StatusCodes.Status406NotAcceptable;
            await context.Response.WriteAsync("驗籤不透過-timespanvalidate");
            return;
        }

        var cacheKey = "thirdClient_" + appKey;
        if (!_memoryCache.TryGetValue(cacheKey, out var token))
        {
            context.Response.StatusCode = StatusCodes.Status406NotAcceptable;
            await context.Response.WriteAsync("token驗證不透過,可能已經過期,請重新獲取token再試-token");
            return;
        }
        var _userContext = serviceProvider.GetRequiredService<UserDbContext>()
                  ?? throw new ArgumentNullException("未獲取到_userContext");
        var thirdClient = _userContext.ThirdClients.FirstOrDefault(p => p.AppKey == appKey.ToString());
        if (thirdClient == null)
        {
            context.Response.StatusCode = StatusCodes.Status406NotAcceptable;
            await context.Response.WriteAsync("驗籤不透過-AppKey");
            return;
        }

        IDictionary<string, string> parameters;
        if (context.Request.Method.Equals(HttpMethod.Get.ToString(), StringComparison.CurrentCultureIgnoreCase))
        {
            parameters = new Dictionary<string, string>();
            foreach (var kvp in context.Request.Query)
            {
                parameters.Add(kvp.Key, kvp.Value.ToString());
            }
        }
        else if (context.Request.Method.Equals(HttpMethod.Post.ToString(), StringComparison.CurrentCultureIgnoreCase))
        {
            var requestData = await new StreamReader(context.Request.Body).ReadToEndAsync();
            parameters = JsonConvert.DeserializeObject<Dictionary<string, string>>(requestData ?? "{}") ?? [];

            // 將修改後的請求體重新寫入HttpContext,防止action拿不到資料包錯
            if (!string.IsNullOrEmpty(requestData))
            {
                byte[] requestBodyBytes = Encoding.UTF8.GetBytes(requestData);
                context.Request.Body = new MemoryStream(requestBodyBytes);
            }                
        }
        else
        {
            context.Response.StatusCode = StatusCodes.Status406NotAcceptable;
            await context.Response.WriteAsync("不能識別的請求方法");
            return;
        }

        string requestDataStr = GetSortedRequestDataStr(parameters); //請求引數字串拼接
        string computedSign = Md5Helper.ComputeMd5Hash(timeStamp.ToString() + nonce.ToString() + appKey.ToString() + token.ToString() + requestDataStr);
        if (!sign.ToString().Equals(computedSign, StringComparison.CurrentCultureIgnoreCase))
        {
            context.Response.StatusCode = StatusCodes.Status406NotAcceptable;
            await context.Response.WriteAsync("驗籤驗證失敗");
            return;
        }
        await _next(context);

    }
    public string GetSortedRequestDataStr(IDictionary<string, string> parameters)
    {
        IDictionary<string, string> sortedParams = new SortedDictionary<string, string>(parameters);
        IEnumerator<KeyValuePair<string, string>> dem = sortedParams.GetEnumerator();

        StringBuilder query = new StringBuilder();
        while (dem.MoveNext())
        {
            string key = dem.Current.Key;
            string value = dem.Current.Value;
            if (!string.IsNullOrEmpty(key))
            {
                query.Append(key).Append(value);
            }
        }
        return query.ToString();
    }
}

參考:ASP.NET WebApi TOKEN+簽名認證_tokensign.getvalue()-CSDN部落格

相關文章