diff --git a/IdentityRole.cs b/IdentityRole.cs new file mode 100644 index 0000000..fb48863 --- /dev/null +++ b/IdentityRole.cs @@ -0,0 +1,49 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace MongoDB.AspNet.Identity +{ + /// + /// Represents a role in the identity system. + /// + public class IdentityRole + { + /// + /// Gets or sets the unique identifier for this role. + /// + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public virtual string Id { get; set; } = ObjectId.GenerateNewId().ToString(); + + /// + /// Gets or sets the name for this role. + /// + public virtual string? Name { get; set; } + + /// + /// Gets or sets the normalized name for this role. + /// + public virtual string? NormalizedName { get; set; } + + /// + /// Gets or sets a random value that should change when the role is persisted to the store. + /// + public virtual string? ConcurrencyStamp { get; set; } = Guid.NewGuid().ToString(); + + /// + /// Initializes a new instance of the class. + /// + public IdentityRole() + { + } + + /// + /// Initializes a new instance of the class with the specified role name. + /// + /// The role name. + public IdentityRole(string roleName) : this() + { + Name = roleName; + } + } +} diff --git a/IdentityUser.cs b/IdentityUser.cs index 01527e6..98a6adc 100644 --- a/IdentityUser.cs +++ b/IdentityUser.cs @@ -1,109 +1,124 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.AspNet.Identity; using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; - namespace MongoDB.AspNet.Identity { /// - /// Class IdentityUser. + /// Represents a user in the identity system. /// - public class IdentityUser : IUser + public class IdentityUser { /// - /// Unique key for the user + /// Gets or sets the unique identifier for this user. /// - /// The identifier. - /// The unique key for the user [BsonId] [BsonRepresentation(BsonType.ObjectId)] - public virtual string Id { get; set; } + public virtual string Id { get; set; } = null!; + /// - /// Gets or sets the name of the user. + /// Gets or sets the user name for this user. /// - /// The name of the user. - public virtual string UserName { get; set; } + public virtual string? UserName { get; set; } + /// - /// Gets or sets the password hash. + /// Gets or sets the normalized user name for this user. /// - /// The password hash. - public virtual string PasswordHash { get; set; } + public virtual string? NormalizedUserName { get; set; } + /// - /// Gets or sets the security stamp. + /// Gets or sets the email address for this user. /// - /// The security stamp. - public virtual string SecurityStamp { get; set; } + public virtual string? Email { get; set; } + /// - /// Gets the roles. + /// Gets or sets the normalized email address for this user. /// - /// The roles. - public virtual List Roles { get; private set; } + public virtual string? NormalizedEmail { get; set; } + /// - /// Gets the claims. + /// Gets or sets a flag indicating if the email address has been confirmed. /// - /// The claims. - public virtual List Claims { get; private set; } + public virtual bool EmailConfirmed { get; set; } + /// - /// Gets the logins. + /// Gets or sets the password hash for this user. /// - /// The logins. - public virtual List Logins { get; private set; } + public virtual string? PasswordHash { get; set; } /// - /// Gets the phone number + /// Gets or sets a random value that should change when a user's credentials change. /// - public virtual string PhoneNumber { get; set; } + public virtual string? SecurityStamp { get; set; } /// - /// Gets Email address + /// Gets or sets a random value that should change when a user is persisted to the store. /// - public virtual string Email { get; set; } + public virtual string? ConcurrencyStamp { get; set; } = Guid.NewGuid().ToString(); /// - /// + /// Gets or sets the phone number for this user. /// - public virtual bool EmailConfirmed { get; set; } + public virtual string? PhoneNumber { get; set; } + + /// + /// Gets or sets a flag indicating if the phone number has been confirmed. + /// + public virtual bool PhoneNumberConfirmed { get; set; } /// - /// + /// Gets or sets a flag indicating if two factor authentication is enabled for this user. /// - public DateTime? LockoutEndDateUtc { get; set; } = DateTime.Now.ToUniversalTime(); + public virtual bool TwoFactorEnabled { get; set; } /// - /// + /// Gets or sets the date and time when any user lockout ends. /// - public int AccessFailedCount { get; set; } + public virtual DateTimeOffset? LockoutEnd { get; set; } /// - /// + /// Gets or sets a flag indicating if lockout is enabled for this user. /// - public bool LockoutEnabled { get; set; } + public virtual bool LockoutEnabled { get; set; } /// - /// + /// Gets or sets the number of failed login attempts for this user. /// - public bool TwoFactorEnabled { get; set; } + public virtual int AccessFailedCount { get; set; } + + /// + /// Gets the roles for this user. + /// + public virtual List Roles { get; private set; } = new List(); + + /// + /// Gets the claims for this user. + /// + public virtual List Claims { get; private set; } = new List(); + + /// + /// Gets the logins for this user. + /// + public virtual List Logins { get; private set; } = new List(); + + /// + /// Gets the authentication tokens for this user. + /// + public virtual List Tokens { get; private set; } = new List(); /// /// Initializes a new instance of the class. /// public IdentityUser() - { - this.Claims = new List(); - this.Roles = new List(); - this.Logins = new List(); - } + { + } /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class with the specified user name. /// - /// Name of the user. - public IdentityUser(string userName) : this() - { - this.UserName = userName; - } - } + /// The user name. + public IdentityUser(string userName) : this() + { + UserName = userName; + } + } } diff --git a/IdentityUserClaim.cs b/IdentityUserClaim.cs index 2482390..7b01788 100644 --- a/IdentityUserClaim.cs +++ b/IdentityUserClaim.cs @@ -1,35 +1,39 @@ -using MongoDB.Bson; -using MongoDB.Bson.Serialization.Attributes; +using System.Security.Claims; namespace MongoDB.AspNet.Identity { /// - /// Class IdentityUserClaim. + /// Represents a claim that a user possesses. /// public class IdentityUserClaim { /// - /// Gets or sets the identifier. + /// Gets or sets the claim type for this claim. /// - /// The identifier. - [BsonId] - [BsonRepresentation(BsonType.ObjectId)] - public virtual string Id { get; set; } + public virtual string ClaimType { get; set; } = null!; + /// - /// Gets or sets the user identifier. + /// Gets or sets the claim value for this claim. /// - /// The user identifier. - public virtual string UserId { get; set; } + public virtual string? ClaimValue { get; set; } + /// - /// Gets or sets the type of the claim. + /// Converts the entity into a Claim instance. /// - /// The type of the claim. - public virtual string ClaimType { get; set; } + /// A representing the claim. + public virtual Claim ToClaim() + { + return new Claim(ClaimType, ClaimValue ?? string.Empty); + } + /// - /// Gets or sets the claim value. + /// Initializes this instance from the specified claim. /// - /// The claim value. - public virtual string ClaimValue { get; set; } - + /// The claim to initialize from. + public virtual void InitializeFromClaim(Claim claim) + { + ClaimType = claim.Type; + ClaimValue = claim.Value; + } } -} \ No newline at end of file +} diff --git a/IdentityUserLogin.cs b/IdentityUserLogin.cs new file mode 100644 index 0000000..074935c --- /dev/null +++ b/IdentityUserLogin.cs @@ -0,0 +1,23 @@ +namespace MongoDB.AspNet.Identity +{ + /// + /// Represents a login and its associated provider for a user. + /// + public class IdentityUserLogin + { + /// + /// Gets or sets the login provider (e.g., google, facebook, twitter). + /// + public virtual string LoginProvider { get; set; } = null!; + + /// + /// Gets or sets the unique identifier for this login provided by the login provider. + /// + public virtual string ProviderKey { get; set; } = null!; + + /// + /// Gets or sets the friendly name used in a UI for this login. + /// + public virtual string? ProviderDisplayName { get; set; } + } +} diff --git a/IdentityUserToken.cs b/IdentityUserToken.cs new file mode 100644 index 0000000..7622d54 --- /dev/null +++ b/IdentityUserToken.cs @@ -0,0 +1,23 @@ +namespace MongoDB.AspNet.Identity +{ + /// + /// Represents an authentication token for a user. + /// + public class IdentityUserToken + { + /// + /// Gets or sets the login provider this token is from. + /// + public virtual string LoginProvider { get; set; } = null!; + + /// + /// Gets or sets the name of the token. + /// + public virtual string Name { get; set; } = null!; + + /// + /// Gets or sets the value of the token. + /// + public virtual string? Value { get; set; } + } +} diff --git a/MigrationHelper.cs b/MigrationHelper.cs new file mode 100644 index 0000000..efd8674 --- /dev/null +++ b/MigrationHelper.cs @@ -0,0 +1,383 @@ +using MongoDB.Bson; +using MongoDB.Driver; + +namespace MongoDB.AspNet.Identity +{ + /// + /// Handles automatic migration of legacy ASP.NET Identity documents to ASP.NET Core Identity format + /// and ensures proper indexes exist. + /// + public static class MigrationHelper + { + private const string MigrationCollectionName = "_IdentityMigrations"; + private const string UsersMigrationKey = "AspNetUsers_v2"; + private const string RolesMigrationKey = "AspNetRoles_v2"; + + /// + /// Ensures the users collection is migrated and indexed. + /// This method is idempotent and safe to call multiple times. + /// + /// The user type. + /// The MongoDB database. + /// The users collection name. + /// Cancellation token. + public static async Task EnsureUsersMigratedAsync( + IMongoDatabase database, + string collectionName = "AspNetUsers", + CancellationToken cancellationToken = default) + where TUser : IdentityUser + { + if (await IsMigrationCompletedAsync(database, UsersMigrationKey, cancellationToken)) + { + return; + } + + var collection = database.GetCollection(collectionName); + + // Migrate documents that don't have NormalizedUserName + await MigrateUsersAsync(collection, cancellationToken); + + // Create indexes + await CreateUserIndexesAsync(collection, cancellationToken); + + // Mark migration as complete + await MarkMigrationCompletedAsync(database, UsersMigrationKey, cancellationToken); + } + + /// + /// Ensures the roles collection is migrated and indexed. + /// This method is idempotent and safe to call multiple times. + /// + /// The MongoDB database. + /// The roles collection name. + /// Cancellation token. + public static async Task EnsureRolesMigratedAsync( + IMongoDatabase database, + string collectionName = "AspNetRoles", + CancellationToken cancellationToken = default) + { + if (await IsMigrationCompletedAsync(database, RolesMigrationKey, cancellationToken)) + { + return; + } + + var collection = database.GetCollection(collectionName); + + // Migrate documents that don't have NormalizedName + await MigrateRolesAsync(collection, cancellationToken); + + // Create indexes + await CreateRoleIndexesAsync(collection, cancellationToken); + + // Mark migration as complete + await MarkMigrationCompletedAsync(database, RolesMigrationKey, cancellationToken); + } + + private static async Task IsMigrationCompletedAsync( + IMongoDatabase database, + string migrationKey, + CancellationToken cancellationToken) + { + var migrationCollection = database.GetCollection(MigrationCollectionName); + var filter = Builders.Filter.Eq("_id", migrationKey); + var doc = await migrationCollection.Find(filter).FirstOrDefaultAsync(cancellationToken); + return doc != null; + } + + private static async Task MarkMigrationCompletedAsync( + IMongoDatabase database, + string migrationKey, + CancellationToken cancellationToken) + { + var migrationCollection = database.GetCollection(MigrationCollectionName); + var doc = new BsonDocument + { + { "_id", migrationKey }, + { "completedAt", DateTime.UtcNow }, + { "version", "2.0.0" } + }; + + await migrationCollection.ReplaceOneAsync( + Builders.Filter.Eq("_id", migrationKey), + doc, + new ReplaceOptions { IsUpsert = true }, + cancellationToken); + } + + private static async Task MigrateUsersAsync( + IMongoCollection collection, + CancellationToken cancellationToken) + { + // Find all documents that need migration (NormalizedUserName doesn't exist or is null) + var filter = Builders.Filter.Or( + Builders.Filter.Exists("NormalizedUserName", false), + Builders.Filter.Eq("NormalizedUserName", BsonNull.Value) + ); + + var cursor = await collection.FindAsync(filter, cancellationToken: cancellationToken); + var batch = new List>(); + + await cursor.ForEachAsync(doc => + { + var updates = new List>(); + + // Normalize UserName + if (doc.Contains("UserName") && doc["UserName"] != BsonNull.Value) + { + var userName = doc["UserName"].AsString; + updates.Add(Builders.Update.Set("NormalizedUserName", userName.ToUpperInvariant())); + } + + // Normalize Email + if (doc.Contains("Email") && doc["Email"] != BsonNull.Value) + { + var email = doc["Email"].AsString; + updates.Add(Builders.Update.Set("NormalizedEmail", email.ToUpperInvariant())); + } + + // Convert LockoutEndDateUtc to LockoutEnd if it exists + if (doc.Contains("LockoutEndDateUtc") && doc["LockoutEndDateUtc"] != BsonNull.Value) + { + var lockoutEndUtc = doc["LockoutEndDateUtc"].ToUniversalTime(); + updates.Add(Builders.Update.Set("LockoutEnd", lockoutEndUtc)); + updates.Add(Builders.Update.Unset("LockoutEndDateUtc")); + } + + // Add ConcurrencyStamp if missing + if (!doc.Contains("ConcurrencyStamp") || doc["ConcurrencyStamp"] == BsonNull.Value) + { + updates.Add(Builders.Update.Set("ConcurrencyStamp", Guid.NewGuid().ToString())); + } + + // Add PhoneNumberConfirmed if missing + if (!doc.Contains("PhoneNumberConfirmed")) + { + updates.Add(Builders.Update.Set("PhoneNumberConfirmed", false)); + } + + // Initialize Tokens array if missing + if (!doc.Contains("Tokens")) + { + updates.Add(Builders.Update.Set("Tokens", new BsonArray())); + } + + // Migrate Logins from UserLoginInfo format to IdentityUserLogin format + if (doc.Contains("Logins") && doc["Logins"].IsBsonArray) + { + var logins = doc["Logins"].AsBsonArray; + var migratedLogins = new BsonArray(); + var needsLoginMigration = false; + + foreach (var login in logins) + { + if (login.IsBsonDocument) + { + var loginDoc = login.AsBsonDocument; + // Check if it's old format (has ProviderKey but no ProviderDisplayName might be missing) + if (!loginDoc.Contains("ProviderDisplayName")) + { + needsLoginMigration = true; + loginDoc["ProviderDisplayName"] = loginDoc.Contains("LoginProvider") + ? loginDoc["LoginProvider"] + : BsonNull.Value; + } + migratedLogins.Add(loginDoc); + } + } + + if (needsLoginMigration) + { + updates.Add(Builders.Update.Set("Logins", migratedLogins)); + } + } + + if (updates.Count > 0) + { + var combinedUpdate = Builders.Update.Combine(updates); + batch.Add(new UpdateOneModel( + Builders.Filter.Eq("_id", doc["_id"]), + combinedUpdate)); + } + }, cancellationToken); + + if (batch.Count > 0) + { + await collection.BulkWriteAsync(batch, cancellationToken: cancellationToken); + } + } + + private static async Task MigrateRolesAsync( + IMongoCollection collection, + CancellationToken cancellationToken) + { + // Find all documents that need migration + var filter = Builders.Filter.Or( + Builders.Filter.Exists("NormalizedName", false), + Builders.Filter.Eq("NormalizedName", BsonNull.Value) + ); + + var cursor = await collection.FindAsync(filter, cancellationToken: cancellationToken); + var batch = new List>(); + + await cursor.ForEachAsync(doc => + { + var updates = new List>(); + + // Normalize Name + if (doc.Contains("Name") && doc["Name"] != BsonNull.Value) + { + var name = doc["Name"].AsString; + updates.Add(Builders.Update.Set("NormalizedName", name.ToUpperInvariant())); + } + + // Add ConcurrencyStamp if missing + if (!doc.Contains("ConcurrencyStamp") || doc["ConcurrencyStamp"] == BsonNull.Value) + { + updates.Add(Builders.Update.Set("ConcurrencyStamp", Guid.NewGuid().ToString())); + } + + if (updates.Count > 0) + { + var combinedUpdate = Builders.Update.Combine(updates); + batch.Add(new UpdateOneModel( + Builders.Filter.Eq("_id", doc["_id"]), + combinedUpdate)); + } + }, cancellationToken); + + if (batch.Count > 0) + { + await collection.BulkWriteAsync(batch, cancellationToken: cancellationToken); + } + } + + private static async Task CreateUserIndexesAsync( + IMongoCollection collection, + CancellationToken cancellationToken) + { + var indexes = new List> + { + // Unique index on NormalizedUserName for fast username lookups + // Using partial filter to only index non-null values (allows multiple users without NormalizedUserName during migration) + new CreateIndexModel( + Builders.IndexKeys.Ascending("NormalizedUserName"), + new CreateIndexOptions + { + Unique = true, + Name = "IX_NormalizedUserName", + PartialFilterExpression = Builders.Filter.And( + Builders.Filter.Exists("NormalizedUserName"), + Builders.Filter.Type("NormalizedUserName", BsonType.String)) + }), + + // Unique index on NormalizedEmail for email lookups + new CreateIndexModel( + Builders.IndexKeys.Ascending("NormalizedEmail"), + new CreateIndexOptions + { + Unique = true, + Name = "IX_NormalizedEmail", + PartialFilterExpression = Builders.Filter.And( + Builders.Filter.Exists("NormalizedEmail"), + Builders.Filter.Type("NormalizedEmail", BsonType.String)) + }), + + // Compound index on Logins for external login lookups + new CreateIndexModel( + Builders.IndexKeys + .Ascending("Logins.LoginProvider") + .Ascending("Logins.ProviderKey"), + new CreateIndexOptions { Sparse = true, Name = "IX_Logins" }), + + // Index on Roles for GetUsersInRole queries + new CreateIndexModel( + Builders.IndexKeys.Ascending("Roles"), + new CreateIndexOptions { Sparse = true, Name = "IX_Roles" }), + + // Index on Claims for GetUsersForClaim queries + new CreateIndexModel( + Builders.IndexKeys + .Ascending("Claims.ClaimType") + .Ascending("Claims.ClaimValue"), + new CreateIndexOptions { Sparse = true, Name = "IX_Claims" }) + }; + + try + { + await collection.Indexes.CreateManyAsync(indexes, cancellationToken); + } + catch (MongoCommandException ex) when (ex.Code == 85 || ex.Code == 86) + { + // Index already exists with different options - try to create individually + foreach (var index in indexes) + { + try + { + await collection.Indexes.CreateOneAsync(index, cancellationToken: cancellationToken); + } + catch (MongoCommandException) + { + // Index exists, skip + } + } + } + } + + private static async Task CreateRoleIndexesAsync( + IMongoCollection collection, + CancellationToken cancellationToken) + { + var indexes = new List> + { + // Unique index on NormalizedName for fast role lookups + // Using partial filter to only index non-null values + new CreateIndexModel( + Builders.IndexKeys.Ascending("NormalizedName"), + new CreateIndexOptions + { + Unique = true, + Name = "IX_NormalizedName", + PartialFilterExpression = Builders.Filter.And( + Builders.Filter.Exists("NormalizedName"), + Builders.Filter.Type("NormalizedName", BsonType.String)) + }) + }; + + try + { + await collection.Indexes.CreateManyAsync(indexes, cancellationToken); + } + catch (MongoCommandException ex) when (ex.Code == 85 || ex.Code == 86) + { + // Index already exists with different options + foreach (var index in indexes) + { + try + { + await collection.Indexes.CreateOneAsync(index, cancellationToken: cancellationToken); + } + catch (MongoCommandException) + { + // Index exists, skip + } + } + } + } + + /// + /// Forces re-running the migration by clearing the migration markers. + /// Use this if you need to re-migrate after schema changes. + /// + /// The MongoDB database. + /// Cancellation token. + public static async Task ResetMigrationAsync( + IMongoDatabase database, + CancellationToken cancellationToken = default) + { + var migrationCollection = database.GetCollection(MigrationCollectionName); + await migrationCollection.DeleteManyAsync( + Builders.Filter.Empty, + cancellationToken); + } + } +} diff --git a/MongoDB.AspNet.Identity.csproj b/MongoDB.AspNet.Identity.csproj index 38b1801..9c9dc81 100644 --- a/MongoDB.AspNet.Identity.csproj +++ b/MongoDB.AspNet.Identity.csproj @@ -1,127 +1,51 @@ - - - + + - Debug - AnyCPU - - - 2.0 - {0FF87680-235F-4231-8302-9BF6FCAC7476} - {349c5851-65df-11da-9384-00065b846f21};{fae04ec0-301f-11d3-bf4b-00c04f79efbc} - Library - Properties - MongoDB.AspNet.Identity - MongoDB.AspNet.Identity - v4.5 - true - - - - - - + net8.0 + enable + enable + true + + + Rammi.MongoDb.AspNet.Identity + 2.0.0 + Jonathan Sheely, Rammi + InspectorIT + ASP.NET Core Identity provider for MongoDB. Allows you to use MongoDB as the storage for ASP.NET Core Identity. + aspnet;identity;mongodb;aspnetcore + MIT + https://github.com/rammicz/MongoDB.AspNet.Identity + git + + + MongoDB.AspNet.Identity + 2.0.0.0 + 2.0.0.0 - - true - full - false - bin\ - DEBUG;TRACE - prompt - 4 - bin\MongoDB.AspNet.Identity.XML - - - pdbonly - true - bin\ - TRACE - prompt - 4 - bin\MongoDB.AspNet.Identity.XML - - - - packages\Microsoft.AspNet.Identity.Core.2.2.1\lib\net45\Microsoft.AspNet.Identity.Core.dll - True - - - packages\MongoDB.Bson.2.4.3\lib\net45\MongoDB.Bson.dll - True - - - packages\MongoDB.Driver.2.4.3\lib\net45\MongoDB.Driver.dll - True - - - packages\MongoDB.Driver.Core.2.4.3\lib\net45\MongoDB.Driver.Core.dll - True - - - packages\mongocsharpdriver.2.4.3\lib\net45\MongoDB.Driver.Legacy.dll - True - - - - - - - packages\System.Runtime.InteropServices.RuntimeInformation.4.0.0\lib\net45\System.Runtime.InteropServices.RuntimeInformation.dll - True - - - - - - - - + - - - - - + + + + - - Designer - + + + + + + + + + + + + + + + + - - - - - 10.0 - $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - - - - - - - - - True - True - 50949 - / - http://localhost:50890/ - False - False - - - False - - - - - - \ No newline at end of file + + diff --git a/MongoDB.AspNet.Identity.nuspec b/MongoDB.AspNet.Identity.nuspec deleted file mode 100644 index c2e3d05..0000000 --- a/MongoDB.AspNet.Identity.nuspec +++ /dev/null @@ -1,27 +0,0 @@ - - - - MongoDB.AspNet.Identity - 1.0.7 - jsheely, sitebro - Jonathan Sheely - https://raw.githubusercontent.com/InspectorIT/MongoDB.AspNet.Identity/master/LICENSE - https://github.com/InspectorIT/MongoDB.AspNet.Identity - https://www.nuget.org/Content/Images/packageDefaultIcon-50x50.png - true - ASP.NET MVC 5 shipped with a new Identity system (in the Microsoft.AspNet.Identity.Core package) in order to support both local login and remote logins via OpenID/OAuth, but only ships with an Entity Framework provider (Microsoft.AspNet.Identity.EntityFramework). -MongoDB.AspNet.Identity is a MongoDB backend provider that is a nearly in place replacement for the EF version. This library has been built to work with the mongocsharpdriver compatibility .NET driver provided by the MongoDB team. - - Version 1.0.7 -- Added properties to IdentityUser required by the default UserManager bundled with the default ASP.NET MVC5 template -- Implemented IUserStore, IUserEmailStore, IUserLockoutStore - - Copyright 2017 - mongodb mongocsharpdriver identity mvc5 - - - - - - - \ No newline at end of file diff --git a/MongoDB.AspNet.Identity.sln b/MongoDB.AspNet.Identity.sln index 6d66dd8..f33498e 100644 --- a/MongoDB.AspNet.Identity.sln +++ b/MongoDB.AspNet.Identity.sln @@ -1,28 +1,42 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25420.1 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MongoDB.AspNet.Identity", "MongoDB.AspNet.Identity.csproj", "{0FF87680-235F-4231-8302-9BF6FCAC7476}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MongoDB.AspNet.Identity", "MongoDB.AspNet.Identity.csproj", "{3E953EAB-8C00-43EE-9C0F-1605EA1BC65F}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestApplication", "TestApplication\TestApplication.csproj", "{4EF07067-48A5-4303-849A-9D800F482E31}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{64A9AD03-6DE1-4DA5-A3F4-F802A2B80BAB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MongoDB.AspNet.Identity.Tests", "tests\MongoDB.AspNet.Identity.Tests\MongoDB.AspNet.Identity.Tests.csproj", "{001FC7E2-ADE3-4E11-90B0-81BACE08D399}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{704F9CA5-3EC4-437A-8092-23BCC4C7D08E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SampleWebApp", "samples\SampleWebApp\SampleWebApp.csproj", "{F0CCA591-D4FF-441E-8D35-8E399F7FFEC6}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {0FF87680-235F-4231-8302-9BF6FCAC7476}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0FF87680-235F-4231-8302-9BF6FCAC7476}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0FF87680-235F-4231-8302-9BF6FCAC7476}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0FF87680-235F-4231-8302-9BF6FCAC7476}.Release|Any CPU.Build.0 = Release|Any CPU - {4EF07067-48A5-4303-849A-9D800F482E31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4EF07067-48A5-4303-849A-9D800F482E31}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4EF07067-48A5-4303-849A-9D800F482E31}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4EF07067-48A5-4303-849A-9D800F482E31}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {3E953EAB-8C00-43EE-9C0F-1605EA1BC65F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3E953EAB-8C00-43EE-9C0F-1605EA1BC65F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3E953EAB-8C00-43EE-9C0F-1605EA1BC65F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3E953EAB-8C00-43EE-9C0F-1605EA1BC65F}.Release|Any CPU.Build.0 = Release|Any CPU + {001FC7E2-ADE3-4E11-90B0-81BACE08D399}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {001FC7E2-ADE3-4E11-90B0-81BACE08D399}.Debug|Any CPU.Build.0 = Debug|Any CPU + {001FC7E2-ADE3-4E11-90B0-81BACE08D399}.Release|Any CPU.ActiveCfg = Release|Any CPU + {001FC7E2-ADE3-4E11-90B0-81BACE08D399}.Release|Any CPU.Build.0 = Release|Any CPU + {F0CCA591-D4FF-441E-8D35-8E399F7FFEC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F0CCA591-D4FF-441E-8D35-8E399F7FFEC6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F0CCA591-D4FF-441E-8D35-8E399F7FFEC6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F0CCA591-D4FF-441E-8D35-8E399F7FFEC6}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {001FC7E2-ADE3-4E11-90B0-81BACE08D399} = {64A9AD03-6DE1-4DA5-A3F4-F802A2B80BAB} + {F0CCA591-D4FF-441E-8D35-8E399F7FFEC6} = {704F9CA5-3EC4-437A-8092-23BCC4C7D08E} + EndGlobalSection EndGlobal diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs deleted file mode 100644 index a1608b7..0000000 --- a/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -// General Information about an assembly is controlled through the following -// set of attributes. Change these attribute values to modify the information -// associated with an assembly. -[assembly: AssemblyTitle("MongoDB.AspNet.Identity")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyConfiguration("")] -[assembly: AssemblyCompany("InspectorIT by Jonathan Sheely")] -[assembly: AssemblyProduct("MongoDB.AspNet.Identity")] -[assembly: AssemblyCopyright("Copyright © 2017")] -[assembly: AssemblyTrademark("")] -[assembly: AssemblyCulture("")] - -// Setting ComVisible to false makes the types in this assembly not visible -// to COM components. If you need to access a type in this assembly from -// COM, set the ComVisible attribute to true on that type. -[assembly: ComVisible(false)] - -// The following GUID is for the ID of the typelib if this project is exposed to COM -[assembly: Guid("9e1632ab-d097-465f-a9a6-9285d24d008a")] - -// Version information for an assembly consists of the following four values: -// -// Major Version -// Minor Version -// Build Number -// Revision -// -// You can specify all the values or you can default the Revision and Build Numbers -// by using the '*' as shown below: -[assembly: AssemblyVersion("1.0.7.0")] -[assembly: AssemblyFileVersion("1.0.7.0")] diff --git a/RoleStore.cs b/RoleStore.cs new file mode 100644 index 0000000..9be3d6c --- /dev/null +++ b/RoleStore.cs @@ -0,0 +1,186 @@ +using Microsoft.AspNetCore.Identity; +using MongoDB.Bson; +using MongoDB.Driver; + +namespace MongoDB.AspNet.Identity +{ + /// + /// Represents a new instance of a persistence store for roles, using MongoDB. + /// + public class RoleStore : IRoleStore, IQueryableRoleStore + { + private readonly IMongoDatabase _database; + private bool _disposed; + private bool _migrationEnsured; + private readonly SemaphoreSlim _migrationLock = new(1, 1); + + private const string CollectionName = "AspNetRoles"; + + private IMongoCollection RolesCollection => _database.GetCollection(CollectionName); + + /// + /// Gets an of roles. + /// + public IQueryable Roles => RolesCollection.AsQueryable(); + + /// + /// Initializes a new instance of using the specified MongoDB database. + /// + /// The MongoDB database to use. + public RoleStore(IMongoDatabase database) + { + _database = database ?? throw new ArgumentNullException(nameof(database)); + } + + #region Migration + + private async Task EnsureMigrationAsync(CancellationToken cancellationToken) + { + if (_migrationEnsured) return; + + await _migrationLock.WaitAsync(cancellationToken); + try + { + if (_migrationEnsured) return; + + await MigrationHelper.EnsureRolesMigratedAsync(_database, CollectionName, cancellationToken); + _migrationEnsured = true; + } + finally + { + _migrationLock.Release(); + } + } + + #endregion + + /// + public async Task CreateAsync(IdentityRole role, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(role); + + await EnsureMigrationAsync(cancellationToken); + await RolesCollection.InsertOneAsync(role, cancellationToken: cancellationToken); + return IdentityResult.Success; + } + + /// + public async Task DeleteAsync(IdentityRole role, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(role); + + await RolesCollection.DeleteOneAsync( + Builders.Filter.Eq(r => r.Id, role.Id), + cancellationToken); + return IdentityResult.Success; + } + + /// + public async Task FindByIdAsync(string roleId, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + await EnsureMigrationAsync(cancellationToken); + return await RolesCollection + .Find(Builders.Filter.Eq(r => r.Id, roleId)) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task FindByNameAsync(string normalizedRoleName, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + await EnsureMigrationAsync(cancellationToken); + return await RolesCollection + .Find(Builders.Filter.Eq(r => r.NormalizedName, normalizedRoleName)) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task GetNormalizedRoleNameAsync(IdentityRole role, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(role); + + return Task.FromResult(role.NormalizedName); + } + + /// + public Task GetRoleIdAsync(IdentityRole role, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(role); + + return Task.FromResult(role.Id); + } + + /// + public Task GetRoleNameAsync(IdentityRole role, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(role); + + return Task.FromResult(role.Name); + } + + /// + public Task SetNormalizedRoleNameAsync(IdentityRole role, string? normalizedName, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(role); + + role.NormalizedName = normalizedName; + return Task.CompletedTask; + } + + /// + public Task SetRoleNameAsync(IdentityRole role, string? roleName, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(role); + + role.Name = roleName; + return Task.CompletedTask; + } + + /// + public async Task UpdateAsync(IdentityRole role, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(role); + + await RolesCollection.ReplaceOneAsync( + Builders.Filter.Eq(r => r.Id, role.Id), + role, + new ReplaceOptions { IsUpsert = false }, + cancellationToken); + + return IdentityResult.Success; + } + + /// + public void Dispose() + { + _disposed = true; + GC.SuppressFinalize(this); + } + + protected void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + } +} diff --git a/UserStore.cs b/UserStore.cs index 94fee2d..f07ca96 100644 --- a/UserStore.cs +++ b/UserStore.cs @@ -1,719 +1,808 @@ -using System; -using System.Collections.Generic; -using System.Configuration; -using System.Linq; using System.Security.Claims; -using System.Threading.Tasks; -using Microsoft.AspNet.Identity; +using Microsoft.AspNetCore.Identity; using MongoDB.Bson; using MongoDB.Driver; namespace MongoDB.AspNet.Identity { /// - /// Class UserStore. + /// Represents a new instance of a persistence store for users, using MongoDB. /// - /// The type of the t user. + /// The type representing a user. public class UserStore : IUserStore, - IUserLoginStore, - IUserClaimStore, + IUserLoginStore, + IUserClaimStore, IUserRoleStore, - IUserPasswordStore, + IUserPasswordStore, IUserSecurityStampStore, IUserEmailStore, - IUserLockoutStore, - IUserTwoFactorStore + IUserLockoutStore, + IUserTwoFactorStore, + IUserPhoneNumberStore, + IUserAuthenticationTokenStore, + IQueryableUserStore where TUser : IdentityUser { - #region Private Methods & Variables + private readonly IMongoDatabase _database; + private bool _disposed; + private bool _migrationEnsured; + private readonly SemaphoreSlim _migrationLock = new(1, 1); /// - /// The database + /// The default collection name for users. /// - private readonly IMongoDatabase db; + private const string CollectionName = "AspNetUsers"; /// - /// The _disposed + /// Gets the users collection. /// - private bool _disposed; + private IMongoCollection UsersCollection => _database.GetCollection(CollectionName); /// - /// The AspNetUsers collection name + /// Gets an of users. /// - private const string collectionName = "AspNetUsers"; + public IQueryable Users => UsersCollection.AsQueryable(); /// - /// Gets the database from connection string. + /// Initializes a new instance of using the specified MongoDB connection string. /// - /// The connection string. - /// MongoDatabase. - /// No database name specified in connection string - private IMongoDatabase GetDatabaseFromSqlStyle(string connectionString) + /// The MongoDB connection string (must include database name). + public UserStore(string connectionString) { var mongoUrl = new MongoUrl(connectionString); - MongoClientSettings settings = MongoClientSettings.FromUrl(mongoUrl); - IMongoClient client = new MongoClient(settings); - if (mongoUrl.DatabaseName == null) + if (string.IsNullOrEmpty(mongoUrl.DatabaseName)) { - throw new Exception("No database name specified in connection string"); + throw new ArgumentException("The connection string must include a database name.", nameof(connectionString)); } - return client.GetDatabase(mongoUrl.DatabaseName); - } - /// - /// Gets the database from URL. - /// - /// The URL. - /// MongoDatabase. - private IMongoDatabase GetDatabaseFromUrl(MongoUrl url) - { - IMongoClient client = new MongoClient(url); - if (url.DatabaseName == null) - { - throw new Exception("No database name specified in connection string"); - } - return client.GetDatabase(url.DatabaseName); // WriteConcern defaulted to Acknowledged + var client = new MongoClient(mongoUrl); + _database = client.GetDatabase(mongoUrl.DatabaseName); } /// - /// Uses connectionString to connect to server and then uses databae name specified. + /// Initializes a new instance of using the specified MongoDB connection string and database name. /// - /// The connection string. - /// Name of the database. - /// MongoDatabase. - private IMongoDatabase GetDatabase(string connectionString, string dbName) + /// The MongoDB connection string. + /// The name of the database. + public UserStore(string connectionString, string databaseName) { var client = new MongoClient(connectionString); - return client.GetDatabase(dbName); + _database = client.GetDatabase(databaseName); } - #endregion - - #region Constructors - /// - /// Initializes a new instance of the class. Uses DefaultConnection name if none was - /// specified. + /// Initializes a new instance of using an existing MongoDB database. /// - public UserStore() - : this("DefaultConnection") + /// The MongoDB database to use. + public UserStore(IMongoDatabase database) { + _database = database ?? throw new ArgumentNullException(nameof(database)); } + #region Migration + /// - /// Initializes a new instance of the class. Uses name from ConfigurationManager or a - /// mongodb:// Url. + /// Ensures migration has been run. This is called automatically on first database access. /// - /// The connection name or URL. - public UserStore(string connectionNameOrUrl) + private async Task EnsureMigrationAsync(CancellationToken cancellationToken) { - if (connectionNameOrUrl.ToLower().StartsWith("mongodb://")) + if (_migrationEnsured) return; + + await _migrationLock.WaitAsync(cancellationToken); + try { - db = GetDatabaseFromUrl(new MongoUrl(connectionNameOrUrl)); + if (_migrationEnsured) return; + + await MigrationHelper.EnsureUsersMigratedAsync(_database, CollectionName, cancellationToken); + _migrationEnsured = true; } - else + finally { - string connStringFromManager = - ConfigurationManager.ConnectionStrings[connectionNameOrUrl].ConnectionString; - db = connStringFromManager.ToLower().StartsWith("mongodb://") - ? GetDatabaseFromUrl(new MongoUrl(connStringFromManager)) - : GetDatabaseFromSqlStyle(connStringFromManager); + _migrationLock.Release(); } } - /// - /// Initializes a new instance of the class. Uses name from ConfigurationManager or a - /// mongodb:// Url. - /// Database can be specified separately from connection server. - /// - /// The connection name or URL. - /// Name of the database. - public UserStore(string connectionNameOrUrl, string dbName) + #endregion + + #region IUserStore + + /// + public async Task CreateAsync(TUser user, CancellationToken cancellationToken = default) { - if (connectionNameOrUrl.ToLower().StartsWith("mongodb://")) - { - db = GetDatabase(connectionNameOrUrl, dbName); - } - else + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + await EnsureMigrationAsync(cancellationToken); + await UsersCollection.InsertOneAsync(user, cancellationToken: cancellationToken); + return IdentityResult.Success; + } + + /// + public async Task DeleteAsync(TUser user, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + await UsersCollection.DeleteOneAsync( + Builders.Filter.Eq("_id", ObjectId.Parse(user.Id)), + cancellationToken); + return IdentityResult.Success; + } + + /// + public async Task FindByIdAsync(string userId, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + if (!ObjectId.TryParse(userId, out var objectId)) { - db = GetDatabase(ConfigurationManager.ConnectionStrings[connectionNameOrUrl].ConnectionString, dbName); + return null; } + + await EnsureMigrationAsync(cancellationToken); + return await UsersCollection + .Find(Builders.Filter.Eq("_id", objectId)) + .FirstOrDefaultAsync(cancellationToken); } - /// - /// Initializes a new instance of the class using a already initialized Mongo Database. - /// - /// The mongo database. - public UserStore(IMongoDatabase mongoDatabase) + /// + public async Task FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken = default) { - db = mongoDatabase; + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + await EnsureMigrationAsync(cancellationToken); + return await UsersCollection + .Find(Builders.Filter.Eq(u => u.NormalizedUserName, normalizedUserName)) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task GetNormalizedUserNameAsync(TUser user, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + return Task.FromResult(user.NormalizedUserName); + } + + /// + public Task GetUserIdAsync(TUser user, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + return Task.FromResult(user.Id); + } + + /// + public Task GetUserNameAsync(TUser user, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + return Task.FromResult(user.UserName); + } + + /// + public Task SetNormalizedUserNameAsync(TUser user, string? normalizedName, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + user.NormalizedUserName = normalizedName; + return Task.CompletedTask; + } + + /// + public Task SetUserNameAsync(TUser user, string? userName, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + user.UserName = userName; + return Task.CompletedTask; + } + + /// + public async Task UpdateAsync(TUser user, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + user.ConcurrencyStamp = Guid.NewGuid().ToString(); + + await UsersCollection.ReplaceOneAsync( + Builders.Filter.Eq("_id", ObjectId.Parse(user.Id)), + user, + new ReplaceOptions { IsUpsert = false }, + cancellationToken); + + return IdentityResult.Success; } #endregion - #region Methods + #region IUserLoginStore - /// - /// Adds the claim asynchronous. - /// - /// The user. - /// The claim. - /// Task. - /// user - public Task AddClaimAsync(TUser user, Claim claim) + /// + public Task AddLoginAsync(TUser user, UserLoginInfo login, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - if (user == null) - throw new ArgumentNullException("user"); + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(login); - if (!user.Claims.Any(x => x.ClaimType == claim.Type && x.ClaimValue == claim.Value)) + if (!user.Logins.Any(x => x.LoginProvider == login.LoginProvider && x.ProviderKey == login.ProviderKey)) { - user.Claims.Add(new IdentityUserClaim + user.Logins.Add(new IdentityUserLogin { - ClaimType = claim.Type, - ClaimValue = claim.Value + LoginProvider = login.LoginProvider, + ProviderKey = login.ProviderKey, + ProviderDisplayName = login.ProviderDisplayName }); } - return Task.FromResult(0); + + return Task.CompletedTask; } - /// - /// Gets the claims asynchronous. - /// - /// The user. - /// Task{IList{Claim}}. - /// user - public Task> GetClaimsAsync(TUser user) + /// + public async Task FindByLoginAsync(string loginProvider, string providerKey, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - if (user == null) - throw new ArgumentNullException("user"); - IList result = user.Claims.Select(c => new Claim(c.ClaimType, c.ClaimValue)).ToList(); - return Task.FromResult(result); + await EnsureMigrationAsync(cancellationToken); + var filter = Builders.Filter.And( + Builders.Filter.Eq("Logins.LoginProvider", loginProvider), + Builders.Filter.Eq("Logins.ProviderKey", providerKey)); + + return await UsersCollection.Find(filter).FirstOrDefaultAsync(cancellationToken); } - /// - /// Removes the claim asynchronous. - /// - /// The user. - /// The claim. - /// Task. - /// user - public Task RemoveClaimAsync(TUser user, Claim claim) + /// + public Task> GetLoginsAsync(TUser user, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - if (user == null) - throw new ArgumentNullException("user"); + ArgumentNullException.ThrowIfNull(user); - user.Claims.RemoveAll(x => x.ClaimType == claim.Type && x.ClaimValue == claim.Value); - return Task.FromResult(0); - } + IList logins = user.Logins + .Select(l => new UserLoginInfo(l.LoginProvider, l.ProviderKey, l.ProviderDisplayName)) + .ToList(); + return Task.FromResult(logins); + } - /// - /// Creates the user asynchronous. - /// - /// The user. - /// Task. - /// user - public Task CreateAsync(TUser user) + /// + public Task RemoveLoginAsync(TUser user, string loginProvider, string providerKey, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - if (user == null) - throw new ArgumentNullException("user"); + ArgumentNullException.ThrowIfNull(user); - db.GetCollection(collectionName).InsertOneAsync(user); - return Task.FromResult(user); + user.Logins.RemoveAll(x => x.LoginProvider == loginProvider && x.ProviderKey == providerKey); + return Task.CompletedTask; } - /// - /// Deletes the user asynchronous. - /// - /// The user. - /// Task. - /// user - public Task DeleteAsync(TUser user) + #endregion + + #region IUserClaimStore + + /// + public Task AddClaimsAsync(TUser user, IEnumerable claims, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - if (user == null) - throw new ArgumentNullException("user"); + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(claims); + + foreach (var claim in claims) + { + if (!user.Claims.Any(x => x.ClaimType == claim.Type && x.ClaimValue == claim.Value)) + { + var identityClaim = new IdentityUserClaim(); + identityClaim.InitializeFromClaim(claim); + user.Claims.Add(identityClaim); + } + } - db.GetCollection(collectionName) - .DeleteOneAsync(Builders.Filter.Eq("_id", ObjectId.Parse(user.Id))); - return Task.FromResult(true); + return Task.CompletedTask; } - /// - /// Finds the by identifier asynchronous. - /// - /// The user identifier. - /// Task{`0}. - public Task FindByIdAsync(string userId) + /// + public Task> GetClaimsAsync(TUser user, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - var user = db.GetCollection(collectionName) - .Find(Builders.Filter.Eq("_id", ObjectId.Parse(userId))) - .FirstOrDefaultAsync(); - return user; + ArgumentNullException.ThrowIfNull(user); + + IList claims = user.Claims.Select(c => c.ToClaim()).ToList(); + return Task.FromResult(claims); } - /// - /// Finds the by name asynchronous. - /// - /// Name of the user. - /// Task{`0}. - public Task FindByNameAsync(string userName) + /// + public async Task> GetUsersForClaimAsync(Claim claim, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - var user = db.GetCollection(collectionName) - .Find(Builders.Filter.Eq("UserName", userName)) - .FirstOrDefaultAsync(); - return user; + ArgumentNullException.ThrowIfNull(claim); + + await EnsureMigrationAsync(cancellationToken); + var filter = Builders.Filter.And( + Builders.Filter.Eq("Claims.ClaimType", claim.Type), + Builders.Filter.Eq("Claims.ClaimValue", claim.Value)); + + return await UsersCollection.Find(filter).ToListAsync(cancellationToken); } - /// - /// Updates the user asynchronous. - /// - /// The user. - /// Task. - /// user - public Task UpdateAsync(TUser user) + /// + public Task RemoveClaimsAsync(TUser user, IEnumerable claims, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - if (user == null) - throw new ArgumentNullException("user"); + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(claims); - db.GetCollection(collectionName) - .FindOneAndReplaceAsync( - Builders.Filter.Eq("_id", ObjectId.Parse(user.Id)), - user, - new FindOneAndReplaceOptions {IsUpsert = true}); + foreach (var claim in claims) + { + user.Claims.RemoveAll(x => x.ClaimType == claim.Type && x.ClaimValue == claim.Value); + } - return Task.FromResult(user); + return Task.CompletedTask; } - /// - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - /// - public void Dispose() + /// + public Task ReplaceClaimAsync(TUser user, Claim claim, Claim newClaim, CancellationToken cancellationToken = default) { - _disposed = true; + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(claim); + ArgumentNullException.ThrowIfNull(newClaim); + + var existingClaim = user.Claims.FirstOrDefault(x => x.ClaimType == claim.Type && x.ClaimValue == claim.Value); + if (existingClaim != null) + { + existingClaim.InitializeFromClaim(newClaim); + } + + return Task.CompletedTask; } - /// - /// Adds the login asynchronous. - /// - /// The user. - /// The login. - /// Task. - /// user - public Task AddLoginAsync(TUser user, UserLoginInfo login) + #endregion + + #region IUserRoleStore + + /// + public Task AddToRoleAsync(TUser user, string roleName, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - if (user == null) - throw new ArgumentNullException("user"); + ArgumentNullException.ThrowIfNull(user); + ArgumentException.ThrowIfNullOrWhiteSpace(roleName); - if (!user.Logins.Any(x => x.LoginProvider == login.LoginProvider && x.ProviderKey == login.ProviderKey)) + if (!user.Roles.Contains(roleName, StringComparer.OrdinalIgnoreCase)) { - user.Logins.Add(login); + user.Roles.Add(roleName); } - return Task.FromResult(true); + + return Task.CompletedTask; } - /// - /// Finds the user asynchronous. - /// - /// The login. - /// Task{`0}. - public Task FindAsync(UserLoginInfo login) + /// + public Task> GetRolesAsync(TUser user, CancellationToken cancellationToken = default) { - var filter = Builders.Filter; - var user = db.GetCollection(collectionName) - .Find(filter.And(filter.Eq("Logins.LoginProvider", login.LoginProvider), - filter.Eq("Logins.ProviderKey", login.ProviderKey))).FirstOrDefaultAsync(); - return user; + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + return Task.FromResult>(user.Roles.ToList()); } - /// - /// Gets the logins asynchronous. - /// - /// The user. - /// Task{IList{UserLoginInfo}}. - /// user - public Task> GetLoginsAsync(TUser user) + /// + public async Task> GetUsersInRoleAsync(string roleName, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - if (user == null) - throw new ArgumentNullException("user"); - return Task.FromResult(user.Logins.ToIList()); + ArgumentException.ThrowIfNullOrWhiteSpace(roleName); + + await EnsureMigrationAsync(cancellationToken); + var filter = Builders.Filter.AnyEq(u => u.Roles, roleName); + return await UsersCollection.Find(filter).ToListAsync(cancellationToken); } - /// - /// Removes the login asynchronous. - /// - /// The user. - /// The login. - /// Task. - /// user - public Task RemoveLoginAsync(TUser user, UserLoginInfo login) + /// + public Task IsInRoleAsync(TUser user, string roleName, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - if (user == null) - throw new ArgumentNullException("user"); - user.Logins.RemoveAll(x => x.LoginProvider == login.LoginProvider && x.ProviderKey == login.ProviderKey); - return Task.FromResult(0); + ArgumentNullException.ThrowIfNull(user); + ArgumentException.ThrowIfNullOrWhiteSpace(roleName); + + return Task.FromResult(user.Roles.Contains(roleName, StringComparer.OrdinalIgnoreCase)); } - /// - /// Gets the password hash asynchronous. - /// - /// The user. - /// Task{System.String}. - /// user - public Task GetPasswordHashAsync(TUser user) + /// + public Task RemoveFromRoleAsync(TUser user, string roleName, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + ArgumentException.ThrowIfNullOrWhiteSpace(roleName); + + user.Roles.RemoveAll(r => string.Equals(r, roleName, StringComparison.OrdinalIgnoreCase)); + return Task.CompletedTask; + } + + #endregion + + #region IUserPasswordStore + + /// + public Task GetPasswordHashAsync(TUser user, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - if (user == null) - throw new ArgumentNullException("user"); + ArgumentNullException.ThrowIfNull(user); + return Task.FromResult(user.PasswordHash); } - /// - /// Determines whether [has password asynchronous] [the specified user]. - /// - /// The user. - /// user - public Task HasPasswordAsync(TUser user) + /// + public Task HasPasswordAsync(TUser user, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - if (user == null) - throw new ArgumentNullException("user"); - return Task.FromResult(user.PasswordHash != null); + ArgumentNullException.ThrowIfNull(user); + + return Task.FromResult(!string.IsNullOrEmpty(user.PasswordHash)); } - /// - /// Sets the password hash asynchronous. - /// - /// The user. - /// The password hash. - /// Task. - /// user - public Task SetPasswordHashAsync(TUser user, string passwordHash) + /// + public Task SetPasswordHashAsync(TUser user, string? passwordHash, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - if (user == null) - throw new ArgumentNullException("user"); + ArgumentNullException.ThrowIfNull(user); + user.PasswordHash = passwordHash; - return Task.FromResult(0); + return Task.CompletedTask; } - /// - /// Adds to role asynchronous. - /// - /// The user. - /// The role. - /// Task. - /// user - public Task AddToRoleAsync(TUser user, string role) + #endregion + + #region IUserSecurityStampStore + + /// + public Task GetSecurityStampAsync(TUser user, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - if (user == null) - throw new ArgumentNullException("user"); + ArgumentNullException.ThrowIfNull(user); - if (!user.Roles.Contains(role, StringComparer.InvariantCultureIgnoreCase)) - user.Roles.Add(role); + return Task.FromResult(user.SecurityStamp); + } + + /// + public Task SetSecurityStampAsync(TUser user, string stamp, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); - return Task.FromResult(true); + user.SecurityStamp = stamp; + return Task.CompletedTask; } - /// - /// Gets the roles asynchronous. - /// - /// The user. - /// Task{IList{System.String}}. - /// user - public Task> GetRolesAsync(TUser user) + #endregion + + #region IUserEmailStore + + /// + public async Task FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - if (user == null) - throw new ArgumentNullException("user"); - return Task.FromResult>(user.Roles); + + await EnsureMigrationAsync(cancellationToken); + return await UsersCollection + .Find(Builders.Filter.Eq(u => u.NormalizedEmail, normalizedEmail)) + .FirstOrDefaultAsync(cancellationToken); } - /// - /// Determines whether [is in role asynchronous] [the specified user]. - /// - /// The user. - /// The role. - /// user - public Task IsInRoleAsync(TUser user, string role) + /// + public Task GetEmailAsync(TUser user, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - if (user == null) - throw new ArgumentNullException("user"); + ArgumentNullException.ThrowIfNull(user); - return Task.FromResult(user.Roles.Contains(role, StringComparer.InvariantCultureIgnoreCase)); + return Task.FromResult(user.Email); } - /// - /// Removes from role asynchronous. - /// - /// The user. - /// The role. - /// Task. - /// user - public Task RemoveFromRoleAsync(TUser user, string role) + /// + public Task GetEmailConfirmedAsync(TUser user, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - if (user == null) - throw new ArgumentNullException("user"); + ArgumentNullException.ThrowIfNull(user); - user.Roles.RemoveAll(r => String.Equals(r, role, StringComparison.InvariantCultureIgnoreCase)); + return Task.FromResult(user.EmailConfirmed); + } - return Task.FromResult(0); + /// + public Task GetNormalizedEmailAsync(TUser user, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + return Task.FromResult(user.NormalizedEmail); } - /// - /// Gets the security stamp asynchronous. - /// - /// The user. - /// Task{System.String}. - /// user - public Task GetSecurityStampAsync(TUser user) + /// + public Task SetEmailAsync(TUser user, string? email, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - if (user == null) - throw new ArgumentNullException("user"); + ArgumentNullException.ThrowIfNull(user); - return Task.FromResult(user.SecurityStamp); + user.Email = email; + return Task.CompletedTask; } - /// - /// Sets the security stamp asynchronous. - /// - /// The user. - /// The stamp. - /// Task. - /// user - public Task SetSecurityStampAsync(TUser user, string stamp) + /// + public Task SetEmailConfirmedAsync(TUser user, bool confirmed, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - if (user == null) - throw new ArgumentNullException("user"); + ArgumentNullException.ThrowIfNull(user); - user.SecurityStamp = stamp; - return Task.FromResult(0); + user.EmailConfirmed = confirmed; + return Task.CompletedTask; } - /// - /// Throws if disposed. - /// - /// - private void ThrowIfDisposed() + /// + public Task SetNormalizedEmailAsync(TUser user, string? normalizedEmail, CancellationToken cancellationToken = default) { - if (_disposed) - throw new ObjectDisposedException(GetType().Name); + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + user.NormalizedEmail = normalizedEmail; + return Task.CompletedTask; } #endregion - /// Set the user email - /// - /// - /// - public Task SetEmailAsync(TUser user, string email) + #region IUserLockoutStore + + /// + public Task GetAccessFailedCountAsync(TUser user, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - if (user == null) - throw new ArgumentNullException("user"); + ArgumentNullException.ThrowIfNull(user); - user.Email = email; - return Task.FromResult(0); + return Task.FromResult(user.AccessFailedCount); } - /// Get the user email - /// - /// - public Task GetEmailAsync(TUser user) + /// + public Task GetLockoutEnabledAsync(TUser user, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - if (user == null) - throw new ArgumentNullException("user"); + ArgumentNullException.ThrowIfNull(user); - return Task.FromResult(user.Email); + return Task.FromResult(user.LockoutEnabled); } - /// Returns true if the user email is confirmed - /// - /// - public Task GetEmailConfirmedAsync(TUser user) + /// + public Task GetLockoutEndDateAsync(TUser user, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - if (user == null) - throw new ArgumentNullException("user"); - return Task.FromResult(user.EmailConfirmed); + ArgumentNullException.ThrowIfNull(user); + + return Task.FromResult(user.LockoutEnd); } - /// Sets whether the user email is confirmed - /// - /// - /// - public Task SetEmailConfirmedAsync(TUser user, bool confirmed) + /// + public Task IncrementAccessFailedCountAsync(TUser user, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - if (user == null) - throw new ArgumentNullException("user"); + ArgumentNullException.ThrowIfNull(user); - user.EmailConfirmed = confirmed; - return Task.FromResult(0); + user.AccessFailedCount++; + return Task.FromResult(user.AccessFailedCount); } - /// Returns the user associated with this email - /// - /// - public Task FindByEmailAsync(string email) + /// + public Task ResetAccessFailedCountAsync(TUser user, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - var user = db.GetCollection(collectionName) - .Find(Builders.Filter.Eq("Email", email)) - .FirstOrDefaultAsync(); - return user; + ArgumentNullException.ThrowIfNull(user); + + user.AccessFailedCount = 0; + return Task.CompletedTask; } - /// - /// Returns the DateTimeOffset that represents the end of a user's lockout, any time in the past should be considered - /// not locked out. - /// - /// - /// - public async Task GetLockoutEndDateAsync(TUser user) + /// + public Task SetLockoutEnabledAsync(TUser user, bool enabled, CancellationToken cancellationToken = default) { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - return await Task.FromResult(user.LockoutEndDateUtc.HasValue - ? new DateTimeOffset(DateTime.SpecifyKind(user.LockoutEndDateUtc.Value, DateTimeKind.Utc)) - : new DateTimeOffset()); + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + user.LockoutEnabled = enabled; + return Task.CompletedTask; } - /// - /// Locks a user out until the specified end date (set to a past date, to unlock a user) - /// - /// - /// - /// - public Task SetLockoutEndDateAsync(TUser user, DateTimeOffset lockoutEnd) + /// + public Task SetLockoutEndDateAsync(TUser user, DateTimeOffset? lockoutEnd, CancellationToken cancellationToken = default) { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - user.LockoutEndDateUtc = lockoutEnd.UtcDateTime; - return Task.FromResult(0); + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + user.LockoutEnd = lockoutEnd; + return Task.CompletedTask; } - /// - /// Used to record when an attempt to access the user has failed - /// - /// - /// - public Task IncrementAccessFailedCountAsync(TUser user) + #endregion + + #region IUserTwoFactorStore + + /// + public Task GetTwoFactorEnabledAsync(TUser user, CancellationToken cancellationToken = default) { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - user.AccessFailedCount++; - return Task.FromResult(user.AccessFailedCount); + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + return Task.FromResult(user.TwoFactorEnabled); } - /// - /// Used to reset the access failed count, typically after the account is successfully accessed - /// - /// - /// - public Task ResetAccessFailedCountAsync(TUser user) + /// + public Task SetTwoFactorEnabledAsync(TUser user, bool enabled, CancellationToken cancellationToken = default) { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - user.AccessFailedCount = 0; - return Task.FromResult(0); + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + user.TwoFactorEnabled = enabled; + return Task.CompletedTask; } - /// - /// Returns the current number of failed access attempts. This number usually will be reset whenever the password is - /// verified or the account is locked out. - /// - /// - /// - public Task GetAccessFailedCountAsync(TUser user) + #endregion + + #region IUserPhoneNumberStore + + /// + public Task GetPhoneNumberAsync(TUser user, CancellationToken cancellationToken = default) { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - return Task.FromResult(user.AccessFailedCount); + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + return Task.FromResult(user.PhoneNumber); } - /// Returns whether the user can be locked out. - /// - /// - public Task GetLockoutEnabledAsync(TUser user) + /// + public Task GetPhoneNumberConfirmedAsync(TUser user, CancellationToken cancellationToken = default) { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - return Task.FromResult(user.LockoutEnabled); + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + return Task.FromResult(user.PhoneNumberConfirmed); } - /// Sets whether the user can be locked out. - /// - /// - /// - public Task SetLockoutEnabledAsync(TUser user, bool enabled) + /// + public Task SetPhoneNumberAsync(TUser user, string? phoneNumber, CancellationToken cancellationToken = default) { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - user.LockoutEnabled = enabled; - return Task.FromResult(0); + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + user.PhoneNumber = phoneNumber; + return Task.CompletedTask; } - /// - /// Sets whether two factor authentication is enabled for the user - /// - /// - /// - /// - public Task SetTwoFactorEnabledAsync(TUser user, bool enabled) + /// + public Task SetPhoneNumberConfirmedAsync(TUser user, bool confirmed, CancellationToken cancellationToken = default) { - if (user == null) + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + user.PhoneNumberConfirmed = confirmed; + return Task.CompletedTask; + } + + #endregion + + #region IUserAuthenticationTokenStore + + /// + public Task GetTokenAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + var token = user.Tokens.FirstOrDefault(t => t.LoginProvider == loginProvider && t.Name == name); + return Task.FromResult(token?.Value); + } + + /// + public Task RemoveTokenAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + user.Tokens.RemoveAll(t => t.LoginProvider == loginProvider && t.Name == name); + return Task.CompletedTask; + } + + /// + public Task SetTokenAsync(TUser user, string loginProvider, string name, string? value, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + var existingToken = user.Tokens.FirstOrDefault(t => t.LoginProvider == loginProvider && t.Name == name); + if (existingToken != null) { - throw new ArgumentNullException(nameof(user)); + existingToken.Value = value; } - user.TwoFactorEnabled = enabled; - return Task.FromResult(0); + else + { + user.Tokens.Add(new IdentityUserToken + { + LoginProvider = loginProvider, + Name = name, + Value = value + }); + } + + return Task.CompletedTask; + } + + #endregion + + #region IDisposable + + /// + public void Dispose() + { + _disposed = true; + GC.SuppressFinalize(this); } /// - /// Returns whether two factor authentication is enabled for the user + /// Throws if this class has been disposed. /// - /// - /// - public Task GetTwoFactorEnabledAsync(TUser user) + protected void ThrowIfDisposed() { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - return Task.FromResult(user.TwoFactorEnabled); + ObjectDisposedException.ThrowIf(_disposed, this); } + + #endregion } -} \ No newline at end of file +} diff --git a/Utils.cs b/Utils.cs deleted file mode 100644 index addfa5a..0000000 --- a/Utils.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using System.Linq; - -namespace MongoDB.AspNet.Identity -{ - internal static class Utils - { - /// - /// Converts an IEnumberable of T to a IList of T - /// - /// - /// The enumerable. - /// IList{``0}. - internal static IList ToIList(this IEnumerable enumerable) - { - return enumerable.ToList(); - } - } -} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a06d4f3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +services: + mongodb: + image: mongo:7.0 + container_name: mongodb-identity + ports: + - "27017:27017" + volumes: + - mongodb_data:/data/db + environment: + - MONGO_INITDB_DATABASE=SampleIdentityDb + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + sample-app: + build: + context: . + dockerfile: samples/SampleWebApp/Dockerfile + container_name: sample-identity-app + ports: + - "5000:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Development + - ConnectionStrings__MongoDB=mongodb://mongodb:27017/SampleIdentityDb + depends_on: + mongodb: + condition: service_healthy + +volumes: + mongodb_data: diff --git a/packages.config b/packages.config deleted file mode 100644 index 7daf4f0..0000000 --- a/packages.config +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/samples/SampleWebApp/Controllers/AuthController.cs b/samples/SampleWebApp/Controllers/AuthController.cs new file mode 100644 index 0000000..c5d9fdc --- /dev/null +++ b/samples/SampleWebApp/Controllers/AuthController.cs @@ -0,0 +1,216 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using MongoIdentityUser = MongoDB.AspNet.Identity.IdentityUser; +using MongoIdentityRole = MongoDB.AspNet.Identity.IdentityRole; + +namespace SampleWebApp.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthController : ControllerBase +{ + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly RoleManager _roleManager; + + public AuthController( + UserManager userManager, + SignInManager signInManager, + RoleManager roleManager) + { + _userManager = userManager; + _signInManager = signInManager; + _roleManager = roleManager; + } + + /// + /// Register a new user. + /// + [HttpPost("register")] + public async Task Register([FromBody] RegisterRequest request) + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + var user = new MongoIdentityUser(request.UserName) + { + Email = request.Email + }; + + var result = await _userManager.CreateAsync(user, request.Password); + + if (!result.Succeeded) + { + return BadRequest(new { Errors = result.Errors.Select(e => e.Description) }); + } + + return Ok(new { Message = "User registered successfully", UserId = user.Id }); + } + + /// + /// Login with username and password. + /// + [HttpPost("login")] + public async Task Login([FromBody] LoginRequest request) + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + var user = await _userManager.FindByNameAsync(request.UserName); + if (user == null) + { + return Unauthorized(new { Message = "Invalid username or password" }); + } + + var result = await _signInManager.CheckPasswordSignInAsync(user, request.Password, lockoutOnFailure: true); + + if (!result.Succeeded) + { + if (result.IsLockedOut) + return Unauthorized(new { Message = "Account is locked out" }); + + return Unauthorized(new { Message = "Invalid username or password" }); + } + + return Ok(new { Message = "Login successful", UserId = user.Id, UserName = user.UserName }); + } + + /// + /// Get current user info. + /// + [HttpGet("me")] + [Authorize] + public async Task GetCurrentUser() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + return NotFound(); + + var roles = await _userManager.GetRolesAsync(user); + var claims = await _userManager.GetClaimsAsync(user); + + return Ok(new + { + user.Id, + user.UserName, + user.Email, + user.EmailConfirmed, + user.PhoneNumber, + user.PhoneNumberConfirmed, + user.TwoFactorEnabled, + Roles = roles, + Claims = claims.Select(c => new { c.Type, c.Value }) + }); + } + + /// + /// Add a role to a user. + /// + [HttpPost("users/{userId}/roles")] + public async Task AddToRole(string userId, [FromBody] RoleRequest request) + { + var user = await _userManager.FindByIdAsync(userId); + if (user == null) + return NotFound(new { Message = "User not found" }); + + // Validate that the role exists + var roleExists = await _roleManager.RoleExistsAsync(request.RoleName); + if (!roleExists) + return BadRequest(new { Message = $"Role '{request.RoleName}' does not exist. Create it first." }); + + var result = await _userManager.AddToRoleAsync(user, request.RoleName); + if (!result.Succeeded) + { + return BadRequest(new { Errors = result.Errors.Select(e => e.Description) }); + } + + return Ok(new { Message = $"User added to role '{request.RoleName}'" }); + } + + /// + /// Get all users. + /// + [HttpGet("users")] + public IActionResult GetAllUsers() + { + var users = _userManager.Users.ToList(); + return Ok(users.Select(u => new + { + u.Id, + u.UserName, + u.Email, + u.EmailConfirmed, + u.Roles + })); + } + + /// + /// Delete a user. + /// + [HttpDelete("users/{userId}")] + public async Task DeleteUser(string userId) + { + var user = await _userManager.FindByIdAsync(userId); + if (user == null) + return NotFound(new { Message = "User not found" }); + + var result = await _userManager.DeleteAsync(user); + if (!result.Succeeded) + { + return BadRequest(new { Errors = result.Errors.Select(e => e.Description) }); + } + + return Ok(new { Message = "User deleted successfully" }); + } + + /// + /// Request a password reset token. + /// + [HttpPost("forgot-password")] + public async Task ForgotPassword([FromBody] ForgotPasswordRequest request) + { + var user = await _userManager.FindByEmailAsync(request.Email); + if (user == null) + { + // Don't reveal that user doesn't exist + return Ok(new { Message = "If the email exists, a reset token has been generated." }); + } + + var token = await _userManager.GeneratePasswordResetTokenAsync(user); + + // In production, you'd send this via email. For demo, we return it. + return Ok(new { + Message = "Password reset token generated", + Token = token, + UserId = user.Id, + Note = "In production, this token would be sent via email" + }); + } + + /// + /// Reset password using token. + /// + [HttpPost("reset-password")] + public async Task ResetPassword([FromBody] ResetPasswordRequest request) + { + var user = await _userManager.FindByIdAsync(request.UserId); + if (user == null) + return BadRequest(new { Message = "Invalid request" }); + + var result = await _userManager.ResetPasswordAsync(user, request.Token, request.NewPassword); + + if (!result.Succeeded) + { + return BadRequest(new { Errors = result.Errors.Select(e => e.Description) }); + } + + return Ok(new { Message = "Password has been reset successfully" }); + } +} + +public record RegisterRequest(string UserName, string Email, string Password); +public record LoginRequest(string UserName, string Password); +public record RoleRequest(string RoleName); +public record ForgotPasswordRequest(string Email); +public record ResetPasswordRequest(string UserId, string Token, string NewPassword); diff --git a/samples/SampleWebApp/Controllers/ClaimsController.cs b/samples/SampleWebApp/Controllers/ClaimsController.cs new file mode 100644 index 0000000..52cfded --- /dev/null +++ b/samples/SampleWebApp/Controllers/ClaimsController.cs @@ -0,0 +1,76 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using MongoIdentityUser = MongoDB.AspNet.Identity.IdentityUser; + +namespace SampleWebApp.Controllers; + +[ApiController] +[Route("api/users/{userId}/[controller]")] +public class ClaimsController : ControllerBase +{ + private readonly UserManager _userManager; + + public ClaimsController(UserManager userManager) + { + _userManager = userManager; + } + + /// + /// Get all claims for a user. + /// + [HttpGet] + public async Task GetClaims(string userId) + { + var user = await _userManager.FindByIdAsync(userId); + if (user == null) + return NotFound(new { Message = "User not found" }); + + var claims = await _userManager.GetClaimsAsync(user); + return Ok(claims.Select(c => new { c.Type, c.Value })); + } + + /// + /// Add a claim to a user. + /// + [HttpPost] + public async Task AddClaim(string userId, [FromBody] ClaimRequest request) + { + var user = await _userManager.FindByIdAsync(userId); + if (user == null) + return NotFound(new { Message = "User not found" }); + + var claim = new Claim(request.Type, request.Value); + var result = await _userManager.AddClaimAsync(user, claim); + + if (!result.Succeeded) + { + return BadRequest(new { Errors = result.Errors.Select(e => e.Description) }); + } + + return Ok(new { Message = $"Claim '{request.Type}' added successfully" }); + } + + /// + /// Remove a claim from a user. + /// + [HttpDelete] + public async Task RemoveClaim(string userId, [FromQuery] string type, [FromQuery] string value) + { + var user = await _userManager.FindByIdAsync(userId); + if (user == null) + return NotFound(new { Message = "User not found" }); + + var claim = new Claim(type, value); + var result = await _userManager.RemoveClaimAsync(user, claim); + + if (!result.Succeeded) + { + return BadRequest(new { Errors = result.Errors.Select(e => e.Description) }); + } + + return Ok(new { Message = "Claim removed successfully" }); + } +} + +public record ClaimRequest(string Type, string Value); diff --git a/samples/SampleWebApp/Controllers/RolesController.cs b/samples/SampleWebApp/Controllers/RolesController.cs new file mode 100644 index 0000000..9476f89 --- /dev/null +++ b/samples/SampleWebApp/Controllers/RolesController.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using MongoIdentityRole = MongoDB.AspNet.Identity.IdentityRole; + +namespace SampleWebApp.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class RolesController : ControllerBase +{ + private readonly RoleManager _roleManager; + + public RolesController(RoleManager roleManager) + { + _roleManager = roleManager; + } + + /// + /// Get all roles. + /// + [HttpGet] + public IActionResult GetAllRoles() + { + var roles = _roleManager.Roles.ToList(); + return Ok(roles.Select(r => new { r.Id, r.Name })); + } + + /// + /// Create a new role. + /// + [HttpPost] + public async Task CreateRole([FromBody] CreateRoleRequest request) + { + if (string.IsNullOrWhiteSpace(request.Name)) + return BadRequest(new { Message = "Role name is required" }); + + var role = new MongoIdentityRole(request.Name); + var result = await _roleManager.CreateAsync(role); + + if (!result.Succeeded) + { + return BadRequest(new { Errors = result.Errors.Select(e => e.Description) }); + } + + return Ok(new { Message = "Role created successfully", RoleId = role.Id, RoleName = role.Name }); + } + + /// + /// Delete a role. + /// + [HttpDelete("{roleId}")] + public async Task DeleteRole(string roleId) + { + var role = await _roleManager.FindByIdAsync(roleId); + if (role == null) + return NotFound(new { Message = "Role not found" }); + + var result = await _roleManager.DeleteAsync(role); + if (!result.Succeeded) + { + return BadRequest(new { Errors = result.Errors.Select(e => e.Description) }); + } + + return Ok(new { Message = "Role deleted successfully" }); + } +} + +public record CreateRoleRequest(string Name); diff --git a/samples/SampleWebApp/Dockerfile b/samples/SampleWebApp/Dockerfile new file mode 100644 index 0000000..8622883 --- /dev/null +++ b/samples/SampleWebApp/Dockerfile @@ -0,0 +1,28 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +# Copy solution and project files +COPY ["MongoDB.AspNet.Identity.sln", "."] +COPY ["MongoDB.AspNet.Identity.csproj", "."] +COPY ["samples/SampleWebApp/SampleWebApp.csproj", "samples/SampleWebApp/"] + +# Restore dependencies +RUN dotnet restore "samples/SampleWebApp/SampleWebApp.csproj" + +# Copy source files +COPY . . + +# Build the application +WORKDIR "/src/samples/SampleWebApp" +RUN dotnet build "SampleWebApp.csproj" -c Release -o /app/build + +# Publish the application +FROM build AS publish +RUN dotnet publish "SampleWebApp.csproj" -c Release -o /app/publish /p:UseAppHost=false + +# Final image +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final +WORKDIR /app +EXPOSE 8080 +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "SampleWebApp.dll"] diff --git a/samples/SampleWebApp/Program.cs b/samples/SampleWebApp/Program.cs new file mode 100644 index 0000000..a852d7a --- /dev/null +++ b/samples/SampleWebApp/Program.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.Identity; +using MongoDB.AspNet.Identity; +using MongoDB.Driver; +using MongoIdentityUser = MongoDB.AspNet.Identity.IdentityUser; +using MongoIdentityRole = MongoDB.AspNet.Identity.IdentityRole; + +var builder = WebApplication.CreateBuilder(args); + +// Configure MongoDB +var connectionString = builder.Configuration.GetConnectionString("MongoDB") + ?? "mongodb://localhost:27017/SampleIdentityDb"; +var mongoUrl = new MongoUrl(connectionString); +var mongoClient = new MongoClient(mongoUrl); +var mongoDatabase = mongoClient.GetDatabase(mongoUrl.DatabaseName); + +// Register MongoDB database as a service +builder.Services.AddSingleton(mongoDatabase); + +// Configure ASP.NET Core Identity with MongoDB +builder.Services.AddIdentity(options => +{ + // Password settings (relaxed for demo purposes) + options.Password.RequireDigit = false; + options.Password.RequireLowercase = false; + options.Password.RequireUppercase = false; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequiredLength = 6; + + // User settings + options.User.RequireUniqueEmail = true; +}) +.AddUserStore>() +.AddRoleStore() +.AddRoleManager>() +.AddSignInManager() +.AddDefaultTokenProviders(); + +// Add authorization +builder.Services.AddAuthorization(); + +// Add controllers +builder.Services.AddControllers(); + +// Add Swagger for API documentation +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +// Serve static files (wwwroot) +app.UseDefaultFiles(); +app.UseStaticFiles(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/samples/SampleWebApp/SampleWebApp.csproj b/samples/SampleWebApp/SampleWebApp.csproj new file mode 100644 index 0000000..f40a4d3 --- /dev/null +++ b/samples/SampleWebApp/SampleWebApp.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/samples/SampleWebApp/appsettings.Development.json b/samples/SampleWebApp/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/samples/SampleWebApp/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/SampleWebApp/appsettings.json b/samples/SampleWebApp/appsettings.json new file mode 100644 index 0000000..cfb1fcd --- /dev/null +++ b/samples/SampleWebApp/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "MongoDB": "mongodb://localhost:27017/SampleIdentityDb" + } +} diff --git a/samples/SampleWebApp/wwwroot/index.html b/samples/SampleWebApp/wwwroot/index.html new file mode 100644 index 0000000..c5d8597 --- /dev/null +++ b/samples/SampleWebApp/wwwroot/index.html @@ -0,0 +1,662 @@ + + + + + + MongoDB Identity Demo + + + +
+ + + + +
+ +
+
+
+
Sign In
+
+
+
+
+ + +
+
+ + +
+ +
+
+
+ +
+
Create Account
+
+
+
+
+ + +
+
+ + +
+
+ + +
Minimum 6 characters
+
+ +
+
+
+
+
+ + +
+
+
+
Step 1: Request Reset Token
+
+
+
+
+ + +
+ +
+
+ Demo Mode: Token will appear here instead of being emailed. +
+
+
+ +
+
Step 2: Set New Password
+
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+ + +
+
+
+
All Users
+ +
+
+

Click a user row to manage their roles and claims

+ + + + + + + + + + +
UsernameEmailRolesActions
+
+
+
+ + +
+
+
Manage Roles
+
+
+
+ + +
+ + + + + + + + +
Role NameActions
+
+
+
+ + +
+
+
+
Manage Roles for
+
+
+
+
+ + +
+
Current Roles:
+
+
+
+
+ + +
+
+
+
Manage Claims for
+
+
+
+
+
+ + + +
+
+ + + + + + + + + +
TypeValueActions
+
+
+
+
+
+ + + + diff --git a/tests/MongoDB.AspNet.Identity.Tests/MigrationTests.cs b/tests/MongoDB.AspNet.Identity.Tests/MigrationTests.cs new file mode 100644 index 0000000..1c9c02a --- /dev/null +++ b/tests/MongoDB.AspNet.Identity.Tests/MigrationTests.cs @@ -0,0 +1,215 @@ +using FluentAssertions; +using MongoDB.Bson; +using MongoDB.Driver; +using Xunit; + +namespace MongoDB.AspNet.Identity.Tests; + +[Collection("MongoDB")] +public class MigrationTests : IAsyncLifetime +{ + private readonly MongoDbFixture _fixture; + private readonly IMongoDatabase _database; + + public MigrationTests(MongoDbFixture fixture) + { + _fixture = fixture; + _database = fixture.GetDatabase($"MigrationTestDb_{Guid.NewGuid():N}"); + } + + public Task InitializeAsync() => Task.CompletedTask; + + public async Task DisposeAsync() + { + await _database.Client.DropDatabaseAsync(_database.DatabaseNamespace.DatabaseName); + } + + [Fact] + public async Task Migration_ShouldAddNormalizedUserName_WhenMissing() + { + // Arrange - Insert a legacy user document without NormalizedUserName + var usersCollection = _database.GetCollection("AspNetUsers"); + var legacyUser = new BsonDocument + { + { "_id", ObjectId.GenerateNewId() }, + { "UserName", "TestUser" }, + { "Email", "test@example.com" }, + { "PasswordHash", "somehash" }, + { "SecurityStamp", Guid.NewGuid().ToString() }, + { "Roles", new BsonArray() }, + { "Claims", new BsonArray() }, + { "Logins", new BsonArray() } + }; + await usersCollection.InsertOneAsync(legacyUser); + + // Reset migration marker to force re-migration + await MigrationHelper.ResetMigrationAsync(_database); + + // Act - Create UserStore which triggers migration + using var userStore = new UserStore(_database); + var foundUser = await userStore.FindByNameAsync("TESTUSER"); + + // Assert + foundUser.Should().NotBeNull(); + foundUser!.UserName.Should().Be("TestUser"); + foundUser.NormalizedUserName.Should().Be("TESTUSER"); + } + + [Fact] + public async Task Migration_ShouldAddNormalizedEmail_WhenMissing() + { + // Arrange - Insert a legacy user document without NormalizedEmail + var usersCollection = _database.GetCollection("AspNetUsers"); + var legacyUser = new BsonDocument + { + { "_id", ObjectId.GenerateNewId() }, + { "UserName", "TestUser2" }, + { "Email", "Test2@Example.com" }, + { "PasswordHash", "somehash" }, + { "SecurityStamp", Guid.NewGuid().ToString() }, + { "Roles", new BsonArray() }, + { "Claims", new BsonArray() }, + { "Logins", new BsonArray() } + }; + await usersCollection.InsertOneAsync(legacyUser); + + // Reset migration marker to force re-migration + await MigrationHelper.ResetMigrationAsync(_database); + + // Act - Create UserStore which triggers migration + using var userStore = new UserStore(_database); + var foundUser = await userStore.FindByEmailAsync("TEST2@EXAMPLE.COM"); + + // Assert + foundUser.Should().NotBeNull(); + foundUser!.Email.Should().Be("Test2@Example.com"); + foundUser.NormalizedEmail.Should().Be("TEST2@EXAMPLE.COM"); + } + + [Fact] + public async Task Migration_ShouldConvertLockoutEndDateUtc_ToLockoutEnd() + { + // Arrange - Insert a legacy user document with LockoutEndDateUtc + var usersCollection = _database.GetCollection("AspNetUsers"); + var lockoutDate = DateTime.UtcNow.AddHours(1); + var legacyUser = new BsonDocument + { + { "_id", ObjectId.GenerateNewId() }, + { "UserName", "LockedUser" }, + { "Email", "locked@example.com" }, + { "LockoutEndDateUtc", lockoutDate }, + { "LockoutEnabled", true }, + { "Roles", new BsonArray() }, + { "Claims", new BsonArray() }, + { "Logins", new BsonArray() } + }; + await usersCollection.InsertOneAsync(legacyUser); + + // Reset migration marker to force re-migration + await MigrationHelper.ResetMigrationAsync(_database); + + // Act - Create UserStore which triggers migration + using var userStore = new UserStore(_database); + var foundUser = await userStore.FindByNameAsync("LOCKEDUSER"); + + // Assert + foundUser.Should().NotBeNull(); + foundUser!.LockoutEnd.Should().NotBeNull(); + foundUser.LockoutEnd!.Value.UtcDateTime.Should().BeCloseTo(lockoutDate, TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task Migration_ShouldAddConcurrencyStamp_WhenMissing() + { + // Arrange - Insert a legacy user document without ConcurrencyStamp + var usersCollection = _database.GetCollection("AspNetUsers"); + var legacyUser = new BsonDocument + { + { "_id", ObjectId.GenerateNewId() }, + { "UserName", "NoConcurrencyUser" }, + { "Email", "noconcurrency@example.com" }, + { "Roles", new BsonArray() }, + { "Claims", new BsonArray() }, + { "Logins", new BsonArray() } + }; + await usersCollection.InsertOneAsync(legacyUser); + + // Reset migration marker to force re-migration + await MigrationHelper.ResetMigrationAsync(_database); + + // Act - Create UserStore which triggers migration + using var userStore = new UserStore(_database); + var foundUser = await userStore.FindByNameAsync("NOCONCURRENCYUSER"); + + // Assert + foundUser.Should().NotBeNull(); + foundUser!.ConcurrencyStamp.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Migration_ShouldCreateIndexes() + { + // Arrange + await MigrationHelper.ResetMigrationAsync(_database); + + // Act - Create UserStore which triggers migration + using var userStore = new UserStore(_database); + // Trigger migration by performing a database operation + await userStore.CreateAsync(new IdentityUser("IndexTestUser") { Email = "indextest@example.com" }); + + // Assert - Check that indexes were created + var usersCollection = _database.GetCollection("AspNetUsers"); + var indexes = await (await usersCollection.Indexes.ListAsync()).ToListAsync(); + + var indexNames = indexes.Select(i => i["name"].AsString).ToList(); + indexNames.Should().Contain("IX_NormalizedUserName"); + indexNames.Should().Contain("IX_NormalizedEmail"); + indexNames.Should().Contain("IX_Logins"); + indexNames.Should().Contain("IX_Roles"); + indexNames.Should().Contain("IX_Claims"); + } + + [Fact] + public async Task Migration_ShouldOnlyRunOnce() + { + // Arrange + await MigrationHelper.ResetMigrationAsync(_database); + + // Act - Create multiple UserStores + using var userStore1 = new UserStore(_database); + await userStore1.CreateAsync(new IdentityUser("User1") { Email = "user1@example.com" }); + + using var userStore2 = new UserStore(_database); + await userStore2.CreateAsync(new IdentityUser("User2") { Email = "user2@example.com" }); + + // Assert - Check migration collection only has one entry + var migrationCollection = _database.GetCollection("_IdentityMigrations"); + var migrationCount = await migrationCollection.CountDocumentsAsync(Builders.Filter.Empty); + migrationCount.Should().Be(1); + } + + [Fact] + public async Task Migration_ShouldMigrateRoles() + { + // Arrange - Insert a legacy role document without NormalizedName + var rolesCollection = _database.GetCollection("AspNetRoles"); + var legacyRole = new BsonDocument + { + { "_id", "role1" }, + { "Name", "Admin" } + }; + await rolesCollection.InsertOneAsync(legacyRole); + + // Reset migration marker to force re-migration + await MigrationHelper.ResetMigrationAsync(_database); + + // Act - Create RoleStore which triggers migration + using var roleStore = new RoleStore(_database); + var foundRole = await roleStore.FindByNameAsync("ADMIN"); + + // Assert + foundRole.Should().NotBeNull(); + foundRole!.Name.Should().Be("Admin"); + foundRole.NormalizedName.Should().Be("ADMIN"); + } +} diff --git a/tests/MongoDB.AspNet.Identity.Tests/MongoDB.AspNet.Identity.Tests.csproj b/tests/MongoDB.AspNet.Identity.Tests/MongoDB.AspNet.Identity.Tests.csproj new file mode 100644 index 0000000..6146cac --- /dev/null +++ b/tests/MongoDB.AspNet.Identity.Tests/MongoDB.AspNet.Identity.Tests.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/tests/MongoDB.AspNet.Identity.Tests/MongoDbFixture.cs b/tests/MongoDB.AspNet.Identity.Tests/MongoDbFixture.cs new file mode 100644 index 0000000..50bad97 --- /dev/null +++ b/tests/MongoDB.AspNet.Identity.Tests/MongoDbFixture.cs @@ -0,0 +1,41 @@ +using MongoDB.Driver; +using Testcontainers.MongoDb; +using Xunit; + +namespace MongoDB.AspNet.Identity.Tests; + +/// +/// Shared fixture that manages a MongoDB container for all tests. +/// +public class MongoDbFixture : IAsyncLifetime +{ + private readonly MongoDbContainer _container = new MongoDbBuilder() + .WithImage("mongo:7.0") + .Build(); + + public string ConnectionString => _container.GetConnectionString(); + + public IMongoDatabase GetDatabase(string name = "TestDb") + { + var client = new MongoClient(ConnectionString); + return client.GetDatabase(name); + } + + public async Task InitializeAsync() + { + await _container.StartAsync(); + } + + public async Task DisposeAsync() + { + await _container.DisposeAsync(); + } +} + +/// +/// Collection definition for sharing the MongoDB fixture across test classes. +/// +[CollectionDefinition("MongoDB")] +public class MongoDbCollection : ICollectionFixture +{ +} diff --git a/tests/MongoDB.AspNet.Identity.Tests/UserStoreTests.cs b/tests/MongoDB.AspNet.Identity.Tests/UserStoreTests.cs new file mode 100644 index 0000000..fe85359 --- /dev/null +++ b/tests/MongoDB.AspNet.Identity.Tests/UserStoreTests.cs @@ -0,0 +1,814 @@ +using System.Security.Claims; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using MongoDB.Driver; +using Xunit; + +namespace MongoDB.AspNet.Identity.Tests; + +[Collection("MongoDB")] +public class UserStoreTests : IAsyncLifetime +{ + private readonly MongoDbFixture _fixture; + private readonly IMongoDatabase _database; + private UserStore _userStore = null!; + + public UserStoreTests(MongoDbFixture fixture) + { + _fixture = fixture; + _database = fixture.GetDatabase($"TestDb_{Guid.NewGuid():N}"); + } + + public Task InitializeAsync() + { + _userStore = new UserStore(_database); + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + _userStore.Dispose(); + await _database.Client.DropDatabaseAsync(_database.DatabaseNamespace.DatabaseName); + } + + #region IUserStore Tests + + [Fact] + public async Task CreateAsync_ShouldCreateUser() + { + // Arrange + var user = new IdentityUser("testuser") { Email = "test@example.com" }; + + // Act + var result = await _userStore.CreateAsync(user); + + // Assert + result.Should().Be(IdentityResult.Success); + user.Id.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task FindByIdAsync_ShouldReturnUser_WhenUserExists() + { + // Arrange + var user = new IdentityUser("testuser") { Email = "test@example.com" }; + await _userStore.CreateAsync(user); + + // Act + var foundUser = await _userStore.FindByIdAsync(user.Id); + + // Assert + foundUser.Should().NotBeNull(); + foundUser!.UserName.Should().Be("testuser"); + foundUser.Email.Should().Be("test@example.com"); + } + + [Fact] + public async Task FindByIdAsync_ShouldReturnNull_WhenUserDoesNotExist() + { + // Act + var foundUser = await _userStore.FindByIdAsync("000000000000000000000000"); + + // Assert + foundUser.Should().BeNull(); + } + + [Fact] + public async Task FindByNameAsync_ShouldReturnUser_WhenNormalizedNameMatches() + { + // Arrange + var user = new IdentityUser("TestUser") { NormalizedUserName = "TESTUSER" }; + await _userStore.CreateAsync(user); + + // Act + var foundUser = await _userStore.FindByNameAsync("TESTUSER"); + + // Assert + foundUser.Should().NotBeNull(); + foundUser!.UserName.Should().Be("TestUser"); + } + + [Fact] + public async Task UpdateAsync_ShouldUpdateUser() + { + // Arrange + var user = new IdentityUser("testuser") { Email = "old@example.com" }; + await _userStore.CreateAsync(user); + + // Act + user.Email = "new@example.com"; + var result = await _userStore.UpdateAsync(user); + + // Assert + result.Should().Be(IdentityResult.Success); + var foundUser = await _userStore.FindByIdAsync(user.Id); + foundUser!.Email.Should().Be("new@example.com"); + } + + [Fact] + public async Task DeleteAsync_ShouldRemoveUser() + { + // Arrange + var user = new IdentityUser("testuser"); + await _userStore.CreateAsync(user); + + // Act + var result = await _userStore.DeleteAsync(user); + + // Assert + result.Should().Be(IdentityResult.Success); + var foundUser = await _userStore.FindByIdAsync(user.Id); + foundUser.Should().BeNull(); + } + + [Fact] + public async Task GetUserIdAsync_ShouldReturnUserId() + { + // Arrange + var user = new IdentityUser("testuser"); + await _userStore.CreateAsync(user); + + // Act + var userId = await _userStore.GetUserIdAsync(user); + + // Assert + userId.Should().Be(user.Id); + } + + [Fact] + public async Task GetUserNameAsync_ShouldReturnUserName() + { + // Arrange + var user = new IdentityUser("testuser"); + + // Act + var userName = await _userStore.GetUserNameAsync(user); + + // Assert + userName.Should().Be("testuser"); + } + + [Fact] + public async Task SetUserNameAsync_ShouldUpdateUserName() + { + // Arrange + var user = new IdentityUser("oldname"); + + // Act + await _userStore.SetUserNameAsync(user, "newname"); + + // Assert + user.UserName.Should().Be("newname"); + } + + [Fact] + public async Task SetNormalizedUserNameAsync_ShouldUpdateNormalizedUserName() + { + // Arrange + var user = new IdentityUser("testuser"); + + // Act + await _userStore.SetNormalizedUserNameAsync(user, "TESTUSER"); + + // Assert + user.NormalizedUserName.Should().Be("TESTUSER"); + } + + #endregion + + #region IUserPasswordStore Tests + + [Fact] + public async Task SetPasswordHashAsync_ShouldSetPasswordHash() + { + // Arrange + var user = new IdentityUser("testuser"); + var passwordHash = "hashedpassword123"; + + // Act + await _userStore.SetPasswordHashAsync(user, passwordHash); + + // Assert + user.PasswordHash.Should().Be(passwordHash); + } + + [Fact] + public async Task GetPasswordHashAsync_ShouldReturnPasswordHash() + { + // Arrange + var user = new IdentityUser("testuser") { PasswordHash = "hashedpassword123" }; + + // Act + var hash = await _userStore.GetPasswordHashAsync(user); + + // Assert + hash.Should().Be("hashedpassword123"); + } + + [Fact] + public async Task HasPasswordAsync_ShouldReturnTrue_WhenPasswordHashExists() + { + // Arrange + var user = new IdentityUser("testuser") { PasswordHash = "hashedpassword123" }; + + // Act + var hasPassword = await _userStore.HasPasswordAsync(user); + + // Assert + hasPassword.Should().BeTrue(); + } + + [Fact] + public async Task HasPasswordAsync_ShouldReturnFalse_WhenPasswordHashIsNull() + { + // Arrange + var user = new IdentityUser("testuser") { PasswordHash = null }; + + // Act + var hasPassword = await _userStore.HasPasswordAsync(user); + + // Assert + hasPassword.Should().BeFalse(); + } + + #endregion + + #region IUserEmailStore Tests + + [Fact] + public async Task SetEmailAsync_ShouldSetEmail() + { + // Arrange + var user = new IdentityUser("testuser"); + + // Act + await _userStore.SetEmailAsync(user, "test@example.com"); + + // Assert + user.Email.Should().Be("test@example.com"); + } + + [Fact] + public async Task GetEmailAsync_ShouldReturnEmail() + { + // Arrange + var user = new IdentityUser("testuser") { Email = "test@example.com" }; + + // Act + var email = await _userStore.GetEmailAsync(user); + + // Assert + email.Should().Be("test@example.com"); + } + + [Fact] + public async Task FindByEmailAsync_ShouldReturnUser_WhenNormalizedEmailMatches() + { + // Arrange + var user = new IdentityUser("testuser") + { + Email = "Test@Example.com", + NormalizedEmail = "TEST@EXAMPLE.COM" + }; + await _userStore.CreateAsync(user); + + // Act + var foundUser = await _userStore.FindByEmailAsync("TEST@EXAMPLE.COM"); + + // Assert + foundUser.Should().NotBeNull(); + foundUser!.Email.Should().Be("Test@Example.com"); + } + + [Fact] + public async Task SetEmailConfirmedAsync_ShouldSetEmailConfirmed() + { + // Arrange + var user = new IdentityUser("testuser"); + + // Act + await _userStore.SetEmailConfirmedAsync(user, true); + + // Assert + user.EmailConfirmed.Should().BeTrue(); + } + + [Fact] + public async Task GetEmailConfirmedAsync_ShouldReturnEmailConfirmed() + { + // Arrange + var user = new IdentityUser("testuser") { EmailConfirmed = true }; + + // Act + var confirmed = await _userStore.GetEmailConfirmedAsync(user); + + // Assert + confirmed.Should().BeTrue(); + } + + #endregion + + #region IUserRoleStore Tests + + [Fact] + public async Task AddToRoleAsync_ShouldAddRole() + { + // Arrange + var user = new IdentityUser("testuser"); + + // Act + await _userStore.AddToRoleAsync(user, "Admin"); + + // Assert + user.Roles.Should().Contain("Admin"); + } + + [Fact] + public async Task AddToRoleAsync_ShouldNotAddDuplicateRole() + { + // Arrange + var user = new IdentityUser("testuser"); + await _userStore.AddToRoleAsync(user, "Admin"); + + // Act + await _userStore.AddToRoleAsync(user, "admin"); // Case-insensitive + + // Assert + user.Roles.Should().HaveCount(1); + } + + [Fact] + public async Task RemoveFromRoleAsync_ShouldRemoveRole() + { + // Arrange + var user = new IdentityUser("testuser"); + await _userStore.AddToRoleAsync(user, "Admin"); + + // Act + await _userStore.RemoveFromRoleAsync(user, "admin"); // Case-insensitive + + // Assert + user.Roles.Should().BeEmpty(); + } + + [Fact] + public async Task GetRolesAsync_ShouldReturnUserRoles() + { + // Arrange + var user = new IdentityUser("testuser"); + await _userStore.AddToRoleAsync(user, "Admin"); + await _userStore.AddToRoleAsync(user, "User"); + + // Act + var roles = await _userStore.GetRolesAsync(user); + + // Assert + roles.Should().HaveCount(2); + roles.Should().Contain("Admin"); + roles.Should().Contain("User"); + } + + [Fact] + public async Task IsInRoleAsync_ShouldReturnTrue_WhenUserHasRole() + { + // Arrange + var user = new IdentityUser("testuser"); + await _userStore.AddToRoleAsync(user, "Admin"); + + // Act + var isInRole = await _userStore.IsInRoleAsync(user, "admin"); // Case-insensitive + + // Assert + isInRole.Should().BeTrue(); + } + + [Fact] + public async Task GetUsersInRoleAsync_ShouldReturnUsersWithRole() + { + // Arrange + var user1 = new IdentityUser("user1"); + var user2 = new IdentityUser("user2"); + var user3 = new IdentityUser("user3"); + + await _userStore.AddToRoleAsync(user1, "Admin"); + await _userStore.AddToRoleAsync(user2, "Admin"); + await _userStore.AddToRoleAsync(user3, "User"); + + await _userStore.CreateAsync(user1); + await _userStore.CreateAsync(user2); + await _userStore.CreateAsync(user3); + + // Act + var admins = await _userStore.GetUsersInRoleAsync("Admin"); + + // Assert + admins.Should().HaveCount(2); + admins.Select(u => u.UserName).Should().Contain(new[] { "user1", "user2" }); + } + + #endregion + + #region IUserClaimStore Tests + + [Fact] + public async Task AddClaimsAsync_ShouldAddClaims() + { + // Arrange + var user = new IdentityUser("testuser"); + var claims = new[] + { + new Claim("department", "IT"), + new Claim("level", "senior") + }; + + // Act + await _userStore.AddClaimsAsync(user, claims); + + // Assert + user.Claims.Should().HaveCount(2); + user.Claims.Should().Contain(c => c.ClaimType == "department" && c.ClaimValue == "IT"); + user.Claims.Should().Contain(c => c.ClaimType == "level" && c.ClaimValue == "senior"); + } + + [Fact] + public async Task GetClaimsAsync_ShouldReturnUserClaims() + { + // Arrange + var user = new IdentityUser("testuser"); + await _userStore.AddClaimsAsync(user, new[] { new Claim("department", "IT") }); + + // Act + var claims = await _userStore.GetClaimsAsync(user); + + // Assert + claims.Should().HaveCount(1); + claims.First().Type.Should().Be("department"); + claims.First().Value.Should().Be("IT"); + } + + [Fact] + public async Task RemoveClaimsAsync_ShouldRemoveClaims() + { + // Arrange + var user = new IdentityUser("testuser"); + var claims = new[] { new Claim("department", "IT"), new Claim("level", "senior") }; + await _userStore.AddClaimsAsync(user, claims); + + // Act + await _userStore.RemoveClaimsAsync(user, new[] { new Claim("department", "IT") }); + + // Assert + user.Claims.Should().HaveCount(1); + user.Claims.First().ClaimType.Should().Be("level"); + } + + [Fact] + public async Task ReplaceClaimAsync_ShouldReplaceClaim() + { + // Arrange + var user = new IdentityUser("testuser"); + await _userStore.AddClaimsAsync(user, new[] { new Claim("department", "IT") }); + + // Act + await _userStore.ReplaceClaimAsync(user, new Claim("department", "IT"), new Claim("department", "HR")); + + // Assert + user.Claims.Should().HaveCount(1); + user.Claims.First().ClaimValue.Should().Be("HR"); + } + + [Fact] + public async Task GetUsersForClaimAsync_ShouldReturnUsersWithClaim() + { + // Arrange + var user1 = new IdentityUser("user1"); + var user2 = new IdentityUser("user2"); + var user3 = new IdentityUser("user3"); + + await _userStore.AddClaimsAsync(user1, new[] { new Claim("department", "IT") }); + await _userStore.AddClaimsAsync(user2, new[] { new Claim("department", "IT") }); + await _userStore.AddClaimsAsync(user3, new[] { new Claim("department", "HR") }); + + await _userStore.CreateAsync(user1); + await _userStore.CreateAsync(user2); + await _userStore.CreateAsync(user3); + + // Act + var users = await _userStore.GetUsersForClaimAsync(new Claim("department", "IT")); + + // Assert + users.Should().HaveCount(2); + users.Select(u => u.UserName).Should().Contain(new[] { "user1", "user2" }); + } + + #endregion + + #region IUserLoginStore Tests + + [Fact] + public async Task AddLoginAsync_ShouldAddLogin() + { + // Arrange + var user = new IdentityUser("testuser"); + var login = new UserLoginInfo("Google", "google-id-123", "Google"); + + // Act + await _userStore.AddLoginAsync(user, login); + + // Assert + user.Logins.Should().HaveCount(1); + user.Logins.First().LoginProvider.Should().Be("Google"); + user.Logins.First().ProviderKey.Should().Be("google-id-123"); + } + + [Fact] + public async Task RemoveLoginAsync_ShouldRemoveLogin() + { + // Arrange + var user = new IdentityUser("testuser"); + await _userStore.AddLoginAsync(user, new UserLoginInfo("Google", "google-id-123", "Google")); + + // Act + await _userStore.RemoveLoginAsync(user, "Google", "google-id-123"); + + // Assert + user.Logins.Should().BeEmpty(); + } + + [Fact] + public async Task GetLoginsAsync_ShouldReturnUserLogins() + { + // Arrange + var user = new IdentityUser("testuser"); + await _userStore.AddLoginAsync(user, new UserLoginInfo("Google", "google-id-123", "Google")); + await _userStore.AddLoginAsync(user, new UserLoginInfo("Facebook", "fb-id-456", "Facebook")); + + // Act + var logins = await _userStore.GetLoginsAsync(user); + + // Assert + logins.Should().HaveCount(2); + } + + [Fact] + public async Task FindByLoginAsync_ShouldReturnUser_WhenLoginExists() + { + // Arrange + var user = new IdentityUser("testuser"); + await _userStore.AddLoginAsync(user, new UserLoginInfo("Google", "google-id-123", "Google")); + await _userStore.CreateAsync(user); + + // Act + var foundUser = await _userStore.FindByLoginAsync("Google", "google-id-123"); + + // Assert + foundUser.Should().NotBeNull(); + foundUser!.UserName.Should().Be("testuser"); + } + + #endregion + + #region IUserLockoutStore Tests + + [Fact] + public async Task SetLockoutEndDateAsync_ShouldSetLockoutEnd() + { + // Arrange + var user = new IdentityUser("testuser"); + var lockoutEnd = DateTimeOffset.UtcNow.AddHours(1); + + // Act + await _userStore.SetLockoutEndDateAsync(user, lockoutEnd); + + // Assert + user.LockoutEnd.Should().BeCloseTo(lockoutEnd, TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task GetLockoutEndDateAsync_ShouldReturnLockoutEnd() + { + // Arrange + var lockoutEnd = DateTimeOffset.UtcNow.AddHours(1); + var user = new IdentityUser("testuser") { LockoutEnd = lockoutEnd }; + + // Act + var result = await _userStore.GetLockoutEndDateAsync(user); + + // Assert + result.Should().BeCloseTo(lockoutEnd, TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task IncrementAccessFailedCountAsync_ShouldIncrementCount() + { + // Arrange + var user = new IdentityUser("testuser") { AccessFailedCount = 2 }; + + // Act + var count = await _userStore.IncrementAccessFailedCountAsync(user); + + // Assert + count.Should().Be(3); + user.AccessFailedCount.Should().Be(3); + } + + [Fact] + public async Task ResetAccessFailedCountAsync_ShouldResetCount() + { + // Arrange + var user = new IdentityUser("testuser") { AccessFailedCount = 5 }; + + // Act + await _userStore.ResetAccessFailedCountAsync(user); + + // Assert + user.AccessFailedCount.Should().Be(0); + } + + [Fact] + public async Task SetLockoutEnabledAsync_ShouldSetLockoutEnabled() + { + // Arrange + var user = new IdentityUser("testuser"); + + // Act + await _userStore.SetLockoutEnabledAsync(user, true); + + // Assert + user.LockoutEnabled.Should().BeTrue(); + } + + #endregion + + #region IUserTwoFactorStore Tests + + [Fact] + public async Task SetTwoFactorEnabledAsync_ShouldSetTwoFactorEnabled() + { + // Arrange + var user = new IdentityUser("testuser"); + + // Act + await _userStore.SetTwoFactorEnabledAsync(user, true); + + // Assert + user.TwoFactorEnabled.Should().BeTrue(); + } + + [Fact] + public async Task GetTwoFactorEnabledAsync_ShouldReturnTwoFactorEnabled() + { + // Arrange + var user = new IdentityUser("testuser") { TwoFactorEnabled = true }; + + // Act + var enabled = await _userStore.GetTwoFactorEnabledAsync(user); + + // Assert + enabled.Should().BeTrue(); + } + + #endregion + + #region IUserSecurityStampStore Tests + + [Fact] + public async Task SetSecurityStampAsync_ShouldSetSecurityStamp() + { + // Arrange + var user = new IdentityUser("testuser"); + var stamp = Guid.NewGuid().ToString(); + + // Act + await _userStore.SetSecurityStampAsync(user, stamp); + + // Assert + user.SecurityStamp.Should().Be(stamp); + } + + [Fact] + public async Task GetSecurityStampAsync_ShouldReturnSecurityStamp() + { + // Arrange + var stamp = Guid.NewGuid().ToString(); + var user = new IdentityUser("testuser") { SecurityStamp = stamp }; + + // Act + var result = await _userStore.GetSecurityStampAsync(user); + + // Assert + result.Should().Be(stamp); + } + + #endregion + + #region IUserPhoneNumberStore Tests + + [Fact] + public async Task SetPhoneNumberAsync_ShouldSetPhoneNumber() + { + // Arrange + var user = new IdentityUser("testuser"); + + // Act + await _userStore.SetPhoneNumberAsync(user, "+1234567890"); + + // Assert + user.PhoneNumber.Should().Be("+1234567890"); + } + + [Fact] + public async Task GetPhoneNumberAsync_ShouldReturnPhoneNumber() + { + // Arrange + var user = new IdentityUser("testuser") { PhoneNumber = "+1234567890" }; + + // Act + var phone = await _userStore.GetPhoneNumberAsync(user); + + // Assert + phone.Should().Be("+1234567890"); + } + + [Fact] + public async Task SetPhoneNumberConfirmedAsync_ShouldSetPhoneNumberConfirmed() + { + // Arrange + var user = new IdentityUser("testuser"); + + // Act + await _userStore.SetPhoneNumberConfirmedAsync(user, true); + + // Assert + user.PhoneNumberConfirmed.Should().BeTrue(); + } + + #endregion + + #region IUserAuthenticationTokenStore Tests + + [Fact] + public async Task SetTokenAsync_ShouldSetToken() + { + // Arrange + var user = new IdentityUser("testuser"); + + // Act + await _userStore.SetTokenAsync(user, "Google", "access_token", "token-value-123"); + + // Assert + user.Tokens.Should().HaveCount(1); + user.Tokens.First().LoginProvider.Should().Be("Google"); + user.Tokens.First().Name.Should().Be("access_token"); + user.Tokens.First().Value.Should().Be("token-value-123"); + } + + [Fact] + public async Task GetTokenAsync_ShouldReturnToken() + { + // Arrange + var user = new IdentityUser("testuser"); + await _userStore.SetTokenAsync(user, "Google", "access_token", "token-value-123"); + + // Act + var token = await _userStore.GetTokenAsync(user, "Google", "access_token"); + + // Assert + token.Should().Be("token-value-123"); + } + + [Fact] + public async Task RemoveTokenAsync_ShouldRemoveToken() + { + // Arrange + var user = new IdentityUser("testuser"); + await _userStore.SetTokenAsync(user, "Google", "access_token", "token-value-123"); + + // Act + await _userStore.RemoveTokenAsync(user, "Google", "access_token"); + + // Assert + user.Tokens.Should().BeEmpty(); + } + + #endregion + + #region IQueryableUserStore Tests + + [Fact] + public async Task Users_ShouldReturnQueryableUsers() + { + // Arrange + await _userStore.CreateAsync(new IdentityUser("user1")); + await _userStore.CreateAsync(new IdentityUser("user2")); + await _userStore.CreateAsync(new IdentityUser("user3")); + + // Act + var users = _userStore.Users.ToList(); + + // Assert + users.Should().HaveCount(3); + } + + #endregion +}