override MapIdentityApi() to fix confirmation link.

This commit is contained in:
2024-08-25 13:38:38 -05:00
parent 484106ca17
commit b474641cd3
5 changed files with 497 additions and 16 deletions

View File

@@ -0,0 +1,488 @@
// This class is derived from https://raw.githubusercontent.com/dotnet/aspnetcore/main/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs
// It was recommended by a member of the .Net platform team to copy this class if I want to customize some of the default behavior.
// That recommendation is here: https://github.com/dotnet/aspnetcore/issues/50303#issuecomment-1836909245
using global::Microsoft.AspNetCore.Authentication.BearerToken;
using global::Microsoft.AspNetCore.Http.HttpResults;
using global::Microsoft.AspNetCore.Http.Metadata;
using global::Microsoft.AspNetCore.Identity;
using global::Microsoft.AspNetCore.Identity.Data;
using global::Microsoft.AspNetCore.WebUtilities;
using global::Microsoft.Extensions.Options;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
namespace Shogi.Api.Controllers;
/// <summary>
/// Provides extension methods for <see cref="IEndpointRouteBuilder"/> to add identity endpoints.
/// </summary>
public static class MyIdentityApiEndpointRouteBuilderExtensions
{
// Validate the email address using DataAnnotations like the UserValidator does when RequireUniqueEmail = true.
private static readonly EmailAddressAttribute _emailAddressAttribute = new();
/// <summary>
/// Add endpoints for registering, logging in, and logging out using ASP.NET Core Identity.
/// </summary>
/// <typeparam name="TUser">The type describing the user. This should match the generic parameter in <see cref="UserManager{TUser}"/>.</typeparam>
/// <param name="endpoints">
/// The <see cref="IEndpointRouteBuilder"/> to add the identity endpoints to.
/// Call <see cref="EndpointRouteBuilderExtensions.MapGroup(IEndpointRouteBuilder, string)"/> to add a prefix to all the endpoints.
/// </param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> to further customize the added endpoints.</returns>
public static IEndpointConventionBuilder MyMapIdentityApi<TUser>(this IEndpointRouteBuilder endpoints, IWebHostEnvironment environment)
where TUser : class, new()
{
ArgumentNullException.ThrowIfNull(endpoints);
var timeProvider = endpoints.ServiceProvider.GetRequiredService<TimeProvider>();
var bearerTokenOptions = endpoints.ServiceProvider.GetRequiredService<IOptionsMonitor<BearerTokenOptions>>();
var emailSender = endpoints.ServiceProvider.GetRequiredService<IEmailSender<TUser>>();
var linkGenerator = endpoints.ServiceProvider.GetRequiredService<LinkGenerator>();
// We'll figure out a unique endpoint name based on the final route pattern during endpoint generation.
string? confirmEmailEndpointName = null;
var routeGroup = endpoints.MapGroup("");
// NOTE: We cannot inject UserManager<TUser> directly because the TUser generic parameter is currently unsupported by RDG.
// https://github.com/dotnet/aspnetcore/issues/47338
routeGroup.MapPost("/register", async Task<Results<Ok, ValidationProblem>>
([FromBody] RegisterRequest registration, HttpContext context, [FromServices] IServiceProvider sp) =>
{
var userManager = sp.GetRequiredService<UserManager<TUser>>();
if (!userManager.SupportsUserEmail)
{
throw new NotSupportedException($"{nameof(MyMapIdentityApi)} requires a user store with email support.");
}
var userStore = sp.GetRequiredService<IUserStore<TUser>>();
var emailStore = (IUserEmailStore<TUser>)userStore;
var email = registration.Email;
if (string.IsNullOrEmpty(email) || !_emailAddressAttribute.IsValid(email))
{
return CreateValidationProblem(IdentityResult.Failed(userManager.ErrorDescriber.InvalidEmail(email)));
}
var user = new TUser();
await userStore.SetUserNameAsync(user, email, CancellationToken.None);
await emailStore.SetEmailAsync(user, email, CancellationToken.None);
var result = await userManager.CreateAsync(user, registration.Password);
if (!result.Succeeded)
{
return CreateValidationProblem(result);
}
await SendConfirmationEmailAsync(user, userManager, context, email);
return TypedResults.Ok();
});
routeGroup.MapPost("/login", async Task<Results<Ok<AccessTokenResponse>, EmptyHttpResult, ProblemHttpResult>>
([FromBody] LoginRequest login, [FromQuery] bool? useCookies, [FromQuery] bool? useSessionCookies, [FromServices] IServiceProvider sp) =>
{
var signInManager = sp.GetRequiredService<SignInManager<TUser>>();
var useCookieScheme = (useCookies == true) || (useSessionCookies == true);
var isPersistent = (useCookies == true) && (useSessionCookies != true);
signInManager.AuthenticationScheme = useCookieScheme ? IdentityConstants.ApplicationScheme : IdentityConstants.BearerScheme;
var result = await signInManager.PasswordSignInAsync(login.Email, login.Password, isPersistent, lockoutOnFailure: true);
if (result.RequiresTwoFactor)
{
if (!string.IsNullOrEmpty(login.TwoFactorCode))
{
result = await signInManager.TwoFactorAuthenticatorSignInAsync(login.TwoFactorCode, isPersistent, rememberClient: isPersistent);
}
else if (!string.IsNullOrEmpty(login.TwoFactorRecoveryCode))
{
result = await signInManager.TwoFactorRecoveryCodeSignInAsync(login.TwoFactorRecoveryCode);
}
}
if (!result.Succeeded)
{
return TypedResults.Problem(result.ToString(), statusCode: StatusCodes.Status401Unauthorized);
}
// The signInManager already produced the needed response in the form of a cookie or bearer token.
return TypedResults.Empty;
});
routeGroup.MapPost("/refresh", async Task<Results<Ok<AccessTokenResponse>, UnauthorizedHttpResult, SignInHttpResult, ChallengeHttpResult>>
([FromBody] RefreshRequest refreshRequest, [FromServices] IServiceProvider sp) =>
{
var signInManager = sp.GetRequiredService<SignInManager<TUser>>();
var refreshTokenProtector = bearerTokenOptions.Get(IdentityConstants.BearerScheme).RefreshTokenProtector;
var refreshTicket = refreshTokenProtector.Unprotect(refreshRequest.RefreshToken);
// Reject the /refresh attempt with a 401 if the token expired or the security stamp validation fails
if (refreshTicket?.Properties?.ExpiresUtc is not { } expiresUtc ||
timeProvider.GetUtcNow() >= expiresUtc ||
await signInManager.ValidateSecurityStampAsync(refreshTicket.Principal) is not TUser user)
{
return TypedResults.Challenge();
}
var newPrincipal = await signInManager.CreateUserPrincipalAsync(user);
return TypedResults.SignIn(newPrincipal, authenticationScheme: IdentityConstants.BearerScheme);
});
routeGroup.MapGet("/confirmEmail", async Task<Results<ContentHttpResult, UnauthorizedHttpResult>>
([FromQuery] string userId, [FromQuery] string code, [FromQuery] string? changedEmail, [FromServices] IServiceProvider sp) =>
{
var userManager = sp.GetRequiredService<UserManager<TUser>>();
if (await userManager.FindByIdAsync(userId) is not { } user)
{
// We could respond with a 404 instead of a 401 like Identity UI, but that feels like unnecessary information.
return TypedResults.Unauthorized();
}
try
{
code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code));
}
catch (FormatException)
{
return TypedResults.Unauthorized();
}
IdentityResult result;
if (string.IsNullOrEmpty(changedEmail))
{
result = await userManager.ConfirmEmailAsync(user, code);
}
else
{
// As with Identity UI, email and user name are one and the same. So when we update the email,
// we need to update the user name.
result = await userManager.ChangeEmailAsync(user, changedEmail, code);
if (result.Succeeded)
{
result = await userManager.SetUserNameAsync(user, changedEmail);
}
}
if (!result.Succeeded)
{
return TypedResults.Unauthorized();
}
return TypedResults.Text("Thank you for confirming your email.");
})
.Add(endpointBuilder =>
{
var finalPattern = ((RouteEndpointBuilder)endpointBuilder).RoutePattern.RawText;
confirmEmailEndpointName = $"{nameof(MyMapIdentityApi)}-{finalPattern}";
endpointBuilder.Metadata.Add(new EndpointNameMetadata(confirmEmailEndpointName));
});
routeGroup.MapPost("/resendConfirmationEmail", async Task<Ok>
([FromBody] ResendConfirmationEmailRequest resendRequest, HttpContext context, [FromServices] IServiceProvider sp) =>
{
var userManager = sp.GetRequiredService<UserManager<TUser>>();
if (await userManager.FindByEmailAsync(resendRequest.Email) is not { } user)
{
return TypedResults.Ok();
}
await SendConfirmationEmailAsync(user, userManager, context, resendRequest.Email);
return TypedResults.Ok();
});
routeGroup.MapPost("/forgotPassword", async Task<Results<Ok, ValidationProblem>>
([FromBody] ForgotPasswordRequest resetRequest, [FromServices] IServiceProvider sp) =>
{
var userManager = sp.GetRequiredService<UserManager<TUser>>();
var user = await userManager.FindByEmailAsync(resetRequest.Email);
if (user is not null && await userManager.IsEmailConfirmedAsync(user))
{
var code = await userManager.GeneratePasswordResetTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
await emailSender.SendPasswordResetCodeAsync(user, resetRequest.Email, HtmlEncoder.Default.Encode(code));
}
// Don't reveal that the user does not exist or is not confirmed, so don't return a 200 if we would have
// returned a 400 for an invalid code given a valid user email.
return TypedResults.Ok();
});
routeGroup.MapPost("/resetPassword", async Task<Results<Ok, ValidationProblem>>
([FromBody] ResetPasswordRequest resetRequest, [FromServices] IServiceProvider sp) =>
{
var userManager = sp.GetRequiredService<UserManager<TUser>>();
var user = await userManager.FindByEmailAsync(resetRequest.Email);
if (user is null || !(await userManager.IsEmailConfirmedAsync(user)))
{
// Don't reveal that the user does not exist or is not confirmed, so don't return a 200 if we would have
// returned a 400 for an invalid code given a valid user email.
return CreateValidationProblem(IdentityResult.Failed(userManager.ErrorDescriber.InvalidToken()));
}
IdentityResult result;
try
{
var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(resetRequest.ResetCode));
result = await userManager.ResetPasswordAsync(user, code, resetRequest.NewPassword);
}
catch (FormatException)
{
result = IdentityResult.Failed(userManager.ErrorDescriber.InvalidToken());
}
if (!result.Succeeded)
{
return CreateValidationProblem(result);
}
return TypedResults.Ok();
});
var accountGroup = routeGroup.MapGroup("/manage").RequireAuthorization();
accountGroup.MapPost("/2fa", async Task<Results<Ok<TwoFactorResponse>, ValidationProblem, NotFound>>
(ClaimsPrincipal claimsPrincipal, [FromBody] TwoFactorRequest tfaRequest, [FromServices] IServiceProvider sp) =>
{
var signInManager = sp.GetRequiredService<SignInManager<TUser>>();
var userManager = signInManager.UserManager;
if (await userManager.GetUserAsync(claimsPrincipal) is not { } user)
{
return TypedResults.NotFound();
}
if (tfaRequest.Enable == true)
{
if (tfaRequest.ResetSharedKey)
{
return CreateValidationProblem("CannotResetSharedKeyAndEnable",
"Resetting the 2fa shared key must disable 2fa until a 2fa token based on the new shared key is validated.");
}
else if (string.IsNullOrEmpty(tfaRequest.TwoFactorCode))
{
return CreateValidationProblem("RequiresTwoFactor",
"No 2fa token was provided by the request. A valid 2fa token is required to enable 2fa.");
}
else if (!await userManager.VerifyTwoFactorTokenAsync(user, userManager.Options.Tokens.AuthenticatorTokenProvider, tfaRequest.TwoFactorCode))
{
return CreateValidationProblem("InvalidTwoFactorCode",
"The 2fa token provided by the request was invalid. A valid 2fa token is required to enable 2fa.");
}
await userManager.SetTwoFactorEnabledAsync(user, true);
}
else if (tfaRequest.Enable == false || tfaRequest.ResetSharedKey)
{
await userManager.SetTwoFactorEnabledAsync(user, false);
}
if (tfaRequest.ResetSharedKey)
{
await userManager.ResetAuthenticatorKeyAsync(user);
}
string[]? recoveryCodes = null;
if (tfaRequest.ResetRecoveryCodes || (tfaRequest.Enable == true && await userManager.CountRecoveryCodesAsync(user) == 0))
{
var recoveryCodesEnumerable = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
recoveryCodes = recoveryCodesEnumerable?.ToArray();
}
if (tfaRequest.ForgetMachine)
{
await signInManager.ForgetTwoFactorClientAsync();
}
var key = await userManager.GetAuthenticatorKeyAsync(user);
if (string.IsNullOrEmpty(key))
{
await userManager.ResetAuthenticatorKeyAsync(user);
key = await userManager.GetAuthenticatorKeyAsync(user);
if (string.IsNullOrEmpty(key))
{
throw new NotSupportedException("The user manager must produce an authenticator key after reset.");
}
}
return TypedResults.Ok(new TwoFactorResponse
{
SharedKey = key,
RecoveryCodes = recoveryCodes,
RecoveryCodesLeft = recoveryCodes?.Length ?? await userManager.CountRecoveryCodesAsync(user),
IsTwoFactorEnabled = await userManager.GetTwoFactorEnabledAsync(user),
IsMachineRemembered = await signInManager.IsTwoFactorClientRememberedAsync(user),
});
});
accountGroup.MapGet("/info", async Task<Results<Ok<InfoResponse>, ValidationProblem, NotFound>>
(ClaimsPrincipal claimsPrincipal, [FromServices] IServiceProvider sp) =>
{
var userManager = sp.GetRequiredService<UserManager<TUser>>();
if (await userManager.GetUserAsync(claimsPrincipal) is not { } user)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(await CreateInfoResponseAsync(user, userManager));
});
accountGroup.MapPost("/info", async Task<Results<Ok<InfoResponse>, ValidationProblem, NotFound>>
(ClaimsPrincipal claimsPrincipal, [FromBody] InfoRequest infoRequest, HttpContext context, [FromServices] IServiceProvider sp) =>
{
var userManager = sp.GetRequiredService<UserManager<TUser>>();
if (await userManager.GetUserAsync(claimsPrincipal) is not { } user)
{
return TypedResults.NotFound();
}
if (!string.IsNullOrEmpty(infoRequest.NewEmail) && !_emailAddressAttribute.IsValid(infoRequest.NewEmail))
{
return CreateValidationProblem(IdentityResult.Failed(userManager.ErrorDescriber.InvalidEmail(infoRequest.NewEmail)));
}
if (!string.IsNullOrEmpty(infoRequest.NewPassword))
{
if (string.IsNullOrEmpty(infoRequest.OldPassword))
{
return CreateValidationProblem("OldPasswordRequired",
"The old password is required to set a new password. If the old password is forgotten, use /resetPassword.");
}
var changePasswordResult = await userManager.ChangePasswordAsync(user, infoRequest.OldPassword, infoRequest.NewPassword);
if (!changePasswordResult.Succeeded)
{
return CreateValidationProblem(changePasswordResult);
}
}
if (!string.IsNullOrEmpty(infoRequest.NewEmail))
{
var email = await userManager.GetEmailAsync(user);
if (email != infoRequest.NewEmail)
{
await SendConfirmationEmailAsync(user, userManager, context, infoRequest.NewEmail, isChange: true);
}
}
return TypedResults.Ok(await CreateInfoResponseAsync(user, userManager));
});
async Task SendConfirmationEmailAsync(TUser user, UserManager<TUser> userManager, HttpContext context, string email, bool isChange = false)
{
if (confirmEmailEndpointName is null)
{
throw new NotSupportedException("No email confirmation endpoint was registered!");
}
var code = isChange
? await userManager.GenerateChangeEmailTokenAsync(user, email)
: await userManager.GenerateEmailConfirmationTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var userId = await userManager.GetUserIdAsync(user);
var routeValues = new RouteValueDictionary()
{
["userId"] = userId,
["code"] = code,
};
if (isChange)
{
// This is validated by the /confirmEmail endpoint on change.
routeValues.Add("changedEmail", email);
}
HostString? host = environment.IsDevelopment() ? null : new HostString("api.lucaserver.space/Shogi.Api");
var confirmEmailUrl = linkGenerator.GetUriByName(context, confirmEmailEndpointName, routeValues, host: host)
?? throw new NotSupportedException($"Could not find endpoint named '{confirmEmailEndpointName}'.");
await emailSender.SendConfirmationLinkAsync(user, email, HtmlEncoder.Default.Encode(confirmEmailUrl));
}
return new IdentityEndpointsConventionBuilder(routeGroup);
}
private static ValidationProblem CreateValidationProblem(string errorCode, string errorDescription) =>
TypedResults.ValidationProblem(new Dictionary<string, string[]> {
{ errorCode, [errorDescription] }
});
private static ValidationProblem CreateValidationProblem(IdentityResult result)
{
// We expect a single error code and description in the normal case.
// This could be golfed with GroupBy and ToDictionary, but perf! :P
Debug.Assert(!result.Succeeded);
var errorDictionary = new Dictionary<string, string[]>(1);
foreach (var error in result.Errors)
{
string[] newDescriptions;
if (errorDictionary.TryGetValue(error.Code, out var descriptions))
{
newDescriptions = new string[descriptions.Length + 1];
Array.Copy(descriptions, newDescriptions, descriptions.Length);
newDescriptions[descriptions.Length] = error.Description;
}
else
{
newDescriptions = [error.Description];
}
errorDictionary[error.Code] = newDescriptions;
}
return TypedResults.ValidationProblem(errorDictionary);
}
private static async Task<InfoResponse> CreateInfoResponseAsync<TUser>(TUser user, UserManager<TUser> userManager)
where TUser : class
{
return new()
{
Email = await userManager.GetEmailAsync(user) ?? throw new NotSupportedException("Users must have an email."),
IsEmailConfirmed = await userManager.IsEmailConfirmedAsync(user),
};
}
// Wrap RouteGroupBuilder with a non-public type to avoid a potential future behavioral breaking change.
private sealed class IdentityEndpointsConventionBuilder(RouteGroupBuilder inner) : IEndpointConventionBuilder
{
private IEndpointConventionBuilder InnerAsConventionBuilder => inner;
public void Add(Action<EndpointBuilder> convention) => InnerAsConventionBuilder.Add(convention);
public void Finally(Action<EndpointBuilder> finallyConvention) => InnerAsConventionBuilder.Finally(finallyConvention);
}
[AttributeUsage(AttributeTargets.Parameter)]
private sealed class FromBodyAttribute : Attribute, IFromBodyMetadata
{
}
[AttributeUsage(AttributeTargets.Parameter)]
private sealed class FromServicesAttribute : Attribute, IFromServiceMetadata
{
}
[AttributeUsage(AttributeTargets.Parameter)]
private sealed class FromQueryAttribute : Attribute, IFromQueryMetadata
{
public string? Name => null;
}
}

