Files
tiku-backend.net/Tiku.Infrastructure/Auth/OwnerActivationService.cs

170 lines
8.3 KiB
C#

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<AuthenticationResult> 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<AuthenticationResult?> 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<TikuDbContext>();
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<UserManager<User>>();
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<IAuthSessionStore>().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);
}