Skip to content

Commit 2887f64

Browse files
iammukeshmjarvis
andauthored
fix(identity): reduce SessionService complexity (#1194)
* 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 * 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 --------- Co-authored-by: jarvis <jarvis@codewithmukesh.com>
1 parent c813c7e commit 2887f64

4 files changed

Lines changed: 115 additions & 26 deletions

File tree

src/Modules/Identity/Modules.Identity/IdentityModule.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,9 @@ public void ConfigureServices(IHostApplicationBuilder builder)
113113
// Register password expiry service
114114
services.AddScoped<IPasswordExpiryService, PasswordExpiryService>();
115115

116-
// Register session service
116+
// Register session service and background cleanup
117117
services.AddScoped<ISessionService, SessionService>();
118+
services.AddHostedService<SessionCleanupHostedService>();
118119

119120
// Register group role service for group-derived permissions
120121
services.AddScoped<IGroupRoleService, GroupRoleService>();
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
namespace FSH.Modules.Identity.Services;
2+
3+
/// <summary>
4+
/// Classifies device types based on user agent device family strings.
5+
/// Extracted from SessionService to reduce cyclomatic complexity.
6+
/// </summary>
7+
public static class DeviceTypeClassifier
8+
{
9+
private const string Desktop = "Desktop";
10+
private const string Mobile = "Mobile";
11+
private const string Tablet = "Tablet";
12+
13+
private static readonly string[] MobileKeywords = ["mobile", "phone", "iphone", "android"];
14+
private static readonly string[] TabletKeywords = ["tablet", "ipad"];
15+
16+
/// <summary>
17+
/// Determines the device type from a user agent device family string.
18+
/// </summary>
19+
/// <param name="deviceFamily">The device family string from user agent parsing.</param>
20+
/// <returns>Device type: "Desktop", "Mobile", or "Tablet".</returns>
21+
public static string Classify(string? deviceFamily)
22+
{
23+
if (string.IsNullOrWhiteSpace(deviceFamily) || deviceFamily == "Other")
24+
{
25+
return Desktop;
26+
}
27+
28+
var normalized = deviceFamily.ToLowerInvariant();
29+
30+
if (MobileKeywords.Any(keyword => normalized.Contains(keyword, StringComparison.Ordinal)))
31+
{
32+
return Mobile;
33+
}
34+
35+
if (TabletKeywords.Any(keyword => normalized.Contains(keyword, StringComparison.Ordinal)))
36+
{
37+
return Tablet;
38+
}
39+
40+
return Desktop;
41+
}
42+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
using FSH.Modules.Identity.Data;
2+
using Microsoft.EntityFrameworkCore;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using Microsoft.Extensions.Hosting;
5+
using Microsoft.Extensions.Logging;
6+
7+
namespace FSH.Modules.Identity.Services;
8+
9+
/// <summary>
10+
/// Background service that periodically cleans up expired sessions.
11+
/// Runs every hour and removes sessions that have been expired for more than 30 days.
12+
/// </summary>
13+
public sealed class SessionCleanupHostedService : BackgroundService
14+
{
15+
private readonly IServiceScopeFactory _scopeFactory;
16+
private readonly ILogger<SessionCleanupHostedService> _logger;
17+
private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(1);
18+
private readonly int _retentionDays = 30;
19+
20+
public SessionCleanupHostedService(
21+
IServiceScopeFactory scopeFactory,
22+
ILogger<SessionCleanupHostedService> logger)
23+
{
24+
_scopeFactory = scopeFactory;
25+
_logger = logger;
26+
}
27+
28+
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
29+
{
30+
_logger.LogInformation("Session cleanup service started");
31+
32+
while (!stoppingToken.IsCancellationRequested)
33+
{
34+
try
35+
{
36+
await Task.Delay(_cleanupInterval, stoppingToken);
37+
await CleanupExpiredSessionsAsync(stoppingToken);
38+
}
39+
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
40+
{
41+
// Expected during shutdown
42+
break;
43+
}
44+
catch (Exception ex)
45+
{
46+
_logger.LogError(ex, "Error during session cleanup");
47+
}
48+
}
49+
50+
_logger.LogInformation("Session cleanup service stopped");
51+
}
52+
53+
private async Task CleanupExpiredSessionsAsync(CancellationToken cancellationToken)
54+
{
55+
using var scope = _scopeFactory.CreateScope();
56+
var db = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
57+
58+
var cutoffDate = DateTime.UtcNow.AddDays(-_retentionDays);
59+
var expiredSessions = await db.UserSessions
60+
.Where(s => s.ExpiresAt < DateTime.UtcNow && s.ExpiresAt < cutoffDate)
61+
.ToListAsync(cancellationToken);
62+
63+
if (expiredSessions.Count > 0)
64+
{
65+
db.UserSessions.RemoveRange(expiredSessions);
66+
await db.SaveChangesAsync(cancellationToken);
67+
_logger.LogInformation("Cleaned up {Count} expired sessions", expiredSessions.Count);
68+
}
69+
}
70+
}

src/Modules/Identity/Modules.Identity/Services/SessionService.cs

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public async Task<UserSessionDto> CreateSessionAsync(
5858
ipAddress: ipAddress,
5959
userAgent: userAgent,
6060
expiresAt: expiresAt,
61-
deviceType: GetDeviceType(clientInfo.Device.Family),
61+
deviceType: DeviceTypeClassifier.Classify(clientInfo.Device.Family),
6262
browser: clientInfo.UA.Family,
6363
browserVersion: clientInfo.UA.Major,
6464
operatingSystem: clientInfo.OS.Family,
@@ -328,30 +328,6 @@ public async Task CleanupExpiredSessionsAsync(
328328
}
329329
}
330330

331-
private static string GetDeviceType(string deviceFamily)
332-
{
333-
if (string.IsNullOrWhiteSpace(deviceFamily) || deviceFamily == "Other")
334-
{
335-
return "Desktop";
336-
}
337-
338-
if (deviceFamily.Contains("mobile", StringComparison.OrdinalIgnoreCase) ||
339-
deviceFamily.Contains("phone", StringComparison.OrdinalIgnoreCase) ||
340-
deviceFamily.Contains("iphone", StringComparison.OrdinalIgnoreCase) ||
341-
deviceFamily.Contains("android", StringComparison.OrdinalIgnoreCase))
342-
{
343-
return "Mobile";
344-
}
345-
346-
if (deviceFamily.Contains("tablet", StringComparison.OrdinalIgnoreCase) ||
347-
deviceFamily.Contains("ipad", StringComparison.OrdinalIgnoreCase))
348-
{
349-
return "Tablet";
350-
}
351-
352-
return "Desktop";
353-
}
354-
355331
private static UserSessionDto MapToDto(UserSession session, bool isCurrentSession)
356332
{
357333
return new UserSessionDto

0 commit comments

Comments
 (0)