Merge branch 'feature/auth-2fa-security' into main
CI and Deploy / test (push) Successful in 2m26s
CI and Deploy / deploy (push) Successful in 51s

Auth/registration/account-security overhaul: per-account lockout,
TOTP 2FA (RFC 6238) with recovery codes, trusted devices (30-day 2FA
skip), configurable email verification enforcement, and server-tracked
sessions (view/revoke/sign-out-others). Full security-settings UI and
login/OAuth 2FA challenge step.

# Conflicts:
#	JobTrackerApi/Services/StartupInitializationExtensions.cs
This commit is contained in:
cesnimda
2026-07-13 08:10:05 +02:00
35 changed files with 3287 additions and 99 deletions
@@ -1,10 +1,12 @@
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
@@ -17,6 +19,388 @@ namespace JobTrackerApi.Tests;
public sealed class AuthAndSystemControllerTests
{
[Fact]
public async Task Login_locks_account_after_five_failed_attempts_and_rejects_sixth_even_with_correct_password()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
var userManager = CreateUserManager();
userManager.Setup(x => x.FindByEmailAsync("person@example.com")).ReturnsAsync(user);
userManager.Setup(x => x.FindByNameAsync("person@example.com")).ReturnsAsync((ApplicationUser?)null);
var failedCount = 0;
var lockedOut = false;
userManager.Setup(x => x.IsLockedOutAsync(user)).Returns(() => Task.FromResult(lockedOut));
userManager.Setup(x => x.AccessFailedAsync(user))
.Callback(() =>
{
failedCount++;
if (failedCount >= 5) lockedOut = true;
})
.ReturnsAsync(IdentityResult.Success);
userManager.Setup(x => x.CheckPasswordAsync(user, "wrong-password")).ReturnsAsync(false);
userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb());
for (var i = 0; i < 5; i++)
{
var attempt = await controller.Login(new AuthController.LoginRequest("person@example.com", "wrong-password"), CancellationToken.None);
Assert.IsType<UnauthorizedResult>(attempt);
}
Assert.True(lockedOut);
var sixthAttempt = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);
Assert.IsType<UnauthorizedResult>(sixthAttempt);
userManager.Verify(x => x.CheckPasswordAsync(user, "correct-password"), Times.Never);
}
[Fact]
public async Task Login_skips_two_factor_when_trusted_device_cookie_matches_current_user()
{
var dbName = Guid.NewGuid().ToString();
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", TwoFactorEnabled = true, TotpSecretEncrypted = "irrelevant" };
const string token = "trusted-device-token";
using (var seedDb = BuildDb(dbName, null))
{
seedDb.TrustedDevices.Add(new TrustedDevice
{
UserId = "user-1",
TokenHash = TrustedDeviceService.HashToken(token),
CreatedAtUtc = DateTimeOffset.UtcNow,
LastSeenAtUtc = DateTimeOffset.UtcNow,
ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(30),
});
seedDb.SaveChanges();
}
var controller = BuildLoginController(user, "correct-password", out var db, dbName, token);
using (db)
{
var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
var session = Assert.IsType<AuthController.AuthSessionResult>(ok.Value);
Assert.True(session.Authenticated);
}
}
[Fact]
public async Task Login_does_not_skip_two_factor_when_trusted_device_belongs_to_different_user()
{
var dbName = Guid.NewGuid().ToString();
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", TwoFactorEnabled = true, TotpSecretEncrypted = "irrelevant" };
const string token = "trusted-device-token";
using (var seedDb = BuildDb(dbName, null))
{
seedDb.TrustedDevices.Add(new TrustedDevice
{
UserId = "user-2", // a different account -- must not skip 2FA for user-1
TokenHash = TrustedDeviceService.HashToken(token),
CreatedAtUtc = DateTimeOffset.UtcNow,
LastSeenAtUtc = DateTimeOffset.UtcNow,
ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(30),
});
seedDb.SaveChanges();
}
var controller = BuildLoginController(user, "correct-password", out var db, dbName, token);
using (db)
{
var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
Assert.IsType<AuthController.TwoFactorRequiredResult>(ok.Value);
}
}
[Fact]
public async Task Login_does_not_skip_two_factor_when_trusted_device_is_expired()
{
var dbName = Guid.NewGuid().ToString();
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", TwoFactorEnabled = true, TotpSecretEncrypted = "irrelevant" };
const string token = "trusted-device-token";
using (var seedDb = BuildDb(dbName, null))
{
seedDb.TrustedDevices.Add(new TrustedDevice
{
UserId = "user-1",
TokenHash = TrustedDeviceService.HashToken(token),
CreatedAtUtc = DateTimeOffset.UtcNow.AddDays(-31),
LastSeenAtUtc = DateTimeOffset.UtcNow.AddDays(-31),
ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(-1), // expired yesterday
});
seedDb.SaveChanges();
}
var controller = BuildLoginController(user, "correct-password", out var db, dbName, token);
using (db)
{
var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
Assert.IsType<AuthController.TwoFactorRequiredResult>(ok.Value);
}
}
[Fact]
public async Task Login_does_not_skip_two_factor_when_no_trusted_device_cookie_present()
{
var dbName = Guid.NewGuid().ToString();
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", TwoFactorEnabled = true, TotpSecretEncrypted = "irrelevant" };
var controller = BuildLoginController(user, "correct-password", out var db, dbName, cookieToken: null);
using (db)
{
var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
Assert.IsType<AuthController.TwoFactorRequiredResult>(ok.Value);
}
}
[Fact]
public async Task Register_sets_EmailConfirmed_false_and_sends_verification_email_when_flag_on()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Auth:AllowRegistration"] = "true",
["Auth:RequireEmailVerification"] = "true",
})
.Build();
var userManager = 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, _) => created = u)
.ReturnsAsync(IdentityResult.Success);
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<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
var emailSender = new Mock<IAppEmailSender>();
var controller = new AuthController(config, userManager.Object, tokenService.Object, emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.Register(new AuthController.RegisterRequest("new.user@example.com", "password123"), CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
Assert.NotNull(created);
Assert.False(created!.EmailConfirmed);
emailSender.Verify(x => x.SendAsync("new.user@example.com", It.IsAny<string>(), It.Is<string>(b => b.Contains("verify-email")), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Register_is_unchanged_when_flag_off()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:AllowRegistration"] = "true" })
.Build();
var userManager = 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, _) => 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");
var emailSender = new Mock<IAppEmailSender>();
var controller = new AuthController(config, userManager.Object, tokenService.Object, emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.Register(new AuthController.RegisterRequest("new.user@example.com", "password123"), CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
Assert.NotNull(created);
Assert.True(created!.EmailConfirmed);
emailSender.Verify(x => x.SendAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Login_rejects_unconfirmed_local_account_when_flag_on()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:RequireEmailVerification"] = "true" })
.Build();
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", EmailConfirmed = false };
var userManager = 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);
var controller = new AuthController(config, userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);
var obj = Assert.IsType<ObjectResult>(result);
Assert.Equal(StatusCodes.Status403Forbidden, obj.StatusCode);
}
[Fact]
public async Task Login_allows_unconfirmed_local_account_when_flag_off()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", EmailConfirmed = false };
var userManager = 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);
var tokenService = new Mock<ITokenService>();
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())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
var session = Assert.IsType<AuthController.AuthSessionResult>(ok.Value);
Assert.True(session.Authenticated);
}
[Fact]
public async Task VerifyEmail_confirms_account_on_valid_token()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", EmailConfirmed = false };
var userManager = CreateUserManager();
userManager.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user);
userManager.Setup(x => x.ConfirmEmailAsync(user, "good-token")).ReturnsAsync(IdentityResult.Success);
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb());
var result = await controller.VerifyEmail(new AuthController.VerifyEmailRequest("user-1", "good-token"));
Assert.IsType<NoContentResult>(result);
}
[Fact]
public async Task ResendVerificationEmail_returns_identical_response_for_real_and_fake_accounts()
{
var user = new ApplicationUser { Id = "user-1", Email = "real@example.com", UserName = "real@example.com", EmailConfirmed = false };
var userManager = CreateUserManager();
userManager.Setup(x => x.FindByEmailAsync("real@example.com")).ReturnsAsync(user);
userManager.Setup(x => x.FindByEmailAsync("fake@example.com")).ReturnsAsync((ApplicationUser?)null);
userManager.Setup(x => x.HasPasswordAsync(user)).ReturnsAsync(true);
userManager.Setup(x => x.GenerateEmailConfirmationTokenAsync(user)).ReturnsAsync("confirm-token");
var emailSender = new Mock<IAppEmailSender>();
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var realResult = await controller.ResendVerificationEmail(new AuthController.ResendVerificationEmailRequest("real@example.com"), CancellationToken.None);
var fakeResult = await controller.ResendVerificationEmail(new AuthController.ResendVerificationEmailRequest("fake@example.com"), CancellationToken.None);
Assert.IsType<NoContentResult>(realResult);
Assert.IsType<NoContentResult>(fakeResult);
emailSender.Verify(x => x.SendAsync("real@example.com", It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Exchange_google_token_new_user_stays_EmailConfirmed_true_even_when_verification_flag_is_on()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Auth:AllowRegistration"] = "true",
["Auth:RequireEmailVerification"] = "true",
})
.Build();
var userManager = CreateUserManager();
userManager.Setup(x => x.Users).Returns(new TestAsyncEnumerable<ApplicationUser>(new List<ApplicationUser>()));
userManager.Setup(x => x.FindByEmailAsync("new.hire@example.com")).ReturnsAsync((ApplicationUser?)null);
ApplicationUser? created = null;
userManager
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>()))
.Callback<ApplicationUser>(u => 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");
var googleValidator = new Mock<IGoogleTokenValidator>();
googleValidator
.Setup(x => x.ValidateAsync("google-token", It.IsAny<CancellationToken>()))
.ReturnsAsync(new GoogleTokenPrincipal("google-subject", "new.hire@example.com", true, "New", "Hire", "New Hire"));
var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), googleValidator.Object, Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.ExchangeGoogleToken(new AuthController.GoogleTokenRequest("google-token"), CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
Assert.NotNull(created);
Assert.True(created!.EmailConfirmed);
}
private static JobTrackerContext BuildDb(string dbName, string? currentUserId)
{
var options = new DbContextOptionsBuilder<JobTrackerContext>().UseInMemoryDatabase(dbName).Options;
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(x => x.UserId).Returns(currentUserId);
return new JobTrackerContext(options, currentUser.Object);
}
private static AuthController BuildLoginController(ApplicationUser user, string password, out JobTrackerContext db, string dbName, string? cookieToken)
{
var userManager = CreateUserManager();
userManager.Setup(x => x.FindByEmailAsync(user.Email!)).ReturnsAsync(user);
userManager.Setup(x => x.FindByNameAsync(user.Email!)).ReturnsAsync((ApplicationUser?)null);
userManager.Setup(x => x.IsLockedOutAsync(user)).ReturnsAsync(false);
userManager.Setup(x => x.CheckPasswordAsync(user, password)).ReturnsAsync(true);
userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success);
var tokenService = new Mock<ITokenService>();
tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
db = BuildDb(dbName, 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() }
};
if (cookieToken is not null)
{
controller.Request.Headers["Cookie"] = $"{AuthSessionOptions.TrustedDeviceCookieName}={cookieToken}";
}
return controller;
}
[Fact]
public async Task Update_profile_applies_trimmed_profile_fields()
{
@@ -25,7 +409,7 @@ public sealed class AuthAndSystemControllerTests
userManager.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance);
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb());
var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(" new@example.com ", " newuser ", " Ada ", " Lovelace ", " Ada L. ", null, null));
@@ -50,7 +434,7 @@ public sealed class AuthAndSystemControllerTests
.Setup(x => x.SendAsync(user.Email!, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("SMTP unavailable"));
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance)
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext
{
@@ -84,14 +468,14 @@ 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
.Setup(x => x.ValidateAsync("google-token", It.IsAny<CancellationToken>()))
.ReturnsAsync(new GoogleTokenPrincipal("google-subject", "dj@cesnimda.co.uk", true, "Dan", "Jones", "Dan Jones"));
var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), googleValidator.Object, Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance)
var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), googleValidator.Object, Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext
{
@@ -101,7 +485,7 @@ public sealed class AuthAndSystemControllerTests
var result = await controller.ExchangeGoogleToken(new AuthController.GoogleTokenRequest("google-token"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var ok = Assert.IsType<OkObjectResult>(result);
var payload = Assert.IsType<AuthController.AuthSessionResult>(ok.Value);
Assert.True(payload.Authenticated);
Assert.Equal("google", payload.Provider);
@@ -124,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
@@ -135,7 +519,7 @@ public sealed class AuthAndSystemControllerTests
.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:AllowRegistration"] = "true" })
.Build();
var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance)
var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext
{
@@ -145,7 +529,7 @@ public sealed class AuthAndSystemControllerTests
var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var ok = Assert.IsType<OkObjectResult>(result);
var payload = Assert.IsType<AuthController.AuthSessionResult>(ok.Value);
Assert.True(payload.Authenticated);
Assert.Equal("microsoft", payload.Provider);
@@ -166,7 +550,7 @@ public sealed class AuthAndSystemControllerTests
.Setup(x => x.ValidateAsync("microsoft-token", It.IsAny<CancellationToken>()))
.ReturnsAsync(new MicrosoftTokenPrincipal("ms-subject", "nobody@example.com", true, null, null, null));
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance)
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext
{
@@ -176,7 +560,7 @@ public sealed class AuthAndSystemControllerTests
var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None);
Assert.IsType<UnauthorizedObjectResult>(result.Result);
Assert.IsType<UnauthorizedObjectResult>(result);
userManager.Verify(x => x.CreateAsync(It.IsAny<ApplicationUser>()), Times.Never);
}
@@ -51,7 +51,7 @@ public sealed class ClientErrorsControllerTests
var userManager = TestHostFactory.CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), Mock.Of<ILogger<AuthController>>())
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), Mock.Of<ILogger<AuthController>>(), Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb())
{
ControllerContext = new ControllerContext
{
@@ -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"));
}
@@ -0,0 +1,280 @@
using JobTrackerApi.Controllers;
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 Moq;
using OtpNet;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class TwoFactorControllerTests
{
private static TwoFactorController BuildController(Mock<UserManager<ApplicationUser>> userManager, JobTrackerApi.Data.JobTrackerContext db, ITwoFactorPendingTokenService? pending = null, ApplicationUser? currentUser = null)
{
if (currentUser is not null)
{
userManager.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(currentUser);
}
var tokenService = new Mock<ITokenService>();
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 ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>()).Build())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
return controller;
}
[Fact]
public async Task Setup_then_verify_with_correct_code_enables_2fa_and_returns_recovery_codes()
{
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);
using var db = TestHostFactory.CreateInMemoryDb("user-1");
var controller = BuildController(userManager, db, currentUser: user);
var setupResult = Assert.IsType<OkObjectResult>(await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None));
var setup = Assert.IsType<TwoFactorController.SetupResult>(setupResult.Value);
Assert.NotNull(user.TotpPendingSecretEncrypted);
Assert.False(user.TwoFactorEnabled);
var code = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
var verifyResult = Assert.IsType<OkObjectResult>(await controller.VerifySetup(new TwoFactorController.VerifySetupRequest(code), CancellationToken.None));
var verify = Assert.IsType<TwoFactorController.VerifySetupResult>(verifyResult.Value);
Assert.True(verify.Enabled);
Assert.Equal(10, verify.RecoveryCodes.Count);
Assert.True(user.TwoFactorEnabled);
Assert.Null(user.TotpPendingSecretEncrypted);
Assert.NotNull(user.TotpSecretEncrypted);
Assert.NotNull(user.TotpEnabledAtUtc);
}
[Fact]
public async Task Verify_setup_with_wrong_code_is_rejected_and_does_not_enable_2fa()
{
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);
using var db = TestHostFactory.CreateInMemoryDb("user-1");
var controller = BuildController(userManager, db, currentUser: user);
await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None);
var result = await controller.VerifySetup(new TwoFactorController.VerifySetupRequest("000000"), CancellationToken.None);
Assert.IsType<UnauthorizedResult>(result);
Assert.False(user.TwoFactorEnabled);
}
[Fact]
public async Task Disable_with_wrong_password_is_rejected()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", TwoFactorEnabled = true, TotpSecretEncrypted = "irrelevant" };
var userManager = TestHostFactory.CreateUserManager();
userManager.Setup(x => x.CheckPasswordAsync(user, "wrong-password")).ReturnsAsync(false);
using var db = TestHostFactory.CreateInMemoryDb("user-1");
var controller = BuildController(userManager, db, currentUser: user);
var result = await controller.Disable(new TwoFactorController.PasswordConfirmRequest("wrong-password"), CancellationToken.None);
Assert.IsType<BadRequestObjectResult>(result);
Assert.True(user.TwoFactorEnabled);
Assert.NotNull(user.TotpSecretEncrypted);
}
[Fact]
public async Task Challenge_with_valid_totp_code_completes_sign_in()
{
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);
var pending = new TwoFactorPendingTokenService(new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
using var db = TestHostFactory.CreateInMemoryDb("user-1");
var controller = BuildController(userManager, db, pending, currentUser: user);
var setupResult = Assert.IsType<OkObjectResult>(await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None));
var setup = Assert.IsType<TwoFactorController.SetupResult>(setupResult.Value);
var code = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
await controller.VerifySetup(new TwoFactorController.VerifySetupRequest(code), CancellationToken.None);
var pendingToken = pending.IssuePendingToken("user-1", rememberMe: false);
var challengeCode = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
var challengeResult = Assert.IsType<OkObjectResult>(await controller.Challenge(new TwoFactorController.ChallengeRequest(pendingToken, challengeCode), CancellationToken.None));
var session = Assert.IsType<AuthController.AuthSessionResult>(challengeResult.Value);
Assert.True(session.Authenticated);
// The pending token is single-use.
var reuse = await controller.Challenge(new TwoFactorController.ChallengeRequest(pendingToken, challengeCode), CancellationToken.None);
Assert.IsType<UnauthorizedResult>(reuse);
}
[Fact]
public async Task Challenge_with_recovery_code_consumes_it_and_rejects_reuse()
{
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);
var pending = new TwoFactorPendingTokenService(new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
using var db = TestHostFactory.CreateInMemoryDb("user-1");
var controller = BuildController(userManager, db, pending, currentUser: user);
var setupResult = Assert.IsType<OkObjectResult>(await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None));
var setup = Assert.IsType<TwoFactorController.SetupResult>(setupResult.Value);
var code = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
var verifyResult = Assert.IsType<OkObjectResult>(await controller.VerifySetup(new TwoFactorController.VerifySetupRequest(code), CancellationToken.None));
var verify = Assert.IsType<TwoFactorController.VerifySetupResult>(verifyResult.Value);
var recoveryCode = verify.RecoveryCodes[0];
var pendingToken1 = pending.IssuePendingToken("user-1", rememberMe: false);
var challengeResult = Assert.IsType<OkObjectResult>(await controller.Challenge(new TwoFactorController.ChallengeRequest(pendingToken1, recoveryCode), CancellationToken.None));
Assert.True(Assert.IsType<AuthController.AuthSessionResult>(challengeResult.Value).Authenticated);
// Same recovery code can't be used a second time, even against a fresh pending token.
var pendingToken2 = pending.IssuePendingToken("user-1", rememberMe: false);
var reuse = await controller.Challenge(new TwoFactorController.ChallengeRequest(pendingToken2, recoveryCode), CancellationToken.None);
Assert.IsType<UnauthorizedResult>(reuse);
}
[Fact]
public async Task Challenge_with_trust_device_true_creates_trusted_device_and_sets_cookie()
{
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);
var pending = new TwoFactorPendingTokenService(new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
using var db = TestHostFactory.CreateInMemoryDb("user-1");
var controller = BuildController(userManager, db, pending, currentUser: user);
var setupResult = Assert.IsType<OkObjectResult>(await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None));
var setup = Assert.IsType<TwoFactorController.SetupResult>(setupResult.Value);
var code = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
await controller.VerifySetup(new TwoFactorController.VerifySetupRequest(code), CancellationToken.None);
var pendingToken = pending.IssuePendingToken("user-1", rememberMe: false);
var challengeCode = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
var challengeResult = Assert.IsType<OkObjectResult>(await controller.Challenge(new TwoFactorController.ChallengeRequest(pendingToken, challengeCode, TrustDevice: true), CancellationToken.None));
Assert.True(Assert.IsType<AuthController.AuthSessionResult>(challengeResult.Value).Authenticated);
var device = Assert.Single(db.TrustedDevices.IgnoreQueryFilters().Where(x => x.UserId == "user-1"));
Assert.NotNull(device.TokenHash);
var setCookieHeaders = controller.Response.Headers["Set-Cookie"];
var trustedDeviceCookie = Assert.Single(setCookieHeaders, h => h!.StartsWith($"{AuthSessionOptions.TrustedDeviceCookieName}=", StringComparison.Ordinal))!;
Assert.Contains("httponly", trustedDeviceCookie, StringComparison.OrdinalIgnoreCase);
Assert.Contains("samesite=strict", trustedDeviceCookie, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Challenge_without_trust_device_does_not_create_trusted_device()
{
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);
var pending = new TwoFactorPendingTokenService(new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
using var db = TestHostFactory.CreateInMemoryDb("user-1");
var controller = BuildController(userManager, db, pending, currentUser: user);
var setupResult = Assert.IsType<OkObjectResult>(await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None));
var setup = Assert.IsType<TwoFactorController.SetupResult>(setupResult.Value);
var code = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
await controller.VerifySetup(new TwoFactorController.VerifySetupRequest(code), CancellationToken.None);
var pendingToken = pending.IssuePendingToken("user-1", rememberMe: false);
var challengeCode = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
await controller.Challenge(new TwoFactorController.ChallengeRequest(pendingToken, challengeCode), CancellationToken.None);
Assert.Empty(db.TrustedDevices.IgnoreQueryFilters().Where(x => x.UserId == "user-1"));
}
[Fact]
public async Task Trusted_devices_can_be_listed_revoked_and_revoked_in_bulk_scoped_to_owner()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
var otherUser = new ApplicationUser { Id = "user-2", Email = "other@example.com", UserName = "other@example.com" };
var userManager = TestHostFactory.CreateUserManager();
using var db = TestHostFactory.CreateInMemoryDb("user-1");
db.TrustedDevices.Add(new TrustedDevice { UserId = "user-1", TokenHash = "hash-1", DeviceLabel = "Chrome on Windows", CreatedAtUtc = DateTimeOffset.UtcNow, LastSeenAtUtc = DateTimeOffset.UtcNow, ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(30) });
db.TrustedDevices.Add(new TrustedDevice { UserId = "user-1", TokenHash = "hash-2", DeviceLabel = "Safari on Mac", CreatedAtUtc = DateTimeOffset.UtcNow, LastSeenAtUtc = DateTimeOffset.UtcNow, ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(30) });
db.TrustedDevices.Add(new TrustedDevice { UserId = "user-2", TokenHash = "hash-3", DeviceLabel = "Someone else's device", CreatedAtUtc = DateTimeOffset.UtcNow, LastSeenAtUtc = DateTimeOffset.UtcNow, ExpiresAtUtc = DateTimeOffset.UtcNow.AddDays(30) });
db.SaveChanges();
var otherDeviceId = db.TrustedDevices.IgnoreQueryFilters().Single(x => x.UserId == "user-2").Id;
var controller = BuildController(userManager, db, currentUser: user);
var listResult = Assert.IsType<OkObjectResult>(await controller.ListTrustedDevices(CancellationToken.None));
var list = Assert.IsType<List<TwoFactorController.TrustedDeviceDto>>(listResult.Value);
Assert.Equal(2, list.Count);
// Can't revoke another user's device, even by guessing its id.
var revokeOther = await controller.RevokeTrustedDevice(otherDeviceId, CancellationToken.None);
Assert.IsType<NotFoundResult>(revokeOther);
Assert.NotNull(db.TrustedDevices.IgnoreQueryFilters().SingleOrDefault(x => x.Id == otherDeviceId));
var ownDeviceId = list[0].Id;
var revokeOwn = await controller.RevokeTrustedDevice(ownDeviceId, CancellationToken.None);
Assert.IsType<NoContentResult>(revokeOwn);
Assert.Null(db.TrustedDevices.IgnoreQueryFilters().SingleOrDefault(x => x.Id == ownDeviceId));
var revokeAll = await controller.RevokeAllTrustedDevices(CancellationToken.None);
Assert.IsType<NoContentResult>(revokeAll);
Assert.Empty(db.TrustedDevices.IgnoreQueryFilters().Where(x => x.UserId == "user-1"));
Assert.NotNull(db.TrustedDevices.IgnoreQueryFilters().SingleOrDefault(x => x.UserId == "user-2"));
}
[Fact]
public async Task Challenge_with_unknown_pending_token_is_rejected()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
var userManager = TestHostFactory.CreateUserManager();
using var db = TestHostFactory.CreateInMemoryDb("user-1");
var controller = BuildController(userManager, db, currentUser: user);
var result = await controller.Challenge(new TwoFactorController.ChallengeRequest("not-a-real-token", "123456"), CancellationToken.None);
Assert.IsType<UnauthorizedResult>(result);
}
}