feat: add wechat authentication

This commit is contained in:
xiong
2026-07-26 13:12:53 +08:00
parent 241b9f777e
commit 70e2503d18
15 changed files with 722 additions and 7 deletions

View File

@@ -11,10 +11,16 @@ public sealed class AuthService(
TikuDbContext dbContext,
IPasswordHasher passwordHasher,
ISmsVerificationService smsVerificationService,
ISessionService sessionService) : IAuthService
ISessionService sessionService,
IWechatOAuthClient wechatOAuthClient) : IAuthService
{
private const string PasswordProvider = "password";
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(
PasswordLoginRequest request,
@@ -118,6 +124,30 @@ public sealed class AuthService(
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(
RefreshSessionRequest request,
CancellationToken cancellationToken = default)
@@ -247,6 +277,210 @@ public sealed class AuthService(
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(
Guid tenantId,
Guid userId,
@@ -295,4 +529,42 @@ public sealed class AuthService(
property.ValueKind == JsonValueKind.String &&
!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);
}
}