diff --git a/src/Microservices.sln b/src/Microservices.sln index bf2eaa1..22d921b 100644 --- a/src/Microservices.sln +++ b/src/Microservices.sln @@ -1,12 +1,10 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 - Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Services", "Services", "{A1B2C3D4-0001-0000-0000-000000000001}" EndProject - Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Identity", "Identity", "{A1B2C3D4-0002-0000-0000-000000000001}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Identity.API", "Services\Identity\Identity.API\Identity.API.csproj", "{B1000001-0000-0000-0000-000000000001}" @@ -15,7 +13,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Identity.Domain", "Services EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Identity.Infrastructure", "Services\Identity\Identity.Infrastructure\Identity.Infrastructure.csproj", "{B1000003-0000-0000-0000-000000000001}" EndProject - Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Customer", "Customer", "{A1B2C3D4-0003-0000-0000-000000000001}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Customer.API", "Services\Customer\Customer.API\Customer.API.csproj", "{B2000001-0000-0000-0000-000000000001}" @@ -24,7 +21,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Customer.Domain", "Services EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Customer.Infrastructure", "Services\Customer\Customer.Infrastructure\Customer.Infrastructure.csproj", "{B2000003-0000-0000-0000-000000000001}" EndProject - Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Order", "Order", "{A1B2C3D4-0004-0000-0000-000000000001}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Order.API", "Services\Order\Order.API\Order.API.csproj", "{B3000001-0000-0000-0000-000000000001}" @@ -33,7 +29,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Order.Domain", "Services\Or EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Order.Infrastructure", "Services\Order\Order.Infrastructure\Order.Infrastructure.csproj", "{B3000003-0000-0000-0000-000000000001}" EndProject - Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Product", "Product", "{A1B2C3D4-0005-0000-0000-000000000001}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Product.API", "Services\Product\Product.API\Product.API.csproj", "{B4000001-0000-0000-0000-000000000001}" @@ -42,7 +37,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Product.Domain", "Services\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Product.Infrastructure", "Services\Product\Product.Infrastructure\Product.Infrastructure.csproj", "{B4000003-0000-0000-0000-000000000001}" EndProject - Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Notification", "Notification", "{A1B2C3D4-0006-0000-0000-000000000001}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Notification.API", "Services\Notification\Notification.API\Notification.API.csproj", "{B5000001-0000-0000-0000-000000000001}" @@ -51,42 +45,279 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Notification.Domain", "Serv EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Notification.Infrastructure", "Services\Notification\Notification.Infrastructure\Notification.Infrastructure.csproj", "{B5000003-0000-0000-0000-000000000001}" EndProject - Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiGateway", "ApiGateway\ApiGateway.csproj", "{C0000001-0000-0000-0000-000000000001}" EndProject - Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{A1B2C3D4-0007-0000-0000-000000000001}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared.Contracts", "Shared\Shared.Contracts\Shared.Contracts.csproj", "{D0000001-0000-0000-0000-000000000001}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shared.Infrastructure", "Shared\Shared.Infrastructure\Shared.Infrastructure.csproj", "{D0000002-0000-0000-0000-000000000001}" EndProject - +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Identity.Tests", "Services\Identity\Identity.Tests\Identity.Tests.csproj", "{5DAC7AF6-A7D6-4067-858C-E78F20D95E9C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B1000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1000001-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B1000001-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B1000001-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B1000001-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B1000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B1000001-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B1000001-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B1000001-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B1000001-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B1000002-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1000002-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1000002-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B1000002-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B1000002-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B1000002-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B1000002-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1000002-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B1000002-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B1000002-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B1000002-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B1000002-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B1000003-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B1000003-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B1000003-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B1000003-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B1000003-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B1000003-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B1000003-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B1000003-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B1000003-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B1000003-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B1000003-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B1000003-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B2000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2000001-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B2000001-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B2000001-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B2000001-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B2000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B2000001-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B2000001-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B2000001-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B2000001-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B2000002-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2000002-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2000002-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B2000002-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B2000002-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B2000002-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B2000002-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2000002-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B2000002-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B2000002-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B2000002-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B2000002-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B2000003-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2000003-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2000003-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B2000003-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B2000003-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B2000003-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B2000003-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2000003-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B2000003-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B2000003-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B2000003-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B2000003-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B3000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B3000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B3000001-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B3000001-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B3000001-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B3000001-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B3000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B3000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B3000001-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B3000001-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B3000001-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B3000001-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B3000002-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B3000002-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B3000002-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B3000002-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B3000002-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B3000002-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B3000002-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B3000002-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B3000002-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B3000002-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B3000002-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B3000002-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B3000003-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B3000003-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B3000003-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B3000003-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B3000003-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B3000003-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B3000003-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B3000003-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B3000003-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B3000003-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B3000003-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B3000003-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B4000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4000001-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4000001-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B4000001-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4000001-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B4000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B4000001-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B4000001-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B4000001-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B4000001-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B4000002-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4000002-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4000002-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4000002-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B4000002-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4000002-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B4000002-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4000002-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B4000002-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B4000002-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B4000002-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B4000002-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B4000003-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4000003-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4000003-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4000003-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B4000003-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4000003-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B4000003-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4000003-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B4000003-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B4000003-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B4000003-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B4000003-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B5000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B5000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B5000001-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B5000001-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B5000001-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B5000001-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B5000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B5000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B5000001-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B5000001-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B5000001-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B5000001-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B5000002-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B5000002-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B5000002-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B5000002-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B5000002-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B5000002-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B5000002-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B5000002-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B5000002-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B5000002-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B5000002-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B5000002-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {B5000003-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B5000003-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B5000003-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {B5000003-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {B5000003-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {B5000003-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {B5000003-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B5000003-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {B5000003-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {B5000003-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {B5000003-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {B5000003-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {C0000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C0000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C0000001-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {C0000001-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {C0000001-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {C0000001-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {C0000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C0000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {C0000001-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {C0000001-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {C0000001-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {C0000001-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {D0000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D0000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D0000001-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {D0000001-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {D0000001-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {D0000001-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {D0000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D0000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {D0000001-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {D0000001-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {D0000001-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {D0000001-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {D0000002-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D0000002-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D0000002-0000-0000-0000-000000000001}.Debug|x64.ActiveCfg = Debug|Any CPU + {D0000002-0000-0000-0000-000000000001}.Debug|x64.Build.0 = Debug|Any CPU + {D0000002-0000-0000-0000-000000000001}.Debug|x86.ActiveCfg = Debug|Any CPU + {D0000002-0000-0000-0000-000000000001}.Debug|x86.Build.0 = Debug|Any CPU + {D0000002-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D0000002-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU + {D0000002-0000-0000-0000-000000000001}.Release|x64.ActiveCfg = Release|Any CPU + {D0000002-0000-0000-0000-000000000001}.Release|x64.Build.0 = Release|Any CPU + {D0000002-0000-0000-0000-000000000001}.Release|x86.ActiveCfg = Release|Any CPU + {D0000002-0000-0000-0000-000000000001}.Release|x86.Build.0 = Release|Any CPU + {5DAC7AF6-A7D6-4067-858C-E78F20D95E9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5DAC7AF6-A7D6-4067-858C-E78F20D95E9C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5DAC7AF6-A7D6-4067-858C-E78F20D95E9C}.Debug|x64.ActiveCfg = Debug|Any CPU + {5DAC7AF6-A7D6-4067-858C-E78F20D95E9C}.Debug|x64.Build.0 = Debug|Any CPU + {5DAC7AF6-A7D6-4067-858C-E78F20D95E9C}.Debug|x86.ActiveCfg = Debug|Any CPU + {5DAC7AF6-A7D6-4067-858C-E78F20D95E9C}.Debug|x86.Build.0 = Debug|Any CPU + {5DAC7AF6-A7D6-4067-858C-E78F20D95E9C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5DAC7AF6-A7D6-4067-858C-E78F20D95E9C}.Release|Any CPU.Build.0 = Release|Any CPU + {5DAC7AF6-A7D6-4067-858C-E78F20D95E9C}.Release|x64.ActiveCfg = Release|Any CPU + {5DAC7AF6-A7D6-4067-858C-E78F20D95E9C}.Release|x64.Build.0 = Release|Any CPU + {5DAC7AF6-A7D6-4067-858C-E78F20D95E9C}.Release|x86.ActiveCfg = Release|Any CPU + {5DAC7AF6-A7D6-4067-858C-E78F20D95E9C}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {A1B2C3D4-0002-0000-0000-000000000001} = {A1B2C3D4-0001-0000-0000-000000000001} - {A1B2C3D4-0003-0000-0000-000000000001} = {A1B2C3D4-0001-0000-0000-000000000001} - {A1B2C3D4-0004-0000-0000-000000000001} = {A1B2C3D4-0001-0000-0000-000000000001} - {A1B2C3D4-0005-0000-0000-000000000001} = {A1B2C3D4-0001-0000-0000-000000000001} - {A1B2C3D4-0006-0000-0000-000000000001} = {A1B2C3D4-0001-0000-0000-000000000001} {B1000001-0000-0000-0000-000000000001} = {A1B2C3D4-0002-0000-0000-000000000001} {B1000002-0000-0000-0000-000000000001} = {A1B2C3D4-0002-0000-0000-000000000001} {B1000003-0000-0000-0000-000000000001} = {A1B2C3D4-0002-0000-0000-000000000001} + {A1B2C3D4-0003-0000-0000-000000000001} = {A1B2C3D4-0001-0000-0000-000000000001} {B2000001-0000-0000-0000-000000000001} = {A1B2C3D4-0003-0000-0000-000000000001} {B2000002-0000-0000-0000-000000000001} = {A1B2C3D4-0003-0000-0000-000000000001} {B2000003-0000-0000-0000-000000000001} = {A1B2C3D4-0003-0000-0000-000000000001} + {A1B2C3D4-0004-0000-0000-000000000001} = {A1B2C3D4-0001-0000-0000-000000000001} {B3000001-0000-0000-0000-000000000001} = {A1B2C3D4-0004-0000-0000-000000000001} {B3000002-0000-0000-0000-000000000001} = {A1B2C3D4-0004-0000-0000-000000000001} {B3000003-0000-0000-0000-000000000001} = {A1B2C3D4-0004-0000-0000-000000000001} + {A1B2C3D4-0005-0000-0000-000000000001} = {A1B2C3D4-0001-0000-0000-000000000001} {B4000001-0000-0000-0000-000000000001} = {A1B2C3D4-0005-0000-0000-000000000001} {B4000002-0000-0000-0000-000000000001} = {A1B2C3D4-0005-0000-0000-000000000001} {B4000003-0000-0000-0000-000000000001} = {A1B2C3D4-0005-0000-0000-000000000001} + {A1B2C3D4-0006-0000-0000-000000000001} = {A1B2C3D4-0001-0000-0000-000000000001} {B5000001-0000-0000-0000-000000000001} = {A1B2C3D4-0006-0000-0000-000000000001} {B5000002-0000-0000-0000-000000000001} = {A1B2C3D4-0006-0000-0000-000000000001} {B5000003-0000-0000-0000-000000000001} = {A1B2C3D4-0006-0000-0000-000000000001} + {5DAC7AF6-A7D6-4067-858C-E78F20D95E9C} = {A1B2C3D4-0002-0000-0000-000000000001} EndGlobalSection EndGlobal diff --git a/src/Services/Identity/Identity.API/Attributes/MinimumCountAttribute.cs b/src/Services/Identity/Identity.API/Attributes/MinimumCountAttribute.cs new file mode 100644 index 0000000..75d007a --- /dev/null +++ b/src/Services/Identity/Identity.API/Attributes/MinimumCountAttribute.cs @@ -0,0 +1,40 @@ +// --------------------------------------- +// Email: quickapp@ebenmonney.com +// Templates: www.ebenmonney.com/templates +// (c) 2024 www.ebenmonney.com/mit-license +// --------------------------------------- + +using System.Collections; +using System.ComponentModel.DataAnnotations; + +namespace Identity.API.Attributes +{ + [AttributeUsage(AttributeTargets.Property)] + public sealed class MinimumCountAttribute(int minCount, bool required = true, bool allowEmptyStringValues = false) : + ValidationAttribute("'{0}' must have at least {1} item.") + { + public MinimumCountAttribute() : this(1) + { + + } + + public override bool IsValid(object? value) + { + if (value == null) + return !required; + + if (!allowEmptyStringValues && value is ICollection stringList) + return stringList.Count(s => !string.IsNullOrWhiteSpace(s)) >= minCount; + + if (value is ICollection list) + return list.Count >= minCount; + + return false; + } + + public override string FormatErrorMessage(string name) + { + return string.Format(ErrorMessageString, name, minCount); + } + } +} diff --git a/src/Services/Identity/Identity.API/Attributes/SanitizeModelAttribute.cs b/src/Services/Identity/Identity.API/Attributes/SanitizeModelAttribute.cs new file mode 100644 index 0000000..7561d2f --- /dev/null +++ b/src/Services/Identity/Identity.API/Attributes/SanitizeModelAttribute.cs @@ -0,0 +1,30 @@ +// --------------------------------------- +// Email: quickapp@ebenmonney.com +// Templates: www.ebenmonney.com/templates +// (c) 2024 www.ebenmonney.com/mit-license +// --------------------------------------- + +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Identity.API.Attributes +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] + public class SanitizeModelAttribute : ActionFilterAttribute + { + public override void OnActionExecuting(ActionExecutingContext context) + { + foreach (var arg in context.ActionArguments.Values) + { + if (arg is ISanitizeModel model) + { + model.SanitizeModel(); + } + } + } + } + + public interface ISanitizeModel + { + public void SanitizeModel(); + } +} diff --git a/src/Services/Identity/Identity.API/Authorization/AuthPolicies.cs b/src/Services/Identity/Identity.API/Authorization/AuthPolicies.cs new file mode 100644 index 0000000..c6d8a5b --- /dev/null +++ b/src/Services/Identity/Identity.API/Authorization/AuthPolicies.cs @@ -0,0 +1,29 @@ +// --------------------------------------- +// Email: quickapp@ebenmonney.com +// Templates: www.ebenmonney.com/templates +// (c) 2024 www.ebenmonney.com/mit-license +// --------------------------------------- + +namespace Identity.API.Authorization +{ + public static class AuthPolicies + { + ///Policy to allow viewing all user records. + public const string ViewAllUsersPolicy = "View All Users"; + + ///Policy to allow adding, removing and updating all user records. + public const string ManageAllUsersPolicy = "Manage All Users"; + + /// Policy to allow viewing details of all roles. + public const string ViewAllRolesPolicy = "View All Roles"; + + /// Policy to allow viewing details of all or specific roles (Requires roleName as parameter). + public const string ViewRoleByRoleNamePolicy = "View Role by RoleName"; + + /// Policy to allow adding, removing and updating all roles. + public const string ManageAllRolesPolicy = "Manage All Roles"; + + /// Policy to allow assigning roles the user has access to (Requires new and current roles as parameter). + public const string AssignAllowedRolesPolicy = "Assign Allowed Roles"; + } +} diff --git a/src/Services/Identity/Identity.API/Authorization/Requirements/AssignRolesAuthorizationRequirement.cs b/src/Services/Identity/Identity.API/Authorization/Requirements/AssignRolesAuthorizationRequirement.cs new file mode 100644 index 0000000..293b7b6 --- /dev/null +++ b/src/Services/Identity/Identity.API/Authorization/Requirements/AssignRolesAuthorizationRequirement.cs @@ -0,0 +1,65 @@ +// --------------------------------------- +// Email: quickapp@ebenmonney.com +// Templates: www.ebenmonney.com/templates +// (c) 2024 www.ebenmonney.com/mit-license +// --------------------------------------- + +using Microsoft.AspNetCore.Authorization; +using Identity.Domain.Authorization; +using Identity.Domain.Exceptions; +using Identity.Domain.Interfaces; +using System.Security.Claims; + +namespace Identity.API.Authorization.Requirements +{ + public class AssignRolesAuthorizationRequirement : IAuthorizationRequirement + { + + } + + public class AssignRolesAuthorizationHandler : + AuthorizationHandler + { + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, + AssignRolesAuthorizationRequirement requirement, (string[] newRoles, string[] currentRoles) roles) + { + if (!GetIsRolesChanged(roles.newRoles, roles.currentRoles)) + { + context.Succeed(requirement); + } + else if (context.User.HasClaim(CustomClaims.Permission, ApplicationPermissions.AssignRoles)) + { + // If user has ViewRoles permission, then he can assign any roles + if (context.User.HasClaim(CustomClaims.Permission, ApplicationPermissions.ViewRoles)) + context.Succeed(requirement); + + // Else user can only assign roles they're part of + else if (GetIsUserInAllAddedRoles(context.User, roles.newRoles, roles.currentRoles)) + context.Succeed(requirement); + } + + return Task.CompletedTask; + } + + private static bool GetIsRolesChanged(string[] newRoles, string[] currentRoles) + { + newRoles ??= []; + currentRoles ??= []; + + var roleAdded = newRoles.Except(currentRoles).Any(); + var roleRemoved = currentRoles.Except(newRoles).Any(); + + return roleAdded || roleRemoved; + } + + private static bool GetIsUserInAllAddedRoles(ClaimsPrincipal contextUser, string[] newRoles, string[] currentRoles) + { + newRoles ??= []; + currentRoles ??= []; + + var addedRoles = newRoles.Except(currentRoles); + + return addedRoles.All(contextUser.IsInRole); + } + } +} \ No newline at end of file diff --git a/src/Services/Identity/Identity.API/Authorization/Requirements/UserAccountAuthorizationRequirement.cs b/src/Services/Identity/Identity.API/Authorization/Requirements/UserAccountAuthorizationRequirement.cs new file mode 100644 index 0000000..19c7405 --- /dev/null +++ b/src/Services/Identity/Identity.API/Authorization/Requirements/UserAccountAuthorizationRequirement.cs @@ -0,0 +1,71 @@ +// --------------------------------------- +// Email: quickapp@ebenmonney.com +// Templates: www.ebenmonney.com/templates +// (c) 2024 www.ebenmonney.com/mit-license +// --------------------------------------- + +using Microsoft.AspNetCore.Authorization; +using Identity.Domain.Authorization; +using Identity.Domain.Exceptions; +using Identity.Domain.Interfaces; +using Identity.API.Services; +using System.Security.Claims; + +namespace Identity.API.Authorization.Requirements +{ + public class UserAccountAuthorizationRequirement(string operationName) : IAuthorizationRequirement + { + public string OperationName { get; private set; } = operationName; + } + + public class ViewUserAuthorizationHandler : AuthorizationHandler + { + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, UserAccountAuthorizationRequirement requirement, string targetUserId) + { + if (context.User == null || requirement.OperationName != UserAccountManagementOperations.ReadOperationName) + return Task.CompletedTask; + + if (context.User.HasClaim(CustomClaims.Permission, ApplicationPermissions.ViewUsers) + || GetIsSameUser(context.User, targetUserId)) + context.Succeed(requirement); + + return Task.CompletedTask; + } + + private static bool GetIsSameUser(ClaimsPrincipal user, string targetUserId) + { + if (string.IsNullOrWhiteSpace(targetUserId)) + return false; + + return Utilities.GetUserId(user) == targetUserId; + } + } + + public class ManageUserAuthorizationHandler : AuthorizationHandler + { + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, UserAccountAuthorizationRequirement requirement, string targetUserId) + { + if (context.User == null || + (requirement.OperationName != UserAccountManagementOperations.CreateOperationName && + requirement.OperationName != UserAccountManagementOperations.UpdateOperationName && + requirement.OperationName != UserAccountManagementOperations.DeleteOperationName)) + return Task.CompletedTask; + + if (context.User.HasClaim(CustomClaims.Permission, ApplicationPermissions.ManageUsers) + || GetIsSameUser(context.User, targetUserId)) + context.Succeed(requirement); + + return Task.CompletedTask; + } + + private static bool GetIsSameUser(ClaimsPrincipal user, string targetUserId) + { + if (string.IsNullOrWhiteSpace(targetUserId)) + return false; + + return Utilities.GetUserId(user) == targetUserId; + } + } +} \ No newline at end of file diff --git a/src/Services/Identity/Identity.API/Authorization/Requirements/ViewRoleAuthorizationRequirement.cs b/src/Services/Identity/Identity.API/Authorization/Requirements/ViewRoleAuthorizationRequirement.cs new file mode 100644 index 0000000..5eaee01 --- /dev/null +++ b/src/Services/Identity/Identity.API/Authorization/Requirements/ViewRoleAuthorizationRequirement.cs @@ -0,0 +1,34 @@ +// --------------------------------------- +// Email: quickapp@ebenmonney.com +// Templates: www.ebenmonney.com/templates +// (c) 2024 www.ebenmonney.com/mit-license +// --------------------------------------- + +using Microsoft.AspNetCore.Authorization; +using Identity.Domain.Authorization; +using Identity.Domain.Exceptions; +using Identity.Domain.Interfaces; + +namespace Identity.API.Authorization.Requirements +{ + public class ViewRoleAuthorizationRequirement : IAuthorizationRequirement + { + + } + + public class ViewRoleAuthorizationHandler : AuthorizationHandler + { + protected override Task HandleRequirementAsync( + AuthorizationHandlerContext context, ViewRoleAuthorizationRequirement requirement, string roleName) + { + if (context.User == null) + return Task.CompletedTask; + + if (context.User.HasClaim(CustomClaims.Permission, ApplicationPermissions.ViewRoles) + || context.User.IsInRole(roleName)) + context.Succeed(requirement); + + return Task.CompletedTask; + } + } +} diff --git a/src/Services/Identity/Identity.API/Authorization/SwaggerAuthorizeOperationFilter.cs b/src/Services/Identity/Identity.API/Authorization/SwaggerAuthorizeOperationFilter.cs new file mode 100644 index 0000000..44aa792 --- /dev/null +++ b/src/Services/Identity/Identity.API/Authorization/SwaggerAuthorizeOperationFilter.cs @@ -0,0 +1,40 @@ +// --------------------------------------- +// Email: quickapp@ebenmonney.com +// Templates: www.ebenmonney.com/templates +// (c) 2024 www.ebenmonney.com/mit-license +// --------------------------------------- + +using Microsoft.AspNetCore.Authorization; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Identity.API.Authorization +{ + // Swagger IOperationFilter implementation that will decide which api action needs authorization + internal class SwaggerAuthorizeOperationFilter : IOperationFilter + { + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + // Check for authorize attribute + var hasAuthorize = context.MethodInfo.DeclaringType?.GetCustomAttributes(true) + .Union(context.MethodInfo.GetCustomAttributes(true)) + .OfType() + .Any(); + + if (hasAuthorize == true) + { + operation.Responses.Add("401", new OpenApiResponse { Description = "Unauthorized" }); + + var oAuthScheme = new OpenApiSecurityScheme + { + Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "oauth2" } + }; + + operation.Security = + [ + new() { [oAuthScheme] = [] } + ]; + } + } + } +} diff --git a/src/Services/Identity/Identity.API/Authorization/UserAccountManagementOperations.cs b/src/Services/Identity/Identity.API/Authorization/UserAccountManagementOperations.cs new file mode 100644 index 0000000..6d0519a --- /dev/null +++ b/src/Services/Identity/Identity.API/Authorization/UserAccountManagementOperations.cs @@ -0,0 +1,26 @@ +// --------------------------------------- +// Email: quickapp@ebenmonney.com +// Templates: www.ebenmonney.com/templates +// (c) 2024 www.ebenmonney.com/mit-license +// --------------------------------------- + +using Identity.API.Authorization.Requirements; + +namespace Identity.API.Authorization +{ + /// + /// Operation Policy to allow adding, viewing, updating and deleting general or specific user records. + /// + public static class UserAccountManagementOperations + { + public const string CreateOperationName = "Create"; + public const string ReadOperationName = "Read"; + public const string UpdateOperationName = "Update"; + public const string DeleteOperationName = "Delete"; + + public static readonly UserAccountAuthorizationRequirement CreateOperationRequirement = new(CreateOperationName); + public static readonly UserAccountAuthorizationRequirement ReadOperationRequirement = new(ReadOperationName); + public static readonly UserAccountAuthorizationRequirement UpdateOperationRequirement = new(UpdateOperationName); + public static readonly UserAccountAuthorizationRequirement DeleteOperationRequirement = new(DeleteOperationName); + } +} diff --git a/src/Services/Identity/Identity.API/Configuration/MappingProfile.cs b/src/Services/Identity/Identity.API/Configuration/MappingProfile.cs new file mode 100644 index 0000000..f633239 --- /dev/null +++ b/src/Services/Identity/Identity.API/Configuration/MappingProfile.cs @@ -0,0 +1,53 @@ +// --------------------------------------- +// Email: quickapp@ebenmonney.com +// Templates: www.ebenmonney.com/templates +// (c) 2024 www.ebenmonney.com/mit-license +// --------------------------------------- + +using AutoMapper; +using Microsoft.AspNetCore.Identity; +using Identity.Domain.Authorization; +using Identity.Domain.Entities; +using Identity.API.ViewModels.Account; + +namespace Identity.API.Configuration +{ + public class MappingProfile : Profile + { + public MappingProfile() + { + CreateMap() + .ForMember(d => d.Roles, map => map.Ignore()); + CreateMap() + .ForMember(d => d.Roles, map => map.Ignore()) + .ForMember(d => d.Id, map => map.Condition(src => src.Id != null)); + + CreateMap() + .ForMember(d => d.Roles, map => map.Ignore()); + CreateMap() + .ForMember(d => d.Roles, map => map.Ignore()) + .ForMember(d => d.Id, map => map.Condition(src => src.Id != null)); + + CreateMap() + .ReverseMap(); + + CreateMap() + .ForMember(d => d.Permissions, map => map.MapFrom(s => s.Claims)) + .ForMember(d => d.UsersCount, map => map.MapFrom(s => s.Users != null ? s.Users.Count : 0)) + .ReverseMap(); + CreateMap() + .ForMember(d => d.Id, map => map.Condition(src => src.Id != null)); + + CreateMap, ClaimVM>() + .ForMember(d => d.Type, map => map.MapFrom(s => s.ClaimType)) + .ForMember(d => d.Value, map => map.MapFrom(s => s.ClaimValue)) + .ReverseMap(); + + CreateMap() + .ReverseMap(); + + CreateMap, PermissionVM>() + .ConvertUsing(s => ((PermissionVM)ApplicationPermissions.GetPermissionByValue(s.ClaimValue))!); + } + } +} diff --git a/src/Services/Identity/Identity.API/Configuration/OidcServerConfig.cs b/src/Services/Identity/Identity.API/Configuration/OidcServerConfig.cs new file mode 100644 index 0000000..d436d8c --- /dev/null +++ b/src/Services/Identity/Identity.API/Configuration/OidcServerConfig.cs @@ -0,0 +1,61 @@ +// --------------------------------------- +// Email: quickapp@ebenmonney.com +// Templates: www.ebenmonney.com/templates +// (c) 2024 www.ebenmonney.com/mit-license +// --------------------------------------- + +using OpenIddict.Abstractions; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Identity.API.Configuration +{ + public static class OidcServerConfig + { + public const string ServerName = "QuickApp API"; + public const string QuickAppClientID = "quickapp_spa"; + public const string SwaggerClientID = "swagger_ui"; + + public static async Task RegisterClientApplicationsAsync(IServiceProvider provider) + { + var manager = provider.GetRequiredService(); + + // Angular SPA Client + if (await manager.FindByClientIdAsync(QuickAppClientID) is null) + { + await manager.CreateAsync(new OpenIddictApplicationDescriptor + { + ClientId = QuickAppClientID, + ClientType = ClientTypes.Public, + DisplayName = "QuickApp SPA", + Permissions = + { + Permissions.Endpoints.Token, + Permissions.GrantTypes.Password, + Permissions.GrantTypes.RefreshToken, + Permissions.Scopes.Profile, + Permissions.Scopes.Email, + Permissions.Scopes.Phone, + Permissions.Scopes.Address, + Permissions.Scopes.Roles + } + }); + } + + // Swagger UI Client + if (await manager.FindByClientIdAsync(SwaggerClientID) is null) + { + await manager.CreateAsync(new OpenIddictApplicationDescriptor + { + ClientId = SwaggerClientID, + ClientType = ClientTypes.Public, + DisplayName = "Swagger UI", + Permissions = + { + Permissions.Endpoints.Token, + Permissions.GrantTypes.Password + } + }); + } + } + } +} diff --git a/src/Services/Identity/Identity.API/Controllers/AuthorizationController.cs b/src/Services/Identity/Identity.API/Controllers/AuthorizationController.cs new file mode 100644 index 0000000..81c3710 --- /dev/null +++ b/src/Services/Identity/Identity.API/Controllers/AuthorizationController.cs @@ -0,0 +1,194 @@ +// --------------------------------------- +// Email: quickapp@ebenmonney.com +// Templates: www.ebenmonney.com/templates +// (c) 2024 www.ebenmonney.com/mit-license +// --------------------------------------- + +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using Identity.Domain.Entities; +using Identity.Domain.Authorization; +using Identity.Domain.Exceptions; +using Identity.Domain.Interfaces; +using System.Security.Claims; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Identity.API.Controllers +{ + public class AuthorizationController : Controller + { + private readonly SignInManager _signInManager; + private readonly UserManager _userManager; + + public AuthorizationController(SignInManager signInManager, + UserManager userManager) + { + _signInManager = signInManager; + _userManager = userManager; + } + + [HttpPost("~/connect/token")] + [Produces("application/json")] + [ApiExplorerSettings(IgnoreApi = true)] + public async Task Exchange() + { + var request = HttpContext.GetOpenIddictServerRequest() + ?? throw new InvalidOperationException("The OpenID Connect request cannot be retrieved."); + + if (request.IsPasswordGrantType()) + { + if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password)) + return GetForbidResult("Username or password cannot be empty."); + + var user = await _userManager.FindByNameAsync(request.Username) + ?? await _userManager.FindByEmailAsync(request.Username); + + if (user == null) + return GetForbidResult("Please check that your username and password is correct."); + + if (!user.IsEnabled) + return GetForbidResult("The specified user account is disabled."); + + var result = + await _signInManager.CheckPasswordSignInAsync(user, request.Password, lockoutOnFailure: true); + + if (result.IsLockedOut) + return GetForbidResult("The specified user account has been suspended."); + + if (result.IsNotAllowed) + return GetForbidResult("The specified user is not allowed to sign in."); + + if (!result.Succeeded) + return GetForbidResult("Please check that your username and password is correct."); + + var principal = await CreateClaimsPrincipalAsync(user, request.GetScopes()); + + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + else if (request.IsRefreshTokenGrantType()) + { + var result = + await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + + var userId = result?.Principal?.GetClaim(Claims.Subject); + var user = userId != null ? await _userManager.FindByIdAsync(userId) : null; + + if (user == null) + return GetForbidResult("The refresh token is no longer valid."); + + if (!user.IsEnabled) + return GetForbidResult("The specified user account is disabled."); + + if (!await _signInManager.CanSignInAsync(user)) + return GetForbidResult("The user is no longer allowed to sign in."); + + var scopes = request.GetScopes(); + if (scopes.Length == 0 && result?.Principal != null) + scopes = result.Principal.GetScopes(); + + // Recreate the claims principal in case they changed since the refresh token was issued. + var principal = await CreateClaimsPrincipalAsync(user, scopes); + + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + throw new InvalidOperationException($"The specified grant type \"{request.GrantType}\" is not supported."); + } + + private ForbidResult GetForbidResult(string errorDescription, string error = Errors.InvalidGrant) + { + var properties = new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = error, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = errorDescription + }); + + return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + private async Task CreateClaimsPrincipalAsync(ApplicationUser user, IEnumerable scopes) + { + var principal = await _signInManager.CreateUserPrincipalAsync(user); + principal.SetScopes(scopes); + + var identity = principal.Identity as ClaimsIdentity + ?? throw new InvalidOperationException("The ClaimsPrincipal's Identity is null."); + + if (user.JobTitle != null) identity.SetClaim(CustomClaims.JobTitle, user.JobTitle); + if (user.FullName != null) identity.SetClaim(CustomClaims.FullName, user.FullName); + if (user.Configuration != null) identity.SetClaim(CustomClaims.Configuration, user.Configuration); + + principal.SetDestinations(GetDestinations); + + return principal; + } + + private static IEnumerable GetDestinations(Claim claim) + { + if (claim.Subject == null) + throw new InvalidOperationException("The Claim's Subject is null."); + + switch (claim.Type) + { + case Claims.Name: + if (claim.Subject.HasScope(Scopes.Profile)) + yield return Destinations.IdentityToken; + + yield break; + + case Claims.Email: + if (claim.Subject.HasScope(Scopes.Email)) + yield return Destinations.IdentityToken; + + yield break; + + case CustomClaims.JobTitle: + if (claim.Subject.HasScope(Scopes.Profile)) + yield return Destinations.IdentityToken; + + yield break; + + case CustomClaims.FullName: + if (claim.Subject.HasScope(Scopes.Profile)) + yield return Destinations.IdentityToken; + + yield break; + + case CustomClaims.Configuration: + if (claim.Subject.HasScope(Scopes.Profile)) + yield return Destinations.IdentityToken; + + yield break; + + case Claims.Role: + yield return Destinations.AccessToken; + + if (claim.Subject.HasScope(Scopes.Roles)) + yield return Destinations.IdentityToken; + + yield break; + + case CustomClaims.Permission: + yield return Destinations.AccessToken; + + if (claim.Subject.HasScope(Scopes.Roles)) + yield return Destinations.IdentityToken; + + yield break; + + // IdentityOptions.ClaimsIdentity.SecurityStampClaimType + case "AspNet.Identity.SecurityStamp": + // Never include the security stamp in the access and identity tokens, as it's a secret value. + yield break; + + default: + yield return Destinations.AccessToken; + yield break; + } + } + } +} diff --git a/src/Services/Identity/Identity.API/Controllers/BaseApiController.cs b/src/Services/Identity/Identity.API/Controllers/BaseApiController.cs new file mode 100644 index 0000000..2eeaeed --- /dev/null +++ b/src/Services/Identity/Identity.API/Controllers/BaseApiController.cs @@ -0,0 +1,49 @@ +// --------------------------------------- +// Email: quickapp@ebenmonney.com +// Templates: www.ebenmonney.com/templates +// (c) 2024 www.ebenmonney.com/mit-license +// --------------------------------------- + +using AutoMapper; +using Microsoft.AspNetCore.Mvc; +using Identity.Domain.Authorization; +using Identity.Domain.Exceptions; +using Identity.Domain.Interfaces; +using Identity.API.Attributes; +using Identity.API.Services; + +namespace Identity.API.Controllers +{ + [Route("api/[controller]")] + [ApiController] + [SanitizeModel] + public class BaseApiController : ControllerBase + { + protected readonly IMapper _mapper; + protected readonly ILogger _logger; + + public BaseApiController(ILogger logger, IMapper mapper) + { + _logger = logger; + _mapper = mapper; + } + + protected string GetCurrentUserId(string errorMsg = "Error retrieving the userId for the current user.") + { + return Utilities.GetUserId(User) ?? throw new UserNotFoundException(errorMsg); + } + + protected void AddModelError(IEnumerable errors, string key = "") + { + foreach (var error in errors) + { + AddModelError(error, key); + } + } + + protected void AddModelError(string error, string key = "") + { + ModelState.AddModelError(key, error); + } + } +} diff --git a/src/Services/Identity/Identity.API/Controllers/IdentityController.cs b/src/Services/Identity/Identity.API/Controllers/IdentityController.cs deleted file mode 100644 index 873b3f2..0000000 --- a/src/Services/Identity/Identity.API/Controllers/IdentityController.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace Identity.API.Controllers; - -[ApiController] -[Route("api/[controller]")] -public class IdentityController : ControllerBase -{ - private readonly ILogger _logger; - - public IdentityController(ILogger logger) - { - _logger = logger; - } - - [HttpGet] - public IActionResult GetAll() - { - // TODO: Implement — migrate logic from monolith's IdentityController - return Ok(new { service = "Identity", status = "scaffold" }); - } - - [HttpGet("{id}")] - public IActionResult GetById(int id) - { - // TODO: Implement — migrate logic from monolith - return Ok(new { service = "Identity", id }); - } -} diff --git a/src/Services/Identity/Identity.API/Controllers/UserAccountController.cs b/src/Services/Identity/Identity.API/Controllers/UserAccountController.cs new file mode 100644 index 0000000..469a172 --- /dev/null +++ b/src/Services/Identity/Identity.API/Controllers/UserAccountController.cs @@ -0,0 +1,379 @@ +// --------------------------------------- +// Email: quickapp@ebenmonney.com +// Templates: www.ebenmonney.com/templates +// (c) 2024 www.ebenmonney.com/mit-license +// --------------------------------------- + +using AutoMapper; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.JsonPatch; +using Microsoft.AspNetCore.Mvc; +using Identity.Domain.Entities; +using Identity.Domain.Authorization; +using Identity.Domain.Exceptions; +using Identity.Domain.Interfaces; +using Identity.API.Authorization; +using Identity.API.ViewModels.Account; + +namespace Identity.API.Controllers +{ + [Route("api/account")] + [Authorize] + public class UserAccountController : BaseApiController + { + private readonly IUserAccountService _userAccountService; + private readonly IAuthorizationService _authorizationService; + + public UserAccountController(ILogger logger, IMapper mapper, + IUserAccountService userAccountService, IAuthorizationService authorizationService) : base(logger, mapper) + { + _userAccountService = userAccountService; + _authorizationService = authorizationService; + } + + [HttpGet("users/me")] + [ProducesResponseType(200, Type = typeof(UserVM))] + public async Task GetCurrentUser() + { + return await GetUserById(GetCurrentUserId()); + } + + [HttpGet("users/{id}", Name = nameof(GetUserById))] + [ProducesResponseType(200, Type = typeof(UserVM))] + [ProducesResponseType(403)] + [ProducesResponseType(404)] + public async Task GetUserById(string id) + { + if (!(await _authorizationService.AuthorizeAsync(User, id, + UserAccountManagementOperations.ReadOperationRequirement)).Succeeded) + return new ChallengeResult(); + + var userVM = await GetUserViewModelHelper(id); + + if (userVM != null) + return Ok(userVM); + + return NotFound(id); + } + + [HttpGet("users/username/{userName}")] + [ProducesResponseType(200, Type = typeof(UserVM))] + [ProducesResponseType(403)] + [ProducesResponseType(404)] + public async Task GetUserByUserName(string userName) + { + var appUser = await _userAccountService.GetUserByUserNameAsync(userName); + + if (!(await _authorizationService.AuthorizeAsync(User, appUser?.Id ?? string.Empty, + UserAccountManagementOperations.ReadOperationRequirement)).Succeeded) + return new ChallengeResult(); + + var userVM = appUser != null ? await GetUserViewModelHelper(appUser.Id) : null; + + if (userVM != null) + return Ok(userVM); + + return NotFound(userName); + } + + [HttpGet("users")] + [Authorize(AuthPolicies.ViewAllUsersPolicy)] + [ProducesResponseType(200, Type = typeof(List))] + public async Task GetUsers() + { + return await GetUsers(-1, -1); + } + + [HttpGet("users/{pageNumber:int}/{pageSize:int}")] + [Authorize(AuthPolicies.ViewAllUsersPolicy)] + [ProducesResponseType(200, Type = typeof(List))] + public async Task GetUsers(int pageNumber, int pageSize) + { + var usersAndRoles = await _userAccountService.GetUsersAndRolesAsync(pageNumber, pageSize); + + var usersVM = new List(); + + foreach (var item in usersAndRoles) + { + var userVM = _mapper.Map(item.User); + userVM.Roles = item.Roles; + + usersVM.Add(userVM); + } + + return Ok(usersVM); + } + + [HttpPut("users/me")] + [ProducesResponseType(204)] + [ProducesResponseType(400)] + [ProducesResponseType(403)] + public async Task UpdateCurrentUser([FromBody] UserEditVM user) + { + var userId = GetCurrentUserId($"Error retrieving the userId for user \"{user.UserName}\"."); + return await UpdateUser(userId, user); + } + + [HttpPut("users/{id}")] + [ProducesResponseType(204)] + [ProducesResponseType(400)] + [ProducesResponseType(403)] + [ProducesResponseType(404)] + public async Task UpdateUser(string id, [FromBody] UserEditVM user) + { + var appUser = await _userAccountService.GetUserByIdAsync(id); + var currentRoles = appUser != null + ? (await _userAccountService.GetUserRolesAsync(appUser)).ToArray() : null; + + var manageUsersPolicy = _authorizationService.AuthorizeAsync(User, id, + UserAccountManagementOperations.UpdateOperationRequirement); + var assignRolePolicy = _authorizationService.AuthorizeAsync(User, (user.Roles, currentRoles), + AuthPolicies.AssignAllowedRolesPolicy); + + if ((await Task.WhenAll(manageUsersPolicy, assignRolePolicy)).Any(r => !r.Succeeded)) + return new ChallengeResult(); + + if (appUser == null) + return NotFound(id); + + if (!string.IsNullOrWhiteSpace(user.Id) && id != user.Id) + AddModelError("Conflicting user id in parameter and model data.", nameof(id)); + + var isNewPassword = !string.IsNullOrWhiteSpace(user.NewPassword); + var isNewUserName = !appUser.UserName!.Equals(user.UserName, StringComparison.OrdinalIgnoreCase); + + if (GetCurrentUserId() == id) + { + if (string.IsNullOrWhiteSpace(user.CurrentPassword)) + { + if (isNewPassword) + AddModelError("Current password is required when changing your own password.", "Password"); + + if (isNewUserName) + AddModelError("Current password is required when changing your own username.", "Username"); + } + else if (isNewPassword || isNewUserName) + { + if (!await _userAccountService.CheckPasswordAsync(appUser, user.CurrentPassword)) + AddModelError("The username/password couple is invalid."); + } + } + + if (ModelState.IsValid) + { + _mapper.Map(user, appUser); + + var result = await _userAccountService.UpdateUserAsync(appUser, user.Roles); + + if (result.Succeeded) + { + if (isNewPassword) + { + if (!string.IsNullOrWhiteSpace(user.CurrentPassword)) + result = await _userAccountService.UpdatePasswordAsync(appUser, user.CurrentPassword, + user.NewPassword!); + else + result = await _userAccountService.ResetPasswordAsync(appUser, user.NewPassword!); + } + + if (result.Succeeded) + return NoContent(); + } + + AddModelError(result.Errors); + } + + return BadRequest(ModelState); + } + + [HttpPatch("users/me")] + [ProducesResponseType(204)] + [ProducesResponseType(400)] + public async Task UpdateCurrentUser([FromBody] JsonPatchDocument patch) + { + return await UpdateUser(GetCurrentUserId(), patch); + } + + [HttpPatch("users/{id}")] + [ProducesResponseType(204)] + [ProducesResponseType(400)] + [ProducesResponseType(403)] + [ProducesResponseType(404)] + public async Task UpdateUser(string id, [FromBody] JsonPatchDocument patch) + { + if (!(await _authorizationService.AuthorizeAsync(User, id, + UserAccountManagementOperations.UpdateOperationRequirement)).Succeeded) + return new ChallengeResult(); + + var appUser = await _userAccountService.GetUserByIdAsync(id); + if (appUser == null) + return NotFound(id); + + var userPVM = _mapper.Map(appUser); + patch.ApplyTo(userPVM, e => AddModelError(e.ErrorMessage)); + + if (ModelState.IsValid) + { + _mapper.Map(userPVM, appUser); + + var result = await _userAccountService.UpdateUserAsync(appUser); + + if (result.Succeeded) + return NoContent(); + + AddModelError(result.Errors); + } + + return BadRequest(ModelState); + } + + [HttpPost("users")] + [Authorize(AuthPolicies.ManageAllUsersPolicy)] + [ProducesResponseType(201, Type = typeof(UserVM))] + [ProducesResponseType(400)] + [ProducesResponseType(403)] + public async Task Register([FromBody] UserEditVM user) + { + if (!(await _authorizationService.AuthorizeAsync(User, (user.Roles, Array.Empty()), + AuthPolicies.AssignAllowedRolesPolicy)).Succeeded) + return new ChallengeResult(); + + if (string.IsNullOrWhiteSpace(user.NewPassword)) + AddModelError($"{nameof(user.NewPassword)} is required when registering a new user.", + nameof(user.NewPassword)); + + if (user.Roles == null) + AddModelError($"{nameof(user.Roles)} is required when registering a new user.", nameof(user.Roles)); + + if (ModelState.IsValid) + { + var appUser = _mapper.Map(user); + var result = await _userAccountService.CreateUserAsync(appUser, user.Roles!, user.NewPassword!); + + if (result.Succeeded) + { + var userVM = await GetUserViewModelHelper(appUser.Id); + return CreatedAtAction(nameof(GetUserById), new { id = userVM?.Id }, userVM); + } + + AddModelError(result.Errors); + } + + return BadRequest(ModelState); + } + + [HttpDelete("users/{id}")] + [ProducesResponseType(200, Type = typeof(UserVM))] + [ProducesResponseType(400)] + [ProducesResponseType(403)] + [ProducesResponseType(404)] + public async Task DeleteUser(string id) + { + if (!(await _authorizationService.AuthorizeAsync(User, id, + UserAccountManagementOperations.DeleteOperationRequirement)).Succeeded) + return new ChallengeResult(); + + var appUser = await _userAccountService.GetUserByIdAsync(id); + + if (appUser == null) + return NotFound(id); + + var canDelete = await _userAccountService.TestCanDeleteUserAsync(id); + if (!canDelete.Success) + { + AddModelError($"User \"{appUser.UserName}\" cannot be deleted at this time. " + + "Delete the associated records and try again."); + AddModelError(canDelete.Errors, "Records"); + } + + if (ModelState.IsValid) + { + var userVM = await GetUserViewModelHelper(appUser.Id); + var result = await _userAccountService.DeleteUserAsync(appUser); + + if (!result.Succeeded) + { + throw new UserAccountException($"The following errors occurred whilst deleting user \"{id}\": " + + $"{string.Join(", ", result.Errors)}"); + } + + return Ok(userVM); + } + + return BadRequest(ModelState); + } + + [HttpPut("users/unblock/{id}")] + [Authorize(AuthPolicies.ManageAllUsersPolicy)] + [ProducesResponseType(204)] + [ProducesResponseType(404)] + public async Task UnblockUser(string id) + { + var appUser = await _userAccountService.GetUserByIdAsync(id); + + if (appUser == null) + return NotFound(id); + + appUser.LockoutEnd = null; + var result = await _userAccountService.UpdateUserAsync(appUser); + + if (!result.Succeeded) + { + throw new UserAccountException($"The following errors occurred whilst unblocking user: " + + $"{string.Join(", ", result.Errors)}"); + } + + return NoContent(); + } + + [HttpGet("users/me/preferences")] + [ProducesResponseType(200, Type = typeof(string))] + public async Task UserPreferences() + { + var userId = GetCurrentUserId(); + + var appUser = await _userAccountService.GetUserByIdAsync(userId); + if (appUser != null) + return Ok(appUser.Configuration); + + return NotFound(userId); + } + + [HttpPut("users/me/preferences")] + [ProducesResponseType(204)] + public async Task UserPreferences([FromBody] string? data) + { + var userId = GetCurrentUserId(); + var appUser = await _userAccountService.GetUserByIdAsync(userId); + + if (appUser != null) + { + appUser.Configuration = data; + var result = await _userAccountService.UpdateUserAsync(appUser); + + if (!result.Succeeded) + { + throw new UserAccountException( + $"The following errors occurred whilst updating User Configurations: " + + $"{string.Join(", ", result.Errors)}"); + } + + return NoContent(); + } + + return NotFound(userId); + } + + private async Task GetUserViewModelHelper(string userId) + { + var userAndRoles = await _userAccountService.GetUserAndRolesAsync(userId); + if (userAndRoles == null) + return null; + + var userVM = _mapper.Map(userAndRoles.Value.User); + userVM.Roles = userAndRoles.Value.Roles; + + return userVM; + } + } +} diff --git a/src/Services/Identity/Identity.API/Controllers/UserRoleController.cs b/src/Services/Identity/Identity.API/Controllers/UserRoleController.cs new file mode 100644 index 0000000..aa78aa4 --- /dev/null +++ b/src/Services/Identity/Identity.API/Controllers/UserRoleController.cs @@ -0,0 +1,201 @@ +// --------------------------------------- +// Email: quickapp@ebenmonney.com +// Templates: www.ebenmonney.com/templates +// (c) 2024 www.ebenmonney.com/mit-license +// --------------------------------------- + +using AutoMapper; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Identity.Domain.Entities; +using Identity.Domain.Authorization; +using Identity.Domain.Exceptions; +using Identity.Domain.Interfaces; +using Identity.API.Authorization; +using Identity.API.ViewModels.Account; +using System.Data; + +namespace Identity.API.Controllers +{ + [Route("api/account")] + [Authorize] + public class UserRoleController : BaseApiController + { + private readonly IUserRoleService _userRoleService; + private readonly IAuthorizationService _authorizationService; + + public UserRoleController(ILogger logger, IMapper mapper, + IUserRoleService userRoleService, IAuthorizationService authorizationService) : base(logger, mapper) + { + _userRoleService = userRoleService; + _authorizationService = authorizationService; + } + + [HttpGet("roles/{id}", Name = nameof(GetRoleById))] + [ProducesResponseType(200, Type = typeof(RoleVM))] + [ProducesResponseType(403)] + [ProducesResponseType(404)] + public async Task GetRoleById(string id) + { + var appRole = await _userRoleService.GetRoleByIdAsync(id); + + if (!(await _authorizationService.AuthorizeAsync(User, appRole?.Name ?? string.Empty, + AuthPolicies.ViewRoleByRoleNamePolicy)).Succeeded) + return new ChallengeResult(); + + var roleVM = appRole != null ? await GetRoleViewModelHelper(appRole.Name!) : null; + + if (roleVM != null) + return Ok(roleVM); + + return NotFound(id); + } + + [HttpGet("roles/name/{name}")] + [ProducesResponseType(200, Type = typeof(RoleVM))] + [ProducesResponseType(403)] + [ProducesResponseType(404)] + public async Task GetRoleByName(string name) + { + if (!(await _authorizationService.AuthorizeAsync(User, name, + AuthPolicies.ViewRoleByRoleNamePolicy)).Succeeded) + return new ChallengeResult(); + + var roleVM = await GetRoleViewModelHelper(name); + + if (roleVM != null) + return Ok(roleVM); + + return NotFound(name); + } + + [HttpGet("roles")] + [Authorize(AuthPolicies.ViewAllRolesPolicy)] + [ProducesResponseType(200, Type = typeof(List))] + public async Task GetRoles() + { + return await GetRoles(-1, -1); + } + + [HttpGet("roles/{pageNumber:int}/{pageSize:int}")] + [Authorize(AuthPolicies.ViewAllRolesPolicy)] + [ProducesResponseType(200, Type = typeof(List))] + public async Task GetRoles(int pageNumber, int pageSize) + { + var roles = await _userRoleService.GetRolesLoadRelatedAsync(pageNumber, pageSize); + return Ok(_mapper.Map>(roles)); + } + + [HttpPut("roles/{id}")] + [Authorize(AuthPolicies.ManageAllRolesPolicy)] + [ProducesResponseType(204)] + [ProducesResponseType(400)] + [ProducesResponseType(404)] + public async Task UpdateRole(string id, [FromBody] RoleVM role) + { + if (role == null) + return BadRequest($"{nameof(role)} cannot be null"); + + var appRole = await _userRoleService.GetRoleByIdAsync(id); + + if (appRole == null) + return NotFound(id); + + if (!string.IsNullOrWhiteSpace(role.Id) && id != role.Id) + return BadRequest("Conflicting role id in parameter and model data"); + + _mapper.Map(role, appRole); + + var result = await _userRoleService + .UpdateRoleAsync(appRole, role.Permissions?.Select(p => p.Value!).ToArray()); + + if (result.Succeeded) + return NoContent(); + + AddModelError(result.Errors); + + return BadRequest(ModelState); + } + + [HttpPost("roles")] + [Authorize(AuthPolicies.ManageAllRolesPolicy)] + [ProducesResponseType(201, Type = typeof(RoleVM))] + [ProducesResponseType(400)] + public async Task CreateRole([FromBody] RoleVM role) + { + if (role == null) + return BadRequest($"{nameof(role)} cannot be null"); + + var appRole = _mapper.Map(role); + + var result = await _userRoleService + .CreateRoleAsync(appRole, role.Permissions?.Select(p => p.Value!).ToArray() ?? []); + + if (result.Succeeded) + { + var roleVM = await GetRoleViewModelHelper(appRole.Name!); + return CreatedAtAction(nameof(GetRoleById), new { id = roleVM?.Id }, roleVM); + } + + AddModelError(result.Errors); + + return BadRequest(ModelState); + } + + [HttpDelete("roles/{id}")] + [Authorize(AuthPolicies.ManageAllRolesPolicy)] + [ProducesResponseType(200, Type = typeof(RoleVM))] + [ProducesResponseType(400)] + [ProducesResponseType(404)] + public async Task DeleteRole(string id) + { + var appRole = await _userRoleService.GetRoleByIdAsync(id); + + if (appRole == null) + return NotFound(id); + + var canDelete = await _userRoleService.TestCanDeleteRoleAsync(id); + if (!canDelete.Success) + { + AddModelError($"Role \"{appRole.Name}\" cannot be deleted at this time. " + + "Delete the associated records and try again"); + AddModelError(canDelete.Errors, "Records"); + } + + if (ModelState.IsValid) + { + var roleVM = await GetRoleViewModelHelper(appRole.Name!, false); + var result = await _userRoleService.DeleteRoleAsync(appRole); + + if (!result.Succeeded) + { + throw new UserRoleException($"The following errors occurred whilst deleting role \"{id}\": " + + $"{string.Join(", ", result.Errors)}"); + } + + return Ok(roleVM); + } + + return BadRequest(ModelState); + } + + [HttpGet("permissions")] + [Authorize(AuthPolicies.ViewAllRolesPolicy)] + [ProducesResponseType(200, Type = typeof(List))] + public IActionResult GetAllPermissions() + { + return Ok(_mapper.Map>(ApplicationPermissions.AllPermissions)); + } + + private async Task GetRoleViewModelHelper(string roleName, bool loadRelatedEntities = true) + { + var role = loadRelatedEntities ? await _userRoleService.GetRoleLoadRelatedAsync(roleName) + : await _userRoleService.GetRoleByNameAsync(roleName); + + if (role != null) + return _mapper.Map(role); + + return null; + } + } +} diff --git a/src/Services/Identity/Identity.API/Extensions/StringExtensions.cs b/src/Services/Identity/Identity.API/Extensions/StringExtensions.cs new file mode 100644 index 0000000..cdb2c11 --- /dev/null +++ b/src/Services/Identity/Identity.API/Extensions/StringExtensions.cs @@ -0,0 +1,13 @@ +// --------------------------------------- +// Email: quickapp@ebenmonney.com +// Templates: www.ebenmonney.com/templates +// (c) 2024 www.ebenmonney.com/mit-license +// --------------------------------------- + +namespace Identity.API.Extensions +{ + public static class StringExtensions + { + public static string? NullIfWhiteSpace(this string? value) => string.IsNullOrWhiteSpace(value) ? null : value; + } +} diff --git a/src/Services/Identity/Identity.API/Identity.API.csproj b/src/Services/Identity/Identity.API/Identity.API.csproj index 9b3e932..86f760a 100644 --- a/src/Services/Identity/Identity.API/Identity.API.csproj +++ b/src/Services/Identity/Identity.API/Identity.API.csproj @@ -7,11 +7,20 @@ - - + + - - + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/src/Services/Identity/Identity.API/Program.cs b/src/Services/Identity/Identity.API/Program.cs index b475a08..51a2c85 100644 --- a/src/Services/Identity/Identity.API/Program.cs +++ b/src/Services/Identity/Identity.API/Program.cs @@ -1,25 +1,234 @@ +using System.Reflection; +using System.Security.Cryptography.X509Certificates; +using Identity.API.Authorization; +using Identity.API.Authorization.Requirements; +using Identity.API.Configuration; +using Identity.API.Services; +using Identity.Domain.Authorization; +using Identity.Domain.Entities; +using Identity.Domain.Interfaces; using Identity.Infrastructure.Data; +using Identity.Infrastructure.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Logging; +using Microsoft.OpenApi.Models; +using OpenIddict.Validation.AspNetCore; +using Quartz; +using static OpenIddict.Abstractions.OpenIddictConstants; var builder = WebApplication.CreateBuilder(args); +/************* ADD SERVICES *************/ + +var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? + throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); + +var migrationsAssembly = typeof(IdentityDbContext).GetTypeInfo().Assembly.GetName().Name; + +builder.Services.AddDbContext(options => +{ + options.UseNpgsql(connectionString, b => b.MigrationsAssembly(migrationsAssembly)); + options.UseOpenIddict(); +}); + +// Add Identity +builder.Services.AddIdentity() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + +// Configure Identity options and password complexity here +builder.Services.Configure(options => +{ + // User settings + options.User.RequireUniqueEmail = true; + + // Configure Identity to use the same JWT claims as OpenIddict + options.ClaimsIdentity.UserNameClaimType = Claims.Name; + options.ClaimsIdentity.UserIdClaimType = Claims.Subject; + options.ClaimsIdentity.RoleClaimType = Claims.Role; + options.ClaimsIdentity.EmailClaimType = Claims.Email; +}); + +// Configure OpenIddict periodic pruning of orphaned authorizations/tokens from the database. +builder.Services.AddQuartz(options => +{ + options.UseSimpleTypeLoader(); + options.UseInMemoryStore(); +}); + +// Register the Quartz.NET service and configure it to block shutdown until jobs are complete. +builder.Services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true); + +builder.Services.AddOpenIddict() + .AddCore(options => + { + options.UseEntityFrameworkCore() + .UseDbContext(); + + options.UseQuartz(); + }) + .AddServer(options => + { + options.SetTokenEndpointUris("connect/token"); + + options.AllowPasswordFlow() + .AllowRefreshTokenFlow(); + + options.RegisterScopes( + Scopes.Profile, + Scopes.Email, + Scopes.Address, + Scopes.Phone, + Scopes.Roles); + + var oidcCertFileName = builder.Configuration["OIDC:Certificates:Path"]; + var oidcCertFilePassword = builder.Configuration["OIDC:Certificates:Password"]; + + if (!string.IsNullOrWhiteSpace(oidcCertFileName)) + { + var oidcCertificate = X509CertificateLoader.LoadPkcs12FromFile(oidcCertFileName, oidcCertFilePassword); + + options.AddEncryptionCertificate(oidcCertificate) + .AddSigningCertificate(oidcCertificate); + } + else + { + // You must configure persisted keys for Encryption and Signing in production. + // See https://documentation.openiddict.com/configuration/encryption-and-signing-credentials.html + options.AddEphemeralEncryptionKey() + .AddEphemeralSigningKey(); + } + + options.UseAspNetCore() + .EnableTokenEndpointPassthrough() + .DisableTransportSecurityRequirement(); + }) + .AddValidation(options => + { + options.UseLocalServer(); + options.UseAspNetCore(); + }); + +builder.Services.AddAuthentication(o => +{ + o.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; + o.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; + o.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; +}); + +builder.Services.AddAuthorizationBuilder() + .AddPolicy(AuthPolicies.ViewAllUsersPolicy, + policy => policy.RequireClaim(CustomClaims.Permission, ApplicationPermissions.ViewUsers)) + .AddPolicy(AuthPolicies.ManageAllUsersPolicy, + policy => policy.RequireClaim(CustomClaims.Permission, ApplicationPermissions.ManageUsers)) + .AddPolicy(AuthPolicies.ViewAllRolesPolicy, + policy => policy.RequireClaim(CustomClaims.Permission, ApplicationPermissions.ViewRoles)) + .AddPolicy(AuthPolicies.ViewRoleByRoleNamePolicy, + policy => policy.Requirements.Add(new ViewRoleAuthorizationRequirement())) + .AddPolicy(AuthPolicies.ManageAllRolesPolicy, + policy => policy.RequireClaim(CustomClaims.Permission, ApplicationPermissions.ManageRoles)) + .AddPolicy(AuthPolicies.AssignAllowedRolesPolicy, + policy => policy.Requirements.Add(new AssignRolesAuthorizationRequirement())); + +// Add cors +builder.Services.AddCors(); + builder.Services.AddControllers(); -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); + +builder.Services.AddHttpContextAccessor(); + builder.Services.AddHealthChecks(); -builder.Services.AddDbContext(options => - options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); + +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new OpenApiInfo { Title = OidcServerConfig.ServerName, Version = "v1" }); + c.OperationFilter(); + c.AddSecurityDefinition("oauth2", new OpenApiSecurityScheme + { + Type = SecuritySchemeType.OAuth2, + Flows = new OpenApiOAuthFlows + { + Password = new OpenApiOAuthFlow + { + TokenUrl = new Uri("/connect/token", UriKind.Relative) + } + } + }); +}); + +builder.Services.AddAutoMapper(cfg => cfg.AddMaps(typeof(Program).Assembly)); + +// Business Services +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Other Services +builder.Services.AddScoped(); + +// Auth Handlers +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// DB Creation and Seeding +builder.Services.AddTransient(); var app = builder.Build(); +/************* CONFIGURE REQUEST PIPELINE *************/ + if (app.Environment.IsDevelopment()) { app.UseSwagger(); - app.UseSwaggerUI(); + app.UseSwaggerUI(c => + { + c.DocumentTitle = "Swagger UI - Identity"; + c.SwaggerEndpoint("/swagger/v1/swagger.json", $"{OidcServerConfig.ServerName} V1"); + c.OAuthClientId(OidcServerConfig.SwaggerClientID); + }); + + IdentityModelEventSource.ShowPII = true; } +app.UseCors(builder => builder + .AllowAnyOrigin() + .AllowAnyHeader() + .AllowAnyMethod()); + +app.UseAuthentication(); +app.UseAuthorization(); + app.MapControllers(); app.MapHealthChecks("/healthz"); +/************* SEED DATABASE *************/ + +using (var scope = app.Services.CreateScope()) +{ + try + { + var dbSeeder = scope.ServiceProvider.GetRequiredService(); + await dbSeeder.SeedAsync(); + + await OidcServerConfig.RegisterClientApplicationsAsync(scope.ServiceProvider); + } + catch (Exception ex) + { + var logger = scope.ServiceProvider.GetRequiredService>(); + logger.LogCritical(ex, "An error occurred whilst creating/seeding database"); + + throw; + } +} + +/************* RUN APP *************/ + app.Run(); + +public partial class Program; diff --git a/src/Services/Identity/Identity.API/Services/UserIdAccessor.cs b/src/Services/Identity/Identity.API/Services/UserIdAccessor.cs new file mode 100644 index 0000000..9cfce60 --- /dev/null +++ b/src/Services/Identity/Identity.API/Services/UserIdAccessor.cs @@ -0,0 +1,19 @@ +// --------------------------------------- +// Email: quickapp@ebenmonney.com +// Templates: www.ebenmonney.com/templates +// (c) 2024 www.ebenmonney.com/mit-license +// --------------------------------------- + +using System.Security.Claims; +using Identity.Domain.Interfaces; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Identity.API.Services +{ + public class UserIdAccessor(IHttpContextAccessor httpContextAccessor) : IUserIdAccessor + { + private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor; + + public string? GetCurrentUserId() => _httpContextAccessor.HttpContext?.User.FindFirstValue(Claims.Subject); + } +} diff --git a/src/Services/Identity/Identity.API/Services/Utilities.cs b/src/Services/Identity/Identity.API/Services/Utilities.cs new file mode 100644 index 0000000..040d65c --- /dev/null +++ b/src/Services/Identity/Identity.API/Services/Utilities.cs @@ -0,0 +1,41 @@ +// --------------------------------------- +// Email: quickapp@ebenmonney.com +// Templates: www.ebenmonney.com/templates +// (c) 2024 www.ebenmonney.com/mit-license +// --------------------------------------- + +using System.Security.Claims; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Identity.API.Services +{ + public static class Utilities + { + public static void QuickLog(string text, string logPath) + { + var dirPath = Path.GetDirectoryName(logPath); + + if (string.IsNullOrWhiteSpace(dirPath)) + throw new ArgumentException($"Specified path \"{logPath}\" is invalid", nameof(logPath)); + + if (!Directory.Exists(dirPath)) + Directory.CreateDirectory(dirPath); + + using var writer = File.AppendText(logPath); + writer.WriteLine($"{DateTime.Now} - {text}"); + } + + public static string? GetUserId(ClaimsPrincipal user) + { + return user.FindFirstValue(Claims.Subject)?.Trim(); + } + + public static string[] GetRoles(ClaimsPrincipal user) + { + return user.Claims + .Where(c => c.Type == Claims.Role) + .Select(c => c.Value) + .ToArray(); + } + } +} diff --git a/src/Services/Identity/Identity.API/ViewModels/Account/ClaimVM.cs b/src/Services/Identity/Identity.API/ViewModels/Account/ClaimVM.cs new file mode 100644 index 0000000..83a6bfa --- /dev/null +++ b/src/Services/Identity/Identity.API/ViewModels/Account/ClaimVM.cs @@ -0,0 +1,14 @@ +// --------------------------------------- +// Email: quickapp@ebenmonney.com +// Templates: www.ebenmonney.com/templates +// (c) 2024 www.ebenmonney.com/mit-license +// --------------------------------------- + +namespace Identity.API.ViewModels.Account +{ + public class ClaimVM + { + public string? Type { get; set; } + public string? Value { get; set; } + } +} diff --git a/src/Services/Identity/Identity.API/ViewModels/Account/PermissionVM.cs b/src/Services/Identity/Identity.API/ViewModels/Account/PermissionVM.cs new file mode 100644 index 0000000..f78e32e --- /dev/null +++ b/src/Services/Identity/Identity.API/ViewModels/Account/PermissionVM.cs @@ -0,0 +1,34 @@ +// --------------------------------------- +// Email: quickapp@ebenmonney.com +// Templates: www.ebenmonney.com/templates +// (c) 2024 www.ebenmonney.com/mit-license +// --------------------------------------- + +using Identity.Domain.Entities; +using System.Diagnostics.CodeAnalysis; + +namespace Identity.API.ViewModels.Account +{ + public class PermissionVM + { + public string? Name { get; set; } + public string? Value { get; set; } + public string? GroupName { get; set; } + public string? Description { get; set; } + + [return: NotNullIfNotNull(nameof(permission))] + public static explicit operator PermissionVM?(ApplicationPermission? permission) + { + if (permission == null) + return null; + + return new PermissionVM + { + Name = permission.Name, + Value = permission.Value, + GroupName = permission.GroupName, + Description = permission.Description + }; + } + } +} diff --git a/src/Services/Identity/Identity.API/ViewModels/Account/RoleVM.cs b/src/Services/Identity/Identity.API/ViewModels/Account/RoleVM.cs new file mode 100644 index 0000000..51f6eaf --- /dev/null +++ b/src/Services/Identity/Identity.API/ViewModels/Account/RoleVM.cs @@ -0,0 +1,34 @@ +// --------------------------------------- +// Email: quickapp@ebenmonney.com +// Templates: www.ebenmonney.com/templates +// (c) 2024 www.ebenmonney.com/mit-license +// --------------------------------------- + +using Identity.API.Extensions; +using Identity.API.Attributes; +using System.ComponentModel.DataAnnotations; + +namespace Identity.API.ViewModels.Account +{ + public class RoleVM : ISanitizeModel + { + public virtual void SanitizeModel() + { + Id = Id.NullIfWhiteSpace(); + Name = Name.NullIfWhiteSpace(); + Description = Description.NullIfWhiteSpace(); + } + + public string? Id { get; set; } + + [Required(ErrorMessage = "Role name is required"), + StringLength(200, MinimumLength = 2, ErrorMessage = "Role name must be between 2 and 200 characters")] + public string? Name { get; set; } + + public string? Description { get; set; } + + public int UsersCount { get; set; } + + public PermissionVM[]? Permissions { get; set; } + } +} diff --git a/src/Services/Identity/Identity.API/ViewModels/Account/UserVMs.cs b/src/Services/Identity/Identity.API/ViewModels/Account/UserVMs.cs new file mode 100644 index 0000000..550f37c --- /dev/null +++ b/src/Services/Identity/Identity.API/ViewModels/Account/UserVMs.cs @@ -0,0 +1,75 @@ +// --------------------------------------- +// Email: quickapp@ebenmonney.com +// Templates: www.ebenmonney.com/templates +// (c) 2024 www.ebenmonney.com/mit-license +// --------------------------------------- + +using Identity.API.Extensions; +using Identity.API.Attributes; +using System.ComponentModel.DataAnnotations; + +namespace Identity.API.ViewModels.Account +{ + public class UserVM : UserBaseVM + { + public bool IsLockedOut { get; set; } + + [MinimumCount(1, ErrorMessage = "Roles cannot be empty")] + public string[]? Roles { get; set; } + } + + public class UserEditVM : UserBaseVM + { + public string? CurrentPassword { get; set; } + + [MinLength(6, ErrorMessage = "New Password must be at least 6 characters")] + public string? NewPassword { get; set; } + + [MinimumCount(1, false, ErrorMessage = "Roles cannot be empty")] + public string[]? Roles { get; set; } + } + + public class UserPatchVM + { + public string? FullName { get; set; } + + public string? JobTitle { get; set; } + + public string? PhoneNumber { get; set; } + + public string? Configuration { get; set; } + } + + public abstract class UserBaseVM : ISanitizeModel + { + public virtual void SanitizeModel() + { + Id = Id.NullIfWhiteSpace(); + FullName = FullName.NullIfWhiteSpace(); + JobTitle = JobTitle.NullIfWhiteSpace(); + PhoneNumber = PhoneNumber.NullIfWhiteSpace(); + Configuration = Configuration.NullIfWhiteSpace(); + } + + public string? Id { get; set; } + + [Required(ErrorMessage = "Username is required"), + StringLength(200, MinimumLength = 2, ErrorMessage = "Username must be between 2 and 200 characters")] + public required string UserName { get; set; } + + public string? FullName { get; set; } + + [Required(ErrorMessage = "Email is required"), + StringLength(200, ErrorMessage = "Email must be at most 200 characters"), + EmailAddress(ErrorMessage = "Invalid email address")] + public required string Email { get; set; } + + public string? JobTitle { get; set; } + + public string? PhoneNumber { get; set; } + + public string? Configuration { get; set; } + + public bool IsEnabled { get; set; } + } +} diff --git a/src/Services/Identity/Identity.Domain/Authorization/ApplicationPermissions.cs b/src/Services/Identity/Identity.Domain/Authorization/ApplicationPermissions.cs new file mode 100644 index 0000000..db8590d --- /dev/null +++ b/src/Services/Identity/Identity.Domain/Authorization/ApplicationPermissions.cs @@ -0,0 +1,75 @@ +using System.Collections.ObjectModel; +using Identity.Domain.Entities; + +namespace Identity.Domain.Authorization; + +public static class ApplicationPermissions +{ + /************* USER PERMISSIONS *************/ + + public const string UsersPermissionGroupName = "User Permissions"; + + public static readonly ApplicationPermission ViewUsers = new( + "View Users", + "users.view", + UsersPermissionGroupName, + "Permission to view other users account details"); + + public static readonly ApplicationPermission ManageUsers = new( + "Manage Users", + "users.manage", + UsersPermissionGroupName, + "Permission to create, delete and modify other users account details"); + + /************* ROLE PERMISSIONS *************/ + + public const string RolesPermissionGroupName = "Role Permissions"; + + public static readonly ApplicationPermission ViewRoles = new( + "View Roles", + "roles.view", + RolesPermissionGroupName, + "Permission to view available roles"); + + public static readonly ApplicationPermission ManageRoles = new( + "Manage Roles", + "roles.manage", + RolesPermissionGroupName, + "Permission to create, delete and modify roles"); + + public static readonly ApplicationPermission AssignRoles = new( + "Assign Roles", + "roles.assign", + RolesPermissionGroupName, + "Permission to assign roles to users"); + + /************* ALL PERMISSIONS *************/ + + public static readonly ReadOnlyCollection AllPermissions = + new List { + ViewUsers, ManageUsers, + ViewRoles, ManageRoles, AssignRoles + }.AsReadOnly(); + + /************* HELPER METHODS *************/ + + public static ApplicationPermission? GetPermissionByName(string? permissionName) + { + return AllPermissions.SingleOrDefault(p => p.Name == permissionName); + } + + public static ApplicationPermission? GetPermissionByValue(string? permissionValue) + { + return AllPermissions.SingleOrDefault(p => p.Value == permissionValue); + } + + public static string[] GetAllPermissionValues() + { + return AllPermissions.Select(p => p.Value).ToArray(); + } + + public static string[] GetAdministrativePermissionValues() + { + return [ManageUsers, ManageRoles, AssignRoles]; + } +} diff --git a/src/Services/Identity/Identity.Domain/Authorization/CustomClaims.cs b/src/Services/Identity/Identity.Domain/Authorization/CustomClaims.cs new file mode 100644 index 0000000..d7ff0ca --- /dev/null +++ b/src/Services/Identity/Identity.Domain/Authorization/CustomClaims.cs @@ -0,0 +1,16 @@ +namespace Identity.Domain.Authorization; + +public static class CustomClaims +{ + ///A claim that specifies the full name of an entity + public const string FullName = "fullname"; + + ///A claim that specifies the job title of an entity + public const string JobTitle = "jobtitle"; + + ///A claim that specifies the configuration/customizations of an entity + public const string Configuration = "configuration"; + + ///A claim that specifies the permission of an entity + public const string Permission = "permission"; +} diff --git a/src/Services/Identity/Identity.Domain/Entities/ApplicationPermission.cs b/src/Services/Identity/Identity.Domain/Entities/ApplicationPermission.cs new file mode 100644 index 0000000..c31d395 --- /dev/null +++ b/src/Services/Identity/Identity.Domain/Entities/ApplicationPermission.cs @@ -0,0 +1,19 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Identity.Domain.Entities; + +public class ApplicationPermission(string name, string value, string groupName, string? description = null) +{ + public string Name { get; set; } = name; + public string Value { get; set; } = value; + public string GroupName { get; set; } = groupName; + public string? Description { get; set; } = description; + + public override string ToString() => Value; + + [return: NotNullIfNotNull(nameof(permission))] + public static implicit operator string?(ApplicationPermission? permission) + { + return permission?.Value; + } +} diff --git a/src/Services/Identity/Identity.Domain/Entities/ApplicationRole.cs b/src/Services/Identity/Identity.Domain/Entities/ApplicationRole.cs new file mode 100644 index 0000000..11cd395 --- /dev/null +++ b/src/Services/Identity/Identity.Domain/Entities/ApplicationRole.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Identity; + +namespace Identity.Domain.Entities; + +public class ApplicationRole : IdentityRole, IAuditableEntity +{ + /// + /// Initializes a new instance of . + /// + /// + /// The Id property is initialized to from a new GUID string value. + /// + public ApplicationRole() + { } + + /// + /// Initializes a new instance of . + /// + /// The role name. + public ApplicationRole(string roleName) : base(roleName) + { } + + /// + /// Initializes a new instance of . + /// + /// The role name. + /// Description of the role. + public ApplicationRole(string roleName, string description) : base(roleName) + { + Description = description; + } + + /// + /// Gets or sets the description for this role. + /// + public string? Description { get; set; } + public string? CreatedBy { get; set; } + public string? UpdatedBy { get; set; } + public DateTime CreatedDate { get; set; } + public DateTime UpdatedDate { get; set; } + + /// + /// Navigation property for the users in this role. + /// + public ICollection> Users { get; } = []; + + /// + /// Navigation property for claims in this role. + /// + public ICollection> Claims { get; } = []; +} diff --git a/src/Services/Identity/Identity.Domain/Entities/ApplicationUser.cs b/src/Services/Identity/Identity.Domain/Entities/ApplicationUser.cs new file mode 100644 index 0000000..de8c1a3 --- /dev/null +++ b/src/Services/Identity/Identity.Domain/Entities/ApplicationUser.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Identity; + +namespace Identity.Domain.Entities; + +public class ApplicationUser : IdentityUser, IAuditableEntity +{ + public virtual string? FriendlyName + { + get + { + var friendlyName = string.IsNullOrWhiteSpace(FullName) ? UserName : FullName; + + if (!string.IsNullOrWhiteSpace(JobTitle)) + friendlyName = $"{JobTitle} {friendlyName}"; + + return friendlyName; + } + } + + public string? JobTitle { get; set; } + public string? FullName { get; set; } + public string? Configuration { get; set; } + public bool IsEnabled { get; set; } + public bool IsLockedOut => LockoutEnabled && LockoutEnd >= DateTimeOffset.UtcNow; + + public string? CreatedBy { get; set; } + public string? UpdatedBy { get; set; } + public DateTime CreatedDate { get; set; } + public DateTime UpdatedDate { get; set; } + + /// + /// Navigation property for the roles this user belongs to. + /// + public ICollection> Roles { get; } = []; + + /// + /// Navigation property for the claims this user possesses. + /// + public ICollection> Claims { get; } = []; +} diff --git a/src/Services/Identity/Identity.Domain/Entities/IAuditableEntity.cs b/src/Services/Identity/Identity.Domain/Entities/IAuditableEntity.cs new file mode 100644 index 0000000..369c838 --- /dev/null +++ b/src/Services/Identity/Identity.Domain/Entities/IAuditableEntity.cs @@ -0,0 +1,9 @@ +namespace Identity.Domain.Entities; + +public interface IAuditableEntity +{ + string? CreatedBy { get; set; } + string? UpdatedBy { get; set; } + DateTime CreatedDate { get; set; } + DateTime UpdatedDate { get; set; } +} diff --git a/src/Services/Identity/Identity.Domain/Exceptions/UserAccountException.cs b/src/Services/Identity/Identity.Domain/Exceptions/UserAccountException.cs new file mode 100644 index 0000000..e625b4f --- /dev/null +++ b/src/Services/Identity/Identity.Domain/Exceptions/UserAccountException.cs @@ -0,0 +1,19 @@ +namespace Identity.Domain.Exceptions; + +/// +/// Represents errors that occur with user account related operations. +/// +public class UserAccountException : Exception +{ + public UserAccountException() : base("A User Account Exception has occurred.") + { + } + + public UserAccountException(string? message) : base(message) + { + } + + public UserAccountException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/src/Services/Identity/Identity.Domain/Exceptions/UserNotFoundException.cs b/src/Services/Identity/Identity.Domain/Exceptions/UserNotFoundException.cs new file mode 100644 index 0000000..05fb69d --- /dev/null +++ b/src/Services/Identity/Identity.Domain/Exceptions/UserNotFoundException.cs @@ -0,0 +1,19 @@ +namespace Identity.Domain.Exceptions; + +/// +/// The exception that is thrown when an attempt to access a particular User Account fails. +/// +public class UserNotFoundException : UserAccountException +{ + public UserNotFoundException() : base("Unable to find the requested User.") + { + } + + public UserNotFoundException(string? message) : base(message) + { + } + + public UserNotFoundException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/src/Services/Identity/Identity.Domain/Exceptions/UserRoleException.cs b/src/Services/Identity/Identity.Domain/Exceptions/UserRoleException.cs new file mode 100644 index 0000000..5cc8126 --- /dev/null +++ b/src/Services/Identity/Identity.Domain/Exceptions/UserRoleException.cs @@ -0,0 +1,19 @@ +namespace Identity.Domain.Exceptions; + +/// +/// Represents errors that occur with user role related operations. +/// +public class UserRoleException : Exception +{ + public UserRoleException() : base("A User Role Exception has occurred.") + { + } + + public UserRoleException(string? message) : base(message) + { + } + + public UserRoleException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/src/Services/Identity/Identity.Domain/Identity.Domain.csproj b/src/Services/Identity/Identity.Domain/Identity.Domain.csproj index 1a57862..ca01047 100644 --- a/src/Services/Identity/Identity.Domain/Identity.Domain.csproj +++ b/src/Services/Identity/Identity.Domain/Identity.Domain.csproj @@ -4,4 +4,7 @@ enable enable + + + diff --git a/src/Services/Identity/Identity.Domain/Interfaces/IDatabaseSeeder.cs b/src/Services/Identity/Identity.Domain/Interfaces/IDatabaseSeeder.cs new file mode 100644 index 0000000..99a7175 --- /dev/null +++ b/src/Services/Identity/Identity.Domain/Interfaces/IDatabaseSeeder.cs @@ -0,0 +1,6 @@ +namespace Identity.Domain.Interfaces; + +public interface IDatabaseSeeder +{ + Task SeedAsync(); +} diff --git a/src/Services/Identity/Identity.Domain/Interfaces/IUserAccountService.cs b/src/Services/Identity/Identity.Domain/Interfaces/IUserAccountService.cs new file mode 100644 index 0000000..dd98bc7 --- /dev/null +++ b/src/Services/Identity/Identity.Domain/Interfaces/IUserAccountService.cs @@ -0,0 +1,22 @@ +using Identity.Domain.Entities; + +namespace Identity.Domain.Interfaces; + +public interface IUserAccountService +{ + Task CheckPasswordAsync(ApplicationUser user, string password); + Task<(bool Succeeded, string[] Errors)> CreateUserAsync(ApplicationUser user, IEnumerable roles, string password); + Task<(bool Succeeded, string[] Errors)> DeleteUserAsync(ApplicationUser user); + Task<(bool Succeeded, string[] Errors)> DeleteUserAsync(string userId); + Task<(ApplicationUser User, string[] Roles)?> GetUserAndRolesAsync(string userId); + Task GetUserByEmailAsync(string email); + Task GetUserByIdAsync(string userId); + Task GetUserByUserNameAsync(string userName); + Task> GetUserRolesAsync(ApplicationUser user); + Task> GetUsersAndRolesAsync(int page, int pageSize); + Task<(bool Succeeded, string[] Errors)> ResetPasswordAsync(ApplicationUser user, string newPassword); + Task<(bool Success, string[] Errors)> TestCanDeleteUserAsync(string userId); + Task<(bool Succeeded, string[] Errors)> UpdatePasswordAsync(ApplicationUser user, string currentPassword, string newPassword); + Task<(bool Succeeded, string[] Errors)> UpdateUserAsync(ApplicationUser user); + Task<(bool Succeeded, string[] Errors)> UpdateUserAsync(ApplicationUser user, IEnumerable? roles); +} diff --git a/src/Services/Identity/Identity.Domain/Interfaces/IUserIdAccessor.cs b/src/Services/Identity/Identity.Domain/Interfaces/IUserIdAccessor.cs new file mode 100644 index 0000000..75653ee --- /dev/null +++ b/src/Services/Identity/Identity.Domain/Interfaces/IUserIdAccessor.cs @@ -0,0 +1,6 @@ +namespace Identity.Domain.Interfaces; + +public interface IUserIdAccessor +{ + string? GetCurrentUserId(); +} diff --git a/src/Services/Identity/Identity.Domain/Interfaces/IUserRoleService.cs b/src/Services/Identity/Identity.Domain/Interfaces/IUserRoleService.cs new file mode 100644 index 0000000..7bb1b95 --- /dev/null +++ b/src/Services/Identity/Identity.Domain/Interfaces/IUserRoleService.cs @@ -0,0 +1,16 @@ +using Identity.Domain.Entities; + +namespace Identity.Domain.Interfaces; + +public interface IUserRoleService +{ + Task<(bool Succeeded, string[] Errors)> CreateRoleAsync(ApplicationRole role, IEnumerable claims); + Task<(bool Succeeded, string[] Errors)> DeleteRoleAsync(ApplicationRole role); + Task<(bool Succeeded, string[] Errors)> DeleteRoleAsync(string roleName); + Task GetRoleByIdAsync(string roleId); + Task GetRoleByNameAsync(string roleName); + Task GetRoleLoadRelatedAsync(string roleName); + Task> GetRolesLoadRelatedAsync(int page, int pageSize); + Task<(bool Success, string[] Errors)> TestCanDeleteRoleAsync(string roleId); + Task<(bool Succeeded, string[] Errors)> UpdateRoleAsync(ApplicationRole role, IEnumerable? claims); +} diff --git a/src/Services/Identity/Identity.Infrastructure/Data/DatabaseSeeder.cs b/src/Services/Identity/Identity.Infrastructure/Data/DatabaseSeeder.cs new file mode 100644 index 0000000..0e91d5a --- /dev/null +++ b/src/Services/Identity/Identity.Infrastructure/Data/DatabaseSeeder.cs @@ -0,0 +1,122 @@ +using Identity.Domain.Authorization; +using Identity.Domain.Entities; +using Identity.Domain.Exceptions; +using Identity.Domain.Interfaces; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Identity.Infrastructure.Data; + +public class DatabaseSeeder(IdentityDbContext dbContext, ILogger logger, + IUserAccountService userAccountService, IUserRoleService userRoleService) : IDatabaseSeeder +{ + private const int MigrationAttempts = 10; + private static readonly TimeSpan MigrationRetryDelay = TimeSpan.FromSeconds(5); + + public async Task SeedAsync() + { + if (dbContext.Database.IsNpgsql()) + await MigrateWithRetryAsync(); + else + await dbContext.Database.EnsureCreatedAsync(); + + await SeedDefaultUsersAsync(); + } + + private async Task MigrateWithRetryAsync() + { + for (var attempt = 1; ; attempt++) + { + try + { + await dbContext.Database.MigrateAsync(); + return; + } + catch (Exception ex) when (attempt < MigrationAttempts) + { + logger.LogWarning(ex, "Database not reachable (attempt {Attempt} of {Attempts}), retrying in {Delay}", + attempt, MigrationAttempts, MigrationRetryDelay); + + await Task.Delay(MigrationRetryDelay); + } + } + } + + /************ DEFAULT USERS **************/ + + private async Task SeedDefaultUsersAsync() + { + if (!await dbContext.Users.AnyAsync()) + { + logger.LogInformation("Generating inbuilt accounts"); + + const string adminRoleName = "administrator"; + const string userRoleName = "user"; + + await EnsureRoleAsync(adminRoleName, "Default administrator", + ApplicationPermissions.GetAllPermissionValues()); + + await EnsureRoleAsync(userRoleName, "Default user", []); + + await CreateUserAsync("admin", + "tempP@ss123", + "Inbuilt Administrator", + "admin@ebenmonney.com", + "+1 (123) 000-0000", + [adminRoleName]); + + await CreateUserAsync("user", + "tempP@ss123", + "Inbuilt Standard User", + "user@ebenmonney.com", + "+1 (123) 000-0001", + [userRoleName]); + + logger.LogInformation("Inbuilt account generation completed"); + } + } + + private async Task EnsureRoleAsync(string roleName, string description, string[] claims) + { + if (await userRoleService.GetRoleByNameAsync(roleName) == null) + { + logger.LogInformation("Generating default role: {roleName}", roleName); + + var applicationRole = new ApplicationRole(roleName, description); + + var result = await userRoleService.CreateRoleAsync(applicationRole, claims); + + if (!result.Succeeded) + { + throw new UserRoleException($"Seeding \"{description}\" role failed. Errors: " + + $"{string.Join(Environment.NewLine, result.Errors)}"); + } + } + } + + private async Task CreateUserAsync( + string userName, string password, string fullName, string email, string phoneNumber, string[] roles) + { + logger.LogInformation("Generating default user: {userName}", userName); + + var applicationUser = new ApplicationUser + { + UserName = userName, + FullName = fullName, + Email = email, + PhoneNumber = phoneNumber, + EmailConfirmed = true, + IsEnabled = true + }; + + var result = await userAccountService.CreateUserAsync(applicationUser, roles, password); + + if (!result.Succeeded) + { + throw new UserAccountException($"Seeding \"{userName}\" user failed. Errors: " + + $"{string.Join(Environment.NewLine, result.Errors)}"); + } + + return applicationUser; + } +} diff --git a/src/Services/Identity/Identity.Infrastructure/Data/DesignTimeDbContextFactory.cs b/src/Services/Identity/Identity.Infrastructure/Data/DesignTimeDbContextFactory.cs new file mode 100644 index 0000000..080750a --- /dev/null +++ b/src/Services/Identity/Identity.Infrastructure/Data/DesignTimeDbContextFactory.cs @@ -0,0 +1,26 @@ +using Identity.Domain.Interfaces; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Identity.Infrastructure.Data; + +public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + public IdentityDbContext CreateDbContext(string[] args) + { + var connectionString = Environment.GetEnvironmentVariable("ConnectionStrings__DefaultConnection") + ?? "Host=localhost;Database=identitydb;Username=postgres;Password=postgres"; + + var builder = new DbContextOptionsBuilder(); + builder.UseNpgsql(connectionString, + b => b.MigrationsAssembly(typeof(IdentityDbContext).Assembly.GetName().Name)); + builder.UseOpenIddict(); + + return new IdentityDbContext(builder.Options, new DesignTimeUserIdAccessor()); + } + + private sealed class DesignTimeUserIdAccessor : IUserIdAccessor + { + public string? GetCurrentUserId() => "SYSTEM"; + } +} diff --git a/src/Services/Identity/Identity.Infrastructure/Data/IdentityDbContext.cs b/src/Services/Identity/Identity.Infrastructure/Data/IdentityDbContext.cs index 52c3b5f..336e73b 100644 --- a/src/Services/Identity/Identity.Infrastructure/Data/IdentityDbContext.cs +++ b/src/Services/Identity/Identity.Infrastructure/Data/IdentityDbContext.cs @@ -1,16 +1,95 @@ +using Identity.Domain.Entities; +using Identity.Domain.Interfaces; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace Identity.Infrastructure.Data; -public class IdentityDbContext : DbContext +public class IdentityDbContext(DbContextOptions options, IUserIdAccessor userIdAccessor) : + IdentityDbContext(options) { - public IdentityDbContext(DbContextOptions options) : base(options) + protected override void OnModelCreating(ModelBuilder builder) { + base.OnModelCreating(builder); + + builder.Entity() + .HasMany(u => u.Claims) + .WithOne() + .HasForeignKey(c => c.UserId) + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + builder.Entity() + .HasMany(u => u.Roles) + .WithOne() + .HasForeignKey(r => r.UserId) + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + + builder.Entity() + .HasMany(r => r.Claims) + .WithOne() + .HasForeignKey(c => c.RoleId) + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + builder.Entity() + .HasMany(r => r.Users) + .WithOne() + .HasForeignKey(r => r.RoleId) + .IsRequired() + .OnDelete(DeleteBehavior.Cascade); + } + + public override int SaveChanges() + { + AddAuditInfo(); + return base.SaveChanges(); + } + + public override int SaveChanges(bool acceptAllChangesOnSuccess) + { + AddAuditInfo(); + return base.SaveChanges(acceptAllChangesOnSuccess); + } + + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + AddAuditInfo(); + return base.SaveChangesAsync(cancellationToken); + } + + public override Task SaveChangesAsync(bool acceptAllChangesOnSuccess, + CancellationToken cancellationToken = default) + { + AddAuditInfo(); + return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); } - protected override void OnModelCreating(ModelBuilder modelBuilder) + private void AddAuditInfo() { - base.OnModelCreating(modelBuilder); - // TODO: Configure entity mappings migrated from monolith + var currentUserId = userIdAccessor.GetCurrentUserId(); + + var modifiedEntries = ChangeTracker.Entries() + .Where(x => x.Entity is IAuditableEntity && + (x.State == EntityState.Added || x.State == EntityState.Modified)); + + foreach (var entry in modifiedEntries) + { + var entity = (IAuditableEntity)entry.Entity; + var now = DateTime.UtcNow; + + if (entry.State == EntityState.Added) + { + entity.CreatedDate = now; + entity.CreatedBy = currentUserId; + } + else + { + base.Entry(entity).Property(x => x.CreatedBy).IsModified = false; + base.Entry(entity).Property(x => x.CreatedDate).IsModified = false; + } + + entity.UpdatedDate = now; + entity.UpdatedBy = currentUserId; + } } } diff --git a/src/Services/Identity/Identity.Infrastructure/Identity.Infrastructure.csproj b/src/Services/Identity/Identity.Infrastructure/Identity.Infrastructure.csproj index ec3c7aa..ad36ee0 100644 --- a/src/Services/Identity/Identity.Infrastructure/Identity.Infrastructure.csproj +++ b/src/Services/Identity/Identity.Infrastructure/Identity.Infrastructure.csproj @@ -8,7 +8,10 @@ - - + + + + + diff --git a/src/Services/Identity/Identity.Infrastructure/Migrations/20260910142958_Initial.Designer.cs b/src/Services/Identity/Identity.Infrastructure/Migrations/20260910142958_Initial.Designer.cs new file mode 100644 index 0000000..a260fcf --- /dev/null +++ b/src/Services/Identity/Identity.Infrastructure/Migrations/20260910142958_Initial.Designer.cs @@ -0,0 +1,574 @@ +// +using System; +using Identity.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Identity.Infrastructure.Migrations +{ + [DbContext(typeof(IdentityDbContext))] + [Migration("20260910142958_Initial")] + partial class Initial + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Identity.Domain.Entities.ApplicationRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Identity.Domain.Entities.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FullName") + .HasColumnType("text"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("JobTitle") + .HasColumnType("text"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Identity.Domain.Entities.ApplicationRole", null) + .WithMany("Claims") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Identity.Domain.Entities.ApplicationUser", null) + .WithMany("Claims") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Identity.Domain.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Identity.Domain.Entities.ApplicationRole", null) + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Identity.Domain.Entities.ApplicationUser", null) + .WithMany("Roles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Identity.Domain.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("Identity.Domain.Entities.ApplicationRole", b => + { + b.Navigation("Claims"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("Identity.Domain.Entities.ApplicationUser", b => + { + b.Navigation("Claims"); + + b.Navigation("Roles"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Identity/Identity.Infrastructure/Migrations/20260910142958_Initial.cs b/src/Services/Identity/Identity.Infrastructure/Migrations/20260910142958_Initial.cs new file mode 100644 index 0000000..38dc708 --- /dev/null +++ b/src/Services/Identity/Identity.Infrastructure/Migrations/20260910142958_Initial.cs @@ -0,0 +1,383 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Identity.Infrastructure.Migrations +{ + /// + public partial class Initial : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: true), + CreatedBy = table.Column(type: "text", nullable: true), + UpdatedBy = table.Column(type: "text", nullable: true), + CreatedDate = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedDate = table.Column(type: "timestamp with time zone", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + JobTitle = table.Column(type: "text", nullable: true), + FullName = table.Column(type: "text", nullable: true), + Configuration = table.Column(type: "text", nullable: true), + IsEnabled = table.Column(type: "boolean", nullable: false), + CreatedBy = table.Column(type: "text", nullable: true), + UpdatedBy = table.Column(type: "text", nullable: true), + CreatedDate = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedDate = table.Column(type: "timestamp with time zone", nullable: false), + UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "boolean", nullable: false), + PasswordHash = table.Column(type: "text", nullable: true), + SecurityStamp = table.Column(type: "text", nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true), + PhoneNumber = table.Column(type: "text", nullable: true), + PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), + TwoFactorEnabled = table.Column(type: "boolean", nullable: false), + LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), + LockoutEnabled = table.Column(type: "boolean", nullable: false), + AccessFailedCount = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "OpenIddictApplications", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ApplicationType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ClientId = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + ClientSecret = table.Column(type: "text", nullable: true), + ClientType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ConsentType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + DisplayName = table.Column(type: "text", nullable: true), + DisplayNames = table.Column(type: "text", nullable: true), + JsonWebKeySet = table.Column(type: "text", nullable: true), + Permissions = table.Column(type: "text", nullable: true), + PostLogoutRedirectUris = table.Column(type: "text", nullable: true), + Properties = table.Column(type: "text", nullable: true), + RedirectUris = table.Column(type: "text", nullable: true), + Requirements = table.Column(type: "text", nullable: true), + Settings = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictApplications", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "OpenIddictScopes", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Description = table.Column(type: "text", nullable: true), + Descriptions = table.Column(type: "text", nullable: true), + DisplayName = table.Column(type: "text", nullable: true), + DisplayNames = table.Column(type: "text", nullable: true), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Properties = table.Column(type: "text", nullable: true), + Resources = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictScopes", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RoleId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "text", nullable: false), + ProviderKey = table.Column(type: "text", nullable: false), + ProviderDisplayName = table.Column(type: "text", nullable: true), + UserId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + RoleId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + LoginProvider = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "OpenIddictAuthorizations", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ApplicationId = table.Column(type: "text", nullable: true), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + CreationDate = table.Column(type: "timestamp with time zone", nullable: true), + Properties = table.Column(type: "text", nullable: true), + Scopes = table.Column(type: "text", nullable: true), + Status = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Subject = table.Column(type: "character varying(400)", maxLength: 400, nullable: true), + Type = table.Column(type: "character varying(50)", maxLength: 50, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictAuthorizations", x => x.Id); + table.ForeignKey( + name: "FK_OpenIddictAuthorizations_OpenIddictApplications_Application~", + column: x => x.ApplicationId, + principalTable: "OpenIddictApplications", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "OpenIddictTokens", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ApplicationId = table.Column(type: "text", nullable: true), + AuthorizationId = table.Column(type: "text", nullable: true), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + CreationDate = table.Column(type: "timestamp with time zone", nullable: true), + ExpirationDate = table.Column(type: "timestamp with time zone", nullable: true), + Payload = table.Column(type: "text", nullable: true), + Properties = table.Column(type: "text", nullable: true), + RedemptionDate = table.Column(type: "timestamp with time zone", nullable: true), + ReferenceId = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + Status = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Subject = table.Column(type: "character varying(400)", maxLength: 400, nullable: true), + Type = table.Column(type: "character varying(150)", maxLength: 150, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictTokens", x => x.Id); + table.ForeignKey( + name: "FK_OpenIddictTokens_OpenIddictApplications_ApplicationId", + column: x => x.ApplicationId, + principalTable: "OpenIddictApplications", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_OpenIddictTokens_OpenIddictAuthorizations_AuthorizationId", + column: x => x.AuthorizationId, + principalTable: "OpenIddictAuthorizations", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictApplications_ClientId", + table: "OpenIddictApplications", + column: "ClientId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictAuthorizations_ApplicationId_Status_Subject_Type", + table: "OpenIddictAuthorizations", + columns: new[] { "ApplicationId", "Status", "Subject", "Type" }); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictScopes_Name", + table: "OpenIddictScopes", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictTokens_ApplicationId_Status_Subject_Type", + table: "OpenIddictTokens", + columns: new[] { "ApplicationId", "Status", "Subject", "Type" }); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictTokens_AuthorizationId", + table: "OpenIddictTokens", + column: "AuthorizationId"); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictTokens_ReferenceId", + table: "OpenIddictTokens", + column: "ReferenceId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "OpenIddictScopes"); + + migrationBuilder.DropTable( + name: "OpenIddictTokens"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + + migrationBuilder.DropTable( + name: "OpenIddictAuthorizations"); + + migrationBuilder.DropTable( + name: "OpenIddictApplications"); + } + } +} diff --git a/src/Services/Identity/Identity.Infrastructure/Migrations/IdentityDbContextModelSnapshot.cs b/src/Services/Identity/Identity.Infrastructure/Migrations/IdentityDbContextModelSnapshot.cs new file mode 100644 index 0000000..b2c4c40 --- /dev/null +++ b/src/Services/Identity/Identity.Infrastructure/Migrations/IdentityDbContextModelSnapshot.cs @@ -0,0 +1,571 @@ +// +using System; +using Identity.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Identity.Infrastructure.Migrations +{ + [DbContext(typeof(IdentityDbContext))] + partial class IdentityDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Identity.Domain.Entities.ApplicationRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Identity.Domain.Entities.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreatedBy") + .HasColumnType("text"); + + b.Property("CreatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FullName") + .HasColumnType("text"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("JobTitle") + .HasColumnType("text"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedBy") + .HasColumnType("text"); + + b.Property("UpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Identity.Domain.Entities.ApplicationRole", null) + .WithMany("Claims") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Identity.Domain.Entities.ApplicationUser", null) + .WithMany("Claims") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Identity.Domain.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Identity.Domain.Entities.ApplicationRole", null) + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Identity.Domain.Entities.ApplicationUser", null) + .WithMany("Roles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Identity.Domain.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("Identity.Domain.Entities.ApplicationRole", b => + { + b.Navigation("Claims"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("Identity.Domain.Entities.ApplicationUser", b => + { + b.Navigation("Claims"); + + b.Navigation("Roles"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Services/Identity/Identity.Infrastructure/Services/UserAccountService.cs b/src/Services/Identity/Identity.Infrastructure/Services/UserAccountService.cs new file mode 100644 index 0000000..fa450b0 --- /dev/null +++ b/src/Services/Identity/Identity.Infrastructure/Services/UserAccountService.cs @@ -0,0 +1,201 @@ +using Identity.Domain.Entities; +using Identity.Domain.Interfaces; +using Identity.Infrastructure.Data; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace Identity.Infrastructure.Services; + +public class UserAccountService(IdentityDbContext context, UserManager userManager) + : IUserAccountService +{ + public async Task GetUserByIdAsync(string userId) + { + return await userManager.FindByIdAsync(userId); + } + + public async Task GetUserByUserNameAsync(string userName) + { + return await userManager.FindByNameAsync(userName); + } + + public async Task GetUserByEmailAsync(string email) + { + return await userManager.FindByEmailAsync(email); + } + + public async Task> GetUserRolesAsync(ApplicationUser user) + { + return await userManager.GetRolesAsync(user); + } + + public async Task<(ApplicationUser User, string[] Roles)?> GetUserAndRolesAsync(string userId) + { + var user = await context.Users + .Include(u => u.Roles) + .Where(u => u.Id == userId) + .SingleOrDefaultAsync(); + + if (user == null) + return null; + + var userRoleIds = user.Roles.Select(r => r.RoleId).ToList(); + + var roles = await context.Roles + .Where(r => userRoleIds.Contains(r.Id)) + .Select(r => r.Name!) + .ToArrayAsync(); + + return (user, roles); + } + + public async Task> GetUsersAndRolesAsync(int page, int pageSize) + { + IQueryable usersQuery = context.Users + .Include(u => u.Roles) + .OrderBy(u => u.UserName); + + if (page != -1) + usersQuery = usersQuery.Skip((page - 1) * pageSize); + + if (pageSize != -1) + usersQuery = usersQuery.Take(pageSize); + + var users = await usersQuery.ToListAsync(); + + var userRoleIds = users.SelectMany(u => u.Roles.Select(r => r.RoleId)).ToList(); + + var roles = await context.Roles + .Where(r => userRoleIds.Contains(r.Id)) + .ToArrayAsync(); + + return users + .Select(u => (u, roles.Where(r => u.Roles.Select(ur => ur.RoleId).Contains(r.Id)).Select(r => r.Name!) + .ToArray())) + .ToList(); + } + + public async Task<(bool Succeeded, string[] Errors)> CreateUserAsync(ApplicationUser user, + IEnumerable roles, string password) + { + var result = await userManager.CreateAsync(user, password); + if (!result.Succeeded) + return (false, result.Errors.Select(e => e.Description).ToArray()); + + user = (await userManager.FindByNameAsync(user.UserName!))!; + + try + { + result = await userManager.AddToRolesAsync(user, roles.Distinct()); + } + catch + { + await DeleteUserAsync(user); + throw; + } + + if (!result.Succeeded) + { + await DeleteUserAsync(user); + return (false, result.Errors.Select(e => e.Description).ToArray()); + } + + return (true, []); + } + + public async Task<(bool Succeeded, string[] Errors)> UpdateUserAsync(ApplicationUser user) + { + return await UpdateUserAsync(user, null); + } + + public async Task<(bool Succeeded, string[] Errors)> UpdateUserAsync(ApplicationUser user, + IEnumerable? roles) + { + var result = await userManager.UpdateAsync(user); + if (!result.Succeeded) + return (false, result.Errors.Select(e => e.Description).ToArray()); + + if (roles != null) + { + var userRoles = await userManager.GetRolesAsync(user); + + var rolesToRemove = userRoles.Except(roles).ToArray(); + var rolesToAdd = roles.Except(userRoles).Distinct().ToArray(); + + if (rolesToRemove.Length != 0) + { + result = await userManager.RemoveFromRolesAsync(user, rolesToRemove); + if (!result.Succeeded) + return (false, result.Errors.Select(e => e.Description).ToArray()); + } + + if (rolesToAdd.Length != 0) + { + result = await userManager.AddToRolesAsync(user, rolesToAdd); + if (!result.Succeeded) + return (false, result.Errors.Select(e => e.Description).ToArray()); + } + } + + return (true, []); + } + + public async Task<(bool Succeeded, string[] Errors)> ResetPasswordAsync(ApplicationUser user, + string newPassword) + { + var resetToken = await userManager.GeneratePasswordResetTokenAsync(user); + + var result = await userManager.ResetPasswordAsync(user, resetToken, newPassword); + return (result.Succeeded, result.Errors.Select(e => e.Description).ToArray()); + } + + public async Task<(bool Succeeded, string[] Errors)> UpdatePasswordAsync(ApplicationUser user, + string currentPassword, string newPassword) + { + var result = await userManager.ChangePasswordAsync(user, currentPassword, newPassword); + if (!result.Succeeded) + return (false, result.Errors.Select(e => e.Description).ToArray()); + + return (true, []); + } + + public async Task CheckPasswordAsync(ApplicationUser user, string password) + { + if (!await userManager.CheckPasswordAsync(user, password)) + { + if (!userManager.SupportsUserLockout) + await userManager.AccessFailedAsync(user); + + return false; + } + + return true; + } + + /// + /// Runs the deletion pre-checks owned by this bounded context. Data owned by other + /// services (such as the orders a cashier is referenced on) is not consulted. + /// + public Task<(bool Success, string[] Errors)> TestCanDeleteUserAsync(string userId) + { + var errors = new List(); + + return Task.FromResult((errors.Count == 0, errors.ToArray())); + } + + public async Task<(bool Succeeded, string[] Errors)> DeleteUserAsync(string userId) + { + var user = await userManager.FindByIdAsync(userId); + + if (user != null) + return await DeleteUserAsync(user); + + return (true, []); + } + + public async Task<(bool Succeeded, string[] Errors)> DeleteUserAsync(ApplicationUser user) + { + var result = await userManager.DeleteAsync(user); + return (result.Succeeded, result.Errors.Select(e => e.Description).ToArray()); + } +} diff --git a/src/Services/Identity/Identity.Infrastructure/Services/UserRoleService.cs b/src/Services/Identity/Identity.Infrastructure/Services/UserRoleService.cs new file mode 100644 index 0000000..08105ee --- /dev/null +++ b/src/Services/Identity/Identity.Infrastructure/Services/UserRoleService.cs @@ -0,0 +1,157 @@ +using System.Security.Claims; +using Identity.Domain.Authorization; +using Identity.Domain.Entities; +using Identity.Domain.Interfaces; +using Identity.Infrastructure.Data; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace Identity.Infrastructure.Services; + +public class UserRoleService(IdentityDbContext context, RoleManager roleManager) : IUserRoleService +{ + public async Task GetRoleByIdAsync(string roleId) + { + return await roleManager.FindByIdAsync(roleId); + } + + public async Task GetRoleByNameAsync(string roleName) + { + return await roleManager.FindByNameAsync(roleName); + } + + public async Task GetRoleLoadRelatedAsync(string roleName) + { + var role = await context.Roles + .Include(r => r.Claims) + .Include(r => r.Users) + .AsSingleQuery() + .Where(r => r.Name == roleName) + .SingleOrDefaultAsync(); + + return role; + } + + public async Task> GetRolesLoadRelatedAsync(int page, int pageSize) + { + IQueryable rolesQuery = context.Roles + .Include(r => r.Claims) + .Include(r => r.Users) + .AsSingleQuery() + .OrderBy(r => r.Name); + + if (page != -1) + rolesQuery = rolesQuery.Skip((page - 1) * pageSize); + + if (pageSize != -1) + rolesQuery = rolesQuery.Take(pageSize); + + var roles = await rolesQuery.ToListAsync(); + + return roles; + } + + public async Task<(bool Succeeded, string[] Errors)> CreateRoleAsync(ApplicationRole role, + IEnumerable claims) + { + var invalidClaims = claims.Where(c => ApplicationPermissions.GetPermissionByValue(c) == null).ToArray(); + if (invalidClaims.Length != 0) + return (false, new[] { $"The following claim types are invalid: {string.Join(", ", invalidClaims)}" }); + + var result = await roleManager.CreateAsync(role); + if (!result.Succeeded) + return (false, result.Errors.Select(e => e.Description).ToArray()); + + role = (await roleManager.FindByNameAsync(role.Name!))!; + + foreach (var claim in claims.Distinct()) + { + result = await roleManager.AddClaimAsync(role, + new Claim(CustomClaims.Permission, ApplicationPermissions.GetPermissionByValue(claim)!)); + + if (!result.Succeeded) + { + await DeleteRoleAsync(role); + return (false, result.Errors.Select(e => e.Description).ToArray()); + } + } + + return (true, []); + } + + public async Task<(bool Succeeded, string[] Errors)> UpdateRoleAsync(ApplicationRole role, + IEnumerable? claims) + { + if (claims != null) + { + var invalidClaims = claims.Where(c => ApplicationPermissions.GetPermissionByValue(c) == null).ToArray(); + if (invalidClaims.Length != 0) + return (false, + new[] { $"The following claim types are invalid: {string.Join(", ", invalidClaims)}" }); + } + + var result = await roleManager.UpdateAsync(role); + if (!result.Succeeded) + return (false, result.Errors.Select(e => e.Description).ToArray()); + + if (claims != null) + { + var roleClaims = (await roleManager.GetClaimsAsync(role)) + .Where(c => c.Type == CustomClaims.Permission); + var roleClaimValues = roleClaims.Select(c => c.Value).ToArray(); + + var claimsToRemove = roleClaimValues.Except(claims).ToArray(); + var claimsToAdd = claims.Except(roleClaimValues).Distinct().ToArray(); + + if (claimsToRemove.Length != 0) + { + foreach (var claim in claimsToRemove) + { + result = await roleManager + .RemoveClaimAsync(role, roleClaims.Where(c => c.Value == claim).First()); + if (!result.Succeeded) + return (false, result.Errors.Select(e => e.Description).ToArray()); + } + } + + if (claimsToAdd.Length != 0) + { + foreach (var claim in claimsToAdd) + { + result = await roleManager.AddClaimAsync(role, new Claim(CustomClaims.Permission, + ApplicationPermissions.GetPermissionByValue(claim)!)); + if (!result.Succeeded) + return (false, result.Errors.Select(e => e.Description).ToArray()); + } + } + } + + return (true, []); + } + + public async Task<(bool Success, string[] Errors)> TestCanDeleteRoleAsync(string roleId) + { + var errors = new List(); + + if (await context.UserRoles.Where(r => r.RoleId == roleId).AnyAsync()) + errors.Add("Role has associated users"); + + return (errors.Count == 0, errors.ToArray()); + } + + public async Task<(bool Succeeded, string[] Errors)> DeleteRoleAsync(string roleName) + { + var role = await roleManager.FindByNameAsync(roleName); + + if (role != null) + return await DeleteRoleAsync(role); + + return (true, []); + } + + public async Task<(bool Succeeded, string[] Errors)> DeleteRoleAsync(ApplicationRole role) + { + var result = await roleManager.DeleteAsync(role); + return (result.Succeeded, result.Errors.Select(e => e.Description).ToArray()); + } +} diff --git a/src/Services/Identity/Identity.Tests/Identity.Tests.csproj b/src/Services/Identity/Identity.Tests/Identity.Tests.csproj new file mode 100644 index 0000000..9577f41 --- /dev/null +++ b/src/Services/Identity/Identity.Tests/Identity.Tests.csproj @@ -0,0 +1,21 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + diff --git a/src/Services/Identity/Identity.Tests/IdentityWebApplicationFactory.cs b/src/Services/Identity/Identity.Tests/IdentityWebApplicationFactory.cs new file mode 100644 index 0000000..b2f5492 --- /dev/null +++ b/src/Services/Identity/Identity.Tests/IdentityWebApplicationFactory.cs @@ -0,0 +1,44 @@ +using Identity.Infrastructure.Data; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Identity.Tests; + +public class IdentityWebApplicationFactory : WebApplicationFactory +{ + private readonly SqliteConnection _connection = new("DataSource=:memory:"); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + _connection.Open(); + + builder.ConfigureServices(services => + { + services.RemoveAll>(); + services.RemoveAll>(); + services.RemoveAll(); + services.RemoveAll(); + + services.AddDbContext(options => + { + options.UseSqlite(_connection); + options.UseOpenIddict(); + }); + }); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (disposing) + _connection.Dispose(); + } +} diff --git a/src/Services/Identity/Identity.Tests/TestClientExtensions.cs b/src/Services/Identity/Identity.Tests/TestClientExtensions.cs new file mode 100644 index 0000000..43177bd --- /dev/null +++ b/src/Services/Identity/Identity.Tests/TestClientExtensions.cs @@ -0,0 +1,62 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Identity.Tests; + +public record TokenResponse( + [property: JsonPropertyName("access_token")] string AccessToken, + [property: JsonPropertyName("refresh_token")] string? RefreshToken, + [property: JsonPropertyName("token_type")] string TokenType); + +public static class TestClientExtensions +{ + public const string AdminUserName = "admin"; + public const string StandardUserName = "user"; + public const string DefaultPassword = "tempP@ss123"; + + public static async Task RequestPasswordTokenAsync( + this HttpClient client, string userName, string password) + { + return await client.PostAsync("/connect/token", new FormUrlEncodedContent( + [ + new KeyValuePair("grant_type", "password"), + new KeyValuePair("username", userName), + new KeyValuePair("password", password), + new KeyValuePair("scope", "openid email phone profile roles offline_access"), + new KeyValuePair("client_id", "quickapp_spa") + ])); + } + + public static async Task RequestRefreshTokenAsync( + this HttpClient client, string refreshToken) + { + return await client.PostAsync("/connect/token", new FormUrlEncodedContent( + [ + new KeyValuePair("grant_type", "refresh_token"), + new KeyValuePair("refresh_token", refreshToken), + new KeyValuePair("client_id", "quickapp_spa") + ])); + } + + public static async Task GetTokenAsync( + this HttpClient client, string userName = AdminUserName, string password = DefaultPassword) + { + var response = await client.RequestPasswordTokenAsync(userName, password); + response.EnsureSuccessStatusCode(); + + return (await response.Content.ReadFromJsonAsync())!; + } + + public static async Task AuthenticateAsync( + this HttpClient client, string userName = AdminUserName, string password = DefaultPassword) + { + var token = await client.GetTokenAsync(userName, password); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); + + return client; + } + + public static JsonElement ReadJson(this string content) => JsonDocument.Parse(content).RootElement; +} diff --git a/src/Services/Identity/Identity.Tests/TokenIssuanceTests.cs b/src/Services/Identity/Identity.Tests/TokenIssuanceTests.cs new file mode 100644 index 0000000..7c2a168 --- /dev/null +++ b/src/Services/Identity/Identity.Tests/TokenIssuanceTests.cs @@ -0,0 +1,82 @@ +using System.Net; +using System.Net.Http.Json; + +namespace Identity.Tests; + +public class TokenIssuanceTests(IdentityWebApplicationFactory factory) : IClassFixture +{ + private readonly IdentityWebApplicationFactory _factory = factory; + + [Fact] + public async Task PasswordGrant_WithSeededCredentials_IssuesAccessAndRefreshTokens() + { + var client = _factory.CreateClient(); + + var response = await client.RequestPasswordTokenAsync( + TestClientExtensions.AdminUserName, TestClientExtensions.DefaultPassword); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var token = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(token); + Assert.False(string.IsNullOrWhiteSpace(token!.AccessToken)); + Assert.False(string.IsNullOrWhiteSpace(token.RefreshToken)); + Assert.Equal("Bearer", token.TokenType); + } + + [Fact] + public async Task PasswordGrant_WithInvalidCredentials_IsRejected() + { + var client = _factory.CreateClient(); + + var response = await client.RequestPasswordTokenAsync( + TestClientExtensions.AdminUserName, "wrong-password"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var payload = (await response.Content.ReadAsStringAsync()).ReadJson(); + + Assert.Equal("invalid_grant", payload.GetProperty("error").GetString()); + } + + [Fact] + public async Task RefreshTokenGrant_WithIssuedRefreshToken_IssuesNewAccessToken() + { + var client = _factory.CreateClient(); + var token = await client.GetTokenAsync(); + + var response = await client.RequestRefreshTokenAsync(token.RefreshToken!); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var refreshed = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(refreshed); + Assert.False(string.IsNullOrWhiteSpace(refreshed!.AccessToken)); + } + + [Fact] + public async Task AccessToken_AuthenticatesAgainstAccountEndpoints() + { + var client = await _factory.CreateClient().AuthenticateAsync(); + + var response = await client.GetAsync("/api/account/users/me"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var payload = (await response.Content.ReadAsStringAsync()).ReadJson(); + + Assert.Equal(TestClientExtensions.AdminUserName, payload.GetProperty("userName").GetString()); + } + + [Fact] + public async Task AccountEndpoints_WithoutToken_AreUnauthorized() + { + var client = _factory.CreateClient(); + + var response = await client.GetAsync("/api/account/users/me"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } +} diff --git a/src/Services/Identity/Identity.Tests/UserAccountTests.cs b/src/Services/Identity/Identity.Tests/UserAccountTests.cs new file mode 100644 index 0000000..c8c4058 --- /dev/null +++ b/src/Services/Identity/Identity.Tests/UserAccountTests.cs @@ -0,0 +1,100 @@ +using System.Net; +using System.Net.Http.Json; + +namespace Identity.Tests; + +public class UserAccountTests(IdentityWebApplicationFactory factory) : IClassFixture +{ + private readonly IdentityWebApplicationFactory _factory = factory; + + private static object NewUser(string userName) => new + { + userName, + fullName = "Integration Test User", + email = $"{userName}@quickapp.test", + jobTitle = "Tester", + isEnabled = true, + roles = new[] { "user" }, + newPassword = "tempP@ss123", + confirmPassword = "tempP@ss123" + }; + + [Fact] + public async Task Admin_CanCreateReadUpdateAndDeleteUsers() + { + var client = await _factory.CreateClient().AuthenticateAsync(); + var userName = $"crud-{Guid.NewGuid():N}"[..12]; + + var createResponse = await client.PostAsJsonAsync("/api/account/users", NewUser(userName)); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + + var created = (await createResponse.Content.ReadAsStringAsync()).ReadJson(); + var userId = created.GetProperty("id").GetString()!; + + var getResponse = await client.GetAsync($"/api/account/users/{userId}"); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + + var updateResponse = await client.PutAsJsonAsync($"/api/account/users/{userId}", new + { + id = userId, + userName, + fullName = "Updated Name", + email = $"{userName}@quickapp.test", + jobTitle = "Senior Tester", + isEnabled = true, + roles = new[] { "user" } + }); + Assert.Equal(HttpStatusCode.NoContent, updateResponse.StatusCode); + + var updated = (await (await client.GetAsync($"/api/account/users/{userId}")).Content.ReadAsStringAsync()).ReadJson(); + Assert.Equal("Updated Name", updated.GetProperty("fullName").GetString()); + + var deleteResponse = await client.DeleteAsync($"/api/account/users/{userId}"); + Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode); + + var afterDelete = await client.GetAsync($"/api/account/users/{userId}"); + Assert.Equal(HttpStatusCode.NotFound, afterDelete.StatusCode); + } + + [Fact] + public async Task Admin_CanAssignRolesToUser() + { + var client = await _factory.CreateClient().AuthenticateAsync(); + var userName = $"role-{Guid.NewGuid():N}"[..12]; + + var createResponse = await client.PostAsJsonAsync("/api/account/users", NewUser(userName)); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + + var userId = (await createResponse.Content.ReadAsStringAsync()).ReadJson().GetProperty("id").GetString()!; + + var updateResponse = await client.PutAsJsonAsync($"/api/account/users/{userId}", new + { + id = userId, + userName, + fullName = "Integration Test User", + email = $"{userName}@quickapp.test", + isEnabled = true, + roles = new[] { "user", "administrator" } + }); + Assert.Equal(HttpStatusCode.NoContent, updateResponse.StatusCode); + + var reloaded = (await (await client.GetAsync($"/api/account/users/{userId}")).Content.ReadAsStringAsync()).ReadJson(); + var roles = reloaded.GetProperty("roles").EnumerateArray().Select(r => r.GetString()).ToArray(); + + Assert.Contains("administrator", roles); + Assert.Contains("user", roles); + + await client.DeleteAsync($"/api/account/users/{userId}"); + } + + [Fact] + public async Task StandardUser_CannotListAllUsers() + { + var client = await _factory.CreateClient() + .AuthenticateAsync(TestClientExtensions.StandardUserName, TestClientExtensions.DefaultPassword); + + var response = await client.GetAsync("/api/account/users"); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } +} diff --git a/src/Services/Identity/Identity.Tests/UserRoleTests.cs b/src/Services/Identity/Identity.Tests/UserRoleTests.cs new file mode 100644 index 0000000..f92d49e --- /dev/null +++ b/src/Services/Identity/Identity.Tests/UserRoleTests.cs @@ -0,0 +1,70 @@ +using System.Net; +using System.Net.Http.Json; + +namespace Identity.Tests; + +public class UserRoleTests(IdentityWebApplicationFactory factory) : IClassFixture +{ + private readonly IdentityWebApplicationFactory _factory = factory; + + [Fact] + public async Task Permissions_EndpointReturnsAllApplicationPermissions() + { + var client = await _factory.CreateClient().AuthenticateAsync(); + + var response = await client.GetAsync("/api/account/permissions"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var permissions = (await response.Content.ReadAsStringAsync()).ReadJson() + .EnumerateArray() + .Select(p => p.GetProperty("value").GetString()) + .ToArray(); + + Assert.Contains("users.view", permissions); + Assert.Contains("roles.manage", permissions); + } + + [Fact] + public async Task SeededAdministratorRole_HasAllPermissions() + { + var client = await _factory.CreateClient().AuthenticateAsync(); + + var response = await client.GetAsync("/api/account/roles/name/administrator"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var role = (await response.Content.ReadAsStringAsync()).ReadJson(); + var permissions = role.GetProperty("permissions").EnumerateArray() + .Select(p => p.GetProperty("value").GetString()) + .ToArray(); + + Assert.Contains("users.manage", permissions); + Assert.Contains("roles.assign", permissions); + } + + [Fact] + public async Task Admin_CanCreateRoleWithPermissionsAndDeleteIt() + { + var client = await _factory.CreateClient().AuthenticateAsync(); + var roleName = $"role-{Guid.NewGuid():N}"[..12]; + + var createResponse = await client.PostAsJsonAsync("/api/account/roles", new + { + name = roleName, + description = "Created by integration test", + permissions = new[] { new { value = "users.view" } } + }); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + + var roleId = (await createResponse.Content.ReadAsStringAsync()).ReadJson().GetProperty("id").GetString()!; + + var role = (await (await client.GetAsync($"/api/account/roles/{roleId}")).Content.ReadAsStringAsync()).ReadJson(); + var permissions = role.GetProperty("permissions").EnumerateArray() + .Select(p => p.GetProperty("value").GetString()) + .ToArray(); + + Assert.Equal("users.view", Assert.Single(permissions)); + + var deleteResponse = await client.DeleteAsync($"/api/account/roles/{roleId}"); + Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode); + } +}