Skip to content

Commit acc2fe0

Browse files
committed
Add support for Alipay certificate signing
1 parent bf35e16 commit acc2fe0

File tree

5 files changed

+273
-2
lines changed

5 files changed

+273
-2
lines changed

src/AspNet.Security.OAuth.Alipay/AlipayAuthenticationConstants.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,16 @@ public static class Claims
2525
/// The user's gender. F: Female; M: Male.
2626
/// </summary>
2727
public const string Gender = "urn:alipay:gender";
28+
29+
/// <summary>
30+
/// OpenID is the unique identifier of Alipay users in the application dimension.
31+
/// See https://opendocs.alipay.com/mini/0ai2i6
32+
/// </summary>
33+
public const string OpenId = "urn:alipay:open_id";
34+
35+
/// <summary>
36+
/// Alipay user system internal identifier, will no longer be independently open in the future, and will be replaced by OpenID.
37+
/// </summary>
38+
public const string UserId = "urn:alipay:user_id";
2839
}
2940
}

src/AspNet.Security.OAuth.Alipay/AlipayAuthenticationHandler.cs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using System.Text;
1212
using System.Text.Encodings.Web;
1313
using System.Text.Json;
14+
using System.Threading.Tasks;
1415
using Microsoft.AspNetCore.Http;
1516
using Microsoft.AspNetCore.WebUtilities;
1617
using Microsoft.Extensions.Logging;
@@ -44,6 +45,21 @@ protected override Task<HandleRequestResult> HandleRemoteAuthenticateAsync()
4445
return base.HandleRemoteAuthenticateAsync();
4546
}
4647

48+
private const string SignType = "RSA2";
49+
50+
private async Task AddCertSignatureParametersAsync(SortedDictionary<string, string?> parameters)
51+
{
52+
ArgumentNullException.ThrowIfNull(Options.PrivateKey);
53+
ArgumentNullException.ThrowIfNull(Options.AppCertSNKeyId);
54+
ArgumentNullException.ThrowIfNull(Options.RootCertSNKeyId);
55+
56+
var app_cert_sn = await Options.PrivateKey(Options.AppCertSNKeyId, Context.RequestAborted);
57+
var alipay_root_cert_sn = await Options.PrivateKey(Options.RootCertSNKeyId, Context.RequestAborted);
58+
59+
parameters["app_cert_sn"] = AntCertificationUtil.GetCertSN(app_cert_sn.Span);
60+
parameters["alipay_root_cert_sn"] = AntCertificationUtil.GetRootCertSN(alipay_root_cert_sn.Span, SignType);
61+
}
62+
4763
protected override async Task<OAuthTokenResponse> ExchangeCodeAsync([NotNull] OAuthCodeExchangeContext context)
4864
{
4965
// See https://opendocs.alipay.com/apis/api_9/alipay.system.oauth.token for details.
@@ -55,10 +71,16 @@ protected override async Task<OAuthTokenResponse> ExchangeCodeAsync([NotNull] OA
5571
["format"] = "JSON",
5672
["grant_type"] = "authorization_code",
5773
["method"] = "alipay.system.oauth.token",
58-
["sign_type"] = "RSA2",
74+
["sign_type"] = SignType,
5975
["timestamp"] = TimeProvider.GetUtcNow().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
6076
["version"] = "1.0",
6177
};
78+
79+
if (Options.EnableCertSignature)
80+
{
81+
await AddCertSignatureParametersAsync(tokenRequestParameters);
82+
}
83+
6284
tokenRequestParameters.Add("sign", GetRSA2Signature(tokenRequestParameters));
6385

6486
// PKCE https://tools.ietf.org/html/rfc7636#section-4.5, see BuildChallengeUrl
@@ -103,10 +125,16 @@ protected override async Task<AuthenticationTicket> CreateTicketAsync(
103125
["charset"] = "utf-8",
104126
["format"] = "JSON",
105127
["method"] = "alipay.user.info.share",
106-
["sign_type"] = "RSA2",
128+
["sign_type"] = SignType,
107129
["timestamp"] = TimeProvider.GetUtcNow().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
108130
["version"] = "1.0",
109131
};
132+
133+
if (Options.EnableCertSignature)
134+
{
135+
await AddCertSignatureParametersAsync(parameters);
136+
}
137+
110138
parameters.Add("sign", GetRSA2Signature(parameters));
111139

112140
var address = QueryHelpers.AddQueryString(Options.UserInformationEndpoint, parameters);

src/AspNet.Security.OAuth.Alipay/AlipayAuthenticationOptions.cs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,52 @@ public AlipayAuthenticationOptions()
2929
ClaimActions.MapJsonKey(Claims.Gender, "gender");
3030
ClaimActions.MapJsonKey(Claims.Nickname, "nick_name");
3131
ClaimActions.MapJsonKey(Claims.Province, "province");
32+
ClaimActions.MapJsonKey(Claims.OpenId, "open_id");
33+
ClaimActions.MapJsonKey(Claims.UserId, "user_id");
34+
}
35+
36+
/// <summary>
37+
/// Get or set a value indicating whether to use certificate mode for signature implementation.
38+
/// <para>https://opendocs.alipay.com/common/057k53?pathHash=e18d6f77#%E8%AF%81%E4%B9%A6%E6%A8%A1%E5%BC%8F</para>
39+
/// </summary>
40+
public bool EnableCertSignature { get; set; }
41+
42+
/// <summary>
43+
/// Gets or sets the optional ID for your Sign in with app_cert_sn.
44+
/// </summary>
45+
public string? AppCertSNKeyId { get; set; }
46+
47+
/// <summary>
48+
/// Gets or sets the optional ID for your Sign in with alipay_root_cert_sn.
49+
/// </summary>
50+
public string? RootCertSNKeyId { get; set; }
51+
52+
/// <summary>
53+
/// Gets or sets an optional delegate to get the client's private key which is passed
54+
/// the value of the <see cref="AppCertSNKeyId"/> or <see cref="RootCertSNKeyId"/> property and the <see cref="CancellationToken"/>
55+
/// associated with the current HTTP request.
56+
/// </summary>
57+
/// <remarks>
58+
/// The private key should be in PKCS #8 (<c>.p8</c>) format.
59+
/// </remarks>
60+
public Func<string, CancellationToken, Task<ReadOnlyMemory<char>>>? PrivateKey { get; set; }
61+
62+
/// <inheritdoc />
63+
public override void Validate()
64+
{
65+
base.Validate();
66+
67+
if (EnableCertSignature)
68+
{
69+
if (string.IsNullOrEmpty(AppCertSNKeyId))
70+
{
71+
throw new ArgumentException($"The '{nameof(AppCertSNKeyId)}' option must be provided if the '{nameof(EnableCertSignature)}' option is set to true.", nameof(AppCertSNKeyId));
72+
}
73+
74+
if (string.IsNullOrEmpty(RootCertSNKeyId))
75+
{
76+
throw new ArgumentException($"The '{nameof(RootCertSNKeyId)}' option must be provided if the '{nameof(EnableCertSignature)}' option is set to true.", nameof(RootCertSNKeyId));
77+
}
78+
}
3279
}
3380
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
3+
* See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers
4+
* for more information concerning the license and the contributors participating to this project.
5+
*/
6+
7+
using AspNet.Security.OAuth.Alipay;
8+
using Microsoft.Extensions.FileProviders;
9+
10+
namespace Microsoft.Extensions.DependencyInjection;
11+
12+
/// <summary>
13+
/// Extension methods to configure Sign in with Alipay authentication capabilities for an HTTP application pipeline.
14+
/// </summary>
15+
public static class AlipayAuthenticationOptionsExtensions
16+
{
17+
/// <summary>
18+
/// Configures the application to use a specified private to generate a client secret for the provider.
19+
/// </summary>
20+
/// <param name="options">The Apple authentication options to configure.</param>
21+
/// <param name="privateKeyFile">
22+
/// A delegate to a method to return the <see cref="IFileInfo"/> for the private
23+
/// key which is passed the value of <see cref="AlipayAuthenticationOptions.AppCertSNKeyId"/> or <see cref="AlipayAuthenticationOptions.RootCertSNKeyId"/>.
24+
/// </param>
25+
/// <returns>
26+
/// The value of the <paramref name="options"/> argument.
27+
/// </returns>
28+
public static AlipayAuthenticationOptions UsePrivateKey(
29+
[NotNull] this AlipayAuthenticationOptions options,
30+
[NotNull] Func<string, IFileInfo> privateKeyFile)
31+
{
32+
options.EnableCertSignature = true;
33+
options.PrivateKey = async (keyId, cancellationToken) =>
34+
{
35+
var fileInfo = privateKeyFile(keyId);
36+
37+
using var stream = fileInfo.CreateReadStream();
38+
using var reader = new StreamReader(stream);
39+
40+
return (await reader.ReadToEndAsync(cancellationToken)).AsMemory();
41+
};
42+
43+
return options;
44+
}
45+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
3+
* See https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers
4+
* for more information concerning the license and the contributors participating to this project.
5+
*/
6+
7+
using System.Buffers;
8+
using System.Globalization;
9+
using System.Numerics;
10+
using System.Security.Cryptography;
11+
using System.Security.Cryptography.X509Certificates;
12+
using System.Text;
13+
14+
namespace AspNet.Security.OAuth.Alipay;
15+
16+
/// <summary>
17+
/// https://github.com/alipay/alipay-sdk-net-all/blob/master/v2/AlipaySDKNet.Standard/Util/AntCertificationUtil.cs
18+
/// </summary>
19+
internal static class AntCertificationUtil
20+
{
21+
public static string GetCertSN(ReadOnlySpan<char> certContent)
22+
{
23+
using var cert = X509Certificate2.CreateFromPem(certContent);
24+
return GetCertSN(cert);
25+
}
26+
27+
public static string GetCertSN(X509Certificate2 cert)
28+
{
29+
var issuerDN = cert.Issuer.Replace(", ", ",", StringComparison.InvariantCulture).AsSpan();
30+
var serialNumber = new BigInteger(cert.GetSerialNumber()).ToString(CultureInfo.InvariantCulture);
31+
var len = issuerDN.Length + serialNumber.Length;
32+
char[]? array = null;
33+
Span<char> chars = len <= StackallocByteThreshold ?
34+
stackalloc char[StackallocByteThreshold] :
35+
(array = ArrayPool<char>.Shared.Rent(len));
36+
try
37+
{
38+
if (issuerDN.StartsWith("CN", StringComparison.InvariantCulture))
39+
{
40+
issuerDN.CopyTo(chars);
41+
serialNumber.AsSpan().CopyTo(chars[issuerDN.Length..]);
42+
return CalculateMd5(chars[..len]);
43+
}
44+
45+
List<Range> attributes = [];
46+
var issuerDNSplit = issuerDN.Split(',');
47+
while (issuerDNSplit.MoveNext())
48+
{
49+
attributes.Add(issuerDNSplit.Current);
50+
}
51+
52+
Span<char> charsTemp = chars;
53+
for (var i = attributes.Count - 1; i >= 0; i--) // attributes.Reverse()
54+
{
55+
var it = issuerDN[attributes[i]];
56+
it.CopyTo(charsTemp);
57+
charsTemp = charsTemp[it.Length..];
58+
if (i != 0)
59+
{
60+
charsTemp[0] = ',';
61+
charsTemp = charsTemp[1..];
62+
}
63+
}
64+
65+
serialNumber.AsSpan().CopyTo(charsTemp);
66+
return CalculateMd5(chars[..len]);
67+
}
68+
finally
69+
{
70+
if (array != null)
71+
{
72+
ArrayPool<char>.Shared.Return(array);
73+
}
74+
}
75+
}
76+
77+
public static string GetRootCertSN(ReadOnlySpan<char> rootCertContent, string signType = "RSA2")
78+
{
79+
var rootCertSN = string.Join('_', GetRootCertSNCore(rootCertContent, signType));
80+
return rootCertSN;
81+
}
82+
83+
private static IEnumerable<string> GetRootCertSNCore(X509Certificate2Collection x509Certificates, string signType)
84+
{
85+
foreach (X509Certificate2 cert in x509Certificates)
86+
{
87+
var signatureAlgorithm = cert.SignatureAlgorithm.Value;
88+
if (signatureAlgorithm != null)
89+
{
90+
if ((signType.StartsWith("RSA", StringComparison.InvariantCultureIgnoreCase) &&
91+
signatureAlgorithm.StartsWith("1.2.840.113549.1.1", StringComparison.InvariantCultureIgnoreCase)) ||
92+
(signType.StartsWith("SM2", StringComparison.InvariantCultureIgnoreCase) &&
93+
signatureAlgorithm.StartsWith("1.2.156.10197.1.501", StringComparison.InvariantCultureIgnoreCase)))
94+
{
95+
yield return GetCertSN(cert);
96+
}
97+
}
98+
}
99+
}
100+
101+
private static IEnumerable<string> GetRootCertSNCore(ReadOnlySpan<char> rootCertContent, string signType)
102+
{
103+
X509Certificate2Collection x509Certificates = [];
104+
x509Certificates.ImportFromPem(rootCertContent);
105+
return GetRootCertSNCore(x509Certificates, signType);
106+
}
107+
108+
/// <summary>
109+
/// https://github.com/dotnet/runtime/blob/v9.0.8/src/libraries/System.Text.Json/Common/JsonConstants.cs#L12
110+
/// </summary>
111+
private const int StackallocByteThreshold = 256;
112+
113+
private static string CalculateMd5(ReadOnlySpan<char> chars)
114+
{
115+
var lenU8 = Encoding.UTF8.GetMaxByteCount(chars.Length);
116+
byte[]? array = null;
117+
Span<byte> bytes = lenU8 <= StackallocByteThreshold ?
118+
stackalloc byte[StackallocByteThreshold] :
119+
(array = ArrayPool<byte>.Shared.Rent(lenU8));
120+
try
121+
{
122+
Encoding.UTF8.TryGetBytes(chars, bytes, out var bytesWritten);
123+
bytes = bytes[..bytesWritten];
124+
125+
Span<byte> hash = stackalloc byte[MD5.HashSizeInBytes];
126+
#pragma warning disable CA5351
127+
MD5.HashData(bytes, hash);
128+
#pragma warning restore CA5351
129+
130+
return Convert.ToHexStringLower(hash);
131+
}
132+
finally
133+
{
134+
if (array != null)
135+
{
136+
ArrayPool<byte>.Shared.Return(array);
137+
}
138+
}
139+
}
140+
}

0 commit comments

Comments
 (0)