diff --git a/src/AspNet.Security.OAuth.Alipay/AlipayAuthenticationConstants.cs b/src/AspNet.Security.OAuth.Alipay/AlipayAuthenticationConstants.cs index d6188fc4f..67122afe5 100644 --- a/src/AspNet.Security.OAuth.Alipay/AlipayAuthenticationConstants.cs +++ b/src/AspNet.Security.OAuth.Alipay/AlipayAuthenticationConstants.cs @@ -25,5 +25,16 @@ public static class Claims /// The user's gender. F: Female; M: Male. /// public const string Gender = "urn:alipay:gender"; + + /// + /// OpenID is the unique identifier for Alipay users at the application level. + /// See https://opendocs.alipay.com/mini/0ai2i6 + /// + public const string OpenId = "urn:alipay:open_id"; + + /// + /// The internal identifier for Alipay users will no longer be independently available going forward and will be replaced by OpenID. + /// + public const string UserId = "urn:alipay:user_id"; } } diff --git a/src/AspNet.Security.OAuth.Alipay/AlipayAuthenticationHandler.cs b/src/AspNet.Security.OAuth.Alipay/AlipayAuthenticationHandler.cs index b33da5630..70893e103 100644 --- a/src/AspNet.Security.OAuth.Alipay/AlipayAuthenticationHandler.cs +++ b/src/AspNet.Security.OAuth.Alipay/AlipayAuthenticationHandler.cs @@ -44,6 +44,21 @@ protected override Task HandleRemoteAuthenticateAsync() return base.HandleRemoteAuthenticateAsync(); } + private const string SignType = "RSA2"; + + private async Task AddCertificateSignatureParametersAsync(SortedDictionary parameters) + { + ArgumentNullException.ThrowIfNull(Options.PrivateKey); + ArgumentNullException.ThrowIfNull(Options.ApplicationCertificateSnKeyId); + ArgumentNullException.ThrowIfNull(Options.RootCertificateSnKeyId); + + var app_cert_sn = await Options.PrivateKey(Options.ApplicationCertificateSnKeyId, Context.RequestAborted); + var alipay_root_cert_sn = await Options.PrivateKey(Options.RootCertificateSnKeyId, Context.RequestAborted); + + parameters["app_cert_sn"] = AlipayCertificationUtil.GetCertSN(app_cert_sn.Span); + parameters["alipay_root_cert_sn"] = AlipayCertificationUtil.GetRootCertSN(alipay_root_cert_sn.Span, SignType); + } + protected override async Task ExchangeCodeAsync([NotNull] OAuthCodeExchangeContext context) { // See https://opendocs.alipay.com/apis/api_9/alipay.system.oauth.token for details. @@ -55,10 +70,16 @@ protected override async Task 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.UseCertificateSignatures) + { + await AddCertificateSignatureParametersAsync(tokenRequestParameters); + } + tokenRequestParameters.Add("sign", GetRSA2Signature(tokenRequestParameters)); // PKCE https://tools.ietf.org/html/rfc7636#section-4.5, see BuildChallengeUrl @@ -103,10 +124,16 @@ protected override async Task 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.UseCertificateSignatures) + { + await AddCertificateSignatureParametersAsync(parameters); + } + parameters.Add("sign", GetRSA2Signature(parameters)); var address = QueryHelpers.AddQueryString(Options.UserInformationEndpoint, parameters); diff --git a/src/AspNet.Security.OAuth.Alipay/AlipayAuthenticationOptions.cs b/src/AspNet.Security.OAuth.Alipay/AlipayAuthenticationOptions.cs index 08677ddd5..e61e84851 100644 --- a/src/AspNet.Security.OAuth.Alipay/AlipayAuthenticationOptions.cs +++ b/src/AspNet.Security.OAuth.Alipay/AlipayAuthenticationOptions.cs @@ -29,5 +29,54 @@ 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"); + } + + /// + /// Gets or sets a value indicating whether to use certificate mode for signing calls. + /// https://opendocs.alipay.com/common/057k53?pathHash=e18d6f77#%E8%AF%81%E4%B9%A6%E6%A8%A1%E5%BC%8F + /// + public bool UseCertificateSignatures { get; set; } + + /// + /// Gets or sets the optional ID for your Sign in with Application Public Key Certificate SN(app_cert_sn). + /// https://opendocs.alipay.com/support/01raux + /// + public string? ApplicationCertificateSnKeyId { get; set; } + + /// + /// Gets or sets the optional ID for your Sign in with Alipay Root Certificate SN. + /// https://opendocs.alipay.com/support/01rauy + /// + public string? RootCertificateSnKeyId { get; set; } + + /// + /// Gets or sets an optional delegate to get the client's private key which is passed + /// the value of the or property and the + /// associated with the current HTTP request. + /// + /// + /// The private key should be in PKCS #8 (.p8) format. + /// + public Func>>? PrivateKey { get; set; } + + /// + public override void Validate() + { + base.Validate(); + + if (UseCertificateSignatures) + { + if (string.IsNullOrEmpty(ApplicationCertificateSnKeyId)) + { + throw new ArgumentException($"The '{nameof(ApplicationCertificateSnKeyId)}' option must be provided if the '{nameof(UseCertificateSignatures)}' option is set to true.", nameof(ApplicationCertificateSnKeyId)); + } + + if (string.IsNullOrEmpty(RootCertificateSnKeyId)) + { + throw new ArgumentException($"The '{nameof(RootCertificateSnKeyId)}' option must be provided if the '{nameof(UseCertificateSignatures)}' option is set to true.", nameof(RootCertificateSnKeyId)); + } + } } } diff --git a/src/AspNet.Security.OAuth.Alipay/AlipayAuthenticationOptionsExtensions.cs b/src/AspNet.Security.OAuth.Alipay/AlipayAuthenticationOptionsExtensions.cs new file mode 100644 index 000000000..2b754f3bc --- /dev/null +++ b/src/AspNet.Security.OAuth.Alipay/AlipayAuthenticationOptionsExtensions.cs @@ -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; + +/// +/// Extension methods to configure Sign in with Alipay authentication capabilities for an HTTP application pipeline. +/// +public static class AlipayAuthenticationOptionsExtensions +{ + /// + /// Configures the application to use a specified private key to generate a client secret for the provider. + /// + /// The Apple authentication options to configure. + /// + /// A delegate to a method to return the for the private + /// key which is passed the value of or . + /// + /// + /// The value of the argument. + /// + public static AlipayAuthenticationOptions UsePrivateKey( + [NotNull] this AlipayAuthenticationOptions options, + [NotNull] Func privateKeyFile) + { + options.UseCertificateSignatures = 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; + } +} diff --git a/src/AspNet.Security.OAuth.Alipay/AlipayCertificationUtil.cs b/src/AspNet.Security.OAuth.Alipay/AlipayCertificationUtil.cs new file mode 100644 index 000000000..c627280e8 --- /dev/null +++ b/src/AspNet.Security.OAuth.Alipay/AlipayCertificationUtil.cs @@ -0,0 +1,105 @@ +/* + * 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; + +/// +/// https://github.com/alipay/alipay-sdk-net-all/blob/b482d75d322e740760f9230d2a3859090af642a7/v2/AlipaySDKNet.Standard/Util/AntCertificationUtil.cs +/// +internal static class AlipayCertificationUtil +{ + public static string GetCertSN(ReadOnlySpan certContent) + { + using var cert = X509Certificate2.CreateFromPem(certContent); + return GetCertSN(cert); + } + + public static string GetCertSN(X509Certificate2 cert) + { + var issuerDN = cert.Issuer.Replace(", ", ",", StringComparison.InvariantCulture); + var serialNumber = new BigInteger(cert.GetSerialNumber()).ToString(CultureInfo.InvariantCulture); + + if (issuerDN.StartsWith("CN", StringComparison.InvariantCulture)) + { + return CalculateMd5(issuerDN + serialNumber); + } + + var attributes = issuerDN.Split(','); + Array.Reverse(attributes); + return CalculateMd5(string.Join(',', attributes) + serialNumber); + } + + public static string GetRootCertSN(ReadOnlySpan rootCertContent, string signType = "RSA2") + { + var rootCertSN = string.Join('_', GetRootCertSNCore(rootCertContent, signType)); + return rootCertSN; + } + + private static IEnumerable 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 GetRootCertSNCore(ReadOnlySpan rootCertContent, string signType) + { + X509Certificate2Collection x509Certificates = []; + x509Certificates.ImportFromPem(rootCertContent); + return GetRootCertSNCore(x509Certificates, signType); + } + + /// + /// https://github.com/dotnet/runtime/blob/v9.0.8/src/libraries/System.Text.Json/Common/JsonConstants.cs#L12 + /// + private const int StackallocByteThreshold = 256; + + private static string CalculateMd5(ReadOnlySpan chars) + { + var lenU8 = Encoding.UTF8.GetMaxByteCount(chars.Length); + byte[]? array = null; + Span bytes = lenU8 <= StackallocByteThreshold ? + stackalloc byte[StackallocByteThreshold] : + (array = ArrayPool.Shared.Rent(lenU8)); + try + { + Encoding.UTF8.TryGetBytes(chars, bytes, out var bytesWritten); + bytes = bytes[..bytesWritten]; + + Span 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.Shared.Return(array); + } + } + } +}