From c6918cbeea219fff270ea45f4d0e2d9e15b3a5ef Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 13 Jul 2026 01:47:31 +0200 Subject: [PATCH] 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. --- Data/JobTrackerContext.cs | 13 + .../AuthAndSystemControllerTests.cs | 14 +- .../SessionsControllerTests.cs | 242 ++++++++++++++++++ .../TwoFactorControllerTests.cs | 6 +- JobTrackerApi/Controllers/AuthController.cs | 4 +- .../Controllers/SessionsController.cs | 104 ++++++++ .../Controllers/TwoFactorController.cs | 6 +- JobTrackerApi/Program.cs | 23 +- JobTrackerApi/Services/AppSessionIssuer.cs | 26 +- .../Services/LocalSessionValidator.cs | 30 +++ .../StartupInitializationExtensions.cs | 41 +++ JobTrackerApi/Services/TokenService.cs | 7 +- .../Services/TrustedDeviceService.cs | 3 +- Models/UserSession.cs | 17 ++ .../src/components/SessionsSettingsCard.tsx | 148 +++++++++++ job-tracker-ui/src/i18n/translations.ts | 22 ++ job-tracker-ui/src/views/ProfilePage.tsx | 2 + 17 files changed, 684 insertions(+), 24 deletions(-) create mode 100644 JobTrackerApi.Tests/SessionsControllerTests.cs create mode 100644 JobTrackerApi/Controllers/SessionsController.cs create mode 100644 JobTrackerApi/Services/LocalSessionValidator.cs create mode 100644 Models/UserSession.cs create mode 100644 job-tracker-ui/src/components/SessionsSettingsCard.tsx diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index f0a0fb8..2ef52ed 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -30,6 +30,7 @@ namespace JobTrackerApi.Data public DbSet TailoredCvDrafts => Set(); public DbSet TwoFactorRecoveryCodes => Set(); public DbSet TrustedDevices => Set(); + public DbSet UserSessions => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -164,6 +165,18 @@ namespace JobTrackerApi.Data modelBuilder.Entity() .HasIndex(x => x.TokenHash); + + // No FK to AspNetUsers, same convention as TrustedDevice/TwoFactorRecoveryCode above: the + // OnTokenValidated auth check reads this table before CurrentUserId is meaningfully set + // for the request being validated, via IgnoreQueryFilters(). + modelBuilder.Entity() + .HasKey(x => x.Id); + + modelBuilder.Entity() + .HasQueryFilter(x => CurrentUserId != null && x.UserId == CurrentUserId); + + modelBuilder.Entity() + .HasIndex(x => x.UserId); } } } diff --git a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs index 45b767d..aa66f69 100644 --- a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs +++ b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs @@ -184,7 +184,7 @@ public sealed class AuthAndSystemControllerTests userManager.Setup(x => x.GenerateEmailConfirmationTokenAsync(It.IsAny())).ReturnsAsync("confirm-token"); var tokenService = new Mock(); - tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); + tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); var emailSender = new Mock(); @@ -217,7 +217,7 @@ public sealed class AuthAndSystemControllerTests .ReturnsAsync(IdentityResult.Success); var tokenService = new Mock(); - tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); + tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); var emailSender = new Mock(); @@ -272,7 +272,7 @@ public sealed class AuthAndSystemControllerTests userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success); var tokenService = new Mock(); - tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny())).ReturnsAsync("app-token"); + tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) { @@ -347,7 +347,7 @@ public sealed class AuthAndSystemControllerTests .ReturnsAsync(IdentityResult.Success); var tokenService = new Mock(); - tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); + tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); var googleValidator = new Mock(); googleValidator @@ -384,7 +384,7 @@ public sealed class AuthAndSystemControllerTests userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success); var tokenService = new Mock(); - tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny())).ReturnsAsync("app-token"); + tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); db = BuildDb(dbName, null); @@ -468,7 +468,7 @@ public sealed class AuthAndSystemControllerTests userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); var tokenService = new Mock(); - tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny())).ReturnsAsync("app-token"); + tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); var googleValidator = new Mock(); googleValidator @@ -508,7 +508,7 @@ public sealed class AuthAndSystemControllerTests userManager.Setup(x => x.UpdateAsync(It.IsAny())).ReturnsAsync(IdentityResult.Success); var tokenService = new Mock(); - tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); + tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); var microsoftValidator = new Mock(); microsoftValidator diff --git a/JobTrackerApi.Tests/SessionsControllerTests.cs b/JobTrackerApi.Tests/SessionsControllerTests.cs new file mode 100644 index 0000000..9faf2d1 --- /dev/null +++ b/JobTrackerApi.Tests/SessionsControllerTests.cs @@ -0,0 +1,242 @@ +using System.Security.Claims; +using JobTrackerApi.Controllers; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using JobTrackerApi.Tests.TestSupport; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using OtpNet; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class SessionsControllerTests +{ + private static IConfiguration BuildConfig() => + new ConfigurationBuilder().AddInMemoryCollection(new Dictionary()).Build(); + + private static SessionsController BuildController(JobTrackerContext db, ApplicationUser user, string? currentSid = null) + { + var userManager = TestHostFactory.CreateUserManager(); + userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + + var claims = new List { new(ClaimTypes.NameIdentifier, user.Id) }; + if (currentSid is not null) claims.Add(new Claim("sid", currentSid)); + + return new SessionsController(userManager.Object, db) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(claims, "local")) } + } + }; + } + + private static UserSession NewSession(string id, string userId, DateTimeOffset? expiresAtUtc = null, DateTimeOffset? revokedAtUtc = null, DateTimeOffset? lastSeenAtUtc = null) + { + var now = DateTimeOffset.UtcNow; + return new UserSession + { + Id = id, + UserId = userId, + DeviceLabel = "Chrome on Windows", + CreatedAtUtc = now, + LastSeenAtUtc = lastSeenAtUtc ?? now, + ExpiresAtUtc = expiresAtUtc ?? now.AddHours(12), + RevokedAtUtc = revokedAtUtc, + }; + } + + // --- Session creation on every sign-in path ----------------------------------------------- + + [Fact] + public async Task Login_creates_a_user_session_row_and_threads_its_id_into_the_token() + { + var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" }; + var userManager = TestHostFactory.CreateUserManager(); + userManager.Setup(x => x.FindByEmailAsync("person@example.com")).ReturnsAsync(user); + userManager.Setup(x => x.FindByNameAsync("person@example.com")).ReturnsAsync((ApplicationUser?)null); + userManager.Setup(x => x.IsLockedOutAsync(user)).ReturnsAsync(false); + userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true); + userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success); + + string? sessionIdPassedToToken = null; + var tokenService = new Mock(); + tokenService + .Setup(x => x.CreateAccessTokenAsync(user, It.IsAny(), It.IsAny())) + .Callback((_, sid, _) => sessionIdPassedToToken = sid) + .ReturnsAsync("app-token"); + + using var db = TestHostFactory.CreateInMemoryDb(null); + var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), db) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + + await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None); + + var sessions = await db.UserSessions.IgnoreQueryFilters().Where(x => x.UserId == "user-1").ToListAsync(); + var created = Assert.Single(sessions); + Assert.False(string.IsNullOrWhiteSpace(created.Id)); + Assert.Equal(created.Id, sessionIdPassedToToken); + Assert.Null(created.RevokedAtUtc); + Assert.True(created.ExpiresAtUtc > DateTimeOffset.UtcNow); + } + + [Fact] + public async Task Register_creates_a_user_session_row_when_it_completes_sign_in() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["Auth:AllowRegistration"] = "true" }) + .Build(); + + var userManager = TestHostFactory.CreateUserManager(); + userManager.Setup(x => x.FindByEmailAsync("new.user@example.com")).ReturnsAsync((ApplicationUser?)null); + ApplicationUser? created = null; + userManager + .Setup(x => x.CreateAsync(It.IsAny(), "password123")) + .Callback((u, _) => { u.Id = "new-user-1"; created = u; }) + .ReturnsAsync(IdentityResult.Success); + + var tokenService = new Mock(); + tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); + + using var db = TestHostFactory.CreateInMemoryDb(null); + var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), db) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + + await controller.Register(new AuthController.RegisterRequest("new.user@example.com", "password123"), CancellationToken.None); + + Assert.NotNull(created); + var sessions = await db.UserSessions.IgnoreQueryFilters().Where(x => x.UserId == created!.Id).ToListAsync(); + Assert.Single(sessions); + } + + [Fact] + public async Task TwoFactor_challenge_creates_a_user_session_row_on_completion() + { + var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" }; + var userManager = TestHostFactory.CreateUserManager(); + userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true); + userManager.Setup(x => x.UpdateAsync(It.IsAny())).ReturnsAsync(IdentityResult.Success); + userManager.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user); + userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + + var pending = new TwoFactorPendingTokenService(new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions())); + var protector = new EphemeralDataProtectionProvider(); + var tokenService = new Mock(); + tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); + + using var db = TestHostFactory.CreateInMemoryDb("user-1"); + var controller = new TwoFactorController(userManager.Object, tokenService.Object, db, pending, protector, BuildConfig()) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + + var setupResult = Assert.IsType(await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None)); + var setup = Assert.IsType(setupResult.Value); + var setupCode = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp(); + await controller.VerifySetup(new TwoFactorController.VerifySetupRequest(setupCode), CancellationToken.None); + + var pendingToken = pending.IssuePendingToken("user-1", rememberMe: false); + var challengeCode = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp(); + Assert.IsType(await controller.Challenge(new TwoFactorController.ChallengeRequest(pendingToken, challengeCode), CancellationToken.None)); + + var sessions = await db.UserSessions.IgnoreQueryFilters().Where(x => x.UserId == "user-1").ToListAsync(); + Assert.Single(sessions); + } + + // --- List/revoke ---------------------------------------------------------------------------- + + [Fact] + public async Task List_returns_only_the_callers_own_active_sessions() + { + using var db = TestHostFactory.CreateInMemoryDb("user-1"); + db.UserSessions.Add(NewSession("sid-mine", "user-1")); + db.UserSessions.Add(NewSession("sid-other-user", "user-2")); + db.UserSessions.Add(NewSession("sid-mine-expired", "user-1", expiresAtUtc: DateTimeOffset.UtcNow.AddHours(-1))); + db.UserSessions.Add(NewSession("sid-mine-revoked", "user-1", revokedAtUtc: DateTimeOffset.UtcNow.AddMinutes(-1))); + await db.SaveChangesAsync(); + + var controller = BuildController(db, new ApplicationUser { Id = "user-1" }, currentSid: "sid-mine"); + + var ok = Assert.IsType(await controller.List(CancellationToken.None)); + var list = Assert.IsType>(ok.Value); + + var only = Assert.Single(list); + Assert.Equal("sid-mine", only.Id); + Assert.True(only.IsCurrentSession); + } + + [Fact] + public async Task Revoke_enforces_ownership_and_blocks_a_subsequent_request_using_that_sessions_token() + { + using var db = TestHostFactory.CreateInMemoryDb("user-1"); + db.UserSessions.Add(NewSession("sid-mine", "user-1")); + db.UserSessions.Add(NewSession("sid-not-mine", "user-2")); + await db.SaveChangesAsync(); + + var controller = BuildController(db, new ApplicationUser { Id = "user-1" }, currentSid: "sid-mine"); + + // Can't revoke someone else's session. + var forbidden = await controller.Revoke("sid-not-mine", CancellationToken.None); + Assert.IsType(forbidden); + Assert.True(await LocalSessionValidator.IsValidAsync(db, PrincipalWithSid("sid-not-mine"), DateTimeOffset.UtcNow)); + + // Revoking your own session actually blocks it going forward. + Assert.True(await LocalSessionValidator.IsValidAsync(db, PrincipalWithSid("sid-mine"), DateTimeOffset.UtcNow)); + var ownResult = await controller.Revoke("sid-mine", CancellationToken.None); + Assert.IsType(ownResult); + Assert.False(await LocalSessionValidator.IsValidAsync(db, PrincipalWithSid("sid-mine"), DateTimeOffset.UtcNow)); + } + + [Fact] + public async Task RevokeOthers_revokes_every_other_session_but_leaves_the_current_one_usable() + { + using var db = TestHostFactory.CreateInMemoryDb("user-1"); + db.UserSessions.Add(NewSession("sid-current", "user-1")); + db.UserSessions.Add(NewSession("sid-other-device", "user-1")); + db.UserSessions.Add(NewSession("sid-not-mine", "user-2")); + await db.SaveChangesAsync(); + + var controller = BuildController(db, new ApplicationUser { Id = "user-1" }, currentSid: "sid-current"); + + Assert.IsType(await controller.RevokeOthers(CancellationToken.None)); + + Assert.True(await LocalSessionValidator.IsValidAsync(db, PrincipalWithSid("sid-current"), DateTimeOffset.UtcNow)); + Assert.False(await LocalSessionValidator.IsValidAsync(db, PrincipalWithSid("sid-other-device"), DateTimeOffset.UtcNow)); + // Untouched: revoke-others must never reach across users. + Assert.True(await LocalSessionValidator.IsValidAsync(db, PrincipalWithSid("sid-not-mine"), DateTimeOffset.UtcNow)); + } + + [Fact] + public async Task LocalSessionValidator_rejects_an_expired_session() + { + using var db = TestHostFactory.CreateInMemoryDb("user-1"); + db.UserSessions.Add(NewSession("sid-expired", "user-1", expiresAtUtc: DateTimeOffset.UtcNow.AddSeconds(-1))); + await db.SaveChangesAsync(); + + Assert.False(await LocalSessionValidator.IsValidAsync(db, PrincipalWithSid("sid-expired"), DateTimeOffset.UtcNow)); + } + + [Fact] + public async Task LocalSessionValidator_rejects_a_token_with_no_sid_claim() + { + using var db = TestHostFactory.CreateInMemoryDb("user-1"); + + var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "user-1") }, "local")); + Assert.False(await LocalSessionValidator.IsValidAsync(db, principal, DateTimeOffset.UtcNow)); + } + + private static ClaimsPrincipal PrincipalWithSid(string sid) => + new(new ClaimsIdentity(new[] { new Claim("sid", sid) }, "local")); +} diff --git a/JobTrackerApi.Tests/TwoFactorControllerTests.cs b/JobTrackerApi.Tests/TwoFactorControllerTests.cs index 80e7a72..3655d67 100644 --- a/JobTrackerApi.Tests/TwoFactorControllerTests.cs +++ b/JobTrackerApi.Tests/TwoFactorControllerTests.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using Moq; using OtpNet; using Xunit; @@ -23,14 +24,15 @@ public sealed class TwoFactorControllerTests } var tokenService = new Mock(); - tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); + tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); var controller = new TwoFactorController( userManager.Object, tokenService.Object, db, pending ?? new TwoFactorPendingTokenService(new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions())), - new EphemeralDataProtectionProvider()) + new EphemeralDataProtectionProvider(), + new ConfigurationBuilder().AddInMemoryCollection(new Dictionary()).Build()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; diff --git a/JobTrackerApi/Controllers/AuthController.cs b/JobTrackerApi/Controllers/AuthController.cs index 83937f9..f7ee64a 100644 --- a/JobTrackerApi/Controllers/AuthController.cs +++ b/JobTrackerApi/Controllers/AuthController.cs @@ -781,7 +781,7 @@ public sealed class AuthController : ControllerBase // (no cookie, wrong user, expired, revoked) -- never errors, just doesn't skip. if (user.TwoFactorEnabled && await TrustedDeviceService.IsDeviceTrustedAsync(_db, Request, user.Id, cancellationToken)) { - await AppSessionIssuer.IssueAsync(Request, Response, _tokens, user, rememberMe, cancellationToken); + await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, rememberMe, cancellationToken); return Ok(new AuthSessionResult(true, provider)); } @@ -791,7 +791,7 @@ public sealed class AuthController : ControllerBase return Ok(new TwoFactorRequiredResult(true, pendingToken)); } - await AppSessionIssuer.IssueAsync(Request, Response, _tokens, user, rememberMe, cancellationToken); + await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, rememberMe, cancellationToken); return Ok(new AuthSessionResult(true, provider)); } diff --git a/JobTrackerApi/Controllers/SessionsController.cs b/JobTrackerApi/Controllers/SessionsController.cs new file mode 100644 index 0000000..a531f3f --- /dev/null +++ b/JobTrackerApi/Controllers/SessionsController.cs @@ -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 _users; + private readonly JobTrackerContext _db; + + public SessionsController(UserManager 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 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 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 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(); + } +} diff --git a/JobTrackerApi/Controllers/TwoFactorController.cs b/JobTrackerApi/Controllers/TwoFactorController.cs index dc73c83..ec2e02f 100644 --- a/JobTrackerApi/Controllers/TwoFactorController.cs +++ b/JobTrackerApi/Controllers/TwoFactorController.cs @@ -28,14 +28,16 @@ public sealed class TwoFactorController : ControllerBase private readonly JobTrackerContext _db; private readonly ITwoFactorPendingTokenService _pending; private readonly IDataProtector _protector; + private readonly IConfiguration _cfg; - public TwoFactorController(UserManager users, ITokenService tokens, JobTrackerContext db, ITwoFactorPendingTokenService pending, IDataProtectionProvider protectionProvider) + public TwoFactorController(UserManager users, ITokenService tokens, JobTrackerContext db, ITwoFactorPendingTokenService pending, IDataProtectionProvider protectionProvider, IConfiguration cfg) { _users = users; _tokens = tokens; _db = db; _pending = pending; _protector = protectionProvider.CreateProtector("totp-secret-v1"); + _cfg = cfg; } public sealed record PasswordConfirmRequest(string CurrentPassword); @@ -202,7 +204,7 @@ public sealed class TwoFactorController : ControllerBase if (!verified) return Unauthorized(); _pending.Resolve(pendingToken, consume: true); - await AppSessionIssuer.IssueAsync(Request, Response, _tokens, user, session.RememberMe, cancellationToken); + await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, session.RememberMe, cancellationToken); if (request.TrustDevice) { diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index b5aef01..e6bfb4a 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -286,16 +286,29 @@ builder.Services.AddAuthentication(options => return Task.CompletedTask; }, - OnTokenValidated = context => + OnTokenValidated = async context => { var userId = LocalAuthIdentity.GetRequiredUserId(context.Principal); - if (userId is not null) + if (userId is null) { - return Task.CompletedTask; + context.Fail("Local tokens must include a subject/nameidentifier claim."); + return; } - context.Fail("Local tokens must include a subject/nameidentifier claim."); - return Task.CompletedTask; + // Resolve a fresh scoped JobTrackerContext for this one lookup -- OnTokenValidated + // runs outside the request's normal DI-constructor scope, so RequestServices (the + // per-request scope) must be used directly rather than a captured/singleton one. + // Fail closed if the session row is missing/revoked/expired (including tokens + // with no "sid" claim at all -- see LocalSessionValidator for why: every JWT + // issued going forward carries one, so a token without it is either pre-deploy + // (forces a single re-login for anyone already signed in when this ships -- + // acceptable, same additive-forward cost the 2FA/trusted-device features on this + // branch already paid) or forged, and either way isn't proof of a live session. + var db = context.HttpContext.RequestServices.GetRequiredService(); + if (!await LocalSessionValidator.IsValidAsync(db, context.Principal, DateTimeOffset.UtcNow)) + { + context.Fail("Session has been revoked or expired."); + } } }; options.TokenValidationParameters = new TokenValidationParameters diff --git a/JobTrackerApi/Services/AppSessionIssuer.cs b/JobTrackerApi/Services/AppSessionIssuer.cs index 043d0d0..e2c30cd 100644 --- a/JobTrackerApi/Services/AppSessionIssuer.cs +++ b/JobTrackerApi/Services/AppSessionIssuer.cs @@ -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)); diff --git a/JobTrackerApi/Services/LocalSessionValidator.cs b/JobTrackerApi/Services/LocalSessionValidator.cs new file mode 100644 index 0000000..0b22553 --- /dev/null +++ b/JobTrackerApi/Services/LocalSessionValidator.cs @@ -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 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; + } +} diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index 1f237ae..399a300 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -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(); diff --git a/JobTrackerApi/Services/TokenService.cs b/JobTrackerApi/Services/TokenService.cs index 797a5fb..7392161 100644 --- a/JobTrackerApi/Services/TokenService.cs +++ b/JobTrackerApi/Services/TokenService.cs @@ -10,7 +10,7 @@ namespace JobTrackerApi.Services; public interface ITokenService { - Task CreateAccessTokenAsync(ApplicationUser user, CancellationToken cancellationToken = default); + Task 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 CreateAccessTokenAsync(ApplicationUser user, CancellationToken cancellationToken = default) + public async Task 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( diff --git a/JobTrackerApi/Services/TrustedDeviceService.cs b/JobTrackerApi/Services/TrustedDeviceService.cs index 468e35b..f3af7ec 100644 --- a/JobTrackerApi/Services/TrustedDeviceService.cs +++ b/JobTrackerApi/Services/TrustedDeviceService.cs @@ -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; diff --git a/Models/UserSession.cs b/Models/UserSession.cs new file mode 100644 index 0000000..ad62950 --- /dev/null +++ b/Models/UserSession.cs @@ -0,0 +1,17 @@ +namespace JobTrackerApi.Models; + +// Server-side record of a JWT issued via AppSessionIssuer. The JWT carries this row's Id as its +// "sid" claim; Program.cs's "local" JwtBearer OnTokenValidated looks the row up on every request +// so a session can actually be revoked before its JWT naturally expires (previously the JWT alone +// was the credential -- see AppSessionIssuer). Same shape/rationale as TrustedDevice, but this +// tracks the *session* itself rather than a "skip 2FA" cookie. +public sealed class UserSession +{ + public string Id { get; set; } = ""; + public string UserId { get; set; } = ""; + public string? DeviceLabel { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } + public DateTimeOffset LastSeenAtUtc { get; set; } + public DateTimeOffset ExpiresAtUtc { get; set; } + public DateTimeOffset? RevokedAtUtc { get; set; } +} diff --git a/job-tracker-ui/src/components/SessionsSettingsCard.tsx b/job-tracker-ui/src/components/SessionsSettingsCard.tsx new file mode 100644 index 0000000..e480fca --- /dev/null +++ b/job-tracker-ui/src/components/SessionsSettingsCard.tsx @@ -0,0 +1,148 @@ +import React, { useEffect, useState } from "react"; + +import { + Alert, + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + List, + ListItem, + ListItemSecondaryAction, + ListItemText, + Paper, + Typography, +} from "@mui/material"; +import DeleteIcon from "@mui/icons-material/Delete"; + +import { api, getApiErrorMessage } from "../api"; +import { useToast } from "../toast"; +import { useI18n } from "../i18n/I18nProvider"; +import { clearAuthClientState } from "../auth"; + +type Session = { + id: string; + deviceLabel: string | null; + createdAtUtc: string; + lastSeenAtUtc: string; + expiresAtUtc: string; + isCurrentSession: boolean; +}; + +function apiErrorMessage(e: any, t: (k: any) => string) { + return getApiErrorMessage(e, t("twoFactorGenericError")); +} + +export default function SessionsSettingsCard() { + const { toast } = useToast(); + const { t } = useI18n(); + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [confirmRevokeOthers, setConfirmRevokeOthers] = useState(false); + + const loadSessions = () => { + setLoading(true); + setError(null); + api + .get("/auth/sessions") + .then((r) => setSessions(r.data)) + .catch((e) => setError(apiErrorMessage(e, t))) + .finally(() => setLoading(false)); + }; + + useEffect(() => { loadSessions(); }, []); + + async function revokeSession(id: string, isCurrentSession: boolean) { + try { + await api.delete(`/auth/sessions/${id}`); + if (isCurrentSession) { + // Same pattern as AuthStatusCard's sign-out: clearing local auth state emits + // "auth-changed", which App.tsx's listener picks up to refetch /auth/me (now 401, + // since the server already deleted the session cookie) and redirect to /login. + clearAuthClientState(); + return; + } + toast(t("sessionsRevoked"), "success"); + loadSessions(); + } catch (e: any) { + setError(apiErrorMessage(e, t)); + } + } + + async function revokeOthers() { + try { + await api.post("/auth/sessions/revoke-others"); + toast(t("sessionsRevokedOthers"), "success"); + setConfirmRevokeOthers(false); + loadSessions(); + } catch (e: any) { + setError(apiErrorMessage(e, t)); + } + } + + return ( + + + {t("sessionsSectionTitle")} + + + {error ? {error} : null} + {!loading && sessions.length === 0 && !error ? ( + {t("sessionsEmpty")} + ) : null} + + {sessions.length > 0 ? ( + + {sessions.map((s) => ( + + + {s.deviceLabel || t("sessionsUnknownDevice")} + {s.isCurrentSession ? ( + + {t("sessionsCurrentDevice")} + + ) : null} + + } + secondary={t("sessionsMeta", { + lastSeen: new Date(s.lastSeenAtUtc).toLocaleString(), + expires: new Date(s.expiresAtUtc).toLocaleDateString(), + })} + /> + + revokeSession(s.id, s.isCurrentSession)}> + + + + + ))} + + ) : null} + + {sessions.length > 1 ? ( + + ) : null} + + setConfirmRevokeOthers(false)} maxWidth="sm" fullWidth> + {t("sessionsRevokeOthersConfirmTitle")} + + {t("sessionsRevokeOthersConfirmBody")} + + + + + + + + ); +} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index adee5f9..295af21 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -356,6 +356,17 @@ export const translations = { twoFactorTrustedDevicesRevokedAll: "All trusted devices have been signed out.", twoFactorRevokeAllConfirmTitle: "Sign out all trusted devices?", twoFactorRevokeAllConfirmBody: "You'll be asked for a 2FA code the next time you sign in on any device, including this one.", + sessionsSectionTitle: "Sessions", + sessionsEmpty: "No active sessions.", + sessionsUnknownDevice: "Unknown device", + sessionsCurrentDevice: "This device", + sessionsMeta: "Last active {lastSeen} · Expires {expires}", + sessionsRevoke: "Sign out", + sessionsRevoked: "Session signed out.", + sessionsRevokeOthers: "Sign out all other devices", + sessionsRevokedOthers: "All other sessions have been signed out.", + sessionsRevokeOthersConfirmTitle: "Sign out all other devices?", + sessionsRevokeOthersConfirmBody: "Every other session for your account will be signed out immediately. This device stays signed in.", cropDialogTitle: "Crop profile image", cropDialogBody: "Position and zoom your image. The saved avatar will be exported as a 512×512 square.", cropDialogZoom: "Zoom", @@ -1393,6 +1404,17 @@ export const translations = { twoFactorTrustedDevicesRevokedAll: "Alle betrodde enheter er logget ut.", twoFactorRevokeAllConfirmTitle: "Logg ut alle betrodde enheter?", twoFactorRevokeAllConfirmBody: "Du vil bli bedt om en 2FA-kode neste gang du logger inn på en enhet, inkludert denne.", + sessionsSectionTitle: "Økter", + sessionsEmpty: "Ingen aktive økter.", + sessionsUnknownDevice: "Ukjent enhet", + sessionsCurrentDevice: "Denne enheten", + sessionsMeta: "Sist aktiv {lastSeen} · Utløper {expires}", + sessionsRevoke: "Logg ut", + sessionsRevoked: "Økten er logget ut.", + sessionsRevokeOthers: "Logg ut alle andre enheter", + sessionsRevokedOthers: "Alle andre økter er logget ut.", + sessionsRevokeOthersConfirmTitle: "Logg ut alle andre enheter?", + sessionsRevokeOthersConfirmBody: "Alle andre økter for kontoen din blir umiddelbart logget ut. Denne enheten forblir innlogget.", cropDialogTitle: "Beskjær profilbilde", cropDialogBody: "Plasser og zoom bildet. Det lagrede avataren eksporteres som en kvadratisk 512×512-fil.", cropDialogZoom: "Zoom", diff --git a/job-tracker-ui/src/views/ProfilePage.tsx b/job-tracker-ui/src/views/ProfilePage.tsx index 1a4fbfe..504c1cc 100644 --- a/job-tracker-ui/src/views/ProfilePage.tsx +++ b/job-tracker-ui/src/views/ProfilePage.tsx @@ -12,6 +12,7 @@ import GoogleAuthCard from "../components/GoogleAuthCard"; import MicrosoftAuthCard from "../components/MicrosoftAuthCard"; import AuthStatusCard from "../components/AuthStatusCard"; import TwoFactorSettingsCard from "../components/TwoFactorSettingsCard"; +import SessionsSettingsCard from "../components/SessionsSettingsCard"; import EmailProviderConnections from "../components/EmailProviderConnections"; import CropImageDialog from "../components/CropImageDialog"; import { useToast } from "../toast"; @@ -1351,6 +1352,7 @@ export default function ProfilePage() { {isLocal ? : null} + {isLocal ? : null} ); }