using Backend.Common; using Backend.Contracts; using Backend.Domain; using Backend.Repositories; using Backend.Security; using System.Security.Cryptography; namespace Backend.Services; public sealed class UserSessionService(IUserSessionRepository userSessionRepository) : IUserSessionService { private static readonly TimeSpan IdleSessionLifetime = TimeSpan.FromHours(12); private static readonly TimeSpan AbsoluteSessionLifetime = TimeSpan.FromDays(30); public async Task ResolveSessionAsync(HttpContext context, CancellationToken cancellationToken = default) { var token = ReadBearerToken(context); if (string.IsNullOrWhiteSpace(token)) { return null; } var session = await userSessionRepository.GetActiveByTokenAsync(token, cancellationToken); if (session is null) { return null; } var now = DateTimeOffset.UtcNow; if (IsExpired(session, now)) { session.IsActive = false; await userSessionRepository.SaveChangesAsync(cancellationToken); return null; } session.LastSeenAt = now; await userSessionRepository.SaveChangesAsync(cancellationToken); context.SetCurrentSession(session); return session; } public Task CreateDevSessionAsync(LoginRequest request, RequestMetadata metadata, CancellationToken cancellationToken = default) { return CreateSessionAsync( request.TwitchUserId, request.DisplayName, request.Role, metadata, cancellationToken); } public Task CreateSessionAsync( string twitchUserId, string displayName, string role, RequestMetadata metadata, CancellationToken cancellationToken = default) { var normalizedTwitchUserId = twitchUserId.Trim(); var normalizedDisplayName = displayName.Trim(); var session = new UserSession { Id = Guid.NewGuid(), SessionToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(), TwitchUserId = normalizedTwitchUserId, DisplayName = normalizedDisplayName, Role = AdminRoles.Normalize(role), CreatedFromIp = metadata.ClientIp, UserAgent = metadata.UserAgent, CreatedAt = DateTimeOffset.UtcNow, LastSeenAt = DateTimeOffset.UtcNow, IsActive = true, }; userSessionRepository.Add(session); return PersistAndReturnAsync(session, cancellationToken); } public Task CountRecentSessionsFromIpAsync(string ipAddress, DateTimeOffset since, CancellationToken cancellationToken = default) => userSessionRepository.CountRecentSessionsFromIpAsync(ipAddress, since, cancellationToken); public async Task LogoutAsync(UserSession session, CancellationToken cancellationToken = default) { session.IsActive = false; await userSessionRepository.SaveChangesAsync(cancellationToken); } private static string? ReadBearerToken(HttpContext context) { var header = context.Request.Headers.Authorization.ToString(); return header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? header["Bearer ".Length..].Trim() : null; } private static bool IsExpired(UserSession session, DateTimeOffset now) => session.CreatedAt <= now.Subtract(AbsoluteSessionLifetime) || session.LastSeenAt <= now.Subtract(IdleSessionLifetime); private async Task PersistAndReturnAsync(UserSession session, CancellationToken cancellationToken) { await userSessionRepository.SaveChangesAsync(cancellationToken); return session; } }