feat(auth): add trusted-device 30-day 2FA skip (backend)

Adds a "trust this device" option to the 2FA challenge: on success, mints a
random token (only its SHA-256 hash is stored), sets it as a new httpOnly,
Secure, SameSite=Strict cookie, and records a TrustedDevice row. AuthController
checks that cookie for the exact signing-in user before gating on 2FA -- a
mismatched user, expired, or revoked device falls through to the normal 2FA
prompt, never errors. TwoFactorController also exposes list/revoke/revoke-all
endpoints for managing trusted devices, scoped to the owning user.

Schema added via the existing raw-SQL reconciler (SQLite + MySQL dialects),
not EF migrations, matching this repo's established pattern.
This commit is contained in:
cesnimda
2026-07-13 01:02:35 +02:00
parent b85dc1ffb7
commit b914630657
10 changed files with 554 additions and 9 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;
@@ -38,7 +40,7 @@ public sealed class AuthAndSystemControllerTests
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>());
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++)
{
@@ -54,6 +56,148 @@ public sealed class AuthAndSystemControllerTests
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);
}
}
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<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()
{
@@ -62,7 +206,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, Mock.Of<ITwoFactorPendingTokenService>());
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));
@@ -87,7 +231,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, Mock.Of<ITwoFactorPendingTokenService>())
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
{
@@ -128,7 +272,7 @@ public sealed class AuthAndSystemControllerTests
.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, Mock.Of<ITwoFactorPendingTokenService>())
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
{
@@ -172,7 +316,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, Mock.Of<ITwoFactorPendingTokenService>())
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
{
@@ -203,7 +347,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, Mock.Of<ITwoFactorPendingTokenService>())
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
{