Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
293 changes: 259 additions & 34 deletions src/CyberSource.cs

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<VersionPrefix>10.11.2</VersionPrefix>
<VersionPrefix>10.25.0</VersionPrefix>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<Title>CyberSource</Title>
<Description>"Payment system, http://www.cybersource.com"</Description>
Expand Down Expand Up @@ -34,7 +34,7 @@
<EmbeddedResource Include="Updates\Payment.html" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Dynamicweb.Ecommerce" Version="10.11.2" />
<PackageReference Include="Dynamicweb.Ecommerce" Version="10.25.7" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
Expand Down
128 changes: 117 additions & 11 deletions src/Helpers/Helper.cs
Original file line number Diff line number Diff line change
@@ -1,43 +1,92 @@
using Dynamicweb.Core.Helpers;
using Dynamicweb.Ecommerce.Orders;
using Dynamicweb.Security.UserManagement;
using System;
using System.Globalization;
using System.IO;
using System.Linq;

namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Helpers;

internal static class Helper
{
internal const string CertificateFolder = "/Files/System";

public static string GetCertificateFilePath(string certificateFile)
{
if (string.IsNullOrWhiteSpace(certificateFile))
return string.Empty;

string path = FilePathHelper.GetAbsolutePath(certificateFile);
string path = FilePathHelper.GetAbsolutePath(certificateFile, CertificateFolder);
if (File.Exists(path))
return path;

return string.Empty;
}

public static string GetCustomerLastName(Order order, string customerName)
public static (string FirstName, string LastName) GetCustomerNameParts(Order order, string customerName)
{
string firstName = GetCustomerFirstName(order, customerName);
string lastName = GetCustomerLastName(order, customerName);

if (string.IsNullOrWhiteSpace(firstName) || string.IsNullOrWhiteSpace(lastName))
{
User user = GetOrderUser(order);
firstName = GetCustomerFirstNameFallback(order, user, firstName);
lastName = GetCustomerLastNameFallback(order, user, lastName);
Comment thread
StanislavSmetaninSSM marked this conversation as resolved.
}

return (firstName, lastName);
}
Comment thread
StanislavSmetaninSSM marked this conversation as resolved.

private static string GetCustomerFirstName(Order order, string customerName)
{
string firstName = order.CustomerFirstName;
int delimiterPosition = customerName.IndexOf(' ');
if (string.IsNullOrWhiteSpace(firstName))
{
firstName = delimiterPosition > -1
? customerName.Substring(0, delimiterPosition)
: customerName;
}

return firstName;
}

private static string GetCustomerLastName(Order order, string customerName)
{
string lastName = order.CustomerSurname;
int delimeterPosition = customerName.IndexOf(' ');
int delimiterPosition = customerName.IndexOf(' ');
if (string.IsNullOrWhiteSpace(lastName))
lastName = delimeterPosition > -1 ? customerName.Substring(delimeterPosition + 1) : customerName;
{
lastName = delimiterPosition > -1
? customerName.Substring(delimiterPosition + 1)
: string.Empty;
}

return lastName;
}

public static string GetCustomerFirstName(Order order, string customerName)
private static string GetCustomerFirstNameFallback(Order order, User user, string firstName)
{
string firstName = order.CustomerFirstName;
int delimeterPosition = customerName.IndexOf(' ');
if (string.IsNullOrWhiteSpace(firstName))
firstName = delimeterPosition > -1 ? customerName.Substring(0, delimeterPosition) : customerName;
if (!string.IsNullOrWhiteSpace(firstName))
return firstName;

return firstName;
return GetFirstValue(user?.FirstName,
GetFirstName(user?.Name),
GetFirstName(user?.UserName),
GetFallbackCustomerName(order, user));
}

private static string GetCustomerLastNameFallback(Order order, User user, string lastName)
{
if (!string.IsNullOrWhiteSpace(lastName))
return lastName;

return GetFirstValue(user?.LastName,
GetLastName(user?.Name),
GetLastName(user?.UserName),
GetFallbackCustomerName(order, user));
}

public static string GetTransactionAmount(Order order)
Expand All @@ -50,8 +99,65 @@ public static string GetTransactionAmount(Order order)
? "0." + new string('0', decimals)
: "0";

string amount = Math.Round(order.Price.Price, decimals).ToString(amountFormat, CultureInfo.InvariantCulture);
string amount = Math.Round(order.Price.Price, decimals)
.ToString(amountFormat, CultureInfo.InvariantCulture);

return amount;
}

