Skip to content

Commit

Permalink
Added HMAC signature from core assembly and added matching rest clien…
Browse files Browse the repository at this point in the history
…t overloads. (#443)
  • Loading branch information
sipsorcery authored Oct 25, 2024
1 parent 5f1ab99 commit 7ad7637
Show file tree
Hide file tree
Showing 5 changed files with 254 additions and 6 deletions.
22 changes: 20 additions & 2 deletions src/NoFrixion.MoneyMoov/ApiClients/PayoutClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
// History:
// 05 Feb 2023 Aaron Clauson Created, Stillorgan Wood, Dublin, Ireland.
// 19 Apr 2023 Aaron Clauson Added batch payout methods.
// 25 Oct 2024 Aaron Clauson Added send payout method with HMAC signature authentication.
//
// License:
// MIT.
Expand All @@ -18,7 +19,6 @@
using Microsoft.Extensions.Logging.Abstractions;
using NoFrixion.MoneyMoov.Models;
using System.Net;
using System.Net.Http.Json;

namespace NoFrixion.MoneyMoov;

Expand All @@ -41,6 +41,8 @@ public interface IPayoutClient
Task<RestApiResponse> SubmitBatchPayoutAsync(string strongUserAccessToken, Guid batchPayoutID);

Task<RestApiResponse> DeletePayoutAsync(string accessToken, Guid payoutID);

Task<RestApiResponse<Payout>> SendPayoutAsync(Guid appID, string secret, Guid merchantID, PayoutCreate payoutCreate);
}

