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
11 changes: 11 additions & 0 deletions src/AspNet.Security.OAuth.Alipay/AlipayAuthenticationConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,16 @@ public static class Claims
/// The user's gender. F: Female; M: Male.
/// </summary>
public const string Gender = "urn:alipay:gender";

/// <summary>
/// OpenID is the unique identifier of Alipay users in the application dimension.
/// See https://opendocs.alipay.com/mini/0ai2i6
/// </summary>
public const string OpenId = "urn:alipay:open_id";

/// <summary>
/// Alipay user system internal identifier, will no longer be independently open in the future, and will be replaced by OpenID.
/// </summary>
public const string UserId = "urn:alipay:user_id";
}
}
32 changes: 30 additions & 2 deletions src/AspNet.Security.OAuth.Alipay/AlipayAuthenticationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -44,6 +45,21 @@ protected override Task<HandleRequestResult> HandleRemoteAuthenticateAsync()
return base.HandleRemoteAuthenticateAsync();
}

private const string SignType = "RSA2";

private async Task AddCertSignatureParametersAsync(SortedDictionary<string, string?> parameters)
{
ArgumentNullException.ThrowIfNull(Options.PrivateKey);
ArgumentNullException.ThrowIfNull(Options.AppCertSNKeyId);
ArgumentNullException.ThrowIfNull(Options.RootCertSNKeyId);

var app_cert_sn = await Options.PrivateKey(Options.AppCertSNKeyId, Context.RequestAborted);
var alipay_root_cert_sn = await Options.PrivateKey(Options.RootCertSNKeyId, Context.RequestAborted);

parameters["app_cert_sn"] = AntCertificationUtil.GetCertSN(app_cert_sn.Span);
parameters["alipay_root_cert_sn"] = AntCertificationUtil.GetRootCertSN(alipay_root_cert_sn.Span, SignType);
}

protected override async Task<OAuthTokenResponse> ExchangeCodeAsync([NotNull] OAuthCodeExchangeContext context)
{
// See https://opendocs.alipay.com/apis/api_9/alipay.system.oauth.token for details.
Expand All @@ -55,10 +71,16 @@ protected override async Task<OAuthTokenResponse> ExchangeCodeAsync([NotNull] OA
["format"] = "JSON",
["grant_type"] = "authorization_code",
["method"] = "alipay.system.oauth.token",
["sign_type"] = "RSA2",
["sign_type"] = SignType,
["timestamp"] = TimeProvider.GetUtcNow().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
["version"] = "1.0",
};

if (Options.EnableCertSignature)
{
await AddCertSignatureParametersAsync(tokenRequestParameters);
}

tokenRequestParameters.Add("sign", GetRSA2Signature(tokenRequestParameters));

// PKCE https://tools.ietf.org/html/rfc7636#section-4.5, see BuildChallengeUrl
Expand Down Expand Up @@ -103,10 +125,16 @@ protected override async Task<AuthenticationTicket> CreateTicketAsync(
["charset"] = "utf-8",
["format"] = "JSON",
["method"] = "alipay.user.info.share",
["sign_type"] = "RSA2",
["sign_type"] = SignType,
["timestamp"] = TimeProvider.GetUtcNow().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
["version"] = "1.0",
};

if (Options.EnableCertSignature)
{
await AddCertSignatureParametersAsync(parameters);
}

parameters.Add("sign", GetRSA2Signature(parameters));

var address = QueryHelpers.AddQueryString(Options.UserInformationEndpoint, parameters);
Expand Down
47 changes: 47 additions & 0 deletions src/AspNet.Security.OAuth.Alipay/AlipayAuthenticationOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,52 @@ public AlipayAuthenticationOptions()
ClaimActions.MapJsonKey(Claims.Gender, "gender");
ClaimActions.MapJsonKey(Claims.Nickname, "nick_name");
ClaimActions.MapJsonKey(Claims.Province, "province");
ClaimActions.MapJsonKey(Claims.OpenId, "open_id");
ClaimActions.MapJsonKey(Claims.UserId, "user_id");
}

