using System.Text.Json; using System.Security.Cryptography; using System.Text; using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Identity; using Microsoft.IdentityModel.Tokens; 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, SignInManager signInManager, UserManager userManager, ISmsVerificationService smsVerificationService, IAuthSessionStore sessionStore, 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 identifier = request.Phone.Trim(); var normalizedEmail = userManager.NormalizeEmail(identifier); var normalizedUserName = userManager.NormalizeName(identifier); var user = await dbContext.Users .SingleOrDefaultAsync(entity => entity.Phone == identifier || entity.NormalizedEmail == normalizedEmail || entity.NormalizedUserName == normalizedUserName, cancellationToken); var passwordResult = user is null || user.Status != UserStatus.Active ? SignInResult.Failed : await signInManager.CheckPasswordSignInAsync(user, request.Password, lockoutOnFailure: true); if (!passwordResult.Succeeded) { var loginResult = passwordResult.IsLockedOut ? AuthLoginResult.Blocked : AuthLoginResult.Failed; var failureCode = passwordResult.IsLockedOut ? "account_locked" : "invalid_credentials"; await AddLoginEventAsync( request.TenantId, user?.Id, PasswordProvider, identifier, loginResult, failureCode, request.IpAddress, request.UserAgent, cancellationToken); throw new InvalidCredentialsException(); } return await CompleteSuccessfulLoginAsync( request.Realm, request.TenantId, user!, PasswordProvider, identifier, 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 { if (!request.TenantId.HasValue) { throw new InvalidCredentialsException("tenant_required_for_sms"); } await smsVerificationService.VerifyCodeAsync( request.TenantId.Value, 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.Realm, 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) { return await sessionStore.RotateAsync( request.RefreshToken, request.IpAddress, request.UserAgent, cancellationToken); } public async Task LogoutAsync( LogoutSessionRequest request, CancellationToken cancellationToken = default) { await sessionStore.RevokeFamilyAsync(request.RefreshToken, "logout", cancellationToken); } public async Task LogoutAllAsync(Guid userId, CancellationToken cancellationToken = default) { var user = await userManager.FindByIdAsync(userId.ToString()) ?? throw new InvalidCredentialsException(); var stampResult = await userManager.UpdateSecurityStampAsync(user); if (!stampResult.Succeeded) { throw new InvalidOperationException("Unable to update the user's security stamp."); } await sessionStore.RevokeAllAsync(userId, "logout_all", cancellationToken); } public async Task SetupTotpAsync( MfaChallengeRequest request, CancellationToken cancellationToken = default) { var challenge = await FindChallengeAsync( request.ChallengeToken, AuthChallengePurpose.MfaEnrollment, cancellationToken); var user = await userManager.FindByIdAsync(challenge.UserId.ToString()) ?? throw new InvalidAuthChallengeException(); var reset = await userManager.ResetAuthenticatorKeyAsync(user); if (!reset.Succeeded) { throw new InvalidOperationException("Unable to initialize the authenticator key."); } var key = await userManager.GetAuthenticatorKeyAsync(user) ?? throw new InvalidOperationException("Authenticator key was not generated."); challenge.SecurityStamp = user.SecurityStamp ?? string.Empty; var account = user.Email ?? user.Phone ?? user.Id.ToString(); var uri = $"otpauth://totp/{Uri.EscapeDataString("TIKU:" + account)}" + $"?secret={Uri.EscapeDataString(key)}&issuer={Uri.EscapeDataString("TIKU")}&digits=6"; await AddSecurityAuditAsync( user.Id, challenge.TenantId, "auth.mfa.enrollment_setup", null, request.IpAddress, request.UserAgent, cancellationToken); return new MfaSetupResult(key, uri); } public async Task ConfirmTotpAsync( MfaChallengeRequest request, CancellationToken cancellationToken = default) { var challenge = await FindChallengeAsync( request.ChallengeToken, AuthChallengePurpose.MfaEnrollment, cancellationToken); var user = await userManager.FindByIdAsync(challenge.UserId.ToString()) ?? throw new InvalidAuthChallengeException(); if (string.IsNullOrWhiteSpace(request.Code) || !await userManager.VerifyTwoFactorTokenAsync( user, TokenOptions.DefaultAuthenticatorProvider, NormalizeTotp(request.Code))) { await AddSecurityAuditAsync( user.Id, challenge.TenantId, "auth.mfa.enrollment_denied", "invalid_code", request.IpAddress, request.UserAgent, cancellationToken); throw new InvalidCredentialsException("invalid_mfa_code"); } var enabled = await userManager.SetTwoFactorEnabledAsync(user, true); if (!enabled.Succeeded) { throw new InvalidOperationException("Unable to enable two-factor authentication."); } await ConsumeChallengeAsync(challenge, cancellationToken); await AddSecurityAuditAsync( user.Id, challenge.TenantId, "auth.mfa.enrollment_confirmed", null, request.IpAddress, request.UserAgent, cancellationToken); var recoveryCodes = (await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10))?.ToArray() ?? []; var authentication = await IssueFromChallengeAsync( challenge, user, request.IpAddress, request.UserAgent, cancellationToken); return new MfaConfirmResult(authentication, recoveryCodes); } public async Task VerifyTotpAsync( MfaChallengeRequest request, CancellationToken cancellationToken = default) { var challenge = await FindChallengeAsync( request.ChallengeToken, AuthChallengePurpose.MfaVerification, cancellationToken); var user = await userManager.FindByIdAsync(challenge.UserId.ToString()) ?? throw new InvalidAuthChallengeException(); var recoveryCode = request.Code?.Trim(); var totpCode = NormalizeTotp(request.Code); var verifiedByTotp = !string.IsNullOrWhiteSpace(totpCode) && await userManager.VerifyTwoFactorTokenAsync( user, TokenOptions.DefaultAuthenticatorProvider, totpCode); var verifiedByRecoveryCode = !verifiedByTotp && !string.IsNullOrWhiteSpace(recoveryCode) && (await userManager.RedeemTwoFactorRecoveryCodeAsync(user, recoveryCode)).Succeeded; if (!verifiedByTotp && !verifiedByRecoveryCode) { await AddSecurityAuditAsync( user.Id, challenge.TenantId, "auth.mfa.verification_denied", "invalid_code", request.IpAddress, request.UserAgent, cancellationToken); throw new InvalidCredentialsException("invalid_mfa_code"); } await ConsumeChallengeAsync(challenge, cancellationToken); await AddSecurityAuditAsync( user.Id, challenge.TenantId, "auth.mfa.verified", verifiedByRecoveryCode ? "recovery_code" : "totp", request.IpAddress, request.UserAgent, cancellationToken); return await IssueFromChallengeAsync( challenge, user, request.IpAddress, request.UserAgent, cancellationToken); } public async Task ChangeRequiredPasswordAsync( PasswordChangeChallengeRequest request, CancellationToken cancellationToken = default) { var challenge = await FindChallengeAsync( request.ChallengeToken, AuthChallengePurpose.PasswordChange, cancellationToken); var user = await userManager.FindByIdAsync(challenge.UserId.ToString()) ?? throw new InvalidAuthChallengeException(); var resetToken = await userManager.GeneratePasswordResetTokenAsync(user); var reset = await userManager.ResetPasswordAsync(user, resetToken, request.NewPassword); if (!reset.Succeeded) { throw new InvalidCredentialsException("invalid_new_password"); } user.ForcePasswordChange = false; var updated = await userManager.UpdateAsync(user); if (!updated.Succeeded) { throw new InvalidOperationException("Unable to clear the password-change requirement."); } await sessionStore.RevokeAllAsync(user.Id, "password_changed", cancellationToken); await ConsumeChallengeAsync(challenge, cancellationToken); await AddSecurityAuditAsync( user.Id, challenge.TenantId, "auth.password.changed", null, request.IpAddress, request.UserAgent, cancellationToken); return await CompleteSuccessfulLoginAsync( challenge.Realm, challenge.TenantId, user, challenge.Provider, user.Email ?? user.Phone ?? user.Id.ToString(), request.IpAddress, request.UserAgent, cancellationToken); } private async Task IssueFromChallengeAsync( AuthChallenge challenge, User user, string? ipAddress, string? userAgent, CancellationToken cancellationToken) { if (!await HasBackendPermissionsAsync( challenge.Realm, challenge.TenantId, user.Id, cancellationToken)) { throw new InvalidAuthChallengeException("backend_access_revoked"); } Tenant? tenant = null; TenantMembership? membership = null; if (challenge.Realm == AuthRealm.Tenant && challenge.TenantId.HasValue) { tenant = await dbContext.Tenants.SingleOrDefaultAsync( item => item.Id == challenge.TenantId.Value && item.Status == TenantStatus.Active, cancellationToken); membership = await FindActiveMembershipAsync(challenge.TenantId.Value, user.Id, cancellationToken); if (tenant is null || membership is null) { throw new TenantAccessDeniedException(); } } return await IssueAuthenticatedResultAsync( user, challenge.Realm, tenant, membership, challenge.Provider, mfaSatisfied: true, null, ipAddress, userAgent, cancellationToken); } private async Task FindChallengeAsync( string token, AuthChallengePurpose purpose, CancellationToken cancellationToken) { var tokenHash = HashChallengeToken(token); var now = DateTimeOffset.UtcNow; return await dbContext.AuthChallenges.SingleOrDefaultAsync( item => item.TokenHash == tokenHash && item.Purpose == purpose && item.ConsumedAt == null && item.ExpiresAt > now && dbContext.Users.Any(user => user.Id == item.UserId && user.Status == UserStatus.Active && user.SecurityStamp == item.SecurityStamp), cancellationToken) ?? throw new InvalidAuthChallengeException(); } private async Task ConsumeChallengeAsync(AuthChallenge challenge, CancellationToken cancellationToken) { var now = DateTimeOffset.UtcNow; var consumed = await dbContext.AuthChallenges .Where(item => item.Id == challenge.Id && item.ConsumedAt == null && item.ExpiresAt > now) .ExecuteUpdateAsync(setters => setters.SetProperty(item => item.ConsumedAt, now), cancellationToken); if (consumed != 1) { throw new InvalidAuthChallengeException(); } } private async Task CompleteSuccessfulLoginAsync( AuthRealm realm, Guid? tenantId, User user, string provider, string identifier, string? ipAddress, string? userAgent, CancellationToken cancellationToken) { if (user.Status != UserStatus.Active) { await AddLoginEventAsync( tenantId, user.Id, provider, identifier, AuthLoginResult.Failed, "user_disabled", ipAddress, userAgent, cancellationToken); throw new InvalidCredentialsException(); } TenantMembership? membership = null; Tenant? tenant = null; if (realm == AuthRealm.Tenant && tenantId.HasValue) { membership = await FindActiveMembershipAsync(tenantId.Value, user.Id, cancellationToken); tenant = await dbContext.Tenants.SingleOrDefaultAsync( item => item.Id == tenantId.Value && item.Status == TenantStatus.Active, cancellationToken); if (membership is null || tenant is null) { await AddLoginEventAsync(tenantId, user.Id, provider, identifier, AuthLoginResult.Failed, "tenant_access_denied", ipAddress, userAgent, cancellationToken); throw new TenantAccessDeniedException(); } } else if (realm == AuthRealm.Platform) { if (!await HasBackendPermissionsAsync(realm, tenantId, user.Id, cancellationToken)) { await AddLoginEventAsync( null, user.Id, provider, identifier, AuthLoginResult.Failed, "platform_access_denied", ipAddress, userAgent, cancellationToken); throw new TenantAccessDeniedException(); } } else { throw new TenantAccessDeniedException(); } if (user.ForcePasswordChange) { return await CreateChallengeResultAsync( user, realm, tenantId, AuthChallengePurpose.PasswordChange, provider, AuthenticationStatus.PasswordChangeRequired, ipAddress, userAgent, cancellationToken); } var requiresMfa = await HasBackendPermissionsAsync(realm, tenantId, user.Id, cancellationToken); if (requiresMfa) { var hasAuthenticator = user.TwoFactorEnabled && !string.IsNullOrWhiteSpace(await userManager.GetAuthenticatorKeyAsync(user)); return await CreateChallengeResultAsync( user, realm, tenantId, hasAuthenticator ? AuthChallengePurpose.MfaVerification : AuthChallengePurpose.MfaEnrollment, provider, hasAuthenticator ? AuthenticationStatus.MfaRequired : AuthenticationStatus.MfaEnrollmentRequired, ipAddress, userAgent, cancellationToken); } return await IssueAuthenticatedResultAsync( user, realm, tenant, membership, provider, mfaSatisfied: false, identifier, ipAddress, userAgent, cancellationToken); } private async Task IssueAuthenticatedResultAsync( User user, AuthRealm realm, Tenant? tenant, TenantMembership? membership, string provider, bool mfaSatisfied, string? identifier, string? ipAddress, string? userAgent, CancellationToken cancellationToken) { var tokens = await sessionStore.IssueAsync( new AuthSessionIssueRequest( user.Id, user.Phone, user.Email, user.SecurityStamp ?? string.Empty, realm, tenant?.Id, provider, mfaSatisfied, ipAddress, userAgent), cancellationToken); await AddLoginEventAsync( tenant?.Id, user.Id, provider, identifier, AuthLoginResult.Success, null, ipAddress, userAgent, cancellationToken); var tenantSummary = tenant is not null && membership is not null ? new TenantMembershipSummary(tenant.Id, tenant.Name, membership.Role, membership.Status) : null; return new AuthenticationResult( AuthenticationStatus.Authenticated, new AuthenticatedUser(user.Id, user.Phone, user.Email, user.Name, realm, tenantSummary, tokens)); } private async Task LoginWithWechatAsync( WechatLoginRequest request, string provider, IReadOnlyList providerAliases, Func> exchangeCodeAsync, CancellationToken cancellationToken) { if (request.Realm != AuthRealm.Tenant || !request.TenantId.HasValue) { throw new InvalidCredentialsException("tenant_realm_required_for_wechat"); } var config = await LoadWechatProviderOptionsAsync( request.TenantId.Value, 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.Value, user.Id, cancellationToken); return await CompleteSuccessfulLoginAsync( request.Realm, 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; 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 CreateChallengeResultAsync( User user, AuthRealm realm, Guid? tenantId, AuthChallengePurpose purpose, string provider, AuthenticationStatus status, string? ipAddress, string? userAgent, CancellationToken cancellationToken) { var realmCode = realm == AuthRealm.Tenant ? "t" : "p"; var tenantCode = tenantId?.ToString("N") ?? "-"; var rawToken = $"c1.{realmCode}.{tenantCode}.{Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(48))}"; var expiresAt = DateTimeOffset.UtcNow.AddMinutes(5); dbContext.AuthChallenges.Add(new AuthChallenge { UserId = user.Id, Realm = realm, TenantId = tenantId, Purpose = purpose, TokenHash = HashChallengeToken(rawToken), SecurityStamp = user.SecurityStamp ?? string.Empty, Provider = provider, ExpiresAt = expiresAt, IpAddress = ipAddress, UserAgent = userAgent }); await dbContext.SaveChangesAsync(cancellationToken); await AddSecurityAuditAsync( user.Id, tenantId, "auth.challenge.issued", status.ToString(), ipAddress, userAgent, cancellationToken); return new AuthenticationResult(status, ChallengeToken: rawToken, ChallengeExpiresAt: expiresAt); } private async Task HasBackendPermissionsAsync( AuthRealm realm, Guid? tenantId, Guid userId, CancellationToken cancellationToken) { if (realm == AuthRealm.Platform) { return await ( from userRole in dbContext.PlatformBackendUserRoles join role in dbContext.PlatformBackendRoles on userRole.RoleId equals role.Id join binding in dbContext.PlatformBackendRolePermissions on role.Id equals binding.RoleId join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code where userRole.UserId == userId && role.Status == Tiku.Domain.Operations.BackendRoleStatus.Active && (permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Platform || permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Both) select permission.Id).AnyAsync(cancellationToken); } if (!tenantId.HasValue) { return false; } return await ( from userRole in dbContext.TenantBackendUserRoles join role in dbContext.TenantBackendRoles on userRole.RoleId equals role.Id join binding in dbContext.TenantBackendRolePermissions on role.Id equals binding.RoleId join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code where userRole.TenantId == tenantId.Value && userRole.UserId == userId && binding.TenantId == tenantId.Value && role.Status == Tiku.Domain.Operations.BackendRoleStatus.Active && (permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Tenant || permission.Area == Tiku.Domain.Operations.BackendPermissionArea.Both) select permission.Id).AnyAsync(cancellationToken); } private static string HashChallengeToken(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token ?? string.Empty))).ToLowerInvariant(); private static string NormalizeTotp(string? code) => (code ?? string.Empty).Replace(" ", string.Empty, StringComparison.Ordinal) .Replace("-", string.Empty, StringComparison.Ordinal); private async Task AddSecurityAuditAsync( Guid userId, Guid? tenantId, string action, string? reason, string? ipAddress, string? userAgent, CancellationToken cancellationToken) { dbContext.AuditLogs.Add(new Tiku.Domain.Operations.AuditLog { TenantId = tenantId, ActorUserId = userId, Action = action, TargetType = "user", TargetId = userId.ToString(), Details = JsonSerializer.SerializeToElement(new { reason }), IpAddress = ipAddress, UserAgent = userAgent }); await dbContext.SaveChangesAsync(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 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) { return JsonSerializer.SerializeToElement(new { openId = identity.OpenId, unionId = identity.UnionId, nickname = identity.Nickname, avatarUrl = identity.AvatarUrl }); } }