using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Auth; using Tiku.Application.Tenancy; 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, ITenantExternalProviderConfigService providerConfigService) : 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 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 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 LoginWithWechatWebAsync( WechatLoginRequest request, CancellationToken cancellationToken = default) { return LoginWithWechatAsync( request, WechatWebProvider, WechatWebProviderAliases, (options, code, token) => wechatOAuthClient.ExchangeWebCodeAsync(options, code, token), cancellationToken); } public Task LoginWithWechatMiniAppAsync( WechatLoginRequest request, CancellationToken cancellationToken = default) { return LoginWithWechatAsync( request, WechatMiniAppProvider, WechatMiniAppProviderAliases, (options, code, token) => wechatOAuthClient.ExchangeMiniAppCodeAsync(options, code, token), cancellationToken); } public async Task RefreshAsync( RefreshSessionRequest request, CancellationToken cancellationToken = default) { if (!sessionService.TryParseRefreshToken(request.RefreshToken, out var locator)) { throw new SessionRevokedException(); } var tokenHash = sessionService.HashRefreshToken(request.RefreshToken); var now = DateTimeOffset.UtcNow; var session = await dbContext.AuthSessions .SingleOrDefaultAsync(entity => entity.Id == locator.SessionId && entity.TenantId == locator.TenantId && 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) { if (!sessionService.TryParseRefreshToken(request.RefreshToken, out var locator)) { return; } var tokenHash = sessionService.HashRefreshToken(request.RefreshToken); var session = await dbContext.AuthSessions .SingleOrDefaultAsync(entity => entity.Id == locator.SessionId && entity.TenantId == locator.TenantId && 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 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 LoginWithWechatAsync( WechatLoginRequest request, string provider, IReadOnlyList providerAliases, Func> 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 LoadWechatProviderOptionsAsync( Guid tenantId, string provider, IReadOnlyList aliases, CancellationToken cancellationToken) { TenantExternalProviderAccount? account = null; foreach (var alias in aliases) { try { account = await providerConfigService.GetActiveProviderAsync( tenantId, TenantExternalProviderCapability.Identity, alias, cancellationToken); break; } catch (TenantExternalProviderException) { } } if (account is null) { throw new AuthProviderNotConfiguredException(provider); } var appId = GetJsonString(account.ConfigPublic, "appId", "clientId"); var appSecret = GetJsonString(account.SecretPayload, "appSecret", "clientSecret", "secret"); if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(appSecret)) { throw new AuthProviderNotConfiguredException(provider); } return new WechatProviderOptions(appId, appSecret); } private async Task 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 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 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(identity.RawJson), updatedAt = DateTimeOffset.UtcNow }; return JsonSerializer.SerializeToElement(payload); } }