diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserPasswordService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserPasswordService.cs new file mode 100644 index 0000000000..4c0f3b37c4 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserPasswordService.cs @@ -0,0 +1,22 @@ +namespace FSH.Modules.Identity.Contracts.Services; + +/// +/// Service for user password operations. +/// +public interface IUserPasswordService +{ + /// + /// Initiates the forgot password flow by sending a reset email. + /// + Task ForgotPasswordAsync(string email, string origin, CancellationToken cancellationToken); + + /// + /// Resets a user's password using a token. + /// + Task ResetPasswordAsync(string email, string password, string token, CancellationToken cancellationToken); + + /// + /// Changes the current user's password. + /// + Task ChangePasswordAsync(string password, string newPassword, string confirmNewPassword, string userId); +} diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserPermissionService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserPermissionService.cs new file mode 100644 index 0000000000..4e1ecd314d --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserPermissionService.cs @@ -0,0 +1,22 @@ +namespace FSH.Modules.Identity.Contracts.Services; + +/// +/// Service for user permission operations. +/// +public interface IUserPermissionService +{ + /// + /// Gets all permissions for a user. + /// + Task?> GetPermissionsAsync(string userId, CancellationToken cancellationToken); + + /// + /// Checks if a user has a specific permission. + /// + Task HasPermissionAsync(string userId, string permission, CancellationToken cancellationToken = default); + + /// + /// Invalidates the permission cache for a user. + /// + Task InvalidatePermissionCacheAsync(string userId, CancellationToken cancellationToken); +} diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserProfileService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserProfileService.cs new file mode 100644 index 0000000000..747f50d54e --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserProfileService.cs @@ -0,0 +1,45 @@ +using FSH.Framework.Shared.Storage; +using FSH.Modules.Identity.Contracts.DTOs; + +namespace FSH.Modules.Identity.Contracts.Services; + +/// +/// Service for user profile operations. +/// +public interface IUserProfileService +{ + /// + /// Gets a user by ID. + /// + Task GetAsync(string userId, CancellationToken cancellationToken); + + /// + /// Gets all users. + /// + Task> GetListAsync(CancellationToken cancellationToken); + + /// + /// Gets the total user count. + /// + Task GetCountAsync(CancellationToken cancellationToken); + + /// + /// Updates a user's profile. + /// + Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage); + + /// + /// Checks if a user exists with the given email. + /// + Task ExistsWithEmailAsync(string email, string? exceptId = null); + + /// + /// Checks if a user exists with the given username. + /// + Task ExistsWithNameAsync(string name); + + /// + /// Checks if a user exists with the given phone number. + /// + Task ExistsWithPhoneNumberAsync(string phoneNumber, string? exceptId = null); +} diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserRegistrationService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserRegistrationService.cs new file mode 100644 index 0000000000..ca5c4a6cda --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserRegistrationService.cs @@ -0,0 +1,38 @@ +using System.Security.Claims; + +namespace FSH.Modules.Identity.Contracts.Services; + +/// +/// Service for user registration and external authentication. +/// +public interface IUserRegistrationService +{ + /// + /// Registers a new user with password. + /// + Task RegisterAsync( + string firstName, + string lastName, + string email, + string userName, + string password, + string confirmPassword, + string phoneNumber, + string origin, + CancellationToken cancellationToken); + + /// + /// Gets or creates a user from an external authentication principal. + /// + Task GetOrCreateFromPrincipalAsync(ClaimsPrincipal principal); + + /// + /// Confirms a user's email address. + /// + Task ConfirmEmailAsync(string userId, string code, string tenant, CancellationToken cancellationToken); + + /// + /// Confirms a user's phone number. + /// + Task ConfirmPhoneNumberAsync(string userId, string code); +} diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserRoleService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserRoleService.cs new file mode 100644 index 0000000000..5ea90cc768 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserRoleService.cs @@ -0,0 +1,19 @@ +using FSH.Modules.Identity.Contracts.DTOs; + +namespace FSH.Modules.Identity.Contracts.Services; + +/// +/// Service for user role management. +/// +public interface IUserRoleService +{ + /// + /// Assigns roles to a user. + /// + Task AssignRolesAsync(string userId, List userRoles, CancellationToken cancellationToken); + + /// + /// Gets all roles for a user. + /// + Task> GetUserRolesAsync(string userId, CancellationToken cancellationToken); +} diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserStatusService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserStatusService.cs new file mode 100644 index 0000000000..9c8213bb60 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/IUserStatusService.cs @@ -0,0 +1,17 @@ +namespace FSH.Modules.Identity.Contracts.Services; + +/// +/// Service for user status and lifecycle operations. +/// +public interface IUserStatusService +{ + /// + /// Toggles a user's active status. + /// + Task ToggleStatusAsync(bool activateUser, string userId, CancellationToken cancellationToken); + + /// + /// Soft-deletes a user by deactivating them. + /// + Task DeleteAsync(string userId); +} diff --git a/src/Modules/Identity/Modules.Identity/IdentityModule.cs b/src/Modules/Identity/Modules.Identity/IdentityModule.cs index 2afb023cb4..da3ea97e04 100644 --- a/src/Modules/Identity/Modules.Identity/IdentityModule.cs +++ b/src/Modules/Identity/Modules.Identity/IdentityModule.cs @@ -77,7 +77,18 @@ public void ConfigureServices(IHostApplicationBuilder builder) services.AddScoped(); services.AddScoped(); services.AddScoped(sp => (ICurrentUserInitializer)sp.GetRequiredService()); + + // User services - focused single-responsibility services + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // Facade for backward compatibility services.AddTransient(); + services.AddTransient(); services.AddHeroStorage(builder.Configuration); services.AddScoped(); diff --git a/src/Modules/Identity/Modules.Identity/Services/UserService.Password.cs b/src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs similarity index 73% rename from src/Modules/Identity/Modules.Identity/Services/UserService.Password.cs rename to src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs index cbf0177ab1..bf897bfc6d 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserService.Password.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs @@ -1,12 +1,27 @@ -using FSH.Framework.Core.Exceptions; +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Core.Exceptions; +using FSH.Framework.Jobs.Services; using FSH.Framework.Mailing; +using FSH.Framework.Mailing.Services; +using FSH.Framework.Shared.Multitenancy; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Domain; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.WebUtilities; using System.Collections.ObjectModel; using System.Text; namespace FSH.Modules.Identity.Services; -internal sealed partial class UserService +internal sealed class UserPasswordService( + UserManager userManager, + IdentityDbContext db, + IJobService jobService, + IMailService mailService, + IMultiTenantContextAccessor multiTenantContextAccessor, + IPasswordHistoryService passwordHistoryService, + IPasswordExpiryService passwordExpiryService) : IUserPasswordService { public async Task ForgotPasswordAsync(string email, string origin, CancellationToken cancellationToken) { @@ -80,9 +95,17 @@ public async Task ChangePasswordAsync(string password, string newPassword, strin await db.SaveChangesAsync(); // Update password expiry date - await _passwordExpiryService.UpdateLastPasswordChangeDateAsync(userId); + await passwordExpiryService.UpdateLastPasswordChangeDateAsync(userId); // Save to history - await _passwordHistoryService.SavePasswordHistoryAsync(userId); + await passwordHistoryService.SavePasswordHistoryAsync(userId); } -} \ No newline at end of file + + private void EnsureValidTenant() + { + if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) + { + throw new UnauthorizedException("invalid tenant"); + } + } +} diff --git a/src/Modules/Identity/Modules.Identity/Services/UserService.Permissions.cs b/src/Modules/Identity/Modules.Identity/Services/UserPermissionService.cs similarity index 83% rename from src/Modules/Identity/Modules.Identity/Services/UserService.Permissions.cs rename to src/Modules/Identity/Modules.Identity/Services/UserPermissionService.cs index b936de9cf6..03224ce6f3 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserService.Permissions.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserPermissionService.cs @@ -1,11 +1,19 @@ -using FSH.Framework.Caching; +using FSH.Framework.Caching; using FSH.Framework.Core.Exceptions; using FSH.Framework.Shared.Constants; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Domain; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; namespace FSH.Modules.Identity.Services; -internal sealed partial class UserService +internal sealed class UserPermissionService( + UserManager userManager, + RoleManager roleManager, + IdentityDbContext db, + ICacheService cache) : IUserPermissionService { public async Task?> GetPermissionsAsync(string userId, CancellationToken cancellationToken) { @@ -51,4 +59,4 @@ public Task InvalidatePermissionCacheAsync(string userId, CancellationToken canc { return cache.RemoveItemAsync(GetPermissionCacheKey(userId), cancellationToken); } -} \ No newline at end of file +} diff --git a/src/Modules/Identity/Modules.Identity/Services/UserService.Profile.cs b/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs similarity index 64% rename from src/Modules/Identity/Modules.Identity/Services/UserService.Profile.cs rename to src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs index 9d39b12640..9bec283971 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserService.Profile.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserProfileService.cs @@ -1,14 +1,30 @@ +using Finbuckle.MultiTenant.Abstractions; using FSH.Framework.Core.Exceptions; +using FSH.Framework.Shared.Multitenancy; using FSH.Framework.Shared.Storage; using FSH.Framework.Storage; +using FSH.Framework.Storage.Services; +using FSH.Framework.Web.Origin; using FSH.Modules.Identity.Contracts.DTOs; +using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Domain; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace FSH.Modules.Identity.Services; -internal sealed partial class UserService +internal sealed class UserProfileService( + UserManager userManager, + SignInManager signInManager, + IStorageService storageService, + IMultiTenantContextAccessor multiTenantContextAccessor, + IOptions originOptions, + IHttpContextAccessor httpContextAccessor) : IUserProfileService { + private readonly Uri? _originUrl = originOptions.Value.OriginUrl; + public async Task GetAsync(string userId, CancellationToken cancellationToken) { var user = await userManager.Users @@ -105,4 +121,43 @@ public async Task ExistsWithPhoneNumberAsync(string phoneNumber, string? e EnsureValidTenant(); return await userManager.Users.FirstOrDefaultAsync(x => x.PhoneNumber == phoneNumber) is FshUser user && user.Id != exceptId; } + + private void EnsureValidTenant() + { + if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) + { + throw new UnauthorizedException("invalid tenant"); + } + } + + private string? ResolveImageUrl(Uri? imageUrl) + { + if (imageUrl is null) + { + return null; + } + + // Absolute URLs (e.g., S3) pass through unchanged. + if (imageUrl.IsAbsoluteUri) + { + return imageUrl.ToString(); + } + + // For relative paths from local storage, prefix with the API origin and wwwroot. + if (_originUrl is null) + { + var request = httpContextAccessor.HttpContext?.Request; + if (request is not null && !string.IsNullOrWhiteSpace(request.Scheme) && request.Host.HasValue) + { + var baseUri = $"{request.Scheme}://{request.Host.Value}{request.PathBase}"; + var relativePath = imageUrl.ToString().TrimStart('/'); + return $"{baseUri.TrimEnd('/')}/{relativePath}"; + } + + return imageUrl.ToString(); + } + + var originRelativePath = imageUrl.ToString().TrimStart('/'); + return $"{_originUrl.AbsoluteUri.TrimEnd('/')}/{originRelativePath}"; + } } diff --git a/src/Modules/Identity/Modules.Identity/Services/UserService.Registration.cs b/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs similarity index 82% rename from src/Modules/Identity/Modules.Identity/Services/UserService.Registration.cs rename to src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs index f60edf4c01..1feac300fd 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserService.Registration.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs @@ -1,19 +1,33 @@ +using Finbuckle.MultiTenant.Abstractions; using FSH.Framework.Core.Common; using FSH.Framework.Core.Exceptions; +using FSH.Framework.Eventing.Outbox; +using FSH.Framework.Jobs.Services; using FSH.Framework.Mailing; +using FSH.Framework.Mailing.Services; using FSH.Framework.Shared.Constants; using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Identity.Contracts.Events; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Data; using FSH.Modules.Identity.Domain; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.WebUtilities; using Microsoft.EntityFrameworkCore; using System.Collections.ObjectModel; +using System.Globalization; using System.Security.Claims; using System.Text; namespace FSH.Modules.Identity.Services; -internal sealed partial class UserService +internal sealed class UserRegistrationService( + UserManager userManager, + IdentityDbContext db, + IJobService jobService, + IMailService mailService, + IMultiTenantContextAccessor multiTenantContextAccessor, + IOutboxStore outboxStore) : IUserRegistrationService { public async Task GetOrCreateFromPrincipalAsync(ClaimsPrincipal principal) { @@ -35,6 +49,71 @@ public async Task GetOrCreateFromPrincipalAsync(ClaimsPrincipal principa return user.Id; } + public async Task RegisterAsync( + string firstName, + string lastName, + string email, + string userName, + string password, + string confirmPassword, + string phoneNumber, + string origin, + CancellationToken cancellationToken) + { + ValidatePasswordMatch(password, confirmPassword); + + var user = await CreateUserWithPasswordAsync(firstName, lastName, email, userName, password, phoneNumber); + await AssignDefaultRoleAndGroupsAsync(user, "System", cancellationToken); + await SendConfirmationEmailAsync(user, origin, cancellationToken); + await PublishUserRegisteredAsync(user, "Identity", cancellationToken); + + return user.Id; + } + + public async Task ConfirmEmailAsync(string userId, string code, string tenant, CancellationToken cancellationToken) + { + EnsureValidTenant(); + + var user = await userManager.Users + .Where(u => u.Id == userId && !u.EmailConfirmed) + .FirstOrDefaultAsync(cancellationToken); + + _ = user ?? throw new CustomException("An error occurred while confirming E-Mail."); + + code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code)); + var result = await userManager.ConfirmEmailAsync(user, code); + + return result.Succeeded + ? string.Format(CultureInfo.InvariantCulture, "Account Confirmed for E-Mail {0}. You can now use the /api/tokens endpoint to generate JWT.", user.Email) + : throw new CustomException(string.Format(CultureInfo.InvariantCulture, "An error occurred while confirming {0}", user.Email)); + } + + public async Task ConfirmPhoneNumberAsync(string userId, string code) + { + EnsureValidTenant(); + + var user = await userManager.Users + .Where(u => u.Id == userId && !u.PhoneNumberConfirmed) + .FirstOrDefaultAsync(); + + _ = user ?? throw new CustomException("An error occurred while confirming phone number."); + + code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code)); + var result = await userManager.ChangePhoneNumberAsync(user, user.PhoneNumber!, code); + + return result.Succeeded + ? string.Format(CultureInfo.InvariantCulture, "Phone number {0} confirmed successfully.", user.PhoneNumber) + : throw new CustomException(string.Format(CultureInfo.InvariantCulture, "An error occurred while confirming phone number {0}", user.PhoneNumber)); + } + + private void EnsureValidTenant() + { + if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) + { + throw new UnauthorizedException("invalid tenant"); + } + } + private static string ExtractEmailFromPrincipal(ClaimsPrincipal principal) { return principal.FindFirstValue(ClaimTypes.Email) @@ -96,27 +175,6 @@ private async Task EnsureUniqueUserNameAsync(string userName) return userName; } - public async Task RegisterAsync( - string firstName, - string lastName, - string email, - string userName, - string password, - string confirmPassword, - string phoneNumber, - string origin, - CancellationToken cancellationToken) - { - ValidatePasswordMatch(password, confirmPassword); - - var user = await CreateUserWithPasswordAsync(firstName, lastName, email, userName, password, phoneNumber); - await AssignDefaultRoleAndGroupsAsync(user, "System", cancellationToken); - await SendConfirmationEmailAsync(user, origin, cancellationToken); - await PublishUserRegisteredAsync(user, "Identity", cancellationToken); - - return user.Id; - } - private static void ValidatePasswordMatch(string password, string confirmPassword) { if (password != confirmPassword) diff --git a/src/Modules/Identity/Modules.Identity/Services/UserService.Roles.cs b/src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs similarity index 83% rename from src/Modules/Identity/Modules.Identity/Services/UserService.Roles.cs rename to src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs index 2d80ddaf5a..935faa18f8 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserService.Roles.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs @@ -1,12 +1,21 @@ +using Finbuckle.MultiTenant.Abstractions; using FSH.Framework.Core.Exceptions; using FSH.Framework.Shared.Constants; using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Identity.Contracts.DTOs; +using FSH.Modules.Identity.Contracts.Services; +using FSH.Modules.Identity.Data; +using FSH.Modules.Identity.Domain; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; namespace FSH.Modules.Identity.Services; -internal sealed partial class UserService +internal sealed class UserRoleService( + UserManager userManager, + RoleManager roleManager, + IdentityDbContext db, + IMultiTenantContextAccessor multiTenantContextAccessor) : IUserRoleService { public async Task AssignRolesAsync(string userId, List userRoles, CancellationToken cancellationToken) { @@ -24,7 +33,30 @@ public async Task AssignRolesAsync(string userId, List user return "User Roles Updated Successfully."; } - private async Task ValidateAdminRoleChangeAsync(Domain.FshUser user, List userRoles) + public async Task> GetUserRolesAsync(string userId, CancellationToken cancellationToken) + { + var user = await userManager.FindByIdAsync(userId) + ?? throw new NotFoundException("user not found"); + + var roles = await roleManager.Roles.AsNoTracking().ToListAsync(cancellationToken) + ?? throw new NotFoundException("roles not found"); + + var userRoles = new List(); + foreach (var role in roles) + { + userRoles.Add(new UserRoleDto + { + RoleId = role.Id, + RoleName = role.Name, + Description = role.Description, + Enabled = await userManager.IsInRoleAsync(user, role.Name!) + }); + } + + return userRoles; + } + + private async Task ValidateAdminRoleChangeAsync(FshUser user, List userRoles) { bool isRemovingAdminRole = userRoles.Exists(a => !a.Enabled && a.RoleName == RoleConstants.Admin); if (!isRemovingAdminRole) @@ -46,7 +78,7 @@ private async Task ValidateAdminRoleChangeAsync(Domain.FshUser user, List> ProcessRoleAssignmentsAsync(Domain.FshUser user, List userRoles) + private async Task> ProcessRoleAssignmentsAsync(FshUser user, List userRoles) { var assignedRoles = new List(); @@ -89,7 +121,7 @@ private async Task> ProcessRoleAssignmentsAsync(Domain.FshUser user return assignedRoles; } - private async Task RaiseRolesAssignedEventAsync(Domain.FshUser user, List assignedRoles, CancellationToken cancellationToken) + private async Task RaiseRolesAssignedEventAsync(FshUser user, List assignedRoles, CancellationToken cancellationToken) { if (assignedRoles.Count == 0) { @@ -100,27 +132,4 @@ private async Task RaiseRolesAssignedEventAsync(Domain.FshUser user, List> GetUserRolesAsync(string userId, CancellationToken cancellationToken) - { - var user = await userManager.FindByIdAsync(userId) - ?? throw new NotFoundException("user not found"); - - var roles = await roleManager.Roles.AsNoTracking().ToListAsync(cancellationToken) - ?? throw new NotFoundException("roles not found"); - - var userRoles = new List(); - foreach (var role in roles) - { - userRoles.Add(new UserRoleDto - { - RoleId = role.Id, - RoleName = role.Name, - Description = role.Description, - Enabled = await userManager.IsInRoleAsync(user, role.Name!) - }); - } - - return userRoles; - } } diff --git a/src/Modules/Identity/Modules.Identity/Services/UserService.Confirmation.cs b/src/Modules/Identity/Modules.Identity/Services/UserService.Confirmation.cs deleted file mode 100644 index f0dd199431..0000000000 --- a/src/Modules/Identity/Modules.Identity/Services/UserService.Confirmation.cs +++ /dev/null @@ -1,46 +0,0 @@ -using FSH.Framework.Core.Exceptions; -using Microsoft.AspNetCore.WebUtilities; -using Microsoft.EntityFrameworkCore; -using System.Globalization; -using System.Text; - -namespace FSH.Modules.Identity.Services; - -internal sealed partial class UserService -{ - public async Task ConfirmEmailAsync(string userId, string code, string tenant, CancellationToken cancellationToken) - { - EnsureValidTenant(); - - var user = await userManager.Users - .Where(u => u.Id == userId && !u.EmailConfirmed) - .FirstOrDefaultAsync(cancellationToken); - - _ = user ?? throw new CustomException("An error occurred while confirming E-Mail."); - - code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code)); - var result = await userManager.ConfirmEmailAsync(user, code); - - return result.Succeeded - ? string.Format(CultureInfo.InvariantCulture, "Account Confirmed for E-Mail {0}. You can now use the /api/tokens endpoint to generate JWT.", user.Email) - : throw new CustomException(string.Format(CultureInfo.InvariantCulture, "An error occurred while confirming {0}", user.Email)); - } - - public async Task ConfirmPhoneNumberAsync(string userId, string code) - { - EnsureValidTenant(); - - var user = await userManager.Users - .Where(u => u.Id == userId && !u.PhoneNumberConfirmed) - .FirstOrDefaultAsync(); - - _ = user ?? throw new CustomException("An error occurred while confirming phone number."); - - code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code)); - var result = await userManager.ChangePhoneNumberAsync(user, user.PhoneNumber!, code); - - return result.Succeeded - ? string.Format(CultureInfo.InvariantCulture, "Phone number {0} confirmed successfully.", user.PhoneNumber) - : throw new CustomException(string.Format(CultureInfo.InvariantCulture, "An error occurred while confirming phone number {0}", user.PhoneNumber)); - } -} diff --git a/src/Modules/Identity/Modules.Identity/Services/UserService.cs b/src/Modules/Identity/Modules.Identity/Services/UserService.cs index f816629ae7..b3d9b997d2 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserService.cs @@ -1,84 +1,94 @@ -using Finbuckle.MultiTenant.Abstractions; -using FSH.Framework.Caching; -using FSH.Framework.Core.Context; -using FSH.Framework.Eventing.Outbox; -using FSH.Framework.Jobs.Services; -using FSH.Framework.Mailing.Services; -using FSH.Framework.Shared.Multitenancy; -using FSH.Framework.Storage.Services; -using FSH.Framework.Web.Origin; -using FSH.Modules.Auditing.Contracts; +using FSH.Framework.Shared.Storage; +using FSH.Modules.Identity.Contracts.DTOs; using FSH.Modules.Identity.Contracts.Services; -using FSH.Modules.Identity.Data; -using FSH.Modules.Identity.Domain; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.Options; +using System.Security.Claims; namespace FSH.Modules.Identity.Services; -internal sealed partial class UserService( - UserManager userManager, - SignInManager signInManager, - RoleManager roleManager, - IdentityDbContext db, - ICacheService cache, - IJobService jobService, - IMailService mailService, - IMultiTenantContextAccessor multiTenantContextAccessor, - IStorageService storageService, - IOutboxStore outboxStore, - IOptions originOptions, - IHttpContextAccessor httpContextAccessor, - ICurrentUser currentUser, - IAuditClient auditClient, - IPasswordHistoryService passwordHistoryService, - IPasswordExpiryService passwordExpiryService - ) : IUserService +/// +/// Facade service that delegates to focused single-responsibility services. +/// Maintained for backward compatibility with existing consumers. +/// +internal sealed class UserService( + IUserRegistrationService registrationService, + IUserProfileService profileService, + IUserStatusService statusService, + IUserRoleService roleService, + IUserPasswordService passwordService, + IUserPermissionService permissionService) : IUserService { - private readonly Uri? _originUrl = originOptions.Value.OriginUrl; - private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor; - private readonly ICurrentUser _currentUser = currentUser; - private readonly IAuditClient _auditClient = auditClient; - private readonly IPasswordHistoryService _passwordHistoryService = passwordHistoryService; - private readonly IPasswordExpiryService _passwordExpiryService = passwordExpiryService; - - private void EnsureValidTenant() - { - if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) - { - throw new FSH.Framework.Core.Exceptions.UnauthorizedException("invalid tenant"); - } - } - - private string? ResolveImageUrl(Uri? imageUrl) - { - if (imageUrl is null) - { - return null; - } - - // Absolute URLs (e.g., S3) pass through unchanged. - if (imageUrl.IsAbsoluteUri) - { - return imageUrl.ToString(); - } - - // For relative paths from local storage, prefix with the API origin and wwwroot. - if (_originUrl is null) - { - var request = _httpContextAccessor.HttpContext?.Request; - if (request is not null && !string.IsNullOrWhiteSpace(request.Scheme) && request.Host.HasValue) - { - var baseUri = $"{request.Scheme}://{request.Host.Value}{request.PathBase}"; - var relativePath = imageUrl.ToString().TrimStart('/'); - return $"{baseUri.TrimEnd('/')}/{relativePath}"; - } - - return imageUrl.ToString(); - } - - var originRelativePath = imageUrl.ToString().TrimStart('/'); - return $"{_originUrl.AbsoluteUri.TrimEnd('/')}/{originRelativePath}"; - } + // Registration operations (delegated to IUserRegistrationService) + public Task RegisterAsync( + string firstName, + string lastName, + string email, + string userName, + string password, + string confirmPassword, + string phoneNumber, + string origin, + CancellationToken cancellationToken) + => registrationService.RegisterAsync(firstName, lastName, email, userName, password, confirmPassword, phoneNumber, origin, cancellationToken); + + public Task GetOrCreateFromPrincipalAsync(ClaimsPrincipal principal) + => registrationService.GetOrCreateFromPrincipalAsync(principal); + + public Task ConfirmEmailAsync(string userId, string code, string tenant, CancellationToken cancellationToken) + => registrationService.ConfirmEmailAsync(userId, code, tenant, cancellationToken); + + public Task ConfirmPhoneNumberAsync(string userId, string code) + => registrationService.ConfirmPhoneNumberAsync(userId, code); + + // Profile operations (delegated to IUserProfileService) + public Task GetAsync(string userId, CancellationToken cancellationToken) + => profileService.GetAsync(userId, cancellationToken); + + public Task> GetListAsync(CancellationToken cancellationToken) + => profileService.GetListAsync(cancellationToken); + + public Task GetCountAsync(CancellationToken cancellationToken) + => profileService.GetCountAsync(cancellationToken); + + public Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage) + => profileService.UpdateAsync(userId, firstName, lastName, phoneNumber, image, deleteCurrentImage); + + public Task ExistsWithEmailAsync(string email, string? exceptId = null) + => profileService.ExistsWithEmailAsync(email, exceptId); + + public Task ExistsWithNameAsync(string name) + => profileService.ExistsWithNameAsync(name); + + public Task ExistsWithPhoneNumberAsync(string phoneNumber, string? exceptId = null) + => profileService.ExistsWithPhoneNumberAsync(phoneNumber, exceptId); + + // Status operations (delegated to IUserStatusService) + public Task ToggleStatusAsync(bool activateUser, string userId, CancellationToken cancellationToken) + => statusService.ToggleStatusAsync(activateUser, userId, cancellationToken); + + public Task DeleteAsync(string userId) + => statusService.DeleteAsync(userId); + + // Role operations (delegated to IUserRoleService) + public Task AssignRolesAsync(string userId, List userRoles, CancellationToken cancellationToken) + => roleService.AssignRolesAsync(userId, userRoles, cancellationToken); + + public Task> GetUserRolesAsync(string userId, CancellationToken cancellationToken) + => roleService.GetUserRolesAsync(userId, cancellationToken); + + // Password operations (delegated to IUserPasswordService) + public Task ForgotPasswordAsync(string email, string origin, CancellationToken cancellationToken) + => passwordService.ForgotPasswordAsync(email, origin, cancellationToken); + + public Task ResetPasswordAsync(string email, string password, string token, CancellationToken cancellationToken) + => passwordService.ResetPasswordAsync(email, password, token, cancellationToken); + + public Task ChangePasswordAsync(string password, string newPassword, string confirmNewPassword, string userId) + => passwordService.ChangePasswordAsync(password, newPassword, confirmNewPassword, userId); + + // Permission operations (delegated to IUserPermissionService) + public Task?> GetPermissionsAsync(string userId, CancellationToken cancellationToken) + => permissionService.GetPermissionsAsync(userId, cancellationToken); + + public Task HasPermissionAsync(string userId, string permission, CancellationToken cancellationToken = default) + => permissionService.HasPermissionAsync(userId, permission, cancellationToken); } diff --git a/src/Modules/Identity/Modules.Identity/Services/UserService.Lifecycle.cs b/src/Modules/Identity/Modules.Identity/Services/UserStatusService.cs similarity index 88% rename from src/Modules/Identity/Modules.Identity/Services/UserService.Lifecycle.cs rename to src/Modules/Identity/Modules.Identity/Services/UserStatusService.cs index 5cf648492f..4a1ecf9276 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserService.Lifecycle.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserStatusService.cs @@ -1,12 +1,21 @@ +using Finbuckle.MultiTenant.Abstractions; +using FSH.Framework.Core.Context; using FSH.Framework.Core.Exceptions; using FSH.Framework.Shared.Constants; +using FSH.Framework.Shared.Multitenancy; using FSH.Modules.Auditing.Contracts; +using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Domain; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; namespace FSH.Modules.Identity.Services; -internal sealed partial class UserService +internal sealed class UserStatusService( + UserManager userManager, + IMultiTenantContextAccessor multiTenantContextAccessor, + ICurrentUser currentUser, + IAuditClient auditClient) : IUserStatusService { public async Task DeleteAsync(string userId) { @@ -37,12 +46,20 @@ public async Task ToggleStatusAsync(bool activateUser, string userId, Cancellati await SaveAndAuditAsync(context, cancellationToken); } + private void EnsureValidTenant() + { + if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) + { + throw new UnauthorizedException("invalid tenant"); + } + } + private async Task BuildToggleContextAsync( string userId, bool activateUser, CancellationToken cancellationToken) { - var actorId = _currentUser.GetUserId(); + var actorId = currentUser.GetUserId(); if (actorId == Guid.Empty) { throw new UnauthorizedException("authenticated user required to toggle status"); @@ -126,7 +143,7 @@ private async Task SaveAndAuditAsync( throw new CustomException("Toggle status failed", result.Errors.Select(e => e.Description).ToList()); } - await _auditClient.WriteActivityAsync( + await auditClient.WriteActivityAsync( ActivityKind.Command, name: "ToggleUserStatus", statusCode: 204, @@ -154,7 +171,7 @@ private async Task AuditPolicyFailureAsync( ["action"] = context.ActivateUser ? "activate" : "deactivate" }; - await _auditClient.WriteSecurityAsync( + await auditClient.WriteSecurityAsync( SecurityAction.PolicyFailed, subjectId: context.ActorId.ToString(), reasonCode: reason,