Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions Microsoft.AspNetCore.SystemWebAdapters.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<Project Path="samples/AuthRemoteFormsAuth/AuthRemoteFormsAuthFramework/AuthRemoteFormsAuthFramework.csproj" />
</Folder>
<Folder Name="/samples/AuthRemoteIdentity/">
<Project Path="samples/AuthRemoteIdentity/AuthIdentityCoreEF6/AuthIdentityCoreEF6.csproj" />
<Project Path="samples/AuthRemoteIdentity/AuthRemoteIdentityAppHost/AuthRemoteIdentityAppHost.csproj" />
<Project Path="samples/AuthRemoteIdentity/AuthRemoteIdentityCore/AuthRemoteIdentityCore.csproj" />
<Project Path="samples/AuthRemoteIdentity/AuthRemoteIdentityFramework/AuthRemoteIdentityFramework.csproj" />
Expand Down Expand Up @@ -64,6 +65,7 @@
<File Path="src/Directory.Build.targets" />
<Project Path="src/Aspire.Hosting.IncrementalMigration/Aspire.Hosting.IncrementalMigration.csproj" />
<Project Path="src/Aspire.Microsoft.AspNetCore.SystemWebAdapters/Aspire.Microsoft.AspNetCore.SystemWebAdapters.csproj" />
<Project Path="src/Microsoft.AspNetCore.Identity.EntityFramework6/Microsoft.AspNetCore.Identity.EntityFramework6.csproj" />
<Project Path="src/Microsoft.AspNetCore.SystemWebAdapters.Abstractions/Microsoft.AspNetCore.SystemWebAdapters.Abstractions.csproj" />
<Project Path="src/Microsoft.AspNetCore.SystemWebAdapters.Analyzers.CSharp/Microsoft.AspNetCore.SystemWebAdapters.Analyzers.CSharp.csproj" />
<Project Path="src/Microsoft.AspNetCore.SystemWebAdapters.Analyzers/Microsoft.AspNetCore.SystemWebAdapters.Analyzers.csproj" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>aspnet-AuthIdentityCoreEF6-22eb46d4-c647-442b-8b28-f8659a00ae49</UserSecretsId>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.AspNetCore.Identity.EntityFramework6\Microsoft.AspNetCore.Identity.EntityFramework6.csproj" />
<ProjectReference Include="..\..\ServiceDefaults\Samples.ServiceDefaults.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Antiforgery;
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 AuthIdentityCoreEF6.Components.Account.Pages;
using AuthIdentityCoreEF6.Components.Account.Pages.Manage;
using AuthIdentityCoreEF6.Data;

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<ApplicationUser> 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,
[FromServices] SignInManager<ApplicationUser> signInManager,
[FromForm] string returnUrl) =>
{
await signInManager.SignOutAsync();
return TypedResults.LocalRedirect($"~/{returnUrl}");
});

accountGroup.MapPost("/PasskeyCreationOptions", async (
HttpContext context,
[FromServices] UserManager<ApplicationUser> userManager,
[FromServices] SignInManager<ApplicationUser> signInManager,
[FromServices] IAntiforgery antiforgery) =>
{
await antiforgery.ValidateRequestAsync(context);

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);
var userName = await userManager.GetUserNameAsync(user) ?? "User";
var optionsJson = await signInManager.MakePasskeyCreationOptionsAsync(new()
{
Id = userId,
Name = userName,
DisplayName = userName
});
return TypedResults.Content(optionsJson, contentType: "application/json");
});

accountGroup.MapPost("/PasskeyRequestOptions", async (
HttpContext context,
[FromServices] UserManager<ApplicationUser> userManager,
[FromServices] SignInManager<ApplicationUser> signInManager,
[FromServices] IAntiforgery antiforgery,
[FromQuery] string? username) =>
{
await antiforgery.ValidateRequestAsync(context);

var user = string.IsNullOrEmpty(username) ? null : await userManager.FindByNameAsync(username);
var optionsJson = await signInManager.MakePasskeyRequestOptionsAsync(user);
return TypedResults.Content(optionsJson, contentType: "application/json");
});

var manageGroup = accountGroup.MapGroup("/Manage").RequireAuthorization();

