Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
fa68e3c
Initial commit of UM Password Registration sample. Functionally works…
ProgrammerAL Jun 12, 2026
5f2d915
Updated styling of the sample to use the new duende styling and colors
ProgrammerAL Jun 15, 2026
7162f32
Merge branch 'main' into alrodri/add-password-registration-sample
ProgrammerAL Jun 16, 2026
99779e3
Added UserManagementPasswordRegistration sample to .sln
ProgrammerAL Jun 16, 2026
3d475bb
Added aspire package to UserManagementPasswordRegistration.AppHost
ProgrammerAL Jun 16, 2026
60cf1fe
Updated Boostrap version in UserManagementPasswordRegistration sample…
ProgrammerAL Jun 16, 2026
449f967
Final cleanup of UserManagementPasswordRegistration sample code
ProgrammerAL Jun 16, 2026
9dba019
Potential fix for pull request finding
ProgrammerAL Jun 16, 2026
ab7fccc
Potential fix for pull request finding
ProgrammerAL Jun 16, 2026
8ec93d1
Potential fix for pull request finding
ProgrammerAL Jun 16, 2026
fb792f1
Potential fix for pull request finding
ProgrammerAL Jun 16, 2026
36e5861
Potential fix for pull request finding
ProgrammerAL Jun 16, 2026
ce27bb7
Potential fix for pull request finding
ProgrammerAL Jun 16, 2026
162117e
Fixes from Copilot suggestions
ProgrammerAL Jun 16, 2026
4c67f8e
Potential fix for pull request finding
ProgrammerAL Jun 16, 2026
c81d7c6
Potential fix for pull request finding
ProgrammerAL Jun 16, 2026
d118816
Added a README.md file for the UserManagementPasswordRegistration sam…
ProgrammerAL Jun 16, 2026
b4518d0
Minor UI adjustment
wcabus Jun 17, 2026
c4072ab
Fixes email key from disappearing when updating the profile
wcabus Jun 17, 2026
130bbee
Added some more documentation to important parts of password reigistr…
ProgrammerAL Jun 17, 2026
8d87e04
Apply suggestions from code review
wcabus Jun 18, 2026
a1dfd60
Merge branch 'main' into alrodri/add-password-registration-sample
ProgrammerAL Jun 18, 2026
6912b79
Moved the Password Registration sample into the new /UserManagement d…
ProgrammerAL Jun 18, 2026
dd9621d
Added string extension to normalize email address
ProgrammerAL Jun 30, 2026
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
42 changes: 42 additions & 0 deletions IdentityServer/v8/UserManagementPasswordRegistration/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Duende User Management Password Registration Sample

This sample demonstrates using IdentityServer v8 with Duende User Management to register a new user and set a password.

## Registration Flow

When navigating to the password-registration sample the /Index page presents the user with 2 choices, Login or Register.

### Registration

To register, a user will enter their email address, which is then validated by emailing an OTP code and waiting for the user to enter it. After the user proves they own the email address by entering the OTP, they are signed in and redirected to the /SetPassword page to create a password. Once the password is set, the user is signed out and redirected to the /Login page to sign-in with email/password.

### Login

After user sign-in, the /Index page displays user claims and custom data from the user's Attributes stored by User Management. The attributes can be modified by clicking on the Manage Account button.

## Unique Attribute

The `UserAttributes.Email` attribute has the `.IsUnique` property set to `true`. This is required to ensure the password is tied to that email address.

## Running the Sample

### Prerequisites

- .NET 10 SDK
- Docker (for Mailpit email testing via Aspire)

### Start with Aspire

```bash
cd UserManagementPasswordRegistration.AppHost
dotnet run
```

This launches:

| Service | URL |
|---------|-----|
| UserManagementPasswordRegistration | `https://password-registration.dev.localhost:6010` |
| Mailpit UI | `http://mailpit-aspire.dev.localhost:8026` |
| Mailpit SMTP | `smtp://localhost:1026` |
| Aspire Dashboard | `https://aspire.dev.localhost:17031` |
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright (c) Duende Software. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

var builder = DistributedApplication.CreateBuilder(args);

var mailpit = builder.AddContainer("mailpit", "axllent/mailpit")
.WithContainerName($"password-registration-mailpit")
.WithEndpoint(1026, 1025, "smtp", name: "mailpit-smtp", isProxied: false)
.WithEndpoint(8026, 8025, "http", name: "mailpit-http", isProxied: false);
var smtpEndpoint = mailpit.GetEndpoint("mailpit-smtp");

_ = builder.AddProject<Projects.UserManagementPasswordRegistration>("password-registration")
.WaitForStart(mailpit)
.WithSmtp(smtpEndpoint);

builder.Build().Run();

