From 93085e465b84eadf216f78a3b2fc1d3d8b98f55c Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 30 Oct 2025 11:10:51 -0700 Subject: [PATCH 1/2] feat: Add unique identifier field to IdentityUserTokens and IdentityUserLogins for V3 schema Added Id primary key field to IdentityUserToken and IdentityUserLogin entities Updated V3 schema configuration to use Id primary keys with unique indexes instead of composite keys Made FindTokenAsync obsolete with guidance to use FindTokenByUniqueIndexAsync for V3 schema Updated UserStoreBase to use FindTokenByUniqueIndexAsync internally for token operations Modified UserStore and UserOnlyStore implementations to handle V3 schema with proper Id generation Updated SqlStoreTestBase to verify V3 schema includes Id columns Added comprehensive integration tests for UserManager with V3 schema Created UserStoreVersionThreeTest with specific V3 schema validation Updated DefaultUI sample to use V3 schema configuration All automated and manual testing passes successfully This change improves the database schema by using surrogate primary keys with unique indexes, which provides better performance and aligns with modern database design patterns. The V3 schema maintains backward compatibility while offering improved data access patterns --- .devcontainer/devcontainer.json | 30 +- .../src/IdentityUserContext.cs | 19 +- .../src/PublicAPI.Unshipped.txt | 2 + .../EntityFrameworkCore/src/RoleStore.cs | 8 +- .../EntityFrameworkCore/src/UserOnlyStore.cs | 19 +- .../EntityFrameworkCore/src/UserStore.cs | 33 +- .../test/EF.Test/CustomSchemaTest.cs | 2 +- .../test/EF.Test/SqlStoreTestBase.cs | 5 +- .../UserManagerVersionThreeIntegrationTest.cs | 314 ++++++++++++++ .../test/EF.Test/UserStoreVersionThreeTest.cs | 399 ++++++++++++++++++ .../test/EF.Test/VersionThreeSchemaTest.cs | 13 +- .../src/IdentityRoleClaim.cs | 2 +- .../src/IdentityUserClaim.cs | 2 +- .../src/IdentityUserLogin.cs | 13 +- .../src/IdentityUserToken.cs | 6 +- .../src/PublicAPI.Unshipped.txt | 7 + .../Extensions.Stores/src/UserStoreBase.cs | 25 +- .../Pages/Account/ExternalLogin.cshtml | 33 ++ .../Pages/Account/ExternalLogin.cshtml.cs | 239 +++++++++++ .../Areas/Identity/Pages/Account/Login.cshtml | 87 ++++ .../Identity/Pages/Account/Login.cshtml.cs | 102 +++++ .../Areas/Identity/Pages/_ViewImports.cshtml | 3 +- .../IdentitySample.DefaultUI/Dockerfile | 59 +++ .../Services/EmailSender.cs | 23 + .../IdentitySample.DefaultUI/Startup.cs | 28 +- .../IdentitySample.DefaultUI/appsettings.json | 11 +- .../docker-compose.yml | 51 +++ .../IdentitySample.DefaultUI/start.cmd | 226 ++++++++++ .../samples/IdentitySample.DefaultUI/start.sh | 241 +++++++++++ 29 files changed, 1956 insertions(+), 46 deletions(-) create mode 100644 src/Identity/EntityFrameworkCore/test/EF.Test/UserManagerVersionThreeIntegrationTest.cs create mode 100644 src/Identity/EntityFrameworkCore/test/EF.Test/UserStoreVersionThreeTest.cs create mode 100644 src/Identity/samples/IdentitySample.DefaultUI/Areas/Identity/Pages/Account/ExternalLogin.cshtml create mode 100644 src/Identity/samples/IdentitySample.DefaultUI/Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs create mode 100644 src/Identity/samples/IdentitySample.DefaultUI/Areas/Identity/Pages/Account/Login.cshtml create mode 100644 src/Identity/samples/IdentitySample.DefaultUI/Areas/Identity/Pages/Account/Login.cshtml.cs create mode 100644 src/Identity/samples/IdentitySample.DefaultUI/Dockerfile create mode 100644 src/Identity/samples/IdentitySample.DefaultUI/Services/EmailSender.cs create mode 100644 src/Identity/samples/IdentitySample.DefaultUI/docker-compose.yml create mode 100644 src/Identity/samples/IdentitySample.DefaultUI/start.cmd create mode 100644 src/Identity/samples/IdentitySample.DefaultUI/start.sh 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.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 From a561ada8759449ba9765afc885144809c51fc3b6 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 30 Oct 2025 13:23:31 -0700 Subject: [PATCH 2/2] Remove IdentityRoleClaim Id.get -> int Remove IdentityUserClaim Id.get -> int from PublicAPI.Shipped.txt otherwise CI fails. --- src/Identity/Extensions.Stores/src/PublicAPI.Shipped.txt | 2 -- 1 file changed, 2 deletions(-) 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!