using System.Text.RegularExpressions; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; using SeaHaven.DataServices.Dto; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Constants; using SeaHaven.Services.DTOs; using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation; public sealed partial class TeamMemberRegistrationService : ITeamMemberRegistrationService { public static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(15); public static readonly TimeSpan ResendCooldown = TimeSpan.FromSeconds(60); public const int MaxCodeAttempts = 5; public const int MaxCodeSends = 10; private readonly ITeamMemberInviteDataService _inviteDataService; private readonly IUserDataService _userDataService; private readonly UserManager _userManager; private readonly IAuthenticationService _authenticationService; private readonly IEmailSender _emailSender; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; public TeamMemberRegistrationService( ITeamMemberInviteDataService inviteDataService, IUserDataService userDataService, UserManager userManager, IAuthenticationService authenticationService, IEmailSender emailSender, TimeProvider timeProvider, ILogger logger) { _inviteDataService = inviteDataService; _userDataService = userDataService; _userManager = userManager; _authenticationService = authenticationService; _emailSender = emailSender; _timeProvider = timeProvider; _logger = logger; } public async Task ResolveAsync(string? token, CancellationToken cancellationToken) { var context = await LoadAsync(token, NowUtc(), cancellationToken); if (context is null) return Outcome(TeamMemberRegistrationStatus.InvalidInvite); var roles = await _userManager.GetRolesAsync(context.User); var role = roles.FirstOrDefault(); return new TeamMemberRegistrationOutcomeDTO { Status = TeamMemberRegistrationStatus.Ok, Details = new TeamMemberInviteDetailsDTO { Name = $"{context.User.FirstName ?? ""} {context.User.LastName ?? ""}".Trim(), Role = TeamMemberConstants.CanonicalRole(role) ?? role ?? "", Email = context.User.Email, Phone = context.User.Contact ?? context.User.PhoneNumber } }; } public async Task SendCodeAsync(string? token, CancellationToken cancellationToken) { var now = NowUtc(); var context = await LoadAsync(token, now, cancellationToken); if (context is null || string.IsNullOrWhiteSpace(context.User.Email)) return Outcome(TeamMemberRegistrationStatus.InvalidInvite); var code = TeamMemberInviteSecrets.NewCode(); var salt = TeamMemberInviteSecrets.NewCodeSalt(); var started = await _inviteDataService.TryStartCodeAsync( new StartTeamMemberInviteCodeCommand { InviteId = context.Invite.Id, CodeHash = TeamMemberInviteSecrets.HashCode(salt, code), CodeSalt = salt, Now = now, CodeExpiresAt = now.Add(CodeLifetime), LastSentNoLaterThan = now.Subtract(ResendCooldown), MaxSends = MaxCodeSends }, cancellationToken); if (!started) { // The refusal may be the invite closing since it was read, not the cooldown or limit: // read it again so a revoked or used link gets the same answer as any other dead link. var current = await LoadAsync(token, now, cancellationToken); return current is null ? Outcome(TeamMemberRegistrationStatus.InvalidInvite) : ResendRefusal(current.Invite, now); } // Read the address at send time so an admin's correction is honoured. var body = $"

Your Seahaven confirmation code is {code}.

" + $"

It expires in {CodeLifetime.TotalMinutes:0} minutes. If you didn't request it, you can ignore this email.

"; if (!await _emailSender.SendEmailAsync(context.User.Email, "Your Seahaven confirmation code", body)) { _logger.LogWarning("Confirmation code email could not be sent for team member invite {InviteId}.", context.Invite.Id); return Outcome(TeamMemberRegistrationStatus.CodeDeliveryFailed); } return Outcome(TeamMemberRegistrationStatus.Ok); } public async Task VerifyCodeAsync( string? token, string? code, CancellationToken cancellationToken) { var now = NowUtc(); var context = await LoadAsync(token, now, cancellationToken); if (context is null) return Outcome(TeamMemberRegistrationStatus.InvalidInvite); var invite = context.Invite; if (invite.CodeHash is null || invite.CodeSalt is null || invite.CodeExpiresAt is null || invite.CodeExpiresAt <= now) return Outcome(TeamMemberRegistrationStatus.CodeExpired); // Reserve the attempt before comparing, so parallel guesses cannot exceed the limit. if (!await _inviteDataService.TryReserveCodeAttemptAsync(invite.Id, MaxCodeAttempts, now, cancellationToken)) { return Outcome(invite.CodeFailedAttempts >= MaxCodeAttempts ? TeamMemberRegistrationStatus.CodeLocked : TeamMemberRegistrationStatus.CodeExpired); } if (!TeamMemberInviteSecrets.CodeMatches(invite.CodeSalt, (code ?? "").Trim(), invite.CodeHash)) { return Outcome(invite.CodeFailedAttempts + 1 >= MaxCodeAttempts ? TeamMemberRegistrationStatus.CodeLocked : TeamMemberRegistrationStatus.CodeIncorrect); } return await _inviteDataService.TryConfirmEmailAsync(invite.Id, invite.CodeHash, now, cancellationToken) ? Outcome(TeamMemberRegistrationStatus.Ok) : Outcome(TeamMemberRegistrationStatus.CodeExpired); } public async Task CompleteAsync( CompleteTeamMemberRegistrationRequestDTO request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); var now = NowUtc(); var context = await LoadAsync(request.Token, now, cancellationToken); if (context is null) return Outcome(TeamMemberRegistrationStatus.InvalidInvite); if (context.Invite.EmailConfirmedAt is null) return Outcome(TeamMemberRegistrationStatus.EmailNotConfirmed); // An omitted phone keeps what the admin entered; an explicit empty value clears it. var updatePhone = request.Phone is not null; var phone = string.IsNullOrWhiteSpace(request.Phone) ? null : request.Phone.Trim(); if (phone is not null && !PhonePattern().IsMatch(phone)) return Outcome(TeamMemberRegistrationStatus.InvalidPhone); var user = context.User; try { await _userDataService.ExecuteTransactionalAsync( async transactionCancellationToken => { if (!await _inviteDataService.TryClaimAsync(context.Invite.Id, now, transactionCancellationToken)) throw new RegistrationAbortedException(TeamMemberRegistrationStatus.InvalidInvite); user.EmailConfirmed = true; user.PendingRegistration = false; user.UniqueName = "Active"; if (updatePhone) { user.PhoneNumber = phone; user.Contact = phone; } // Identity applies the shared password policy and persists the user. var result = await _userManager.AddPasswordAsync(user, request.Password ?? ""); if (!result.Succeeded) throw new RegistrationAbortedException(StatusFor(result)); }, cancellationToken); } catch (RegistrationAbortedException aborted) { return Outcome(aborted.Status); } _logger.LogInformation("Team member {UserId} completed invite registration.", user.Id); return new TeamMemberRegistrationOutcomeDTO { Status = TeamMemberRegistrationStatus.Ok, Session = await _authenticationService.CreateSessionAsync(user, cancellationToken) }; } private async Task LoadAsync(string? token, DateTime now, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); if (string.IsNullOrWhiteSpace(token) || token.Length > TeamMemberInviteSecrets.MaxTokenLength) return null; var invite = await _inviteDataService.GetByTokenHashAsync( TeamMemberInviteSecrets.HashToken(token.Trim()), cancellationToken); if (invite is null || invite.UsedAt is not null || invite.RevokedAt is not null || invite.ExpiresAt <= now) return null; var user = await _userManager.FindByIdAsync(invite.UserId); if (user is null || user.IsDeleted == true || user.PendingRegistration != true) return null; return new RegistrationContext(invite, user); } private static TeamMemberRegistrationOutcomeDTO ResendRefusal(TeamMemberInviteData invite, DateTime now) { if (invite.CodeSendCount >= MaxCodeSends) return Outcome(TeamMemberRegistrationStatus.ResendLimitReached); var allowedAt = (invite.CodeSentAt ?? now).Add(ResendCooldown); return new TeamMemberRegistrationOutcomeDTO { Status = TeamMemberRegistrationStatus.ResendTooSoon, RetryAfterSeconds = Math.Max(1, (int)Math.Ceiling((allowedAt - now).TotalSeconds)) }; } private static TeamMemberRegistrationStatus StatusFor(IdentityResult result) { // A password already set means the account was registered by another route. if (result.Errors.Any(error => error.Code == nameof(IdentityErrorDescriber.UserAlreadyHasPassword))) return TeamMemberRegistrationStatus.InvalidInvite; if (IdentityPasswordPolicy.IsPolicyRejection(result)) return TeamMemberRegistrationStatus.PasswordRejected; // Anything else (a concurrency conflict, a store failure) is not the member's password: // roll back and let the endpoint answer with its generic error. Only codes are recorded. throw new InvalidOperationException( "Identity refused the registration: " + string.Join(", ", result.Errors.Select(error => error.Code))); } private DateTime NowUtc() => _timeProvider.GetUtcNow().UtcDateTime; private static TeamMemberRegistrationOutcomeDTO Outcome(TeamMemberRegistrationStatus status) => new() { Status = status }; [GeneratedRegex(@"^[0-9+()\-.\s]{7,32}$")] private static partial Regex PhonePattern(); private sealed record RegistrationContext(TeamMemberInviteData Invite, ApplicationUser User); private sealed class RegistrationAbortedException : Exception { public RegistrationAbortedException(TeamMemberRegistrationStatus status) : base(status.ToString()) { Status = status; } public TeamMemberRegistrationStatus Status { get; } } }