From 28da2841baac53a2175d7100b065cb159d553c9d Mon Sep 17 00:00:00 2001 From: iuri dotta Date: Wed, 4 Mar 2026 15:12:23 -0300 Subject: [PATCH 01/12] feat(validation): add ValidationHelper for FluentValidation integration refactor(validators): update password validation to use IdmtPasswordOptions chore(solution): remove old .sln file and add new .slnx format fix(dependencies): update package versions for Idmt.BasicSample project test(integration): remove obsolete system info tests from AdminIntegrationTests test(integration): update AuthIntegrationTests to reflect new endpoint naming conventions test(integration): modify ManageIntegrationTests to use new reset-password endpoint test(integration): adjust MultiTenancyIntegrationTests for updated endpoint paths chore(tests): update Idmt.UnitTests project dependencies and remove unused tests test(unit): refactor ValidatorsTests to use IdmtPasswordOptions and remove obsolete validations --- .gitignore | 2 + src/Idmt.Plugin/Configuration/IdmtOptions.cs | 21 +-- src/Idmt.Plugin/Constants/AuditAction.cs | 8 + src/Idmt.Plugin/Constants/IdmtClaimTypes.cs | 6 + src/Idmt.Plugin/DT.cs | 10 - src/Idmt.Plugin/Errors/ErrorOrExtensions.cs | 23 +++ src/Idmt.Plugin/Errors/IdmtErrors.cs | 131 ++++++++++++++ .../ApplicationBuilderExtensions.cs | 98 ++-------- .../Extensions/ServiceCollectionExtensions.cs | 41 +++-- src/Idmt.Plugin/Features/Admin/AdminModels.cs | 9 + .../Features/Admin/CreateTenant.cs | 87 +++------ .../Features/Admin/DeleteTenant.cs | 28 +-- .../Features/Admin/GetAllTenants.cs | 21 ++- .../Features/Admin/GetSystemInfo.cs | 63 ------- .../Features/Admin/GetUserTenants.cs | 45 ++--- .../Features/Admin/GrantTenantAccess.cs | 50 +++-- .../Features/Admin/RevokeTenantAccess.cs | 49 ++--- src/Idmt.Plugin/Features/AdminEndpoints.cs | 1 - src/Idmt.Plugin/Features/Auth/ConfirmEmail.cs | 105 ++++------- .../Features/Auth/ForgotPassword.cs | 38 ++-- src/Idmt.Plugin/Features/Auth/Login.cs | 110 +++++------ src/Idmt.Plugin/Features/Auth/RefreshToken.cs | 36 ++-- .../Features/Auth/ResendConfirmationEmail.cs | 35 ++-- .../Features/Auth/ResetPassword.cs | 123 +++++-------- .../Features/Health/BasicHealthCheck.cs | 6 +- .../Features/Manage/GetUserInfo.cs | 2 +- .../Features/Manage/RegisterUser.cs | 164 +++-------------- .../Features/Manage/UnregisterUser.cs | 30 +-- src/Idmt.Plugin/Features/Manage/UpdateUser.cs | 30 +-- .../Features/Manage/UpdateUserInfo.cs | 61 +++---- src/Idmt.Plugin/Features/Result.cs | 35 ---- src/Idmt.Plugin/Idmt.Plugin.csproj | 21 ++- .../ValidateBearerTokenTenantMiddleware.cs | 9 +- src/Idmt.Plugin/Models/IdmtAuditLog.cs | 2 +- src/Idmt.Plugin/Models/IdmtTenantInfo.cs | 27 ++- src/Idmt.Plugin/Persistence/IdmtDbContext.cs | 21 ++- .../Services/BetterSignInManager.cs | 44 ----- .../Services/CurrentUserService.cs | 2 +- .../Services/ICurrentUserService.cs | 1 + .../Services/ITenantOperationService.cs | 16 ++ src/Idmt.Plugin/Services/IdmtEmailSender.cs | 3 - .../IdmtUserClaimsPrincipalFactory.cs | 15 +- .../Services/TenantAccessService.cs | 5 +- .../Services/TenantOperationService.cs | 46 +++++ .../ConfirmEmailRequestValidator.cs | 21 +++ .../CreateTenantRequestValidator.cs | 21 +++ .../ForgotPasswordRequestValidator.cs | 13 ++ .../Validation/LoginRequestValidator.cs | 29 +++ .../RefreshTokenRequestValidator.cs | 13 ++ .../RegisterUserRequestValidator.cs | 29 +++ ...ResendConfirmationEmailRequestValidator.cs | 13 ++ .../ResetPasswordRequestValidator.cs | 16 ++ .../UpdateUserInfoRequestValidator.cs | 31 ++++ .../Validation/ValidationHelper.cs | 19 ++ src/Idmt.Plugin/Validation/Validators.cs | 18 +- src/Idmt.sln | 49 ----- src/Idmt.slnx | 10 + .../Idmt.BasicSample/Idmt.BasicSample.csproj | 4 +- .../AdminIntegrationTests.cs | 58 +----- .../AuthIntegrationTests.cs | 40 ++-- .../Idmt.BasicSample.Tests.csproj | 8 +- .../ManageIntegrationTests.cs | 12 +- .../MultiTenancyIntegrationTests.cs | 14 +- .../Idmt.UnitTests/Idmt.UnitTests.csproj | 6 +- .../Services/CoreServicesTests.cs | 171 +----------------- .../IdmtUserClaimsPrincipalFactoryTests.cs | 6 +- .../Services/TenantAccessServiceTests.cs | 53 +++++- .../Validation/ValidatorsTests.cs | 38 +--- 68 files changed, 1048 insertions(+), 1324 deletions(-) create mode 100644 src/Idmt.Plugin/Constants/AuditAction.cs create mode 100644 src/Idmt.Plugin/Constants/IdmtClaimTypes.cs delete mode 100644 src/Idmt.Plugin/DT.cs create mode 100644 src/Idmt.Plugin/Errors/ErrorOrExtensions.cs create mode 100644 src/Idmt.Plugin/Errors/IdmtErrors.cs create mode 100644 src/Idmt.Plugin/Features/Admin/AdminModels.cs delete mode 100644 src/Idmt.Plugin/Features/Admin/GetSystemInfo.cs delete mode 100644 src/Idmt.Plugin/Features/Result.cs delete mode 100644 src/Idmt.Plugin/Services/BetterSignInManager.cs create mode 100644 src/Idmt.Plugin/Services/ITenantOperationService.cs create mode 100644 src/Idmt.Plugin/Services/TenantOperationService.cs create mode 100644 src/Idmt.Plugin/Validation/ConfirmEmailRequestValidator.cs create mode 100644 src/Idmt.Plugin/Validation/CreateTenantRequestValidator.cs create mode 100644 src/Idmt.Plugin/Validation/ForgotPasswordRequestValidator.cs create mode 100644 src/Idmt.Plugin/Validation/LoginRequestValidator.cs create mode 100644 src/Idmt.Plugin/Validation/RefreshTokenRequestValidator.cs create mode 100644 src/Idmt.Plugin/Validation/RegisterUserRequestValidator.cs create mode 100644 src/Idmt.Plugin/Validation/ResendConfirmationEmailRequestValidator.cs create mode 100644 src/Idmt.Plugin/Validation/ResetPasswordRequestValidator.cs create mode 100644 src/Idmt.Plugin/Validation/UpdateUserInfoRequestValidator.cs create mode 100644 src/Idmt.Plugin/Validation/ValidationHelper.cs delete mode 100644 src/Idmt.sln create mode 100644 src/Idmt.slnx diff --git a/.gitignore b/.gitignore index c6ddfad..3d09d56 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,5 @@ nunit-*.xml *.db *.db-shm *.db-wal + +.claude/settings.local.json diff --git a/src/Idmt.Plugin/Configuration/IdmtOptions.cs b/src/Idmt.Plugin/Configuration/IdmtOptions.cs index 34818e6..1746ac6 100644 --- a/src/Idmt.Plugin/Configuration/IdmtOptions.cs +++ b/src/Idmt.Plugin/Configuration/IdmtOptions.cs @@ -24,7 +24,7 @@ public class IdmtOptions /// /// Identity configuration options /// - public AuthOptions Identity { get; set; } = new(); + public IdmtAuthOptions Identity { get; set; } = new(); /// /// Multi-tenant configuration options @@ -62,7 +62,7 @@ public class ApplicationOptions /// /// ASP.NET Core Identity configuration /// -public class AuthOptions +public class IdmtAuthOptions { public const string CookieOrBearerScheme = "CookieOrBearer"; @@ -75,7 +75,7 @@ public class AuthOptions /// /// Password requirements /// - public PasswordOptions Password { get; set; } = new(); + public IdmtPasswordOptions Password { get; set; } = new(); /// /// User requirements @@ -90,7 +90,7 @@ public class AuthOptions /// /// Cookie configuration options /// - public CookieOptions Cookie { get; set; } = new(); + public IdmtCookieOptions Cookie { get; set; } = new(); /// /// Bearer token configuration options @@ -106,13 +106,13 @@ public class AuthOptions /// /// Password configuration options /// -public class PasswordOptions +public class IdmtPasswordOptions { public bool RequireDigit { get; set; } = true; public bool RequireLowercase { get; set; } = true; public bool RequireUppercase { get; set; } = true; public bool RequireNonAlphanumeric { get; set; } = false; - public int RequiredLength { get; set; } = 6; + public int RequiredLength { get; set; } = 8; public int RequiredUniqueChars { get; set; } = 1; } @@ -137,11 +137,11 @@ public class SignInOptions /// /// Cookie configuration options /// -public class CookieOptions +public class IdmtCookieOptions { public string Name { get; set; } = ".Idmt.Application"; public bool HttpOnly { get; set; } = true; - public Microsoft.AspNetCore.Http.CookieSecurePolicy SecurePolicy { get; set; } = Microsoft.AspNetCore.Http.CookieSecurePolicy.SameAsRequest; + public Microsoft.AspNetCore.Http.CookieSecurePolicy SecurePolicy { get; set; } = Microsoft.AspNetCore.Http.CookieSecurePolicy.Always; public Microsoft.AspNetCore.Http.SameSiteMode SameSite { get; set; } = Microsoft.AspNetCore.Http.SameSiteMode.Lax; public TimeSpan ExpireTimeSpan { get; set; } = TimeSpan.FromDays(14); public bool SlidingExpiration { get; set; } = true; @@ -209,11 +209,6 @@ public class MultiTenantOptions /// public class DatabaseOptions { - /// - /// Connection string template with placeholder for tenant's properties - /// - public string ConnectionStringTemplate { get; set; } = string.Empty; - /// /// Auto-migrate database on startup /// diff --git a/src/Idmt.Plugin/Constants/AuditAction.cs b/src/Idmt.Plugin/Constants/AuditAction.cs new file mode 100644 index 0000000..7180927 --- /dev/null +++ b/src/Idmt.Plugin/Constants/AuditAction.cs @@ -0,0 +1,8 @@ +namespace Idmt.Plugin.Constants; + +public enum AuditAction +{ + Created, + Modified, + Deleted +} diff --git a/src/Idmt.Plugin/Constants/IdmtClaimTypes.cs b/src/Idmt.Plugin/Constants/IdmtClaimTypes.cs new file mode 100644 index 0000000..940b050 --- /dev/null +++ b/src/Idmt.Plugin/Constants/IdmtClaimTypes.cs @@ -0,0 +1,6 @@ +namespace Idmt.Plugin.Constants; + +public static class IdmtClaimTypes +{ + public const string IsActive = "is_active"; +} diff --git a/src/Idmt.Plugin/DT.cs b/src/Idmt.Plugin/DT.cs deleted file mode 100644 index 0c5a7b5..0000000 --- a/src/Idmt.Plugin/DT.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Idmt.Plugin; - -/// -/// DateTime substiute to guarantee UTC timezone -/// -public static class DT -{ - public static DateTime UtcNow => DateTime.UtcNow; - public static DateTimeOffset UtcNowOffset => DateTimeOffset.UtcNow; -} \ No newline at end of file diff --git a/src/Idmt.Plugin/Errors/ErrorOrExtensions.cs b/src/Idmt.Plugin/Errors/ErrorOrExtensions.cs new file mode 100644 index 0000000..08e8c1c --- /dev/null +++ b/src/Idmt.Plugin/Errors/ErrorOrExtensions.cs @@ -0,0 +1,23 @@ +using ErrorOr; +using Microsoft.AspNetCore.Http; + +namespace Idmt.Plugin.Errors; + +public static class ErrorOrExtensions +{ + public static IResult ToHttpResult(this List errors) + { + if (errors.Count == 0) + return TypedResults.InternalServerError(); + + var firstError = errors[0]; + return firstError.Type switch + { + ErrorType.NotFound => TypedResults.NotFound(), + ErrorType.Validation => TypedResults.BadRequest(), + ErrorType.Unauthorized => TypedResults.Unauthorized(), + ErrorType.Forbidden => TypedResults.Forbid(), + _ => TypedResults.InternalServerError(), + }; + } +} diff --git a/src/Idmt.Plugin/Errors/IdmtErrors.cs b/src/Idmt.Plugin/Errors/IdmtErrors.cs new file mode 100644 index 0000000..4326dae --- /dev/null +++ b/src/Idmt.Plugin/Errors/IdmtErrors.cs @@ -0,0 +1,131 @@ +using ErrorOr; + +namespace Idmt.Plugin.Errors; + +public static class IdmtErrors +{ + public static class Auth + { + public static Error Unauthorized => Error.Unauthorized( + code: "Auth.Unauthorized", + description: "Unauthorized"); + + public static Error Forbidden => Error.Forbidden( + code: "Auth.Forbidden", + description: "Forbidden"); + + public static Error UserDeactivated => Error.Forbidden( + code: "Auth.UserDeactivated", + description: "User is deactivated"); + + public static Error InvalidCredentials => Error.Unauthorized( + code: "Auth.InvalidCredentials", + description: "Invalid credentials"); + } + + public static class Tenant + { + public static Error NotFound => Error.NotFound( + code: "Tenant.NotFound", + description: "Tenant not found"); + + public static Error Inactive => Error.Validation( + code: "Tenant.Inactive", + description: "Tenant is not active"); + + public static Error NotResolved => Error.Validation( + code: "Tenant.NotResolved", + description: "Tenant not resolved"); + + public static Error CannotDeleteDefault => Error.Forbidden( + code: "Tenant.CannotDeleteDefault", + description: "Cannot delete the default tenant"); + + public static Error CreationFailed => Error.Failure( + code: "Tenant.CreationFailed", + description: "Failed to create tenant"); + + public static Error UpdateFailed => Error.Failure( + code: "Tenant.UpdateFailed", + description: "Failed to update tenant"); + + public static Error DeletionFailed => Error.Failure( + code: "Tenant.DeletionFailed", + description: "Failed to delete tenant"); + + public static Error RoleSeedFailed => Error.Failure( + code: "Tenant.RoleSeedFailed", + description: "Failed to guarantee tenant roles"); + + public static Error AccessError => Error.Failure( + code: "Tenant.AccessError", + description: "An error occurred while managing tenant access"); + } + + public static class User + { + public static Error NotFound => Error.NotFound( + code: "User.NotFound", + description: "User not found"); + + public static Error CreationFailed => Error.Failure( + code: "User.CreationFailed", + description: "Failed to create user"); + + public static Error UpdateFailed => Error.Failure( + code: "User.UpdateFailed", + description: "Failed to update user"); + + public static Error RoleNotFound => Error.Validation( + code: "User.RoleNotFound", + description: "Role not found"); + + public static Error InsufficientPermissions => Error.Forbidden( + code: "User.InsufficientPermissions", + description: "Insufficient permissions"); + + public static Error NoRolesAssigned => Error.Validation( + code: "User.NoRolesAssigned", + description: "User has no roles assigned"); + + public static Error ClaimsNotFound => Error.Validation( + code: "User.ClaimsNotFound", + description: "User claims not found"); + + public static Error Inactive => Error.Forbidden( + code: "User.Inactive", + description: "User is not active"); + + public static Error DeletionFailed => Error.Failure( + code: "User.DeletionFailed", + description: "Failed to delete user"); + } + + public static class Token + { + public static Error Invalid => Error.Validation( + code: "Token.Invalid", + description: "Invalid token"); + } + + public static class Email + { + public static Error ConfirmationFailed => Error.Failure( + code: "Email.ConfirmationFailed", + description: "Unable to confirm email"); + } + + public static class Password + { + public static Error ResetFailed => Error.Failure( + code: "Password.ResetFailed", + description: "Unable to reset password"); + } + + public static class General + { + public static Error Unexpected => Error.Unexpected( + code: "General.Unexpected", + description: "An unexpected error occurred"); + } +} diff --git a/src/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs b/src/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs index 4cd2a27..af49a74 100644 --- a/src/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs +++ b/src/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs @@ -1,4 +1,3 @@ -using System.Reflection; using Finbuckle.MultiTenant.AspNetCore.Extensions; using Idmt.Plugin.Configuration; using Idmt.Plugin.Features; @@ -23,12 +22,13 @@ namespace Idmt.Plugin.Extensions; public static class ApplicationBuilderExtensions { /// - /// Middleware pipeline for security before calling UseIdmt() + /// Adds IDMT middleware to the application pipeline, including security headers. /// - /// The web application - /// The web application - public static IApplicationBuilder UseIdmtSecurity(this WebApplication app) + /// The application builder + /// The application builder + public static IApplicationBuilder UseIdmt(this IApplicationBuilder app) { + // Security headers app.Use(async (context, next) => { context.Response.Headers.XContentTypeOptions = "nosniff"; @@ -38,16 +38,6 @@ public static IApplicationBuilder UseIdmtSecurity(this WebApplication app) await next(); }); - return app; - } - - /// - /// Adds IDMT middleware to the application pipeline - /// - /// The application builder - /// The application builder - public static IApplicationBuilder UseIdmt(this IApplicationBuilder app) - { // Add multi-tenant middleware - must come before authentication app.UseMultiTenant(); @@ -78,7 +68,7 @@ public static IEndpointRouteBuilder MapIdmtEndpoints(this IEndpointRouteBuilder endpoints.MapAuthEndpoints(); endpoints.MapAuthManageEndpoints(); endpoints.MapAdminEndpoints(); - endpoints.MapHealthChecks("/healthz").RequireAuthorization(AuthOptions.RequireSysUserPolicy); + endpoints.MapHealthChecks("/healthz").RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy); return endpoints; } @@ -123,9 +113,8 @@ public static async Task EnsureIdmtDatabaseAsync(this IApplicationBuilder app, b // NOTE: IdmtTenantStoreDbContext shares the same database/connection // No separate initialization needed - it accesses tables created above } - catch (Exception ex) + catch { - Console.Error.WriteLine($"Database initialization failed: {ex.Message}"); throw; } } @@ -151,9 +140,8 @@ public static async Task SeedIdmtDataAsync(this IApplicatio await seedAction(services); } } - catch (Exception ex) + catch { - Console.Error.WriteLine($"Data seeding failed: {ex.Message}"); throw; } @@ -172,73 +160,15 @@ await createTenantHandler.HandleAsync(new CreateTenant.CreateTenantRequest( private static void VerifyUserStoreSupportsEmail(IApplicationBuilder app) { - // Check if the service is registered without resolving it to avoid circular dependencies - var serviceProvider = app.ApplicationServices; - - // Fallback: Use reflection to access service descriptors for older .NET versions - var serviceDescriptors = GetServiceDescriptors(serviceProvider); - var userStoreDescriptor = serviceDescriptors.FirstOrDefault(sd => sd.ServiceType == typeof(IUserStore)) - ?? throw new InvalidOperationException("No IUserStore is registered. Ensure Identity is configured before calling UseIdmt()."); - - // Check if the implementation type implements IUserEmailStore - var implementationType = userStoreDescriptor.ImplementationType; - if (implementationType == null) - { - // If using a factory or instance, we can't verify without resolving - // EntityFrameworkStores always implements IUserEmailStore, so we assume it's correct - return; - } - - // Verify the implementation type implements IUserEmailStore - var emailStoreType = typeof(IUserEmailStore); - if (!emailStoreType.IsAssignableFrom(implementationType)) - { - throw new NotSupportedException($"Idmt.Plugin requires a user store that implements IUserEmailStore (email support). Found: {implementationType.FullName}"); - } - } - - private static IEnumerable GetServiceDescriptors(IServiceProvider serviceProvider) - { - // Use reflection to access the internal CallSiteFactory which contains the service descriptors - // This avoids resolving services and prevents circular dependency issues - object? callSiteFactory = null; - - var field = serviceProvider.GetType().GetField("_serviceProvider", BindingFlags.NonPublic | BindingFlags.Instance) - ?? serviceProvider.GetType().GetField("_callSiteFactory", BindingFlags.NonPublic | BindingFlags.Instance); - - if (field != null) - { - callSiteFactory = field.GetValue(serviceProvider); - } - else - { - var property = serviceProvider.GetType().GetProperty("CallSiteFactory", BindingFlags.NonPublic | BindingFlags.Instance); - if (property != null) - { - callSiteFactory = property.GetValue(serviceProvider); - } - } - - if (callSiteFactory == null) - { - return []; - } - - // Access the descriptors from the call site factory - var descriptorsProperty = callSiteFactory.GetType().GetProperty("Descriptors", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); - if (descriptorsProperty != null) + using var scope = app.ApplicationServices.CreateScope(); + var userStore = scope.ServiceProvider.GetService>(); + if (userStore is null) { - var descriptors = descriptorsProperty.GetValue(callSiteFactory) as IEnumerable; - return descriptors ?? Enumerable.Empty(); + throw new InvalidOperationException("No IUserStore is registered. Ensure Identity is configured before calling UseIdmt()."); } - - var descriptorsField = callSiteFactory.GetType().GetField("_descriptors", BindingFlags.NonPublic | BindingFlags.Instance); - if (descriptorsField != null) + if (userStore is not IUserEmailStore) { - var descriptors = descriptorsField.GetValue(callSiteFactory) as IEnumerable; - return descriptors ?? Enumerable.Empty(); + throw new NotSupportedException("Idmt.Plugin requires a user store that supports email (IUserEmailStore)."); } - - return []; } } \ No newline at end of file diff --git a/src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs b/src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs index b179378..e74332f 100644 --- a/src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs +++ b/src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs @@ -17,7 +17,9 @@ using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; +using FluentValidation; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Idmt.Plugin.Extensions; @@ -86,9 +88,11 @@ public static IServiceCollection AddIdmt( this IServiceCollection services, IConfiguration configuration, Action? configureDb = null, - Action? configureOptions = null) + Action? configureOptions = null, + CustomizeAuthentication? customizeAuthentication = null, + CustomizeAuthorization? customizeAuthorization = null) { - return services.AddIdmt(configuration, configureDb, configureOptions); + return services.AddIdmt(configuration, configureDb, configureOptions, customizeAuthentication, customizeAuthorization); } #region Private Configuration Methods @@ -250,7 +254,7 @@ private static void ConfigureIdentity(IServiceCollection services, IdmtOptions i }) .AddRoles() .AddEntityFrameworkStores() - .AddSignInManager() + .AddSignInManager() .AddClaimsPrincipalFactory() .AddDefaultTokenProviders(); } @@ -263,8 +267,8 @@ private static void ConfigureAuthentication( // Configure authentication with both cookie and bearer token support var authenticationBuilder = services.AddAuthentication(options => { - options.DefaultScheme = AuthOptions.CookieOrBearerScheme; - options.DefaultChallengeScheme = AuthOptions.CookieOrBearerScheme; + options.DefaultScheme = IdmtAuthOptions.CookieOrBearerScheme; + options.DefaultChallengeScheme = IdmtAuthOptions.CookieOrBearerScheme; }); // Cookie authentication @@ -325,7 +329,7 @@ private static void ConfigureAuthentication( }); // PolicyScheme - automatically routes based on request - authenticationBuilder.AddPolicyScheme(AuthOptions.CookieOrBearerScheme, "Cookie or Bearer", options => + authenticationBuilder.AddPolicyScheme(IdmtAuthOptions.CookieOrBearerScheme, "Cookie or Bearer", options => { options.ForwardDefaultSelector = context => { @@ -348,34 +352,34 @@ private static void ConfigureAuthorization(IServiceCollection services, Customiz { // Configure authorization var authorizationBuilder = services.AddAuthorizationBuilder() - .SetDefaultPolicy(new AuthorizationPolicyBuilder(AuthOptions.CookieOrBearerScheme) + .SetDefaultPolicy(new AuthorizationPolicyBuilder(IdmtAuthOptions.CookieOrBearerScheme) .RequireAuthenticatedUser() .Build()) // Cookie-only policy (rare - for web-specific features) - .AddPolicy(AuthOptions.CookieOnlyPolicy, policy => policy + .AddPolicy(IdmtAuthOptions.CookieOnlyPolicy, policy => policy .RequireAuthenticatedUser() .AddAuthenticationSchemes(IdentityConstants.ApplicationScheme)) // Bearer-only policy (rare - for strict API endpoints) - .AddPolicy(AuthOptions.BearerOnlyPolicy, policy => policy + .AddPolicy(IdmtAuthOptions.BearerOnlyPolicy, policy => policy .RequireAuthenticatedUser() .AddAuthenticationSchemes(IdentityConstants.BearerScheme)) // Add system admin policy - .AddPolicy(AuthOptions.RequireSysAdminPolicy, policy => + .AddPolicy(IdmtAuthOptions.RequireSysAdminPolicy, policy => policy.RequireRole(IdmtDefaultRoleTypes.SysAdmin) - .AddAuthenticationSchemes(AuthOptions.CookieOrBearerScheme)) + .AddAuthenticationSchemes(IdmtAuthOptions.CookieOrBearerScheme)) // Add system user policy - .AddPolicy(AuthOptions.RequireSysUserPolicy, policy => + .AddPolicy(IdmtAuthOptions.RequireSysUserPolicy, policy => policy.RequireRole(IdmtDefaultRoleTypes.SysAdmin, IdmtDefaultRoleTypes.SysSupport) - .AddAuthenticationSchemes(AuthOptions.CookieOrBearerScheme)) + .AddAuthenticationSchemes(IdmtAuthOptions.CookieOrBearerScheme)) // Add tenant admin policy - .AddPolicy(AuthOptions.RequireTenantManagerPolicy, policy => + .AddPolicy(IdmtAuthOptions.RequireTenantManagerPolicy, policy => policy.RequireRole(IdmtDefaultRoleTypes.SysAdmin, IdmtDefaultRoleTypes.SysSupport, IdmtDefaultRoleTypes.TenantAdmin) - .AddAuthenticationSchemes(AuthOptions.CookieOrBearerScheme)); + .AddAuthenticationSchemes(IdmtAuthOptions.CookieOrBearerScheme)); customizeAuthorization?.Invoke(authorizationBuilder); } @@ -385,9 +389,16 @@ private static void RegisterApplicationServices(IServiceCollection services) // Register scoped services for per-request context services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddTransient, IdmtEmailSender>(); + // Register TimeProvider for testable time access + services.TryAddSingleton(TimeProvider.System); + + // Register FluentValidation validators + services.AddValidatorsFromAssemblyContaining(ServiceLifetime.Scoped); + // Register HTTP context accessor for service access to HTTP context services.AddHttpContextAccessor(); } diff --git a/src/Idmt.Plugin/Features/Admin/AdminModels.cs b/src/Idmt.Plugin/Features/Admin/AdminModels.cs new file mode 100644 index 0000000..e790a64 --- /dev/null +++ b/src/Idmt.Plugin/Features/Admin/AdminModels.cs @@ -0,0 +1,9 @@ +namespace Idmt.Plugin.Features.Admin; + +public sealed record TenantInfoResponse( + string Id, + string Identifier, + string Name, + string Plan, + bool IsActive +); diff --git a/src/Idmt.Plugin/Features/Admin/CreateTenant.cs b/src/Idmt.Plugin/Features/Admin/CreateTenant.cs index be18c50..9887dd3 100644 --- a/src/Idmt.Plugin/Features/Admin/CreateTenant.cs +++ b/src/Idmt.Plugin/Features/Admin/CreateTenant.cs @@ -1,6 +1,10 @@ +using ErrorOr; using Finbuckle.MultiTenant.Abstractions; +using FluentValidation; using Idmt.Plugin.Configuration; +using Idmt.Plugin.Errors; using Idmt.Plugin.Models; +using Idmt.Plugin.Services; using Idmt.Plugin.Validation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -31,18 +35,16 @@ string DisplayName public interface ICreateTenantHandler { - Task> HandleAsync(CreateTenantRequest request, CancellationToken cancellationToken = default); + Task> HandleAsync(CreateTenantRequest request, CancellationToken cancellationToken = default); } internal sealed class CreateTenantHandler( IMultiTenantStore tenantStore, - IMultiTenantContextSetter tenantContextSetter, - IMultiTenantContextAccessor tenantContextAccessor, - IServiceProvider serviceProvider, + ITenantOperationService tenantOps, IOptions options, ILogger logger) : ICreateTenantHandler { - public async Task> HandleAsync(CreateTenantRequest request, CancellationToken cancellationToken = default) + public async Task> HandleAsync(CreateTenantRequest request, CancellationToken cancellationToken = default) { IdmtTenantInfo resultTenant; @@ -56,7 +58,7 @@ public async Task> HandleAsync(CreateTenantRequest existingTenant = existingTenant with { IsActive = true }; if (!await tenantStore.UpdateAsync(existingTenant)) { - return Result.Failure("Failed to update tenant", StatusCodes.Status500InternalServerError); + return IdmtErrors.Tenant.UpdateFailed; } } resultTenant = existingTenant; @@ -65,12 +67,12 @@ public async Task> HandleAsync(CreateTenantRequest { var tenant = new IdmtTenantInfo(request.Identifier, request.Name) { - DisplayName = request.DisplayName + Name = request.DisplayName }; if (!await tenantStore.AddAsync(tenant)) { - return Result.Failure("Failed to create tenant", StatusCodes.Status400BadRequest); + return IdmtErrors.Tenant.CreationFailed; } resultTenant = tenant; } @@ -78,7 +80,7 @@ public async Task> HandleAsync(CreateTenantRequest catch (Exception ex) { logger.LogError(ex, "Error creating tenant with identifier {Identifier}", request.Identifier); - return Result.Failure($"Error creating tenant: {ex.Message}", StatusCodes.Status500InternalServerError); + return IdmtErrors.General.Unexpected; } try @@ -86,7 +88,7 @@ public async Task> HandleAsync(CreateTenantRequest bool ok = await GuaranteeTenantRolesAsync(resultTenant); if (!ok) { - return Result.Failure($"Failed to guarantee tenant roles.", StatusCodes.Status500InternalServerError); + return IdmtErrors.Tenant.RoleSeedFailed; } } catch (Exception ex) @@ -94,11 +96,11 @@ public async Task> HandleAsync(CreateTenantRequest logger.LogError(ex, "Error seeding roles for tenant {Identifier}", request.Identifier); } - return Result.Success(new CreateTenantResponse( + return new CreateTenantResponse( resultTenant.Id ?? string.Empty, resultTenant.Identifier ?? string.Empty, resultTenant.Name ?? string.Empty, - resultTenant.DisplayName ?? string.Empty), StatusCodes.Status200OK); + resultTenant.Name ?? string.Empty); } private async Task GuaranteeTenantRolesAsync(IdmtTenantInfo tenantInfo) @@ -109,81 +111,48 @@ private async Task GuaranteeTenantRolesAsync(IdmtTenantInfo tenantInfo) roles = [.. roles, .. options.Value.Identity.ExtraRoles]; } - // Set tenant context before seeding roles to avoid NullReferenceException with multi-tenant filters - var previousContext = tenantContextAccessor.MultiTenantContext; - try + var result = await tenantOps.ExecuteInTenantScopeAsync(tenantInfo.Identifier!, async provider => { - tenantContextSetter.MultiTenantContext = new MultiTenantContext(tenantInfo); - - // Seed default roles - using var scope = serviceProvider.CreateScope(); - var roleManager = scope.ServiceProvider.GetRequiredService>(); + var roleManager = provider.GetRequiredService>(); foreach (var role in roles) { if (!await roleManager.RoleExistsAsync(role)) { - var result = await roleManager.CreateAsync(new IdmtRole(role)); - if (!result.Succeeded) + var createResult = await roleManager.CreateAsync(new IdmtRole(role)); + if (!createResult.Succeeded) { - return false; + return IdmtErrors.Tenant.RoleSeedFailed; } } } - } - finally - { - // Restore previous context - tenantContextSetter.MultiTenantContext = previousContext; - } - - return true; - } - } - - public static Dictionary? Validate(this CreateTenantRequest request) - { - var errors = new Dictionary(); + return Result.Success; + }, requireActive: false); - if (string.IsNullOrEmpty(request.Identifier)) - { - errors["Identifier"] = ["Identifier is required"]; + return !result.IsError; } - else if (!Validators.IsValidTenantIdentifier(request.Identifier)) - { - errors["Identifier"] = ["Identifier can only contain lowercase alphanumeric characters, dashes, and underscores"]; - } - if (string.IsNullOrEmpty(request.Name)) - { - errors["Name"] = ["Name is required"]; - } - if (string.IsNullOrEmpty(request.DisplayName)) - { - errors["DisplayName"] = ["Display Name is required"]; - } - - return errors.Count > 0 ? errors : null; } public static RouteHandlerBuilder MapCreateTenantEndpoint(this IEndpointRouteBuilder endpoints) { - return endpoints.MapPost("/tenants", static async Task, Created, ValidationProblem, BadRequest>> ( + return endpoints.MapPost("/tenants", async Task, Created, ValidationProblem, BadRequest>> ( [FromBody] CreateTenantRequest request, [FromServices] ICreateTenantHandler handler, + [FromServices] IValidator validator, HttpContext context) => { - if (request.Validate() is { } validationErrors) + if (ValidationHelper.Validate(request, validator) is { } validationErrors) { return TypedResults.ValidationProblem(validationErrors); } var response = await handler.HandleAsync(request, cancellationToken: context.RequestAborted); - if (!response.IsSuccess) + if (response.IsError) { return TypedResults.BadRequest(); } return TypedResults.Ok(response.Value); }) - .RequireAuthorization(AuthOptions.RequireSysUserPolicy) + .RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy) .WithSummary("Create Tenant") .WithDescription("Create a new tenant in the system or reactivate an existing inactive tenant"); } -} \ No newline at end of file +} diff --git a/src/Idmt.Plugin/Features/Admin/DeleteTenant.cs b/src/Idmt.Plugin/Features/Admin/DeleteTenant.cs index 080cc8b..7c5ed2f 100644 --- a/src/Idmt.Plugin/Features/Admin/DeleteTenant.cs +++ b/src/Idmt.Plugin/Features/Admin/DeleteTenant.cs @@ -1,5 +1,7 @@ +using ErrorOr; using Finbuckle.MultiTenant.Abstractions; using Idmt.Plugin.Configuration; +using Idmt.Plugin.Errors; using Idmt.Plugin.Models; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -14,38 +16,38 @@ public static class DeleteTenant { public interface IDeleteTenantHandler { - Task HandleAsync(string tenantIdentifier, CancellationToken cancellationToken = default); + Task> HandleAsync(string tenantIdentifier, CancellationToken cancellationToken = default); } internal sealed class DeleteTenantHandler( IMultiTenantStore tenantStore, ILogger logger) : IDeleteTenantHandler { - public async Task HandleAsync(string tenantIdentifier, CancellationToken cancellationToken = default) + public async Task> HandleAsync(string tenantIdentifier, CancellationToken cancellationToken = default) { try { if (string.Compare(tenantIdentifier, MultiTenantOptions.DefaultTenantIdentifier, StringComparison.OrdinalIgnoreCase) == 0) { - return Result.Failure("Cannot delete the default tenant", StatusCodes.Status403Forbidden); + return IdmtErrors.Tenant.CannotDeleteDefault; } var tenant = await tenantStore.GetByIdentifierAsync(tenantIdentifier); if (tenant is null) { - return Result.Failure("Tenant not found", StatusCodes.Status404NotFound); + return IdmtErrors.Tenant.NotFound; } tenant = tenant with { IsActive = false }; var updateResult = await tenantStore.UpdateAsync(tenant); if (!updateResult) { - return Result.Failure("Failed to delete tenant", StatusCodes.Status500InternalServerError); + return IdmtErrors.Tenant.DeletionFailed; } - return Result.Success(); + return Result.Success; } catch (Exception ex) { logger.LogError(ex, "An error occurred while deleting tenant with ID {TenantId}", tenantIdentifier); - return Result.Failure($"An error occurred while deleting the tenant: {ex.Message}", StatusCodes.Status500InternalServerError); + return IdmtErrors.General.Unexpected; } } } @@ -58,19 +60,19 @@ public static RouteHandlerBuilder MapDeleteTenantEndpoint(this IEndpointRouteBui CancellationToken cancellationToken = default) => { var result = await handler.HandleAsync(tenantIdentifier, cancellationToken); - if (!result.IsSuccess) + if (result.IsError) { - return result.StatusCode switch + return result.FirstError.Type switch { - StatusCodes.Status403Forbidden => TypedResults.Forbid(), - StatusCodes.Status404NotFound => TypedResults.NotFound(), + ErrorType.Forbidden => TypedResults.Forbid(), + ErrorType.NotFound => TypedResults.NotFound(), _ => TypedResults.InternalServerError(), }; } return TypedResults.NoContent(); }) - .RequireAuthorization(AuthOptions.RequireSysAdminPolicy) + .RequireAuthorization(IdmtAuthOptions.RequireSysAdminPolicy) .WithSummary("Delete tenant") .WithDescription("Soft deletes a tenant by its identifier"); } -} \ No newline at end of file +} diff --git a/src/Idmt.Plugin/Features/Admin/GetAllTenants.cs b/src/Idmt.Plugin/Features/Admin/GetAllTenants.cs index b5f3f4e..040cdf7 100644 --- a/src/Idmt.Plugin/Features/Admin/GetAllTenants.cs +++ b/src/Idmt.Plugin/Features/Admin/GetAllTenants.cs @@ -1,5 +1,7 @@ +using ErrorOr; using Finbuckle.MultiTenant.Abstractions; using Idmt.Plugin.Configuration; +using Idmt.Plugin.Errors; using Idmt.Plugin.Models; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -13,14 +15,14 @@ public static class GetAllTenants { public interface IGetAllTenantsHandler { - Task> HandleAsync(CancellationToken cancellationToken = default); + Task> HandleAsync(CancellationToken cancellationToken = default); } internal sealed class GetAllTenantsHandler( IMultiTenantStore tenantStore, ILogger logger) : IGetAllTenantsHandler { - public async Task> HandleAsync(CancellationToken cancellationToken = default) + public async Task> HandleAsync(CancellationToken cancellationToken = default) { try { @@ -32,16 +34,15 @@ public async Task> HandleAsync(CancellationToken ca t!.Id ?? string.Empty, t.Identifier ?? string.Empty, t.Name ?? string.Empty, - t.DisplayName ?? string.Empty, t.Plan ?? string.Empty, t.IsActive)).ToArray(); - return Result.Success(res); + return res; } catch (Exception ex) { logger.LogError(ex, "An error occurred while retrieving all tenants"); - return Result.Failure($"An error occurred while retrieving tenants: {ex.Message}", StatusCodes.Status500InternalServerError); + return IdmtErrors.General.Unexpected; } } } @@ -53,13 +54,13 @@ public static RouteHandlerBuilder MapGetAllTenantsEndpoint(this IEndpointRouteBu CancellationToken cancellationToken) => { var result = await handler.HandleAsync(cancellationToken); - if (!result.IsSuccess) + if (result.IsError) { return TypedResults.InternalServerError(); } - return TypedResults.Ok(result.Value!); + return TypedResults.Ok(result.Value); }) - .RequireAuthorization(AuthOptions.RequireSysUserPolicy) - .WithSummary("Get tenants accessible by user"); + .RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy) + .WithSummary("Get all tenants"); } -} \ No newline at end of file +} diff --git a/src/Idmt.Plugin/Features/Admin/GetSystemInfo.cs b/src/Idmt.Plugin/Features/Admin/GetSystemInfo.cs deleted file mode 100644 index 5f00e7b..0000000 --- a/src/Idmt.Plugin/Features/Admin/GetSystemInfo.cs +++ /dev/null @@ -1,63 +0,0 @@ -using Finbuckle.MultiTenant.Abstractions; -using Idmt.Plugin.Configuration; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Routing; - -namespace Idmt.Plugin.Features.Admin; - -public static class GetSystemInfo -{ - public sealed record SystemInfoResponse - { - public string ApplicationName { get; set; } = string.Empty; - public string Version { get; set; } = string.Empty; - public string Environment { get; set; } = string.Empty; - public TenantInfo? CurrentTenant { get; set; } - public DateTime ServerTime { get; set; } - public List Features { get; set; } = []; - } - - public sealed record TenantInfo - { - public string? Id { get; set; } - public string? Name { get; set; } - public string? Identifier { get; set; } - } - - public static RouteHandlerBuilder MapGetSystemInfoEndpoint(this IEndpointRouteBuilder endpoints) - { - return endpoints.MapGet("/info", Task> ( - [FromServices] IMultiTenantContextAccessor tenantAccessor) => - { - var currentTenant = tenantAccessor.MultiTenantContext?.TenantInfo; - - var systemInfo = new SystemInfoResponse - { - ApplicationName = "IDMT Sample API", - Version = "1.0.0", - Environment = System.Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production", - CurrentTenant = currentTenant != null ? new TenantInfo - { - Id = currentTenant.Id, - Name = currentTenant.Name, - Identifier = currentTenant.Identifier - } : null, - ServerTime = DT.UtcNow, - Features = - [ - "Multi-Tenant Support", - "Vertical Slice Architecture", - "Minimal APIs", - "OpenAPI/Swagger Documentation" - ] - }; - - return Task.FromResult(TypedResults.Ok(systemInfo)); - }) - .RequireAuthorization(AuthOptions.RequireSysUserPolicy) - .WithSummary("Detailed system information"); - } -} diff --git a/src/Idmt.Plugin/Features/Admin/GetUserTenants.cs b/src/Idmt.Plugin/Features/Admin/GetUserTenants.cs index e13a4d8..e5a883f 100644 --- a/src/Idmt.Plugin/Features/Admin/GetUserTenants.cs +++ b/src/Idmt.Plugin/Features/Admin/GetUserTenants.cs @@ -1,5 +1,7 @@ +using ErrorOr; using Finbuckle.MultiTenant.Abstractions; using Idmt.Plugin.Configuration; +using Idmt.Plugin.Errors; using Idmt.Plugin.Models; using Idmt.Plugin.Persistence; using Microsoft.AspNetCore.Builder; @@ -11,20 +13,11 @@ namespace Idmt.Plugin.Features.Admin; -public sealed record TenantInfoResponse( - string Id, - string Identifier, - string Name, - string DisplayName, - string Plan, - bool IsActive -); - public static class GetUserTenants { public interface IGetUserTenantsHandler { - Task> HandleAsync(Guid userId, CancellationToken cancellationToken = default); + Task> HandleAsync(Guid userId, CancellationToken cancellationToken = default); } internal sealed class GetUserTenantsHandler( @@ -32,7 +25,7 @@ internal sealed class GetUserTenantsHandler( IMultiTenantStore tenantStore, ILogger logger) : IGetUserTenantsHandler { - public async Task> HandleAsync(Guid userId, CancellationToken cancellationToken = default) + public async Task> HandleAsync(Guid userId, CancellationToken cancellationToken = default) { try { @@ -41,23 +34,25 @@ public async Task> HandleAsync(Guid userId, Cancell .Select(ta => ta.TenantId) .ToArrayAsync(cancellationToken); - var tenantTasks = tenantIds.Select(tenantStore.GetAsync); - var tenants = await Task.WhenAll(tenantTasks); + var allTenants = await tenantStore.GetAllAsync(); + var tenantIdSet = new HashSet(tenantIds.Where(id => id != null)!); - var res = tenants.Where(t => t != null).Select(t => new TenantInfoResponse( - t!.Id ?? string.Empty, - t.Identifier ?? string.Empty, - t.Name ?? string.Empty, - t.DisplayName ?? string.Empty, - t.Plan ?? string.Empty, - t.IsActive)).ToArray(); + var res = allTenants + .Where(t => t != null && tenantIdSet.Contains(t.Id!)) + .Select(t => new TenantInfoResponse( + t!.Id ?? string.Empty, + t.Identifier ?? string.Empty, + t.Name ?? string.Empty, + t.Plan ?? string.Empty, + t.IsActive)) + .ToArray(); - return Result.Success(res); + return res; } catch (Exception ex) { logger.LogError(ex, "An error occurred while retrieving tenants for user {UserId}", userId); - return Result.Failure($"An error occurred while retrieving tenants: {ex.Message}", StatusCodes.Status500InternalServerError); + return IdmtErrors.General.Unexpected; } } } @@ -70,13 +65,13 @@ public static RouteHandlerBuilder MapGetUserTenantsEndpoint(this IEndpointRouteB CancellationToken cancellationToken) => { var result = await handler.HandleAsync(userId, cancellationToken); - if (!result.IsSuccess) + if (result.IsError) { return TypedResults.InternalServerError(); } - return TypedResults.Ok(result.Value!); + return TypedResults.Ok(result.Value); }) - .RequireAuthorization(AuthOptions.RequireSysUserPolicy) + .RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy) .WithSummary("Get tenants accessible by user"); } } diff --git a/src/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs b/src/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs index 2b1b71c..8d49335 100644 --- a/src/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs +++ b/src/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs @@ -1,7 +1,10 @@ +using ErrorOr; using Finbuckle.MultiTenant.Abstractions; using Idmt.Plugin.Configuration; +using Idmt.Plugin.Errors; using Idmt.Plugin.Models; using Idmt.Plugin.Persistence; +using Idmt.Plugin.Services; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; @@ -20,15 +23,16 @@ public sealed record GrantAccessRequest(DateTime? ExpiresAt); public interface IGrantTenantAccessHandler { - Task HandleAsync(Guid userId, string tenantIdentifier, DateTime? expiresAt = null, CancellationToken cancellationToken = default); + Task> HandleAsync(Guid userId, string tenantIdentifier, DateTime? expiresAt = null, CancellationToken cancellationToken = default); } internal sealed class GrantTenantAccessHandler( IServiceProvider serviceProvider, + ITenantOperationService tenantOps, ILogger logger ) : IGrantTenantAccessHandler { - public async Task HandleAsync(Guid userId, string tenantIdentifier, DateTime? expiresAt = null, CancellationToken cancellationToken = default) + public async Task> HandleAsync(Guid userId, string tenantIdentifier, DateTime? expiresAt = null, CancellationToken cancellationToken = default) { IdmtUser? user = null; IdmtTenantInfo? targetTenant = null; @@ -46,20 +50,20 @@ public async Task HandleAsync(Guid userId, string tenantIdentifier, Date user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); if (user is null) { - return Result.Failure("User not found", StatusCodes.Status404NotFound); + return IdmtErrors.User.NotFound; } targetTenant = await tenantStore.GetByIdentifierAsync(tenantIdentifier); if (targetTenant is null) { - return Result.Failure("Tenant not found", StatusCodes.Status404NotFound); + return IdmtErrors.Tenant.NotFound; } userRoles = await userManager.GetRolesAsync(user); if (userRoles.Count == 0) { logger.LogWarning("User {UserId} has no roles assigned; cannot grant tenant access.", userId); - return Result.Failure("User has no roles assigned", StatusCodes.Status400BadRequest); + return IdmtErrors.User.NoRolesAssigned; } var tenantAccess = await dbContext.TenantAccess @@ -86,25 +90,12 @@ public async Task HandleAsync(Guid userId, string tenantIdentifier, Date catch (Exception ex) { logger.LogError(ex, "Error granting tenant access to user {UserId} for tenant {TenantIdentifier}", userId, tenantIdentifier); - return Result.Failure("An error occurred while granting tenant access", StatusCodes.Status500InternalServerError); + return IdmtErrors.Tenant.AccessError; } } - using (var scope = serviceProvider.CreateScope()) + return await tenantOps.ExecuteInTenantScopeAsync(tenantIdentifier, async sp => { - var sp = scope.ServiceProvider; - - var tenantStore = sp.GetRequiredService>(); - var tenantInfo = await tenantStore.GetByIdentifierAsync(tenantIdentifier); - if (tenantInfo is null || !tenantInfo.IsActive) - { - return Result.Failure("Tenant not found or inactive", StatusCodes.Status404NotFound); - } - // Set Tenant Context BEFORE resolving DbContext/Managers - var tenantContextSetter = sp.GetRequiredService(); - var tenantContext = new MultiTenantContext(tenantInfo); - tenantContextSetter.MultiTenantContext = tenantContext; - try { var targetUserManager = sp.GetRequiredService>(); @@ -115,6 +106,7 @@ public async Task HandleAsync(Guid userId, string tenantIdentifier, Date if (targetUser is null) { // Create new user record for the target tenant + // SecurityStamp is copied to enable cross-tenant token validation targetUser = new IdmtUser { UserName = user.UserName, @@ -141,14 +133,14 @@ public async Task HandleAsync(Guid userId, string tenantIdentifier, Date await targetUserManager.UpdateAsync(targetUser); } - return Result.Success(); + return Result.Success; } catch (Exception ex) { logger.LogError(ex, "Error granting tenant access to user {UserId} in tenant {TenantIdentifier}", userId, tenantIdentifier); - return Result.Failure("An error occurred while granting tenant access", StatusCodes.Status500InternalServerError); + return IdmtErrors.Tenant.AccessError; } - } + }); } } @@ -162,18 +154,18 @@ public static RouteHandlerBuilder MapGrantTenantAccessEndpoint(this IEndpointRou CancellationToken cancellationToken) => { var result = await handler.HandleAsync(userId, tenantIdentifier, request.ExpiresAt, cancellationToken); - if (!result.IsSuccess) + if (result.IsError) { - return result.StatusCode switch + return result.FirstError.Type switch { - StatusCodes.Status400BadRequest => TypedResults.BadRequest(), - StatusCodes.Status404NotFound => TypedResults.NotFound(), - _ => TypedResults.InternalServerError() + ErrorType.Validation => TypedResults.BadRequest(), + ErrorType.NotFound => TypedResults.NotFound(), + _ => TypedResults.InternalServerError(), }; } return TypedResults.Ok(); }) - .RequireAuthorization(AuthOptions.RequireSysUserPolicy) + .RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy) .WithSummary("Grant user access to a tenant"); } } diff --git a/src/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs b/src/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs index 7d04ed2..7d7f3f4 100644 --- a/src/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs +++ b/src/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs @@ -1,7 +1,10 @@ +using ErrorOr; using Finbuckle.MultiTenant.Abstractions; using Idmt.Plugin.Configuration; +using Idmt.Plugin.Errors; using Idmt.Plugin.Models; using Idmt.Plugin.Persistence; +using Idmt.Plugin.Services; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; @@ -17,14 +20,15 @@ public static class RevokeTenantAccess { public interface IRevokeTenantAccessHandler { - Task HandleAsync(Guid userId, string tenantIdentifier, CancellationToken cancellationToken = default); + Task> HandleAsync(Guid userId, string tenantIdentifier, CancellationToken cancellationToken = default); } internal sealed class RevokeTenantAccessHandler( IServiceProvider serviceProvider, + ITenantOperationService tenantOps, ILogger logger) : IRevokeTenantAccessHandler { - public async Task HandleAsync(Guid userId, string tenantIdentifier, CancellationToken cancellationToken = default) + public async Task> HandleAsync(Guid userId, string tenantIdentifier, CancellationToken cancellationToken = default) { IdmtUser? user; using (var scope = serviceProvider.CreateScope()) @@ -40,13 +44,13 @@ public async Task HandleAsync(Guid userId, string tenantIdentifier, Canc user = await dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); if (user is null) { - return Result.Failure("User not found", StatusCodes.Status404NotFound); + return IdmtErrors.User.NotFound; } var targetTenant = await tenantStore.GetByIdentifierAsync(tenantIdentifier); if (targetTenant is null) { - return Result.Failure("Tenant not found", StatusCodes.Status404NotFound); + return IdmtErrors.Tenant.NotFound; } var tenantAccess = await dbContext.TenantAccess @@ -61,46 +65,29 @@ public async Task HandleAsync(Guid userId, string tenantIdentifier, Canc catch (Exception ex) { logger.LogError(ex, "Error revoking tenant access for user {UserId} and tenant {TenantIdentifier}", userId, tenantIdentifier); - return Result.Failure("An error occurred while revoking tenant access", StatusCodes.Status500InternalServerError); + return IdmtErrors.Tenant.AccessError; } } - using (var scope = serviceProvider.CreateScope()) + return await tenantOps.ExecuteInTenantScopeAsync(tenantIdentifier, async sp => { - var sp = scope.ServiceProvider; - - var tenantStore = sp.GetRequiredService>(); - var tenantInfo = await tenantStore.GetByIdentifierAsync(tenantIdentifier); - if (tenantInfo is null) - { - return Result.Failure("Tenant not found", StatusCodes.Status404NotFound); - } - // Set Tenant Context BEFORE resolving DbContext/Managers - var tenantContextSetter = sp.GetRequiredService(); - var tenantContext = new MultiTenantContext(tenantInfo); - tenantContextSetter.MultiTenantContext = tenantContext; - var userManager = sp.GetRequiredService>(); try { var targetUser = await userManager.Users.FirstOrDefaultAsync(u => u.Email == user.Email && u.UserName == user.UserName, cancellationToken); - if (targetUser is null) - { - return Result.Success(); - } - else + if (targetUser is not null) { targetUser.IsActive = false; await userManager.UpdateAsync(targetUser); } - return Result.Success(); + return Result.Success; } catch (Exception ex) { logger.LogError(ex, "Error deactivating user {UserId} in tenant {TenantIdentifier}", userId, tenantIdentifier); - return Result.Failure("An error occurred while deactivating user", StatusCodes.Status500InternalServerError); + return IdmtErrors.Tenant.AccessError; } - } + }, requireActive: false); } } @@ -113,17 +100,17 @@ public static RouteHandlerBuilder MapRevokeTenantAccessEndpoint(this IEndpointRo CancellationToken cancellationToken) => { var result = await handler.HandleAsync(userId, tenantId, cancellationToken); - if (!result.IsSuccess) + if (result.IsError) { - return result.StatusCode switch + return result.FirstError.Type switch { - StatusCodes.Status404NotFound => TypedResults.NotFound(), + ErrorType.NotFound => TypedResults.NotFound(), _ => TypedResults.InternalServerError(), }; } return TypedResults.Ok(); }) - .RequireAuthorization(AuthOptions.RequireSysUserPolicy) + .RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy) .WithSummary("Revoke user access from a tenant"); } } diff --git a/src/Idmt.Plugin/Features/AdminEndpoints.cs b/src/Idmt.Plugin/Features/AdminEndpoints.cs index 1286941..acdf7d3 100644 --- a/src/Idmt.Plugin/Features/AdminEndpoints.cs +++ b/src/Idmt.Plugin/Features/AdminEndpoints.cs @@ -17,7 +17,6 @@ public static void MapAdminEndpoints(this IEndpointRouteBuilder endpoints) admin.MapGetUserTenantsEndpoint(); admin.MapGrantTenantAccessEndpoint(); admin.MapRevokeTenantAccessEndpoint(); - admin.MapGetSystemInfoEndpoint(); admin.MapGetAllTenantsEndpoint(); } } \ No newline at end of file diff --git a/src/Idmt.Plugin/Features/Auth/ConfirmEmail.cs b/src/Idmt.Plugin/Features/Auth/ConfirmEmail.cs index 52998ca..09986ef 100644 --- a/src/Idmt.Plugin/Features/Auth/ConfirmEmail.cs +++ b/src/Idmt.Plugin/Features/Auth/ConfirmEmail.cs @@ -1,6 +1,9 @@ -using Finbuckle.MultiTenant.Abstractions; +using ErrorOr; +using FluentValidation; using Idmt.Plugin.Configuration; +using Idmt.Plugin.Errors; using Idmt.Plugin.Models; +using Idmt.Plugin.Services; using Idmt.Plugin.Validation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -19,105 +22,65 @@ public sealed record ConfirmEmailRequest(string TenantIdentifier, string Email, public interface IConfirmEmailHandler { - Task HandleAsync(ConfirmEmailRequest request, CancellationToken cancellationToken = default); + Task> HandleAsync(ConfirmEmailRequest request, CancellationToken cancellationToken = default); } - internal sealed class ConfirmEmailHandler(IServiceProvider serviceProvider, ILogger logger) : IConfirmEmailHandler + internal sealed class ConfirmEmailHandler(ITenantOperationService tenantOps, ILogger logger) : IConfirmEmailHandler { - public async Task HandleAsync(ConfirmEmailRequest request, CancellationToken cancellationToken = default) + public async Task> HandleAsync(ConfirmEmailRequest request, CancellationToken cancellationToken = default) { - using var scope = serviceProvider.CreateScope(); - var provider = scope.ServiceProvider; - - var tenantStore = provider.GetRequiredService>(); - var tenantInfo = await tenantStore.GetByIdentifierAsync(request.TenantIdentifier); - if (tenantInfo is null || !tenantInfo.IsActive) - { - return Result.Failure("Invalid tenant", StatusCodes.Status400BadRequest); - } - // Set Tenant Context BEFORE resolving DbContext/Managers - var tenantContextSetter = provider.GetRequiredService(); - var tenantContext = new MultiTenantContext(tenantInfo); - tenantContextSetter.MultiTenantContext = tenantContext; - - var userManager = provider.GetRequiredService>(); - try + return await tenantOps.ExecuteInTenantScopeAsync(request.TenantIdentifier, async provider => { - var user = await userManager.FindByEmailAsync(request.Email); - if (user == null) + var userManager = provider.GetRequiredService>(); + try { - // Avoid revealing that the email does not exist - return Result.Failure("User not found", StatusCodes.Status400BadRequest); - } + var user = await userManager.FindByEmailAsync(request.Email); + if (user == null) + { + return IdmtErrors.Email.ConfirmationFailed; + } + + var result = await userManager.ConfirmEmailAsync(user, request.Token!); - // Reset password using the token - var result = await userManager.ConfirmEmailAsync(user, request.Token!); + if (!result.Succeeded) + { + return IdmtErrors.Email.ConfirmationFailed; + } - if (!result.Succeeded) + return Result.Success; + } + catch (Exception ex) { - var errors = string.Join("\n", result.Errors.Select(e => e.Description)); - return Result.Failure(errors, StatusCodes.Status400BadRequest); + logger.LogError(ex, "Error confirming email for {Email} in tenant {TenantIdentifier}", request.Email, request.TenantIdentifier); + return IdmtErrors.General.Unexpected; } - - return Result.Success(); - } - catch (Exception ex) - { - logger.LogError(ex, "Error confirming email for {Email} in tenant {TenantIdentifier}", request.Email, request.TenantIdentifier); - return Result.Failure(ex.Message, StatusCodes.Status500InternalServerError); - } + }); } } - public static Dictionary? Validate(this ConfirmEmailRequest request) - { - var errors = new Dictionary(); - - if (string.IsNullOrEmpty(request.TenantIdentifier)) - { - errors["TenantIdentifier"] = ["Tenant identifier is required"]; - } - if (string.IsNullOrEmpty(request.Email)) - { - errors["Email"] = ["Email is required"]; - } - if (!Validators.IsValidEmail(request.Email)) - { - errors["Email"] = ["Invalid email address"]; - } - if (string.IsNullOrEmpty(request.Token)) - { - errors["Token"] = ["Token is required"]; - } - - return errors.Count == 0 ? null : errors; - } - public static RouteHandlerBuilder MapConfirmEmailEndpoint(this IEndpointRouteBuilder endpoints) { - return endpoints.MapGet("/confirmEmail", async Task> ( + return endpoints.MapGet("/confirm-email", async Task> ( [FromQuery] string tenantIdentifier, [FromQuery] string email, [FromQuery] string token, [FromServices] IConfirmEmailHandler handler, - IServiceProvider sp, + [FromServices] IValidator validator, HttpContext context) => { var request = new ConfirmEmailRequest(tenantIdentifier, email, token); - if (request.Validate() is { } validationErrors) + if (ValidationHelper.Validate(request, validator) is { } validationErrors) { return TypedResults.ValidationProblem(validationErrors); } var result = await handler.HandleAsync(request, cancellationToken: context.RequestAborted); - if (!result.IsSuccess) + if (result.IsError) { - return result.StatusCode switch - { - StatusCodes.Status400BadRequest => TypedResults.BadRequest(), - _ => TypedResults.InternalServerError(), - }; + return result.FirstError.Type == ErrorType.NotFound + ? TypedResults.BadRequest() + : TypedResults.InternalServerError(); } return TypedResults.Ok(); }) diff --git a/src/Idmt.Plugin/Features/Auth/ForgotPassword.cs b/src/Idmt.Plugin/Features/Auth/ForgotPassword.cs index b9f05c4..718152f 100644 --- a/src/Idmt.Plugin/Features/Auth/ForgotPassword.cs +++ b/src/Idmt.Plugin/Features/Auth/ForgotPassword.cs @@ -1,3 +1,6 @@ +using ErrorOr; +using FluentValidation; +using Idmt.Plugin.Errors; using Idmt.Plugin.Models; using Idmt.Plugin.Services; using Idmt.Plugin.Validation; @@ -14,11 +17,11 @@ public static class ForgotPassword { public sealed record ForgotPasswordRequest(string Email); - public sealed record ForgotPasswordResponse(string? ResetToken = null, string? ResetUrl = null); + public sealed record ForgotPasswordResponse; public interface IForgotPasswordHandler { - Task> HandleAsync( + Task> HandleAsync( bool useApiLinks, ForgotPasswordRequest request, CancellationToken cancellationToken = default); @@ -29,7 +32,7 @@ internal sealed class ForgotPasswordHandler( IEmailSender emailSender, IIdmtLinkGenerator linkGenerator) : IForgotPasswordHandler { - public async Task> HandleAsync( + public async Task> HandleAsync( bool useApiLinks, ForgotPasswordRequest request, CancellationToken cancellationToken = default) @@ -40,7 +43,7 @@ public async Task> HandleAsync( if (user == null || !user.IsActive) { // Don't reveal whether user exists or not for security - return Result.Success(new ForgotPasswordResponse(null, null), StatusCodes.Status200OK); + return new ForgotPasswordResponse(); } // Generate password reset token @@ -54,43 +57,32 @@ public async Task> HandleAsync( // Send email with reset code await emailSender.SendPasswordResetCodeAsync(user, request.Email, resetUrl); - return Result.Success(new ForgotPasswordResponse(token, resetUrl), StatusCodes.Status200OK); + return new ForgotPasswordResponse(); } - catch (Exception ex) + catch (Exception) { - return Result.Failure(ex.Message, StatusCodes.Status500InternalServerError); + return IdmtErrors.General.Unexpected; } } } - public static Dictionary? Validate(this ForgotPasswordRequest request) - { - var errors = new Dictionary(); - - if (!Validators.IsValidEmail(request.Email)) - { - errors["Email"] = ["Invalid email address."]; - } - - return errors.Count == 0 ? null : errors; - } - public static RouteHandlerBuilder MapForgotPasswordEndpoint(this IEndpointRouteBuilder endpoints) { - return endpoints.MapPost("/forgotPassword", async Task> ( + return endpoints.MapPost("/forgot-password", async Task> ( [FromQuery] bool useApiLinks, [FromBody] ForgotPasswordRequest request, [FromServices] IForgotPasswordHandler handler, + [FromServices] IValidator validator, HttpContext context) => { - if (request.Validate() is { } validationErrors) + if (ValidationHelper.Validate(request, validator) is { } validationErrors) { return TypedResults.ValidationProblem(validationErrors); } var result = await handler.HandleAsync(useApiLinks, request, cancellationToken: context.RequestAborted); - if (!result.IsSuccess) + if (result.IsError) { - return TypedResults.StatusCode(result.StatusCode); + return TypedResults.StatusCode(StatusCodes.Status500InternalServerError); } return TypedResults.Ok(); }) diff --git a/src/Idmt.Plugin/Features/Auth/Login.cs b/src/Idmt.Plugin/Features/Auth/Login.cs index e73c362..b651380 100644 --- a/src/Idmt.Plugin/Features/Auth/Login.cs +++ b/src/Idmt.Plugin/Features/Auth/Login.cs @@ -1,5 +1,7 @@ +using ErrorOr; using Finbuckle.MultiTenant.Abstractions; -using Idmt.Plugin.Configuration; +using FluentValidation; +using Idmt.Plugin.Errors; using Idmt.Plugin.Models; using Idmt.Plugin.Validation; using Microsoft.AspNetCore.Authentication; @@ -42,14 +44,14 @@ public sealed record AccessTokenResponse public interface ILoginHandler { - Task> HandleAsync( + Task> HandleAsync( LoginRequest loginRequest, CancellationToken cancellationToken = default); } public interface ITokenLoginHandler { - Task> HandleAsync( + Task> HandleAsync( LoginRequest request, CancellationToken cancellationToken = default); } @@ -58,9 +60,10 @@ internal sealed class LoginHandler( UserManager userManager, SignInManager signInManager, IMultiTenantContextAccessor multiTenantContextAccessor, + TimeProvider timeProvider, ILogger logger) : ILoginHandler { - public async Task> HandleAsync( + public async Task> HandleAsync( LoginRequest request, CancellationToken cancellationToken = default) { @@ -70,7 +73,7 @@ public async Task> HandleAsync( var tenantInfo = multiTenantContextAccessor.MultiTenantContext?.TenantInfo; if (tenantInfo == null || string.IsNullOrEmpty(tenantInfo.Id)) { - return Result.Failure("Tenant not resolved", StatusCodes.Status400BadRequest); + return IdmtErrors.Tenant.NotResolved; } // Find user by email or username @@ -86,7 +89,7 @@ public async Task> HandleAsync( } if (user == null) { - return Result.Failure("Unauthorized", StatusCodes.Status401Unauthorized); + return IdmtErrors.Auth.Unauthorized; } var result = await signInManager.CheckPasswordSignInAsync( @@ -108,14 +111,14 @@ public async Task> HandleAsync( if (!result.Succeeded) { - return Result.Failure("Unauthorized", StatusCodes.Status401Unauthorized); + return IdmtErrors.Auth.Unauthorized; } // Check if user is active if (!user.IsActive) { logger.LogWarning("Login attempt failed: User {UserId} is inactive", user.Id); - return Result.Failure("User is deactivated", StatusCodes.Status403Forbidden); + return IdmtErrors.Auth.UserDeactivated; } // Direct cookie sign-in (no middleware delay) @@ -126,19 +129,19 @@ await signInManager.Context.SignInAsync( new AuthenticationProperties { IsPersistent = request.RememberMe, - ExpiresUtc = DT.UtcNow.AddDays(30) + ExpiresUtc = timeProvider.GetUtcNow().AddDays(30) }); // Update last login timestamp - user.LastLoginAt = DT.UtcNow; + user.LastLoginAt = timeProvider.GetUtcNow().UtcDateTime; await userManager.UpdateAsync(user); - return Result.Success(new LoginResponse { UserId = user.Id }); + return new LoginResponse { UserId = user.Id }; } catch (Exception ex) { logger.LogError(ex, "An error occurred during login for identifier {Email} {Username}", request.Email ?? "unknown", request.Username ?? "unknown"); - return Result.Failure("An error occurred during login", StatusCodes.Status500InternalServerError); + return IdmtErrors.General.Unexpected; } } } @@ -151,7 +154,7 @@ internal sealed class TokenLoginHandler( TimeProvider timeProvider, ILogger logger) : ITokenLoginHandler { - public async Task> HandleAsync( + public async Task> HandleAsync( LoginRequest request, CancellationToken cancellationToken = default) { @@ -161,7 +164,7 @@ public async Task> HandleAsync( var tenantInfo = multiTenantContextAccessor.MultiTenantContext?.TenantInfo; if (tenantInfo == null || string.IsNullOrEmpty(tenantInfo.Id)) { - return Result.Failure("Tenant not resolved", StatusCodes.Status400BadRequest); + return IdmtErrors.Tenant.NotResolved; } // Find user by email or username @@ -177,7 +180,7 @@ public async Task> HandleAsync( } if (user == null) { - return Result.Failure("Unauthorized", StatusCodes.Status401Unauthorized); + return IdmtErrors.Auth.Unauthorized; } var result = await signInManager.CheckPasswordSignInAsync( @@ -199,14 +202,14 @@ public async Task> HandleAsync( if (!result.Succeeded) { - return Result.Failure("Unauthorized", StatusCodes.Status401Unauthorized); + return IdmtErrors.Auth.Unauthorized; } // Check if user is active if (!user.IsActive) { logger.LogWarning("Login attempt failed: User {UserId} is inactive", user.Id); - return Result.Failure("User is deactivated", StatusCodes.Status403Forbidden); + return IdmtErrors.Auth.UserDeactivated; } // Generate tokens using BearerToken @@ -239,82 +242,50 @@ public async Task> HandleAsync( var refreshToken = refreshTokenProtector.Protect(refreshTicket); // Update last login timestamp - user.LastLoginAt = DT.UtcNow; + user.LastLoginAt = timeProvider.GetUtcNow().UtcDateTime; await userManager.UpdateAsync(user); var expiresIn = (long)bearerOptions.BearerTokenExpiration.TotalSeconds; - return Result.Success(new AccessTokenResponse + return new AccessTokenResponse { AccessToken = accessToken, RefreshToken = refreshToken, ExpiresIn = expiresIn, TokenType = "Bearer" - }); + }; } catch (Exception ex) { logger.LogError(ex, "An error occurred during login for identifier {Email} {Username}", request.Email ?? request.Username ?? "unknown", request.Email ?? request.Username ?? "unknown"); - return Result.Failure("An error occurred during login", StatusCodes.Status500InternalServerError); + return IdmtErrors.General.Unexpected; } } } - /// - /// Validate the login request. - /// - /// A list of validation errors or null if the request is valid. - public static Dictionary? Validate(this LoginRequest request) - { - var errors = new Dictionary(); - - if (request.Email is null && request.Username is null) - { - errors["Identifier"] = ["Email or Username is required."]; - } - else - { - if (request.Email is not null && !Validators.IsValidEmail(request.Email)) - { - errors["Email"] = ["Invalid email."]; - } - - if (request.Username is not null && !Validators.IsValidUsername(request.Username)) - { - errors["Username"] = ["Invalid username."]; - } - } - - if (string.IsNullOrWhiteSpace(request.Password)) - { - errors["Password"] = ["Password is required."]; - } - - return errors.Count == 0 ? null : errors; - } - public static RouteHandlerBuilder MapCookieLoginEndpoint(this IEndpointRouteBuilder endpoints) { return endpoints.MapPost("/login", async Task, UnauthorizedHttpResult, ForbidHttpResult, ValidationProblem, ProblemHttpResult>> ( [FromBody] LoginRequest request, [FromServices] ILoginHandler handler, + [FromServices] IValidator validator, HttpContext context) => { - if (request.Validate() is { } validationErrors) + if (ValidationHelper.Validate(request, validator) is { } validationErrors) { return TypedResults.ValidationProblem(validationErrors); } var response = await handler.HandleAsync(request, cancellationToken: context.RequestAborted); - if (!response.IsSuccess) + if (response.IsError) { - return response.StatusCode switch + return response.FirstError.Type switch { - StatusCodes.Status401Unauthorized => TypedResults.Unauthorized(), - StatusCodes.Status403Forbidden => TypedResults.Forbid(), - _ => TypedResults.Problem(response.ErrorMessage, statusCode: response.StatusCode), + ErrorType.Unauthorized => TypedResults.Unauthorized(), + ErrorType.Forbidden => TypedResults.Forbid(), + _ => TypedResults.Problem(response.FirstError.Description, statusCode: StatusCodes.Status500InternalServerError), }; } - return TypedResults.Ok(response.Value!); + return TypedResults.Ok(response.Value); }) .WithSummary("Login user") .WithDescription("Authenticate user and return cookie"); @@ -325,25 +296,26 @@ public static RouteHandlerBuilder MapTokenLoginEndpoint(this IEndpointRouteBuild return endpoints.MapPost("/token", async Task, UnauthorizedHttpResult, ForbidHttpResult, ValidationProblem, ProblemHttpResult>> ( [FromBody] LoginRequest request, [FromServices] ITokenLoginHandler handler, + [FromServices] IValidator validator, HttpContext context) => { - if (request.Validate() is { } validationErrors) + if (ValidationHelper.Validate(request, validator) is { } validationErrors) { return TypedResults.ValidationProblem(validationErrors); } var response = await handler.HandleAsync(request, cancellationToken: context.RequestAborted); - if (!response.IsSuccess) + if (response.IsError) { - return response.StatusCode switch + return response.FirstError.Type switch { - StatusCodes.Status401Unauthorized => TypedResults.Unauthorized(), - StatusCodes.Status403Forbidden => TypedResults.Forbid(), - _ => TypedResults.Problem(response.ErrorMessage, statusCode: response.StatusCode), + ErrorType.Unauthorized => TypedResults.Unauthorized(), + ErrorType.Forbidden => TypedResults.Forbid(), + _ => TypedResults.Problem(response.FirstError.Description, statusCode: StatusCodes.Status500InternalServerError), }; } - return TypedResults.Ok(response.Value!); + return TypedResults.Ok(response.Value); }) .WithSummary("Login user") .WithDescription("Authenticate user and return bearer token"); } -} \ No newline at end of file +} diff --git a/src/Idmt.Plugin/Features/Auth/RefreshToken.cs b/src/Idmt.Plugin/Features/Auth/RefreshToken.cs index 7dc161f..fbff41a 100644 --- a/src/Idmt.Plugin/Features/Auth/RefreshToken.cs +++ b/src/Idmt.Plugin/Features/Auth/RefreshToken.cs @@ -1,5 +1,9 @@ using System.Security.Claims; +using ErrorOr; +using FluentValidation; +using Idmt.Plugin.Errors; using Idmt.Plugin.Models; +using Idmt.Plugin.Validation; using Microsoft.AspNetCore.Authentication.BearerToken; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -19,7 +23,7 @@ public sealed record RefreshTokenResponse(ClaimsPrincipal ClaimsPrincipal); public interface IRefreshTokenHandler { - Task> HandleAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default); + Task> HandleAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default); } internal sealed class RefreshTokenHandler( @@ -28,7 +32,7 @@ internal sealed class RefreshTokenHandler( SignInManager signInManager) : IRefreshTokenHandler { - public async Task> HandleAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default) + public async Task> HandleAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default) { var refreshTokenProtector = bearerTokenOptions.Get(IdentityConstants.BearerScheme).RefreshTokenProtector; var refreshTicket = refreshTokenProtector.Unprotect(request.RefreshToken); @@ -37,47 +41,35 @@ public async Task> HandleAsync(RefreshTokenRequest timeProvider.GetUtcNow() >= expiresUtc || await signInManager.ValidateSecurityStampAsync(refreshTicket.Principal) is not IdmtUser user) { - return Result.Failure("Invalid refresh token", StatusCodes.Status400BadRequest); + return IdmtErrors.Token.Invalid; } ClaimsPrincipal claimsPrincipal = await signInManager.CreateUserPrincipalAsync(user); - return Result.Success(new RefreshTokenResponse(claimsPrincipal)); + return new RefreshTokenResponse(claimsPrincipal); } } - public static Dictionary? Validate(this RefreshTokenRequest request) - { - if (string.IsNullOrEmpty(request.RefreshToken)) - { - return new Dictionary - { - ["RefreshToken"] = ["Refresh token is required."] - }; - } - return null; - } - public static RouteHandlerBuilder MapRefreshTokenEndpoint(this IEndpointRouteBuilder endpoints) { return endpoints.MapPost("/refresh", async Task, SignInHttpResult, ChallengeHttpResult, ValidationProblem>> ( [FromBody] RefreshTokenRequest request, [FromServices] IRefreshTokenHandler handler, + [FromServices] IValidator validator, HttpContext context) => { - if (request.Validate() is { } validationErrors) + if (ValidationHelper.Validate(request, validator) is { } validationErrors) { return TypedResults.ValidationProblem(validationErrors); } var response = await handler.HandleAsync(request, cancellationToken: context.RequestAborted); - if (!response.IsSuccess) + if (response.IsError) { return TypedResults.Challenge(); } - return TypedResults.SignIn(response.Value!.ClaimsPrincipal, authenticationScheme: IdentityConstants.BearerScheme); + return TypedResults.SignIn(response.Value.ClaimsPrincipal, authenticationScheme: IdentityConstants.BearerScheme); }) .WithSummary("Refresh token") - .WithDescription("Refresh JWT token using refresh token") - .RequireAuthorization(); + .WithDescription("Refresh JWT token using refresh token"); } -} \ No newline at end of file +} diff --git a/src/Idmt.Plugin/Features/Auth/ResendConfirmationEmail.cs b/src/Idmt.Plugin/Features/Auth/ResendConfirmationEmail.cs index ee7a364..30d4a98 100644 --- a/src/Idmt.Plugin/Features/Auth/ResendConfirmationEmail.cs +++ b/src/Idmt.Plugin/Features/Auth/ResendConfirmationEmail.cs @@ -1,4 +1,7 @@ using System.Text.Encodings.Web; +using ErrorOr; +using FluentValidation; +using Idmt.Plugin.Errors; using Idmt.Plugin.Models; using Idmt.Plugin.Services; using Idmt.Plugin.Validation; @@ -18,7 +21,7 @@ public sealed record ResendConfirmationEmailRequest(string Email); public interface IResendConfirmationEmailHandler { - Task HandleAsync( + Task> HandleAsync( bool useApiLinks, ResendConfirmationEmailRequest request, CancellationToken cancellationToken = default); @@ -31,7 +34,7 @@ internal sealed class ResendConfirmationEmailHandler( ILogger logger ) : IResendConfirmationEmailHandler { - public async Task HandleAsync( + public async Task> HandleAsync( bool useApiLinks, ResendConfirmationEmailRequest request, CancellationToken cancellationToken = default) @@ -42,12 +45,12 @@ public async Task HandleAsync( if (user == null || !user.IsActive) { // Don't reveal whether user exists for security - return Result.Success(StatusCodes.Status200OK); + return Result.Success; } if (user.EmailConfirmed) { - return Result.Success(StatusCodes.Status200OK); + return Result.Success; } // Generate email confirmation token @@ -59,44 +62,32 @@ public async Task HandleAsync( await emailSender.SendConfirmationLinkAsync(user, request.Email, HtmlEncoder.Default.Encode(confirmEmailUrl)); - return Result.Success(StatusCodes.Status200OK); + return Result.Success; } catch (Exception ex) { logger.LogError(ex, "Error resending confirmation email to {Email}", request.Email); - return Result.Failure($"An error occurred while resending confirmation email: {ex.Message}", StatusCodes.Status500InternalServerError); + return IdmtErrors.General.Unexpected; } } } - public static Dictionary? Validate(this ResendConfirmationEmailRequest request) - { - if (!Validators.IsValidEmail(request.Email)) - { - return new Dictionary - { - ["Email"] = ["Invalid email address."] - }; - } - - return null; - } - public static RouteHandlerBuilder MapResendConfirmationEmailEndpoint(this IEndpointRouteBuilder endpoints) { - return endpoints.MapPost("/resendConfirmationEmail", async Task> ( + return endpoints.MapPost("/resend-confirmation-email", async Task> ( [FromQuery] bool useApiLinks, [FromBody] ResendConfirmationEmailRequest request, [FromServices] IResendConfirmationEmailHandler handler, + [FromServices] IValidator validator, HttpContext context) => { - if (request.Validate() is { } validationErrors) + if (ValidationHelper.Validate(request, validator) is { } validationErrors) { return TypedResults.ValidationProblem(validationErrors); } var result = await handler.HandleAsync(useApiLinks, request, cancellationToken: context.RequestAborted); - if (!result.IsSuccess) + if (result.IsError) { return TypedResults.InternalServerError(); } diff --git a/src/Idmt.Plugin/Features/Auth/ResetPassword.cs b/src/Idmt.Plugin/Features/Auth/ResetPassword.cs index f5e0fdd..9117572 100644 --- a/src/Idmt.Plugin/Features/Auth/ResetPassword.cs +++ b/src/Idmt.Plugin/Features/Auth/ResetPassword.cs @@ -1,6 +1,9 @@ -using Finbuckle.MultiTenant.Abstractions; +using ErrorOr; +using FluentValidation; using Idmt.Plugin.Configuration; +using Idmt.Plugin.Errors; using Idmt.Plugin.Models; +using Idmt.Plugin.Services; using Idmt.Plugin.Validation; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -19,101 +22,75 @@ public sealed record ResetPasswordRequest(string NewPassword); public interface IResetPasswordHandler { - Task HandleAsync(string tenantIdentifier, string email, string token, ResetPasswordRequest request, CancellationToken cancellationToken = default); + Task> HandleAsync(string tenantIdentifier, string email, string token, ResetPasswordRequest request, CancellationToken cancellationToken = default); } - internal sealed class ResetPasswordHandler(IServiceProvider serviceProvider) : IResetPasswordHandler + internal sealed class ResetPasswordHandler(ITenantOperationService tenantOps) : IResetPasswordHandler { - public async Task HandleAsync(string tenantIdentifier, string email, string token, ResetPasswordRequest request, CancellationToken cancellationToken = default) + public async Task> HandleAsync(string tenantIdentifier, string email, string token, ResetPasswordRequest request, CancellationToken cancellationToken = default) { - using var scope = serviceProvider.CreateScope(); - var provider = scope.ServiceProvider; - - var tenantStore = provider.GetRequiredService>(); - var tenantInfo = await tenantStore.GetByIdentifierAsync(tenantIdentifier); - if (tenantInfo is null || !tenantInfo.IsActive) - { - return Result.Failure("Invalid tenant", StatusCodes.Status400BadRequest); - } - // Set Tenant Context BEFORE resolving DbContext/Managers - var tenantContextSetter = provider.GetRequiredService(); - var tenantContext = new MultiTenantContext(tenantInfo); - tenantContextSetter.MultiTenantContext = tenantContext; - - var userManager = provider.GetRequiredService>(); - try + return await tenantOps.ExecuteInTenantScopeAsync(tenantIdentifier, async provider => { - var user = await userManager.FindByEmailAsync(email); - if (user is null) + var userManager = provider.GetRequiredService>(); + try { - // Avoid revealing that the email does not exist - return Result.Failure("User not found", StatusCodes.Status400BadRequest); + var user = await userManager.FindByEmailAsync(email); + if (user is null) + { + return IdmtErrors.Password.ResetFailed; + } + + var result = await userManager.ResetPasswordAsync(user, token, request.NewPassword); + + if (!result.Succeeded) + { + return IdmtErrors.Password.ResetFailed; + } + + if (!user.EmailConfirmed) + { + user.EmailConfirmed = true; + await userManager.UpdateAsync(user); + } + + return Result.Success; } - - // Reset password using the token - var result = await userManager.ResetPasswordAsync(user, token, request.NewPassword); - - if (!result.Succeeded) + catch (Exception) { - var errors = string.Join("\n", result.Errors.Select(e => e.Description)); - return Result.Failure(errors, StatusCodes.Status400BadRequest); + return IdmtErrors.General.Unexpected; } - - if (!user.EmailConfirmed) - { - user.EmailConfirmed = true; - await userManager.UpdateAsync(user); - } - - return Result.Success(); - } - catch (Exception ex) - { - return Result.Failure($"An error occurred while resetting the password: {ex.Message}", StatusCodes.Status500InternalServerError); - } + }); } } - public static Dictionary? Validate(this ResetPasswordRequest request, string tenantIdentifier, string email, string token, Configuration.PasswordOptions options) - { - var errors = new Dictionary(); - if (string.IsNullOrEmpty(tenantIdentifier)) - { - errors["TenantIdentifier"] = ["Tenant ID is required"]; - } - if (!Validators.IsValidEmail(email)) - { - errors["Email"] = ["Invalid email address."]; - } - if (string.IsNullOrEmpty(token)) - { - errors["Token"] = ["Token is required"]; - } - if (!Validators.IsValidNewPassword(request.NewPassword, options, out var newPasswordErrors)) - { - errors["NewPassword"] = newPasswordErrors ?? []; - } - - return errors.Count == 0 ? null : errors; - } - public static RouteHandlerBuilder MapResetPasswordEndpoint(this IEndpointRouteBuilder endpoints) { - return endpoints.MapPost("/resetPassword", async Task> ( + return endpoints.MapPost("/reset-password", async Task> ( [FromQuery] string tenantIdentifier, [FromQuery] string email, [FromQuery] string token, [FromBody] ResetPasswordRequest request, [FromServices] IResetPasswordHandler handler, - [FromServices] IOptions options, + [FromServices] IValidator validator, HttpContext context) => { - if (request.Validate(tenantIdentifier, email, token, options.Value.Identity.Password) is { } validationErrors) + // Validate query parameters + var queryErrors = new Dictionary(); + if (string.IsNullOrEmpty(tenantIdentifier)) + queryErrors["tenantIdentifier"] = ["Tenant identifier is required"]; + if (!Validators.IsValidEmail(email)) + queryErrors["email"] = ["Invalid email address."]; + if (string.IsNullOrEmpty(token)) + queryErrors["token"] = ["Token is required"]; + if (queryErrors.Count > 0) + return TypedResults.ValidationProblem(queryErrors); + + if (ValidationHelper.Validate(request, validator) is { } validationErrors) { return TypedResults.ValidationProblem(validationErrors); } var result = await handler.HandleAsync(tenantIdentifier, email, token, request, cancellationToken: context.RequestAborted); - if (!result.IsSuccess) + if (result.IsError) { return TypedResults.BadRequest(); } @@ -126,7 +103,7 @@ public static RouteHandlerBuilder MapResetPasswordEndpoint(this IEndpointRouteBu public static RouteHandlerBuilder MapResetPasswordRedirectEndpoint(this IEndpointRouteBuilder endpoints) { - return endpoints.MapGet("/resetPassword", Results ( + return endpoints.MapGet("/reset-password", Results ( [FromQuery] string tenantIdentifier, [FromQuery] string email, [FromQuery] string token, @@ -158,4 +135,4 @@ public static RouteHandlerBuilder MapResetPasswordRedirectEndpoint(this IEndpoin .WithSummary("Redirect to reset password form") .WithDescription("Redirect to reset password form"); } -} \ No newline at end of file +} diff --git a/src/Idmt.Plugin/Features/Health/BasicHealthCheck.cs b/src/Idmt.Plugin/Features/Health/BasicHealthCheck.cs index 0c4d819..1cabdf4 100644 --- a/src/Idmt.Plugin/Features/Health/BasicHealthCheck.cs +++ b/src/Idmt.Plugin/Features/Health/BasicHealthCheck.cs @@ -5,7 +5,7 @@ namespace Idmt.Plugin.Features.Health; -public class BasicHealthCheck(IdmtDbContext dbContext, IMultiTenantContextAccessor tenantAccessor) : IHealthCheck +public class BasicHealthCheck(IdmtDbContext dbContext, IMultiTenantContextAccessor tenantAccessor, TimeProvider timeProvider) : IHealthCheck { public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) { @@ -29,7 +29,7 @@ public async Task CheckHealthAsync(HealthCheckContext context { "database_connected", canConnect }, { "current_tenant", currentTenant?.Id ?? "No tenant" }, { "tenant_user_count", userCount }, - { "timestamp", DT.UtcNow } + { "timestamp", timeProvider.GetUtcNow().UtcDateTime } }); } catch (Exception ex) @@ -38,7 +38,7 @@ public async Task CheckHealthAsync(HealthCheckContext context new Dictionary { { "error", ex.Message }, - { "timestamp", DT.UtcNow } + { "timestamp", timeProvider.GetUtcNow().UtcDateTime } }); } } diff --git a/src/Idmt.Plugin/Features/Manage/GetUserInfo.cs b/src/Idmt.Plugin/Features/Manage/GetUserInfo.cs index 8cd8d58..b264608 100644 --- a/src/Idmt.Plugin/Features/Manage/GetUserInfo.cs +++ b/src/Idmt.Plugin/Features/Manage/GetUserInfo.cs @@ -52,7 +52,7 @@ internal sealed class GetUserInfoHandler(UserManager userManager, IMul appUser.UserName ?? string.Empty, role ?? string.Empty, tenant.Identifier ?? string.Empty, - tenant.DisplayName ?? string.Empty + tenant.Name ?? string.Empty ); } } diff --git a/src/Idmt.Plugin/Features/Manage/RegisterUser.cs b/src/Idmt.Plugin/Features/Manage/RegisterUser.cs index 2f37490..0649a21 100644 --- a/src/Idmt.Plugin/Features/Manage/RegisterUser.cs +++ b/src/Idmt.Plugin/Features/Manage/RegisterUser.cs @@ -1,5 +1,7 @@ -using System.Text.RegularExpressions; +using ErrorOr; +using FluentValidation; using Idmt.Plugin.Configuration; +using Idmt.Plugin.Errors; using Idmt.Plugin.Models; using Idmt.Plugin.Persistence; using Idmt.Plugin.Services; @@ -16,84 +18,28 @@ namespace Idmt.Plugin.Features.Manage; public static class RegisterUser { - /// - /// Request model for user registration. Represents the data required to create a new user account. - /// The user will be created without a password and will need to set it via the password setup token. - /// public sealed record RegisterUserRequest { - /// - /// Email address for the user. Required and must be a valid email format. - /// public required string Email { get; init; } - - /// - /// Optional username. If not provided, the email address will be used as the username. - /// public string? Username { get; init; } - - /// - /// Role name to assign to the user upon registration. Required and must be an existing role. - /// public required string Role { get; init; } } - /// - /// Response model for user registration operations. Contains the result of the registration attempt, - /// including success status, user identifier, password setup token, and any validation or error messages. - /// public sealed record RegisterUserResponse { - /// - /// The unique identifier of the created user (as string). Only populated when Success is true. - /// public string? UserId { get; init; } - - /// - /// Password reset token that can be used to set the user's initial password. - /// This token is generated using ASP.NET Core Identity's password reset token mechanism. - /// Only populated when Success is true. - /// public string? PasswordSetupToken { get; init; } - - /// - /// Fully constructed URL for password setup. - /// Contains the email and token as query parameters. Only populated when Success is true. - /// public string? PasswordSetupUrl { get; init; } } - /// - /// Handler interface for user registration operations following the vertical slice architecture pattern. - /// Implementations handle the complete registration workflow including validation, user creation, - /// role assignment, and password setup token generation. - /// public interface IRegisterUserHandler { - /// - /// Handles a user registration request. Creates a new user account without a password, - /// assigns the specified role, and generates a password setup token. - /// - /// The registration request containing email, optional username, and role - /// Cancellation token to cancel the operation - /// Registration response containing success status, user ID, password setup token, and any errors - Task> HandleAsync( + Task> HandleAsync( bool useApiLinks, RegisterUserRequest request, CancellationToken cancellationToken = default); } - - /// - /// Handler implementation for user registration following the vertical slice architecture pattern. - /// Handles the complete registration workflow: validates input, checks role existence, creates user account, - /// assigns role, and generates password setup token. - /// - /// - /// This handler creates users without passwords. Users must set their password using the generated token. - /// The user's email is not confirmed until they set their password (email confirmation is handled elsewhere). - /// Users are created as active by default, and soft-deleted by setting IsActive to false. - /// internal sealed class RegisterHandler( ILogger logger, UserManager userManager, @@ -105,23 +51,7 @@ internal sealed class RegisterHandler( IIdmtLinkGenerator linkGenerator, IEmailSender emailSender) : IRegisterUserHandler { - /// - /// Handles the user registration request. Executes the complete registration workflow: - /// 1. Validates the request data - /// 2. Creates the user entity with basic information - /// 3. Begins a database transaction - /// 4. Verifies the role exists (within transaction to prevent race conditions) - /// 5. Creates the user account with tenant context (within transaction) - /// 6. Assigns the specified role (within transaction) - /// 7. Commits the transaction if all operations succeed - /// 8. Generates password setup token - /// 9. Constructs password setup URL if configured - /// - /// The registration request containing email, optional username, and role - /// Cancellation token to cancel the operation - /// Registration response with success status, user ID, password setup token, and any errors - /// Thrown when the user store does not support email functionality - public async Task> HandleAsync( + public async Task> HandleAsync( bool useApiLinks, RegisterUserRequest request, CancellationToken cancellationToken = default) @@ -129,82 +59,66 @@ public async Task> HandleAsync( // Security check: Validate role assignment permissions based on current user's role if (!tenantAccessService.CanAssignRole(request.Role)) { - return Result.Failure("Insufficient permissions to assign this role.", StatusCodes.Status403Forbidden); + return IdmtErrors.User.InsufficientPermissions; } // Get the tenant ID from the current user service (from tenant context) var tenantId = currentUserService.TenantId ?? throw new InvalidOperationException("Tenant context is not available. Cannot register user without tenant context."); - // Create user entity with basic information, no password set - // User is active by default, but email is not confirmed until password is set - // When the user is unregistered, we set IsActive to false (soft delete) var user = new IdmtUser { UserName = request.Username ?? request.Email, Email = request.Email, - EmailConfirmed = false, // Will be confirmed when password is set + EmailConfirmed = false, IsActive = true, TenantId = tenantId, LastLoginAt = null, }; - // Use a database transaction to ensure atomicity: all operations (role check, user creation, role assignment) - // happen atomically. If any step fails, everything is rolled back. await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); try { - // Verify that the specified role exists in the system within the transaction - // This prevents race conditions where the role could be deleted between check and assignment - // Note: This check is not tenant-aware - roles are global across all tenants bool roleExists = await roleManager.RoleExistsAsync(request.Role); if (!roleExists) { await transaction.RollbackAsync(cancellationToken); - return Result.Failure("Role not found", StatusCodes.Status400BadRequest); + return IdmtErrors.User.RoleNotFound; } - // Create the user account (this will validate uniqueness constraints per tenant) var result = await userManager.CreateAsync(user); if (!result.Succeeded) { await transaction.RollbackAsync(cancellationToken); logger.LogError("Failed to create user: {ErrorMessage}", result.Errors); - return Result.Failure("Failed to create user", StatusCodes.Status400BadRequest); + return IdmtErrors.User.CreationFailed; } - // Set username and email using store-specific methods (ensures proper normalization) await userStore.SetUserNameAsync(user, request.Username ?? request.Email, cancellationToken); IUserEmailStore emailStore = userStore as IUserEmailStore ?? throw new NotSupportedException("The user store does not support email functionality."); await emailStore.SetEmailAsync(user, request.Email, cancellationToken); - // Assign the specified role to the user - // If this fails, the transaction will rollback and the user will not be created var roleResult = await userManager.AddToRoleAsync(user, request.Role); if (!roleResult.Succeeded) { await transaction.RollbackAsync(cancellationToken); logger.LogError("Failed to assign role to user: {ErrorMessage}", roleResult.Errors); - return Result.Failure("Failed to assign role to user", StatusCodes.Status400BadRequest); + return IdmtErrors.User.CreationFailed; } - // Commit the transaction only if role check, user creation, and role assignment all succeeded await transaction.CommitAsync(cancellationToken); } catch (Exception ex) { await transaction.RollbackAsync(cancellationToken); logger.LogError(ex, "Exception occurred during user registration. Transaction rolled back."); - return Result.Failure($"An error occurred during user registration: {ex.Message}", StatusCodes.Status500InternalServerError); + return IdmtErrors.General.Unexpected; } - // Generate password setup token using ASP.NET Core Identity's password reset token mechanism - // This token is secure, time-limited, and can be used to set the user's initial password var token = await userManager.GeneratePasswordResetTokenAsync(user); - // Generate password setup URL var passwordSetupUrl = useApiLinks ? linkGenerator.GeneratePasswordResetApiLink(user.Email, token) : linkGenerator.GeneratePasswordResetFormLink(user.Email, token); @@ -213,47 +127,13 @@ public async Task> HandleAsync( await emailSender.SendPasswordResetLinkAsync(user, user.Email, passwordSetupUrl); - return Result.Success(new RegisterUserResponse + return new RegisterUserResponse { UserId = user.GetId(), PasswordSetupToken = token, PasswordSetupUrl = passwordSetupUrl - }); - } - } - - /// - /// Validates the registration request and returns a dictionary of validation errors if any exist. - /// Returns null if validation passes. - /// - /// The registration request to validate - /// Dictionary of field names to error messages if validation fails, null if validation succeeds - public static Dictionary? Validate(this RegisterUserRequest request, string? allowedUsernameCharacters = null) - { - var errors = new Dictionary(); - - // Validate email format using standard email validation - if (!Validators.IsValidEmail(request.Email)) - { - errors["Email"] = ["Invalid email address."]; - } - - // Validate username length if provided (minimum 3 characters) - if (request.Username is not null) - { - if (!string.IsNullOrEmpty(allowedUsernameCharacters) && !Regex.IsMatch(request.Username, $"^[{allowedUsernameCharacters}]+$")) - { - errors["Username"] = [$"Username must contain only the following characters: {allowedUsernameCharacters}"]; - } - } - - // Validate that role is provided and not empty - if (string.IsNullOrEmpty(request.Role)) - { - errors["Role"] = ["Role is required."]; + }; } - - return errors.Count == 0 ? null : errors; } public static RouteHandlerBuilder MapRegisterUserEndpoint(this IEndpointRouteBuilder endpoints) @@ -262,28 +142,28 @@ public static RouteHandlerBuilder MapRegisterUserEndpoint(this IEndpointRouteBui [FromQuery] bool useApiLinks, [FromBody] RegisterUserRequest request, [FromServices] IRegisterUserHandler handler, + [FromServices] IValidator validator, HttpContext context) => { - // Validate request data (email format, username length, role presence) - if (request.Validate() is { } validationErrors) + if (ValidationHelper.Validate(request, validator) is { } validationErrors) { return TypedResults.ValidationProblem(validationErrors); } var response = await handler.HandleAsync(useApiLinks, request, cancellationToken: context.RequestAborted); - if (!response.IsSuccess) + if (response.IsError) { - return response.StatusCode switch + return response.FirstError.Type switch { - StatusCodes.Status403Forbidden => TypedResults.Forbid(), - StatusCodes.Status400BadRequest => TypedResults.BadRequest(), + ErrorType.Forbidden => TypedResults.Forbid(), + ErrorType.Validation => TypedResults.BadRequest(), _ => TypedResults.InternalServerError(), }; } - return TypedResults.Ok(response.Value!); + return TypedResults.Ok(response.Value); }) - .RequireAuthorization(AuthOptions.RequireTenantManagerPolicy) + .RequireAuthorization(IdmtAuthOptions.RequireTenantManagerPolicy) .WithSummary("Register user") .WithDescription("Register a new user for a tenant (Admin/System only)"); } -} \ No newline at end of file +} diff --git a/src/Idmt.Plugin/Features/Manage/UnregisterUser.cs b/src/Idmt.Plugin/Features/Manage/UnregisterUser.cs index 7f74d96..2e3c4da 100644 --- a/src/Idmt.Plugin/Features/Manage/UnregisterUser.cs +++ b/src/Idmt.Plugin/Features/Manage/UnregisterUser.cs @@ -1,5 +1,7 @@ using System.Security.Claims; +using ErrorOr; using Idmt.Plugin.Configuration; +using Idmt.Plugin.Errors; using Idmt.Plugin.Models; using Idmt.Plugin.Services; using Microsoft.AspNetCore.Builder; @@ -17,7 +19,7 @@ public static class UnregisterUser { public interface IUnregisterUserHandler { - Task HandleAsync(Guid userId, CancellationToken cancellationToken = default); + Task> HandleAsync(Guid userId, CancellationToken cancellationToken = default); } internal sealed class UnregisterUserHandler( @@ -26,7 +28,7 @@ internal sealed class UnregisterUserHandler( UserManager userManager, ITenantAccessService tenantAccessService) : IUnregisterUserHandler { - public async Task HandleAsync(Guid userId, CancellationToken cancellationToken = default) + public async Task> HandleAsync(Guid userId, CancellationToken cancellationToken = default) { try { @@ -34,14 +36,14 @@ public async Task HandleAsync(Guid userId, CancellationToken cancellatio var appUser = await userManager.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); if (appUser is null) { - return Result.Failure("User not found", StatusCodes.Status404NotFound); + return IdmtErrors.User.NotFound; } var userRoles = await userManager.GetRolesAsync(appUser); if (!tenantAccessService.CanManageUser(userRoles)) { - return Result.Failure("Insufficient permissions to delete this user.", StatusCodes.Status403Forbidden); + return IdmtErrors.User.InsufficientPermissions; } var result = await userManager.DeleteAsync(appUser); @@ -50,16 +52,16 @@ public async Task HandleAsync(Guid userId, CancellationToken cancellatio { var errors = string.Join("\n", result.Errors.Select(e => e.Description)); logger.LogError("Failed to unregister user {UserId}: {Errors}", userId, errors); - return Result.Failure(errors, StatusCodes.Status400BadRequest); + return IdmtErrors.User.DeletionFailed; } } catch (Exception ex) { logger.LogError(ex, "Exception occurred while unregistering user {UserId}", userId); - return Result.Failure($"An error occurred while unregistering the user: {ex.Message}", StatusCodes.Status500InternalServerError); + return IdmtErrors.General.Unexpected; } - return Result.Success(); + return Result.Success; } } @@ -72,20 +74,20 @@ public static RouteHandlerBuilder MapUnregisterUserEndpoint(this IEndpointRouteB HttpContext context) => { var result = await handler.HandleAsync(userId, cancellationToken: context.RequestAborted); - if (!result.IsSuccess) + if (result.IsError) { - return result.StatusCode switch + return result.FirstError.Type switch { - StatusCodes.Status404NotFound => TypedResults.NotFound(), - StatusCodes.Status403Forbidden => TypedResults.Forbid(), - StatusCodes.Status400BadRequest => TypedResults.BadRequest(), + ErrorType.NotFound => TypedResults.NotFound(), + ErrorType.Forbidden => TypedResults.Forbid(), + ErrorType.Failure => TypedResults.BadRequest(), _ => TypedResults.InternalServerError(), }; } return TypedResults.Ok(); }) - .RequireAuthorization(AuthOptions.RequireTenantManagerPolicy) + .RequireAuthorization(IdmtAuthOptions.RequireTenantManagerPolicy) .WithSummary("Delete user") .WithDescription("Delete a user within the same tenant (Admin/System only)"); } -} \ No newline at end of file +} diff --git a/src/Idmt.Plugin/Features/Manage/UpdateUser.cs b/src/Idmt.Plugin/Features/Manage/UpdateUser.cs index 33a5dec..8731594 100644 --- a/src/Idmt.Plugin/Features/Manage/UpdateUser.cs +++ b/src/Idmt.Plugin/Features/Manage/UpdateUser.cs @@ -1,4 +1,6 @@ +using ErrorOr; using Idmt.Plugin.Configuration; +using Idmt.Plugin.Errors; using Idmt.Plugin.Models; using Idmt.Plugin.Services; using Microsoft.AspNetCore.Builder; @@ -18,7 +20,7 @@ public sealed record UpdateUserRequest(bool IsActive); public interface IUpdateUserHandler { - Task HandleAsync(Guid userId, UpdateUserRequest request, CancellationToken cancellationToken = default); + Task> HandleAsync(Guid userId, UpdateUserRequest request, CancellationToken cancellationToken = default); } internal sealed class UpdateUserHandler( @@ -26,21 +28,21 @@ internal sealed class UpdateUserHandler( ITenantAccessService tenantAccessService, ILogger logger) : IUpdateUserHandler { - public async Task HandleAsync(Guid userId, UpdateUserRequest request, CancellationToken cancellationToken = default) + public async Task> HandleAsync(Guid userId, UpdateUserRequest request, CancellationToken cancellationToken = default) { try { var appUser = await userManager.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken); if (appUser == null) { - return Result.Failure("User not found", StatusCodes.Status404NotFound); + return IdmtErrors.User.NotFound; } var userRoles = await userManager.GetRolesAsync(appUser); if (!tenantAccessService.CanManageUser(userRoles)) { - return Result.Failure("Insufficient permissions to update this user.", StatusCodes.Status403Forbidden); + return IdmtErrors.User.InsufficientPermissions; } appUser.IsActive = request.IsActive; @@ -48,14 +50,14 @@ public async Task HandleAsync(Guid userId, UpdateUserRequest request, Ca var result = await userManager.UpdateAsync(appUser); if (!result.Succeeded) { - return Result.Failure("Failed to update user", StatusCodes.Status400BadRequest); + return IdmtErrors.User.UpdateFailed; } - return Result.Success(); + return Result.Success; } catch (Exception ex) { logger.LogError(ex, "Exception occurred while updating user {UserId}", userId); - return Result.Failure($"An error occurred while updating the user: {ex.Message}", StatusCodes.Status500InternalServerError); + return IdmtErrors.General.Unexpected; } } } @@ -69,20 +71,20 @@ public static RouteHandlerBuilder MapUpdateUserEndpoint(this IEndpointRouteBuild HttpContext context) => { var result = await handler.HandleAsync(userId, request, cancellationToken: context.RequestAborted); - if (!result.IsSuccess) + if (result.IsError) { - return result.StatusCode switch + return result.FirstError.Type switch { - StatusCodes.Status404NotFound => TypedResults.NotFound(), - StatusCodes.Status403Forbidden => TypedResults.Forbid(), - StatusCodes.Status400BadRequest => TypedResults.BadRequest(), + ErrorType.NotFound => TypedResults.NotFound(), + ErrorType.Forbidden => TypedResults.Forbid(), + ErrorType.Failure => TypedResults.BadRequest(), _ => TypedResults.InternalServerError(), }; } return TypedResults.Ok(); }) - .RequireAuthorization(AuthOptions.RequireTenantManagerPolicy) + .RequireAuthorization(IdmtAuthOptions.RequireTenantManagerPolicy) .WithSummary("Activate/Deactivate user") .WithDescription("Activate/Deactivate a user within the same tenant (Admin/System only)"); } -} \ No newline at end of file +} diff --git a/src/Idmt.Plugin/Features/Manage/UpdateUserInfo.cs b/src/Idmt.Plugin/Features/Manage/UpdateUserInfo.cs index 29ed29d..32a2826 100644 --- a/src/Idmt.Plugin/Features/Manage/UpdateUserInfo.cs +++ b/src/Idmt.Plugin/Features/Manage/UpdateUserInfo.cs @@ -1,5 +1,7 @@ using System.Security.Claims; -using Idmt.Plugin.Configuration; +using ErrorOr; +using FluentValidation; +using Idmt.Plugin.Errors; using Idmt.Plugin.Models; using Idmt.Plugin.Persistence; using Idmt.Plugin.Validation; @@ -10,7 +12,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; namespace Idmt.Plugin.Features.Manage; @@ -25,7 +26,7 @@ public sealed record UpdateUserInfoRequest( public interface IUpdateUserInfoHandler { - Task HandleAsync(UpdateUserInfoRequest request, ClaimsPrincipal user, CancellationToken cancellationToken = default); + Task> HandleAsync(UpdateUserInfoRequest request, ClaimsPrincipal user, CancellationToken cancellationToken = default); } internal sealed class UpdateUserInfoHandler( @@ -33,7 +34,7 @@ internal sealed class UpdateUserInfoHandler( IdmtDbContext dbContext, ILogger logger) : IUpdateUserInfoHandler { - public async Task HandleAsync( + public async Task> HandleAsync( UpdateUserInfoRequest request, ClaimsPrincipal user, CancellationToken cancellationToken = default) @@ -41,17 +42,17 @@ public async Task HandleAsync( var userEmail = user.FindFirstValue(ClaimTypes.Email); if (string.IsNullOrEmpty(userEmail)) { - return Result.Failure("User email not found in claims", StatusCodes.Status400BadRequest); + return IdmtErrors.User.ClaimsNotFound; } var appUser = await userManager.FindByEmailAsync(userEmail); if (appUser == null) { - return Result.Failure("User not found", StatusCodes.Status404NotFound); + return IdmtErrors.User.NotFound; } if (!appUser.IsActive) { - return Result.Failure("User is not active", StatusCodes.Status403Forbidden); + return IdmtErrors.User.Inactive; } await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); @@ -65,7 +66,7 @@ public async Task HandleAsync( { logger.LogError("Failed to set username: {ErrorMessage}", setUsernameResult.Errors.Select(e => e.Description)); await transaction.RollbackAsync(cancellationToken); - return Result.Failure("Failed to update username", StatusCodes.Status400BadRequest); + return IdmtErrors.User.UpdateFailed; } } @@ -82,7 +83,7 @@ public async Task HandleAsync( { logger.LogError("Failed to change email: {ErrorMessage}", result.Errors.Select(e => e.Description)); await transaction.RollbackAsync(cancellationToken); - return Result.Failure("Failed to update email", StatusCodes.Status400BadRequest); + return IdmtErrors.User.UpdateFailed; } } @@ -94,7 +95,7 @@ public async Task HandleAsync( { logger.LogError("Failed to change password: {ErrorMessage}", changePasswordResult.Errors.Select(e => e.Description)); await transaction.RollbackAsync(cancellationToken); - return Result.Failure("Failed to update password", StatusCodes.Status400BadRequest); + return IdmtErrors.Password.ResetFailed; } } @@ -102,59 +103,39 @@ public async Task HandleAsync( await transaction.CommitAsync(cancellationToken); - return Result.Success(); + return Result.Success; } catch (Exception ex) { await transaction.RollbackAsync(cancellationToken); logger.LogError(ex, "Exception occurred during user registration. Transaction rolled back."); - return Result.Failure($"An error occurred while updating user info: {ex.Message}", StatusCodes.Status500InternalServerError); + return IdmtErrors.General.Unexpected; } } } - public static Dictionary? Validate(this UpdateUserInfoRequest request, Configuration.PasswordOptions options) - { - var errors = new Dictionary(); - // Only require old password when NewPassword is provided - if (!string.IsNullOrEmpty(request.NewPassword) && string.IsNullOrEmpty(request.OldPassword)) - { - errors["OldPassword"] = ["Old password is required to change password"]; - } - if (request.NewEmail is not null && !Validators.IsValidEmail(request.NewEmail)) - { - errors["NewEmail"] = ["New email is not valid"]; - } - if (request.NewPassword is not null && !Validators.IsValidNewPassword(request.NewPassword, options, out var newPasswordErrors)) - { - errors["NewPassword"] = newPasswordErrors ?? []; - } - - return errors.Count == 0 ? null : errors; - } - public static RouteHandlerBuilder MapUpdateUserInfoEndpoint(this IEndpointRouteBuilder endpoints) { return endpoints.MapPut("/info", async Task> ( [FromBody] UpdateUserInfoRequest request, ClaimsPrincipal user, [FromServices] IUpdateUserInfoHandler handler, - [FromServices] IOptions options, + [FromServices] IValidator validator, HttpContext context) => { - if (request.Validate(options.Value.Identity.Password) is { } errors) + if (ValidationHelper.Validate(request, validator) is { } errors) { return TypedResults.ValidationProblem(errors); } var result = await handler.HandleAsync(request, user, cancellationToken: context.RequestAborted); - if (!result.IsSuccess) + if (result.IsError) { - return result.StatusCode switch + return result.FirstError.Type switch { - StatusCodes.Status400BadRequest => TypedResults.BadRequest(), - StatusCodes.Status403Forbidden => TypedResults.Forbid(), - StatusCodes.Status404NotFound => TypedResults.NotFound(), + ErrorType.NotFound => TypedResults.NotFound(), + ErrorType.Forbidden => TypedResults.Forbid(), + ErrorType.Validation => TypedResults.BadRequest(), _ => TypedResults.InternalServerError(), }; } @@ -164,4 +145,4 @@ public static RouteHandlerBuilder MapUpdateUserInfoEndpoint(this IEndpointRouteB .WithDescription("Update current user authentication info") .RequireAuthorization(); } -} \ No newline at end of file +} diff --git a/src/Idmt.Plugin/Features/Result.cs b/src/Idmt.Plugin/Features/Result.cs deleted file mode 100644 index fffc236..0000000 --- a/src/Idmt.Plugin/Features/Result.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace Idmt.Plugin.Features; - -public class Result -{ - public bool IsSuccess { get; init; } - public string? ErrorMessage { get; init; } - public int StatusCode { get; init; } = StatusCodes.Status200OK; - - public static Result Success(int statusCode = StatusCodes.Status200OK) - { - return new Result { IsSuccess = true, StatusCode = statusCode }; - } - - public static Result Failure(string errorMessage, int statusCode = StatusCodes.Status400BadRequest) - { - return new Result { IsSuccess = false, ErrorMessage = errorMessage, StatusCode = statusCode }; - } - - public static Result Success(T value, int statusCode = StatusCodes.Status200OK) - { - return new Result { IsSuccess = true, Value = value, StatusCode = statusCode }; - } - - public static Result Failure(string errorMessage, int statusCode = StatusCodes.Status400BadRequest) - { - return new Result { IsSuccess = false, ErrorMessage = errorMessage, StatusCode = statusCode }; - } -} - -public class Result : Result -{ - public T? Value { get; init; } -} \ No newline at end of file diff --git a/src/Idmt.Plugin/Idmt.Plugin.csproj b/src/Idmt.Plugin/Idmt.Plugin.csproj index 8e86b25..e2ff9ef 100644 --- a/src/Idmt.Plugin/Idmt.Plugin.csproj +++ b/src/Idmt.Plugin/Idmt.Plugin.csproj @@ -18,15 +18,18 @@ - - - - - - - - - + + + + + + + + + + + + diff --git a/src/Idmt.Plugin/Middleware/ValidateBearerTokenTenantMiddleware.cs b/src/Idmt.Plugin/Middleware/ValidateBearerTokenTenantMiddleware.cs index 322ab64..49a8e58 100644 --- a/src/Idmt.Plugin/Middleware/ValidateBearerTokenTenantMiddleware.cs +++ b/src/Idmt.Plugin/Middleware/ValidateBearerTokenTenantMiddleware.cs @@ -1,13 +1,15 @@ using Finbuckle.MultiTenant.Abstractions; using Idmt.Plugin.Configuration; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Idmt.Plugin.Middleware; public class ValidateBearerTokenTenantMiddleware( IMultiTenantContextAccessor tenantContextAccessor, - IOptions idmtOptions) : IMiddleware + IOptions idmtOptions, + ILogger logger) : IMiddleware { public async Task InvokeAsync(HttpContext context, RequestDelegate next) { @@ -67,9 +69,10 @@ private bool ValidateTokenTenant( return true; // Validation passed } - catch + catch (Exception ex) { - return false; // On error, reject the request + logger.LogWarning(ex, "Error validating bearer token tenant"); + return false; } } } diff --git a/src/Idmt.Plugin/Models/IdmtAuditLog.cs b/src/Idmt.Plugin/Models/IdmtAuditLog.cs index bc76078..f88d775 100644 --- a/src/Idmt.Plugin/Models/IdmtAuditLog.cs +++ b/src/Idmt.Plugin/Models/IdmtAuditLog.cs @@ -53,7 +53,7 @@ public class IdmtAuditLog /// /// When this action occurred. /// - public DateTime Timestamp { get; set; } = DT.UtcNow; + public DateTime Timestamp { get; set; } /// /// Whether this was a successful action. diff --git a/src/Idmt.Plugin/Models/IdmtTenantInfo.cs b/src/Idmt.Plugin/Models/IdmtTenantInfo.cs index 3e2b255..7327091 100644 --- a/src/Idmt.Plugin/Models/IdmtTenantInfo.cs +++ b/src/Idmt.Plugin/Models/IdmtTenantInfo.cs @@ -7,20 +7,37 @@ namespace Idmt.Plugin.Models; /// Implements ITenantInfo interface from Finbuckle.MultiTenant. /// Identifier must be at least 3 characters long. /// -public record IdmtTenantInfo : TenantInfo, IAuditable +public record IdmtTenantInfo : ITenantInfo, IAuditable { - public IdmtTenantInfo(string id, string identifier, string name) : base(id, identifier, name) + public IdmtTenantInfo(string id, string identifier, string name) { + ArgumentException.ThrowIfNullOrEmpty(id); + ArgumentException.ThrowIfNullOrEmpty(identifier); + ArgumentException.ThrowIfNullOrEmpty(name); + if (identifier.Length < 3) + { + throw new ArgumentException("Identifier must be at least 3 characters long.", nameof(identifier)); + } + + Id = id; + Identifier = identifier; + Name = name; } - public IdmtTenantInfo(string identifier, string name) : base(Guid.CreateVersion7().ToString(), identifier, name) + public IdmtTenantInfo(string identifier, string name) : this(Guid.CreateVersion7().ToString(), identifier, name) { } + /// + public string Id { get; init; } + + /// + public string Identifier { get; init; } + /// /// Human-readable display name for the tenant. /// - public string? DisplayName { get; init; } + public string? Name { get; init; } /// /// The tenant's subscription or feature plan, if applicable. @@ -54,7 +71,7 @@ public IdmtTenantInfo(string identifier, string name) : base(Guid.CreateVersion7 public string GetId() => Id ?? string.Empty; - public string GetName() => nameof(IdmtTenantInfo); + public string GetName() => Name ?? Identifier; public string? GetTenantId() => Id; } \ No newline at end of file diff --git a/src/Idmt.Plugin/Persistence/IdmtDbContext.cs b/src/Idmt.Plugin/Persistence/IdmtDbContext.cs index 847e9e5..3ca79b7 100644 --- a/src/Idmt.Plugin/Persistence/IdmtDbContext.cs +++ b/src/Idmt.Plugin/Persistence/IdmtDbContext.cs @@ -14,30 +14,36 @@ public class IdmtDbContext : MultiTenantIdentityDbContext { private readonly ICurrentUserService _currentUserService; + private readonly TimeProvider _timeProvider; public IdmtDbContext( - IMultiTenantContextAccessor multiTenantContextAccessor, ICurrentUserService currentUserService) + IMultiTenantContextAccessor multiTenantContextAccessor, ICurrentUserService currentUserService, TimeProvider timeProvider) : base(multiTenantContextAccessor) { _currentUserService = currentUserService; + _timeProvider = timeProvider; } public IdmtDbContext( IMultiTenantContextAccessor multiTenantContextAccessor, DbContextOptions options, - ICurrentUserService currentUserService) + ICurrentUserService currentUserService, + TimeProvider timeProvider) : base(multiTenantContextAccessor, options) { _currentUserService = currentUserService; + _timeProvider = timeProvider; } protected IdmtDbContext( IMultiTenantContextAccessor multiTenantContextAccessor, DbContextOptions options, - ICurrentUserService currentUserService) + ICurrentUserService currentUserService, + TimeProvider timeProvider) : base(multiTenantContextAccessor, options) { _currentUserService = currentUserService; + _timeProvider = timeProvider; } /// @@ -101,7 +107,6 @@ protected override void OnModelCreating(ModelBuilder builder) // Property configurations for custom properties entity.Property(ti => ti.Name).HasMaxLength(200); - entity.Property(ti => ti.DisplayName).HasMaxLength(200); entity.Property(ti => ti.Plan).HasMaxLength(100); entity.Property(ti => ti.IsActive).IsRequired().HasDefaultValue(true); @@ -128,7 +133,7 @@ public override Task SaveChangesAsync(CancellationToken cancellationToken = Resource = entry.Entity.GetName(), ResourceId = entry.Entity.GetId(), Success = true, - Timestamp = DT.UtcNow, + Timestamp = _timeProvider.GetUtcNow().UtcDateTime, IpAddress = _currentUserService.IpAddress, UserAgent = _currentUserService.UserAgent, }); @@ -143,7 +148,7 @@ public override Task SaveChangesAsync(CancellationToken cancellationToken = Resource = entry.Entity.GetName(), ResourceId = entry.Entity.GetId(), Success = true, - Timestamp = DT.UtcNow, + Timestamp = _timeProvider.GetUtcNow().UtcDateTime, IpAddress = _currentUserService.IpAddress, UserAgent = _currentUserService.UserAgent, }); @@ -162,7 +167,7 @@ public override Task SaveChangesAsync(CancellationToken cancellationToken = ResourceId = entry.Entity.GetId(), Details = details, Success = true, - Timestamp = DT.UtcNow, + Timestamp = _timeProvider.GetUtcNow().UtcDateTime, IpAddress = _currentUserService.IpAddress, UserAgent = _currentUserService.UserAgent, }); @@ -173,5 +178,5 @@ public override Task SaveChangesAsync(CancellationToken cancellationToken = } public override int SaveChanges() => - SaveChangesAsync(CancellationToken.None).GetAwaiter().GetResult(); + throw new NotSupportedException("Use SaveChangesAsync. Sync SaveChanges is not supported in IdmtDbContext."); } \ No newline at end of file diff --git a/src/Idmt.Plugin/Services/BetterSignInManager.cs b/src/Idmt.Plugin/Services/BetterSignInManager.cs deleted file mode 100644 index 80758ae..0000000 --- a/src/Idmt.Plugin/Services/BetterSignInManager.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Idmt.Plugin.Models; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Idmt.Plugin.Services; - -/// -/// Provides the APIs for user sign in. -/// -/// The type encapsulating a user. -internal sealed class BetterSignInManager( - UserManager userManager, - IHttpContextAccessor contextAccessor, - IUserClaimsPrincipalFactory claimsFactory, - IOptions optionsAccessor, - ILogger> logger, - IAuthenticationSchemeProvider schemes, - IUserConfirmation confirmation) - : SignInManager(userManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation) -{ - /// - /// Attempts to sign in the specified and combination - /// as an asynchronous operation. - /// - /// The userName or email to sign in. - /// The password to attempt to sign in with. - /// Flag indicating whether the sign-in cookie should persist after the browser is closed. - /// Flag indicating if the user account should be locked if the sign in fails. - /// The task object representing the asynchronous operation containing the - /// for the sign-in attempt. - public override async Task PasswordSignInAsync(string userName, string password, bool isPersistent, bool lockoutOnFailure) - { - var user = await UserManager.FindByEmailAsync(userName); - user ??= await UserManager.FindByNameAsync(userName); - if (user == null) - { - return SignInResult.Failed; - } - return await PasswordSignInAsync(user, password, isPersistent, lockoutOnFailure); - } -} \ No newline at end of file diff --git a/src/Idmt.Plugin/Services/CurrentUserService.cs b/src/Idmt.Plugin/Services/CurrentUserService.cs index cbaf8f0..7bc5ee7 100644 --- a/src/Idmt.Plugin/Services/CurrentUserService.cs +++ b/src/Idmt.Plugin/Services/CurrentUserService.cs @@ -16,7 +16,7 @@ internal sealed class CurrentUserService( public string? UserAgent { get; private set; } public Guid? UserId => - Guid.TryParse(User?.FindFirstValue(ClaimTypes.NameIdentifier) ?? Guid.Empty.ToString(), out var userId) ? userId : null; + Guid.TryParse(User?.FindFirstValue(ClaimTypes.NameIdentifier), out var userId) ? userId : null; public string? UserIdAsString => User?.FindFirstValue(ClaimTypes.NameIdentifier); diff --git a/src/Idmt.Plugin/Services/ICurrentUserService.cs b/src/Idmt.Plugin/Services/ICurrentUserService.cs index 40c57df..b871ee6 100644 --- a/src/Idmt.Plugin/Services/ICurrentUserService.cs +++ b/src/Idmt.Plugin/Services/ICurrentUserService.cs @@ -14,6 +14,7 @@ public interface ICurrentUserService string? UserName { get; } string? TenantId { get; } string? TenantIdentifier { get; } + bool IsActive { get; } bool IsInRole(string role); diff --git a/src/Idmt.Plugin/Services/ITenantOperationService.cs b/src/Idmt.Plugin/Services/ITenantOperationService.cs new file mode 100644 index 0000000..26d4375 --- /dev/null +++ b/src/Idmt.Plugin/Services/ITenantOperationService.cs @@ -0,0 +1,16 @@ +using ErrorOr; + +namespace Idmt.Plugin.Services; + +public interface ITenantOperationService +{ + Task> ExecuteInTenantScopeAsync( + string tenantIdentifier, + Func>> operation, + bool requireActive = true); + + Task> ExecuteInTenantScopeAsync( + string tenantIdentifier, + Func>> operation, + bool requireActive = true); +} diff --git a/src/Idmt.Plugin/Services/IdmtEmailSender.cs b/src/Idmt.Plugin/Services/IdmtEmailSender.cs index f9ed466..30cbc07 100644 --- a/src/Idmt.Plugin/Services/IdmtEmailSender.cs +++ b/src/Idmt.Plugin/Services/IdmtEmailSender.cs @@ -8,21 +8,18 @@ public class IdmtEmailSender(ILogger logger) : IEmailSender roleManager, IOptions optionsAccessor, IMultiTenantStore tenantStore, - IOptions idmtOptions) + IOptions idmtOptions, + ILogger logger) : UserClaimsPrincipalFactory(userManager, roleManager, optionsAccessor) { protected override async Task GenerateClaimsAsync(IdmtUser user) @@ -20,14 +23,20 @@ protected override async Task GenerateClaimsAsync(IdmtUser user) var identity = await base.GenerateClaimsAsync(user); // Add custom claims - identity.AddClaim(new Claim("is_active", user.IsActive.ToString())); + identity.AddClaim(new Claim(IdmtClaimTypes.IsActive, user.IsActive.ToString())); // Add tenant claim for multi-tenant strategies (header, claim, route) // This ensures token validation includes tenant context var claimKey = idmtOptions.Value.MultiTenant.StrategyOptions.GetValueOrDefault(IdmtMultiTenantStrategy.Claim, IdmtMultiTenantStrategy.DefaultClaim); // Try to get tenant info from store using user's TenantId - var tenantInfo = await tenantStore.GetAsync(user.TenantId) ?? throw new InvalidOperationException($"Tenant information not found for tenant ID: {user.TenantId}. User ID: {user.Id}"); + var tenantInfo = await tenantStore.GetAsync(user.TenantId); + if (tenantInfo is null) + { + logger.LogWarning("Tenant information not found for tenant ID: {TenantId}. User ID: {UserId}. Returning identity without tenant claim.", user.TenantId, user.Id); + return identity; + } + identity.AddClaim(new Claim(claimKey, tenantInfo.Identifier)); return identity; diff --git a/src/Idmt.Plugin/Services/TenantAccessService.cs b/src/Idmt.Plugin/Services/TenantAccessService.cs index 3e8993e..e463cc5 100644 --- a/src/Idmt.Plugin/Services/TenantAccessService.cs +++ b/src/Idmt.Plugin/Services/TenantAccessService.cs @@ -6,7 +6,8 @@ namespace Idmt.Plugin.Services; internal sealed class TenantAccessService( IdmtDbContext dbContext, - ICurrentUserService currentUserService) : ITenantAccessService + ICurrentUserService currentUserService, + TimeProvider timeProvider) : ITenantAccessService { public async Task CanAccessTenantAsync(Guid userId, string tenantId) { @@ -15,7 +16,7 @@ public async Task CanAccessTenantAsync(Guid userId, string tenantId) ta.UserId == userId && ta.TenantId == tenantId && ta.IsActive && - (ta.ExpiresAt == null || ta.ExpiresAt > DT.UtcNow)); + (ta.ExpiresAt == null || ta.ExpiresAt > timeProvider.GetUtcNow().UtcDateTime)); } public bool CanAssignRole(string role) diff --git a/src/Idmt.Plugin/Services/TenantOperationService.cs b/src/Idmt.Plugin/Services/TenantOperationService.cs new file mode 100644 index 0000000..917ae80 --- /dev/null +++ b/src/Idmt.Plugin/Services/TenantOperationService.cs @@ -0,0 +1,46 @@ +using ErrorOr; +using Finbuckle.MultiTenant.Abstractions; +using Idmt.Plugin.Errors; +using Idmt.Plugin.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace Idmt.Plugin.Services; + +internal sealed class TenantOperationService(IServiceProvider serviceProvider) : ITenantOperationService +{ + public async Task> ExecuteInTenantScopeAsync( + string tenantIdentifier, + Func>> operation, + bool requireActive = true) + { + using var scope = serviceProvider.CreateScope(); + var provider = scope.ServiceProvider; + + var tenantStore = provider.GetRequiredService>(); + var tenantInfo = await tenantStore.GetByIdentifierAsync(tenantIdentifier); + + if (tenantInfo is null) + { + return IdmtErrors.Tenant.NotFound; + } + + if (requireActive && !tenantInfo.IsActive) + { + return IdmtErrors.Tenant.Inactive; + } + + // Set tenant context before resolving scoped services + var tenantContextSetter = provider.GetRequiredService(); + tenantContextSetter.MultiTenantContext = new MultiTenantContext(tenantInfo); + + return await operation(provider); + } + + public async Task> ExecuteInTenantScopeAsync( + string tenantIdentifier, + Func>> operation, + bool requireActive = true) + { + return await ExecuteInTenantScopeAsync(tenantIdentifier, operation, requireActive); + } +} diff --git a/src/Idmt.Plugin/Validation/ConfirmEmailRequestValidator.cs b/src/Idmt.Plugin/Validation/ConfirmEmailRequestValidator.cs new file mode 100644 index 0000000..63ea408 --- /dev/null +++ b/src/Idmt.Plugin/Validation/ConfirmEmailRequestValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using Idmt.Plugin.Features.Auth; + +namespace Idmt.Plugin.Validation; + +public class ConfirmEmailRequestValidator : AbstractValidator +{ + public ConfirmEmailRequestValidator() + { + RuleFor(x => x.TenantIdentifier).NotEmpty() + .WithMessage("Tenant identifier is required"); + + RuleFor(x => x.Email).NotEmpty() + .WithMessage("Email is required") + .Must(Validators.IsValidEmail) + .WithMessage("Invalid email address"); + + RuleFor(x => x.Token).NotEmpty() + .WithMessage("Token is required"); + } +} diff --git a/src/Idmt.Plugin/Validation/CreateTenantRequestValidator.cs b/src/Idmt.Plugin/Validation/CreateTenantRequestValidator.cs new file mode 100644 index 0000000..67ccbe9 --- /dev/null +++ b/src/Idmt.Plugin/Validation/CreateTenantRequestValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using Idmt.Plugin.Features.Admin; + +namespace Idmt.Plugin.Validation; + +public class CreateTenantRequestValidator : AbstractValidator +{ + public CreateTenantRequestValidator() + { + RuleFor(x => x.Identifier).NotEmpty() + .WithMessage("Identifier is required") + .Must(Validators.IsValidTenantIdentifier) + .WithMessage("Identifier can only contain lowercase alphanumeric characters, dashes, and underscores"); + + RuleFor(x => x.Name).NotEmpty() + .WithMessage("Name is required"); + + RuleFor(x => x.DisplayName).NotEmpty() + .WithMessage("Display Name is required"); + } +} diff --git a/src/Idmt.Plugin/Validation/ForgotPasswordRequestValidator.cs b/src/Idmt.Plugin/Validation/ForgotPasswordRequestValidator.cs new file mode 100644 index 0000000..f01f746 --- /dev/null +++ b/src/Idmt.Plugin/Validation/ForgotPasswordRequestValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; +using Idmt.Plugin.Features.Auth; + +namespace Idmt.Plugin.Validation; + +public class ForgotPasswordRequestValidator : AbstractValidator +{ + public ForgotPasswordRequestValidator() + { + RuleFor(x => x.Email).Must(Validators.IsValidEmail) + .WithMessage("Invalid email address."); + } +} diff --git a/src/Idmt.Plugin/Validation/LoginRequestValidator.cs b/src/Idmt.Plugin/Validation/LoginRequestValidator.cs new file mode 100644 index 0000000..5a27121 --- /dev/null +++ b/src/Idmt.Plugin/Validation/LoginRequestValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using Idmt.Plugin.Features.Auth; + +namespace Idmt.Plugin.Validation; + +public class LoginRequestValidator : AbstractValidator +{ + public LoginRequestValidator() + { + RuleFor(x => x).Must(x => x.Email is not null || x.Username is not null) + .WithMessage("Email or Username is required.") + .WithName("Identifier"); + + When(x => x.Email is not null, () => + { + RuleFor(x => x.Email).Must(Validators.IsValidEmail) + .WithMessage("Invalid email."); + }); + + When(x => x.Username is not null, () => + { + RuleFor(x => x.Username).Must(Validators.IsValidUsername) + .WithMessage("Invalid username."); + }); + + RuleFor(x => x.Password).NotEmpty() + .WithMessage("Password is required."); + } +} diff --git a/src/Idmt.Plugin/Validation/RefreshTokenRequestValidator.cs b/src/Idmt.Plugin/Validation/RefreshTokenRequestValidator.cs new file mode 100644 index 0000000..4f0f838 --- /dev/null +++ b/src/Idmt.Plugin/Validation/RefreshTokenRequestValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; +using Idmt.Plugin.Features.Auth; + +namespace Idmt.Plugin.Validation; + +public class RefreshTokenRequestValidator : AbstractValidator +{ + public RefreshTokenRequestValidator() + { + RuleFor(x => x.RefreshToken).NotEmpty() + .WithMessage("Refresh token is required."); + } +} diff --git a/src/Idmt.Plugin/Validation/RegisterUserRequestValidator.cs b/src/Idmt.Plugin/Validation/RegisterUserRequestValidator.cs new file mode 100644 index 0000000..b7dd58a --- /dev/null +++ b/src/Idmt.Plugin/Validation/RegisterUserRequestValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using Idmt.Plugin.Configuration; +using Idmt.Plugin.Features.Manage; +using Microsoft.Extensions.Options; + +namespace Idmt.Plugin.Validation; + +public class RegisterUserRequestValidator : AbstractValidator +{ + public RegisterUserRequestValidator(IOptions options) + { + RuleFor(x => x.Email).Must(Validators.IsValidEmail) + .WithMessage("Invalid email address."); + + When(x => x.Username is not null, () => + { + var allowedChars = options.Value.Identity.User.AllowedUserNameCharacters; + When(_ => !string.IsNullOrEmpty(allowedChars), () => + { + RuleFor(x => x.Username) + .Must(username => username!.All(c => allowedChars.Contains(c))) + .WithMessage($"Username must contain only the following characters: {allowedChars}"); + }); + }); + + RuleFor(x => x.Role).NotEmpty() + .WithMessage("Role is required."); + } +} diff --git a/src/Idmt.Plugin/Validation/ResendConfirmationEmailRequestValidator.cs b/src/Idmt.Plugin/Validation/ResendConfirmationEmailRequestValidator.cs new file mode 100644 index 0000000..9a4c3b6 --- /dev/null +++ b/src/Idmt.Plugin/Validation/ResendConfirmationEmailRequestValidator.cs @@ -0,0 +1,13 @@ +using FluentValidation; +using Idmt.Plugin.Features.Auth; + +namespace Idmt.Plugin.Validation; + +public class ResendConfirmationEmailRequestValidator : AbstractValidator +{ + public ResendConfirmationEmailRequestValidator() + { + RuleFor(x => x.Email).Must(Validators.IsValidEmail) + .WithMessage("Invalid email address."); + } +} diff --git a/src/Idmt.Plugin/Validation/ResetPasswordRequestValidator.cs b/src/Idmt.Plugin/Validation/ResetPasswordRequestValidator.cs new file mode 100644 index 0000000..2633339 --- /dev/null +++ b/src/Idmt.Plugin/Validation/ResetPasswordRequestValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; +using Idmt.Plugin.Configuration; +using Idmt.Plugin.Features.Auth; +using Microsoft.Extensions.Options; + +namespace Idmt.Plugin.Validation; + +public class ResetPasswordRequestValidator : AbstractValidator +{ + public ResetPasswordRequestValidator(IOptions options) + { + RuleFor(x => x.NewPassword).Must(password => + Validators.IsValidNewPassword(password, options.Value.Identity.Password, out _)) + .WithMessage("Password does not meet requirements."); + } +} diff --git a/src/Idmt.Plugin/Validation/UpdateUserInfoRequestValidator.cs b/src/Idmt.Plugin/Validation/UpdateUserInfoRequestValidator.cs new file mode 100644 index 0000000..8c91764 --- /dev/null +++ b/src/Idmt.Plugin/Validation/UpdateUserInfoRequestValidator.cs @@ -0,0 +1,31 @@ +using FluentValidation; +using Idmt.Plugin.Configuration; +using Idmt.Plugin.Features.Manage; +using Microsoft.Extensions.Options; + +namespace Idmt.Plugin.Validation; + +public class UpdateUserInfoRequestValidator : AbstractValidator +{ + public UpdateUserInfoRequestValidator(IOptions options) + { + When(x => !string.IsNullOrEmpty(x.NewPassword), () => + { + RuleFor(x => x.OldPassword).NotEmpty() + .WithMessage("Old password is required to change password"); + }); + + When(x => x.NewEmail is not null, () => + { + RuleFor(x => x.NewEmail).Must(Validators.IsValidEmail) + .WithMessage("New email is not valid"); + }); + + When(x => x.NewPassword is not null, () => + { + RuleFor(x => x.NewPassword).Must(password => + Validators.IsValidNewPassword(password, options.Value.Identity.Password, out _)) + .WithMessage("Password does not meet requirements."); + }); + } +} diff --git a/src/Idmt.Plugin/Validation/ValidationHelper.cs b/src/Idmt.Plugin/Validation/ValidationHelper.cs new file mode 100644 index 0000000..ac7cdf0 --- /dev/null +++ b/src/Idmt.Plugin/Validation/ValidationHelper.cs @@ -0,0 +1,19 @@ +using FluentValidation; + +namespace Idmt.Plugin.Validation; + +public static class ValidationHelper +{ + public static Dictionary? Validate(T instance, IValidator validator) + { + var result = validator.Validate(instance); + if (result.IsValid) + return null; + + return result.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary( + g => g.Key, + g => g.Select(e => e.ErrorMessage).ToArray()); + } +} diff --git a/src/Idmt.Plugin/Validation/Validators.cs b/src/Idmt.Plugin/Validation/Validators.cs index fd30ae0..222a278 100644 --- a/src/Idmt.Plugin/Validation/Validators.cs +++ b/src/Idmt.Plugin/Validation/Validators.cs @@ -42,7 +42,7 @@ public static bool IsValidEmail(string? email) /// The list of errors if the password is invalid. /// /// True if the password is valid, false otherwise. - public static bool IsValidNewPassword(string? password, PasswordOptions options, out string[]? errors) + public static bool IsValidNewPassword(string? password, IdmtPasswordOptions options, out string[]? errors) { if (string.IsNullOrWhiteSpace(password)) { @@ -97,22 +97,6 @@ public static bool IsValidNewPassword(string? password, PasswordOptions options, return true; } - /// - /// Validates a GUID string. - /// - public static bool IsValidGuid(string? guidString) - { - return !string.IsNullOrWhiteSpace(guidString) && Guid.TryParse(guidString, out _); - } - - /// - /// Validates a tenant ID (non-empty string). - /// - public static bool IsValidTenantId(string? tenantId) - { - return !string.IsNullOrWhiteSpace(tenantId) && tenantId.Length >= 3; - } - /// /// Validates a username (non-empty string with minimum 3 characters). /// diff --git a/src/Idmt.sln b/src/Idmt.sln deleted file mode 100644 index cfedd5c..0000000 --- a/src/Idmt.sln +++ /dev/null @@ -1,49 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Idmt.Plugin", "Idmt.Plugin\Idmt.Plugin.csproj", "{733A6665-226B-498C-97F1-3F33063A4CEB}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{A98C7932-C24A-431A-9944-987E193ACFBC}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Idmt.BasicSample", "samples\Idmt.BasicSample\Idmt.BasicSample.csproj", "{7A3DB082-6320-4B95-B0E1-848688215E21}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F3D577DA-B949-4028-B52F-8BC6CA8A9647}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Idmt.BasicSample.Tests", "tests\Idmt.BasicSample.Tests\Idmt.BasicSample.Tests.csproj", "{CD3EFBE5-8F2E-4A5B-A5A6-0D989BC1BE91}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Idmt.UnitTests", "tests\Idmt.UnitTests\Idmt.UnitTests.csproj", "{C3116626-A482-42EF-AACC-1C1061504BD7}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {733A6665-226B-498C-97F1-3F33063A4CEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {733A6665-226B-498C-97F1-3F33063A4CEB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {733A6665-226B-498C-97F1-3F33063A4CEB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {733A6665-226B-498C-97F1-3F33063A4CEB}.Release|Any CPU.Build.0 = Release|Any CPU - {7A3DB082-6320-4B95-B0E1-848688215E21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7A3DB082-6320-4B95-B0E1-848688215E21}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7A3DB082-6320-4B95-B0E1-848688215E21}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7A3DB082-6320-4B95-B0E1-848688215E21}.Release|Any CPU.Build.0 = Release|Any CPU - {CD3EFBE5-8F2E-4A5B-A5A6-0D989BC1BE91}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CD3EFBE5-8F2E-4A5B-A5A6-0D989BC1BE91}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CD3EFBE5-8F2E-4A5B-A5A6-0D989BC1BE91}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CD3EFBE5-8F2E-4A5B-A5A6-0D989BC1BE91}.Release|Any CPU.Build.0 = Release|Any CPU - {C3116626-A482-42EF-AACC-1C1061504BD7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C3116626-A482-42EF-AACC-1C1061504BD7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C3116626-A482-42EF-AACC-1C1061504BD7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C3116626-A482-42EF-AACC-1C1061504BD7}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {7A3DB082-6320-4B95-B0E1-848688215E21} = {A98C7932-C24A-431A-9944-987E193ACFBC} - {CD3EFBE5-8F2E-4A5B-A5A6-0D989BC1BE91} = {F3D577DA-B949-4028-B52F-8BC6CA8A9647} - {C3116626-A482-42EF-AACC-1C1061504BD7} = {F3D577DA-B949-4028-B52F-8BC6CA8A9647} - EndGlobalSection -EndGlobal diff --git a/src/Idmt.slnx b/src/Idmt.slnx new file mode 100644 index 0000000..46ddcb0 --- /dev/null +++ b/src/Idmt.slnx @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/samples/Idmt.BasicSample/Idmt.BasicSample.csproj b/src/samples/Idmt.BasicSample/Idmt.BasicSample.csproj index c00c310..c0aa06c 100644 --- a/src/samples/Idmt.BasicSample/Idmt.BasicSample.csproj +++ b/src/samples/Idmt.BasicSample/Idmt.BasicSample.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/src/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs b/src/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs index 33b429a..792f6ec 100644 --- a/src/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs +++ b/src/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs @@ -35,51 +35,6 @@ public async Task Healthz_endpoint_allows_authenticated_user() #endregion - #region Get System Info Tests - - [Fact] - public async Task GetSystemInfo_returns_system_details() - { - var client = await CreateAuthenticatedClientAsync(); - - var response = await client.GetAsync("/admin/info"); - await response.AssertSuccess(); - - var sysInfo = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(sysInfo); - Assert.NotEmpty(sysInfo!.ApplicationName); - Assert.NotEmpty(sysInfo.Version); - Assert.NotEmpty(sysInfo.Environment); - Assert.True(sysInfo.ServerTime > DateTime.MinValue); - } - - [Fact] - public async Task GetSystemInfo_returns_current_tenant_info() - { - var client = await CreateAuthenticatedClientAsync(); - - var response = await client.GetAsync("/admin/info"); - await response.AssertSuccess(); - - var sysInfo = await response.Content.ReadFromJsonAsync(); - Assert.NotNull(sysInfo); - Assert.NotNull(sysInfo!.CurrentTenant); - var currentTenant = sysInfo.CurrentTenant!; - Assert.NotNull(currentTenant.Identifier); - Assert.NotNull(currentTenant.Name); - } - - [Fact] - public async Task GetSystemInfo_requires_authentication() - { - var client = Factory.CreateClientWithTenant(); - - var response = await client.GetAsync("/admin/info"); - Assert.Contains(response.StatusCode, new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden, HttpStatusCode.Found }); - } - - #endregion - #region Create Tenant Tests (Handler-based) [Fact] @@ -92,9 +47,8 @@ public async Task CreateTenant_handler_with_valid_data_succeeds() var request = new CreateTenant.CreateTenantRequest(tenantIdentifier, "Test Tenant", "Test Tenant Display"); var result = await handler.HandleAsync(request); - Assert.True(result.IsSuccess); - Assert.NotNull(result.Value); - Assert.Equal(tenantIdentifier, result.Value!.Identifier); + Assert.False(result.IsError); + Assert.Equal(tenantIdentifier, result.Value.Identifier); } [Fact] @@ -116,8 +70,8 @@ public async Task CreateTenant_handler_with_duplicate_identifier_reactivates() // Reactivate by creating again var reactivateResult = await handler.HandleAsync(request); - Assert.True(reactivateResult.IsSuccess); - Assert.Equal(tenantId, reactivateResult.Value!.Id); + Assert.False(reactivateResult.IsError); + Assert.Equal(tenantId, reactivateResult.Value.Id); } #endregion @@ -136,7 +90,7 @@ public async Task DeleteTenant_handler_with_valid_identifier_succeeds() await createHandler.HandleAsync(request); var deleted = await deleteHandler.HandleAsync(tenantIdentifier); - Assert.True(deleted.IsSuccess); + Assert.False(deleted.IsError); } [Fact] @@ -146,7 +100,7 @@ public async Task DeleteTenant_handler_with_invalid_identifier_returns_false() var deleteHandler = scope.ServiceProvider.GetRequiredService(); var deleted = await deleteHandler.HandleAsync($"nonexistent-{Guid.NewGuid():N}"); - Assert.False(deleted.IsSuccess); + Assert.True(deleted.IsError); } #endregion diff --git a/src/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs b/src/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs index 5326f78..842143b 100644 --- a/src/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs +++ b/src/tests/Idmt.BasicSample.Tests/AuthIntegrationTests.cs @@ -11,7 +11,7 @@ namespace Idmt.BasicSample.Tests; /// /// Integration tests for Authentication endpoints. -/// Covers: /auth/login, /auth/token, /auth/logout, /auth/refresh, /auth/confirmEmail, /auth/resendConfirmationEmail, /auth/forgotPassword, /auth/resetPassword +/// Covers: /auth/login, /auth/token, /auth/logout, /auth/refresh, /auth/confirm-email, /auth/resend-confirmation-email, /auth/forgot-password, /auth/reset-password /// public class AuthIntegrationTests : BaseIntegrationTest { @@ -277,7 +277,7 @@ public async Task ConfirmEmail_with_valid_token_succeeds() using var publicClient = Factory.CreateClient(); var resetResponse = await publicClient.PostAsJsonAsync( - QueryHelpers.AddQueryString("/auth/resetPassword", new Dictionary + QueryHelpers.AddQueryString("/auth/reset-password", new Dictionary { ["tenantIdentifier"] = IdmtApiFactory.DefaultTenantIdentifier, ["email"] = newEmail, @@ -298,7 +298,7 @@ public async Task ConfirmEmail_with_valid_token_succeeds() var tokens = await loginResponse.Content.ReadFromJsonAsync(); tenantClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens!.AccessToken); - var resendResponse = await tenantClient.PostAsJsonAsync($"/auth/resendConfirmationEmail?useApiLinks=true", new + var resendResponse = await tenantClient.PostAsJsonAsync($"/auth/resend-confirmation-email?useApiLinks=true", new { Email = newEmail }); @@ -317,7 +317,7 @@ public async Task ConfirmEmail_with_valid_token_succeeds() // // Now confirm email using the email confirmation token // var confirmResponse = await publicClient.GetAsync( - // $"/auth/confirmEmail?tenantIdentifier={IdmtApiFactory.DefaultTenantIdentifier}&email={newEmail}&token={Uri.EscapeDataString(confirmToken)}"); + // $"/auth/confirm-email?tenantIdentifier={IdmtApiFactory.DefaultTenantIdentifier}&email={newEmail}&token={Uri.EscapeDataString(confirmToken)}"); // await confirmResponse.AssertSuccess(); // var result = await confirmResponse.Content.ReadFromJsonAsync(); @@ -332,7 +332,7 @@ public async Task ConfirmEmail_with_invalid_token_fails() using var publicClient = Factory.CreateClient(); var confirmResponse = await publicClient.GetAsync( - $"/auth/confirmEmail?tenantIdentifier={IdmtApiFactory.DefaultTenantIdentifier}&email={newEmail}&token=invalid-token"); + $"/auth/confirm-email?tenantIdentifier={IdmtApiFactory.DefaultTenantIdentifier}&email={newEmail}&token=invalid-token"); Assert.False(confirmResponse.IsSuccessStatusCode); } @@ -344,7 +344,7 @@ public async Task ConfirmEmail_with_invalid_tenant_fails() using var publicClient = Factory.CreateClient(); var confirmResponse = await publicClient.GetAsync( - $"/auth/confirmEmail?tenantIdentifier=nonexistent-tenant&email={newEmail}&token=some-token"); + $"/auth/confirm-email?tenantIdentifier=nonexistent-tenant&email={newEmail}&token=some-token"); Assert.False(confirmResponse.IsSuccessStatusCode); } @@ -355,7 +355,7 @@ public async Task ConfirmEmail_with_missing_email_fails() using var publicClient = Factory.CreateClient(); var confirmResponse = await publicClient.GetAsync( - $"/auth/confirmEmail?tenantIdentifier={IdmtApiFactory.DefaultTenantIdentifier}&email=&token=some-token"); + $"/auth/confirm-email?tenantIdentifier={IdmtApiFactory.DefaultTenantIdentifier}&email=&token=some-token"); Assert.False(confirmResponse.IsSuccessStatusCode); } @@ -367,7 +367,7 @@ public async Task ConfirmEmail_with_missing_token_fails() using var publicClient = Factory.CreateClient(); var confirmResponse = await publicClient.GetAsync( - $"/auth/confirmEmail?tenantIdentifier={IdmtApiFactory.DefaultTenantIdentifier}&email={newEmail}&token="); + $"/auth/confirm-email?tenantIdentifier={IdmtApiFactory.DefaultTenantIdentifier}&email={newEmail}&token="); Assert.False(confirmResponse.IsSuccessStatusCode); } @@ -393,7 +393,7 @@ public async Task ResendConfirmationEmail_for_unconfirmed_user_succeeds() // Resend confirmation email using var publicClient = Factory.CreateClientWithTenant(); - var resendResponse = await publicClient.PostAsJsonAsync($"/auth/resendConfirmationEmail?useApiLinks=true", new + var resendResponse = await publicClient.PostAsJsonAsync($"/auth/resend-confirmation-email?useApiLinks=true", new { Email = newEmail }); @@ -423,7 +423,7 @@ public async Task ResendConfirmationEmail_sends_email() // Resend confirmation email using var publicClient = Factory.CreateClientWithTenant(); - var resendResponse = await publicClient.PostAsJsonAsync($"/auth/resendConfirmationEmail?useApiLinks=false", new + var resendResponse = await publicClient.PostAsJsonAsync($"/auth/resend-confirmation-email?useApiLinks=false", new { Email = newEmail }); @@ -442,7 +442,7 @@ public async Task ResendConfirmationEmail_with_invalid_email_returns_validation_ { using var publicClient = Factory.CreateClientWithTenant(); - var resendResponse = await publicClient.PostAsJsonAsync($"/auth/resendConfirmationEmail?useApiLinks=false", new + var resendResponse = await publicClient.PostAsJsonAsync($"/auth/resend-confirmation-email?useApiLinks=false", new { Email = "not-an-email" }); @@ -454,7 +454,7 @@ public async Task ResendConfirmationEmail_with_nonexistent_email_returns_validat { using var publicClient = Factory.CreateClientWithTenant(); - var resendResponse = await publicClient.PostAsJsonAsync($"/auth/resendConfirmationEmail?useApiLinks=false", new + var resendResponse = await publicClient.PostAsJsonAsync($"/auth/resend-confirmation-email?useApiLinks=false", new { Email = "nonexistent@example.com" }); @@ -485,7 +485,7 @@ public async Task ForgotPassword_generates_reset_token() using var publicClient = Factory.CreateClient(); var setupToken = (await registerResponse.Content.ReadFromJsonAsync())!.PasswordSetupToken; await publicClient.PostAsJsonAsync( - QueryHelpers.AddQueryString("/auth/resetPassword", new Dictionary + QueryHelpers.AddQueryString("/auth/reset-password", new Dictionary { ["tenantIdentifier"] = IdmtApiFactory.DefaultTenantIdentifier, ["email"] = email, @@ -497,7 +497,7 @@ await publicClient.PostAsJsonAsync( // Request forgot password using var tenantClient = Factory.CreateClientWithTenant(); - var forgotResponse = await tenantClient.PostAsJsonAsync("/auth/forgotPassword?useApiLinks=false", new { Email = email }); + var forgotResponse = await tenantClient.PostAsJsonAsync("/auth/forgot-password?useApiLinks=false", new { Email = email }); await forgotResponse.AssertSuccess(); // Verify email was sent @@ -513,7 +513,7 @@ public async Task ForgotPassword_with_invalid_email_returns_validation_error() { using var client = Factory.CreateClientWithTenant(); - var response = await client.PostAsJsonAsync("/auth/forgotPassword?useApiLinks=false", new { Email = "invalid-email" }); + var response = await client.PostAsJsonAsync("/auth/forgot-password?useApiLinks=false", new { Email = "invalid-email" }); Assert.False(response.IsSuccessStatusCode); } @@ -522,7 +522,7 @@ public async Task ForgotPassword_with_nonexistent_email_succeeds_silently() { using var client = Factory.CreateClientWithTenant(); - var response = await client.PostAsJsonAsync("/auth/forgotPassword?useApiLinks=false", new { Email = "nonexistent@example.com" }); + var response = await client.PostAsJsonAsync("/auth/forgot-password?useApiLinks=false", new { Email = "nonexistent@example.com" }); // Should succeed for security (don't leak user existence) Assert.True(response.IsSuccessStatusCode); } @@ -549,7 +549,7 @@ public async Task ResetPassword_with_valid_token_succeeds() // Reset password with token using var publicClient = Factory.CreateClient(); var resetResponse = await publicClient.PostAsJsonAsync( - QueryHelpers.AddQueryString("/auth/resetPassword", new Dictionary + QueryHelpers.AddQueryString("/auth/reset-password", new Dictionary { ["tenantIdentifier"] = IdmtApiFactory.DefaultTenantIdentifier, ["email"] = email, @@ -579,7 +579,7 @@ public async Task ResetPassword_with_new_password_allows_login() using var publicClient = Factory.CreateClient(); const string newPassword = "NewPassword1!"; await publicClient.PostAsJsonAsync( - QueryHelpers.AddQueryString("/auth/resetPassword", new Dictionary + QueryHelpers.AddQueryString("/auth/reset-password", new Dictionary { ["tenantIdentifier"] = IdmtApiFactory.DefaultTenantIdentifier, ["email"] = email, @@ -605,7 +605,7 @@ public async Task ResetPassword_with_invalid_token_fails() using var publicClient = Factory.CreateClient(); var resetResponse = await publicClient.PostAsJsonAsync( - QueryHelpers.AddQueryString("/auth/resetPassword", new Dictionary + QueryHelpers.AddQueryString("/auth/reset-password", new Dictionary { ["tenantIdentifier"] = IdmtApiFactory.DefaultTenantIdentifier, ["email"] = email, @@ -634,7 +634,7 @@ public async Task ResetPassword_with_weak_password_fails() // Try reset with weak password using var publicClient = Factory.CreateClient(); var resetResponse = await publicClient.PostAsJsonAsync( - QueryHelpers.AddQueryString("/auth/resetPassword", new Dictionary + QueryHelpers.AddQueryString("/auth/reset-password", new Dictionary { ["tenantIdentifier"] = IdmtApiFactory.DefaultTenantIdentifier, ["email"] = email, diff --git a/src/tests/Idmt.BasicSample.Tests/Idmt.BasicSample.Tests.csproj b/src/tests/Idmt.BasicSample.Tests/Idmt.BasicSample.Tests.csproj index 78571bb..a526a95 100644 --- a/src/tests/Idmt.BasicSample.Tests/Idmt.BasicSample.Tests.csproj +++ b/src/tests/Idmt.BasicSample.Tests/Idmt.BasicSample.Tests.csproj @@ -8,10 +8,10 @@ - - - - + + + + diff --git a/src/tests/Idmt.BasicSample.Tests/ManageIntegrationTests.cs b/src/tests/Idmt.BasicSample.Tests/ManageIntegrationTests.cs index 5f082b8..75578bd 100644 --- a/src/tests/Idmt.BasicSample.Tests/ManageIntegrationTests.cs +++ b/src/tests/Idmt.BasicSample.Tests/ManageIntegrationTests.cs @@ -219,7 +219,7 @@ public async Task UnregisterUser_prevents_login_after_deletion() // Set password using var publicClient = Factory.CreateClient(); await publicClient.PostAsJsonAsync( - QueryHelpers.AddQueryString("/auth/resetPassword", new Dictionary + QueryHelpers.AddQueryString("/auth/reset-password", new Dictionary { ["tenantIdentifier"] = IdmtApiFactory.DefaultTenantIdentifier, ["email"] = email, @@ -317,7 +317,7 @@ public async Task UpdateUser_prevents_login_when_deactivated() // Set password using var publicClient = Factory.CreateClient(); await publicClient.PostAsJsonAsync( - QueryHelpers.AddQueryString("/auth/resetPassword", new Dictionary + QueryHelpers.AddQueryString("/auth/reset-password", new Dictionary { ["tenantIdentifier"] = IdmtApiFactory.DefaultTenantIdentifier, ["email"] = email, @@ -390,7 +390,7 @@ public async Task GetUserInfo_returns_correct_role() // Set password and login using var publicClient = Factory.CreateClient(); await publicClient.PostAsJsonAsync( - QueryHelpers.AddQueryString("/auth/resetPassword", new Dictionary + QueryHelpers.AddQueryString("/auth/reset-password", new Dictionary { ["tenantIdentifier"] = IdmtApiFactory.DefaultTenantIdentifier, ["email"] = email, @@ -439,7 +439,7 @@ public async Task UpdateUserInfo_change_password_succeeds() using var publicClient = Factory.CreateClient(); await publicClient.PostAsJsonAsync( - QueryHelpers.AddQueryString("/auth/resetPassword", new Dictionary + QueryHelpers.AddQueryString("/auth/reset-password", new Dictionary { ["tenantIdentifier"] = IdmtApiFactory.DefaultTenantIdentifier, ["email"] = email, @@ -487,7 +487,7 @@ public async Task UpdateUserInfo_change_password_requires_old_password() using var publicClient = Factory.CreateClient(); await publicClient.PostAsJsonAsync( - QueryHelpers.AddQueryString("/auth/resetPassword", new Dictionary + QueryHelpers.AddQueryString("/auth/reset-password", new Dictionary { ["tenantIdentifier"] = IdmtApiFactory.DefaultTenantIdentifier, ["email"] = email, @@ -529,7 +529,7 @@ public async Task UpdateUserInfo_change_username_succeeds() using var publicClient = Factory.CreateClient(); await publicClient.PostAsJsonAsync( - QueryHelpers.AddQueryString("/auth/resetPassword", new Dictionary + QueryHelpers.AddQueryString("/auth/reset-password", new Dictionary { ["tenantIdentifier"] = IdmtApiFactory.DefaultTenantIdentifier, ["email"] = email, diff --git a/src/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs b/src/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs index d51e49b..593ca3f 100644 --- a/src/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs +++ b/src/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs @@ -186,28 +186,28 @@ public async Task User_can_only_see_their_own_tenant_info() } [Fact] - public async Task User_in_other_tenant_cannot_see_system_info_for_current_tenant() + public async Task User_in_other_tenant_cannot_access_protected_endpoint_for_current_tenant() { await EnsureTenantsExistAsync(); // Create user in Tenant A - var emailA = $"sysinfo-{Guid.NewGuid():N}@example.com"; + var emailA = $"crosstoken-{Guid.NewGuid():N}@example.com"; var passwordA = "PasswordA1!"; await CreateUserInTenantAsync(TenantA, emailA, passwordA, IdmtDefaultRoleTypes.SysSupport); - // Get system info for Tenant A + // Login as Tenant A user var clientA = Factory.CreateClientWithTenant(TenantA); var loginA = await clientA.PostAsJsonAsync("/auth/token", new { Email = emailA, Password = passwordA }); var tokens = await loginA.Content.ReadFromJsonAsync(); clientA.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens!.AccessToken); - var infoResponseA = await clientA.GetAsync("/admin/info"); - var infoA = await infoResponseA.Content.ReadFromJsonAsync(); + var infoResponseA = await clientA.GetAsync("/manage/info"); + Assert.True(infoResponseA.IsSuccessStatusCode); // Try to access Tenant B with Tenant A token var clientB = Factory.CreateClientWithTenant(TenantB); clientB.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); - var infoResponseB = await clientB.GetAsync("/admin/info"); + var infoResponseB = await clientB.GetAsync("/manage/info"); Assert.Contains(infoResponseB.StatusCode, new[] { HttpStatusCode.NotFound, HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden }); } @@ -309,7 +309,7 @@ public async Task Complete_user_lifecycle_flow_across_tenants() const string setupPassword = "SetupPassword1!"; using var publicClient = Factory.CreateClient(); var resetResponse = await publicClient.PostAsJsonAsync( - $"/auth/resetPassword?tenantIdentifier={TenantA}&email={emailA}&token={Uri.EscapeDataString(registerResult!.PasswordSetupToken!)}", + $"/auth/reset-password?tenantIdentifier={TenantA}&email={emailA}&token={Uri.EscapeDataString(registerResult!.PasswordSetupToken!)}", new { NewPassword = setupPassword }); await resetResponse.AssertSuccess(); diff --git a/src/tests/Idmt.UnitTests/Idmt.UnitTests.csproj b/src/tests/Idmt.UnitTests/Idmt.UnitTests.csproj index 0b08e5e..739b80f 100644 --- a/src/tests/Idmt.UnitTests/Idmt.UnitTests.csproj +++ b/src/tests/Idmt.UnitTests/Idmt.UnitTests.csproj @@ -8,9 +8,9 @@ - - - + + + diff --git a/src/tests/Idmt.UnitTests/Services/CoreServicesTests.cs b/src/tests/Idmt.UnitTests/Services/CoreServicesTests.cs index a4bec0f..70c9172 100644 --- a/src/tests/Idmt.UnitTests/Services/CoreServicesTests.cs +++ b/src/tests/Idmt.UnitTests/Services/CoreServicesTests.cs @@ -1,12 +1,10 @@ using Finbuckle.MultiTenant.Abstractions; using Idmt.Plugin.Configuration; using Idmt.Plugin.Models; -using Idmt.Plugin.Persistence; using Idmt.Plugin.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.WebUtilities; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; @@ -51,7 +49,7 @@ public void UserId_ReturnsCurrentUserId_WhenUserExists() } [Fact] - public void UserId_ReturnsEmptyGuid_WhenUserDoesNotExist() + public void UserId_ReturnsNull_WhenUserDoesNotExist() { var user = new System.Security.Claims.ClaimsPrincipal( new System.Security.Claims.ClaimsIdentity()); @@ -60,8 +58,7 @@ public void UserId_ReturnsEmptyGuid_WhenUserDoesNotExist() var result = _service.UserId; - Assert.NotNull(result); - Assert.Equal(Guid.Empty, result); + Assert.Null(result); } [Fact] @@ -242,170 +239,6 @@ public void IsActive_ReturnsFalse_WhenUserNotSet() } } -/// -/// Extended unit tests for TenantAccessService covering additional scenarios. -/// -public class TenantAccessServiceExtendedTests -{ - private readonly Mock _tenantAccessorMock; - private readonly Mock _currentUserServiceMock; - private readonly IdmtDbContext _dbContext; - private readonly TenantAccessService _service; - - public TenantAccessServiceExtendedTests() - { - _tenantAccessorMock = new Mock(); - _currentUserServiceMock = new Mock(); - - var dummyTenant = new IdmtTenantInfo("system-test-tenant", "system-test", "System Test Tenant"); - var dummyContext = new MultiTenantContext(dummyTenant); - _tenantAccessorMock.SetupGet(x => x.MultiTenantContext).Returns(dummyContext); - - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) - .Options; - - _dbContext = new IdmtDbContext( - _tenantAccessorMock.Object, - options, - _currentUserServiceMock.Object); - - _service = new TenantAccessService(_dbContext, _currentUserServiceMock.Object); - } - - [Fact] - public async Task CanAccessTenantAsync_ReturnsTrue_WhenAccessExistsAndIsActive() - { - var userId = Guid.NewGuid(); - var tenantId = "tenant1"; - - _dbContext.TenantAccess.Add( - new TenantAccess { UserId = userId, TenantId = tenantId, IsActive = true } - ); - await _dbContext.SaveChangesAsync(); - - var result = await _service.CanAccessTenantAsync(userId, tenantId); - - Assert.True(result); - } - - [Fact] - public async Task CanAccessTenantAsync_ReturnsFalse_WhenAccessIsInactive() - { - var userId = Guid.NewGuid(); - var tenantId = "tenant1"; - - _dbContext.TenantAccess.Add( - new TenantAccess { UserId = userId, TenantId = tenantId, IsActive = false } - ); - await _dbContext.SaveChangesAsync(); - - var result = await _service.CanAccessTenantAsync(userId, tenantId); - - Assert.False(result); - } - - [Fact] - public async Task CanAccessTenantAsync_ReturnsFalse_WhenAccessExpired() - { - var userId = Guid.NewGuid(); - var tenantId = "tenant1"; - - _dbContext.TenantAccess.Add( - new TenantAccess { UserId = userId, TenantId = tenantId, IsActive = true, ExpiresAt = DateTime.UtcNow.AddDays(-1) } - ); - await _dbContext.SaveChangesAsync(); - - var result = await _service.CanAccessTenantAsync(userId, tenantId); - - Assert.False(result); - } - - [Fact] - public async Task CanAccessTenantAsync_ReturnsTrue_WhenAccessExpiringInFuture() - { - var userId = Guid.NewGuid(); - var tenantId = "tenant1"; - - _dbContext.TenantAccess.Add( - new TenantAccess { UserId = userId, TenantId = tenantId, IsActive = true, ExpiresAt = DateTime.UtcNow.AddDays(1) } - ); - await _dbContext.SaveChangesAsync(); - - var result = await _service.CanAccessTenantAsync(userId, tenantId); - - Assert.True(result); - } - - [Fact] - public async Task CanAccessTenantAsync_ReturnsFalse_WhenNoAccessRecord() - { - var userId = Guid.NewGuid(); - var tenantId = "tenant1"; - - var result = await _service.CanAccessTenantAsync(userId, tenantId); - - Assert.False(result); - } - - [Theory] - [InlineData(IdmtDefaultRoleTypes.SysSupport, IdmtDefaultRoleTypes.SysAdmin, false)] - [InlineData(IdmtDefaultRoleTypes.SysSupport, IdmtDefaultRoleTypes.TenantAdmin, true)] - [InlineData(IdmtDefaultRoleTypes.TenantAdmin, IdmtDefaultRoleTypes.SysAdmin, false)] - [InlineData(IdmtDefaultRoleTypes.TenantAdmin, IdmtDefaultRoleTypes.SysSupport, false)] - [InlineData(IdmtDefaultRoleTypes.TenantAdmin, "TenantUser", true)] - [InlineData("TenantUser", IdmtDefaultRoleTypes.SysAdmin, false)] - public void CanAssignRole_ValidatesRoleHierarchy(string currentUserRole, string targetRole, bool expected) - { - _currentUserServiceMock.Reset(); - _currentUserServiceMock.Setup(x => x.IsInRole(currentUserRole)).Returns(true); - - var result = _service.CanAssignRole(targetRole); - - Assert.Equal(expected, result); - } - - [Fact] - public void CanManageUser_ReturnsFalse_WhenSysSupportManagesSysAdmin() - { - _currentUserServiceMock.Setup(x => x.IsInRole(IdmtDefaultRoleTypes.SysSupport)).Returns(true); - - var result = _service.CanManageUser([IdmtDefaultRoleTypes.SysAdmin]); - - Assert.False(result); - } - - [Fact] - public void CanManageUser_ReturnsTrue_WhenSysSupportManagesTenantAdmin() - { - _currentUserServiceMock.Setup(x => x.IsInRole(IdmtDefaultRoleTypes.SysSupport)).Returns(true); - - var result = _service.CanManageUser([IdmtDefaultRoleTypes.TenantAdmin]); - - Assert.True(result); - } - - [Fact] - public void CanManageUser_ReturnsFalse_WhenTenantAdminManagesSysUser() - { - _currentUserServiceMock.Setup(x => x.IsInRole(IdmtDefaultRoleTypes.TenantAdmin)).Returns(true); - - var result = _service.CanManageUser([IdmtDefaultRoleTypes.SysSupport]); - - Assert.False(result); - } - - [Fact] - public void CanManageUser_ReturnsTrue_WhenTenantAdminManagesTenantUser() - { - _currentUserServiceMock.Setup(x => x.IsInRole(IdmtDefaultRoleTypes.TenantAdmin)).Returns(true); - - var result = _service.CanManageUser(["CustomRole"]); - - Assert.True(result); - } -} - /// /// Extended unit tests for IdmtLinkGenerator covering additional scenarios. /// diff --git a/src/tests/Idmt.UnitTests/Services/IdmtUserClaimsPrincipalFactoryTests.cs b/src/tests/Idmt.UnitTests/Services/IdmtUserClaimsPrincipalFactoryTests.cs index 89376e8..de2b931 100644 --- a/src/tests/Idmt.UnitTests/Services/IdmtUserClaimsPrincipalFactoryTests.cs +++ b/src/tests/Idmt.UnitTests/Services/IdmtUserClaimsPrincipalFactoryTests.cs @@ -75,7 +75,8 @@ public IdmtUserClaimsPrincipalFactoryTests() _roleManagerMock.Object, _identityOptionsMock.Object, _tenantStoreMock.Object, - _idmtOptionsMock.Object); + _idmtOptionsMock.Object, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } private async Task CallGenerateClaimsAsync(IdmtUser user) @@ -213,7 +214,8 @@ public async Task CreateAsync_AddsTenantClaim_WithCustomClaimType() _roleManagerMock.Object, _identityOptionsMock.Object, customTenantStoreMock.Object, - customOptionsMock.Object); + customOptionsMock.Object, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); var user = new IdmtUser { diff --git a/src/tests/Idmt.UnitTests/Services/TenantAccessServiceTests.cs b/src/tests/Idmt.UnitTests/Services/TenantAccessServiceTests.cs index 448b0e5..61ecc62 100644 --- a/src/tests/Idmt.UnitTests/Services/TenantAccessServiceTests.cs +++ b/src/tests/Idmt.UnitTests/Services/TenantAccessServiceTests.cs @@ -31,11 +31,13 @@ public TenantAccessServiceTests() _dbContext = new IdmtDbContext( _tenantAccessorMock.Object, options, - _currentUserServiceMock.Object); + _currentUserServiceMock.Object, + TimeProvider.System); _service = new TenantAccessService( _dbContext, - _currentUserServiceMock.Object); + _currentUserServiceMock.Object, + TimeProvider.System); } [Fact] @@ -135,4 +137,51 @@ public void CanManageUser_ReturnsTrue_WhenSysSupportManagesTenantAdmin() Assert.True(result); } + + [Fact] + public async Task CanAccessTenantAsync_ReturnsTrue_WhenAccessExpiringInFuture() + { + var userId = Guid.NewGuid(); + var tenantId = "tenant1"; + + _dbContext.TenantAccess.Add( + new TenantAccess { UserId = userId, TenantId = tenantId, IsActive = true, ExpiresAt = DateTime.UtcNow.AddDays(1) } + ); + await _dbContext.SaveChangesAsync(); + + var result = await _service.CanAccessTenantAsync(userId, tenantId); + + Assert.True(result); + } + + [Fact] + public async Task CanAccessTenantAsync_ReturnsFalse_WhenNoAccessRecord() + { + var userId = Guid.NewGuid(); + var tenantId = "tenant1"; + + var result = await _service.CanAccessTenantAsync(userId, tenantId); + + Assert.False(result); + } + + [Fact] + public void CanManageUser_ReturnsFalse_WhenTenantAdminManagesSysUser() + { + _currentUserServiceMock.Setup(x => x.IsInRole(IdmtDefaultRoleTypes.TenantAdmin)).Returns(true); + + var result = _service.CanManageUser([IdmtDefaultRoleTypes.SysSupport]); + + Assert.False(result); + } + + [Fact] + public void CanManageUser_ReturnsTrue_WhenTenantAdminManagesTenantUser() + { + _currentUserServiceMock.Setup(x => x.IsInRole(IdmtDefaultRoleTypes.TenantAdmin)).Returns(true); + + var result = _service.CanManageUser(["CustomRole"]); + + Assert.True(result); + } } diff --git a/src/tests/Idmt.UnitTests/Validation/ValidatorsTests.cs b/src/tests/Idmt.UnitTests/Validation/ValidatorsTests.cs index 0aa75d9..9e3c363 100644 --- a/src/tests/Idmt.UnitTests/Validation/ValidatorsTests.cs +++ b/src/tests/Idmt.UnitTests/Validation/ValidatorsTests.cs @@ -21,7 +21,7 @@ public void IsValidEmail_ValidatesCorrectly(string? email, bool expected) [Fact] public void IsValidNewPassword_ReturnsFalse_WhenPasswordIsEmpty() { - var options = new PasswordOptions(); + var options = new IdmtPasswordOptions(); var result = Validators.IsValidNewPassword("", options, out var errors); Assert.False(result); @@ -32,7 +32,7 @@ public void IsValidNewPassword_ReturnsFalse_WhenPasswordIsEmpty() [Fact] public void IsValidNewPassword_ValidatesLength() { - var options = new PasswordOptions { RequiredLength = 8 }; + var options = new IdmtPasswordOptions { RequiredLength = 8 }; var result = Validators.IsValidNewPassword("short", options, out var errors); Assert.False(result); @@ -43,7 +43,7 @@ public void IsValidNewPassword_ValidatesLength() [Fact] public void IsValidNewPassword_ValidatesDigit() { - var options = new PasswordOptions { RequireDigit = true }; + var options = new IdmtPasswordOptions { RequireDigit = true }; var result = Validators.IsValidNewPassword("NoDigit", options, out var errors); Assert.False(result); @@ -54,7 +54,7 @@ public void IsValidNewPassword_ValidatesDigit() [Fact] public void IsValidNewPassword_ValidatesLowercase() { - var options = new PasswordOptions { RequireLowercase = true }; + var options = new IdmtPasswordOptions { RequireLowercase = true }; var result = Validators.IsValidNewPassword("NOLOWERCASE1", options, out var errors); Assert.False(result); @@ -65,7 +65,7 @@ public void IsValidNewPassword_ValidatesLowercase() [Fact] public void IsValidNewPassword_ValidatesUppercase() { - var options = new PasswordOptions { RequireUppercase = true }; + var options = new IdmtPasswordOptions { RequireUppercase = true }; var result = Validators.IsValidNewPassword("nouppercase1", options, out var errors); Assert.False(result); @@ -76,7 +76,7 @@ public void IsValidNewPassword_ValidatesUppercase() [Fact] public void IsValidNewPassword_ValidatesNonAlphanumeric() { - var options = new PasswordOptions { RequireNonAlphanumeric = true }; + var options = new IdmtPasswordOptions { RequireNonAlphanumeric = true }; var result = Validators.IsValidNewPassword("NoSpecialChar1", options, out var errors); Assert.False(result); @@ -87,7 +87,7 @@ public void IsValidNewPassword_ValidatesNonAlphanumeric() [Fact] public void IsValidNewPassword_ValidatesUniqueChars() { - var options = new PasswordOptions { RequiredUniqueChars = 4 }; + var options = new IdmtPasswordOptions { RequiredUniqueChars = 4 }; var result = Validators.IsValidNewPassword("aaaa1", options, out var errors); Assert.False(result); @@ -98,7 +98,7 @@ public void IsValidNewPassword_ValidatesUniqueChars() [Fact] public void IsValidNewPassword_ReturnsTrue_WhenAllRequirementsMet() { - var options = new PasswordOptions + var options = new IdmtPasswordOptions { RequiredLength = 6, RequireDigit = true, @@ -114,28 +114,6 @@ public void IsValidNewPassword_ReturnsTrue_WhenAllRequirementsMet() Assert.Null(errors); } - [Theory] - [InlineData("d81e3678-00a8-444f-a715-171804791e84", true)] - [InlineData("invalid-guid", false)] - [InlineData("", false)] - [InlineData(null, false)] - public void IsValidGuid_ValidatesCorrectly(string? guid, bool expected) - { - var result = Validators.IsValidGuid(guid); - Assert.Equal(expected, result); - } - - [Theory] - [InlineData("tenant1", true)] - [InlineData("te", false)] // Length < 3 - [InlineData("", false)] - [InlineData(null, false)] - public void IsValidTenantId_ValidatesCorrectly(string? tenantId, bool expected) - { - var result = Validators.IsValidTenantId(tenantId); - Assert.Equal(expected, result); - } - [Theory] [InlineData("user1", true)] [InlineData("us", false)] // Length < 3 From 31ca955c4adbf5a4edd4de551ddb88e612c1d43a Mon Sep 17 00:00:00 2001 From: iuri dotta Date: Wed, 4 Mar 2026 18:06:39 -0300 Subject: [PATCH 02/12] feat(auth): require authorization for logout endpoint feat(auth): enhance refresh token handling with tenant validation fix(auth): add logging for password reset errors refactor(health): simplify health check by removing tenant user count feat(manage): update GetUserInfo to return detailed errors refactor(manage): streamline user registration process fix(manage): ensure user info updates only when changes occur fix(middleware): improve error handling in ValidateBearerTokenTenantMiddleware fix(persistence): use enum for audit actions in IdmtDbContext chore(tests): add unit tests for tenant operation service chore(tests): implement fluent validation tests for various requests chore(tests): update integration tests for tenant management --- src/Idmt.Plugin/Configuration/IdmtOptions.cs | 2 +- src/Idmt.Plugin/Errors/ErrorOrExtensions.cs | 23 -- src/Idmt.Plugin/Errors/IdmtErrors.cs | 14 ++ .../ApplicationBuilderExtensions.cs | 53 ++--- .../Extensions/ServiceCollectionExtensions.cs | 16 +- .../Features/Admin/CreateTenant.cs | 24 +- .../Features/Admin/GetAllTenants.cs | 1 - .../Features/Admin/GetUserTenants.cs | 36 ++- .../Features/Admin/GrantTenantAccess.cs | 101 ++++---- .../Features/Admin/RevokeTenantAccess.cs | 13 +- src/Idmt.Plugin/Features/AdminEndpoints.cs | 2 + src/Idmt.Plugin/Features/Auth/ConfirmEmail.cs | 9 +- .../Features/Auth/ForgotPassword.cs | 8 +- src/Idmt.Plugin/Features/Auth/Login.cs | 116 +++++++--- src/Idmt.Plugin/Features/Auth/Logout.cs | 1 + src/Idmt.Plugin/Features/Auth/RefreshToken.cs | 22 +- .../Features/Auth/ResetPassword.cs | 9 +- .../Features/Health/BasicHealthCheck.cs | 20 +- .../Features/Manage/GetUserInfo.cs | 37 +-- .../Features/Manage/RegisterUser.cs | 12 +- .../Features/Manage/UpdateUserInfo.cs | 25 +- .../Middleware/CurrentUserMiddleware.cs | 3 +- .../ValidateBearerTokenTenantMiddleware.cs | 1 + src/Idmt.Plugin/Persistence/IdmtDbContext.cs | 7 +- .../Persistence/IdmtTenantStoreDbContext.cs | 4 +- .../IdmtUserClaimsPrincipalFactory.cs | 2 +- .../CreateTenantRequestValidator.cs | 3 - .../UpdateUserInfoRequestValidator.cs | 6 +- .../AdminIntegrationTests.cs | 47 +++- .../MultiTenancyIntegrationTests.cs | 4 +- ...alidateBearerTokenTenantMiddlewareTests.cs | 161 +++++++++++++ .../Services/IdmtLinkGeneratorTests.cs | 6 - .../Services/TenantAccessServiceTests.cs | 12 + .../Services/TenantOperationServiceTests.cs | 90 ++++++++ .../Validation/FluentValidatorTests.cs | 217 ++++++++++++++++++ 35 files changed, 848 insertions(+), 259 deletions(-) delete mode 100644 src/Idmt.Plugin/Errors/ErrorOrExtensions.cs create mode 100644 src/tests/Idmt.UnitTests/Middleware/ValidateBearerTokenTenantMiddlewareTests.cs create mode 100644 src/tests/Idmt.UnitTests/Services/TenantOperationServiceTests.cs create mode 100644 src/tests/Idmt.UnitTests/Validation/FluentValidatorTests.cs diff --git a/src/Idmt.Plugin/Configuration/IdmtOptions.cs b/src/Idmt.Plugin/Configuration/IdmtOptions.cs index 1746ac6..9abc6ad 100644 --- a/src/Idmt.Plugin/Configuration/IdmtOptions.cs +++ b/src/Idmt.Plugin/Configuration/IdmtOptions.cs @@ -191,7 +191,7 @@ public class MultiTenantOptions /// public const string DefaultTenantIdentifier = "system-tenant"; - public string DefaultTenantDisplayName { get; set; } = "System Tenant"; + public string DefaultTenantName { get; set; } = "System Tenant"; /// /// Tenant resolution strategy (header, subdomain, etc.) diff --git a/src/Idmt.Plugin/Errors/ErrorOrExtensions.cs b/src/Idmt.Plugin/Errors/ErrorOrExtensions.cs deleted file mode 100644 index 08e8c1c..0000000 --- a/src/Idmt.Plugin/Errors/ErrorOrExtensions.cs +++ /dev/null @@ -1,23 +0,0 @@ -using ErrorOr; -using Microsoft.AspNetCore.Http; - -namespace Idmt.Plugin.Errors; - -public static class ErrorOrExtensions -{ - public static IResult ToHttpResult(this List errors) - { - if (errors.Count == 0) - return TypedResults.InternalServerError(); - - var firstError = errors[0]; - return firstError.Type switch - { - ErrorType.NotFound => TypedResults.NotFound(), - ErrorType.Validation => TypedResults.BadRequest(), - ErrorType.Unauthorized => TypedResults.Unauthorized(), - ErrorType.Forbidden => TypedResults.Forbid(), - _ => TypedResults.InternalServerError(), - }; - } -} diff --git a/src/Idmt.Plugin/Errors/IdmtErrors.cs b/src/Idmt.Plugin/Errors/IdmtErrors.cs index 4326dae..faaa4a5 100644 --- a/src/Idmt.Plugin/Errors/IdmtErrors.cs +++ b/src/Idmt.Plugin/Errors/IdmtErrors.cs @@ -18,9 +18,19 @@ public static class Auth code: "Auth.UserDeactivated", description: "User is deactivated"); + public static Error TwoFactorRequired => Error.Custom( + type: 42, + code: "Auth.TwoFactorRequired", + description: "Two-factor authentication is required"); + public static Error InvalidCredentials => Error.Unauthorized( code: "Auth.InvalidCredentials", description: "Invalid credentials"); + + public static Error LockedOut => Error.Custom( + type: 43, + code: "Auth.LockedOut", + description: "Account is locked out due to too many failed attempts"); } public static class Tenant @@ -60,6 +70,10 @@ public static class Tenant public static Error AccessError => Error.Failure( code: "Tenant.AccessError", description: "An error occurred while managing tenant access"); + + public static Error AccessNotFound => Error.NotFound( + code: "Tenant.AccessNotFound", + description: "No tenant access record found for this user"); } public static class User diff --git a/src/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs b/src/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs index af49a74..0b8f340 100644 --- a/src/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs +++ b/src/Idmt.Plugin/Extensions/ApplicationBuilderExtensions.cs @@ -88,35 +88,28 @@ public static async Task EnsureIdmtDatabaseAsync(this IApplicationBuilder app, b var options = services.GetRequiredService>(); var context = services.GetRequiredService(); - try - { - var shouldMigrate = autoMigrate || options.Value.Database.AutoMigrate; + var shouldMigrate = autoMigrate || options.Value.Database.AutoMigrate; - if (shouldMigrate) + if (shouldMigrate) + { + // Try to migrate, fall back to EnsureCreated if migrations not supported + try { - // Try to migrate, fall back to EnsureCreated if migrations not supported - try - { - await context.Database.MigrateAsync(); - } - catch (InvalidOperationException) - { - // Migrations not supported (e.g., in-memory database) - await context.Database.EnsureCreatedAsync(); - } + await context.Database.MigrateAsync(); } - else + catch (InvalidOperationException) { + // Migrations not supported (e.g., in-memory database) await context.Database.EnsureCreatedAsync(); } - - // NOTE: IdmtTenantStoreDbContext shares the same database/connection - // No separate initialization needed - it accesses tables created above } - catch + else { - throw; + await context.Database.EnsureCreatedAsync(); } + + // NOTE: IdmtTenantStoreDbContext shares the same database/connection + // No separate initialization needed - it accesses tables created above } /// @@ -129,20 +122,13 @@ public static async Task SeedIdmtDataAsync(this IApplicatio { using var scope = app.ApplicationServices.CreateScope(); var services = scope.ServiceProvider; - try - { - // Run default seeding - await SeedDefaultDataAsync(services); + // Run default seeding + await SeedDefaultDataAsync(services); - // Run custom seeding if provided - if (seedAction != null) - { - await seedAction(services); - } - } - catch + // Run custom seeding if provided + if (seedAction != null) { - throw; + await seedAction(services); } return app; @@ -154,8 +140,7 @@ private static async Task SeedDefaultDataAsync(IServiceProvider services) var createTenantHandler = services.GetRequiredService(); await createTenantHandler.HandleAsync(new CreateTenant.CreateTenantRequest( MultiTenantOptions.DefaultTenantIdentifier, - MultiTenantOptions.DefaultTenantIdentifier, - options.Value.MultiTenant.DefaultTenantDisplayName)); + options.Value.MultiTenant.DefaultTenantName)); } private static void VerifyUserStoreSupportsEmail(IApplicationBuilder app) diff --git a/src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs b/src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs index e74332f..ec3e825 100644 --- a/src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs +++ b/src/Idmt.Plugin/Extensions/ServiceCollectionExtensions.cs @@ -55,27 +55,25 @@ public static IServiceCollection AddIdmt( // 2. Configure Database Contexts ConfigureDatabase(services, configureDb, idmtOptions); - // 3. Configure MultiTenant - // ConfigureMultiTenant(services, idmtOptions); - - // 4. Configure Identity + // 3. Configure Identity ConfigureIdentity(services, idmtOptions); - // 5. Configure Authentication + // 4. Configure Authentication ConfigureAuthentication(services, idmtOptions, customizeAuthentication); - // 6. Configure Authorization + // 5. Configure Authorization ConfigureAuthorization(services, customizeAuthorization); + // 6. Configure MultiTenant ConfigureMultiTenant(services, idmtOptions); - // 6. Register Application Services + // 7. Register Application Services RegisterApplicationServices(services); - // 7. Register Feature Handlers + // 8. Register Feature Handlers RegisterFeatures(services); - // 8. Register Middleware + // 9. Register Middleware RegisterMiddleware(services); return services; diff --git a/src/Idmt.Plugin/Features/Admin/CreateTenant.cs b/src/Idmt.Plugin/Features/Admin/CreateTenant.cs index 9887dd3..61ab4be 100644 --- a/src/Idmt.Plugin/Features/Admin/CreateTenant.cs +++ b/src/Idmt.Plugin/Features/Admin/CreateTenant.cs @@ -22,15 +22,13 @@ public static class CreateTenant { public sealed record CreateTenantRequest( string Identifier, - string Name, - string DisplayName + string Name ); public sealed record CreateTenantResponse( string Id, string Identifier, - string Name, - string DisplayName + string Name ); public interface ICreateTenantHandler @@ -65,10 +63,7 @@ public async Task> HandleAsync(CreateTenantRequest } else { - var tenant = new IdmtTenantInfo(request.Identifier, request.Name) - { - Name = request.DisplayName - }; + var tenant = new IdmtTenantInfo(request.Identifier, request.Name); if (!await tenantStore.AddAsync(tenant)) { @@ -94,12 +89,12 @@ public async Task> HandleAsync(CreateTenantRequest catch (Exception ex) { logger.LogError(ex, "Error seeding roles for tenant {Identifier}", request.Identifier); + return IdmtErrors.Tenant.RoleSeedFailed; } return new CreateTenantResponse( resultTenant.Id ?? string.Empty, resultTenant.Identifier ?? string.Empty, - resultTenant.Name ?? string.Empty, resultTenant.Name ?? string.Empty); } @@ -134,7 +129,7 @@ private async Task GuaranteeTenantRolesAsync(IdmtTenantInfo tenantInfo) public static RouteHandlerBuilder MapCreateTenantEndpoint(this IEndpointRouteBuilder endpoints) { - return endpoints.MapPost("/tenants", async Task, Created, ValidationProblem, BadRequest>> ( + return endpoints.MapPost("/tenants", async Task, ValidationProblem, BadRequest, ProblemHttpResult>> ( [FromBody] CreateTenantRequest request, [FromServices] ICreateTenantHandler handler, [FromServices] IValidator validator, @@ -147,11 +142,14 @@ public static RouteHandlerBuilder MapCreateTenantEndpoint(this IEndpointRouteBui var response = await handler.HandleAsync(request, cancellationToken: context.RequestAborted); if (response.IsError) { - return TypedResults.BadRequest(); + return response.FirstError.Type switch + { + ErrorType.Validation => TypedResults.BadRequest(), + _ => TypedResults.Problem(response.FirstError.Description, statusCode: StatusCodes.Status500InternalServerError), + }; } - return TypedResults.Ok(response.Value); + return TypedResults.Created($"/admin/tenants/{response.Value.Identifier}", response.Value); }) - .RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy) .WithSummary("Create Tenant") .WithDescription("Create a new tenant in the system or reactivate an existing inactive tenant"); } diff --git a/src/Idmt.Plugin/Features/Admin/GetAllTenants.cs b/src/Idmt.Plugin/Features/Admin/GetAllTenants.cs index 040cdf7..55e9f07 100644 --- a/src/Idmt.Plugin/Features/Admin/GetAllTenants.cs +++ b/src/Idmt.Plugin/Features/Admin/GetAllTenants.cs @@ -60,7 +60,6 @@ public static RouteHandlerBuilder MapGetAllTenantsEndpoint(this IEndpointRouteBu } return TypedResults.Ok(result.Value); }) - .RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy) .WithSummary("Get all tenants"); } } diff --git a/src/Idmt.Plugin/Features/Admin/GetUserTenants.cs b/src/Idmt.Plugin/Features/Admin/GetUserTenants.cs index e5a883f..dcd5e20 100644 --- a/src/Idmt.Plugin/Features/Admin/GetUserTenants.cs +++ b/src/Idmt.Plugin/Features/Admin/GetUserTenants.cs @@ -1,5 +1,4 @@ using ErrorOr; -using Finbuckle.MultiTenant.Abstractions; using Idmt.Plugin.Configuration; using Idmt.Plugin.Errors; using Idmt.Plugin.Models; @@ -22,32 +21,30 @@ public interface IGetUserTenantsHandler internal sealed class GetUserTenantsHandler( IdmtDbContext dbContext, - IMultiTenantStore tenantStore, + TimeProvider timeProvider, ILogger logger) : IGetUserTenantsHandler { public async Task> HandleAsync(Guid userId, CancellationToken cancellationToken = default) { try { - var tenantIds = await dbContext.TenantAccess - .Where(ta => ta.UserId == userId && ta.IsActive) - .Select(ta => ta.TenantId) - .ToArrayAsync(cancellationToken); - - var allTenants = await tenantStore.GetAllAsync(); - var tenantIdSet = new HashSet(tenantIds.Where(id => id != null)!); + var now = timeProvider.GetUtcNow().UtcDateTime; - var res = allTenants - .Where(t => t != null && tenantIdSet.Contains(t.Id!)) - .Select(t => new TenantInfoResponse( - t!.Id ?? string.Empty, - t.Identifier ?? string.Empty, - t.Name ?? string.Empty, - t.Plan ?? string.Empty, - t.IsActive)) - .ToArray(); + var results = await dbContext.TenantAccess + .Where(ta => ta.UserId == userId && ta.IsActive && + (ta.ExpiresAt == null || ta.ExpiresAt > now)) + .Join(dbContext.Set(), + ta => ta.TenantId, + ti => ti.Id, + (ta, ti) => new TenantInfoResponse( + ti.Id ?? string.Empty, + ti.Identifier ?? string.Empty, + ti.Name ?? string.Empty, + ti.Plan ?? string.Empty, + ti.IsActive)) + .ToArrayAsync(cancellationToken); - return res; + return results; } catch (Exception ex) { @@ -71,7 +68,6 @@ public static RouteHandlerBuilder MapGetUserTenantsEndpoint(this IEndpointRouteB } return TypedResults.Ok(result.Value); }) - .RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy) .WithSummary("Get tenants accessible by user"); } } diff --git a/src/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs b/src/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs index 8d49335..79a8db1 100644 --- a/src/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs +++ b/src/Idmt.Plugin/Features/Admin/GrantTenantAccess.cs @@ -41,9 +41,9 @@ public async Task> HandleAsync(Guid userId, string tenantIdenti { var sp = scope.ServiceProvider; + var dbContext = sp.GetRequiredService(); try { - var dbContext = sp.GetRequiredService(); var userManager = sp.GetRequiredService>(); var tenantStore = sp.GetRequiredService>(); @@ -59,6 +59,11 @@ public async Task> HandleAsync(Guid userId, string tenantIdenti return IdmtErrors.Tenant.NotFound; } + if (!targetTenant.IsActive) + { + return IdmtErrors.Tenant.Inactive; + } + userRoles = await userManager.GetRolesAsync(user); if (userRoles.Count == 0) { @@ -85,62 +90,79 @@ public async Task> HandleAsync(Guid userId, string tenantIdenti }; dbContext.TenantAccess.Add(tenantAccess); } - await dbContext.SaveChangesAsync(cancellationToken); } catch (Exception ex) { logger.LogError(ex, "Error granting tenant access to user {UserId} for tenant {TenantIdentifier}", userId, tenantIdentifier); return IdmtErrors.Tenant.AccessError; } - } - return await tenantOps.ExecuteInTenantScopeAsync(tenantIdentifier, async sp => - { - try + // Execute tenant-scope operation BEFORE persisting TenantAccess to prevent orphaned records + var tenantResult = await tenantOps.ExecuteInTenantScopeAsync(tenantIdentifier, async tsp => { - var targetUserManager = sp.GetRequiredService>(); + try + { + var targetUserManager = tsp.GetRequiredService>(); - var targetUser = await targetUserManager.Users - .FirstOrDefaultAsync(u => u.Email == user.Email && u.UserName == user.UserName, cancellationToken); + var targetUser = await targetUserManager.Users + .FirstOrDefaultAsync(u => u.Email == user.Email && u.UserName == user.UserName, cancellationToken); - if (targetUser is null) - { - // Create new user record for the target tenant - // SecurityStamp is copied to enable cross-tenant token validation - targetUser = new IdmtUser + if (targetUser is null) { - UserName = user.UserName, - Email = user.Email, - EmailConfirmed = user.EmailConfirmed, - PasswordHash = user.PasswordHash, - SecurityStamp = user.SecurityStamp, - ConcurrencyStamp = user.ConcurrencyStamp, - PhoneNumber = user.PhoneNumber, - PhoneNumberConfirmed = user.PhoneNumberConfirmed, - TwoFactorEnabled = user.TwoFactorEnabled, - LockoutEnd = user.LockoutEnd, - LockoutEnabled = user.LockoutEnabled, - AccessFailedCount = user.AccessFailedCount, - IsActive = true - }; + targetUser = new IdmtUser + { + UserName = user.UserName, + Email = user.Email, + EmailConfirmed = user.EmailConfirmed, + PasswordHash = user.PasswordHash, + SecurityStamp = user.SecurityStamp, + ConcurrencyStamp = user.ConcurrencyStamp, + PhoneNumber = user.PhoneNumber, + PhoneNumberConfirmed = user.PhoneNumberConfirmed, + TwoFactorEnabled = user.TwoFactorEnabled, + LockoutEnd = user.LockoutEnd, + LockoutEnabled = user.LockoutEnabled, + AccessFailedCount = user.AccessFailedCount, + IsActive = true + }; - await targetUserManager.CreateAsync(targetUser); - await targetUserManager.AddToRolesAsync(targetUser, userRoles); + var createResult = await targetUserManager.CreateAsync(targetUser); + if (!createResult.Succeeded) + { + logger.LogError("Failed to create user in target tenant: {Errors}", string.Join(", ", createResult.Errors.Select(e => e.Description))); + return IdmtErrors.Tenant.AccessError; + } + var roleResult = await targetUserManager.AddToRolesAsync(targetUser, userRoles); + if (!roleResult.Succeeded) + { + logger.LogError("Failed to assign roles in target tenant: {Errors}", string.Join(", ", roleResult.Errors.Select(e => e.Description))); + return IdmtErrors.Tenant.AccessError; + } + } + else + { + targetUser.IsActive = true; + await targetUserManager.UpdateAsync(targetUser); + } + + return Result.Success; } - else + catch (Exception ex) { - targetUser.IsActive = true; - await targetUserManager.UpdateAsync(targetUser); + logger.LogError(ex, "Error granting tenant access to user {UserId} in tenant {TenantIdentifier}", userId, tenantIdentifier); + return IdmtErrors.Tenant.AccessError; } + }); - return Result.Success; - } - catch (Exception ex) + if (tenantResult.IsError) { - logger.LogError(ex, "Error granting tenant access to user {UserId} in tenant {TenantIdentifier}", userId, tenantIdentifier); - return IdmtErrors.Tenant.AccessError; + return tenantResult; } - }); + + // Tenant-scope operation succeeded — now persist the TenantAccess record + await dbContext.SaveChangesAsync(cancellationToken); + return Result.Success; + } } } @@ -165,7 +187,6 @@ public static RouteHandlerBuilder MapGrantTenantAccessEndpoint(this IEndpointRou } return TypedResults.Ok(); }) - .RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy) .WithSummary("Grant user access to a tenant"); } } diff --git a/src/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs b/src/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs index 7d7f3f4..5e77729 100644 --- a/src/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs +++ b/src/Idmt.Plugin/Features/Admin/RevokeTenantAccess.cs @@ -55,11 +55,13 @@ public async Task> HandleAsync(Guid userId, string tenantIdenti var tenantAccess = await dbContext.TenantAccess .FirstOrDefaultAsync(ta => ta.UserId == userId && ta.TenantId == targetTenant.Id, cancellationToken); - if (tenantAccess is not null) + if (tenantAccess is null) { - tenantAccess.IsActive = false; - dbContext.TenantAccess.Update(tenantAccess); + return IdmtErrors.Tenant.AccessNotFound; } + + tenantAccess.IsActive = false; + dbContext.TenantAccess.Update(tenantAccess); await dbContext.SaveChangesAsync(cancellationToken); } catch (Exception ex) @@ -93,7 +95,7 @@ public async Task> HandleAsync(Guid userId, string tenantIdenti public static RouteHandlerBuilder MapRevokeTenantAccessEndpoint(this IEndpointRouteBuilder endpoints) { - return endpoints.MapDelete("/users/{userId:guid}/tenants/{tenantId}", async Task> ( + return endpoints.MapDelete("/users/{userId:guid}/tenants/{tenantId}", async Task> ( Guid userId, string tenantId, IRevokeTenantAccessHandler handler, @@ -108,9 +110,8 @@ public static RouteHandlerBuilder MapRevokeTenantAccessEndpoint(this IEndpointRo _ => TypedResults.InternalServerError(), }; } - return TypedResults.Ok(); + return TypedResults.NoContent(); }) - .RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy) .WithSummary("Revoke user access from a tenant"); } } diff --git a/src/Idmt.Plugin/Features/AdminEndpoints.cs b/src/Idmt.Plugin/Features/AdminEndpoints.cs index acdf7d3..dfe369d 100644 --- a/src/Idmt.Plugin/Features/AdminEndpoints.cs +++ b/src/Idmt.Plugin/Features/AdminEndpoints.cs @@ -1,3 +1,4 @@ +using Idmt.Plugin.Configuration; using Idmt.Plugin.Features.Admin; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -10,6 +11,7 @@ public static class AdminEndpoints public static void MapAdminEndpoints(this IEndpointRouteBuilder endpoints) { var admin = endpoints.MapGroup("/admin") + .RequireAuthorization(IdmtAuthOptions.RequireSysUserPolicy) .WithTags("Admin"); admin.MapCreateTenantEndpoint(); diff --git a/src/Idmt.Plugin/Features/Auth/ConfirmEmail.cs b/src/Idmt.Plugin/Features/Auth/ConfirmEmail.cs index 09986ef..1333179 100644 --- a/src/Idmt.Plugin/Features/Auth/ConfirmEmail.cs +++ b/src/Idmt.Plugin/Features/Auth/ConfirmEmail.cs @@ -78,9 +78,12 @@ public static RouteHandlerBuilder MapConfirmEmailEndpoint(this IEndpointRouteBui if (result.IsError) { - return result.FirstError.Type == ErrorType.NotFound - ? TypedResults.BadRequest() - : TypedResults.InternalServerError(); + return result.FirstError.Type switch + { + ErrorType.NotFound => TypedResults.BadRequest(), + ErrorType.Failure => TypedResults.BadRequest(), + _ => TypedResults.InternalServerError(), + }; } return TypedResults.Ok(); }) diff --git a/src/Idmt.Plugin/Features/Auth/ForgotPassword.cs b/src/Idmt.Plugin/Features/Auth/ForgotPassword.cs index 718152f..e0f8353 100644 --- a/src/Idmt.Plugin/Features/Auth/ForgotPassword.cs +++ b/src/Idmt.Plugin/Features/Auth/ForgotPassword.cs @@ -5,6 +5,7 @@ using Idmt.Plugin.Services; using Idmt.Plugin.Validation; using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Logging; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Identity; @@ -30,7 +31,8 @@ Task> HandleAsync( internal sealed class ForgotPasswordHandler( UserManager userManager, IEmailSender emailSender, - IIdmtLinkGenerator linkGenerator) : IForgotPasswordHandler + IIdmtLinkGenerator linkGenerator, + ILogger logger) : IForgotPasswordHandler { public async Task> HandleAsync( bool useApiLinks, @@ -59,8 +61,10 @@ public async Task> HandleAsync( return new ForgotPasswordResponse(); } - catch (Exception) + catch (Exception ex) { + logger.LogError(ex, "An error occurred during forgot password for {Email}", + request.Email.Length > 3 ? string.Concat(request.Email.AsSpan(0, 3), "***") : "***"); return IdmtErrors.General.Unexpected; } } diff --git a/src/Idmt.Plugin/Features/Auth/Login.cs b/src/Idmt.Plugin/Features/Auth/Login.cs index b651380..fc774a2 100644 --- a/src/Idmt.Plugin/Features/Auth/Login.cs +++ b/src/Idmt.Plugin/Features/Auth/Login.cs @@ -87,7 +87,7 @@ public async Task> HandleAsync( { user = await userManager.FindByNameAsync(request.Username); } - if (user == null) + if (user is null || !user.IsActive) { return IdmtErrors.Auth.Unauthorized; } @@ -99,26 +99,40 @@ public async Task> HandleAsync( if (result.RequiresTwoFactor) { + if (await userManager.IsLockedOutAsync(user)) + { + return IdmtErrors.Auth.LockedOut; + } + if (!string.IsNullOrEmpty(request.TwoFactorCode)) { - result = await signInManager.TwoFactorAuthenticatorSignInAsync(request.TwoFactorCode, request.RememberMe, request.RememberMe); + var isValid = await userManager.VerifyTwoFactorTokenAsync( + user, userManager.Options.Tokens.AuthenticatorTokenProvider, request.TwoFactorCode); + if (!isValid) + { + await userManager.AccessFailedAsync(user); + return IdmtErrors.Auth.Unauthorized; + } } else if (!string.IsNullOrEmpty(request.TwoFactorRecoveryCode)) { - result = await signInManager.TwoFactorRecoveryCodeSignInAsync(request.TwoFactorRecoveryCode); + var redeemResult = await userManager.RedeemTwoFactorRecoveryCodeAsync(user, request.TwoFactorRecoveryCode); + if (!redeemResult.Succeeded) + { + await userManager.AccessFailedAsync(user); + return IdmtErrors.Auth.Unauthorized; + } + } + else + { + return IdmtErrors.Auth.TwoFactorRequired; } - } - if (!result.Succeeded) - { - return IdmtErrors.Auth.Unauthorized; + await userManager.ResetAccessFailedCountAsync(user); } - - // Check if user is active - if (!user.IsActive) + else if (!result.Succeeded) { - logger.LogWarning("Login attempt failed: User {UserId} is inactive", user.Id); - return IdmtErrors.Auth.UserDeactivated; + return IdmtErrors.Auth.Unauthorized; } // Direct cookie sign-in (no middleware delay) @@ -134,7 +148,11 @@ await signInManager.Context.SignInAsync( // Update last login timestamp user.LastLoginAt = timeProvider.GetUtcNow().UtcDateTime; - await userManager.UpdateAsync(user); + var loginUpdateResult = await userManager.UpdateAsync(user); + if (!loginUpdateResult.Succeeded) + { + logger.LogWarning("Failed to update LastLoginAt for user {UserId}", user.Id); + } return new LoginResponse { UserId = user.Id }; } @@ -178,7 +196,7 @@ public async Task> HandleAsync( { user = await userManager.FindByNameAsync(request.Username); } - if (user == null) + if (user == null || !user.IsActive) { return IdmtErrors.Auth.Unauthorized; } @@ -190,26 +208,40 @@ public async Task> HandleAsync( if (result.RequiresTwoFactor) { + if (await userManager.IsLockedOutAsync(user)) + { + return IdmtErrors.Auth.LockedOut; + } + if (!string.IsNullOrEmpty(request.TwoFactorCode)) { - result = await signInManager.TwoFactorAuthenticatorSignInAsync(request.TwoFactorCode, request.RememberMe, request.RememberMe); + var isValid = await userManager.VerifyTwoFactorTokenAsync( + user, userManager.Options.Tokens.AuthenticatorTokenProvider, request.TwoFactorCode); + if (!isValid) + { + await userManager.AccessFailedAsync(user); + return IdmtErrors.Auth.Unauthorized; + } } else if (!string.IsNullOrEmpty(request.TwoFactorRecoveryCode)) { - result = await signInManager.TwoFactorRecoveryCodeSignInAsync(request.TwoFactorRecoveryCode); + var redeemResult = await userManager.RedeemTwoFactorRecoveryCodeAsync(user, request.TwoFactorRecoveryCode); + if (!redeemResult.Succeeded) + { + await userManager.AccessFailedAsync(user); + return IdmtErrors.Auth.Unauthorized; + } + } + else + { + return IdmtErrors.Auth.TwoFactorRequired; } - } - if (!result.Succeeded) - { - return IdmtErrors.Auth.Unauthorized; + await userManager.ResetAccessFailedCountAsync(user); } - - // Check if user is active - if (!user.IsActive) + else if (!result.Succeeded) { - logger.LogWarning("Login attempt failed: User {UserId} is inactive", user.Id); - return IdmtErrors.Auth.UserDeactivated; + return IdmtErrors.Auth.Unauthorized; } // Generate tokens using BearerToken @@ -243,7 +275,11 @@ public async Task> HandleAsync( // Update last login timestamp user.LastLoginAt = timeProvider.GetUtcNow().UtcDateTime; - await userManager.UpdateAsync(user); + var tokenLoginUpdateResult = await userManager.UpdateAsync(user); + if (!tokenLoginUpdateResult.Succeeded) + { + logger.LogWarning("Failed to update LastLoginAt for user {UserId}", user.Id); + } var expiresIn = (long)bearerOptions.BearerTokenExpiration.TotalSeconds; @@ -257,7 +293,7 @@ public async Task> HandleAsync( } catch (Exception ex) { - logger.LogError(ex, "An error occurred during login for identifier {Email} {Username}", request.Email ?? request.Username ?? "unknown", request.Email ?? request.Username ?? "unknown"); + logger.LogError(ex, "An error occurred during login for identifier {Email} {Username}", request.Email ?? "unknown", request.Username ?? "unknown"); return IdmtErrors.General.Unexpected; } } @@ -278,6 +314,19 @@ public static RouteHandlerBuilder MapCookieLoginEndpoint(this IEndpointRouteBuil var response = await handler.HandleAsync(request, cancellationToken: context.RequestAborted); if (response.IsError) { + if (response.FirstError.Code == "Auth.LockedOut") + { + return TypedResults.Problem( + response.FirstError.Description, + statusCode: StatusCodes.Status429TooManyRequests); + } + if (response.FirstError.Code == "Auth.TwoFactorRequired") + { + return TypedResults.Problem( + response.FirstError.Description, + statusCode: StatusCodes.Status422UnprocessableEntity, + title: response.FirstError.Code); + } return response.FirstError.Type switch { ErrorType.Unauthorized => TypedResults.Unauthorized(), @@ -306,6 +355,19 @@ public static RouteHandlerBuilder MapTokenLoginEndpoint(this IEndpointRouteBuild var response = await handler.HandleAsync(request, cancellationToken: context.RequestAborted); if (response.IsError) { + if (response.FirstError.Code == "Auth.LockedOut") + { + return TypedResults.Problem( + response.FirstError.Description, + statusCode: StatusCodes.Status429TooManyRequests); + } + if (response.FirstError.Code == "Auth.TwoFactorRequired") + { + return TypedResults.Problem( + response.FirstError.Description, + statusCode: StatusCodes.Status422UnprocessableEntity, + title: response.FirstError.Code); + } return response.FirstError.Type switch { ErrorType.Unauthorized => TypedResults.Unauthorized(), diff --git a/src/Idmt.Plugin/Features/Auth/Logout.cs b/src/Idmt.Plugin/Features/Auth/Logout.cs index b7f3605..29fffce 100644 --- a/src/Idmt.Plugin/Features/Auth/Logout.cs +++ b/src/Idmt.Plugin/Features/Auth/Logout.cs @@ -50,6 +50,7 @@ public static RouteHandlerBuilder MapLogoutEndpoint(this IEndpointRouteBuilder e await logoutHandler.HandleAsync(cancellationToken); return TypedResults.NoContent(); }) + .RequireAuthorization() .WithSummary("Logout user") .WithDescription("Logout user and invalidate bearer token or cookie"); } diff --git a/src/Idmt.Plugin/Features/Auth/RefreshToken.cs b/src/Idmt.Plugin/Features/Auth/RefreshToken.cs index fbff41a..93cf238 100644 --- a/src/Idmt.Plugin/Features/Auth/RefreshToken.cs +++ b/src/Idmt.Plugin/Features/Auth/RefreshToken.cs @@ -1,6 +1,8 @@ using System.Security.Claims; using ErrorOr; +using Finbuckle.MultiTenant.Abstractions; using FluentValidation; +using Idmt.Plugin.Configuration; using Idmt.Plugin.Errors; using Idmt.Plugin.Models; using Idmt.Plugin.Validation; @@ -29,7 +31,9 @@ public interface IRefreshTokenHandler internal sealed class RefreshTokenHandler( IOptionsMonitor bearerTokenOptions, TimeProvider timeProvider, - SignInManager signInManager) + SignInManager signInManager, + IMultiTenantContextAccessor tenantContextAccessor, + IOptions idmtOptions) : IRefreshTokenHandler { public async Task> HandleAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default) @@ -44,6 +48,22 @@ await signInManager.ValidateSecurityStampAsync(refreshTicket.Principal) is not I return IdmtErrors.Token.Invalid; } + if (!user.IsActive) + { + return IdmtErrors.Auth.Unauthorized; + } + + // Validate tenant context matches refresh token + var tenantClaimKey = idmtOptions.Value.MultiTenant.StrategyOptions.GetValueOrDefault( + IdmtMultiTenantStrategy.Claim, IdmtMultiTenantStrategy.DefaultClaim); + var tokenTenantClaim = refreshTicket.Principal?.FindFirst(tenantClaimKey)?.Value; + var currentTenant = tenantContextAccessor.MultiTenantContext?.TenantInfo?.Identifier; + + if (tokenTenantClaim is not null && currentTenant is not null && tokenTenantClaim != currentTenant) + { + return IdmtErrors.Auth.Unauthorized; + } + ClaimsPrincipal claimsPrincipal = await signInManager.CreateUserPrincipalAsync(user); return new RefreshTokenResponse(claimsPrincipal); } diff --git a/src/Idmt.Plugin/Features/Auth/ResetPassword.cs b/src/Idmt.Plugin/Features/Auth/ResetPassword.cs index 9117572..ed91f3d 100644 --- a/src/Idmt.Plugin/Features/Auth/ResetPassword.cs +++ b/src/Idmt.Plugin/Features/Auth/ResetPassword.cs @@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Idmt.Plugin.Features.Auth; @@ -25,7 +26,9 @@ public interface IResetPasswordHandler Task> HandleAsync(string tenantIdentifier, string email, string token, ResetPasswordRequest request, CancellationToken cancellationToken = default); } - internal sealed class ResetPasswordHandler(ITenantOperationService tenantOps) : IResetPasswordHandler + internal sealed class ResetPasswordHandler( + ITenantOperationService tenantOps, + ILogger logger) : IResetPasswordHandler { public async Task> HandleAsync(string tenantIdentifier, string email, string token, ResetPasswordRequest request, CancellationToken cancellationToken = default) { @@ -55,8 +58,10 @@ public async Task> HandleAsync(string tenantIdentifier, string return Result.Success; } - catch (Exception) + catch (Exception ex) { + logger.LogError(ex, "An error occurred during password reset for {Email}", + email.Length > 3 ? string.Concat(email.AsSpan(0, 3), "***") : "***"); return IdmtErrors.General.Unexpected; } }); diff --git a/src/Idmt.Plugin/Features/Health/BasicHealthCheck.cs b/src/Idmt.Plugin/Features/Health/BasicHealthCheck.cs index 1cabdf4..b0b88d7 100644 --- a/src/Idmt.Plugin/Features/Health/BasicHealthCheck.cs +++ b/src/Idmt.Plugin/Features/Health/BasicHealthCheck.cs @@ -1,43 +1,29 @@ -using Finbuckle.MultiTenant.Abstractions; using Idmt.Plugin.Persistence; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Diagnostics.HealthChecks; namespace Idmt.Plugin.Features.Health; -public class BasicHealthCheck(IdmtDbContext dbContext, IMultiTenantContextAccessor tenantAccessor, TimeProvider timeProvider) : IHealthCheck +public class BasicHealthCheck(IdmtDbContext dbContext, TimeProvider timeProvider) : IHealthCheck { public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) { - var currentTenant = tenantAccessor.MultiTenantContext?.TenantInfo; - try { // Check database connectivity var canConnect = await dbContext.Database.CanConnectAsync(); - // Get user count for current tenant - var tenantId = currentTenant?.Id ?? "default"; - var userCount = await dbContext.Users - .Where(u => u.TenantId == tenantId) - .CountAsync(cancellationToken: cancellationToken); - - - return HealthCheckResult.Healthy("Database and tenant user count are healthy", + return HealthCheckResult.Healthy("Database is healthy", new Dictionary { { "database_connected", canConnect }, - { "current_tenant", currentTenant?.Id ?? "No tenant" }, - { "tenant_user_count", userCount }, { "timestamp", timeProvider.GetUtcNow().UtcDateTime } }); } catch (Exception ex) { - return HealthCheckResult.Unhealthy("Database and tenant user count are unhealthy", ex, + return HealthCheckResult.Unhealthy("Database is unhealthy", ex, new Dictionary { - { "error", ex.Message }, { "timestamp", timeProvider.GetUtcNow().UtcDateTime } }); } diff --git a/src/Idmt.Plugin/Features/Manage/GetUserInfo.cs b/src/Idmt.Plugin/Features/Manage/GetUserInfo.cs index b264608..eae4ed2 100644 --- a/src/Idmt.Plugin/Features/Manage/GetUserInfo.cs +++ b/src/Idmt.Plugin/Features/Manage/GetUserInfo.cs @@ -1,5 +1,7 @@ using System.Security.Claims; +using ErrorOr; using Finbuckle.MultiTenant.Abstractions; +using Idmt.Plugin.Errors; using Idmt.Plugin.Models; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -18,39 +20,41 @@ public sealed record GetUserInfoResponse( string UserName, string Role, string TenantIdentifier, - string TenantDisplayName + string TenantName ); public interface IGetUserInfoHandler { - Task HandleAsync(ClaimsPrincipal user, CancellationToken cancellationToken = default); + Task> HandleAsync(ClaimsPrincipal user, CancellationToken cancellationToken = default); } internal sealed class GetUserInfoHandler(UserManager userManager, IMultiTenantStore tenantStore) : IGetUserInfoHandler { - public async Task HandleAsync(ClaimsPrincipal user, CancellationToken cancellationToken = default) + public async Task> HandleAsync(ClaimsPrincipal user, CancellationToken cancellationToken = default) { var userEmail = user.FindFirstValue(ClaimTypes.Email); if (string.IsNullOrEmpty(userEmail)) { - return null; + return IdmtErrors.User.ClaimsNotFound; } var appUser = await userManager.FindByEmailAsync(userEmail); if (appUser == null || !appUser.IsActive) { - return null; + return IdmtErrors.User.NotFound; } - // Fail fast - var role = (await userManager.GetRolesAsync(appUser)).FirstOrDefault() ?? throw new InvalidOperationException("User has no role assigned"); - var tenant = await tenantStore.GetAsync(appUser.TenantId) ?? throw new InvalidOperationException("Tenant not found"); + var role = (await userManager.GetRolesAsync(appUser)).FirstOrDefault(); + if (role is null) return IdmtErrors.User.NoRolesAssigned; + + var tenant = await tenantStore.GetAsync(appUser.TenantId); + if (tenant is null) return IdmtErrors.Tenant.NotFound; return new GetUserInfoResponse( appUser.Id.ToString(), appUser.Email ?? string.Empty, appUser.UserName ?? string.Empty, - role ?? string.Empty, + role, tenant.Identifier ?? string.Empty, tenant.Name ?? string.Empty ); @@ -59,20 +63,25 @@ internal sealed class GetUserInfoHandler(UserManager userManager, IMul public static RouteHandlerBuilder MapGetUserInfoEndpoint(this IEndpointRouteBuilder endpoints) { - return endpoints.MapGet("/info", async Task, NotFound>> ( + return endpoints.MapGet("/info", async Task, NotFound, BadRequest, ProblemHttpResult>> ( ClaimsPrincipal user, [FromServices] IGetUserInfoHandler handler, HttpContext context) => { var result = await handler.HandleAsync(user, cancellationToken: context.RequestAborted); - if (result == null) + if (result.IsError) { - return TypedResults.NotFound(); + return result.FirstError.Type switch + { + ErrorType.NotFound => TypedResults.NotFound(), + ErrorType.Validation => TypedResults.BadRequest(), + _ => TypedResults.Problem(result.FirstError.Description, statusCode: StatusCodes.Status500InternalServerError), + }; } - return TypedResults.Ok(result); + return TypedResults.Ok(result.Value); }) .WithSummary("Get user info") .WithDescription("Get current user authentication info") .RequireAuthorization(); } -} \ No newline at end of file +} diff --git a/src/Idmt.Plugin/Features/Manage/RegisterUser.cs b/src/Idmt.Plugin/Features/Manage/RegisterUser.cs index 0649a21..b53421c 100644 --- a/src/Idmt.Plugin/Features/Manage/RegisterUser.cs +++ b/src/Idmt.Plugin/Features/Manage/RegisterUser.cs @@ -44,7 +44,6 @@ internal sealed class RegisterHandler( ILogger logger, UserManager userManager, RoleManager roleManager, - IUserStore userStore, ICurrentUserService currentUserService, ITenantAccessService tenantAccessService, IdmtDbContext dbContext, @@ -95,11 +94,6 @@ public async Task> HandleAsync( return IdmtErrors.User.CreationFailed; } - await userStore.SetUserNameAsync(user, request.Username ?? request.Email, cancellationToken); - IUserEmailStore emailStore = userStore as IUserEmailStore - ?? throw new NotSupportedException("The user store does not support email functionality."); - await emailStore.SetEmailAsync(user, request.Email, cancellationToken); - var roleResult = await userManager.AddToRoleAsync(user, request.Role); if (!roleResult.Succeeded) { @@ -120,12 +114,12 @@ public async Task> HandleAsync( var token = await userManager.GeneratePasswordResetTokenAsync(user); var passwordSetupUrl = useApiLinks - ? linkGenerator.GeneratePasswordResetApiLink(user.Email, token) - : linkGenerator.GeneratePasswordResetFormLink(user.Email, token); + ? linkGenerator.GeneratePasswordResetApiLink(user.Email ?? request.Email, token) + : linkGenerator.GeneratePasswordResetFormLink(user.Email ?? request.Email, token); logger.LogInformation("User created: {Email}. Request by {RequestingUserId}. Tenant: {TenantId}.", user.Email, currentUserService.UserId, tenantId); - await emailSender.SendPasswordResetLinkAsync(user, user.Email, passwordSetupUrl); + await emailSender.SendPasswordResetLinkAsync(user, user.Email ?? request.Email, passwordSetupUrl); return new RegisterUserResponse { diff --git a/src/Idmt.Plugin/Features/Manage/UpdateUserInfo.cs b/src/Idmt.Plugin/Features/Manage/UpdateUserInfo.cs index 32a2826..006ba66 100644 --- a/src/Idmt.Plugin/Features/Manage/UpdateUserInfo.cs +++ b/src/Idmt.Plugin/Features/Manage/UpdateUserInfo.cs @@ -58,6 +58,8 @@ public async Task> HandleAsync( await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); try { + bool hasChanges = false; + // Update username if provided if (!string.IsNullOrWhiteSpace(request.NewUsername) && request.NewUsername != appUser.UserName) { @@ -68,6 +70,7 @@ public async Task> HandleAsync( await transaction.RollbackAsync(cancellationToken); return IdmtErrors.User.UpdateFailed; } + hasChanges = true; } // Update email if provided @@ -76,19 +79,18 @@ public async Task> HandleAsync( // Generate email change token var token = await userManager.GenerateChangeEmailTokenAsync(appUser, request.NewEmail); var result = await userManager.ChangeEmailAsync(appUser, request.NewEmail, token); - appUser.EmailConfirmed = false; - await userManager.UpdateAsync(appUser); - if (!result.Succeeded) { logger.LogError("Failed to change email: {ErrorMessage}", result.Errors.Select(e => e.Description)); await transaction.RollbackAsync(cancellationToken); return IdmtErrors.User.UpdateFailed; } + appUser.EmailConfirmed = false; + hasChanges = true; } // Update password if provided - if (!string.IsNullOrEmpty(request.OldPassword) && !string.IsNullOrWhiteSpace(request.NewPassword)) + if (!string.IsNullOrWhiteSpace(request.OldPassword) && !string.IsNullOrWhiteSpace(request.NewPassword)) { var changePasswordResult = await userManager.ChangePasswordAsync(appUser, request.OldPassword, request.NewPassword); if (!changePasswordResult.Succeeded) @@ -97,9 +99,19 @@ public async Task> HandleAsync( await transaction.RollbackAsync(cancellationToken); return IdmtErrors.Password.ResetFailed; } + hasChanges = true; } - await userManager.UpdateAsync(appUser); + if (hasChanges) + { + var updateResult = await userManager.UpdateAsync(appUser); + if (!updateResult.Succeeded) + { + logger.LogError("Failed to update user: {Errors}", string.Join(", ", updateResult.Errors.Select(e => e.Description))); + await transaction.RollbackAsync(cancellationToken); + return IdmtErrors.User.UpdateFailed; + } + } await transaction.CommitAsync(cancellationToken); @@ -108,7 +120,7 @@ public async Task> HandleAsync( catch (Exception ex) { await transaction.RollbackAsync(cancellationToken); - logger.LogError(ex, "Exception occurred during user registration. Transaction rolled back."); + logger.LogError(ex, "Exception occurred during user info update. Transaction rolled back."); return IdmtErrors.General.Unexpected; } } @@ -136,6 +148,7 @@ public static RouteHandlerBuilder MapUpdateUserInfoEndpoint(this IEndpointRouteB ErrorType.NotFound => TypedResults.NotFound(), ErrorType.Forbidden => TypedResults.Forbid(), ErrorType.Validation => TypedResults.BadRequest(), + ErrorType.Failure => TypedResults.BadRequest(), _ => TypedResults.InternalServerError(), }; } diff --git a/src/Idmt.Plugin/Middleware/CurrentUserMiddleware.cs b/src/Idmt.Plugin/Middleware/CurrentUserMiddleware.cs index 2fe4d38..d7504e0 100644 --- a/src/Idmt.Plugin/Middleware/CurrentUserMiddleware.cs +++ b/src/Idmt.Plugin/Middleware/CurrentUserMiddleware.cs @@ -4,8 +4,7 @@ namespace Idmt.Plugin.Middleware; /// -/// Middleware for setting current user and validating tenant isolation on bearer tokens. -/// Ensures that users cannot use tokens from one tenant to access another tenant's resources. +/// Middleware for setting current user context from the authenticated HTTP request. /// public class CurrentUserMiddleware(ICurrentUserService currentUserService) : IMiddleware { diff --git a/src/Idmt.Plugin/Middleware/ValidateBearerTokenTenantMiddleware.cs b/src/Idmt.Plugin/Middleware/ValidateBearerTokenTenantMiddleware.cs index 49a8e58..8e2c30b 100644 --- a/src/Idmt.Plugin/Middleware/ValidateBearerTokenTenantMiddleware.cs +++ b/src/Idmt.Plugin/Middleware/ValidateBearerTokenTenantMiddleware.cs @@ -72,6 +72,7 @@ private bool ValidateTokenTenant( catch (Exception ex) { logger.LogWarning(ex, "Error validating bearer token tenant"); + context.Response.StatusCode = StatusCodes.Status401Unauthorized; return false; } } diff --git a/src/Idmt.Plugin/Persistence/IdmtDbContext.cs b/src/Idmt.Plugin/Persistence/IdmtDbContext.cs index 3ca79b7..33b4aae 100644 --- a/src/Idmt.Plugin/Persistence/IdmtDbContext.cs +++ b/src/Idmt.Plugin/Persistence/IdmtDbContext.cs @@ -1,6 +1,7 @@ using Finbuckle.MultiTenant.Abstractions; using Finbuckle.MultiTenant.EntityFrameworkCore.Extensions; using Finbuckle.MultiTenant.Identity.EntityFrameworkCore; +using Idmt.Plugin.Constants; using Idmt.Plugin.Models; using Idmt.Plugin.Services; using Microsoft.EntityFrameworkCore; @@ -129,7 +130,7 @@ public override Task SaveChangesAsync(CancellationToken cancellationToken = { UserId = _currentUserService.UserId, TenantId = entry.Entity.GetTenantId(), - Action = "Created", + Action = AuditAction.Created.ToString(), Resource = entry.Entity.GetName(), ResourceId = entry.Entity.GetId(), Success = true, @@ -144,7 +145,7 @@ public override Task SaveChangesAsync(CancellationToken cancellationToken = { UserId = _currentUserService.UserId, TenantId = entry.Entity.GetTenantId(), - Action = "Deleted", + Action = AuditAction.Deleted.ToString(), Resource = entry.Entity.GetName(), ResourceId = entry.Entity.GetId(), Success = true, @@ -162,7 +163,7 @@ public override Task SaveChangesAsync(CancellationToken cancellationToken = { UserId = _currentUserService.UserId, TenantId = entry.Entity.GetTenantId(), - Action = "Modified", + Action = AuditAction.Modified.ToString(), Resource = entry.Entity.GetName(), ResourceId = entry.Entity.GetId(), Details = details, diff --git a/src/Idmt.Plugin/Persistence/IdmtTenantStoreDbContext.cs b/src/Idmt.Plugin/Persistence/IdmtTenantStoreDbContext.cs index 99d603b..88ee424 100644 --- a/src/Idmt.Plugin/Persistence/IdmtTenantStoreDbContext.cs +++ b/src/Idmt.Plugin/Persistence/IdmtTenantStoreDbContext.cs @@ -22,7 +22,7 @@ protected IdmtTenantStoreDbContext(DbContextOptions options) : base(options) protected override void OnModelCreating(ModelBuilder modelBuilder) { - // IdmtDbContext owns all table configurations - // This keeps migrations simple: only run dotnet ef migrations with IdmtDbContext + base.OnModelCreating(modelBuilder); + // IdmtDbContext owns all table configurations for migrations } } diff --git a/src/Idmt.Plugin/Services/IdmtUserClaimsPrincipalFactory.cs b/src/Idmt.Plugin/Services/IdmtUserClaimsPrincipalFactory.cs index f3d909c..3ae5711 100644 --- a/src/Idmt.Plugin/Services/IdmtUserClaimsPrincipalFactory.cs +++ b/src/Idmt.Plugin/Services/IdmtUserClaimsPrincipalFactory.cs @@ -37,7 +37,7 @@ protected override async Task GenerateClaimsAsync(IdmtUser user) return identity; } - identity.AddClaim(new Claim(claimKey, tenantInfo.Identifier)); + identity.AddClaim(new Claim(claimKey, tenantInfo.Identifier ?? string.Empty)); return identity; } diff --git a/src/Idmt.Plugin/Validation/CreateTenantRequestValidator.cs b/src/Idmt.Plugin/Validation/CreateTenantRequestValidator.cs index 67ccbe9..52ea8bc 100644 --- a/src/Idmt.Plugin/Validation/CreateTenantRequestValidator.cs +++ b/src/Idmt.Plugin/Validation/CreateTenantRequestValidator.cs @@ -14,8 +14,5 @@ public CreateTenantRequestValidator() RuleFor(x => x.Name).NotEmpty() .WithMessage("Name is required"); - - RuleFor(x => x.DisplayName).NotEmpty() - .WithMessage("Display Name is required"); } } diff --git a/src/Idmt.Plugin/Validation/UpdateUserInfoRequestValidator.cs b/src/Idmt.Plugin/Validation/UpdateUserInfoRequestValidator.cs index 8c91764..c19aafb 100644 --- a/src/Idmt.Plugin/Validation/UpdateUserInfoRequestValidator.cs +++ b/src/Idmt.Plugin/Validation/UpdateUserInfoRequestValidator.cs @@ -9,19 +9,19 @@ public class UpdateUserInfoRequestValidator : AbstractValidator options) { - When(x => !string.IsNullOrEmpty(x.NewPassword), () => + When(x => !string.IsNullOrWhiteSpace(x.NewPassword), () => { RuleFor(x => x.OldPassword).NotEmpty() .WithMessage("Old password is required to change password"); }); - When(x => x.NewEmail is not null, () => + When(x => !string.IsNullOrWhiteSpace(x.NewEmail), () => { RuleFor(x => x.NewEmail).Must(Validators.IsValidEmail) .WithMessage("New email is not valid"); }); - When(x => x.NewPassword is not null, () => + When(x => !string.IsNullOrWhiteSpace(x.NewPassword), () => { RuleFor(x => x.NewPassword).Must(password => Validators.IsValidNewPassword(password, options.Value.Identity.Password, out _)) diff --git a/src/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs b/src/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs index 792f6ec..90b3227 100644 --- a/src/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs +++ b/src/tests/Idmt.BasicSample.Tests/AdminIntegrationTests.cs @@ -44,7 +44,7 @@ public async Task CreateTenant_handler_with_valid_data_succeeds() var handler = scope.ServiceProvider.GetRequiredService(); var tenantIdentifier = $"tenant-{Guid.NewGuid():N}"; - var request = new CreateTenant.CreateTenantRequest(tenantIdentifier, "Test Tenant", "Test Tenant Display"); + var request = new CreateTenant.CreateTenantRequest(tenantIdentifier, "Test Tenant"); var result = await handler.HandleAsync(request); Assert.False(result.IsError); @@ -61,7 +61,7 @@ public async Task CreateTenant_handler_with_duplicate_identifier_reactivates() var tenantIdentifier = $"tenant-{Guid.NewGuid():N}"; // Create initial tenant - var request = new CreateTenant.CreateTenantRequest(tenantIdentifier, "Test Tenant", "Test Display"); + var request = new CreateTenant.CreateTenantRequest(tenantIdentifier, "Test Tenant"); var result = await handler.HandleAsync(request); var tenantId = result.Value!.Id; @@ -86,7 +86,7 @@ public async Task DeleteTenant_handler_with_valid_identifier_succeeds() var deleteHandler = scope.ServiceProvider.GetRequiredService(); var tenantIdentifier = $"tenant-{Guid.NewGuid():N}"; - var request = new CreateTenant.CreateTenantRequest(tenantIdentifier, "Test Tenant", "Test Display"); + var request = new CreateTenant.CreateTenantRequest(tenantIdentifier, "Test Tenant"); await createHandler.HandleAsync(request); var deleted = await deleteHandler.HandleAsync(tenantIdentifier); @@ -232,7 +232,7 @@ public async Task GrantTenantAccess_requires_authorization() #region Revoke Tenant Access Tests [Fact] - public async Task RevokeTenantAccess_with_valid_data_succeeds() + public async Task RevokeTenantAccess_with_valid_data_returns_no_content() { var sysClient = await CreateAuthenticatedClientAsync(); var email = $"revoke-{Guid.NewGuid():N}@example.com"; @@ -253,7 +253,7 @@ await sysClient.PostAsJsonAsync( // Revoke access var revokeResponse = await sysClient.DeleteAsync($"/admin/users/{userId}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}"); - await revokeResponse.AssertSuccess(); + Assert.Equal(HttpStatusCode.NoContent, revokeResponse.StatusCode); } [Fact] @@ -281,7 +281,8 @@ await sysClient.PostAsJsonAsync( Assert.Contains(tenantsBeforeRevoke!, t => t.Identifier == IdmtApiFactory.DefaultTenantIdentifier); // Revoke access - await sysClient.DeleteAsync($"/admin/users/{userId}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}"); + var revokeResp = await sysClient.DeleteAsync($"/admin/users/{userId}/tenants/{IdmtApiFactory.DefaultTenantIdentifier}"); + Assert.Equal(HttpStatusCode.NoContent, revokeResp.StatusCode); // Verify access is removed var tenantsAfterRevoke = await sysClient.GetFromJsonAsync($"/admin/users/{userId}/tenants"); @@ -365,13 +366,16 @@ public async Task GetUserTenants_returns_empty_for_user_without_access() } [Fact] - public async Task GetUserTenants_with_nonexistent_user_succeeds_empty() + public async Task GetUserTenants_with_nonexistent_user_returns_ok_empty() { var sysClient = await CreateAuthenticatedClientAsync(); var response = await sysClient.GetAsync($"/admin/users/{Guid.NewGuid()}/tenants"); - // May return 200 with empty or 404 - Assert.True(response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.NotFound); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var tenants = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(tenants); + Assert.Empty(tenants!); } [Fact] @@ -384,4 +388,29 @@ public async Task GetUserTenants_requires_authorization() } #endregion + + #region Get All Tenants Tests + + [Fact] + public async Task GetAllTenants_returns_ok_with_tenant_list() + { + var sysClient = await CreateAuthenticatedClientAsync(); + + var response = await sysClient.GetAsync("/admin/tenants"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var tenants = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(tenants); + } + + [Fact] + public async Task GetAllTenants_requires_authorization() + { + var client = Factory.CreateClientWithTenant(); + + var response = await client.GetAsync("/admin/tenants"); + Assert.Contains(response.StatusCode, new[] { HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden }); + } + + #endregion } diff --git a/src/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs b/src/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs index 593ca3f..c62e83e 100644 --- a/src/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs +++ b/src/tests/Idmt.BasicSample.Tests/MultiTenancyIntegrationTests.cs @@ -28,8 +28,8 @@ private async Task EnsureTenantsExistAsync() using var scope = Factory.Services.CreateScope(); var handler = scope.ServiceProvider.GetRequiredService(); - await handler.HandleAsync(new CreateTenant.CreateTenantRequest(TenantA, TenantA, "Tenant A")); - await handler.HandleAsync(new CreateTenant.CreateTenantRequest(TenantB, TenantB, "Tenant B")); + await handler.HandleAsync(new CreateTenant.CreateTenantRequest(TenantA, TenantA)); + await handler.HandleAsync(new CreateTenant.CreateTenantRequest(TenantB, TenantB)); } private async Task CreateUserInTenantAsync(string tenantIdentifier, string email, string password, string role = IdmtDefaultRoleTypes.TenantAdmin) diff --git a/src/tests/Idmt.UnitTests/Middleware/ValidateBearerTokenTenantMiddlewareTests.cs b/src/tests/Idmt.UnitTests/Middleware/ValidateBearerTokenTenantMiddlewareTests.cs new file mode 100644 index 0000000..1a62221 --- /dev/null +++ b/src/tests/Idmt.UnitTests/Middleware/ValidateBearerTokenTenantMiddlewareTests.cs @@ -0,0 +1,161 @@ +using System.Security.Claims; +using Finbuckle.MultiTenant.Abstractions; +using Idmt.Plugin.Configuration; +using Idmt.Plugin.Middleware; +using Idmt.Plugin.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace Idmt.UnitTests.Middleware; + +public class ValidateBearerTokenTenantMiddlewareTests +{ + private readonly Mock _tenantAccessorMock; + private readonly Mock> _optionsMock; + private readonly Mock> _loggerMock; + private readonly ValidateBearerTokenTenantMiddleware _middleware; + private readonly IdmtOptions _options; + + public ValidateBearerTokenTenantMiddlewareTests() + { + _tenantAccessorMock = new Mock(); + _optionsMock = new Mock>(); + _loggerMock = new Mock>(); + _options = new IdmtOptions(); + _optionsMock.Setup(x => x.Value).Returns(_options); + + _middleware = new ValidateBearerTokenTenantMiddleware( + _tenantAccessorMock.Object, + _optionsMock.Object, + _loggerMock.Object); + } + + [Fact] + public async Task InvokeAsync_UnauthenticatedRequest_PassesThrough() + { + var context = new DefaultHttpContext(); + var nextCalled = false; + + await _middleware.InvokeAsync(context, _ => + { + nextCalled = true; + return Task.CompletedTask; + }); + + Assert.True(nextCalled); + } + + [Fact] + public async Task InvokeAsync_CookieAuthRequest_PassesThrough() + { + var context = new DefaultHttpContext(); + var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "user") }, "Cookie"); + context.User = new ClaimsPrincipal(identity); + // No Bearer header + var nextCalled = false; + + await _middleware.InvokeAsync(context, _ => + { + nextCalled = true; + return Task.CompletedTask; + }); + + Assert.True(nextCalled); + } + + [Fact] + public async Task InvokeAsync_MissingTenantClaim_Returns401() + { + var context = CreateBearerContext(tenantClaimValue: null); + SetupTenantContext("test-tenant"); + + var nextCalled = false; + await _middleware.InvokeAsync(context, _ => + { + nextCalled = true; + return Task.CompletedTask; + }); + + Assert.False(nextCalled); + Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + } + + [Fact] + public async Task InvokeAsync_MismatchedTenantClaim_Returns403() + { + var context = CreateBearerContext(tenantClaimValue: "tenant-a"); + SetupTenantContext("tenant-b"); + + var nextCalled = false; + await _middleware.InvokeAsync(context, _ => + { + nextCalled = true; + return Task.CompletedTask; + }); + + Assert.False(nextCalled); + Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode); + } + + [Fact] + public async Task InvokeAsync_MatchingTenantClaim_PassesThrough() + { + var context = CreateBearerContext(tenantClaimValue: "test-tenant"); + SetupTenantContext("test-tenant"); + + var nextCalled = false; + await _middleware.InvokeAsync(context, _ => + { + nextCalled = true; + return Task.CompletedTask; + }); + + Assert.True(nextCalled); + } + + [Fact] + public async Task InvokeAsync_ExceptionInValidation_Returns401() + { + var context = CreateBearerContext(tenantClaimValue: "test-tenant"); + // Setup accessor to throw + _tenantAccessorMock.SetupGet(x => x.MultiTenantContext) + .Throws(new InvalidOperationException("test exception")); + + var nextCalled = false; + await _middleware.InvokeAsync(context, _ => + { + nextCalled = true; + return Task.CompletedTask; + }); + + Assert.False(nextCalled); + Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + } + + private DefaultHttpContext CreateBearerContext(string? tenantClaimValue) + { + var context = new DefaultHttpContext(); + context.Request.Headers.Authorization = "Bearer test-token"; + + var claims = new List { new(ClaimTypes.Name, "user") }; + if (tenantClaimValue != null) + { + var claimKey = _options.MultiTenant.StrategyOptions.GetValueOrDefault( + IdmtMultiTenantStrategy.Claim, IdmtMultiTenantStrategy.DefaultClaim); + claims.Add(new Claim(claimKey, tenantClaimValue)); + } + + var identity = new ClaimsIdentity(claims, "Bearer"); + context.User = new ClaimsPrincipal(identity); + return context; + } + + private void SetupTenantContext(string identifier) + { + var tenant = new IdmtTenantInfo("id", identifier, "Test"); + var multiTenantContext = new MultiTenantContext(tenant); + _tenantAccessorMock.SetupGet(x => x.MultiTenantContext).Returns(multiTenantContext); + } +} diff --git a/src/tests/Idmt.UnitTests/Services/IdmtLinkGeneratorTests.cs b/src/tests/Idmt.UnitTests/Services/IdmtLinkGeneratorTests.cs index 1bff3de..c1c6c1d 100644 --- a/src/tests/Idmt.UnitTests/Services/IdmtLinkGeneratorTests.cs +++ b/src/tests/Idmt.UnitTests/Services/IdmtLinkGeneratorTests.cs @@ -51,12 +51,6 @@ public IdmtLinkGeneratorTests() _loggerMock.Object); } - [Fact] - public void Constructor_ShouldInitialize() - { - Assert.NotNull(_service); - } - [Fact] public void GenerateConfirmEmailApiLink_UsesLinkGenerator() { diff --git a/src/tests/Idmt.UnitTests/Services/TenantAccessServiceTests.cs b/src/tests/Idmt.UnitTests/Services/TenantAccessServiceTests.cs index 61ecc62..aa1b810 100644 --- a/src/tests/Idmt.UnitTests/Services/TenantAccessServiceTests.cs +++ b/src/tests/Idmt.UnitTests/Services/TenantAccessServiceTests.cs @@ -89,6 +89,7 @@ public async Task CanAccessTenantAsync_ReturnsFalse_WhenAccessExpired() } [Theory] + [InlineData(IdmtDefaultRoleTypes.SysAdmin, "AnyRole", true)] [InlineData(IdmtDefaultRoleTypes.SysSupport, IdmtDefaultRoleTypes.SysAdmin, false)] [InlineData(IdmtDefaultRoleTypes.SysSupport, IdmtDefaultRoleTypes.TenantAdmin, true)] [InlineData(IdmtDefaultRoleTypes.TenantAdmin, IdmtDefaultRoleTypes.SysAdmin, false)] @@ -108,6 +109,17 @@ public void CanAssignRole_ValidatesRoleHierarchy(string currentUserRole, string Assert.Equal(expected, result); } + [Fact] + public void CanManageUser_ReturnsTrue_WhenSysAdminManagesAnyUser() + { + _currentUserServiceMock.Reset(); + _currentUserServiceMock.Setup(x => x.IsInRole(IdmtDefaultRoleTypes.SysAdmin)).Returns(true); + + var result = _service.CanManageUser([IdmtDefaultRoleTypes.SysAdmin]); + + Assert.True(result); + } + [Fact] public void CanManageUser_ReturnsFalse_WhenSysSupportManagesSysAdmin() { diff --git a/src/tests/Idmt.UnitTests/Services/TenantOperationServiceTests.cs b/src/tests/Idmt.UnitTests/Services/TenantOperationServiceTests.cs new file mode 100644 index 0000000..fddc99c --- /dev/null +++ b/src/tests/Idmt.UnitTests/Services/TenantOperationServiceTests.cs @@ -0,0 +1,90 @@ +using ErrorOr; +using Finbuckle.MultiTenant.Abstractions; +using Idmt.Plugin.Errors; +using Idmt.Plugin.Models; +using Idmt.Plugin.Services; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Idmt.UnitTests.Services; + +public class TenantOperationServiceTests +{ + private readonly Mock> _tenantStoreMock; + private readonly Mock _tenantContextSetterMock; + private readonly TenantOperationService _service; + + public TenantOperationServiceTests() + { + _tenantStoreMock = new Mock>(); + _tenantContextSetterMock = new Mock(); + + var services = new ServiceCollection(); + services.AddSingleton(_tenantStoreMock.Object); + services.AddSingleton(_tenantContextSetterMock.Object); + var serviceProvider = services.BuildServiceProvider(); + + _service = new TenantOperationService(serviceProvider); + } + + [Fact] + public async Task ExecuteInTenantScopeAsync_ReturnsTenantNotFound_WhenTenantDoesNotExist() + { + _tenantStoreMock.Setup(x => x.GetByIdentifierAsync("nonexistent")) + .ReturnsAsync((IdmtTenantInfo?)null); + + var result = await _service.ExecuteInTenantScopeAsync("nonexistent", + _ => Task.FromResult>(Result.Success)); + + Assert.True(result.IsError); + Assert.Equal("Tenant.NotFound", result.FirstError.Code); + } + + [Fact] + public async Task ExecuteInTenantScopeAsync_ReturnsTenantInactive_WhenRequireActiveAndTenantInactive() + { + var tenant = new IdmtTenantInfo("inactive-tenant", "inactive-tenant", "Inactive") { IsActive = false }; + _tenantStoreMock.Setup(x => x.GetByIdentifierAsync("inactive-tenant")) + .ReturnsAsync(tenant); + + var result = await _service.ExecuteInTenantScopeAsync("inactive-tenant", + _ => Task.FromResult>(Result.Success), + requireActive: true); + + Assert.True(result.IsError); + Assert.Equal("Tenant.Inactive", result.FirstError.Code); + } + + [Fact] + public async Task ExecuteInTenantScopeAsync_AllowsExecution_WhenRequireActiveFalseAndTenantInactive() + { + var tenant = new IdmtTenantInfo("inactive-tenant", "inactive-tenant", "Inactive") { IsActive = false }; + _tenantStoreMock.Setup(x => x.GetByIdentifierAsync("inactive-tenant")) + .ReturnsAsync(tenant); + + var result = await _service.ExecuteInTenantScopeAsync("inactive-tenant", + _ => Task.FromResult>(Result.Success), + requireActive: false); + + Assert.False(result.IsError); + } + + [Fact] + public async Task ExecuteInTenantScopeAsync_SetsTenantContext_BeforeCallingOperation() + { + var tenant = new IdmtTenantInfo("test-tenant", "test-tenant", "Test") { IsActive = true }; + _tenantStoreMock.Setup(x => x.GetByIdentifierAsync("test-tenant")) + .ReturnsAsync(tenant); + + IMultiTenantContext? capturedContext = null; + _tenantContextSetterMock.SetupSet(x => x.MultiTenantContext = It.IsAny()) + .Callback(ctx => capturedContext = ctx); + + var result = await _service.ExecuteInTenantScopeAsync("test-tenant", + _ => Task.FromResult>(Result.Success)); + + Assert.False(result.IsError); + Assert.NotNull(capturedContext); + Assert.Equal("test-tenant", capturedContext!.TenantInfo?.Identifier); + } +} diff --git a/src/tests/Idmt.UnitTests/Validation/FluentValidatorTests.cs b/src/tests/Idmt.UnitTests/Validation/FluentValidatorTests.cs new file mode 100644 index 0000000..c14b4a7 --- /dev/null +++ b/src/tests/Idmt.UnitTests/Validation/FluentValidatorTests.cs @@ -0,0 +1,217 @@ +using FluentValidation.TestHelper; +using Idmt.Plugin.Configuration; +using Idmt.Plugin.Features.Admin; +using Idmt.Plugin.Features.Auth; +using Idmt.Plugin.Features.Manage; +using Idmt.Plugin.Validation; +using Microsoft.Extensions.Options; +using Moq; + +namespace Idmt.UnitTests.Validation; + +public class FluentValidatorTests +{ + private static IOptions DefaultOptions() + { + var mock = new Mock>(); + mock.Setup(x => x.Value).Returns(new IdmtOptions()); + return mock.Object; + } + + #region LoginRequestValidator + + [Fact] + public void LoginRequestValidator_Fails_WhenNeitherEmailNorUsername() + { + var validator = new LoginRequestValidator(); + var request = new Login.LoginRequest { Password = "Test1234!" }; + var result = validator.TestValidate(request); + Assert.False(result.IsValid); + } + + [Fact] + public void LoginRequestValidator_Passes_WithEmail() + { + var validator = new LoginRequestValidator(); + var request = new Login.LoginRequest { Email = "user@example.com", Password = "Test1234!" }; + var result = validator.TestValidate(request); + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void LoginRequestValidator_Passes_WithUsername() + { + var validator = new LoginRequestValidator(); + var request = new Login.LoginRequest { Username = "testuser", Password = "Test1234!" }; + var result = validator.TestValidate(request); + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void LoginRequestValidator_Fails_WithEmptyPassword() + { + var validator = new LoginRequestValidator(); + var request = new Login.LoginRequest { Email = "user@example.com", Password = "" }; + var result = validator.TestValidate(request); + result.ShouldHaveValidationErrorFor(x => x.Password); + } + + #endregion + + #region CreateTenantRequestValidator + + [Fact] + public void CreateTenantRequestValidator_Fails_WithUppercaseIdentifier() + { + var validator = new CreateTenantRequestValidator(); + var request = new CreateTenant.CreateTenantRequest("UPPERCASE", "Name"); + var result = validator.TestValidate(request); + result.ShouldHaveValidationErrorFor(x => x.Identifier); + } + + [Fact] + public void CreateTenantRequestValidator_Passes_WithValidData() + { + var validator = new CreateTenantRequestValidator(); + var request = new CreateTenant.CreateTenantRequest("valid-tenant_1", "Name"); + var result = validator.TestValidate(request); + result.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void CreateTenantRequestValidator_Fails_WithEmptyIdentifier() + { + var validator = new CreateTenantRequestValidator(); + var request = new CreateTenant.CreateTenantRequest("", "Name"); + var result = validator.TestValidate(request); + result.ShouldHaveValidationErrorFor(x => x.Identifier); + } + + #endregion + + #region RegisterUserRequestValidator + + [Fact] + public void RegisterUserRequestValidator_Fails_WithInvalidEmail() + { + var validator = new RegisterUserRequestValidator(DefaultOptions()); + var request = new RegisterUser.RegisterUserRequest { Email = "not-an-email", Role = "Admin" }; + var result = validator.TestValidate(request); + result.ShouldHaveValidationErrorFor(x => x.Email); + } + + [Fact] + public void RegisterUserRequestValidator_Fails_WithDisallowedUsernameChars() + { + var options = DefaultOptions(); + var validator = new RegisterUserRequestValidator(options); + var request = new RegisterUser.RegisterUserRequest + { + Email = "user@example.com", + Username = "user