diff --git a/src/Identity/Core/src/DTO/ForgotPasswordRequest.cs b/src/Identity/Core/src/DTO/ForgotPasswordRequest.cs new file mode 100644 index 000000000000..0a5a61af1496 --- /dev/null +++ b/src/Identity/Core/src/DTO/ForgotPasswordRequest.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 ForgotPasswordRequest +{ + public required string Email { get; init; } +} diff --git a/src/Identity/Core/src/DTO/InfoRequest.cs b/src/Identity/Core/src/DTO/InfoRequest.cs index 857b98fa43c4..094a10e0dcfd 100644 --- a/src/Identity/Core/src/DTO/InfoRequest.cs +++ b/src/Identity/Core/src/DTO/InfoRequest.cs @@ -5,7 +5,6 @@ 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 index 56422f86cbd7..413a22e27fcd 100644 --- a/src/Identity/Core/src/DTO/InfoResponse.cs +++ b/src/Identity/Core/src/DTO/InfoResponse.cs @@ -5,7 +5,7 @@ namespace Microsoft.AspNetCore.Identity.DTO; internal sealed class InfoResponse { - public required string Username { get; init; } public required string Email { get; init; } + public required bool IsEmailConfirmed { 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 27e345f65d7f..fef16c3806a2 100644 --- a/src/Identity/Core/src/DTO/LoginRequest.cs +++ b/src/Identity/Core/src/DTO/LoginRequest.cs @@ -5,7 +5,7 @@ namespace Microsoft.AspNetCore.Identity.DTO; internal sealed class LoginRequest { - public required string Username { get; init; } + public required string Email { 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 58c55355b05f..adb4a4824d2f 100644 --- a/src/Identity/Core/src/DTO/RegisterRequest.cs +++ b/src/Identity/Core/src/DTO/RegisterRequest.cs @@ -5,7 +5,6 @@ namespace Microsoft.AspNetCore.Identity.DTO; internal sealed class RegisterRequest { - public required string Username { get; init; } - public required string Password { get; init; } public required string Email { get; init; } + public required string Password { get; init; } } diff --git a/src/Identity/Core/src/DTO/ResetPasswordRequest.cs b/src/Identity/Core/src/DTO/ResetPasswordRequest.cs index 441420662bbd..494bd6a41bdd 100644 --- a/src/Identity/Core/src/DTO/ResetPasswordRequest.cs +++ b/src/Identity/Core/src/DTO/ResetPasswordRequest.cs @@ -6,6 +6,6 @@ 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; } + public required string ResetCode { get; init; } + public required string NewPassword { get; init; } } diff --git a/src/Identity/Core/src/DTO/AuthenticatorKeyResponse.cs b/src/Identity/Core/src/DTO/TwoFactorResponse.cs similarity index 100% rename from src/Identity/Core/src/DTO/AuthenticatorKeyResponse.cs rename to src/Identity/Core/src/DTO/TwoFactorResponse.cs diff --git a/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs b/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs index e2bc8a7bfec7..eb4623332477 100644 --- a/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs +++ b/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs @@ -1,17 +1,16 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.ComponentModel.DataAnnotations; 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; @@ -28,6 +27,9 @@ namespace Microsoft.AspNetCore.Routing; /// public static class IdentityApiEndpointRouteBuilderExtensions { + // Validate the email address using DataAnnotations like the UserValidator does when RequireUniqueEmail = true. + private static readonly EmailAddressAttribute _emailAddressAttribute = new(); + /// /// Add endpoints for registering, logging in, and logging out using ASP.NET Core Identity. /// @@ -66,19 +68,25 @@ public static IEndpointConventionBuilder MapIdentityApi(this IEndpointRou var userStore = sp.GetRequiredService>(); var emailStore = (IUserEmailStore)userStore; + var email = registration.Email; + + if (string.IsNullOrEmpty(email) || !_emailAddressAttribute.IsValid(email)) + { + return CreateValidationProblem(IdentityResult.Failed(userManager.ErrorDescriber.InvalidEmail(email))); + } var user = new TUser(); - await userStore.SetUserNameAsync(user, registration.Username, CancellationToken.None); - await emailStore.SetEmailAsync(user, registration.Email, CancellationToken.None); + await userStore.SetUserNameAsync(user, email, CancellationToken.None); + await emailStore.SetEmailAsync(user, email, CancellationToken.None); var result = await userManager.CreateAsync(user, registration.Password); - if (result.Succeeded) + if (!result.Succeeded) { - await SendConfirmationEmailAsync(user, userManager, registration.Email); - return TypedResults.Ok(); + return CreateValidationProblem(result); } - return CreateValidationProblem(result); + await SendConfirmationEmailAsync(user, userManager, email); + return TypedResults.Ok(); }); routeGroup.MapPost("/login", async Task, EmptyHttpResult, ProblemHttpResult>> @@ -89,7 +97,7 @@ public static IEndpointConventionBuilder MapIdentityApi(this IEndpointRou signInManager.PrimaryAuthenticationScheme = cookieMode == true ? IdentityConstants.ApplicationScheme : IdentityConstants.BearerScheme; var isPersistent = persistCookies ?? true; - var result = await signInManager.PasswordSignInAsync(login.Username, login.Password, isPersistent, lockoutOnFailure: true); + var result = await signInManager.PasswordSignInAsync(login.Email, login.Password, isPersistent, lockoutOnFailure: true); if (result.RequiresTwoFactor) { @@ -103,13 +111,13 @@ public static IEndpointConventionBuilder MapIdentityApi(this IEndpointRou } } - if (result.Succeeded) + if (!result.Succeeded) { - // The signInManager already produced the needed response in the form of a cookie or bearer token. - return TypedResults.Empty; + return TypedResults.Problem(result.ToString(), statusCode: StatusCodes.Status401Unauthorized); } - return TypedResults.Problem(result.ToString(), statusCode: StatusCodes.Status401Unauthorized); + // The signInManager already produced the needed response in the form of a cookie or bearer token. + return TypedResults.Empty; }); routeGroup.MapPost("/refresh", async Task, UnauthorizedHttpResult, SignInHttpResult, ChallengeHttpResult>> @@ -142,24 +150,33 @@ await signInManager.ValidateSecurityStampAsync(refreshTicket.Principal) is not T 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(); } + IdentityResult result; + + if (string.IsNullOrEmpty(changedEmail)) + { + result = await userManager.ConfirmEmailAsync(user, code); + } + else + { + // As with Identity UI, email and user name are one and the same. So when we update the email, + // we need to update the user name. + result = await userManager.ChangeEmailAsync(user, changedEmail, code); + + if (result.Succeeded) + { + result = await userManager.SetUserNameAsync(user, changedEmail); + } + } + if (!result.Succeeded) { return TypedResults.Unauthorized(); @@ -188,54 +205,54 @@ await signInManager.ValidateSecurityStampAsync(refreshTicket.Principal) is not T return TypedResults.Ok(); }); - routeGroup.MapPost("/resetPassword", async Task> - ([FromBody] ResetPasswordRequest resetRequest, [FromServices] IServiceProvider sp) => + routeGroup.MapPost("/forgotPassword", async Task> + ([FromBody] ForgotPasswordRequest resetRequest, [FromServices] IServiceProvider sp) => { var userManager = sp.GetRequiredService>(); + var user = await userManager.FindByEmailAsync(resetRequest.Email); - if (!string.IsNullOrEmpty(resetRequest.ResetCode) && string.IsNullOrEmpty(resetRequest.NewPassword)) + if (user is not null && await userManager.IsEmailConfirmedAsync(user)) { - return CreateValidationProblem("MissingNewPassword", "A password reset code was provided without a new password."); + 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)}"); } + // Don't reveal that the user does not exist or is not confirmed, so don't return a 200 if we would have + // returned a 400 for an invalid code given a valid user email. + return TypedResults.Ok(); + }); + + routeGroup.MapPost("/resetPassword", async Task> + ([FromBody] ResetPasswordRequest resetRequest, [FromServices] IServiceProvider sp) => + { + var userManager = sp.GetRequiredService>(); + var user = await userManager.FindByEmailAsync(resetRequest.Email); if (user is null || !(await userManager.IsEmailConfirmedAsync(user))) { // Don't reveal that the user does not exist or is not confirmed, so don't return a 200 if we would have // returned a 400 for an invalid code given a valid user email. - if (!string.IsNullOrEmpty(resetRequest.ResetCode)) - { - return CreateValidationProblem(IdentityResult.Failed(userManager.ErrorDescriber.InvalidToken())); - } + 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)}"); + IdentityResult result; + try + { + var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(resetRequest.ResetCode)); + result = await userManager.ResetPasswordAsync(user, code, resetRequest.NewPassword); } - else + catch (FormatException) { - 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()); - } + result = IdentityResult.Failed(userManager.ErrorDescriber.InvalidToken()); + } - if (!result.Succeeded) - { - return CreateValidationProblem(result); - } + if (!result.Succeeded) + { + return CreateValidationProblem(result); } return TypedResults.Ok(); @@ -243,18 +260,6 @@ await emailSender.SendEmailAsync(resetRequest.Email, "Reset your password", 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) => { @@ -307,7 +312,26 @@ await emailSender.SendEmailAsync(resetRequest.Email, "Reset your password", await signInManager.ForgetTwoFactorClientAsync(); } - return TypedResults.Ok(await CreateTwoFactorResponseAsync(user, signInManager, recoveryCodes)); + var key = await userManager.GetAuthenticatorKeyAsync(user); + if (string.IsNullOrEmpty(key)) + { + await userManager.ResetAuthenticatorKeyAsync(user); + key = await userManager.GetAuthenticatorKeyAsync(user); + + if (string.IsNullOrEmpty(key)) + { + throw new NotSupportedException("The user manager must produce an authenticator key after reset."); + } + } + + return TypedResults.Ok(new TwoFactorResponse + { + SharedKey = key, + RecoveryCodes = recoveryCodes, + RecoveryCodesLeft = recoveryCodes?.Length ?? await userManager.CountRecoveryCodesAsync(user), + IsTwoFactorEnabled = await userManager.GetTwoFactorEnabledAsync(user), + IsMachineRemembered = await signInManager.IsTwoFactorClientRememberedAsync(user), + }); }); accountGroup.MapGet("/info", async Task, ValidationProblem, NotFound>> @@ -323,24 +347,31 @@ await emailSender.SendEmailAsync(resetRequest.Email, "Reset your password", }); accountGroup.MapPost("/info", async Task, ValidationProblem, NotFound>> - (HttpContext httpContext, [FromBody] InfoRequest infoRequest, [FromServices] IServiceProvider sp) => + (ClaimsPrincipal claimsPrincipal, [FromBody] InfoRequest infoRequest, [FromServices] IServiceProvider sp) => { - var signInManager = sp.GetRequiredService>(); - var userManager = signInManager.UserManager; - if (await userManager.GetUserAsync(httpContext.User) is not { } user) + var userManager = sp.GetRequiredService>(); + if (await userManager.GetUserAsync(claimsPrincipal) is not { } user) { return TypedResults.NotFound(); } - List? failedResults = null; + if (!string.IsNullOrEmpty(infoRequest.NewEmail) && !_emailAddressAttribute.IsValid(infoRequest.NewEmail)) + { + return CreateValidationProblem(IdentityResult.Failed(userManager.ErrorDescriber.InvalidEmail(infoRequest.NewEmail))); + } - if (!string.IsNullOrEmpty(infoRequest.NewUsername)) + if (!string.IsNullOrEmpty(infoRequest.NewPassword)) { - var userName = await userManager.GetUserNameAsync(user); + if (string.IsNullOrEmpty(infoRequest.OldPassword)) + { + return CreateValidationProblem("OldPasswordRequired", + "The old password is required to set a new password. If the old password is forgotten, use /resetPassword."); + } - if (userName != infoRequest.NewUsername) + var changePasswordResult = await userManager.ChangePasswordAsync(user, infoRequest.OldPassword, infoRequest.NewPassword); + if (!changePasswordResult.Succeeded) { - AddIfFailed(ref failedResults, await userManager.SetUserNameAsync(user, infoRequest.NewUsername)); + return CreateValidationProblem(changePasswordResult); } } @@ -354,38 +385,7 @@ await emailSender.SendEmailAsync(resetRequest.Email, "Reset your password", } } - 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)); - } + return TypedResults.Ok(await CreateInfoResponseAsync(user, claimsPrincipal, userManager)); }); async Task SendConfirmationEmailAsync(TUser user, UserManager userManager, string email, bool isChange = false) @@ -423,46 +423,18 @@ await emailSender.SendEmailAsync(email, "Confirm your email", 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); + var errorDictionary = new Dictionary(1); + foreach (var error in result.Errors) { string[] newDescriptions; @@ -480,33 +452,8 @@ private static void AddErrorsToDictionary(Dictionary errorDict 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), - }; + return TypedResults.ValidationProblem(errorDictionary); } private static async Task CreateInfoResponseAsync(TUser user, ClaimsPrincipal claimsPrincipal, UserManager userManager) @@ -514,8 +461,8 @@ private static async Task CreateInfoResponseAsync(TUser use { 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."), + IsEmailConfirmed = await userManager.IsEmailConfirmedAsync(user), Claims = claimsPrincipal.Claims.ToDictionary(c => c.Type, c => c.Value), }; } diff --git a/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs b/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs index 3b2d6df4ea15..387920a60d9c 100644 --- a/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs +++ b/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs @@ -31,7 +31,7 @@ namespace Microsoft.AspNetCore.Identity.FunctionalTests; public class MapIdentityApiTests : LoggedTest { - private string Username { get; } = $"{Guid.NewGuid()}@example.com"; + private string Email { get; } = $"{Guid.NewGuid()}@example.com"; private string Password { get; } = "[PLACEHOLDER]-1a"; [Theory] @@ -41,7 +41,7 @@ public async Task CanRegisterUser(string addIdentityMode) await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]); using var client = app.GetTestClient(); - AssertOkAndEmpty(await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username })); + AssertOkAndEmpty(await client.PostAsJsonAsync("/identity/register", new { Email, Password })); } [Fact] @@ -50,7 +50,30 @@ public async Task RegisterFailsGivenNoEmail() await using var app = await CreateAppAsync(); using var client = app.GetTestClient(); - AssertBadRequestAndEmpty(await client.PostAsJsonAsync("/identity/register", new { Username, Password })); + AssertBadRequestAndEmpty(await client.PostAsJsonAsync("/identity/register", new { Password })); + } + + [Theory] + [MemberData(nameof(AddIdentityModes))] + public async Task RegisterFailsGivenInvalidEmail(string addIdentityMode) + { + await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]); + using var client = app.GetTestClient(); + + await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/register", new { Email = "invalid", Password }), + "InvalidEmail"); + } + + [Theory] + [MemberData(nameof(AddIdentityModes))] + public async Task RegisterFailsGivenDuplicateEmail(string addIdentityMode) + { + await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]); + using var client = app.GetTestClient(); + + AssertOkAndEmpty(await client.PostAsJsonAsync("/identity/register", new { Email, Password })); + await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/register", new { Email, Password }), + "DuplicateUserName"); } [Fact] @@ -59,7 +82,7 @@ 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 }), + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }), "Failed"); } @@ -70,7 +93,7 @@ public async Task LoginFailsGivenWrongPassword() using var client = app.GetTestClient(); await RegisterAsync(client); - await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" }), + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password = "wrong" }), "Failed"); } @@ -82,7 +105,7 @@ public async Task CanLoginWithBearerToken(string addIdentityMode) using var client = app.GetTestClient(); await RegisterAsync(client); - var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); + var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password }); loginResponse.EnsureSuccessStatusCode(); Assert.False(loginResponse.Headers.Contains(HeaderNames.SetCookie)); @@ -96,7 +119,7 @@ public async Task CanLoginWithBearerToken(string addIdentityMode) Assert.Equal(3600, expiresIn); client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); - Assert.Equal($"Hello, {Username}!", await client.GetStringAsync("/auth/hello")); + Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello")); } [Fact] @@ -119,7 +142,7 @@ public async Task CanCustomizeBearerTokenExpiration() using var client = app.GetTestClient(); await RegisterAsync(client); - var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); + var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password }); var loginContent = await loginResponse.Content.ReadFromJsonAsync(); var accessToken = loginContent.GetProperty("access_token").GetString(); @@ -130,12 +153,12 @@ public async Task CanCustomizeBearerTokenExpiration() client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); // Works without time passing. - Assert.Equal($"Hello, {Username}!", await client.GetStringAsync("/auth/hello")); + Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello")); clock.Advance(TimeSpan.FromSeconds(expireTimeSpan.TotalSeconds - 1)); // Still works one second before expiration. - Assert.Equal($"Hello, {Username}!", await client.GetStringAsync("/auth/hello")); + Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello")); clock.Advance(TimeSpan.FromSeconds(1)); @@ -150,7 +173,7 @@ public async Task CanLoginWithCookies() using var client = app.GetTestClient(); await RegisterAsync(client); - var loginResponse = await client.PostAsJsonAsync("/identity/login?cookieMode=true", new { Username, Password }); + var loginResponse = await client.PostAsJsonAsync("/identity/login?cookieMode=true", new { Email, Password }); AssertOkAndEmpty(loginResponse); Assert.True(loginResponse.Headers.TryGetValues(HeaderNames.SetCookie, out var setCookieHeaders)); @@ -163,7 +186,7 @@ public async Task CanLoginWithCookies() } client.DefaultRequestHeaders.Add(HeaderNames.Cookie, cookie); - Assert.Equal($"Hello, {Username}!", await client.GetStringAsync("/auth/hello")); + Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello")); } [Fact] @@ -175,7 +198,7 @@ public async Task CannotLoginWithCookiesWithOnlyCoreServices() await RegisterAsync(client); await Assert.ThrowsAsync(() - => client.PostAsJsonAsync("/identity/login?cookieMode=true", new { Username, Password })); + => client.PostAsJsonAsync("/identity/login?cookieMode=true", new { Email, Password })); } [Fact] @@ -198,16 +221,16 @@ public async Task CanReadBearerTokenFromQueryString() using var client = app.GetTestClient(); await RegisterAsync(client); - var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); + var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password }); var loginContent = await loginResponse.Content.ReadFromJsonAsync(); var accessToken = loginContent.GetProperty("access_token").GetString(); - Assert.Equal($"Hello, {Username}!", await client.GetStringAsync($"/auth/hello?access_token={accessToken}")); + Assert.Equal($"Hello, {Email}!", await client.GetStringAsync($"/auth/hello?access_token={accessToken}")); // The normal header still works client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); - Assert.Equal($"Hello, {Username}!", await client.GetStringAsync("/auth/hello")); + Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello")); } [Theory] @@ -234,7 +257,7 @@ public async Task CanUseRefreshToken(string addIdentityMode) using var client = app.GetTestClient(); await RegisterAsync(client); - var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); + var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password }); var loginContent = await loginResponse.Content.ReadFromJsonAsync(); var refreshToken = loginContent.GetProperty("refresh_token").GetString(); @@ -243,7 +266,7 @@ public async Task CanUseRefreshToken(string addIdentityMode) var accessToken = refreshContent.GetProperty("access_token").GetString(); client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); - Assert.Equal($"Hello, {Username}!", await client.GetStringAsync("/auth/hello")); + Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello")); } [Fact] @@ -279,7 +302,7 @@ public async Task CanCustomizeRefreshTokenExpiration() using var client = app.GetTestClient(); await RegisterAsync(client); - var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); + var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password }); var loginContent = await loginResponse.Content.ReadFromJsonAsync(); var refreshToken = loginContent.GetProperty("refresh_token").GetString(); @@ -313,7 +336,7 @@ public async Task CanCustomizeRefreshTokenExpiration() accessToken = refreshContent.GetProperty("access_token").GetString(); client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); - Assert.Equal($"Hello, {Username}!", await client.GetStringAsync("/auth/hello")); + Assert.Equal($"Hello, {Email}!", await client.GetStringAsync("/auth/hello")); } [Fact] @@ -326,7 +349,7 @@ public async Task RefreshReturns401UnauthorizedIfSecurityStampChanges() var refreshToken = await LoginAsync(client); var userManager = app.Services.GetRequiredService>(); - var user = await userManager.FindByNameAsync(Username); + var user = await userManager.FindByNameAsync(Email); Assert.NotNull(user); @@ -345,11 +368,11 @@ public async Task RefreshUpdatesUserFromStore() var refreshToken = await LoginAsync(client); var userManager = app.Services.GetRequiredService>(); - var user = await userManager.FindByNameAsync(Username); + var user = await userManager.FindByNameAsync(Email); Assert.NotNull(user); - var newUsername = $"{Guid.NewGuid()}@example.org"; + var newUsername = $"{Guid.NewGuid()}@example.com"; user.UserName = newUsername; await userManager.UpdateAsync(user); @@ -376,17 +399,17 @@ public async Task LoginCanBeLockedOut() await RegisterAsync(client); - await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" }), + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password = "wrong" }), "Failed"); - await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" }), + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, 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 }), + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }), "LockedOut"); } @@ -406,14 +429,14 @@ public async Task LockoutCanBeDisabled() await RegisterAsync(client); - await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" }), + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, 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 })); + AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email, Password })); } [Fact] @@ -486,22 +509,22 @@ public async Task EmailConfirmationCanBeResent() var firstEmail = Assert.Single(emailSender.Emails); Assert.Equal("Confirm your email", firstEmail.Subject); - Assert.Equal(Username, firstEmail.Address); + Assert.Equal(Email, firstEmail.Address); - await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password }), + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }), "NotAllowed"); AssertOk(await client.PostAsJsonAsync("/identity/resendConfirmationEmail", new { Email = "wrong" })); - AssertOk(await client.PostAsJsonAsync("/identity/resendConfirmationEmail", new { Email = Username })); + AssertOk(await client.PostAsJsonAsync("/identity/resendConfirmationEmail", new { Email = Email })); // 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); + Assert.Equal(Email, resentEmail.Address); AssertOk(await client.GetAsync(GetEmailConfirmationLink(resentEmail))); - AssertOk(await client.PostAsJsonAsync("/identity/login", new { Username, Password })); + AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email, Password })); } [Fact] @@ -526,11 +549,11 @@ public async Task CanAddEndpointsToMultipleRouteGroupsForSameUserType() 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, "/identity", "a@example.com"); + await LoginWithEmailConfirmationAsync(client, emailSender, "/identity", "a@example.com"); - await RegisterAsync(client, "/identity2", username: "b"); - await LoginWithEmailConfirmationAsync(client, emailSender, "/identity2", username: "b"); + await RegisterAsync(client, "/identity2", "b@example.com"); + await LoginWithEmailConfirmationAsync(client, emailSender, "/identity2", "b@example.com"); } [Fact] @@ -572,7 +595,7 @@ public async Task CanAddEndpointsToMultipleRouteGroupsForMultipleUsersTypes() await app.StartAsync(); using var client = app.GetTestClient(); - // We can use the same username twice since we're using two distinct DbContexts. + // We can use the same email twice since we're using two distinct DbContexts. await RegisterAsync(client, "/identity"); await LoginWithEmailConfirmationAsync(client, emailSender, "/identity"); @@ -588,13 +611,13 @@ public async Task CanEnableAndLoginWithTwoFactor(string addIdentityMode) using var client = app.GetTestClient(); await RegisterAsync(client); - var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); + var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, 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")); + AssertUnauthorizedAndEmpty(await client.PostAsync("/identity/account/2fa", null)); client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); @@ -604,11 +627,16 @@ public async Task CanEnableAndLoginWithTwoFactor(string addIdentityMode) 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()); + // Even though we're now authenticated, we must send at least "{}" in the request body. An empty request fails. + AssertBadRequestAndEmpty(await client.PostAsync("/identity/account/2fa", null)); + AssertBadRequestAndEmpty(await client.PostAsJsonAsync("/identity/account/2fa", null)); + + var twoFactorKeyResponse = await client.PostAsJsonAsync("/identity/account/2fa", new object()); + var twoFactorKeyContent = await twoFactorKeyResponse.Content.ReadFromJsonAsync(); + Assert.False(twoFactorKeyContent.GetProperty("isTwoFactorEnabled").GetBoolean()); + Assert.False(twoFactorKeyContent.GetProperty("isMachineRemembered").GetBoolean()); - var sharedKey = twoFactorKeyResponse.GetProperty("sharedKey").GetString(); + var sharedKey = twoFactorKeyContent.GetProperty("sharedKey").GetString(); var keyBytes = Base32.FromBase32(sharedKey); var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); @@ -621,17 +649,17 @@ public async Task CanEnableAndLoginWithTwoFactor(string addIdentityMode) 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")); + Assert.Equal($"Hello, {Email}!", 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 }), + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }), "RequiresTwoFactor"); - AssertOk(await client.PostAsJsonAsync("/identity/login", new { Username, Password, twoFactorCode })); + AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email, Password, twoFactorCode })); } [Fact] @@ -641,14 +669,15 @@ public async Task CanLoginWithRecoveryCodeAndDisableTwoFactor() using var client = app.GetTestClient(); await RegisterAsync(client); - var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); + var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, 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 twoFactorKeyResponse = await client.PostAsJsonAsync("/identity/account/2fa", new object()); + var twoFactorKeyContent = await twoFactorKeyResponse.Content.ReadFromJsonAsync(); + var sharedKey = twoFactorKeyContent.GetProperty("sharedKey").GetString(); var keyBytes = Base32.FromBase32(sharedKey); var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); @@ -664,10 +693,10 @@ public async Task CanLoginWithRecoveryCodeAndDisableTwoFactor() client.DefaultRequestHeaders.Clear(); - await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password }), + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }), "RequiresTwoFactor"); - var recoveryLoginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password, TwoFactorRecoveryCode = recoveryCodes[0] }); + var recoveryLoginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, Password, TwoFactorRecoveryCode = recoveryCodes[0] }); var recoveryLoginContent = await recoveryLoginResponse.Content.ReadFromJsonAsync(); var recoveryAccessToken = recoveryLoginContent.GetProperty("access_token").GetString(); @@ -681,7 +710,7 @@ public async Task CanLoginWithRecoveryCodeAndDisableTwoFactor() client.DefaultRequestHeaders.Clear(); - AssertOk(await client.PostAsJsonAsync("/identity/login", new { Username, Password })); + AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email, Password })); } [Fact] @@ -691,14 +720,15 @@ public async Task CanResetSharedKey() using var client = app.GetTestClient(); await RegisterAsync(client); - var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); + var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, 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 twoFactorKeyResponse = await client.PostAsJsonAsync("/identity/account/2fa", new object()); + var twoFactorKeyContent = await twoFactorKeyResponse.Content.ReadFromJsonAsync(); + var sharedKey = twoFactorKeyContent.GetProperty("sharedKey").GetString(); var keyBytes = Base32.FromBase32(sharedKey); var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); @@ -737,14 +767,15 @@ public async Task CanResetRecoveryCodes() using var client = app.GetTestClient(); await RegisterAsync(client); - var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); + var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, 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 twoFactorKeyResponse = await client.PostAsJsonAsync("/identity/account/2fa", new object()); + var twoFactorKeyContent = await twoFactorKeyResponse.Content.ReadFromJsonAsync(); + var sharedKey = twoFactorKeyContent.GetProperty("sharedKey").GetString(); var keyBytes = Base32.FromBase32(sharedKey); var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); @@ -759,22 +790,23 @@ public async Task CanResetRecoveryCodes() client.DefaultRequestHeaders.Clear(); - await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password }), + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }), "RequiresTwoFactor"); - AssertOk(await client.PostAsJsonAsync("/identity/login", new { Username, Password, TwoFactorRecoveryCode = recoveryCodes[0] })); + AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email, Password, TwoFactorRecoveryCode = recoveryCodes[0] })); // Cannot reuse codes - await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password, TwoFactorRecoveryCode = recoveryCodes[0] }), + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password, TwoFactorRecoveryCode = recoveryCodes[0] }), "Failed"); - var recoveryLoginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password, TwoFactorRecoveryCode = recoveryCodes[1] }); + var recoveryLoginResponse = await client.PostAsJsonAsync("/identity/login", new { Email, 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"); + var updated2faResponse = await client.PostAsJsonAsync("/identity/account/2fa", new object()); + var updated2faContent = await updated2faResponse.Content.ReadFromJsonAsync();; Assert.Equal(8, updated2faContent.GetProperty("recoveryCodesLeft").GetInt32()); Assert.Null(updated2faContent.GetProperty("recoveryCodes").GetString()); @@ -790,10 +822,10 @@ public async Task CanResetRecoveryCodes() client.DefaultRequestHeaders.Clear(); - AssertOk(await client.PostAsJsonAsync("/identity/login", new { Username, Password, TwoFactorRecoveryCode = resetRecoveryCodes[0] })); + AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email, 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] }), + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password, TwoFactorRecoveryCode = recoveryCodes[2] }), "Failed"); } @@ -804,14 +836,15 @@ public async Task CanUsePersistentTwoFactorCookies() using var client = app.GetTestClient(); await RegisterAsync(client); - var loginResponse = await client.PostAsJsonAsync("/identity/login?cookieMode=true", new { Username, Password }); + var loginResponse = await client.PostAsJsonAsync("/identity/login?cookieMode=true", new { Email, 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 twoFactorKeyResponse = await client.PostAsJsonAsync("/identity/account/2fa", new object()); + var twoFactorKeyContent = await twoFactorKeyResponse.Content.ReadFromJsonAsync(); + Assert.False(twoFactorKeyContent.GetProperty("isTwoFactorEnabled").GetBoolean()); + Assert.False(twoFactorKeyContent.GetProperty("isMachineRemembered").GetBoolean()); - var sharedKey = twoFactorKeyResponse.GetProperty("sharedKey").GetString(); + var sharedKey = twoFactorKeyContent.GetProperty("sharedKey").GetString(); var keyBytes = Base32.FromBase32(sharedKey); var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); @@ -823,22 +856,24 @@ public async Task CanUsePersistentTwoFactorCookies() Assert.True(enable2faContent.GetProperty("isTwoFactorEnabled").GetBoolean()); Assert.False(enable2faContent.GetProperty("isMachineRemembered").GetBoolean()); - await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password }), + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }), "RequiresTwoFactor"); - var twoFactorLoginResponse = await client.PostAsJsonAsync("/identity/login?cookieMode=true&persistCookies=false", new { Username, Password, twoFactorCode }); + var twoFactorLoginResponse = await client.PostAsJsonAsync("/identity/login?cookieMode=true&persistCookies=false", new { Email, 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 cookie2faResponse = await client.PostAsJsonAsync("/identity/account/2fa", new object()); + var cookie2faContent = await cookie2faResponse.Content.ReadFromJsonAsync(); + Assert.True(cookie2faContent.GetProperty("isTwoFactorEnabled").GetBoolean()); + Assert.False(cookie2faContent.GetProperty("isMachineRemembered").GetBoolean()); - var persistentLoginResponse = await client.PostAsJsonAsync("/identity/login?cookieMode=true", new { Username, Password, twoFactorCode }); + var persistentLoginResponse = await client.PostAsJsonAsync("/identity/login?cookieMode=true", new { Email, 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()); + var persistent2faResponse = await client.PostAsJsonAsync("/identity/account/2fa", new object()); + var persistent2faContent = await persistent2faResponse.Content.ReadFromJsonAsync(); + Assert.True(persistent2faContent.GetProperty("isTwoFactorEnabled").GetBoolean()); + Assert.True(persistent2faContent.GetProperty("isMachineRemembered").GetBoolean()); } [Fact] @@ -857,24 +892,21 @@ public async Task CanResetPassword() }); 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, email: confirmedEmail); + await LoginWithEmailConfirmationAsync(client, emailSender, email: confirmedEmail); - await RegisterAsync(client, username: unconfirmedUsername, email: unconfirmedEmail); + await RegisterAsync(client, 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" })); + AssertOkAndEmpty(await client.PostAsJsonAsync("/identity/forgotPassword", new { Email = confirmedEmail })); + AssertOkAndEmpty(await client.PostAsJsonAsync("/identity/forgotPassword", new { Email = unconfirmedEmail })); + AssertOkAndEmpty(await client.PostAsJsonAsync("/identity/forgotPassword", new { Email = "wrong" })); // But only one email was sent for the confirmed address Assert.Equal(3, emailSender.Emails.Count); @@ -887,12 +919,9 @@ public async Task CanResetPassword() 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"); + AssertBadRequestAndEmpty(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = confirmedEmail, resetCode })); + AssertBadRequestAndEmpty(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = unconfirmedEmail, resetCode })); + AssertBadRequestAndEmpty(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = "wrong", resetCode })); await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = confirmedEmail, ResetCode = "wrong", newPassword }), "InvalidToken"); @@ -901,14 +930,20 @@ public async Task CanResetPassword() await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = "wrong", ResetCode = "wrong", newPassword }), "InvalidToken"); + // Only with a valid reset code is it possible to get more problem details + await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = confirmedEmail, ResetCode = "wrong", NewPassword = "" }), + "InvalidToken"); + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/resetPassword", new { Email = confirmedEmail, resetCode, NewPassword = "" }), + detail: null, title: "One or more validation errors occurred.", status: HttpStatusCode.BadRequest); + 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 }), + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email = confirmedEmail, Password }), "Failed"); // But the new password is - AssertOk(await client.PostAsJsonAsync("/identity/login", new { Username = confirmedUsername, Password = newPassword })); + AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email = confirmedEmail, Password = newPassword })); } [Fact] @@ -917,19 +952,15 @@ 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); + await RegisterAsync(client); + await LoginAsync(client); var infoResponse = await client.GetFromJsonAsync("/identity/account/info"); - Assert.Equal(username, infoResponse.GetProperty("username").GetString()); - Assert.Equal(email, infoResponse.GetProperty("email").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(Email, 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()); } @@ -957,37 +988,39 @@ public async Task CanChangeEmail(string addIdentityModes) 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()); + Assert.Equal(Email, infoResponse.GetProperty("email").GetString()); + Assert.True(infoResponse.GetProperty("isEmailConfirmed").GetBoolean()); + 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()); + Assert.Equal(Email, infoClaims.GetProperty(ClaimTypes.Name).GetString()); + Assert.Equal(Email, infoClaims.GetProperty(ClaimTypes.Email).GetString()); var originalNameIdentifier = infoResponse.GetProperty("claims").GetProperty(ClaimTypes.NameIdentifier).GetString(); - var newUsername = $"NewUsernamePrefix-{Username}"; - var newEmail = $"NewEmailPrefix-{Username}"; + var newEmail = $"New-{Email}"; + + // The email must pass DataAnnotations validation by EmailAddressAttribute. + await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/account/info", new { NewEmail = "invalid" }), + "InvalidEmail"); - var infoPostResponse = await client.PostAsJsonAsync("/identity/account/info", new { newUsername, newEmail }); + var infoPostResponse = await client.PostAsJsonAsync("/identity/account/info", new { 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()); + // The email isn't updated until the new email is confirmed. + Assert.Equal(Email, infoPostContent.GetProperty("email").GetString()); + Assert.True(infoPostContent.GetProperty("isEmailConfirmed").GetBoolean()); // 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(Email, infoPostClaims.GetProperty(ClaimTypes.Name).GetString()); + Assert.Equal(Email, 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); + // We cannot log in with the new email until we confirm the email change. + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email = newEmail, Password }), + "Failed"); - // Which gives us a new refresh token that is valid for now. - AssertOk(await client.PostAsJsonAsync("/identity/refresh", new { RefreshToken = secondRefreshToken })); + // And we can still use the original refresh token since the email change has not yet been confirmed. + AssertOk(await client.PostAsJsonAsync("/identity/refresh", new { RefreshToken = originalRefreshToken })); // 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); @@ -999,34 +1032,33 @@ public async Task CanChangeEmail(string addIdentityModes) 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. + // 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(Email, claimsAfterEmailChange.GetProperty(ClaimTypes.Name).GetString()); + Assert.Equal(Email, 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 })); + // And now the email has changed, the refresh token is invalidated by the security stamp. + AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/refresh", new { RefreshToken = originalRefreshToken })); // We will finally see all the claims updated after logging in again. - await LoginAsync(client, username: newUsername); + await LoginAsync(client, email: newEmail); var infoAfterFinalLogin = await client.GetFromJsonAsync("/identity/account/info"); - Assert.Equal(newUsername, infoAfterFinalLogin.GetProperty("username").GetString()); Assert.Equal(newEmail, infoAfterFinalLogin.GetProperty("email").GetString()); + Assert.True(infoAfterFinalLogin.GetProperty("isEmailConfirmed").GetBoolean()); var claimsAfterFinalLogin = infoAfterFinalLogin.GetProperty("claims"); - Assert.Equal(newUsername, claimsAfterFinalLogin.GetProperty(ClaimTypes.Name).GetString()); + Assert.Equal(newEmail, 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() + public async Task CannotUpdateClaimsDuringInfoPostWithCookies() { var emailSender = new TestEmailSender(); @@ -1048,33 +1080,30 @@ public async Task CanUpdateClaimsDuringInfoPostWithCookies() // 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 }); + var loginResponse = await client.PostAsJsonAsync("/identity/login?cookieMode=true", new { Email, 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()); + Assert.Equal(Email, 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()); + Assert.Equal(Email, infoClaims.GetProperty(ClaimTypes.Name).GetString()); + Assert.Equal(Email, infoClaims.GetProperty(ClaimTypes.Email).GetString()); var originalNameIdentifier = infoResponse.GetProperty("claims").GetProperty(ClaimTypes.NameIdentifier).GetString(); - var newUsername = $"NewUsernamePrefix-{Username}"; - var newEmail = $"NewEmailPrefix-{Username}"; + var newEmail = $"NewEmailPrefix-{Email}"; - var infoPostResponse = await client.PostAsJsonAsync("/identity/account/info", new { newUsername, newEmail }); - ApplyCookies(client, infoPostResponse); + var infoPostResponse = await client.PostAsJsonAsync("/identity/account/info", new { newEmail }); + // There are no cookie updates because nothing has changed yet. + Assert.False(infoPostResponse.Headers.Contains(HeaderNames.SetCookie)); 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()); + Assert.Equal(Email, infoPostContent.GetProperty("email").GetString()); - // The claims have been updated to match. + // The claims have not 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(Email, 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. @@ -1084,29 +1113,30 @@ public async Task CanUpdateClaimsDuringInfoPostWithCookies() Assert.Equal("Confirm your email", email.Subject); Assert.Equal(newEmail, email.Address); - AssertOk(await client.GetAsync(GetEmailConfirmationLink(email))); + var emailConfirmationResponse = await client.GetAsync(GetEmailConfirmationLink(email)); + // Even though the user does change during this request, we still don't refresh the cookie, because this + // request doesn't rely on authentication. It's entirely possible the client is logged in as a different user. + Assert.False(emailConfirmationResponse.Headers.Contains(HeaderNames.SetCookie)); + AssertOk(emailConfirmationResponse); 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. + // 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(Email, 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 }); + var secondLoginResponse = await client.PostAsJsonAsync("/identity/login?cookieMode=true", new { Email = newEmail, 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.Name).GetString()); Assert.Equal(newEmail, claimsAfterFinalLogin.GetProperty(ClaimTypes.Email).GetString()); Assert.Equal(originalNameIdentifier, infoClaims.GetProperty(ClaimTypes.NameIdentifier).GetString()); } @@ -1129,37 +1159,69 @@ public async Task CanChangePasswordWithoutResetEmail() client.DefaultRequestHeaders.Clear(); // We can immediately log in with the new password - await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password }), + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }), "Failed"); - AssertOk(await client.PostAsJsonAsync("/identity/login", new { Username, Password = newPassword })); + AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email, Password = newPassword })); } [Fact] - public async Task CanReportMultipleInfoUpdateErrorsAtOnce() + public async Task MustSendValidRequestToSendEmailChangeConfirmation() { - await using var app = await CreateAppAsync(); + var emailSender = new TestEmailSender(); + + await using var app = await CreateAppAsync(services => + { + AddIdentityApiEndpoints(services); + services.AddSingleton(emailSender); + }); using var client = app.GetTestClient(); await RegisterAsync(client); - // Register a second user that conflicts with our first NewUsername - await RegisterAsync(client, username: "taken"); + + // We're not going to bother to confirm the original email, but it should be there. + Assert.Single(emailSender.Emails); + emailSender.Emails.Clear(); await LoginAsync(client); + var newEmail = $"New-{Email}"; 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); + await AssertValidationProblemAsync(await client.PostAsJsonAsync("/identity/account/info", new { newPassword, newEmail }), + "OldPasswordRequired"); + + // Since the request is invalid, no change email confirmation was sent. + Assert.Empty(emailSender.Emails); - 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 the response wont show a email update until we confirm the email. + var infoPostResponse = await client.PostAsJsonAsync("/identity/account/info", new { OldPassword = Password, newPassword, newEmail }); + + var infoPostContent = await infoPostResponse.Content.ReadFromJsonAsync(); + // The email isn't updated until the email is confirmed. + Assert.Equal(Email, infoPostContent.GetProperty("email").GetString()); + Assert.False(infoPostContent.GetProperty("isEmailConfirmed").GetBoolean()); + + // We cannot login with the new email yet. + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email = newEmail, Password = newPassword }), + "Failed"); + // And we cannot login with the old email and password either. + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Email, Password }), + "Failed"); + // We'll have to use the old email with the new password until we confirm the new email. + AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email, Password = newPassword })); + + // Confirm the email change. + var changeEmail = Assert.Single(emailSender.Emails); + Assert.Equal(newEmail, changeEmail.Address); + AssertOk(await client.GetAsync(GetEmailConfirmationLink(changeEmail))); + + var infoGetContent = await client.GetFromJsonAsync("/identity/account/info"); + // The email isn't updated until the email is confirmed. + Assert.Equal(newEmail, infoGetContent.GetProperty("email").GetString()); + Assert.True(infoGetContent.GetProperty("isEmailConfirmed").GetBoolean()); - // 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 })); + // We can now login with the new email too. + AssertOk(await client.PostAsJsonAsync("/identity/login", new { Email = newEmail, Password = newPassword })); } private async Task CreateAppAsync(Action? configureServices, bool autoStart = true) @@ -1235,7 +1297,7 @@ private Task CreateAppAsync(Action? configur public static object[][] AddIdentityModes => AddIdentityActions.Keys.Select(key => new object[] { key }).ToArray(); - private static string GetEmailConfirmationLink(Email email) + private static string GetEmailConfirmationLink(TestEmail email) { // Update if we add more links to the email. var confirmationMatch = Regex.Match(email.HtmlMessage, "href='(.*?)'"); @@ -1245,7 +1307,7 @@ private static string GetEmailConfirmationLink(Email email) return WebUtility.HtmlDecode(confirmationMatch.Groups[1].Value); } - private static string GetPasswordResetCode(Email email) + private static string GetPasswordResetCode(TestEmail email) { // Update if we add more links to the email. var confirmationMatch = Regex.Match(email.HtmlMessage, "code: (.*?)$"); @@ -1255,23 +1317,21 @@ private static string GetPasswordResetCode(Email email) return WebUtility.HtmlDecode(confirmationMatch.Groups[1].Value); } - private async Task RegisterAsync(HttpClient client, string? groupPrefix = null, string? username = null, string? email = null) + private async Task RegisterAsync(HttpClient client, string? groupPrefix = null, string? email = null) { groupPrefix ??= "/identity"; - username ??= Username; - email ??= Username; + email ??= Email; - AssertOkAndEmpty(await client.PostAsJsonAsync($"{groupPrefix}/register", new { username, Password, email })); + AssertOkAndEmpty(await client.PostAsJsonAsync($"{groupPrefix}/register", new { email, Password })); } - private async Task LoginAsync(HttpClient client, string? groupPrefix = null, string? username = null, string? email = null) + private async Task LoginAsync(HttpClient client, string? groupPrefix = null, string? email = null) { groupPrefix ??= "/identity"; - username ??= Username; - email ??= Username; + email ??= Email; - await client.PostAsJsonAsync($"{groupPrefix}/login", new { username, Password, email }); - var loginResponse = await client.PostAsJsonAsync("/identity/login", new { username, Password }); + await client.PostAsJsonAsync($"{groupPrefix}/login", new { email, Password }); + var loginResponse = await client.PostAsJsonAsync("/identity/login", new { email, Password }); var loginContent = await loginResponse.Content.ReadFromJsonAsync(); var accessToken = loginContent.GetProperty("access_token").GetString(); var refreshToken = loginContent.GetProperty("refresh_token").GetString(); @@ -1282,23 +1342,22 @@ private async Task LoginAsync(HttpClient client, string? groupPrefix = n return refreshToken; } - private async Task LoginWithEmailConfirmationAsync(HttpClient client, TestEmailSender emailSender, string? groupPrefix = null, string? username = null, string? email = null) + private async Task LoginWithEmailConfirmationAsync(HttpClient client, TestEmailSender emailSender, string? groupPrefix = null, string? email = null) { groupPrefix ??= "/identity"; - username ??= Username; - email ??= Username; + email ??= Email; 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 }), + await AssertProblemAsync(await client.PostAsJsonAsync($"{groupPrefix}/login", new { email, Password }), "NotAllowed"); AssertOk(await client.GetAsync(GetEmailConfirmationLink(receivedEmail))); - return await LoginAsync(client, groupPrefix, username, email); + return await LoginAsync(client, groupPrefix, email); } private static void AssertOk(HttpResponseMessage response) @@ -1324,18 +1383,20 @@ private static void AssertUnauthorizedAndEmpty(HttpResponseMessage response) Assert.Equal(0, response.Content.Headers.ContentLength); } - private static async Task AssertProblemAsync(HttpResponseMessage response, string detail, HttpStatusCode status = HttpStatusCode.Unauthorized) + private static async Task AssertProblemAsync(HttpResponseMessage response, string? detail, string? title = null, HttpStatusCode status = HttpStatusCode.Unauthorized) { Assert.Equal(status, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.ToString()); var problem = await response.Content.ReadFromJsonAsync(); Assert.NotNull(problem); - Assert.Equal(ReasonPhrases.GetReasonPhrase((int)status), problem.Title); + Assert.Equal(title ?? 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); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.ToString()); var problem = await response.Content.ReadFromJsonAsync(); Assert.NotNull(problem); var errorEntry = Assert.Single(problem.Errors); @@ -1388,7 +1449,7 @@ private static string MakeToken(string purpose, string userId) private sealed class TestEmailSender : IEmailSender { - public List Emails { get; set; } = new(); + public List Emails { get; set; } = new(); public Task SendEmailAsync(string email, string subject, string htmlMessage) { @@ -1397,5 +1458,5 @@ public Task SendEmailAsync(string email, string subject, string htmlMessage) } } - private sealed record Email(string Address, string Subject, string HtmlMessage); + private sealed record TestEmail(string Address, string Subject, string HtmlMessage); } diff --git a/src/Security/Authentication/BearerToken/src/BearerTokenHandler.cs b/src/Security/Authentication/BearerToken/src/BearerTokenHandler.cs index 9aa55f15d32e..a7e9b9607545 100644 --- a/src/Security/Authentication/BearerToken/src/BearerTokenHandler.cs +++ b/src/Security/Authentication/BearerToken/src/BearerTokenHandler.cs @@ -14,8 +14,6 @@ namespace Microsoft.AspNetCore.Authentication.BearerToken; internal sealed class BearerTokenHandler(IOptionsMonitor optionsMonitor, ILoggerFactory loggerFactory, UrlEncoder urlEncoder) : SignInAuthenticationHandler(optionsMonitor, loggerFactory, urlEncoder) { - private static readonly long OneSecondTicks = TimeSpan.FromSeconds(1).Ticks; - private static readonly AuthenticateResult FailedUnprotectingToken = AuthenticateResult.Fail("Unprotected token failed"); private static readonly AuthenticateResult TokenExpired = AuthenticateResult.Fail("Token expired"); @@ -23,7 +21,7 @@ internal sealed class BearerTokenHandler(IOptionsMonitor opt protected override async Task HandleAuthenticateAsync() { - // Give application opportunity to find from a different location, adjust, or reject token + // Give application opportunity to find from a different location, adjust, or reject token. var messageReceivedContext = new MessageReceivedContext(Context, Scheme, Options); await Events.MessageReceivedAsync(messageReceivedContext); @@ -66,12 +64,12 @@ protected override async Task HandleSignInAsync(ClaimsPrincipal user, Authentica var utcNow = TimeProvider.GetUtcNow(); properties ??= new(); - properties.ExpiresUtc ??= utcNow + Options.BearerTokenExpiration; + properties.ExpiresUtc = utcNow + Options.BearerTokenExpiration; var response = new AccessTokenResponse { AccessToken = Options.BearerTokenProtector.Protect(CreateBearerTicket(user, properties)), - ExpiresInSeconds = CalculateExpiresInSeconds(utcNow, properties.ExpiresUtc), + ExpiresInSeconds = (long)Options.BearerTokenExpiration.TotalSeconds, RefreshToken = Options.RefreshTokenProtector.Protect(CreateRefreshTicket(user, utcNow)), }; @@ -92,24 +90,6 @@ protected override async Task HandleSignInAsync(ClaimsPrincipal user, Authentica : null; } - private long CalculateExpiresInSeconds(DateTimeOffset utcNow, DateTimeOffset? expiresUtc) - { - static DateTimeOffset FloorSeconds(DateTimeOffset dateTimeOffset) - => new(dateTimeOffset.Ticks / OneSecondTicks * OneSecondTicks, dateTimeOffset.Offset); - - // AuthenticationProperties floors ExpiresUtc. If this remains unchanged, we'll use BearerTokenExpiration directly - // to produce a consistent ExpiresInTotalSeconds values. If ExpiresUtc was overridden, we just calculate the - // the difference from utcNow and round even though this will likely result in unstable values. - var expiresTimeSpan = Options.BearerTokenExpiration; - var expectedExpiresUtc = FloorSeconds(utcNow + expiresTimeSpan); - return (long)(expiresUtc switch - { - DateTimeOffset d when d == expectedExpiresUtc => expiresTimeSpan.TotalSeconds, - DateTimeOffset d => (d - utcNow).TotalSeconds, - _ => expiresTimeSpan.TotalSeconds, - }); - } - private AuthenticationTicket CreateBearerTicket(ClaimsPrincipal user, AuthenticationProperties properties) => new(user, properties, $"{Scheme.Name}:AccessToken");