diff --git a/src/Http/Routing/src/EndpointNameMetadata.cs b/src/Http/Routing/src/EndpointNameMetadata.cs index dc356cd90edc..41428703bfcc 100644 --- a/src/Http/Routing/src/EndpointNameMetadata.cs +++ b/src/Http/Routing/src/EndpointNameMetadata.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Shared; @@ -13,6 +14,7 @@ namespace Microsoft.AspNetCore.Routing; /// Endpoint names must be unique within an application, and can be used to unambiguously /// identify a desired endpoint for URI generation using . /// +[DebuggerDisplay("{ToString(),nq}")] public class EndpointNameMetadata : IEndpointNameMetadata { /// diff --git a/src/Identity/Core/src/DTO/AuthenticatorKeyResponse.cs b/src/Identity/Core/src/DTO/AuthenticatorKeyResponse.cs new file mode 100644 index 000000000000..613626f5e850 --- /dev/null +++ b/src/Identity/Core/src/DTO/AuthenticatorKeyResponse.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Identity.DTO; + +internal sealed class TwoFactorResponse +{ + public required string SharedKey { get; init; } + public required int RecoveryCodesLeft { get; init; } + public string[]? RecoveryCodes { get; init; } + public required bool IsTwoFactorEnabled { get; init; } + public required bool IsMachineRemembered { get; init; } +} diff --git a/src/Identity/Core/src/DTO/IdentityEndpointsJsonSerializerContext.cs b/src/Identity/Core/src/DTO/IdentityEndpointsJsonSerializerContext.cs index 88db894aaed8..0f12850b1174 100644 --- a/src/Identity/Core/src/DTO/IdentityEndpointsJsonSerializerContext.cs +++ b/src/Identity/Core/src/DTO/IdentityEndpointsJsonSerializerContext.cs @@ -7,6 +7,13 @@ namespace Microsoft.AspNetCore.Identity.DTO; [JsonSerializable(typeof(RegisterRequest))] [JsonSerializable(typeof(LoginRequest))] +[JsonSerializable(typeof(RefreshRequest))] +[JsonSerializable(typeof(ResetPasswordRequest))] +[JsonSerializable(typeof(ResendEmailRequest))] +[JsonSerializable(typeof(InfoRequest))] +[JsonSerializable(typeof(InfoResponse))] +[JsonSerializable(typeof(TwoFactorRequest))] +[JsonSerializable(typeof(TwoFactorResponse))] internal sealed partial class IdentityEndpointsJsonSerializerContext : JsonSerializerContext { } diff --git a/src/Identity/Core/src/DTO/InfoRequest.cs b/src/Identity/Core/src/DTO/InfoRequest.cs new file mode 100644 index 000000000000..857b98fa43c4 --- /dev/null +++ b/src/Identity/Core/src/DTO/InfoRequest.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Identity.DTO; + +internal sealed class InfoRequest +{ + public string? NewUsername { get; init; } + public string? NewEmail { get; init; } + public string? NewPassword { get; init; } + public string? OldPassword { get; init; } +} diff --git a/src/Identity/Core/src/DTO/InfoResponse.cs b/src/Identity/Core/src/DTO/InfoResponse.cs new file mode 100644 index 000000000000..56422f86cbd7 --- /dev/null +++ b/src/Identity/Core/src/DTO/InfoResponse.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Identity.DTO; + +internal sealed class InfoResponse +{ + public required string Username { get; init; } + public required string Email { get; init; } + public required IDictionary Claims { get; init; } +} diff --git a/src/Identity/Core/src/DTO/LoginRequest.cs b/src/Identity/Core/src/DTO/LoginRequest.cs index fbe6b6900f0f..27e345f65d7f 100644 --- a/src/Identity/Core/src/DTO/LoginRequest.cs +++ b/src/Identity/Core/src/DTO/LoginRequest.cs @@ -7,4 +7,6 @@ internal sealed class LoginRequest { public required string Username { get; init; } public required string Password { get; init; } + public string? TwoFactorCode { get; init; } + public string? TwoFactorRecoveryCode { get; init; } } diff --git a/src/Identity/Core/src/DTO/RegisterRequest.cs b/src/Identity/Core/src/DTO/RegisterRequest.cs index 26b91eb512d4..58c55355b05f 100644 --- a/src/Identity/Core/src/DTO/RegisterRequest.cs +++ b/src/Identity/Core/src/DTO/RegisterRequest.cs @@ -7,4 +7,5 @@ internal sealed class RegisterRequest { public required string Username { get; init; } public required string Password { get; init; } + public required string Email { get; init; } } diff --git a/src/Identity/Core/src/DTO/ResendEmailRequest.cs b/src/Identity/Core/src/DTO/ResendEmailRequest.cs new file mode 100644 index 000000000000..34aa7c11fd49 --- /dev/null +++ b/src/Identity/Core/src/DTO/ResendEmailRequest.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Identity.DTO; + +internal sealed class ResendEmailRequest +{ + public required string Email { get; init; } +} diff --git a/src/Identity/Core/src/DTO/ResetPasswordRequest.cs b/src/Identity/Core/src/DTO/ResetPasswordRequest.cs new file mode 100644 index 000000000000..441420662bbd --- /dev/null +++ b/src/Identity/Core/src/DTO/ResetPasswordRequest.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Identity.DTO; + +internal sealed class ResetPasswordRequest +{ + public required string Email { get; init; } + public string? ResetCode { get; init; } + public string? NewPassword { get; init; } +} diff --git a/src/Identity/Core/src/DTO/TwoFactorRequest.cs b/src/Identity/Core/src/DTO/TwoFactorRequest.cs new file mode 100644 index 000000000000..92290c0d9ab1 --- /dev/null +++ b/src/Identity/Core/src/DTO/TwoFactorRequest.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Identity.DTO; + +internal sealed class TwoFactorRequest +{ + public bool? Enable { get; init; } + public string? TwoFactorCode { get; init; } + + public bool ResetSharedKey { get; init; } + public bool ResetRecoveryCodes { get; init; } + public bool ForgetMachine { get; init; } +} diff --git a/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs b/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs index c65182c1aa2f..e2bc8a7bfec7 100644 --- a/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs +++ b/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs @@ -1,15 +1,23 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; using System.Linq; +using System.Security.Claims; +using System.Text; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.BearerToken; using Microsoft.AspNetCore.Authentication.BearerToken.DTO; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.DTO; +using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -29,10 +37,19 @@ public static class IdentityApiEndpointRouteBuilderExtensions /// Call to add a prefix to all the endpoints. /// /// An to further customize the added endpoints. - public static IEndpointConventionBuilder MapIdentityApi(this IEndpointRouteBuilder endpoints) where TUser : class, new() + public static IEndpointConventionBuilder MapIdentityApi(this IEndpointRouteBuilder endpoints) + 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. @@ -42,44 +59,64 @@ public static class IdentityApiEndpointRouteBuilderExtensions { var userManager = 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 user = new TUser(); - await userManager.SetUserNameAsync(user, registration.Username); + await userStore.SetUserNameAsync(user, registration.Username, CancellationToken.None); + await emailStore.SetEmailAsync(user, registration.Email, CancellationToken.None); var result = await userManager.CreateAsync(user, registration.Password); if (result.Succeeded) { + await SendConfirmationEmailAsync(user, userManager, registration.Email); return TypedResults.Ok(); } - return TypedResults.ValidationProblem(result.Errors.ToDictionary(e => e.Code, e => new[] { e.Description })); + return CreateValidationProblem(result); }); - routeGroup.MapPost("/login", async Task, SignInHttpResult>> - ([FromBody] LoginRequest login, [FromQuery] bool? cookieMode, [FromServices] IServiceProvider sp) => + routeGroup.MapPost("/login", async Task, EmptyHttpResult, ProblemHttpResult>> + ([FromBody] LoginRequest login, [FromQuery] bool? cookieMode, [FromQuery] bool? persistCookies, [FromServices] IServiceProvider sp) => { - var userManager = sp.GetRequiredService>(); - var user = await userManager.FindByNameAsync(login.Username); + var signInManager = sp.GetRequiredService>(); + + signInManager.PrimaryAuthenticationScheme = cookieMode == true ? IdentityConstants.ApplicationScheme : IdentityConstants.BearerScheme; + var isPersistent = persistCookies ?? true; - if (user is null || !await userManager.CheckPasswordAsync(user, login.Password)) + var result = await signInManager.PasswordSignInAsync(login.Username, login.Password, isPersistent, lockoutOnFailure: true); + + if (result.RequiresTwoFactor) { - return TypedResults.Unauthorized(); + 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); + } } - var claimsFactory = sp.GetRequiredService>(); - var claimsPrincipal = await claimsFactory.CreateAsync(user); - - var useCookies = cookieMode ?? false; - var scheme = useCookies ? IdentityConstants.ApplicationScheme : IdentityConstants.BearerScheme; + if (result.Succeeded) + { + // The signInManager already produced the needed response in the form of a cookie or bearer token. + return TypedResults.Empty; + } - return TypedResults.SignIn(claimsPrincipal, authenticationScheme: scheme); + return TypedResults.Problem(result.ToString(), statusCode: StatusCodes.Status401Unauthorized); }); - routeGroup.MapPost("/refresh", async Task, SignInHttpResult, ChallengeHttpResult>> - ([FromBody] RefreshRequest refreshRequest, [FromServices] IOptionsMonitor optionsMonitor, [FromServices] TimeProvider timeProvider, [FromServices] IServiceProvider sp) => + routeGroup.MapPost("/refresh", async Task, UnauthorizedHttpResult, SignInHttpResult, ChallengeHttpResult>> + ([FromBody] RefreshRequest refreshRequest, [FromServices] IServiceProvider sp) => { var signInManager = sp.GetRequiredService>(); - var identityBearerOptions = optionsMonitor.Get(IdentityConstants.BearerScheme); - var refreshTokenProtector = identityBearerOptions.RefreshTokenProtector ?? throw new ArgumentException($"{nameof(identityBearerOptions.RefreshTokenProtector)} is null", nameof(optionsMonitor)); + 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 @@ -95,15 +132,398 @@ await signInManager.ValidateSecurityStampAsync(refreshTicket.Principal) is not T 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(); + } + + IdentityResult result; + try + { + code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code)); + if (string.IsNullOrEmpty(changedEmail)) + { + result = await userManager.ConfirmEmailAsync(user, code); + } + else + { + result = await userManager.ChangeEmailAsync(user, changedEmail, code); + } + } + catch (FormatException) + { + return TypedResults.Unauthorized(); + } + + 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)); + endpointBuilder.Metadata.Add(new RouteNameMetadata(confirmEmailEndpointName)); + }); + + routeGroup.MapPost("/resendConfirmationEmail", async Task + ([FromBody] ResendEmailRequest resendRequest, [FromServices] IServiceProvider sp) => + { + var userManager = sp.GetRequiredService>(); + if (await userManager.FindByEmailAsync(resendRequest.Email) is not { } user) + { + return TypedResults.Ok(); + } + + await SendConfirmationEmailAsync(user, userManager, resendRequest.Email); + return TypedResults.Ok(); + }); + + routeGroup.MapPost("/resetPassword", async Task> + ([FromBody] ResetPasswordRequest resetRequest, [FromServices] IServiceProvider sp) => + { + var userManager = sp.GetRequiredService>(); + + if (!string.IsNullOrEmpty(resetRequest.ResetCode) && string.IsNullOrEmpty(resetRequest.NewPassword)) + { + return CreateValidationProblem("MissingNewPassword", "A password reset code was provided without a new password."); + } + + 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. + if (!string.IsNullOrEmpty(resetRequest.ResetCode)) + { + return CreateValidationProblem(IdentityResult.Failed(userManager.ErrorDescriber.InvalidToken())); + } + } + else if (string.IsNullOrEmpty(resetRequest.ResetCode)) + { + var code = await userManager.GeneratePasswordResetTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + + await emailSender.SendEmailAsync(resetRequest.Email, "Reset your password", + $"Reset your password using the following code: {HtmlEncoder.Default.Encode(code)}"); + } + else + { + Debug.Assert(!string.IsNullOrEmpty(resetRequest.NewPassword)); + + 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("/account").RequireAuthorization(); + + accountGroup.MapGet("/2fa", async Task, NotFound>> + (ClaimsPrincipal claimsPrincipal, [FromServices] IServiceProvider sp) => + { + var signInManager = sp.GetRequiredService>(); + if (await signInManager.UserManager.GetUserAsync(claimsPrincipal) is not { } user) + { + return TypedResults.NotFound(); + } + + return TypedResults.Ok(await CreateTwoFactorResponseAsync(user, signInManager)); + }); + + 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(); + } + + return TypedResults.Ok(await CreateTwoFactorResponseAsync(user, signInManager, recoveryCodes)); + }); + + 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, claimsPrincipal, userManager)); + }); + + accountGroup.MapPost("/info", async Task, ValidationProblem, NotFound>> + (HttpContext httpContext, [FromBody] InfoRequest infoRequest, [FromServices] IServiceProvider sp) => + { + var signInManager = sp.GetRequiredService>(); + var userManager = signInManager.UserManager; + if (await userManager.GetUserAsync(httpContext.User) is not { } user) + { + return TypedResults.NotFound(); + } + + List? failedResults = null; + + if (!string.IsNullOrEmpty(infoRequest.NewUsername)) + { + var userName = await userManager.GetUserNameAsync(user); + + if (userName != infoRequest.NewUsername) + { + AddIfFailed(ref failedResults, await userManager.SetUserNameAsync(user, infoRequest.NewUsername)); + } + } + + if (!string.IsNullOrEmpty(infoRequest.NewEmail)) + { + var email = await userManager.GetEmailAsync(user); + + if (email != infoRequest.NewEmail) + { + await SendConfirmationEmailAsync(user, userManager, infoRequest.NewEmail, isChange: true); + } + } + + if (!string.IsNullOrEmpty(infoRequest.NewPassword)) + { + if (string.IsNullOrEmpty(infoRequest.OldPassword)) + { + AddIfFailed(ref failedResults, IdentityResult.Failed(new IdentityError + { + Code = "OldPasswordRequired", + Description = "The old password is required to set a new password. If the old password is forgotten, use /resetPassword.", + })); + } + else + { + AddIfFailed(ref failedResults, await userManager.ChangePasswordAsync(user, infoRequest.OldPassword, infoRequest.NewPassword)); + } + } + + // Update cookie if the user is authenticated that way. + // Currently, the user will have to log in again with bearer tokens to see updated claims. + var authFeature = httpContext.Features.GetRequiredFeature(); + if (authFeature.AuthenticateResult?.Ticket?.AuthenticationScheme == IdentityConstants.ApplicationScheme) + { + await signInManager.RefreshSignInAsync(user); + } + + if (failedResults is not null) + { + return CreateValidationProblem(failedResults); + } + else + { + return TypedResults.Ok(await CreateInfoResponseAsync(user, httpContext.User, userManager)); + } + }); + + async Task SendConfirmationEmailAsync(TUser user, UserManager userManager, 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.GetPathByName(confirmEmailEndpointName, routeValues) + ?? throw new NotSupportedException($"Could not find endpoint named '{confirmEmailEndpointName}'."); + + await emailSender.SendEmailAsync(email, "Confirm your email", + $"Please confirm your account by clicking here."); + } + return new IdentityEndpointsConventionBuilder(routeGroup); } + private static void AddIfFailed(ref List? results, IdentityResult result) + { + if (result.Succeeded) + { + return; + } + + results ??= new(); + results.Add(result); + } + + private static ValidationProblem CreateValidationProblem(string errorCode, string errorDescription) => + TypedResults.ValidationProblem(new Dictionary { + { errorCode, new[] { errorDescription } } + }); + + private static ValidationProblem CreateValidationProblem(IdentityResult result) + { + var errorDictionary = new Dictionary(1); + AddErrorsToDictionary(errorDictionary, result); + return TypedResults.ValidationProblem(errorDictionary); + } + + private static ValidationProblem CreateValidationProblem(List results) + { + var errorDictionary = new Dictionary(results.Count); + + foreach (var result in results) + { + AddErrorsToDictionary(errorDictionary, result); + } + + return TypedResults.ValidationProblem(errorDictionary); + } + + private static void AddErrorsToDictionary(Dictionary errorDictionary, 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); + 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 = new[] { error.Description }; + } + + errorDictionary[error.Code] = newDescriptions; + } + } + + private static async Task CreateTwoFactorResponseAsync(TUser user, SignInManager signInManager, string[]? recoveryCodes = null) + where TUser : class + { + var userManager = signInManager.UserManager; + + 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 new() + { + SharedKey = key, + RecoveryCodes = recoveryCodes, + RecoveryCodesLeft = recoveryCodes?.Length ?? await userManager.CountRecoveryCodesAsync(user), + IsTwoFactorEnabled = await userManager.GetTwoFactorEnabledAsync(user), + IsMachineRemembered = await signInManager.IsTwoFactorClientRememberedAsync(user), + }; + } + + private static async Task CreateInfoResponseAsync(TUser user, ClaimsPrincipal claimsPrincipal, UserManager userManager) + where TUser : class + { + return new() + { + Username = await userManager.GetUserNameAsync(user) ?? throw new NotSupportedException("Users must have a user name."), + Email = await userManager.GetEmailAsync(user) ?? throw new NotSupportedException("Users must have an email."), + Claims = claimsPrincipal.Claims.ToDictionary(c => c.Type, c => c.Value), + }; + } + // Wrap RouteGroupBuilder with a non-public type to avoid a potential future behavioral breaking change. private sealed class IdentityEndpointsConventionBuilder(RouteGroupBuilder inner) : IEndpointConventionBuilder { -#pragma warning disable CA1822 // Mark members as static False positive reported by https://github.com/dotnet/roslyn-analyzers/issues/6573 private IEndpointConventionBuilder InnerAsConventionBuilder => inner; -#pragma warning restore CA1822 // Mark members as static public void Add(Action convention) => InnerAsConventionBuilder.Add(convention); public void Finally(Action finallyConvention) => InnerAsConventionBuilder.Finally(finallyConvention); diff --git a/src/Identity/Core/src/IdentityAuthenticationBuilderExtensions.cs b/src/Identity/Core/src/IdentityAuthenticationBuilderExtensions.cs deleted file mode 100644 index 684c0795c45e..000000000000 --- a/src/Identity/Core/src/IdentityAuthenticationBuilderExtensions.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.BearerToken; -using Microsoft.Extensions.DependencyInjection; - -namespace Microsoft.AspNetCore.Identity; - -/// -/// Extension methods to enable bearer token authentication for use with identity. -/// -public static class IdentityAuthenticationBuilderExtensions -{ - /// - /// Adds cookie authentication. - /// - /// The current instance. - /// The . - public static AuthenticationBuilder AddIdentityBearerToken(this AuthenticationBuilder builder) - where TUser : class, new() - => builder.AddIdentityBearerToken(o => { }); - - /// - /// Adds the cookie authentication needed for sign in manager. - /// - /// The current instance. - /// Action used to configure the bearer token handler. - /// The . - public static AuthenticationBuilder AddIdentityBearerToken(this AuthenticationBuilder builder, Action configureOptions) - where TUser : class, new() - { - ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(configureOptions); - - return builder.AddBearerToken(IdentityConstants.BearerScheme, configureOptions); - } -} diff --git a/src/Identity/Core/src/IdentityBuilderExtensions.cs b/src/Identity/Core/src/IdentityBuilderExtensions.cs index 1e2be3b64c7d..cb6278957d4c 100644 --- a/src/Identity/Core/src/IdentityBuilderExtensions.cs +++ b/src/Identity/Core/src/IdentityBuilderExtensions.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.BearerToken; using Microsoft.AspNetCore.Http.Json; +using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -95,6 +96,8 @@ public static IdentityBuilder AddApiEndpoints(this IdentityBuilder builder) ArgumentNullException.ThrowIfNull(builder); builder.AddSignInManager(); + builder.AddDefaultTokenProviders(); + builder.Services.TryAddTransient(); builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, IdentityEndpointsJsonOptionsSetup>()); return builder; } diff --git a/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs b/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs index 7d5153c80fb7..0fd66e73a05f 100644 --- a/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs +++ b/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs @@ -144,14 +144,10 @@ public static IdentityBuilder AddIdentityApiEndpoints(this IServiceCollec compositeOptions.ForwardDefault = IdentityConstants.BearerScheme; compositeOptions.ForwardAuthenticate = IdentityConstants.BearerAndApplicationScheme; }) - .AddIdentityBearerToken() + .AddBearerToken(IdentityConstants.BearerScheme) .AddIdentityCookies(); - return services.AddIdentityCore(o => - { - o.Stores.MaxLengthForKeys = 128; - configure(o); - }) + return services.AddIdentityCore(configure) .AddApiEndpoints(); } diff --git a/src/Identity/Core/src/Microsoft.AspNetCore.Identity.csproj b/src/Identity/Core/src/Microsoft.AspNetCore.Identity.csproj index 6e19a41a58fc..1488733572e1 100644 --- a/src/Identity/Core/src/Microsoft.AspNetCore.Identity.csproj +++ b/src/Identity/Core/src/Microsoft.AspNetCore.Identity.csproj @@ -19,6 +19,7 @@ + diff --git a/src/Identity/Core/src/PublicAPI.Unshipped.txt b/src/Identity/Core/src/PublicAPI.Unshipped.txt index 9bd478611156..9d71c07cd782 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -1,13 +1,12 @@ #nullable enable -Microsoft.AspNetCore.Identity.IdentityAuthenticationBuilderExtensions Microsoft.AspNetCore.Identity.SecurityStampValidator.SecurityStampValidator(Microsoft.Extensions.Options.IOptions! options, Microsoft.AspNetCore.Identity.SignInManager! signInManager, Microsoft.Extensions.Logging.ILoggerFactory! logger) -> void Microsoft.AspNetCore.Identity.SecurityStampValidator.TimeProvider.get -> System.TimeProvider! Microsoft.AspNetCore.Identity.SecurityStampValidatorOptions.TimeProvider.get -> System.TimeProvider? Microsoft.AspNetCore.Identity.SecurityStampValidatorOptions.TimeProvider.set -> void +Microsoft.AspNetCore.Identity.SignInManager.PrimaryAuthenticationScheme.get -> string! +Microsoft.AspNetCore.Identity.SignInManager.PrimaryAuthenticationScheme.set -> void Microsoft.AspNetCore.Identity.TwoFactorSecurityStampValidator.TwoFactorSecurityStampValidator(Microsoft.Extensions.Options.IOptions! options, Microsoft.AspNetCore.Identity.SignInManager! signInManager, Microsoft.Extensions.Logging.ILoggerFactory! logger) -> void Microsoft.AspNetCore.Routing.IdentityApiEndpointRouteBuilderExtensions -static Microsoft.AspNetCore.Identity.IdentityAuthenticationBuilderExtensions.AddIdentityBearerToken(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder! builder) -> Microsoft.AspNetCore.Authentication.AuthenticationBuilder! -static Microsoft.AspNetCore.Identity.IdentityAuthenticationBuilderExtensions.AddIdentityBearerToken(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder! builder, System.Action! configureOptions) -> Microsoft.AspNetCore.Authentication.AuthenticationBuilder! static Microsoft.AspNetCore.Identity.IdentityBuilderExtensions.AddApiEndpoints(this Microsoft.AspNetCore.Identity.IdentityBuilder! builder) -> Microsoft.AspNetCore.Identity.IdentityBuilder! static Microsoft.AspNetCore.Routing.IdentityApiEndpointRouteBuilderExtensions.MapIdentityApi(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! static Microsoft.Extensions.DependencyInjection.IdentityServiceCollectionExtensions.AddIdentityApiEndpoints(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.AspNetCore.Identity.IdentityBuilder! diff --git a/src/Identity/Core/src/SignInManager.cs b/src/Identity/Core/src/SignInManager.cs index 5c9bd280fb39..7c9f98ed672d 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -21,6 +21,12 @@ public class SignInManager where TUser : class private const string LoginProviderKey = "LoginProvider"; private const string XsrfKey = "XsrfId"; + private readonly IHttpContextAccessor _contextAccessor; + private readonly IAuthenticationSchemeProvider _schemes; + private readonly IUserConfirmation _confirmation; + private HttpContext? _context; + private TwoFactorAuthenticationInfo? _twoFactorInfo; + /// /// Creates a new instance of . /// @@ -52,11 +58,6 @@ public SignInManager(UserManager userManager, _confirmation = confirmation; } - private readonly IHttpContextAccessor _contextAccessor; - private readonly IAuthenticationSchemeProvider _schemes; - private readonly IUserConfirmation _confirmation; - private HttpContext? _context; - /// /// Gets the used to log messages from the manager. /// @@ -80,6 +81,11 @@ public SignInManager(UserManager userManager, /// public IdentityOptions Options { get; set; } + /// + /// The authentication scheme to sign in with. Defaults to . + /// + public string PrimaryAuthenticationScheme { get; set; } = IdentityConstants.ApplicationScheme; + /// /// The used. /// @@ -116,7 +122,7 @@ public virtual bool IsSignedIn(ClaimsPrincipal principal) { ArgumentNullException.ThrowIfNull(principal); return principal.Identities != null && - principal.Identities.Any(i => i.AuthenticationType == IdentityConstants.ApplicationScheme); + principal.Identities.Any(i => i.AuthenticationType == PrimaryAuthenticationScheme); } /// @@ -155,7 +161,7 @@ public virtual async Task CanSignInAsync(TUser user) /// The task object representing the asynchronous operation. public virtual async Task RefreshSignInAsync(TUser user) { - var auth = await Context.AuthenticateAsync(IdentityConstants.ApplicationScheme); + var auth = await Context.AuthenticateAsync(PrimaryAuthenticationScheme); IList claims = Array.Empty(); var authenticationMethod = auth?.Principal?.FindFirst(ClaimTypes.AuthenticationMethod); @@ -231,9 +237,12 @@ public virtual async Task SignInWithClaimsAsync(TUser user, AuthenticationProper { userPrincipal.Identities.First().AddClaim(claim); } - await Context.SignInAsync(IdentityConstants.ApplicationScheme, + await Context.SignInAsync(PrimaryAuthenticationScheme, userPrincipal, authenticationProperties ?? new AuthenticationProperties()); + + // This is useful for updating claims immediately when hitting MapIdentityApi's /account/info endpoint with cookies. + Context.User = userPrincipal; } /// @@ -241,9 +250,16 @@ await Context.SignInAsync(IdentityConstants.ApplicationScheme, /// public virtual async Task SignOutAsync() { - await Context.SignOutAsync(IdentityConstants.ApplicationScheme); - await Context.SignOutAsync(IdentityConstants.ExternalScheme); - await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); + await Context.SignOutAsync(PrimaryAuthenticationScheme); + + if (await _schemes.GetSchemeAsync(IdentityConstants.ExternalScheme) != null) + { + await Context.SignOutAsync(IdentityConstants.ExternalScheme); + } + if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorUserIdScheme) != null) + { + await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); + } } /// @@ -414,6 +430,11 @@ public virtual async Task CheckPasswordSignInAsync(TUser user, str /// public virtual async Task IsTwoFactorClientRememberedAsync(TUser user) { + if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorRememberMeScheme) == null) + { + return false; + } + var userId = await UserManager.GetUserIdAsync(user); var result = await Context.AuthenticateAsync(IdentityConstants.TwoFactorRememberMeScheme); return (result?.Principal != null && result.Principal.FindFirstValue(ClaimTypes.Name) == userId); @@ -450,20 +471,15 @@ public virtual Task ForgetTwoFactorClientAsync() public virtual async Task TwoFactorRecoveryCodeSignInAsync(string recoveryCode) { var twoFactorInfo = await RetrieveTwoFactorInfoAsync(); - if (twoFactorInfo == null || twoFactorInfo.UserId == null) - { - return SignInResult.Failed; - } - var user = await UserManager.FindByIdAsync(twoFactorInfo.UserId); - if (user == null) + if (twoFactorInfo == null) { return SignInResult.Failed; } - var result = await UserManager.RedeemTwoFactorRecoveryCodeAsync(user, recoveryCode); + var result = await UserManager.RedeemTwoFactorRecoveryCodeAsync(twoFactorInfo.User, recoveryCode); if (result.Succeeded) { - return await DoTwoFactorSignInAsync(user, twoFactorInfo, isPersistent: false, rememberClient: false); + return await DoTwoFactorSignInAsync(twoFactorInfo.User, twoFactorInfo, isPersistent: false, rememberClient: false); } // We don't protect against brute force attacks since codes are expected to be random. @@ -484,17 +500,23 @@ private async Task DoTwoFactorSignInAsync(TUser user, TwoFactorAut var claims = new List(); claims.Add(new Claim("amr", "mfa")); - // Cleanup external cookie if (twoFactorInfo.LoginProvider != null) { claims.Add(new Claim(ClaimTypes.AuthenticationMethod, twoFactorInfo.LoginProvider)); + } + // Cleanup external cookie + if (await _schemes.GetSchemeAsync(IdentityConstants.ExternalScheme) != null) + { await Context.SignOutAsync(IdentityConstants.ExternalScheme); } // Cleanup two factor user id cookie - await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); - if (rememberClient) + if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorUserIdScheme) != null) { - await RememberTwoFactorClientAsync(user); + await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); + if (rememberClient) + { + await RememberTwoFactorClientAsync(user); + } } await SignInWithClaimsAsync(user, isPersistent, claims); return SignInResult.Success; @@ -512,16 +534,12 @@ private async Task DoTwoFactorSignInAsync(TUser user, TwoFactorAut public virtual async Task TwoFactorAuthenticatorSignInAsync(string code, bool isPersistent, bool rememberClient) { var twoFactorInfo = await RetrieveTwoFactorInfoAsync(); - if (twoFactorInfo == null || twoFactorInfo.UserId == null) - { - return SignInResult.Failed; - } - var user = await UserManager.FindByIdAsync(twoFactorInfo.UserId); - if (user == null) + if (twoFactorInfo == null) { return SignInResult.Failed; } + var user = twoFactorInfo.User; var error = await PreSignInCheck(user); if (error != null) { @@ -559,16 +577,12 @@ public virtual async Task TwoFactorAuthenticatorSignInAsync(string public virtual async Task TwoFactorSignInAsync(string provider, string code, bool isPersistent, bool rememberClient) { var twoFactorInfo = await RetrieveTwoFactorInfoAsync(); - if (twoFactorInfo == null || twoFactorInfo.UserId == null) - { - return SignInResult.Failed; - } - var user = await UserManager.FindByIdAsync(twoFactorInfo.UserId); - if (user == null) + if (twoFactorInfo == null) { return SignInResult.Failed; } + var user = twoFactorInfo.User; var error = await PreSignInCheck(user); if (error != null) { @@ -605,7 +619,7 @@ public virtual async Task TwoFactorSignInAsync(string provider, st return null; } - return await UserManager.FindByIdAsync(info.UserId!); + return info.User; } /// @@ -798,9 +812,21 @@ protected virtual async Task SignInOrTwoFactorAsync(TUser user, bo { if (!await IsTwoFactorClientRememberedAsync(user)) { - // Store the userId for use after two factor check - var userId = await UserManager.GetUserIdAsync(user); - await Context.SignInAsync(IdentityConstants.TwoFactorUserIdScheme, StoreTwoFactorInfo(userId, loginProvider)); + // Allow the two-factor flow to continue later within the same request with or without a TwoFactorUserIdScheme in + // the event that the two-factor code or recovery code has already been provided as is the case for MapIdentityApi. + _twoFactorInfo = new() + { + User = user, + LoginProvider = loginProvider, + }; + + if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorUserIdScheme) != null) + { + // Store the userId for use after two factor check + var userId = await UserManager.GetUserIdAsync(user); + await Context.SignInAsync(IdentityConstants.TwoFactorUserIdScheme, StoreTwoFactorInfo(userId, loginProvider)); + } + return SignInResult.TwoFactorRequired; } } @@ -822,16 +848,34 @@ protected virtual async Task SignInOrTwoFactorAsync(TUser user, bo private async Task RetrieveTwoFactorInfoAsync() { + if (_twoFactorInfo != null) + { + return _twoFactorInfo; + } + var result = await Context.AuthenticateAsync(IdentityConstants.TwoFactorUserIdScheme); - if (result?.Principal != null) + if (result?.Principal == null) { - return new TwoFactorAuthenticationInfo - { - UserId = result.Principal.FindFirstValue(ClaimTypes.Name), - LoginProvider = result.Principal.FindFirstValue(ClaimTypes.AuthenticationMethod) - }; + return null; } - return null; + + var userId = result.Principal.FindFirstValue(ClaimTypes.Name); + if (userId == null) + { + return null; + } + + var user = await UserManager.FindByIdAsync(userId); + if (user == null) + { + return null; + } + + return new TwoFactorAuthenticationInfo + { + User = user, + LoginProvider = result.Principal.FindFirstValue(ClaimTypes.AuthenticationMethod), + }; } /// @@ -953,7 +997,7 @@ public override string Message internal sealed class TwoFactorAuthenticationInfo { - public string? UserId { get; set; } - public string? LoginProvider { get; set; } + public required TUser User { get; init; } + public string? LoginProvider { get; init; } } } diff --git a/src/Identity/Extensions.Core/src/IEmailSender.cs b/src/Identity/Extensions.Core/src/IEmailSender.cs new file mode 100644 index 000000000000..614a1fd6254e --- /dev/null +++ b/src/Identity/Extensions.Core/src/IEmailSender.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity.UI.Services; + +/// +/// This API supports the ASP.NET Core Identity infrastructure and is not intended to be used as a general purpose +/// email abstraction. It should be implemented by the application so the Identity infrastructure can send confirmation emails. +/// +public interface IEmailSender +{ + /// + /// This API supports the ASP.NET Core Identity infrastructure and is not intended to be used as a general purpose + /// email abstraction. It should be implemented by the application so the Identity infrastructure can send confirmation emails. + /// + /// The recipient's email address. + /// The subject of the email. + /// The body of the email which may contain HTML tags. Do not double encode this. + /// + Task SendEmailAsync(string email, string subject, string htmlMessage); +} diff --git a/src/Identity/Extensions.Core/src/IdentityBuilder.cs b/src/Identity/Extensions.Core/src/IdentityBuilder.cs index b64111fc77af..df34d05d7fe5 100644 --- a/src/Identity/Extensions.Core/src/IdentityBuilder.cs +++ b/src/Identity/Extensions.Core/src/IdentityBuilder.cs @@ -149,7 +149,17 @@ public virtual IdentityBuilder AddTokenProvider(string providerName, [Dynamicall } Services.Configure(options => { - options.Tokens.ProviderMap[providerName] = new TokenProviderDescriptor(provider); + // Overwrite ProviderType if it exists for backcompat, but keep a reference to the old one in case it's needed + // by a SignInManager with a different UserType. We'll continue to just overwrite ProviderInstance until someone asks for a fix though. + if (options.Tokens.ProviderMap.TryGetValue(providerName, out var descriptor)) + { + descriptor.ProviderInstance = null; + descriptor.AddProviderType(provider); + } + else + { + options.Tokens.ProviderMap[providerName] = new TokenProviderDescriptor(provider); + } }); Services.AddTransient(provider); return this; diff --git a/src/Identity/Extensions.Core/src/NoOpEmailSender.cs b/src/Identity/Extensions.Core/src/NoOpEmailSender.cs new file mode 100644 index 000000000000..aadc3dd502a6 --- /dev/null +++ b/src/Identity/Extensions.Core/src/NoOpEmailSender.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Identity.UI.Services; + +/// +/// The default that does nothing in . +/// It is used to detect that the has been customized. If not, Identity UI provides a development +/// experience where the email confirmation link is rendered by the UI immediately rather than sent via an email. +/// +public sealed class NoOpEmailSender : IEmailSender +{ + /// + /// This method does nothing other return . It should be replaced by a custom implementation + /// in production. + /// + public Task SendEmailAsync(string email, string subject, string htmlMessage) => Task.CompletedTask; +} diff --git a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt index d9173be85cd0..33bb8e55c6e3 100644 --- a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt @@ -2,6 +2,11 @@ Microsoft.AspNetCore.Identity.IdentitySchemaVersions Microsoft.AspNetCore.Identity.StoreOptions.SchemaVersion.get -> System.Version! Microsoft.AspNetCore.Identity.StoreOptions.SchemaVersion.set -> void +Microsoft.AspNetCore.Identity.UI.Services.IEmailSender +Microsoft.AspNetCore.Identity.UI.Services.IEmailSender.SendEmailAsync(string! email, string! subject, string! htmlMessage) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.UI.Services.NoOpEmailSender +Microsoft.AspNetCore.Identity.UI.Services.NoOpEmailSender.NoOpEmailSender() -> void +Microsoft.AspNetCore.Identity.UI.Services.NoOpEmailSender.SendEmailAsync(string! email, string! subject, string! htmlMessage) -> System.Threading.Tasks.Task! static readonly Microsoft.AspNetCore.Identity.IdentitySchemaVersions.Default -> System.Version! static readonly Microsoft.AspNetCore.Identity.IdentitySchemaVersions.Version1 -> System.Version! static readonly Microsoft.AspNetCore.Identity.IdentitySchemaVersions.Version2 -> System.Version! diff --git a/src/Identity/Extensions.Core/src/SignInResult.cs b/src/Identity/Extensions.Core/src/SignInResult.cs index 01ae9864e2a8..1cef6983de14 100644 --- a/src/Identity/Extensions.Core/src/SignInResult.cs +++ b/src/Identity/Extensions.Core/src/SignInResult.cs @@ -80,7 +80,7 @@ public class SignInResult /// A string representation of value of the current object. public override string ToString() { - return IsLockedOut ? "Lockedout" : + return IsLockedOut ? "LockedOut" : IsNotAllowed ? "NotAllowed" : RequiresTwoFactor ? "RequiresTwoFactor" : Succeeded ? "Succeeded" : "Failed"; diff --git a/src/Identity/Extensions.Core/src/TokenProviderDescriptor.cs b/src/Identity/Extensions.Core/src/TokenProviderDescriptor.cs index 20dd8001d9ba..e1d5ea1cff1e 100644 --- a/src/Identity/Extensions.Core/src/TokenProviderDescriptor.cs +++ b/src/Identity/Extensions.Core/src/TokenProviderDescriptor.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; namespace Microsoft.AspNetCore.Identity; @@ -10,22 +11,40 @@ namespace Microsoft.AspNetCore.Identity; /// public class TokenProviderDescriptor { + // Provides support for multiple TUser types at once. + // See MapIdentityApiTests.CanAddEndpointsToMultipleRouteGroupsForMultipleUsersTypes for example usage. + private readonly Stack _providerTypes = new(1); + /// /// Initializes a new instance of the class. /// /// The concrete type for this token provider. public TokenProviderDescriptor(Type type) { - ProviderType = type; + _providerTypes.Push(type); } /// /// The type that will be used for this token provider. /// - public Type ProviderType { get; } + public Type ProviderType => _providerTypes.Peek(); /// /// If specified, the instance to be used for the token provider. /// public object? ProviderInstance { get; set; } + + internal void AddProviderType(Type type) => _providerTypes.Push(type); + + internal Type? GetProviderType() + { + foreach (var providerType in _providerTypes) + { + if (typeof(T).IsAssignableFrom(providerType)) + { + return providerType; + } + } + return null; + } } diff --git a/src/Identity/Extensions.Core/src/UserManager.cs b/src/Identity/Extensions.Core/src/UserManager.cs index 7ea477fc949b..33c5eaaf62bd 100644 --- a/src/Identity/Extensions.Core/src/UserManager.cs +++ b/src/Identity/Extensions.Core/src/UserManager.cs @@ -106,8 +106,12 @@ public UserManager(IUserStore store, { var description = Options.Tokens.ProviderMap[providerName]; - var provider = (description.ProviderInstance ?? services.GetRequiredService(description.ProviderType)) - as IUserTwoFactorTokenProvider; + var provider = description.ProviderInstance as IUserTwoFactorTokenProvider; + if (provider == null && description.GetProviderType>() is Type providerType) + { + provider = (IUserTwoFactorTokenProvider)services.GetRequiredService(providerType); + } + if (provider != null) { RegisterTokenProvider(providerName, provider); diff --git a/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/RegisterConfirmation.cshtml.cs b/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/RegisterConfirmation.cshtml.cs index f2b5236a621d..0423e2820fee 100644 --- a/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/RegisterConfirmation.cshtml.cs +++ b/src/Identity/UI/src/Areas/Identity/Pages/V4/Account/RegisterConfirmation.cshtml.cs @@ -70,7 +70,7 @@ public override async Task OnGetAsync(string email, string? retur Email = email; // If the email sender is a no-op, display the confirm link in the page - DisplayConfirmAccountLink = _sender is EmailSender; + DisplayConfirmAccountLink = _sender is NoOpEmailSender; if (DisplayConfirmAccountLink) { var userId = await _userManager.GetUserIdAsync(user); diff --git a/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/RegisterConfirmation.cshtml.cs b/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/RegisterConfirmation.cshtml.cs index 259172fbcfa8..51a090db6f82 100644 --- a/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/RegisterConfirmation.cshtml.cs +++ b/src/Identity/UI/src/Areas/Identity/Pages/V5/Account/RegisterConfirmation.cshtml.cs @@ -70,7 +70,7 @@ public override async Task OnGetAsync(string email, string? retur Email = email; // If the email sender is a no-op, display the confirm link in the page - DisplayConfirmAccountLink = _sender is EmailSender; + DisplayConfirmAccountLink = _sender is NoOpEmailSender; if (DisplayConfirmAccountLink) { var userId = await _userManager.GetUserIdAsync(user); diff --git a/src/Identity/UI/src/Areas/Identity/Services/EmailSender.cs b/src/Identity/UI/src/Areas/Identity/Services/EmailSender.cs deleted file mode 100644 index 5903931795b1..000000000000 --- a/src/Identity/UI/src/Areas/Identity/Services/EmailSender.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.AspNetCore.Identity.UI.Services; - -internal sealed class EmailSender : IEmailSender -{ - public Task SendEmailAsync(string email, string subject, string htmlMessage) - { - return Task.CompletedTask; - } -} diff --git a/src/Identity/UI/src/Areas/Identity/Services/IEmailSender.cs b/src/Identity/UI/src/Areas/Identity/Services/IEmailSender.cs deleted file mode 100644 index 79f6f3172636..000000000000 --- a/src/Identity/UI/src/Areas/Identity/Services/IEmailSender.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -namespace Microsoft.AspNetCore.Identity.UI.Services; - -/// -/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used -/// directly from your code. This API may change or be removed in future releases. -/// -public interface IEmailSender -{ - /// - /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used - /// directly from your code. This API may change or be removed in future releases. - /// - Task SendEmailAsync(string email, string subject, string htmlMessage); -} diff --git a/src/Identity/UI/src/IdentityBuilderUIExtensions.cs b/src/Identity/UI/src/IdentityBuilderUIExtensions.cs index c9379a16f2ce..014936cbbabe 100644 --- a/src/Identity/UI/src/IdentityBuilderUIExtensions.cs +++ b/src/Identity/UI/src/IdentityBuilderUIExtensions.cs @@ -59,7 +59,7 @@ public static IdentityBuilder AddDefaultUI(this IdentityBuilder builder) builder.Services.ConfigureOptions( typeof(IdentityDefaultUIConfigureOptions<>) .MakeGenericType(builder.UserType)); - builder.Services.TryAddTransient(); + builder.Services.TryAddTransient(); return builder; } diff --git a/src/Identity/UI/src/Properties/AssemblyInfo.cs b/src/Identity/UI/src/Properties/AssemblyInfo.cs new file mode 100644 index 000000000000..29b2e1359358 --- /dev/null +++ b/src/Identity/UI/src/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.Identity.UI.Services; + +[assembly: TypeForwardedTo(typeof(IEmailSender))] diff --git a/src/Identity/UI/src/PublicAPI.Unshipped.txt b/src/Identity/UI/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..4a3b9f9670bb 100644 --- a/src/Identity/UI/src/PublicAPI.Unshipped.txt +++ b/src/Identity/UI/src/PublicAPI.Unshipped.txt @@ -1 +1,5 @@ #nullable enable +*REMOVED*Microsoft.AspNetCore.Identity.UI.Services.IEmailSender +*REMOVED*Microsoft.AspNetCore.Identity.UI.Services.IEmailSender.SendEmailAsync(string! email, string! subject, string! htmlMessage) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Identity.UI.Services.IEmailSender (forwarded, contained in Microsoft.Extensions.Identity.Core) +Microsoft.AspNetCore.Identity.UI.Services.IEmailSender.SendEmailAsync(string! email, string! subject, string! htmlMessage) -> System.Threading.Tasks.Task! (forwarded, contained in Microsoft.Extensions.Identity.Core) diff --git a/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs b/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs index 7dc09295f896..74475dda762f 100644 --- a/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs +++ b/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs @@ -8,24 +8,30 @@ using System.Net.Http.Json; using System.Security.Claims; using System.Text.Json; +using System.Text.RegularExpressions; using Identity.DefaultUI.WebSite; using Identity.DefaultUI.WebSite.Data; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.TestHost; using Microsoft.AspNetCore.Testing; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; +using Xunit.Sdk; namespace Microsoft.AspNetCore.Identity.FunctionalTests; public class MapIdentityApiTests : LoggedTest { private string Username { get; } = $"{Guid.NewGuid()}@example.com"; - private string Password { get; } = $"[PLACEHOLDER]-1a"; + private string Password { get; } = "[PLACEHOLDER]-1a"; [Theory] [MemberData(nameof(AddIdentityModes))] @@ -34,10 +40,37 @@ public async Task CanRegisterUser(string addIdentityMode) await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]); using var client = app.GetTestClient(); - var response = await client.PostAsJsonAsync("/identity/register", new { Username, Password }); + AssertOkAndEmpty(await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username })); + } - response.EnsureSuccessStatusCode(); - Assert.Equal(0, response.Content.Headers.ContentLength); + [Fact] + public async Task RegisterFailsGivenNoEmail() + { + await using var app = await CreateAppAsync(); + using var client = app.GetTestClient(); + + AssertBadRequestAndEmpty(await client.PostAsJsonAsync("/identity/register", new { Username, Password })); + } + + [Fact] + public async Task LoginFailsGivenUnregisteredUser() + { + await using var app = await CreateAppAsync(); + using var client = app.GetTestClient(); + + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password }), + "Failed"); + } + + [Fact] + public async Task LoginFailsGivenWrongPassword() + { + await using var app = await CreateAppAsync(); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" }), + "Failed"); } [Theory] @@ -47,7 +80,7 @@ public async Task CanLoginWithBearerToken(string addIdentityMode) await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]); using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password }); + await RegisterAsync(client); var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); loginResponse.EnsureSuccessStatusCode(); @@ -73,17 +106,18 @@ public async Task CanCustomizeBearerTokenExpiration() await using var app = await CreateAppAsync(services => { + services.AddSingleton(clock); + services.AddDbContext((sp, options) => options.UseSqlite(sp.GetRequiredService())); services.AddIdentityCore().AddApiEndpoints().AddEntityFrameworkStores(); - services.AddAuthentication(IdentityConstants.BearerScheme).AddIdentityBearerToken(options => + services.AddAuthentication().AddBearerToken(IdentityConstants.BearerScheme, options => { options.BearerTokenExpiration = expireTimeSpan; - options.TimeProvider = clock; }); }); using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password }); + await RegisterAsync(client); var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); var loginContent = await loginResponse.Content.ReadFromJsonAsync(); @@ -114,32 +148,30 @@ public async Task CanLoginWithCookies() await using var app = await CreateAppAsync(); using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password }); + await RegisterAsync(client); var loginResponse = await client.PostAsJsonAsync("/identity/login?cookieMode=true", new { Username, Password }); - loginResponse.EnsureSuccessStatusCode(); - Assert.Equal(0, loginResponse.Content.Headers.ContentLength); - + AssertOkAndEmpty(loginResponse); Assert.True(loginResponse.Headers.TryGetValues(HeaderNames.SetCookie, out var setCookieHeaders)); var setCookieHeader = Assert.Single(setCookieHeaders); // The compiler does not see Assert.True's DoesNotReturnIfAttribute :( - if (setCookieHeader.Split(';', 2) is not [var cookieHeader, _]) + if (setCookieHeader.Split(';', 2) is not [var cookie, _]) { - throw new Exception("Invalid Set-Cookie header!"); + throw new XunitException("Invalid Set-Cookie header!"); } - client.DefaultRequestHeaders.Add(HeaderNames.Cookie, cookieHeader); + client.DefaultRequestHeaders.Add(HeaderNames.Cookie, cookie); Assert.Equal($"Hello, {Username}!", await client.GetStringAsync("/auth/hello")); } [Fact] public async Task CannotLoginWithCookiesWithOnlyCoreServices() { - await using var app = await CreateAppAsync(AddIdentityEndpointsBearerOnly); + await using var app = await CreateAppAsync(services => AddIdentityApiEndpointsBearerOnly(services)); using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password }); + await RegisterAsync(client); await Assert.ThrowsAsync(() => client.PostAsJsonAsync("/identity/login?cookieMode=true", new { Username, Password })); @@ -150,8 +182,9 @@ public async Task CanReadBearerTokenFromQueryString() { await using var app = await CreateAppAsync(services => { + services.AddDbContext((sp, options) => options.UseSqlite(sp.GetRequiredService())); services.AddIdentityCore().AddApiEndpoints().AddEntityFrameworkStores(); - services.AddAuthentication(IdentityConstants.BearerScheme).AddIdentityBearerToken(options => + services.AddAuthentication().AddBearerToken(IdentityConstants.BearerScheme, options => { options.Events.OnMessageReceived = context => { @@ -163,7 +196,7 @@ public async Task CanReadBearerTokenFromQueryString() using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password }); + await RegisterAsync(client); var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); var loginContent = await loginResponse.Content.ReadFromJsonAsync(); @@ -173,7 +206,7 @@ public async Task CanReadBearerTokenFromQueryString() // The normal header still works client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); - Assert.Equal($"Hello, {Username}!", await client.GetStringAsync($"/auth/hello")); + Assert.Equal($"Hello, {Username}!", await client.GetStringAsync("/auth/hello")); } [Theory] @@ -183,13 +216,13 @@ public async Task Returns401UnauthorizedStatusGivenNoBearerTokenOrCookie(string await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]); using var client = app.GetTestClient(); - AssertUnauthorizedAndEmpty(await client.GetAsync($"/auth/hello")); + AssertUnauthorizedAndEmpty(await client.GetAsync("/auth/hello")); client.DefaultRequestHeaders.Authorization = new("Bearer"); - AssertUnauthorizedAndEmpty(await client.GetAsync($"/auth/hello")); + AssertUnauthorizedAndEmpty(await client.GetAsync("/auth/hello")); client.DefaultRequestHeaders.Authorization = new("Bearer", ""); - AssertUnauthorizedAndEmpty(await client.GetAsync($"/auth/hello")); + AssertUnauthorizedAndEmpty(await client.GetAsync("/auth/hello")); } [Theory] @@ -199,14 +232,14 @@ public async Task CanUseRefreshToken(string addIdentityMode) await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]); using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password }); + await RegisterAsync(client); var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); var loginContent = await loginResponse.Content.ReadFromJsonAsync(); var refreshToken = loginContent.GetProperty("refresh_token").GetString(); var refreshResponse = await client.PostAsJsonAsync("/identity/refresh", new { refreshToken }); var refreshContent = await refreshResponse.Content.ReadFromJsonAsync(); - var accessToken = loginContent.GetProperty("access_token").GetString(); + var accessToken = refreshContent.GetProperty("access_token").GetString(); client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); Assert.Equal($"Hello, {Username}!", await client.GetStringAsync("/auth/hello")); @@ -234,8 +267,9 @@ public async Task CanCustomizeRefreshTokenExpiration() await using var app = await CreateAppAsync(services => { services.AddSingleton(clock); + services.AddDbContext((sp, options) => options.UseSqlite(sp.GetRequiredService())); services.AddIdentityCore().AddApiEndpoints().AddEntityFrameworkStores(); - services.AddAuthentication(IdentityConstants.BearerScheme).AddIdentityBearerToken(options => + services.AddAuthentication().AddBearerToken(IdentityConstants.BearerScheme, options => { options.RefreshTokenExpiration = expireTimeSpan; }); @@ -243,7 +277,7 @@ public async Task CanCustomizeRefreshTokenExpiration() using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password }); + await RegisterAsync(client); var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); var loginContent = await loginResponse.Content.ReadFromJsonAsync(); @@ -259,7 +293,7 @@ public async Task CanCustomizeRefreshTokenExpiration() // Still works one second before expiration. refreshResponse = await client.PostAsJsonAsync("/identity/refresh", new { refreshToken }); Assert.True(refreshResponse.IsSuccessStatusCode); - + // The bearer token stopped working 41 hours ago with the default 1 hour expiration. client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); AssertUnauthorizedAndEmpty(await client.GetAsync("/auth/hello")); @@ -270,7 +304,7 @@ public async Task CanCustomizeRefreshTokenExpiration() AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/refresh", new { refreshToken })); // But the last refresh_token from the successful /refresh only a second ago has not expired. - var refreshContent = await refreshResponse.Content.ReadFromJsonAsync(); + var refreshContent = await refreshResponse.Content.ReadFromJsonAsync(); refreshToken = refreshContent.GetProperty("refresh_token").GetString(); refreshResponse = await client.PostAsJsonAsync("/identity/refresh", new { refreshToken }); @@ -281,17 +315,14 @@ public async Task CanCustomizeRefreshTokenExpiration() Assert.Equal($"Hello, {Username}!", await client.GetStringAsync("/auth/hello")); } - [Theory] - [MemberData(nameof(AddIdentityModes))] - public async Task RefreshReturns401UnauthorizedIfSecurityStampChanges(string addIdentityMode) + [Fact] + public async Task RefreshReturns401UnauthorizedIfSecurityStampChanges() { - await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]); + await using var app = await CreateAppAsync(); using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password }); - var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); - var loginContent = await loginResponse.Content.ReadFromJsonAsync(); - var refreshToken = loginContent.GetProperty("refresh_token").GetString(); + await RegisterAsync(client); + var refreshToken = await LoginAsync(client); var userManager = app.Services.GetRequiredService>(); var user = await userManager.FindByNameAsync(Username); @@ -303,17 +334,14 @@ public async Task RefreshReturns401UnauthorizedIfSecurityStampChanges(string add AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/refresh", new { refreshToken })); } - [Theory] - [MemberData(nameof(AddIdentityModes))] - public async Task RefreshUpdatesUserFromStore(string addIdentityMode) + [Fact] + public async Task RefreshUpdatesUserFromStore() { - await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]); + await using var app = await CreateAppAsync(); using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password }); - var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); - var loginContent = await loginResponse.Content.ReadFromJsonAsync(); - var refreshToken = loginContent.GetProperty("refresh_token").GetString(); + await RegisterAsync(client); + var refreshToken = await LoginAsync(client); var userManager = app.Services.GetRequiredService>(); var user = await userManager.FindByNameAsync(Username); @@ -332,13 +360,808 @@ public async Task RefreshUpdatesUserFromStore(string addIdentityMode) Assert.Equal($"Hello, {newUsername}!", await client.GetStringAsync("/auth/hello")); } - private static void AssertUnauthorizedAndEmpty(HttpResponseMessage response) + [Fact] + public async Task LoginCanBeLockedOut() { - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - Assert.Equal(0, response.Content.Headers.ContentLength); + await using var app = await CreateAppAsync(services => + { + AddIdentityApiEndpoints(services); + services.Configure(options => + { + options.Lockout.MaxFailedAccessAttempts = 2; + }); + }); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" }), + "Failed"); + + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" }), + "LockedOut"); + + Assert.Single(TestSink.Writes, w => + w.LoggerName == "Microsoft.AspNetCore.Identity.SignInManager" && + w.EventId == new EventId(3, "UserLockedOut")); + + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password }), + "LockedOut"); + } + + [Fact] + public async Task LockoutCanBeDisabled() + { + await using var app = await CreateAppAsync(services => + { + AddIdentityApiEndpoints(services); + services.Configure(options => + { + options.Lockout.AllowedForNewUsers = false; + options.Lockout.MaxFailedAccessAttempts = 1; + }); + }); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" }), + "Failed"); + + Assert.DoesNotContain(TestSink.Writes, w => + w.LoggerName == "Microsoft.AspNetCore.Identity.SignInManager" && + w.EventId == new EventId(3, "UserLockedOut")); + + AssertOk(await client.PostAsJsonAsync("/identity/login", new { Username, Password })); + } + + [Fact] + public async Task AccountConfirmationCanBeEnabled() + { + var emailSender = new TestEmailSender(); + + await using var app = await CreateAppAsync(services => + { + AddIdentityApiEndpoints(services); + services.AddSingleton(emailSender); + services.Configure(options => + { + options.SignIn.RequireConfirmedAccount = true; + }); + }); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + await LoginWithEmailConfirmationAsync(client, emailSender); + + Assert.Single(emailSender.Emails); + Assert.Single(TestSink.Writes, w => + w.LoggerName == "Microsoft.AspNetCore.Identity.SignInManager" && + w.EventId == new EventId(4, "UserCannotSignInWithoutConfirmedAccount")); + } + + [Fact] + public async Task EmailConfirmationCanBeEnabled() + { + var emailSender = new TestEmailSender(); + + await using var app = await CreateAppAsync(services => + { + AddIdentityApiEndpoints(services); + services.AddSingleton(emailSender); + services.Configure(options => + { + options.SignIn.RequireConfirmedEmail = true; + }); + }); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + await LoginWithEmailConfirmationAsync(client, emailSender); + + Assert.Single(emailSender.Emails); + Assert.Single(TestSink.Writes, w => + w.LoggerName == "Microsoft.AspNetCore.Identity.SignInManager" && + w.EventId == new EventId(0, "UserCannotSignInWithoutConfirmedEmail")); } - private async Task CreateAppAsync(Action? configureServices) + [Fact] + public async Task EmailConfirmationCanBeResent() + { + var emailSender = new TestEmailSender(); + + await using var app = await CreateAppAsync(services => + { + AddIdentityApiEndpoints(services); + services.AddSingleton(emailSender); + services.Configure(options => + { + options.SignIn.RequireConfirmedEmail = true; + }); + }); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + + var firstEmail = Assert.Single(emailSender.Emails); + Assert.Equal("Confirm your email", firstEmail.Subject); + Assert.Equal(Username, firstEmail.Address); + + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password }), + "NotAllowed"); + + AssertOk(await client.PostAsJsonAsync("/identity/resendConfirmationEmail", new { Email = "wrong" })); + AssertOk(await client.PostAsJsonAsync("/identity/resendConfirmationEmail", new { Email = Username })); + + // Even though both resendConfirmationEmail requests returned a 200, only one for a valid registration was sent + Assert.Equal(2, emailSender.Emails.Count); + var resentEmail = emailSender.Emails[1]; + Assert.Equal("Confirm your email", resentEmail.Subject); + Assert.Equal(Username, resentEmail.Address); + + AssertOk(await client.GetAsync(GetEmailConfirmationLink(resentEmail))); + AssertOk(await client.PostAsJsonAsync("/identity/login", new { Username, Password })); + } + + [Fact] + public async Task CanAddEndpointsToMultipleRouteGroupsForSameUserType() + { + // Test with confirmation email since that tests link generation capabilities + var emailSender = new TestEmailSender(); + + await using var app = await CreateAppAsync(services => + { + AddIdentityApiEndpoints(services); + services.AddSingleton(emailSender); + services.Configure(options => + { + options.SignIn.RequireConfirmedAccount = true; + }); + }, autoStart: false); + + app.MapGroup("/identity2").MapIdentityApi(); + + await app.StartAsync(); + using var client = app.GetTestClient(); + + // We have to use different user names to register twice since they use the same store. + await RegisterAsync(client, "/identity", username: "a"); + await LoginWithEmailConfirmationAsync(client, emailSender, "/identity", username: "a"); + + await RegisterAsync(client, "/identity2", username: "b"); + await LoginWithEmailConfirmationAsync(client, emailSender, "/identity2", username: "b"); + } + + [Fact] + public async Task CanAddEndpointsToMultipleRouteGroupsForMultipleUsersTypes() + { + // Test with confirmation email since that tests link generation capabilities + var emailSender = new TestEmailSender(); + + // Even with OnModelCreating tricks to prefix table names, using the same database + // for multiple user tables is difficult because index conflics, so we just use a different db. + var dbConnection2 = new SqliteConnection("DataSource=:memory:"); + + await using var app = await CreateAppAsync(services => + { + AddIdentityApiEndpoints(services); + + // We just added cookie and/or bearer auth scheme(s) above. We cannot re-add these without an error. + services + .AddDbContext((sp, options) => options.UseSqlite(dbConnection2)) + .AddIdentityCore() + .AddEntityFrameworkStores() + .AddApiEndpoints(); + + services.AddSingleton(_ => dbConnection2); + + services.AddSingleton(emailSender); + services.Configure(options => + { + options.SignIn.RequireConfirmedAccount = true; + }); + }, autoStart: false); + + // The following two lines are already taken care of by CreateAppAsync for ApplicationUser and ApplicationDbContext + await dbConnection2.OpenAsync(); + await app.Services.GetRequiredService().Database.EnsureCreatedAsync(); + + app.MapGroup("/identity2").MapIdentityApi(); + + await app.StartAsync(); + using var client = app.GetTestClient(); + + // We can use the same username twice since we're using two distinct DbContexts. + await RegisterAsync(client, "/identity"); + await LoginWithEmailConfirmationAsync(client, emailSender, "/identity"); + + await RegisterAsync(client, "/identity2"); + await LoginWithEmailConfirmationAsync(client, emailSender, "/identity2"); + } + + [Theory] + [MemberData(nameof(AddIdentityModes))] + public async Task CanEnableAndLoginWithTwoFactor(string addIdentityMode) + { + await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); + + var loginContent = await loginResponse.Content.ReadFromJsonAsync(); + var accessToken = loginContent.GetProperty("access_token").GetString(); + var refreshToken = loginContent.GetProperty("refresh_token").GetString(); + + AssertUnauthorizedAndEmpty(await client.GetAsync("/identity/account/2fa")); + + client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + + // We cannot enable 2fa without verifying we can produce a valid token. + await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/account/2fa", new { Enable = true }), + "RequiresTwoFactor"); + await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/account/2fa", new { Enable = true, TwoFactorCode = "wrong" }), + "InvalidTwoFactorCode"); + + var twoFactorKeyResponse = await client.GetFromJsonAsync("/identity/account/2fa"); + Assert.False(twoFactorKeyResponse.GetProperty("isTwoFactorEnabled").GetBoolean()); + Assert.False(twoFactorKeyResponse.GetProperty("isMachineRemembered").GetBoolean()); + + var sharedKey = twoFactorKeyResponse.GetProperty("sharedKey").GetString(); + + var keyBytes = Base32.FromBase32(sharedKey); + var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var timestep = Convert.ToInt64(unixTimestamp / 30); + var twoFactorCode = Rfc6238AuthenticationService.ComputeTotp(keyBytes, (ulong)timestep, modifierBytes: null).ToString(); + + var enable2faResponse = await client.PostAsJsonAsync("/identity/account/2fa", new { twoFactorCode, Enable = true }); + var enable2faContent = await enable2faResponse.Content.ReadFromJsonAsync(); + Assert.True(enable2faContent.GetProperty("isTwoFactorEnabled").GetBoolean()); + Assert.False(enable2faContent.GetProperty("isMachineRemembered").GetBoolean()); + + // We can still access auth'd endpoints with old access token. + Assert.Equal($"Hello, {Username}!", await client.GetStringAsync("/auth/hello")); + + // But the refresh token is invalidated by the security stamp. + AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/refresh", new { refreshToken })); + + client.DefaultRequestHeaders.Clear(); + + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password }), + "RequiresTwoFactor"); + + AssertOk(await client.PostAsJsonAsync("/identity/login", new { Username, Password, twoFactorCode })); + } + + [Fact] + public async Task CanLoginWithRecoveryCodeAndDisableTwoFactor() + { + await using var app = await CreateAppAsync(); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); + + var loginContent = await loginResponse.Content.ReadFromJsonAsync(); + var accessToken = loginContent.GetProperty("access_token").GetString(); + client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + + var twoFactorKeyResponse = await client.GetFromJsonAsync("/identity/account/2fa"); + var sharedKey = twoFactorKeyResponse.GetProperty("sharedKey").GetString(); + + var keyBytes = Base32.FromBase32(sharedKey); + var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var timestep = Convert.ToInt64(unixTimestamp / 30); + var twoFactorCode = Rfc6238AuthenticationService.ComputeTotp(keyBytes, (ulong)timestep, modifierBytes: null).ToString(); + + var enable2faResponse = await client.PostAsJsonAsync("/identity/account/2fa", new { twoFactorCode, Enable = true }); + var enable2faContent = await enable2faResponse.Content.ReadFromJsonAsync(); + Assert.True(enable2faContent.GetProperty("isTwoFactorEnabled").GetBoolean()); + + var recoveryCodes = enable2faContent.GetProperty("recoveryCodes").EnumerateArray().Select(e => e.GetString()).ToArray(); + Assert.Equal(10, recoveryCodes.Length); + + client.DefaultRequestHeaders.Clear(); + + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password }), + "RequiresTwoFactor"); + + var recoveryLoginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password, TwoFactorRecoveryCode = recoveryCodes[0] }); + + var recoveryLoginContent = await recoveryLoginResponse.Content.ReadFromJsonAsync(); + var recoveryAccessToken = recoveryLoginContent.GetProperty("access_token").GetString(); + Assert.NotEqual(accessToken, recoveryAccessToken); + + client.DefaultRequestHeaders.Authorization = new("Bearer", recoveryAccessToken); + + var disable2faResponse = await client.PostAsJsonAsync("/identity/account/2fa", new { Enable = false }); + var disable2faContent = await disable2faResponse.Content.ReadFromJsonAsync(); + Assert.False(disable2faContent.GetProperty("isTwoFactorEnabled").GetBoolean()); + + client.DefaultRequestHeaders.Clear(); + + AssertOk(await client.PostAsJsonAsync("/identity/login", new { Username, Password })); + } + + [Fact] + public async Task CanResetSharedKey() + { + await using var app = await CreateAppAsync(); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); + + var loginContent = await loginResponse.Content.ReadFromJsonAsync(); + var accessToken = loginContent.GetProperty("access_token").GetString(); + client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + + var twoFactorKeyResponse = await client.GetFromJsonAsync("/identity/account/2fa"); + var sharedKey = twoFactorKeyResponse.GetProperty("sharedKey").GetString(); + + var keyBytes = Base32.FromBase32(sharedKey); + var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var timestep = Convert.ToInt64(unixTimestamp / 30); + var twoFactorCode = Rfc6238AuthenticationService.ComputeTotp(keyBytes, (ulong)timestep, modifierBytes: null).ToString(); + + await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/account/2fa", new { twoFactorCode, Enable = true, ResetSharedKey = true }), + "CannotResetSharedKeyAndEnable"); + + var enable2faResponse = await client.PostAsJsonAsync("/identity/account/2fa", new { twoFactorCode, Enable = true }); + var enable2faContent = await enable2faResponse.Content.ReadFromJsonAsync(); + Assert.True(enable2faContent.GetProperty("isTwoFactorEnabled").GetBoolean()); + + var resetKeyResponse = await client.PostAsJsonAsync("/identity/account/2fa", new { ResetSharedKey = true }); + var resetKeyContent = await resetKeyResponse.Content.ReadFromJsonAsync(); + Assert.False(resetKeyContent.GetProperty("isTwoFactorEnabled").GetBoolean()); + + var resetSharedKey = resetKeyContent.GetProperty("sharedKey").GetString(); + + var resetKeyBytes = Base32.FromBase32(sharedKey); + var resetTwoFactorCode = Rfc6238AuthenticationService.ComputeTotp(keyBytes, (ulong)timestep, modifierBytes: null).ToString(); + + // The old 2fa code no longer works + await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/account/2fa", new { twoFactorCode, Enable = true }), + "InvalidTwoFactorCode"); + + var reenable2faResponse = await client.PostAsJsonAsync("/identity/account/2fa", new { TwoFactorCode = resetTwoFactorCode, Enable = true }); + var reenable2faContent = await reenable2faResponse.Content.ReadFromJsonAsync(); + Assert.True(enable2faContent.GetProperty("isTwoFactorEnabled").GetBoolean()); + } + + [Fact] + public async Task CanResetRecoveryCodes() + { + await using var app = await CreateAppAsync(); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); + + var loginContent = await loginResponse.Content.ReadFromJsonAsync(); + var accessToken = loginContent.GetProperty("access_token").GetString(); + client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + + var twoFactorKeyResponse = await client.GetFromJsonAsync("/identity/account/2fa"); + var sharedKey = twoFactorKeyResponse.GetProperty("sharedKey").GetString(); + + var keyBytes = Base32.FromBase32(sharedKey); + var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var timestep = Convert.ToInt64(unixTimestamp / 30); + var twoFactorCode = Rfc6238AuthenticationService.ComputeTotp(keyBytes, (ulong)timestep, modifierBytes: null).ToString(); + + var enable2faResponse = await client.PostAsJsonAsync("/identity/account/2fa", new { twoFactorCode, Enable = true }); + var enable2faContent = await enable2faResponse.Content.ReadFromJsonAsync(); + var recoveryCodes = enable2faContent.GetProperty("recoveryCodes").EnumerateArray().Select(e => e.GetString()).ToArray(); + Assert.Equal(10, enable2faContent.GetProperty("recoveryCodesLeft").GetInt32()); + Assert.Equal(10, recoveryCodes.Length); + + client.DefaultRequestHeaders.Clear(); + + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password }), + "RequiresTwoFactor"); + + AssertOk(await client.PostAsJsonAsync("/identity/login", new { Username, Password, TwoFactorRecoveryCode = recoveryCodes[0] })); + // Cannot reuse codes + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password, TwoFactorRecoveryCode = recoveryCodes[0] }), + "Failed"); + + var recoveryLoginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password, TwoFactorRecoveryCode = recoveryCodes[1] }); + var recoveryLoginContent = await recoveryLoginResponse.Content.ReadFromJsonAsync(); + var recoveryAccessToken = recoveryLoginContent.GetProperty("access_token").GetString(); + Assert.NotEqual(accessToken, recoveryAccessToken); + + client.DefaultRequestHeaders.Authorization = new("Bearer", recoveryAccessToken); + + var updated2faContent = await client.GetFromJsonAsync("/identity/account/2fa"); + Assert.Equal(8, updated2faContent.GetProperty("recoveryCodesLeft").GetInt32()); + Assert.Null(updated2faContent.GetProperty("recoveryCodes").GetString()); + + await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/account/2fa", new { twoFactorCode, Enable = true, ResetSharedKey = true }), + "CannotResetSharedKeyAndEnable"); + + var resetRecoveryResponse = await client.PostAsJsonAsync("/identity/account/2fa", new { ResetRecoveryCodes = true }); + var resetRecoveryContent = await resetRecoveryResponse.Content.ReadFromJsonAsync(); + var resetRecoveryCodes = resetRecoveryContent.GetProperty("recoveryCodes").EnumerateArray().Select(e => e.GetString()).ToArray(); + Assert.Equal(10, resetRecoveryContent.GetProperty("recoveryCodesLeft").GetInt32()); + Assert.Equal(10, resetRecoveryCodes.Length); + Assert.Empty(recoveryCodes.Intersect(resetRecoveryCodes)); + + client.DefaultRequestHeaders.Clear(); + + AssertOk(await client.PostAsJsonAsync("/identity/login", new { Username, Password, TwoFactorRecoveryCode = resetRecoveryCodes[0] })); + + // Even unused codes from before the reset now fail. + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password, TwoFactorRecoveryCode = recoveryCodes[2] }), + "Failed"); + } + + [Fact] + public async Task CanUsePersistentTwoFactorCookies() + { + await using var app = await CreateAppAsync(); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + var loginResponse = await client.PostAsJsonAsync("/identity/login?cookieMode=true", new { Username, Password }); + ApplyCookies(client, loginResponse); + + var twoFactorKeyResponse = await client.GetFromJsonAsync("/identity/account/2fa"); + Assert.False(twoFactorKeyResponse.GetProperty("isTwoFactorEnabled").GetBoolean()); + Assert.False(twoFactorKeyResponse.GetProperty("isMachineRemembered").GetBoolean()); + + var sharedKey = twoFactorKeyResponse.GetProperty("sharedKey").GetString(); + + var keyBytes = Base32.FromBase32(sharedKey); + var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var timestep = Convert.ToInt64(unixTimestamp / 30); + var twoFactorCode = Rfc6238AuthenticationService.ComputeTotp(keyBytes, (ulong)timestep, modifierBytes: null).ToString(); + + var enable2faResponse = await client.PostAsJsonAsync("/identity/account/2fa", new { twoFactorCode, Enable = true }); + var enable2faContent = await enable2faResponse.Content.ReadFromJsonAsync(); + Assert.True(enable2faContent.GetProperty("isTwoFactorEnabled").GetBoolean()); + Assert.False(enable2faContent.GetProperty("isMachineRemembered").GetBoolean()); + + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password }), + "RequiresTwoFactor"); + + var twoFactorLoginResponse = await client.PostAsJsonAsync("/identity/login?cookieMode=true&persistCookies=false", new { Username, Password, twoFactorCode }); + ApplyCookies(client, twoFactorLoginResponse); + + var cookie2faResponse = await client.GetFromJsonAsync("/identity/account/2fa"); + Assert.True(cookie2faResponse.GetProperty("isTwoFactorEnabled").GetBoolean()); + Assert.False(cookie2faResponse.GetProperty("isMachineRemembered").GetBoolean()); + + var persistentLoginResponse = await client.PostAsJsonAsync("/identity/login?cookieMode=true", new { Username, Password, twoFactorCode }); + ApplyCookies(client, persistentLoginResponse); + + var persistent2faResponse = await client.GetFromJsonAsync("/identity/account/2fa"); + Assert.True(persistent2faResponse.GetProperty("isTwoFactorEnabled").GetBoolean()); + Assert.True(persistent2faResponse.GetProperty("isMachineRemembered").GetBoolean()); + } + + [Fact] + public async Task CanResetPassword() + { + var emailSender = new TestEmailSender(); + + await using var app = await CreateAppAsync(services => + { + AddIdentityApiEndpoints(services); + services.AddSingleton(emailSender); + services.Configure(options => + { + options.SignIn.RequireConfirmedAccount = true; + }); + }); + using var client = app.GetTestClient(); + + var confirmedUsername = "confirmed"; + var confirmedEmail = "confirmed@example.com"; + + var unconfirmedUsername = "unconfirmed"; + var unconfirmedEmail = "unconfirmed@example.com"; + + await RegisterAsync(client, username: confirmedUsername, email: confirmedEmail); + await LoginWithEmailConfirmationAsync(client, emailSender, username: confirmedUsername, email: confirmedEmail); + + await RegisterAsync(client, username: unconfirmedUsername, email: unconfirmedEmail); + + // Two emails were sent, but only one was confirmed + Assert.Equal(2, emailSender.Emails.Count); + + // Returns 200 status for invalid email addresses + AssertOkAndEmpty(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = confirmedEmail })); + AssertOkAndEmpty(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = unconfirmedEmail })); + AssertOkAndEmpty(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = "wrong" })); + + // But only one email was sent for the confirmed address + Assert.Equal(3, emailSender.Emails.Count); + var resetEmail = emailSender.Emails[2]; + + Assert.Equal("Reset your password", resetEmail.Subject); + Assert.Equal(confirmedEmail, resetEmail.Address); + + var resetCode = GetPasswordResetCode(resetEmail); + var newPassword = $"{Password}!"; + + // The same validation errors are returned even for invalid emails + await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = confirmedEmail, resetCode }), + "MissingNewPassword"); + await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = unconfirmedEmail, resetCode }), + "MissingNewPassword"); + await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = "wrong", resetCode }), + "MissingNewPassword"); + + await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = confirmedEmail, ResetCode = "wrong", newPassword }), + "InvalidToken"); + await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = unconfirmedEmail, ResetCode = "wrong", newPassword }), + "InvalidToken"); + await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = "wrong", ResetCode = "wrong", newPassword }), + "InvalidToken"); + + AssertOkAndEmpty(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = confirmedEmail, resetCode, newPassword })); + + // The old password is no longer valid + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username = confirmedUsername, Password }), + "Failed"); + + // But the new password is + AssertOk(await client.PostAsJsonAsync("/identity/login", new { Username = confirmedUsername, Password = newPassword })); + } + + [Fact] + public async Task CanGetClaims() + { + await using var app = await CreateAppAsync(); + using var client = app.GetTestClient(); + + var username = $"UsernamePrefix-{Username}"; + var email = $"EmailPrefix-{Username}"; + + await RegisterAsync(client, username: username, email: email); + await LoginAsync(client, username: username, email: email); + + var infoResponse = await client.GetFromJsonAsync("/identity/account/info"); + Assert.Equal(username, infoResponse.GetProperty("username").GetString()); + Assert.Equal(email, infoResponse.GetProperty("email").GetString()); + + var claims = infoResponse.GetProperty("claims"); + Assert.Equal(username, claims.GetProperty(ClaimTypes.Name).GetString()); + Assert.Equal(email, claims.GetProperty(ClaimTypes.Email).GetString()); + Assert.Equal("pwd", claims.GetProperty("amr").GetString()); + Assert.NotNull(claims.GetProperty(ClaimTypes.NameIdentifier).GetString()); + } + + [Theory] + [MemberData(nameof(AddIdentityModes))] + public async Task CanChangeEmail(string addIdentityModes) + { + var emailSender = new TestEmailSender(); + + await using var app = await CreateAppAsync(services => + { + AddIdentityActions[addIdentityModes](services); + services.AddSingleton(emailSender); + services.Configure(options => + { + options.SignIn.RequireConfirmedAccount = true; + }); + }); + using var client = app.GetTestClient(); + + AssertUnauthorizedAndEmpty(await client.GetAsync("/identity/account/info")); + + await RegisterAsync(client); + var originalRefreshToken = await LoginWithEmailConfirmationAsync(client, emailSender); + + var infoResponse = await client.GetFromJsonAsync("/identity/account/info"); + Assert.Equal(Username, infoResponse.GetProperty("username").GetString()); + Assert.Equal(Username, infoResponse.GetProperty("email").GetString()); + var infoClaims = infoResponse.GetProperty("claims"); + Assert.Equal("pwd", infoClaims.GetProperty("amr").GetString()); + Assert.Equal(Username, infoClaims.GetProperty(ClaimTypes.Name).GetString()); + Assert.Equal(Username, infoClaims.GetProperty(ClaimTypes.Email).GetString()); + + var originalNameIdentifier = infoResponse.GetProperty("claims").GetProperty(ClaimTypes.NameIdentifier).GetString(); + var newUsername = $"NewUsernamePrefix-{Username}"; + var newEmail = $"NewEmailPrefix-{Username}"; + + var infoPostResponse = await client.PostAsJsonAsync("/identity/account/info", new { newUsername, newEmail }); + var infoPostContent = await infoPostResponse.Content.ReadFromJsonAsync(); + Assert.Equal(newUsername, infoPostContent.GetProperty("username").GetString()); + // The email isn't updated until the email is confirmed. + Assert.Equal(Username, infoPostContent.GetProperty("email").GetString()); + + // And none of the claims have yet been updated. + var infoPostClaims = infoPostContent.GetProperty("claims"); + Assert.Equal(Username, infoPostClaims.GetProperty(ClaimTypes.Name).GetString()); + Assert.Equal(Username, infoPostClaims.GetProperty(ClaimTypes.Email).GetString()); + Assert.Equal(originalNameIdentifier, infoClaims.GetProperty(ClaimTypes.NameIdentifier).GetString()); + + // The refresh token is now invalidated by the security stamp. + AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/refresh", new { RefreshToken = originalRefreshToken })); + + // But we can immediately log in with the new username. + var secondRefreshToken = await LoginAsync(client, username: newUsername); + + // Which gives us a new refresh token that is valid for now. + AssertOk(await client.PostAsJsonAsync("/identity/refresh", new { RefreshToken = secondRefreshToken })); + + // Two emails have now been sent. The first was sent during registration. And the second for the email change. + Assert.Equal(2, emailSender.Emails.Count); + var email = emailSender.Emails[1]; + + Assert.Equal("Confirm your email", email.Subject); + Assert.Equal(newEmail, email.Address); + + AssertOk(await client.GetAsync(GetEmailConfirmationLink(email))); + + var infoAfterEmailChange = await client.GetFromJsonAsync("/identity/account/info"); + Assert.Equal(newUsername, infoAfterEmailChange.GetProperty("username").GetString()); + // The email is immediately updated after the email is confirmed. + Assert.Equal(newEmail, infoAfterEmailChange.GetProperty("email").GetString()); + + // The username claim is updated from the second login, but the email still won't be available as a claim until we get a new token. + var claimsAfterEmailChange = infoAfterEmailChange.GetProperty("claims"); + Assert.Equal(newUsername, claimsAfterEmailChange.GetProperty(ClaimTypes.Name).GetString()); + Assert.Equal(Username, claimsAfterEmailChange.GetProperty(ClaimTypes.Email).GetString()); + Assert.Equal(originalNameIdentifier, infoClaims.GetProperty(ClaimTypes.NameIdentifier).GetString()); + + // And now the email has changed, the refresh token is once again invalidated by the security stamp. + AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/refresh", new { RefreshToken = secondRefreshToken })); + + // We will finally see all the claims updated after logging in again. + await LoginAsync(client, username: newUsername); + + var infoAfterFinalLogin = await client.GetFromJsonAsync("/identity/account/info"); + Assert.Equal(newUsername, infoAfterFinalLogin.GetProperty("username").GetString()); + Assert.Equal(newEmail, infoAfterFinalLogin.GetProperty("email").GetString()); + + var claimsAfterFinalLogin = infoAfterFinalLogin.GetProperty("claims"); + Assert.Equal(newUsername, claimsAfterFinalLogin.GetProperty(ClaimTypes.Name).GetString()); + Assert.Equal(newEmail, claimsAfterFinalLogin.GetProperty(ClaimTypes.Email).GetString()); + Assert.Equal(originalNameIdentifier, infoClaims.GetProperty(ClaimTypes.NameIdentifier).GetString()); + } + + [Fact] + public async Task CanUpdateClaimsDuringInfoPostWithCookies() + { + var emailSender = new TestEmailSender(); + + await using var app = await CreateAppAsync(services => + { + AddIdentityApiEndpoints(services); + services.AddSingleton(emailSender); + services.Configure(options => + { + options.SignIn.RequireConfirmedAccount = true; + }); + }); + using var client = app.GetTestClient(); + + AssertUnauthorizedAndEmpty(await client.GetAsync("/identity/account/info")); + + await RegisterAsync(client); + await LoginWithEmailConfirmationAsync(client, emailSender); + + // Clear bearer token. We just used the common login email for convenient email verification. + client.DefaultRequestHeaders.Clear(); + var loginResponse = await client.PostAsJsonAsync("/identity/login?cookieMode=true", new { Username, Password }); + ApplyCookies(client, loginResponse); + + var infoResponse = await client.GetFromJsonAsync("/identity/account/info"); + Assert.Equal(Username, infoResponse.GetProperty("username").GetString()); + Assert.Equal(Username, infoResponse.GetProperty("email").GetString()); + var infoClaims = infoResponse.GetProperty("claims"); + Assert.Equal("pwd", infoClaims.GetProperty("amr").GetString()); + Assert.Equal(Username, infoClaims.GetProperty(ClaimTypes.Name).GetString()); + Assert.Equal(Username, infoClaims.GetProperty(ClaimTypes.Email).GetString()); + + var originalNameIdentifier = infoResponse.GetProperty("claims").GetProperty(ClaimTypes.NameIdentifier).GetString(); + var newUsername = $"NewUsernamePrefix-{Username}"; + var newEmail = $"NewEmailPrefix-{Username}"; + + var infoPostResponse = await client.PostAsJsonAsync("/identity/account/info", new { newUsername, newEmail }); + ApplyCookies(client, infoPostResponse); + + var infoPostContent = await infoPostResponse.Content.ReadFromJsonAsync(); + Assert.Equal(newUsername, infoPostContent.GetProperty("username").GetString()); + // The email isn't updated until the email is confirmed. + Assert.Equal(Username, infoPostContent.GetProperty("email").GetString()); + + // The claims have been updated to match. + var infoPostClaims = infoPostContent.GetProperty("claims"); + Assert.Equal(newUsername, infoPostClaims.GetProperty(ClaimTypes.Name).GetString()); + Assert.Equal(Username, infoPostClaims.GetProperty(ClaimTypes.Email).GetString()); + Assert.Equal(originalNameIdentifier, infoClaims.GetProperty(ClaimTypes.NameIdentifier).GetString()); + + // Two emails have now been sent. The first was sent during registration. And the second for the email change. + Assert.Equal(2, emailSender.Emails.Count); + var email = emailSender.Emails[1]; + + Assert.Equal("Confirm your email", email.Subject); + Assert.Equal(newEmail, email.Address); + + AssertOk(await client.GetAsync(GetEmailConfirmationLink(email))); + + var infoAfterEmailChange = await client.GetFromJsonAsync("/identity/account/info"); + Assert.Equal(newUsername, infoAfterEmailChange.GetProperty("username").GetString()); + // The email is immediately updated after the email is confirmed. + Assert.Equal(newEmail, infoAfterEmailChange.GetProperty("email").GetString()); + + // The username claim is updated from the /account/info post, but the email still won't be available as a claim until we get a new cookie. + var claimsAfterEmailChange = infoAfterEmailChange.GetProperty("claims"); + Assert.Equal(newUsername, claimsAfterEmailChange.GetProperty(ClaimTypes.Name).GetString()); + Assert.Equal(Username, claimsAfterEmailChange.GetProperty(ClaimTypes.Email).GetString()); + Assert.Equal(originalNameIdentifier, infoClaims.GetProperty(ClaimTypes.NameIdentifier).GetString()); + + // We will finally see all the claims updated after logging in again. + var secondLoginResponse = await client.PostAsJsonAsync("/identity/login?cookieMode=true", new { Username = newUsername, Password }); + ApplyCookies(client, secondLoginResponse); + + var infoAfterFinalLogin = await client.GetFromJsonAsync("/identity/account/info"); + Assert.Equal(newUsername, infoAfterFinalLogin.GetProperty("username").GetString()); + Assert.Equal(newEmail, infoAfterFinalLogin.GetProperty("email").GetString()); + + var claimsAfterFinalLogin = infoAfterFinalLogin.GetProperty("claims"); + Assert.Equal(newUsername, claimsAfterFinalLogin.GetProperty(ClaimTypes.Name).GetString()); + Assert.Equal(newEmail, claimsAfterFinalLogin.GetProperty(ClaimTypes.Email).GetString()); + Assert.Equal(originalNameIdentifier, infoClaims.GetProperty(ClaimTypes.NameIdentifier).GetString()); + } + + [Fact] + public async Task CanChangePasswordWithoutResetEmail() + { + await using var app = await CreateAppAsync(); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + await LoginAsync(client); + + var newPassword = $"{Password}!"; + + await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/account/info", new { newPassword }), + "OldPasswordRequired"); + AssertOk(await client.PostAsJsonAsync("/identity/account/info", new { OldPassword = Password, newPassword })); + + client.DefaultRequestHeaders.Clear(); + + // We can immediately log in with the new password + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password }), + "Failed"); + AssertOk(await client.PostAsJsonAsync("/identity/login", new { Username, Password = newPassword })); + } + + [Fact] + public async Task CanReportMultipleInfoUpdateErrorsAtOnce() + { + await using var app = await CreateAppAsync(); + using var client = app.GetTestClient(); + + await RegisterAsync(client); + // Register a second user that conflicts with our first NewUsername + await RegisterAsync(client, username: "taken"); + + await LoginAsync(client); + + var newPassword = $"{Password}!"; + var multipleProblemResponse = await client.PostAsJsonAsync("/identity/account/info", new { newPassword, NewUsername = "taken" }); + + Assert.Equal(HttpStatusCode.BadRequest, multipleProblemResponse.StatusCode); + var problemDetails = await multipleProblemResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(problemDetails); + + Assert.Equal(2, problemDetails.Errors.Count); + Assert.Contains("OldPasswordRequired", problemDetails.Errors.Keys); + Assert.Contains("DuplicateUserName", problemDetails.Errors.Keys); + + // We can in fact update multiple things at once if we do it correctly though. + AssertOk(await client.PostAsJsonAsync("/identity/account/info", new { OldPassword = Password, newPassword, NewUsername = "not-taken" })); + AssertOk(await client.PostAsJsonAsync("/identity/login", new { Username = "not-taken", Password = newPassword })); + } + + private async Task CreateAppAsync(Action? configureServices, bool autoStart = true) where TUser : class, new() where TContext : DbContext { @@ -347,12 +1170,11 @@ private async Task CreateAppAsync(Action(options => options.UseSqlite(dbConnection)); + var dbConnection = new SqliteConnection("DataSource=:memory:"); // Dispose SqliteConnection with host by registering as a singleton factory. - builder.Services.AddSingleton(() => dbConnection); + builder.Services.AddSingleton(_ => dbConnection); - configureServices ??= AddIdentityEndpoints; + configureServices ??= services => AddIdentityApiEndpoints(services); configureServices(builder.Services); var app = builder.Build(); @@ -368,23 +1190,37 @@ private async Task CreateAppAsync(Action().Database.EnsureCreatedAsync(); - await app.StartAsync(); + + if (autoStart) + { + await app.StartAsync(); + } return app; } - private static void AddIdentityEndpoints(IServiceCollection services) - => services.AddIdentityApiEndpoints().AddEntityFrameworkStores(); + private static IdentityBuilder AddIdentityApiEndpoints(IServiceCollection services) + where TUser : class, new() + where TContext : DbContext + { + return services.AddDbContext((sp, options) => options.UseSqlite(sp.GetRequiredService())) + .AddIdentityApiEndpoints().AddEntityFrameworkStores(); + } + + private static IdentityBuilder AddIdentityApiEndpoints(IServiceCollection services) + => AddIdentityApiEndpoints(services); - private static void AddIdentityEndpointsBearerOnly(IServiceCollection services) + private static IdentityBuilder AddIdentityApiEndpointsBearerOnly(IServiceCollection services) { services + .AddAuthentication() + .AddBearerToken(IdentityConstants.BearerScheme); + + return services + .AddDbContext((sp, options) => options.UseSqlite(sp.GetRequiredService())) .AddIdentityCore() .AddEntityFrameworkStores() .AddApiEndpoints(); - services - .AddAuthentication(IdentityConstants.BearerScheme) - .AddIdentityBearerToken(); } private Task CreateAppAsync(Action? configureServices = null) @@ -392,9 +1228,173 @@ private Task CreateAppAsync(Action? configur private static Dictionary> AddIdentityActions { get; } = new() { - [nameof(AddIdentityEndpoints)] = AddIdentityEndpoints, - [nameof(AddIdentityEndpointsBearerOnly)] = AddIdentityEndpointsBearerOnly, + [nameof(AddIdentityApiEndpoints)] = services => AddIdentityApiEndpoints(services), + [nameof(AddIdentityApiEndpointsBearerOnly)] = services => AddIdentityApiEndpointsBearerOnly(services), }; public static object[][] AddIdentityModes => AddIdentityActions.Keys.Select(key => new object[] { key }).ToArray(); + + private static string GetEmailConfirmationLink(Email email) + { + // Update if we add more links to the email. + var confirmationMatch = Regex.Match(email.HtmlMessage, "href='(.*?)'"); + Assert.True(confirmationMatch.Success); + Assert.Equal(2, confirmationMatch.Groups.Count); + + return WebUtility.HtmlDecode(confirmationMatch.Groups[1].Value); + } + + private static string GetPasswordResetCode(Email email) + { + // Update if we add more links to the email. + var confirmationMatch = Regex.Match(email.HtmlMessage, "code: (.*?)$"); + Assert.True(confirmationMatch.Success); + Assert.Equal(2, confirmationMatch.Groups.Count); + + return WebUtility.HtmlDecode(confirmationMatch.Groups[1].Value); + } + + private async Task RegisterAsync(HttpClient client, string? groupPrefix = null, string? username = null, string? email = null) + { + groupPrefix ??= "/identity"; + username ??= Username; + email ??= Username; + + AssertOkAndEmpty(await client.PostAsJsonAsync($"{groupPrefix}/register", new { username, Password, email })); + } + + private async Task LoginAsync(HttpClient client, string? groupPrefix = null, string? username = null, string? email = null) + { + groupPrefix ??= "/identity"; + username ??= Username; + email ??= Username; + + await client.PostAsJsonAsync($"{groupPrefix}/login", new { username, Password, email }); + var loginResponse = await client.PostAsJsonAsync("/identity/login", new { username, Password }); + var loginContent = await loginResponse.Content.ReadFromJsonAsync(); + var accessToken = loginContent.GetProperty("access_token").GetString(); + var refreshToken = loginContent.GetProperty("refresh_token").GetString(); + Assert.NotNull(accessToken); + Assert.NotNull(refreshToken); + client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + + return refreshToken; + } + + private async Task LoginWithEmailConfirmationAsync(HttpClient client, TestEmailSender emailSender, string? groupPrefix = null, string? username = null, string? email = null) + { + groupPrefix ??= "/identity"; + username ??= Username; + email ??= Username; + + var receivedEmail = emailSender.Emails.Last(); + + Assert.Equal("Confirm your email", receivedEmail.Subject); + Assert.Equal(email, receivedEmail.Address); + + await AssertProblemAsync(await client.PostAsJsonAsync($"{groupPrefix}/login", new { username, Password }), + "NotAllowed"); + + AssertOk(await client.GetAsync(GetEmailConfirmationLink(receivedEmail))); + + return await LoginAsync(client, groupPrefix, username, email); + } + + private static void AssertOk(HttpResponseMessage response) + { + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + private static void AssertOkAndEmpty(HttpResponseMessage response) + { + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(0, response.Content.Headers.ContentLength); + } + + private static void AssertBadRequestAndEmpty(HttpResponseMessage response) + { + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(0, response.Content.Headers.ContentLength); + } + + private static void AssertUnauthorizedAndEmpty(HttpResponseMessage response) + { + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(0, response.Content.Headers.ContentLength); + } + + private static async Task AssertProblemAsync(HttpResponseMessage response, string detail, HttpStatusCode status = HttpStatusCode.Unauthorized) + { + Assert.Equal(status, response.StatusCode); + var problem = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(problem); + Assert.Equal(ReasonPhrases.GetReasonPhrase((int)status), problem.Title); + Assert.Equal(detail, problem.Detail); + } + + private static async Task AssertValidationProblemAsync(HttpResponseMessage response, string error) + { + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var problem = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(problem); + var errorEntry = Assert.Single(problem.Errors); + Assert.Equal(error, errorEntry.Key); + } + + private static void ApplyCookies(HttpClient client, HttpResponseMessage response) + { + AssertOk(response); + + Assert.True(response.Headers.TryGetValues(HeaderNames.SetCookie, out var setCookieHeaders)); + foreach (var setCookieHeader in setCookieHeaders) + { + if (setCookieHeader.Split(';', 2) is not [var cookie, _]) + { + throw new XunitException("Invalid Set-Cookie header!"); + } + + // Cookies starting with "CookieName=;" are being deleted + if (!cookie.EndsWith("=", StringComparison.Ordinal)) + { + client.DefaultRequestHeaders.Add(HeaderNames.Cookie, cookie); + } + } + } + + private sealed class TestTokenProvider : IUserTwoFactorTokenProvider + where TUser : class + { + public async Task GenerateAsync(string purpose, UserManager manager, TUser user) + { + return MakeToken(purpose, await manager.GetUserIdAsync(user)); + } + + public async Task ValidateAsync(string purpose, string token, UserManager manager, TUser user) + { + return token == MakeToken(purpose, await manager.GetUserIdAsync(user)); + } + + public Task CanGenerateTwoFactorTokenAsync(UserManager manager, TUser user) + { + return Task.FromResult(true); + } + + private static string MakeToken(string purpose, string userId) + { + return string.Join(":", userId, purpose, "ImmaToken"); + } + } + + private sealed class TestEmailSender : IEmailSender + { + public List Emails { get; set; } = new(); + + public Task SendEmailAsync(string email, string subject, string htmlMessage) + { + Emails.Add(new(email, subject, htmlMessage)); + return Task.CompletedTask; + } + } + + private sealed record Email(string Address, string Subject, string HtmlMessage); } diff --git a/src/Identity/test/Identity.Test/SignInManagerTest.cs b/src/Identity/test/Identity.Test/SignInManagerTest.cs index 6f7082fe09e8..01e748d4e2c4 100644 --- a/src/Identity/test/Identity.Test/SignInManagerTest.cs +++ b/src/Identity/test/Identity.Test/SignInManagerTest.cs @@ -3,6 +3,7 @@ using System.Security.Claims; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -105,7 +106,7 @@ private static SignInManager SetupSignInManager(UserManager var options = new Mock>(); options.Setup(a => a.Value).Returns(identityOptions); var claimsFactory = new UserClaimsPrincipalFactory(manager, roleManager.Object, options.Object); - schemeProvider = schemeProvider ?? new Mock().Object; + schemeProvider = schemeProvider ?? new MockSchemeProvider(); var sm = new SignInManager(manager, contextAccessor.Object, claimsFactory, options.Object, null, schemeProvider, new DefaultUserConfirmation()); sm.Logger = logger ?? NullLogger>.Instance; return sm; @@ -364,7 +365,7 @@ public async Task CanTwoFactorAuthenticatorSignIn(string providerName, bool isPe var context = new DefaultHttpContext(); var auth = MockAuth(context); var helper = SetupSignInManager(manager.Object, context); - var twoFactorInfo = new SignInManager.TwoFactorAuthenticationInfo { UserId = user.Id }; + var twoFactorInfo = new SignInManager.TwoFactorAuthenticationInfo { User = user }; if (providerName != null) { helper.Options.Tokens.AuthenticatorTokenProvider = providerName; @@ -406,7 +407,7 @@ public async Task TwoFactorAuthenticatorSignInFailWithoutLockout() var context = new DefaultHttpContext(); var auth = MockAuth(context); var helper = SetupSignInManager(manager.Object, context); - var twoFactorInfo = new SignInManager.TwoFactorAuthenticationInfo { UserId = user.Id }; + var twoFactorInfo = new SignInManager.TwoFactorAuthenticationInfo { User = user }; if (providerName != null) { helper.Options.Tokens.AuthenticatorTokenProvider = providerName; @@ -485,7 +486,7 @@ public async Task CanTwoFactorRecoveryCodeSignIn(bool supportsLockout, bool exte var context = new DefaultHttpContext(); var auth = MockAuth(context); var helper = SetupSignInManager(manager.Object, context); - var twoFactorInfo = new SignInManager.TwoFactorAuthenticationInfo { UserId = user.Id }; + var twoFactorInfo = new SignInManager.TwoFactorAuthenticationInfo { User = user }; var loginProvider = "loginprovider"; var id = SignInManager.StoreTwoFactorInfo(user.Id, externalLogin ? loginProvider : null); if (externalLogin) @@ -628,7 +629,7 @@ public async Task CanTwoFactorSignIn(bool isPersistent, bool supportsLockout, bo var context = new DefaultHttpContext(); var auth = MockAuth(context); var helper = SetupSignInManager(manager.Object, context); - var twoFactorInfo = new SignInManager.TwoFactorAuthenticationInfo { UserId = user.Id }; + var twoFactorInfo = new SignInManager.TwoFactorAuthenticationInfo { User = user }; var loginProvider = "loginprovider"; var id = SignInManager.StoreTwoFactorInfo(user.Id, externalLogin ? loginProvider : null); if (externalLogin) @@ -1277,4 +1278,29 @@ protected override Task ResetLockout(TUser user) return Task.CompletedTask; } } + + private sealed class MockSchemeProvider : IAuthenticationSchemeProvider + { + private static AuthenticationScheme CreateCookieScheme(string name) => new(IdentityConstants.ApplicationScheme, displayName: null, typeof(CookieAuthenticationHandler)); + + private static readonly Dictionary _defaultCookieSchemes = new() + { + [IdentityConstants.ApplicationScheme] = CreateCookieScheme(IdentityConstants.ApplicationScheme), + [IdentityConstants.ExternalScheme] = CreateCookieScheme(IdentityConstants.ExternalScheme), + [IdentityConstants.TwoFactorRememberMeScheme] = CreateCookieScheme(IdentityConstants.TwoFactorRememberMeScheme), + [IdentityConstants.TwoFactorUserIdScheme] = CreateCookieScheme(IdentityConstants.TwoFactorUserIdScheme), + }; + + public Task> GetAllSchemesAsync() => Task.FromResult>(_defaultCookieSchemes.Values); + public Task GetSchemeAsync(string name) => Task.FromResult(_defaultCookieSchemes.TryGetValue(name, out var scheme) ? scheme : null); + + public void AddScheme(AuthenticationScheme scheme) => throw new NotImplementedException(); + public void RemoveScheme(string name) => throw new NotImplementedException(); + public Task GetDefaultAuthenticateSchemeAsync() => throw new NotImplementedException(); + public Task GetDefaultChallengeSchemeAsync() => throw new NotImplementedException(); + public Task GetDefaultForbidSchemeAsync() => throw new NotImplementedException(); + public Task GetDefaultSignInSchemeAsync() => throw new NotImplementedException(); + public Task GetDefaultSignOutSchemeAsync() => throw new NotImplementedException(); + public Task> GetRequestHandlerSchemesAsync() => throw new NotImplementedException(); + } } diff --git a/src/Testing/src/ExceptionAssertions.cs b/src/Testing/src/ExceptionAssertions.cs index adf326deafe1..cbe119cf09ce 100644 --- a/src/Testing/src/ExceptionAssertions.cs +++ b/src/Testing/src/ExceptionAssertions.cs @@ -238,14 +238,12 @@ private static Exception RecordException(Action testCode) private static Exception UnwrapException(Exception exception) { - var aggEx = exception as AggregateException; - return aggEx != null ? aggEx.GetBaseException() : exception; + return exception is AggregateException aggEx ? aggEx.GetBaseException() : exception; } private static TException VerifyException(Exception exception) { - var tie = exception as TargetInvocationException; - if (tie != null) + if (exception is TargetInvocationException tie) { exception = tie.InnerException; }