using System.Security.Cryptography; using System.Text; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Application.Auth; using Tiku.Application.Security; using Tiku.Application.Tenancy; using Tiku.Domain.Identity; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Auth; internal sealed class OwnerActivationService( ITenantExecutionScope tenantExecutionScope, ITenantRuntimeCacheInvalidator runtimeCacheInvalidator) : IOwnerActivationService { public async Task CompleteAsync( CompleteOwnerActivationRequest request, CancellationToken cancellationToken = default) { await CompleteCoreAsync(request, null, null, null, null, false, cancellationToken); } public async Task CompleteAndAuthenticateAsync( CompleteOwnerActivationRequest request, Guid expectedTenantId, string expectedHost, string? ipAddress, string? userAgent, CancellationToken cancellationToken = default) { var result = await CompleteCoreAsync( request, expectedTenantId, expectedHost, ipAddress, userAgent, true, cancellationToken) ?? throw Error("Owner activation session could not be established.", "owner_activation_failed"); await runtimeCacheInvalidator.InvalidateAsync(expectedTenantId, cancellationToken); return result; } private Task CompleteCoreAsync( CompleteOwnerActivationRequest request, Guid? expectedTenantId, string? expectedHost, string? ipAddress, string? userAgent, bool authenticate, CancellationToken cancellationToken) => tenantExecutionScope.ExecuteAsync( new SystemScopeRequest( null, SystemScopeCallerType.Anonymous, nameof(OwnerActivationService), "Complete tenant owner activation", request.ActivationId.ToString("N"), true), async (services, token) => { var dbContext = services.GetRequiredService(); var grant = await dbContext.TenantOwnerActivationGrants .SingleOrDefaultAsync(value => value.Id == request.ActivationId, token) ?? throw Error("Owner activation was not found.", "owner_activation_invalid"); var now = DateTimeOffset.UtcNow; var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(request.Token))).ToLowerInvariant(); if (grant.ConsumedAt.HasValue) { throw Error("Owner activation was already consumed.", "owner_activation_consumed"); } if (grant.RevokedAt.HasValue || grant.ExpiresAt <= now || !CryptographicOperations.FixedTimeEquals( Convert.FromHexString(grant.TokenHash), Convert.FromHexString(tokenHash))) { throw Error("Owner activation is invalid or expired.", "owner_activation_invalid"); } if (expectedTenantId.HasValue) { if (grant.TenantId != expectedTenantId || grant.DomainId is not { } domainId) { throw Error("Owner activation does not belong to this tenant host.", "owner_activation_host_mismatch"); } var normalizedHost = expectedHost!.Trim().TrimEnd('.').ToLowerInvariant(); var domainMatches = await dbContext.TenantDomains.AsNoTracking().AnyAsync(value => value.Id == domainId && value.TenantId == grant.TenantId && value.IsPrimary && value.Status == TenantDomainStatus.Active && value.Host == normalizedHost, token); if (!domainMatches) { throw Error("Owner activation does not belong to this tenant host.", "owner_activation_host_mismatch"); } } var claimed = await dbContext.TenantOwnerActivationGrants .Where(value => value.Id == grant.Id && value.ConsumedAt == null && value.RevokedAt == null && value.ExpiresAt > now && value.TokenHash == tokenHash) .ExecuteUpdateAsync(setters => setters .SetProperty(value => value.ConsumedAt, now) .SetProperty(value => value.UpdatedAt, now), token); if (claimed != 1) { throw Error("Owner activation is invalid or already consumed.", "owner_activation_consumed"); } var userManager = services.GetRequiredService>(); var user = await userManager.FindByIdAsync(grant.UserId.ToString()) ?? throw Error("Owner account was not found.", "owner_activation_invalid"); if (await userManager.HasPasswordAsync(user)) { throw Error("Owner account was already activated.", "owner_activation_consumed"); } var result = await userManager.AddPasswordAsync(user, request.NewPassword); if (!result.Succeeded) { throw Error( string.Join("; ", result.Errors.Select(error => error.Description)), "owner_activation_password_invalid"); } user.ForcePasswordChange = false; var updateResult = await userManager.UpdateAsync(user); if (!updateResult.Succeeded) { throw Error("Owner account activation could not be completed.", "owner_activation_failed"); } await userManager.UpdateSecurityStampAsync(user); dbContext.AuditLogs.Add(new AuditLog { TenantId = grant.TenantId, ActorUserId = grant.UserId, Action = "tenant.owner.activated", TargetType = "users", TargetId = grant.UserId.ToString() }); AuthenticationResult? authentication = null; if (authenticate) { var tenant = await dbContext.Tenants.SingleAsync(value => value.Id == grant.TenantId, token); var membership = await dbContext.TenantMemberships.AsNoTracking().SingleAsync(value => value.TenantId == grant.TenantId && value.UserId == grant.UserId && value.Status == MembershipStatus.Active, token); var tokens = await services.GetRequiredService().IssueAsync( new AuthSessionIssueRequest( user.Id, user.Phone, user.Email, user.SecurityStamp ?? string.Empty, AuthRealm.Tenant, tenant.Id, "owner_activation", ipAddress, userAgent), token); dbContext.AuthLoginEvents.Add(new AuthLoginEvent { TenantId = tenant.Id, UserId = user.Id, Provider = "owner_activation", Identifier = user.Email ?? user.Phone, Result = AuthLoginResult.Success, IpAddress = ipAddress, UserAgent = userAgent }); authentication = new AuthenticationResult( AuthenticationStatus.Authenticated, new AuthenticatedUser( user.Id, user.Phone, user.Email, user.Name, AuthRealm.Tenant, new TenantMembershipSummary(tenant.Id, tenant.Name, membership.Role, membership.Status), tokens)); } await dbContext.SaveChangesAsync(token); return authentication; }, cancellationToken); private static OwnerActivationException Error(string message, string code) => new(message, code); }