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;
}