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
+23 -3
View File
@@ -1,17 +1,37 @@
using System.Security.Cryptography;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
namespace JobTrackerApi.Services;
// Shared by AuthController (local/Google/Microsoft sign-in) and TwoFactorController (the
// post-challenge sign-in) so the httpOnly session cookie + readable CSRF cookie are always
// issued the same way, from one place.
// issued the same way, from one place. Also the single place a UserSession row is created, so
// every JWT this app ever hands out has a matching server-side row Program.cs can revoke.
public static class AppSessionIssuer
{
public static async Task IssueAsync(HttpRequest request, HttpResponse response, ITokenService tokens, ApplicationUser user, bool rememberMe, CancellationToken cancellationToken)
public static async Task IssueAsync(HttpRequest request, HttpResponse response, ITokenService tokens, JobTrackerContext db, IConfiguration cfg, ApplicationUser user, bool rememberMe, CancellationToken cancellationToken)
{
var token = await tokens.CreateAccessTokenAsync(user, cancellationToken);
var minutes = cfg.GetValue("Auth:JwtExpiresMinutes", 60 * 12);
if (minutes < 5) minutes = 5;
if (minutes > 60 * 24 * 30) minutes = 60 * 24 * 30;
var now = DateTimeOffset.UtcNow;
var session = new UserSession
{
Id = Guid.NewGuid().ToString("N"),
UserId = user.Id,
DeviceLabel = TrustedDeviceService.DescribeUserAgent(request.Headers["User-Agent"].ToString()),
CreatedAtUtc = now,
LastSeenAtUtc = now,
ExpiresAtUtc = now.AddMinutes(minutes),
};
db.UserSessions.Add(session);
await db.SaveChangesAsync(cancellationToken);
var token = await tokens.CreateAccessTokenAsync(user, session.Id, cancellationToken);
var secure = request.IsHttps || string.Equals(request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase);
response.Cookies.Append(AuthSessionOptions.SessionCookieName, token, AuthSessionOptions.BuildSessionCookie(rememberMe, secure));