Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace FSH.Modules.Identity.Contracts.Services;

/// <summary>
/// Service for user password operations.
/// </summary>
public interface IUserPasswordService
{
/// <summary>
/// Initiates the forgot password flow by sending a reset email.
/// </summary>
Task ForgotPasswordAsync(string email, string origin, CancellationToken cancellationToken);

/// <summary>
/// Resets a user's password using a token.
/// </summary>
Task ResetPasswordAsync(string email, string password, string token, CancellationToken cancellationToken);

/// <summary>
/// Changes the current user's password.
/// </summary>
Task ChangePasswordAsync(string password, string newPassword, string confirmNewPassword, string userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace FSH.Modules.Identity.Contracts.Services;

/// <summary>
/// Service for user permission operations.
/// </summary>
public interface IUserPermissionService
{
/// <summary>
/// Gets all permissions for a user.
/// </summary>
Task<List<string>?> GetPermissionsAsync(string userId, CancellationToken cancellationToken);

/// <summary>
/// Checks if a user has a specific permission.
/// </summary>
Task<bool> HasPermissionAsync(string userId, string permission, CancellationToken cancellationToken = default);

/// <summary>
/// Invalidates the permission cache for a user.
/// </summary>
Task InvalidatePermissionCacheAsync(string userId, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using FSH.Framework.Shared.Storage;
using FSH.Modules.Identity.Contracts.DTOs;

namespace FSH.Modules.Identity.Contracts.Services;

/// <summary>
/// Service for user profile operations.
/// </summary>
public interface IUserProfileService
{
/// <summary>
/// Gets a user by ID.
/// </summary>
Task<UserDto> GetAsync(string userId, CancellationToken cancellationToken);

/// <summary>
/// Gets all users.
/// </summary>
Task<List<UserDto>> GetListAsync(CancellationToken cancellationToken);

/// <summary>
/// Gets the total user count.
/// </summary>
Task<int> GetCountAsync(CancellationToken cancellationToken);

/// <summary>
/// Updates a user's profile.
/// </summary>
Task UpdateAsync(string userId, string firstName, string lastName, string phoneNumber, FileUploadRequest image, bool deleteCurrentImage);

/// <summary>
/// Checks if a user exists with the given email.
/// </summary>
Task<bool> ExistsWithEmailAsync(string email, string? exceptId = null);

/// <summary>
/// Checks if a user exists with the given username.
/// </summary>
Task<bool> ExistsWithNameAsync(string name);

/// <summary>
/// Checks if a user exists with the given phone number.
/// </summary>
Task<bool> ExistsWithPhoneNumberAsync(string phoneNumber, string? exceptId = null);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System.Security.Claims;

namespace FSH.Modules.Identity.Contracts.Services;

/// <summary>
/// Service for user registration and external authentication.
/// </summary>
public interface IUserRegistrationService
{
/// <summary>
/// Registers a new user with password.
/// </summary>
Task<string> RegisterAsync(
string firstName,
string lastName,
string email,
string userName,
string password,
string confirmPassword,
string phoneNumber,
string origin,
CancellationToken cancellationToken);

/// <summary>
/// Gets or creates a user from an external authentication principal.
/// </summary>
Task<string> GetOrCreateFromPrincipalAsync(ClaimsPrincipal principal);

/// <summary>
/// Confirms a user's email address.
/// </summary>
Task<string> ConfirmEmailAsync(string userId, string code, string tenant, CancellationToken cancellationToken);

/// <summary>
/// Confirms a user's phone number.
/// </summary>
Task<string> ConfirmPhoneNumberAsync(string userId, string code);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using FSH.Modules.Identity.Contracts.DTOs;

namespace FSH.Modules.Identity.Contracts.Services;

/// <summary>
/// Service for user role management.
/// </summary>
public interface IUserRoleService
{
/// <summary>
/// Assigns roles to a user.
/// </summary>
Task<string> AssignRolesAsync(string userId, List<UserRoleDto> userRoles, CancellationToken cancellationToken);

/// <summary>
/// Gets all roles for a user.
/// </summary>
Task<List<UserRoleDto>> GetUserRolesAsync(string userId, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace FSH.Modules.Identity.Contracts.Services;

/// <summary>
/// Service for user status and lifecycle operations.
/// </summary>
public interface IUserStatusService
{
/// <summary>
/// Toggles a user's active status.
/// </summary>
Task ToggleStatusAsync(bool activateUser, string userId, CancellationToken cancellationToken);

/// <summary>
/// Soft-deletes a user by deactivating them.
/// </summary>
Task DeleteAsync(string userId);
}
11 changes: 11 additions & 0 deletions src/Modules/Identity/Modules.Identity/IdentityModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,18 @@ public void ConfigureServices(IHostApplicationBuilder builder)
services.AddScoped<IRequestContext, RequestContextService>();
services.AddScoped<ITokenService, TokenService>();
services.AddScoped(sp => (ICurrentUserInitializer)sp.GetRequiredService<ICurrentUser>());

// User services - focused single-responsibility services
services.AddTransient<IUserRegistrationService, UserRegistrationService>();
services.AddTransient<IUserProfileService, UserProfileService>();
services.AddTransient<IUserStatusService, UserStatusService>();
services.AddTransient<IUserRoleService, UserRoleService>();
services.AddTransient<IUserPasswordService, UserPasswordService>();
services.AddTransient<IUserPermissionService, UserPermissionService>();

// Facade for backward compatibility
services.AddTransient<IUserService, UserService>();

services.AddTransient<IRoleService, RoleService>();
services.AddHeroStorage(builder.Configuration);
services.AddScoped<IIdentityService, IdentityService>();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<FshUser> userManager,
IdentityDbContext db,
IJobService jobService,
IMailService mailService,
IMultiTenantContextAccessor<AppTenantInfo> multiTenantContextAccessor,
IPasswordHistoryService passwordHistoryService,
IPasswordExpiryService passwordExpiryService) : IUserPasswordService
{
public async Task ForgotPasswordAsync(string email, string origin, CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -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);
}
}

private void EnsureValidTenant()
{
if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id))
{
throw new UnauthorizedException("invalid tenant");
}
}
}
Original file line number Diff line number Diff line change
@@ -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<FshUser> userManager,
RoleManager<FshRole> roleManager,
IdentityDbContext db,
ICacheService cache) : IUserPermissionService
{
public async Task<List<string>?> GetPermissionsAsync(string userId, CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -51,4 +59,4 @@ public Task InvalidatePermissionCacheAsync(string userId, CancellationToken canc
{
return cache.RemoveItemAsync(GetPermissionCacheKey(userId), cancellationToken);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<FshUser> userManager,
SignInManager<FshUser> signInManager,
IStorageService storageService,
IMultiTenantContextAccessor<AppTenantInfo> multiTenantContextAccessor,
IOptions<OriginOptions> originOptions,
IHttpContextAccessor httpContextAccessor) : IUserProfileService
{
private readonly Uri? _originUrl = originOptions.Value.OriginUrl;

public async Task<UserDto> GetAsync(string userId, CancellationToken cancellationToken)
{
var user = await userManager.Users
Expand Down Expand Up @@ -105,4 +121,43 @@ public async Task<bool> 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}";
}
}
Loading
Loading