using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Tiku.Application.Auth; using Tiku.Application.Security; using Tiku.Application.Tenancy; using Tiku.Domain.Identity; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; namespace Tiku.Infrastructure.Auth; public sealed partial class AuthService { 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); return await IssueAuthenticatedResultAsync( user, realm, tenant, membership, provider, identifier, ipAddress, userAgent, cancellationToken); } private async Task IssueAuthenticatedResultAsync( User user, AuthRealm realm, Tenant? tenant, TenantMembership? membership, string provider, 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, 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; } await using var transaction = dbContext.Database.CurrentTransaction is null ? await dbContext.Database.BeginTransactionAsync(cancellationToken) : null; var providerSubject = $"{config.AppId}:{identity.OpenId}"; var user = await UpsertWechatUserAsync( provider, providerSubject, config.AppId, identity, cancellationToken); await EnsureTenantMembershipAsync( request.TenantId.Value, user.Id, cancellationToken); // Persist the external identity and membership together only after the // tenant policy and existing membership state have accepted the login. // A denied first login must not leave a user or provider identity behind. await dbContext.SaveChangesAsync(cancellationToken); if (transaction is not null) await transaction.CommitAsync(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; 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 not null) // Invited and Disabled memberships require an explicit administrator action. throw new TenantAccessDeniedException(); var policy = await dbContext.TenantAuthPolicies.AsNoTracking() .SingleOrDefaultAsync(item => item.TenantId == tenantId, cancellationToken); if (policy is not null && !policy.AllowExternalStudentSelfRegistration) throw new TenantAccessDeniedException(); await featureAccessService.ConsumeQuotaIfConfiguredAsync( tenantId, SaasQuotaMetricCatalog.StudentCount, cancellationToken: cancellationToken); dbContext.TenantMemberships.Add(new TenantMembership { TenantId = tenantId, UserId = userId, Role = TenantRole.Student, Status = MembershipStatus.Active }); } 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 == BackendRoleStatus.Active && (permission.Area == BackendPermissionArea.Platform || permission.Area == 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 == BackendRoleStatus.Active && (permission.Area == BackendPermissionArea.Tenant || permission.Area == BackendPermissionArea.Both) select permission.Id).AnyAsync(cancellationToken); } private static string HashChallengeToken(string token) { return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token ?? string.Empty))).ToLowerInvariant(); } private async Task AddSecurityAuditAsync( Guid userId, Guid? tenantId, string action, string? reason, string? ipAddress, string? userAgent, CancellationToken cancellationToken) { dbContext.AuditLogs.Add(new 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 }); } }