.NET Web API におけるリクエスト署名検証の実装

公開APIにおいて、HTTPのPOSTやGETリクエストを処理する際、セキュリティ上の課題が数多く存在します。通信中のデータ安全性を確保するために、TOKENとパラメータ署名を組み合わせた検証方式が有効です。

  • TOKEN(本記事ではJWTを想定)は、APIにアクセスするユーザーが正当な権限を持つことを保証します。主にフロントエンド(Web、モバイルアプリ)からのインターフェースアクセス認証に使用されます。
  • Sign(署名)パラメータは、データ改ざん防止とリクエストの一意性保証を目的とし、本記事の焦点です。主にサードパーティにAPIを提供する際の認証に利用されます。

実装コード全体は、以下のリポジトリで確認できます。
https://gitee.com/xiaoqingyao/web-app-identity.git

処理フローの概要

  1. API提供側は、提携先となるサードパーティに対してAppIdとAppSecretを発行します。
  2. サードパーティは、これらのAppIdとAppSecretを使用して特定のWeb APIを呼び出し、動的なTOKENを取得します。
  3. サードパーティは、他の業務APIを呼び出す際に、AppId、タイムスタンプ、業務パラメータ(キーと値のペア)を所定のルールで連結した文字列、ランダム値(Nonce)、および取得したTOKENを連結し、その文字列のMD5ハッシュ値を計算してSignKeyとします。このSignKeyは、AppId、タイムスタンプ、NonceとともにHTTPヘッダーに格納されます(TOKEN自体はヘッダーに含めません)。
  4. サーバーはリクエスト受信時に、クライアントと同じ計算ルールでSignKeyを生成し、クライアントから送信されたヘッダー内の値と比較します。一致すれば署名検証が成功と見なされます。

詳細手順とコード例

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);
    }

    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);
        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())}");
        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();
        var jsonContent = "{\"appKey\":\"etcp\", \"appSecret\":\"34853399624948488e1b3cb63ea5e578\"}"; // 事前に提供されたAppKeyとAppSecret
        var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
        var response = await client.PostAsync("http://localhost:5203/ThirdClient/GetToken", content);
        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 isExpired = ts > 30; // 30秒以内のリクエストのみ有効
        if (isExpired || !timespanvalidate)
        {
            context.Response.StatusCode = StatusCodes.Status406NotAcceptable;
            await context.Response.WriteAsync("署名検証失敗 - タイムスタンプ無効");
            return;
        }

        var cacheKey = "thirdClient_" + appKey;
        if (!_memoryCache.TryGetValue(cacheKey, out var token))
        {
            context.Response.StatusCode = StatusCodes.Status406NotAcceptable;
            await context.Response.WriteAsync("TOKEN検証失敗 - TOKENが期限切れか存在しません。再取得してください。");
            return;
        }
        var _userContext = serviceProvider.GetRequiredService<UserDbContext>()
            ?? throw new ArgumentNullException("UserDbContextの取得に失敗しました");
        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 ?? "{}") ?? new Dictionary<string, string>();

            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("未対応のHTTPメソッドです");
            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();
    }
}

タグ: .NET Web API 署名検証 APIセキュリティ MD5

7月7日 19:08 投稿