diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/ICurrentUserService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/ICurrentUserService.cs new file mode 100644 index 0000000000..b32c14fe53 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/ICurrentUserService.cs @@ -0,0 +1,12 @@ +using System.Security.Claims; +using FSH.Framework.Core.Context; + +namespace FSH.Modules.Identity.Contracts.Services; + +/// +/// Service interface for managing the current user context. +/// Combines user identity access with initialization capabilities. +/// +public interface ICurrentUserService : ICurrentUser, ICurrentUserInitializer +{ +} diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Services/IRequestContextService.cs b/src/Modules/Identity/Modules.Identity.Contracts/Services/IRequestContextService.cs new file mode 100644 index 0000000000..3bf9966703 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity.Contracts/Services/IRequestContextService.cs @@ -0,0 +1,11 @@ +using FSH.Framework.Core.Context; + +namespace FSH.Modules.Identity.Contracts.Services; + +/// +/// Service interface for accessing HTTP request context information. +/// Provides request metadata for auditing, logging, and other cross-cutting concerns. +/// +public interface IRequestContextService : IRequestContext +{ +} diff --git a/src/Modules/Identity/Modules.Identity/IdentityModule.cs b/src/Modules/Identity/Modules.Identity/IdentityModule.cs index da3ea97e04..c976615f2f 100644 --- a/src/Modules/Identity/Modules.Identity/IdentityModule.cs +++ b/src/Modules/Identity/Modules.Identity/IdentityModule.cs @@ -73,10 +73,12 @@ public void ConfigureServices(IHostApplicationBuilder builder) ArgumentNullException.ThrowIfNull(builder); var services = builder.Services; services.AddSingleton(); - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); services.AddScoped(); - services.AddScoped(sp => (ICurrentUserInitializer)sp.GetRequiredService()); // User services - focused single-responsibility services services.AddTransient(); @@ -111,8 +113,9 @@ public void ConfigureServices(IHostApplicationBuilder builder) // Register password expiry service services.AddScoped(); - // Register session service + // Register session service and background cleanup services.AddScoped(); + services.AddHostedService(); // Register group role service for group-derived permissions services.AddScoped(); diff --git a/src/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs b/src/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs index 888d251e57..2fa5ff12d3 100644 --- a/src/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/CurrentUserService.cs @@ -1,11 +1,12 @@ using FSH.Framework.Core.Context; using FSH.Framework.Core.Exceptions; using FSH.Framework.Shared.Identity.Claims; +using FSH.Modules.Identity.Contracts.Services; using System.Security.Claims; namespace FSH.Modules.Identity.Services; -public class CurrentUserService : ICurrentUser, ICurrentUserInitializer +internal sealed class CurrentUserService : ICurrentUserService { private ClaimsPrincipal? _user; diff --git a/src/Modules/Identity/Modules.Identity/Services/DeviceTypeClassifier.cs b/src/Modules/Identity/Modules.Identity/Services/DeviceTypeClassifier.cs new file mode 100644 index 0000000000..e2f92adff7 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Services/DeviceTypeClassifier.cs @@ -0,0 +1,42 @@ +namespace FSH.Modules.Identity.Services; + +/// +/// Classifies device types based on user agent device family strings. +/// Extracted from SessionService to reduce cyclomatic complexity. +/// +public static class DeviceTypeClassifier +{ + private const string Desktop = "Desktop"; + private const string Mobile = "Mobile"; + private const string Tablet = "Tablet"; + + private static readonly string[] MobileKeywords = ["mobile", "phone", "iphone", "android"]; + private static readonly string[] TabletKeywords = ["tablet", "ipad"]; + + /// + /// Determines the device type from a user agent device family string. + /// + /// The device family string from user agent parsing. + /// Device type: "Desktop", "Mobile", or "Tablet". + public static string Classify(string? deviceFamily) + { + if (string.IsNullOrWhiteSpace(deviceFamily) || deviceFamily == "Other") + { + return Desktop; + } + + var normalized = deviceFamily.ToLowerInvariant(); + + if (MobileKeywords.Any(keyword => normalized.Contains(keyword, StringComparison.Ordinal))) + { + return Mobile; + } + + if (TabletKeywords.Any(keyword => normalized.Contains(keyword, StringComparison.Ordinal))) + { + return Tablet; + } + + return Desktop; + } +} diff --git a/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs b/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs index 8184f2b112..a59b399660 100644 --- a/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs @@ -1,5 +1,6 @@ using FSH.Framework.Core.Context; using FSH.Framework.Web.Origin; +using FSH.Modules.Identity.Contracts.Services; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; @@ -9,7 +10,7 @@ namespace FSH.Modules.Identity.Services; /// Provides HTTP request context information through an abstraction. /// This allows handlers to access request metadata without direct ASP.NET Core dependencies. /// -public sealed class RequestContextService : IRequestContext +internal sealed class RequestContextService : IRequestContextService { private readonly IHttpContextAccessor _httpContextAccessor; private readonly Uri? _originUrl; diff --git a/src/Modules/Identity/Modules.Identity/Services/SessionCleanupHostedService.cs b/src/Modules/Identity/Modules.Identity/Services/SessionCleanupHostedService.cs new file mode 100644 index 0000000000..8f6348b216 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Services/SessionCleanupHostedService.cs @@ -0,0 +1,70 @@ +using FSH.Modules.Identity.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace FSH.Modules.Identity.Services; + +/// +/// Background service that periodically cleans up expired sessions. +/// Runs every hour and removes sessions that have been expired for more than 30 days. +/// +public sealed class SessionCleanupHostedService : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(1); + private readonly int _retentionDays = 30; + + public SessionCleanupHostedService( + IServiceScopeFactory scopeFactory, + ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Session cleanup service started"); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await Task.Delay(_cleanupInterval, stoppingToken); + await CleanupExpiredSessionsAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Expected during shutdown + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during session cleanup"); + } + } + + _logger.LogInformation("Session cleanup service stopped"); + } + + private async Task CleanupExpiredSessionsAsync(CancellationToken cancellationToken) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var cutoffDate = DateTime.UtcNow.AddDays(-_retentionDays); + var expiredSessions = await db.UserSessions + .Where(s => s.ExpiresAt < DateTime.UtcNow && s.ExpiresAt < cutoffDate) + .ToListAsync(cancellationToken); + + if (expiredSessions.Count > 0) + { + db.UserSessions.RemoveRange(expiredSessions); + await db.SaveChangesAsync(cancellationToken); + _logger.LogInformation("Cleaned up {Count} expired sessions", expiredSessions.Count); + } + } +} diff --git a/src/Modules/Identity/Modules.Identity/Services/SessionService.cs b/src/Modules/Identity/Modules.Identity/Services/SessionService.cs index a116c6b68b..a16dd9baaa 100644 --- a/src/Modules/Identity/Modules.Identity/Services/SessionService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/SessionService.cs @@ -58,7 +58,7 @@ public async Task CreateSessionAsync( ipAddress: ipAddress, userAgent: userAgent, expiresAt: expiresAt, - deviceType: GetDeviceType(clientInfo.Device.Family), + deviceType: DeviceTypeClassifier.Classify(clientInfo.Device.Family), browser: clientInfo.UA.Family, browserVersion: clientInfo.UA.Major, operatingSystem: clientInfo.OS.Family, @@ -328,30 +328,6 @@ public async Task CleanupExpiredSessionsAsync( } } - private static string GetDeviceType(string deviceFamily) - { - if (string.IsNullOrWhiteSpace(deviceFamily) || deviceFamily == "Other") - { - return "Desktop"; - } - - if (deviceFamily.Contains("mobile", StringComparison.OrdinalIgnoreCase) || - deviceFamily.Contains("phone", StringComparison.OrdinalIgnoreCase) || - deviceFamily.Contains("iphone", StringComparison.OrdinalIgnoreCase) || - deviceFamily.Contains("android", StringComparison.OrdinalIgnoreCase)) - { - return "Mobile"; - } - - if (deviceFamily.Contains("tablet", StringComparison.OrdinalIgnoreCase) || - deviceFamily.Contains("ipad", StringComparison.OrdinalIgnoreCase)) - { - return "Tablet"; - } - - return "Desktop"; - } - private static UserSessionDto MapToDto(UserSession session, bool isCurrentSession) { return new UserSessionDto