feat(auth): add server-tracked sessions with view/revoke

JWTs were previously fully stateless -- the token alone was the credential
until its own expiry, with no way to list or kill a session server-side. Add
a UserSession table alongside every JWT issued (AppSessionIssuer), embed its
id as a "sid" claim, and check that claim against the DB on every "local"
scheme request (Program.cs OnTokenValidated) so a session can actually be
revoked before its JWT naturally expires. New /api/auth/sessions endpoints
(list, revoke one, revoke-others) plus a Sessions card on the profile page.

Fails closed on a missing "sid" claim: every JWT issued going forward has
one, so a token without it is either pre-deploy (forces one re-login for
already-signed-in users at deploy time, same additive-forward cost the
2FA/trusted-device work on this branch already paid) or forged.
This commit is contained in:
cesnimda
2026-07-13 01:47:31 +02:00
parent 904f3a8ec8
commit c6918cbeea
17 changed files with 684 additions and 24 deletions
@@ -0,0 +1,104 @@
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;
public SessionsController(UserManager<ApplicationUser> 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<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))
{
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<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();
}
}