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:
@@ -184,7 +184,7 @@ public sealed class AuthAndSystemControllerTests
|
||||
userManager.Setup(x => x.GenerateEmailConfirmationTokenAsync(It.IsAny<ApplicationUser>())).ReturnsAsync("confirm-token");
|
||||
|
||||
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>();
|
||||
|
||||
@@ -217,7 +217,7 @@ public sealed class AuthAndSystemControllerTests
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
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>();
|
||||
|
||||
@@ -272,7 +272,7 @@ public sealed class AuthAndSystemControllerTests
|
||||
userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
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())
|
||||
{
|
||||
@@ -347,7 +347,7 @@ public sealed class AuthAndSystemControllerTests
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
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>();
|
||||
googleValidator
|
||||
@@ -384,7 +384,7 @@ public sealed class AuthAndSystemControllerTests
|
||||
userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
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);
|
||||
|
||||
@@ -468,7 +468,7 @@ public sealed class AuthAndSystemControllerTests
|
||||
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
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>();
|
||||
googleValidator
|
||||
@@ -508,7 +508,7 @@ public sealed class AuthAndSystemControllerTests
|
||||
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
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>();
|
||||
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.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<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(
|
||||
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<string, string?>()).Build())
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user