mirror of
https://gitea.tendokyu.moe/Kayori/Medusa.net.git
synced 2026-09-25 07:48:10 +03:00
- Refactor handler dispatch to use a parsed GameModel instead of raw model strings (Handler.cs, HandlerService.cs, new GameModel.cs) - Add a Unity asset-extraction subsystem (Abstractions/Extractors/Unity) - Add WriteEventLogHandler, XrpcDouble serialization type - Rename EaCoin*Handler -> *EaCoinHandler for naming consistency - Add PIN/username update and forgot/reset password flows, with a console-logging IEmailSender until real email is wired up - Add a settings page and a theming pass (theme tokens, tailwind config) - Add plugin nav/route/UI-manifest frontend types
565 lines
24 KiB
C#
565 lines
24 KiB
C#
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<TimeProvider>();
|
|
var bearerTokenOptions = endpoints.ServiceProvider.GetRequiredService<IOptionsMonitor<BearerTokenOptions>>();
|
|
var emailSender = endpoints.ServiceProvider.GetRequiredService<IEmailSender<User>>();
|
|
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("/auth");
|
|
|
|
routeGroup.MapPost("/register", async Task<Results<Ok, ValidationProblem>>
|
|
([FromBody] Models.Request.RegisterRequest registration, HttpContext context, [FromServices] IServiceProvider sp) =>
|
|
{
|
|
var userManager = sp.GetRequiredService<UserManager<User>>();
|
|
var signinManager = sp.GetRequiredService<SignInManager<User>>();
|
|
var userService = sp.GetRequiredService<IUserService>();
|
|
var cardService = sp.GetRequiredService<ICardService>();
|
|
|
|
if (!userManager.SupportsUserEmail)
|
|
{
|
|
throw new NotSupportedException($"{nameof(MapIdentityApi)} requires a user store with email support.");
|
|
}
|
|
|
|
var userStore = sp.GetRequiredService<IUserStore<User>>();
|
|
var emailStore = (IUserEmailStore<User>)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<Results<Ok<AccessTokenResponse>, EmptyHttpResult, ProblemHttpResult>>
|
|
([FromBody] LoginRequest login, [FromQuery] bool? useCookies, [FromQuery] bool? useSessionCookies, [FromServices] IServiceProvider sp) =>
|
|
{
|
|
var signInManager = sp.GetRequiredService<SignInManager<User>>();
|
|
|
|
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<Results<Ok<AccessTokenResponse>, UnauthorizedHttpResult, SignInHttpResult, ChallengeHttpResult>>
|
|
([FromBody] RefreshRequest refreshRequest, [FromServices] IServiceProvider sp) =>
|
|
{
|
|
var signInManager = sp.GetRequiredService<SignInManager<User>>();
|
|
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<Results<ContentHttpResult, UnauthorizedHttpResult>>
|
|
([FromQuery] string userId, [FromQuery] string code, [FromQuery] string? changedEmail, [FromServices] IServiceProvider sp) =>
|
|
{
|
|
var userManager = sp.GetRequiredService<UserManager<User>>();
|
|
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<Ok>
|
|
([FromBody] ResendConfirmationEmailRequest resendRequest, HttpContext context, [FromServices] IServiceProvider sp) =>
|
|
{
|
|
var userManager = sp.GetRequiredService<UserManager<User>>();
|
|
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<User>>();
|
|
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<User>>();
|
|
|
|
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<User>>();
|
|
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<Results<Ok<InfoResponse>, ValidationProblem, NotFound>>
|
|
(ClaimsPrincipal claimsPrincipal, [FromServices] IServiceProvider sp) =>
|
|
{
|
|
var userManager = sp.GetRequiredService<UserManager<User>>();
|
|
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<User>>();
|
|
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<Results<Ok, ValidationProblem, NotFound>>
|
|
(ClaimsPrincipal claimsPrincipal, [FromBody] Models.Request.UpdateUsernameRequest usernameRequest, [FromServices] IServiceProvider sp) =>
|
|
{
|
|
var userManager = sp.GetRequiredService<UserManager<User>>();
|
|
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<Results<Ok<Models.Response.PinResponse>, NotFound>>
|
|
(ClaimsPrincipal claimsPrincipal, [FromServices] IServiceProvider sp) =>
|
|
{
|
|
var userManager = sp.GetRequiredService<UserManager<User>>();
|
|
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<Results<Ok, ValidationProblem, NotFound>>
|
|
(ClaimsPrincipal claimsPrincipal, [FromBody] Models.Request.UpdatePinRequest pinRequest, [FromServices] IServiceProvider sp) =>
|
|
{
|
|
var userManager = sp.GetRequiredService<UserManager<User>>();
|
|
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<User> 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<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;
|
|
}
|
|
} |