using System.ComponentModel.DataAnnotations; using System.Diagnostics; using System.Security.Claims; using System.Text; using System.Text.Encodings.Web; using Abstractions.Entities; using Abstractions.Services; using Microsoft.AspNetCore.Authentication.BearerToken; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.Data; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Options; using System.Text.RegularExpressions; namespace Server.Authentication; public static class AuthEndpoints { // Validate the email address using DataAnnotations like the UserValidator does when RequireUniqueEmail = true. private static readonly EmailAddressAttribute EmailAddressAttribute = new(); // Mirrors the client-side pin validation in register.vue: exactly 4 digits. private static readonly Regex PinFormat = new("^\\d{4}$", RegexOptions.Compiled); public static IEndpointConventionBuilder MapIdentityApi(this IEndpointRouteBuilder endpoints) { 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("/auth"); routeGroup.MapPost("/register", async Task> ([FromBody] Models.Request.RegisterRequest registration, HttpContext context, [FromServices] IServiceProvider sp) => { var userManager = sp.GetRequiredService>(); var signinManager = sp.GetRequiredService>(); var userService = sp.GetRequiredService(); var cardService = sp.GetRequiredService(); if (!userManager.SupportsUserEmail) { throw new NotSupportedException($"{nameof(MapIdentityApi)} 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 validCard = await cardService.ValidatePinAsync(registration.KonamiId, registration.Pin); if (!validCard) { return CreateValidationProblem("InvalidCard", "The provided Card is invalid"); } var user = await userService.GetUserByKonamiId(registration.KonamiId); if (user == null) { return CreateValidationProblem("InvalidKonamiId", "The provided Card is not found"); } await userStore.SetUserNameAsync(user, registration.Username, CancellationToken.None); await emailStore.SetEmailAsync(user, email, CancellationToken.None); var result = await userManager.UpdateAsync(user); if (!result.Succeeded) { return CreateValidationProblem(result); } result = await userManager.AddPasswordAsync(user, registration.Password); 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 user = await signInManager.UserManager.FindByEmailAsync(login.Email); user ??= await signInManager.UserManager.FindByNameAsync(login.Email); if (user == null) { return TypedResults.Problem("Invalid login attempt.", statusCode: StatusCodes.Status401Unauthorized); } var result = await signInManager.PasswordSignInAsync(user!.UserName!, 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 { } 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 { // Unlike Identity UI's default assumption, username and email are // independent here (registration collects them separately, and // Settings lets either be changed on its own) — so confirming a // changed email must not also overwrite the username. result = await userManager.ChangeEmailAsync(user, changedEmail, code); } 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(MapIdentityApi)}-{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."); } 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."); } 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)); }); accountGroup.MapPost("/username", async Task> (ClaimsPrincipal claimsPrincipal, [FromBody] Models.Request.UpdateUsernameRequest usernameRequest, [FromServices] IServiceProvider sp) => { var userManager = sp.GetRequiredService>(); if (await userManager.GetUserAsync(claimsPrincipal) is not { } user) { return TypedResults.NotFound(); } if (string.IsNullOrWhiteSpace(usernameRequest.NewUsername) || usernameRequest.NewUsername.Length < 3) { return CreateValidationProblem("InvalidUserName", "Username must be at least 3 characters."); } var result = await userManager.SetUserNameAsync(user, usernameRequest.NewUsername); if (!result.Succeeded) { return CreateValidationProblem(result); } return TypedResults.Ok(); }); accountGroup.MapGet("/pin", async Task, NotFound>> (ClaimsPrincipal claimsPrincipal, [FromServices] IServiceProvider sp) => { var userManager = sp.GetRequiredService>(); if (await userManager.GetUserAsync(claimsPrincipal) is not { } user) { return TypedResults.NotFound(); } return TypedResults.Ok(new Models.Response.PinResponse { Pin = user.Pin }); }); accountGroup.MapPost("/pin", async Task> (ClaimsPrincipal claimsPrincipal, [FromBody] Models.Request.UpdatePinRequest pinRequest, [FromServices] IServiceProvider sp) => { var userManager = sp.GetRequiredService>(); if (await userManager.GetUserAsync(claimsPrincipal) is not { } user) { return TypedResults.NotFound(); } if (user.Pin != pinRequest.OldPin) { return CreateValidationProblem("InvalidPin", "The current pin is incorrect."); } if (!PinFormat.IsMatch(pinRequest.NewPin)) { return CreateValidationProblem("InvalidPin", "Pin must be exactly 4 digits."); } user.Pin = pinRequest.NewPin; var result = await userManager.UpdateAsync(user); if (!result.Succeeded) { return CreateValidationProblem(result); } return TypedResults.Ok(); }); async Task SendConfirmationEmailAsync(User 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); } var confirmEmailUrl = linkGenerator.GetUriByName(context, confirmEmailEndpointName, routeValues) ?? 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; } }