diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a30ea9f0a474..5a4bad217170 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -11,19 +11,23 @@ } }, // Add the IDs of extensions you want installed when the container is created. - "extensions": [ - "ms-dotnettools.csdevkit", - "EditorConfig.EditorConfig", - "k--kato.docomment", - "dbaeumer.vscode-eslint" - ], - "settings": { - "dotnet.defaultSolution": "AspNetCore.slnx", - // Loading projects on demand is better for larger codebases - "omnisharp.enableMsBuildLoadProjectsOnDemand": true, - "omnisharp.enableRoslynAnalyzers": true, - "omnisharp.enableEditorConfigSupport": true, - "omnisharp.enableImportCompletion": true, + "customizations": { + "vscode": { + "extensions": [ + "ms-dotnettools.csdevkit", + "EditorConfig.EditorConfig", + "k--kato.docomment", + "dbaeumer.vscode-eslint" + ], + "settings": { + "dotnet.defaultSolution": "AspNetCore.slnx", + // Loading projects on demand is better for larger codebases + "omnisharp.enableMsBuildLoadProjectsOnDemand": true, + "omnisharp.enableRoslynAnalyzers": true, + "omnisharp.enableEditorConfigSupport": true, + "omnisharp.enableImportCompletion": true + } + } }, // Use 'postCreateCommand' to run commands after the container is created. "onCreateCommand": "bash -i ${containerWorkspaceFolder}/.devcontainer/scripts/container-creation.sh", diff --git a/src/Identity/EntityFrameworkCore/src/IdentityUserContext.cs b/src/Identity/EntityFrameworkCore/src/IdentityUserContext.cs index 60a3d5d887d3..409f7f12c825 100644 --- a/src/Identity/EntityFrameworkCore/src/IdentityUserContext.cs +++ b/src/Identity/EntityFrameworkCore/src/IdentityUserContext.cs @@ -191,6 +191,7 @@ internal virtual void OnModelCreatingVersion3(ModelBuilder builder) { // Differences from Version 2: // - Add a passkey entity + // - Changes added to IdentityUserToken and IdentityUserLogin to add a unique id field. var storeOptions = GetStoreOptions(); var maxKeyLength = storeOptions?.MaxLengthForKeys ?? 0; @@ -244,7 +245,8 @@ internal virtual void OnModelCreatingVersion3(ModelBuilder builder) builder.Entity(b => { - b.HasKey(l => new { l.LoginProvider, l.ProviderKey }); + b.HasKey(l => l.Id); + b.HasIndex(l => new { l.LoginProvider, l.ProviderKey }).IsUnique(); if (maxKeyLength > 0) { @@ -257,7 +259,8 @@ internal virtual void OnModelCreatingVersion3(ModelBuilder builder) builder.Entity(b => { - b.HasKey(t => new { t.UserId, t.LoginProvider, t.Name }); + b.HasKey(t => t.Id); + b.HasIndex(t => new { t.UserId, t.LoginProvider, t.Name }).IsUnique(); if (maxKeyLength > 0) { @@ -356,6 +359,9 @@ internal virtual void OnModelCreatingVersion2(ModelBuilder builder) { b.HasKey(l => new { l.LoginProvider, l.ProviderKey }); + // V2 schema uses composite key, ignore the Id property added for V3 + b.Ignore(l => l.Id); + if (maxKeyLength > 0) { b.Property(l => l.LoginProvider).HasMaxLength(maxKeyLength); @@ -369,6 +375,9 @@ internal virtual void OnModelCreatingVersion2(ModelBuilder builder) { b.HasKey(t => new { t.UserId, t.LoginProvider, t.Name }); + // V2 schema uses composite key, ignore the Id property added for V3 + b.Ignore(t => t.Id); + if (maxKeyLength > 0) { b.Property(t => t.LoginProvider).HasMaxLength(maxKeyLength); @@ -451,6 +460,9 @@ internal virtual void OnModelCreatingVersion1(ModelBuilder builder) { b.HasKey(l => new { l.LoginProvider, l.ProviderKey }); + // V1 schema uses composite key, ignore the Id property added for V3 + b.Ignore(l => l.Id); + if (maxKeyLength > 0) { b.Property(l => l.LoginProvider).HasMaxLength(maxKeyLength); @@ -464,6 +476,9 @@ internal virtual void OnModelCreatingVersion1(ModelBuilder builder) { b.HasKey(t => new { t.UserId, t.LoginProvider, t.Name }); + // V1 schema uses composite key, ignore the Id property added for V3 + b.Ignore(t => t.Id); + if (maxKeyLength > 0) { b.Property(t => t.LoginProvider).HasMaxLength(maxKeyLength); diff --git a/src/Identity/EntityFrameworkCore/src/PublicAPI.Unshipped.txt b/src/Identity/EntityFrameworkCore/src/PublicAPI.Unshipped.txt index ce6365507d75..f8dc8f8210d3 100644 --- a/src/Identity/EntityFrameworkCore/src/PublicAPI.Unshipped.txt +++ b/src/Identity/EntityFrameworkCore/src/PublicAPI.Unshipped.txt @@ -32,6 +32,7 @@ override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindByLoginAsync(string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindByNameAsync(string! normalizedUserName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindTokenAsync(TUser! user, string! loginProvider, string! name, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindTokenByUniqueIndexAsync(TUser! user, string! loginProvider, string! name, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindUserAsync(TKey userId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindUserLoginAsync(string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserOnlyStore.FindUserLoginAsync(TKey userId, string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! @@ -56,6 +57,7 @@ override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindByNameAsync(string! normalizedUserName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindRoleAsync(string! normalizedRoleName, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindTokenAsync(TUser! user, string! loginProvider, string! name, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindTokenByUniqueIndexAsync(TUser! user, string! loginProvider, string! name, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindUserAsync(TKey userId, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindUserLoginAsync(string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! override Microsoft.AspNetCore.Identity.EntityFrameworkCore.UserStore.FindUserLoginAsync(TKey userId, string! loginProvider, string! providerKey, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! diff --git a/src/Identity/EntityFrameworkCore/src/RoleStore.cs b/src/Identity/EntityFrameworkCore/src/RoleStore.cs index e77cc173dada..3d27aeb05a01 100644 --- a/src/Identity/EntityFrameworkCore/src/RoleStore.cs +++ b/src/Identity/EntityFrameworkCore/src/RoleStore.cs @@ -354,7 +354,13 @@ protected void ThrowIfDisposed() ArgumentNullException.ThrowIfNull(role); ArgumentNullException.ThrowIfNull(claim); - RoleClaims.Add(CreateRoleClaim(role, claim)); + var roleClaim = CreateRoleClaim(role, claim); + // For string keys, ensure Id is set before adding to EF + if (typeof(TKey) == typeof(string) && EqualityComparer.Default.Equals(roleClaim.Id, default!)) + { + roleClaim.Id = (TKey)(object)Guid.NewGuid().ToString(); + } + RoleClaims.Add(roleClaim); return Task.FromResult(false); } diff --git a/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs b/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs index 2db6172b8f35..f8f4434c11bb 100644 --- a/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs +++ b/src/Identity/EntityFrameworkCore/src/UserOnlyStore.cs @@ -262,7 +262,7 @@ protected Task SaveChanges(CancellationToken cancellationToken) cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); var id = ConvertIdFromString(userId); - return UsersSet.FindAsync(new object?[] { id }, cancellationToken).AsTask(); + return UsersSet.FindAsync([id], cancellationToken).AsTask(); } /// @@ -529,9 +529,21 @@ join user in Users on userclaims.UserId equals user.Id /// The name of the token. /// The used to propagate notifications that the operation should be canceled. /// The user token if it exists. + [Obsolete("This method uses composite primary keys from V1/V2 schema. Consider using FindTokenByUniqueIndexAsync for V3 schema with Id primary key and unique indexes.")] protected override Task FindTokenAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken) => UserTokens.FindAsync(new object[] { user.Id, loginProvider, name }, cancellationToken).AsTask(); + /// + /// Find a user token if it exists using the unique index (for V3 schema with Id primary key). + /// + /// The token owner. + /// The login provider for the token. + /// The name of the token. + /// The used to propagate notifications that the operation should be canceled. + /// The user token if it exists. + protected override Task FindTokenByUniqueIndexAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken) + => UserTokens.SingleOrDefaultAsync(ut => ut.UserId.Equals(user.Id) && ut.LoginProvider == loginProvider && ut.Name == name, cancellationToken); + /// /// Add a new user token. /// @@ -539,6 +551,11 @@ join user in Users on userclaims.UserId equals user.Id /// protected override Task AddUserTokenAsync(TUserToken token) { + // For string keys in V3 schema, ensure Id is set before adding to EF + if (typeof(TKey) == typeof(string) && EqualityComparer.Default.Equals(token.Id, default!)) + { + token.Id = (TKey)(object)Guid.NewGuid().ToString(); + } UserTokens.Add(token); return Task.CompletedTask; } diff --git a/src/Identity/EntityFrameworkCore/src/UserStore.cs b/src/Identity/EntityFrameworkCore/src/UserStore.cs index 6ee3419cbb0d..a9207a358f48 100644 --- a/src/Identity/EntityFrameworkCore/src/UserStore.cs +++ b/src/Identity/EntityFrameworkCore/src/UserStore.cs @@ -470,7 +470,13 @@ where userRole.UserId.Equals(userId) ArgumentNullException.ThrowIfNull(claims); foreach (var claim in claims) { - UserClaims.Add(CreateUserClaim(user, claim)); + var userClaim = CreateUserClaim(user, claim); + // For string keys, ensure Id is set before adding to EF + if (typeof(TKey) == typeof(string) && EqualityComparer.Default.Equals(userClaim.Id, default!)) + { + userClaim.Id = (TKey)(object)Guid.NewGuid().ToString(); + } + UserClaims.Add(userClaim); } return Task.FromResult(false); } @@ -534,7 +540,13 @@ public override Task AddLoginAsync(TUser user, UserLoginInfo login, ThrowIfDisposed(); ArgumentNullException.ThrowIfNull(user); ArgumentNullException.ThrowIfNull(login); - UserLogins.Add(CreateUserLogin(user, login)); + var userLogin = CreateUserLogin(user, login); + // For string keys in V3 schema, ensure Id is set before adding to EF + if (typeof(TKey) == typeof(string) && EqualityComparer.Default.Equals(userLogin.Id, default!)) + { + userLogin.Id = (TKey)(object)Guid.NewGuid().ToString(); + } + UserLogins.Add(userLogin); return Task.FromResult(false); } @@ -674,9 +686,21 @@ where userrole.RoleId.Equals(role.Id) /// The name of the token. /// The used to propagate notifications that the operation should be canceled. /// The user token if it exists. + [Obsolete("This method uses composite primary keys from V1/V2 schema. Consider using FindTokenByUniqueIndexAsync for V3 schema with Id primary key and unique indexes.")] protected override Task FindTokenAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken) => UserTokens.FindAsync(new object[] { user.Id, loginProvider, name }, cancellationToken).AsTask(); + /// + /// Find a user token if it exists using the unique index (for V3 schema with Id primary key). + /// + /// The token owner. + /// The login provider for the token. + /// The name of the token. + /// The used to propagate notifications that the operation should be canceled. + /// The user token if it exists. + protected override Task FindTokenByUniqueIndexAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken) + => UserTokens.SingleOrDefaultAsync(ut => ut.UserId.Equals(user.Id) && ut.LoginProvider == loginProvider && ut.Name == name, cancellationToken); + /// /// Add a new user token. /// @@ -684,6 +708,11 @@ where userrole.RoleId.Equals(role.Id) /// protected override Task AddUserTokenAsync(TUserToken token) { + // For string keys in V3 schema, ensure Id is set before adding to EF + if (typeof(TKey) == typeof(string) && EqualityComparer.Default.Equals(token.Id, default!)) + { + token.Id = (TKey)(object)Guid.NewGuid().ToString(); + } UserTokens.Add(token); return Task.CompletedTask; } diff --git a/src/Identity/EntityFrameworkCore/test/EF.Test/CustomSchemaTest.cs b/src/Identity/EntityFrameworkCore/test/EF.Test/CustomSchemaTest.cs index c6bca733e34a..3b83b796db19 100644 --- a/src/Identity/EntityFrameworkCore/test/EF.Test/CustomSchemaTest.cs +++ b/src/Identity/EntityFrameworkCore/test/EF.Test/CustomSchemaTest.cs @@ -41,7 +41,7 @@ public void CanAddCustomColumn() { using var scope = _builder.ApplicationServices.GetRequiredService().CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - VersionTwoSchemaTest.VerifyVersion2Schema(db); + VersionThreeSchemaTest.VerifyVersion3Schema(db); using var sqlConn = (SqliteConnection)db.Database.GetDbConnection(); sqlConn.Open(); Assert.True(DbUtil.VerifyColumns(sqlConn, "CustomColumns", "Id")); diff --git a/src/Identity/EntityFrameworkCore/test/EF.Test/SqlStoreTestBase.cs b/src/Identity/EntityFrameworkCore/test/EF.Test/SqlStoreTestBase.cs index 6d1718c79ae2..13d382b4fc0b 100644 --- a/src/Identity/EntityFrameworkCore/test/EF.Test/SqlStoreTestBase.cs +++ b/src/Identity/EntityFrameworkCore/test/EF.Test/SqlStoreTestBase.cs @@ -139,8 +139,9 @@ internal static void VerifyDefaultSchema(TestDbContext dbContext) Assert.True(DbUtil.VerifyColumns(db, "AspNetRoles", "Id", "Name", "NormalizedName", "ConcurrencyStamp")); Assert.True(DbUtil.VerifyColumns(db, "AspNetUserRoles", "UserId", "RoleId")); Assert.True(DbUtil.VerifyColumns(db, "AspNetUserClaims", "Id", "UserId", "ClaimType", "ClaimValue")); - Assert.True(DbUtil.VerifyColumns(db, "AspNetUserLogins", "UserId", "ProviderKey", "LoginProvider", "ProviderDisplayName")); - Assert.True(DbUtil.VerifyColumns(db, "AspNetUserTokens", "UserId", "LoginProvider", "Name", "Value")); + // V3 schema includes Id column for UserLogins and UserTokens + Assert.True(DbUtil.VerifyColumns(db, "AspNetUserLogins", "Id", "UserId", "ProviderKey", "LoginProvider", "ProviderDisplayName")); + Assert.True(DbUtil.VerifyColumns(db, "AspNetUserTokens", "Id", "UserId", "LoginProvider", "Name", "Value")); Assert.True(DbUtil.VerifyMaxLength(dbContext, "AspNetUsers", 256, "UserName", "Email", "NormalizedUserName", "NormalizedEmail")); Assert.True(DbUtil.VerifyMaxLength(dbContext, "AspNetRoles", 256, "Name", "NormalizedName")); diff --git a/src/Identity/EntityFrameworkCore/test/EF.Test/UserManagerVersionThreeIntegrationTest.cs b/src/Identity/EntityFrameworkCore/test/EF.Test/UserManagerVersionThreeIntegrationTest.cs new file mode 100644 index 000000000000..cf9547071107 --- /dev/null +++ b/src/Identity/EntityFrameworkCore/test/EF.Test/UserManagerVersionThreeIntegrationTest.cs @@ -0,0 +1,314 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.Linq.Expressions; +using Microsoft.AspNetCore.Identity.Test; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore.Test; + +/// +/// UserManager integration tests for V3 schema with Id primary keys on UserTokens and UserLogins +/// +public class UserManagerVersionThreeIntegrationTest : UserManagerSpecificationTestBase, IClassFixture +{ + private readonly ScratchDatabaseFixture _fixture; + + public UserManagerVersionThreeIntegrationTest(ScratchDatabaseFixture fixture) + { + _fixture = fixture; + } + + protected override object CreateTestContext() + { + var db = DbUtil.Create(_fixture.Connection); + db.Database.EnsureCreated(); + return db; + } + + protected override void AddUserStore(IServiceCollection services, object context = null) + { + services.AddSingleton>(new UserStore((VersionThreeDbContext)context)); + } + + protected override void SetUserPasswordHash(IdentityUser user, string hashedPassword) + { + user.PasswordHash = hashedPassword; + } + + protected override IdentityUser CreateTestUser(string namePrefix = "", string email = "", string phoneNumber = "", + bool lockoutEnabled = false, DateTimeOffset? lockoutEnd = null, bool useNamePrefixAsUserName = false) + { + return new IdentityUser + { + UserName = useNamePrefixAsUserName ? namePrefix : string.Format(CultureInfo.InvariantCulture, "{0}{1}", namePrefix, Guid.NewGuid()), + Email = email, + PhoneNumber = phoneNumber, + LockoutEnabled = lockoutEnabled, + LockoutEnd = lockoutEnd + }; + } + + protected override Expression> UserNameEqualsPredicate(string userName) => u => u.UserName == userName; + + protected override Expression> UserNameStartsWithPredicate(string userName) => u => u.UserName.StartsWith(userName); + + [Fact] + public async Task UserManager_CanSetGetAndRemoveAuthenticationToken_WithV3Schema() + { + var manager = CreateManager(); + var user = CreateTestUser(); + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user)); + + const string loginProvider = "TestProvider"; + const string tokenName = "TestToken"; + const string tokenValue = "TestValue"; + + // Set token + IdentityResultAssert.IsSuccess(await manager.SetAuthenticationTokenAsync(user, loginProvider, tokenName, tokenValue)); + + // Get token + var retrievedValue = await manager.GetAuthenticationTokenAsync(user, loginProvider, tokenName); + Assert.Equal(tokenValue, retrievedValue); + + // Remove token + IdentityResultAssert.IsSuccess(await manager.RemoveAuthenticationTokenAsync(user, loginProvider, tokenName)); + + // Verify token is removed + var removedValue = await manager.GetAuthenticationTokenAsync(user, loginProvider, tokenName); + Assert.Null(removedValue); + } + + [Fact] + public async Task UserManager_CanUpdateAuthenticationToken_WithV3Schema() + { + var manager = CreateManager(); + var user = CreateTestUser(); + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user)); + + const string loginProvider = "TestProvider"; + const string tokenName = "TestToken"; + + // Set initial token + IdentityResultAssert.IsSuccess(await manager.SetAuthenticationTokenAsync(user, loginProvider, tokenName, "InitialValue")); + Assert.Equal("InitialValue", await manager.GetAuthenticationTokenAsync(user, loginProvider, tokenName)); + + // Update token + IdentityResultAssert.IsSuccess(await manager.SetAuthenticationTokenAsync(user, loginProvider, tokenName, "UpdatedValue")); + Assert.Equal("UpdatedValue", await manager.GetAuthenticationTokenAsync(user, loginProvider, tokenName)); + } + + [Fact] + public async Task UserManager_CanAddFindAndRemoveLogin_WithV3Schema() + { + var manager = CreateManager(); + var user = CreateTestUser(); + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user)); + + var login = new UserLoginInfo("TestProvider", "UserManager_CanAddFind_" + Guid.NewGuid(), "TestDisplayName"); + + // Add login + IdentityResultAssert.IsSuccess(await manager.AddLoginAsync(user, login)); + + // Find by login + var foundUser = await manager.FindByLoginAsync(login.LoginProvider, login.ProviderKey); + Assert.NotNull(foundUser); + Assert.Equal(user.Id, foundUser.Id); + + // Get logins + var logins = await manager.GetLoginsAsync(user); + Assert.Single(logins); + Assert.Equal(login.LoginProvider, logins[0].LoginProvider); + Assert.Equal(login.ProviderKey, logins[0].ProviderKey); + + // Remove login + IdentityResultAssert.IsSuccess(await manager.RemoveLoginAsync(user, login.LoginProvider, login.ProviderKey)); + + // Verify removal + Assert.Null(await manager.FindByLoginAsync(login.LoginProvider, login.ProviderKey)); + logins = await manager.GetLoginsAsync(user); + Assert.Empty(logins); + } + + [Fact] + public async Task UserManager_CannotAddDuplicateLogin_WithV3Schema() + { + var manager = CreateManager(); + var user1 = CreateTestUser("user1"); + var user2 = CreateTestUser("user2"); + + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user1)); + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user2)); + + var login = new UserLoginInfo("TestProvider", "TestProviderKey", "TestDisplayName"); + + // Add login to first user + IdentityResultAssert.IsSuccess(await manager.AddLoginAsync(user1, login)); + + // Attempt to add same login to second user should fail + var result = await manager.AddLoginAsync(user2, login); + Assert.False(result.Succeeded); + Assert.Contains(result.Errors, e => e.Code == "LoginAlreadyAssociated"); + } + + [Fact] + public async Task UserManager_MultipleTokensPerUser_WithV3Schema() + { + var manager = CreateManager(); + var user = CreateTestUser(); + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user)); + + // Set multiple tokens + IdentityResultAssert.IsSuccess(await manager.SetAuthenticationTokenAsync(user, "Provider1", "Token1", "Value1")); + IdentityResultAssert.IsSuccess(await manager.SetAuthenticationTokenAsync(user, "Provider1", "Token2", "Value2")); + IdentityResultAssert.IsSuccess(await manager.SetAuthenticationTokenAsync(user, "Provider2", "Token1", "Value3")); + + // Verify all tokens + Assert.Equal("Value1", await manager.GetAuthenticationTokenAsync(user, "Provider1", "Token1")); + Assert.Equal("Value2", await manager.GetAuthenticationTokenAsync(user, "Provider1", "Token2")); + Assert.Equal("Value3", await manager.GetAuthenticationTokenAsync(user, "Provider2", "Token1")); + + // Remove one token + IdentityResultAssert.IsSuccess(await manager.RemoveAuthenticationTokenAsync(user, "Provider1", "Token1")); + + // Verify only the removed token is gone + Assert.Null(await manager.GetAuthenticationTokenAsync(user, "Provider1", "Token1")); + Assert.Equal("Value2", await manager.GetAuthenticationTokenAsync(user, "Provider1", "Token2")); + Assert.Equal("Value3", await manager.GetAuthenticationTokenAsync(user, "Provider2", "Token1")); + } + + [Fact] + public async Task UserManager_MultipleLoginsPerUser_WithV3Schema() + { + var manager = CreateManager(); + var user = CreateTestUser(); + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user)); + + var login1 = new UserLoginInfo("Provider1", "Key1", "Display1"); + var login2 = new UserLoginInfo("Provider2", "Key2", "Display2"); + var login3 = new UserLoginInfo("Provider3", "Key3", "Display3"); + + // Add multiple logins + IdentityResultAssert.IsSuccess(await manager.AddLoginAsync(user, login1)); + IdentityResultAssert.IsSuccess(await manager.AddLoginAsync(user, login2)); + IdentityResultAssert.IsSuccess(await manager.AddLoginAsync(user, login3)); + + // Get logins + var logins = await manager.GetLoginsAsync(user); + Assert.Equal(3, logins.Count); + + // Verify each login + Assert.Contains(logins, l => l.LoginProvider == "Provider1" && l.ProviderKey == "Key1"); + Assert.Contains(logins, l => l.LoginProvider == "Provider2" && l.ProviderKey == "Key2"); + Assert.Contains(logins, l => l.LoginProvider == "Provider3" && l.ProviderKey == "Key3"); + + // Remove one login + IdentityResultAssert.IsSuccess(await manager.RemoveLoginAsync(user, login2.LoginProvider, login2.ProviderKey)); + + // Verify remaining logins + logins = await manager.GetLoginsAsync(user); + Assert.Equal(2, logins.Count); + Assert.Contains(logins, l => l.LoginProvider == "Provider1"); + Assert.Contains(logins, l => l.LoginProvider == "Provider3"); + Assert.DoesNotContain(logins, l => l.LoginProvider == "Provider2"); + } + + [Fact] + public async Task UserManager_TokensIsolatedBetweenUsers_WithV3Schema() + { + var manager = CreateManager(); + var user1 = CreateTestUser("isolation1"); + var user2 = CreateTestUser("isolation2"); + + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user1)); + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user2)); + + const string loginProvider = "TestProvider"; + const string tokenName = "TestToken"; + + // Set different tokens for each user + IdentityResultAssert.IsSuccess(await manager.SetAuthenticationTokenAsync(user1, loginProvider, tokenName, "User1Value")); + IdentityResultAssert.IsSuccess(await manager.SetAuthenticationTokenAsync(user2, loginProvider, tokenName, "User2Value")); + + // Verify isolation + Assert.Equal("User1Value", await manager.GetAuthenticationTokenAsync(user1, loginProvider, tokenName)); + Assert.Equal("User2Value", await manager.GetAuthenticationTokenAsync(user2, loginProvider, tokenName)); + + // Remove token from user1 + IdentityResultAssert.IsSuccess(await manager.RemoveAuthenticationTokenAsync(user1, loginProvider, tokenName)); + + // Verify user2's token is unaffected + Assert.Null(await manager.GetAuthenticationTokenAsync(user1, loginProvider, tokenName)); + Assert.Equal("User2Value", await manager.GetAuthenticationTokenAsync(user2, loginProvider, tokenName)); + } + + [Fact] + public async Task UserManager_LoginsIsolatedBetweenUsers_WithV3Schema() + { + var manager = CreateManager(); + var user1 = CreateTestUser("loginiso1"); + var user2 = CreateTestUser("loginiso2"); + + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user1)); + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user2)); + + var login1 = new UserLoginInfo("Provider", "Key1", "Display"); + var login2 = new UserLoginInfo("Provider", "Key2", "Display"); + + // Add different logins to each user + IdentityResultAssert.IsSuccess(await manager.AddLoginAsync(user1, login1)); + IdentityResultAssert.IsSuccess(await manager.AddLoginAsync(user2, login2)); + + // Verify each user can be found by their login + var foundUser1 = await manager.FindByLoginAsync(login1.LoginProvider, login1.ProviderKey); + var foundUser2 = await manager.FindByLoginAsync(login2.LoginProvider, login2.ProviderKey); + + Assert.Equal(user1.Id, foundUser1.Id); + Assert.Equal(user2.Id, foundUser2.Id); + } + + [Fact] + public async Task UserManager_SecurityStampChangesOnLoginRemoval_WithV3Schema() + { + var manager = CreateManager(); + var user = CreateTestUser(); + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user)); + + var login = new UserLoginInfo("TestProvider", "SecurityStampTest_" + Guid.NewGuid(), "TestDisplayName"); + + // Add login and capture security stamp + IdentityResultAssert.IsSuccess(await manager.AddLoginAsync(user, login)); + var stampAfterAdd = await manager.GetSecurityStampAsync(user); + + // Remove login and verify security stamp changed + IdentityResultAssert.IsSuccess(await manager.RemoveLoginAsync(user, login.LoginProvider, login.ProviderKey)); + var stampAfterRemove = await manager.GetSecurityStampAsync(user); + + Assert.NotEqual(stampAfterAdd, stampAfterRemove); + } + + [Fact] + public async Task UserManager_CanRemoveNonExistentToken_WithV3Schema() + { + var manager = CreateManager(); + var user = CreateTestUser(); + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user)); + + // Removing non-existent token should succeed + IdentityResultAssert.IsSuccess(await manager.RemoveAuthenticationTokenAsync(user, "NonExistentProvider", "NonExistentToken")); + } + + [Fact] + public async Task UserManager_CanRemoveNonExistentLogin_WithV3Schema() + { + var manager = CreateManager(); + var user = CreateTestUser(); + IdentityResultAssert.IsSuccess(await manager.CreateAsync(user)); + + // Removing non-existent login should succeed + IdentityResultAssert.IsSuccess(await manager.RemoveLoginAsync(user, "NonExistentProvider", "NonExistentKey")); + } +} diff --git a/src/Identity/EntityFrameworkCore/test/EF.Test/UserStoreVersionThreeTest.cs b/src/Identity/EntityFrameworkCore/test/EF.Test/UserStoreVersionThreeTest.cs new file mode 100644 index 000000000000..ed99d1821c5e --- /dev/null +++ b/src/Identity/EntityFrameworkCore/test/EF.Test/UserStoreVersionThreeTest.cs @@ -0,0 +1,399 @@ +// 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.Builder; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore.Test; + +/// +/// Tests for Version 3 schema changes to UserTokens and UserLogins with Id primary keys +/// +public class UserStoreVersionThreeTest : IClassFixture +{ + private readonly ScratchDatabaseFixture _fixture; + + public UserStoreVersionThreeTest(ScratchDatabaseFixture fixture) + { + _fixture = fixture; + } + + private VersionThreeDbContext CreateContext() + { + var services = new ServiceCollection(); + services + .AddSingleton(new ConfigurationBuilder().Build()) + .AddDbContext(o => + o.UseSqlite(_fixture.Connection) + .ConfigureWarnings(b => b.Log(CoreEventId.ManyServiceProvidersCreatedWarning))) + .AddIdentity(o => + { + o.Stores.SchemaVersion = IdentitySchemaVersions.Version3; + }) + .AddEntityFrameworkStores(); + + services.AddLogging(); + + var provider = services.BuildServiceProvider(); + var scope = provider.GetRequiredService().CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + + return db; + } + + [Fact] + public void VerifyV3SchemaHasIdPrimaryKeys() + { + using var db = CreateContext(); + using var sqlConn = (SqliteConnection)db.Database.GetDbConnection(); + sqlConn.Open(); + + // Verify UserLogins has Id column + Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserLogins", "Id", "UserId", "ProviderKey", "LoginProvider", "ProviderDisplayName")); + + // Verify UserTokens has Id column + Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserTokens", "Id", "UserId", "LoginProvider", "Name", "Value")); + } + + [Fact] + public void VerifyV3SchemaHasUniqueIndexes() + { + using var db = CreateContext(); + using var sqlConn = (SqliteConnection)db.Database.GetDbConnection(); + sqlConn.Open(); + + // Verify unique index on UserLogins (LoginProvider, ProviderKey) + DbUtil.VerifyIndex(sqlConn, "AspNetUserLogins", "IX_AspNetUserLogins_LoginProvider_ProviderKey", isUnique: true); + + // Verify unique index on UserTokens (UserId, LoginProvider, Name) + DbUtil.VerifyIndex(sqlConn, "AspNetUserTokens", "IX_AspNetUserTokens_UserId_LoginProvider_Name", isUnique: true); + } + + [Fact] + public async Task CanSetAndGetTokenWithV3Schema() + { + using var db = CreateContext(); + var store = new UserStore(db); + + var user = new IdentityUser { UserName = "TokenTestUser" }; + await store.CreateAsync(user, CancellationToken.None); + + const string loginProvider = "TestProvider"; + const string tokenName = "TestToken"; + const string tokenValue = "TestValue"; + + // Set token + await store.SetTokenAsync(user, loginProvider, tokenName, tokenValue, CancellationToken.None); + await db.SaveChangesAsync(); + + // Get token + var retrievedValue = await store.GetTokenAsync(user, loginProvider, tokenName, CancellationToken.None); + Assert.Equal(tokenValue, retrievedValue); + } + + [Fact] + public async Task CanUpdateTokenWithV3Schema() + { + using var db = CreateContext(); + var store = new UserStore(db); + + var user = new IdentityUser { UserName = "UpdateTokenUser" }; + await store.CreateAsync(user, CancellationToken.None); + + const string loginProvider = "TestProvider"; + const string tokenName = "TestToken"; + const string initialValue = "InitialValue"; + const string updatedValue = "UpdatedValue"; + + // Set initial token + await store.SetTokenAsync(user, loginProvider, tokenName, initialValue, CancellationToken.None); + await db.SaveChangesAsync(); + + // Update token + await store.SetTokenAsync(user, loginProvider, tokenName, updatedValue, CancellationToken.None); + await db.SaveChangesAsync(); + + // Verify updated value + var retrievedValue = await store.GetTokenAsync(user, loginProvider, tokenName, CancellationToken.None); + Assert.Equal(updatedValue, retrievedValue); + } + + [Fact] + public async Task CanRemoveTokenWithV3Schema() + { + using var db = CreateContext(); + var store = new UserStore(db); + + var user = new IdentityUser { UserName = "RemoveTokenUser" }; + await store.CreateAsync(user, CancellationToken.None); + + const string loginProvider = "TestProvider"; + const string tokenName = "TestToken"; + const string tokenValue = "TestValue"; + + // Set token + await store.SetTokenAsync(user, loginProvider, tokenName, tokenValue, CancellationToken.None); + await db.SaveChangesAsync(); + + // Remove token + await store.RemoveTokenAsync(user, loginProvider, tokenName, CancellationToken.None); + await db.SaveChangesAsync(); + + // Verify token is removed + var retrievedValue = await store.GetTokenAsync(user, loginProvider, tokenName, CancellationToken.None); + Assert.Null(retrievedValue); + } + + [Fact] + public async Task CanHandleMultipleTokensForSameUser() + { + using var db = CreateContext(); + var store = new UserStore(db); + + var user = new IdentityUser { UserName = "MultiTokenUser" }; + await store.CreateAsync(user, CancellationToken.None); + + // Set multiple tokens + await store.SetTokenAsync(user, "Provider1", "Token1", "Value1", CancellationToken.None); + await store.SetTokenAsync(user, "Provider1", "Token2", "Value2", CancellationToken.None); + await store.SetTokenAsync(user, "Provider2", "Token1", "Value3", CancellationToken.None); + await db.SaveChangesAsync(); + + // Verify all tokens + Assert.Equal("Value1", await store.GetTokenAsync(user, "Provider1", "Token1", CancellationToken.None)); + Assert.Equal("Value2", await store.GetTokenAsync(user, "Provider1", "Token2", CancellationToken.None)); + Assert.Equal("Value3", await store.GetTokenAsync(user, "Provider2", "Token1", CancellationToken.None)); + } + + [Fact] + public async Task TokensAreIsolatedBetweenUsers() + { + using var db = CreateContext(); + var store = new UserStore(db); + + var user1 = new IdentityUser { UserName = "TokenIsolationUser1" }; + var user2 = new IdentityUser { UserName = "TokenIsolationUser2" }; + await store.CreateAsync(user1, CancellationToken.None); + await store.CreateAsync(user2, CancellationToken.None); + + const string loginProvider = "TestProvider"; + const string tokenName = "TestToken"; + + // Set different tokens for each user + await store.SetTokenAsync(user1, loginProvider, tokenName, "User1Value", CancellationToken.None); + await store.SetTokenAsync(user2, loginProvider, tokenName, "User2Value", CancellationToken.None); + await db.SaveChangesAsync(); + + // Verify tokens are isolated + Assert.Equal("User1Value", await store.GetTokenAsync(user1, loginProvider, tokenName, CancellationToken.None)); + Assert.Equal("User2Value", await store.GetTokenAsync(user2, loginProvider, tokenName, CancellationToken.None)); + } + + [Fact] + public async Task CanAddAndFindLoginWithV3Schema() + { + using var db = CreateContext(); + var store = new UserStore(db); + + var user = new IdentityUser { UserName = "LoginTestUser" }; + await store.CreateAsync(user, CancellationToken.None); + + var login = new UserLoginInfo("TestProvider", "AddFindLoginKey_" + Guid.NewGuid(), "TestDisplayName"); + + // Add login + await store.AddLoginAsync(user, login, CancellationToken.None); + await db.SaveChangesAsync(); + + // Find by login + var foundUser = await store.FindByLoginAsync(login.LoginProvider, login.ProviderKey, CancellationToken.None); + Assert.NotNull(foundUser); + Assert.Equal(user.Id, foundUser.Id); + } + + [Fact] + public async Task CanGetLoginsForUserWithV3Schema() + { + using var db = CreateContext(); + var store = new UserStore(db); + + var user = new IdentityUser { UserName = "GetLoginsUser" }; + await store.CreateAsync(user, CancellationToken.None); + + var login1 = new UserLoginInfo("Provider1", "Key1", "Display1"); + var login2 = new UserLoginInfo("Provider2", "Key2", "Display2"); + + // Add logins + await store.AddLoginAsync(user, login1, CancellationToken.None); + await store.AddLoginAsync(user, login2, CancellationToken.None); + await db.SaveChangesAsync(); + + // Get logins + var logins = await store.GetLoginsAsync(user, CancellationToken.None); + Assert.Equal(2, logins.Count); + Assert.Contains(logins, l => l.LoginProvider == "Provider1" && l.ProviderKey == "Key1"); + Assert.Contains(logins, l => l.LoginProvider == "Provider2" && l.ProviderKey == "Key2"); + } + + [Fact] + public async Task CanRemoveLoginWithV3Schema() + { + using var db = CreateContext(); + var store = new UserStore(db); + + var user = new IdentityUser { UserName = "RemoveLoginUser" }; + await store.CreateAsync(user, CancellationToken.None); + + var login = new UserLoginInfo("TestProvider", "RemoveLoginKey_" + Guid.NewGuid(), "TestDisplayName"); + + // Add login + await store.AddLoginAsync(user, login, CancellationToken.None); + await db.SaveChangesAsync(); + + // Remove login + await store.RemoveLoginAsync(user, login.LoginProvider, login.ProviderKey, CancellationToken.None); + await db.SaveChangesAsync(); + + // Verify login is removed + var foundUser = await store.FindByLoginAsync(login.LoginProvider, login.ProviderKey, CancellationToken.None); + Assert.Null(foundUser); + + var logins = await store.GetLoginsAsync(user, CancellationToken.None); + Assert.Empty(logins); + } + + [Fact] + public async Task UniqueIndexPreventsIdDuplicateLogins() + { + using var db = CreateContext(); + var store = new UserStore(db); + + var user = new IdentityUser { UserName = "DuplicateLoginUser" }; + await store.CreateAsync(user, CancellationToken.None); + + var login = new UserLoginInfo("TestProvider", "UniqueLoginKey_" + Guid.NewGuid(), "TestDisplayName"); + + // Add login first time + await store.AddLoginAsync(user, login, CancellationToken.None); + await db.SaveChangesAsync(); + + // Attempt to add same login again should fail due to unique index + await store.AddLoginAsync(user, login, CancellationToken.None); + await Assert.ThrowsAsync(() => db.SaveChangesAsync()); + } + + [Fact] + public async Task UniqueIndexPreventsDuplicateTokens() + { + using var db = CreateContext(); + var store = new UserStore(db); + + var user = new IdentityUser { UserName = "DuplicateTokenUser" }; + await store.CreateAsync(user, CancellationToken.None); + + const string loginProvider = "TestProvider"; + const string tokenName = "TestToken"; + + // Set token first time + await store.SetTokenAsync(user, loginProvider, tokenName, "Value1", CancellationToken.None); + await db.SaveChangesAsync(); + + // Setting same token again should update, not create duplicate + await store.SetTokenAsync(user, loginProvider, tokenName, "Value2", CancellationToken.None); + await db.SaveChangesAsync(); + + // Verify only one token exists with updated value + var value = await store.GetTokenAsync(user, loginProvider, tokenName, CancellationToken.None); + Assert.Equal("Value2", value); + + // Count tokens directly in database + var tokenCount = await db.UserTokens + .Where(t => t.UserId == user.Id && t.LoginProvider == loginProvider && t.Name == tokenName) + .CountAsync(); + Assert.Equal(1, tokenCount); + } + + [Fact] + public async Task FindTokenByUniqueIndexAsyncWorksWithV3Schema() + { + using var db = CreateContext(); + var store = new UserOnlyStore(db); + + var user = new IdentityUser { UserName = "FindTokenByIndexUser" }; + await store.CreateAsync(user, CancellationToken.None); + + const string loginProvider = "TestProvider"; + const string tokenName = "TestToken"; + const string tokenValue = "TestValue"; + + // Set token + await store.SetTokenAsync(user, loginProvider, tokenName, tokenValue, CancellationToken.None); + await db.SaveChangesAsync(); + + // Verify token can be retrieved (internally uses FindTokenByUniqueIndexAsync for V3) + var retrievedValue = await store.GetTokenAsync(user, loginProvider, tokenName, CancellationToken.None); + Assert.Equal(tokenValue, retrievedValue); + } + + [Fact] + public async Task V3SchemaSupportsNullTokenValues() + { + using var db = CreateContext(); + var store = new UserStore(db); + + var user = new IdentityUser { UserName = "NullTokenUser" }; + await store.CreateAsync(user, CancellationToken.None); + + const string loginProvider = "TestProvider"; + const string tokenName = "TestToken"; + + // Set token with null value + await store.SetTokenAsync(user, loginProvider, tokenName, null, CancellationToken.None); + await db.SaveChangesAsync(); + + // Retrieve null token + var retrievedValue = await store.GetTokenAsync(user, loginProvider, tokenName, CancellationToken.None); + Assert.Null(retrievedValue); + } + + [Fact] + public async Task V3SchemaTokenOperationsAreTransactional() + { + using var db = CreateContext(); + var store = new UserStore(db); + + var user = new IdentityUser { UserName = "TransactionalUser" }; + await store.CreateAsync(user, CancellationToken.None); + + const string loginProvider = "TestProvider"; + const string tokenName = "TestToken"; + + // Set token but don't save + await store.SetTokenAsync(user, loginProvider, tokenName, "Value1", CancellationToken.None); + + // Verify token is not persisted yet + using (var newDb = CreateContext()) + { + var newStore = new UserStore(newDb); + var foundUser = await newStore.FindByIdAsync(user.Id, CancellationToken.None); + var value = await newStore.GetTokenAsync(foundUser!, loginProvider, tokenName, CancellationToken.None); + Assert.Null(value); + } + + // Save changes + await db.SaveChangesAsync(); + + // Verify token is now persisted + using (var newDb = CreateContext()) + { + var newStore = new UserStore(newDb); + var foundUser = await newStore.FindByIdAsync(user.Id, CancellationToken.None); + var value = await newStore.GetTokenAsync(foundUser!, loginProvider, tokenName, CancellationToken.None); + Assert.Equal("Value1", value); + } + } +} diff --git a/src/Identity/EntityFrameworkCore/test/EF.Test/VersionThreeSchemaTest.cs b/src/Identity/EntityFrameworkCore/test/EF.Test/VersionThreeSchemaTest.cs index 6df5064bf8cc..099064151745 100644 --- a/src/Identity/EntityFrameworkCore/test/EF.Test/VersionThreeSchemaTest.cs +++ b/src/Identity/EntityFrameworkCore/test/EF.Test/VersionThreeSchemaTest.cs @@ -56,8 +56,13 @@ internal static void VerifyVersion3Schema(DbContext dbContext) Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetRoles", "Id", "Name", "NormalizedName", "ConcurrencyStamp")); Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserRoles", "UserId", "RoleId")); Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserClaims", "Id", "UserId", "ClaimType", "ClaimValue")); - Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserLogins", "UserId", "ProviderKey", "LoginProvider", "ProviderDisplayName")); - Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserTokens", "UserId", "LoginProvider", "Name", "Value")); + + // V3 Schema: UserLogins now has Id primary key with unique index on (LoginProvider, ProviderKey) + Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserLogins", "Id", "UserId", "ProviderKey", "LoginProvider", "ProviderDisplayName")); + + // V3 Schema: UserTokens now has Id primary key with unique index on (UserId, LoginProvider, Name) + Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserTokens", "Id", "UserId", "LoginProvider", "Name", "Value")); + Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserPasskeys", "UserId", "CredentialId", "Data")); Assert.True(DbUtil.VerifyMaxLength(dbContext, "AspNetUsers", 256, "UserName", "Email", "NormalizedUserName", "NormalizedEmail", "PhoneNumber")); @@ -65,6 +70,10 @@ internal static void VerifyVersion3Schema(DbContext dbContext) Assert.True(DbUtil.VerifyMaxLength(dbContext, "AspNetUserLogins", 128, "LoginProvider", "ProviderKey")); Assert.True(DbUtil.VerifyMaxLength(dbContext, "AspNetUserTokens", 128, "LoginProvider", "Name")); + // V3 Schema unique indexes + DbUtil.VerifyIndex(sqlConn, "AspNetUserLogins", "IX_AspNetUserLogins_LoginProvider_ProviderKey", isUnique: true); + DbUtil.VerifyIndex(sqlConn, "AspNetUserTokens", "IX_AspNetUserTokens_UserId_LoginProvider_Name", isUnique: true); + DbUtil.VerifyIndex(sqlConn, "AspNetRoles", "RoleNameIndex", isUnique: true); DbUtil.VerifyIndex(sqlConn, "AspNetUsers", "UserNameIndex", isUnique: true); DbUtil.VerifyIndex(sqlConn, "AspNetUsers", "EmailIndex"); diff --git a/src/Identity/Extensions.Stores/src/IdentityRoleClaim.cs b/src/Identity/Extensions.Stores/src/IdentityRoleClaim.cs index 1b988f92e1b3..95533c32c95f 100644 --- a/src/Identity/Extensions.Stores/src/IdentityRoleClaim.cs +++ b/src/Identity/Extensions.Stores/src/IdentityRoleClaim.cs @@ -15,7 +15,7 @@ public class IdentityRoleClaim where TKey : IEquatable /// /// Gets or sets the identifier for this role claim. /// - public virtual int Id { get; set; } = default!; + public virtual TKey Id { get; set; } = default!; /// /// Gets or sets the of the primary key of the role associated with this claim. diff --git a/src/Identity/Extensions.Stores/src/IdentityUserClaim.cs b/src/Identity/Extensions.Stores/src/IdentityUserClaim.cs index 1028d60901a6..8ebc2891c912 100644 --- a/src/Identity/Extensions.Stores/src/IdentityUserClaim.cs +++ b/src/Identity/Extensions.Stores/src/IdentityUserClaim.cs @@ -15,7 +15,7 @@ public class IdentityUserClaim where TKey : IEquatable /// /// Gets or sets the identifier for this user claim. /// - public virtual int Id { get; set; } = default!; + public virtual TKey Id { get; set; } = default!; /// /// Gets or sets the primary key of the user associated with this claim. diff --git a/src/Identity/Extensions.Stores/src/IdentityUserLogin.cs b/src/Identity/Extensions.Stores/src/IdentityUserLogin.cs index f42069d6afa2..5374264437fd 100644 --- a/src/Identity/Extensions.Stores/src/IdentityUserLogin.cs +++ b/src/Identity/Extensions.Stores/src/IdentityUserLogin.cs @@ -11,6 +11,14 @@ namespace Microsoft.AspNetCore.Identity; /// The type of the primary key of the user associated with this login. public class IdentityUserLogin where TKey : IEquatable { + /// + /// Gets or sets the primary key of the external login. + /// + public virtual TKey Id { get; set; } = default!; + /// + /// Gets or sets the foreign key of the user associated with this login. + /// + public virtual TKey UserId { get; set; } = default!; /// /// Gets or sets the login provider for the login (e.g. facebook, google) /// @@ -25,9 +33,4 @@ public class IdentityUserLogin where TKey : IEquatable /// Gets or sets the friendly name used in a UI for this login. /// public virtual string? ProviderDisplayName { get; set; } - - /// - /// Gets or sets the primary key of the user associated with this login. - /// - public virtual TKey UserId { get; set; } = default!; } diff --git a/src/Identity/Extensions.Stores/src/IdentityUserToken.cs b/src/Identity/Extensions.Stores/src/IdentityUserToken.cs index a7a44460e43b..5816c3810c56 100644 --- a/src/Identity/Extensions.Stores/src/IdentityUserToken.cs +++ b/src/Identity/Extensions.Stores/src/IdentityUserToken.cs @@ -12,7 +12,11 @@ namespace Microsoft.AspNetCore.Identity; public class IdentityUserToken where TKey : IEquatable { /// - /// Gets or sets the primary key of the user that the token belongs to. + /// Gets or sets the primary key. + /// + public virtual TKey Id { get; set; } = default!; + /// + /// Gets or sets the foreign kwy for the user identifier. /// public virtual TKey UserId { get; set; } = default!; diff --git a/src/Identity/Extensions.Stores/src/PublicAPI.Shipped.txt b/src/Identity/Extensions.Stores/src/PublicAPI.Shipped.txt index dfc33a990254..ecaa2770c78c 100644 --- a/src/Identity/Extensions.Stores/src/PublicAPI.Shipped.txt +++ b/src/Identity/Extensions.Stores/src/PublicAPI.Shipped.txt @@ -86,7 +86,6 @@ virtual Microsoft.AspNetCore.Identity.IdentityRoleClaim.ClaimType.get -> s virtual Microsoft.AspNetCore.Identity.IdentityRoleClaim.ClaimType.set -> void virtual Microsoft.AspNetCore.Identity.IdentityRoleClaim.ClaimValue.get -> string? virtual Microsoft.AspNetCore.Identity.IdentityRoleClaim.ClaimValue.set -> void -virtual Microsoft.AspNetCore.Identity.IdentityRoleClaim.Id.get -> int virtual Microsoft.AspNetCore.Identity.IdentityRoleClaim.Id.set -> void virtual Microsoft.AspNetCore.Identity.IdentityRoleClaim.InitializeFromClaim(System.Security.Claims.Claim? other) -> void virtual Microsoft.AspNetCore.Identity.IdentityRoleClaim.RoleId.get -> TKey @@ -126,7 +125,6 @@ virtual Microsoft.AspNetCore.Identity.IdentityUserClaim.ClaimType.get -> s virtual Microsoft.AspNetCore.Identity.IdentityUserClaim.ClaimType.set -> void virtual Microsoft.AspNetCore.Identity.IdentityUserClaim.ClaimValue.get -> string? virtual Microsoft.AspNetCore.Identity.IdentityUserClaim.ClaimValue.set -> void -virtual Microsoft.AspNetCore.Identity.IdentityUserClaim.Id.get -> int virtual Microsoft.AspNetCore.Identity.IdentityUserClaim.Id.set -> void virtual Microsoft.AspNetCore.Identity.IdentityUserClaim.InitializeFromClaim(System.Security.Claims.Claim! claim) -> void virtual Microsoft.AspNetCore.Identity.IdentityUserClaim.ToClaim() -> System.Security.Claims.Claim! diff --git a/src/Identity/Extensions.Stores/src/PublicAPI.Unshipped.txt b/src/Identity/Extensions.Stores/src/PublicAPI.Unshipped.txt index 6c0240f93f85..002c2dfd1a45 100644 --- a/src/Identity/Extensions.Stores/src/PublicAPI.Unshipped.txt +++ b/src/Identity/Extensions.Stores/src/PublicAPI.Unshipped.txt @@ -1,4 +1,5 @@ #nullable enable +abstract Microsoft.AspNetCore.Identity.UserStoreBase.FindTokenByUniqueIndexAsync(TUser! user, string! loginProvider, string! name, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Identity.IdentityPasskeyData Microsoft.AspNetCore.Identity.IdentityPasskeyData.IdentityPasskeyData() -> void Microsoft.AspNetCore.Identity.IdentityUserPasskey @@ -23,9 +24,15 @@ virtual Microsoft.AspNetCore.Identity.IdentityPasskeyData.SignCount.get -> uint virtual Microsoft.AspNetCore.Identity.IdentityPasskeyData.SignCount.set -> void virtual Microsoft.AspNetCore.Identity.IdentityPasskeyData.Transports.get -> string![]? virtual Microsoft.AspNetCore.Identity.IdentityPasskeyData.Transports.set -> void +virtual Microsoft.AspNetCore.Identity.IdentityRoleClaim.Id.get -> TKey +virtual Microsoft.AspNetCore.Identity.IdentityUserClaim.Id.get -> TKey +virtual Microsoft.AspNetCore.Identity.IdentityUserLogin.Id.get -> TKey +virtual Microsoft.AspNetCore.Identity.IdentityUserLogin.Id.set -> void virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.CredentialId.get -> byte[]! virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.CredentialId.set -> void virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.Data.get -> Microsoft.AspNetCore.Identity.IdentityPasskeyData! virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.Data.set -> void virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.UserId.get -> TKey virtual Microsoft.AspNetCore.Identity.IdentityUserPasskey.UserId.set -> void +virtual Microsoft.AspNetCore.Identity.IdentityUserToken.Id.get -> TKey +virtual Microsoft.AspNetCore.Identity.IdentityUserToken.Id.set -> void diff --git a/src/Identity/Extensions.Stores/src/UserStoreBase.cs b/src/Identity/Extensions.Stores/src/UserStoreBase.cs index b9bcf87fd5d4..ea1a988ee4b9 100644 --- a/src/Identity/Extensions.Stores/src/UserStoreBase.cs +++ b/src/Identity/Extensions.Stores/src/UserStoreBase.cs @@ -78,15 +78,13 @@ protected virtual TUserClaim CreateUserClaim(TUser user, Claim claim) /// The associated login. /// protected virtual TUserLogin CreateUserLogin(TUser user, UserLoginInfo login) - { - return new TUserLogin + => new TUserLogin { UserId = user.Id, ProviderKey = login.ProviderKey, LoginProvider = login.LoginProvider, ProviderDisplayName = login.ProviderDisplayName }; - } /// /// Called to create a new instance of a . @@ -97,15 +95,13 @@ protected virtual TUserLogin CreateUserLogin(TUser user, UserLoginInfo login) /// The value of the user token. /// protected virtual TUserToken CreateUserToken(TUser user, string loginProvider, string name, string? value) - { - return new TUserToken + => new TUserToken { UserId = user.Id, LoginProvider = loginProvider, Name = name, Value = value }; - } /// /// Gets the user identifier for the specified . @@ -800,8 +796,19 @@ public void Dispose() /// The name of the token. /// The used to propagate notifications that the operation should be canceled. /// The user token if it exists. + [Obsolete("This method uses composite primary keys from V1/V2 schema. Consider using FindTokenByUniqueIndexAsync for V3 schema with Id primary key and unique indexes.")] protected abstract Task FindTokenAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken); + /// + /// Find a user token if it exists using the unique index (for V3 schema with Id primary key). + /// + /// The token owner. + /// The login provider for the token. + /// The name of the token. + /// The used to propagate notifications that the operation should be canceled. + /// The user token if it exists. + protected abstract Task FindTokenByUniqueIndexAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken); + /// /// Add a new user token. /// @@ -832,7 +839,7 @@ public virtual async Task SetTokenAsync(TUser user, string loginProvider, string ArgumentNullThrowHelper.ThrowIfNull(user); - var token = await FindTokenAsync(user, loginProvider, name, cancellationToken).ConfigureAwait(false); + var token = await FindTokenByUniqueIndexAsync(user, loginProvider, name, cancellationToken).ConfigureAwait(false); if (token == null) { await AddUserTokenAsync(CreateUserToken(user, loginProvider, name, value)).ConfigureAwait(false); @@ -857,7 +864,7 @@ public virtual async Task RemoveTokenAsync(TUser user, string loginProvider, str ThrowIfDisposed(); ArgumentNullThrowHelper.ThrowIfNull(user); - var entry = await FindTokenAsync(user, loginProvider, name, cancellationToken).ConfigureAwait(false); + var entry = await FindTokenByUniqueIndexAsync(user, loginProvider, name, cancellationToken).ConfigureAwait(false); if (entry != null) { await RemoveUserTokenAsync(entry).ConfigureAwait(false); @@ -878,7 +885,7 @@ public virtual async Task RemoveTokenAsync(TUser user, string loginProvider, str ThrowIfDisposed(); ArgumentNullThrowHelper.ThrowIfNull(user); - var entry = await FindTokenAsync(user, loginProvider, name, cancellationToken).ConfigureAwait(false); + var entry = await FindTokenByUniqueIndexAsync(user, loginProvider, name, cancellationToken).ConfigureAwait(false); return entry?.Value; } diff --git a/src/Identity/samples/IdentitySample.DefaultUI/Areas/Identity/Pages/Account/ExternalLogin.cshtml b/src/Identity/samples/IdentitySample.DefaultUI/Areas/Identity/Pages/Account/ExternalLogin.cshtml new file mode 100644 index 000000000000..5c99badaf0c0 --- /dev/null +++ b/src/Identity/samples/IdentitySample.DefaultUI/Areas/Identity/Pages/Account/ExternalLogin.cshtml @@ -0,0 +1,33 @@ +@page +@model ExternalLoginModel +@{ + ViewData["Title"] = "Register"; +} + +

