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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user