public class PayoutClient : IPayoutClient
Expand Down Expand Up @@ -229,7 +231,7 @@ public Task<RestApiResponse> SubmitBatchPayoutAsync(string strongUserAccessToken
/// </summary>
/// <param name="accessToken">The user, or merchant, access token deleting the payout.</param>
/// <param name="payoutID"></param>
/// <returns></returns>
/// <returns>An API response indicating the result of the delete attempt.</returns>
public Task<RestApiResponse> DeletePayoutAsync(string accessToken, Guid payoutID)
{
var url = MoneyMoovUrlBuilder.PayoutsApi.PayoutUrl(_apiClient.GetBaseUri().ToString(), payoutID);
Expand All @@ -242,4 +244,20 @@ public Task<RestApiResponse> DeletePayoutAsync(string accessToken, Guid payoutID
_ => Task.FromResult(new RestApiResponse(HttpStatusCode.PreconditionFailed, new Uri(url), prob))
};
}

/// <summary>
/// Calls the Trusted Third Party (TTP) MoneyMoov Payout endpoint to send a payout WITHOUT requiring NoFrixion Strong
/// Customer Authentication (SCA). This method relies on the caller to use ther own form of SCA.
/// </summary>
/// <param name="appID">The TTP application ID.</param>
/// <param name="secret">The TTP secret.</param>
/// <param name="merchantID">The merchant ID the send payout is being attempted for.</param>
/// <param name="payoutCreate">The payout create object with details of the payout to send.</param>
/// <returns>An API response indicating the result of the send attempt.</returns>
public Task<RestApiResponse<Payout>> SendPayoutAsync(Guid appID, string secret, Guid merchantID, PayoutCreate payoutCreate)
{
var url = MoneyMoovUrlBuilder.PayoutsApi.SendPayoutUrl(_apiClient.GetBaseUri().ToString());

return _apiClient.PostAsync<Payout>(url, appID, secret, merchantID, payoutCreate.ToJsonContent());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// -----------------------------------------------------------------------------
// Filename: HmacAuthenticationConstants.cs
//
// Description: Constants for HMAC authentication:
//
// Author(s):
// Donal O'Connor ([email protected])
//
// History:
// 22 04 2024 Donal O'Connor Created, Harcourt St, Dublin, Ireland.
//
// License:
// MIT.
// -----------------------------------------------------------------------------

namespace NoFrixion.MoneyMoov;

public static class HmacAuthenticationConstants
{
public const string SIGNATURE_SCHEME_NAME = "Signature";
public const string APP_ID_HEADER_NAME = "appId";
public const string AUTHORIZATION_HEADER_NAME = "Authorization";
public const string DATE_HEADER_NAME = "Date";
public const string NONCE_HEADER_NAME = "x-mod-nonce";
public const string MERCHANT_ID_HEADER_NAME = "x-nfx-merchantid";
public const string NOFRIXION_SIGNATURE_HEADER_NAME = "x-nfx-signature";
public const string HTTP_RETRY_HEADER_NAME = "x-mod-retry";
public const string IDEMPOTENT_HEADER_NAME = "idempotency-key";
}
106 changes: 106 additions & 0 deletions src/NoFrixion.MoneyMoov/HmacSignature/HmacSignatureAuthHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// -----------------------------------------------------------------------------
// Filename: HmacSignatureAuthHelper.cs
//
// Description: Used for generating HMAC signatures and verifying them.
//
// Author(s):
// Donal O'Connor ([email protected])
//
// History:
// 08 02 2022 Donal O'Connor Created, Carmichael House,
// Dublin, Ireland.
//
// License:
// MIT.
// -----------------------------------------------------------------------------

using System.Net;
using System.Security.Cryptography;
using System.Text;

namespace NoFrixion.MoneyMoov;

public static class HmacSignatureAuthHelper
{
public static Dictionary<string, string> GetAppHeaders(string appId,
string idempotencyKey,
string secret,
DateTime date,
Guid merchantId)
{
var signature = GenerateSignature(idempotencyKey, date, secret, true);

var headers = new Dictionary<string, string>
{
{HmacAuthenticationConstants.AUTHORIZATION_HEADER_NAME, GenerateAppAuthHeaderContent(appId, signature)},
{HmacAuthenticationConstants.DATE_HEADER_NAME, date.ToString("R")},
{HmacAuthenticationConstants.IDEMPOTENT_HEADER_NAME, idempotencyKey},
{HmacAuthenticationConstants.MERCHANT_ID_HEADER_NAME, merchantId.ToString()},
};

return headers;
}

public static Dictionary<string, string> GetHeaders(string keyId,
string nonce,
string secret,
DateTime date,
bool asRetry = false)
{
var signature = GenerateSignature(nonce, date, secret);

var headers = new Dictionary<string, string>
{
{HmacAuthenticationConstants.AUTHORIZATION_HEADER_NAME, GenerateAuthHeaderContent(keyId, signature)},
{HmacAuthenticationConstants.DATE_HEADER_NAME, date.ToString("R")},
{HmacAuthenticationConstants.NONCE_HEADER_NAME, nonce},
{HmacAuthenticationConstants.HTTP_RETRY_HEADER_NAME, asRetry.ToString().ToLower()},
{HmacAuthenticationConstants.NOFRIXION_SIGNATURE_HEADER_NAME, signature},
};

return headers;
}

public static string GenerateSignature(string nonce, DateTime date, string secret, bool hmac256 = false)
{
return hmac256 ?
HashAndEncode256($"date: {date:R}\n{HmacAuthenticationConstants.IDEMPOTENT_HEADER_NAME}: {nonce}", secret) :
HashAndEncode($"date: {date:R}\n{HmacAuthenticationConstants.NONCE_HEADER_NAME}: {nonce}", secret);
}

private static string GenerateAppAuthHeaderContent(string apiKey, string signature)
{
return $"Signature appId=\"{apiKey}\",headers=\"date {HmacAuthenticationConstants.IDEMPOTENT_HEADER_NAME}\",signature=\"{signature}\"";
}

private static string GenerateAuthHeaderContent(string apiKey, string signature)
{
return $"Signature keyId=\"{apiKey}\",headers=\"date x-mod-nonce\",signature=\"{signature}\"";
}

private static string HashAndEncode(string message, string secret)
{
var ascii = Encoding.ASCII;

HMACSHA1 hmac = new HMACSHA1(ascii.GetBytes(secret));
hmac.Initialize();

byte[] messageBuffer = ascii.GetBytes(message);
byte[] hash = hmac.ComputeHash(messageBuffer);

return WebUtility.UrlEncode(Convert.ToBase64String(hash));
}

private static string HashAndEncode256(string message, string secret)
{
var ascii = Encoding.ASCII;

var hmac = new HMACSHA256(ascii.GetBytes(secret));
hmac.Initialize();

var messageBuffer = ascii.GetBytes(message);
var hash = hmac.ComputeHash(messageBuffer);

return WebUtility.UrlEncode(Convert.ToBase64String(hash));
}
}
3 changes: 3 additions & 0 deletions src/NoFrixion.MoneyMoov/MoneyMoovUrlBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,9 @@ public static string CancelScheduledPayoutUrl(string moneyMoovBaseUrl, Guid payo

public static string RejectPayoutUrl(string moneyMoovBaseUrl, Guid payoutID)
=> $"{moneyMoovBaseUrl}/{MoneyMoovResources.payouts}/reject/{payoutID}";

public static string SendPayoutUrl(string moneyMoovBaseUrl)
=> $"{moneyMoovBaseUrl}/{MoneyMoovResources.payouts}/send";
}

/// <summary>
Expand Down
100 changes: 96 additions & 4 deletions src/NoFrixion.MoneyMoov/RestClient/RestApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
//
// History:
// 26 Nov 2022 Aaron Clauson Created, Stillorgan Wood, Dublin, Ireland.
// 25 OCt 2024 Aaron Clauson Added HMAC signature authentication overloads.
//
// License:
// MIT.
Expand All @@ -25,35 +26,53 @@ public interface IRestApiClient

Task<RestApiResponse<T>> GetAsync<T>(string path, string accessToken);

Task<RestApiResponse<T>> GetAsync<T>(string path, Guid appID, string secret, Guid merchantID);

Task<RestApiResponse<T>> PostAsync<T>(string path);

Task<RestApiResponse> PostAsync(string path, HttpContent content);

Task<RestApiResponse> PostAsync(string path, string accessToken);

Task<RestApiResponse> PostAsync(string path, Guid appID, string secret, Guid merchantID);

Task<RestApiResponse<T>> PostAsync<T>(string path, HttpContent content);

Task<RestApiResponse<T>> PostAsync<T>(string path, HttpContent content, Dictionary<string, string> headers,
MediaTypeWithQualityHeaderValue acceptHeader);

Task<RestApiResponse> PostAsync(string path, string accessToken, HttpContent content);

Task<RestApiResponse> PostAsync(string path, Guid appID, string secret, Guid merchantID, HttpContent content);

Task<RestApiResponse<T>> PostAsync<T>(string path, string accessToken, HttpContent content);

Task<RestApiResponse<T>> PostAsync<T>(string path, Guid appID, string secret, Guid merchantID, HttpContent content);

Task<RestApiResponse> PutAsync(string path);

Task<RestApiResponse> PutAsync(string path, string accessToken);

Task<RestApiResponse> PutAsync(string path, Guid appID, string secret, Guid merchantID);

Task<RestApiResponse<T>> PutAsync<T>(string path, string accessToken, HttpContent content);


Task<RestApiResponse<T>> PutAsync<T>(string path, Guid appID, string secret, Guid merchantID, HttpContent content);

Task<RestApiResponse<T>> PutAsync<T>(string path, string accessToken, HttpContent content, string rowVersion);

Task<RestApiResponse<T>> PutAsync<T>(string path, Guid appID, string secret, Guid merchantID, HttpContent content, string rowVersion);

Task<RestApiResponse> DeleteAsync(string path);

Task<RestApiResponse> DeleteAsync(string path, string accessToken);


Task<RestApiResponse> DeleteAsync(string path, Guid appID, string secret, Guid merchantID);

Task<RestApiResponse> DeleteAsync(string path, string accessToken, string rowVersion);

Task<RestApiResponse> DeleteAsync(string path, Guid appID, string secret, Guid merchantID, string rowVersion);

Uri GetBaseUri();

NoFrixionProblem CheckAccessToken(string accessToken, string callerName);
Expand Down Expand Up @@ -98,6 +117,9 @@ public Task<RestApiResponse<T>> GetAsync<T>(string path)
public Task<RestApiResponse<T>> GetAsync<T>(string path, string accessToken)
=> ExecAsync<T>(BuildRequest(HttpMethod.Get, path, accessToken, Option<HttpContent>.None));

public Task<RestApiResponse<T>> GetAsync<T>(string path, Guid appID, string secret, Guid merchantID)
=> ExecAsync<T>(BuildRequest(HttpMethod.Get, path, appID, secret,merchantID, Option<HttpContent>.None));

public Task<RestApiResponse<T>> PostAsync<T>(string path)
=> ExecAsync<T>(BuildRequest(HttpMethod.Post, path, string.Empty, Option<HttpContent>.None));

Expand All @@ -114,33 +136,57 @@ public Task<RestApiResponse<T>> PostAsync<T>(string path, HttpContent content, D
public Task<RestApiResponse> PostAsync(string path, string accessToken, HttpContent content)
=> ExecAsync(BuildRequest(HttpMethod.Post, path, accessToken, content));

public Task<RestApiResponse> PostAsync(string path, Guid appID, string secret, Guid merchantID, HttpContent content)
=> ExecAsync(BuildRequest(HttpMethod.Post, path, appID, secret, merchantID, content));

public Task<RestApiResponse> PostAsync(string path, string accessToken)
=> ExecAsync(BuildRequest(HttpMethod.Post, path, accessToken, Option<HttpContent>.None));

public Task<RestApiResponse> PostAsync(string path, Guid appID, string secret, Guid merchantID)
=> ExecAsync(BuildRequest(HttpMethod.Post, path, appID, secret, merchantID, Option<HttpContent>.None));

public Task<RestApiResponse<T>> PostAsync<T>(string path, string accessToken, HttpContent content)
=> ExecAsync<T>(BuildRequest(HttpMethod.Post, path, accessToken, content));

public Task<RestApiResponse<T>> PostAsync<T>(string path, Guid appID, string secret, Guid merchantID, HttpContent content)
=> ExecAsync<T>(BuildRequest(HttpMethod.Post, path, appID, secret, merchantID, content));

public Task<RestApiResponse> PutAsync(string path)
=> ExecAsync(BuildRequest(HttpMethod.Put, path, string.Empty, Option<HttpContent>.None));

public Task<RestApiResponse> PutAsync(string path, string accessToken)
=> ExecAsync(BuildRequest(HttpMethod.Put, path, accessToken, Option<HttpContent>.None));

public Task<RestApiResponse> PutAsync(string path, Guid appID, string secret, Guid merchantID)
=> ExecAsync(BuildRequest(HttpMethod.Put, path, appID, secret, merchantID, Option<HttpContent>.None));

public Task<RestApiResponse<T>> PutAsync<T>(string path, string accessToken, HttpContent content)
=> ExecAsync<T>(BuildRequest(HttpMethod.Put, path, accessToken, content));


public Task<RestApiResponse<T>> PutAsync<T>(string path, Guid appID, string secret, Guid merchantID, HttpContent content)
=> ExecAsync<T>(BuildRequest(HttpMethod.Put, path, appID, secret, merchantID, content));

public Task<RestApiResponse<T>> PutAsync<T>(string path, string accessToken, HttpContent content, string rowVersion)
=> ExecAsync<T>(BuildRequest(HttpMethod.Put, path, accessToken, content, rowVersion));

public Task<RestApiResponse<T>> PutAsync<T>(string path, Guid appID, string secret, Guid merchantID, HttpContent content, string rowVersion)
=> ExecAsync<T>(BuildRequest(HttpMethod.Put, path, appID, secret, merchantID, content, rowVersion));

public Task<RestApiResponse> DeleteAsync(string path)
=> ExecAsync(BuildRequest(HttpMethod.Delete, path, string.Empty, Option<HttpContent>.None));

public Task<RestApiResponse> DeleteAsync(string path, string accessToken)
=> ExecAsync(BuildRequest(HttpMethod.Delete, path, accessToken, Option<HttpContent>.None));


public Task<RestApiResponse> DeleteAsync(string path, Guid appID, string secret, Guid merchantID)
=> ExecAsync(BuildRequest(HttpMethod.Delete, path, appID, secret, merchantID, Option<HttpContent>.None));

public Task<RestApiResponse> DeleteAsync(string path, string accessToken, string rowVersion)
=> ExecAsync(BuildRequest(HttpMethod.Delete, path, accessToken, Option<HttpContent>.None, rowVersion));

public Task<RestApiResponse> DeleteAsync(string path, Guid appID, string secret, Guid merchantID, string rowVersion)
=> ExecAsync(BuildRequest(HttpMethod.Delete, path, appID, secret, merchantID, Option<HttpContent>.None, rowVersion));

public NoFrixionProblem CheckAccessToken(string accessToken, string callerName)
{
if (string.IsNullOrEmpty(accessToken))
Expand All @@ -153,6 +199,52 @@ public NoFrixionProblem CheckAccessToken(string accessToken, string callerName)
}
}

/// <summary>
/// Builds a request that will use a HMAC signature for authetnication.
/// </summary>
/// <param name="method">The HTTP method for teh request.</param>
/// <param name="path">The URL the request will be sent to.</param>
/// <param name="appID">The application ID for the HMAC signature.</param>
/// <param name="secret">The secret for the HMAC signature.</param>
/// <param name="merchantID">The merchant ID for the HMAC signature.</param>
/// <param name="httpContent">Optional HTTP content to include with the request.</param>
/// <param name="rowVersion">Optional row version header to include with the request.</param>
/// <param name="headers">Optional list of HTTP headers to set on the request.</param>
/// <param name="acceptHeader">Optional HTTP accept header to set on the request.</param>
/// <returns>A HTTP request message object ready to send to an HTTP server.</returns>
private HttpRequestMessage BuildRequest(
HttpMethod method,
string path,
Guid appID,
string secret,
Guid merchantID,
Option<HttpContent> httpContent,
string? rowVersion = null,
Dictionary<string, string>? headers = null,
MediaTypeWithQualityHeaderValue? acceptHeader = null)
{
var signatureHeaders = HmacSignatureAuthHelper.GetAppHeaders(
appID.ToString(),
Guid.NewGuid().ToString(),
secret,
DateTime.UtcNow,
merchantID);

if(headers == null)
{
headers = signatureHeaders;
}
else
{
foreach (var header in signatureHeaders)
{
headers.Add(header.Key, header.Value);
}
}

return BuildRequest(method, path, string.Empty, httpContent, rowVersion, headers, acceptHeader);
}

private HttpRequestMessage BuildRequest(
HttpMethod method,
string path,
Expand Down

0 comments on commit 7ad7637

Please sign in to comment.