From b474641cd380fcba149937d38cf932236de09455 Mon Sep 17 00:00:00 2001 From: Lucas Morgan Date: Sun, 25 Aug 2024 13:38:38 -0500 Subject: [PATCH] override MapIdentityApi() to fix confirmation link. --- Shogi.Api/Controllers/AccountController.cs | 2 +- ...entityApiEndpointRouteBuilderExtensions.cs | 488 ++++++++++++++++++ Shogi.Api/Program.cs | 20 +- Shogi.Api/Repositories/EmailSender.cs | 1 - Shogi.Database/FirstTimeSetup.sql | 2 +- 5 files changed, 497 insertions(+), 16 deletions(-) create mode 100644 Shogi.Api/Controllers/MyIdentityApiEndpointRouteBuilderExtensions.cs diff --git a/Shogi.Api/Controllers/AccountController.cs b/Shogi.Api/Controllers/AccountController.cs index 4f89005..a744f09 100644 --- a/Shogi.Api/Controllers/AccountController.cs +++ b/Shogi.Api/Controllers/AccountController.cs @@ -28,7 +28,7 @@ public class AccountController( } result = await UserManager.CreateAsync(newUser2, pass); - if(result != null && !result.Succeeded) + if (result != null && !result.Succeeded) { return this.Problem(string.Join(",", result.Errors.Select(e => e.Description))); } diff --git a/Shogi.Api/Controllers/MyIdentityApiEndpointRouteBuilderExtensions.cs b/Shogi.Api/Controllers/MyIdentityApiEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..85fe6e8 --- /dev/null +++ b/Shogi.Api/Controllers/MyIdentityApiEndpointRouteBuilderExtensions.cs @@ -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; + +/// +/// Provides extension methods for to add identity endpoints. +/// +public static class MyIdentityApiEndpointRouteBuilderExtensions +{ + // Validate the email address using DataAnnotations like the UserValidator does when RequireUniqueEmail = true. + private static readonly EmailAddressAttribute _emailAddressAttribute = new(); + + /// + /// Add endpoints for registering, logging in, and logging out using ASP.NET Core Identity. + /// + /// The type describing the user. This should match the generic parameter in . + /// + /// The to add the identity endpoints to. + /// Call to add a prefix to all the endpoints. + /// + /// An to further customize the added endpoints. + public static IEndpointConventionBuilder MyMapIdentityApi(this IEndpointRouteBuilder endpoints, IWebHostEnvironment environment) + where TUser : class, new() + { + ArgumentNullException.ThrowIfNull(endpoints); + + var timeProvider = endpoints.ServiceProvider.GetRequiredService(); + var bearerTokenOptions = endpoints.ServiceProvider.GetRequiredService>(); + var emailSender = endpoints.ServiceProvider.GetRequiredService>(); + var linkGenerator = endpoints.ServiceProvider.GetRequiredService(); + + // 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 directly because the TUser generic parameter is currently unsupported by RDG. + // https://github.com/dotnet/aspnetcore/issues/47338 + routeGroup.MapPost("/register", async Task> + ([FromBody] RegisterRequest registration, HttpContext context, [FromServices] IServiceProvider sp) => + { + var userManager = sp.GetRequiredService>(); + + if (!userManager.SupportsUserEmail) + { + throw new NotSupportedException($"{nameof(MyMapIdentityApi)} requires a user store with email support."); + } + + var userStore = sp.GetRequiredService>(); + var emailStore = (IUserEmailStore)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, EmptyHttpResult, ProblemHttpResult>> + ([FromBody] LoginRequest login, [FromQuery] bool? useCookies, [FromQuery] bool? useSessionCookies, [FromServices] IServiceProvider sp) => + { + var signInManager = sp.GetRequiredService>(); + + 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, UnauthorizedHttpResult, SignInHttpResult, ChallengeHttpResult>> + ([FromBody] RefreshRequest refreshRequest, [FromServices] IServiceProvider sp) => + { + var signInManager = sp.GetRequiredService>(); + 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> + ([FromQuery] string userId, [FromQuery] string code, [FromQuery] string? changedEmail, [FromServices] IServiceProvider sp) => + { + var userManager = sp.GetRequiredService>(); + 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 + ([FromBody] ResendConfirmationEmailRequest resendRequest, HttpContext context, [FromServices] IServiceProvider sp) => + { + var userManager = sp.GetRequiredService>(); + 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> + ([FromBody] ForgotPasswordRequest resetRequest, [FromServices] IServiceProvider sp) => + { + var userManager = sp.GetRequiredService>(); + 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> + ([FromBody] ResetPasswordRequest resetRequest, [FromServices] IServiceProvider sp) => + { + var userManager = sp.GetRequiredService>(); + + 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, ValidationProblem, NotFound>> + (ClaimsPrincipal claimsPrincipal, [FromBody] TwoFactorRequest tfaRequest, [FromServices] IServiceProvider sp) => + { + var signInManager = sp.GetRequiredService>(); + 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, ValidationProblem, NotFound>> + (ClaimsPrincipal claimsPrincipal, [FromServices] IServiceProvider sp) => + { + var userManager = sp.GetRequiredService>(); + if (await userManager.GetUserAsync(claimsPrincipal) is not { } user) + { + return TypedResults.NotFound(); + } + + return TypedResults.Ok(await CreateInfoResponseAsync(user, userManager)); + }); + + accountGroup.MapPost("/info", async Task, ValidationProblem, NotFound>> + (ClaimsPrincipal claimsPrincipal, [FromBody] InfoRequest infoRequest, HttpContext context, [FromServices] IServiceProvider sp) => + { + var userManager = sp.GetRequiredService>(); + 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 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 { + { 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(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 CreateInfoResponseAsync(TUser user, UserManager 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 convention) => InnerAsConventionBuilder.Add(convention); + public void Finally(Action 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; + } +} + diff --git a/Shogi.Api/Program.cs b/Shogi.Api/Program.cs index 5fd1b28..05a587e 100644 --- a/Shogi.Api/Program.cs +++ b/Shogi.Api/Program.cs @@ -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(); +app.MyMapIdentityApi(builder.Environment); if (app.Environment.IsDevelopment()) { @@ -67,11 +68,11 @@ static void AddIdentity(WebApplicationBuilder builder, ConfigurationManager conf { policy.RequireAuthenticatedUser(); policy.RequireAssertion(context => context.User?.Identity?.Name switch - { - "Hauth@live.com" => true, - "aat-account" => true, - _ => false - }); + { + "Hauth@live.com" => true, + "aat-account" => true, + _ => false + }); }); builder.Services @@ -90,13 +91,6 @@ static void AddIdentity(WebApplicationBuilder builder, ConfigurationManager conf }) .AddEntityFrameworkStores(); - // I shouldn't this because I have it above, right? - //builder.Services.Configure(options => - //{ - // options.SignIn.RequireConfirmedEmail = true; - // options.User.RequireUniqueEmail = true; - //}); - builder.Services.ConfigureApplicationCookie(options => { options.SlidingExpiration = true; diff --git a/Shogi.Api/Repositories/EmailSender.cs b/Shogi.Api/Repositories/EmailSender.cs index d9e5c6d..828d878 100644 --- a/Shogi.Api/Repositories/EmailSender.cs +++ b/Shogi.Api/Repositories/EmailSender.cs @@ -21,7 +21,6 @@ public class EmailSender : IEmailSender this.client = client; } - public async Task SendEmailAsync(string email, string subject, string htmlMessage) { var body = new diff --git a/Shogi.Database/FirstTimeSetup.sql b/Shogi.Database/FirstTimeSetup.sql index 40422fe..1ff3da0 100644 --- a/Shogi.Database/FirstTimeSetup.sql +++ b/Shogi.Database/FirstTimeSetup.sql @@ -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 */ \ No newline at end of file