-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
3f08b9b
commit b36ed17
Showing
61 changed files
with
3,010 additions
and
3,130 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
224 changes: 112 additions & 112 deletions
224
...BlazorTemplate.Web/Components/Account/IdentityComponentsEndpointRouteBuilderExtensions.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,112 +1,112 @@ | ||
using System.Security.Claims; | ||
using System.Text.Json; | ||
using Microsoft.AspNetCore.Authentication; | ||
using Microsoft.AspNetCore.Components.Authorization; | ||
using Microsoft.AspNetCore.Http.Extensions; | ||
using Microsoft.AspNetCore.Identity; | ||
using Microsoft.AspNetCore.Mvc; | ||
using Microsoft.Extensions.Primitives; | ||
using BlazorTemplate.Web.Components.Account.Pages; | ||
using BlazorTemplate.Web.Components.Account.Pages.Manage; | ||
using BlazorTemplate.Infrastructure.Identity; | ||
|
||
namespace Microsoft.AspNetCore.Routing; | ||
|
||
internal static class IdentityComponentsEndpointRouteBuilderExtensions | ||
{ | ||
// These endpoints are required by the Identity Razor components defined in the /Components/Account/Pages directory of this project. | ||
public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEndpointRouteBuilder endpoints) | ||
{ | ||
ArgumentNullException.ThrowIfNull(endpoints); | ||
|
||
var accountGroup = endpoints.MapGroup("/Account"); | ||
|
||
accountGroup.MapPost("/PerformExternalLogin", ( | ||
HttpContext context, | ||
[FromServices] SignInManager<User> signInManager, | ||
[FromForm] string provider, | ||
[FromForm] string returnUrl) => | ||
{ | ||
IEnumerable<KeyValuePair<string, StringValues>> query = [ | ||
new("ReturnUrl", returnUrl), | ||
new("Action", ExternalLogin.LoginCallbackAction)]; | ||
|
||
var redirectUrl = UriHelper.BuildRelative( | ||
context.Request.PathBase, | ||
"/Account/ExternalLogin", | ||
QueryString.Create(query)); | ||
|
||
var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl); | ||
return TypedResults.Challenge(properties, [provider]); | ||
}); | ||
|
||
accountGroup.MapPost("/Logout", async ( | ||
ClaimsPrincipal user, | ||
SignInManager<User> signInManager, | ||
[FromForm] string returnUrl) => | ||
{ | ||
await signInManager.SignOutAsync(); | ||
return TypedResults.LocalRedirect($"~/{returnUrl}"); | ||
}); | ||
|
||
var manageGroup = accountGroup.MapGroup("/Manage").RequireAuthorization(); | ||
|
||
manageGroup.MapPost("/LinkExternalLogin", async ( | ||
HttpContext context, | ||
[FromServices] SignInManager<User> signInManager, | ||
[FromForm] string provider) => | ||
{ | ||
// Clear the existing external cookie to ensure a clean login process | ||
await context.SignOutAsync(IdentityConstants.ExternalScheme); | ||
|
||
var redirectUrl = UriHelper.BuildRelative( | ||
context.Request.PathBase, | ||
"/Account/Manage/ExternalLogins", | ||
QueryString.Create("Action", ExternalLogins.LinkLoginCallbackAction)); | ||
|
||
var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, signInManager.UserManager.GetUserId(context.User)); | ||
return TypedResults.Challenge(properties, [provider]); | ||
}); | ||
|
||
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>(); | ||
var downloadLogger = loggerFactory.CreateLogger("DownloadPersonalData"); | ||
|
||
manageGroup.MapPost("/DownloadPersonalData", async ( | ||
HttpContext context, | ||
[FromServices] UserManager<User> userManager, | ||
[FromServices] AuthenticationStateProvider authenticationStateProvider) => | ||
{ | ||
var user = await userManager.GetUserAsync(context.User); | ||
if (user is null) | ||
{ | ||
return Results.NotFound($"Unable to load user with ID '{userManager.GetUserId(context.User)}'."); | ||
} | ||
|
||
var userId = await userManager.GetUserIdAsync(user); | ||
downloadLogger.LogInformation("User with ID '{UserId}' asked for their personal data.", userId); | ||
|
||
// Only include personal data for download | ||
var personalData = new Dictionary<string, string>(); | ||
var personalDataProps = typeof(User).GetProperties().Where( | ||
prop => Attribute.IsDefined(prop, typeof(PersonalDataAttribute))); | ||
foreach (var p in personalDataProps) | ||
{ | ||
personalData.Add(p.Name, p.GetValue(user)?.ToString() ?? "null"); | ||
} | ||
|
||
var logins = await userManager.GetLoginsAsync(user); | ||
foreach (var l in logins) | ||
{ | ||
personalData.Add($"{l.LoginProvider} external login provider key", l.ProviderKey); | ||
} | ||
|
||
personalData.Add("Authenticator Key", (await userManager.GetAuthenticatorKeyAsync(user))!); | ||
var fileBytes = JsonSerializer.SerializeToUtf8Bytes(personalData); | ||
|
||
context.Response.Headers.TryAdd("Content-Disposition", "attachment; filename=PersonalData.json"); | ||
return TypedResults.File(fileBytes, contentType: "application/json", fileDownloadName: "PersonalData.json"); | ||
}); | ||
|
||
return accountGroup; | ||
} | ||
} | ||
using System.Security.Claims; | ||
using System.Text.Json; | ||
using BlazorTemplate.Infrastructure.Identity; | ||
using BlazorTemplate.Web.Components.Account.Pages; | ||
using BlazorTemplate.Web.Components.Account.Pages.Manage; | ||
using Microsoft.AspNetCore.Authentication; | ||
using Microsoft.AspNetCore.Components.Authorization; | ||
using Microsoft.AspNetCore.Http.Extensions; | ||
using Microsoft.AspNetCore.Identity; | ||
using Microsoft.AspNetCore.Mvc; | ||
using Microsoft.Extensions.Primitives; | ||
|
||
namespace Microsoft.AspNetCore.Routing; | ||
|
||
internal static class IdentityComponentsEndpointRouteBuilderExtensions | ||
{ | ||
// These endpoints are required by the Identity Razor components defined in the /Components/Account/Pages directory of this project. | ||
public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEndpointRouteBuilder endpoints) | ||
{ | ||
ArgumentNullException.ThrowIfNull(endpoints); | ||
|
||
var accountGroup = endpoints.MapGroup("/Account"); | ||
|
||
accountGroup.MapPost("/PerformExternalLogin", ( | ||
HttpContext context, | ||
[FromServices] SignInManager<User> signInManager, | ||
[FromForm] string provider, | ||
[FromForm] string returnUrl) => | ||
{ | ||
IEnumerable<KeyValuePair<string, StringValues>> query = [ | ||
new("ReturnUrl", returnUrl), | ||
new("Action", ExternalLogin.LoginCallbackAction)]; | ||
|
||
var redirectUrl = UriHelper.BuildRelative( | ||
context.Request.PathBase, | ||
"/Account/ExternalLogin", | ||
QueryString.Create(query)); | ||
|
||
var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl); | ||
return TypedResults.Challenge(properties, [provider]); | ||
}); | ||
|
||
accountGroup.MapPost("/Logout", async ( | ||
ClaimsPrincipal user, | ||
SignInManager<User> signInManager, | ||
[FromForm] string returnUrl) => | ||
{ | ||
await signInManager.SignOutAsync(); | ||
return TypedResults.LocalRedirect($"~/{returnUrl}"); | ||
}); | ||
|
||
var manageGroup = accountGroup.MapGroup("/Manage").RequireAuthorization(); | ||
|
||
manageGroup.MapPost("/LinkExternalLogin", async ( | ||
HttpContext context, | ||
[FromServices] SignInManager<User> signInManager, | ||
[FromForm] string provider) => | ||
{ | ||
// Clear the existing external cookie to ensure a clean login process | ||
await context.SignOutAsync(IdentityConstants.ExternalScheme); | ||
|
||
var redirectUrl = UriHelper.BuildRelative( | ||
context.Request.PathBase, | ||
"/Account/Manage/ExternalLogins", | ||
QueryString.Create("Action", ExternalLogins.LinkLoginCallbackAction)); | ||
|
||
var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, signInManager.UserManager.GetUserId(context.User)); | ||
return TypedResults.Challenge(properties, [provider]); | ||
}); | ||
|
||
var loggerFactory = endpoints.ServiceProvider.GetRequiredService<ILoggerFactory>(); | ||
var downloadLogger = loggerFactory.CreateLogger("DownloadPersonalData"); | ||
|
||
manageGroup.MapPost("/DownloadPersonalData", async ( | ||
HttpContext context, | ||
[FromServices] UserManager<User> userManager, | ||
[FromServices] AuthenticationStateProvider authenticationStateProvider) => | ||
{ | ||
var user = await userManager.GetUserAsync(context.User); | ||
if (user is null) | ||
{ | ||
return Results.NotFound($"Unable to load user with ID '{userManager.GetUserId(context.User)}'."); | ||
} | ||
|
||
var userId = await userManager.GetUserIdAsync(user); | ||
downloadLogger.LogInformation("User with ID '{UserId}' asked for their personal data.", userId); | ||
|
||
// Only include personal data for download | ||
var personalData = new Dictionary<string, string>(); | ||
var personalDataProps = typeof(User).GetProperties().Where( | ||
prop => Attribute.IsDefined(prop, typeof(PersonalDataAttribute))); | ||
foreach (var p in personalDataProps) | ||
{ | ||
personalData.Add(p.Name, p.GetValue(user)?.ToString() ?? "null"); | ||
} | ||
|
||
var logins = await userManager.GetLoginsAsync(user); | ||
foreach (var l in logins) | ||
{ | ||
personalData.Add($"{l.LoginProvider} external login provider key", l.ProviderKey); | ||
} | ||
|
||
personalData.Add("Authenticator Key", (await userManager.GetAuthenticatorKeyAsync(user))!); | ||
var fileBytes = JsonSerializer.SerializeToUtf8Bytes(personalData); | ||
|
||
context.Response.Headers.TryAdd("Content-Disposition", "attachment; filename=PersonalData.json"); | ||
return TypedResults.File(fileBytes, contentType: "application/json", fileDownloadName: "PersonalData.json"); | ||
}); | ||
|
||
return accountGroup; | ||
} | ||
} |
40 changes: 20 additions & 20 deletions
40
src/BlazorTemplate.Web/Components/Account/IdentityNoOpEmailSender.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,20 +1,20 @@ | ||
using Microsoft.AspNetCore.Identity; | ||
using Microsoft.AspNetCore.Identity.UI.Services; | ||
using BlazorTemplate.Infrastructure.Identity; | ||
|
||
namespace BlazorTemplate.Web.Components.Account; | ||
|
||
// Remove the "else if (EmailSender is IdentityNoOpEmailSender)" block from RegisterConfirmation.razor after updating with a real implementation. | ||
internal sealed class IdentityNoOpEmailSender : IEmailSender<User> | ||
{ | ||
private readonly IEmailSender emailSender = new NoOpEmailSender(); | ||
|
||
public Task SendConfirmationLinkAsync(User user, string email, string confirmationLink) => | ||
emailSender.SendEmailAsync(email, "Confirm your email", $"Please confirm your account by <a href='{confirmationLink}'>clicking here</a>."); | ||
|
||
public Task SendPasswordResetLinkAsync(User user, string email, string resetLink) => | ||
emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password by <a href='{resetLink}'>clicking here</a>."); | ||
|
||
public Task SendPasswordResetCodeAsync(User user, string email, string resetCode) => | ||
emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}"); | ||
} | ||
using Microsoft.AspNetCore.Identity; | ||
using Microsoft.AspNetCore.Identity.UI.Services; | ||
using BlazorTemplate.Infrastructure.Identity; | ||
|
||
namespace BlazorTemplate.Web.Components.Account; | ||
|
||
// Remove the "else if (EmailSender is IdentityNoOpEmailSender)" block from RegisterConfirmation.razor after updating with a real implementation. | ||
internal sealed class IdentityNoOpEmailSender : IEmailSender<User> | ||
{ | ||
private readonly IEmailSender emailSender = new NoOpEmailSender(); | ||
|
||
public Task SendConfirmationLinkAsync(User user, string email, string confirmationLink) => | ||
emailSender.SendEmailAsync(email, "Confirm your email", $"Please confirm your account by <a href='{confirmationLink}'>clicking here</a>."); | ||
|
||
public Task SendPasswordResetLinkAsync(User user, string email, string resetLink) => | ||
emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password by <a href='{resetLink}'>clicking here</a>."); | ||
|
||
public Task SendPasswordResetCodeAsync(User user, string email, string resetCode) => | ||
emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}"); | ||
} |
116 changes: 58 additions & 58 deletions
116
src/BlazorTemplate.Web/Components/Account/IdentityRedirectManager.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,58 +1,58 @@ | ||
using System.Diagnostics.CodeAnalysis; | ||
using Microsoft.AspNetCore.Components; | ||
|
||
namespace BlazorTemplate.Web.Components.Account; | ||
|
||
internal sealed class IdentityRedirectManager(NavigationManager navigationManager) | ||
{ | ||
public const string StatusCookieName = "Identity.StatusMessage"; | ||
|
||
private static readonly CookieBuilder StatusCookieBuilder = new() | ||
{ | ||
SameSite = SameSiteMode.Strict, | ||
HttpOnly = true, | ||
IsEssential = true, | ||
MaxAge = TimeSpan.FromSeconds(5), | ||
}; | ||
|
||
[DoesNotReturn] | ||
public void RedirectTo(string? uri) | ||
{ | ||
uri ??= ""; | ||
|
||
// Prevent open redirects. | ||
if (!Uri.IsWellFormedUriString(uri, UriKind.Relative)) | ||
{ | ||
uri = navigationManager.ToBaseRelativePath(uri); | ||
} | ||
|
||
// During static rendering, NavigateTo throws a NavigationException which is handled by the framework as a redirect. | ||
// So as long as this is called from a statically rendered Identity component, the InvalidOperationException is never thrown. | ||
navigationManager.NavigateTo(uri); | ||
throw new InvalidOperationException($"{nameof(IdentityRedirectManager)} can only be used during static rendering."); | ||
} | ||
|
||
[DoesNotReturn] | ||
public void RedirectTo(string uri, Dictionary<string, object?> queryParameters) | ||
{ | ||
var uriWithoutQuery = navigationManager.ToAbsoluteUri(uri).GetLeftPart(UriPartial.Path); | ||
var newUri = navigationManager.GetUriWithQueryParameters(uriWithoutQuery, queryParameters); | ||
RedirectTo(newUri); | ||
} | ||
|
||
[DoesNotReturn] | ||
public void RedirectToWithStatus(string uri, string message, HttpContext context) | ||
{ | ||
context.Response.Cookies.Append(StatusCookieName, message, StatusCookieBuilder.Build(context)); | ||
RedirectTo(uri); | ||
} | ||
|
||
private string CurrentPath => navigationManager.ToAbsoluteUri(navigationManager.Uri).GetLeftPart(UriPartial.Path); | ||
|
||
[DoesNotReturn] | ||
public void RedirectToCurrentPage() => RedirectTo(CurrentPath); | ||
|
||
[DoesNotReturn] | ||
public void RedirectToCurrentPageWithStatus(string message, HttpContext context) | ||
=> RedirectToWithStatus(CurrentPath, message, context); | ||
} | ||
using System.Diagnostics.CodeAnalysis; | ||
using Microsoft.AspNetCore.Components; | ||
|
||
namespace BlazorTemplate.Web.Components.Account; | ||
|
||
internal sealed class IdentityRedirectManager(NavigationManager navigationManager) | ||
{ | ||
public const string StatusCookieName = "Identity.StatusMessage"; | ||
|
||
private static readonly CookieBuilder StatusCookieBuilder = new() | ||
{ | ||
SameSite = SameSiteMode.Strict, | ||
HttpOnly = true, | ||
IsEssential = true, | ||
MaxAge = TimeSpan.FromSeconds(5), | ||
}; | ||
|
||
[DoesNotReturn] | ||
public void RedirectTo(string? uri) | ||
{ | ||
uri ??= ""; | ||
|
||
// Prevent open redirects. | ||
if (!Uri.IsWellFormedUriString(uri, UriKind.Relative)) | ||
{ | ||
uri = navigationManager.ToBaseRelativePath(uri); | ||
} | ||
|
||
// During static rendering, NavigateTo throws a NavigationException which is handled by the framework as a redirect. | ||
// So as long as this is called from a statically rendered Identity component, the InvalidOperationException is never thrown. | ||
navigationManager.NavigateTo(uri); | ||
throw new InvalidOperationException($"{nameof(IdentityRedirectManager)} can only be used during static rendering."); | ||
} | ||
|
||
[DoesNotReturn] | ||
public void RedirectTo(string uri, Dictionary<string, object?> queryParameters) | ||
{ | ||
var uriWithoutQuery = navigationManager.ToAbsoluteUri(uri).GetLeftPart(UriPartial.Path); | ||
var newUri = navigationManager.GetUriWithQueryParameters(uriWithoutQuery, queryParameters); | ||
RedirectTo(newUri); | ||
} | ||
|
||
[DoesNotReturn] | ||
public void RedirectToWithStatus(string uri, string message, HttpContext context) | ||
{ | ||
context.Response.Cookies.Append(StatusCookieName, message, StatusCookieBuilder.Build(context)); | ||
RedirectTo(uri); | ||
} | ||
|
||
private string CurrentPath => navigationManager.ToAbsoluteUri(navigationManager.Uri).GetLeftPart(UriPartial.Path); | ||
|
||
[DoesNotReturn] | ||
public void RedirectToCurrentPage() => RedirectTo(CurrentPath); | ||
|
||
[DoesNotReturn] | ||
public void RedirectToCurrentPageWithStatus(string message, HttpContext context) | ||
=> RedirectToWithStatus(CurrentPath, message, context); | ||
} |
Oops, something went wrong.