公開APIにおいて、HTTPのPOSTやGETリクエストを処理する際、セキュリティ上の課題が数多く存在します。通信中のデータ安全性を確保するために、TOKENとパラメータ署名を組み合わせた検証方式が有効です。
- TOKEN(本記事ではJWTを想定)は、APIにアクセスするユーザーが正当な権限を持つことを保証します。主にフロントエンド(Web、モバイルアプリ)からのインターフェースアクセス認証に使用されます。
- Sign(署名)パラメータは、データ改ざん防止とリクエストの一意性保証を目的とし、本記事の焦点です。主にサードパーティにAPIを提供する際の認証に利用されます。
実装コード全体は、以下のリポジトリで確認できます。
https://gitee.com/xiaoqingyao/web-app-identity.git
処理フローの概要
- API提供側は、提携先となるサードパーティに対してAppIdとAppSecretを発行します。
- サードパーティは、これらのAppIdとAppSecretを使用して特定のWeb APIを呼び出し、動的なTOKENを取得します。
- サードパーティは、他の業務APIを呼び出す際に、AppId、タイムスタンプ、業務パラメータ(キーと値のペア)を所定のルールで連結した文字列、ランダム値(Nonce)、および取得したTOKENを連結し、その文字列のMD5ハッシュ値を計算してSignKeyとします。このSignKeyは、AppId、タイムスタンプ、NonceとともにHTTPヘッダーに格納されます(TOKEN自体はヘッダーに含めません)。
- サーバーはリクエスト受信時に、クライアントと同じ計算ルールで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();
}
}