diff --git a/Honamic.Framework.sln b/Honamic.Framework.sln index e79d79c..382ff07 100644 --- a/Honamic.Framework.sln +++ b/Honamic.Framework.sln @@ -93,7 +93,19 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Honamic.Todo.Endpoints.WebA EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{196209A4-3BCC-442B-9686-CF311ADEBF6D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Honamic.Framework.Tools.IdGenerators", "src\Tools\Honamic.Framework.Tools.IdGenerators\Honamic.Framework.Tools.IdGenerators.csproj", "{462B177A-3E97-4F60-A141-7A01F8B437B5}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Honamic.Framework.Tools.IdGenerators", "src\Tools\Honamic.Framework.Tools.IdGenerators\Honamic.Framework.Tools.IdGenerators.csproj", "{462B177A-3E97-4F60-A141-7A01F8B437B5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "IdentityPlus", "IdentityPlus", "{7ACD81EB-9F97-44A5-B91B-2E12C5F45607}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Honamic.IdentityPlus.Persistence", "src\IdentityPlus\Persistence\Honamic.IdentityPlus.Persistence.csproj", "{CD6164DC-0762-4E6E-8BAC-2D56CEB8C8C3}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Honamic.IdentityPlus.Domain.Abstractions", "src\IdentityPlus\Domain.Abstractions\Honamic.IdentityPlus.Domain.Abstractions.csproj", "{81DDE308-A3CF-4502-8CFF-A1A5411EE37D}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Honamic.IdentityPlus.Application", "src\IdentityPlus\Application\Honamic.IdentityPlus.Application.csproj", "{A46468C4-5C78-459C-BAF0-AF358CA57B65}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Honamic.IdentityPlus.Domain", "src\IdentityPlus\Domain\Honamic.IdentityPlus.Domain.csproj", "{B2FA9794-6654-4B7D-B996-EEB6F0C36072}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Honamic.IdentityPlus.WebApi", "src\IdentityPlus\WebApi\Honamic.IdentityPlus.WebApi.csproj", "{DBE18797-E5D4-4000-8BE8-D0F3BA1F8C9A}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -225,6 +237,26 @@ Global {462B177A-3E97-4F60-A141-7A01F8B437B5}.Debug|Any CPU.Build.0 = Debug|Any CPU {462B177A-3E97-4F60-A141-7A01F8B437B5}.Release|Any CPU.ActiveCfg = Release|Any CPU {462B177A-3E97-4F60-A141-7A01F8B437B5}.Release|Any CPU.Build.0 = Release|Any CPU + {CD6164DC-0762-4E6E-8BAC-2D56CEB8C8C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CD6164DC-0762-4E6E-8BAC-2D56CEB8C8C3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CD6164DC-0762-4E6E-8BAC-2D56CEB8C8C3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CD6164DC-0762-4E6E-8BAC-2D56CEB8C8C3}.Release|Any CPU.Build.0 = Release|Any CPU + {81DDE308-A3CF-4502-8CFF-A1A5411EE37D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {81DDE308-A3CF-4502-8CFF-A1A5411EE37D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {81DDE308-A3CF-4502-8CFF-A1A5411EE37D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {81DDE308-A3CF-4502-8CFF-A1A5411EE37D}.Release|Any CPU.Build.0 = Release|Any CPU + {A46468C4-5C78-459C-BAF0-AF358CA57B65}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A46468C4-5C78-459C-BAF0-AF358CA57B65}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A46468C4-5C78-459C-BAF0-AF358CA57B65}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A46468C4-5C78-459C-BAF0-AF358CA57B65}.Release|Any CPU.Build.0 = Release|Any CPU + {B2FA9794-6654-4B7D-B996-EEB6F0C36072}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2FA9794-6654-4B7D-B996-EEB6F0C36072}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2FA9794-6654-4B7D-B996-EEB6F0C36072}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2FA9794-6654-4B7D-B996-EEB6F0C36072}.Release|Any CPU.Build.0 = Release|Any CPU + {DBE18797-E5D4-4000-8BE8-D0F3BA1F8C9A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DBE18797-E5D4-4000-8BE8-D0F3BA1F8C9A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DBE18797-E5D4-4000-8BE8-D0F3BA1F8C9A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DBE18797-E5D4-4000-8BE8-D0F3BA1F8C9A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -274,6 +306,12 @@ Global {AED57B53-F951-4DE2-9367-18BD2DD063BB} = {39275E89-52E0-4D1B-ABD5-5A8D334A0A8D} {196209A4-3BCC-442B-9686-CF311ADEBF6D} = {E9FC6BF1-7A52-43AD-90F3-E8A20BE3FE99} {462B177A-3E97-4F60-A141-7A01F8B437B5} = {196209A4-3BCC-442B-9686-CF311ADEBF6D} + {7ACD81EB-9F97-44A5-B91B-2E12C5F45607} = {E9FC6BF1-7A52-43AD-90F3-E8A20BE3FE99} + {CD6164DC-0762-4E6E-8BAC-2D56CEB8C8C3} = {7ACD81EB-9F97-44A5-B91B-2E12C5F45607} + {81DDE308-A3CF-4502-8CFF-A1A5411EE37D} = {7ACD81EB-9F97-44A5-B91B-2E12C5F45607} + {A46468C4-5C78-459C-BAF0-AF358CA57B65} = {7ACD81EB-9F97-44A5-B91B-2E12C5F45607} + {B2FA9794-6654-4B7D-B996-EEB6F0C36072} = {7ACD81EB-9F97-44A5-B91B-2E12C5F45607} + {DBE18797-E5D4-4000-8BE8-D0F3BA1F8C9A} = {7ACD81EB-9F97-44A5-B91B-2E12C5F45607} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E15D83FC-8F5C-4D9C-9DCD-D114F5609922} diff --git a/TodoSample/Core/Application/Extensions/ApplicationServiceCollectionExtensions.cs b/TodoSample/Core/Application/Extensions/ApplicationServiceCollectionExtensions.cs index 2e8e03d..b985f9b 100644 --- a/TodoSample/Core/Application/Extensions/ApplicationServiceCollectionExtensions.cs +++ b/TodoSample/Core/Application/Extensions/ApplicationServiceCollectionExtensions.cs @@ -5,6 +5,8 @@ using Honamic.Todo.Application.TodoItems.CommandHandlers; using Honamic.Todo.Domain.Extensions; using Honamic.Framework.Tools.IdGenerators; +using Honamic.Todo.Application.TodoItems.EventHandlers; +using Honamic.IdentityPlus.Domain.Users; namespace Honamic.Todo.Application.Extensions; @@ -14,6 +16,7 @@ public static void AddApplicationServices(this IServiceCollection services, ICon { services.AddDefaultApplicationsServices(); services.AddCommandHandlers(); + services.AddEventHandlers(); services.AddDomainServices(); services.AddSnowflakeIdGeneratorServices(); } @@ -23,4 +26,10 @@ private static void AddCommandHandlers(this IServiceCollection services) services.AddCommandHandler(); services.AddCommandHandler(); } + + private static void AddEventHandlers(this IServiceCollection services) + { + services.AddEventHandler(); + services.AddEventHandler(); + } } \ No newline at end of file diff --git a/TodoSample/Core/Application/Honamic.Todo.Application.csproj b/TodoSample/Core/Application/Honamic.Todo.Application.csproj index 466be40..bb8f57a 100644 --- a/TodoSample/Core/Application/Honamic.Todo.Application.csproj +++ b/TodoSample/Core/Application/Honamic.Todo.Application.csproj @@ -14,6 +14,7 @@ + diff --git a/TodoSample/Core/Application/TodoItems/EventHandlers/UserCreatedEventHandler.cs b/TodoSample/Core/Application/TodoItems/EventHandlers/UserCreatedEventHandler.cs new file mode 100644 index 0000000..8777bab --- /dev/null +++ b/TodoSample/Core/Application/TodoItems/EventHandlers/UserCreatedEventHandler.cs @@ -0,0 +1,11 @@ +using Honamic.Framework.Events; +using Honamic.IdentityPlus.Domain.Users; + +namespace Honamic.Todo.Application.TodoItems.EventHandlers; +public class UserCreatedEventHandler : IEventHandler +{ + public Task HandleAsync(UserCreatedEvent eventToHandle) + { + return Task.CompletedTask; + } +} diff --git a/TodoSample/Core/Application/TodoItems/EventHandlers/UserLoggedEventHandler.cs b/TodoSample/Core/Application/TodoItems/EventHandlers/UserLoggedEventHandler.cs new file mode 100644 index 0000000..162d12f --- /dev/null +++ b/TodoSample/Core/Application/TodoItems/EventHandlers/UserLoggedEventHandler.cs @@ -0,0 +1,11 @@ +using Honamic.Framework.Events; +using Honamic.IdentityPlus.Domain.Users; + +namespace Honamic.Todo.Application.TodoItems.EventHandlers; +public class UserLoggedEventHandler : IEventHandler +{ + public Task HandleAsync(UserLoggedEvent eventToHandle) + { + return Task.CompletedTask; + } +} diff --git a/TodoSample/Endpoints/WebApi/Honamic.Todo.Endpoints.WebApi/Extensions/ServiceCollectionExtensions.cs b/TodoSample/Endpoints/WebApi/Honamic.Todo.Endpoints.WebApi/Extensions/ServiceCollectionExtensions.cs index c446267..f6c5ad3 100644 --- a/TodoSample/Endpoints/WebApi/Honamic.Todo.Endpoints.WebApi/Extensions/ServiceCollectionExtensions.cs +++ b/TodoSample/Endpoints/WebApi/Honamic.Todo.Endpoints.WebApi/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Honamic.Framework.Endpoints.Web.Extensions; using Honamic.Framework.Utilities.Web.Json; using Honamic.Todo.Facade.Extensions; +using Honamic.IdentityPlus.WebApi.Extensions; namespace Honamic.Todo.Endpoints.WebApi.Extensions; @@ -9,7 +10,9 @@ public static class ServiceCollectionExtensions public static IServiceCollection ConfigureServices(this IServiceCollection services, IConfiguration configuration) { services.AddFacades(configuration); + services.AddIdentityPlusApiEndpoint(); services.AddEndpointsServices(configuration); + return services; } @@ -22,7 +25,7 @@ private static void AddEndpointsServices(this IServiceCollection services, IConf c.JsonSerializerOptions .Converters.Add(new CustomLongToStringConverter()); }); - services.AddEndpointsApiExplorer(); + services.AddEndpointsApiExplorer(); services.AddSwaggerGen(); services.AddCors(); } diff --git a/TodoSample/Endpoints/WebApi/Honamic.Todo.Endpoints.WebApi/Extensions/WebApplicationExtensions.cs b/TodoSample/Endpoints/WebApi/Honamic.Todo.Endpoints.WebApi/Extensions/WebApplicationExtensions.cs index fdadc67..4eda5ca 100644 --- a/TodoSample/Endpoints/WebApi/Honamic.Todo.Endpoints.WebApi/Extensions/WebApplicationExtensions.cs +++ b/TodoSample/Endpoints/WebApi/Honamic.Todo.Endpoints.WebApi/Extensions/WebApplicationExtensions.cs @@ -1,6 +1,6 @@ using Honamic.Framework.Endpoints.Web.Extensions; using Honamic.Framework.Facade.Web.Middleware; - +using Honamic.IdentityPlus.WebApi.Extensions; namespace Honamic.Todo.Endpoints.WebApi.Extensions; public static class WebApplicationExtensions @@ -29,6 +29,8 @@ public static WebApplication UseConfigurations(this WebApplication app) app.UseAuthorization(); + app.MapIdentityPlusApi(); + app.MapControllers(); return app; diff --git a/TodoSample/Endpoints/WebApi/Honamic.Todo.Endpoints.WebApi/Honamic.Todo.Endpoints.WebApi.csproj b/TodoSample/Endpoints/WebApi/Honamic.Todo.Endpoints.WebApi/Honamic.Todo.Endpoints.WebApi.csproj index cf1afdb..a663f29 100644 --- a/TodoSample/Endpoints/WebApi/Honamic.Todo.Endpoints.WebApi/Honamic.Todo.Endpoints.WebApi.csproj +++ b/TodoSample/Endpoints/WebApi/Honamic.Todo.Endpoints.WebApi/Honamic.Todo.Endpoints.WebApi.csproj @@ -18,6 +18,7 @@ + diff --git a/TodoSample/Infra/Persistence/Extensions/ServiceCollectionExtensions.cs b/TodoSample/Infra/Persistence/Extensions/ServiceCollectionExtensions.cs index 5f370e1..5e22f20 100644 --- a/TodoSample/Infra/Persistence/Extensions/ServiceCollectionExtensions.cs +++ b/TodoSample/Infra/Persistence/Extensions/ServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ using Honamic.Todo.Domain.TodoItems; using Honamic.Todo.Domain; using Honamic.Todo.Persistence.EntityFramework.TodoItems; +using Honamic.IdentityPlus.Persistence.Extensions; namespace Honamic.Todo.Persistence.EntityFramework.Extensions; @@ -28,6 +29,8 @@ public static void AddPersistenceEntityFrameworkServices(this IServiceCollection services.AddScoped((sp) => sp.GetRequiredService()); services.AddTransient(); services.AddUnitOfWorkByEntityFramework(); + + services.AddIdentityPlusPersistence(); } private static void DebuggerConnectionStringLog(string? SqlServerConnection) diff --git a/TodoSample/Infra/Persistence/Honamic.Todo.Persistence.EntityFramework.csproj b/TodoSample/Infra/Persistence/Honamic.Todo.Persistence.EntityFramework.csproj index d098acd..fcff7ac 100644 --- a/TodoSample/Infra/Persistence/Honamic.Todo.Persistence.EntityFramework.csproj +++ b/TodoSample/Infra/Persistence/Honamic.Todo.Persistence.EntityFramework.csproj @@ -23,6 +23,7 @@ + diff --git a/TodoSample/Infra/Persistence/Migrations/20231222201004_IdentityPlus.Designer.cs b/TodoSample/Infra/Persistence/Migrations/20231222201004_IdentityPlus.Designer.cs new file mode 100644 index 0000000..53960b9 --- /dev/null +++ b/TodoSample/Infra/Persistence/Migrations/20231222201004_IdentityPlus.Designer.cs @@ -0,0 +1,385 @@ +// +using System; +using Honamic.Todo.Persistence.EntityFramework; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Honamic.Todo.Persistence.EntityFramework.Migrations +{ + [DbContext(typeof(TodoDbContext))] + [Migration("20231222201004_IdentityPlus")] + partial class IdentityPlus + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Roles.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Roles.RoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.UserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.UserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.UserRole", b => + { + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("RoleId") + .HasColumnType("bigint"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.UserToken", b => + { + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("Honamic.Todo.Domain.TodoItems.TodoItem", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedSources") + .HasColumnType("nvarchar(max)"); + + b.Property("Done") + .HasColumnType("bit"); + + b.Property("ModifiedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedOn") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedSources") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("TodoItem", "Todo"); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Roles.RoleClaim", b => + { + b.HasOne("Honamic.IdentityPlus.Domain.Roles.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.UserClaim", b => + { + b.HasOne("Honamic.IdentityPlus.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.UserLogin", b => + { + b.HasOne("Honamic.IdentityPlus.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.UserRole", b => + { + b.HasOne("Honamic.IdentityPlus.Domain.Roles.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Honamic.IdentityPlus.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.UserToken", b => + { + b.HasOne("Honamic.IdentityPlus.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Honamic.Todo.Domain.TodoItems.TodoItem", b => + { + b.OwnsMany("Honamic.Todo.Domain.TodoItems.TodoItemLog", "Logs", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b1.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b1.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b1.Property("ModifiedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b1.Property("ModifiedOn") + .HasColumnType("datetimeoffset"); + + b1.Property("TodoItemRef") + .HasColumnType("bigint"); + + b1.Property("Type") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b1.HasKey("Id"); + + b1.HasIndex("TodoItemRef"); + + b1.ToTable("TodoItemLog", "Todo"); + + b1.WithOwner() + .HasForeignKey("TodoItemRef"); + }); + + b.Navigation("Logs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TodoSample/Infra/Persistence/Migrations/20231222201004_IdentityPlus.cs b/TodoSample/Infra/Persistence/Migrations/20231222201004_IdentityPlus.cs new file mode 100644 index 0000000..9e04e23 --- /dev/null +++ b/TodoSample/Infra/Persistence/Migrations/20231222201004_IdentityPlus.cs @@ -0,0 +1,226 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Honamic.Todo.Persistence.EntityFramework.Migrations +{ + /// + public partial class IdentityPlus : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Roles", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Roles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "bit", nullable: false), + PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), + SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumber = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), + TwoFactorEnabled = table.Column(type: "bit", nullable: false), + LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), + LockoutEnabled = table.Column(type: "bit", nullable: false), + AccessFailedCount = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RoleClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + RoleId = table.Column(type: "bigint", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_RoleClaims_Roles_RoleId", + column: x => x.RoleId, + principalTable: "Roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "bigint", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserClaims", x => x.Id); + table.ForeignKey( + name: "FK_UserClaims_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + ProviderKey = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), + UserId = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_UserLogins_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserRoles", + columns: table => new + { + UserId = table.Column(type: "bigint", nullable: false), + RoleId = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_UserRoles_Roles_RoleId", + column: x => x.RoleId, + principalTable: "Roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserRoles_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserTokens", + columns: table => new + { + UserId = table.Column(type: "bigint", nullable: false), + LoginProvider = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Name = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_UserTokens_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_RoleClaims_RoleId", + table: "RoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "Roles", + column: "NormalizedName", + unique: true, + filter: "[NormalizedName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_UserClaims_UserId", + table: "UserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_UserLogins_UserId", + table: "UserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_UserRoles_RoleId", + table: "UserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "Users", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "Users", + column: "NormalizedUserName", + unique: true, + filter: "[NormalizedUserName] IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RoleClaims"); + + migrationBuilder.DropTable( + name: "UserClaims"); + + migrationBuilder.DropTable( + name: "UserLogins"); + + migrationBuilder.DropTable( + name: "UserRoles"); + + migrationBuilder.DropTable( + name: "UserTokens"); + + migrationBuilder.DropTable( + name: "Roles"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/TodoSample/Infra/Persistence/Migrations/20231224133951_Remove_Identity.Designer.cs b/TodoSample/Infra/Persistence/Migrations/20231224133951_Remove_Identity.Designer.cs new file mode 100644 index 0000000..3f62509 --- /dev/null +++ b/TodoSample/Infra/Persistence/Migrations/20231224133951_Remove_Identity.Designer.cs @@ -0,0 +1,128 @@ +// +using System; +using Honamic.Todo.Persistence.EntityFramework; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Honamic.Todo.Persistence.EntityFramework.Migrations +{ + [DbContext(typeof(TodoDbContext))] + [Migration("20231224133951_Remove_Identity")] + partial class Remove_Identity + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Honamic.Todo.Domain.TodoItems.TodoItem", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedSources") + .HasColumnType("nvarchar(max)"); + + b.Property("Done") + .HasColumnType("bit"); + + b.Property("ModifiedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedOn") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedSources") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("TodoItem", "Todo"); + }); + + modelBuilder.Entity("Honamic.Todo.Domain.TodoItems.TodoItem", b => + { + b.OwnsMany("Honamic.Todo.Domain.TodoItems.TodoItemLog", "Logs", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b1.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b1.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b1.Property("ModifiedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b1.Property("ModifiedOn") + .HasColumnType("datetimeoffset"); + + b1.Property("TodoItemRef") + .HasColumnType("bigint"); + + b1.Property("Type") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b1.HasKey("Id"); + + b1.HasIndex("TodoItemRef"); + + b1.ToTable("TodoItemLog", "Todo"); + + b1.WithOwner() + .HasForeignKey("TodoItemRef"); + }); + + b.Navigation("Logs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TodoSample/Infra/Persistence/Migrations/20231224133951_Remove_Identity.cs b/TodoSample/Infra/Persistence/Migrations/20231224133951_Remove_Identity.cs new file mode 100644 index 0000000..202d7dd --- /dev/null +++ b/TodoSample/Infra/Persistence/Migrations/20231224133951_Remove_Identity.cs @@ -0,0 +1,226 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Honamic.Todo.Persistence.EntityFramework.Migrations +{ + /// + public partial class Remove_Identity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RoleClaims"); + + migrationBuilder.DropTable( + name: "UserClaims"); + + migrationBuilder.DropTable( + name: "UserLogins"); + + migrationBuilder.DropTable( + name: "UserRoles"); + + migrationBuilder.DropTable( + name: "UserTokens"); + + migrationBuilder.DropTable( + name: "Roles"); + + migrationBuilder.DropTable( + name: "Users"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Roles", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Roles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + AccessFailedCount = table.Column(type: "int", nullable: false), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "bit", nullable: false), + LockoutEnabled = table.Column(type: "bit", nullable: false), + LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), + NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumber = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), + SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), + TwoFactorEnabled = table.Column(type: "bit", nullable: false), + UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RoleClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true), + RoleId = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_RoleClaims_Roles_RoleId", + column: x => x.RoleId, + principalTable: "Roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true), + UserId = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserClaims", x => x.Id); + table.ForeignKey( + name: "FK_UserClaims_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + ProviderKey = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), + UserId = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_UserLogins_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserRoles", + columns: table => new + { + UserId = table.Column(type: "bigint", nullable: false), + RoleId = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_UserRoles_Roles_RoleId", + column: x => x.RoleId, + principalTable: "Roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserRoles_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserTokens", + columns: table => new + { + UserId = table.Column(type: "bigint", nullable: false), + LoginProvider = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Name = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_UserTokens_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_RoleClaims_RoleId", + table: "RoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "Roles", + column: "NormalizedName", + unique: true, + filter: "[NormalizedName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_UserClaims_UserId", + table: "UserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_UserLogins_UserId", + table: "UserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_UserRoles_RoleId", + table: "UserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "Users", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "Users", + column: "NormalizedUserName", + unique: true, + filter: "[NormalizedUserName] IS NOT NULL"); + } + } +} diff --git a/TodoSample/Infra/Persistence/Migrations/20231224134108_IdentityPlus2.Designer.cs b/TodoSample/Infra/Persistence/Migrations/20231224134108_IdentityPlus2.Designer.cs new file mode 100644 index 0000000..9fe6f83 --- /dev/null +++ b/TodoSample/Infra/Persistence/Migrations/20231224134108_IdentityPlus2.Designer.cs @@ -0,0 +1,507 @@ +// +using System; +using Honamic.Todo.Persistence.EntityFramework; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Honamic.Todo.Persistence.EntityFramework.Migrations +{ + [DbContext(typeof(TodoDbContext))] + [Migration("20231224134108_IdentityPlus2")] + partial class IdentityPlus2 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Roles.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedSources") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedOn") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedSources") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Roles.RoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedOn") + .HasColumnType("datetimeoffset"); + + b.Property("RoleId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedSources") + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedOn") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedSources") + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.UserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedOn") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.UserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("ModifiedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedOn") + .HasColumnType("datetimeoffset"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.UserRole", b => + { + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("RoleId") + .HasColumnType("bigint"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("ModifiedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedOn") + .HasColumnType("datetimeoffset"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.UserToken", b => + { + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("ModifiedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedOn") + .HasColumnType("datetimeoffset"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("Honamic.Todo.Domain.TodoItems.TodoItem", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedSources") + .HasColumnType("nvarchar(max)"); + + b.Property("Done") + .HasColumnType("bit"); + + b.Property("ModifiedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedOn") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedSources") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("TodoItem", "Todo"); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Roles.RoleClaim", b => + { + b.HasOne("Honamic.IdentityPlus.Domain.Roles.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.UserClaim", b => + { + b.HasOne("Honamic.IdentityPlus.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.UserLogin", b => + { + b.HasOne("Honamic.IdentityPlus.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.UserRole", b => + { + b.HasOne("Honamic.IdentityPlus.Domain.Roles.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Honamic.IdentityPlus.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.UserToken", b => + { + b.HasOne("Honamic.IdentityPlus.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Honamic.Todo.Domain.TodoItems.TodoItem", b => + { + b.OwnsMany("Honamic.Todo.Domain.TodoItems.TodoItemLog", "Logs", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b1.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b1.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b1.Property("ModifiedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b1.Property("ModifiedOn") + .HasColumnType("datetimeoffset"); + + b1.Property("TodoItemRef") + .HasColumnType("bigint"); + + b1.Property("Type") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b1.HasKey("Id"); + + b1.HasIndex("TodoItemRef"); + + b1.ToTable("TodoItemLog", "Todo"); + + b1.WithOwner() + .HasForeignKey("TodoItemRef"); + }); + + b.Navigation("Logs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TodoSample/Infra/Persistence/Migrations/20231224134108_IdentityPlus2.cs b/TodoSample/Infra/Persistence/Migrations/20231224134108_IdentityPlus2.cs new file mode 100644 index 0000000..c370efc --- /dev/null +++ b/TodoSample/Infra/Persistence/Migrations/20231224134108_IdentityPlus2.cs @@ -0,0 +1,262 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Honamic.Todo.Persistence.EntityFramework.Migrations +{ + /// + public partial class IdentityPlus2 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Roles", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), + CreatedBy = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + CreatedOn = table.Column(type: "datetimeoffset", nullable: true), + ModifiedBy = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + ModifiedOn = table.Column(type: "datetimeoffset", nullable: true), + Version = table.Column(type: "bigint", nullable: false), + ModifiedSources = table.Column(type: "nvarchar(max)", nullable: true), + CreatedSources = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Roles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false), + UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "bit", nullable: false), + PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), + SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumber = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), + TwoFactorEnabled = table.Column(type: "bit", nullable: false), + LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), + LockoutEnabled = table.Column(type: "bit", nullable: false), + AccessFailedCount = table.Column(type: "int", nullable: false), + CreatedBy = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + CreatedOn = table.Column(type: "datetimeoffset", nullable: true), + ModifiedBy = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + ModifiedOn = table.Column(type: "datetimeoffset", nullable: true), + Version = table.Column(type: "bigint", nullable: false), + ModifiedSources = table.Column(type: "nvarchar(max)", nullable: true), + CreatedSources = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RoleClaims", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + RoleId = table.Column(type: "bigint", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true), + CreatedBy = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + CreatedOn = table.Column(type: "datetimeoffset", nullable: true), + ModifiedBy = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + ModifiedOn = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_RoleClaims_Roles_RoleId", + column: x => x.RoleId, + principalTable: "Roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserClaims", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "bigint", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true), + CreatedBy = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + CreatedOn = table.Column(type: "datetimeoffset", nullable: true), + ModifiedBy = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + ModifiedOn = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserClaims", x => x.Id); + table.ForeignKey( + name: "FK_UserClaims_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + ProviderKey = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), + UserId = table.Column(type: "bigint", nullable: false), + Id = table.Column(type: "bigint", nullable: false), + CreatedBy = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + CreatedOn = table.Column(type: "datetimeoffset", nullable: true), + ModifiedBy = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + ModifiedOn = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_UserLogins_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserRoles", + columns: table => new + { + UserId = table.Column(type: "bigint", nullable: false), + RoleId = table.Column(type: "bigint", nullable: false), + Id = table.Column(type: "bigint", nullable: false), + CreatedBy = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + CreatedOn = table.Column(type: "datetimeoffset", nullable: true), + ModifiedBy = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + ModifiedOn = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_UserRoles_Roles_RoleId", + column: x => x.RoleId, + principalTable: "Roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserRoles_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "UserTokens", + columns: table => new + { + UserId = table.Column(type: "bigint", nullable: false), + LoginProvider = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Name = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: true), + Id = table.Column(type: "bigint", nullable: false), + CreatedBy = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + CreatedOn = table.Column(type: "datetimeoffset", nullable: true), + ModifiedBy = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + ModifiedOn = table.Column(type: "datetimeoffset", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_UserTokens_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_RoleClaims_RoleId", + table: "RoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "Roles", + column: "NormalizedName", + unique: true, + filter: "[NormalizedName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_UserClaims_UserId", + table: "UserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_UserLogins_UserId", + table: "UserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_UserRoles_RoleId", + table: "UserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "Users", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "Users", + column: "NormalizedUserName", + unique: true, + filter: "[NormalizedUserName] IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RoleClaims"); + + migrationBuilder.DropTable( + name: "UserClaims"); + + migrationBuilder.DropTable( + name: "UserLogins"); + + migrationBuilder.DropTable( + name: "UserRoles"); + + migrationBuilder.DropTable( + name: "UserTokens"); + + migrationBuilder.DropTable( + name: "Roles"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/TodoSample/Infra/Persistence/Migrations/TodoDbContextModelSnapshot.cs b/TodoSample/Infra/Persistence/Migrations/TodoDbContextModelSnapshot.cs index 6ae2f06..a0fd1fc 100644 --- a/TodoSample/Infra/Persistence/Migrations/TodoDbContextModelSnapshot.cs +++ b/TodoSample/Infra/Persistence/Migrations/TodoDbContextModelSnapshot.cs @@ -22,6 +22,334 @@ protected override void BuildModel(ModelBuilder modelBuilder) SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Roles.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedSources") + .HasColumnType("nvarchar(max)"); + + b.Property("ModifiedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedOn") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedSources") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Roles.RoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedOn") + .HasColumnType("datetimeoffset"); + + b.Property("RoleId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedSources") + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedOn") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedSources") + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.UserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedOn") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.UserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("ModifiedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedOn") + .HasColumnType("datetimeoffset"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.UserRole", b => + { + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("RoleId") + .HasColumnType("bigint"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("ModifiedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedOn") + .HasColumnType("datetimeoffset"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.UserToken", b => + { + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("CreatedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedOn") + .HasColumnType("datetimeoffset"); + + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("ModifiedBy") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ModifiedOn") + .HasColumnType("datetimeoffset"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + modelBuilder.Entity("Honamic.Todo.Domain.TodoItems.TodoItem", b => { b.Property("Id") @@ -72,6 +400,57 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("TodoItem", "Todo"); }); + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Roles.RoleClaim", b => + { + b.HasOne("Honamic.IdentityPlus.Domain.Roles.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.UserClaim", b => + { + b.HasOne("Honamic.IdentityPlus.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.UserLogin", b => + { + b.HasOne("Honamic.IdentityPlus.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.UserRole", b => + { + b.HasOne("Honamic.IdentityPlus.Domain.Roles.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Honamic.IdentityPlus.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Honamic.IdentityPlus.Domain.Users.UserToken", b => + { + b.HasOne("Honamic.IdentityPlus.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Honamic.Todo.Domain.TodoItems.TodoItem", b => { b.OwnsMany("Honamic.Todo.Domain.TodoItems.TodoItemLog", "Logs", b1 => diff --git a/TodoSample/Infra/Persistence/TodoDbContext.cs b/TodoSample/Infra/Persistence/TodoDbContext.cs index 13b4dd8..5a10d1f 100644 --- a/TodoSample/Infra/Persistence/TodoDbContext.cs +++ b/TodoSample/Infra/Persistence/TodoDbContext.cs @@ -1,6 +1,6 @@ using Honamic.Todo.Persistence.EntityFramework.TodoItems; using Microsoft.EntityFrameworkCore; - +using Honamic.IdentityPlus.Persistence.Extensions; namespace Honamic.Todo.Persistence.EntityFramework; public class TodoDbContext : DbContext @@ -15,6 +15,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfiguration(new TodoItemEntityConfiguration()); + modelBuilder.AddIdentityPlusModel(); + base.OnModelCreating(modelBuilder); } } diff --git a/src/Core/Applications/Extensions/ServiceCollectionExtensions.cs b/src/Core/Applications/Extensions/ServiceCollectionExtensions.cs index 6f6a682..0d9a1af 100644 --- a/src/Core/Applications/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Applications/Extensions/ServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using Honamic.Framework.Domain; using Honamic.Framework.Events.Extensions; using Honamic.Framework.Queries.Extensions; +using Honamic.Framework.Events; namespace Honamic.Framework.Applications.Extensions; @@ -45,4 +46,11 @@ public static void AddCommandHandler(this services.AddTransient, TCommandHandler>(); services.Decorate, TransactionalCommandHandlerDecorator>(); } + + public static void AddEventHandler(this IServiceCollection services) + where TEvent : IEvent + where TEventHandler : class, IEventHandler + { + services.AddTransient, TEventHandler>(); + } } \ No newline at end of file diff --git a/src/Core/Domain.Abstractions/AggregateRoot.cs b/src/Core/Domain.Abstractions/AggregateRoot.cs index 02925a9..efb2c83 100644 --- a/src/Core/Domain.Abstractions/AggregateRoot.cs +++ b/src/Core/Domain.Abstractions/AggregateRoot.cs @@ -5,7 +5,7 @@ namespace Honamic.Framework.Domain; public abstract class AggregateRoot : Entity, IAuditCreateSources, IAuditUpdateSources, IAggregateRoot { - private List _events= new List(); + private List _events = new List(); private bool _markAsDeleted; private long _version; @@ -24,7 +24,9 @@ public long Version public void RaiseEvent(DomainEvent @event) { - @event?.SetAggregateVersion(Version); + ArgumentNullException.ThrowIfNull(@event, nameof(@event)); + + @event.SetAggregateVersion(Version); _events.Add(@event); } diff --git a/src/Core/Domain.Abstractions/DomainEvent.cs b/src/Core/Domain.Abstractions/DomainEvent.cs index 0aaef5c..82247c1 100644 --- a/src/Core/Domain.Abstractions/DomainEvent.cs +++ b/src/Core/Domain.Abstractions/DomainEvent.cs @@ -16,7 +16,7 @@ protected DomainEvent(long aggregateId) public long AggregateVersion { get; private set; } - public IEventUserInfo UserInfo { get; private set; } + public IEventUserInfo? UserInfo { get; private set; } public void SetUserContextValue(IEventUserInfo userInfo) { diff --git a/src/Core/Domain.Abstractions/Entity.cs b/src/Core/Domain.Abstractions/Entity.cs index 79c3138..cc54366 100644 --- a/src/Core/Domain.Abstractions/Entity.cs +++ b/src/Core/Domain.Abstractions/Entity.cs @@ -4,7 +4,7 @@ namespace Honamic.Framework.Domain; public abstract class Entity : IAuditCreate, IAuditUpdate { - public TKey Id { get; set; } + public virtual TKey Id { get; set; } = default!; [StringLength(100)] public string? CreatedBy { get; set; } diff --git a/src/EntityFramework/Default/Interceptors/AuditSources/AuditSourcesValues.cs b/src/EntityFramework/Default/Interceptors/AuditSources/AuditSourcesValues.cs index 16a26e3..eada378 100644 --- a/src/EntityFramework/Default/Interceptors/AuditSources/AuditSourcesValues.cs +++ b/src/EntityFramework/Default/Interceptors/AuditSources/AuditSourcesValues.cs @@ -33,7 +33,7 @@ public class AuditSourceValues public string? ClientVersion { get; set; } [JsonPropertyName("o")] - public string Other { get; set; } + public string? Other { get; set; } public string SerializeJson() { @@ -43,6 +43,6 @@ public string SerializeJson() public static AuditSourceValues DeserializeJson(string value) { - return JsonSerializer.Deserialize(value); + return JsonSerializer.Deserialize(value)!; } } diff --git a/src/IdentityPlus/Application/Extensions/IdentityPlusApplicationServiceCollection.cs b/src/IdentityPlus/Application/Extensions/IdentityPlusApplicationServiceCollection.cs new file mode 100644 index 0000000..6caa0d3 --- /dev/null +++ b/src/IdentityPlus/Application/Extensions/IdentityPlusApplicationServiceCollection.cs @@ -0,0 +1,37 @@ +using Honamic.IdentityPlus.Domain.Roles; +using Honamic.IdentityPlus.Domain.Users; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; + +namespace Honamic.IdentityPlus.Application.Extensions; + +public static class IdentityPlusApplicationServiceCollection +{ + public static IdentityBuilder? IdentityBuilder { get; private set; } + public static IServiceCollection AddIdentityPlusApplication(this IServiceCollection services) + { + services.AddIdentityPlusApplication(_ => { }); + + return services; + } + + public static IServiceCollection AddIdentityPlusApplication(this IServiceCollection services, + Action configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configure); + + services.AddScoped, IdentityPlusUserManager>(); + services.AddScoped(); + + services.AddScoped, IdentityPlusRoleManager>(); + services.AddScoped(); + + IdentityBuilder = services.AddIdentityCore((opt) => + { + + }); + + return services; + } +} diff --git a/src/IdentityPlus/Application/Honamic.IdentityPlus.Application.csproj b/src/IdentityPlus/Application/Honamic.IdentityPlus.Application.csproj new file mode 100644 index 0000000..ae4d00f --- /dev/null +++ b/src/IdentityPlus/Application/Honamic.IdentityPlus.Application.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/src/IdentityPlus/Application/IdentityPlusOptions.cs b/src/IdentityPlus/Application/IdentityPlusOptions.cs new file mode 100644 index 0000000..d49c9bc --- /dev/null +++ b/src/IdentityPlus/Application/IdentityPlusOptions.cs @@ -0,0 +1,7 @@ +using Microsoft.AspNetCore.Identity; + +namespace Honamic.IdentityPlus.Application; +public class IdentityPlusOptions : IdentityOptions +{ + +} diff --git a/src/IdentityPlus/Application/IdentityPlusRoleManager.cs b/src/IdentityPlus/Application/IdentityPlusRoleManager.cs new file mode 100644 index 0000000..5d4a920 --- /dev/null +++ b/src/IdentityPlus/Application/IdentityPlusRoleManager.cs @@ -0,0 +1,18 @@ +using Honamic.IdentityPlus.Domain.Roles; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; + +namespace Honamic.IdentityPlus.Application; + +public class IdentityPlusRoleManager : RoleManager +{ + public IdentityPlusRoleManager(IRoleStore store, + IEnumerable> roleValidators, + ILookupNormalizer keyNormalizer, + IdentityErrorDescriber errors, + ILogger> logger) + : base(store, roleValidators, keyNormalizer, errors, logger) + { + + } +} \ No newline at end of file diff --git a/src/IdentityPlus/Application/IdentityPlusUserManager.cs b/src/IdentityPlus/Application/IdentityPlusUserManager.cs new file mode 100644 index 0000000..121f6ca --- /dev/null +++ b/src/IdentityPlus/Application/IdentityPlusUserManager.cs @@ -0,0 +1,22 @@ +using Honamic.IdentityPlus.Domain.Users; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Honamic.IdentityPlus.Application; +public class IdentityPlusUserManager : UserManager +{ + public IdentityPlusUserManager(IUserStore store, + IOptions optionsAccessor, + IPasswordHasher passwordHasher, + IEnumerable> userValidators, + IEnumerable> passwordValidators, + ILookupNormalizer keyNormalizer, + IdentityErrorDescriber errors, + IServiceProvider services, + ILogger> logger) + : base(store, optionsAccessor, passwordHasher, userValidators, passwordValidators, keyNormalizer, errors, services, logger) + { + + } +} diff --git a/src/IdentityPlus/Application/SignInManager.cs b/src/IdentityPlus/Application/SignInManager.cs new file mode 100644 index 0000000..ecdda57 --- /dev/null +++ b/src/IdentityPlus/Application/SignInManager.cs @@ -0,0 +1,55 @@ +using Honamic.Framework.Events; +using Honamic.IdentityPlus.Domain.Users; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Honamic.IdentityPlus.Application; +public class IdentityPlusSignInManager : SignInManager +{ + private readonly IEventBus _eventBus; + + public IdentityPlusSignInManager(UserManager userManager, + IHttpContextAccessor contextAccessor, + IUserClaimsPrincipalFactory claimsFactory, + IOptions optionsAccessor, + ILogger> logger, + IAuthenticationSchemeProvider schemes, + IUserConfirmation confirmation, + IEventBus eventBus + ) + : base(userManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation) + { + _eventBus = eventBus; + } + + public override async Task PasswordSignInAsync(User user, string password, bool isPersistent, bool lockoutOnFailure) + { + var result = await base.PasswordSignInAsync(user, password, isPersistent, lockoutOnFailure); + + await PublishLoggedEvent(user, result); + + return result; + } + + + protected override async Task SignInOrTwoFactorAsync(User user, bool isPersistent, string? loginProvider = null, bool bypassTwoFactor = false) + { + var result = await base.SignInOrTwoFactorAsync(user, isPersistent, loginProvider, bypassTwoFactor); + + await PublishLoggedEvent(user, result); + + return result; + } + + private async Task PublishLoggedEvent(User user, SignInResult result) + { + if (result.Succeeded) + { + await _eventBus.PublishAsync(new UserLoggedEvent(user.Id, user.UserName)); + } + } + +} diff --git a/src/IdentityPlus/Domain.Abstractions/Honamic.IdentityPlus.Domain.Abstractions.csproj b/src/IdentityPlus/Domain.Abstractions/Honamic.IdentityPlus.Domain.Abstractions.csproj new file mode 100644 index 0000000..bdae270 --- /dev/null +++ b/src/IdentityPlus/Domain.Abstractions/Honamic.IdentityPlus.Domain.Abstractions.csproj @@ -0,0 +1,14 @@ + + + net8.0 + enable + enable + $(MSBuildProjectName.Replace(" ", "_").Replace(".Abstractions", "")) + + + + + + + + diff --git a/src/IdentityPlus/Domain.Abstractions/Users/UserCreatedEvent.cs b/src/IdentityPlus/Domain.Abstractions/Users/UserCreatedEvent.cs new file mode 100644 index 0000000..dd91b6d --- /dev/null +++ b/src/IdentityPlus/Domain.Abstractions/Users/UserCreatedEvent.cs @@ -0,0 +1,10 @@ +using Honamic.Framework.Domain; + +namespace Honamic.IdentityPlus.Domain.Users; +public class UserCreatedEvent : DomainEvent +{ + public UserCreatedEvent(long userId) : base(userId) + { + + } +} diff --git a/src/IdentityPlus/Domain.Abstractions/Users/UserLoggedEvent.cs b/src/IdentityPlus/Domain.Abstractions/Users/UserLoggedEvent.cs new file mode 100644 index 0000000..7fef2fa --- /dev/null +++ b/src/IdentityPlus/Domain.Abstractions/Users/UserLoggedEvent.cs @@ -0,0 +1,14 @@ +using Honamic.Framework.Domain; + +namespace Honamic.IdentityPlus.Domain.Users; +public class UserLoggedEvent : DomainEvent +{ + public UserLoggedEvent(long userId, string? userName) : base(userId) + { + UserId = userId; + UserName = userName; + } + + public long UserId { get; } + public string? UserName { get; } +} diff --git a/src/IdentityPlus/Domain/Honamic.IdentityPlus.Domain.csproj b/src/IdentityPlus/Domain/Honamic.IdentityPlus.Domain.csproj new file mode 100644 index 0000000..f5cd805 --- /dev/null +++ b/src/IdentityPlus/Domain/Honamic.IdentityPlus.Domain.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/src/IdentityPlus/Domain/ProtectedPersonalDataAttribute.cs b/src/IdentityPlus/Domain/ProtectedPersonalDataAttribute.cs new file mode 100644 index 0000000..1e0bc1c --- /dev/null +++ b/src/IdentityPlus/Domain/ProtectedPersonalDataAttribute.cs @@ -0,0 +1,12 @@ + + +namespace Honamic.IdentityPlus.Domain; + +[AttributeUsage(AttributeTargets.Property)] +public class IdenityPlusPersonalDataAttribute : Attribute +{ +} + +public class IdentityPlusProtectedPersonalDataAttribute : IdenityPlusPersonalDataAttribute +{ +} \ No newline at end of file diff --git a/src/IdentityPlus/Domain/Roles/Role.IdentitySource.cs b/src/IdentityPlus/Domain/Roles/Role.IdentitySource.cs new file mode 100644 index 0000000..3d23918 --- /dev/null +++ b/src/IdentityPlus/Domain/Roles/Role.IdentitySource.cs @@ -0,0 +1,28 @@ + +namespace Honamic.IdentityPlus.Domain.Roles; +public partial class Role +{ + /// + /// 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; } + + /// + /// A random value that should change whenever a role is persisted to the store + /// + public virtual string? ConcurrencyStamp { get; set; } + + /// + /// Returns the name of the role. + /// + /// The name of the role. + public override string ToString() + { + return Name ?? string.Empty; + } +} \ No newline at end of file diff --git a/src/IdentityPlus/Domain/Roles/Role.cs b/src/IdentityPlus/Domain/Roles/Role.cs new file mode 100644 index 0000000..1b51a96 --- /dev/null +++ b/src/IdentityPlus/Domain/Roles/Role.cs @@ -0,0 +1,8 @@ +using Honamic.Framework.Domain; + +namespace Honamic.IdentityPlus.Domain.Roles; + +public partial class Role : AggregateRoot +{ + +} diff --git a/src/IdentityPlus/Domain/Roles/RoleClaim.IdentitySource.cs b/src/IdentityPlus/Domain/Roles/RoleClaim.IdentitySource.cs new file mode 100644 index 0000000..5347073 --- /dev/null +++ b/src/IdentityPlus/Domain/Roles/RoleClaim.IdentitySource.cs @@ -0,0 +1,37 @@ +using System.Security.Claims; + +namespace Honamic.IdentityPlus.Domain.Roles; + +public partial class RoleClaim +{ + public virtual long RoleId { get; set; } = default!; + + /// + /// Gets or sets the claim type for this claim. + /// + public virtual string? ClaimType { get; set; } + + /// + /// Gets or sets the claim value for this claim. + /// + public virtual string? ClaimValue { get; set; } + + /// + /// Constructs a new claim with the type and value. + /// + /// The that was produced. + public virtual Claim ToClaim() + { + return new Claim(ClaimType!, ClaimValue!); + } + + /// + /// Initializes by copying ClaimType and ClaimValue from the other claim. + /// + /// The claim to initialize from. + public virtual void InitializeFromClaim(Claim? other) + { + ClaimType = other?.Type; + ClaimValue = other?.Value; + } +} diff --git a/src/IdentityPlus/Domain/Roles/RoleClaim.cs b/src/IdentityPlus/Domain/Roles/RoleClaim.cs new file mode 100644 index 0000000..66e859c --- /dev/null +++ b/src/IdentityPlus/Domain/Roles/RoleClaim.cs @@ -0,0 +1,11 @@ +using Honamic.Framework.Domain; + +namespace Honamic.IdentityPlus.Domain.Roles; + +public partial class RoleClaim : Entity, IEquatable +{ + public bool Equals(long other) + { + return Id == other; + } +} diff --git a/src/IdentityPlus/Domain/Users/User.IdentitySource.cs b/src/IdentityPlus/Domain/Users/User.IdentitySource.cs new file mode 100644 index 0000000..d4358f8 --- /dev/null +++ b/src/IdentityPlus/Domain/Users/User.IdentitySource.cs @@ -0,0 +1,99 @@ + +namespace Honamic.IdentityPlus.Domain.Users; + +public partial class User +{ + + [IdenityPlusPersonalData] + public override long Id { get => base.Id; set => base.Id = value; } + + /// + /// Gets or sets the user name for this user. + /// + [IdentityPlusProtectedPersonalData] + public virtual string? UserName { get; set; } + + /// + /// Gets or sets the normalized user name for this user. + /// + public virtual string? NormalizedUserName { get; set; } + + /// + /// Gets or sets the email address for this user. + /// + [IdentityPlusProtectedPersonalData] + public virtual string? Email { get; set; } + + /// + /// Gets or sets the normalized email address for this user. + /// + public virtual string? NormalizedEmail { get; set; } + + /// + /// Gets or sets a flag indicating if a user has confirmed their email address. + /// + /// True if the email address has been confirmed, otherwise false. + [IdenityPlusPersonalData] + public virtual bool EmailConfirmed { get; set; } + + /// + /// Gets or sets a salted and hashed representation of the password for this user. + /// + public virtual string? PasswordHash { get; set; } + + /// + /// A random value that must change whenever a users credentials change (password changed, login removed) + /// + public virtual string? SecurityStamp { get; set; } + + /// + /// A random value that must change whenever a user is persisted to the store + /// + public virtual string? ConcurrencyStamp { get; set; } = Guid.NewGuid().ToString(); + + /// + /// Gets or sets a telephone number for the user. + /// + [IdentityPlusProtectedPersonalData] + public virtual string? PhoneNumber { get; set; } + + /// + /// Gets or sets a flag indicating if a user has confirmed their telephone address. + /// + /// True if the telephone number has been confirmed, otherwise false. + [IdenityPlusPersonalData] + public virtual bool PhoneNumberConfirmed { get; set; } + + /// + /// Gets or sets a flag indicating if two factor authentication is enabled for this user. + /// + /// True if 2fa is enabled, otherwise false. + [IdenityPlusPersonalData] + public virtual bool TwoFactorEnabled { get; set; } + + /// + /// Gets or sets the date and time, in UTC, when any user lockout ends. + /// + /// + /// A value in the past means the user is not locked out. + /// + public virtual DateTimeOffset? LockoutEnd { get; set; } + + /// + /// Gets or sets a flag indicating if the user could be locked out. + /// + /// True if the user could be locked out, otherwise false. + public virtual bool LockoutEnabled { get; set; } + + /// + /// Gets or sets the number of failed login attempts for the current user. + /// + public virtual int AccessFailedCount { get; set; } + + + /// + /// Returns the username for this user. + /// + public override string ToString() + => UserName ?? string.Empty; +} \ No newline at end of file diff --git a/src/IdentityPlus/Domain/Users/User.cs b/src/IdentityPlus/Domain/Users/User.cs new file mode 100644 index 0000000..a2da68d --- /dev/null +++ b/src/IdentityPlus/Domain/Users/User.cs @@ -0,0 +1,33 @@ +using Honamic.Framework.Domain; +using System.Reflection; + +namespace Honamic.IdentityPlus.Domain.Users; +public partial class User : AggregateRoot, IEquatable +{ + public User() + { + RaiseEvent(new UserCreatedEvent(Id)); + } + + public User(string userName) : this() + { + UserName = userName; + } + + public void SetManualId(long id) + { + Id = id; + var createdEvent = this.Events.OfType().FirstOrDefault(); + if (createdEvent != null) + { + Type type = createdEvent.GetType(); + PropertyInfo? prop = type.BaseType?.GetProperty(nameof(createdEvent.AggregateId)); + prop?.SetValue(createdEvent, id); + } + } + + public bool Equals(long other) + { + return Id == other; + } +} \ No newline at end of file diff --git a/src/IdentityPlus/Domain/Users/UserClaim.IdentitySource.cs b/src/IdentityPlus/Domain/Users/UserClaim.IdentitySource.cs new file mode 100644 index 0000000..99e568f --- /dev/null +++ b/src/IdentityPlus/Domain/Users/UserClaim.IdentitySource.cs @@ -0,0 +1,37 @@ +using System.Security.Claims; + +namespace Honamic.IdentityPlus.Domain.Users; + +public partial class UserClaim +{ + public virtual long UserId { get; set; } = default!; + + /// + /// Gets or sets the claim type for this claim. + /// + public virtual string? ClaimType { get; set; } + + /// + /// Gets or sets the claim value for this claim. + /// + public virtual string? ClaimValue { get; set; } + + /// + /// Converts the entity into a Claim instance. + /// + /// + public virtual Claim ToClaim() + { + return new Claim(ClaimType!, ClaimValue!); + } + + /// + /// Reads the type and value from the Claim. + /// + /// + public virtual void InitializeFromClaim(Claim claim) + { + ClaimType = claim.Type; + ClaimValue = claim.Value; + } +} \ No newline at end of file diff --git a/src/IdentityPlus/Domain/Users/UserClaim.cs b/src/IdentityPlus/Domain/Users/UserClaim.cs new file mode 100644 index 0000000..93884ec --- /dev/null +++ b/src/IdentityPlus/Domain/Users/UserClaim.cs @@ -0,0 +1,8 @@ +using Honamic.Framework.Domain; + +namespace Honamic.IdentityPlus.Domain.Users; + +public partial class UserClaim : Entity +{ + +} \ No newline at end of file diff --git a/src/IdentityPlus/Domain/Users/UserLogin.IdentitySource.cs b/src/IdentityPlus/Domain/Users/UserLogin.IdentitySource.cs new file mode 100644 index 0000000..5af83e4 --- /dev/null +++ b/src/IdentityPlus/Domain/Users/UserLogin.IdentitySource.cs @@ -0,0 +1,25 @@ + +namespace Honamic.IdentityPlus.Domain.Users; + +public partial class UserLogin +{ + /// + /// Gets or sets the login provider for the login (e.g. facebook, google) + /// + public virtual string LoginProvider { get; set; } = default!; + + /// + /// Gets or sets the unique provider identifier for this login. + /// + public virtual string ProviderKey { get; set; } = default!; + + /// + /// Gets or sets the friendly name used in a UI for this login. + /// + public virtual string? ProviderDisplayName { get; set; } + + /// + /// Gets or sets the primary key of the user associated with this login. + /// + public virtual long UserId { get; set; } = default!; +} \ No newline at end of file diff --git a/src/IdentityPlus/Domain/Users/UserLogin.cs b/src/IdentityPlus/Domain/Users/UserLogin.cs new file mode 100644 index 0000000..13dd82f --- /dev/null +++ b/src/IdentityPlus/Domain/Users/UserLogin.cs @@ -0,0 +1,11 @@ +using Honamic.Framework.Domain; + +namespace Honamic.IdentityPlus.Domain.Users; + +public partial class UserLogin : Entity, IEquatable +{ + public bool Equals(long other) + { + return Id == other; + } +} \ No newline at end of file diff --git a/src/IdentityPlus/Domain/Users/UserRole.cs b/src/IdentityPlus/Domain/Users/UserRole.cs new file mode 100644 index 0000000..62f66a6 --- /dev/null +++ b/src/IdentityPlus/Domain/Users/UserRole.cs @@ -0,0 +1,16 @@ +using Honamic.Framework.Domain; + +namespace Honamic.IdentityPlus.Domain.Users; + +public partial class UserRole : Entity +{ + +} + +// IdentitySource +public partial class UserRole +{ + public virtual long UserId { get; set; } = default!; + + public virtual long RoleId { get; set; } = default!; +} \ No newline at end of file diff --git a/src/IdentityPlus/Domain/Users/UserToken.IdentitySource.cs b/src/IdentityPlus/Domain/Users/UserToken.IdentitySource.cs new file mode 100644 index 0000000..e0f91c8 --- /dev/null +++ b/src/IdentityPlus/Domain/Users/UserToken.IdentitySource.cs @@ -0,0 +1,26 @@ + +namespace Honamic.IdentityPlus.Domain.Users; + +public partial class UserToken +{ + /// + /// Gets or sets the primary key of the user that the token belongs to. + /// + public virtual long UserId { get; set; } = default!; + + /// + /// Gets or sets the LoginProvider this token is from. + /// + public virtual string LoginProvider { get; set; } = default!; + + /// + /// Gets or sets the name of the token. + /// + public virtual string Name { get; set; } = default!; + + /// + /// Gets or sets the token value. + /// + [IdentityPlusProtectedPersonalData] + public virtual string? Value { get; set; } +} diff --git a/src/IdentityPlus/Domain/Users/UserToken.cs b/src/IdentityPlus/Domain/Users/UserToken.cs new file mode 100644 index 0000000..3d6c793 --- /dev/null +++ b/src/IdentityPlus/Domain/Users/UserToken.cs @@ -0,0 +1,8 @@ +using Honamic.Framework.Domain; + +namespace Honamic.IdentityPlus.Domain.Users; + +public partial class UserToken :Entity +{ + +} \ No newline at end of file diff --git a/src/IdentityPlus/Persistence/Extensions/AddIdentityPlusPersistence.cs b/src/IdentityPlus/Persistence/Extensions/AddIdentityPlusPersistence.cs new file mode 100644 index 0000000..3a23823 --- /dev/null +++ b/src/IdentityPlus/Persistence/Extensions/AddIdentityPlusPersistence.cs @@ -0,0 +1,22 @@ +using Honamic.IdentityPlus.Domain.Roles; +using Honamic.IdentityPlus.Domain.Users; +using Honamic.IdentityPlus.Persistence.Roles; +using Honamic.IdentityPlus.Persistence.Users; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Honamic.IdentityPlus.Persistence.Extensions; + +public static class SeviceCollectionExtensions +{ + + public static IServiceCollection AddIdentityPlusPersistence(this IServiceCollection services) + { + services.TryAddScoped,UserRepository>(); + services.TryAddScoped,RoleRepository>(); + + return services; + } + +} \ No newline at end of file diff --git a/src/IdentityPlus/Persistence/Extensions/EFServiceCollectionExtensions.cs b/src/IdentityPlus/Persistence/Extensions/EFServiceCollectionExtensions.cs new file mode 100644 index 0000000..c473d24 --- /dev/null +++ b/src/IdentityPlus/Persistence/Extensions/EFServiceCollectionExtensions.cs @@ -0,0 +1,83 @@ +using Honamic.IdentityPlus.Domain; +using Honamic.IdentityPlus.Domain.Roles; +using Honamic.IdentityPlus.Domain.Users; +using Honamic.IdentityPlus.Persistence.Users; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace Honamic.IdentityPlus.Persistence.Extensions; + +public static class EFServiceCollectionExtensions +{ + public static void AddIdentityPlusModel(this ModelBuilder modelBuilder + , string? schema = null) + { + if (modelBuilder == null) + { + throw new ArgumentNullException(nameof(modelBuilder)); + } + + //IOptions.Stores?.MaxLengthForKeys + // if (maxKeyLength == 0) {maxKeyLength = 128;} + var maxLengthForKeys = 128; + var encryptPersonalData = false; + PersonalDataConverter? _personalDataConverter = null; + + modelBuilder.ApplyConfiguration(new UserConfigurations("Users", schema, encryptPersonalData, _personalDataConverter)); + + modelBuilder.Entity(b => + { + b.HasKey(uc => uc.Id); + b.ToTable("UserClaims", schema); + }); + + modelBuilder.Entity(b => + { + b.HasKey(l => new { l.LoginProvider, l.ProviderKey }); + b.Property(l => l.LoginProvider).HasMaxLength(maxLengthForKeys); + b.Property(l => l.ProviderKey).HasMaxLength(maxLengthForKeys); + b.ToTable("UserLogins"); + }); + + modelBuilder.Entity(b => + { + b.HasKey(t => new { t.UserId, t.LoginProvider, t.Name }); + b.Property(t => t.LoginProvider).HasMaxLength(maxLengthForKeys); + b.Property(t => t.Name).HasMaxLength(maxLengthForKeys); + + if (encryptPersonalData) + { + var tokenProps = typeof(UserToken).GetProperties().Where( + prop => Attribute.IsDefined(prop, typeof(ProtectedPersonalDataAttribute)) || + Attribute.IsDefined(prop, typeof(IdentityPlusProtectedPersonalDataAttribute)) + ); + foreach (var p in tokenProps) + { + if (p.PropertyType != typeof(string)) + { + throw new InvalidOperationException("[ProtectedPersonalData] only works strings by default."); + } + b.Property(typeof(string), p.Name) + .HasConversion(_personalDataConverter); + } + } + + b.ToTable("UserTokens"); + }); + + + modelBuilder.ApplyConfiguration(new RoleEntityConfigurations("Roles", schema)); + + modelBuilder.Entity(b => + { + b.HasKey(rc => rc.Id); + b.ToTable("RoleClaims"); + }); + + modelBuilder.Entity(b => + { + b.HasKey(r => new { r.UserId, r.RoleId }); + b.ToTable("UserRoles"); + }); + } +} diff --git a/src/IdentityPlus/Persistence/Honamic.IdentityPlus.Persistence.csproj b/src/IdentityPlus/Persistence/Honamic.IdentityPlus.Persistence.csproj new file mode 100644 index 0000000..5b355a0 --- /dev/null +++ b/src/IdentityPlus/Persistence/Honamic.IdentityPlus.Persistence.csproj @@ -0,0 +1,34 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + True + True + Resources.resx + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + diff --git a/src/IdentityPlus/Persistence/IdentityPlusContext.cs b/src/IdentityPlus/Persistence/IdentityPlusContext.cs new file mode 100644 index 0000000..542fa06 --- /dev/null +++ b/src/IdentityPlus/Persistence/IdentityPlusContext.cs @@ -0,0 +1,34 @@ +using Honamic.IdentityPlus.Domain.Users; +using Honamic.IdentityPlus.Persistence.Extensions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore; + +public abstract partial class IdentityPlusContext : DbContext +{ + public IdentityPlusContext(DbContextOptions options) : base(options) { } + + protected IdentityPlusContext() { } + + + public virtual DbSet Users { get; set; } = default!; + public virtual DbSet UserClaims { get; set; } = default!; + public virtual DbSet UserLogins { get; set; } = default!; + public virtual DbSet UserTokens { get; set; } = default!; + + + private StoreOptions? GetStoreOptions() => this.GetService() + .Extensions.OfType() + .FirstOrDefault()?.ApplicationServiceProvider + ?.GetService>() + ?.Value?.Stores; + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.AddIdentityPlusModel(); + + } +} \ No newline at end of file diff --git a/src/IdentityPlus/Persistence/IdentitySource/Resources.Designer.cs b/src/IdentityPlus/Persistence/IdentitySource/Resources.Designer.cs new file mode 100644 index 0000000..3da1ed7 --- /dev/null +++ b/src/IdentityPlus/Persistence/IdentitySource/Resources.Designer.cs @@ -0,0 +1,108 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Honamic.IdentityPlus.Persistence.IdentitySource { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Honamic.IdentityPlus.Persistence.IdentitySource.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to [ProtectedPersonalData] only works strings by default.. + /// + internal static string CanOnlyProtectStrings { + get { + return ResourceManager.GetString("CanOnlyProtectStrings", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to AddEntityFrameworkStores can only be called with a role that derives from IdentityRole<TKey>.. + /// + internal static string NotIdentityRole { + get { + return ResourceManager.GetString("NotIdentityRole", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to AddEntityFrameworkStores can only be called with a user that derives from IdentityUser<TKey>.. + /// + internal static string NotIdentityUser { + get { + return ResourceManager.GetString("NotIdentityUser", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Role {0} does not exist.. + /// + internal static string RoleNotFound { + get { + return ResourceManager.GetString("RoleNotFound", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Value cannot be null or empty.. + /// + internal static string ValueCannotBeNullOrEmpty { + get { + return ResourceManager.GetString("ValueCannotBeNullOrEmpty", resourceCulture); + } + } + } +} diff --git a/src/IdentityPlus/Persistence/IdentitySource/Resources.resx b/src/IdentityPlus/Persistence/IdentitySource/Resources.resx new file mode 100644 index 0000000..1f85b13 --- /dev/null +++ b/src/IdentityPlus/Persistence/IdentitySource/Resources.resx @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + [ProtectedPersonalData] only works strings by default. + error when attribute is used on a non string property. + + + AddEntityFrameworkStores can only be called with a role that derives from IdentityRole<TKey>. + error when the role does not derive from IdentityRole + + + AddEntityFrameworkStores can only be called with a user that derives from IdentityUser<TKey>. + error when the user does not derive from IdentityUser + + + Role {0} does not exist. + error when a role does not exist + + + Value cannot be null or empty. + error when something cannot be null or empty + + \ No newline at end of file diff --git a/src/IdentityPlus/Persistence/IdentitySource/RoleStore.cs b/src/IdentityPlus/Persistence/IdentitySource/RoleStore.cs new file mode 100644 index 0000000..a6ac533 --- /dev/null +++ b/src/IdentityPlus/Persistence/IdentitySource/RoleStore.cs @@ -0,0 +1,341 @@ +using System.ComponentModel; +using System.Security.Claims; +using Honamic.IdentityPlus.Domain.Roles; +using Honamic.IdentityPlus.Domain.Users; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace Honamic.IdentityPlus.Persistence.IdentitySource; + +/// +/// Creates a new instance of a persistence store for roles. +/// +/// The type of the class representing a role. +/// The type of the data context class used to access the store. +/// The type of the primary key for a role. +/// The type of the class representing a user role. +/// The type of the class representing a role claim. +public class RoleStore : + IQueryableRoleStore, + IRoleClaimStore + where TRole : Role + where TKey : IEquatable + where TContext : DbContext + where TUserRole : UserRole, new() + where TRoleClaim : RoleClaim, new() +{ + /// + /// Constructs a new instance of . + /// + /// The . + /// The . + public RoleStore(TContext context, IdentityErrorDescriber? describer = null) + { + ArgumentNullException.ThrowIfNull(context); + Context = context; + ErrorDescriber = describer ?? new IdentityErrorDescriber(); + } + + private bool _disposed; + + /// + /// Gets the database context for this store. + /// + public virtual TContext Context { get; private set; } + + /// + /// Gets or sets the for any error that occurred with the current operation. + /// + public IdentityErrorDescriber ErrorDescriber { get; set; } + + /// + /// Gets or sets a flag indicating if changes should be persisted after CreateAsync, UpdateAsync and DeleteAsync are called. + /// + /// + /// True if changes should be automatically persisted, otherwise false. + /// + public bool AutoSaveChanges { get; set; } = true; + + /// Saves the current store. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + protected virtual async Task SaveChanges(CancellationToken cancellationToken) + { + if (AutoSaveChanges) + { + await Context.SaveChangesAsync(cancellationToken); + } + } + + /// + /// Creates a new role in a store as an asynchronous operation. + /// + /// The role to create in the store. + /// The used to propagate notifications that the operation should be canceled. + /// A that represents the of the asynchronous query. + public virtual async Task CreateAsync(TRole role, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(role); + Context.Add(role); + await SaveChanges(cancellationToken); + return IdentityResult.Success; + } + + /// + /// Updates a role in a store as an asynchronous operation. + /// + /// The role to update in the store. + /// The used to propagate notifications that the operation should be canceled. + /// A that represents the of the asynchronous query. + public virtual async Task UpdateAsync(TRole role, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(role); + Context.Attach(role); + role.ConcurrencyStamp = Guid.NewGuid().ToString(); + Context.Update(role); + try + { + await SaveChanges(cancellationToken); + } + catch (DbUpdateConcurrencyException) + { + return IdentityResult.Failed(ErrorDescriber.ConcurrencyFailure()); + } + return IdentityResult.Success; + } + + /// + /// Deletes a role from the store as an asynchronous operation. + /// + /// The role to delete from the store. + /// The used to propagate notifications that the operation should be canceled. + /// A that represents the of the asynchronous query. + public virtual async Task DeleteAsync(TRole role, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(role); + Context.Remove(role); + try + { + await SaveChanges(cancellationToken); + } + catch (DbUpdateConcurrencyException) + { + return IdentityResult.Failed(ErrorDescriber.ConcurrencyFailure()); + } + return IdentityResult.Success; + } + + /// + /// Gets the ID for a role from the store as an asynchronous operation. + /// + /// The role whose ID should be returned. + /// The used to propagate notifications that the operation should be canceled. + /// A that contains the ID of the role. + public virtual Task GetRoleIdAsync(TRole role, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(role); + return Task.FromResult(role.Id.ToString()); + } + + /// + /// Gets the name of a role from the store as an asynchronous operation. + /// + /// The role whose name should be returned. + /// The used to propagate notifications that the operation should be canceled. + /// A that contains the name of the role. + public virtual Task GetRoleNameAsync(TRole role, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(role); + return Task.FromResult(role.Name); + } + + /// + /// Sets the name of a role in the store as an asynchronous operation. + /// + /// The role whose name should be set. + /// The name of the role. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public virtual Task SetRoleNameAsync(TRole role, string? roleName, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(role); + role.Name = roleName; + return Task.CompletedTask; + } + + /// + /// Converts the provided to a strongly typed key object. + /// + /// The id to convert. + /// An instance of representing the provided . + public virtual TKey? ConvertIdFromString(string id) + { + if (id == null) + { + return default; + } + return (TKey?)TypeDescriptor.GetConverter(typeof(TKey)).ConvertFromInvariantString(id); + } + + /// + /// Converts the provided to its string representation. + /// + /// The id to convert. + /// An representation of the provided . + public virtual string? ConvertIdToString(TKey id) + { + if (id.Equals(default)) + { + return null; + } + return id.ToString(); + } + + /// + /// Finds the role who has the specified ID as an asynchronous operation. + /// + /// The role ID to look for. + /// The used to propagate notifications that the operation should be canceled. + /// A that result of the look up. + public virtual Task FindByIdAsync(string id, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + var roleId = ConvertIdFromString(id); + return Roles.FirstOrDefaultAsync(u => u.Id.Equals(roleId), cancellationToken); + } + + /// + /// Finds the role who has the specified normalized name as an asynchronous operation. + /// + /// The normalized role name to look for. + /// The used to propagate notifications that the operation should be canceled. + /// A that result of the look up. + public virtual Task FindByNameAsync(string normalizedName, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + return Roles.FirstOrDefaultAsync(r => r.NormalizedName == normalizedName, cancellationToken); + } + + /// + /// Get a role's normalized name as an asynchronous operation. + /// + /// The role whose normalized name should be retrieved. + /// The used to propagate notifications that the operation should be canceled. + /// A that contains the name of the role. + public virtual Task GetNormalizedRoleNameAsync(TRole role, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(role); + return Task.FromResult(role.NormalizedName); + } + + /// + /// Set a role's normalized name as an asynchronous operation. + /// + /// The role whose normalized name should be set. + /// The normalized name to set + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public virtual Task SetNormalizedRoleNameAsync(TRole role, string? normalizedName, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(role); + role.NormalizedName = normalizedName; + return Task.CompletedTask; + } + + /// + /// Throws if this class has been disposed. + /// + protected void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + + /// + /// Dispose the stores + /// + public void Dispose() => _disposed = true; + + /// + /// Get the claims associated with the specified as an asynchronous operation. + /// + /// The role whose claims should be retrieved. + /// The used to propagate notifications that the operation should be canceled. + /// A that contains the claims granted to a role. + public virtual async Task> GetClaimsAsync(TRole role, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(role); + + return await RoleClaims.Where(rc => rc.RoleId.Equals(role.Id)).Select(c => new Claim(c.ClaimType!, c.ClaimValue!)).ToListAsync(cancellationToken); + } + + /// + /// Adds the given to the specified . + /// + /// The role to add the claim to. + /// The claim to add to the role. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public virtual Task AddClaimAsync(TRole role, Claim claim, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(role); + ArgumentNullException.ThrowIfNull(claim); + + RoleClaims.Add(CreateRoleClaim(role, claim)); + return Task.FromResult(false); + } + + /// + /// Removes the given from the specified . + /// + /// The role to remove the claim from. + /// The claim to remove from the role. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public virtual async Task RemoveClaimAsync(TRole role, Claim claim, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(role); + ArgumentNullException.ThrowIfNull(claim); + var claims = await RoleClaims.Where(rc => rc.RoleId.Equals(role.Id) && rc.ClaimValue == claim.Value && rc.ClaimType == claim.Type).ToListAsync(cancellationToken); + foreach (var c in claims) + { + RoleClaims.Remove(c); + } + } + + /// + /// A navigation property for the roles the store contains. + /// + public virtual IQueryable Roles => Context.Set(); + + private DbSet RoleClaims { get { return Context.Set(); } } + + /// + /// Creates an entity representing a role claim. + /// + /// The associated role. + /// The associated claim. + /// The role claim entity. + protected virtual TRoleClaim CreateRoleClaim(TRole role, Claim claim) + => new TRoleClaim { RoleId = role.Id, ClaimType = claim.Type, ClaimValue = claim.Value }; +} \ No newline at end of file diff --git a/src/IdentityPlus/Persistence/IdentitySource/UserStore.cs b/src/IdentityPlus/Persistence/IdentitySource/UserStore.cs new file mode 100644 index 0000000..b2ee9df --- /dev/null +++ b/src/IdentityPlus/Persistence/IdentitySource/UserStore.cs @@ -0,0 +1,589 @@ +using System.Globalization; +using System.Security.Claims; +using Honamic.IdentityPlus.Domain.Roles; +using Honamic.IdentityPlus.Domain.Users; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace Honamic.IdentityPlus.Persistence.IdentitySource; + +/// +/// Represents a new instance of a persistence store for the specified user and role types. +/// +/// The type representing a user. +/// The type representing a role. +/// The type of the data context class used to access the store. +/// The type representing a claim. +/// The type representing a user role. +/// The type representing a user external login. +/// The type representing a user token. +/// The type representing a role claim. +public class UserStore : + UserStoreBase, + IProtectedUserStore + where TUser : User + where TRole : Role + where TContext : DbContext + where TUserClaim : UserClaim, new() + where TUserRole : UserRole, new() + where TUserLogin : UserLogin, new() + where TUserToken : UserToken, new() + where TRoleClaim : RoleClaim, new() +{ + /// + /// Creates a new instance of the store. + /// + /// The context used to access the store. + /// The used to describe store errors. + public UserStore(TContext context, IdentityErrorDescriber? describer = null) : base(describer ?? new IdentityErrorDescriber()) + { + ArgumentNullException.ThrowIfNull(context); + Context = context; + } + + /// + /// Gets the database context for this store. + /// + public virtual TContext Context { get; private set; } + + private DbSet UsersSet { get { return Context.Set(); } } + private DbSet Roles { get { return Context.Set(); } } + private DbSet UserClaims { get { return Context.Set(); } } + private DbSet UserRoles { get { return Context.Set(); } } + private DbSet UserLogins { get { return Context.Set(); } } + private DbSet UserTokens { get { return Context.Set(); } } + + /// + /// Gets or sets a flag indicating if changes should be persisted after CreateAsync, UpdateAsync and DeleteAsync are called. + /// + /// + /// True if changes should be automatically persisted, otherwise false. + /// + public bool AutoSaveChanges { get; set; } = true; + + /// Saves the current store. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + protected Task SaveChanges(CancellationToken cancellationToken) + { + return AutoSaveChanges ? Context.SaveChangesAsync(cancellationToken) : Task.CompletedTask; + } + + /// + /// Creates the specified in the user store. + /// + /// The user to create. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing the of the creation operation. + public override async Task CreateAsync(TUser user, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + Context.Add(user); + await SaveChanges(cancellationToken); + return IdentityResult.Success; + } + + /// + /// Updates the specified in the user store. + /// + /// The user to update. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing the of the update operation. + public override async Task UpdateAsync(TUser user, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + Context.Attach(user); + user.ConcurrencyStamp = Guid.NewGuid().ToString(); + Context.Update(user); + try + { + await SaveChanges(cancellationToken); + } + catch (DbUpdateConcurrencyException) + { + return IdentityResult.Failed(ErrorDescriber.ConcurrencyFailure()); + } + return IdentityResult.Success; + } + + /// + /// Deletes the specified from the user store. + /// + /// The user to delete. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing the of the update operation. + public override async Task DeleteAsync(TUser user, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + Context.Remove(user); + try + { + await SaveChanges(cancellationToken); + } + catch (DbUpdateConcurrencyException) + { + return IdentityResult.Failed(ErrorDescriber.ConcurrencyFailure()); + } + return IdentityResult.Success; + } + + /// + /// Finds and returns a user, if any, who has the specified . + /// + /// The user ID to search for. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The that represents the asynchronous operation, containing the user matching the specified if it exists. + /// + public override Task FindByIdAsync(string userId, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + var id = ConvertIdFromString(userId); + return UsersSet.FindAsync(new object?[] { id }, cancellationToken).AsTask(); + } + + /// + /// Finds and returns a user, if any, who has the specified normalized user name. + /// + /// The normalized user name to search for. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The that represents the asynchronous operation, containing the user matching the specified if it exists. + /// + public override Task FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + return Users.FirstOrDefaultAsync(u => u.NormalizedUserName == normalizedUserName, cancellationToken); + } + + /// + /// A navigation property for the users the store contains. + /// + public override IQueryable Users + { + get { return UsersSet; } + } + + /// + /// Return a role with the normalized name if it exists. + /// + /// The normalized role name. + /// The used to propagate notifications that the operation should be canceled. + /// The role if it exists. + protected override Task FindRoleAsync(string normalizedRoleName, CancellationToken cancellationToken) + { + return Roles.SingleOrDefaultAsync(r => r.NormalizedName == normalizedRoleName, cancellationToken); + } + + /// + /// Return a user role for the userId and roleId if it exists. + /// + /// The user's id. + /// The role's id. + /// The used to propagate notifications that the operation should be canceled. + /// The user role if it exists. + protected override Task FindUserRoleAsync(long userId, long roleId, CancellationToken cancellationToken) + { + return UserRoles.FindAsync(new object[] { userId, roleId }, cancellationToken).AsTask(); + } + + /// + /// Return a user with the matching userId if it exists. + /// + /// The user's id. + /// The used to propagate notifications that the operation should be canceled. + /// The user if it exists. + protected override Task FindUserAsync(long userId, CancellationToken cancellationToken) + { + return Users.SingleOrDefaultAsync(u => u.Id.Equals(userId), cancellationToken); + } + + /// + /// Return a user login with the matching userId, provider, providerKey if it exists. + /// + /// The user's id. + /// The login provider name. + /// The key provided by the to identify a user. + /// The used to propagate notifications that the operation should be canceled. + /// The user login if it exists. + protected override Task FindUserLoginAsync(long userId, string loginProvider, string providerKey, CancellationToken cancellationToken) + { + return UserLogins.SingleOrDefaultAsync(userLogin => userLogin.UserId.Equals(userId) && userLogin.LoginProvider == loginProvider && userLogin.ProviderKey == providerKey, cancellationToken); + } + + /// + /// Return a user login with provider, providerKey if it exists. + /// + /// The login provider name. + /// The key provided by the to identify a user. + /// The used to propagate notifications that the operation should be canceled. + /// The user login if it exists. + protected override Task FindUserLoginAsync(string loginProvider, string providerKey, CancellationToken cancellationToken) + { + return UserLogins.SingleOrDefaultAsync(userLogin => userLogin.LoginProvider == loginProvider && userLogin.ProviderKey == providerKey, cancellationToken); + } + + /// + /// Adds the given to the specified . + /// + /// The user to add the role to. + /// The role to add. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public override async Task AddToRoleAsync(TUser user, string normalizedRoleName, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + if (string.IsNullOrWhiteSpace(normalizedRoleName)) + { + throw new ArgumentException(Resources.ValueCannotBeNullOrEmpty, nameof(normalizedRoleName)); + } + var roleEntity = await FindRoleAsync(normalizedRoleName, cancellationToken); + if (roleEntity == null) + { + throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture, Resources.RoleNotFound, normalizedRoleName)); + } + UserRoles.Add(CreateUserRole(user, roleEntity)); + } + + /// + /// Removes the given from the specified . + /// + /// The user to remove the role from. + /// The role to remove. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public override async Task RemoveFromRoleAsync(TUser user, string normalizedRoleName, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + if (string.IsNullOrWhiteSpace(normalizedRoleName)) + { + throw new ArgumentException(Resources.ValueCannotBeNullOrEmpty, nameof(normalizedRoleName)); + } + var roleEntity = await FindRoleAsync(normalizedRoleName, cancellationToken); + if (roleEntity != null) + { + var userRole = await FindUserRoleAsync(user.Id, roleEntity.Id, cancellationToken); + if (userRole != null) + { + UserRoles.Remove(userRole); + } + } + } + + /// + /// Retrieves the roles the specified is a member of. + /// + /// The user whose roles should be retrieved. + /// The used to propagate notifications that the operation should be canceled. + /// A that contains the roles the user is a member of. + public override async Task> GetRolesAsync(TUser user, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + var userId = user.Id; + var query = from userRole in UserRoles + join role in Roles on userRole.RoleId equals role.Id + where userRole.UserId.Equals(userId) + select role.Name; + return await query.ToListAsync(cancellationToken); + } + + /// + /// Returns a flag indicating if the specified user is a member of the give . + /// + /// The user whose role membership should be checked. + /// The role to check membership of + /// The used to propagate notifications that the operation should be canceled. + /// A containing a flag indicating if the specified user is a member of the given group. If the + /// user is a member of the group the returned value with be true, otherwise it will be false. + public override async Task IsInRoleAsync(TUser user, string normalizedRoleName, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + if (string.IsNullOrWhiteSpace(normalizedRoleName)) + { + throw new ArgumentException(Resources.ValueCannotBeNullOrEmpty, nameof(normalizedRoleName)); + } + var role = await FindRoleAsync(normalizedRoleName, cancellationToken); + if (role != null) + { + var userRole = await FindUserRoleAsync(user.Id, role.Id, cancellationToken); + return userRole != null; + } + return false; + } + + /// + /// Get the claims associated with the specified as an asynchronous operation. + /// + /// The user whose claims should be retrieved. + /// The used to propagate notifications that the operation should be canceled. + /// A that contains the claims granted to a user. + public override async Task> GetClaimsAsync(TUser user, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + + return await UserClaims.Where(uc => uc.UserId.Equals(user.Id)).Select(c => c.ToClaim()).ToListAsync(cancellationToken); + } + + /// + /// Adds the given to the specified . + /// + /// The user to add the claim to. + /// The claim to add to the user. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public override Task AddClaimsAsync(TUser user, IEnumerable claims, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(claims); + foreach (var claim in claims) + { + UserClaims.Add(CreateUserClaim(user, claim)); + } + return Task.FromResult(false); + } + + /// + /// Replaces the on the specified , with the . + /// + /// The user to replace the claim on. + /// The claim replace. + /// The new claim replacing the . + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public override async Task ReplaceClaimAsync(TUser user, Claim claim, Claim newClaim, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(claim); + ArgumentNullException.ThrowIfNull(newClaim); + + var matchedClaims = await UserClaims.Where(uc => uc.UserId.Equals(user.Id) && uc.ClaimValue == claim.Value && uc.ClaimType == claim.Type).ToListAsync(cancellationToken); + foreach (var matchedClaim in matchedClaims) + { + matchedClaim.ClaimValue = newClaim.Value; + matchedClaim.ClaimType = newClaim.Type; + } + } + + /// + /// Removes the given from the specified . + /// + /// The user to remove the claims from. + /// The claim to remove. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public override async Task RemoveClaimsAsync(TUser user, IEnumerable claims, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(claims); + foreach (var claim in claims) + { + var matchedClaims = await UserClaims.Where(uc => uc.UserId.Equals(user.Id) && uc.ClaimValue == claim.Value && uc.ClaimType == claim.Type).ToListAsync(cancellationToken); + foreach (var c in matchedClaims) + { + UserClaims.Remove(c); + } + } + } + + /// + /// Adds the given to the specified . + /// + /// The user to add the login to. + /// The login to add to the user. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public override Task AddLoginAsync(TUser user, UserLoginInfo login, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(login); + UserLogins.Add(CreateUserLogin(user, login)); + return Task.FromResult(false); + } + + /// + /// Removes the given from the specified . + /// + /// The user to remove the login from. + /// The login to remove from the user. + /// The key provided by the to identify a user. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public override async Task RemoveLoginAsync(TUser user, string loginProvider, string providerKey, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + var entry = await FindUserLoginAsync(user.Id, loginProvider, providerKey, cancellationToken); + if (entry != null) + { + UserLogins.Remove(entry); + } + } + + /// + /// Retrieves the associated logins for the specified . + /// + /// The user whose associated logins to retrieve. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The for the asynchronous operation, containing a list of for the specified , if any. + /// + public override async Task> GetLoginsAsync(TUser user, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + var userId = user.Id; + return await UserLogins.Where(l => l.UserId.Equals(userId)) + .Select(l => new UserLoginInfo(l.LoginProvider, l.ProviderKey, l.ProviderDisplayName)).ToListAsync(cancellationToken); + } + + /// + /// Retrieves the user associated with the specified login provider and login provider key. + /// + /// The login provider who provided the . + /// The key provided by the to identify a user. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The for the asynchronous operation, containing the user, if any which matched the specified login provider and key. + /// + public override async Task FindByLoginAsync(string loginProvider, string providerKey, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + var userLogin = await FindUserLoginAsync(loginProvider, providerKey, cancellationToken); + if (userLogin != null) + { + return await FindUserAsync(userLogin.UserId, cancellationToken); + } + return null; + } + + /// + /// Gets the user, if any, associated with the specified, normalized email address. + /// + /// The normalized email address to return the user for. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The task object containing the results of the asynchronous lookup operation, the user if any associated with the specified normalized email address. + /// + public override Task FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + return Users.SingleOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, cancellationToken); + } + + /// + /// Retrieves all users with the specified claim. + /// + /// The claim whose users should be retrieved. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The contains a list of users, if any, that contain the specified claim. + /// + public override async Task> GetUsersForClaimAsync(Claim claim, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(claim); + + var query = from userclaims in UserClaims + join user in Users on userclaims.UserId equals user.Id + where userclaims.ClaimValue == claim.Value + && userclaims.ClaimType == claim.Type + select user; + + return await query.ToListAsync(cancellationToken); + } + + /// + /// Retrieves all users in the specified role. + /// + /// The role whose users should be retrieved. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The contains a list of users, if any, that are in the specified role. + /// + public override async Task> GetUsersInRoleAsync(string normalizedRoleName, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentException.ThrowIfNullOrEmpty(normalizedRoleName); + + var role = await FindRoleAsync(normalizedRoleName, cancellationToken); + + if (role != null) + { + var query = from userrole in UserRoles + join user in Users on userrole.UserId equals user.Id + where userrole.RoleId.Equals(role.Id) + select user; + + return await query.ToListAsync(cancellationToken); + } + return new List(); + } + + /// + /// Find a user token if it exists. + /// + /// The token owner. + /// The login provider for the token. + /// The name of the token. + /// The used to propagate notifications that the operation should be canceled. + /// The user token if it exists. + protected override Task FindTokenAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken) + => UserTokens.FindAsync(new object[] { user.Id, loginProvider, name }, cancellationToken).AsTask(); + + /// + /// Add a new user token. + /// + /// The token to be added. + /// + protected override Task AddUserTokenAsync(TUserToken token) + { + UserTokens.Add(token); + return Task.CompletedTask; + } + + /// + /// Remove a new user token. + /// + /// The token to be removed. + /// + protected override Task RemoveUserTokenAsync(TUserToken token) + { + UserTokens.Remove(token); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/IdentityPlus/Persistence/IdentitySource/UserStoreBase.cs b/src/IdentityPlus/Persistence/IdentitySource/UserStoreBase.cs new file mode 100644 index 0000000..05fc32c --- /dev/null +++ b/src/IdentityPlus/Persistence/IdentitySource/UserStoreBase.cs @@ -0,0 +1,1089 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using Honamic.IdentityPlus.Domain.Roles; +using Honamic.IdentityPlus.Domain.Users; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Shared; + +namespace Honamic.IdentityPlus.Persistence.IdentitySource; + +/// +/// Represents a new instance of a persistence store for the specified user type. +/// +/// The type representing a user. +/// The type representing a claim. +/// The type representing a user external login. +/// The type representing a user token. +public abstract class UserStoreBase : + IUserLoginStore, + IUserClaimStore, + IUserPasswordStore, + IUserSecurityStampStore, + IUserEmailStore, + IUserLockoutStore, + IUserPhoneNumberStore, + IQueryableUserStore, + IUserTwoFactorStore, + IUserAuthenticationTokenStore, + IUserAuthenticatorKeyStore, + IUserTwoFactorRecoveryCodeStore + where TUser : User + where TUserClaim : UserClaim, new() + where TUserLogin : UserLogin, new() + where TUserToken : UserToken, new() +{ + /// + /// Creates a new instance. + /// + /// The used to describe store errors. + public UserStoreBase(IdentityErrorDescriber describer) + { + ArgumentNullException.ThrowIfNull(describer); + + ErrorDescriber = describer; + } + + private bool _disposed; + + /// + /// Gets or sets the for any error that occurred with the current operation. + /// + public IdentityErrorDescriber ErrorDescriber { get; set; } + + /// + /// Called to create a new instance of a . + /// + /// The associated user. + /// The associated claim. + /// + protected virtual TUserClaim CreateUserClaim(TUser user, Claim claim) + { + var userClaim = new TUserClaim { UserId = user.Id }; + userClaim.InitializeFromClaim(claim); + return userClaim; + } + + /// + /// Called to create a new instance of a . + /// + /// The associated user. + /// The associated login. + /// + protected virtual TUserLogin CreateUserLogin(TUser user, UserLoginInfo login) + { + return new TUserLogin + { + UserId = user.Id, + ProviderKey = login.ProviderKey, + LoginProvider = login.LoginProvider, + ProviderDisplayName = login.ProviderDisplayName + }; + } + + /// + /// Called to create a new instance of a . + /// + /// The associated user. + /// The associated login provider. + /// The name of the user token. + /// The value of the user token. + /// + protected virtual TUserToken CreateUserToken(TUser user, string loginProvider, string name, string? value) + { + return new TUserToken + { + UserId = user.Id, + LoginProvider = loginProvider, + Name = name, + Value = value + }; + } + + /// + /// Gets the user identifier for the specified . + /// + /// The user whose identifier should be retrieved. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing the identifier for the specified . + public virtual Task GetUserIdAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + return Task.FromResult(user.Id.ToString()); + } + + /// + /// Gets the user name for the specified . + /// + /// The user whose name should be retrieved. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing the name for the specified . + public virtual Task GetUserNameAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + return Task.FromResult(user.UserName); + } + + /// + /// Sets the given for the specified . + /// + /// The user whose name should be set. + /// The user name to set. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public virtual Task SetUserNameAsync(TUser user, string? userName, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + user.UserName = userName; + return Task.CompletedTask; + } + + /// + /// Gets the normalized user name for the specified . + /// + /// The user whose normalized name should be retrieved. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing the normalized user name for the specified . + public virtual Task GetNormalizedUserNameAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + return Task.FromResult(user.NormalizedUserName); + } + + /// + /// Sets the given normalized name for the specified . + /// + /// The user whose name should be set. + /// The normalized name to set. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public virtual Task SetNormalizedUserNameAsync(TUser user, string? normalizedName, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + user.NormalizedUserName = normalizedName; + return Task.CompletedTask; + } + + /// + /// Creates the specified in the user store. + /// + /// The user to create. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing the of the creation operation. + public abstract Task CreateAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Updates the specified in the user store. + /// + /// The user to update. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing the of the update operation. + public abstract Task UpdateAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Deletes the specified from the user store. + /// + /// The user to delete. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing the of the update operation. + public abstract Task DeleteAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Finds and returns a user, if any, who has the specified . + /// + /// The user ID to search for. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The that represents the asynchronous operation, containing the user matching the specified if it exists. + /// + public abstract Task FindByIdAsync(string userId, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Converts the provided to a strongly typed key object. + /// + /// The id to convert. + /// An instance of representing the provided . + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", + Justification = "long is annoated with RequiresUnreferencedCodeAttribute.All.")] + public virtual long? ConvertIdFromString(string? id) + { + if (id == null) + { + return default(long); + } + return (long?)TypeDescriptor.GetConverter(typeof(long)).ConvertFromInvariantString(id); + } + + /// + /// Converts the provided to its string representation. + /// + /// The id to convert. + /// An representation of the provided . + public virtual string? ConvertIdToString(long id) + { + if (object.Equals(id, default(long))) + { + return null; + } + return id.ToString(); + } + + /// + /// Finds and returns a user, if any, who has the specified normalized user name. + /// + /// The normalized user name to search for. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The that represents the asynchronous operation, containing the user matching the specified if it exists. + /// + public abstract Task FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// A navigation property for the users the store contains. + /// + public abstract IQueryable Users + { + get; + } + + /// + /// Sets the password hash for a user. + /// + /// The user to set the password hash for. + /// The password hash to set. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public virtual Task SetPasswordHashAsync(TUser user, string? passwordHash, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + user.PasswordHash = passwordHash; + return Task.CompletedTask; + } + + /// + /// Gets the password hash for a user. + /// + /// The user to retrieve the password hash for. + /// The used to propagate notifications that the operation should be canceled. + /// A that contains the password hash for the user. + public virtual Task GetPasswordHashAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + return Task.FromResult(user.PasswordHash); + } + + /// + /// Returns a flag indicating if the specified user has a password. + /// + /// The user to retrieve the password hash for. + /// The used to propagate notifications that the operation should be canceled. + /// A containing a flag indicating if the specified user has a password. If the + /// user has a password the returned value with be true, otherwise it will be false. + public virtual Task HasPasswordAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(user.PasswordHash != null); + } + + /// + /// Return a user with the matching userId if it exists. + /// + /// The user's id. + /// The used to propagate notifications that the operation should be canceled. + /// The user if it exists. + protected abstract Task FindUserAsync(long userId, CancellationToken cancellationToken); + + /// + /// Return a user login with the matching userId, provider, providerKey if it exists. + /// + /// The user's id. + /// The login provider name. + /// The key provided by the to identify a user. + /// The used to propagate notifications that the operation should be canceled. + /// The user login if it exists. + protected abstract Task FindUserLoginAsync(long userId, string loginProvider, string providerKey, CancellationToken cancellationToken); + + /// + /// Return a user login with provider, providerKey if it exists. + /// + /// The login provider name. + /// The key provided by the to identify a user. + /// The used to propagate notifications that the operation should be canceled. + /// The user login if it exists. + protected abstract Task FindUserLoginAsync(string loginProvider, string providerKey, CancellationToken cancellationToken); + + /// + /// Throws if this class has been disposed. + /// + protected void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + + /// + /// Dispose the store + /// + public void Dispose() + { + _disposed = true; + } + + /// + /// Get the claims associated with the specified as an asynchronous operation. + /// + /// The user whose claims should be retrieved. + /// The used to propagate notifications that the operation should be canceled. + /// A that contains the claims granted to a user. + public abstract Task> GetClaimsAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Adds the given to the specified . + /// + /// The user to add the claim to. + /// The claim to add to the user. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public abstract Task AddClaimsAsync(TUser user, IEnumerable claims, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Replaces the on the specified , with the . + /// + /// The user to replace the claim on. + /// The claim replace. + /// The new claim replacing the . + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public abstract Task ReplaceClaimAsync(TUser user, Claim claim, Claim newClaim, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Removes the given from the specified . + /// + /// The user to remove the claims from. + /// The claim to remove. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public abstract Task RemoveClaimsAsync(TUser user, IEnumerable claims, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Adds the given to the specified . + /// + /// The user to add the login to. + /// The login to add to the user. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public abstract Task AddLoginAsync(TUser user, UserLoginInfo login, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Removes the given from the specified . + /// + /// The user to remove the login from. + /// The login to remove from the user. + /// The key provided by the to identify a user. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public abstract Task RemoveLoginAsync(TUser user, string loginProvider, string providerKey, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Retrieves the associated logins for the specified . + /// + /// The user whose associated logins to retrieve. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The for the asynchronous operation, containing a list of for the specified , if any. + /// + public abstract Task> GetLoginsAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Retrieves the user associated with the specified login provider and login provider key.. + /// + /// The login provider who provided the . + /// The key provided by the to identify a user. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The for the asynchronous operation, containing the user, if any which matched the specified login provider and key. + /// + public virtual async Task FindByLoginAsync(string loginProvider, string providerKey, + CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + var userLogin = await FindUserLoginAsync(loginProvider, providerKey, cancellationToken).ConfigureAwait(false); + if (userLogin != null) + { + return await FindUserAsync(userLogin.UserId, cancellationToken).ConfigureAwait(false); + } + return null; + } + + /// + /// Gets a flag indicating whether the email address for the specified has been verified, true if the email address is verified otherwise + /// false. + /// + /// The user whose email confirmation status should be returned. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The task object containing the results of the asynchronous operation, a flag indicating whether the email address for the specified + /// has been confirmed or not. + /// + public virtual Task GetEmailConfirmedAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + return Task.FromResult(user.EmailConfirmed); + } + + /// + /// Sets the flag indicating whether the specified 's email address has been confirmed or not. + /// + /// The user whose email confirmation status should be set. + /// A flag indicating if the email address has been confirmed, true if the address is confirmed otherwise false. + /// The used to propagate notifications that the operation should be canceled. + /// The task object representing the asynchronous operation. + public virtual Task SetEmailConfirmedAsync(TUser user, bool confirmed, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + user.EmailConfirmed = confirmed; + return Task.CompletedTask; + } + + /// + /// Sets the address for a . + /// + /// The user whose email should be set. + /// The email to set. + /// The used to propagate notifications that the operation should be canceled. + /// The task object representing the asynchronous operation. + public virtual Task SetEmailAsync(TUser user, string? email, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + user.Email = email; + return Task.CompletedTask; + } + + /// + /// Gets the email address for the specified . + /// + /// The user whose email should be returned. + /// The used to propagate notifications that the operation should be canceled. + /// The task object containing the results of the asynchronous operation, the email address for the specified . + public virtual Task GetEmailAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + return Task.FromResult(user.Email); + } + + /// + /// Returns the normalized email for the specified . + /// + /// The user whose email address to retrieve. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The task object containing the results of the asynchronous lookup operation, the normalized email address if any associated with the specified user. + /// + public virtual Task GetNormalizedEmailAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + return Task.FromResult(user.NormalizedEmail); + } + + /// + /// Sets the normalized email for the specified . + /// + /// The user whose email address to set. + /// The normalized email to set for the specified . + /// The used to propagate notifications that the operation should be canceled. + /// The task object representing the asynchronous operation. + public virtual Task SetNormalizedEmailAsync(TUser user, string? normalizedEmail, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + user.NormalizedEmail = normalizedEmail; + return Task.CompletedTask; + } + + /// + /// Gets the user, if any, associated with the specified, normalized email address. + /// + /// The normalized email address to return the user for. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The task object containing the results of the asynchronous lookup operation, the user if any associated with the specified normalized email address. + /// + public abstract Task FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Gets the last a user's last lockout expired, if any. + /// Any time in the past should be indicates a user is not locked out. + /// + /// The user whose lockout date should be retrieved. + /// The used to propagate notifications that the operation should be canceled. + /// + /// A that represents the result of the asynchronous query, a containing the last time + /// a user's lockout expired, if any. + /// + public virtual Task GetLockoutEndDateAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + return Task.FromResult(user.LockoutEnd); + } + + /// + /// Locks out a user until the specified end date has passed. Setting a end date in the past immediately unlocks a user. + /// + /// The user whose lockout date should be set. + /// The after which the 's lockout should end. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public virtual Task SetLockoutEndDateAsync(TUser user, DateTimeOffset? lockoutEnd, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + user.LockoutEnd = lockoutEnd; + return Task.CompletedTask; + } + + /// + /// Records that a failed access has occurred, incrementing the failed access count. + /// + /// The user whose cancellation count should be incremented. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing the incremented failed access count. + public virtual Task IncrementAccessFailedCountAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + user.AccessFailedCount++; + return Task.FromResult(user.AccessFailedCount); + } + + /// + /// Resets a user's failed access count. + /// + /// The user whose failed access count should be reset. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + /// This is typically called after the account is successfully accessed. + public virtual Task ResetAccessFailedCountAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + user.AccessFailedCount = 0; + return Task.CompletedTask; + } + + /// + /// Retrieves the current failed access count for the specified .. + /// + /// The user whose failed access count should be retrieved. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing the failed access count. + public virtual Task GetAccessFailedCountAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + return Task.FromResult(user.AccessFailedCount); + } + + /// + /// Retrieves a flag indicating whether user lockout can enabled for the specified user. + /// + /// The user whose ability to be locked out should be returned. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The that represents the asynchronous operation, true if a user can be locked out, otherwise false. + /// + public virtual Task GetLockoutEnabledAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + return Task.FromResult(user.LockoutEnabled); + } + + /// + /// Set the flag indicating if the specified can be locked out.. + /// + /// The user whose ability to be locked out should be set. + /// A flag indicating if lock out can be enabled for the specified . + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public virtual Task SetLockoutEnabledAsync(TUser user, bool enabled, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + user.LockoutEnabled = enabled; + return Task.CompletedTask; + } + + /// + /// Sets the telephone number for the specified . + /// + /// The user whose telephone number should be set. + /// The telephone number to set. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public virtual Task SetPhoneNumberAsync(TUser user, string? phoneNumber, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + user.PhoneNumber = phoneNumber; + return Task.CompletedTask; + } + + /// + /// Gets the telephone number, if any, for the specified . + /// + /// The user whose telephone number should be retrieved. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing the user's telephone number, if any. + public virtual Task GetPhoneNumberAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + return Task.FromResult(user.PhoneNumber); + } + + /// + /// Gets a flag indicating whether the specified 's telephone number has been confirmed. + /// + /// The user to return a flag for, indicating whether their telephone number is confirmed. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The that represents the asynchronous operation, returning true if the specified has a confirmed + /// telephone number otherwise false. + /// + public virtual Task GetPhoneNumberConfirmedAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + return Task.FromResult(user.PhoneNumberConfirmed); + } + + /// + /// Sets a flag indicating if the specified 's phone number has been confirmed.. + /// + /// The user whose telephone number confirmation status should be set. + /// A flag indicating whether the user's telephone number has been confirmed. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public virtual Task SetPhoneNumberConfirmedAsync(TUser user, bool confirmed, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + user.PhoneNumberConfirmed = confirmed; + return Task.CompletedTask; + } + + /// + /// Sets the provided security for the specified . + /// + /// The user whose security stamp should be set. + /// The security stamp to set. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public virtual Task SetSecurityStampAsync(TUser user, string stamp, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(stamp); + user.SecurityStamp = stamp; + return Task.CompletedTask; + } + + /// + /// Get the security stamp for the specified . + /// + /// The user whose security stamp should be set. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing the security stamp for the specified . + public virtual Task GetSecurityStampAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + return Task.FromResult(user.SecurityStamp); + } + + /// + /// Sets a flag indicating whether the specified has two factor authentication enabled or not, + /// as an asynchronous operation. + /// + /// The user whose two factor authentication enabled status should be set. + /// A flag indicating whether the specified has two factor authentication enabled. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public virtual Task SetTwoFactorEnabledAsync(TUser user, bool enabled, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + user.TwoFactorEnabled = enabled; + return Task.CompletedTask; + } + + /// + /// Returns a flag indicating whether the specified has two factor authentication enabled or not, + /// as an asynchronous operation. + /// + /// The user whose two factor authentication enabled status should be set. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The that represents the asynchronous operation, containing a flag indicating whether the specified + /// has two factor authentication enabled or not. + /// + public virtual Task GetTwoFactorEnabledAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(user); + return Task.FromResult(user.TwoFactorEnabled); + } + + /// + /// Retrieves all users with the specified claim. + /// + /// The claim whose users should be retrieved. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The contains a list of users, if any, that contain the specified claim. + /// + public abstract Task> GetUsersForClaimAsync(Claim claim, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Find a user token if it exists. + /// + /// The token owner. + /// The login provider for the token. + /// The name of the token. + /// The used to propagate notifications that the operation should be canceled. + /// The user token if it exists. + protected abstract Task FindTokenAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken); + + /// + /// Add a new user token. + /// + /// The token to be added. + /// + protected abstract Task AddUserTokenAsync(TUserToken token); + + /// + /// Remove a new user token. + /// + /// The token to be removed. + /// + protected abstract Task RemoveUserTokenAsync(TUserToken token); + + /// + /// Sets the token value for a particular user. + /// + /// The user. + /// The authentication provider for the token. + /// The name of the token. + /// The value of the token. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public virtual async Task SetTokenAsync(TUser user, string loginProvider, string name, string? value, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + ArgumentNullException.ThrowIfNull(user); + + var token = await FindTokenAsync(user, loginProvider, name, cancellationToken).ConfigureAwait(false); + if (token == null) + { + await AddUserTokenAsync(CreateUserToken(user, loginProvider, name, value)).ConfigureAwait(false); + } + else + { + token.Value = value; + } + } + + /// + /// Deletes a token for a user. + /// + /// The user. + /// The authentication provider for the token. + /// The name of the token. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public virtual async Task RemoveTokenAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + ArgumentNullException.ThrowIfNull(user); + var entry = await FindTokenAsync(user, loginProvider, name, cancellationToken).ConfigureAwait(false); + if (entry != null) + { + await RemoveUserTokenAsync(entry).ConfigureAwait(false); + } + } + + /// + /// Returns the token value. + /// + /// The user. + /// The authentication provider for the token. + /// The name of the token. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public virtual async Task GetTokenAsync(TUser user, string loginProvider, string name, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + ArgumentNullException.ThrowIfNull(user); + var entry = await FindTokenAsync(user, loginProvider, name, cancellationToken).ConfigureAwait(false); + return entry?.Value; + } + + private const string InternalLoginProvider = "[AspNetUserStore]"; + private const string AuthenticatorKeyTokenName = "AuthenticatorKey"; + private const string RecoveryCodeTokenName = "RecoveryCodes"; + + /// + /// Sets the authenticator key for the specified . + /// + /// The user whose authenticator key should be set. + /// The authenticator key to set. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public virtual Task SetAuthenticatorKeyAsync(TUser user, string key, CancellationToken cancellationToken) + => SetTokenAsync(user, InternalLoginProvider, AuthenticatorKeyTokenName, key, cancellationToken); + + /// + /// Get the authenticator key for the specified . + /// + /// The user whose security stamp should be set. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation, containing the security stamp for the specified . + public virtual Task GetAuthenticatorKeyAsync(TUser user, CancellationToken cancellationToken) + => GetTokenAsync(user, InternalLoginProvider, AuthenticatorKeyTokenName, cancellationToken); + + /// + /// Returns how many recovery code are still valid for a user. + /// + /// The user who owns the recovery code. + /// The used to propagate notifications that the operation should be canceled. + /// The number of valid recovery codes for the user.. + public virtual async Task CountCodesAsync(TUser user, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + ArgumentNullException.ThrowIfNull(user); + var mergedCodes = await GetTokenAsync(user, InternalLoginProvider, RecoveryCodeTokenName, cancellationToken).ConfigureAwait(false) ?? ""; + if (mergedCodes.Length > 0) + { +#if NET8_0_OR_GREATER + return mergedCodes.AsSpan().Count(';') + 1; +#else + // non-allocating version of mergedCodes.Split(';').Length + var count = 1; + var index = 0; + while (index < mergedCodes.Length) + { + var semiColonIndex = mergedCodes.IndexOf(';', index); + if (semiColonIndex < 0) + { + break; + } + count++; + index = semiColonIndex + 1; + } + return count; +#endif + } + return 0; + } + + /// + /// Updates the recovery codes for the user while invalidating any previous recovery codes. + /// + /// The user to store new recovery codes for. + /// The new recovery codes for the user. + /// The used to propagate notifications that the operation should be canceled. + /// The new recovery codes for the user. + public virtual Task ReplaceCodesAsync(TUser user, IEnumerable recoveryCodes, CancellationToken cancellationToken) + { + var mergedCodes = string.Join(";", recoveryCodes); + return SetTokenAsync(user, InternalLoginProvider, RecoveryCodeTokenName, mergedCodes, cancellationToken); + } + + /// + /// Returns whether a recovery code is valid for a user. Note: recovery codes are only valid + /// once, and will be invalid after use. + /// + /// The user who owns the recovery code. + /// The recovery code to use. + /// The used to propagate notifications that the operation should be canceled. + /// True if the recovery code was found for the user. + public virtual async Task RedeemCodeAsync(TUser user, string code, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfDisposed(); + + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(code); + + var mergedCodes = await GetTokenAsync(user, InternalLoginProvider, RecoveryCodeTokenName, cancellationToken).ConfigureAwait(false) ?? ""; + var splitCodes = mergedCodes.Split(';'); + if (splitCodes.Contains(code)) + { + var updatedCodes = new List(splitCodes.Where(s => s != code)); + await ReplaceCodesAsync(user, updatedCodes, cancellationToken).ConfigureAwait(false); + return true; + } + return false; + } +} + +/// +/// Represents a new instance of a persistence store for the specified user and role types. +/// +/// The type representing a user. +/// The type representing a role. +/// The type of the primary key for a role. +/// The type representing a claim. +/// The type representing a user role. +/// The type representing a user external login. +/// The type representing a user token. +/// The type representing a role claim. +public abstract class UserStoreBase : + UserStoreBase, + IUserRoleStore + where TUser : User + where TRole : Role + where TUserClaim : UserClaim, new() + where TUserRole : UserRole, new() + where TUserLogin : UserLogin, new() + where TUserToken : UserToken, new() + where TRoleClaim : RoleClaim, new() +{ + /// + /// Creates a new instance. + /// + /// The used to describe store errors. + public UserStoreBase(IdentityErrorDescriber describer) : base(describer) { } + + /// + /// Called to create a new instance of a . + /// + /// The associated user. + /// The associated role. + /// + protected virtual TUserRole CreateUserRole(TUser user, TRole role) + { + return new TUserRole() + { + UserId = user.Id, + RoleId = role.Id + }; + } + + /// + /// Retrieves all users in the specified role. + /// + /// The role whose users should be retrieved. + /// The used to propagate notifications that the operation should be canceled. + /// + /// The contains a list of users, if any, that are in the specified role. + /// + public abstract Task> GetUsersInRoleAsync(string normalizedRoleName, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Adds the given to the specified . + /// + /// The user to add the role to. + /// The role to add. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public abstract Task AddToRoleAsync(TUser user, string normalizedRoleName, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Removes the given from the specified . + /// + /// The user to remove the role from. + /// The role to remove. + /// The used to propagate notifications that the operation should be canceled. + /// The that represents the asynchronous operation. + public abstract Task RemoveFromRoleAsync(TUser user, string normalizedRoleName, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Retrieves the roles the specified is a member of. + /// + /// The user whose roles should be retrieved. + /// The used to propagate notifications that the operation should be canceled. + /// A that contains the roles the user is a member of. + public abstract Task> GetRolesAsync(TUser user, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Returns a flag indicating if the specified user is a member of the give . + /// + /// The user whose role membership should be checked. + /// The role to check membership of + /// The used to propagate notifications that the operation should be canceled. + /// A containing a flag indicating if the specified user is a member of the given group. If the + /// user is a member of the group the returned value with be true, otherwise it will be false. + public abstract Task IsInRoleAsync(TUser user, string normalizedRoleName, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Return a role with the normalized name if it exists. + /// + /// The normalized role name. + /// The used to propagate notifications that the operation should be canceled. + /// The role if it exists. + protected abstract Task FindRoleAsync(string normalizedRoleName, CancellationToken cancellationToken); + + /// + /// Return a user role for the userId and roleId if it exists. + /// + /// The user's id. + /// The role's id. + /// The used to propagate notifications that the operation should be canceled. + /// The user role if it exists. + protected abstract Task FindUserRoleAsync(long userId, long roleId, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/IdentityPlus/Persistence/PersonalDataConverter.cs b/src/IdentityPlus/Persistence/PersonalDataConverter.cs new file mode 100644 index 0000000..73e7ece --- /dev/null +++ b/src/IdentityPlus/Persistence/PersonalDataConverter.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Honamic.IdentityPlus.Persistence; + +internal sealed class PersonalDataConverter : ValueConverter +{ + public PersonalDataConverter(IPersonalDataProtector protector) + : base(s => protector.Protect(s), s => protector.Unprotect(s), default) + { + + } +} diff --git a/src/IdentityPlus/Persistence/Roles/RoleEntityConfigurations.cs b/src/IdentityPlus/Persistence/Roles/RoleEntityConfigurations.cs new file mode 100644 index 0000000..d58f59b --- /dev/null +++ b/src/IdentityPlus/Persistence/Roles/RoleEntityConfigurations.cs @@ -0,0 +1,43 @@ +using Honamic.IdentityPlus.Domain.Roles; +using Honamic.IdentityPlus.Domain.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using System.Reflection.Emit; + +namespace Honamic.IdentityPlus.Persistence.Users; +internal class RoleEntityConfigurations : IEntityTypeConfiguration +{ + private readonly string tableName; + private readonly string? schema; + public RoleEntityConfigurations(string tableName, string? schema) + { + this.tableName = tableName; + this.schema = schema; + } + + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(r => r.Id); + builder.HasIndex(r => r.NormalizedName) + .HasDatabaseName("RoleNameIndex") + .IsUnique(); + + builder.ToTable(tableName, schema); + + builder.Property(r => r.ConcurrencyStamp) + .IsConcurrencyToken(); + + builder.Property(u => u.Name).HasMaxLength(256); + builder.Property(u => u.NormalizedName).HasMaxLength(256); + + builder.HasMany() + .WithOne() + .HasForeignKey(ur => ur.RoleId) + .IsRequired(); + + builder.HasMany() + .WithOne() + .HasForeignKey(rc => rc.RoleId).IsRequired(); + + } +} \ No newline at end of file diff --git a/src/IdentityPlus/Persistence/Roles/RoleRepository.cs b/src/IdentityPlus/Persistence/Roles/RoleRepository.cs new file mode 100644 index 0000000..9178018 --- /dev/null +++ b/src/IdentityPlus/Persistence/Roles/RoleRepository.cs @@ -0,0 +1,16 @@ +using Honamic.IdentityPlus.Domain.Roles; +using Honamic.IdentityPlus.Domain.Users; +using Honamic.IdentityPlus.Persistence.IdentitySource; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace Honamic.IdentityPlus.Persistence.Roles; + +internal class RoleRepository : RoleStore +{ + public RoleRepository(DbContext context, IdentityErrorDescriber? describer = null) + : base(context, describer) + { + + } +} diff --git a/src/IdentityPlus/Persistence/Users/UserConfigurations.cs b/src/IdentityPlus/Persistence/Users/UserConfigurations.cs new file mode 100644 index 0000000..36a4c6f --- /dev/null +++ b/src/IdentityPlus/Persistence/Users/UserConfigurations.cs @@ -0,0 +1,85 @@ +using Honamic.IdentityPlus.Domain; +using Honamic.IdentityPlus.Domain.Users; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Honamic.IdentityPlus.Persistence.Users; +internal class UserConfigurations : IEntityTypeConfiguration +{ + private readonly string tableName; + private readonly string? schema; + private bool _encryptPersonalData; + private PersonalDataConverter? _personalDataConverter; + public UserConfigurations(string tableName, string? schema, bool encryptPersonalData, PersonalDataConverter? personalDataConverter = null) + { + this.tableName = tableName; + this.schema = schema; + _encryptPersonalData = encryptPersonalData; + _personalDataConverter = personalDataConverter; + } + + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(u => u.Id); + + builder.Property(p => p.Id).ValueGeneratedNever(); + + builder.HasIndex(u => u.NormalizedUserName) + .HasDatabaseName("UserNameIndex") + .IsUnique(); + + builder.HasIndex(u => u.NormalizedEmail) + .HasDatabaseName("EmailIndex"); + + builder.ToTable(tableName, schema); + + builder.Property(u => u.ConcurrencyStamp) + .IsConcurrencyToken(); + + builder.Property(u => u.UserName).HasMaxLength(256); + builder.Property(u => u.NormalizedUserName).HasMaxLength(256); + builder.Property(u => u.Email).HasMaxLength(256); + builder.Property(u => u.NormalizedEmail).HasMaxLength(256); + builder.Property(u => u.PhoneNumber).HasMaxLength(256); + + + if (_encryptPersonalData) + { + var personalDataProps = typeof(User).GetProperties().Where( + prop => Attribute.IsDefined(prop, typeof(ProtectedPersonalDataAttribute)) || + Attribute.IsDefined(prop, typeof(IdentityPlusProtectedPersonalDataAttribute))); + + foreach (var p in personalDataProps) + { + if (p.PropertyType != typeof(string)) + { + throw new InvalidOperationException("[ProtectedPersonalData] only works strings by default."); + } + builder.Property(typeof(string), p.Name) + .HasConversion(_personalDataConverter); + } + } + + builder.HasMany() + .WithOne() + .HasForeignKey(uc => uc.UserId) + .IsRequired(); + + builder.HasMany() + .WithOne() + .HasForeignKey(ul => ul.UserId) + .IsRequired(); + + builder.HasMany() + .WithOne() + .HasForeignKey(ut => ut.UserId) + .IsRequired(); + + builder.HasMany() + .WithOne() + .HasForeignKey(ur => ur.UserId) + .IsRequired(); + + } +} diff --git a/src/IdentityPlus/Persistence/Users/UserRepository.cs b/src/IdentityPlus/Persistence/Users/UserRepository.cs new file mode 100644 index 0000000..99b6859 --- /dev/null +++ b/src/IdentityPlus/Persistence/Users/UserRepository.cs @@ -0,0 +1,44 @@ +using Honamic.Framework.Domain; +using Honamic.IdentityPlus.Domain.Roles; +using Honamic.IdentityPlus.Domain.Users; +using Honamic.IdentityPlus.Persistence.IdentitySource; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace Honamic.IdentityPlus.Persistence.Users; +internal class UserRepository : UserStore +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IIdGenerator _idGenerator; + + public UserRepository(DbContext context, IUnitOfWork unitOfWork, IIdGenerator idGenerator, IdentityErrorDescriber? describer = null) + : base(context, describer) + { + _unitOfWork = unitOfWork; + _idGenerator = idGenerator; + } + + + public override async Task CreateAsync(User user, CancellationToken cancellationToken = default) + { + if (user.Id == 0) + { + user.SetManualId(_idGenerator.GetNewId()); + } + var beforeAutoSaveChanges = AutoSaveChanges; + AutoSaveChanges = false; + + var result = await base.CreateAsync(user, cancellationToken); + + if (!result.Succeeded) + { + return result; + } + + AutoSaveChanges = beforeAutoSaveChanges; + + await _unitOfWork.SaveChangesAsync(); + + return result; + } +} diff --git a/src/IdentityPlus/WebApi/Extensions/IdentityPlusApiEndpointRouteBuilderExtensions.cs b/src/IdentityPlus/WebApi/Extensions/IdentityPlusApiEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..f5427ad --- /dev/null +++ b/src/IdentityPlus/WebApi/Extensions/IdentityPlusApiEndpointRouteBuilderExtensions.cs @@ -0,0 +1,14 @@ +using Honamic.IdentityPlus.Domain.Users; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; + +namespace Honamic.IdentityPlus.WebApi.Extensions; +public static class IdentityPlusApiEndpointRouteBuilderExtensions +{ + public static IEndpointConventionBuilder MapIdentityPlusApi(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGroup("/identity").MapIdentityApi(); + + } + +} \ No newline at end of file diff --git a/src/IdentityPlus/WebApi/Extensions/IdentityPlusConstants.cs b/src/IdentityPlus/WebApi/Extensions/IdentityPlusConstants.cs new file mode 100644 index 0000000..994beea --- /dev/null +++ b/src/IdentityPlus/WebApi/Extensions/IdentityPlusConstants.cs @@ -0,0 +1,9 @@ + +namespace Honamic.IdentityPlus.WebApi.Extensions; +internal class IdentityPlusConstants +{ + private const string IdentityPrefix = "Identity"; + + internal const string BearerAndApplicationScheme = IdentityPrefix + ".BearerAndApplication"; + +} diff --git a/src/IdentityPlus/WebApi/Extensions/ServiceCollection.cs b/src/IdentityPlus/WebApi/Extensions/ServiceCollection.cs new file mode 100644 index 0000000..88e4b1f --- /dev/null +++ b/src/IdentityPlus/WebApi/Extensions/ServiceCollection.cs @@ -0,0 +1,81 @@ +using Microsoft.Extensions.DependencyInjection; +using Honamic.IdentityPlus.Application.Extensions; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Security.Claims; +using System.Text.Encodings.Web; +using Honamic.IdentityPlus.Application; + +namespace Honamic.IdentityPlus.WebApi.Extensions; + +public static class ServiceCollection +{ + + public static IServiceCollection AddIdentityPlusApiEndpoint(this IServiceCollection services) + { + return services.AddIdentityPlusApiEndpoint(_ => { }); + } + + public static IServiceCollection AddIdentityPlusApiEndpoint(this IServiceCollection services, + Action configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configure); + + + services + .AddAuthentication(IdentityPlusConstants.BearerAndApplicationScheme) + .AddScheme(IdentityPlusConstants.BearerAndApplicationScheme, null, compositeOptions => + { + compositeOptions.ForwardDefault = IdentityConstants.BearerScheme; + compositeOptions.ForwardAuthenticate = IdentityPlusConstants.BearerAndApplicationScheme; + }) + .AddBearerToken(IdentityConstants.BearerScheme) + .AddIdentityCookies(); + + if (IdentityPlusApplicationServiceCollection + .IdentityBuilder is null) + { + services.AddIdentityPlusApplication(configure); + } + + IdentityPlusApplicationServiceCollection + .IdentityBuilder?.AddApiEndpoints(); + + IdentityPlusApplicationServiceCollection + .IdentityBuilder?.AddSignInManager(); + + return services; + } + + + private sealed class CompositeIdentityHandler(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) + : SignInAuthenticationHandler(options, logger, encoder) + { + protected override async Task HandleAuthenticateAsync() + { + var bearerResult = await Context.AuthenticateAsync(IdentityConstants.BearerScheme); + + // Only try to authenticate with the application cookie if there is no bearer token. + if (!bearerResult.None) + { + return bearerResult; + } + + // Cookie auth will return AuthenticateResult.NoResult() like bearer auth just did if there is no cookie. + return await Context.AuthenticateAsync(IdentityConstants.ApplicationScheme); + } + + protected override Task HandleSignInAsync(ClaimsPrincipal user, AuthenticationProperties? properties) + { + throw new NotImplementedException(); + } + + protected override Task HandleSignOutAsync(AuthenticationProperties? properties) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/IdentityPlus/WebApi/Honamic.IdentityPlus.WebApi.csproj b/src/IdentityPlus/WebApi/Honamic.IdentityPlus.WebApi.csproj new file mode 100644 index 0000000..52b34ed --- /dev/null +++ b/src/IdentityPlus/WebApi/Honamic.IdentityPlus.WebApi.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + +