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:
@@ -30,6 +30,7 @@ namespace JobTrackerApi.Data
|
|||||||
public DbSet<TailoredCvDraft> TailoredCvDrafts => Set<TailoredCvDraft>();
|
public DbSet<TailoredCvDraft> TailoredCvDrafts => Set<TailoredCvDraft>();
|
||||||
public DbSet<TwoFactorRecoveryCode> TwoFactorRecoveryCodes => Set<TwoFactorRecoveryCode>();
|
public DbSet<TwoFactorRecoveryCode> TwoFactorRecoveryCodes => Set<TwoFactorRecoveryCode>();
|
||||||
public DbSet<TrustedDevice> TrustedDevices => Set<TrustedDevice>();
|
public DbSet<TrustedDevice> TrustedDevices => Set<TrustedDevice>();
|
||||||
|
public DbSet<UserSession> UserSessions => Set<UserSession>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -164,6 +165,18 @@ namespace JobTrackerApi.Data
|
|||||||
|
|
||||||
modelBuilder.Entity<TrustedDevice>()
|
modelBuilder.Entity<TrustedDevice>()
|
||||||
.HasIndex(x => x.TokenHash);
|
.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<UserSession>()
|
||||||
|
.HasKey(x => x.Id);
|
||||||
|
|
||||||
|
modelBuilder.Entity<UserSession>()
|
||||||
|
.HasQueryFilter(x => CurrentUserId != null && x.UserId == CurrentUserId);
|
||||||
|
|
||||||
|
modelBuilder.Entity<UserSession>()
|
||||||
|
.HasIndex(x => x.UserId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ public sealed class AuthAndSystemControllerTests
|
|||||||
userManager.Setup(x => x.GenerateEmailConfirmationTokenAsync(It.IsAny<ApplicationUser>())).ReturnsAsync("confirm-token");
|
userManager.Setup(x => x.GenerateEmailConfirmationTokenAsync(It.IsAny<ApplicationUser>())).ReturnsAsync("confirm-token");
|
||||||
|
|
||||||
var tokenService = new Mock<ITokenService>();
|
var tokenService = new Mock<ITokenService>();
|
||||||
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
|
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
|
||||||
|
|
||||||
var emailSender = new Mock<IAppEmailSender>();
|
var emailSender = new Mock<IAppEmailSender>();
|
||||||
|
|
||||||
@@ -217,7 +217,7 @@ public sealed class AuthAndSystemControllerTests
|
|||||||
.ReturnsAsync(IdentityResult.Success);
|
.ReturnsAsync(IdentityResult.Success);
|
||||||
|
|
||||||
var tokenService = new Mock<ITokenService>();
|
var tokenService = new Mock<ITokenService>();
|
||||||
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
|
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
|
||||||
|
|
||||||
var emailSender = new Mock<IAppEmailSender>();
|
var emailSender = new Mock<IAppEmailSender>();
|
||||||
|
|
||||||
@@ -272,7 +272,7 @@ public sealed class AuthAndSystemControllerTests
|
|||||||
userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success);
|
userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||||
|
|
||||||
var tokenService = new Mock<ITokenService>();
|
var tokenService = new Mock<ITokenService>();
|
||||||
tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
|
tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
|
||||||
|
|
||||||
var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
|
var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
|
||||||
{
|
{
|
||||||
@@ -347,7 +347,7 @@ public sealed class AuthAndSystemControllerTests
|
|||||||
.ReturnsAsync(IdentityResult.Success);
|
.ReturnsAsync(IdentityResult.Success);
|
||||||
|
|
||||||
var tokenService = new Mock<ITokenService>();
|
var tokenService = new Mock<ITokenService>();
|
||||||
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
|
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
|
||||||
|
|
||||||
var googleValidator = new Mock<IGoogleTokenValidator>();
|
var googleValidator = new Mock<IGoogleTokenValidator>();
|
||||||
googleValidator
|
googleValidator
|
||||||
@@ -384,7 +384,7 @@ public sealed class AuthAndSystemControllerTests
|
|||||||
userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success);
|
userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||||
|
|
||||||
var tokenService = new Mock<ITokenService>();
|
var tokenService = new Mock<ITokenService>();
|
||||||
tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
|
tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
|
||||||
|
|
||||||
db = BuildDb(dbName, null);
|
db = BuildDb(dbName, null);
|
||||||
|
|
||||||
@@ -468,7 +468,7 @@ public sealed class AuthAndSystemControllerTests
|
|||||||
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||||
|
|
||||||
var tokenService = new Mock<ITokenService>();
|
var tokenService = new Mock<ITokenService>();
|
||||||
tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
|
tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
|
||||||
|
|
||||||
var googleValidator = new Mock<IGoogleTokenValidator>();
|
var googleValidator = new Mock<IGoogleTokenValidator>();
|
||||||
googleValidator
|
googleValidator
|
||||||
@@ -508,7 +508,7 @@ public sealed class AuthAndSystemControllerTests
|
|||||||
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
|
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
|
||||||
|
|
||||||
var tokenService = new Mock<ITokenService>();
|
var tokenService = new Mock<ITokenService>();
|
||||||
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
|
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
|
||||||
|
|
||||||
var microsoftValidator = new Mock<IMicrosoftTokenValidator>();
|
var microsoftValidator = new Mock<IMicrosoftTokenValidator>();
|
||||||
microsoftValidator
|
microsoftValidator
|
||||||
|
|||||||
@@ -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<string, string?>()).Build();
|
||||||
|
|
||||||
|
private static SessionsController BuildController(JobTrackerContext db, ApplicationUser user, string? currentSid = null)
|
||||||
|
{
|
||||||
|
var userManager = TestHostFactory.CreateUserManager();
|
||||||
|
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||||
|
|
||||||
|
var claims = new List<Claim> { 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<ITokenService>();
|
||||||
|
tokenService
|
||||||
|
.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Callback<ApplicationUser, string?, CancellationToken>((_, sid, _) => sessionIdPassedToToken = sid)
|
||||||
|
.ReturnsAsync("app-token");
|
||||||
|
|
||||||
|
using var db = TestHostFactory.CreateInMemoryDb(null);
|
||||||
|
var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), 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<string, string?> { ["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<ApplicationUser>(), "password123"))
|
||||||
|
.Callback<ApplicationUser, string>((u, _) => { u.Id = "new-user-1"; created = u; })
|
||||||
|
.ReturnsAsync(IdentityResult.Success);
|
||||||
|
|
||||||
|
var tokenService = new Mock<ITokenService>();
|
||||||
|
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
|
||||||
|
|
||||||
|
using var db = TestHostFactory.CreateInMemoryDb(null);
|
||||||
|
var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), 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<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
|
||||||
|
userManager.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user);
|
||||||
|
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).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<ITokenService>();
|
||||||
|
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).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<OkObjectResult>(await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None));
|
||||||
|
var setup = Assert.IsType<TwoFactorController.SetupResult>(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<OkObjectResult>(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<OkObjectResult>(await controller.List(CancellationToken.None));
|
||||||
|
var list = Assert.IsType<List<SessionsController.SessionDto>>(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<NotFoundResult>(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<NoContentResult>(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<NoContentResult>(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"));
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Http;
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Moq;
|
using Moq;
|
||||||
using OtpNet;
|
using OtpNet;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -23,14 +24,15 @@ public sealed class TwoFactorControllerTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
var tokenService = new Mock<ITokenService>();
|
var tokenService = new Mock<ITokenService>();
|
||||||
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
|
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
|
||||||
|
|
||||||
var controller = new TwoFactorController(
|
var controller = new TwoFactorController(
|
||||||
userManager.Object,
|
userManager.Object,
|
||||||
tokenService.Object,
|
tokenService.Object,
|
||||||
db,
|
db,
|
||||||
pending ?? new TwoFactorPendingTokenService(new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions())),
|
pending ?? new TwoFactorPendingTokenService(new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions())),
|
||||||
new EphemeralDataProtectionProvider())
|
new EphemeralDataProtectionProvider(),
|
||||||
|
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>()).Build())
|
||||||
{
|
{
|
||||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -781,7 +781,7 @@ public sealed class AuthController : ControllerBase
|
|||||||
// (no cookie, wrong user, expired, revoked) -- never errors, just doesn't skip.
|
// (no cookie, wrong user, expired, revoked) -- never errors, just doesn't skip.
|
||||||
if (user.TwoFactorEnabled && await TrustedDeviceService.IsDeviceTrustedAsync(_db, Request, user.Id, cancellationToken))
|
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));
|
return Ok(new AuthSessionResult(true, provider));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -791,7 +791,7 @@ public sealed class AuthController : ControllerBase
|
|||||||
return Ok(new TwoFactorRequiredResult(true, pendingToken));
|
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));
|
return Ok(new AuthSessionResult(true, provider));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,14 +28,16 @@ public sealed class TwoFactorController : ControllerBase
|
|||||||
private readonly JobTrackerContext _db;
|
private readonly JobTrackerContext _db;
|
||||||
private readonly ITwoFactorPendingTokenService _pending;
|
private readonly ITwoFactorPendingTokenService _pending;
|
||||||
private readonly IDataProtector _protector;
|
private readonly IDataProtector _protector;
|
||||||
|
private readonly IConfiguration _cfg;
|
||||||
|
|
||||||
public TwoFactorController(UserManager<ApplicationUser> users, ITokenService tokens, JobTrackerContext db, ITwoFactorPendingTokenService pending, IDataProtectionProvider protectionProvider)
|
public TwoFactorController(UserManager<ApplicationUser> users, ITokenService tokens, JobTrackerContext db, ITwoFactorPendingTokenService pending, IDataProtectionProvider protectionProvider, IConfiguration cfg)
|
||||||
{
|
{
|
||||||
_users = users;
|
_users = users;
|
||||||
_tokens = tokens;
|
_tokens = tokens;
|
||||||
_db = db;
|
_db = db;
|
||||||
_pending = pending;
|
_pending = pending;
|
||||||
_protector = protectionProvider.CreateProtector("totp-secret-v1");
|
_protector = protectionProvider.CreateProtector("totp-secret-v1");
|
||||||
|
_cfg = cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record PasswordConfirmRequest(string CurrentPassword);
|
public sealed record PasswordConfirmRequest(string CurrentPassword);
|
||||||
@@ -202,7 +204,7 @@ public sealed class TwoFactorController : ControllerBase
|
|||||||
if (!verified) return Unauthorized();
|
if (!verified) return Unauthorized();
|
||||||
|
|
||||||
_pending.Resolve(pendingToken, consume: true);
|
_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)
|
if (request.TrustDevice)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -286,16 +286,29 @@ builder.Services.AddAuthentication(options =>
|
|||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
},
|
},
|
||||||
OnTokenValidated = context =>
|
OnTokenValidated = async context =>
|
||||||
{
|
{
|
||||||
var userId = LocalAuthIdentity.GetRequiredUserId(context.Principal);
|
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.");
|
// Resolve a fresh scoped JobTrackerContext for this one lookup -- OnTokenValidated
|
||||||
return Task.CompletedTask;
|
// 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<JobTrackerContext>();
|
||||||
|
if (!await LocalSessionValidator.IsValidAsync(db, context.Principal, DateTimeOffset.UtcNow))
|
||||||
|
{
|
||||||
|
context.Fail("Session has been revoked or expired.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
options.TokenValidationParameters = new TokenValidationParameters
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
|||||||
@@ -1,17 +1,37 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using JobTrackerApi.Data;
|
||||||
using JobTrackerApi.Models;
|
using JobTrackerApi.Models;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace JobTrackerApi.Services;
|
namespace JobTrackerApi.Services;
|
||||||
|
|
||||||
// Shared by AuthController (local/Google/Microsoft sign-in) and TwoFactorController (the
|
// 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
|
// 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 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);
|
var secure = request.IsHttps || string.Equals(request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase);
|
||||||
response.Cookies.Append(AuthSessionOptions.SessionCookieName, token, AuthSessionOptions.BuildSessionCookie(rememberMe, secure));
|
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");""");
|
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);
|
EnsureGmailConnectionsTable(conn);
|
||||||
EnsureMicrosoftGraphConnectionsTable(conn);
|
EnsureMicrosoftGraphConnectionsTable(conn);
|
||||||
EnsureImapConnectionsTable(conn);
|
EnsureImapConnectionsTable(conn);
|
||||||
EnsureCvTables(conn);
|
EnsureCvTables(conn);
|
||||||
EnsureTwoFactorRecoveryCodesTable(conn);
|
EnsureTwoFactorRecoveryCodesTable(conn);
|
||||||
EnsureTrustedDevicesTable(conn);
|
EnsureTrustedDevicesTable(conn);
|
||||||
|
EnsureUserSessionsTable(conn);
|
||||||
|
|
||||||
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
|
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
|
||||||
// and at least one of the new columns already exists.
|
// and at least one of the new columns already exists.
|
||||||
@@ -1079,6 +1097,29 @@ public static class StartupInitializationExtensions
|
|||||||
cmd.ExecuteNonQuery();
|
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"))
|
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
|
||||||
{
|
{
|
||||||
using var cmd = conn.CreateCommand();
|
using var cmd = conn.CreateCommand();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ namespace JobTrackerApi.Services;
|
|||||||
|
|
||||||
public interface ITokenService
|
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
|
public sealed class TokenService : ITokenService
|
||||||
@@ -24,7 +24,7 @@ public sealed class TokenService : ITokenService
|
|||||||
_users = users;
|
_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();
|
var jwtKey = (_cfg["Auth:JwtKey"] ?? "").Trim();
|
||||||
if (string.IsNullOrWhiteSpace(jwtKey))
|
if (string.IsNullOrWhiteSpace(jwtKey))
|
||||||
@@ -57,6 +57,9 @@ public sealed class TokenService : ITokenService
|
|||||||
foreach (var r in roles)
|
foreach (var r in roles)
|
||||||
claims.Add(new Claim(ClaimTypes.Role, r));
|
claims.Add(new Claim(ClaimTypes.Role, r));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(sessionId))
|
||||||
|
claims.Add(new Claim("sid", sessionId));
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
var token = new JwtSecurityToken(
|
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
|
// 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.
|
// 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;
|
if (string.IsNullOrWhiteSpace(userAgent)) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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<Session[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [confirmRevokeOthers, setConfirmRevokeOthers] = useState(false);
|
||||||
|
|
||||||
|
const loadSessions = () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
api
|
||||||
|
.get<Session[]>("/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 (
|
||||||
|
<Paper sx={{ mt: 2, p: 2 }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||||
|
{t("sessionsSectionTitle")}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{error ? <Alert severity="error" role="alert" sx={{ mb: 1 }}>{error}</Alert> : null}
|
||||||
|
{!loading && sessions.length === 0 && !error ? (
|
||||||
|
<Typography sx={{ color: "text.secondary", mb: 1 }}>{t("sessionsEmpty")}</Typography>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{sessions.length > 0 ? (
|
||||||
|
<List dense disablePadding>
|
||||||
|
{sessions.map((s) => (
|
||||||
|
<ListItem key={s.id} divider>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<>
|
||||||
|
{s.deviceLabel || t("sessionsUnknownDevice")}
|
||||||
|
{s.isCurrentSession ? (
|
||||||
|
<Typography component="span" variant="caption" sx={{ ml: 1, color: "primary.main", fontWeight: 700 }}>
|
||||||
|
{t("sessionsCurrentDevice")}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
secondary={t("sessionsMeta", {
|
||||||
|
lastSeen: new Date(s.lastSeenAtUtc).toLocaleString(),
|
||||||
|
expires: new Date(s.expiresAtUtc).toLocaleDateString(),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<ListItemSecondaryAction>
|
||||||
|
<IconButton edge="end" aria-label={t("sessionsRevoke")} onClick={() => revokeSession(s.id, s.isCurrentSession)}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</ListItemSecondaryAction>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{sessions.length > 1 ? (
|
||||||
|
<Button variant="text" color="warning" size="small" sx={{ mt: 1 }} onClick={() => setConfirmRevokeOthers(true)}>
|
||||||
|
{t("sessionsRevokeOthers")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Dialog open={confirmRevokeOthers} onClose={() => setConfirmRevokeOthers(false)} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>{t("sessionsRevokeOthersConfirmTitle")}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography>{t("sessionsRevokeOthersConfirmBody")}</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button type="button" onClick={() => setConfirmRevokeOthers(false)}>{t("cancel")}</Button>
|
||||||
|
<Button variant="contained" color="warning" onClick={revokeOthers}>
|
||||||
|
{t("sessionsRevokeOthers")}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -356,6 +356,17 @@ export const translations = {
|
|||||||
twoFactorTrustedDevicesRevokedAll: "All trusted devices have been signed out.",
|
twoFactorTrustedDevicesRevokedAll: "All trusted devices have been signed out.",
|
||||||
twoFactorRevokeAllConfirmTitle: "Sign out all trusted devices?",
|
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.",
|
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",
|
cropDialogTitle: "Crop profile image",
|
||||||
cropDialogBody: "Position and zoom your image. The saved avatar will be exported as a 512×512 square.",
|
cropDialogBody: "Position and zoom your image. The saved avatar will be exported as a 512×512 square.",
|
||||||
cropDialogZoom: "Zoom",
|
cropDialogZoom: "Zoom",
|
||||||
@@ -1393,6 +1404,17 @@ export const translations = {
|
|||||||
twoFactorTrustedDevicesRevokedAll: "Alle betrodde enheter er logget ut.",
|
twoFactorTrustedDevicesRevokedAll: "Alle betrodde enheter er logget ut.",
|
||||||
twoFactorRevokeAllConfirmTitle: "Logg ut alle betrodde enheter?",
|
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.",
|
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",
|
cropDialogTitle: "Beskjær profilbilde",
|
||||||
cropDialogBody: "Plasser og zoom bildet. Det lagrede avataren eksporteres som en kvadratisk 512×512-fil.",
|
cropDialogBody: "Plasser og zoom bildet. Det lagrede avataren eksporteres som en kvadratisk 512×512-fil.",
|
||||||
cropDialogZoom: "Zoom",
|
cropDialogZoom: "Zoom",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import GoogleAuthCard from "../components/GoogleAuthCard";
|
|||||||
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
|
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
|
||||||
import AuthStatusCard from "../components/AuthStatusCard";
|
import AuthStatusCard from "../components/AuthStatusCard";
|
||||||
import TwoFactorSettingsCard from "../components/TwoFactorSettingsCard";
|
import TwoFactorSettingsCard from "../components/TwoFactorSettingsCard";
|
||||||
|
import SessionsSettingsCard from "../components/SessionsSettingsCard";
|
||||||
import EmailProviderConnections from "../components/EmailProviderConnections";
|
import EmailProviderConnections from "../components/EmailProviderConnections";
|
||||||
import CropImageDialog from "../components/CropImageDialog";
|
import CropImageDialog from "../components/CropImageDialog";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
@@ -1351,6 +1352,7 @@ export default function ProfilePage() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{isLocal ? <TwoFactorSettingsCard /> : null}
|
{isLocal ? <TwoFactorSettingsCard /> : null}
|
||||||
|
{isLocal ? <SessionsSettingsCard /> : null}
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user