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,12 @@
using System.Security.Claims;
using FSH.Framework.Core.Context;

namespace FSH.Modules.Identity.Contracts.Services;

/// <summary>
/// Service interface for managing the current user context.
/// Combines user identity access with initialization capabilities.
/// </summary>
public interface ICurrentUserService : ICurrentUser, ICurrentUserInitializer
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using FSH.Framework.Core.Context;

namespace FSH.Modules.Identity.Contracts.Services;

/// <summary>
/// Service interface for accessing HTTP request context information.
/// Provides request metadata for auditing, logging, and other cross-cutting concerns.
/// </summary>
public interface IRequestContextService : IRequestContext
{
}
11 changes: 7 additions & 4 deletions src/Modules/Identity/Modules.Identity/IdentityModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,12 @@ public void ConfigureServices(IHostApplicationBuilder builder)
ArgumentNullException.ThrowIfNull(builder);
var services = builder.Services;
services.AddSingleton<IAuthorizationMiddlewareResultHandler, PathAwareAuthorizationHandler>();
services.AddScoped<ICurrentUser, CurrentUserService>();
services.AddScoped<IRequestContext, RequestContextService>();
services.AddScoped<ICurrentUserService, CurrentUserService>();
services.AddScoped<ICurrentUser>(sp => sp.GetRequiredService<ICurrentUserService>());
services.AddScoped<ICurrentUserInitializer>(sp => sp.GetRequiredService<ICurrentUserService>());
services.AddScoped<IRequestContextService, RequestContextService>();
services.AddScoped<IRequestContext>(sp => sp.GetRequiredService<IRequestContextService>());
services.AddScoped<ITokenService, TokenService>();
services.AddScoped(sp => (ICurrentUserInitializer)sp.GetRequiredService<ICurrentUser>());

// User services - focused single-responsibility services
services.AddTransient<IUserRegistrationService, UserRegistrationService>();
Expand Down Expand Up @@ -111,8 +113,9 @@ public void ConfigureServices(IHostApplicationBuilder builder)
// Register password expiry service
services.AddScoped<IPasswordExpiryService, PasswordExpiryService>();

// Register session service
// Register session service and background cleanup
services.AddScoped<ISessionService, SessionService>();
services.AddHostedService<SessionCleanupHostedService>();

// Register group role service for group-derived permissions
services.AddScoped<IGroupRoleService, GroupRoleService>();
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
namespace FSH.Modules.Identity.Services;

/// <summary>
/// Classifies device types based on user agent device family strings.
/// Extracted from SessionService to reduce cyclomatic complexity.
/// </summary>
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"];

/// <summary>
/// Determines the device type from a user agent device family string.
/// </summary>
/// <param name="deviceFamily">The device family string from user agent parsing.</param>
/// <returns>Device type: "Desktop", "Mobile", or "Tablet".</returns>
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;
}
}
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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.
/// </summary>
public sealed class RequestContextService : IRequestContext
internal sealed class RequestContextService : IRequestContextService
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly Uri? _originUrl;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Background service that periodically cleans up expired sessions.
/// Runs every hour and removes sessions that have been expired for more than 30 days.
/// </summary>
public sealed class SessionCleanupHostedService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<SessionCleanupHostedService> _logger;
private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(1);
private readonly int _retentionDays = 30;

public SessionCleanupHostedService(
IServiceScopeFactory scopeFactory,
ILogger<SessionCleanupHostedService> 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<IdentityDbContext>();

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);
}
}
}
26 changes: 1 addition & 25 deletions src/Modules/Identity/Modules.Identity/Services/SessionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public async Task<UserSessionDto> 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,
Expand Down Expand Up @@ -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
Expand Down
Loading