/// <summary>
/// Get or set a value indicating whether to use certificate mode for signature implementation.
/// <para>https://opendocs.alipay.com/common/057k53?pathHash=e18d6f77#%E8%AF%81%E4%B9%A6%E6%A8%A1%E5%BC%8F</para>
/// </summary>
public bool EnableCertSignature { get; set; }

/// <summary>
/// Gets or sets the optional ID for your Sign in with app_cert_sn.
/// </summary>
public string? AppCertSNKeyId { get; set; }

/// <summary>
/// Gets or sets the optional ID for your Sign in with alipay_root_cert_sn.
/// </summary>
public string? RootCertSNKeyId { get; set; }

/// <summary>
/// Gets or sets an optional delegate to get the client's private key which is passed
/// the value of the <see cref="AppCertSNKeyId"/> or <see cref="RootCertSNKeyId"/> property and the <see cref="CancellationToken"/>
/// associated with the current HTTP request.
/// </summary>
/// <remarks>
/// The private key should be in PKCS #8 (<c>.p8</c>) format.
/// </remarks>
public Func<string, CancellationToken, Task<ReadOnlyMemory<char>>>? PrivateKey { get; set; }

/// <inheritdoc />
public override void Validate()
{
base.Validate();

if (EnableCertSignature)
{
if (string.IsNullOrEmpty(AppCertSNKeyId))
{
throw new ArgumentException($"The '{nameof(AppCertSNKeyId)}' option must be provided if the '{nameof(EnableCertSignature)}' option is set to true.", nameof(AppCertSNKeyId));
}

if (string.IsNullOrEmpty(RootCertSNKeyId))
{
throw new ArgumentException($"The '{nameof(RootCertSNKeyId)}' option must be provided if the '{nameof(EnableCertSignature)}' option is set to true.", nameof(RootCertSNKeyId));
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
* See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers
* for more information concerning the license and the contributors participating to this project.
*/

using AspNet.Security.OAuth.Alipay;
using Microsoft.Extensions.FileProviders;

namespace Microsoft.Extensions.DependencyInjection;

/// <summary>
/// Extension methods to configure Sign in with Alipay authentication capabilities for an HTTP application pipeline.
/// </summary>
public static class AlipayAuthenticationOptionsExtensions
{
/// <summary>
/// Configures the application to use a specified private to generate a client secret for the provider.
/// </summary>
/// <param name="options">The Apple authentication options to configure.</param>
/// <param name="privateKeyFile">
/// A delegate to a method to return the <see cref="IFileInfo"/> for the private
/// key which is passed the value of <see cref="AlipayAuthenticationOptions.AppCertSNKeyId"/> or <see cref="AlipayAuthenticationOptions.RootCertSNKeyId"/>.
/// </param>
/// <returns>
/// The value of the <paramref name="options"/> argument.
/// </returns>
public static AlipayAuthenticationOptions UsePrivateKey(
[NotNull] this AlipayAuthenticationOptions options,
[NotNull] Func<string, IFileInfo> privateKeyFile)
{
options.EnableCertSignature = true;
options.PrivateKey = async (keyId, cancellationToken) =>
{
var fileInfo = privateKeyFile(keyId);

using var stream = fileInfo.CreateReadStream();
using var reader = new StreamReader(stream);

return (await reader.ReadToEndAsync(cancellationToken)).AsMemory();
};

return options;
}
}
140 changes: 140 additions & 0 deletions src/AspNet.Security.OAuth.Alipay/AntCertificationUtil.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/*
* Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
* See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers
* for more information concerning the license and the contributors participating to this project.
*/

using System.Buffers;
using System.Globalization;
using System.Numerics;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;

namespace AspNet.Security.OAuth.Alipay;

/// <summary>
/// https://github.com/alipay/alipay-sdk-net-all/blob/master/v2/AlipaySDKNet.Standard/Util/AntCertificationUtil.cs
/// </summary>
internal static class AntCertificationUtil
{
public static string GetCertSN(ReadOnlySpan<char> certContent)
{
using var cert = X509Certificate2.CreateFromPem(certContent);
return GetCertSN(cert);
}

public static string GetCertSN(X509Certificate2 cert)
{
var issuerDN = cert.Issuer.Replace(", ", ",", StringComparison.InvariantCulture).AsSpan();
var serialNumber = new BigInteger(cert.GetSerialNumber()).ToString(CultureInfo.InvariantCulture);
var len = issuerDN.Length + serialNumber.Length;
char[]? array = null;
Span<char> chars = len <= StackallocByteThreshold ?
stackalloc char[StackallocByteThreshold] :
(array = ArrayPool<char>.Shared.Rent(len));
try
{
if (issuerDN.StartsWith("CN", StringComparison.InvariantCulture))
{
issuerDN.CopyTo(chars);
serialNumber.AsSpan().CopyTo(chars[issuerDN.Length..]);
return CalculateMd5(chars[..len]);
}

List<Range> attributes = [];
var issuerDNSplit = issuerDN.Split(',');
while (issuerDNSplit.MoveNext())
{
attributes.Add(issuerDNSplit.Current);
}

Span<char> charsTemp = chars;
for (var i = attributes.Count - 1; i >= 0; i--) // attributes.Reverse()
{
var it = issuerDN[attributes[i]];
it.CopyTo(charsTemp);
charsTemp = charsTemp[it.Length..];
if (i != 0)
{
charsTemp[0] = ',';
charsTemp = charsTemp[1..];
}
}

serialNumber.AsSpan().CopyTo(charsTemp);
return CalculateMd5(chars[..len]);
}
finally
{
if (array != null)
{
ArrayPool<char>.Shared.Return(array);
}
}
}

public static string GetRootCertSN(ReadOnlySpan<char> rootCertContent, string signType = "RSA2")
{
var rootCertSN = string.Join('_', GetRootCertSNCore(rootCertContent, signType));
return rootCertSN;
}

private static IEnumerable<string> GetRootCertSNCore(X509Certificate2Collection x509Certificates, string signType)
{
foreach (X509Certificate2 cert in x509Certificates)
{
var signatureAlgorithm = cert.SignatureAlgorithm.Value;
if (signatureAlgorithm != null)
{
if ((signType.StartsWith("RSA", StringComparison.InvariantCultureIgnoreCase) &&
signatureAlgorithm.StartsWith("1.2.840.113549.1.1", StringComparison.InvariantCultureIgnoreCase)) ||
(signType.StartsWith("SM2", StringComparison.InvariantCultureIgnoreCase) &&
signatureAlgorithm.StartsWith("1.2.156.10197.1.501", StringComparison.InvariantCultureIgnoreCase)))
{
yield return GetCertSN(cert);
}
}
}
}

private static IEnumerable<string> GetRootCertSNCore(ReadOnlySpan<char> rootCertContent, string signType)
{
X509Certificate2Collection x509Certificates = [];
x509Certificates.ImportFromPem(rootCertContent);
return GetRootCertSNCore(x509Certificates, signType);
}

/// <summary>
/// https://github.com/dotnet/runtime/blob/v9.0.8/src/libraries/System.Text.Json/Common/JsonConstants.cs#L12
/// </summary>
private const int StackallocByteThreshold = 256;

private static string CalculateMd5(ReadOnlySpan<char> chars)
{
var lenU8 = Encoding.UTF8.GetMaxByteCount(chars.Length);
byte[]? array = null;
Span<byte> bytes = lenU8 <= StackallocByteThreshold ?
stackalloc byte[StackallocByteThreshold] :
(array = ArrayPool<byte>.Shared.Rent(lenU8));
try
{
Encoding.UTF8.TryGetBytes(chars, bytes, out var bytesWritten);
bytes = bytes[..bytesWritten];

Span<byte> hash = stackalloc byte[MD5.HashSizeInBytes];
#pragma warning disable CA5351
MD5.HashData(bytes, hash);
#pragma warning restore CA5351

return Convert.ToHexStringLower(hash);
}
finally
{
if (array != null)
{
ArrayPool<byte>.Shared.Return(array);
}
}
}
}