@ViewData["Title"]

+

Associate your @Model.ProviderDisplayName account.

+
+ +

+ You've successfully authenticated with @Model.ProviderDisplayName. + Please enter an email address for this site below and click the Register button to finish + logging in. +

+ +
+
+
+ +
+ + + +
+ +
+
+
+ +@section Scripts { + +} diff --git a/src/Identity/samples/IdentitySample.DefaultUI/Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs b/src/Identity/samples/IdentitySample.DefaultUI/Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs new file mode 100644 index 000000000000..165aa5df8af8 --- /dev/null +++ b/src/Identity/samples/IdentitySample.DefaultUI/Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs @@ -0,0 +1,239 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.ComponentModel.DataAnnotations; +using System.Security.Claims; +using System.Text; +using System.Text.Encodings.Web; +using IdentitySample.DefaultUI.Data; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.WebUtilities; + +namespace IdentitySample.DefaultUI.Areas.Identity.Pages.Account; + +[AllowAnonymous] +public class ExternalLoginModel : PageModel +{ + private readonly SignInManager _signInManager; + private readonly UserManager _userManager; + private readonly IUserStore _userStore; + private readonly IUserEmailStore _emailStore; + private readonly IEmailSender _emailSender; + private readonly ILogger _logger; + + public ExternalLoginModel( + SignInManager signInManager, + UserManager userManager, + IUserStore userStore, + ILogger logger, + IEmailSender emailSender) + { + _signInManager = signInManager; + _userManager = userManager; + _userStore = userStore; + _emailStore = GetEmailStore(); + _logger = logger; + _emailSender = emailSender; + } + + [BindProperty] + public InputModel Input { get; set; } = default!; + + public string? ProviderDisplayName { get; set; } + + public string? ReturnUrl { get; set; } + + [TempData] + public string? ErrorMessage { get; set; } + + public class InputModel + { + [Required] + [EmailAddress] + public string Email { get; set; } = default!; + } + + public IActionResult OnGet() => RedirectToPage("./Login"); + + public IActionResult OnPost(string provider, string? returnUrl = null) + { + // Request a redirect to the external login provider. + var redirectUrl = Url.Page("./ExternalLogin", pageHandler: "Callback", values: new { returnUrl }); + var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl); + return new ChallengeResult(provider, properties); + } + + public async Task OnGetCallbackAsync(string? returnUrl = null, string? remoteError = null) + { + returnUrl = returnUrl ?? Url.Content("~/"); + if (remoteError != null) + { + ErrorMessage = $"Error from external provider: {remoteError}"; + return RedirectToPage("./Login", new { ReturnUrl = returnUrl }); + } + + var info = await _signInManager.GetExternalLoginInfoAsync(); + if (info == null) + { + ErrorMessage = "Error loading external login information."; + return RedirectToPage("./Login", new { ReturnUrl = returnUrl }); + } + + // Sign in the user with this external login provider if the user already has a login. + var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true); + if (result.Succeeded) + { + _logger.LogInformation("User logged in with {LoginProvider} provider.", info.LoginProvider); + return LocalRedirect(returnUrl); + } + + if (result.IsLockedOut) + { + return RedirectToPage("./Lockout"); + } + else + { + // Automatically link an existing local account that matches the external email. + var email = info.Principal.FindFirstValue(ClaimTypes.Email); + if (!string.IsNullOrEmpty(email)) + { + // Try to find user by email first, then by username (since email is often used as username) + var existingUser = await _userManager.FindByEmailAsync(email); + if (existingUser is null) + { + existingUser = await _userManager.FindByNameAsync(email); + } + + if (existingUser is not null) + { + // Check if this external login is already linked to this user + var existingLogins = await _userManager.GetLoginsAsync(existingUser); + var isAlreadyLinked = existingLogins.Any(l => + l.LoginProvider == info.LoginProvider && + l.ProviderKey == info.ProviderKey); + + if (!isAlreadyLinked) + { + var addLoginResult = await _userManager.AddLoginAsync(existingUser, info); + if (!addLoginResult.Succeeded) + { + // If AddLoginAsync fails, log the errors but don't show the registration form + _logger.LogWarning("Failed to link {LoginProvider} to existing account: {Errors}", + info.LoginProvider, + string.Join(", ", addLoginResult.Errors.Select(e => e.Description))); + ErrorMessage = "Unable to link your external account. Please contact support."; + return RedirectToPage("./Login", new { ReturnUrl = returnUrl }); + } + + _logger.LogInformation("User linked {LoginProvider} provider to existing account.", info.LoginProvider); + } + + await _signInManager.UpdateExternalAuthenticationTokensAsync(info); + await _signInManager.SignInAsync(existingUser, isPersistent: false, info.LoginProvider); + return LocalRedirect(returnUrl); + } + } + + // If the user does not have an account, then ask the user to create an account. + ReturnUrl = returnUrl; + ProviderDisplayName = info.ProviderDisplayName; + if (info.Principal.HasClaim(c => c.Type == ClaimTypes.Email)) + { + Input = new InputModel + { + Email = info.Principal.FindFirstValue(ClaimTypes.Email)! + }; + } + return Page(); + } + } + + public async Task OnPostConfirmationAsync(string? returnUrl = null) + { + returnUrl = returnUrl ?? Url.Content("~/"); + + // Get the information about the user from the external login provider + var info = await _signInManager.GetExternalLoginInfoAsync(); + if (info == null) + { + ErrorMessage = "Error loading external login information during confirmation."; + return RedirectToPage("./Login", new { ReturnUrl = returnUrl }); + } + + if (ModelState.IsValid) + { + var user = CreateUser(); + + await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None); + await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None); + + var result = await _userManager.CreateAsync(user); + if (result.Succeeded) + { + result = await _userManager.AddLoginAsync(user, info); + if (result.Succeeded) + { + _logger.LogInformation("User created an account using {Name} provider.", info.LoginProvider); + + var userId = await _userManager.GetUserIdAsync(user); + var code = await _userManager.GenerateEmailConfirmationTokenAsync(user); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + var callbackUrl = Url.Page( + "/Account/ConfirmEmail", + pageHandler: null, + values: new { area = "Identity", userId = userId, code = code }, + protocol: Request.Scheme)!; + + await _emailSender.SendEmailAsync(Input.Email, "Confirm your email", + $"Please confirm your account by clicking here."); + + // If account confirmation is required, we need to show the link if we don't have a real email sender + if (_userManager.Options.SignIn.RequireConfirmedAccount) + { + return RedirectToPage("./RegisterConfirmation", new { Email = Input.Email }); + } + + await _signInManager.SignInAsync(user, isPersistent: false, info.LoginProvider); + return LocalRedirect(returnUrl); + } + } + + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + } + + ProviderDisplayName = info.ProviderDisplayName; + ReturnUrl = returnUrl; + return Page(); + } + + private ApplicationUser CreateUser() + { + try + { + return Activator.CreateInstance(); + } + catch + { + throw new InvalidOperationException($"Can't create an instance of '{nameof(ApplicationUser)}'. " + + $"Ensure that '{nameof(ApplicationUser)}' is not an abstract class and has a parameterless constructor."); + } + } + + private IUserEmailStore GetEmailStore() + { + if (!_userManager.SupportsUserEmail) + { + throw new NotSupportedException("The default UI requires a user store with email support."); + } + return (IUserEmailStore)_userStore; + } +} diff --git a/src/Identity/samples/IdentitySample.DefaultUI/Areas/Identity/Pages/Account/Login.cshtml b/src/Identity/samples/IdentitySample.DefaultUI/Areas/Identity/Pages/Account/Login.cshtml new file mode 100644 index 000000000000..06455644bcce --- /dev/null +++ b/src/Identity/samples/IdentitySample.DefaultUI/Areas/Identity/Pages/Account/Login.cshtml @@ -0,0 +1,87 @@ +@page +@model LoginModel + +@{ + ViewData["Title"] = "Log in"; +} + +

@ViewData["Title"]

+
+
+
+
+

Use a local account to log in.

+
+ +
+ + + +
+
+ + + +
+
+ +
+
+ +
+ +
+
+
+
+
+

Use another service to log in.

+
+ @{ + if ((Model.ExternalLogins?.Count ?? 0) == 0) + { +
+

+ There are no external authentication services configured. See this + article + about setting up this ASP.NET application to support logging in via external services + . +

+
+ } + else + { +
+
+

+ @foreach (var provider in Model.ExternalLogins!) + { + + } +

+
+
+ } + } +
+
+
+ +@section Scripts { + +} diff --git a/src/Identity/samples/IdentitySample.DefaultUI/Areas/Identity/Pages/Account/Login.cshtml.cs b/src/Identity/samples/IdentitySample.DefaultUI/Areas/Identity/Pages/Account/Login.cshtml.cs new file mode 100644 index 000000000000..ec3cb4650677 --- /dev/null +++ b/src/Identity/samples/IdentitySample.DefaultUI/Areas/Identity/Pages/Account/Login.cshtml.cs @@ -0,0 +1,102 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.ComponentModel.DataAnnotations; +using IdentitySample.DefaultUI.Data; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace IdentitySample.DefaultUI.Areas.Identity.Pages.Account; + +public class LoginModel : PageModel +{ + private readonly SignInManager _signInManager; + private readonly ILogger _logger; + + public LoginModel(SignInManager signInManager, ILogger logger) + { + _signInManager = signInManager; + _logger = logger; + } + + [BindProperty] + public InputModel Input { get; set; } = default!; + + public IList? ExternalLogins { get; set; } + + public string? ReturnUrl { get; set; } + + [TempData] + public string? ErrorMessage { get; set; } + + public class InputModel + { + [Required] + [EmailAddress] + public string Email { get; set; } = default!; + + [Required] + [DataType(DataType.Password)] + public string Password { get; set; } = default!; + + [Display(Name = "Remember me?")] + public bool RememberMe { get; set; } + } + + public async Task OnGetAsync(string? returnUrl = null) + { + if (!string.IsNullOrEmpty(ErrorMessage)) + { + ModelState.AddModelError(string.Empty, ErrorMessage); + } + + returnUrl ??= Url.Content("~/"); + + // Clear the existing external cookie to ensure a clean login process + await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); + + ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); + + ReturnUrl = returnUrl; + } + + public async Task OnPostAsync(string? returnUrl = null) + { + returnUrl ??= Url.Content("~/"); + + ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); + + if (ModelState.IsValid) + { + // This doesn't count login failures towards account lockout + // To enable password failures to trigger account lockout, set lockoutOnFailure: true + var result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false); + if (result.Succeeded) + { + _logger.LogInformation("User logged in."); + return LocalRedirect(returnUrl); + } + if (result.RequiresTwoFactor) + { + return RedirectToPage("./LoginWith2fa", new { ReturnUrl = returnUrl, RememberMe = Input.RememberMe }); + } + if (result.IsLockedOut) + { + _logger.LogWarning("User account locked out."); + return RedirectToPage("./Lockout"); + } + else + { + ModelState.AddModelError(string.Empty, "Invalid login attempt."); + return Page(); + } + } + + // If we got this far, something failed, redisplay form + return Page(); + } +} diff --git a/src/Identity/samples/IdentitySample.DefaultUI/Areas/Identity/Pages/_ViewImports.cshtml b/src/Identity/samples/IdentitySample.DefaultUI/Areas/Identity/Pages/_ViewImports.cshtml index 33850ab42481..36802cf47bfe 100644 --- a/src/Identity/samples/IdentitySample.DefaultUI/Areas/Identity/Pages/_ViewImports.cshtml +++ b/src/Identity/samples/IdentitySample.DefaultUI/Areas/Identity/Pages/_ViewImports.cshtml @@ -1,2 +1,3 @@ -@namespace IdentitySample.DefaultUI +@using IdentitySample.DefaultUI.Areas.Identity.Pages.Account +@namespace IdentitySample.DefaultUI @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/Identity/samples/IdentitySample.DefaultUI/Dockerfile b/src/Identity/samples/IdentitySample.DefaultUI/Dockerfile new file mode 100644 index 000000000000..22740a04e11a --- /dev/null +++ b/src/Identity/samples/IdentitySample.DefaultUI/Dockerfile @@ -0,0 +1,59 @@ +# Use the nightly Microsoft .NET SDK image for building +FROM mcr.microsoft.com/dotnet/nightly/sdk:10.0-preview AS build +WORKDIR /src + +# Set environment variable to bypass OpenSSL 3.x compatibility issues +ENV OPENSSL_CONF=/dev/null + +# Copy necessary build property files from repository root +COPY ["Directory.Build.props", "/src/"] +COPY ["Directory.Build.targets", "/src/"] +COPY ["global.json", "/src/"] +COPY ["NuGet.config", "/src/"] +COPY ["eng/", "/src/eng/"] +COPY ["artifacts/bin/GenerateFiles/", "/src/artifacts/bin/GenerateFiles/"] + +# Copy all source projects (needed for Reference resolution) +COPY ["src/", "/src/src/"] + +# Set working directory to the sample project +WORKDIR /src/src/Identity/samples/IdentitySample.DefaultUI + +# Restore dependencies +RUN dotnet restore "IdentitySample.DefaultUI.csproj" /p:AllowMissingPrunePackageData=true + +# Build the application +RUN dotnet build "IdentitySample.DefaultUI.csproj" -c Release -o /app/build /p:AllowMissingPrunePackageData=true + +# Publish the application +FROM build AS publish +RUN dotnet publish "IdentitySample.DefaultUI.csproj" -c Release -o /app/publish \ + /p:AllowMissingPrunePackageData=true \ + /p:UseAppHost=false \ + --self-contained false + +# Use the official .NET runtime image for running the app +FROM mcr.microsoft.com/dotnet/nightly/aspnet:10.0-preview AS final + +# Install the specific runtime version needed +RUN apt-get update && apt-get install -y curl && \ + curl -sSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh && \ + chmod +x /tmp/dotnet-install.sh && \ + /tmp/dotnet-install.sh --channel 10.0 --quality preview --runtime dotnet --install-dir /usr/share/dotnet && \ + /tmp/dotnet-install.sh --channel 10.0 --quality preview --runtime aspnetcore --install-dir /usr/share/dotnet && \ + rm /tmp/dotnet-install.sh && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app +EXPOSE 80 +EXPOSE 443 + +# Set environment variables +ENV DOTNET_ROLL_FORWARD=LatestMinor +ENV DOTNET_ROLL_FORWARD_TO_PRERELEASE=1 + +# Copy the published application +COPY --from=publish /app/publish . + +# Set the entry point +ENTRYPOINT ["dotnet", "IdentitySample.DefaultUI.dll"] diff --git a/src/Identity/samples/IdentitySample.DefaultUI/Services/EmailSender.cs b/src/Identity/samples/IdentitySample.DefaultUI/Services/EmailSender.cs new file mode 100644 index 000000000000..49de3c69d086 --- /dev/null +++ b/src/Identity/samples/IdentitySample.DefaultUI/Services/EmailSender.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Identity.UI.Services; + +namespace IdentitySample.DefaultUI.Services; + +public class EmailSender : IEmailSender +{ + private readonly ILogger _logger; + + public EmailSender(ILogger logger) + { + _logger = logger; + } + + public Task SendEmailAsync(string email, string subject, string htmlMessage) + { + _logger.LogInformation("Sending email to {Email} with subject: {Subject}", email, subject); + _logger.LogInformation("Email body: {Body}", htmlMessage); + return Task.CompletedTask; + } +} diff --git a/src/Identity/samples/IdentitySample.DefaultUI/Startup.cs b/src/Identity/samples/IdentitySample.DefaultUI/Startup.cs index dc48228d2131..031761b3d4e1 100644 --- a/src/Identity/samples/IdentitySample.DefaultUI/Startup.cs +++ b/src/Identity/samples/IdentitySample.DefaultUI/Startup.cs @@ -2,7 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using IdentitySample.DefaultUI.Data; +using IdentitySample.DefaultUI.Services; using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; @@ -42,10 +44,34 @@ public void ConfigureServices(IServiceCollection services) services.AddMvc().AddNewtonsoftJson(); - services.AddDefaultIdentity(o => o.SignIn.RequireConfirmedAccount = true) + services.AddDefaultIdentity(o => + { + o.SignIn.RequireConfirmedAccount = true; + // Configure Identity to use V3 schema with Id primary keys and unique indexes + o.Stores.SchemaVersion = IdentitySchemaVersions.Version3; + }) .AddRoles() .AddEntityFrameworkStores(); + // Add external authentication providers + var authentication = services.AddAuthentication(); + + // Google Authentication + var googleClientId = Configuration["Authentication:Google:ClientId"]; + var googleClientSecret = Configuration["Authentication:Google:ClientSecret"]; + if (!string.IsNullOrEmpty(googleClientId) && !string.IsNullOrEmpty(googleClientSecret)) + { + authentication.AddGoogle(options => + { + options.ClientId = googleClientId; + options.ClientSecret = googleClientSecret; + options.SaveTokens = true; + }); + } + + // Add email sender for account confirmation + services.AddTransient(); + services.AddDatabaseDeveloperPageExceptionFilter(); } diff --git a/src/Identity/samples/IdentitySample.DefaultUI/appsettings.json b/src/Identity/samples/IdentitySample.DefaultUI/appsettings.json index f781a53564a9..f42f0ba46e42 100644 --- a/src/Identity/samples/IdentitySample.DefaultUI/appsettings.json +++ b/src/Identity/samples/IdentitySample.DefaultUI/appsettings.json @@ -1,9 +1,14 @@ -{ +{ "ConnectionStrings": { - "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=aspnet-IdentitySample.DefaultUI-47781151-7d38-4b7b-8fe4-9a8b299f124f;Trusted_Connection=True;MultipleActiveResultSets=true" + "DefaultConnection": "Server=localhost,1433;Database=IdentitySample_V3;User Id=sa;Password=YourStrong@Passw0rd;TrustServerCertificate=True;MultipleActiveResultSets=true" + }, + "Authentication": { + "Google": { + "ClientId": "", + "ClientSecret": "" + } }, "Logging": { - "IncludeScopes": false, "LogLevel": { "Default": "Debug", "System": "Information", diff --git a/src/Identity/samples/IdentitySample.DefaultUI/docker-compose.yml b/src/Identity/samples/IdentitySample.DefaultUI/docker-compose.yml new file mode 100644 index 000000000000..f23a2bb7a343 --- /dev/null +++ b/src/Identity/samples/IdentitySample.DefaultUI/docker-compose.yml @@ -0,0 +1,51 @@ +services: + sqlserver: + image: mcr.microsoft.com/mssql/server:2022-latest + container_name: identity-sqlserver + environment: + - ACCEPT_EULA=Y + - SA_PASSWORD=YourStrong@Passw0rd + - MSSQL_PID=Developer + ports: + - "1433:1433" + volumes: + - sqlserver-data:/var/opt/mssql + networks: + - identity-network + healthcheck: + test: /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P YourStrong@Passw0rd -No -Q "SELECT 1" || exit 1 + interval: 10s + timeout: 3s + retries: 10 + start_period: 10s + + webapp: + build: + context: ../../../../ + dockerfile: src/Identity/samples/IdentitySample.DefaultUI/Dockerfile + container_name: identity-webapp + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ASPNETCORE_URLS=http://+:80 + - OPENSSL_CONF=/dev/null + - ConnectionStrings__DefaultConnection=Server=sqlserver;Database=IdentitySample_V3;User Id=sa;Password=YourStrong@Passw0rd;TrustServerCertificate=True;MultipleActiveResultSets=true + # Social login credentials (set these in a .env file or here) + - Authentication__Google__ClientId=${GoogleClientId} + - Authentication__Google__ClientSecret=${GoogleClientSecret} + ports: + - "5000:80" + volumes: + - dataprotection-keys:/root/.aspnet/DataProtection-Keys + depends_on: + sqlserver: + condition: service_healthy + networks: + - identity-network + +volumes: + sqlserver-data: + dataprotection-keys: + +networks: + identity-network: + driver: bridge diff --git a/src/Identity/samples/IdentitySample.DefaultUI/start.cmd b/src/Identity/samples/IdentitySample.DefaultUI/start.cmd new file mode 100644 index 000000000000..0e8036ea13b1 --- /dev/null +++ b/src/Identity/samples/IdentitySample.DefaultUI/start.cmd @@ -0,0 +1,226 @@ +@echo off +setlocal enabledelayedexpansion + +:: Identity V3 Schema - Docker Quick Start Script + +:: Set environment variable to bypass OpenSSL 3.x compatibility issues +set OPENSSL_CONF=/dev/null + +:MENU +cls +echo ========================================== +echo Identity V3 Schema - Docker Setup +echo ========================================== +echo. + +:: Check if Docker is running +docker info >nul 2>&1 +if errorlevel 1 ( + echo [ERROR] Docker is not running! + echo Please start Docker Desktop and try again. + pause + exit /b 1 +) + +echo [OK] Docker is running +echo. + +echo Choose an option: +echo. +echo 1) Start Everything (SQL Server + Web App) +echo 2) Start SQL Server Only +echo 3) Stop All Containers +echo 4) Stop and Remove All Data +echo 5) View Logs +echo 6) Check Database Schema +echo 7) Connect to SQL Server (sqlcmd) +echo 8) Rebuild and Restart +echo 9) Exit +echo. + +set /p choice="Enter option [1-9]: " + +if "%choice%"=="1" goto START_ALL +if "%choice%"=="2" goto START_SQL +if "%choice%"=="3" goto STOP_ALL +if "%choice%"=="4" goto REMOVE_ALL +if "%choice%"=="5" goto VIEW_LOGS +if "%choice%"=="6" goto CHECK_SCHEMA +if "%choice%"=="7" goto CONNECT_SQL +if "%choice%"=="8" goto REBUILD +if "%choice%"=="9" goto EXIT +goto INVALID + +:START_ALL +echo. +echo [*] Starting SQL Server and Web Application... +docker-compose up --build -d +echo. +echo Waiting for SQL Server to be ready... +timeout /t 5 >nul + +set attempts=0 +:WAIT_SQL_ALL +docker exec identity-sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P YourStrong@Passw0rd -No -Q "SELECT 1" >nul 2>&1 +if errorlevel 1 ( + set /a attempts+=1 + if !attempts! geq 12 ( + echo [ERROR] SQL Server did not start in time + pause + goto MENU + ) + echo Waiting... (attempt !attempts!/12) + timeout /t 5 >nul + goto WAIT_SQL_ALL +) + +echo. +echo [OK] Containers started! +echo. +echo Web Application: http://localhost:5000 +echo SQL Server: localhost:1433 +echo Username: sa +echo Password: YourStrong@Passw0rd +echo Database: IdentitySample_V3 +echo. +pause +goto MENU + +:START_SQL +echo. +echo [*] Starting SQL Server only... +docker-compose up sqlserver -d +echo. +echo Waiting for SQL Server to be ready... +timeout /t 5 >nul + +set attempts=0 +:WAIT_SQL +docker exec identity-sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P YourStrong@Passw0rd -No -Q "SELECT 1" >nul 2>&1 +if errorlevel 1 ( + set /a attempts+=1 + if !attempts! geq 12 ( + echo [ERROR] SQL Server did not start in time + pause + goto MENU + ) + echo Waiting... (attempt !attempts!/12) + timeout /t 5 >nul + goto WAIT_SQL +) + +echo. +echo [OK] SQL Server started! +echo. +echo Connection details: +echo Server: localhost,1433 +echo Username: sa +echo Password: YourStrong@Passw0rd +echo. +echo To run the web app locally: +echo dotnet run +echo. +pause +goto MENU + +:STOP_ALL +echo. +echo [*] Stopping all containers... +docker-compose stop +echo [OK] Containers stopped +echo. +pause +goto MENU + +:REMOVE_ALL +echo. +echo [WARNING] This will remove all data! +set /p confirm="Are you sure? (yes/no): " +if /i "%confirm%"=="yes" ( + echo [*] Stopping and removing containers and volumes... + docker-compose down -v + echo [OK] All containers and data removed +) else ( + echo Cancelled +) +echo. +pause +goto MENU + +:VIEW_LOGS +echo. +echo [*] Viewing logs (Ctrl+C to exit)... +echo. +docker-compose logs -f +goto MENU + +:CHECK_SCHEMA +echo. +echo [*] Checking V3 Schema... +echo. + +echo 1. Checking if database exists... +docker exec -it identity-sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P YourStrong@Passw0rd -No -Q "SELECT name FROM sys.databases WHERE name = 'IdentitySample_V3'" + +echo. +echo 2. Checking AspNetUserLogins columns (should include 'Id')... +docker exec -it identity-sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P YourStrong@Passw0rd -No -d IdentitySample_V3 -Q "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'AspNetUserLogins' ORDER BY ORDINAL_POSITION" + +echo. +echo 3. Checking primary key on AspNetUserLogins (should be 'Id')... +docker exec -it identity-sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P YourStrong@Passw0rd -No -d IdentitySample_V3 -Q "EXEC sp_pkeys 'AspNetUserLogins'" + +echo. +pause +goto MENU + +:CONNECT_SQL +echo. +echo [*] Connecting to SQL Server... +echo (Type 'EXIT' to return to menu) +echo. +docker exec -it identity-sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P YourStrong@Passw0rd -No -d IdentitySample_V3 +goto MENU + +:REBUILD +echo. +echo [*] Rebuilding and restarting... +docker-compose down +docker-compose up --build -d +echo. +echo Waiting for SQL Server to be ready... +timeout /t 5 >nul + +set attempts=0 +:WAIT_SQL_REBUILD +docker exec identity-sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P YourStrong@Passw0rd -No -Q "SELECT 1" >nul 2>&1 +if errorlevel 1 ( + set /a attempts+=1 + if !attempts! geq 12 ( + echo [ERROR] SQL Server did not start in time + pause + goto MENU + ) + echo Waiting... (attempt !attempts!/12) + timeout /t 5 >nul + goto WAIT_SQL_REBUILD +) + +echo. +echo [OK] Rebuild complete! +echo Web Application: http://localhost:5000 +echo. +pause +goto MENU + +:INVALID +echo. +echo [ERROR] Invalid option. Please try again. +echo. +timeout /t 2 >nul +goto MENU + +:EXIT +echo. +echo Goodbye! +exit /b 0 diff --git a/src/Identity/samples/IdentitySample.DefaultUI/start.sh b/src/Identity/samples/IdentitySample.DefaultUI/start.sh new file mode 100644 index 000000000000..bf871f0ec8ec --- /dev/null +++ b/src/Identity/samples/IdentitySample.DefaultUI/start.sh @@ -0,0 +1,241 @@ +#!/bin/bash + +# Identity V3 Schema - Docker Quick Start Script + +# Set environment variable to bypass OpenSSL 3.x compatibility issues +export OPENSSL_CONF=/dev/null + +echo "==========================================" +echo "Identity V3 Schema - Docker Setup" +echo "==========================================" +echo "" + +# Check if Docker is running +if ! docker info > /dev/null 2>&1; then + echo "❌ Error: Docker is not running!" + echo "Please start Docker Desktop and try again." + exit 1 +fi + +echo "✅ Docker is running" +echo "" + +# Function to display menu +show_menu() { + echo "Choose an option:" + echo "" + echo " 1) Start Everything (SQL Server + Web App)" + echo " 2) Start SQL Server Only" + echo " 3) Stop All Containers" + echo " 4) Stop and Remove All Data" + echo " 5) View Logs" + echo " 6) Check Database Schema" + echo " 7) Connect to SQL Server (sqlcmd)" + echo " 8) Rebuild and Restart" + echo " 9) Exit" + echo "" + read -p "Enter option [1-9]: " choice +} + +# Function to check SQL Server health +check_sql_health() { + echo "Checking SQL Server health..." + docker exec identity-sqlserver /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U sa -P YourStrong@Passw0rd -No \ + -Q "SELECT @@VERSION" 2>/dev/null + + if [ $? -eq 0 ]; then + echo "✅ SQL Server is healthy" + return 0 + else + echo "❌ SQL Server is not ready yet" + return 1 + fi +} + +# Main loop +while true; do + show_menu + + case $choice in + 1) + echo "" + echo "🚀 Starting SQL Server and Web Application..." + docker compose up --build -d + echo "" + echo "Waiting for SQL Server to be ready..." + sleep 5 + + attempts=0 + max_attempts=12 + while [ $attempts -lt $max_attempts ]; do + if check_sql_health; then + break + fi + attempts=$((attempts+1)) + echo "Waiting... (attempt $attempts/$max_attempts)" + sleep 5 + done + + echo "" + echo "✅ Containers started!" + echo "" + echo "📱 Web Application: http://localhost:5000" + echo "🗄️ SQL Server: localhost:1433" + echo " Username: sa" + echo " Password: YourStrong@Passw0rd" + echo " Database: IdentitySample_V3" + echo "" + echo "Press Enter to continue..." + read + ;; + + 2) + echo "" + echo "🗄️ Starting SQL Server only..." + docker compose up sqlserver -d + echo "" + echo "Waiting for SQL Server to be ready..." + sleep 5 + + attempts=0 + max_attempts=12 + while [ $attempts -lt $max_attempts ]; do + if check_sql_health; then + break + fi + attempts=$((attempts+1)) + echo "Waiting... (attempt $attempts/$max_attempts)" + sleep 5 + done + + echo "" + echo "✅ SQL Server started!" + echo "" + echo "Connection details:" + echo " Server: localhost,1433" + echo " Username: sa" + echo " Password: YourStrong@Passw0rd" + echo "" + echo "To run the web app locally:" + echo " dotnet run" + echo "" + echo "Press Enter to continue..." + read + ;; + + 3) + echo "" + echo "🛑 Stopping all containers..." + docker compose stop + echo "✅ Containers stopped" + echo "" + echo "Press Enter to continue..." + read + ;; + + 4) + echo "" + echo "⚠️ WARNING: This will remove all data!" + read -p "Are you sure? (yes/no): " confirm + if [ "$confirm" = "yes" ]; then + echo "🗑️ Stopping and removing containers and volumes..." + docker compose down -v + echo "✅ All containers and data removed" + else + echo "Cancelled" + fi + echo "" + echo "Press Enter to continue..." + read + ;; + + 5) + echo "" + echo "📋 Viewing logs (Ctrl+C to exit)..." + echo "" + docker compose logs -f + ;; + + 6) + echo "" + echo "🔍 Checking V3 Schema..." + echo "" + + # Check if database exists + echo "1. Checking if database exists..." + docker exec -it identity-sqlserver /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U sa -P YourStrong@Passw0rd -No \ + -Q "SELECT name FROM sys.databases WHERE name = 'IdentitySample_V3'" + + echo "" + echo "2. Checking AspNetUserLogins columns (should include 'Id')..." + docker exec -it identity-sqlserver /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U sa -P YourStrong@Passw0rd -No \ + -d IdentitySample_V3 \ + -Q "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'AspNetUserLogins' ORDER BY ORDINAL_POSITION" + + echo "" + echo "3. Checking primary key on AspNetUserLogins (should be 'Id')..." + docker exec -it identity-sqlserver /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U sa -P YourStrong@Passw0rd -No \ + -d IdentitySample_V3 \ + -Q "EXEC sp_pkeys 'AspNetUserLogins'" + + echo "" + echo "Press Enter to continue..." + read + ;; + + 7) + echo "" + echo "🔌 Connecting to SQL Server..." + echo " (Type 'EXIT' to return to menu)" + echo "" + docker exec -it identity-sqlserver /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U sa -P YourStrong@Passw0rd -No \ + -d IdentitySample_V3 + ;; + + 8) + echo "" + echo "🔨 Rebuilding and restarting..." + docker compose down + docker compose up --build -d + echo "" + echo "Waiting for SQL Server to be ready..." + sleep 5 + + attempts=0 + max_attempts=12 + while [ $attempts -lt $max_attempts ]; do + if check_sql_health; then + break + fi + attempts=$((attempts+1)) + echo "Waiting... (attempt $attempts/$max_attempts)" + sleep 5 + done + + echo "" + echo "✅ Rebuild complete!" + echo "📱 Web Application: http://localhost:5000" + echo "" + echo "Press Enter to continue..." + read + ;; + + 9) + echo "" + echo "👋 Goodbye!" + exit 0 + ;; + + *) + echo "" + echo "❌ Invalid option. Please try again." + echo "" + sleep 2 + ;; + esac +done