internal static class AspireExtensions
{
/// <summary>
/// Injects SMTP configuration (Smtp:Host, Smtp:Port, Smtp:EnableSsl) from a mailpit endpoint.
/// </summary>
internal static IResourceBuilder<T> WithSmtp<T>(this IResourceBuilder<T> project, EndpointReference smtpEndpoint)
where T : IResourceWithEnvironment =>
project
.WithEnvironment("Smtp__Host", smtpEndpoint.Property(EndpointProperty.Host))
.WithEnvironment("Smtp__Port", smtpEndpoint.Property(EndpointProperty.Port))
.WithEnvironment("Smtp__FromEmail", "noreply@example.com")
.WithEnvironment("Smtp__FromName", "noreply")
.WithEnvironment("Smtp__EnableSsl", "false");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://aspire.dev.localhost:17031",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21144",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23111",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22058"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Aspire.AppHost.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>f7c23bdb-303f-4eed-af99-23322c28992a</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\UserManagementPasswordRegistration\UserManagementPasswordRegistration.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<Solution>
<Project Path="UserManagementPasswordRegistration.AppHost/UserManagementPasswordRegistration.AppHost.csproj" />
<Project Path="UserManagementPasswordRegistration/UserManagementPasswordRegistration.csproj" />
</Solution>
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Copyright (c) Duende Software. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;
using System.Text.Json;

using Duende.UserManagement;
using Duende.UserManagement.Authentication.Otp;

using Microsoft.AspNetCore.DataProtection;

namespace UserManagementPasswordRegistration;

public sealed class OtpCookie(IDataProtectionProvider dataProtectionProvider, IHttpContextAccessor httpContextAccessor)
{
private const string CookieName = "__Host-OtpAuthentication";

private readonly IDataProtector _protector = dataProtectionProvider.CreateProtector("OtpAuthentication.v1");

private HttpContext HttpContext =>
httpContextAccessor.HttpContext ?? throw new InvalidOperationException("No active HttpContext is available.");

public void Write(OtpToken token, EmailAddress emailAddress, DateTimeOffset expiresAtUtc)
{
var json = JsonSerializer.Serialize(new CookieValues(token.ToString(), emailAddress.ToString()));
var protectedJson = _protector.Protect(json);

var cookieOptions = new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict,
IsEssential = true,
Expires = expiresAtUtc
};

HttpContext.Response.Cookies.Append(CookieName, protectedJson, cookieOptions);
}

public bool TryRead([NotNullWhen(true)] out OtpToken? token,
[NotNullWhen(true)] out EmailAddress? emailAddress)
{
token = null;
emailAddress = null;

if (!HttpContext.Request.Cookies.TryGetValue(CookieName, out var protectedJson) ||
string.IsNullOrWhiteSpace(protectedJson))
{
return false;
}

string json;
try
{
json = _protector.Unprotect(protectedJson);
}
catch (CryptographicException)
{
return false;
}

CookieValues? values;
try
{
values = JsonSerializer.Deserialize<CookieValues>(json);
}
catch (JsonException)
{
return false;
}

if (values is null)
{
return false;
}

token = OtpToken.Create(values.Token);
emailAddress = EmailAddress.Create(values.Email);
return true;
}

public void Clear() =>
HttpContext.Response.Cookies.Delete(
CookieName,
new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict,
IsEssential = true
});

private sealed record CookieValues(string Token, string Email);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
@page
@model AccountModel
@{
ViewData["Title"] = "Account";
}

<div class="row justify-content-center mt-4">
<div class="col-md-8">

@if (Model.ErrorMessages.Count > 0)
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
@foreach (var error in Model.ErrorMessages)
{
<div><i class="bi bi-exclamation-triangle-fill me-2"></i>@error</div>
}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}

@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check-circle-fill me-2"></i>@Model.SuccessMessage
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}

<!-- profile -->
<div class="card shadow-sm">
<div class="card-header text-white fw-bold">
<i class="bi bi-person-circle me-2"></i>Profile
</div>
<div class="card-body">
<form method="post">
<div asp-validation-summary="ModelOnly" class="text-danger mb-3" role="alert"></div>

<div class="mb-4">
<span class="fw-bold"><i class="bi bi-envelope me-1"></i>Email Address:</span>
<span>@Model.Email</span>
</div>

<div class="mb-4 mt-4">
<label asp-for="Name" class="form-label fw-bold">
<i class="bi bi-person me-1"></i>Name
</label>
<input asp-for="Name" class="form-control" placeholder="Enter your full name" title="@Model.NameDescription" />
<span asp-validation-for="Name" class="text-danger"></span>
</div>
<div class="mb-4 mt-4">
<label asp-for="FavoriteDinosaur" class="form-label fw-bold">
<i class="bi bi-person me-1"></i>Favorite Dinosaur
</label>
<input asp-for="FavoriteDinosaur" class="form-control" placeholder="Enter your favorite dinosaur" title="@Model.FavoriteDinosaurDescription" />
<span asp-validation-for="FavoriteDinosaur" class="text-danger"></span>
</div>

<div class="d-grid">
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i>Save
</button>
</div>
</form>
</div>
</div>
</div>
</div>

@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
Loading
Loading