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));
@@ -0,0 +1,30 @@
using System.Security.Claims;
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
// The actual revocation check behind Program.cs's "local" JwtBearer OnTokenValidated. Pulled out
// of Program.cs so it's unit-testable without standing up a full TestServer/HTTP pipeline.
public static class LocalSessionValidator
{
public static async Task<bool> IsValidAsync(JobTrackerContext db, ClaimsPrincipal? principal, DateTimeOffset now, CancellationToken cancellationToken = default)
{
var sid = principal?.FindFirst("sid")?.Value;
// Fail closed: see the comment on the OnTokenValidated wiring in Program.cs for why a
// missing sid is rejected rather than grandfathered in.
if (string.IsNullOrWhiteSpace(sid)) return false;
var session = await db.UserSessions.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => x.Id == sid, cancellationToken);
if (session is null || session.RevokedAtUtc is not null || session.ExpiresAtUtc <= now) return false;
if (now - session.LastSeenAtUtc > TimeSpan.FromMinutes(5))
{
session.LastSeenAtUtc = now;
await db.SaveChangesAsync(cancellationToken);
}
return true;
}
}
@@ -665,12 +665,30 @@ public static class StartupInitializationExtensions
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TrustedDevices_TokenHash" ON "TrustedDevices" ("TokenHash");""");
}
static void EnsureUserSessionsTable(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "UserSessions" (
"Id" TEXT NOT NULL CONSTRAINT "PK_UserSessions" PRIMARY KEY,
"UserId" TEXT NOT NULL,
"DeviceLabel" TEXT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"LastSeenAtUtc" TEXT NOT NULL,
"ExpiresAtUtc" TEXT NOT NULL,
"RevokedAtUtc" TEXT NULL
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_UserSessions_UserId" ON "UserSessions" ("UserId");""");
}
EnsureGmailConnectionsTable(conn);
EnsureMicrosoftGraphConnectionsTable(conn);
EnsureImapConnectionsTable(conn);
EnsureCvTables(conn);
EnsureTwoFactorRecoveryCodesTable(conn);
EnsureTrustedDevicesTable(conn);
EnsureUserSessionsTable(conn);
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
// and at least one of the new columns already exists.
@@ -1079,6 +1097,29 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "UserSessions"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `UserSessions` (
`Id` varchar(64) NOT NULL,
`UserId` varchar(255) NOT NULL,
`DeviceLabel` varchar(255) NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
`LastSeenAtUtc` datetime(6) NOT NULL,
`ExpiresAtUtc` datetime(6) NOT NULL,
`RevokedAtUtc` datetime(6) NULL,
PRIMARY KEY (`Id`)
);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "UserSessions", "IX_UserSessions_UserId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_UserSessions_UserId` ON `UserSessions` (`UserId`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
{
using var cmd = conn.CreateCommand();
+5 -2
View File
@@ -10,7 +10,7 @@ namespace JobTrackerApi.Services;
public interface ITokenService
{
Task<string> CreateAccessTokenAsync(ApplicationUser user, CancellationToken cancellationToken = default);
Task<string> CreateAccessTokenAsync(ApplicationUser user, string? sessionId = null, CancellationToken cancellationToken = default);
}
public sealed class TokenService : ITokenService
@@ -24,7 +24,7 @@ public sealed class TokenService : ITokenService
_users = users;
}
public async Task<string> CreateAccessTokenAsync(ApplicationUser user, CancellationToken cancellationToken = default)
public async Task<string> CreateAccessTokenAsync(ApplicationUser user, string? sessionId = null, CancellationToken cancellationToken = default)
{
var jwtKey = (_cfg["Auth:JwtKey"] ?? "").Trim();
if (string.IsNullOrWhiteSpace(jwtKey))
@@ -57,6 +57,9 @@ public sealed class TokenService : ITokenService
foreach (var r in roles)
claims.Add(new Claim(ClaimTypes.Role, r));
if (!string.IsNullOrWhiteSpace(sessionId))
claims.Add(new Claim("sid", sessionId));
var now = DateTime.UtcNow;
var token = new JwtSecurityToken(
@@ -96,7 +96,8 @@ public static class TrustedDeviceService
// ponytail: substring sniffing, not a real UA parser -- this only feeds a display label in
// a security-settings list ("Chrome on Windows"), nothing security-relevant depends on it.
private static string? DescribeUserAgent(string? userAgent)
// Public: also reused by AppSessionIssuer for UserSession device labels.
public static string? DescribeUserAgent(string? userAgent)
{
if (string.IsNullOrWhiteSpace(userAgent)) return null;