forked from xiongyuxing/tiku-backend.net
feat: integrate senparc wechat authentication
This commit is contained in:
@@ -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" />
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user