042641d841
Cover admin provision-now, public self-service redeem, and admin invite-by-email across CMS + public-site tracks. Add standalone AuthBlocks password-reset team brief.
241 lines
14 KiB
Markdown
241 lines
14 KiB
Markdown
# Team Brief — Email-Backed Password Reset for AuthBlocks
|
||
|
||
**Audience:** an orchestrator (and its implementers) working **only** in the AuthBlocks repository at
|
||
`C:\Development\AuthBlocks`. You do not need, and should not assume, any knowledge of the products that
|
||
consume AuthBlocks. Everything you need is in this brief or in that repo.
|
||
|
||
**Status:** scoped request, not yet started. Author: product-designer (for a downstream consumer team).
|
||
Date: 2026-06-19.
|
||
|
||
---
|
||
|
||
## 1. The goal in one sentence
|
||
|
||
Replace the non-functional "Reset Password" stub on the AuthBlocks user-administration **Users** page
|
||
with a real, email-backed password-reset flow — so that triggering "Reset Password" for a user sends
|
||
that user an email containing a secure, time-limited reset link, and following the link lets them set a
|
||
new password.
|
||
|
||
This is an **upstream library feature**, delivered entirely inside AuthBlocks and published as a normal
|
||
version bump. Consumers pick it up by referencing the new package version.
|
||
|
||
---
|
||
|
||
## 2. Where the stub lives today
|
||
|
||
`AuthBlocksWeb/Components/Pages/UserAdmin/Users/Users.razor` — the user grid has a per-row **Reset
|
||
Password** `MudButton` whose handler is empty:
|
||
|
||
```csharp
|
||
private async Task ResetPassword(UserInputModel? item)
|
||
{
|
||
// todo integrate with email for secure reset
|
||
}
|
||
```
|
||
|
||
There is **no backing API endpoint** for this action. `AuthBlocksLib/Routes/AuthRoutes.cs` maps
|
||
`login`, `register`, `admin-register`, `refresh`, `logout`, `me`, `roles` — and nothing for password
|
||
reset. So this is a build-from-scratch flow on both the API side (new endpoints) and the Web side
|
||
(wire the button + add a public reset page), reusing AuthBlocks' existing email and token machinery.
|
||
|
||
---
|
||
|
||
## 3. What AuthBlocks already has that you should reuse
|
||
|
||
**The pending-registration flow is your template.** AuthBlocks already does almost exactly this shape
|
||
of work for invitations — generate a secure token, email a link, validate the token when the user
|
||
returns. Read it end-to-end before designing reset; you are building a sibling flow:
|
||
|
||
- **Email sending is real and wired.** `AuthBlocksLib/AuthBlocksExtensions.cs` (~line 109) registers
|
||
`services.AddScoped<IGeneralEmailSender, MailtrapEmailSender>();`. The `IGeneralEmailSender`
|
||
abstraction and `MailtrapEmailSender` implementation come from the shared NetBlocks library
|
||
(namespace `API.Common.Email.Mailtrap`). The send signature in use is:
|
||
|
||
```csharp
|
||
await emailSender.SendEmailAsync(toAddress, cc: null, subject, htmlBody);
|
||
```
|
||
|
||
See it called for real at `AuthBlocksLib/Routes/PendingRegistrationRoutes.cs:124`.
|
||
|
||
- **Email connection config.** The host populates `AuthBlocksOptions.EmailConnection` (a NetBlocks
|
||
`EmailConnection` with `Host` + `Token`) plus `ApplicationName` and `SupportEmail` when it calls
|
||
`AddAuthBlocks(options => { ... })`. Those flow into `AuthBlocksExtensions` and are available to your
|
||
reset endpoint exactly as they are to the registration endpoint. **You do not need to invent any new
|
||
config or sender** — reuse `IGeneralEmailSender` and `AuthBlocksOptions`.
|
||
|
||
- **An HTML email template pattern.** `AuthBlocksLib/Common/RegistrationEmailTemplate.cs` is a static
|
||
`Create(token, link, applicationName, supportEmail)` returning a styled HTML string. Build a sibling
|
||
`PasswordResetEmailTemplate.Create(...)` in the same file's neighbourhood and the same house style
|
||
(the registration template is teal-branded, table-layout, support-line-collapses-when-empty — match
|
||
it). Do **not** reuse the registration template verbatim; the copy is invitation-specific.
|
||
|
||
- **A token service pattern.** `AuthBlocksLib/Services/RegistrationTokenService.cs` generates a random
|
||
token, SHA-256-hashes `{email}::{token}`, persists the hash with a 7-day expiry, and validates /
|
||
consumes it. **However — for password reset, prefer ASP.NET Identity's built-in reset token** (see
|
||
§4) rather than re-implementing this hand-rolled scheme. The registration token service is a *style*
|
||
reference for endpoint shape and email-link construction, not necessarily the token mechanism.
|
||
|
||
- **The deep-link construction idiom.** The registration flow builds its link with
|
||
`QueryHelpers.AddQueryString(returnHost, { UserEmail, RegistrationToken })` and the public register
|
||
page reads those query params and pre-fills (`Register.razor`, `[SupplyParameterFromQuery]`). Mirror
|
||
this for the reset page: link carries `email` + `resetToken`; the reset page reads them.
|
||
|
||
- **Identity is fully present.** `UserService` wraps `UserManager<ApplicationUser>` (see
|
||
`AuthBlocksData/Services/UserService.cs`). `UserManager` gives you the canonical reset primitives —
|
||
use them.
|
||
|
||
---
|
||
|
||
## 4. Recommended mechanism: ASP.NET Identity's built-in reset token
|
||
|
||
Password reset is a solved problem in ASP.NET Identity, and rolling your own token store for it is an
|
||
avoidable security surface. **Strong recommendation:** use `UserManager<ApplicationUser>`'s built-in
|
||
reset tokens rather than the hand-rolled `RegistrationTokenService` SHA-256 scheme.
|
||
|
||
- `var token = await userManager.GeneratePasswordResetTokenAsync(user);` — produces a token bound to
|
||
the user's security stamp; invalidated when the password changes or the stamp rotates.
|
||
- `var result = await userManager.ResetPasswordAsync(user, token, newPassword);` — validates and
|
||
applies in one call; enforces the configured password policy.
|
||
- Token lifetime is governed by `DataProtectionTokenProviderOptions.TokenLifespan` (default 1 day) —
|
||
confirm/configure to a sensible reset window (recommend 1–2 hours for reset, tighter than the 7-day
|
||
registration window).
|
||
|
||
This means you likely **do not** need a new DB table or migration for reset (unlike registration,
|
||
which persists pending rows). Confirm whether the default token providers are registered in the
|
||
AuthBlocks Identity setup; if `AddDefaultTokenProviders()` (or equivalent) is not already called in the
|
||
Identity configuration, add it — that is the one wiring prerequisite for `GeneratePasswordResetTokenAsync`
|
||
to work.
|
||
|
||
*Alternative considered (and not recommended):* extend `RegistrationTokenService` / `PendingRegistration`
|
||
into a generic token table that also serves reset. Rejected — it couples two unrelated flows, re-implements
|
||
what Identity already does correctly, and adds a migration for no benefit. Use it only if there is a
|
||
hard reason the Identity token provider cannot be enabled in this setup.
|
||
|
||
---
|
||
|
||
## 5. The surfaces to build
|
||
|
||
Three pieces, mirroring the registration flow's API-endpoint + email-template + web-page triad.
|
||
|
||
### 5.1 API endpoints (`AuthBlocksLib/Routes/AuthRoutes.cs`)
|
||
|
||
Add to the `api/auth` group. Two endpoints, both **unauthenticated** (a user resetting a forgotten
|
||
password is by definition not logged in — the admin "Reset Password" button triggers the *first* of
|
||
these on the user's behalf, but the endpoint itself authenticates via the token, not a bearer):
|
||
|
||
1. **`POST api/auth/forgot-password`** — body `{ email, returnHost }`. Looks up the user; if found,
|
||
generates a reset token and emails the reset link (`{returnHost}?email=&resetToken=`). **Always
|
||
return success** regardless of whether the email exists — do **not** leak account existence (a known
|
||
reset-flow security requirement; the registration flow's "user already exists" message is acceptable
|
||
for an *admin-gated* invite but a *public* forgot-password must not reveal it). On email-send failure,
|
||
log and return a generic failure.
|
||
|
||
2. **`POST api/auth/reset-password`** — body `{ email, resetToken, newPassword }`. Resolves the user,
|
||
calls `ResetPasswordAsync(user, token, newPassword)`, returns the Identity result mapped to the
|
||
AuthBlocks `Result`/`ApiResult` convention (see how `Register` maps results in `AuthRoutes.cs`).
|
||
|
||
Follow the existing `AuthRoutes` conventions exactly: `ApiResult<T>` / `ApiResultDto<T>` wrapping,
|
||
`ILogger<AuthLogger>` for logging, `Results.Ok` / `Results.BadRequest` / `Results.Json(..., 500)` shapes.
|
||
|
||
### 5.2 Email template (`AuthBlocksLib/Common/PasswordResetEmailTemplate.cs`)
|
||
|
||
New static `Create(resetLink, applicationName, supportEmail)` in the visual style of
|
||
`RegistrationEmailTemplate`. Reset copy: a clear "you (or an admin) requested a password reset," the CTA
|
||
button to the reset link, an expiry notice matching the token lifespan, and "ignore this email if you
|
||
didn't request it." No registration code box — reset uses an opaque token in the link, not a
|
||
user-typed code (recommended; do not show the Identity token as a copy-paste code — it is long and
|
||
URL-encoded).
|
||
|
||
### 5.3 Web surfaces (`AuthBlocksWeb`)
|
||
|
||
- **Wire the admin button.** In `Users.razor`, replace the empty `ResetPassword` handler with a call to
|
||
an `IAuthApiClient` (or the appropriate existing client) method that hits `POST api/auth/forgot-password`
|
||
for `item.Email`, and show a confirmation (a `StatusMessage` / dialog: "Reset email sent to {email}").
|
||
This is the admin-initiated trigger.
|
||
- **Add a public reset page.** New `AuthBlocksWeb/Components/Pages/Account/ResetPassword.razor`,
|
||
`@page "/account/reset-password"`, `@rendermode InteractiveServer`, **no role gate** (a forgotten-password
|
||
user is unauthenticated). Read `email` + `resetToken` from query params (mirror `Register.razor`'s
|
||
`[SupplyParameterFromQuery]` pre-fill), present new-password + confirm fields, submit to
|
||
`POST api/auth/reset-password`, and on success route to `/account/login` with a success message. Match
|
||
`Register.razor`'s form structure and validation idiom.
|
||
- **Optional: a public "forgot password?" entry.** Consider a `/account/forgot-password` page (link from
|
||
`Login.razor`) where a user enters their email to self-initiate reset — same `forgot-password` endpoint.
|
||
Decide whether this is in scope or whether reset is admin-initiated only (see open questions).
|
||
|
||
### 5.4 Client method
|
||
|
||
Add the `forgot-password` / `reset-password` calls to whichever API client the Web project uses for auth
|
||
(the registration/login flows go through `JwtAuthenticationStateProvider` / `IAuthApiClient` — follow the
|
||
same pattern; do not introduce a new HTTP client).
|
||
|
||
---
|
||
|
||
## 6. Constraints
|
||
|
||
- **No account-existence leak** on the public `forgot-password` path (§5.1).
|
||
- **Reuse, don't reinvent:** `IGeneralEmailSender` for sending, `AuthBlocksOptions` for config, Identity's
|
||
token provider for tokens, the existing `Result`/`ApiResult` conventions for endpoint returns, and the
|
||
`RegistrationEmailTemplate` house style for the email.
|
||
- **Match the existing route + result conventions** in `AuthRoutes.cs` precisely — this is a library;
|
||
consumers rely on the shape staying idiomatic.
|
||
- **Versioning:** this lands as a normal AuthBlocks version bump (packed/pushed by `pack.ps1` like the
|
||
other packages). Note the new version so consumers can pin to it.
|
||
- **Token lifespan** for reset should be short (recommend 1–2 hours), distinct from the 7-day
|
||
registration token.
|
||
- **Password policy** is enforced by `ResetPasswordAsync` automatically — do not duplicate validation,
|
||
but surface the Identity error messages back through the result.
|
||
|
||
---
|
||
|
||
## 7. Acceptance criteria
|
||
|
||
1. Clicking "Reset Password" for a user on the Users admin page sends that user a styled email with a
|
||
working reset link, and shows the admin a confirmation. No unhandled exception, no silent no-op.
|
||
2. Following the reset link lands on `/account/reset-password` with the email pre-filled; setting a new
|
||
password that meets policy succeeds and the user can immediately log in with the new password.
|
||
3. An expired or tampered token is rejected with a clear, non-leaky error.
|
||
4. The public `forgot-password` endpoint returns the same response whether or not the email maps to a
|
||
real account (no existence leak).
|
||
5. Email send is exercised through the real `IGeneralEmailSender` (Mailtrap in the configured
|
||
environment) — verify an email actually arrives.
|
||
6. No new required config beyond what `AddAuthBlocks` already accepts (reset reuses the existing email
|
||
connection + application-name + support-email options). If a token-provider registration was missing,
|
||
it is added and documented.
|
||
7. Published as a version bump; the new version is recorded.
|
||
|
||
---
|
||
|
||
## 8. Open questions for the implementing team / its sponsor
|
||
|
||
1. **Admin-initiated only, or also public self-serve?** Is the only entry point the admin "Reset
|
||
Password" button (§5.3 first bullet), or do you also want a public "forgot password?" link from the
|
||
login page (§5.3 last bullet)? The endpoints support both; the question is which Web surfaces to build.
|
||
*Recommendation: build both endpoints, ship the admin button now, and add the public forgot-password
|
||
page in the same pass since it is nearly free once the endpoint exists.*
|
||
2. **Token mechanism — confirm Identity's built-in is acceptable** (§4 recommendation) vs. a hard
|
||
requirement to use the hand-rolled hashed-token scheme. *Recommendation: Identity built-in.*
|
||
3. **Reset token lifespan** — confirm the window (recommend 1–2 hours).
|
||
4. **Return host / link base** — the registration flow has the *caller* pass `returnHost`. Confirm the
|
||
reset flow does the same (the consumer supplies the base URL of its public reset page), vs. AuthBlocks
|
||
configuring a reset base URL in options. *Recommendation: pass `returnHost` per-call, mirroring
|
||
registration, so AuthBlocks stays host-agnostic.*
|
||
5. **Does the public reset page (`/account/reset-password`) need to render in a consumer's own layout?**
|
||
The page ships in the AuthBlocks RCL with no `@layout`, so it inherits whatever the consuming host sets
|
||
as default — same as `Register.razor`. Confirm this is acceptable (it should be; it is how registration
|
||
already behaves).
|
||
|
||
---
|
||
|
||
## 9. Suggested reading order in the repo
|
||
|
||
1. `AuthBlocksLib/Routes/PendingRegistrationRoutes.cs` — the email-sending endpoint to mirror.
|
||
2. `AuthBlocksLib/Routes/AuthRoutes.cs` — where your endpoints go; the result/logging conventions.
|
||
3. `AuthBlocksLib/Common/RegistrationEmailTemplate.cs` — the email house style.
|
||
4. `AuthBlocksWeb/Components/Pages/Account/Register.razor` — the public-page + query-param-prefill pattern
|
||
for your reset page.
|
||
5. `AuthBlocksWeb/Components/Pages/UserAdmin/Users/Users.razor` — the stub to replace.
|
||
6. `AuthBlocksLib/AuthBlocksExtensions.cs` + `AuthBlocksOptions.cs` — the email sender + options wiring you
|
||
reuse (and where to add a token-provider registration if one is missing).
|
||
7. `AuthBlocksData/Services/UserService.cs` — the `UserManager<ApplicationUser>` access point for the
|
||
Identity reset primitives.
|