Files
jobtrackingapp/JobTrackerApi/Controllers/SessionsController.cs
T

106 lines
4.2 KiB
C#

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<ApplicationUser> _users;
private readonly JobTrackerContext _db;
private readonly ExternalOrigin _externalOrigin;
public SessionsController(UserManager<ApplicationUser> users, JobTrackerContext db, ExternalOrigin? externalOrigin = null)
{
_users = users;
_db = db;
_externalOrigin = externalOrigin ?? ExternalOrigin.Parse(null, production: false);
}
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<IActionResult> 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<IActionResult> 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))
{
Response.Cookies.Delete(AuthSessionOptions.SessionCookieName, AuthSessionOptions.BuildExpiredCookie(_externalOrigin.UsesHttps));
}
return NoContent();
}
[HttpPost("revoke-others")]
public async Task<IActionResult> 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();
}
}