mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 10:43:13 +00:00
SendCodeAsync validates the invite, then starts the code with a conditional update that also requires the invite to still be open. When an admin revoked the link (or it was used) between those two reads, the refusal was reported as resend_too_soon with a Retry-After, although the link was already dead. On a refused start the invite is now read again: a closed invite gets the same generic invalid_invite response as any other dead link, and a cooldown or send limit refusal is computed from the fresh row.
270 lines
12 KiB
C#
270 lines
12 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)
|
|
{
|
|
// 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 =
|
|
$"<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);
|
|
|
|
// 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<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.
|
|
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; }
|
|
}
|
|
}
|