forked from xiongyuxing/tiku-backend.net
571 lines
19 KiB
C#
571 lines
19 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Tiku.Application.Auth;
|
|
using Tiku.Domain.Identity;
|
|
using Tiku.Domain.Tenancy;
|
|
using Tiku.Infrastructure.Persistence;
|
|
|
|
namespace Tiku.Infrastructure.Auth;
|
|
|
|
public sealed class AuthService(
|
|
TikuDbContext dbContext,
|
|
IPasswordHasher passwordHasher,
|
|
ISmsVerificationService smsVerificationService,
|
|
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,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
|
|
var user = await dbContext.Users
|
|
.SingleOrDefaultAsync(entity => entity.Phone == phone, cancellationToken);
|
|
var identity = user is null
|
|
? null
|
|
: await dbContext.UserIdentities
|
|
.SingleOrDefaultAsync(
|
|
entity =>
|
|
entity.UserId == user.Id &&
|
|
entity.Provider == PasswordProvider &&
|
|
entity.ProviderSubject == phone,
|
|
cancellationToken);
|
|
|
|
if (user is null ||
|
|
identity is null ||
|
|
!TryGetPasswordHash(identity.SecretPayload, out var passwordHash) ||
|
|
!passwordHasher.Verify(request.Password, passwordHash))
|
|
{
|
|
await AddLoginEventAsync(
|
|
request.TenantId,
|
|
user?.Id,
|
|
PasswordProvider,
|
|
phone,
|
|
AuthLoginResult.Failed,
|
|
"invalid_credentials",
|
|
request.IpAddress,
|
|
request.UserAgent,
|
|
cancellationToken);
|
|
throw new InvalidCredentialsException();
|
|
}
|
|
|
|
return await CompleteSuccessfulLoginAsync(
|
|
request.TenantId,
|
|
user,
|
|
PasswordProvider,
|
|
phone,
|
|
request.IpAddress,
|
|
request.UserAgent,
|
|
cancellationToken);
|
|
}
|
|
|
|
public async Task<AuthenticatedUser> LoginWithSmsAsync(
|
|
SmsLoginRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
|
|
var user = await dbContext.Users
|
|
.SingleOrDefaultAsync(entity => entity.Phone == phone, cancellationToken);
|
|
|
|
try
|
|
{
|
|
await smsVerificationService.VerifyCodeAsync(
|
|
request.TenantId,
|
|
phone,
|
|
SmsPurpose.Login,
|
|
request.Code,
|
|
cancellationToken);
|
|
}
|
|
catch (InvalidCredentialsException exception)
|
|
{
|
|
await AddLoginEventAsync(
|
|
request.TenantId,
|
|
user?.Id,
|
|
SmsProvider,
|
|
phone,
|
|
AuthLoginResult.Failed,
|
|
exception.Code,
|
|
request.IpAddress,
|
|
request.UserAgent,
|
|
cancellationToken);
|
|
throw;
|
|
}
|
|
|
|
if (user is null)
|
|
{
|
|
await AddLoginEventAsync(
|
|
request.TenantId,
|
|
null,
|
|
SmsProvider,
|
|
phone,
|
|
AuthLoginResult.Failed,
|
|
"user_not_found",
|
|
request.IpAddress,
|
|
request.UserAgent,
|
|
cancellationToken);
|
|
throw new InvalidCredentialsException();
|
|
}
|
|
|
|
return await CompleteSuccessfulLoginAsync(
|
|
request.TenantId,
|
|
user,
|
|
SmsProvider,
|
|
phone,
|
|
request.IpAddress,
|
|
request.UserAgent,
|
|
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)
|
|
{
|
|
var tokenHash = sessionService.HashRefreshToken(request.RefreshToken);
|
|
var now = DateTimeOffset.UtcNow;
|
|
var session = await dbContext.AuthSessions
|
|
.SingleOrDefaultAsync(entity => entity.TokenHash == tokenHash, cancellationToken);
|
|
|
|
if (session is null || session.RevokedAt is not null || session.ExpiresAt <= now)
|
|
{
|
|
throw new SessionRevokedException();
|
|
}
|
|
|
|
var user = await dbContext.Users.FindAsync([session.UserId], cancellationToken)
|
|
?? throw new SessionRevokedException();
|
|
var membership = await FindActiveMembershipAsync(session.TenantId, session.UserId, cancellationToken)
|
|
?? throw new TenantAccessDeniedException();
|
|
|
|
session.RevokedAt = now;
|
|
await AddLoginEventAsync(
|
|
session.TenantId,
|
|
session.UserId,
|
|
"refresh",
|
|
user.Phone ?? user.Email,
|
|
AuthLoginResult.Success,
|
|
null,
|
|
request.IpAddress,
|
|
request.UserAgent,
|
|
cancellationToken);
|
|
|
|
return await sessionService.IssueAsync(
|
|
user.Id,
|
|
user.Phone,
|
|
user.Email,
|
|
membership,
|
|
"refresh",
|
|
request.IpAddress,
|
|
request.UserAgent,
|
|
cancellationToken);
|
|
}
|
|
|
|
public async Task LogoutAsync(
|
|
LogoutSessionRequest request,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var tokenHash = sessionService.HashRefreshToken(request.RefreshToken);
|
|
var session = await dbContext.AuthSessions
|
|
.SingleOrDefaultAsync(entity => entity.TokenHash == tokenHash, cancellationToken);
|
|
|
|
if (session is null || session.RevokedAt is not null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
session.RevokedAt = DateTimeOffset.UtcNow;
|
|
await AddLoginEventAsync(
|
|
session.TenantId,
|
|
session.UserId,
|
|
"logout",
|
|
null,
|
|
AuthLoginResult.Success,
|
|
null,
|
|
null,
|
|
null,
|
|
cancellationToken);
|
|
}
|
|
|
|
private async Task<AuthenticatedUser> CompleteSuccessfulLoginAsync(
|
|
Guid tenantId,
|
|
User user,
|
|
string provider,
|
|
string identifier,
|
|
string? ipAddress,
|
|
string? userAgent,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var membership = await FindActiveMembershipAsync(tenantId, user.Id, cancellationToken);
|
|
if (membership is null)
|
|
{
|
|
await AddLoginEventAsync(
|
|
tenantId,
|
|
user.Id,
|
|
provider,
|
|
identifier,
|
|
AuthLoginResult.Failed,
|
|
"tenant_access_denied",
|
|
ipAddress,
|
|
userAgent,
|
|
cancellationToken);
|
|
throw new TenantAccessDeniedException();
|
|
}
|
|
|
|
var tenant = await dbContext.Tenants.FindAsync([tenantId], cancellationToken)
|
|
?? throw new TenantAccessDeniedException();
|
|
var tokens = await sessionService.IssueAsync(
|
|
user.Id,
|
|
user.Phone,
|
|
user.Email,
|
|
membership,
|
|
provider,
|
|
ipAddress,
|
|
userAgent,
|
|
cancellationToken);
|
|
|
|
await AddLoginEventAsync(
|
|
tenantId,
|
|
user.Id,
|
|
provider,
|
|
identifier,
|
|
AuthLoginResult.Success,
|
|
null,
|
|
ipAddress,
|
|
userAgent,
|
|
cancellationToken);
|
|
|
|
return new AuthenticatedUser(
|
|
user.Id,
|
|
user.Phone,
|
|
user.Email,
|
|
user.Name,
|
|
new TenantMembershipSummary(
|
|
tenant.Id,
|
|
tenant.Name,
|
|
membership.Role,
|
|
membership.Status),
|
|
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,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return await dbContext.TenantMemberships
|
|
.Where(entity =>
|
|
entity.TenantId == tenantId &&
|
|
entity.UserId == userId &&
|
|
entity.Status == MembershipStatus.Active)
|
|
.OrderBy(entity => entity.Role)
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
}
|
|
|
|
private async Task AddLoginEventAsync(
|
|
Guid tenantId,
|
|
Guid? userId,
|
|
string provider,
|
|
string? identifier,
|
|
AuthLoginResult result,
|
|
string? failureCode,
|
|
string? ipAddress,
|
|
string? userAgent,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
dbContext.AuthLoginEvents.Add(new AuthLoginEvent
|
|
{
|
|
TenantId = tenantId,
|
|
UserId = userId,
|
|
Provider = provider,
|
|
Identifier = identifier,
|
|
Result = result,
|
|
FailureCode = failureCode,
|
|
IpAddress = ipAddress,
|
|
UserAgent = userAgent
|
|
});
|
|
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
private static bool TryGetPasswordHash(JsonElement secretPayload, out string passwordHash)
|
|
{
|
|
passwordHash = string.Empty;
|
|
return secretPayload.ValueKind == JsonValueKind.Object &&
|
|
secretPayload.TryGetProperty("passwordHash", out var property) &&
|
|
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);
|
|
}
|
|
}
|