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