View File

@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.EntityFrameworkCore;
using Shogi.Api;
using Shogi.Api.Application;
using Shogi.Api.Controllers;
using Shogi.Api.Identity;
using Shogi.Api.Repositories;
@@ -35,7 +36,7 @@ builder.Services.AddResponseCompression(opts =>
});
var app = builder.Build();
app.MapIdentityApi<ShogiUser>();
app.MyMapIdentityApi<ShogiUser>(builder.Environment);
if (app.Environment.IsDevelopment())
{
@@ -90,13 +91,6 @@ static void AddIdentity(WebApplicationBuilder builder, ConfigurationManager conf
})
.AddEntityFrameworkStores<ApplicationDbContext>();
// I shouldn't this because I have it above, right?
//builder.Services.Configure<IdentityOptions>(options =>
//{
// options.SignIn.RequireConfirmedEmail = true;
// options.User.RequireUniqueEmail = true;
//});
builder.Services.ConfigureApplicationCookie(options =>
{
options.SlidingExpiration = true;

View File

@@ -21,7 +21,6 @@ public class EmailSender : IEmailSender
this.client = client;
}
public async Task SendEmailAsync(string email, string subject, string htmlMessage)
{
var body = new

View File

@@ -9,6 +9,6 @@
/**
* Local setup instructions, in order:
* 1. To setup the Shogi database, use the dacpac process in visual studio with the Shogi.Database project.
* 1. To setup the Shogi database, use the publish menu option in visual studio with the Shogi.Database project.
* 2. To setup the Entity Framework users database, run this powershell command using Shogi.Api as the target project: dotnet ef database update
*/