private static string GetFirstValue(params string[] values)
{
return values
.Select(value => value?.Trim())
.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty;
}

private static User GetOrderUser(Order order)
{
if (order.CustomerAccessUserId <= 0)
return null;

return UserManagementServices.Users.GetUserById(order.CustomerAccessUserId);
}

private static string GetFallbackCustomerName(Order order, User user)
{
return GetFirstValue(
order.CustomerCompany,
user?.Name,
user?.UserName,
GetEmailUserName(order.CustomerEmail),
order.Id,
"Customer");
}

private static string GetFirstName(string name)
{
name = name?.Trim() ?? string.Empty;
int delimiterPosition = name.IndexOf(' ');

return delimiterPosition > -1
? name.Substring(0, delimiterPosition)
: name;
}

private static string GetLastName(string name)
{
name = name?.Trim() ?? string.Empty;
int delimiterPosition = name.IndexOf(' ');

return delimiterPosition > -1
? name.Substring(delimiterPosition + 1)
: name;
}

private static string GetEmailUserName(string email)
{
email = email?.Trim() ?? string.Empty;
int delimiterPosition = email.IndexOf('@');

return delimiterPosition > -1
? email.Substring(0, delimiterPosition)
: email;
}
}
169 changes: 169 additions & 0 deletions src/Helpers/JwtAuthenticationHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;

namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Helpers;

