From 8d1ea88fd68e3d1de46ab250e229793890da82f1 Mon Sep 17 00:00:00 2001 From: jarvis Date: Mon, 26 Jan 2026 20:38:12 +0000 Subject: [PATCH 1/2] feat(identity): add ICurrentUserService and IRequestContextService interfaces - Create ICurrentUserService interface combining ICurrentUser and ICurrentUserInitializer - Create IRequestContextService interface extending IRequestContext - Update CurrentUserService to implement ICurrentUserService (internal sealed) - Update RequestContextService to implement IRequestContextService (internal sealed) - Update DI registration to register services with their new interfaces - Maintain backward compatibility with existing ICurrentUser and IRequestContext consumers Closes #1180 --- .../Services/ICurrentUserService.cs | 12 ++++++++++++ .../Services/IRequestContextService.cs | 11 +++++++++++ .../Identity/Modules.Identity/IdentityModule.cs | 8 +++++--- .../Modules.Identity/Services/CurrentUserService.cs | 3 ++- .../Services/RequestContextService.cs | 3 ++- 5 files changed, 32 insertions(+), 5 deletions(-) create mode 100644 src/Modules/Identity/Modules.Identity.Contracts/Services/ICurrentUserService.cs create mode 100644 src/Modules/Identity/Modules.Identity.Contracts/Services/IRequestContextService.cs 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..061107b5b5 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(); 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/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; From e92435169e1a0d9d680712c4ae9af18a9f7be6b0 Mon Sep 17 00:00:00 2001 From: jarvis Date: Mon, 26 Jan 2026 20:43:15 +0000 Subject: [PATCH 2/2] fix(identity): reduce SessionService complexity Refactored SessionService.cs to reduce cyclomatic complexity: - Extract DeviceTypeClassifier: Moved device type detection logic to a dedicated static helper class, reducing CC from 9 to 3 (uses LINQ Any) - Add SessionCleanupHostedService: Background service for automated session cleanup (runs hourly, removes sessions expired >30 days) - Register hosted service in IdentityModule for automatic cleanup Changes: - SessionService.cs: ~350 lines (was 372), removed GetDeviceType method - DeviceTypeClassifier.cs: New helper class (~40 lines) - SessionCleanupHostedService.cs: New background service (~65 lines) - IdentityModule.cs: Register SessionCleanupHostedService Build: 0 errors, 0 new warnings Closes #1177 --- .../Modules.Identity/IdentityModule.cs | 3 +- .../Services/DeviceTypeClassifier.cs | 42 +++++++++++ .../Services/SessionCleanupHostedService.cs | 70 +++++++++++++++++++ .../Services/SessionService.cs | 26 +------ 4 files changed, 115 insertions(+), 26 deletions(-) create mode 100644 src/Modules/Identity/Modules.Identity/Services/DeviceTypeClassifier.cs create mode 100644 src/Modules/Identity/Modules.Identity/Services/SessionCleanupHostedService.cs diff --git a/src/Modules/Identity/Modules.Identity/IdentityModule.cs b/src/Modules/Identity/Modules.Identity/IdentityModule.cs index 061107b5b5..c976615f2f 100644 --- a/src/Modules/Identity/Modules.Identity/IdentityModule.cs +++ b/src/Modules/Identity/Modules.Identity/IdentityModule.cs @@ -113,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/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/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