From 56cab875c8edd136bb19f0168eaaedd68a51bcd3 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 11 Jul 2023 18:44:47 -0700 Subject: [PATCH 1/6] Add lockout support --- .../IdentityEndpointsJsonSerializerContext.cs | 1 + ...entityApiEndpointRouteBuilderExtensions.cs | 36 +++---- src/Identity/Core/src/PublicAPI.Unshipped.txt | 2 + src/Identity/Core/src/SignInManager.cs | 23 ++-- .../src}/IEmailSender.cs | 4 +- .../src/PublicAPI.Unshipped.txt | 2 + .../UI/src/Properties/AssemblyInfo.cs | 7 ++ src/Identity/UI/src/PublicAPI.Unshipped.txt | 4 + .../MapIdentityApiTests.cs | 101 +++++++++++++++--- src/Testing/src/ExceptionAssertions.cs | 6 +- 10 files changed, 140 insertions(+), 46 deletions(-) rename src/Identity/{UI/src/Areas/Identity/Services => Extensions.Core/src}/IEmailSender.cs (87%) create mode 100644 src/Identity/UI/src/Properties/AssemblyInfo.cs diff --git a/src/Identity/Core/src/DTO/IdentityEndpointsJsonSerializerContext.cs b/src/Identity/Core/src/DTO/IdentityEndpointsJsonSerializerContext.cs index 88db894aaed8..c8023669212b 100644 --- a/src/Identity/Core/src/DTO/IdentityEndpointsJsonSerializerContext.cs +++ b/src/Identity/Core/src/DTO/IdentityEndpointsJsonSerializerContext.cs @@ -7,6 +7,7 @@ namespace Microsoft.AspNetCore.Identity.DTO; [JsonSerializable(typeof(RegisterRequest))] [JsonSerializable(typeof(LoginRequest))] +[JsonSerializable(typeof(RefreshRequest))] internal sealed partial class IdentityEndpointsJsonSerializerContext : JsonSerializerContext { } diff --git a/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs b/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs index c65182c1aa2f..59faeadfa078 100644 --- a/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs +++ b/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs @@ -20,6 +20,8 @@ namespace Microsoft.AspNetCore.Routing; /// public static class IdentityApiEndpointRouteBuilderExtensions { + private static readonly NoopResult _noopResult = new NoopResult(); + /// /// Add endpoints for registering, logging in, and logging out using ASP.NET Core Identity. /// @@ -35,6 +37,9 @@ public static class IdentityApiEndpointRouteBuilderExtensions var routeGroup = endpoints.MapGroup(""); + var timeProvider = endpoints.ServiceProvider.GetRequiredService(); + var bearerTokenOptions = endpoints.ServiceProvider.GetRequiredService>(); + // NOTE: We cannot inject UserManager directly because the TUser generic parameter is currently unsupported by RDG. // https://github.com/dotnet/aspnetcore/issues/47338 routeGroup.MapPost("/register", async Task> @@ -54,32 +59,22 @@ public static class IdentityApiEndpointRouteBuilderExtensions return TypedResults.ValidationProblem(result.Errors.ToDictionary(e => e.Code, e => new[] { e.Description })); }); - routeGroup.MapPost("/login", async Task, SignInHttpResult>> + routeGroup.MapPost("/login", async Task, IResult>> ([FromBody] LoginRequest login, [FromQuery] bool? cookieMode, [FromServices] IServiceProvider sp) => { - var userManager = sp.GetRequiredService>(); - var user = await userManager.FindByNameAsync(login.Username); - - if (user is null || !await userManager.CheckPasswordAsync(user, login.Password)) - { - return TypedResults.Unauthorized(); - } - - var claimsFactory = sp.GetRequiredService>(); - var claimsPrincipal = await claimsFactory.CreateAsync(user); + var signInManager = sp.GetRequiredService>(); - var useCookies = cookieMode ?? false; - var scheme = useCookies ? IdentityConstants.ApplicationScheme : IdentityConstants.BearerScheme; + signInManager.AuthenticationScheme = cookieMode == true ? IdentityConstants.ApplicationScheme : IdentityConstants.BearerScheme; + var result = await signInManager.PasswordSignInAsync(login.Username, login.Password, isPersistent: true, lockoutOnFailure: true); - return TypedResults.SignIn(claimsPrincipal, authenticationScheme: scheme); + return result.Succeeded ? _noopResult : TypedResults.Unauthorized(); }); routeGroup.MapPost("/refresh", async Task, SignInHttpResult, ChallengeHttpResult>> - ([FromBody] RefreshRequest refreshRequest, [FromServices] IOptionsMonitor optionsMonitor, [FromServices] TimeProvider timeProvider, [FromServices] IServiceProvider sp) => + ([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 @@ -101,14 +96,17 @@ await signInManager.ValidateSecurityStampAsync(refreshTicket.Principal) is not T // 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); } + private sealed class NoopResult : IResult + { + public Task ExecuteAsync(HttpContext httpContext) => Task.CompletedTask; + } + [AttributeUsage(AttributeTargets.Parameter)] private sealed class FromBodyAttribute : Attribute, IFromBodyMetadata { diff --git a/src/Identity/Core/src/PublicAPI.Unshipped.txt b/src/Identity/Core/src/PublicAPI.Unshipped.txt index 9bd478611156..1b70b930f17e 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -4,6 +4,8 @@ Microsoft.AspNetCore.Identity.SecurityStampValidator.SecurityStampValidat 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.AuthenticationScheme.get -> string! +Microsoft.AspNetCore.Identity.SignInManager.AuthenticationScheme.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! diff --git a/src/Identity/Core/src/SignInManager.cs b/src/Identity/Core/src/SignInManager.cs index 5c9bd280fb39..b76e166ba6e1 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -21,6 +21,11 @@ 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; + /// /// Creates a new instance of . /// @@ -52,11 +57,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 +80,11 @@ public SignInManager(UserManager userManager, /// public IdentityOptions Options { get; set; } + /// + /// The authentication scheme to sign in with. Defaults to . + /// + public string AuthenticationScheme { get; set; } = IdentityConstants.ApplicationScheme; + /// /// The used. /// @@ -116,7 +121,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 == AuthenticationScheme); } /// @@ -155,7 +160,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(AuthenticationScheme); IList claims = Array.Empty(); var authenticationMethod = auth?.Principal?.FindFirst(ClaimTypes.AuthenticationMethod); @@ -231,7 +236,7 @@ public virtual async Task SignInWithClaimsAsync(TUser user, AuthenticationProper { userPrincipal.Identities.First().AddClaim(claim); } - await Context.SignInAsync(IdentityConstants.ApplicationScheme, + await Context.SignInAsync(AuthenticationScheme, userPrincipal, authenticationProperties ?? new AuthenticationProperties()); } @@ -241,7 +246,7 @@ await Context.SignInAsync(IdentityConstants.ApplicationScheme, /// public virtual async Task SignOutAsync() { - await Context.SignOutAsync(IdentityConstants.ApplicationScheme); + await Context.SignOutAsync(AuthenticationScheme); await Context.SignOutAsync(IdentityConstants.ExternalScheme); await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); } diff --git a/src/Identity/UI/src/Areas/Identity/Services/IEmailSender.cs b/src/Identity/Extensions.Core/src/IEmailSender.cs similarity index 87% rename from src/Identity/UI/src/Areas/Identity/Services/IEmailSender.cs rename to src/Identity/Extensions.Core/src/IEmailSender.cs index 79f6f3172636..a86bd6562b08 100644 --- a/src/Identity/UI/src/Areas/Identity/Services/IEmailSender.cs +++ b/src/Identity/Extensions.Core/src/IEmailSender.cs @@ -1,6 +1,8 @@ -// Licensed to the .NET Foundation under one or more agreements. +// 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; /// diff --git a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt index d9173be85cd0..7298aa35aa90 100644 --- a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt @@ -2,6 +2,8 @@ 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! 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/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..d1c6031175ee 100644 --- a/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs +++ b/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs @@ -18,7 +18,9 @@ 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; @@ -34,10 +36,27 @@ 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 })); + } - response.EnsureSuccessStatusCode(); - Assert.Equal(0, response.Content.Headers.ContentLength); + [Theory] + [MemberData(nameof(AddIdentityModes))] + public async Task LoginFailsGivenUnregisteredUser(string addIdentityMode) + { + await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]); + using var client = app.GetTestClient(); + + AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/login", new { Username, Password })); + } + + [Fact] + public async Task LoginFailsGivenWrongPassword() + { + await using var app = await CreateAppAsync(); + using var client = app.GetTestClient(); + + await client.PostAsJsonAsync("/identity/register", new { Username, Password }); + AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" })); } [Theory] @@ -73,11 +92,11 @@ public async Task CanCustomizeBearerTokenExpiration() await using var app = await CreateAppAsync(services => { + services.AddSingleton(clock); services.AddIdentityCore().AddApiEndpoints().AddEntityFrameworkStores(); services.AddAuthentication(IdentityConstants.BearerScheme).AddIdentityBearerToken(options => { options.BearerTokenExpiration = expireTimeSpan; - options.TimeProvider = clock; }); }); @@ -126,7 +145,7 @@ public async Task CanLoginWithCookies() // The compiler does not see Assert.True's DoesNotReturnIfAttribute :( if (setCookieHeader.Split(';', 2) is not [var cookieHeader, _]) { - throw new Exception("Invalid Set-Cookie header!"); + throw new XunitException("Invalid Set-Cookie header!"); } client.DefaultRequestHeaders.Add(HeaderNames.Cookie, cookieHeader); @@ -136,7 +155,7 @@ public async Task CanLoginWithCookies() [Fact] public async Task CannotLoginWithCookiesWithOnlyCoreServices() { - await using var app = await CreateAppAsync(AddIdentityEndpointsBearerOnly); + await using var app = await CreateAppAsync(AddIdentityApiEndpointsBearerOnly); using var client = app.GetTestClient(); await client.PostAsJsonAsync("/identity/register", new { Username, Password }); @@ -259,7 +278,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 +289,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 }); @@ -332,6 +351,62 @@ public async Task RefreshUpdatesUserFromStore(string addIdentityMode) Assert.Equal($"Hello, {newUsername}!", await client.GetStringAsync("/auth/hello")); } + [Fact] + public async Task LoginCanBeLockedOut() + { + await using var app = await CreateAppAsync(services => + { + AddIdentityApiEndpoints(services); + services.Configure(options => + { + options.Lockout.MaxFailedAccessAttempts = 1; + }); + }); + using var client = app.GetTestClient(); + + await client.PostAsJsonAsync("/identity/register", new { Username, Password }); + + AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" })); + + Assert.Single(TestSink.Writes, w => + w.LoggerName == "Microsoft.AspNetCore.Identity.SignInManager" && + w.EventId == new EventId(3, "UserLockedOut")); + + AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/login", new { Username, Password })); + } + + [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 client.PostAsJsonAsync("/identity/register", new { Username, Password }); + + AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" })); + + Assert.DoesNotContain(TestSink.Writes, w => + w.LoggerName == "Microsoft.AspNetCore.Identity.SignInManager" && + w.EventId == new EventId(3, "UserLockedOut")); + + var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); + loginResponse.EnsureSuccessStatusCode(); + } + + private static void AssertOkAndEmpty(HttpResponseMessage response) + { + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(0, response.Content.Headers.ContentLength); + } + private static void AssertUnauthorizedAndEmpty(HttpResponseMessage response) { Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); @@ -352,7 +427,7 @@ private async Task CreateAppAsync(Action dbConnection); - configureServices ??= AddIdentityEndpoints; + configureServices ??= AddIdentityApiEndpoints; configureServices(builder.Services); var app = builder.Build(); @@ -373,10 +448,10 @@ private async Task CreateAppAsync(Action services.AddIdentityApiEndpoints().AddEntityFrameworkStores(); - private static void AddIdentityEndpointsBearerOnly(IServiceCollection services) + private static void AddIdentityApiEndpointsBearerOnly(IServiceCollection services) { services .AddIdentityCore() @@ -392,8 +467,8 @@ private Task CreateAppAsync(Action? configur private static Dictionary> AddIdentityActions { get; } = new() { - [nameof(AddIdentityEndpoints)] = AddIdentityEndpoints, - [nameof(AddIdentityEndpointsBearerOnly)] = AddIdentityEndpointsBearerOnly, + [nameof(AddIdentityApiEndpoints)] = AddIdentityApiEndpoints, + [nameof(AddIdentityApiEndpointsBearerOnly)] = AddIdentityApiEndpointsBearerOnly, }; public static object[][] AddIdentityModes => AddIdentityActions.Keys.Select(key => new object[] { key }).ToArray(); 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; } From 91b20ebfc48ec05c9714a0a76f2e29e07d98278c Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Sat, 15 Jul 2023 12:41:05 -0700 Subject: [PATCH 2/6] Add email support --- src/Http/Routing/src/EndpointNameMetadata.cs | 2 + src/Identity/Core/src/DTO/RegisterRequest.cs | 1 + ...entityApiEndpointRouteBuilderExtensions.cs | 77 ++++- .../Core/src/IdentityBuilderExtensions.cs | 3 + .../Extensions.Core/src/IEmailSender.cs | 12 +- .../Extensions.Core/src/NoOpEmailSender.cs | 20 ++ .../src/PublicAPI.Unshipped.txt | 3 + .../V4/Account/RegisterConfirmation.cshtml.cs | 2 +- .../V5/Account/RegisterConfirmation.cshtml.cs | 2 +- .../Areas/Identity/Services/EmailSender.cs | 12 - .../UI/src/IdentityBuilderUIExtensions.cs | 2 +- .../Microsoft.AspNetCore.Identity.UI.csproj | 13 +- .../MapIdentityApiTests.cs | 283 +++++++++++++++--- 13 files changed, 367 insertions(+), 65 deletions(-) create mode 100644 src/Identity/Extensions.Core/src/NoOpEmailSender.cs delete mode 100644 src/Identity/UI/src/Areas/Identity/Services/EmailSender.cs 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/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/IdentityApiEndpointRouteBuilderExtensions.cs b/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs index 59faeadfa078..4c24c7f90518 100644 --- a/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs +++ b/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Linq; +using System.Text; +using System.Text.Encodings.Web; using Microsoft.AspNetCore.Authentication.BearerToken; using Microsoft.AspNetCore.Authentication.BearerToken.DTO; using Microsoft.AspNetCore.Builder; @@ -10,6 +12,8 @@ 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; @@ -31,7 +35,8 @@ 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); @@ -39,6 +44,11 @@ public static class IdentityApiEndpointRouteBuilderExtensions 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; // NOTE: We cannot inject UserManager directly because the TUser generic parameter is currently unsupported by RDG. // https://github.com/dotnet/aspnetcore/issues/47338 @@ -47,12 +57,44 @@ public static class IdentityApiEndpointRouteBuilderExtensions { var userManager = sp.GetRequiredService>(); + if (!userManager.SupportsUserLockout) + { + throw new NotSupportedException($"{nameof(MapIdentityApi)} requires a user store with email support."); + } + + if (confirmEmailEndpointName is null) + { + throw new NotSupportedException("No email confirmation endpoint was registered!"); + } + + var emailStore = (IUserEmailStore)sp.GetRequiredService>(); + var user = new TUser(); + // TODO: Use store directly to save DB round trips await userManager.SetUserNameAsync(user, registration.Username); + await emailStore.SetEmailAsync(user, registration.Email, CancellationToken.None); var result = await userManager.CreateAsync(user, registration.Password); if (result.Succeeded) { + var userId = await userManager.GetUserIdAsync(user); + var code = await userManager.GenerateEmailConfirmationTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + + var confirmEmailUrl = linkGenerator.GetPathByName(confirmEmailEndpointName, new() + { + ["userId"] = userId, + ["code"] = code, + }); + + if (confirmEmailUrl is null) + { + throw new NotSupportedException($"Could not find endpoint named '{confirmEmailEndpointName}'."); + } + + await emailSender.SendEmailAsync(registration.Email, "Confirm your email", + $"Please confirm your account by clicking here."); + return TypedResults.Ok(); } @@ -67,6 +109,7 @@ public static class IdentityApiEndpointRouteBuilderExtensions signInManager.AuthenticationScheme = cookieMode == true ? IdentityConstants.ApplicationScheme : IdentityConstants.BearerScheme; var result = await signInManager.PasswordSignInAsync(login.Username, login.Password, isPersistent: true, lockoutOnFailure: true); + // TODO: Use problem details for lockout. return result.Succeeded ? _noopResult : TypedResults.Unauthorized(); }); @@ -90,6 +133,38 @@ await signInManager.ValidateSecurityStampAsync(refreshTicket.Principal) is not T return TypedResults.SignIn(newPrincipal, authenticationScheme: IdentityConstants.BearerScheme); }); + // TODO: Add option for redirect. + routeGroup.MapGet("/confirmEmail", async Task> + ([FromQuery] string userId, [FromQuery] string code, [FromServices] IServiceProvider sp) => + { + var userManager = sp.GetRequiredService>(); + + var user = await userManager.FindByIdAsync(userId); + if (user is null) + { + // We could respond with a 404 instead of a 401 like Identity UI, but that feels like + // unnecessary information disclosure. + return TypedResults.Unauthorized(); + } + + code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code)); + var result = await userManager.ConfirmEmailAsync(user, code); + + if (!result.Succeeded) + { + return TypedResults.Unauthorized(); + } + + return TypedResults.Text("Thank you for confirming your email."); + }) + .Add(endpointBuilder => + { + var finalPattern = ((RouteEndpointBuilder)endpointBuilder).RoutePattern.RawText; + confirmEmailEndpointName = $"{nameof(MapIdentityApi)}-{finalPattern}"; + endpointBuilder.Metadata.Add(new EndpointNameMetadata(confirmEmailEndpointName)); + endpointBuilder.Metadata.Add(new RouteNameMetadata(confirmEmailEndpointName)); + }); + return new IdentityEndpointsConventionBuilder(routeGroup); } 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/Extensions.Core/src/IEmailSender.cs b/src/Identity/Extensions.Core/src/IEmailSender.cs index a86bd6562b08..614a1fd6254e 100644 --- a/src/Identity/Extensions.Core/src/IEmailSender.cs +++ b/src/Identity/Extensions.Core/src/IEmailSender.cs @@ -6,14 +6,18 @@ 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. +/// 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 default UI infrastructure and is not intended to be used - /// directly from your code. This API may change or be removed in future releases. + /// 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/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 7298aa35aa90..33bb8e55c6e3 100644 --- a/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Extensions.Core/src/PublicAPI.Unshipped.txt @@ -4,6 +4,9 @@ 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/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/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/Microsoft.AspNetCore.Identity.UI.csproj b/src/Identity/UI/src/Microsoft.AspNetCore.Identity.UI.csproj index 986c84729a81..7f203921cfbf 100644 --- a/src/Identity/UI/src/Microsoft.AspNetCore.Identity.UI.csproj +++ b/src/Identity/UI/src/Microsoft.AspNetCore.Identity.UI.csproj @@ -36,6 +36,10 @@ + + + + <_RazorGenerate Include="Areas\Identity\Pages\**\*.cshtml" /> @@ -57,14 +61,7 @@ $([System.IO.Path]::GetFullPath($(_ReferenceAssetContentRoot))) - + diff --git a/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs b/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs index d1c6031175ee..2225a0d34b55 100644 --- a/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs +++ b/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs @@ -8,10 +8,13 @@ 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; @@ -36,14 +39,22 @@ 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 })); + AssertOkAndEmpty(await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username })); } - [Theory] - [MemberData(nameof(AddIdentityModes))] - public async Task LoginFailsGivenUnregisteredUser(string addIdentityMode) + [Fact] + public async Task RegisterFailsGivenNoEmail() { - await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]); + 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(); AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/login", new { Username, Password })); @@ -55,7 +66,7 @@ public async Task LoginFailsGivenWrongPassword() await using var app = await CreateAppAsync(); using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password }); + await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" })); } @@ -66,7 +77,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 client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); loginResponse.EnsureSuccessStatusCode(); @@ -102,7 +113,7 @@ public async Task CanCustomizeBearerTokenExpiration() using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password }); + await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); var loginContent = await loginResponse.Content.ReadFromJsonAsync(); @@ -133,7 +144,7 @@ public async Task CanLoginWithCookies() await using var app = await CreateAppAsync(); using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password }); + await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); var loginResponse = await client.PostAsJsonAsync("/identity/login?cookieMode=true", new { Username, Password }); loginResponse.EnsureSuccessStatusCode(); @@ -158,7 +169,7 @@ public async Task CannotLoginWithCookiesWithOnlyCoreServices() await using var app = await CreateAppAsync(AddIdentityApiEndpointsBearerOnly); using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password }); + await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); await Assert.ThrowsAsync(() => client.PostAsJsonAsync("/identity/login?cookieMode=true", new { Username, Password })); @@ -182,7 +193,7 @@ public async Task CanReadBearerTokenFromQueryString() using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password }); + await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); var loginContent = await loginResponse.Content.ReadFromJsonAsync(); @@ -218,7 +229,7 @@ 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 client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); var loginContent = await loginResponse.Content.ReadFromJsonAsync(); var refreshToken = loginContent.GetProperty("refresh_token").GetString(); @@ -262,7 +273,7 @@ public async Task CanCustomizeRefreshTokenExpiration() using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password }); + await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); var loginContent = await loginResponse.Content.ReadFromJsonAsync(); @@ -300,14 +311,13 @@ 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 }); + await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); var loginContent = await loginResponse.Content.ReadFromJsonAsync(); var refreshToken = loginContent.GetProperty("refresh_token").GetString(); @@ -322,14 +332,13 @@ 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 }); + await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); var loginContent = await loginResponse.Content.ReadFromJsonAsync(); var refreshToken = loginContent.GetProperty("refresh_token").GetString(); @@ -364,7 +373,7 @@ public async Task LoginCanBeLockedOut() }); using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password }); + await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" })); @@ -389,7 +398,7 @@ public async Task LockoutCanBeDisabled() }); using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password }); + await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" })); @@ -401,19 +410,148 @@ public async Task LockoutCanBeDisabled() loginResponse.EnsureSuccessStatusCode(); } - private static void AssertOkAndEmpty(HttpResponseMessage response) + [Fact] + public async Task AccountConfirmationCanBeEnabled() { - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(0, response.Content.Headers.ContentLength); + 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 client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); + + var email = Assert.Single(emailSender.Emails); + + AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/login", new { Username, Password })); + + Assert.Single(TestSink.Writes, w => + w.LoggerName == "Microsoft.AspNetCore.Identity.SignInManager" && + w.EventId == new EventId(4, "UserCannotSignInWithoutConfirmedAccount")); + + var confirmEmailResponse = await client.GetAsync(GetEmailConfirmationLink(email)); + confirmEmailResponse.EnsureSuccessStatusCode(); + + var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); + loginResponse.EnsureSuccessStatusCode(); } - private static void AssertUnauthorizedAndEmpty(HttpResponseMessage response) + [Fact] + public async Task EmailConfirmationCanBeEnabled() { - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - Assert.Equal(0, response.Content.Headers.ContentLength); + 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 client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); + + var email = Assert.Single(emailSender.Emails); + + AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/login", new { Username, Password })); + + Assert.Single(TestSink.Writes, w => + w.LoggerName == "Microsoft.AspNetCore.Identity.SignInManager" && + w.EventId == new EventId(0, "UserCannotSignInWithoutConfirmedEmail")); + + var confirmEmailResponse = await client.GetAsync(GetEmailConfirmationLink(email)); + confirmEmailResponse.EnsureSuccessStatusCode(); + + var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); + loginResponse.EnsureSuccessStatusCode(); + } + + [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(); + + async Task TestLoginWithConfirmedAccount(string groupPrefix, string userName) + { + await client.PostAsJsonAsync($"{groupPrefix}/register", new { userName, Password, Email = Username }); + + var email = emailSender.Emails.Last(); + + AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync($"{groupPrefix}/login", new { userName, Password })); + + var confirmEmailResponse = await client.GetAsync(GetEmailConfirmationLink(email)); + confirmEmailResponse.EnsureSuccessStatusCode(); + + var loginResponse = await client.PostAsJsonAsync($"{groupPrefix}/login", new { userName, Password }); + loginResponse.EnsureSuccessStatusCode(); + } + + await TestLoginWithConfirmedAccount("/identity", "a@example.com"); + await TestLoginWithConfirmedAccount("/identity2", "b@example.com"); + } + + [Fact] + public async Task CanAddEndpointsToMultipleRouteGroupsForMultipleUsersTypes() + { + // Test with confirmation email since that tests link generation capabilities + var emailSender = new TestEmailSender(); + + 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(sp.GetRequiredService())) + .AddIdentityCore() + .AddEntityFrameworkStores>() + .AddApiEndpoints(); + + 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 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 TestRegistrationWithAccountConfirmation(client, emailSender, "/identity", Username); + await TestRegistrationWithAccountConfirmation(client, emailSender, "/identity2", Username); } - private async Task CreateAppAsync(Action? configureServices) + private async Task CreateAppAsync(Action? configureServices, bool autoStart = true) where TUser : class, new() where TContext : DbContext { @@ -423,11 +561,10 @@ private async Task CreateAppAsync(Action(options => options.UseSqlite(dbConnection)); // Dispose SqliteConnection with host by registering as a singleton factory. - builder.Services.AddSingleton(() => dbConnection); + builder.Services.AddSingleton(_ => dbConnection); - configureServices ??= AddIdentityApiEndpoints; + configureServices ??= AddIdentityApiEndpoints; configureServices(builder.Services); var app = builder.Build(); @@ -443,17 +580,30 @@ private async Task CreateAppAsync(Action().Database.EnsureCreatedAsync(); - await app.StartAsync(); + + if (autoStart) + { + await app.StartAsync(); + } return app; } + private static void AddIdentityApiEndpoints(IServiceCollection services) + where TUser : class, new() + where TContext : DbContext + { + services.AddDbContext((sp, options) => options.UseSqlite(sp.GetRequiredService())) + .AddIdentityApiEndpoints().AddEntityFrameworkStores(); + } + private static void AddIdentityApiEndpoints(IServiceCollection services) - => services.AddIdentityApiEndpoints().AddEntityFrameworkStores(); + => AddIdentityApiEndpoints(services); private static void AddIdentityApiEndpointsBearerOnly(IServiceCollection services) { services + .AddDbContext((sp, options) => options.UseSqlite(sp.GetRequiredService())) .AddIdentityCore() .AddEntityFrameworkStores() .AddApiEndpoints(); @@ -472,4 +622,63 @@ private Task CreateAppAsync(Action? configur }; public static object[][] AddIdentityModes => AddIdentityActions.Keys.Select(key => new object[] { key }).ToArray(); + + 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 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 async Task TestRegistrationWithAccountConfirmation(HttpClient client, TestEmailSender emailSender, string? groupPrefix = null, string? username = null) + { + groupPrefix ??= ""; + username ??= Username; + + await client.PostAsJsonAsync($"{groupPrefix}/register", new { username, Password, Email = username }); + + var email = emailSender.Emails.Last(); + + AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync($"{groupPrefix}/login", new { username, Password })); + + var confirmEmailResponse = await client.GetAsync(GetEmailConfirmationLink(email)); + confirmEmailResponse.EnsureSuccessStatusCode(); + + var loginResponse = await client.PostAsJsonAsync($"{groupPrefix}/login", new { username, Password }); + loginResponse.EnsureSuccessStatusCode(); + } + + 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); } From 7eb53ce24f2f2d99f6d246c482845336241b7e14 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Mon, 17 Jul 2023 02:15:57 -0700 Subject: [PATCH 3/6] Add multitenency --- .../Extensions.Core/src/IdentityBuilder.cs | 13 ++++++- .../src/TokenProviderDescriptor.cs | 7 +++- .../Extensions.Core/src/UserManager.cs | 12 ++++++ .../MapIdentityApiTests.cs | 37 +++++++++---------- 4 files changed, 47 insertions(+), 22 deletions(-) diff --git a/src/Identity/Extensions.Core/src/IdentityBuilder.cs b/src/Identity/Extensions.Core/src/IdentityBuilder.cs index b64111fc77af..8fcfc304ff8d 100644 --- a/src/Identity/Extensions.Core/src/IdentityBuilder.cs +++ b/src/Identity/Extensions.Core/src/IdentityBuilder.cs @@ -149,7 +149,18 @@ 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.ProviderType is not null) + { + descriptor.OtherProviderTypes ??= new(); + descriptor.OtherProviderTypes.Add(descriptor.ProviderType); + descriptor.ProviderType = provider; + } + else + { + options.Tokens.ProviderMap[providerName] = new TokenProviderDescriptor(provider); + } }); Services.AddTransient(provider); return this; diff --git a/src/Identity/Extensions.Core/src/TokenProviderDescriptor.cs b/src/Identity/Extensions.Core/src/TokenProviderDescriptor.cs index 20dd8001d9ba..165888105240 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; @@ -22,10 +23,14 @@ public TokenProviderDescriptor(Type type) /// /// The type that will be used for this token provider. /// - public Type ProviderType { get; } + public Type ProviderType { get; internal set; } /// /// If specified, the instance to be used for the token provider. /// public object? ProviderInstance { get; set; } + + // Temporary fix to test MapIdentityApi's support for multiple TUser and TContext. + // There's nothing as permanent as a temporary fix, but it seems better than now support. + internal List? OtherProviderTypes { get; set; } } diff --git a/src/Identity/Extensions.Core/src/UserManager.cs b/src/Identity/Extensions.Core/src/UserManager.cs index 7ea477fc949b..8d89812f43d7 100644 --- a/src/Identity/Extensions.Core/src/UserManager.cs +++ b/src/Identity/Extensions.Core/src/UserManager.cs @@ -112,6 +112,18 @@ public UserManager(IUserStore store, { RegisterTokenProvider(providerName, provider); } + else if (description.OtherProviderTypes != null) + { + foreach (var otherProviderType in description.OtherProviderTypes) + { + var otherProvider = services.GetRequiredService(otherProviderType) as IUserTwoFactorTokenProvider; + if (otherProvider != null) + { + RegisterTokenProvider(providerName, otherProvider); + break; + } + } + } } } diff --git a/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs b/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs index 2225a0d34b55..6cdfbdd440d1 100644 --- a/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs +++ b/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs @@ -104,6 +104,7 @@ 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 => { @@ -180,6 +181,7 @@ 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 => { @@ -264,6 +266,7 @@ 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 => { @@ -491,28 +494,14 @@ public async Task CanAddEndpointsToMultipleRouteGroupsForSameUserType() options.SignIn.RequireConfirmedAccount = true; }); }, autoStart: false); + app.MapGroup("/identity2").MapIdentityApi(); await app.StartAsync(); using var client = app.GetTestClient(); - async Task TestLoginWithConfirmedAccount(string groupPrefix, string userName) - { - await client.PostAsJsonAsync($"{groupPrefix}/register", new { userName, Password, Email = Username }); - - var email = emailSender.Emails.Last(); - - AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync($"{groupPrefix}/login", new { userName, Password })); - - var confirmEmailResponse = await client.GetAsync(GetEmailConfirmationLink(email)); - confirmEmailResponse.EnsureSuccessStatusCode(); - - var loginResponse = await client.PostAsJsonAsync($"{groupPrefix}/login", new { userName, Password }); - loginResponse.EnsureSuccessStatusCode(); - } - - await TestLoginWithConfirmedAccount("/identity", "a@example.com"); - await TestLoginWithConfirmedAccount("/identity2", "b@example.com"); + await TestRegistrationWithAccountConfirmation(client, emailSender, "/identity", "a@example.com"); + await TestRegistrationWithAccountConfirmation(client, emailSender, "/identity2", "b@example.com"); } [Fact] @@ -521,17 +510,23 @@ 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(sp.GetRequiredService())) + .AddDbContext((sp, options) => options.UseSqlite(dbConnection2)) .AddIdentityCore() - .AddEntityFrameworkStores>() + .AddEntityFrameworkStores() .AddApiEndpoints(); + services.AddSingleton(_ => dbConnection2); + services.AddSingleton(emailSender); services.Configure(options => { @@ -540,7 +535,9 @@ public async Task CanAddEndpointsToMultipleRouteGroupsForMultipleUsersTypes() }, autoStart: false); // The following two lines are already taken care of by CreateAppAsync for ApplicationUser and ApplicationDbContext - await app.Services.GetRequiredService>().Database.EnsureCreatedAsync(); + await dbConnection2.OpenAsync(); + await app.Services.GetRequiredService().Database.EnsureCreatedAsync(); + app.MapGroup("/identity2").MapIdentityApi(); await app.StartAsync(); From c522805daa73fbfa9c1220d926f4a743d0978006 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 18 Jul 2023 04:51:42 -0700 Subject: [PATCH 4/6] Add 2fa --- src/Identity/Core/src/DTO/LoginRequest.cs | 1 + .../Core/src/DTO/TwoFactorKeyResponse.cs | 9 ++ ...entityApiEndpointRouteBuilderExtensions.cs | 81 +++++++++- .../IdentityServiceCollectionExtensions.cs | 6 +- .../src/Microsoft.AspNetCore.Identity.csproj | 1 + src/Identity/Core/src/PublicAPI.Unshipped.txt | 6 +- src/Identity/Core/src/SignInManager.cs | 112 +++++++++----- .../Extensions.Core/src/IdentityBuilder.cs | 6 +- .../Extensions.Core/src/SignInResult.cs | 2 +- .../src/TokenProviderDescriptor.cs | 24 ++- .../Extensions.Core/src/UserManager.cs | 20 +-- .../MapIdentityApiTests.cs | 146 +++++++++++++----- 12 files changed, 295 insertions(+), 119 deletions(-) create mode 100644 src/Identity/Core/src/DTO/TwoFactorKeyResponse.cs diff --git a/src/Identity/Core/src/DTO/LoginRequest.cs b/src/Identity/Core/src/DTO/LoginRequest.cs index fbe6b6900f0f..1975574937e1 100644 --- a/src/Identity/Core/src/DTO/LoginRequest.cs +++ b/src/Identity/Core/src/DTO/LoginRequest.cs @@ -7,4 +7,5 @@ internal sealed class LoginRequest { public required string Username { get; init; } public required string Password { get; init; } + public string? TwoFactorCode { get; init; } } diff --git a/src/Identity/Core/src/DTO/TwoFactorKeyResponse.cs b/src/Identity/Core/src/DTO/TwoFactorKeyResponse.cs new file mode 100644 index 000000000000..d79ffadfa9bd --- /dev/null +++ b/src/Identity/Core/src/DTO/TwoFactorKeyResponse.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 TwoFactorKeyResponse +{ + public required string SharedKey { get; init; } +} diff --git a/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs b/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs index 4c24c7f90518..c3368b362f89 100644 --- a/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs +++ b/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Linq; +using System.Security.Claims; using System.Text; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Authentication.BearerToken; @@ -70,7 +71,6 @@ public static IEndpointConventionBuilder MapIdentityApi(this IEndpointRou var emailStore = (IUserEmailStore)sp.GetRequiredService>(); var user = new TUser(); - // TODO: Use store directly to save DB round trips await userManager.SetUserNameAsync(user, registration.Username); await emailStore.SetEmailAsync(user, registration.Email, CancellationToken.None); var result = await userManager.CreateAsync(user, registration.Password); @@ -101,19 +101,25 @@ await emailSender.SendEmailAsync(registration.Email, "Confirm your email", return TypedResults.ValidationProblem(result.Errors.ToDictionary(e => e.Code, e => new[] { e.Description })); }); - routeGroup.MapPost("/login", async Task, IResult>> + routeGroup.MapPost("/login", async Task, ProblemHttpResult, IResult>> ([FromBody] LoginRequest login, [FromQuery] bool? cookieMode, [FromServices] IServiceProvider sp) => { var signInManager = sp.GetRequiredService>(); - signInManager.AuthenticationScheme = cookieMode == true ? IdentityConstants.ApplicationScheme : IdentityConstants.BearerScheme; + signInManager.PrimaryAuthenticationScheme = cookieMode == true ? IdentityConstants.ApplicationScheme : IdentityConstants.BearerScheme; + signInManager.TwoFactorCode = login.TwoFactorCode; var result = await signInManager.PasswordSignInAsync(login.Username, login.Password, isPersistent: true, lockoutOnFailure: true); - // TODO: Use problem details for lockout. - return result.Succeeded ? _noopResult : TypedResults.Unauthorized(); + if (result.Succeeded) + { + // The signInManager already produced the needed response in the form of a cookie or bearer token. + return _noopResult; + } + + return TypedResults.Problem(result.ToString(), statusCode: StatusCodes.Status401Unauthorized); }); - routeGroup.MapPost("/refresh", async Task, SignInHttpResult, ChallengeHttpResult>> + routeGroup.MapPost("/refresh", async Task, UnauthorizedHttpResult, SignInHttpResult, ChallengeHttpResult>> ([FromBody] RefreshRequest refreshRequest, [FromServices] IServiceProvider sp) => { var signInManager = sp.GetRequiredService>(); @@ -133,7 +139,6 @@ await signInManager.ValidateSecurityStampAsync(refreshTicket.Principal) is not T return TypedResults.SignIn(newPrincipal, authenticationScheme: IdentityConstants.BearerScheme); }); - // TODO: Add option for redirect. routeGroup.MapGet("/confirmEmail", async Task> ([FromQuery] string userId, [FromQuery] string code, [FromServices] IServiceProvider sp) => { @@ -165,6 +170,67 @@ await signInManager.ValidateSecurityStampAsync(refreshTicket.Principal) is not T endpointBuilder.Metadata.Add(new RouteNameMetadata(confirmEmailEndpointName)); }); + var accountGroup = routeGroup.MapGroup("/account").RequireAuthorization(); + + accountGroup.MapPost("/get2faKey", async Task, NotFound>> + (ClaimsPrincipal claimsPrincipal, [FromServices] IServiceProvider sp) => + { + var userManager = sp.GetRequiredService>(); + var user = await userManager.GetUserAsync(claimsPrincipal); + + if (user is null) + { + return TypedResults.NotFound(); + } + + var key = await userManager.GetAuthenticatorKeyAsync(user); + if (string.IsNullOrEmpty(key)) + { + await userManager.ResetAuthenticatorKeyAsync(user); + key = await userManager.GetAuthenticatorKeyAsync(user); + } + + return TypedResults.Ok(new TwoFactorKeyResponse + { + SharedKey = key!, + }); + }); + + accountGroup.MapPost("/enable2fa", async Task> + (ClaimsPrincipal claimsPrincipal, [FromQuery] string twoFactorCode, [FromServices] IServiceProvider sp) => + { + var userManager = sp.GetRequiredService>(); + var user = await userManager.GetUserAsync(claimsPrincipal); + + if (user is null) + { + return TypedResults.NotFound(); + } + + if (!await userManager.VerifyTwoFactorTokenAsync(user, userManager.Options.Tokens.AuthenticatorTokenProvider, twoFactorCode)) + { + return TypedResults.Problem("InvalidCode"); + } + + await userManager.SetTwoFactorEnabledAsync(user, true); + return TypedResults.Ok(); + }); + + accountGroup.MapPost("/disable2fa", async Task> + (ClaimsPrincipal claimsPrincipal, [FromServices] IServiceProvider sp) => + { + var userManager = sp.GetRequiredService>(); + var user = await userManager.GetUserAsync(claimsPrincipal); + + if (user is null) + { + return TypedResults.NotFound(); + } + + await userManager.SetTwoFactorEnabledAsync(user, false); + return TypedResults.Ok(); + }); + return new IdentityEndpointsConventionBuilder(routeGroup); } @@ -198,3 +264,4 @@ private sealed class FromQueryAttribute : Attribute, IFromQueryMetadata public string? Name => null; } } + diff --git a/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs b/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs index 7d5153c80fb7..db622a841182 100644 --- a/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs +++ b/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs @@ -147,11 +147,7 @@ public static IdentityBuilder AddIdentityApiEndpoints(this IServiceCollec .AddIdentityBearerToken() .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 1b70b930f17e..128df69fa552 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -4,8 +4,10 @@ Microsoft.AspNetCore.Identity.SecurityStampValidator.SecurityStampValidat 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.AuthenticationScheme.get -> string! -Microsoft.AspNetCore.Identity.SignInManager.AuthenticationScheme.set -> void +Microsoft.AspNetCore.Identity.SignInManager.PrimaryAuthenticationScheme.get -> string! +Microsoft.AspNetCore.Identity.SignInManager.PrimaryAuthenticationScheme.set -> void +Microsoft.AspNetCore.Identity.SignInManager.TwoFactorCode.get -> string? +Microsoft.AspNetCore.Identity.SignInManager.TwoFactorCode.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! diff --git a/src/Identity/Core/src/SignInManager.cs b/src/Identity/Core/src/SignInManager.cs index b76e166ba6e1..26804456ff16 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -25,6 +25,7 @@ public class SignInManager where TUser : class private readonly IAuthenticationSchemeProvider _schemes; private readonly IUserConfirmation _confirmation; private HttpContext? _context; + private TUser? _twoFactorUser; /// /// Creates a new instance of . @@ -83,7 +84,12 @@ public SignInManager(UserManager userManager, /// /// The authentication scheme to sign in with. Defaults to . /// - public string AuthenticationScheme { get; set; } = IdentityConstants.ApplicationScheme; + public string PrimaryAuthenticationScheme { get; set; } = IdentityConstants.ApplicationScheme; + + /// + /// The two-factor code used to immediately complete password sign in if required. + /// + public string? TwoFactorCode { get; set; } /// /// The used. @@ -121,7 +127,7 @@ public virtual bool IsSignedIn(ClaimsPrincipal principal) { ArgumentNullException.ThrowIfNull(principal); return principal.Identities != null && - principal.Identities.Any(i => i.AuthenticationType == AuthenticationScheme); + principal.Identities.Any(i => i.AuthenticationType == PrimaryAuthenticationScheme); } /// @@ -160,7 +166,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(AuthenticationScheme); + var auth = await Context.AuthenticateAsync(PrimaryAuthenticationScheme); IList claims = Array.Empty(); var authenticationMethod = auth?.Principal?.FindFirst(ClaimTypes.AuthenticationMethod); @@ -236,7 +242,7 @@ public virtual async Task SignInWithClaimsAsync(TUser user, AuthenticationProper { userPrincipal.Identities.First().AddClaim(claim); } - await Context.SignInAsync(AuthenticationScheme, + await Context.SignInAsync(PrimaryAuthenticationScheme, userPrincipal, authenticationProperties ?? new AuthenticationProperties()); } @@ -246,7 +252,7 @@ await Context.SignInAsync(AuthenticationScheme, /// public virtual async Task SignOutAsync() { - await Context.SignOutAsync(AuthenticationScheme); + await Context.SignOutAsync(PrimaryAuthenticationScheme); await Context.SignOutAsync(IdentityConstants.ExternalScheme); await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); } @@ -419,6 +425,11 @@ public virtual async Task CheckPasswordSignInAsync(TUser user, str /// public virtual async Task IsTwoFactorClientRememberedAsync(TUser user) { + if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorUserIdScheme) == 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); @@ -455,20 +466,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. @@ -496,10 +502,13 @@ private async Task DoTwoFactorSignInAsync(TUser user, TwoFactorAut 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; @@ -517,16 +526,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) { @@ -564,16 +569,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) { @@ -610,7 +611,7 @@ public virtual async Task TwoFactorSignInAsync(string provider, st return null; } - return await UserManager.FindByIdAsync(info.UserId!); + return info.User; } /// @@ -801,11 +802,21 @@ protected virtual async Task SignInOrTwoFactorAsync(TUser user, bo { if (!bypassTwoFactor && await IsTwoFactorEnabledAsync(user)) { - if (!await IsTwoFactorClientRememberedAsync(user)) + if (TwoFactorCode != null) { - // Store the userId for use after two factor check - var userId = await UserManager.GetUserIdAsync(user); - await Context.SignInAsync(IdentityConstants.TwoFactorUserIdScheme, StoreTwoFactorInfo(userId, loginProvider)); + _twoFactorUser = user; + // isPersistent and rememberClient affect the application and 2fa cookies respectively if used. + return await TwoFactorAuthenticatorSignInAsync(TwoFactorCode, isPersistent, rememberClient: isPersistent); + } + else if (!await IsTwoFactorClientRememberedAsync(user)) + { + 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; } } @@ -827,16 +838,37 @@ protected virtual async Task SignInOrTwoFactorAsync(TUser user, bo private async Task RetrieveTwoFactorInfoAsync() { - var result = await Context.AuthenticateAsync(IdentityConstants.TwoFactorUserIdScheme); - if (result?.Principal != null) + if (_twoFactorUser != null) { return new TwoFactorAuthenticationInfo { - UserId = result.Principal.FindFirstValue(ClaimTypes.Name), - LoginProvider = result.Principal.FindFirstValue(ClaimTypes.AuthenticationMethod) + User = _twoFactorUser, }; } - return null; + + var result = await Context.AuthenticateAsync(IdentityConstants.TwoFactorUserIdScheme); + if (result?.Principal == 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), + }; } /// @@ -958,7 +990,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/IdentityBuilder.cs b/src/Identity/Extensions.Core/src/IdentityBuilder.cs index 8fcfc304ff8d..9336f488ef38 100644 --- a/src/Identity/Extensions.Core/src/IdentityBuilder.cs +++ b/src/Identity/Extensions.Core/src/IdentityBuilder.cs @@ -151,11 +151,9 @@ public virtual IdentityBuilder AddTokenProvider(string providerName, [Dynamicall { // 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.ProviderType is not null) + if (options.Tokens.ProviderMap.TryGetValue(providerName, out var descriptor)) { - descriptor.OtherProviderTypes ??= new(); - descriptor.OtherProviderTypes.Add(descriptor.ProviderType); - descriptor.ProviderType = provider; + descriptor.AddProviderType(provider); } else { 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 165888105240..8d62b076bea5 100644 --- a/src/Identity/Extensions.Core/src/TokenProviderDescriptor.cs +++ b/src/Identity/Extensions.Core/src/TokenProviderDescriptor.cs @@ -11,26 +11,40 @@ namespace Microsoft.AspNetCore.Identity; /// public class TokenProviderDescriptor { + // Temporary fix to test MapIdentityApi's support for multiple TUser and TContext. + // There's nothing as permanent as a temporary fix, but it seems better than now support. + 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; internal set; } + public Type ProviderType => _providerTypes.Peek(); /// /// If specified, the instance to be used for the token provider. /// public object? ProviderInstance { get; set; } - // Temporary fix to test MapIdentityApi's support for multiple TUser and TContext. - // There's nothing as permanent as a temporary fix, but it seems better than now support. - internal List? OtherProviderTypes { 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 8d89812f43d7..33c5eaaf62bd 100644 --- a/src/Identity/Extensions.Core/src/UserManager.cs +++ b/src/Identity/Extensions.Core/src/UserManager.cs @@ -106,23 +106,15 @@ public UserManager(IUserStore store, { var description = Options.Tokens.ProviderMap[providerName]; - var provider = (description.ProviderInstance ?? services.GetRequiredService(description.ProviderType)) - as IUserTwoFactorTokenProvider; - if (provider != null) + var provider = description.ProviderInstance as IUserTwoFactorTokenProvider; + if (provider == null && description.GetProviderType>() is Type providerType) { - RegisterTokenProvider(providerName, provider); + provider = (IUserTwoFactorTokenProvider)services.GetRequiredService(providerType); } - else if (description.OtherProviderTypes != null) + + if (provider != null) { - foreach (var otherProviderType in description.OtherProviderTypes) - { - var otherProvider = services.GetRequiredService(otherProviderType) as IUserTwoFactorTokenProvider; - if (otherProvider != null) - { - RegisterTokenProvider(providerName, otherProvider); - break; - } - } + RegisterTokenProvider(providerName, provider); } } } diff --git a/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs b/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs index 6cdfbdd440d1..11e9f50af873 100644 --- a/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs +++ b/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs @@ -15,6 +15,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.TestHost; using Microsoft.AspNetCore.Testing; @@ -57,7 +58,8 @@ public async Task LoginFailsGivenUnregisteredUser() await using var app = await CreateAppAsync(); using var client = app.GetTestClient(); - AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/login", new { Username, Password })); + await AssertUnauthorizedAndProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password }), + "Failed"); } [Fact] @@ -67,7 +69,8 @@ public async Task LoginFailsGivenWrongPassword() using var client = app.GetTestClient(); await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); - AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" })); + await AssertUnauthorizedAndProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" }), + "Failed"); } [Theory] @@ -167,7 +170,7 @@ public async Task CanLoginWithCookies() [Fact] public async Task CannotLoginWithCookiesWithOnlyCoreServices() { - await using var app = await CreateAppAsync(AddIdentityApiEndpointsBearerOnly); + await using var app = await CreateAppAsync(services => AddIdentityApiEndpointsBearerOnly(services)); using var client = app.GetTestClient(); await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); @@ -371,20 +374,25 @@ public async Task LoginCanBeLockedOut() AddIdentityApiEndpoints(services); services.Configure(options => { - options.Lockout.MaxFailedAccessAttempts = 1; + options.Lockout.MaxFailedAccessAttempts = 2; }); }); using var client = app.GetTestClient(); await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); - AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" })); + await AssertUnauthorizedAndProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" }), + "Failed"); + + await AssertUnauthorizedAndProblemAsync(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")); - AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/login", new { Username, Password })); + await AssertUnauthorizedAndProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password }), + "LockedOut"); } [Fact] @@ -403,7 +411,8 @@ public async Task LockoutCanBeDisabled() await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); - AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" })); + await AssertUnauthorizedAndProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" }), + "Failed"); Assert.DoesNotContain(TestSink.Writes, w => w.LoggerName == "Microsoft.AspNetCore.Identity.SignInManager" && @@ -429,21 +438,11 @@ public async Task AccountConfirmationCanBeEnabled() }); using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); - - var email = Assert.Single(emailSender.Emails); - - AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/login", new { Username, Password })); + await TestRegistrationWithAccountConfirmation(client, emailSender); Assert.Single(TestSink.Writes, w => w.LoggerName == "Microsoft.AspNetCore.Identity.SignInManager" && w.EventId == new EventId(4, "UserCannotSignInWithoutConfirmedAccount")); - - var confirmEmailResponse = await client.GetAsync(GetEmailConfirmationLink(email)); - confirmEmailResponse.EnsureSuccessStatusCode(); - - var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); - loginResponse.EnsureSuccessStatusCode(); } [Fact] @@ -462,21 +461,11 @@ public async Task EmailConfirmationCanBeEnabled() }); using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); - - var email = Assert.Single(emailSender.Emails); - - AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync("/identity/login", new { Username, Password })); + await TestRegistrationWithAccountConfirmation(client, emailSender); Assert.Single(TestSink.Writes, w => w.LoggerName == "Microsoft.AspNetCore.Identity.SignInManager" && w.EventId == new EventId(0, "UserCannotSignInWithoutConfirmedEmail")); - - var confirmEmailResponse = await client.GetAsync(GetEmailConfirmationLink(email)); - confirmEmailResponse.EnsureSuccessStatusCode(); - - var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); - loginResponse.EnsureSuccessStatusCode(); } [Fact] @@ -548,6 +537,41 @@ public async Task CanAddEndpointsToMultipleRouteGroupsForMultipleUsersTypes() await TestRegistrationWithAccountConfirmation(client, emailSender, "/identity2", Username); } + [Theory] + [MemberData(nameof(AddIdentityModes))] + public async Task CanUseTwoFactor(string addIdentityMode) + { + await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]); + + var userManager = app.Services.GetRequiredService>(); + Assert.True(userManager.SupportsUserTwoFactor); + + using var client = app.GetTestClient(); + + await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); + + var user = await userManager.FindByNameAsync(Username); + Assert.NotNull(user); + + // Enable 2fa + AssertSuccess(await userManager.ResetAuthenticatorKeyAsync(user)); + AssertSuccess(await userManager.SetTwoFactorEnabledAsync(user, enabled: true)); + + // Generate 2fa code from key + var authenticatorKey = await userManager.GetAuthenticatorKeyAsync(user); + Assert.NotNull(authenticatorKey); + var keyBytes = Base32.FromBase32(authenticatorKey); + var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var timestep = Convert.ToInt64(unixTimestamp / 30); + var twoFactorCode = Rfc6238AuthenticationService.ComputeTotp(keyBytes, (ulong)timestep, modifierBytes: null).ToString(); + + await AssertUnauthorizedAndProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password }), + "RequiresTwoFactor"); + + var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password, twoFactorCode }); + loginResponse.EnsureSuccessStatusCode(); + } + private async Task CreateAppAsync(Action? configureServices, bool autoStart = true) where TUser : class, new() where TContext : DbContext @@ -561,7 +585,7 @@ private async Task CreateAppAsync(Action dbConnection); - configureServices ??= AddIdentityApiEndpoints; + configureServices ??= services => AddIdentityApiEndpoints(services); configureServices(builder.Services); var app = builder.Build(); @@ -586,27 +610,28 @@ private async Task CreateAppAsync(Action(IServiceCollection services) + private static IdentityBuilder AddIdentityApiEndpoints(IServiceCollection services) where TUser : class, new() where TContext : DbContext { - services.AddDbContext((sp, options) => options.UseSqlite(sp.GetRequiredService())) + return services.AddDbContext((sp, options) => options.UseSqlite(sp.GetRequiredService())) .AddIdentityApiEndpoints().AddEntityFrameworkStores(); } - private static void AddIdentityApiEndpoints(IServiceCollection services) + private static IdentityBuilder AddIdentityApiEndpoints(IServiceCollection services) => AddIdentityApiEndpoints(services); - private static void AddIdentityApiEndpointsBearerOnly(IServiceCollection services) + private static IdentityBuilder AddIdentityApiEndpointsBearerOnly(IServiceCollection services) { services + .AddAuthentication(IdentityConstants.BearerScheme) + .AddIdentityBearerToken(); + + return services .AddDbContext((sp, options) => options.UseSqlite(sp.GetRequiredService())) .AddIdentityCore() .AddEntityFrameworkStores() .AddApiEndpoints(); - services - .AddAuthentication(IdentityConstants.BearerScheme) - .AddIdentityBearerToken(); } private Task CreateAppAsync(Action? configureServices = null) @@ -614,8 +639,8 @@ private Task CreateAppAsync(Action? configur private static Dictionary> AddIdentityActions { get; } = new() { - [nameof(AddIdentityApiEndpoints)] = AddIdentityApiEndpoints, - [nameof(AddIdentityApiEndpointsBearerOnly)] = AddIdentityApiEndpointsBearerOnly, + [nameof(AddIdentityApiEndpoints)] = services => AddIdentityApiEndpoints(services), + [nameof(AddIdentityApiEndpointsBearerOnly)] = services => AddIdentityApiEndpointsBearerOnly(services), }; public static object[][] AddIdentityModes => AddIdentityActions.Keys.Select(key => new object[] { key }).ToArray(); @@ -638,6 +663,20 @@ private static void AssertUnauthorizedAndEmpty(HttpResponseMessage response) Assert.Equal(0, response.Content.Headers.ContentLength); } + private static async Task AssertUnauthorizedAndProblemAsync(HttpResponseMessage response, string title) + { + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + var problem = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(problem); + Assert.Equal("Unauthorized", problem.Title); + Assert.Equal(title, problem.Detail); + } + + private static void AssertSuccess(IdentityResult result) + { + Assert.True(result.Succeeded); + } + private static string GetEmailConfirmationLink(Email email) { // Update if we add more links to the email. @@ -650,14 +689,15 @@ private static string GetEmailConfirmationLink(Email email) private async Task TestRegistrationWithAccountConfirmation(HttpClient client, TestEmailSender emailSender, string? groupPrefix = null, string? username = null) { - groupPrefix ??= ""; + groupPrefix ??= "/identity"; username ??= Username; await client.PostAsJsonAsync($"{groupPrefix}/register", new { username, Password, Email = username }); var email = emailSender.Emails.Last(); - AssertUnauthorizedAndEmpty(await client.PostAsJsonAsync($"{groupPrefix}/login", new { username, Password })); + await AssertUnauthorizedAndProblemAsync(await client.PostAsJsonAsync($"{groupPrefix}/login", new { username, Password }), + "NotAllowed"); var confirmEmailResponse = await client.GetAsync(GetEmailConfirmationLink(email)); confirmEmailResponse.EnsureSuccessStatusCode(); @@ -666,6 +706,30 @@ private async Task TestRegistrationWithAccountConfirmation(HttpClient client, Te loginResponse.EnsureSuccessStatusCode(); } + 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(); From 46e448190ca5b2a317910a2a1b2ff9e856e1fa84 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 18 Jul 2023 12:29:04 -0700 Subject: [PATCH 5/6] Add /account management APIs and general cleanup --- .../Core/src/DTO/AuthenticatorKeyResponse.cs | 13 + .../IdentityEndpointsJsonSerializerContext.cs | 6 + src/Identity/Core/src/DTO/InfoRequest.cs | 12 + src/Identity/Core/src/DTO/InfoResponse.cs | 11 + src/Identity/Core/src/DTO/LoginRequest.cs | 1 + ...orKeyResponse.cs => ResendEmailRequest.cs} | 4 +- .../Core/src/DTO/ResetPasswordRequest.cs | 11 + src/Identity/Core/src/DTO/TwoFactorRequest.cs | 14 + ...entityApiEndpointRouteBuilderExtensions.cs | 414 +++++++-- ...IdentityAuthenticationBuilderExtensions.cs | 38 - .../IdentityServiceCollectionExtensions.cs | 2 +- src/Identity/Core/src/PublicAPI.Unshipped.txt | 5 - src/Identity/Core/src/SignInManager.cs | 53 +- .../MapIdentityApiTests.cs | 840 ++++++++++++++++-- .../test/Identity.Test/SignInManagerTest.cs | 8 +- 15 files changed, 1201 insertions(+), 231 deletions(-) create mode 100644 src/Identity/Core/src/DTO/AuthenticatorKeyResponse.cs create mode 100644 src/Identity/Core/src/DTO/InfoRequest.cs create mode 100644 src/Identity/Core/src/DTO/InfoResponse.cs rename src/Identity/Core/src/DTO/{TwoFactorKeyResponse.cs => ResendEmailRequest.cs} (66%) create mode 100644 src/Identity/Core/src/DTO/ResetPasswordRequest.cs create mode 100644 src/Identity/Core/src/DTO/TwoFactorRequest.cs delete mode 100644 src/Identity/Core/src/IdentityAuthenticationBuilderExtensions.cs 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 c8023669212b..0f12850b1174 100644 --- a/src/Identity/Core/src/DTO/IdentityEndpointsJsonSerializerContext.cs +++ b/src/Identity/Core/src/DTO/IdentityEndpointsJsonSerializerContext.cs @@ -8,6 +8,12 @@ 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 1975574937e1..27e345f65d7f 100644 --- a/src/Identity/Core/src/DTO/LoginRequest.cs +++ b/src/Identity/Core/src/DTO/LoginRequest.cs @@ -8,4 +8,5 @@ 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/TwoFactorKeyResponse.cs b/src/Identity/Core/src/DTO/ResendEmailRequest.cs similarity index 66% rename from src/Identity/Core/src/DTO/TwoFactorKeyResponse.cs rename to src/Identity/Core/src/DTO/ResendEmailRequest.cs index d79ffadfa9bd..34aa7c11fd49 100644 --- a/src/Identity/Core/src/DTO/TwoFactorKeyResponse.cs +++ b/src/Identity/Core/src/DTO/ResendEmailRequest.cs @@ -3,7 +3,7 @@ namespace Microsoft.AspNetCore.Identity.DTO; -internal sealed class TwoFactorKeyResponse +internal sealed class ResendEmailRequest { - public required string SharedKey { get; init; } + 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 c3368b362f89..20ee16fdc2e3 100644 --- a/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs +++ b/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs @@ -1,14 +1,17 @@ // 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; @@ -25,7 +28,7 @@ namespace Microsoft.AspNetCore.Routing; /// public static class IdentityApiEndpointRouteBuilderExtensions { - private static readonly NoopResult _noopResult = new NoopResult(); + private static readonly NoopResult _noopHttpResult = new NoopResult(); /// /// Add endpoints for registering, logging in, and logging out using ASP.NET Core Identity. @@ -41,8 +44,6 @@ public static IEndpointConventionBuilder MapIdentityApi(this IEndpointRou { ArgumentNullException.ThrowIfNull(endpoints); - var routeGroup = endpoints.MapGroup(""); - var timeProvider = endpoints.ServiceProvider.GetRequiredService(); var bearerTokenOptions = endpoints.ServiceProvider.GetRequiredService>(); var emailSender = endpoints.ServiceProvider.GetRequiredService(); @@ -51,6 +52,8 @@ public static IEndpointConventionBuilder MapIdentityApi(this IEndpointRou // 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. // https://github.com/dotnet/aspnetcore/issues/47338 routeGroup.MapPost("/register", async Task> @@ -58,16 +61,11 @@ public static IEndpointConventionBuilder MapIdentityApi(this IEndpointRou { var userManager = sp.GetRequiredService>(); - if (!userManager.SupportsUserLockout) + if (!userManager.SupportsUserEmail) { throw new NotSupportedException($"{nameof(MapIdentityApi)} requires a user store with email support."); } - if (confirmEmailEndpointName is null) - { - throw new NotSupportedException("No email confirmation endpoint was registered!"); - } - var emailStore = (IUserEmailStore)sp.GetRequiredService>(); var user = new TUser(); @@ -77,43 +75,39 @@ public static IEndpointConventionBuilder MapIdentityApi(this IEndpointRou if (result.Succeeded) { - var userId = await userManager.GetUserIdAsync(user); - var code = await userManager.GenerateEmailConfirmationTokenAsync(user); - code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); - - var confirmEmailUrl = linkGenerator.GetPathByName(confirmEmailEndpointName, new() - { - ["userId"] = userId, - ["code"] = code, - }); - - if (confirmEmailUrl is null) - { - throw new NotSupportedException($"Could not find endpoint named '{confirmEmailEndpointName}'."); - } - - await emailSender.SendEmailAsync(registration.Email, "Confirm your email", - $"Please confirm your account by clicking here."); - + 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, ProblemHttpResult, IResult>> - ([FromBody] LoginRequest login, [FromQuery] bool? cookieMode, [FromServices] IServiceProvider sp) => + ([FromBody] LoginRequest login, [FromQuery] bool? cookieMode, [FromQuery] bool? persistCookies, [FromServices] IServiceProvider sp) => { var signInManager = sp.GetRequiredService>(); signInManager.PrimaryAuthenticationScheme = cookieMode == true ? IdentityConstants.ApplicationScheme : IdentityConstants.BearerScheme; - signInManager.TwoFactorCode = login.TwoFactorCode; - var result = await signInManager.PasswordSignInAsync(login.Username, login.Password, isPersistent: true, lockoutOnFailure: true); + var isPersistent = persistCookies ?? true; + + var result = await signInManager.PasswordSignInAsync(login.Username, login.Password, isPersistent, lockoutOnFailure: true); + + if (result.RequiresTwoFactor) + { + if (!string.IsNullOrEmpty(login.TwoFactorCode)) + { + result = await signInManager.TwoFactorAuthenticatorSignInAsync(login.TwoFactorCode, isPersistent, rememberClient: isPersistent); + } + else if (!string.IsNullOrEmpty(login.TwoFactorRecoveryCode)) + { + result = await signInManager.TwoFactorRecoveryCodeSignInAsync(login.TwoFactorRecoveryCode); + } + } if (result.Succeeded) { // The signInManager already produced the needed response in the form of a cookie or bearer token. - return _noopResult; + return _noopHttpResult; } return TypedResults.Problem(result.ToString(), statusCode: StatusCodes.Status401Unauthorized); @@ -140,20 +134,32 @@ await signInManager.ValidateSecurityStampAsync(refreshTicket.Principal) is not T }); routeGroup.MapGet("/confirmEmail", async Task> - ([FromQuery] string userId, [FromQuery] string code, [FromServices] IServiceProvider sp) => + ([FromQuery] string userId, [FromQuery] string code, [FromQuery] string? changedEmail, [FromServices] IServiceProvider sp) => { var userManager = sp.GetRequiredService>(); - - var user = await userManager.FindByIdAsync(userId); - if (user is null) + 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 disclosure. + // We could respond with a 404 instead of a 401 like Identity UI, but that feels like unnecessary information. return TypedResults.Unauthorized(); } - code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code)); - var result = await userManager.ConfirmEmailAsync(user, code); + 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) { @@ -170,70 +176,343 @@ await signInManager.ValidateSecurityStampAsync(refreshTicket.Principal) is not T endpointBuilder.Metadata.Add(new RouteNameMetadata(confirmEmailEndpointName)); }); - var accountGroup = routeGroup.MapGroup("/account").RequireAuthorization(); + 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(); + } - accountGroup.MapPost("/get2faKey", async Task, NotFound>> - (ClaimsPrincipal claimsPrincipal, [FromServices] IServiceProvider sp) => + await SendConfirmationEmailAsync(user, userManager, resendRequest.Email); + return TypedResults.Ok(); + }); + + routeGroup.MapPost("/resetPassword", async Task> + ([FromBody] ResetPasswordRequest resetRequest, [FromServices] IServiceProvider sp) => { var userManager = sp.GetRequiredService>(); - var user = await userManager.GetUserAsync(claimsPrincipal); - if (user is null) + if (!string.IsNullOrEmpty(resetRequest.ResetCode) && string.IsNullOrEmpty(resetRequest.NewPassword)) { - return TypedResults.NotFound(); + return CreateValidationProblem("MissingNewPassword", "A password reset code was provided without a new password."); } - var key = await userManager.GetAuthenticatorKeyAsync(user); - if (string.IsNullOrEmpty(key)) + var user = await userManager.FindByEmailAsync(resetRequest.Email); + + if (user is null || !(await userManager.IsEmailConfirmedAsync(user))) { - await userManager.ResetAuthenticatorKeyAsync(user); - key = await userManager.GetAuthenticatorKeyAsync(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)); - return TypedResults.Ok(new TwoFactorKeyResponse + await emailSender.SendEmailAsync(resetRequest.Email, "Reset your password", + $"Reset your password using the following code: {HtmlEncoder.Default.Encode(code)}"); + } + else { - SharedKey = key!, - }); + 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(); }); - accountGroup.MapPost("/enable2fa", async Task> - (ClaimsPrincipal claimsPrincipal, [FromQuery] string twoFactorCode, [FromServices] IServiceProvider sp) => + var accountGroup = routeGroup.MapGroup("/account").RequireAuthorization(); + + accountGroup.MapGet("/2fa", async Task, NotFound>> + (ClaimsPrincipal claimsPrincipal, [FromServices] IServiceProvider sp) => { - var userManager = sp.GetRequiredService>(); - var user = await userManager.GetUserAsync(claimsPrincipal); + var signInManager = sp.GetRequiredService>(); + if (await signInManager.UserManager.GetUserAsync(claimsPrincipal) is not { } user) + { + return TypedResults.NotFound(); + } - if (user is null) + 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 (!await userManager.VerifyTwoFactorTokenAsync(user, userManager.Options.Tokens.AuthenticatorTokenProvider, twoFactorCode)) + 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 provide 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) { - return TypedResults.Problem("InvalidCode"); + await userManager.ResetAuthenticatorKeyAsync(user); } - await userManager.SetTwoFactorEnabledAsync(user, true); - return TypedResults.Ok(); + 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.MapPost("/disable2fa", async Task> + accountGroup.MapGet("/info", async Task, ValidationProblem, NotFound>> (ClaimsPrincipal claimsPrincipal, [FromServices] IServiceProvider sp) => { var userManager = sp.GetRequiredService>(); - var user = await userManager.GetUserAsync(claimsPrincipal); + if (await userManager.GetUserAsync(claimsPrincipal) is not { } user) + { + return TypedResults.NotFound(); + } + + return TypedResults.Ok(await CreateInfoResponseAsync(user, claimsPrincipal, userManager)); + }); - if (user is null) + 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(); } - await userManager.SetTwoFactorEnabledAsync(user, false); - return TypedResults.Ok(); + var failedIdentityResults = new List(); + + if (!string.IsNullOrEmpty(infoRequest.NewUsername)) + { + var userName = await userManager.GetUserNameAsync(user); + + if (userName != infoRequest.NewUsername) + { + var setUserNameResult = await userManager.SetUserNameAsync(user, infoRequest.NewUsername); + + if (!setUserNameResult.Succeeded) + { + failedIdentityResults.Add(setUserNameResult); + } + } + } + + 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)) + { + failedIdentityResults.Add(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 + { + var changePasswordResult = await userManager.ChangePasswordAsync(user, infoRequest.OldPassword, infoRequest.NewPassword); + + if (!changePasswordResult.Succeeded) + { + failedIdentityResults.Add(changePasswordResult); + } + } + } + + // 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 (failedIdentityResults.Count > 0) + { + return CreateValidationProblem(failedIdentityResults.ToArray()); + } + 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); + + if (confirmEmailUrl is null) + { + 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 ValidationProblem CreateValidationProblem(string errorCode, string errorDescription) => + TypedResults.ValidationProblem(new Dictionary { + { errorCode, new[] { errorDescription } } + }); + + private static ValidationProblem CreateValidationProblem(params IdentityResult[] results) + { + var errorDictionary = new Dictionary(results.Length); + + foreach (var result in results) + { + 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; + } + } + + return TypedResults.ValidationProblem(errorDictionary); + } + + 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); + } + + 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 + { + var claimsArray = claimsPrincipal.Claims.ToArray(); + var claimsDictionary = new Dictionary(claimsArray.Length); + + foreach (var claim in claimsArray) + { + claimsDictionary.Add(claim.Type, claim.Value); + } + + 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 = claimsDictionary, + }; + } + // Wrap RouteGroupBuilder with a non-public type to avoid a potential future behavioral breaking change. private sealed class IdentityEndpointsConventionBuilder(RouteGroupBuilder inner) : IEndpointConventionBuilder { @@ -264,4 +543,3 @@ private sealed class FromQueryAttribute : Attribute, IFromQueryMetadata public string? Name => null; } } - 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/IdentityServiceCollectionExtensions.cs b/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs index db622a841182..0fd66e73a05f 100644 --- a/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs +++ b/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs @@ -144,7 +144,7 @@ public static IdentityBuilder AddIdentityApiEndpoints(this IServiceCollec compositeOptions.ForwardDefault = IdentityConstants.BearerScheme; compositeOptions.ForwardAuthenticate = IdentityConstants.BearerAndApplicationScheme; }) - .AddIdentityBearerToken() + .AddBearerToken(IdentityConstants.BearerScheme) .AddIdentityCookies(); return services.AddIdentityCore(configure) diff --git a/src/Identity/Core/src/PublicAPI.Unshipped.txt b/src/Identity/Core/src/PublicAPI.Unshipped.txt index 128df69fa552..9d71c07cd782 100644 --- a/src/Identity/Core/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Core/src/PublicAPI.Unshipped.txt @@ -1,17 +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.SignInManager.TwoFactorCode.get -> string? -Microsoft.AspNetCore.Identity.SignInManager.TwoFactorCode.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 26804456ff16..7c9f98ed672d 100644 --- a/src/Identity/Core/src/SignInManager.cs +++ b/src/Identity/Core/src/SignInManager.cs @@ -25,7 +25,7 @@ public class SignInManager where TUser : class private readonly IAuthenticationSchemeProvider _schemes; private readonly IUserConfirmation _confirmation; private HttpContext? _context; - private TUser? _twoFactorUser; + private TwoFactorAuthenticationInfo? _twoFactorInfo; /// /// Creates a new instance of . @@ -86,11 +86,6 @@ public SignInManager(UserManager userManager, /// public string PrimaryAuthenticationScheme { get; set; } = IdentityConstants.ApplicationScheme; - /// - /// The two-factor code used to immediately complete password sign in if required. - /// - public string? TwoFactorCode { get; set; } - /// /// The used. /// @@ -245,6 +240,9 @@ public virtual async Task SignInWithClaimsAsync(TUser user, AuthenticationProper 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; } /// @@ -253,8 +251,15 @@ await Context.SignInAsync(PrimaryAuthenticationScheme, public virtual async Task SignOutAsync() { await Context.SignOutAsync(PrimaryAuthenticationScheme); - await Context.SignOutAsync(IdentityConstants.ExternalScheme); - await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); + + if (await _schemes.GetSchemeAsync(IdentityConstants.ExternalScheme) != null) + { + await Context.SignOutAsync(IdentityConstants.ExternalScheme); + } + if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorUserIdScheme) != null) + { + await Context.SignOutAsync(IdentityConstants.TwoFactorUserIdScheme); + } } /// @@ -425,7 +430,7 @@ public virtual async Task CheckPasswordSignInAsync(TUser user, str /// public virtual async Task IsTwoFactorClientRememberedAsync(TUser user) { - if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorUserIdScheme) == null) + if (await _schemes.GetSchemeAsync(IdentityConstants.TwoFactorRememberMeScheme) == null) { return false; } @@ -495,10 +500,13 @@ 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 @@ -802,14 +810,16 @@ protected virtual async Task SignInOrTwoFactorAsync(TUser user, bo { if (!bypassTwoFactor && await IsTwoFactorEnabledAsync(user)) { - if (TwoFactorCode != null) - { - _twoFactorUser = user; - // isPersistent and rememberClient affect the application and 2fa cookies respectively if used. - return await TwoFactorAuthenticatorSignInAsync(TwoFactorCode, isPersistent, rememberClient: isPersistent); - } - else if (!await IsTwoFactorClientRememberedAsync(user)) + if (!await IsTwoFactorClientRememberedAsync(user)) { + // 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 @@ -838,19 +848,16 @@ protected virtual async Task SignInOrTwoFactorAsync(TUser user, bo private async Task RetrieveTwoFactorInfoAsync() { - if (_twoFactorUser != null) + if (_twoFactorInfo != null) { - return new TwoFactorAuthenticationInfo - { - User = _twoFactorUser, - }; + return _twoFactorInfo; } var result = await Context.AuthenticateAsync(IdentityConstants.TwoFactorUserIdScheme); if (result?.Principal == null) { return null; - } + } var userId = result.Principal.FindFirstValue(ClaimTypes.Name); if (userId == null) diff --git a/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs b/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs index 11e9f50af873..712460627aa5 100644 --- a/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs +++ b/src/Identity/test/Identity.FunctionalTests/MapIdentityApiTests.cs @@ -19,6 +19,7 @@ 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; @@ -31,7 +32,7 @@ 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))] @@ -58,7 +59,7 @@ public async Task LoginFailsGivenUnregisteredUser() await using var app = await CreateAppAsync(); using var client = app.GetTestClient(); - await AssertUnauthorizedAndProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password }), + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password }), "Failed"); } @@ -68,8 +69,8 @@ public async Task LoginFailsGivenWrongPassword() await using var app = await CreateAppAsync(); using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); - await AssertUnauthorizedAndProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" }), + await RegisterAsync(client); + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" }), "Failed"); } @@ -80,7 +81,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, Email = Username }); + await RegisterAsync(client); var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); loginResponse.EnsureSuccessStatusCode(); @@ -109,7 +110,7 @@ public async Task CanCustomizeBearerTokenExpiration() 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; }); @@ -117,7 +118,7 @@ public async Task CanCustomizeBearerTokenExpiration() using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); + await RegisterAsync(client); var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); var loginContent = await loginResponse.Content.ReadFromJsonAsync(); @@ -148,22 +149,20 @@ public async Task CanLoginWithCookies() await using var app = await CreateAppAsync(); using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); + 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 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")); } @@ -173,7 +172,7 @@ public async Task CannotLoginWithCookiesWithOnlyCoreServices() await using var app = await CreateAppAsync(services => AddIdentityApiEndpointsBearerOnly(services)); using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); + await RegisterAsync(client); await Assert.ThrowsAsync(() => client.PostAsJsonAsync("/identity/login?cookieMode=true", new { Username, Password })); @@ -186,7 +185,7 @@ public async Task CanReadBearerTokenFromQueryString() { 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 => { @@ -198,7 +197,7 @@ public async Task CanReadBearerTokenFromQueryString() using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); + await RegisterAsync(client); var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); var loginContent = await loginResponse.Content.ReadFromJsonAsync(); @@ -234,14 +233,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, Email = Username }); + 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")); @@ -271,7 +270,7 @@ public async Task CanCustomizeRefreshTokenExpiration() 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; }); @@ -279,7 +278,7 @@ public async Task CanCustomizeRefreshTokenExpiration() using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); + await RegisterAsync(client); var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); var loginContent = await loginResponse.Content.ReadFromJsonAsync(); @@ -323,10 +322,8 @@ public async Task RefreshReturns401UnauthorizedIfSecurityStampChanges() await using var app = await CreateAppAsync(); using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); - 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); @@ -344,10 +341,8 @@ public async Task RefreshUpdatesUserFromStore() await using var app = await CreateAppAsync(); using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); - 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); @@ -379,19 +374,19 @@ public async Task LoginCanBeLockedOut() }); using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); + await RegisterAsync(client); - await AssertUnauthorizedAndProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" }), + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" }), "Failed"); - await AssertUnauthorizedAndProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" }), + 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 AssertUnauthorizedAndProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password }), + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password }), "LockedOut"); } @@ -409,17 +404,16 @@ public async Task LockoutCanBeDisabled() }); using var client = app.GetTestClient(); - await client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); + await RegisterAsync(client); - await AssertUnauthorizedAndProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password = "wrong" }), + 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")); - var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); - loginResponse.EnsureSuccessStatusCode(); + AssertOk(await client.PostAsJsonAsync("/identity/login", new { Username, Password })); } [Fact] @@ -438,8 +432,10 @@ public async Task AccountConfirmationCanBeEnabled() }); using var client = app.GetTestClient(); - await TestRegistrationWithAccountConfirmation(client, emailSender); + 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")); @@ -461,13 +457,53 @@ public async Task EmailConfirmationCanBeEnabled() }); using var client = app.GetTestClient(); - await TestRegistrationWithAccountConfirmation(client, emailSender); + 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")); } + [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() { @@ -489,8 +525,12 @@ public async Task CanAddEndpointsToMultipleRouteGroupsForSameUserType() await app.StartAsync(); using var client = app.GetTestClient(); - await TestRegistrationWithAccountConfirmation(client, emailSender, "/identity", "a@example.com"); - await TestRegistrationWithAccountConfirmation(client, emailSender, "/identity2", "b@example.com"); + // 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] @@ -533,43 +573,597 @@ public async Task CanAddEndpointsToMultipleRouteGroupsForMultipleUsersTypes() using var client = app.GetTestClient(); // We can use the same username twice since we're using two distinct DbContexts. - await TestRegistrationWithAccountConfirmation(client, emailSender, "/identity", Username); - await TestRegistrationWithAccountConfirmation(client, emailSender, "/identity2", Username); + 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 CanUseTwoFactor(string addIdentityMode) + public async Task CanEnableAndLoginWithTwoFactor(string addIdentityMode) { await using var app = await CreateAppAsync(AddIdentityActions[addIdentityMode]); + using var client = app.GetTestClient(); - var userManager = app.Services.GetRequiredService>(); - Assert.True(userManager.SupportsUserTwoFactor); + 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 + 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 client.PostAsJsonAsync("/identity/register", new { Username, Password, Email = Username }); + await RegisterAsync(client); + var loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password }); - var user = await userManager.FindByNameAsync(Username); - Assert.NotNull(user); + var loginContent = await loginResponse.Content.ReadFromJsonAsync(); + var accessToken = loginContent.GetProperty("access_token").GetString(); + client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); - // Enable 2fa - AssertSuccess(await userManager.ResetAuthenticatorKeyAsync(user)); - AssertSuccess(await userManager.SetTwoFactorEnabledAsync(user, enabled: true)); + var twoFactorKeyResponse = await client.GetFromJsonAsync("/identity/account/2fa"); + var sharedKey = twoFactorKeyResponse.GetProperty("sharedKey").GetString(); - // Generate 2fa code from key - var authenticatorKey = await userManager.GetAuthenticatorKeyAsync(user); - Assert.NotNull(authenticatorKey); - var keyBytes = Base32.FromBase32(authenticatorKey); + 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 AssertUnauthorizedAndProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password }), + 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 loginResponse = await client.PostAsJsonAsync("/identity/login", new { Username, Password, twoFactorCode }); - loginResponse.EnsureSuccessStatusCode(); + 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()); + + client.DefaultRequestHeaders.Clear(); + + await AssertProblemAsync(await client.PostAsJsonAsync("/identity/login", new { Username, Password }), + "RequiresTwoFactor"); + + var twoFactorLoginResponse = await client.PostAsJsonAsync("/identity/login?cookieMode=true", new { Username, Password, twoFactorCode }); + ApplyCookies(client, twoFactorLoginResponse); + + var cookie2faResponse = await client.GetFromJsonAsync("/identity/account/2fa"); + Assert.True(cookie2faResponse.GetProperty("isTwoFactorEnabled").GetBoolean()); + Assert.False(enable2faContent.GetProperty("isMachineRemembered").GetBoolean()); + + client.DefaultRequestHeaders.Clear(); + + var persistentLoginResponse = await client.PostAsJsonAsync("/identity/login?cookieMode=true&persistCookies=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 invalidated 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) @@ -624,8 +1218,8 @@ private static IdentityBuilder AddIdentityApiEndpoints(IServiceCollection servic private static IdentityBuilder AddIdentityApiEndpointsBearerOnly(IServiceCollection services) { services - .AddAuthentication(IdentityConstants.BearerScheme) - .AddIdentityBearerToken(); + .AddAuthentication() + .AddBearerToken(IdentityConstants.BearerScheme); return services .AddDbContext((sp, options) => options.UseSqlite(sp.GetRequiredService())) @@ -645,6 +1239,77 @@ private Task CreateAppAsync(Action? configur 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); @@ -663,47 +1328,42 @@ private static void AssertUnauthorizedAndEmpty(HttpResponseMessage response) Assert.Equal(0, response.Content.Headers.ContentLength); } - private static async Task AssertUnauthorizedAndProblemAsync(HttpResponseMessage response, string title) + private static async Task AssertProblemAsync(HttpResponseMessage response, string detail, HttpStatusCode status = HttpStatusCode.Unauthorized) { - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(status, response.StatusCode); var problem = await response.Content.ReadFromJsonAsync(); Assert.NotNull(problem); - Assert.Equal("Unauthorized", problem.Title); - Assert.Equal(title, problem.Detail); - } - - private static void AssertSuccess(IdentityResult result) - { - Assert.True(result.Succeeded); + Assert.Equal(ReasonPhrases.GetReasonPhrase((int)status), problem.Title); + Assert.Equal(detail, problem.Detail); } - private static string GetEmailConfirmationLink(Email email) + private static async Task AssertValidationProblemAsync(HttpResponseMessage response, string error) { - // 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); + 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 async Task TestRegistrationWithAccountConfirmation(HttpClient client, TestEmailSender emailSender, string? groupPrefix = null, string? username = null) + private static void ApplyCookies(HttpClient client, HttpResponseMessage response) { - groupPrefix ??= "/identity"; - username ??= Username; - - await client.PostAsJsonAsync($"{groupPrefix}/register", new { username, Password, Email = username }); - - var email = emailSender.Emails.Last(); - - await AssertUnauthorizedAndProblemAsync(await client.PostAsJsonAsync($"{groupPrefix}/login", new { username, Password }), - "NotAllowed"); + AssertOk(response); - var confirmEmailResponse = await client.GetAsync(GetEmailConfirmationLink(email)); - confirmEmailResponse.EnsureSuccessStatusCode(); + 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!"); + } - var loginResponse = await client.PostAsJsonAsync($"{groupPrefix}/login", new { username, Password }); - loginResponse.EnsureSuccessStatusCode(); + // Cookies starting with "CookieName=;" are being deleted + if (!cookie.EndsWith("=", StringComparison.Ordinal)) + { + client.DefaultRequestHeaders.Add(HeaderNames.Cookie, cookie); + } + } } private sealed class TestTokenProvider : IUserTwoFactorTokenProvider diff --git a/src/Identity/test/Identity.Test/SignInManagerTest.cs b/src/Identity/test/Identity.Test/SignInManagerTest.cs index 6f7082fe09e8..415e1ba04f4d 100644 --- a/src/Identity/test/Identity.Test/SignInManagerTest.cs +++ b/src/Identity/test/Identity.Test/SignInManagerTest.cs @@ -364,7 +364,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 +406,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 +485,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 +628,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) From cc1148e323b765e4561bb71943c1317523f39058 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 20 Jul 2023 17:47:15 -0700 Subject: [PATCH 6/6] Minor cleanup --- ...entityApiEndpointRouteBuilderExtensions.cs | 10 ++----- .../Microsoft.AspNetCore.Identity.UI.csproj | 13 +++++---- .../test/Identity.Test/SignInManagerTest.cs | 28 ++++++++++++++++++- 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs b/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs index 20ee16fdc2e3..c7d9471e8dd8 100644 --- a/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs +++ b/src/Identity/Core/src/IdentityApiEndpointRouteBuilderExtensions.cs @@ -28,7 +28,7 @@ namespace Microsoft.AspNetCore.Routing; /// public static class IdentityApiEndpointRouteBuilderExtensions { - private static readonly NoopResult _noopHttpResult = new NoopResult(); + private static readonly NoopResult _noopHttpResult = new(); /// /// Add endpoints for registering, logging in, and logging out using ASP.NET Core Identity. @@ -424,12 +424,8 @@ async Task SendConfirmationEmailAsync(TUser user, UserManager userManager routeValues.Add("changedEmail", email); } - var confirmEmailUrl = linkGenerator.GetPathByName(confirmEmailEndpointName, routeValues); - - if (confirmEmailUrl is null) - { - throw new NotSupportedException($"Could not find endpoint named '{confirmEmailEndpointName}'."); - } + 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."); diff --git a/src/Identity/UI/src/Microsoft.AspNetCore.Identity.UI.csproj b/src/Identity/UI/src/Microsoft.AspNetCore.Identity.UI.csproj index 7f203921cfbf..986c84729a81 100644 --- a/src/Identity/UI/src/Microsoft.AspNetCore.Identity.UI.csproj +++ b/src/Identity/UI/src/Microsoft.AspNetCore.Identity.UI.csproj @@ -36,10 +36,6 @@ - - - - <_RazorGenerate Include="Areas\Identity\Pages\**\*.cshtml" /> @@ -61,7 +57,14 @@ $([System.IO.Path]::GetFullPath($(_ReferenceAssetContentRoot))) - + diff --git a/src/Identity/test/Identity.Test/SignInManagerTest.cs b/src/Identity/test/Identity.Test/SignInManagerTest.cs index 415e1ba04f4d..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; @@ -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(); + } }