forked from gongxuegit/tiku-backend.net
feat: add wechat authentication
This commit is contained in:
@@ -22,6 +22,7 @@
|
|||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||||
<PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" />
|
<PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" />
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ Tiku.Infrastructure/Persistence/Migrations/20260725220742_InitialSchema.cs
|
|||||||
- 本地认证不依赖 Supabase Auth:
|
- 本地认证不依赖 Supabase Auth:
|
||||||
- 手机号 + 密码登录
|
- 手机号 + 密码登录
|
||||||
- 短信验证码登录
|
- 短信验证码登录
|
||||||
|
- 微信网页 OAuth 登录
|
||||||
|
- 微信小程序 `wx.login` 登录
|
||||||
- JWT access token
|
- JWT access token
|
||||||
- 数据库 `auth_sessions` refresh/session
|
- 数据库 `auth_sessions` refresh/session
|
||||||
- `auth_login_events` 登录事件
|
- `auth_login_events` 登录事件
|
||||||
@@ -88,6 +90,8 @@ Tiku.Infrastructure/Persistence/Migrations/20260725220742_InitialSchema.cs
|
|||||||
- 第一批认证/租户接口已经建立:
|
- 第一批认证/租户接口已经建立:
|
||||||
- `POST /api/auth/login/password`
|
- `POST /api/auth/login/password`
|
||||||
- `POST /api/auth/login/sms`
|
- `POST /api/auth/login/sms`
|
||||||
|
- `POST /api/auth/oauth/wechat`
|
||||||
|
- `POST /api/auth/oauth/wechat-miniapp`
|
||||||
- `POST /api/auth/refresh`
|
- `POST /api/auth/refresh`
|
||||||
- `POST /api/auth/logout`
|
- `POST /api/auth/logout`
|
||||||
- `GET /api/me`
|
- `GET /api/me`
|
||||||
@@ -122,6 +126,7 @@ Tiku.Infrastructure/Persistence/Migrations/20260725220742_InitialSchema.cs
|
|||||||
- 无权限返回 403
|
- 无权限返回 403
|
||||||
- 密码登录成功/失败
|
- 密码登录成功/失败
|
||||||
- 短信验证码登录成功/失败
|
- 短信验证码登录成功/失败
|
||||||
|
- 微信登录 upsert 用户、身份和租户成员
|
||||||
- 登录后访问当前用户和当前租户
|
- 登录后访问当前用户和当前租户
|
||||||
- 登出后旧 access token / refresh token 被拒绝
|
- 登出后旧 access token / refresh token 被拒绝
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,40 @@ public sealed class AuthController(IAuthService authService) : ControllerBase
|
|||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("oauth/wechat")]
|
||||||
|
public async Task<ActionResult<AuthenticatedUser>> LoginWithWechatWeb(
|
||||||
|
WechatLoginHttpRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await authService.LoginWithWechatWebAsync(
|
||||||
|
new WechatLoginRequest(
|
||||||
|
request.TenantId,
|
||||||
|
request.Code,
|
||||||
|
GetIpAddress(),
|
||||||
|
Request.Headers.UserAgent.ToString()),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("oauth/wechat-miniapp")]
|
||||||
|
public async Task<ActionResult<AuthenticatedUser>> LoginWithWechatMiniApp(
|
||||||
|
WechatLoginHttpRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await authService.LoginWithWechatMiniAppAsync(
|
||||||
|
new WechatLoginRequest(
|
||||||
|
request.TenantId,
|
||||||
|
request.Code,
|
||||||
|
GetIpAddress(),
|
||||||
|
Request.Headers.UserAgent.ToString()),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("refresh")]
|
[HttpPost("refresh")]
|
||||||
public async Task<ActionResult<AuthTokenPair>> Refresh(
|
public async Task<ActionResult<AuthTokenPair>> Refresh(
|
||||||
@@ -89,4 +123,8 @@ public sealed record SmsLoginHttpRequest(
|
|||||||
string Phone,
|
string Phone,
|
||||||
string Code);
|
string Code);
|
||||||
|
|
||||||
|
public sealed record WechatLoginHttpRequest(
|
||||||
|
Guid TenantId,
|
||||||
|
string Code);
|
||||||
|
|
||||||
public sealed record RefreshHttpRequest(string RefreshToken);
|
public sealed record RefreshHttpRequest(string RefreshToken);
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ public sealed class ExceptionHandlingMiddleware(
|
|||||||
{
|
{
|
||||||
"tenant_access_denied" => StatusCodes.Status403Forbidden,
|
"tenant_access_denied" => StatusCodes.Status403Forbidden,
|
||||||
"sms_rate_limited" => StatusCodes.Status429TooManyRequests,
|
"sms_rate_limited" => StatusCodes.Status429TooManyRequests,
|
||||||
|
"auth_provider_not_configured" => StatusCodes.Status503ServiceUnavailable,
|
||||||
"session_revoked" => StatusCodes.Status401Unauthorized,
|
"session_revoked" => StatusCodes.Status401Unauthorized,
|
||||||
_ => StatusCodes.Status401Unauthorized
|
_ => StatusCodes.Status401Unauthorized
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ public sealed record SmsLoginRequest(
|
|||||||
string? IpAddress,
|
string? IpAddress,
|
||||||
string? UserAgent);
|
string? UserAgent);
|
||||||
|
|
||||||
|
public sealed record WechatLoginRequest(
|
||||||
|
Guid TenantId,
|
||||||
|
string Code,
|
||||||
|
string? IpAddress,
|
||||||
|
string? UserAgent);
|
||||||
|
|
||||||
public sealed record RefreshSessionRequest(
|
public sealed record RefreshSessionRequest(
|
||||||
string RefreshToken,
|
string RefreshToken,
|
||||||
string? IpAddress,
|
string? IpAddress,
|
||||||
|
|||||||
@@ -16,3 +16,6 @@ public sealed class SessionRevokedException()
|
|||||||
|
|
||||||
public sealed class SmsRateLimitedException()
|
public sealed class SmsRateLimitedException()
|
||||||
: AuthException("sms_rate_limited", "SMS verification requests are rate limited.");
|
: AuthException("sms_rate_limited", "SMS verification requests are rate limited.");
|
||||||
|
|
||||||
|
public sealed class AuthProviderNotConfiguredException(string provider)
|
||||||
|
: AuthException("auth_provider_not_configured", $"The {provider} auth provider is not configured.");
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ public interface IAuthService
|
|||||||
SmsLoginRequest request,
|
SmsLoginRequest request,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<AuthenticatedUser> LoginWithWechatWebAsync(
|
||||||
|
WechatLoginRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<AuthenticatedUser> LoginWithWechatMiniAppAsync(
|
||||||
|
WechatLoginRequest request,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<AuthTokenPair> RefreshAsync(
|
Task<AuthTokenPair> RefreshAsync(
|
||||||
RefreshSessionRequest request,
|
RefreshSessionRequest request,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|||||||
26
Tiku.Application/Auth/IWechatOAuthClient.cs
Normal file
26
Tiku.Application/Auth/IWechatOAuthClient.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
namespace Tiku.Application.Auth;
|
||||||
|
|
||||||
|
public sealed record WechatProviderOptions(
|
||||||
|
string AppId,
|
||||||
|
string AppSecret);
|
||||||
|
|
||||||
|
public sealed record WechatIdentity(
|
||||||
|
string OpenId,
|
||||||
|
string? UnionId,
|
||||||
|
string? Nickname,
|
||||||
|
string? AvatarUrl,
|
||||||
|
string? SessionKey,
|
||||||
|
string RawJson);
|
||||||
|
|
||||||
|
public interface IWechatOAuthClient
|
||||||
|
{
|
||||||
|
Task<WechatIdentity> ExchangeWebCodeAsync(
|
||||||
|
WechatProviderOptions options,
|
||||||
|
string code,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<WechatIdentity> ExchangeMiniAppCodeAsync(
|
||||||
|
WechatProviderOptions options,
|
||||||
|
string code,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -11,10 +11,16 @@ public sealed class AuthService(
|
|||||||
TikuDbContext dbContext,
|
TikuDbContext dbContext,
|
||||||
IPasswordHasher passwordHasher,
|
IPasswordHasher passwordHasher,
|
||||||
ISmsVerificationService smsVerificationService,
|
ISmsVerificationService smsVerificationService,
|
||||||
ISessionService sessionService) : IAuthService
|
ISessionService sessionService,
|
||||||
|
IWechatOAuthClient wechatOAuthClient) : IAuthService
|
||||||
{
|
{
|
||||||
private const string PasswordProvider = "password";
|
private const string PasswordProvider = "password";
|
||||||
private const string SmsProvider = "sms";
|
private const string SmsProvider = "sms";
|
||||||
|
private const string WechatWebProvider = "wechat_web";
|
||||||
|
private const string WechatMiniAppProvider = "wechat-miniapp";
|
||||||
|
private static readonly string[] WechatWebProviderAliases = ["wechat_web", "wechat-web", "wechat"];
|
||||||
|
private static readonly string[] WechatMiniAppProviderAliases = ["wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"];
|
||||||
|
private static readonly string[] WechatIdentityProviders = ["wechat_web", "wechat-web", "wechat", "wechat-miniapp", "wechat_miniapp", "wechat-mini", "wechatMiniapp"];
|
||||||
|
|
||||||
public async Task<AuthenticatedUser> LoginWithPasswordAsync(
|
public async Task<AuthenticatedUser> LoginWithPasswordAsync(
|
||||||
PasswordLoginRequest request,
|
PasswordLoginRequest request,
|
||||||
@@ -118,6 +124,30 @@ public sealed class AuthService(
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<AuthenticatedUser> LoginWithWechatWebAsync(
|
||||||
|
WechatLoginRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return LoginWithWechatAsync(
|
||||||
|
request,
|
||||||
|
WechatWebProvider,
|
||||||
|
WechatWebProviderAliases,
|
||||||
|
(options, code, token) => wechatOAuthClient.ExchangeWebCodeAsync(options, code, token),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<AuthenticatedUser> LoginWithWechatMiniAppAsync(
|
||||||
|
WechatLoginRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return LoginWithWechatAsync(
|
||||||
|
request,
|
||||||
|
WechatMiniAppProvider,
|
||||||
|
WechatMiniAppProviderAliases,
|
||||||
|
(options, code, token) => wechatOAuthClient.ExchangeMiniAppCodeAsync(options, code, token),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<AuthTokenPair> RefreshAsync(
|
public async Task<AuthTokenPair> RefreshAsync(
|
||||||
RefreshSessionRequest request,
|
RefreshSessionRequest request,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -247,6 +277,210 @@ public sealed class AuthService(
|
|||||||
tokens);
|
tokens);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<AuthenticatedUser> LoginWithWechatAsync(
|
||||||
|
WechatLoginRequest request,
|
||||||
|
string provider,
|
||||||
|
IReadOnlyList<string> providerAliases,
|
||||||
|
Func<WechatProviderOptions, string, CancellationToken, Task<WechatIdentity>> exchangeCodeAsync,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var config = await LoadWechatProviderOptionsAsync(
|
||||||
|
request.TenantId,
|
||||||
|
provider,
|
||||||
|
providerAliases,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
WechatIdentity identity;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
identity = await exchangeCodeAsync(config, request.Code, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (AuthException exception)
|
||||||
|
{
|
||||||
|
await AddLoginEventAsync(
|
||||||
|
request.TenantId,
|
||||||
|
null,
|
||||||
|
provider,
|
||||||
|
null,
|
||||||
|
AuthLoginResult.Failed,
|
||||||
|
exception.Code,
|
||||||
|
request.IpAddress,
|
||||||
|
request.UserAgent,
|
||||||
|
cancellationToken);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
var providerSubject = $"{config.AppId}:{identity.OpenId}";
|
||||||
|
var user = await UpsertWechatUserAsync(
|
||||||
|
provider,
|
||||||
|
providerSubject,
|
||||||
|
config.AppId,
|
||||||
|
identity,
|
||||||
|
cancellationToken);
|
||||||
|
await EnsureTenantMembershipAsync(
|
||||||
|
request.TenantId,
|
||||||
|
user.Id,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return await CompleteSuccessfulLoginAsync(
|
||||||
|
request.TenantId,
|
||||||
|
user,
|
||||||
|
provider,
|
||||||
|
identity.OpenId,
|
||||||
|
request.IpAddress,
|
||||||
|
request.UserAgent,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<WechatProviderOptions> LoadWechatProviderOptionsAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
string provider,
|
||||||
|
IReadOnlyList<string> aliases,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var rows = await dbContext.TenantAuthProviders
|
||||||
|
.Where(entity =>
|
||||||
|
entity.TenantId == tenantId &&
|
||||||
|
aliases.Contains(entity.Provider) &&
|
||||||
|
(entity.Status == TenantAuthProviderStatus.Active ||
|
||||||
|
entity.Status == TenantAuthProviderStatus.Testing))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var row = aliases
|
||||||
|
.Select(alias => rows.FirstOrDefault(entity => entity.Provider == alias))
|
||||||
|
.FirstOrDefault(entity => entity is not null);
|
||||||
|
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
throw new AuthProviderNotConfiguredException(provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
var appId = GetJsonString(row.ConfigPublic, "appId", "clientId");
|
||||||
|
var appSecret = GetJsonString(row.ConfigPublic, "appSecret", "clientSecret", "secret");
|
||||||
|
if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(appSecret))
|
||||||
|
{
|
||||||
|
throw new AuthProviderNotConfiguredException(provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WechatProviderOptions(appId, appSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<User> UpsertWechatUserAsync(
|
||||||
|
string provider,
|
||||||
|
string providerSubject,
|
||||||
|
string appId,
|
||||||
|
WechatIdentity wechatIdentity,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var existingIdentity = await dbContext.UserIdentities
|
||||||
|
.SingleOrDefaultAsync(
|
||||||
|
identity =>
|
||||||
|
identity.Provider == provider &&
|
||||||
|
identity.ProviderSubject == providerSubject,
|
||||||
|
cancellationToken);
|
||||||
|
var user = existingIdentity is null
|
||||||
|
? await FindUserByWechatUnionIdAsync(wechatIdentity.UnionId, cancellationToken)
|
||||||
|
: await dbContext.Users.FindAsync([existingIdentity.UserId], cancellationToken);
|
||||||
|
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
user = new User
|
||||||
|
{
|
||||||
|
Name = wechatIdentity.Nickname,
|
||||||
|
AvatarUrl = wechatIdentity.AvatarUrl,
|
||||||
|
PrimaryRole = "student",
|
||||||
|
RawProfile = CreateWechatRawProfile(wechatIdentity)
|
||||||
|
};
|
||||||
|
dbContext.Users.Add(user);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
user.Name = string.IsNullOrWhiteSpace(user.Name) ? wechatIdentity.Nickname : user.Name;
|
||||||
|
user.AvatarUrl = string.IsNullOrWhiteSpace(user.AvatarUrl) ? wechatIdentity.AvatarUrl : user.AvatarUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingIdentity is null)
|
||||||
|
{
|
||||||
|
existingIdentity = new UserIdentity
|
||||||
|
{
|
||||||
|
UserId = user.Id,
|
||||||
|
Provider = provider,
|
||||||
|
ProviderSubject = providerSubject
|
||||||
|
};
|
||||||
|
dbContext.UserIdentities.Add(existingIdentity);
|
||||||
|
}
|
||||||
|
|
||||||
|
existingIdentity.UserId = user.Id;
|
||||||
|
existingIdentity.OpenId = wechatIdentity.OpenId;
|
||||||
|
existingIdentity.UnionId = wechatIdentity.UnionId;
|
||||||
|
existingIdentity.SecretPayload = CreateWechatSecretPayload(appId, wechatIdentity);
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<User?> FindUserByWechatUnionIdAsync(
|
||||||
|
string? unionId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(unionId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity = await dbContext.UserIdentities
|
||||||
|
.Where(entity =>
|
||||||
|
entity.UnionId == unionId &&
|
||||||
|
WechatIdentityProviders.Contains(entity.Provider))
|
||||||
|
.OrderBy(entity => entity.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
return identity is null
|
||||||
|
? null
|
||||||
|
: await dbContext.Users.FindAsync([identity.UserId], cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureTenantMembershipAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid userId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var activeMembershipExists = await dbContext.TenantMemberships.AnyAsync(
|
||||||
|
membership =>
|
||||||
|
membership.TenantId == tenantId &&
|
||||||
|
membership.UserId == userId &&
|
||||||
|
membership.Status == MembershipStatus.Active,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (activeMembershipExists)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var studentMembership = await dbContext.TenantMemberships
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
membership =>
|
||||||
|
membership.TenantId == tenantId &&
|
||||||
|
membership.UserId == userId &&
|
||||||
|
membership.Role == TenantRole.Student,
|
||||||
|
cancellationToken);
|
||||||
|
if (studentMembership is null)
|
||||||
|
{
|
||||||
|
dbContext.TenantMemberships.Add(new TenantMembership
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
UserId = userId,
|
||||||
|
Role = TenantRole.Student,
|
||||||
|
Status = MembershipStatus.Active
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
studentMembership.Status = MembershipStatus.Active;
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<TenantMembership?> FindActiveMembershipAsync(
|
private async Task<TenantMembership?> FindActiveMembershipAsync(
|
||||||
Guid tenantId,
|
Guid tenantId,
|
||||||
Guid userId,
|
Guid userId,
|
||||||
@@ -295,4 +529,42 @@ public sealed class AuthService(
|
|||||||
property.ValueKind == JsonValueKind.String &&
|
property.ValueKind == JsonValueKind.String &&
|
||||||
!string.IsNullOrWhiteSpace(passwordHash = property.GetString() ?? string.Empty);
|
!string.IsNullOrWhiteSpace(passwordHash = property.GetString() ?? string.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? GetJsonString(JsonElement element, params string[] names)
|
||||||
|
{
|
||||||
|
if (element.ValueKind != JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var name in names)
|
||||||
|
{
|
||||||
|
if (element.TryGetProperty(name, out var property) &&
|
||||||
|
property.ValueKind == JsonValueKind.String &&
|
||||||
|
!string.IsNullOrWhiteSpace(property.GetString()))
|
||||||
|
{
|
||||||
|
return property.GetString()!.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement CreateWechatRawProfile(WechatIdentity identity)
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(identity.RawJson);
|
||||||
|
return document.RootElement.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement CreateWechatSecretPayload(string appId, WechatIdentity identity)
|
||||||
|
{
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
appId,
|
||||||
|
sessionKey = identity.SessionKey,
|
||||||
|
raw = JsonSerializer.Deserialize<JsonElement>(identity.RawJson),
|
||||||
|
updatedAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
return JsonSerializer.SerializeToElement(payload);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
115
Tiku.Infrastructure/Auth/WechatOAuthClient.cs
Normal file
115
Tiku.Infrastructure/Auth/WechatOAuthClient.cs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Tiku.Application.Auth;
|
||||||
|
|
||||||
|
namespace Tiku.Infrastructure.Auth;
|
||||||
|
|
||||||
|
public sealed class WechatOAuthClient(HttpClient httpClient) : 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(
|
||||||
|
WechatProviderOptions options,
|
||||||
|
string code,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var tokenUri = BuildUri(WebAccessTokenEndpoint, new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["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>
|
||||||
|
{
|
||||||
|
["access_token"] = accessToken,
|
||||||
|
["openid"] = openId,
|
||||||
|
["lang"] = "zh_CN"
|
||||||
|
});
|
||||||
|
using var userDocument = await GetWechatJsonAsync(userInfoUri, cancellationToken);
|
||||||
|
var user = userDocument.RootElement;
|
||||||
|
|
||||||
|
return new WechatIdentity(
|
||||||
|
openId,
|
||||||
|
OptionalString(user, "unionid") ?? unionId,
|
||||||
|
OptionalString(user, "nickname"),
|
||||||
|
OptionalString(user, "headimgurl"),
|
||||||
|
null,
|
||||||
|
user.GetRawText());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WechatIdentity> ExchangeMiniAppCodeAsync(
|
||||||
|
WechatProviderOptions options,
|
||||||
|
string code,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var uri = BuildUri(MiniAppCode2SessionEndpoint, new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["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(
|
||||||
|
RequiredString(root, "openid", "wechat_openid_missing"),
|
||||||
|
OptionalString(root, "unionid"),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
RequiredString(root, "session_key", "wechat_session_key_missing"),
|
||||||
|
root.GetRawText());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<JsonDocument> GetWechatJsonAsync(
|
||||||
|
Uri uri,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var response = await httpClient.GetAsync(uri, cancellationToken);
|
||||||
|
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 document;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Uri BuildUri(Uri endpoint, IReadOnlyDictionary<string, string> query)
|
||||||
|
{
|
||||||
|
var builder = new UriBuilder(endpoint);
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
return OptionalString(element, property) ?? throw new InvalidCredentialsException(errorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? OptionalString(JsonElement element, string property)
|
||||||
|
{
|
||||||
|
return element.TryGetProperty(property, out var value) &&
|
||||||
|
value.ValueKind == JsonValueKind.String &&
|
||||||
|
!string.IsNullOrWhiteSpace(value.GetString())
|
||||||
|
? value.GetString()!.Trim()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,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<IAuthService, AuthService>();
|
services.AddScoped<IAuthService, AuthService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||||
<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" />
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ using Microsoft.AspNetCore.Mvc.Testing;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
using Tiku.Application.Auth;
|
||||||
using Tiku.Domain.Identity;
|
using Tiku.Domain.Identity;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
namespace Tiku.IntegrationTests.Api;
|
namespace Tiku.IntegrationTests.Api;
|
||||||
|
|
||||||
public sealed class ApiTestFactory : WebApplicationFactory<Program>
|
public sealed class ApiTestFactory(IWechatOAuthClient? wechatOAuthClient = null) : WebApplicationFactory<Program>
|
||||||
{
|
{
|
||||||
private readonly string databaseName = Guid.NewGuid().ToString();
|
private readonly string databaseName = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
@@ -28,6 +29,11 @@ public sealed class ApiTestFactory : WebApplicationFactory<Program>
|
|||||||
|
|
||||||
services.AddDbContext<TikuDbContext>(options =>
|
services.AddDbContext<TikuDbContext>(options =>
|
||||||
options.UseInMemoryDatabase(databaseName));
|
options.UseInMemoryDatabase(databaseName));
|
||||||
|
|
||||||
|
if (wechatOAuthClient is not null)
|
||||||
|
{
|
||||||
|
services.AddSingleton(wechatOAuthClient);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Net;
|
|||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Tiku.Application.Auth;
|
||||||
using Tiku.Api.Controllers;
|
using Tiku.Api.Controllers;
|
||||||
using Tiku.Domain.Identity;
|
using Tiku.Domain.Identity;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
@@ -91,6 +92,53 @@ public sealed class AuthEndpointTests
|
|||||||
Assert.Equal(HttpStatusCode.Unauthorized, refreshResponse.StatusCode);
|
Assert.Equal(HttpStatusCode.Unauthorized, refreshResponse.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Wechat_miniapp_login_can_access_current_user()
|
||||||
|
{
|
||||||
|
await using var factory = new ApiTestFactory(new FakeWechatOAuthClient());
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
await factory.SeedAsync(
|
||||||
|
new Tenant
|
||||||
|
{
|
||||||
|
Id = tenantId,
|
||||||
|
Slug = tenantId.ToString("N"),
|
||||||
|
Name = "Wechat Tenant"
|
||||||
|
},
|
||||||
|
new TenantAuthProvider
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
Provider = "wechat-miniapp",
|
||||||
|
Status = TenantAuthProviderStatus.Testing,
|
||||||
|
ConfigPublic = JsonSerializer.SerializeToElement(new
|
||||||
|
{
|
||||||
|
appId = "wx-app-id",
|
||||||
|
appSecret = "wx-app-secret"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
var loginResponse = await client.PostAsJsonAsync(
|
||||||
|
"/api/auth/oauth/wechat-miniapp",
|
||||||
|
new WechatLoginHttpRequest(tenantId, "wx-code"));
|
||||||
|
var loginJson = await ReadJsonAsync(loginResponse);
|
||||||
|
var accessToken = loginJson.RootElement
|
||||||
|
.GetProperty("tokens")
|
||||||
|
.GetProperty("accessToken")
|
||||||
|
.GetString();
|
||||||
|
|
||||||
|
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||||
|
var meResponse = await client.GetAsync("/api/me");
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, meResponse.StatusCode);
|
||||||
|
Assert.Contains(dbContext.UserIdentities, identity =>
|
||||||
|
identity.Provider == "wechat-miniapp" &&
|
||||||
|
identity.OpenId == "mini-open-id" &&
|
||||||
|
identity.UnionId == "union-id");
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLoginUserAsync(
|
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLoginUserAsync(
|
||||||
ApiTestFactory factory)
|
ApiTestFactory factory)
|
||||||
{
|
{
|
||||||
@@ -163,4 +211,35 @@ public sealed class AuthEndpointTests
|
|||||||
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
||||||
return document.RootElement.Clone();
|
return document.RootElement.Clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class FakeWechatOAuthClient : IWechatOAuthClient
|
||||||
|
{
|
||||||
|
public Task<WechatIdentity> ExchangeWebCodeAsync(
|
||||||
|
WechatProviderOptions options,
|
||||||
|
string code,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new WechatIdentity(
|
||||||
|
"web-open-id",
|
||||||
|
"union-id",
|
||||||
|
"Wechat User",
|
||||||
|
"https://example.test/avatar.png",
|
||||||
|
null,
|
||||||
|
"""{"openid":"web-open-id","unionid":"union-id"}"""));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<WechatIdentity> ExchangeMiniAppCodeAsync(
|
||||||
|
WechatProviderOptions options,
|
||||||
|
string code,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new WechatIdentity(
|
||||||
|
"mini-open-id",
|
||||||
|
"union-id",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"session-key",
|
||||||
|
"""{"openid":"mini-open-id","unionid":"union-id","session_key":"session-key"}"""));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,9 +152,94 @@ public sealed class AuthServiceTests
|
|||||||
|
|
||||||
await Assert.ThrowsAsync<SessionRevokedException>(() =>
|
await Assert.ThrowsAsync<SessionRevokedException>(() =>
|
||||||
service.RefreshAsync(new RefreshSessionRequest(
|
service.RefreshAsync(new RefreshSessionRequest(
|
||||||
login.Tokens.RefreshToken,
|
login.Tokens.RefreshToken,
|
||||||
null,
|
null,
|
||||||
null)));
|
null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Wechat_miniapp_login_creates_user_identity_membership_and_session()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var tenantId = await SeedTenantWithWechatProviderAsync(context, "wechat-miniapp");
|
||||||
|
var service = CreateAuthService(
|
||||||
|
context,
|
||||||
|
new FakeWechatOAuthClient(
|
||||||
|
MiniAppIdentity: new WechatIdentity(
|
||||||
|
"mini-open-id",
|
||||||
|
"union-id",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"session-key",
|
||||||
|
"""{"openid":"mini-open-id","unionid":"union-id","session_key":"session-key"}""")));
|
||||||
|
|
||||||
|
var result = await service.LoginWithWechatMiniAppAsync(new WechatLoginRequest(
|
||||||
|
tenantId,
|
||||||
|
"wx-code",
|
||||||
|
null,
|
||||||
|
null));
|
||||||
|
|
||||||
|
Assert.Equal(tenantId, result.Tenant.TenantId);
|
||||||
|
Assert.Single(context.Users);
|
||||||
|
Assert.Contains(context.UserIdentities, identity =>
|
||||||
|
identity.Provider == "wechat-miniapp" &&
|
||||||
|
identity.ProviderSubject == "wx-app-id:mini-open-id" &&
|
||||||
|
identity.OpenId == "mini-open-id" &&
|
||||||
|
identity.UnionId == "union-id");
|
||||||
|
Assert.Contains(context.TenantMemberships, membership =>
|
||||||
|
membership.TenantId == tenantId &&
|
||||||
|
membership.UserId == result.UserId &&
|
||||||
|
membership.Status == MembershipStatus.Active);
|
||||||
|
Assert.Single(context.AuthSessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Wechat_union_id_reuses_existing_user_across_providers()
|
||||||
|
{
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var tenantId = await SeedTenantWithWechatProviderAsync(context, "wechat-miniapp");
|
||||||
|
context.TenantAuthProviders.Add(new TenantAuthProvider
|
||||||
|
{
|
||||||
|
TenantId = tenantId,
|
||||||
|
Provider = "wechat_web",
|
||||||
|
Status = TenantAuthProviderStatus.Testing,
|
||||||
|
ConfigPublic = WechatProviderConfig()
|
||||||
|
});
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = "Existing"
|
||||||
|
};
|
||||||
|
context.Users.Add(user);
|
||||||
|
context.UserIdentities.Add(new UserIdentity
|
||||||
|
{
|
||||||
|
UserId = user.Id,
|
||||||
|
Provider = "wechat_web",
|
||||||
|
ProviderSubject = "wx-app-id:web-open-id",
|
||||||
|
OpenId = "web-open-id",
|
||||||
|
UnionId = "same-union"
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var service = CreateAuthService(
|
||||||
|
context,
|
||||||
|
new FakeWechatOAuthClient(
|
||||||
|
MiniAppIdentity: new WechatIdentity(
|
||||||
|
"mini-open-id",
|
||||||
|
"same-union",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"session-key",
|
||||||
|
"""{"openid":"mini-open-id","unionid":"same-union","session_key":"session-key"}""")));
|
||||||
|
|
||||||
|
var result = await service.LoginWithWechatMiniAppAsync(new WechatLoginRequest(
|
||||||
|
tenantId,
|
||||||
|
"wx-code",
|
||||||
|
null,
|
||||||
|
null));
|
||||||
|
|
||||||
|
Assert.Equal(user.Id, result.UserId);
|
||||||
|
Assert.Single(context.Users);
|
||||||
|
Assert.Equal(2, context.UserIdentities.Count());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static TikuDbContext CreateContext()
|
private static TikuDbContext CreateContext()
|
||||||
@@ -166,7 +251,9 @@ public sealed class AuthServiceTests
|
|||||||
return new TikuDbContext(options);
|
return new TikuDbContext(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IAuthService CreateAuthService(TikuDbContext context)
|
private static IAuthService CreateAuthService(
|
||||||
|
TikuDbContext context,
|
||||||
|
IWechatOAuthClient? wechatOAuthClient = null)
|
||||||
{
|
{
|
||||||
var tokenService = new TokenService(Options.Create(JwtOptions));
|
var tokenService = new TokenService(Options.Create(JwtOptions));
|
||||||
var sessionService = new SessionService(context, tokenService, Options.Create(JwtOptions));
|
var sessionService = new SessionService(context, tokenService, Options.Create(JwtOptions));
|
||||||
@@ -176,7 +263,40 @@ public sealed class AuthServiceTests
|
|||||||
context,
|
context,
|
||||||
new PasswordHasher(),
|
new PasswordHasher(),
|
||||||
smsService,
|
smsService,
|
||||||
sessionService);
|
sessionService,
|
||||||
|
wechatOAuthClient ?? new FakeWechatOAuthClient());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Guid> SeedTenantWithWechatProviderAsync(
|
||||||
|
TikuDbContext context,
|
||||||
|
string provider)
|
||||||
|
{
|
||||||
|
var tenant = new Tenant
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Slug = Guid.NewGuid().ToString("N"),
|
||||||
|
Name = "Wechat Tenant"
|
||||||
|
};
|
||||||
|
context.Tenants.Add(tenant);
|
||||||
|
context.TenantAuthProviders.Add(new TenantAuthProvider
|
||||||
|
{
|
||||||
|
TenantId = tenant.Id,
|
||||||
|
Provider = provider,
|
||||||
|
Status = TenantAuthProviderStatus.Testing,
|
||||||
|
ConfigPublic = WechatProviderConfig()
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
return tenant.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement WechatProviderConfig()
|
||||||
|
{
|
||||||
|
return JsonSerializer.SerializeToElement(new
|
||||||
|
{
|
||||||
|
appId = "wx-app-id",
|
||||||
|
appSecret = "wx-app-secret"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedUserAsync(
|
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedUserAsync(
|
||||||
@@ -226,4 +346,37 @@ public sealed class AuthServiceTests
|
|||||||
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
|
||||||
return document.RootElement.Clone();
|
return document.RootElement.Clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class FakeWechatOAuthClient(
|
||||||
|
WechatIdentity? WebIdentity = null,
|
||||||
|
WechatIdentity? MiniAppIdentity = null) : IWechatOAuthClient
|
||||||
|
{
|
||||||
|
public Task<WechatIdentity> ExchangeWebCodeAsync(
|
||||||
|
WechatProviderOptions options,
|
||||||
|
string code,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return Task.FromResult(WebIdentity ?? new WechatIdentity(
|
||||||
|
"web-open-id",
|
||||||
|
"union-id",
|
||||||
|
"Wechat User",
|
||||||
|
"https://example.test/avatar.png",
|
||||||
|
null,
|
||||||
|
"""{"openid":"web-open-id","unionid":"union-id"}"""));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<WechatIdentity> ExchangeMiniAppCodeAsync(
|
||||||
|
WechatProviderOptions options,
|
||||||
|
string code,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return Task.FromResult(MiniAppIdentity ?? new WechatIdentity(
|
||||||
|
"mini-open-id",
|
||||||
|
"union-id",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"session-key",
|
||||||
|
"""{"openid":"mini-open-id","unionid":"union-id","session_key":"session-key"}"""));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user