c6918cbeea
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.
105 lines
4.1 KiB
C#
105 lines
4.1 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;
|
|
|
|
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();
|
|
}
|
|
}
|