mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
feat(team-members): invite registration with emailed code confirmation (SH-385)
This commit is contained in:
parent
66a49ab957
commit
c0ae8479ce
27 changed files with 6363 additions and 54 deletions
120
Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs
Normal file
120
Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
using System.Reflection;
|
||||||
|
using Api.SeaHavenIndustries.Controllers;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Moq;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class TeamMemberInviteControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void RegistrationRequests_CarryNoUserIdentifier()
|
||||||
|
{
|
||||||
|
var requestTypes = new[]
|
||||||
|
{
|
||||||
|
typeof(TeamMemberInviteTokenRequestDTO),
|
||||||
|
typeof(VerifyTeamMemberInviteCodeRequestDTO),
|
||||||
|
typeof(CompleteTeamMemberRegistrationRequestDTO)
|
||||||
|
};
|
||||||
|
|
||||||
|
var properties = requestTypes
|
||||||
|
.SelectMany(type => type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||||
|
.Select(property => property.Name)
|
||||||
|
.Distinct()
|
||||||
|
.OrderBy(name => name);
|
||||||
|
|
||||||
|
properties.Should().Equal("Code", "Password", "Phone", "Token");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegistrationEndpoints_AreAnonymousAndNeverCached()
|
||||||
|
{
|
||||||
|
var type = typeof(TeamMemberInviteController);
|
||||||
|
|
||||||
|
type.GetCustomAttribute<AllowAnonymousAttribute>().Should().NotBeNull();
|
||||||
|
var cache = type.GetCustomAttribute<ResponseCacheAttribute>();
|
||||||
|
cache.Should().NotBeNull();
|
||||||
|
cache!.NoStore.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(TeamMemberRegistrationStatus.InvalidInvite)]
|
||||||
|
[InlineData(TeamMemberRegistrationStatus.Ok)]
|
||||||
|
public async Task Complete_WithoutASession_ReturnsTheGenericInviteError(TeamMemberRegistrationStatus status)
|
||||||
|
{
|
||||||
|
var service = new Mock<ITeamMemberRegistrationService>();
|
||||||
|
service.Setup(x => x.CompleteAsync(It.IsAny<CompleteTeamMemberRegistrationRequestDTO>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new TeamMemberRegistrationOutcomeDTO { Status = status });
|
||||||
|
var controller = new TeamMemberInviteController(service.Object)
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await controller.Complete(new CompleteTeamMemberRegistrationRequestDTO(), CancellationToken.None);
|
||||||
|
|
||||||
|
var failure = result.Should().BeOfType<ObjectResult>().Subject;
|
||||||
|
failure.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
|
||||||
|
failure.Value.Should().BeEquivalentTo(new
|
||||||
|
{
|
||||||
|
code = "invalid_invite",
|
||||||
|
message = TeamMemberInviteController.InvalidInviteMessage
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResendInvite_RequiresAuthenticatedCaller()
|
||||||
|
{
|
||||||
|
typeof(TeamMemberController).GetCustomAttribute<AuthorizeAttribute>().Should().NotBeNull();
|
||||||
|
typeof(TeamMemberController).GetMethod(nameof(TeamMemberController.ResendInvite))!
|
||||||
|
.GetCustomAttribute<AllowAnonymousAttribute>().Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResendInvite_Success_ReturnsInviteSent()
|
||||||
|
{
|
||||||
|
var invites = new Mock<ITeamMemberInviteService>();
|
||||||
|
invites.Setup(x => x.ResendAsync("user-1", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new TeamMemberInviteResendOutcomeDTO { Success = true });
|
||||||
|
|
||||||
|
var result = await NewTeamMemberController(invites).ResendInvite("user-1", CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeOfType<OkObjectResult>().Which.Value.Should().BeEquivalentTo(new { message = "Invite sent" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Forbidden", typeof(ForbidResult))]
|
||||||
|
[InlineData("Team member not found.", typeof(NotFoundObjectResult))]
|
||||||
|
[InlineData("Only pending team members can be re-invited.", typeof(BadRequestObjectResult))]
|
||||||
|
[InlineData("The invite could not be emailed. Try again.", typeof(BadRequestObjectResult))]
|
||||||
|
public async Task ResendInvite_Failure_MapsToHttpResult(string error, Type expected)
|
||||||
|
{
|
||||||
|
var invites = new Mock<ITeamMemberInviteService>();
|
||||||
|
invites.Setup(x => x.ResendAsync("user-1", It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new TeamMemberInviteResendOutcomeDTO { Success = false, Error = error });
|
||||||
|
|
||||||
|
var result = await NewTeamMemberController(invites).ResendInvite("user-1", CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeOfType(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TeamMemberController NewTeamMemberController(Mock<ITeamMemberInviteService> invites)
|
||||||
|
{
|
||||||
|
return new TeamMemberController(Mock.Of<ITeamMemberService>(), invites.Object)
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "Test"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,7 +28,8 @@ public sealed class TeamMemberServiceTests
|
||||||
Mock.Of<IUserServiceAreaDataService>(),
|
Mock.Of<IUserServiceAreaDataService>(),
|
||||||
Mock.Of<ITeamPermissionOverrideDataService>(),
|
Mock.Of<ITeamPermissionOverrideDataService>(),
|
||||||
Mock.Of<IUserDataService>(),
|
Mock.Of<IUserDataService>(),
|
||||||
Mock.Of<ITeamPermissionService>());
|
Mock.Of<ITeamPermissionService>(),
|
||||||
|
Mock.Of<ITeamMemberInviteService>());
|
||||||
|
|
||||||
var result = await service.CreateAsync(
|
var result = await service.CreateAsync(
|
||||||
ValidRequest() with { ServiceAreas = Array.Empty<string>() },
|
ValidRequest() with { ServiceAreas = Array.Empty<string>() },
|
||||||
|
|
@ -408,7 +409,8 @@ public sealed class TeamMemberServiceTests
|
||||||
areas.Object,
|
areas.Object,
|
||||||
overrides.Object,
|
overrides.Object,
|
||||||
userData.Object,
|
userData.Object,
|
||||||
permissions.Object);
|
permissions.Object,
|
||||||
|
Mock.Of<ITeamMemberInviteService>());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Mock<UserManager<ApplicationUser>> UserManager()
|
private static Mock<UserManager<ApplicationUser>> UserManager()
|
||||||
|
|
|
||||||
|
|
@ -33,16 +33,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
var result = await _authenticationService.LoginAsync(model.Username, model.Password, cancellationToken);
|
var result = await _authenticationService.LoginAsync(model.Username, model.Password, cancellationToken);
|
||||||
if (result != null)
|
if (result != null)
|
||||||
{
|
{
|
||||||
return Ok(new
|
return Ok(LoginPayload.From(result));
|
||||||
{
|
|
||||||
token = result.Token,
|
|
||||||
expiration = result.Expiration,
|
|
||||||
email = result.Email,
|
|
||||||
userRoles = result.UserRole,
|
|
||||||
phoneNumber = result.PhoneNumber,
|
|
||||||
fullname = result.Fullname,
|
|
||||||
id = result.Id
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,27 @@ namespace Api.SeaHavenIndustries.Controllers;
|
||||||
public sealed class TeamMemberController : ControllerBase
|
public sealed class TeamMemberController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ITeamMemberService _teamMemberService;
|
private readonly ITeamMemberService _teamMemberService;
|
||||||
|
private readonly ITeamMemberInviteService _inviteService;
|
||||||
|
|
||||||
public TeamMemberController(ITeamMemberService teamMemberService)
|
public TeamMemberController(ITeamMemberService teamMemberService, ITeamMemberInviteService inviteService)
|
||||||
{
|
{
|
||||||
_teamMemberService = teamMemberService;
|
_teamMemberService = teamMemberService;
|
||||||
|
_inviteService = inviteService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{userId}/invite")]
|
||||||
|
public async Task<IActionResult> ResendInvite(string userId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var outcome = await _inviteService.ResendAsync(userId, User, cancellationToken);
|
||||||
|
if (outcome.Success)
|
||||||
|
return Ok(new { message = "Invite sent" });
|
||||||
|
|
||||||
|
return outcome.Error switch
|
||||||
|
{
|
||||||
|
"Forbidden" => Forbid(),
|
||||||
|
"Team member not found." => NotFound(new { message = outcome.Error }),
|
||||||
|
_ => BadRequest(new { message = outcome.Error })
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
using Api.SeaHavenIndustries.DTOs;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Anonymous invite registration. The invite token travels only in request bodies,
|
||||||
|
/// and every failure maps to a fixed public message.
|
||||||
|
/// </summary>
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/team-member-invites")]
|
||||||
|
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||||
|
public sealed class TeamMemberInviteController : ControllerBase
|
||||||
|
{
|
||||||
|
public const string InvalidInviteMessage =
|
||||||
|
"This invite link is invalid or has expired. Ask your admin to send a new invite.";
|
||||||
|
|
||||||
|
private readonly ITeamMemberRegistrationService _registrationService;
|
||||||
|
|
||||||
|
public TeamMemberInviteController(ITeamMemberRegistrationService registrationService)
|
||||||
|
{
|
||||||
|
_registrationService = registrationService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("resolve")]
|
||||||
|
public async Task<IActionResult> Resolve(TeamMemberInviteTokenRequestDTO request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var outcome = await _registrationService.ResolveAsync(request.Token, cancellationToken);
|
||||||
|
return outcome.Status == TeamMemberRegistrationStatus.Ok ? Ok(outcome.Details) : Failure(outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("send-code")]
|
||||||
|
public async Task<IActionResult> SendCode(TeamMemberInviteTokenRequestDTO request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var outcome = await _registrationService.SendCodeAsync(request.Token, cancellationToken);
|
||||||
|
return outcome.Status == TeamMemberRegistrationStatus.Ok
|
||||||
|
? Ok(new { message = "Code sent" })
|
||||||
|
: Failure(outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("verify-code")]
|
||||||
|
public async Task<IActionResult> VerifyCode(VerifyTeamMemberInviteCodeRequestDTO request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var outcome = await _registrationService.VerifyCodeAsync(request.Token, request.Code, cancellationToken);
|
||||||
|
return outcome.Status == TeamMemberRegistrationStatus.Ok
|
||||||
|
? Ok(new { message = "Email confirmed" })
|
||||||
|
: Failure(outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("complete")]
|
||||||
|
public async Task<IActionResult> Complete(CompleteTeamMemberRegistrationRequestDTO request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var outcome = await _registrationService.CompleteAsync(request, cancellationToken);
|
||||||
|
return outcome.Status == TeamMemberRegistrationStatus.Ok && outcome.Session is not null
|
||||||
|
? Ok(LoginPayload.From(outcome.Session))
|
||||||
|
: Failure(outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IActionResult Failure(TeamMemberRegistrationOutcomeDTO outcome)
|
||||||
|
{
|
||||||
|
var (statusCode, code, message) = outcome.Status switch
|
||||||
|
{
|
||||||
|
TeamMemberRegistrationStatus.CodeIncorrect =>
|
||||||
|
(StatusCodes.Status400BadRequest, "code_incorrect", "Incorrect code — check your email and try again"),
|
||||||
|
TeamMemberRegistrationStatus.CodeExpired =>
|
||||||
|
(StatusCodes.Status400BadRequest, "code_expired", "This code has expired — request a new code"),
|
||||||
|
TeamMemberRegistrationStatus.CodeLocked =>
|
||||||
|
(StatusCodes.Status400BadRequest, "code_locked", "Too many incorrect attempts — request a new code"),
|
||||||
|
TeamMemberRegistrationStatus.ResendTooSoon =>
|
||||||
|
(StatusCodes.Status429TooManyRequests, "resend_too_soon", "Please wait a moment before requesting another code"),
|
||||||
|
TeamMemberRegistrationStatus.ResendLimitReached =>
|
||||||
|
(StatusCodes.Status429TooManyRequests, "resend_limit_reached", "Too many codes were requested. Ask your admin to send a new invite."),
|
||||||
|
TeamMemberRegistrationStatus.CodeDeliveryFailed =>
|
||||||
|
(StatusCodes.Status503ServiceUnavailable, "code_delivery_failed", "We couldn't send the code. Try again in a minute."),
|
||||||
|
TeamMemberRegistrationStatus.EmailNotConfirmed =>
|
||||||
|
(StatusCodes.Status400BadRequest, "email_not_confirmed", "Confirm your email before finishing registration"),
|
||||||
|
TeamMemberRegistrationStatus.PasswordRejected =>
|
||||||
|
(StatusCodes.Status400BadRequest, "password_rejected", "Password must be at least 6 characters and include one uppercase letter, one number, and one special character."),
|
||||||
|
TeamMemberRegistrationStatus.InvalidPhone =>
|
||||||
|
(StatusCodes.Status400BadRequest, "invalid_phone", "Enter a valid phone number"),
|
||||||
|
_ => (StatusCodes.Status400BadRequest, "invalid_invite", InvalidInviteMessage)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (outcome.RetryAfterSeconds is int retryAfter)
|
||||||
|
Response.Headers.RetryAfter = retryAfter.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
return StatusCode(statusCode, new { code, message, retryAfterSeconds = outcome.RetryAfterSeconds });
|
||||||
|
}
|
||||||
|
}
|
||||||
24
Api.SeaHavenIndustries/DTOs/LoginPayload.cs
Normal file
24
Api.SeaHavenIndustries/DTOs/LoginPayload.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.DTOs
|
||||||
|
{
|
||||||
|
/// <summary>The session payload the web app stores after sign-in.</summary>
|
||||||
|
public static class LoginPayload
|
||||||
|
{
|
||||||
|
public static object From(LoginResultDTO result)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(result);
|
||||||
|
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
token = result.Token,
|
||||||
|
expiration = result.Expiration,
|
||||||
|
email = result.Email,
|
||||||
|
userRoles = result.UserRole,
|
||||||
|
phoneNumber = result.PhoneNumber,
|
||||||
|
fullname = result.Fullname,
|
||||||
|
id = result.Id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -311,6 +311,21 @@ namespace Data.SeaHavenIndustries
|
||||||
.HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey");
|
.HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<TeamMemberInvite>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasIndex(invite => invite.TokenHash)
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_TeamMemberInvites_TokenHash");
|
||||||
|
|
||||||
|
entity.HasIndex(invite => invite.UserId)
|
||||||
|
.HasDatabaseName("IX_TeamMemberInvites_UserId");
|
||||||
|
|
||||||
|
entity.HasOne(invite => invite.User)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(invite => invite.UserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<UserServiceArea>(entity =>
|
builder.Entity<UserServiceArea>(entity =>
|
||||||
{
|
{
|
||||||
entity.HasKey(area => new { area.UserId, area.Area });
|
entity.HasKey(area => new { area.UserId, area.Area });
|
||||||
|
|
@ -388,6 +403,7 @@ namespace Data.SeaHavenIndustries
|
||||||
public DbSet<Region> Regions { get; set; }
|
public DbSet<Region> Regions { get; set; }
|
||||||
public DbSet<UserPermissionOverride> UserPermissionOverrides { get; set; }
|
public DbSet<UserPermissionOverride> UserPermissionOverrides { get; set; }
|
||||||
public DbSet<UserServiceArea> UserServiceAreas { get; set; }
|
public DbSet<UserServiceArea> UserServiceAreas { get; set; }
|
||||||
|
public DbSet<TeamMemberInvite> TeamMemberInvites { get; set; }
|
||||||
|
|
||||||
public override int SaveChanges()
|
public override int SaveChanges()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
4289
Data.SeaHavenIndustries/Migrations/20260925142034_AddTeamMemberInvites.Designer.cs
generated
Normal file
4289
Data.SeaHavenIndustries/Migrations/20260925142034_AddTeamMemberInvites.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,64 @@
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTeamMemberInvites : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TeamMemberInvites",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
UserId = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: false),
|
||||||
|
TokenHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
ExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UsedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
RevokedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CodeHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||||
|
CodeSalt = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: true),
|
||||||
|
CodeExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CodeFailedAttempts = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CodeSentAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CodeSendCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
EmailConfirmedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TeamMemberInvites", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TeamMemberInvites_AspNetUsers_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TeamMemberInvites_TokenHash",
|
||||||
|
table: "TeamMemberInvites",
|
||||||
|
column: "TokenHash",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TeamMemberInvites_UserId",
|
||||||
|
table: "TeamMemberInvites",
|
||||||
|
column: "UserId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TeamMemberInvites");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2067,6 +2067,71 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.ToTable("TaskListTemplateItems");
|
b.ToTable("TaskListTemplateItems");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Data.SeaHavenIndustries.TeamMemberInvite", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CodeExpiresAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<int>("CodeFailedAttempts")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("CodeHash")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("CodeSalt")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("nvarchar(32)");
|
||||||
|
|
||||||
|
b.Property<int>("CodeSendCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CodeSentAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("EmailConfirmedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RevokedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UsedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_TeamMemberInvites_TokenHash");
|
||||||
|
|
||||||
|
b.HasIndex("UserId")
|
||||||
|
.HasDatabaseName("IX_TeamMemberInvites_UserId");
|
||||||
|
|
||||||
|
b.ToTable("TeamMemberInvites");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Data.SeaHavenIndustries.Template", b =>
|
modelBuilder.Entity("Data.SeaHavenIndustries.Template", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|
@ -3785,6 +3850,17 @@ namespace Data.SeaHavenIndustries.Migrations
|
||||||
b.Navigation("Template");
|
b.Navigation("Template");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Data.SeaHavenIndustries.TeamMemberInvite", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Data.SeaHavenIndustries.Template", b =>
|
modelBuilder.Entity("Data.SeaHavenIndustries.Template", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser")
|
b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser")
|
||||||
|
|
|
||||||
44
Data.SeaHavenIndustries/Models/TeamMemberInvite.cs
Normal file
44
Data.SeaHavenIndustries/Models/TeamMemberInvite.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Data.SeaHavenIndustries
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A single-use registration invite for one pending team member. Only hashes of
|
||||||
|
/// the invite token and of the email confirmation code are stored.
|
||||||
|
/// </summary>
|
||||||
|
public class TeamMemberInvite
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(450)]
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[ForeignKey(nameof(UserId))]
|
||||||
|
public virtual ApplicationUser? User { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Lowercase hex SHA-256 of the raw invite token.</summary>
|
||||||
|
[Required]
|
||||||
|
[MaxLength(64)]
|
||||||
|
public string TokenHash { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
public DateTime? UsedAt { get; set; }
|
||||||
|
public DateTime? RevokedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Lowercase hex SHA-256 of the salt followed by the confirmation code.</summary>
|
||||||
|
[MaxLength(64)]
|
||||||
|
public string? CodeHash { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(32)]
|
||||||
|
public string? CodeSalt { get; set; }
|
||||||
|
|
||||||
|
public DateTime? CodeExpiresAt { get; set; }
|
||||||
|
public int CodeFailedAttempts { get; set; }
|
||||||
|
public DateTime? CodeSentAt { get; set; }
|
||||||
|
public int CodeSendCount { get; set; }
|
||||||
|
public DateTime? EmailConfirmedAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
36
SeaHaven.DataServices/Dto/TeamMemberInviteReadModels.cs
Normal file
36
SeaHaven.DataServices/Dto/TeamMemberInviteReadModels.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
namespace SeaHaven.DataServices.Dto
|
||||||
|
{
|
||||||
|
public sealed class TeamMemberInviteData
|
||||||
|
{
|
||||||
|
public required int Id { get; init; }
|
||||||
|
public required string UserId { get; init; }
|
||||||
|
public DateTime ExpiresAt { get; init; }
|
||||||
|
public DateTime? UsedAt { get; init; }
|
||||||
|
public DateTime? RevokedAt { get; init; }
|
||||||
|
public string? CodeHash { get; init; }
|
||||||
|
public string? CodeSalt { get; init; }
|
||||||
|
public DateTime? CodeExpiresAt { get; init; }
|
||||||
|
public int CodeFailedAttempts { get; init; }
|
||||||
|
public DateTime? CodeSentAt { get; init; }
|
||||||
|
public int CodeSendCount { get; init; }
|
||||||
|
public DateTime? EmailConfirmedAt { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces the confirmation code on an open invite when the resend cooldown has
|
||||||
|
/// elapsed and the send limit has not been reached.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StartTeamMemberInviteCodeCommand
|
||||||
|
{
|
||||||
|
public required int InviteId { get; init; }
|
||||||
|
public required string CodeHash { get; init; }
|
||||||
|
public required string CodeSalt { get; init; }
|
||||||
|
public required DateTime Now { get; init; }
|
||||||
|
public required DateTime CodeExpiresAt { get; init; }
|
||||||
|
|
||||||
|
/// <summary>A code may be replaced only when the previous one was sent at or before this instant.</summary>
|
||||||
|
public required DateTime LastSentNoLaterThan { get; init; }
|
||||||
|
|
||||||
|
public required int MaxSends { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Dto;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
{
|
||||||
|
public class TeamMemberInviteDataService : ITeamMemberInviteDataService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
|
||||||
|
public TeamMemberInviteDataService(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddAsync(TeamMemberInvite invite, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(invite);
|
||||||
|
|
||||||
|
_context.TeamMemberInvites.Add(invite);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReplaceOpenForUserAsync(
|
||||||
|
TeamMemberInvite invite,
|
||||||
|
DateTime now,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(invite);
|
||||||
|
|
||||||
|
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
|
||||||
|
await _context.TeamMemberInvites
|
||||||
|
.Where(existing => existing.UserId == invite.UserId
|
||||||
|
&& existing.UsedAt == null
|
||||||
|
&& existing.RevokedAt == null)
|
||||||
|
.ExecuteUpdateAsync(
|
||||||
|
setters => setters.SetProperty(existing => existing.RevokedAt, now),
|
||||||
|
cancellationToken);
|
||||||
|
_context.TeamMemberInvites.Add(invite);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<TeamMemberInviteData?> GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _context.TeamMemberInvites
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(invite => invite.TokenHash == tokenHash)
|
||||||
|
.Select(invite => new TeamMemberInviteData
|
||||||
|
{
|
||||||
|
Id = invite.Id,
|
||||||
|
UserId = invite.UserId,
|
||||||
|
ExpiresAt = invite.ExpiresAt,
|
||||||
|
UsedAt = invite.UsedAt,
|
||||||
|
RevokedAt = invite.RevokedAt,
|
||||||
|
CodeHash = invite.CodeHash,
|
||||||
|
CodeSalt = invite.CodeSalt,
|
||||||
|
CodeExpiresAt = invite.CodeExpiresAt,
|
||||||
|
CodeFailedAttempts = invite.CodeFailedAttempts,
|
||||||
|
CodeSentAt = invite.CodeSentAt,
|
||||||
|
CodeSendCount = invite.CodeSendCount,
|
||||||
|
EmailConfirmedAt = invite.EmailConfirmedAt
|
||||||
|
})
|
||||||
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> TryStartCodeAsync(
|
||||||
|
StartTeamMemberInviteCodeCommand command,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(command);
|
||||||
|
|
||||||
|
var updated = await OpenInvite(command.InviteId, command.Now)
|
||||||
|
.Where(invite => invite.CodeSendCount < command.MaxSends
|
||||||
|
&& (invite.CodeSentAt == null || invite.CodeSentAt <= command.LastSentNoLaterThan))
|
||||||
|
.ExecuteUpdateAsync(
|
||||||
|
setters => setters
|
||||||
|
.SetProperty(invite => invite.CodeHash, command.CodeHash)
|
||||||
|
.SetProperty(invite => invite.CodeSalt, command.CodeSalt)
|
||||||
|
.SetProperty(invite => invite.CodeExpiresAt, command.CodeExpiresAt)
|
||||||
|
.SetProperty(invite => invite.CodeFailedAttempts, 0)
|
||||||
|
.SetProperty(invite => invite.CodeSentAt, command.Now)
|
||||||
|
.SetProperty(invite => invite.CodeSendCount, invite => invite.CodeSendCount + 1),
|
||||||
|
cancellationToken);
|
||||||
|
return updated == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> TryReserveCodeAttemptAsync(
|
||||||
|
int inviteId,
|
||||||
|
int maxAttempts,
|
||||||
|
DateTime now,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var updated = await OpenInvite(inviteId, now)
|
||||||
|
.Where(invite => invite.CodeHash != null
|
||||||
|
&& invite.CodeExpiresAt > now
|
||||||
|
&& invite.CodeFailedAttempts < maxAttempts)
|
||||||
|
.ExecuteUpdateAsync(
|
||||||
|
setters => setters.SetProperty(
|
||||||
|
invite => invite.CodeFailedAttempts,
|
||||||
|
invite => invite.CodeFailedAttempts + 1),
|
||||||
|
cancellationToken);
|
||||||
|
return updated == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> TryConfirmEmailAsync(
|
||||||
|
int inviteId,
|
||||||
|
string codeHash,
|
||||||
|
DateTime now,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var updated = await OpenInvite(inviteId, now)
|
||||||
|
.Where(invite => invite.CodeHash == codeHash && invite.CodeExpiresAt > now)
|
||||||
|
.ExecuteUpdateAsync(
|
||||||
|
setters => setters
|
||||||
|
.SetProperty(invite => invite.EmailConfirmedAt, now)
|
||||||
|
.SetProperty(invite => invite.CodeHash, (string?)null)
|
||||||
|
.SetProperty(invite => invite.CodeSalt, (string?)null)
|
||||||
|
.SetProperty(invite => invite.CodeExpiresAt, (DateTime?)null)
|
||||||
|
.SetProperty(invite => invite.CodeFailedAttempts, 0),
|
||||||
|
cancellationToken);
|
||||||
|
return updated == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> TryClaimAsync(int inviteId, DateTime now, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var updated = await OpenInvite(inviteId, now)
|
||||||
|
.Where(invite => invite.EmailConfirmedAt != null)
|
||||||
|
.ExecuteUpdateAsync(
|
||||||
|
setters => setters.SetProperty(invite => invite.UsedAt, now),
|
||||||
|
cancellationToken);
|
||||||
|
return updated == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<TeamMemberInvite> OpenInvite(int inviteId, DateTime now)
|
||||||
|
{
|
||||||
|
return _context.TeamMemberInvites.Where(invite => invite.Id == inviteId
|
||||||
|
&& invite.UsedAt == null
|
||||||
|
&& invite.RevokedAt == null
|
||||||
|
&& invite.ExpiresAt > now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using SeaHaven.DataServices.Dto;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Persistence for team member registration invites. Every state transition that
|
||||||
|
/// guards a security limit is a single conditional update, so concurrent requests
|
||||||
|
/// cannot exceed the limit; each returns whether the transition happened.
|
||||||
|
/// </summary>
|
||||||
|
public interface ITeamMemberInviteDataService
|
||||||
|
{
|
||||||
|
Task AddAsync(TeamMemberInvite invite, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Revokes every open invite for the invite's user and adds the new one, atomically.</summary>
|
||||||
|
Task ReplaceOpenForUserAsync(TeamMemberInvite invite, DateTime now, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<TeamMemberInviteData?> GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<bool> TryStartCodeAsync(StartTeamMemberInviteCodeCommand command, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Counts one verification attempt against a live code; false once the attempt limit is used up.</summary>
|
||||||
|
Task<bool> TryReserveCodeAttemptAsync(int inviteId, int maxAttempts, DateTime now, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<bool> TryConfirmEmailAsync(int inviteId, string codeHash, DateTime now, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Marks an open, unexpired, email-confirmed invite as used; false when another request already did.</summary>
|
||||||
|
Task<bool> TryClaimAsync(int inviteId, DateTime now, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
SeaHaven.Services/DTOs/TeamMemberInviteDTOs.cs
Normal file
71
SeaHaven.Services/DTOs/TeamMemberInviteDTOs.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
namespace SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
/// <summary>A freshly issued invite. The raw token is never persisted and never printed.</summary>
|
||||||
|
public sealed class IssuedTeamMemberInvite
|
||||||
|
{
|
||||||
|
public IssuedTeamMemberInvite(string token, DateTime expiresAt)
|
||||||
|
{
|
||||||
|
Token = token;
|
||||||
|
ExpiresAt = expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Token { get; }
|
||||||
|
public DateTime ExpiresAt { get; }
|
||||||
|
|
||||||
|
public override string ToString() => $"IssuedTeamMemberInvite {{ ExpiresAt = {ExpiresAt:O} }}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TeamMemberInviteResendOutcomeDTO
|
||||||
|
{
|
||||||
|
public bool Success { get; init; }
|
||||||
|
public string? Error { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TeamMemberRegistrationStatus
|
||||||
|
{
|
||||||
|
Ok,
|
||||||
|
InvalidInvite,
|
||||||
|
CodeIncorrect,
|
||||||
|
CodeExpired,
|
||||||
|
CodeLocked,
|
||||||
|
ResendTooSoon,
|
||||||
|
ResendLimitReached,
|
||||||
|
CodeDeliveryFailed,
|
||||||
|
EmailNotConfirmed,
|
||||||
|
PasswordRejected,
|
||||||
|
InvalidPhone
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TeamMemberInviteDetailsDTO
|
||||||
|
{
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public required string Role { get; init; }
|
||||||
|
public string? Email { get; init; }
|
||||||
|
public string? Phone { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TeamMemberRegistrationOutcomeDTO
|
||||||
|
{
|
||||||
|
public TeamMemberRegistrationStatus Status { get; init; }
|
||||||
|
public TeamMemberInviteDetailsDTO? Details { get; init; }
|
||||||
|
public LoginResultDTO? Session { get; init; }
|
||||||
|
public int? RetryAfterSeconds { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TeamMemberInviteTokenRequestDTO
|
||||||
|
{
|
||||||
|
public string? Token { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class VerifyTeamMemberInviteCodeRequestDTO
|
||||||
|
{
|
||||||
|
public string? Token { get; init; }
|
||||||
|
public string? Code { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CompleteTeamMemberRegistrationRequestDTO
|
||||||
|
{
|
||||||
|
public string? Token { get; init; }
|
||||||
|
public string? Password { get; init; }
|
||||||
|
public string? Phone { get; init; }
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using SeaHaven.Services.Configuration;
|
using SeaHaven.Services.Configuration;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
|
@ -13,6 +14,7 @@ namespace SeaHaven.Services.DependencyInjection
|
||||||
|
|
||||||
public static IServiceCollection AddBusinessServices(this IServiceCollection services, IConfiguration configuration)
|
public static IServiceCollection AddBusinessServices(this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
|
services.TryAddSingleton(TimeProvider.System);
|
||||||
services.Configure<FrontendOptions>(configuration);
|
services.Configure<FrontendOptions>(configuration);
|
||||||
services.Configure<JwtOptions>(configuration.GetSection(JwtOptions.SectionName));
|
services.Configure<JwtOptions>(configuration.GetSection(JwtOptions.SectionName));
|
||||||
services.Configure<ApprovalsOptions>(configuration.GetSection(ApprovalsOptions.SectionName));
|
services.Configure<ApprovalsOptions>(configuration.GetSection(ApprovalsOptions.SectionName));
|
||||||
|
|
|
||||||
62
SeaHaven.Services/Helpers/TeamMemberInviteSecrets.cs
Normal file
62
SeaHaven.Services/Helpers/TeamMemberInviteSecrets.cs
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Helpers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Generation and hashing for invite tokens and email confirmation codes. Raw
|
||||||
|
/// values exist only in memory and in the email sent to the invitee.
|
||||||
|
/// </summary>
|
||||||
|
public static class TeamMemberInviteSecrets
|
||||||
|
{
|
||||||
|
/// <summary>256 bits from the OS CSPRNG.</summary>
|
||||||
|
public const int TokenBytes = 32;
|
||||||
|
|
||||||
|
/// <summary>Upper bound on an accepted token string; a 32-byte base64url token is 43 characters.</summary>
|
||||||
|
public const int MaxTokenLength = 128;
|
||||||
|
|
||||||
|
public static string NewToken()
|
||||||
|
{
|
||||||
|
return Convert.ToBase64String(RandomNumberGenerator.GetBytes(TokenBytes))
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_')
|
||||||
|
.TrimEnd('=');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string HashToken(string token)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(token);
|
||||||
|
return Sha256Hex(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string NewCode()
|
||||||
|
{
|
||||||
|
return RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string NewCodeSalt()
|
||||||
|
{
|
||||||
|
return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string HashCode(string salt, string code)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(salt);
|
||||||
|
ArgumentNullException.ThrowIfNull(code);
|
||||||
|
return Sha256Hex(salt + ":" + code);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool CodeMatches(string salt, string candidate, string expectedHash)
|
||||||
|
{
|
||||||
|
var actual = Encoding.ASCII.GetBytes(HashCode(salt, candidate));
|
||||||
|
var expected = Encoding.ASCII.GetBytes(expectedHash);
|
||||||
|
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Sha256Hex(string value)
|
||||||
|
{
|
||||||
|
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -39,47 +39,53 @@ namespace SeaHaven.Services.Implementation
|
||||||
{
|
{
|
||||||
var user = await _userManager.FindByNameAsync(username ?? "");
|
var user = await _userManager.FindByNameAsync(username ?? "");
|
||||||
if (user != null && user.IsDeleted != true && await _userManager.CheckPasswordAsync(user, password ?? ""))
|
if (user != null && user.IsDeleted != true && await _userManager.CheckPasswordAsync(user, password ?? ""))
|
||||||
{
|
return await CreateSessionAsync(user, cancellationToken);
|
||||||
var userRoles = await _userManager.GetRolesAsync(user);
|
|
||||||
var authClaims = new List<Claim>
|
|
||||||
{
|
|
||||||
new Claim(ClaimTypes.Name, user.UserName ?? ""),
|
|
||||||
new Claim(ClaimTypes.NameIdentifier, user.Id),
|
|
||||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
|
||||||
};
|
|
||||||
foreach (var userRole in userRoles)
|
|
||||||
{
|
|
||||||
authClaims.Add(new Claim(ClaimTypes.Role, userRole));
|
|
||||||
}
|
|
||||||
if (user.AccountId.HasValue)
|
|
||||||
{
|
|
||||||
authClaims.Add(new Claim(
|
|
||||||
SeaHavenClaimTypes.AccountId,
|
|
||||||
user.AccountId.Value.ToString()));
|
|
||||||
}
|
|
||||||
else if (userRoles.Contains("Admin"))
|
|
||||||
{
|
|
||||||
// Explicit signed org-wide elevation — never elevate via absence of account_id.
|
|
||||||
authClaims.Add(new Claim(
|
|
||||||
SeaHavenClaimTypes.OrgScope,
|
|
||||||
SeaHavenClaimTypes.OrgScopeAll));
|
|
||||||
}
|
|
||||||
var token = GetToken(authClaims);
|
|
||||||
return new LoginResultDTO
|
|
||||||
{
|
|
||||||
Token = new JwtSecurityTokenHandler().WriteToken(token),
|
|
||||||
Expiration = token.ValidTo,
|
|
||||||
Email = user.Email,
|
|
||||||
UserRole = userRoles.FirstOrDefault(),
|
|
||||||
PhoneNumber = user.PhoneNumber,
|
|
||||||
Fullname = user.FirstName + " " + user.LastName,
|
|
||||||
Id = user.Id
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<LoginResultDTO> CreateSessionAsync(ApplicationUser user, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(user);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var userRoles = await _userManager.GetRolesAsync(user);
|
||||||
|
var authClaims = new List<Claim>
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.Name, user.UserName ?? ""),
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, user.Id),
|
||||||
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||||
|
};
|
||||||
|
foreach (var userRole in userRoles)
|
||||||
|
{
|
||||||
|
authClaims.Add(new Claim(ClaimTypes.Role, userRole));
|
||||||
|
}
|
||||||
|
if (user.AccountId.HasValue)
|
||||||
|
{
|
||||||
|
authClaims.Add(new Claim(
|
||||||
|
SeaHavenClaimTypes.AccountId,
|
||||||
|
user.AccountId.Value.ToString()));
|
||||||
|
}
|
||||||
|
else if (userRoles.Contains("Admin"))
|
||||||
|
{
|
||||||
|
// Explicit signed org-wide elevation — never elevate via absence of account_id.
|
||||||
|
authClaims.Add(new Claim(
|
||||||
|
SeaHavenClaimTypes.OrgScope,
|
||||||
|
SeaHavenClaimTypes.OrgScopeAll));
|
||||||
|
}
|
||||||
|
var token = GetToken(authClaims);
|
||||||
|
return new LoginResultDTO
|
||||||
|
{
|
||||||
|
Token = new JwtSecurityTokenHandler().WriteToken(token),
|
||||||
|
Expiration = token.ValidTo,
|
||||||
|
Email = user.Email,
|
||||||
|
UserRole = userRoles.FirstOrDefault(),
|
||||||
|
PhoneNumber = user.PhoneNumber,
|
||||||
|
Fullname = user.FirstName + " " + user.LastName,
|
||||||
|
Id = user.Id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ChangePasswordResultDTO> ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken)
|
public async Task<ChangePasswordResultDTO> ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
|
||||||
119
SeaHaven.Services/Implementation/TeamMemberInviteService.cs
Normal file
119
SeaHaven.Services/Implementation/TeamMemberInviteService.cs
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.Configuration;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Implementation;
|
||||||
|
|
||||||
|
public sealed class TeamMemberInviteService : ITeamMemberInviteService
|
||||||
|
{
|
||||||
|
/// <summary>The prototype states no lifetime; seven days is the product decision.</summary>
|
||||||
|
public static readonly TimeSpan InviteLifetime = TimeSpan.FromDays(7);
|
||||||
|
|
||||||
|
private readonly ITeamMemberInviteDataService _inviteDataService;
|
||||||
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
|
private readonly IEmailSender _emailSender;
|
||||||
|
private readonly FrontendOptions _frontendOptions;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly ILogger<TeamMemberInviteService> _logger;
|
||||||
|
|
||||||
|
public TeamMemberInviteService(
|
||||||
|
ITeamMemberInviteDataService inviteDataService,
|
||||||
|
UserManager<ApplicationUser> userManager,
|
||||||
|
IEmailSender emailSender,
|
||||||
|
IOptions<FrontendOptions> frontendOptions,
|
||||||
|
TimeProvider timeProvider,
|
||||||
|
ILogger<TeamMemberInviteService> logger)
|
||||||
|
{
|
||||||
|
_inviteDataService = inviteDataService;
|
||||||
|
_userManager = userManager;
|
||||||
|
_emailSender = emailSender;
|
||||||
|
_frontendOptions = frontendOptions.Value;
|
||||||
|
_timeProvider = timeProvider;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IssuedTeamMemberInvite> CreateAsync(string userId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
|
||||||
|
|
||||||
|
var (invite, issued) = NewInvite(userId);
|
||||||
|
await _inviteDataService.AddAsync(invite, cancellationToken);
|
||||||
|
return issued;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> SendAsync(
|
||||||
|
IssuedTeamMemberInvite invite,
|
||||||
|
string email,
|
||||||
|
string name,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(invite);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var link = $"{_frontendOptions.FrontendBaseUrl?.TrimEnd('/')}/invite#{invite.Token}";
|
||||||
|
var body =
|
||||||
|
$"<p>Hi {WebUtility.HtmlEncode(name)},</p>" +
|
||||||
|
"<p>You've been added to Seahaven. Set your password and confirm your email to finish creating your account.</p>" +
|
||||||
|
$"<p><a href=\"{WebUtility.HtmlEncode(link)}\">Finish registration</a></p>" +
|
||||||
|
$"<p>This link expires in {InviteLifetime.Days} days and can be used once.</p>";
|
||||||
|
|
||||||
|
var sent = await _emailSender.SendEmailAsync(email, "You're invited to Seahaven", body);
|
||||||
|
if (!sent)
|
||||||
|
_logger.LogWarning("Team member invite email could not be sent for invite expiring {ExpiresAt}.", invite.ExpiresAt);
|
||||||
|
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TeamMemberInviteResendOutcomeDTO> ResendAsync(
|
||||||
|
string userId,
|
||||||
|
ClaimsPrincipal caller,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (caller?.IsInRole("Admin") != true)
|
||||||
|
return ResendFailure("Forbidden");
|
||||||
|
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var user = await _userManager.FindByIdAsync(userId);
|
||||||
|
if (user is null)
|
||||||
|
return ResendFailure("Team member not found.");
|
||||||
|
|
||||||
|
if (user.IsDeleted == true || user.PendingRegistration != true || string.IsNullOrWhiteSpace(user.Email))
|
||||||
|
return ResendFailure("Only pending team members can be re-invited.");
|
||||||
|
|
||||||
|
var (invite, issued) = NewInvite(user.Id);
|
||||||
|
await _inviteDataService.ReplaceOpenForUserAsync(invite, NowUtc(), cancellationToken);
|
||||||
|
|
||||||
|
var name = $"{user.FirstName ?? ""} {user.LastName ?? ""}".Trim();
|
||||||
|
if (!await SendAsync(issued, user.Email, name, cancellationToken))
|
||||||
|
return ResendFailure("The invite could not be emailed. Try again.");
|
||||||
|
|
||||||
|
return new TeamMemberInviteResendOutcomeDTO { Success = true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private (TeamMemberInvite Invite, IssuedTeamMemberInvite Issued) NewInvite(string userId)
|
||||||
|
{
|
||||||
|
var now = NowUtc();
|
||||||
|
var token = TeamMemberInviteSecrets.NewToken();
|
||||||
|
var invite = new TeamMemberInvite
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
TokenHash = TeamMemberInviteSecrets.HashToken(token),
|
||||||
|
CreatedAt = now,
|
||||||
|
ExpiresAt = now.Add(InviteLifetime)
|
||||||
|
};
|
||||||
|
return (invite, new IssuedTeamMemberInvite(token, invite.ExpiresAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private DateTime NowUtc() => _timeProvider.GetUtcNow().UtcDateTime;
|
||||||
|
|
||||||
|
private static TeamMemberInviteResendOutcomeDTO ResendFailure(string error) =>
|
||||||
|
new() { Success = false, Error = error };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,251 @@
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ public sealed class TeamMemberService : ITeamMemberService
|
||||||
private readonly ITeamPermissionOverrideDataService _permissionDataService;
|
private readonly ITeamPermissionOverrideDataService _permissionDataService;
|
||||||
private readonly IUserDataService _userDataService;
|
private readonly IUserDataService _userDataService;
|
||||||
private readonly ITeamPermissionService _permissionService;
|
private readonly ITeamPermissionService _permissionService;
|
||||||
|
private readonly ITeamMemberInviteService _inviteService;
|
||||||
|
|
||||||
public TeamMemberService(
|
public TeamMemberService(
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
|
|
@ -27,7 +28,8 @@ public sealed class TeamMemberService : ITeamMemberService
|
||||||
IUserServiceAreaDataService areaDataService,
|
IUserServiceAreaDataService areaDataService,
|
||||||
ITeamPermissionOverrideDataService permissionDataService,
|
ITeamPermissionOverrideDataService permissionDataService,
|
||||||
IUserDataService userDataService,
|
IUserDataService userDataService,
|
||||||
ITeamPermissionService permissionService)
|
ITeamPermissionService permissionService,
|
||||||
|
ITeamMemberInviteService inviteService)
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_roleManager = roleManager;
|
_roleManager = roleManager;
|
||||||
|
|
@ -35,6 +37,7 @@ public sealed class TeamMemberService : ITeamMemberService
|
||||||
_permissionDataService = permissionDataService;
|
_permissionDataService = permissionDataService;
|
||||||
_userDataService = userDataService;
|
_userDataService = userDataService;
|
||||||
_permissionService = permissionService;
|
_permissionService = permissionService;
|
||||||
|
_inviteService = inviteService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<CreateTeamMemberOutcomeDTO> CreateAsync(
|
public async Task<CreateTeamMemberOutcomeDTO> CreateAsync(
|
||||||
|
|
@ -68,6 +71,7 @@ public sealed class TeamMemberService : ITeamMemberService
|
||||||
PendingRegistrationCreatedDate = now
|
PendingRegistrationCreatedDate = now
|
||||||
};
|
};
|
||||||
|
|
||||||
|
IssuedTeamMemberInvite? invite = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _userDataService.ExecuteTransactionalAsync(
|
await _userDataService.ExecuteTransactionalAsync(
|
||||||
|
|
@ -100,6 +104,7 @@ public sealed class TeamMemberService : ITeamMemberService
|
||||||
|
|
||||||
await _areaDataService.ReplaceAsync(user.Id, areas!, transactionCancellationToken);
|
await _areaDataService.ReplaceAsync(user.Id, areas!, transactionCancellationToken);
|
||||||
await _permissionDataService.SetOverridesAsync(user.Id, overrides!, transactionCancellationToken);
|
await _permissionDataService.SetOverridesAsync(user.Id, overrides!, transactionCancellationToken);
|
||||||
|
invite = await _inviteService.CreateAsync(user.Id, transactionCancellationToken);
|
||||||
},
|
},
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
@ -108,6 +113,10 @@ public sealed class TeamMemberService : ITeamMemberService
|
||||||
return Failure(exception.Message);
|
return Failure(exception.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The member and their invite commit together; the email goes out only after
|
||||||
|
// commit, so a rolled-back member never receives a link.
|
||||||
|
await _inviteService.SendAsync(invite!, user.Email!, user.FirstName!, cancellationToken);
|
||||||
|
|
||||||
return new CreateTeamMemberOutcomeDTO
|
return new CreateTeamMemberOutcomeDTO
|
||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ namespace SeaHaven.Services.Interfaces
|
||||||
public interface IAuthenticationService
|
public interface IAuthenticationService
|
||||||
{
|
{
|
||||||
Task<LoginResultDTO?> LoginAsync(string? username, string? password, CancellationToken cancellationToken);
|
Task<LoginResultDTO?> LoginAsync(string? username, string? password, CancellationToken cancellationToken);
|
||||||
|
/// <summary>Builds the same signed session payload that a successful login returns.</summary>
|
||||||
|
Task<LoginResultDTO> CreateSessionAsync(Data.SeaHavenIndustries.ApplicationUser user, CancellationToken cancellationToken);
|
||||||
Task<ChangePasswordResultDTO> ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken);
|
Task<ChangePasswordResultDTO> ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken);
|
||||||
Task<UserProfileDTO?> UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken);
|
Task<UserProfileDTO?> UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken);
|
||||||
Task<bool> ForgetPasswordAsync(string email, CancellationToken cancellationToken);
|
Task<bool> ForgetPasswordAsync(string email, CancellationToken cancellationToken);
|
||||||
|
|
|
||||||
23
SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs
Normal file
23
SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
public interface ITeamMemberInviteService
|
||||||
|
{
|
||||||
|
/// <summary>Persists a new invite for a pending member and returns its raw token.</summary>
|
||||||
|
Task<IssuedTeamMemberInvite> CreateAsync(string userId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Emails the invite link. Returns false when the email could not be handed off.</summary>
|
||||||
|
Task<bool> SendAsync(
|
||||||
|
IssuedTeamMemberInvite invite,
|
||||||
|
string email,
|
||||||
|
string name,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Admin-only: revokes a pending member's open invites and emails a new one.</summary>
|
||||||
|
Task<TeamMemberInviteResendOutcomeDTO> ResendAsync(
|
||||||
|
string userId,
|
||||||
|
ClaimsPrincipal caller,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The anonymous invite-registration flow. Every operation is scoped by the invite
|
||||||
|
/// token alone; unknown, expired, used and revoked invites share one outcome.
|
||||||
|
/// </summary>
|
||||||
|
public interface ITeamMemberRegistrationService
|
||||||
|
{
|
||||||
|
Task<TeamMemberRegistrationOutcomeDTO> ResolveAsync(string? token, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<TeamMemberRegistrationOutcomeDTO> SendCodeAsync(string? token, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<TeamMemberRegistrationOutcomeDTO> VerifyCodeAsync(
|
||||||
|
string? token,
|
||||||
|
string? code,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<TeamMemberRegistrationOutcomeDTO> CompleteAsync(
|
||||||
|
CompleteTeamMemberRegistrationRequestDTO request,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
@ -35,7 +35,8 @@ public sealed class TeamMemberCreateTransactionTests
|
||||||
await using (var setup = new SqliteTeamMemberTestDbContext(options))
|
await using (var setup = new SqliteTeamMemberTestDbContext(options))
|
||||||
await setup.Database.EnsureCreatedAsync();
|
await setup.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
await using var serviceProvider = BuildServiceProvider(connection, injectFailure: true);
|
var emailSender = new CapturingEmailSender();
|
||||||
|
await using var serviceProvider = BuildServiceProvider(connection, injectFailure: true, emailSender);
|
||||||
ThrowingAfterPersistPermissionDataService failingPermissionData;
|
ThrowingAfterPersistPermissionDataService failingPermissionData;
|
||||||
await using (var createScope = serviceProvider.CreateAsyncScope())
|
await using (var createScope = serviceProvider.CreateAsyncScope())
|
||||||
{
|
{
|
||||||
|
|
@ -66,6 +67,8 @@ public sealed class TeamMemberCreateTransactionTests
|
||||||
Assert.Empty(await verify.UserRoles.AsNoTracking().ToListAsync());
|
Assert.Empty(await verify.UserRoles.AsNoTracking().ToListAsync());
|
||||||
Assert.Empty(await verify.UserServiceAreas.AsNoTracking().ToListAsync());
|
Assert.Empty(await verify.UserServiceAreas.AsNoTracking().ToListAsync());
|
||||||
Assert.Empty(await verify.UserPermissionOverrides.AsNoTracking().ToListAsync());
|
Assert.Empty(await verify.UserPermissionOverrides.AsNoTracking().ToListAsync());
|
||||||
|
Assert.Empty(await verify.TeamMemberInvites.AsNoTracking().ToListAsync());
|
||||||
|
Assert.Empty(emailSender.Messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -81,7 +84,8 @@ public sealed class TeamMemberCreateTransactionTests
|
||||||
await using (var setup = new SqliteTeamMemberTestDbContext(options))
|
await using (var setup = new SqliteTeamMemberTestDbContext(options))
|
||||||
await setup.Database.EnsureCreatedAsync();
|
await setup.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
await using var serviceProvider = BuildServiceProvider(connection);
|
var emailSender = new CapturingEmailSender();
|
||||||
|
await using var serviceProvider = BuildServiceProvider(connection, emailSender: emailSender);
|
||||||
await using (var createScope = serviceProvider.CreateAsyncScope())
|
await using (var createScope = serviceProvider.CreateAsyncScope())
|
||||||
{
|
{
|
||||||
var service = createScope.ServiceProvider.GetRequiredService<ITeamMemberService>();
|
var service = createScope.ServiceProvider.GetRequiredService<ITeamMemberService>();
|
||||||
|
|
@ -110,9 +114,18 @@ public sealed class TeamMemberCreateTransactionTests
|
||||||
var permissionOverride = await verify.UserPermissionOverrides.AsNoTracking()
|
var permissionOverride = await verify.UserPermissionOverrides.AsNoTracking()
|
||||||
.SingleAsync(permission => permission.UserId == user.Id);
|
.SingleAsync(permission => permission.UserId == user.Id);
|
||||||
Assert.Equal("deleteSites", permissionOverride.PermissionKey);
|
Assert.Equal("deleteSites", permissionOverride.PermissionKey);
|
||||||
|
|
||||||
|
var invite = await verify.TeamMemberInvites.AsNoTracking().SingleAsync();
|
||||||
|
Assert.Equal(user.Id, invite.UserId);
|
||||||
|
var sent = Assert.Single(emailSender.Messages);
|
||||||
|
Assert.Equal("taylor@example.com", sent.To);
|
||||||
|
Assert.Equal("You're invited to Seahaven", sent.Subject);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ServiceProvider BuildServiceProvider(SqliteConnection connection, bool injectFailure = false)
|
private static ServiceProvider BuildServiceProvider(
|
||||||
|
SqliteConnection connection,
|
||||||
|
bool injectFailure = false,
|
||||||
|
CapturingEmailSender? emailSender = null)
|
||||||
{
|
{
|
||||||
var configuration = new ConfigurationBuilder().Build();
|
var configuration = new ConfigurationBuilder().Build();
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
|
|
@ -124,6 +137,7 @@ public sealed class TeamMemberCreateTransactionTests
|
||||||
services.AddIdentity<ApplicationUser, IdentityRole>()
|
services.AddIdentity<ApplicationUser, IdentityRole>()
|
||||||
.AddEntityFrameworkStores<ApplicationDbContext>()
|
.AddEntityFrameworkStores<ApplicationDbContext>()
|
||||||
.AddDefaultTokenProviders();
|
.AddDefaultTokenProviders();
|
||||||
|
services.AddSingleton<IEmailSender>(emailSender ?? new CapturingEmailSender());
|
||||||
services.AddDataServices();
|
services.AddDataServices();
|
||||||
services.AddBusinessServices(configuration);
|
services.AddBusinessServices(configuration);
|
||||||
|
|
||||||
|
|
|
||||||
523
SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs
Normal file
523
SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs
Normal file
|
|
@ -0,0 +1,523 @@
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Api.SeaHavenIndustries.Controllers;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public sealed class TeamMemberInviteRegistrationTests
|
||||||
|
{
|
||||||
|
private const string Email = "taylor@example.com";
|
||||||
|
private const string Password = "Abc1!x";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreatingPendingMember_EmailsHighEntropyInviteAndStoresOnlyItsHash()
|
||||||
|
{
|
||||||
|
await using var host = await TeamMemberInviteTestHost.CreateAsync();
|
||||||
|
|
||||||
|
var (userId, token) = await host.AddPendingMemberAsync(Email);
|
||||||
|
|
||||||
|
var invite = Assert.Single(await host.InvitesAsync(userId));
|
||||||
|
Assert.True(Base64UrlDecode(token).Length >= 16);
|
||||||
|
Assert.Equal(Sha256Hex(token), invite.TokenHash);
|
||||||
|
Assert.DoesNotContain(token, JsonSerializer.Serialize(invite));
|
||||||
|
Assert.Equal(host.Time.Now.UtcDateTime.AddDays(7), invite.ExpiresAt);
|
||||||
|
Assert.Null(invite.UsedAt);
|
||||||
|
|
||||||
|
var sent = Assert.Single(host.Sent.Messages);
|
||||||
|
Assert.Equal(Email, sent.To);
|
||||||
|
Assert.Contains($"{TeamMemberInviteTestHost.FrontendBaseUrl}/invite#{token}", sent.Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FullFlow_FromEmailedToken_ActivatesMemberConfirmsEmailAndSignsIn()
|
||||||
|
{
|
||||||
|
await using var host = await TeamMemberInviteTestHost.CreateAsync();
|
||||||
|
var (userId, token) = await host.AddPendingMemberAsync(Email, "Taylor Reed");
|
||||||
|
|
||||||
|
var resolved = await host.RegistrationAsync(service => service.ResolveAsync(token, CancellationToken.None));
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.Ok, resolved.Status);
|
||||||
|
Assert.Equal("Taylor Reed", resolved.Details!.Name);
|
||||||
|
Assert.Equal("Dispatcher", resolved.Details.Role);
|
||||||
|
Assert.Equal(Email, resolved.Details.Email);
|
||||||
|
|
||||||
|
await host.ConfirmEmailAsync(token, Email);
|
||||||
|
var completed = await CompleteAsync(host, token, Password, "(555) 010-2000");
|
||||||
|
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.Ok, completed.Status);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(completed.Session!.Token));
|
||||||
|
Assert.Equal(Email, completed.Session.Email);
|
||||||
|
Assert.Equal(userId, completed.Session.Id);
|
||||||
|
Assert.Contains("Dispatcher", completed.Session.UserRole);
|
||||||
|
|
||||||
|
var user = await host.ReloadUserAsync(userId);
|
||||||
|
Assert.False(user.PendingRegistration);
|
||||||
|
Assert.True(user.EmailConfirmed);
|
||||||
|
Assert.Equal("Active", user.UniqueName);
|
||||||
|
Assert.Equal("(555) 010-2000", user.PhoneNumber);
|
||||||
|
Assert.Equal("(555) 010-2000", user.Contact);
|
||||||
|
Assert.True(await host.InScopeAsync(provider =>
|
||||||
|
provider.GetRequiredService<UserManager<ApplicationUser>>().CheckPasswordAsync(user, Password)));
|
||||||
|
Assert.NotNull(Assert.Single(await host.InvitesAsync(userId)).UsedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UsedToken_CannotRegisterAgain()
|
||||||
|
{
|
||||||
|
await using var host = await TeamMemberInviteTestHost.CreateAsync();
|
||||||
|
var (_, token) = await host.AddPendingMemberAsync(Email);
|
||||||
|
await host.ConfirmEmailAsync(token, Email);
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, token, Password)).Status);
|
||||||
|
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite,
|
||||||
|
(await host.RegistrationAsync(service => service.ResolveAsync(token, CancellationToken.None))).Status);
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite,
|
||||||
|
(await host.RegistrationAsync(service => service.SendCodeAsync(token, CancellationToken.None))).Status);
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite,
|
||||||
|
(await CompleteAsync(host, token, "Xyz9?q")).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UnknownExpiredUsedRevokedAndDeactivatedInvites_GetTheSameGenericResponse()
|
||||||
|
{
|
||||||
|
await using var host = await TeamMemberInviteTestHost.CreateAsync();
|
||||||
|
|
||||||
|
var (_, used) = await host.AddPendingMemberAsync("used@example.com");
|
||||||
|
await host.ConfirmEmailAsync(used, "used@example.com");
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, used, Password)).Status);
|
||||||
|
|
||||||
|
var (revokedUserId, revoked) = await host.AddPendingMemberAsync("revoked@example.com");
|
||||||
|
Assert.True((await ResendInviteAsync(host, revokedUserId)).Success);
|
||||||
|
|
||||||
|
var (deactivatedUserId, deactivated) = await host.AddPendingMemberAsync("deactivated@example.com");
|
||||||
|
await host.InScopeAsync(provider => provider.GetRequiredService<ApplicationDbContext>().Users
|
||||||
|
.Where(user => user.Id == deactivatedUserId)
|
||||||
|
.ExecuteUpdateAsync(setters => setters
|
||||||
|
.SetProperty(user => user.IsDeleted, true)
|
||||||
|
.SetProperty(user => user.UniqueName, "Inactive")));
|
||||||
|
|
||||||
|
var (_, expired) = await host.AddPendingMemberAsync("expired@example.com");
|
||||||
|
|
||||||
|
var responses = new List<string>
|
||||||
|
{
|
||||||
|
await ResolveResponseAsync(host, used),
|
||||||
|
await ResolveResponseAsync(host, revoked),
|
||||||
|
await ResolveResponseAsync(host, deactivated),
|
||||||
|
await ResolveResponseAsync(host, TeamMemberInviteSecretsForTests.UnknownToken()),
|
||||||
|
await ResolveResponseAsync(host, ""),
|
||||||
|
await ResolveResponseAsync(host, new string('a', 4096))
|
||||||
|
};
|
||||||
|
host.Time.Advance(TimeSpan.FromDays(7).Add(TimeSpan.FromSeconds(1)));
|
||||||
|
responses.Add(await ResolveResponseAsync(host, expired));
|
||||||
|
|
||||||
|
var expected =
|
||||||
|
"400 {\"code\":\"invalid_invite\",\"message\":\"This invite link is invalid or has expired. Ask your admin to send a new invite.\",\"retryAfterSeconds\":null}";
|
||||||
|
Assert.All(responses, response => Assert.Equal(expected, response));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Invite_ExpiresAfterSevenDays()
|
||||||
|
{
|
||||||
|
await using var host = await TeamMemberInviteTestHost.CreateAsync();
|
||||||
|
var (_, token) = await host.AddPendingMemberAsync(Email);
|
||||||
|
|
||||||
|
host.Time.Advance(TimeSpan.FromDays(7).Subtract(TimeSpan.FromSeconds(1)));
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.Ok,
|
||||||
|
(await host.RegistrationAsync(service => service.ResolveAsync(token, CancellationToken.None))).Status);
|
||||||
|
|
||||||
|
host.Time.Advance(TimeSpan.FromSeconds(1));
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite,
|
||||||
|
(await host.RegistrationAsync(service => service.ResolveAsync(token, CancellationToken.None))).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Code_IsSixDigitsAndStoredOnlyAsSaltedHash()
|
||||||
|
{
|
||||||
|
await using var host = await TeamMemberInviteTestHost.CreateAsync();
|
||||||
|
var (userId, token) = await host.AddPendingMemberAsync(Email);
|
||||||
|
|
||||||
|
var code = await host.SendCodeAsync(token, Email);
|
||||||
|
|
||||||
|
Assert.Matches("^[0-9]{6}$", code);
|
||||||
|
var invite = Assert.Single(await host.InvitesAsync(userId));
|
||||||
|
Assert.NotNull(invite.CodeHash);
|
||||||
|
Assert.NotNull(invite.CodeSalt);
|
||||||
|
Assert.NotEqual(code, invite.CodeHash);
|
||||||
|
Assert.DoesNotContain(code, invite.CodeHash);
|
||||||
|
Assert.Equal(Sha256Hex($"{invite.CodeSalt}:{code}"), invite.CodeHash);
|
||||||
|
Assert.Equal(host.Time.Now.UtcDateTime.AddMinutes(15), invite.CodeExpiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WrongCodes_AreCountedAndLockAfterFive_UntilANewCodeIsSent()
|
||||||
|
{
|
||||||
|
await using var host = await TeamMemberInviteTestHost.CreateAsync();
|
||||||
|
var (userId, token) = await host.AddPendingMemberAsync(Email);
|
||||||
|
var code = await host.SendCodeAsync(token, Email);
|
||||||
|
var wrong = code == "000000" ? "111111" : "000000";
|
||||||
|
|
||||||
|
for (var attempt = 1; attempt <= 4; attempt++)
|
||||||
|
{
|
||||||
|
var outcome = await VerifyAsync(host, token, wrong);
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.CodeIncorrect, outcome.Status);
|
||||||
|
Assert.Equal(attempt, Assert.Single(await host.InvitesAsync(userId)).CodeFailedAttempts);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.CodeLocked, (await VerifyAsync(host, token, wrong)).Status);
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.CodeLocked, (await VerifyAsync(host, token, code)).Status);
|
||||||
|
Assert.Equal(5, Assert.Single(await host.InvitesAsync(userId)).CodeFailedAttempts);
|
||||||
|
Assert.Null(Assert.Single(await host.InvitesAsync(userId)).EmailConfirmedAt);
|
||||||
|
|
||||||
|
host.Time.Advance(TeamMemberRegistrationService.ResendCooldown);
|
||||||
|
var fresh = await host.SendCodeAsync(token, Email);
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.Ok, (await VerifyAsync(host, token, fresh)).Status);
|
||||||
|
Assert.NotNull(Assert.Single(await host.InvitesAsync(userId)).EmailConfirmedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AttemptReservation_IsEnforcedByTheDatabaseGuard()
|
||||||
|
{
|
||||||
|
await using var host = await TeamMemberInviteTestHost.CreateAsync();
|
||||||
|
var (userId, token) = await host.AddPendingMemberAsync(Email);
|
||||||
|
await host.SendCodeAsync(token, Email);
|
||||||
|
var inviteId = Assert.Single(await host.InvitesAsync(userId)).Id;
|
||||||
|
|
||||||
|
var granted = 0;
|
||||||
|
for (var i = 0; i < 8; i++)
|
||||||
|
{
|
||||||
|
if (await host.InScopeAsync(provider => provider.GetRequiredService<ITeamMemberInviteDataService>()
|
||||||
|
.TryReserveCodeAttemptAsync(inviteId, TeamMemberRegistrationService.MaxCodeAttempts, host.Time.Now.UtcDateTime, CancellationToken.None)))
|
||||||
|
granted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(TeamMemberRegistrationService.MaxCodeAttempts, granted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Code_ExpiresAfterFifteenMinutes()
|
||||||
|
{
|
||||||
|
await using var host = await TeamMemberInviteTestHost.CreateAsync();
|
||||||
|
var (_, token) = await host.AddPendingMemberAsync(Email);
|
||||||
|
var code = await host.SendCodeAsync(token, Email);
|
||||||
|
|
||||||
|
host.Time.Advance(TeamMemberRegistrationService.CodeLifetime);
|
||||||
|
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.CodeExpired, (await VerifyAsync(host, token, code)).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resend_IsRateLimitedAndReplacesThePreviousCode()
|
||||||
|
{
|
||||||
|
await using var host = await TeamMemberInviteTestHost.CreateAsync();
|
||||||
|
var (_, token) = await host.AddPendingMemberAsync(Email);
|
||||||
|
var first = await host.SendCodeAsync(token, Email);
|
||||||
|
|
||||||
|
var tooSoon = await host.RegistrationAsync(service => service.SendCodeAsync(token, CancellationToken.None));
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.ResendTooSoon, tooSoon.Status);
|
||||||
|
Assert.Equal(60, tooSoon.RetryAfterSeconds);
|
||||||
|
|
||||||
|
host.Time.Advance(TimeSpan.FromSeconds(45));
|
||||||
|
var stillTooSoon = await host.RegistrationAsync(service => service.SendCodeAsync(token, CancellationToken.None));
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.ResendTooSoon, stillTooSoon.Status);
|
||||||
|
Assert.Equal(15, stillTooSoon.RetryAfterSeconds);
|
||||||
|
Assert.Single(host.Sent.Messages, message => message.Subject == "Your Seahaven confirmation code");
|
||||||
|
|
||||||
|
host.Time.Advance(TimeSpan.FromSeconds(15));
|
||||||
|
var second = await host.SendCodeAsync(token, Email);
|
||||||
|
if (second != first)
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.CodeIncorrect, (await VerifyAsync(host, token, first)).Status);
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.Ok, (await VerifyAsync(host, token, second)).Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resend_StopsAfterTheSendLimit()
|
||||||
|
{
|
||||||
|
await using var host = await TeamMemberInviteTestHost.CreateAsync();
|
||||||
|
var (_, token) = await host.AddPendingMemberAsync(Email);
|
||||||
|
|
||||||
|
for (var send = 0; send < TeamMemberRegistrationService.MaxCodeSends; send++)
|
||||||
|
{
|
||||||
|
await host.SendCodeAsync(token, Email);
|
||||||
|
host.Time.Advance(TeamMemberRegistrationService.ResendCooldown);
|
||||||
|
}
|
||||||
|
|
||||||
|
var refused = await host.RegistrationAsync(service => service.SendCodeAsync(token, CancellationToken.None));
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.ResendLimitReached, refused.Status);
|
||||||
|
Assert.Equal(TeamMemberRegistrationService.MaxCodeSends,
|
||||||
|
host.Sent.Messages.Count(message => message.Subject == "Your Seahaven confirmation code"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Complete_RequiresConfirmedEmail()
|
||||||
|
{
|
||||||
|
await using var host = await TeamMemberInviteTestHost.CreateAsync();
|
||||||
|
var (userId, token) = await host.AddPendingMemberAsync(Email);
|
||||||
|
await host.SendCodeAsync(token, Email);
|
||||||
|
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.EmailNotConfirmed, (await CompleteAsync(host, token, Password)).Status);
|
||||||
|
|
||||||
|
var user = await host.ReloadUserAsync(userId);
|
||||||
|
Assert.True(user.PendingRegistration);
|
||||||
|
Assert.Null(user.PasswordHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Complete_WeakPassword_RollsBackAndARetrySucceeds()
|
||||||
|
{
|
||||||
|
await using var host = await TeamMemberInviteTestHost.CreateAsync();
|
||||||
|
var (userId, token) = await host.AddPendingMemberAsync(Email);
|
||||||
|
await host.ConfirmEmailAsync(token, Email);
|
||||||
|
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.PasswordRejected, (await CompleteAsync(host, token, "abc12!")).Status);
|
||||||
|
|
||||||
|
var pending = await host.ReloadUserAsync(userId);
|
||||||
|
Assert.True(pending.PendingRegistration);
|
||||||
|
Assert.False(pending.EmailConfirmed);
|
||||||
|
Assert.Null(pending.PasswordHash);
|
||||||
|
Assert.Null(Assert.Single(await host.InvitesAsync(userId)).UsedAt);
|
||||||
|
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, token, Password)).Status);
|
||||||
|
Assert.False((await host.ReloadUserAsync(userId)).PendingRegistration);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Complete_InvalidPhone_IsRejectedWithoutConsumingTheInvite()
|
||||||
|
{
|
||||||
|
await using var host = await TeamMemberInviteTestHost.CreateAsync();
|
||||||
|
var (userId, token) = await host.AddPendingMemberAsync(Email);
|
||||||
|
await host.ConfirmEmailAsync(token, Email);
|
||||||
|
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.InvalidPhone, (await CompleteAsync(host, token, Password, "call me")).Status);
|
||||||
|
Assert.Null(Assert.Single(await host.InvitesAsync(userId)).UsedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Invite_OnlyEverChangesItsOwnMember()
|
||||||
|
{
|
||||||
|
await using var host = await TeamMemberInviteTestHost.CreateAsync();
|
||||||
|
var (ownerId, ownerToken) = await host.AddPendingMemberAsync(Email, "Taylor Reed");
|
||||||
|
var (otherId, otherToken) = await host.AddPendingMemberAsync("jordan@example.com", "Jordan Lee");
|
||||||
|
var otherBefore = await host.ReloadUserAsync(otherId);
|
||||||
|
|
||||||
|
await host.ConfirmEmailAsync(ownerToken, Email);
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, ownerToken, Password, "555-123-4567")).Status);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(host.Sent.Messages,
|
||||||
|
message => message.To == "jordan@example.com" && message.Subject == "Your Seahaven confirmation code");
|
||||||
|
var otherAfter = await host.ReloadUserAsync(otherId);
|
||||||
|
Assert.True(otherAfter.PendingRegistration);
|
||||||
|
Assert.False(otherAfter.EmailConfirmed);
|
||||||
|
Assert.Null(otherAfter.PasswordHash);
|
||||||
|
Assert.Equal(otherBefore.PhoneNumber, otherAfter.PhoneNumber);
|
||||||
|
Assert.Equal(otherBefore.SecurityStamp, otherAfter.SecurityStamp);
|
||||||
|
var otherInvite = Assert.Single(await host.InvitesAsync(otherId));
|
||||||
|
Assert.Null(otherInvite.UsedAt);
|
||||||
|
Assert.Null(otherInvite.EmailConfirmedAt);
|
||||||
|
|
||||||
|
var otherDetails = await host.RegistrationAsync(service => service.ResolveAsync(otherToken, CancellationToken.None));
|
||||||
|
Assert.Equal("Jordan Lee", otherDetails.Details!.Name);
|
||||||
|
Assert.False((await host.ReloadUserAsync(ownerId)).PendingRegistration);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reinvite_RevokesOlderTokensAndOnlyAdminsCanSendIt()
|
||||||
|
{
|
||||||
|
await using var host = await TeamMemberInviteTestHost.CreateAsync();
|
||||||
|
var (userId, original) = await host.AddPendingMemberAsync(Email);
|
||||||
|
|
||||||
|
var forbidden = await host.InScopeAsync(provider => provider.GetRequiredService<ITeamMemberInviteService>()
|
||||||
|
.ResendAsync(userId, TeamMemberInviteTestHost.Dispatcher(), CancellationToken.None));
|
||||||
|
Assert.Equal("Forbidden", forbidden.Error);
|
||||||
|
|
||||||
|
Assert.True((await ResendInviteAsync(host, userId)).Success);
|
||||||
|
var replacement = host.Sent.LatestTokenFor(Email);
|
||||||
|
|
||||||
|
Assert.NotEqual(original, replacement);
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite,
|
||||||
|
(await host.RegistrationAsync(service => service.ResolveAsync(original, CancellationToken.None))).Status);
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.Ok,
|
||||||
|
(await host.RegistrationAsync(service => service.ResolveAsync(replacement, CancellationToken.None))).Status);
|
||||||
|
var invites = await host.InvitesAsync(userId);
|
||||||
|
Assert.Equal(2, invites.Count);
|
||||||
|
Assert.NotNull(invites[0].RevokedAt);
|
||||||
|
Assert.Null(invites[1].RevokedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reinvite_IsRefusedOnceTheMemberIsActive()
|
||||||
|
{
|
||||||
|
await using var host = await TeamMemberInviteTestHost.CreateAsync();
|
||||||
|
var (userId, token) = await host.AddPendingMemberAsync(Email);
|
||||||
|
await host.ConfirmEmailAsync(token, Email);
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, token, Password)).Status);
|
||||||
|
|
||||||
|
var refused = await ResendInviteAsync(host, userId);
|
||||||
|
|
||||||
|
Assert.False(refused.Success);
|
||||||
|
Assert.Equal("Only pending team members can be re-invited.", refused.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TokensAndCodes_NeverReachTheLogs()
|
||||||
|
{
|
||||||
|
await using var host = await TeamMemberInviteTestHost.CreateAsync();
|
||||||
|
var (userId, token) = await host.AddPendingMemberAsync(Email);
|
||||||
|
var code = await host.SendCodeAsync(token, Email);
|
||||||
|
await VerifyAsync(host, token, code == "000000" ? "111111" : "000000");
|
||||||
|
|
||||||
|
host.Sent.Succeeds = false;
|
||||||
|
host.Time.Advance(TeamMemberRegistrationService.ResendCooldown);
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.CodeDeliveryFailed,
|
||||||
|
(await host.RegistrationAsync(service => service.SendCodeAsync(token, CancellationToken.None))).Status);
|
||||||
|
Assert.False((await ResendInviteAsync(host, userId)).Success);
|
||||||
|
var failedInviteToken = host.Sent.LatestTokenFor(Email);
|
||||||
|
host.Sent.Succeeds = true;
|
||||||
|
|
||||||
|
Assert.True((await ResendInviteAsync(host, userId)).Success);
|
||||||
|
var liveToken = host.Sent.LatestTokenFor(Email);
|
||||||
|
await host.ConfirmEmailAsync(liveToken, Email);
|
||||||
|
var liveCode = host.Sent.LatestCodeFor(Email);
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, liveToken, Password)).Status);
|
||||||
|
|
||||||
|
Assert.NotEmpty(host.Logged.Entries);
|
||||||
|
Assert.Contains(host.Logged.Entries, entry => entry.Contains("could not be sent", StringComparison.Ordinal));
|
||||||
|
foreach (var secret in new[] { token, failedInviteToken, liveToken, code, liveCode, Password })
|
||||||
|
Assert.DoesNotContain(host.Logged.Entries, entry => entry.Contains(secret, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestObjects_DoNotPrintTheirSecrets()
|
||||||
|
{
|
||||||
|
await using var host = await TeamMemberInviteTestHost.CreateAsync();
|
||||||
|
var (_, token) = await host.AddPendingMemberAsync(Email);
|
||||||
|
|
||||||
|
var printed = string.Join(" ",
|
||||||
|
new TeamMemberInviteTokenRequestDTO { Token = token }.ToString(),
|
||||||
|
new VerifyTeamMemberInviteCodeRequestDTO { Token = token, Code = "123456" }.ToString(),
|
||||||
|
new CompleteTeamMemberRegistrationRequestDTO { Token = token, Password = Password }.ToString(),
|
||||||
|
new IssuedTeamMemberInvite(token, DateTime.UtcNow).ToString());
|
||||||
|
|
||||||
|
Assert.DoesNotContain(token, printed);
|
||||||
|
Assert.DoesNotContain("123456", printed);
|
||||||
|
Assert.DoesNotContain(Password, printed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CancelledRequests_StopBeforeChangingAnything()
|
||||||
|
{
|
||||||
|
await using var host = await TeamMemberInviteTestHost.CreateAsync();
|
||||||
|
var (userId, token) = await host.AddPendingMemberAsync(Email);
|
||||||
|
using var cancelled = new CancellationTokenSource();
|
||||||
|
await cancelled.CancelAsync();
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
|
||||||
|
host.RegistrationAsync(service => service.SendCodeAsync(token, cancelled.Token)));
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => host.RegistrationAsync(service =>
|
||||||
|
service.CompleteAsync(new CompleteTeamMemberRegistrationRequestDTO { Token = token, Password = Password }, cancelled.Token)));
|
||||||
|
|
||||||
|
Assert.DoesNotContain(host.Sent.Messages, message => message.Subject == "Your Seahaven confirmation code");
|
||||||
|
Assert.Equal(0, Assert.Single(await host.InvitesAsync(userId)).CodeSendCount);
|
||||||
|
Assert.True((await host.ReloadUserAsync(userId)).PendingRegistration);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Controller_MapsCodeFailuresToClearMessagesAndRetryAfter()
|
||||||
|
{
|
||||||
|
await using var host = await TeamMemberInviteTestHost.CreateAsync();
|
||||||
|
var (_, token) = await host.AddPendingMemberAsync(Email);
|
||||||
|
var code = await host.SendCodeAsync(token, Email);
|
||||||
|
|
||||||
|
var wrong = await InvokeControllerAsync(host, controller => controller.VerifyCode(
|
||||||
|
new VerifyTeamMemberInviteCodeRequestDTO { Token = token, Code = code == "000000" ? "111111" : "000000" },
|
||||||
|
CancellationToken.None));
|
||||||
|
Assert.Equal(
|
||||||
|
"400 {\"code\":\"code_incorrect\",\"message\":\"Incorrect code \\u2014 check your email and try again\",\"retryAfterSeconds\":null}",
|
||||||
|
wrong.Body);
|
||||||
|
|
||||||
|
var tooSoon = await InvokeControllerAsync(host, controller => controller.SendCode(
|
||||||
|
new TeamMemberInviteTokenRequestDTO { Token = token }, CancellationToken.None));
|
||||||
|
Assert.StartsWith("429 {\"code\":\"resend_too_soon\"", tooSoon.Body);
|
||||||
|
Assert.Equal("60", tooSoon.RetryAfter);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Controller_CompleteReturnsTheLoginPayloadShape()
|
||||||
|
{
|
||||||
|
await using var host = await TeamMemberInviteTestHost.CreateAsync();
|
||||||
|
var (userId, token) = await host.AddPendingMemberAsync(Email);
|
||||||
|
await host.ConfirmEmailAsync(token, Email);
|
||||||
|
|
||||||
|
var response = await InvokeControllerAsync(host, controller => controller.Complete(
|
||||||
|
new CompleteTeamMemberRegistrationRequestDTO { Token = token, Password = Password, Phone = "555-123-4567" },
|
||||||
|
CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.StartsWith("200 ", response.Body);
|
||||||
|
using var payload = JsonDocument.Parse(response.Body[4..]);
|
||||||
|
var keys = payload.RootElement.EnumerateObject().Select(property => property.Name).ToArray();
|
||||||
|
Assert.Equal(new[] { "token", "expiration", "email", "userRoles", "phoneNumber", "fullname", "id" }, keys);
|
||||||
|
Assert.Equal(userId, payload.RootElement.GetProperty("id").GetString());
|
||||||
|
Assert.Equal("555-123-4567", payload.RootElement.GetProperty("phoneNumber").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<TeamMemberRegistrationOutcomeDTO> VerifyAsync(TeamMemberInviteTestHost host, string token, string code) =>
|
||||||
|
host.RegistrationAsync(service => service.VerifyCodeAsync(token, code, CancellationToken.None));
|
||||||
|
|
||||||
|
private static Task<TeamMemberRegistrationOutcomeDTO> CompleteAsync(
|
||||||
|
TeamMemberInviteTestHost host,
|
||||||
|
string token,
|
||||||
|
string password,
|
||||||
|
string? phone = null) =>
|
||||||
|
host.RegistrationAsync(service => service.CompleteAsync(
|
||||||
|
new CompleteTeamMemberRegistrationRequestDTO { Token = token, Password = password, Phone = phone },
|
||||||
|
CancellationToken.None));
|
||||||
|
|
||||||
|
private static Task<TeamMemberInviteResendOutcomeDTO> ResendInviteAsync(TeamMemberInviteTestHost host, string userId) =>
|
||||||
|
host.InScopeAsync(provider => provider.GetRequiredService<ITeamMemberInviteService>()
|
||||||
|
.ResendAsync(userId, TeamMemberInviteTestHost.Admin(), CancellationToken.None));
|
||||||
|
|
||||||
|
private static async Task<string> ResolveResponseAsync(TeamMemberInviteTestHost host, string token) =>
|
||||||
|
(await InvokeControllerAsync(host, controller => controller.Resolve(
|
||||||
|
new TeamMemberInviteTokenRequestDTO { Token = token }, CancellationToken.None))).Body;
|
||||||
|
|
||||||
|
private static Task<(string Body, string? RetryAfter)> InvokeControllerAsync(
|
||||||
|
TeamMemberInviteTestHost host,
|
||||||
|
Func<TeamMemberInviteController, Task<IActionResult>> action) =>
|
||||||
|
host.InScopeAsync(async provider =>
|
||||||
|
{
|
||||||
|
var httpContext = new DefaultHttpContext();
|
||||||
|
var controller = new TeamMemberInviteController(provider.GetRequiredService<ITeamMemberRegistrationService>())
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext { HttpContext = httpContext }
|
||||||
|
};
|
||||||
|
var result = Assert.IsAssignableFrom<ObjectResult>(await action(controller));
|
||||||
|
var status = result.StatusCode ?? StatusCodes.Status200OK;
|
||||||
|
var retryAfter = httpContext.Response.Headers.RetryAfter.ToString();
|
||||||
|
return ($"{status} {JsonSerializer.Serialize(result.Value)}", string.IsNullOrEmpty(retryAfter) ? null : retryAfter);
|
||||||
|
});
|
||||||
|
|
||||||
|
private static byte[] Base64UrlDecode(string value)
|
||||||
|
{
|
||||||
|
var padded = value.Replace('-', '+').Replace('_', '/');
|
||||||
|
padded += new string('=', (4 - padded.Length % 4) % 4);
|
||||||
|
return Convert.FromBase64String(padded);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Sha256Hex(string value) =>
|
||||||
|
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||||
|
|
||||||
|
private static class TeamMemberInviteSecretsForTests
|
||||||
|
{
|
||||||
|
public static string UnknownToken() =>
|
||||||
|
Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||||
|
}
|
||||||
|
}
|
||||||
258
SeaHavenIndustries.Tests/TeamMemberInviteTestHost.cs
Normal file
258
SeaHavenIndustries.Tests/TeamMemberInviteTestHost.cs
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Api.SeaHavenIndustries.Infrastructure;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using SeaHaven.DataServices.DependencyInjection;
|
||||||
|
using SeaHaven.Services.DependencyInjection;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A SQLite-backed composition of the real team member, invite and registration
|
||||||
|
/// services, with Identity registered exactly as the API host registers it. Email
|
||||||
|
/// goes to an in-memory sender and every log line is captured.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class TeamMemberInviteTestHost : IAsyncDisposable
|
||||||
|
{
|
||||||
|
public const string FrontendBaseUrl = "https://shoc.test";
|
||||||
|
|
||||||
|
private readonly SqliteConnection _connection;
|
||||||
|
private ServiceProvider _provider = null!;
|
||||||
|
|
||||||
|
private TeamMemberInviteTestHost(SqliteConnection connection)
|
||||||
|
{
|
||||||
|
_connection = connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CapturingEmailSender Sent { get; } = new();
|
||||||
|
public ManualTimeProvider Time { get; } = new();
|
||||||
|
public CapturingLoggerProvider Logged { get; } = new();
|
||||||
|
|
||||||
|
public static async Task<TeamMemberInviteTestHost> CreateAsync()
|
||||||
|
{
|
||||||
|
var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(connection).Options;
|
||||||
|
await using (var setup = new SqliteInviteTestDbContext(options))
|
||||||
|
await setup.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var host = new TeamMemberInviteTestHost(connection);
|
||||||
|
host._provider = BuildProvider(connection, host);
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ServiceProvider BuildProvider(SqliteConnection connection, TeamMemberInviteTestHost fakes)
|
||||||
|
{
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["FrontendBaseUrl"] = FrontendBaseUrl,
|
||||||
|
["JWT:Secret"] = new string('k', 64),
|
||||||
|
["JWT:ValidIssuer"] = "issuer",
|
||||||
|
["JWT:ValidAudience"] = "audience"
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging(logging => logging
|
||||||
|
.SetMinimumLevel(LogLevel.Trace)
|
||||||
|
.AddProvider(fakes.Logged));
|
||||||
|
services.AddDbContext<ApplicationDbContext>(builder => builder.UseSqlite(connection));
|
||||||
|
services.Replace(ServiceDescriptor.Scoped<ApplicationDbContext>(provider =>
|
||||||
|
new SqliteInviteTestDbContext(provider.GetRequiredService<DbContextOptions<ApplicationDbContext>>())));
|
||||||
|
services.AddSeaHavenIdentity();
|
||||||
|
services.AddSingleton<TimeProvider>(fakes.Time);
|
||||||
|
services.AddSingleton<IEmailSender>(fakes.Sent);
|
||||||
|
services.AddDataServices();
|
||||||
|
services.AddBusinessServices(configuration);
|
||||||
|
return services.BuildServiceProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<T> InScopeAsync<T>(Func<IServiceProvider, Task<T>> action)
|
||||||
|
{
|
||||||
|
await using var scope = _provider.CreateAsyncScope();
|
||||||
|
return await action(scope.ServiceProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<TeamMemberRegistrationOutcomeDTO> RegistrationAsync(
|
||||||
|
Func<ITeamMemberRegistrationService, Task<TeamMemberRegistrationOutcomeDTO>> action) =>
|
||||||
|
InScopeAsync(provider => action(provider.GetRequiredService<ITeamMemberRegistrationService>()));
|
||||||
|
|
||||||
|
/// <summary>Creates a pending member through the admin create path and returns the emailed token.</summary>
|
||||||
|
public async Task<(string UserId, string Token)> AddPendingMemberAsync(string email, string name = "Taylor Reed")
|
||||||
|
{
|
||||||
|
var outcome = await InScopeAsync(provider => provider.GetRequiredService<ITeamMemberService>().CreateAsync(
|
||||||
|
new CreateTeamMemberRequestDTO
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Role = "dispatcher",
|
||||||
|
Color = "#0D9488",
|
||||||
|
Email = email,
|
||||||
|
Phone = "555-0100",
|
||||||
|
ServiceAreas = new[] { "East" }
|
||||||
|
},
|
||||||
|
Admin(),
|
||||||
|
CancellationToken.None));
|
||||||
|
Assert.True(outcome.Success, outcome.Error);
|
||||||
|
|
||||||
|
return (outcome.Member!.Id, Sent.LatestTokenFor(email));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> SendCodeAsync(string token, string email)
|
||||||
|
{
|
||||||
|
var outcome = await RegistrationAsync(service => service.SendCodeAsync(token, CancellationToken.None));
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.Ok, outcome.Status);
|
||||||
|
return Sent.LatestCodeFor(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ConfirmEmailAsync(string token, string email)
|
||||||
|
{
|
||||||
|
var code = await SendCodeAsync(token, email);
|
||||||
|
var verified = await RegistrationAsync(service => service.VerifyCodeAsync(token, code, CancellationToken.None));
|
||||||
|
Assert.Equal(TeamMemberRegistrationStatus.Ok, verified.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ApplicationUser> ReloadUserAsync(string userId) =>
|
||||||
|
InScopeAsync(async provider => await provider.GetRequiredService<ApplicationDbContext>()
|
||||||
|
.Users.AsNoTracking().SingleAsync(user => user.Id == userId));
|
||||||
|
|
||||||
|
public Task<List<TeamMemberInvite>> InvitesAsync(string userId) =>
|
||||||
|
InScopeAsync(provider => provider.GetRequiredService<ApplicationDbContext>()
|
||||||
|
.TeamMemberInvites.AsNoTracking().Where(invite => invite.UserId == userId)
|
||||||
|
.OrderBy(invite => invite.Id).ToListAsync());
|
||||||
|
|
||||||
|
public static ClaimsPrincipal Admin() =>
|
||||||
|
new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "Test"));
|
||||||
|
|
||||||
|
public static ClaimsPrincipal Dispatcher() =>
|
||||||
|
new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Dispatcher") }, "Test"));
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _provider.DisposeAsync();
|
||||||
|
await _connection.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SqliteInviteTestDbContext : ApplicationDbContext
|
||||||
|
{
|
||||||
|
public SqliteInviteTestDbContext(DbContextOptions<ApplicationDbContext> options) : base(options)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(builder);
|
||||||
|
|
||||||
|
// Keep filtered unique indexes filtered: translate SQL Server identifier quoting.
|
||||||
|
foreach (var index in builder.Model.GetEntityTypes().SelectMany(entity => entity.GetIndexes()))
|
||||||
|
{
|
||||||
|
if (index.GetFilter() is { } filter)
|
||||||
|
index.SetFilter(filter.Replace('[', '"').Replace(']', '"'));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var property in builder.Model.GetEntityTypes()
|
||||||
|
.SelectMany(entity => entity.GetProperties())
|
||||||
|
.Where(property => property.Name == "RowVersion" && property.ClrType == typeof(byte[])))
|
||||||
|
{
|
||||||
|
property.ValueGenerated = ValueGenerated.Never;
|
||||||
|
property.IsConcurrencyToken = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed partial class CapturingEmailSender : IEmailSender
|
||||||
|
{
|
||||||
|
private readonly ConcurrentQueue<SentEmail> _messages = new();
|
||||||
|
|
||||||
|
public bool Succeeds { get; set; } = true;
|
||||||
|
|
||||||
|
public IReadOnlyList<SentEmail> Messages => _messages.ToArray();
|
||||||
|
|
||||||
|
public Task<bool> SendEmailAsync(string emailTo, string subject, string body)
|
||||||
|
{
|
||||||
|
_messages.Enqueue(new SentEmail(emailTo, subject, body));
|
||||||
|
return Task.FromResult(Succeeds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string LatestTokenFor(string email)
|
||||||
|
{
|
||||||
|
var message = Messages.Last(sent => sent.To == email && sent.Subject == "You're invited to Seahaven");
|
||||||
|
return InviteLink().Match(message.Body).Groups["token"].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string LatestCodeFor(string email)
|
||||||
|
{
|
||||||
|
var message = Messages.Last(sent => sent.To == email && sent.Subject == "Your Seahaven confirmation code");
|
||||||
|
return Code().Match(message.Body).Groups["code"].Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex("https://shoc\\.test/invite#(?<token>[A-Za-z0-9_-]+)")]
|
||||||
|
public static partial Regex InviteLink();
|
||||||
|
|
||||||
|
[GeneratedRegex("<strong>(?<code>[0-9]{6})</strong>")]
|
||||||
|
private static partial Regex Code();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record SentEmail(string To, string Subject, string Body);
|
||||||
|
|
||||||
|
internal sealed class ManualTimeProvider : TimeProvider
|
||||||
|
{
|
||||||
|
public DateTimeOffset Now { get; private set; } = new(2026, 9, 25, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
public override DateTimeOffset GetUtcNow() => Now;
|
||||||
|
|
||||||
|
public void Advance(TimeSpan by) => Now = Now.Add(by);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class CapturingLoggerProvider : ILoggerProvider
|
||||||
|
{
|
||||||
|
private readonly ConcurrentQueue<string> _entries = new();
|
||||||
|
|
||||||
|
public IReadOnlyList<string> Entries => _entries.ToArray();
|
||||||
|
|
||||||
|
public ILogger CreateLogger(string categoryName) => new CapturingLogger(_entries);
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CapturingLogger : ILogger
|
||||||
|
{
|
||||||
|
private readonly ConcurrentQueue<string> _entries;
|
||||||
|
|
||||||
|
public CapturingLogger(ConcurrentQueue<string> entries) => _entries = entries;
|
||||||
|
|
||||||
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull
|
||||||
|
{
|
||||||
|
_entries.Enqueue(state.ToString() ?? "");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsEnabled(LogLevel logLevel) => true;
|
||||||
|
|
||||||
|
public void Log<TState>(
|
||||||
|
LogLevel logLevel,
|
||||||
|
EventId eventId,
|
||||||
|
TState state,
|
||||||
|
Exception? exception,
|
||||||
|
Func<TState, Exception?, string> formatter)
|
||||||
|
{
|
||||||
|
var values = state is IEnumerable<KeyValuePair<string, object?>> pairs
|
||||||
|
? string.Join(" ", pairs.Select(pair => $"{pair.Key}={pair.Value}"))
|
||||||
|
: "";
|
||||||
|
_entries.Enqueue($"{formatter(state, exception)} {values} {exception}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue