Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 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
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
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.PasswordRegistration>("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,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="..\PasswordRegistration\PasswordRegistration.csproj" />
</ItemGroup>

</Project>
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,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="PasswordRegistration.AppHost/PasswordRegistration.AppHost.csproj" />
<Project Path="PasswordRegistration/PasswordRegistration.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 PasswordRegistration;

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" />
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Copyright (c) Duende Software. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

using Duende.IdentityModel;
using Duende.IdentityServer.Extensions;
using Duende.Storage.EntityAttributeValue;
using Duende.UserManagement.Profiles;

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

using System.Security.Claims;

namespace PasswordRegistration.Pages;

[Authorize]
public class AccountModel(IUserProfileSelfService profileSelfService) : PageModel
{
public string? Email { get; set; }

[BindProperty] public string? Name { get; set; }

[BindProperty] public string? FavoriteDinosaur { get; set; }

public string NameDescription { get; } = UserAttributes.Name.Description?.ToString() ?? "";
public string FavoriteDinosaurDescription { get; } = UserAttributes.FavoriteDinosaur.Description?.ToString() ?? "";

[TempData] public string? SuccessMessage { get; set; }

public List<string> ErrorMessages { get; set; } = [];

public async Task<IActionResult> OnGetAsync()
{
LoadModel();

var profile = await profileSelfService.TryGetAsync(User.GetSubjectId(), HttpContext.RequestAborted);
if (profile is not null)
{
if (profile.Attributes.GetValueOrDefault(UserAttributes.Name.Code)?.TryGetValue<string>(out var name) == true)
{
Name = name;
}

if (profile.Attributes.GetValueOrDefault(UserAttributes.FavoriteDinosaur.Code)?.TryGetValue<string>(out var favoriteDinosaur) == true)
{
FavoriteDinosaur = favoriteDinosaur;
}
}

return Page();
}

public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
LoadModel();
return Page();
}

var schema = await profileSelfService.GetSchemaAsync(HttpContext.RequestAborted);

if (string.IsNullOrWhiteSpace(Name))
{
ModelState.AddModelError(nameof(Name), "Invalid name format");
LoadModel();
return Page();
}

if (string.IsNullOrWhiteSpace(FavoriteDinosaur))
{
ModelState.AddModelError(nameof(FavoriteDinosaur), "Invalid favorite dinosaur format");
LoadModel();
return Page();
}

var subjectId = User.GetSubjectId();

if (await profileSelfService.TryGetAsync(subjectId, HttpContext.RequestAborted) is not { } profile)
{
ModelState.AddModelError(string.Empty, "Profile does not exist");
LoadModel();
return Page();
}
else
{
//Load all atrributes for the user profile, and then modify
var updatedAttributes = new AttributeValueCollection(schema, profile.Attributes.Values);
updatedAttributes.Set(UserAttributes.Name, Name);
updatedAttributes.Set(UserAttributes.FavoriteDinosaur, FavoriteDinosaur);

if (!updatedAttributes.TryValidate(out var validatedUpdatedAttributes, out var errors))
{
var errorsString = string.Join(", ", errors);
ErrorMessages.Add($"Failed to update profile. Please try again. Errors; {errorsString}");
LoadModel();
return Page();
}

if (await profileSelfService.TryUpdateAsync(profile.SubjectId, validatedUpdatedAttributes, HttpContext.RequestAborted) is null)
{
ErrorMessages.Add("Failed to update profile. Please try again.");
LoadModel();
return Page();
}

SuccessMessage = "Profile updated!";
}

await HttpContext.SignInAsync(User);
return RedirectToPage();
}

private void LoadModel()
{
if (User.FindFirst(JwtClaimTypes.Email) is not null)
{
Email = User.FindFirst(JwtClaimTypes.Email)?.Value ?? User.FindFirst(ClaimTypes.Email)?.Value;
}
}
}
Loading
Loading