manageGroup.MapPost("/LinkExternalLogin", async (
HttpContext context,
[FromServices] SignInManager<ApplicationUser> 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<ApplicationUser> 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(ApplicationUser).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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using AuthIdentityCoreEF6.Data;

namespace AuthIdentityCoreEF6.Components.Account;

// Remove the "else if (EmailSender is IdentityNoOpEmailSender)" block from RegisterConfirmation.razor after updating with a real implementation.
internal sealed class IdentityNoOpEmailSender : IEmailSender<ApplicationUser>
{
private readonly IEmailSender emailSender = new NoOpEmailSender();

public Task SendConfirmationLinkAsync(ApplicationUser 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(ApplicationUser 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(ApplicationUser user, string email, string resetCode) =>
emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Identity;
using AuthIdentityCoreEF6.Data;

namespace AuthIdentityCoreEF6.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),
};

public void RedirectTo(string? uri)
{
uri ??= "";

// Prevent open redirects.
if (!Uri.IsWellFormedUriString(uri, UriKind.Relative))
{
uri = navigationManager.ToBaseRelativePath(uri);
}

navigationManager.NavigateTo(uri);
}

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);
}

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);

public void RedirectToCurrentPage() => RedirectTo(CurrentPath);

public void RedirectToCurrentPageWithStatus(string message, HttpContext context)
=> RedirectToWithStatus(CurrentPath, message, context);

public void RedirectToInvalidUser(UserManager<ApplicationUser> userManager, HttpContext context)
=> RedirectToWithStatus("Account/InvalidUser", $"Error: Unable to load user with ID '{userManager.GetUserId(context.User)}'.", context);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using AuthIdentityCoreEF6.Data;

namespace AuthIdentityCoreEF6.Components.Account;

// This is a server-side AuthenticationStateProvider that revalidates the security stamp for the connected user
// every 30 minutes an interactive circuit is connected.
internal sealed class IdentityRevalidatingAuthenticationStateProvider(
ILoggerFactory loggerFactory,
IServiceScopeFactory scopeFactory,
IOptions<IdentityOptions> options)
: RevalidatingServerAuthenticationStateProvider(loggerFactory)
{
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);

protected override async Task<bool> ValidateAuthenticationStateAsync(
AuthenticationState authenticationState, CancellationToken cancellationToken)
{
// Get the user manager from a new scope to ensure it fetches fresh data
await using var scope = scopeFactory.CreateAsyncScope();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
return await ValidateSecurityStampAsync(userManager, authenticationState.User);
}

private async Task<bool> ValidateSecurityStampAsync(UserManager<ApplicationUser> userManager, ClaimsPrincipal principal)
{
var user = await userManager.GetUserAsync(principal);
if (user is null)
{
return false;
}
else if (!userManager.SupportsUserSecurityStamp)
{
return true;
}
else
{
var principalStamp = principal.FindFirstValue(options.Value.ClaimsIdentity.SecurityStampClaimType);
var userStamp = await userManager.GetSecurityStampAsync(user);
return principalStamp == userStamp;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
@page "/Account/AccessDenied"

<PageTitle>Access denied</PageTitle>

<header>
<h1 class="text-danger">Access denied</h1>
<p class="text-danger">You do not have access to this resource.</p>
</header>
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
@page "/Account/ConfirmEmail"

@using System.Text
@using Microsoft.AspNetCore.Identity
@using Microsoft.AspNetCore.WebUtilities
@using AuthIdentityCoreEF6.Data

@inject UserManager<ApplicationUser> UserManager
@inject IdentityRedirectManager RedirectManager

<PageTitle>Confirm email</PageTitle>

<h1>Confirm email</h1>
<StatusMessage Message="@statusMessage" />

@code {
private string? statusMessage;

[CascadingParameter]
private HttpContext HttpContext { get; set; } = default!;

[SupplyParameterFromQuery]
private string? UserId { get; set; }

[SupplyParameterFromQuery]
private string? Code { get; set; }

protected override async Task OnInitializedAsync()
{
if (UserId is null || Code is null)
{
RedirectManager.RedirectTo("");
return;
}

var user = await UserManager.FindByIdAsync(UserId);
if (user is null)
{
HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
statusMessage = $"Error loading user with ID {UserId}";
}
else
{
var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(Code));
var result = await UserManager.ConfirmEmailAsync(user, code);
statusMessage = result.Succeeded ? "Thank you for confirming your email." : "Error confirming your email.";
}
}
}
Loading
Loading