Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<User>` password-reset
path, so nothing breaks on upgrade. Register your own to give invitees copy that reads like
an invitation:

```csharp
services.AddScoped<IUserInvitationEmailSender, MyInvitationEmailSender>();
```

Invitation links live for 7 days by default (independent of the shorter password-reset
lifespan). Override it if needed:

```csharp
services.Configure<UserInvitationTokenProviderOptions>(o => o.TokenLifespan = TimeSpan.FromDays(3));
```

## Security model

The library ships secure defaults but delegates some security-critical concerns to
Expand Down
13 changes: 13 additions & 0 deletions THREAT-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
80 changes: 41 additions & 39 deletions samples/MartenIdentity.Aspire.Web/Components/Pages/Setup.razor
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,53 +17,53 @@

<PageTitle>First-run setup</PageTitle>

<RadzenStack Gap="1rem" Style="max-width: 480px; margin: 2rem auto;">
<RadzenText TagName="TagName.H1" TextStyle="TextStyle.H4">Create the first administrator</RadzenText>
<RadzenText TextStyle="TextStyle.Body2">
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.
</RadzenText>

@if (errors.Count > 0)
{
<RadzenAlert AlertStyle="AlertStyle.Danger" Variant="Variant.Flat" Shade="Shade.Lighter" AllowClose="false">
<ul style="margin: 0; padding-left: 1.2rem;">
@foreach (var error in errors)
{
<li>@error</li>
}
</ul>
</RadzenAlert>
}
<div class="ag-login-head">
<RadzenText TagName="TagName.H1" TextStyle="TextStyle.H4" Text="Create the first administrator" />
<RadzenText TextStyle="TextStyle.Body2" class="ag-login-sub"
Text="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." />
</div>

@if (errors.Count > 0)
{
<RadzenAlert AlertStyle="AlertStyle.Danger" Variant="Variant.Flat" Shade="Shade.Lighter" AllowClose="false">
<ul style="margin: 0; padding-left: 1.2rem;">
@foreach (var error in errors)
{
<li>@error</li>
}
</ul>
</RadzenAlert>
}

<RadzenTemplateForm TItem="Model" Data="@model" Submit="@OnSubmitAsync">
<RadzenStack Gap="1rem">
<RadzenFormField Text="Email">
<RadzenTextBox @bind-Value="model.Email" Name="Email" autocomplete="username" />
</RadzenFormField>
<RadzenFormField Text="Password (at least 12 characters)">
<RadzenPassword @bind-Value="model.Password" Name="Password" autocomplete="new-password" />
</RadzenFormField>
<RadzenFormField Text="Confirm password">
<RadzenPassword @bind-Value="model.ConfirmPassword" Name="ConfirmPassword" autocomplete="new-password" />
</RadzenFormField>
<RadzenButton ButtonType="ButtonType.Submit" Text="Create administrator"
Disabled="@busy" IsBusy="@busy" BusyText="Creating…" />
<RadzenTemplateForm TItem="Model" Data="@model" Submit="@OnSubmitAsync">
<RadzenStack Gap="1.25rem">
<RadzenStack Gap="0.4rem">
<RadzenLabel Text="Email" Component="Email" />
<RadzenTextBox @bind-Value="model.Email" Name="Email"
Placeholder="name@example.com" autocomplete="username" Style="width:100%" />
</RadzenStack>
<RadzenStack Gap="0.4rem">
<RadzenLabel Text="Password (at least 12 characters)" Component="Password" />
<RadzenPassword @bind-Value="model.Password" Name="Password"
autocomplete="new-password" Style="width:100%" />
</RadzenStack>
<RadzenStack Gap="0.4rem">
<RadzenLabel Text="Confirm password" Component="ConfirmPassword" />
<RadzenPassword @bind-Value="model.ConfirmPassword" Name="ConfirmPassword"
autocomplete="new-password" Style="width:100%" />
</RadzenStack>
</RadzenTemplateForm>
</RadzenStack>
<div class="ag-login-actions">
<RadzenButton ButtonType="ButtonType.Submit" Text="Create administrator"
ButtonStyle="ButtonStyle.Primary" Disabled="@busy" IsBusy="@busy" BusyText="Creating…" />
</div>
</RadzenStack>
</RadzenTemplateForm>

@code {
private readonly Model model = new();
private readonly List<string> 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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
@page "/Account/AcceptInvitation"
@attribute [AllowAnonymous]
@layout LoginLayout
@rendermode InteractiveServer
@using Microsoft.AspNetCore.WebUtilities

@inject UserManager<User> UserManager
@inject NavigationManager NavigationManager
@inject NotificationService NotificationService
@inject IOptions<IdentityOptions> 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). *@

<IdentityPageTitle Title="Accept invitation" />

<div class="ag-login-head">
<RadzenText TagName="TagName.H1" TextStyle="TextStyle.H4" Text="Accept your invitation" />
@if (!_invalidLink)
{
<RadzenText TextStyle="TextStyle.Body2" class="ag-login-sub">
Set a password for <strong>@_email</strong> to finish setting up your account.
</RadzenText>
}
</div>

@if (_invalidLink)
{
<RadzenAlert AlertStyle="AlertStyle.Danger" Shade="Shade.Lighter" Variant="Variant.Flat" AllowClose="false">
This invitation link is invalid or has expired. Ask your administrator to send a new one.
</RadzenAlert>
}
else
{
<RadzenTemplateForm TItem="InputModel" Data=@Input Submit=@OnValidSubmitAsync>
<RadzenStack Gap="1.25rem">

<RadzenStack Gap="0.4rem">
<RadzenLabel Text="New password" Component="Password" />
<RadzenPassword @bind-Value=@Input.Password Name="Password" Style="width:100%" />
<RadzenRequiredValidator Component="Password" Text="Password is required" />
<RadzenLengthValidator Component="Password" Min="@MinPasswordLength" Max="100"
Text="@($"Password must be between {MinPasswordLength} and 100 characters")" />
</RadzenStack>

<RadzenStack Gap="0.4rem">
<RadzenLabel Text="Confirm new password" Component="ConfirmPassword" />
<RadzenPassword @bind-Value=@Input.ConfirmPassword Name="ConfirmPassword" Style="width:100%" />
<RadzenRequiredValidator Component="ConfirmPassword" Text="Please confirm your password" />
<RadzenCompareValidator Component="ConfirmPassword"
Value=@Input.Password
Text="The passwords do not match"
Operator="CompareOperator.Equal" />
</RadzenStack>

<div class="ag-login-actions">
<RadzenButton ButtonType="ButtonType.Submit"
Text="Set password & sign in"
ButtonStyle="ButtonStyle.Primary"
IsBusy=@_isProcessing
BusyText="Setting up..." />
</div>

</RadzenStack>
</RadzenTemplateForm>
}

<SignInHandoff Action="/login" Handle="@_handle" />

@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; } = "";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
@inject DialogService DialogService

<RadzenStack Gap="1rem" Orientation="Orientation.Vertical" JustifyContent="JustifyContent.SpaceBetween">
<RadzenStack Gap="1rem">
<RadzenStack Gap="0.4rem">
<RadzenLabel Text="Email" Component="Email" />
<RadzenTextBox Name="Email"
Style="width: 100%"
Placeholder="name@example.com"
@bind-Value="@_email" />
</RadzenStack>
<RadzenStack Gap="0.4rem">
<RadzenLabel Text="Roles (optional)" Component="Roles" />
<RadzenListBox Name="Roles"
@bind-Value="@_selectedRoles"
Data="@_roles"
TextProperty="@nameof(Role.Name)"
ValueProperty="@nameof(Role.Name)"
Multiple="true"
AllowClear="true"
Placeholder="Select roles" />
</RadzenStack>
</RadzenStack>
<RadzenStack Orientation="Orientation.Horizontal" JustifyContent="JustifyContent.End" Gap="0.5rem">
<RadzenButton Click="@SendInvitationAsync"
ButtonStyle="ButtonStyle.Primary"
Text="Send invitation"
IsBusy="@_busy"
BusyText="Sending..."
Style="width: 150px" />
<RadzenButton Click="@(() => DialogService.Close(false))"
ButtonStyle="ButtonStyle.Light"
Text="Cancel"
Style="width: 120px" />
</RadzenStack>
</RadzenStack>
Loading
Loading