using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Controllers; // List/revoke the server-tracked UserSession rows behind the JWTs AppSessionIssuer hands out. // Not 2FA-specific (any local-auth user has sessions, 2FA or not), hence its own small controller // rather than folding into TwoFactorController. [ApiController] [Route("api/auth/sessions")] [Authorize(AuthenticationSchemes = "local")] public sealed class SessionsController : ControllerBase { private readonly UserManager _users; private readonly JobTrackerContext _db; public SessionsController(UserManager users, JobTrackerContext db) { _users = users; _db = db; } public sealed record SessionDto(string Id, string? DeviceLabel, DateTimeOffset CreatedAtUtc, DateTimeOffset LastSeenAtUtc, DateTimeOffset ExpiresAtUtc, bool IsCurrentSession); private string? CurrentSid => User.FindFirst("sid")?.Value; [HttpGet] public async Task List(CancellationToken cancellationToken) { var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); var now = DateTimeOffset.UtcNow; var currentSid = CurrentSid; // IgnoreQueryFilters + an explicit UserId filter, same convention as // TrustedDeviceService/TwoFactorController's device-list queries. // Equality-only in the DB query, then filter/sort DateTimeOffset client-side: SQLite's EF // Core provider cannot translate ">" or ORDER BY over DateTimeOffset to SQL ("SQLite does // not support expressions of type 'DateTimeOffset' in ORDER BY clauses"), so ExpiresAtUtc // comparison and the LastSeenAtUtc sort have to happen after materializing the (small, // per-user) row set. var candidates = await _db.UserSessions.IgnoreQueryFilters() .Where(x => x.UserId == user.Id && x.RevokedAtUtc == null) .ToListAsync(cancellationToken); var sessions = candidates .Where(x => x.ExpiresAtUtc > now) .OrderByDescending(x => x.LastSeenAtUtc) .Select(x => new SessionDto(x.Id, x.DeviceLabel, x.CreatedAtUtc, x.LastSeenAtUtc, x.ExpiresAtUtc, x.Id == currentSid)) .ToList(); return Ok(sessions); } [HttpDelete("{id}")] public async Task Revoke(string id, CancellationToken cancellationToken) { var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); var session = await _db.UserSessions.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id && x.UserId == user.Id, cancellationToken); if (session is null) return NotFound(); session.RevokedAtUtc = DateTimeOffset.UtcNow; await _db.SaveChangesAsync(cancellationToken); if (string.Equals(id, CurrentSid, StringComparison.Ordinal)) { var secure = Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase); Response.Cookies.Delete(AuthSessionOptions.SessionCookieName, AuthSessionOptions.BuildExpiredCookie(secure)); } return NoContent(); } [HttpPost("revoke-others")] public async Task RevokeOthers(CancellationToken cancellationToken) { var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); var currentSid = CurrentSid; var now = DateTimeOffset.UtcNow; var others = await _db.UserSessions.IgnoreQueryFilters() .Where(x => x.UserId == user.Id && x.RevokedAtUtc == null && x.Id != currentSid) .ToListAsync(cancellationToken); foreach (var session in others) { session.RevokedAtUtc = now; } if (others.Count > 0) { await _db.SaveChangesAsync(cancellationToken); } return NoContent(); } }