Files
jobtrackingapp/JobTrackerApi.Tests/AuthSessionRevocationTests.cs
T

207 lines
10 KiB
C#

using System.Security.Claims;
using System.IdentityModel.Tokens.Jwt;
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class AuthSessionRevocationTests
{
[Fact]
public async Task Logout_revokes_the_exact_session_and_a_copied_principal_stops_working()
{
using var db = TestHostFactory.CreateInMemoryDb(null);
db.UserSessions.Add(Session("sid-current", "user-1"));
db.UserSessions.Add(Session("sid-other", "user-2"));
await db.SaveChangesAsync();
var controller = CreateAuthController(db, TestHostFactory.CreateUserManager()) ;
controller.HttpContext.User = Principal("user-1", "sid-current");
Assert.IsType<NoContentResult>(await controller.Logout(CancellationToken.None));
Assert.False(await LocalSessionValidator.IsValidAsync(db, Principal("user-1", "sid-current"), DateTimeOffset.UtcNow));
Assert.True(await LocalSessionValidator.IsValidAsync(db, Principal("user-2", "sid-other"), DateTimeOffset.UtcNow));
}
[Fact]
public async Task Logout_best_effort_revokes_an_expired_cookie_without_an_authenticated_principal()
{
using var db = TestHostFactory.CreateInMemoryDb(null);
db.UserSessions.Add(Session("sid-expired-cookie", "user-1"));
await db.SaveChangesAsync();
var controller = CreateAuthController(db, TestHostFactory.CreateUserManager());
var expired = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken(
claims: new[]
{
new Claim(ClaimTypes.NameIdentifier, "user-1"),
new Claim("sid", "sid-expired-cookie"),
},
expires: DateTime.UtcNow.AddMinutes(-10)));
controller.Request.Headers.Cookie = $"{AuthSessionOptions.SessionCookieName}={expired}";
Assert.IsType<NoContentResult>(await controller.Logout(CancellationToken.None));
Assert.NotNull((await db.UserSessions.IgnoreQueryFilters().SingleAsync()).RevokedAtUtc);
}
[Fact]
public async Task Password_reset_revokes_all_target_sessions_and_trusted_devices_but_preserves_two_factor()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", TwoFactorEnabled = true, TotpSecretEncrypted = "still-present" };
var users = TestHostFactory.CreateUserManager();
users.Setup(x => x.FindByEmailAsync(user.Email)).ReturnsAsync(user);
users.Setup(x => x.ResetPasswordAsync(user, "valid-token", "new-password")).ReturnsAsync(Microsoft.AspNetCore.Identity.IdentityResult.Success);
using var db = TestHostFactory.CreateInMemoryDb(null);
db.UserSessions.AddRange(Session("sid-a", user.Id), Session("sid-b", user.Id), Session("sid-other", "user-2"));
db.TrustedDevices.AddRange(Device(user.Id, "hash-a"), Device(user.Id, "hash-b"), Device("user-2", "hash-other"));
await db.SaveChangesAsync();
var controller = CreateAuthController(db, users);
Assert.IsType<NoContentResult>(await controller.ResetPassword(
new AuthController.ResetPasswordRequest(user.Email, "valid-token", "new-password"), CancellationToken.None));
Assert.All(await db.UserSessions.IgnoreQueryFilters().Where(x => x.UserId == user.Id).ToListAsync(), x => Assert.NotNull(x.RevokedAtUtc));
Assert.Null((await db.UserSessions.IgnoreQueryFilters().SingleAsync(x => x.UserId == "user-2")).RevokedAtUtc);
Assert.Empty(await db.TrustedDevices.IgnoreQueryFilters().Where(x => x.UserId == user.Id).ToListAsync());
Assert.Single(await db.TrustedDevices.IgnoreQueryFilters().Where(x => x.UserId == "user-2").ToListAsync());
Assert.True(user.TwoFactorEnabled);
Assert.Equal("still-present", user.TotpSecretEncrypted);
}
[Fact]
public async Task Password_change_rotates_the_session_and_keeps_only_the_current_trusted_device()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
var users = TestHostFactory.CreateUserManager();
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
users.Setup(x => x.ChangePasswordAsync(user, "old-password", "new-password"))
.ReturnsAsync(Microsoft.AspNetCore.Identity.IdentityResult.Success);
var tokens = new Mock<ITokenService>();
tokens.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("new-jwt");
using var db = TestHostFactory.CreateInMemoryDb(null);
db.UserSessions.AddRange(Session("sid-current", user.Id), Session("sid-other", user.Id));
const string trustedToken = "current-device-token";
var currentHash = TrustedDeviceService.HashToken(trustedToken);
db.TrustedDevices.AddRange(Device(user.Id, currentHash), Device(user.Id, "other-hash"), Device("user-2", "other-user-hash"));
await db.SaveChangesAsync();
var controller = CreateAuthController(db, users, tokens.Object);
controller.HttpContext.User = Principal(user.Id, "sid-current");
controller.Request.Headers.Cookie = $"{AuthSessionOptions.TrustedDeviceCookieName}={trustedToken}";
Assert.IsType<NoContentResult>(await controller.ChangePassword(
new AuthController.ChangePasswordRequest("old-password", "new-password"), CancellationToken.None));
var sessions = await db.UserSessions.IgnoreQueryFilters().Where(x => x.UserId == user.Id).ToListAsync();
Assert.Equal(2, sessions.Count(x => x.RevokedAtUtc is not null));
Assert.Single(sessions, x => x.RevokedAtUtc is null);
Assert.Equal(currentHash, (await db.TrustedDevices.IgnoreQueryFilters().SingleAsync(x => x.UserId == user.Id)).TokenHash);
Assert.Single(await db.TrustedDevices.IgnoreQueryFilters().Where(x => x.UserId == "user-2").ToListAsync());
}
[Fact]
public async Task Session_validator_requires_sid_and_user_to_match_the_same_row()
{
using var db = TestHostFactory.CreateInMemoryDb(null);
db.UserSessions.Add(Session("sid-user-1", "user-1"));
await db.SaveChangesAsync();
Assert.False(await LocalSessionValidator.IsValidAsync(db, Principal("user-2", "sid-user-1"), DateTimeOffset.UtcNow));
}
[Fact]
public async Task Session_validator_rejects_an_unconfirmed_account_when_verification_is_required()
{
using var db = TestHostFactory.CreateInMemoryDb(null);
db.Users.Add(new ApplicationUser { Id = "user-1", Email = "pending@example.com", UserName = "pending@example.com", EmailConfirmed = false });
db.UserSessions.Add(Session("sid-pending", "user-1"));
await db.SaveChangesAsync();
Assert.False(await LocalSessionValidator.IsValidAsync(db, Principal("user-1", "sid-pending"), DateTimeOffset.UtcNow, requireConfirmedEmail: true));
Assert.True(await LocalSessionValidator.IsValidAsync(db, Principal("user-1", "sid-pending"), DateTimeOffset.UtcNow));
}
[Fact]
public async Task Password_security_stamp_change_invalidates_a_pending_two_factor_challenge()
{
var user = new ApplicationUser
{
Id = "user-1",
TwoFactorEnabled = true,
TotpSecretEncrypted = "not-read-on-stamp-mismatch",
SecurityStamp = "new-stamp",
};
var users = TestHostFactory.CreateUserManager(user);
var pending = new TwoFactorPendingTokenService(new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
var pendingToken = pending.IssuePendingToken(user.Id, rememberMe: false, securityStamp: "old-stamp");
using var db = TestHostFactory.CreateInMemoryDb(null);
var controller = new TwoFactorController(
users.Object,
Mock.Of<ITokenService>(),
db,
pending,
new EphemeralDataProtectionProvider(),
BuildConfig())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() },
};
Assert.IsType<UnauthorizedResult>(await controller.Challenge(
new TwoFactorController.ChallengeRequest(pendingToken, "123456"), CancellationToken.None));
Assert.Null(pending.Resolve(pendingToken, consume: false));
}
private static AuthController CreateAuthController(
JobTrackerApi.Data.JobTrackerContext db,
Mock<Microsoft.AspNetCore.Identity.UserManager<ApplicationUser>> users,
ITokenService? tokens = null) =>
new(
BuildConfig(),
users.Object,
tokens ?? Mock.Of<ITokenService>(),
Mock.Of<IAppEmailSender>(),
Mock.Of<IGoogleTokenValidator>(),
Mock.Of<IMicrosoftTokenValidator>(),
NullLogger<AuthController>.Instance,
Mock.Of<ITwoFactorPendingTokenService>(),
db)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() },
};
private static IConfiguration BuildConfig() => new ConfigurationBuilder().AddInMemoryCollection().Build();
private static UserSession Session(string id, string userId) => new()
{
Id = id,
UserId = userId,
CreatedAtUtc = DateTimeOffset.UtcNow,
LastSeenAtUtc = DateTimeOffset.UtcNow,
ExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(1),
};
private static TrustedDevice Device(string userId, string hash) => new()
{
UserId = userId,
TokenHash = hash,
CreatedAtUtc = DateTimeOffset.UtcNow,
LastSeenAtUtc = DateTimeOffset.UtcNow,
ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(30),
};
private static ClaimsPrincipal Principal(string userId, string sid) => new(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, userId),
new Claim("sid", sid),
}, "local"));
}