feat: integrate senparc wechat authentication

This commit is contained in:
xiong
2026-07-26 18:22:37 +08:00
parent 91a4162908
commit fcf70db8aa
4 changed files with 112 additions and 77 deletions

View File

@@ -30,6 +30,9 @@
<PackageVersion Include="Npgsql" Version="10.0.3" /> <PackageVersion Include="Npgsql" Version="10.0.3" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" /> <PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" /> <PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
<PackageVersion Include="Senparc.Weixin" Version="6.25.0" />
<PackageVersion Include="Senparc.Weixin.MP" Version="16.25.1" />
<PackageVersion Include="Senparc.Weixin.WxOpen" Version="3.28.1" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" /> <PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Enrichers.Environment" Version="3.0.1" /> <PackageVersion Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" /> <PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />

View File

@@ -1,49 +1,65 @@
using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using Senparc.Weixin.MP.AdvancedAPIs;
using Senparc.Weixin.WxOpen.AdvancedAPIs.Sns;
using Tiku.Application.Auth; using Tiku.Application.Auth;
namespace Tiku.Infrastructure.Auth; namespace Tiku.Infrastructure.Auth;
public sealed class WechatOAuthClient(HttpClient httpClient) : IWechatOAuthClient public sealed class WechatOAuthClient : IWechatOAuthClient
{ {
private static readonly Uri WebAccessTokenEndpoint = new("https://api.weixin.qq.com/sns/oauth2/access_token");
private static readonly Uri WebUserInfoEndpoint = new("https://api.weixin.qq.com/sns/userinfo");
private static readonly Uri MiniAppCode2SessionEndpoint = new("https://api.weixin.qq.com/sns/jscode2session");
public async Task<WechatIdentity> ExchangeWebCodeAsync( public async Task<WechatIdentity> ExchangeWebCodeAsync(
WechatProviderOptions options, WechatProviderOptions options,
string code, string code,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var tokenUri = BuildUri(WebAccessTokenEndpoint, new Dictionary<string, string> cancellationToken.ThrowIfCancellationRequested();
{
["appid"] = options.AppId,
["secret"] = options.AppSecret,
["code"] = code,
["grant_type"] = "authorization_code"
});
using var tokenDocument = await GetWechatJsonAsync(tokenUri, cancellationToken);
var token = tokenDocument.RootElement;
var accessToken = RequiredString(token, "access_token", "wechat_access_token_missing");
var openId = RequiredString(token, "openid", "wechat_openid_missing");
var unionId = OptionalString(token, "unionid");
var userInfoUri = BuildUri(WebUserInfoEndpoint, new Dictionary<string, string> try
{ {
["access_token"] = accessToken, var token = await OAuthApi.GetAccessTokenAsync(
["openid"] = openId, options.AppId,
["lang"] = "zh_CN" options.AppSecret,
}); code,
using var userDocument = await GetWechatJsonAsync(userInfoUri, cancellationToken); "authorization_code");
var user = userDocument.RootElement; cancellationToken.ThrowIfCancellationRequested();
EnsureSuccess(token.ErrorCodeValue, token.errmsg);
return new WechatIdentity( if (string.IsNullOrWhiteSpace(token.access_token))
openId, {
OptionalString(user, "unionid") ?? unionId, throw new InvalidCredentialsException("wechat_access_token_missing");
OptionalString(user, "nickname"), }
OptionalString(user, "headimgurl"),
null, if (string.IsNullOrWhiteSpace(token.openid))
user.GetRawText()); {
throw new InvalidCredentialsException("wechat_openid_missing");
}
var user = await OAuthApi.GetUserInfoAsync(
token.access_token,
token.openid,
Senparc.Weixin.Language.zh_CN);
cancellationToken.ThrowIfCancellationRequested();
return new WechatIdentity(
token.openid.Trim(),
FirstNonBlank(user.unionid, token.unionid),
NullIfBlank(user.nickname),
NullIfBlank(user.headimgurl),
null,
SerializeRaw(new
{
accessToken = token,
user
}));
}
catch (InvalidCredentialsException)
{
throw;
}
catch (Exception exception)
{
throw new InvalidCredentialsException(MapWechatException(exception));
}
} }
public async Task<WechatIdentity> ExchangeMiniAppCodeAsync( public async Task<WechatIdentity> ExchangeMiniAppCodeAsync(
@@ -51,65 +67,78 @@ public sealed class WechatOAuthClient(HttpClient httpClient) : IWechatOAuthClien
string code, string code,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var uri = BuildUri(MiniAppCode2SessionEndpoint, new Dictionary<string, string> cancellationToken.ThrowIfCancellationRequested();
{
["appid"] = options.AppId,
["secret"] = options.AppSecret,
["js_code"] = code,
["grant_type"] = "authorization_code"
});
using var document = await GetWechatJsonAsync(uri, cancellationToken);
var root = document.RootElement;
return new WechatIdentity( try
RequiredString(root, "openid", "wechat_openid_missing"), {
OptionalString(root, "unionid"), var result = await SnsApi.JsCode2JsonAsync(
null, options.AppId,
null, options.AppSecret,
RequiredString(root, "session_key", "wechat_session_key_missing"), code,
root.GetRawText()); "authorization_code");
cancellationToken.ThrowIfCancellationRequested();
EnsureSuccess(result.ErrorCodeValue, result.errmsg);
if (string.IsNullOrWhiteSpace(result.openid))
{
throw new InvalidCredentialsException("wechat_openid_missing");
}
if (string.IsNullOrWhiteSpace(result.session_key))
{
throw new InvalidCredentialsException("wechat_session_key_missing");
}
return new WechatIdentity(
result.openid.Trim(),
NullIfBlank(result.unionid),
null,
null,
result.session_key.Trim(),
SerializeRaw(result));
}
catch (InvalidCredentialsException)
{
throw;
}
catch (Exception exception)
{
throw new InvalidCredentialsException(MapWechatException(exception));
}
} }
private async Task<JsonDocument> GetWechatJsonAsync( private static void EnsureSuccess(int errorCode, string? errorMessage)
Uri uri,
CancellationToken cancellationToken)
{ {
using var response = await httpClient.GetAsync(uri, cancellationToken); if (errorCode == 0)
response.EnsureSuccessStatusCode();
var document = await response.Content.ReadFromJsonAsync<JsonDocument>(cancellationToken)
?? throw new InvalidCredentialsException("wechat_empty_response");
if (document.RootElement.TryGetProperty("errcode", out var errcode) &&
errcode.ValueKind == JsonValueKind.Number &&
errcode.GetInt32() != 0)
{ {
throw new InvalidCredentialsException("wechat_code_exchange_failed"); return;
} }
return document; throw new InvalidCredentialsException(
string.IsNullOrWhiteSpace(errorMessage)
? "wechat_code_exchange_failed"
: $"wechat_code_exchange_failed:{errorCode}");
} }
private static Uri BuildUri(Uri endpoint, IReadOnlyDictionary<string, string> query) private static string SerializeRaw<T>(T value)
{ {
var builder = new UriBuilder(endpoint); return JsonSerializer.Serialize(value);
builder.Query = string.Join(
'&',
query.Select(pair =>
$"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"));
return builder.Uri;
} }
private static string RequiredString(JsonElement element, string property, string errorCode) private static string? FirstNonBlank(params string?[] values)
{ {
return OptionalString(element, property) ?? throw new InvalidCredentialsException(errorCode); return values.Select(NullIfBlank).FirstOrDefault(value => value is not null);
} }
private static string? OptionalString(JsonElement element, string property) private static string? NullIfBlank(string? value)
{ {
return element.TryGetProperty(property, out var value) && return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
value.ValueKind == JsonValueKind.String && }
!string.IsNullOrWhiteSpace(value.GetString())
? value.GetString()!.Trim() private static string MapWechatException(Exception exception)
: null; {
return exception is HttpRequestException
? "wechat_http_error"
: "wechat_code_exchange_failed";
} }
} }