internal static class JwtAuthenticationHelper
{
/// <summary>
/// Generates a CyberSource REST API JWT v2 authentication token.
/// The required claims follow the current CyberSource REST JWT documentation and match the official
/// CyberSource .NET Standard authentication SDK output.
/// See https://developer.cybersource.com/docs/cybs/en-us/platform/developer/all/rest/rest-getting-started/restgs-jwt-message-intro/restgs-security-p12-intro.html.
/// </summary>
public static string GenerateCertificateToken(string merchantId, string certificateFile, string certificatePassword, HttpMethod method, string host, string resourcePath, string data)
{
try
{
string certificatePath = Helper.GetCertificateFilePath(certificateFile);
if (string.IsNullOrEmpty(certificatePath))
throw new Exception("Certificate for REST API is not found");

using X509Certificate2 x5Cert = new(certificatePath, certificatePassword, X509KeyStorageFlags.MachineKeySet);
using RSA privateKey = x5Cert.GetRSAPrivateKey();
if (privateKey is null)
throw new Exception("Certificate for REST API does not contain an RSA private key");

Dictionary<string, object> header = GetHeaderClaims("RS256", GetCertificateKeyId(x5Cert));
Dictionary<string, object> payload = GetPayloadClaims(merchantId, method, host, resourcePath, data);

return BuildJwt(
JsonSerializer.Serialize(header),
JsonSerializer.Serialize(payload),
privateKey);
}
catch (Exception ex)
{
throw new Exception("Certificate JWT token creation failed", ex);
}
}

/// <summary>
/// Generates a CyberSource REST API JWT v2 token signed with a REST Shared Secret key.
/// </summary>
/// <param name="merchantId">The CyberSource merchant ID used as the JWT issuer and merchant identifier.</param>
/// <param name="sharedSecretKeyId">The REST Shared Secret key ID from CyberSource Business Center Key Management.</param>
/// <param name="sharedSecret">The base64-encoded REST Shared Secret value from CyberSource Business Center Key Management.</param>
/// <param name="method">The HTTP method used for the CyberSource REST request.</param>
/// <param name="host">The CyberSource REST API host, for example <c>apitest.cybersource.com</c>.</param>
/// <param name="resourcePath">The REST resource path and query string being requested.</param>
/// <param name="data">The serialized request body. When present, its SHA-256 digest is included in the JWT payload.</param>
/// <returns>A signed JWT token suitable for the CyberSource REST API <c>Authorization: Bearer</c> header.</returns>
/// <exception cref="Exception">
/// Thrown when required shared-secret settings are missing, the shared secret is not valid base64, or token signing fails.
/// </exception>
public static string GenerateSharedSecretToken(string merchantId, string sharedSecretKeyId, string sharedSecret, HttpMethod method, string host, string resourcePath, string data)
{
try
{
if (string.IsNullOrWhiteSpace(sharedSecretKeyId))
throw new Exception("REST Shared Secret Key ID is not configured");
if (string.IsNullOrWhiteSpace(sharedSecret))
throw new Exception("REST Shared Secret is not configured");

Dictionary<string, object> header = GetHeaderClaims("HS256", sharedSecretKeyId);
Dictionary<string, object> payload = GetPayloadClaims(merchantId, method, host, resourcePath, data);

return BuildJwt(
JsonSerializer.Serialize(header),
JsonSerializer.Serialize(payload),
Convert.FromBase64String(sharedSecret.Trim()));
}
catch (Exception ex)
{
throw new Exception("Shared Secret JWT token creation failed", ex);
}
}

private static Dictionary<string, object> GetHeaderClaims(string algorithm, string keyId) => new()
{
["alg"] = algorithm,
["kid"] = keyId,
["typ"] = "JWT"
};

private static Dictionary<string, object> GetPayloadClaims(string merchantId, HttpMethod method, string host, string resourcePath, string data)
{
DateTimeOffset now = DateTimeOffset.UtcNow;
var payload = new Dictionary<string, object>
{
["iat"] = now.ToUnixTimeSeconds(),
["exp"] = now.AddMinutes(2).ToUnixTimeSeconds(),
["request-method"] = method.Method.ToLowerInvariant(),
["request-resource-path"] = resourcePath,
["request-host"] = host,
["iss"] = merchantId,
["jti"] = Guid.NewGuid().ToString(),
["v-c-jwt-version"] = "2",
["v-c-merchant-id"] = merchantId
};

if (!string.IsNullOrEmpty(data))
{
payload["digest"] = GenerateDigest(data);
payload["digestAlgorithm"] = "SHA-256";
}

return payload;
}

private static string BuildJwt(string header, string payload, RSA privateKey)
{
string signingInput = $"{Base64UrlEncode(header)}.{Base64UrlEncode(payload)}";
byte[] signature = privateKey.SignData(Encoding.UTF8.GetBytes(signingInput), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);

return $"{signingInput}.{Base64UrlEncode(signature)}";
}

private static string BuildJwt(string header, string payload, byte[] sharedSecret)
{
string signingInput = $"{Base64UrlEncode(header)}.{Base64UrlEncode(payload)}";
using HMACSHA256 hmac = new(sharedSecret);
byte[] signature = hmac.ComputeHash(Encoding.UTF8.GetBytes(signingInput));

return $"{signingInput}.{Base64UrlEncode(signature)}";
}

private static string Base64UrlEncode(string value) => Base64UrlEncode(Encoding.UTF8.GetBytes(value));

private static string Base64UrlEncode(byte[] bytes) =>
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');

private static string GenerateDigest(string data)
{
using SHA256 sha256 = SHA256.Create();

return Convert.ToBase64String(sha256.ComputeHash(Encoding.UTF8.GetBytes(data)));
}

private static string GetCertificateKeyId(X509Certificate2 certificate)
{
Match subjectSerialNumber = Regex.Match(certificate.Subject, @"(?:^|,\s*)SERIALNUMBER\s*=\s*([^,]+)", RegexOptions.IgnoreCase);
if (subjectSerialNumber.Success)
return subjectSerialNumber.Groups[1].Value.Trim();

string decodedSerialNumber = DecodeHexSerialNumber(certificate.SerialNumber);

return string.IsNullOrWhiteSpace(decodedSerialNumber) ? certificate.SerialNumber : decodedSerialNumber;
}

private static string DecodeHexSerialNumber(string serialNumber)
{
if (string.IsNullOrEmpty(serialNumber) || serialNumber.Length % 2 is not 0 || serialNumber.Any(character => !Uri.IsHexDigit(character)))
return string.Empty;

byte[] bytes = Enumerable.Range(0, serialNumber.Length / 2)
.Select(index => Convert.ToByte(serialNumber.Substring(index * 2, 2), 16))
.ToArray();

string decoded = Encoding.ASCII.GetString(bytes);
return decoded.All(character => char.IsLetterOrDigit(character)) ? decoded : string.Empty;
}
}
4 changes: 2 additions & 2 deletions src/Helpers/SecurityHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ public static bool ValidateResponseSignation(NameValueCollection parameters, str
/// Signs parameters with secret key
/// </summary>
/// <param name="parameters">set of key value pairs</param>
/// <param name="secretKey">key that is used for encryption</param>
/// <returns>Encrypted string</returns>
/// <param name="secretKey">key that is used for signing</param>
/// <returns>Signature string</returns>
public static string Sign(Dictionary<string, string> parameters, string secretKey)
{
return Sign(BuildSignation(parameters), secretKey);
Expand Down
7 changes: 7 additions & 0 deletions src/RestAuthenticationMethod.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource;

internal enum RestAuthenticationMethod
{
Certificate,
SharedSecret
}
Loading
Loading