shoc-backend/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs

251 lines
11 KiB
C#

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<ApplicationUser> _userManager;
private readonly IAuthenticationService _authenticationService;
private readonly IEmailSender _emailSender;
private readonly TimeProvider _timeProvider;
private readonly ILogger<TeamMemberRegistrationService> _logger;
public TeamMemberRegistrationService(
ITeamMemberInviteDataService inviteDataService,
IUserDataService userDataService,
UserManager<ApplicationUser> userManager,
IAuthenticationService authenticationService,
IEmailSender emailSender,
TimeProvider timeProvider,
ILogger<TeamMemberRegistrationService> logger)
{
_inviteDataService = inviteDataService;
_userDataService = userDataService;
_userManager = userManager;
_authenticationService = authenticationService;
_emailSender = emailSender;
_timeProvider = timeProvider;
_logger = logger;
}
public async Task<TeamMemberRegistrationOutcomeDTO> 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<TeamMemberRegistrationOutcomeDTO> 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)
return ResendRefusal(context.Invite, now);
// Read the address at send time so an admin's correction is honoured.
var body =
$"<p>Your Seahaven confirmation code is <strong>{code}</strong>.</p>" +
$"<p>It expires in {CodeLifetime.TotalMinutes:0} minutes. If you didn't request it, you can ignore this email.</p>";
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<TeamMemberRegistrationOutcomeDTO> 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<TeamMemberRegistrationOutcomeDTO> 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);
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";
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<RegistrationContext?> 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.
return result.Errors.Any(error => error.Code == nameof(IdentityErrorDescriber.UserAlreadyHasPassword))
? TeamMemberRegistrationStatus.InvalidInvite
: TeamMemberRegistrationStatus.PasswordRejected;
}
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; }
}
}