Skip to content

Commit 8bc4e3a

Browse files
committed
Add account management pages and update layout for user authentication
1 parent 6af8a14 commit 8bc4e3a

66 files changed

Lines changed: 3923 additions & 2 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Docs/Useing-Domain-Events.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public sealed class DomainEventPublishingInterceptor : SaveChangesInterceptor
3030
var context = eventData.Context;
3131
if (context is null) return result;
3232

33-
var entities = context.ChangeTracker.Entries<EntityBase<int>>()
33+
var entities = context.ChangeTracker.Entries<IHaveDomainEvents>()
3434
.Where(e => e.Entity.DomainEvents.Count > 0)
3535
.Select(e => e.Entity)
3636
.ToList();
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
using System.Security.Claims;
2+
using System.Text.Json;
3+
4+
using Microsoft.AspNetCore.Authentication;
5+
using Microsoft.AspNetCore.Components.Authorization;
6+
using Microsoft.AspNetCore.Http.Extensions;
7+
using Microsoft.AspNetCore.Identity;
8+
using Microsoft.AspNetCore.Mvc;
9+
using Microsoft.Extensions.Primitives;
10+
11+
using EFCoreBlazorWebAppExampleWithAuth.Components.Account.Pages;
12+
using EFCoreBlazorWebAppExampleWithAuth.Components.Account.Pages.Manage;
13+
using EFCoreBlazorWebAppExampleWithAuth.Data;
14+
15+
namespace Microsoft.AspNetCore.Routing;
16+
17+
internal static class IdentityComponentsEndpointRouteBuilderExtensions
18+
{
19+
// These endpoints are required by the Identity Razor components defined in the /Components/Account/Pages directory of this project.
20+
public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEndpointRouteBuilder endpoints)
21+
{
22+
ArgumentNullException.ThrowIfNull(endpoints);
23+
24+
var accountGroup = endpoints.MapGroup("/Account");
25+
26+
accountGroup.MapPost("/PerformExternalLogin", (
27+
HttpContext context,
28+
[FromServices] SignInManager<ApplicationUser> signInManager,
29+
[FromForm] string provider,
30+
[FromForm] string returnUrl) =>
31+
{
32+
IEnumerable<KeyValuePair<string, StringValues>> query =
33+
[
34+
new("ReturnUrl", returnUrl),
35+
new("Action", ExternalLogin.LoginCallbackAction)
36+
];
37+
38+
var redirectUrl = UriHelper.BuildRelative(
39+
context.Request.PathBase,
40+
"/Account/ExternalLogin",
41+
QueryString.Create(query));
42+
43+
var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
44+
return TypedResults.Challenge(properties, [provider]);
45+
});
46+
47+
accountGroup.MapPost("/Logout", async (
48+
ClaimsPrincipal user,
49+
[FromServices] SignInManager<ApplicationUser> signInManager,
50+
[FromForm] string returnUrl) =>
51+
{
52+
await signInManager.SignOutAsync();
53+
return TypedResults.LocalRedirect($"~/{returnUrl}");
54+
});
55+
56+
var manageGroup = accountGroup.MapGroup("/Manage").RequireAuthorization();
57+
58+
manageGroup.MapPost("/LinkExternalLogin", async (
59+
HttpContext context,
60+
[FromServices] SignInManager<ApplicationUser> signInManager,
61+
[FromForm] string provider) =>
62+
{
63+
// Clear the existing external cookie to ensure a clean login process
64+
await context.SignOutAsync(IdentityConstants.ExternalScheme);
65+
66+
var redirectUrl = UriHelper.BuildRelative(
67+
context.Request.PathBase,
68+
"/Account/Manage/ExternalLogins",
69+
QueryString.Create("Action", ExternalLogins.LinkLoginCallbackAction));
70+
71+
var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl,
72+
signInManager.UserManager.GetUserId(context.User));
73+
return TypedResults.Challenge(properties, [provider]);
74+
});
75+
76+
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>();
77+
var downloadLogger = loggerFactory.CreateLogger("DownloadPersonalData");
78+
79+
manageGroup.MapPost("/DownloadPersonalData", async (
80+
HttpContext context,
81+
[FromServices] UserManager<ApplicationUser> userManager,
82+
[FromServices] AuthenticationStateProvider authenticationStateProvider) =>
83+
{
84+
var user = await userManager.GetUserAsync(context.User);
85+
if (user is null)
86+
{
87+
return Results.NotFound($"Unable to load user with ID '{userManager.GetUserId(context.User)}'.");
88+
}
89+
90+
var userId = await userManager.GetUserIdAsync(user);
91+
downloadLogger.LogInformation("User with ID '{UserId}' asked for their personal data.", userId);
92+
93+
// Only include personal data for download
94+
var personalData = new Dictionary<string, string>();
95+
var personalDataProps = typeof(ApplicationUser).GetProperties()
96+
.Where(prop => Attribute.IsDefined(prop, typeof(PersonalDataAttribute)));
97+
foreach (var p in personalDataProps)
98+
{
99+
personalData.Add(p.Name, p.GetValue(user)?.ToString() ?? "null");
100+
}
101+
102+
var logins = await userManager.GetLoginsAsync(user);
103+
foreach (var l in logins)
104+
{
105+
personalData.Add($"{l.LoginProvider} external login provider key", l.ProviderKey);
106+
}
107+
108+
personalData.Add("Authenticator Key", (await userManager.GetAuthenticatorKeyAsync(user))!);
109+
var fileBytes = JsonSerializer.SerializeToUtf8Bytes(personalData);
110+
111+
context.Response.Headers.TryAdd("Content-Disposition", "attachment; filename=PersonalData.json");
112+
return TypedResults.File(fileBytes, contentType: "application/json", fileDownloadName: "PersonalData.json");
113+
});
114+
115+
return accountGroup;
116+
}
117+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using Microsoft.AspNetCore.Identity;
2+
using Microsoft.AspNetCore.Identity.UI.Services;
3+
4+
using EFCoreBlazorWebAppExampleWithAuth.Data;
5+
6+
namespace EFCoreBlazorWebAppExampleWithAuth.Components.Account;
7+
8+
// Remove the "else if (EmailSender is IdentityNoOpEmailSender)" block from RegisterConfirmation.razor after updating with a real implementation.
9+
internal sealed class IdentityNoOpEmailSender : IEmailSender<ApplicationUser>
10+
{
11+
private readonly IEmailSender emailSender = new NoOpEmailSender();
12+
13+
public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink) =>
14+
emailSender.SendEmailAsync(email, "Confirm your email",
15+
$"Please confirm your account by <a href='{confirmationLink}'>clicking here</a>.");
16+
17+
public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) =>
18+
emailSender.SendEmailAsync(email, "Reset your password",
19+
$"Please reset your password by <a href='{resetLink}'>clicking here</a>.");
20+
21+
public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) =>
22+
emailSender.SendEmailAsync(email, "Reset your password",
23+
$"Please reset your password using the following code: {resetCode}");
24+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
3+
using Microsoft.AspNetCore.Components;
4+
5+
namespace EFCoreBlazorWebAppExampleWithAuth.Components.Account;
6+
7+
internal sealed class IdentityRedirectManager(NavigationManager navigationManager)
8+
{
9+
public const string StatusCookieName = "Identity.StatusMessage";
10+
11+
private static readonly CookieBuilder StatusCookieBuilder = new()
12+
{
13+
SameSite = SameSiteMode.Strict, HttpOnly = true, IsEssential = true, MaxAge = TimeSpan.FromSeconds(5),
14+
};
15+
16+
[DoesNotReturn]
17+
public void RedirectTo(string? uri)
18+
{
19+
uri ??= "";
20+
21+
// Prevent open redirects.
22+
if (!Uri.IsWellFormedUriString(uri, UriKind.Relative))
23+
{
24+
uri = navigationManager.ToBaseRelativePath(uri);
25+
}
26+
27+
// During static rendering, NavigateTo throws a NavigationException which is handled by the framework as a redirect.
28+
// So as long as this is called from a statically rendered Identity component, the InvalidOperationException is never thrown.
29+
navigationManager.NavigateTo(uri);
30+
throw new InvalidOperationException(
31+
$"{nameof(IdentityRedirectManager)} can only be used during static rendering.");
32+
}
33+
34+
[DoesNotReturn]
35+
public void RedirectTo(string uri, Dictionary<string, object?> queryParameters)
36+
{
37+
var uriWithoutQuery = navigationManager.ToAbsoluteUri(uri).GetLeftPart(UriPartial.Path);
38+
var newUri = navigationManager.GetUriWithQueryParameters(uriWithoutQuery, queryParameters);
39+
RedirectTo(newUri);
40+
}
41+
42+
[DoesNotReturn]
43+
public void RedirectToWithStatus(string uri, string message, HttpContext context)
44+
{
45+
context.Response.Cookies.Append(StatusCookieName, message, StatusCookieBuilder.Build(context));
46+
RedirectTo(uri);
47+
}
48+
49+
private string CurrentPath => navigationManager.ToAbsoluteUri(navigationManager.Uri).GetLeftPart(UriPartial.Path);
50+
51+
[DoesNotReturn]
52+
public void RedirectToCurrentPage() => RedirectTo(CurrentPath);
53+
54+
[DoesNotReturn]
55+
public void RedirectToCurrentPageWithStatus(string message, HttpContext context)
56+
=> RedirectToWithStatus(CurrentPath, message, context);
57+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
using System.Security.Claims;
2+
3+
using Microsoft.AspNetCore.Components.Authorization;
4+
using Microsoft.AspNetCore.Components.Server;
5+
using Microsoft.AspNetCore.Identity;
6+
using Microsoft.Extensions.Options;
7+
8+
using EFCoreBlazorWebAppExampleWithAuth.Data;
9+
10+
namespace EFCoreBlazorWebAppExampleWithAuth.Components.Account;
11+
12+
// This is a server-side AuthenticationStateProvider that revalidates the security stamp for the connected user
13+
// every 30 minutes an interactive circuit is connected.
14+
internal sealed class IdentityRevalidatingAuthenticationStateProvider(
15+
ILoggerFactory loggerFactory,
16+
IServiceScopeFactory scopeFactory,
17+
IOptions<IdentityOptions> options)
18+
: RevalidatingServerAuthenticationStateProvider(loggerFactory)
19+
{
20+
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);
21+
22+
protected override async Task<bool> ValidateAuthenticationStateAsync(
23+
AuthenticationState authenticationState, CancellationToken cancellationToken)
24+
{
25+
// Get the user manager from a new scope to ensure it fetches fresh data
26+
await using var scope = scopeFactory.CreateAsyncScope();
27+
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
28+
return await ValidateSecurityStampAsync(userManager, authenticationState.User);
29+
}
30+
31+
private async Task<bool> ValidateSecurityStampAsync(UserManager<ApplicationUser> userManager,
32+
ClaimsPrincipal principal)
33+
{
34+
var user = await userManager.GetUserAsync(principal);
35+
if (user is null)
36+
{
37+
return false;
38+
}
39+
else if (!userManager.SupportsUserSecurityStamp)
40+
{
41+
return true;
42+
}
43+
else
44+
{
45+
var principalStamp = principal.FindFirstValue(options.Value.ClaimsIdentity.SecurityStampClaimType);
46+
var userStamp = await userManager.GetSecurityStampAsync(user);
47+
return principalStamp == userStamp;
48+
}
49+
}
50+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using Microsoft.AspNetCore.Identity;
2+
3+
using EFCoreBlazorWebAppExampleWithAuth.Data;
4+
5+
namespace EFCoreBlazorWebAppExampleWithAuth.Components.Account;
6+
7+
internal sealed class IdentityUserAccessor(
8+
UserManager<ApplicationUser> userManager,
9+
IdentityRedirectManager redirectManager)
10+
{
11+
public async Task<ApplicationUser> GetRequiredUserAsync(HttpContext context)
12+
{
13+
var user = await userManager.GetUserAsync(context.User);
14+
15+
if (user is null)
16+
{
17+
redirectManager.RedirectToWithStatus("Account/InvalidUser",
18+
$"Error: Unable to load user with ID '{userManager.GetUserId(context.User)}'.", context);
19+
}
20+
21+
return user;
22+
}
23+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
@page "/Account/AccessDenied"
2+
3+
<PageTitle>Access denied</PageTitle>
4+
5+
<MudAlert Severity="Severity.Error">You do not have access to this resource.</MudAlert>
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
@page "/Account/ConfirmEmail"
2+
3+
@using System.Text
4+
@using Microsoft.AspNetCore.Identity
5+
@using Microsoft.AspNetCore.WebUtilities
6+
@using EFCoreBlazorWebAppExampleWithAuth.Data
7+
8+
@inject UserManager<ApplicationUser> UserManager
9+
@inject IdentityRedirectManager RedirectManager
10+
11+
<PageTitle>Confirm email</PageTitle>
12+
13+
<h1>Confirm email</h1>
14+
<StatusMessage Message="@statusMessage"/>
15+
16+
@code {
17+
private string? statusMessage;
18+
19+
[CascadingParameter] private HttpContext HttpContext { get; set; } = default!;
20+
21+
[SupplyParameterFromQuery] private string? UserId { get; set; }
22+
23+
[SupplyParameterFromQuery] private string? Code { get; set; }
24+
25+
protected override async Task OnInitializedAsync()
26+
{
27+
if (UserId is null || Code is null)
28+
{
29+
RedirectManager.RedirectTo("");
30+
}
31+
32+
var user = await UserManager.FindByIdAsync(UserId);
33+
if (user is null)
34+
{
35+
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
36+
statusMessage = $"Error loading user with ID {UserId}";
37+
}
38+
else
39+
{
40+
var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code));
41+
var result = await UserManager.ConfirmEmailAsync(user, code);
42+
statusMessage = result.Succeeded ? "Thank you for confirming your email." : "Error confirming your email.";
43+
}
44+
}
45+
46+
}

0 commit comments

Comments
 (0)