221 lines
11 KiB
C#
221 lines
11 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.Users.AddRange(
|
|
new ApplicationUser { Id = "user-1", UserName = "one@example.test", Email = "one@example.test" },
|
|
new ApplicationUser { Id = "user-2", UserName = "two@example.test", Email = "two@example.test" });
|
|
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 Session_validator_rejects_an_account_pending_deletion()
|
|
{
|
|
using var db = TestHostFactory.CreateInMemoryDb(null);
|
|
db.Users.Add(new ApplicationUser { Id = "user-1", Email = "pending-delete@example.test", UserName = "pending-delete@example.test", DeletionStatus = AccountDeletionStatuses.Pending });
|
|
db.UserSessions.Add(Session("sid-delete", "user-1"));
|
|
await db.SaveChangesAsync();
|
|
|
|
Assert.False(await LocalSessionValidator.IsValidAsync(db, Principal("user-1", "sid-delete"), 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"));
|
|
}
|