mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 11:53:12 +00:00
251 lines
11 KiB
C#
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; }
|
|
}
|
|
}
|