From b914630657abf42980989ccca9f13f864fdc1c55 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 13 Jul 2026 01:02:35 +0200 Subject: [PATCH] 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. --- Data/JobTrackerContext.cs | 13 ++ .../AuthAndSystemControllerTests.cs | 156 +++++++++++++++++- .../ClientErrorsControllerTests.cs | 2 +- .../TwoFactorControllerTests.cs | 99 +++++++++++ JobTrackerApi/Controllers/AuthController.cs | 15 +- .../Controllers/TwoFactorController.cs | 68 +++++++- JobTrackerApi/Services/AuthSessionOptions.cs | 32 ++++ .../StartupInitializationExtensions.cs | 51 ++++++ .../Services/TrustedDeviceService.cs | 111 +++++++++++++ Models/TrustedDevice.cs | 16 ++ 10 files changed, 554 insertions(+), 9 deletions(-) create mode 100644 JobTrackerApi/Services/TrustedDeviceService.cs create mode 100644 Models/TrustedDevice.cs diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index 47d27f5..f0a0fb8 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -29,6 +29,7 @@ namespace JobTrackerApi.Data public DbSet CvExtractionRuns => Set(); public DbSet TailoredCvDrafts => Set(); public DbSet TwoFactorRecoveryCodes => Set(); + public DbSet TrustedDevices => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -151,6 +152,18 @@ namespace JobTrackerApi.Data modelBuilder.Entity() .HasIndex(x => new { x.UserId, x.UsedAtUtc }); + + // No FK to AspNetUsers: the login-time trusted-device check reads these rows before a + // session (and thus CurrentUserId) exists, via IgnoreQueryFilters() -- same convention + // as TwoFactorRecoveryCode above. + modelBuilder.Entity() + .HasQueryFilter(x => CurrentUserId != null && x.UserId == CurrentUserId); + + modelBuilder.Entity() + .HasIndex(x => x.UserId); + + modelBuilder.Entity() + .HasIndex(x => x.TokenHash); } } } diff --git a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs index 579a2dd..5b17fd2 100644 --- a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs +++ b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs @@ -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(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of()); + var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), 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(result); + var session = Assert.IsType(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(result); + Assert.IsType(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(result); + Assert.IsType(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(result); + Assert.IsType(ok.Value); + } + } + + private static JobTrackerContext BuildDb(string dbName, string? currentUserId) + { + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(dbName).Options; + var currentUser = new Mock(); + 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(); + tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny())).ReturnsAsync("app-token"); + + db = BuildDb(dbName, null); + + var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), 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())).ReturnsAsync(user); userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); - var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of()); + var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), 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(), It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("SMTP unavailable")); - var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), emailSender.Object, Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of()) + var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), emailSender.Object, Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) { ControllerContext = new ControllerContext { @@ -128,7 +272,7 @@ public sealed class AuthAndSystemControllerTests .Setup(x => x.ValidateAsync("google-token", It.IsAny())) .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(), googleValidator.Object, Mock.Of(), NullLogger.Instance, Mock.Of()) + var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of(), googleValidator.Object, Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) { ControllerContext = new ControllerContext { @@ -172,7 +316,7 @@ public sealed class AuthAndSystemControllerTests .AddInMemoryCollection(new Dictionary { ["Auth:AllowRegistration"] = "true" }) .Build(); - var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of(), Mock.Of(), microsoftValidator.Object, NullLogger.Instance, Mock.Of()) + var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of(), Mock.Of(), microsoftValidator.Object, NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) { ControllerContext = new ControllerContext { @@ -203,7 +347,7 @@ public sealed class AuthAndSystemControllerTests .Setup(x => x.ValidateAsync("microsoft-token", It.IsAny())) .ReturnsAsync(new MicrosoftTokenPrincipal("ms-subject", "nobody@example.com", true, null, null, null)); - var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), microsoftValidator.Object, NullLogger.Instance, Mock.Of()) + var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), microsoftValidator.Object, NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) { ControllerContext = new ControllerContext { diff --git a/JobTrackerApi.Tests/ClientErrorsControllerTests.cs b/JobTrackerApi.Tests/ClientErrorsControllerTests.cs index 9dcd5eb..a0c6d0e 100644 --- a/JobTrackerApi.Tests/ClientErrorsControllerTests.cs +++ b/JobTrackerApi.Tests/ClientErrorsControllerTests.cs @@ -51,7 +51,7 @@ public sealed class ClientErrorsControllerTests var userManager = TestHostFactory.CreateUserManager(); userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); - var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of>(), Mock.Of()) + var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of>(), Mock.Of(), TestHostFactory.CreateInMemoryDb()) { ControllerContext = new ControllerContext { diff --git a/JobTrackerApi.Tests/TwoFactorControllerTests.cs b/JobTrackerApi.Tests/TwoFactorControllerTests.cs index c331840..80e7a72 100644 --- a/JobTrackerApi.Tests/TwoFactorControllerTests.cs +++ b/JobTrackerApi.Tests/TwoFactorControllerTests.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using Moq; using OtpNet; using Xunit; @@ -163,6 +164,104 @@ public sealed class TwoFactorControllerTests Assert.IsType(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())).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(await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None)); + var setup = Assert.IsType(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(await controller.Challenge(new TwoFactorController.ChallengeRequest(pendingToken, challengeCode, TrustDevice: true), CancellationToken.None)); + Assert.True(Assert.IsType(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())).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(await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None)); + var setup = Assert.IsType(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(await controller.ListTrustedDevices(CancellationToken.None)); + var list = Assert.IsType>(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(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(revokeOwn); + Assert.Null(db.TrustedDevices.IgnoreQueryFilters().SingleOrDefault(x => x.Id == ownDeviceId)); + + var revokeAll = await controller.RevokeAllTrustedDevices(CancellationToken.None); + Assert.IsType(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() { diff --git a/JobTrackerApi/Controllers/AuthController.cs b/JobTrackerApi/Controllers/AuthController.cs index 5ec1ee4..af62956 100644 --- a/JobTrackerApi/Controllers/AuthController.cs +++ b/JobTrackerApi/Controllers/AuthController.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Security.Claims; +using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Authorization; @@ -22,8 +23,9 @@ public sealed class AuthController : ControllerBase private readonly IMicrosoftTokenValidator _microsoftTokens; private readonly ILogger _logger; private readonly ITwoFactorPendingTokenService _twoFactorPending; + private readonly JobTrackerContext _db; - public AuthController(IConfiguration cfg, UserManager users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger logger, ITwoFactorPendingTokenService twoFactorPending) + public AuthController(IConfiguration cfg, UserManager users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger logger, ITwoFactorPendingTokenService twoFactorPending, JobTrackerContext db) { _cfg = cfg; _users = users; @@ -33,6 +35,7 @@ public sealed class AuthController : ControllerBase _microsoftTokens = microsoftTokens; _logger = logger; _twoFactorPending = twoFactorPending; + _db = db; } [HttpGet("config")] @@ -671,6 +674,16 @@ public sealed class AuthController : ControllerBase // decorative: skipping straight to AppSessionIssuer here would defeat the whole feature. private async Task CompleteSignInAsync(ApplicationUser user, bool rememberMe, string provider, CancellationToken cancellationToken) { + // "Trust this device" cookie check happens BEFORE the 2FA gate: if it matches a + // non-expired row for this exact user, skip straight to a real session, same as if 2FA + // weren't required at all. Falls through to the normal gate for any other outcome + // (no cookie, wrong user, expired, revoked) -- never errors, just doesn't skip. + if (user.TwoFactorEnabled && await TrustedDeviceService.IsDeviceTrustedAsync(_db, Request, user.Id, cancellationToken)) + { + await AppSessionIssuer.IssueAsync(Request, Response, _tokens, user, rememberMe, cancellationToken); + return Ok(new AuthSessionResult(true, provider)); + } + if (user.TwoFactorEnabled) { var pendingToken = _twoFactorPending.IssuePendingToken(user.Id, rememberMe); diff --git a/JobTrackerApi/Controllers/TwoFactorController.cs b/JobTrackerApi/Controllers/TwoFactorController.cs index 3530a72..dc73c83 100644 --- a/JobTrackerApi/Controllers/TwoFactorController.cs +++ b/JobTrackerApi/Controllers/TwoFactorController.cs @@ -44,7 +44,8 @@ public sealed class TwoFactorController : ControllerBase public sealed record VerifySetupResult(bool Enabled, IReadOnlyList RecoveryCodes); public sealed record StatusResult(bool Enabled, DateTimeOffset? EnabledAtUtc); public sealed record RecoveryCodesResult(IReadOnlyList RecoveryCodes); - public sealed record ChallengeRequest(string PendingToken, string Code); + public sealed record ChallengeRequest(string PendingToken, string Code, bool TrustDevice = false); + public sealed record TrustedDeviceDto(int Id, string? DeviceLabel, DateTimeOffset CreatedAtUtc, DateTimeOffset LastSeenAtUtc, DateTimeOffset ExpiresAtUtc, bool IsCurrentDevice); [HttpPost("setup")] [Authorize(AuthenticationSchemes = "local")] @@ -202,9 +203,74 @@ public sealed class TwoFactorController : ControllerBase _pending.Resolve(pendingToken, consume: true); await AppSessionIssuer.IssueAsync(Request, Response, _tokens, user, session.RememberMe, cancellationToken); + + if (request.TrustDevice) + { + await TrustedDeviceService.IssueAsync(_db, Request, Response, user.Id, cancellationToken); + } + return Ok(new AuthController.AuthSessionResult(true, "local")); } + [HttpGet("trusted-devices")] + [Authorize(AuthenticationSchemes = "local")] + public async Task ListTrustedDevices(CancellationToken cancellationToken) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + + var currentHash = TrustedDeviceService.CurrentDeviceTokenHash(Request); + var devices = await _db.TrustedDevices + .Where(x => x.UserId == user.Id) + .OrderByDescending(x => x.LastSeenAtUtc) + .Select(x => new TrustedDeviceDto(x.Id, x.DeviceLabel, x.CreatedAtUtc, x.LastSeenAtUtc, x.ExpiresAtUtc, currentHash != null && x.TokenHash == currentHash)) + .ToListAsync(cancellationToken); + + return Ok(devices); + } + + [HttpDelete("trusted-devices/{id:int}")] + [Authorize(AuthenticationSchemes = "local")] + public async Task RevokeTrustedDevice(int id, CancellationToken cancellationToken) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + + var device = await _db.TrustedDevices.FirstOrDefaultAsync(x => x.Id == id && x.UserId == user.Id, cancellationToken); + if (device is null) return NotFound(); + + var currentHash = TrustedDeviceService.CurrentDeviceTokenHash(Request); + var isCurrentDevice = currentHash != null && string.Equals(device.TokenHash, currentHash, StringComparison.Ordinal); + + _db.TrustedDevices.Remove(device); + await _db.SaveChangesAsync(cancellationToken); + + if (isCurrentDevice) + { + TrustedDeviceService.ClearCookie(Request, Response); + } + + return NoContent(); + } + + [HttpPost("trusted-devices/revoke-all")] + [Authorize(AuthenticationSchemes = "local")] + public async Task RevokeAllTrustedDevices(CancellationToken cancellationToken) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + + var devices = await _db.TrustedDevices.Where(x => x.UserId == user.Id).ToListAsync(cancellationToken); + if (devices.Count > 0) + { + _db.TrustedDevices.RemoveRange(devices); + await _db.SaveChangesAsync(cancellationToken); + } + + TrustedDeviceService.ClearCookie(Request, Response); + return NoContent(); + } + private static bool VerifyCode(string base32Secret, string? code) { code = (code ?? string.Empty).Trim(); diff --git a/JobTrackerApi/Services/AuthSessionOptions.cs b/JobTrackerApi/Services/AuthSessionOptions.cs index 61b0a03..8979030 100644 --- a/JobTrackerApi/Services/AuthSessionOptions.cs +++ b/JobTrackerApi/Services/AuthSessionOptions.cs @@ -7,6 +7,7 @@ public static class AuthSessionOptions public const string SessionCookieName = "jobtracker_auth"; public const string CsrfCookieName = "XSRF-TOKEN"; public const string CsrfHeaderName = "X-CSRF-TOKEN"; + public const string TrustedDeviceCookieName = "jobtracker_td"; public static CookieOptions BuildSessionCookie(bool persistent, bool secure) { @@ -75,4 +76,35 @@ public static class AuthSessionOptions MaxAge = TimeSpan.Zero, }; } + + // Stricter than the session cookie (SameSite=Strict, never HttpOnly=false): this cookie's + // only job is "skip the 2FA prompt", so it must never be readable by JS and should not even + // be sent on cross-site navigations. + public static CookieOptions BuildTrustedDeviceCookie(bool secure) + { + return new CookieOptions + { + HttpOnly = true, + IsEssential = true, + SameSite = SameSiteMode.Strict, + Secure = secure, + Path = "/", + Expires = DateTimeOffset.UtcNow.AddDays(30), + MaxAge = TimeSpan.FromDays(30), + }; + } + + public static CookieOptions BuildExpiredTrustedDeviceCookie(bool secure) + { + return new CookieOptions + { + HttpOnly = true, + IsEssential = true, + SameSite = SameSiteMode.Strict, + Secure = secure, + Path = "/", + Expires = DateTimeOffset.UnixEpoch, + MaxAge = TimeSpan.Zero, + }; + } } diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index 4edcc34..1f237ae 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -647,11 +647,30 @@ public static class StartupInitializationExtensions Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc" ON "TwoFactorRecoveryCodes" ("UserId", "UsedAtUtc");"""); } + static void EnsureTrustedDevicesTable(DbConnection c) + { + Exec(c, """ + CREATE TABLE IF NOT EXISTS "TrustedDevices" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_TrustedDevices" PRIMARY KEY AUTOINCREMENT, + "UserId" TEXT NOT NULL, + "TokenHash" TEXT NOT NULL, + "DeviceLabel" TEXT NULL, + "CreatedAtUtc" TEXT NOT NULL, + "LastSeenAtUtc" TEXT NOT NULL, + "ExpiresAtUtc" TEXT NOT NULL + ); + """); + + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TrustedDevices_UserId" ON "TrustedDevices" ("UserId");"""); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TrustedDevices_TokenHash" ON "TrustedDevices" ("TokenHash");"""); + } + EnsureGmailConnectionsTable(conn); EnsureMicrosoftGraphConnectionsTable(conn); EnsureImapConnectionsTable(conn); EnsureCvTables(conn); EnsureTwoFactorRecoveryCodesTable(conn); + EnsureTrustedDevicesTable(conn); // Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded, // and at least one of the new columns already exists. @@ -1028,6 +1047,38 @@ public static class StartupInitializationExtensions cmd.ExecuteNonQuery(); } + if (!HasMySqlTable(conn, "TrustedDevices")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TrustedDevices` ( + `Id` int NOT NULL AUTO_INCREMENT, + `UserId` varchar(255) NOT NULL, + `TokenHash` varchar(255) NOT NULL, + `DeviceLabel` varchar(255) NULL, + `CreatedAtUtc` datetime(6) NOT NULL, + `LastSeenAtUtc` datetime(6) NOT NULL, + `ExpiresAtUtc` datetime(6) NOT NULL, + PRIMARY KEY (`Id`) + );"; + cmd.ExecuteNonQuery(); + } + + EnsureMySqlAutoIncrementPrimaryKey(conn, "TrustedDevices", "Id"); + + if (!MySqlIndexExists(conn, "TrustedDevices", "IX_TrustedDevices_UserId")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE INDEX `IX_TrustedDevices_UserId` ON `TrustedDevices` (`UserId`);"; + cmd.ExecuteNonQuery(); + } + + if (!MySqlIndexExists(conn, "TrustedDevices", "IX_TrustedDevices_TokenHash")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE INDEX `IX_TrustedDevices_TokenHash` ON `TrustedDevices` (`TokenHash`);"; + cmd.ExecuteNonQuery(); + } + if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId")) { using var cmd = conn.CreateCommand(); diff --git a/JobTrackerApi/Services/TrustedDeviceService.cs b/JobTrackerApi/Services/TrustedDeviceService.cs new file mode 100644 index 0000000..468e35b --- /dev/null +++ b/JobTrackerApi/Services/TrustedDeviceService.cs @@ -0,0 +1,111 @@ +using System.Security.Cryptography; +using System.Text; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; + +namespace JobTrackerApi.Services; + +// "Trust this device for 30 days" -- lets a 2FA challenge be skipped on the same browser for a +// month. Shared by AuthController (checks the cookie before gating on 2FA) and +// TwoFactorController (issues/lists/revokes the cookie's backing row). Never stores the +// plaintext token, only its SHA-256 hash. +public static class TrustedDeviceService +{ + private static readonly TimeSpan Lifetime = TimeSpan.FromDays(30); + + // Only returns true (and thus skips 2FA) when the cookie's hash matches a non-expired row + // for THIS SPECIFIC user -- UserId is part of the DB query itself, not a check applied + // after the fact, so a trusted-device cookie minted for user A can never skip 2FA for user + // B even if somehow presented on their request. + public static async Task IsDeviceTrustedAsync(JobTrackerContext db, HttpRequest request, string userId, CancellationToken cancellationToken) + { + var token = request.Cookies[AuthSessionOptions.TrustedDeviceCookieName]; + if (string.IsNullOrWhiteSpace(token)) return false; + + var hash = HashToken(token); + var now = DateTimeOffset.UtcNow; + var match = await db.TrustedDevices + .IgnoreQueryFilters() + .FirstOrDefaultAsync(x => x.UserId == userId && x.TokenHash == hash && x.ExpiresAtUtc > now, cancellationToken); + if (match is null) return false; + + match.LastSeenAtUtc = now; + await db.SaveChangesAsync(cancellationToken); + return true; + } + + public static async Task IssueAsync(JobTrackerContext db, HttpRequest request, HttpResponse response, string userId, CancellationToken cancellationToken) + { + var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); + var now = DateTimeOffset.UtcNow; + + db.TrustedDevices.Add(new TrustedDevice + { + UserId = userId, + TokenHash = HashToken(token), + DeviceLabel = DescribeUserAgent(request.Headers["User-Agent"].ToString()), + CreatedAtUtc = now, + LastSeenAtUtc = now, + ExpiresAtUtc = now.Add(Lifetime), + }); + await db.SaveChangesAsync(cancellationToken); + + var secure = request.IsHttps || string.Equals(request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase); + response.Cookies.Append(AuthSessionOptions.TrustedDeviceCookieName, token, AuthSessionOptions.BuildTrustedDeviceCookie(secure)); + } + + public static void ClearCookie(HttpRequest request, HttpResponse response) + { + var secure = request.IsHttps || string.Equals(request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase); + response.Cookies.Delete(AuthSessionOptions.TrustedDeviceCookieName, AuthSessionOptions.BuildExpiredTrustedDeviceCookie(secure)); + } + + // Used to flag "this device" in the trusted-devices list without ever sending a token or + // hash to the client -- just a boolean per row. + public static string? CurrentDeviceTokenHash(HttpRequest request) + { + var token = request.Cookies[AuthSessionOptions.TrustedDeviceCookieName]; + return string.IsNullOrWhiteSpace(token) ? null : HashToken(token); + } + + public static string HashToken(string token) + { + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token.Trim()))).ToLowerInvariant(); + } + + private static readonly (string Needle, string Label)[] BrowserMarkers = + { + ("Edg/", "Edge"), + ("OPR/", "Opera"), + ("Chrome/", "Chrome"), + ("Firefox/", "Firefox"), + ("Safari/", "Safari"), + }; + + private static readonly (string Needle, string Label)[] OsMarkers = + { + ("Windows", "Windows"), + ("Mac OS X", "Mac"), + ("iPhone", "iOS"), + ("iPad", "iOS"), + ("Android", "Android"), + ("Linux", "Linux"), + }; + + // ponytail: substring sniffing, not a real UA parser -- this only feeds a display label in + // a security-settings list ("Chrome on Windows"), nothing security-relevant depends on it. + private static string? DescribeUserAgent(string? userAgent) + { + if (string.IsNullOrWhiteSpace(userAgent)) return null; + + var browser = BrowserMarkers.FirstOrDefault(m => userAgent.Contains(m.Needle, StringComparison.Ordinal)).Label; + var os = OsMarkers.FirstOrDefault(m => userAgent.Contains(m.Needle, StringComparison.Ordinal)).Label; + + if (browser is null && os is null) return userAgent.Length > 80 ? userAgent[..80] : userAgent; + if (browser is null) return os; + if (os is null) return browser; + return $"{browser} on {os}"; + } +} diff --git a/Models/TrustedDevice.cs b/Models/TrustedDevice.cs new file mode 100644 index 0000000..341525d --- /dev/null +++ b/Models/TrustedDevice.cs @@ -0,0 +1,16 @@ +namespace JobTrackerApi.Models; + +// "Trust this device for 30 days" -- lets a browser skip the 2FA code step after one successful +// challenge. Never store the plaintext token, only its SHA-256 hash, same rationale as +// TwoFactorRecoveryCode.CodeHash: a DB read (backup, replica, leaked snapshot) can't be turned +// into a working cookie. +public sealed class TrustedDevice +{ + public int Id { get; set; } + public string UserId { get; set; } = ""; + public string TokenHash { get; set; } = ""; + public string? DeviceLabel { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } + public DateTimeOffset LastSeenAtUtc { get; set; } + public DateTimeOffset ExpiresAtUtc { get; set; } +}