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