diff --git a/README.md b/README.md index a4c0a15..aa31751 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,30 @@ git tag v1.0.0 git push origin v1.0.0 ``` +## Inviting users + +When self-service registration is disabled (the right default for a staff-only backend), +an administrator adds people from **Administration → Users → Invite user**. The invitation +creates a passwordless, unconfirmed account and emails a single-use link; the invitee sets +their own password and lands signed in. No password ever passes through the admin's hands, +and registration stays closed. + +The invitation email is sent through `IUserInvitationEmailSender`. Registering one is +optional — without it, invitations reuse your existing `IEmailSender` password-reset +path, so nothing breaks on upgrade. Register your own to give invitees copy that reads like +an invitation: + +```csharp +services.AddScoped(); +``` + +Invitation links live for 7 days by default (independent of the shorter password-reset +lifespan). Override it if needed: + +```csharp +services.Configure(o => o.TokenLifespan = TimeSpan.FromDays(3)); +``` + ## Security model The library ships secure defaults but delegates some security-critical concerns to diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md index 773c0a7..52d498a 100644 --- a/THREAT-MODEL.md +++ b/THREAT-MODEL.md @@ -86,6 +86,19 @@ These are enforced by the library itself — you do not need to wire them up. *Not* guarded: `CreateAsync` and `UpdateAsync`, which ASP.NET Identity legitimately drives through **anonymous** flows (registration, password reset, email confirmation, lockout) — their authorization is the reset/confirmation **token** at the UI layer. +- **Admin-initiated invitations.** Because `CreateAsync` is intentionally ungated (above), + the invitation flow enforces its own check: creating an invited account requires the + acting user to hold the `Administrator` role, verified in `UserInvitationService` + against the live projection, and failing closed for an unidentified caller (#100). The + invitation link carries a purpose-scoped, DataProtection-backed token (default lifespan + **7 days**, independent of the shorter password-reset token) that is **single use**: + accepting sets a password, which rotates the security stamp the token is bound to, so a + forwarded or replayed link no longer validates. An invited account is created + passwordless and **unconfirmed**, which also keeps it off the forgot-password path + (that path requires a confirmed email) — so a pending invitation cannot be redirected by + anyone who merely knows the address. Re-inviting an already-accepted account is refused, + so "resend invitation" can never become an unauthenticated password reset for a live + account. - **PII erasure.** Past the retention period the cleanup job erases personal data from the event stream via Marten event-data masking (no raw SQL). - **Parameterized data access** throughout — no SQL injection surface in the library. diff --git a/samples/MartenIdentity.Aspire.Web/Components/Pages/Setup.razor b/samples/MartenIdentity.Aspire.Web/Components/Pages/Setup.razor index e357f02..faa6acd 100644 --- a/samples/MartenIdentity.Aspire.Web/Components/Pages/Setup.razor +++ b/samples/MartenIdentity.Aspire.Web/Components/Pages/Setup.razor @@ -1,8 +1,10 @@ @page "/Setup" @rendermode InteractiveServer @attribute [AllowAnonymous] +@layout LoginLayout @using AndreGoepel.Marten.Identity +@using AndreGoepel.Marten.Identity.Blazor.Components.Layout @using AndreGoepel.Marten.Identity.Services @using Marten @using Microsoft.AspNetCore.Identity @@ -15,53 +17,53 @@ First-run setup - - Create the first administrator - - No administrator exists yet. The account you create here becomes the un-deletable - root administrator, so complete this step on a trusted network before exposing the app. - - - @if (errors.Count > 0) - { - -
    - @foreach (var error in errors) - { -
  • @error
  • - } -
-
- } + + +@if (errors.Count > 0) +{ + +
    + @foreach (var error in errors) + { +
  • @error
  • + } +
+
+} - - - - - - - - - - - - + + + + + + + + + + + + + - - + +
+ @code { private readonly Model model = new(); private readonly List errors = new(); private bool busy; - // Set the topbar breadcrumb for the first-run setup page. - [CascadingParameter] - private BreadcrumbState? Breadcrumb { get; set; } - - protected override void OnParametersSet() => Breadcrumb?.Set("Setup"); - protected override async Task OnInitializedAsync() { // Defence in depth: the redirect middleware already blocks /Setup once an diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Components/Account/Pages/AcceptInvitation.razor b/src/AndreGoepel.Marten.Identity.Blazor/Components/Account/Pages/AcceptInvitation.razor new file mode 100644 index 0000000..e852aea --- /dev/null +++ b/src/AndreGoepel.Marten.Identity.Blazor/Components/Account/Pages/AcceptInvitation.razor @@ -0,0 +1,199 @@ +@page "/Account/AcceptInvitation" +@attribute [AllowAnonymous] +@layout LoginLayout +@rendermode InteractiveServer +@using Microsoft.AspNetCore.WebUtilities + +@inject UserManager UserManager +@inject NavigationManager NavigationManager +@inject NotificationService NotificationService +@inject IOptions IdentityOptions +@inject LoginTokenProtector LoginTokens + +@* Deliberately NOT registered in IdentityFeatureGateMiddleware.MapPathToFeature: this page + is the way a host adds people while self-service registration is disabled, so gating it + behind the UserRegistration flag would defeat its purpose (#100). *@ + + + + + +@if (_invalidLink) +{ + + This invitation link is invalid or has expired. Ask your administrator to send a new one. + +} +else +{ + + + + + + + + + + + + + + + + + + + + + +} + + + +@code { + [SupplyParameterFromQuery] + private string? UserId { get; set; } + + [SupplyParameterFromQuery] + private string? Code { get; set; } + + private InputModel Input { get; set; } = new(); + private bool _isProcessing; + private bool _invalidLink; + private string? _email; + private string? _decodedCode; + private string? _handle; + + private int MinPasswordLength => IdentityOptions.Value.Password.RequiredLength; + + protected override async Task OnInitializedAsync() + { + if (string.IsNullOrEmpty(UserId) || string.IsNullOrEmpty(Code)) + { + _invalidLink = true; + return; + } + + var user = await UserManager.FindByIdAsync(UserId); + if (user is null) + { + _invalidLink = true; + return; + } + + _decodedCode = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code)); + + // Verify before rendering the form so an expired or already-redeemed link never + // shows a password box. Redeeming rotates the security stamp the token is bound to, + // so a second visit to the same link lands here as invalid — single use, for free. + var valid = await UserManager.VerifyUserTokenAsync( + user, + UserInvitationTokenProvider.ProviderName, + UserInvitationTokenProvider.Purpose, + _decodedCode + ); + + if (!valid) + { + _invalidLink = true; + return; + } + + _email = user.Email; + } + + private async Task OnValidSubmitAsync(InputModel model) + { + if (_invalidLink || _decodedCode is null) + { + return; + } + + _isProcessing = true; + + try + { + var user = UserId is null ? null : await UserManager.FindByIdAsync(UserId); + if (user is null) + { + _invalidLink = true; + return; + } + + // Re-verify on submit: the account could have been claimed on another device + // between load and submit, and the check is cheap. Fail closed if so. + var valid = await UserManager.VerifyUserTokenAsync( + user, + UserInvitationTokenProvider.ProviderName, + UserInvitationTokenProvider.Purpose, + _decodedCode + ); + if (!valid) + { + _invalidLink = true; + return; + } + + var addPassword = await UserManager.AddPasswordAsync(user, model.Password); + if (!addPassword.Succeeded) + { + NotificationService.Notify( + NotificationSeverity.Error, + "Could not set password", + string.Join(", ", addPassword.Errors.Select(e => e.Description)) + ); + return; + } + + // Redeeming the invitation proves control of the mailbox it was sent to, so the + // email is now confirmed. Generate-then-consume a confirmation token rather than + // writing EmailConfirmed directly, to go through the store's normal event path. + if (!user.EmailConfirmed) + { + var confirmToken = await UserManager.GenerateEmailConfirmationTokenAsync(user); + await UserManager.ConfirmEmailAsync(user, confirmToken); + } + + // Sign in the same way the login page does: hand a single-use handle to the + // cookie middleware via SignInHandoff (the circuit cannot set cookies itself). + _handle = LoginTokens.Protect( + new LoginInfo(user.Email!, model.Password, RememberMe: false, "/") + ); + } + finally + { + _isProcessing = false; + } + } + + private sealed class InputModel + { + [Required] + [StringLength(100, MinimumLength = 12)] + [DataType(DataType.Password)] + public string Password { get; set; } = ""; + + [DataType(DataType.Password)] + [Compare(nameof(Password))] + public string ConfirmPassword { get; set; } = ""; + } +} diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Components/Administration/Dialogs/InviteUser.razor b/src/AndreGoepel.Marten.Identity.Blazor/Components/Administration/Dialogs/InviteUser.razor new file mode 100644 index 0000000..8cf2658 --- /dev/null +++ b/src/AndreGoepel.Marten.Identity.Blazor/Components/Administration/Dialogs/InviteUser.razor @@ -0,0 +1,36 @@ +@inject DialogService DialogService + + + + + + + + + + + + + + + + + diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Components/Administration/Dialogs/InviteUser.razor.cs b/src/AndreGoepel.Marten.Identity.Blazor/Components/Administration/Dialogs/InviteUser.razor.cs new file mode 100644 index 0000000..95e23aa --- /dev/null +++ b/src/AndreGoepel.Marten.Identity.Blazor/Components/Administration/Dialogs/InviteUser.razor.cs @@ -0,0 +1,61 @@ +using AndreGoepel.Marten.Identity.Blazor.Email; +using AndreGoepel.Marten.Identity.Roles; +using AndreGoepel.Marten.Identity.Users; +using Marten; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Identity; + +namespace AndreGoepel.Marten.Identity.Blazor.Components.Administration.Dialogs; + +public partial class InviteUser +{ + [Inject] + private RoleManager RoleManager { get; set; } = default!; + + [Inject] + private UserInvitationMailer InvitationMailer { get; set; } = default!; + + private string _email = ""; + private IReadOnlyList _roles = []; + private IEnumerable _selectedRoles = []; + private bool _busy; + + protected override async Task OnInitializedAsync() + { + _roles = await RoleManager + .Roles.Where(role => !role.Deleted) + .ToListAsync(CancellationToken.None); + } + + private async Task SendInvitationAsync() + { + if (string.IsNullOrWhiteSpace(_email)) + { + await DialogService.Alert("Email is required.", "Invite user"); + return; + } + + _busy = true; + try + { + var result = await InvitationMailer.InviteAsync( + _email.Trim(), + _selectedRoles.ToArray() + ); + if (!result.Succeeded) + { + await DialogService.Alert( + string.Join(", ", result.Errors.Select(e => e.Description)), + "Could not invite user" + ); + return; + } + + DialogService.Close(true); + } + finally + { + _busy = false; + } + } +} diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Components/Administration/Pages/Users.razor b/src/AndreGoepel.Marten.Identity.Blazor/Components/Administration/Pages/Users.razor index fcad90a..3121dbd 100644 --- a/src/AndreGoepel.Marten.Identity.Blazor/Components/Administration/Pages/Users.razor +++ b/src/AndreGoepel.Marten.Identity.Blazor/Components/Administration/Pages/Users.razor @@ -1,11 +1,14 @@ @page "/Administration/Users" @rendermode InteractiveServer @using AndreGoepel.Marten.Identity.Blazor.Components.Administration.Dialogs +@using AndreGoepel.Marten.Identity.Blazor.Email @inject UserManager UserManager @inject UserStore UserStore @inject DialogService DialogService @inject ContextMenuService ContextMenuService +@inject UserInvitationMailer InvitationMailer +@inject NotificationService NotificationService @attribute [Authorize(Roles = "Administrator")] @@ -23,6 +26,11 @@ + @@ -64,6 +72,12 @@ { Confirmed } + else if (UserInvitationService.IsPending(user)) + { + @* Design package ships only success/danger badges; use its + warning palette inline for the pending-invite state (#100). *@ + Invited + } else { Unconfirmed @@ -150,6 +164,35 @@ } } + private async Task InviteUserAsync() + { + var result = await DialogService.OpenAsync("Invite user", + new Dictionary(), + new DialogOptions() { Width = "480px" }); + + if (result == true) + { + NotificationService.Notify(NotificationSeverity.Success, "Invitation sent"); + await LoadUsersAsync(); + } + } + + private async Task ResendInvitationAsync(User user) + { + var result = await InvitationMailer.ResendAsync(user); + if (result.Succeeded) + { + NotificationService.Notify(NotificationSeverity.Success, "Invitation resent"); + } + else + { + await DialogService.Alert( + string.Join(", ", result.Errors.Select(e => e.Description)), + "Could not resend invitation" + ); + } + } + private async Task DeleteUserAsync(User user) { if (await DialogService.Confirm("Are you sure you want to delete this user?", "Delete User") == true) @@ -188,7 +231,8 @@ await LoadUsersAsync(); } - private static bool RowHasMenu(User user) => user.Deleted || user.Deletable; + private static bool RowHasMenu(User user) => + user.Deleted || user.Deletable || UserInvitationService.IsPending(user); private void OpenRowMenu(MouseEventArgs args, User user) { @@ -197,9 +241,17 @@ { items.Add(new ContextMenuItem { Text = "Restore", Value = "restore", Icon = "restore" }); } - else if (user.Deletable) + else { - items.Add(new ContextMenuItem { Text = "Delete", Value = "delete", Icon = "delete", IconColor = "var(--ag-danger)" }); + if (UserInvitationService.IsPending(user)) + { + items.Add(new ContextMenuItem { Text = "Resend invitation", Value = "resend", Icon = "mail" }); + } + + if (user.Deletable) + { + items.Add(new ContextMenuItem { Text = "Delete", Value = "delete", Icon = "delete", IconColor = "var(--ag-danger)" }); + } } ContextMenuService.Open(args, items, async e => @@ -210,6 +262,9 @@ case "restore": await RestoreUserAsync(user); break; + case "resend": + await ResendInvitationAsync(user); + break; case "delete": await DeleteUserAsync(user); break; diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Email/DefaultUserInvitationEmailSender.cs b/src/AndreGoepel.Marten.Identity.Blazor/Email/DefaultUserInvitationEmailSender.cs new file mode 100644 index 0000000..b08c4ef --- /dev/null +++ b/src/AndreGoepel.Marten.Identity.Blazor/Email/DefaultUserInvitationEmailSender.cs @@ -0,0 +1,25 @@ +using AndreGoepel.Marten.Identity.Users; +using Microsoft.AspNetCore.Identity; + +namespace AndreGoepel.Marten.Identity.Blazor.Email; + +/// +/// Fallback used when a host registers none (#100). +/// +/// It reuses , which every host +/// already implements, so invitations work out of the box on upgrade with no host change. +/// The trade-off is the copy: the invitee is told to reset a password for an account they +/// have never seen. Hosts that care should register their own implementation — that is the +/// point of the interface being optional. +/// +/// +internal sealed class DefaultUserInvitationEmailSender(IEmailSender emailSender) + : IUserInvitationEmailSender +{ + public Task SendInvitationLinkAsync( + User user, + string email, + string invitationLink, + CancellationToken cancellationToken = default + ) => emailSender.SendPasswordResetLinkAsync(user, email, invitationLink); +} diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Email/IUserInvitationEmailSender.cs b/src/AndreGoepel.Marten.Identity.Blazor/Email/IUserInvitationEmailSender.cs new file mode 100644 index 0000000..b5b781c --- /dev/null +++ b/src/AndreGoepel.Marten.Identity.Blazor/Email/IUserInvitationEmailSender.cs @@ -0,0 +1,30 @@ +using AndreGoepel.Marten.Identity.Users; + +namespace AndreGoepel.Marten.Identity.Blazor.Email; + +/// +/// Sends the email carrying an invitation link (#100). +/// +/// This exists because IEmailSender<TUser> — Microsoft's abstraction, which the +/// rest of the identity UI uses — has no invitation method; its three methods cover +/// confirmation and password reset only. Registering an implementation is optional: a host +/// that does nothing gets , which reuses the +/// existing password-reset mail path, so upgrading breaks no one. Implement this to give +/// invitees copy that reads like an invitation instead of a password reset. +/// +/// +public interface IUserInvitationEmailSender +{ + /// The invited, not-yet-claimed account. + /// Address to send to. + /// + /// Absolute acceptance URL, unencoded. HTML-encode it yourself if — and only if — you + /// embed it in an HTML body; leave it as-is for plain-text delivery. + /// + Task SendInvitationLinkAsync( + User user, + string email, + string invitationLink, + CancellationToken cancellationToken = default + ); +} diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Email/UserInvitationMailer.cs b/src/AndreGoepel.Marten.Identity.Blazor/Email/UserInvitationMailer.cs new file mode 100644 index 0000000..0949fe6 --- /dev/null +++ b/src/AndreGoepel.Marten.Identity.Blazor/Email/UserInvitationMailer.cs @@ -0,0 +1,72 @@ +using System.Text; +using AndreGoepel.Marten.Identity.Users; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.WebUtilities; + +namespace AndreGoepel.Marten.Identity.Blazor.Email; + +/// +/// Ties the three steps of an invitation together — create the account, build the +/// acceptance link, send the mail — so the admin UI never has to (#100). +/// +/// Both entry points (invite, resend) need the identical link-building and send, so it +/// lives here once rather than being copied into the dialog and the Users page. The +/// account creation and its authorization check stay in +/// ; this only adds the Blazor-layer link and mail. +/// +/// +internal sealed class UserInvitationMailer( + UserInvitationService invitations, + IUserInvitationEmailSender emailSender, + NavigationManager navigation +) +{ + public async Task InviteAsync( + string email, + IEnumerable roles, + CancellationToken cancellationToken = default + ) + { + var result = await invitations.InviteAsync(email, roles, cancellationToken); + if (!result.Succeeded) + return result.Result; + + await SendAsync(result, email, cancellationToken); + return IdentityResult.Success; + } + + public async Task ResendAsync( + User user, + CancellationToken cancellationToken = default + ) + { + var result = await invitations.ResendAsync(user, cancellationToken); + if (!result.Succeeded) + return result.Result; + + await SendAsync(result, user.Email!, cancellationToken); + return IdentityResult.Success; + } + + private async Task SendAsync( + UserInvitationResult result, + string email, + CancellationToken cancellationToken + ) + { + // userId identifies the account; the token rides in the query Base64Url-encoded so + // it survives the URL intact. + var encodedCode = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(result.Token!)); + var link = navigation.GetUriWithQueryParameters( + navigation.ToAbsoluteUri("Account/AcceptInvitation").AbsoluteUri, + new Dictionary { ["userId"] = result.User!.Id, ["code"] = encodedCode } + ); + + // Pass the raw URL. HTML-encoding is the sender's job, done only if it embeds the + // link in HTML — encoding here turns the query separator into "&", which breaks + // the link for any sender that emits plain text (e.g. a dev logger writing it to a + // console, where "&" is copied verbatim into the browser and splits the query). + await emailSender.SendInvitationLinkAsync(result.User, email, link, cancellationToken); + } +} diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Initialization.cs b/src/AndreGoepel.Marten.Identity.Blazor/Initialization.cs index 743ee8d..7476a52 100644 --- a/src/AndreGoepel.Marten.Identity.Blazor/Initialization.cs +++ b/src/AndreGoepel.Marten.Identity.Blazor/Initialization.cs @@ -1,4 +1,5 @@ using AndreGoepel.Marten.Identity.Blazor.Components.Account; +using AndreGoepel.Marten.Identity.Blazor.Email; using AndreGoepel.Marten.Identity.Blazor.Features; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; @@ -29,6 +30,12 @@ public static IServiceCollection AddMartenIdentityBlazor( // register a persistence-backed provider that takes precedence. services.TryAddScoped(); + // Invitation mail falls back to the host's existing password-reset path, so hosts + // that register nothing keep working on upgrade. TryAdd lets one that wants proper + // invitation copy register its own sender instead (#100). + services.TryAddScoped(); + services.TryAddScoped(); + return services; } } diff --git a/src/AndreGoepel.Marten.Identity/Initialization.cs b/src/AndreGoepel.Marten.Identity/Initialization.cs index 8614d64..34f4d6b 100644 --- a/src/AndreGoepel.Marten.Identity/Initialization.cs +++ b/src/AndreGoepel.Marten.Identity/Initialization.cs @@ -69,12 +69,19 @@ var scheme in new[] .AddRoleManager>() .AddRoleStore>() .AddSignInManager() - .AddDefaultTokenProviders(); + .AddDefaultTokenProviders() + // Invitations get their own token provider so their lifespan (7 days by + // default) is independent of the 1-day default that password-reset tokens + // rely on — a shared provider would force one lifespan onto both (#100). + .AddTokenProvider( + UserInvitationTokenProvider.ProviderName + ); services.AddScoped(); services.AddScoped(); services.AddScoped>(); services.AddScoped>(); + services.AddScoped(); services.AddSingleton(); return services; diff --git a/src/AndreGoepel.Marten.Identity/Users/UserInvitationService.cs b/src/AndreGoepel.Marten.Identity/Users/UserInvitationService.cs new file mode 100644 index 0000000..1aa4475 --- /dev/null +++ b/src/AndreGoepel.Marten.Identity/Users/UserInvitationService.cs @@ -0,0 +1,156 @@ +using AndreGoepel.Marten.Identity.Services; +using Microsoft.AspNetCore.Identity; + +namespace AndreGoepel.Marten.Identity.Users; + +/// +/// Creates accounts on an administrator's behalf so a host can add people while +/// self-service registration stays closed (#100). +/// +/// The invitee — not the administrator — chooses the password: an invitation creates a +/// passwordless, unconfirmed account and emails a link the invitee redeems. No colleague's +/// password ever passes through an admin's hands or a side channel. +/// +/// +/// Why the authorization check lives here. Unlike the rest of the store surface, +/// 's create path is deliberately ungated — self-service +/// registration is anonymous, so it cannot require an administrator. That makes this +/// service, not the store, the place the invitation path has to enforce authority; the +/// page-level [Authorize] attribute would otherwise be the only barrier, which is +/// exactly the single point of failure exists to back up +/// (#69/#41). +/// +/// +public sealed class UserInvitationService( + UserManager userManager, + IIdentityAuthorizer authorizer +) +{ + private static IdentityResult NotAuthorized() => + IdentityResult.Failed( + new IdentityError + { + Code = "NotAuthorized", + Description = "Inviting a user requires administrator authority.", + } + ); + + private static IdentityResult Failure(string code, string description) => + IdentityResult.Failed(new IdentityError { Code = code, Description = description }); + + /// + /// Creates a passwordless, unconfirmed account for , assigns + /// , and returns the invitation token to embed in the link. + /// + public async Task InviteAsync( + string email, + IEnumerable? roles = null, + CancellationToken cancellationToken = default + ) + { + if (!await authorizer.IsCurrentUserAdministratorAsync(cancellationToken)) + return UserInvitationResult.Failed(NotAuthorized()); + + if (await userManager.FindByEmailAsync(email) is not null) + return UserInvitationResult.Failed( + Failure("DuplicateEmail", $"An account already exists for {email}.") + ); + + // No password argument: the account is created with a null password hash, so it + // cannot be signed in to until the invitee redeems the link and sets one. Leaving + // EmailConfirmed false also keeps the account off the forgot-password path, which + // requires a confirmed email — so a pending invitation cannot be claimed by + // someone who merely guesses the address. + var user = new User { UserName = email, Email = email }; + + var createResult = await userManager.CreateAsync(user); + if (!createResult.Succeeded) + return UserInvitationResult.Failed(createResult); + + var roleList = roles?.ToArray() ?? []; + if (roleList.Length > 0) + { + try + { + var roleResult = await userManager.AddToRolesAsync(user, roleList); + if (!roleResult.Succeeded) + return UserInvitationResult.Failed(roleResult); + } + catch (IdentityAuthorizationException ex) + { + // The role-assignment store methods signal an authorization failure by + // throwing rather than returning a result (#69/#41). Reaching this after + // the check above would mean authority was lost mid-operation; surface it + // as a normal failure rather than letting it escape as an exception. + return UserInvitationResult.Failed(Failure("NotAuthorized", ex.Message)); + } + } + + return UserInvitationResult.Success(user, await GenerateTokenAsync(user)); + } + + /// + /// Issues a fresh invitation token for an account that was invited but has not been + /// claimed yet, for when the first email is lost or expires. + /// + public async Task ResendAsync( + User user, + CancellationToken cancellationToken = default + ) + { + if (!await authorizer.IsCurrentUserAdministratorAsync(cancellationToken)) + return UserInvitationResult.Failed(NotAuthorized()); + + // Refuse to re-issue against a claimed account. Without this, "resend invitation" + // would amount to a password reset for an active colleague that skips their + // mailbox check: it would mint a link setting their password on demand. + if (!IsPending(user)) + return UserInvitationResult.Failed( + Failure( + "InvitationAlreadyAccepted", + "This account has already been set up; send a password reset instead." + ) + ); + + return UserInvitationResult.Success(user, await GenerateTokenAsync(user)); + } + + /// + /// True while an invitation is outstanding: the account exists but has never been + /// claimed, so it has no password and an unconfirmed email. + /// + public static bool IsPending(User user) => + user.PasswordHash is null && !user.EmailConfirmed && !user.Deleted; + + private Task GenerateTokenAsync(User user) => + userManager.GenerateUserTokenAsync( + user, + UserInvitationTokenProvider.ProviderName, + UserInvitationTokenProvider.Purpose + ); +} + +/// Outcome of an invitation attempt: the created user and its token on success. +public sealed record UserInvitationResult +{ + private UserInvitationResult() { } + + public bool Succeeded { get; private init; } + public IdentityResult Result { get; private init; } = IdentityResult.Success; + public User? User { get; private init; } + public string? Token { get; private init; } + + /// The failure descriptions, joined for display. + public string ErrorMessage => string.Join(", ", Result.Errors.Select(e => e.Description)); + + internal static UserInvitationResult Success(User user, string token) => + new() + { + Succeeded = true, + User = user, + Token = token, + }; + + internal static UserInvitationResult Failed(IdentityResult result) => + new() { Succeeded = false, Result = result }; +} diff --git a/src/AndreGoepel.Marten.Identity/Users/UserInvitationTokenProvider.cs b/src/AndreGoepel.Marten.Identity/Users/UserInvitationTokenProvider.cs new file mode 100644 index 0000000..7ec1491 --- /dev/null +++ b/src/AndreGoepel.Marten.Identity/Users/UserInvitationTokenProvider.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace AndreGoepel.Marten.Identity.Users; + +/// +/// Issues and validates the single-use tokens carried by invitation links (#100). +/// +/// Single use is not enforced here; it falls out of the design. The token embeds the +/// user's security stamp, and accepting an invitation sets a password, which rotates that +/// stamp — so a link stops validating the moment it is redeemed, and a forwarded or leaked +/// invitation cannot be replayed against an account that has already been claimed. +/// +/// +public sealed class UserInvitationTokenProvider( + IDataProtectionProvider dataProtectionProvider, + IOptions options, + ILogger> logger +) : DataProtectorTokenProvider(dataProtectionProvider, options, logger) +{ + /// Name this provider is registered under in IdentityOptions.Tokens. + public const string ProviderName = "MartenIdentityInvitation"; + + /// + /// Token purpose. Distinct from every built-in purpose so an invitation token can never + /// be redeemed as an email-confirmation or password-reset token, or vice versa. + /// + public const string Purpose = "UserInvitation"; +} diff --git a/src/AndreGoepel.Marten.Identity/Users/UserInvitationTokenProviderOptions.cs b/src/AndreGoepel.Marten.Identity/Users/UserInvitationTokenProviderOptions.cs new file mode 100644 index 0000000..be08790 --- /dev/null +++ b/src/AndreGoepel.Marten.Identity/Users/UserInvitationTokenProviderOptions.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Identity; + +namespace AndreGoepel.Marten.Identity.Users; + +/// +/// Settings for the invitation token provider (#100). +/// +/// Invitations deliberately get their own provider rather than reusing the default one: an +/// invitation has to survive a colleague reading their mail after a weekend, whereas a +/// password-reset token should stay short-lived. Sharing a provider would force one +/// lifespan onto both, so lengthening the invite would weaken the reset path. +/// +/// +/// Hosts override the default with +/// services.Configure<UserInvitationTokenProviderOptions>(o => o.TokenLifespan = ...). +/// +/// +public sealed class UserInvitationTokenProviderOptions : DataProtectionTokenProviderOptions +{ + public UserInvitationTokenProviderOptions() + { + Name = UserInvitationTokenProvider.ProviderName; + TokenLifespan = TimeSpan.FromDays(7); + } +} diff --git a/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Account/Pages/AcceptInvitation.Tests.cs b/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Account/Pages/AcceptInvitation.Tests.cs new file mode 100644 index 0000000..7cf60cb --- /dev/null +++ b/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Account/Pages/AcceptInvitation.Tests.cs @@ -0,0 +1,154 @@ +using System.Text; +using AndreGoepel.Marten.Identity.Blazor.Components.Account.Pages; +using AndreGoepel.Marten.Identity.Http; +using AndreGoepel.Marten.Identity.Users; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NSubstitute; +using Radzen; + +namespace AndreGoepel.Marten.Identity.Blazor.Tests.Account.Pages; + +/// +/// The accept-invitation page must never render its password form for a link that will not +/// redeem — a missing/garbled query, an unknown user, or a token the store rejects (expired +/// or already used). Showing the form in those cases would invite a confusing failure only +/// after the invitee typed a password. These pin the invalid-link gate; the full happy path +/// is exercised end-to-end in the E2E suite (#100). +/// +public class AcceptInvitationTests : BunitContext +{ + #region Helpers + + private const string InvalidLinkText = "invitation link is invalid"; + + private IRenderedComponent Render( + string? userId, + string? code, + Action>? configure = null + ) + { + JSInterop.Mode = JSRuntimeMode.Loose; + var store = Substitute.For>(); + var um = Substitute.For>( + store, + Options.Create(new IdentityOptions()), + null!, + null!, + null!, + null!, + null!, + null!, + null! + ); + configure?.Invoke(um); + + Services.AddSingleton(um); + Services.AddSingleton(new NotificationService()); + Services.AddSingleton(new LoginTokenProtector(DataProtectionProvider.Create("Tests"))); + + var query = new Dictionary(); + if (userId is not null) + query["userId"] = userId; + if (code is not null) + query["code"] = code; + + var nav = Services.GetRequiredService(); + nav.NavigateTo(nav.GetUriWithQueryParameters("Account/AcceptInvitation", query)); + + return Render(); + } + + private static string Encode(string token) => + WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token)); + + #endregion + + [Fact] + public void MissingQueryParameters_ShowsInvalidLink() + { + // Arrange / Act + var cut = Render(userId: null, code: null); + + // Assert + Assert.Contains(InvalidLinkText, cut.Markup); + Assert.Empty(cut.FindAll("input[name=Password]")); + } + + [Fact] + public void UnknownUser_ShowsInvalidLink() + { + // Arrange + // Act — a well-formed link whose user id resolves to nobody. + var cut = Render( + userId: Guid.NewGuid().ToString(), + code: Encode("token"), + configure: um => um.FindByIdAsync(Arg.Any()).Returns((User?)null) + ); + + // Assert + Assert.Contains(InvalidLinkText, cut.Markup); + Assert.Empty(cut.FindAll("input[name=Password]")); + } + + [Fact] + public void RejectedToken_ShowsInvalidLink() + { + // Arrange + var user = new User { Email = "invitee@example.com" }; + + // Act — the user exists, but the store rejects the token (expired or already used). + var cut = Render( + userId: Guid.NewGuid().ToString(), + code: Encode("stale-token"), + configure: um => + { + um.FindByIdAsync(Arg.Any()).Returns(user); + um.VerifyUserTokenAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ) + .Returns(false); + } + ); + + // Assert + Assert.Contains(InvalidLinkText, cut.Markup); + Assert.Empty(cut.FindAll("input[name=Password]")); + } + + [Fact] + public void ValidToken_ShowsPasswordForm() + { + // Arrange + var user = new User { Email = "invitee@example.com" }; + + // Act + var cut = Render( + userId: Guid.NewGuid().ToString(), + code: Encode("good-token"), + configure: um => + { + um.FindByIdAsync(Arg.Any()).Returns(user); + um.VerifyUserTokenAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ) + .Returns(true); + } + ); + + // Assert + Assert.DoesNotContain(InvalidLinkText, cut.Markup); + Assert.NotEmpty(cut.FindAll("input[name=Password]")); + } +} diff --git a/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Features/IdentityFeatureGateMiddlewareTests.cs b/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Features/IdentityFeatureGateMiddlewareTests.cs index 6aede57..4b0b995 100644 --- a/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Features/IdentityFeatureGateMiddlewareTests.cs +++ b/tests/AndreGoepel.Marten.Identity.Blazor.Tests/Features/IdentityFeatureGateMiddlewareTests.cs @@ -94,6 +94,10 @@ public async Task Passkey_Disabled_IsBlocked(string path) [InlineData("/Account/ForgotPassword")] [InlineData("/Account/Manage/Profile")] [InlineData("/dashboard")] + // AcceptInvitation is the admin-invite counterpart to Register. It must stay reachable + // precisely when registration is off, or disabling self-service signup would also + // disable the only supported way to add a user (#100). + [InlineData("/Account/AcceptInvitation")] public async Task UngatedPaths_AlwaysPassThrough(string path) { var (mw, ctx, called) = Build(path, secFetchDest: "document"); diff --git a/tests/AndreGoepel.Marten.Identity.E2ETests/Tests/InvitationTests.cs b/tests/AndreGoepel.Marten.Identity.E2ETests/Tests/InvitationTests.cs new file mode 100644 index 0000000..f03a3c9 --- /dev/null +++ b/tests/AndreGoepel.Marten.Identity.E2ETests/Tests/InvitationTests.cs @@ -0,0 +1,101 @@ +using AndreGoepel.Marten.Identity.E2ETests.Infrastructure; + +namespace AndreGoepel.Marten.Identity.E2ETests.Tests; + +/// +/// The admin-invite flow end to end: an administrator invites a colleague by email, the +/// colleague redeems the link, sets a password, and lands signed in — with self-service +/// registration untouched (#100). This is the flow that closes the "no way to add a user +/// when registration is disabled" gap. +/// +public sealed class InvitationTests(E2EAppFixture fixture) : E2ETestBase(fixture) +{ + [Fact] + public async Task Admin_InvitesUser_InviteeSetsPassword_AndIsSignedIn() + { + // Arrange — signed-in admin on the Users page; a fresh invitee address. + await LoginAsAdminAsync(); + await Page.GotoAsync("/Administration/Users"); + var invitee = TestData.NewEmail("invitee"); + + // Act — open the invite dialog, send the invitation. + await Page.ClickButtonAsync("Invite user"); + await Page.FillFieldAsync("Email", invitee); + await Page.ClickButtonAsync("Send invitation"); + + // The invitation link is captured from the outgoing mail (the sample's default + // sender routes invitations through the password-reset path). + var link = await Fixture.Email.WaitForLinkAsync(invitee, "Account/AcceptInvitation"); + + // The invitee opens the link in their own fresh session, not the admin's. + await using var inviteeContext = await Fixture.NewContextAsync(); + var inviteePage = await inviteeContext.NewPageAsync(); + await inviteePage.GotoAsync(link); + await inviteePage.WaitForBlazorAsync(); + + await inviteePage.FillFieldAsync("Password", TestData.DefaultPassword); + await inviteePage.FillFieldAsync("ConfirmPassword", TestData.DefaultPassword); + await inviteePage.ClickButtonAsync("Set password"); + + // Assert — the invitee leaves the accept page (the sign-in handoff redirects away) + // and can reach an authentication-only page without being bounced to login. + await inviteePage.WaitForURLAsync(url => + !new Uri(url).AbsolutePath.Contains( + "AcceptInvitation", + StringComparison.OrdinalIgnoreCase + ) + ); + await inviteePage.GotoAsync("/Account/Manage/Profile"); + await inviteePage.AssertOnPathAsync("Account/Manage/Profile"); + + // And the admin now sees the account as Confirmed in the grid. Filter to the + // invitee first: the grid pages at 10 rows, and by the time this runs the shared + // suite database holds enough accounts to push a newly-created one onto a later + // page, where Radzen would not render its row at all. + await Page.GotoAsync("/Administration/Users"); + await Page.Locator(".ag-search-input").FillAsync(invitee); + var row = Page.Locator(".rz-data-grid tr", new() { HasTextString = invitee }); + await Expect(row.GetByText("Confirmed")).ToBeVisibleAsync(); + } + + [Fact] + public async Task InvitationLink_CannotBeReplayed_AfterAcceptance() + { + // Arrange — invite and accept once. + await LoginAsAdminAsync(); + await Page.GotoAsync("/Administration/Users"); + var invitee = TestData.NewEmail("replay"); + + await Page.ClickButtonAsync("Invite user"); + await Page.FillFieldAsync("Email", invitee); + await Page.ClickButtonAsync("Send invitation"); + var link = await Fixture.Email.WaitForLinkAsync(invitee, "Account/AcceptInvitation"); + + await using (var firstContext = await Fixture.NewContextAsync()) + { + var firstVisit = await firstContext.NewPageAsync(); + await firstVisit.GotoAsync(link); + await firstVisit.WaitForBlazorAsync(); + await firstVisit.FillFieldAsync("Password", TestData.DefaultPassword); + await firstVisit.FillFieldAsync("ConfirmPassword", TestData.DefaultPassword); + await firstVisit.ClickButtonAsync("Set password"); + await firstVisit.WaitForURLAsync(url => + !new Uri(url).AbsolutePath.Contains( + "AcceptInvitation", + StringComparison.OrdinalIgnoreCase + ) + ); + } + + // Act — a second visit to the same link, after the account was claimed. + await using var replayContext = await Fixture.NewContextAsync(); + var replay = await replayContext.NewPageAsync(); + await replay.GotoAsync(link); + await replay.WaitForBlazorAsync(); + + // Assert — the token no longer validates (setting the password rotated the security + // stamp it was bound to), so the page refuses instead of offering the form again. + await Expect(replay.GetByText("invitation link is invalid")).ToBeVisibleAsync(); + Assert.Empty(await replay.Locator("input[name=Password]").AllAsync()); + } +} diff --git a/tests/AndreGoepel.Marten.Identity.IntegrationTests/Users/UserInvitationServiceTests.cs b/tests/AndreGoepel.Marten.Identity.IntegrationTests/Users/UserInvitationServiceTests.cs new file mode 100644 index 0000000..9d954e7 --- /dev/null +++ b/tests/AndreGoepel.Marten.Identity.IntegrationTests/Users/UserInvitationServiceTests.cs @@ -0,0 +1,219 @@ +using AndreGoepel.Marten.Identity; +using AndreGoepel.Marten.Identity.IntegrationTests.Infrastructure; +using AndreGoepel.Marten.Identity.Roles; +using AndreGoepel.Marten.Identity.Roles.Events; +using AndreGoepel.Marten.Identity.Services; +using AndreGoepel.Marten.Identity.Users; +using Marten; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace AndreGoepel.Marten.Identity.IntegrationTests.Users; + +/// +/// The invitation service is the authorization gate for admin-created accounts: unlike the +/// rest of the store surface, the underlying create path is ungated, so this service is +/// where "only an administrator may invite" is enforced (#100, mirroring #69/#41). These +/// tests pin that gate, plus the shape of an invited account (no password, unconfirmed) and +/// the refusal to re-invite an account that has already been claimed. +/// +[Collection(IntegrationCollection.Name)] +public class UserInvitationServiceTests(MartenFixture fixture) : IAsyncLifetime +{ + private readonly List _scopes = []; + private ServiceProvider? _provider; + + private CancellationToken Ct => TestContext.Current.CancellationToken; + + public async ValueTask InitializeAsync() => await fixture.ResetAsync(Ct); + + public ValueTask DisposeAsync() + { + foreach (var scope in _scopes) + scope.Dispose(); + _provider?.Dispose(); + return ValueTask.CompletedTask; + } + + [Fact] + public async Task InviteAsync_AnonymousActor_ReturnsNotAuthorized() + { + var (invitations, _) = BuildFor(default); // Guid.Empty — fails closed + + var result = await invitations.InviteAsync("new@example.com", cancellationToken: Ct); + + Assert.False(result.Succeeded); + Assert.Contains(result.Result.Errors, e => e.Code == "NotAuthorized"); + } + + [Fact] + public async Task InviteAsync_NonAdminActor_ReturnsNotAuthorized() + { + var (invitations, _) = BuildFor(UserId.New()); // identified, but not an admin + + var result = await invitations.InviteAsync("new@example.com", cancellationToken: Ct); + + Assert.False(result.Succeeded); + Assert.Contains(result.Result.Errors, e => e.Code == "NotAuthorized"); + Assert.Null(await FindAsync("new@example.com")); + } + + [Fact] + public async Task InviteAsync_AdminActor_CreatesPasswordlessUnconfirmedUserWithToken() + { + var admin = await SeedAdminAsync(); + var (invitations, _) = BuildFor(admin); + + var result = await invitations.InviteAsync("new@example.com", cancellationToken: Ct); + + Assert.True(result.Succeeded); + Assert.False(string.IsNullOrEmpty(result.Token)); + + var created = await FindAsync("new@example.com"); + Assert.NotNull(created); + Assert.Null(created!.PasswordHash); + Assert.False(created.EmailConfirmed); + Assert.True(UserInvitationService.IsPending(created)); + } + + [Fact] + public async Task InviteAsync_WithRoles_AssignsThem() + { + var admin = await SeedAdminAsync(); + await SeedRoleAsync("Member"); + var (invitations, users) = BuildFor(admin); + + var result = await invitations.InviteAsync( + "new@example.com", + ["Member"], + cancellationToken: Ct + ); + + Assert.True(result.Succeeded); + var created = await users.FindByEmailAsync("new@example.com"); + Assert.Contains("Member", await users.GetRolesAsync(created!)); + } + + [Fact] + public async Task InviteAsync_DuplicateEmail_Fails() + { + var admin = await SeedAdminAsync(); + var (invitations, _) = BuildFor(admin); + await invitations.InviteAsync("dupe@example.com", cancellationToken: Ct); + + var again = await invitations.InviteAsync("dupe@example.com", cancellationToken: Ct); + + Assert.False(again.Succeeded); + Assert.Contains(again.Result.Errors, e => e.Code == "DuplicateEmail"); + } + + [Fact] + public async Task ResendAsync_PendingInvitation_IssuesFreshToken() + { + var admin = await SeedAdminAsync(); + var (invitations, users) = BuildFor(admin); + await invitations.InviteAsync("pending@example.com", cancellationToken: Ct); + var user = await users.FindByEmailAsync("pending@example.com"); + + var resend = await invitations.ResendAsync(user!, Ct); + + Assert.True(resend.Succeeded); + Assert.False(string.IsNullOrEmpty(resend.Token)); + } + + [Fact] + public async Task ResendAsync_AlreadyAcceptedAccount_IsRefused() + { + var admin = await SeedAdminAsync(); + var (invitations, users) = BuildFor(admin); + await invitations.InviteAsync("claimed@example.com", cancellationToken: Ct); + var user = await users.FindByEmailAsync("claimed@example.com"); + + // Simulate acceptance: the invitee has set a password. Re-inviting now would be an + // unauthenticated password reset for a live account, which the service must refuse. + await users.AddPasswordAsync(user!, "Accept3d-Passw0rd!"); + var claimed = await users.FindByEmailAsync("claimed@example.com"); + + var resend = await invitations.ResendAsync(claimed!, Ct); + + Assert.False(resend.Succeeded); + Assert.Contains(resend.Result.Errors, e => e.Code == "InvitationAlreadyAccepted"); + } + + #region Harness + + private (UserInvitationService Invitations, UserManager Users) BuildFor(UserId actor) + { + _provider ??= BuildProvider(); + var scope = _provider.CreateScope(); + _scopes.Add(scope); + + var current = (MutableCurrentUser) + scope.ServiceProvider.GetRequiredService(); + current.Current = actor; + + return ( + scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService>() + ); + } + + private ServiceProvider BuildProvider() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDataProtection(); + services.AddSingleton(fixture.Store); + services.AddScoped(_ => fixture.Store.LightweightSession()); + services.AddScoped(_ => fixture.Store.QuerySession()); + services.AddMartenIdentity(); + + // Drive the "who is the caller" input the authorizer reads, per test, without an + // HTTP context. The real IdentityAuthorizer still runs and still queries the store + // for the Administrator role — only the identity of the caller is substituted. + services.RemoveAll(); + services.AddScoped(); + + return services.BuildServiceProvider(); + } + + private async Task FindAsync(string email) + { + await using var session = fixture.Store.QuerySession(); + var normalized = email.ToUpperInvariant(); + return await session + .Query() + .Where(u => u.NormalizedEmail == normalized) + .FirstOrDefaultAsync(Ct); + } + + private async Task SeedAdminAsync() + { + // Root creation auto-assigns Administrator via a direct event append, so it needs no + // existing admin (matches UserStoreAuthorizationTests). + var store = UserStoreTestHelpers.BuildStore(fixture.Store); + var root = UserStoreTestHelpers.NewUser("root@example.com"); + root.RootUser = true; + await store.CreateAsync(root, Ct); + return root.UserId; + } + + private async Task SeedRoleAsync(string name) + { + await using var session = fixture.Store.LightweightSession(); + var roleId = RoleId.New(); + session.Events.Append(roleId.Value, new RoleCreated(roleId, name, UserId.New())); + await session.SaveChangesAsync(Ct); + } + + private sealed class MutableCurrentUser : ICurrentUserService + { + public UserId Current { get; set; } + + public Task GetCurrentUserIdAsync(CancellationToken cancellationToken = default) => + Task.FromResult(Current); + } + + #endregion +}