View File

@@ -40,7 +40,7 @@ public static class DependencyInjection
services.AddScoped<ITokenService, TokenService>(); services.AddScoped<ITokenService, TokenService>();
services.AddScoped<ISessionService, SessionService>(); services.AddScoped<ISessionService, SessionService>();
services.AddScoped<ISmsVerificationService, SmsVerificationService>(); services.AddScoped<ISmsVerificationService, SmsVerificationService>();
services.AddHttpClient<IWechatOAuthClient, WechatOAuthClient>(); services.AddScoped<IWechatOAuthClient, WechatOAuthClient>();
services.AddScoped<IAuthService, AuthService>(); services.AddScoped<IAuthService, AuthService>();
services.AddScoped<ICatalogQueryService, CatalogQueryService>(); services.AddScoped<ICatalogQueryService, CatalogQueryService>();
services.AddScoped<IContentNavigationQueryService, ContentNavigationQueryService>(); services.AddScoped<IContentNavigationQueryService, ContentNavigationQueryService>();

View File

@@ -14,6 +14,9 @@
<PackageReference Include="Microsoft.Extensions.Options" /> <PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Npgsql" /> <PackageReference Include="Npgsql" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Senparc.Weixin" />
<PackageReference Include="Senparc.Weixin.MP" />
<PackageReference Include="Senparc.Weixin.WxOpen" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" />
</ItemGroup> </ItemGroup>