From c68b49eda0a77258867b256a522fd0bdf94c5575 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 12 Jul 2026 20:48:09 +0200 Subject: [PATCH 1/7] feat(auth): add per-account lockout and TOTP 2FA with recovery codes Adds three layers of account-security hardening, all gated behind the existing SignInWithAppSessionAsync-equivalent (now AppSessionIssuer) so every sign-in path -- local, Google, Microsoft -- goes through the same lockout/2FA checks: - Per-account lockout: Identity's built-in lockout store (columns already provisioned, previously unused) is now wired up in AuthController.Login via IsLockedOutAsync/AccessFailedAsync/ResetAccessFailedCountAsync, 5 failed attempts / 15 min, same generic 401 as wrong-password to avoid enumeration. - RFC 6238 TOTP 2FA (Otp.NET) with QR-code setup (QRCoder, fully local/ offline) on a new TwoFactorController: setup requires password re-confirmation and returns a pending (unconfirmed) secret + QR; the secret is only persisted as active once verify-setup checks a real code. Secrets are encrypted at rest via the same IDataProtector pattern already used for Gmail/Microsoft OAuth refresh tokens. - Login/OAuth exchange now checks TwoFactorEnabled before issuing a real session. If enabled, it hands back an opaque, server-side (IMemoryCache) pending token via a new ITwoFactorPendingTokenService -- deliberately NOT a JWT, so it can never be presented as a bearer token to bypass the 2FA check on any other endpoint. Only POST /api/auth/2fa/challenge can redeem it, rate-limited at 5/5min (tighter than password login, since a 6-digit space is far more brute-forceable). - One-time recovery codes (10 per enable/regenerate, SHA-256-hashed at rest, shown once in plaintext) accepted in the same challenge endpoint as an alternative to a TOTP code. Schema: AspNetUsers gains TotpSecretEncrypted / TotpPendingSecretEncrypted / TotpEnabledAtUtc, plus a new TwoFactorRecoveryCodes table, added to both the SQLite and MySQL dialect blocks in the startup schema reconciler. Co-Authored-By: Claude Sonnet 5 --- Data/JobTrackerContext.cs | 10 + .../AuthAndSystemControllerTests.cs | 53 +++- .../ClientErrorsControllerTests.cs | 2 +- .../TwoFactorControllerTests.cs | 179 ++++++++++++ JobTrackerApi/Controllers/AuthController.cs | 58 ++-- .../Controllers/TwoFactorController.cs | 272 ++++++++++++++++++ JobTrackerApi/Program.cs | 17 ++ JobTrackerApi/Services/AppSessionIssuer.cs | 21 ++ .../StartupInitializationExtensions.cs | 53 +++- .../Services/TwoFactorPendingTokenService.cs | 47 +++ JobTrackerBackend/JobTrackerBackend.csproj | 2 + Models/ApplicationUser.cs | 3 + Models/TwoFactorRecoveryCode.cs | 12 + 13 files changed, 699 insertions(+), 30 deletions(-) create mode 100644 JobTrackerApi.Tests/TwoFactorControllerTests.cs create mode 100644 JobTrackerApi/Controllers/TwoFactorController.cs create mode 100644 JobTrackerApi/Services/AppSessionIssuer.cs create mode 100644 JobTrackerApi/Services/TwoFactorPendingTokenService.cs create mode 100644 Models/TwoFactorRecoveryCode.cs diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index 9f699e4..47d27f5 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -28,6 +28,7 @@ namespace JobTrackerApi.Data public DbSet CvUploadArtifacts => Set(); public DbSet CvExtractionRuns => Set(); public DbSet TailoredCvDrafts => Set(); + public DbSet TwoFactorRecoveryCodes => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -141,6 +142,15 @@ namespace JobTrackerApi.Data .WithOne(j => j.TailoredCvDraft) .HasForeignKey(x => x.JobApplicationId) .OnDelete(DeleteBehavior.Cascade); + + // No FK to AspNetUsers: the login-time challenge endpoint reads these rows before a + // session (and thus CurrentUserId) exists, via IgnoreQueryFilters() -- same convention + // as AdminAuditController's cross-cutting queries. + modelBuilder.Entity() + .HasQueryFilter(x => CurrentUserId != null && x.UserId == CurrentUserId); + + modelBuilder.Entity() + .HasIndex(x => new { x.UserId, x.UsedAtUtc }); } } } diff --git a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs index 2471710..579a2dd 100644 --- a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs +++ b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs @@ -17,6 +17,43 @@ 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(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of()); + + for (var i = 0; i < 5; i++) + { + var attempt = await controller.Login(new AuthController.LoginRequest("person@example.com", "wrong-password"), CancellationToken.None); + Assert.IsType(attempt); + } + + Assert.True(lockedOut); + + var sixthAttempt = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None); + + Assert.IsType(sixthAttempt); + userManager.Verify(x => x.CheckPasswordAsync(user, "correct-password"), Times.Never); + } + [Fact] public async Task Update_profile_applies_trimmed_profile_fields() { @@ -25,7 +62,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); + var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of()); var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(" new@example.com ", " newuser ", " Ada ", " Lovelace ", " Ada L. ", null, null)); @@ -50,7 +87,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) + var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), emailSender.Object, Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of()) { ControllerContext = new ControllerContext { @@ -91,7 +128,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) + var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of(), googleValidator.Object, Mock.Of(), NullLogger.Instance, Mock.Of()) { ControllerContext = new ControllerContext { @@ -101,7 +138,7 @@ public sealed class AuthAndSystemControllerTests var result = await controller.ExchangeGoogleToken(new AuthController.GoogleTokenRequest("google-token"), CancellationToken.None); - var ok = Assert.IsType(result.Result); + var ok = Assert.IsType(result); var payload = Assert.IsType(ok.Value); Assert.True(payload.Authenticated); Assert.Equal("google", payload.Provider); @@ -135,7 +172,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) + var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of(), Mock.Of(), microsoftValidator.Object, NullLogger.Instance, Mock.Of()) { ControllerContext = new ControllerContext { @@ -145,7 +182,7 @@ public sealed class AuthAndSystemControllerTests var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None); - var ok = Assert.IsType(result.Result); + var ok = Assert.IsType(result); var payload = Assert.IsType(ok.Value); Assert.True(payload.Authenticated); Assert.Equal("microsoft", payload.Provider); @@ -166,7 +203,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) + var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), microsoftValidator.Object, NullLogger.Instance, Mock.Of()) { ControllerContext = new ControllerContext { @@ -176,7 +213,7 @@ public sealed class AuthAndSystemControllerTests var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None); - Assert.IsType(result.Result); + Assert.IsType(result); userManager.Verify(x => x.CreateAsync(It.IsAny()), Times.Never); } diff --git a/JobTrackerApi.Tests/ClientErrorsControllerTests.cs b/JobTrackerApi.Tests/ClientErrorsControllerTests.cs index 1fef5df..9dcd5eb 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>()) + var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of>(), Mock.Of()) { ControllerContext = new ControllerContext { diff --git a/JobTrackerApi.Tests/TwoFactorControllerTests.cs b/JobTrackerApi.Tests/TwoFactorControllerTests.cs new file mode 100644 index 0000000..c331840 --- /dev/null +++ b/JobTrackerApi.Tests/TwoFactorControllerTests.cs @@ -0,0 +1,179 @@ +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 Moq; +using OtpNet; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class TwoFactorControllerTests +{ + private static TwoFactorController BuildController(Mock> userManager, JobTrackerApi.Data.JobTrackerContext db, ITwoFactorPendingTokenService? pending = null, ApplicationUser? currentUser = null) + { + if (currentUser is not null) + { + userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(currentUser); + } + + var tokenService = new Mock(); + tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny())).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()) + { + 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())).ReturnsAsync(IdentityResult.Success); + + using var db = TestHostFactory.CreateInMemoryDb("user-1"); + var controller = BuildController(userManager, db, currentUser: user); + + var setupResult = Assert.IsType(await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None)); + var setup = Assert.IsType(setupResult.Value); + Assert.NotNull(user.TotpPendingSecretEncrypted); + Assert.False(user.TwoFactorEnabled); + + var code = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp(); + + var verifyResult = Assert.IsType(await controller.VerifySetup(new TwoFactorController.VerifySetupRequest(code), CancellationToken.None)); + var verify = Assert.IsType(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())).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(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(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())).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), CancellationToken.None)); + var session = Assert.IsType(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(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())).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(); + var verifyResult = Assert.IsType(await controller.VerifySetup(new TwoFactorController.VerifySetupRequest(code), CancellationToken.None)); + var verify = Assert.IsType(verifyResult.Value); + var recoveryCode = verify.RecoveryCodes[0]; + + var pendingToken1 = pending.IssuePendingToken("user-1", rememberMe: false); + var challengeResult = Assert.IsType(await controller.Challenge(new TwoFactorController.ChallengeRequest(pendingToken1, recoveryCode), CancellationToken.None)); + Assert.True(Assert.IsType(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(reuse); + } + + [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(result); + } +} diff --git a/JobTrackerApi/Controllers/AuthController.cs b/JobTrackerApi/Controllers/AuthController.cs index a83d1f4..5ec1ee4 100644 --- a/JobTrackerApi/Controllers/AuthController.cs +++ b/JobTrackerApi/Controllers/AuthController.cs @@ -21,8 +21,9 @@ public sealed class AuthController : ControllerBase private readonly IGoogleTokenValidator _googleTokens; private readonly IMicrosoftTokenValidator _microsoftTokens; private readonly ILogger _logger; + private readonly ITwoFactorPendingTokenService _twoFactorPending; - public AuthController(IConfiguration cfg, UserManager users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger logger) + public AuthController(IConfiguration cfg, UserManager users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger logger, ITwoFactorPendingTokenService twoFactorPending) { _cfg = cfg; _users = users; @@ -31,6 +32,7 @@ public sealed class AuthController : ControllerBase _googleTokens = googleTokens; _microsoftTokens = microsoftTokens; _logger = logger; + _twoFactorPending = twoFactorPending; } [HttpGet("config")] @@ -55,6 +57,7 @@ public sealed class AuthController : ControllerBase public sealed record LoginRequest(string Email, string Password, bool RememberMe = true); public sealed record RegisterRequest(string Email, string Password, bool RememberMe = true); public sealed record AuthSessionResult(bool Authenticated, string Provider); + public sealed record TwoFactorRequiredResult(bool RequiresTwoFactor, string PendingToken); public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt); public sealed record MicrosoftLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt); public sealed record MeResult( @@ -83,7 +86,7 @@ public sealed class AuthController : ControllerBase [HttpPost("login")] [AllowAnonymous] [EnableRateLimiting("auth-login")] - public async Task> Login([FromBody] LoginRequest request, CancellationToken cancellationToken) + public async Task Login([FromBody] LoginRequest request, CancellationToken cancellationToken) { var email = (request.Email ?? string.Empty).Trim(); var password = request.Password ?? string.Empty; @@ -94,17 +97,26 @@ public sealed class AuthController : ControllerBase var user = await _users.FindByEmailAsync(email) ?? await _users.FindByNameAsync(email); if (user is null) return Unauthorized(); - var ok = await _users.CheckPasswordAsync(user, password); - if (!ok) return Unauthorized(); + // Same generic 401 whether the account doesn't exist, is locked out, or the password is + // wrong -- don't let a client distinguish "locked" from "wrong password" (enumeration). + if (await _users.IsLockedOutAsync(user)) return Unauthorized(); - await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken); - return Ok(new AuthSessionResult(true, "local")); + var ok = await _users.CheckPasswordAsync(user, password); + if (!ok) + { + await _users.AccessFailedAsync(user); + return Unauthorized(); + } + + await _users.ResetAccessFailedCountAsync(user); + + return await CompleteSignInAsync(user, request.RememberMe, "local", cancellationToken); } [HttpPost("register")] [AllowAnonymous] [EnableRateLimiting("auth-login")] - public async Task> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken) + public async Task Register([FromBody] RegisterRequest request, CancellationToken cancellationToken) { var allow = _cfg.GetValue("Auth:AllowRegistration", false); if (!allow) return StatusCode(403, "Registration is disabled."); @@ -125,14 +137,13 @@ public sealed class AuthController : ControllerBase return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description))); } - await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken); - return Ok(new AuthSessionResult(true, "local")); + return await CompleteSignInAsync(user, request.RememberMe, "local", cancellationToken); } [HttpPost("google/exchange")] [AllowAnonymous] [EnableRateLimiting("auth-login")] - public async Task> ExchangeGoogleToken([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken) + public async Task ExchangeGoogleToken([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken) { var token = (request.Token ?? string.Empty).Trim(); if (token.Length == 0) return BadRequest("Google token is required."); @@ -193,14 +204,13 @@ public sealed class AuthController : ControllerBase await _users.UpdateAsync(user); } - await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken); - return Ok(new AuthSessionResult(true, "google")); + return await CompleteSignInAsync(user, request.RememberMe, "google", cancellationToken); } [HttpPost("microsoft/exchange")] [AllowAnonymous] [EnableRateLimiting("auth-login")] - public async Task> ExchangeMicrosoftToken([FromBody] MicrosoftTokenRequest request, CancellationToken cancellationToken) + public async Task ExchangeMicrosoftToken([FromBody] MicrosoftTokenRequest request, CancellationToken cancellationToken) { var token = (request.Token ?? string.Empty).Trim(); if (token.Length == 0) return BadRequest("Microsoft token is required."); @@ -261,8 +271,7 @@ public sealed class AuthController : ControllerBase await _users.UpdateAsync(user); } - await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken); - return Ok(new AuthSessionResult(true, "microsoft")); + return await CompleteSignInAsync(user, request.RememberMe, "microsoft", cancellationToken); } [HttpPost("logout")] @@ -655,12 +664,21 @@ public sealed class AuthController : ControllerBase return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: detail); } - private async Task SignInWithAppSessionAsync(ApplicationUser user, bool rememberMe, CancellationToken cancellationToken) + // Shared by local/Google/Microsoft sign-in. If the account has TOTP 2FA enabled, this does + // NOT issue the real session -- it hands back a short-lived opaque pending token that only + // POST /api/auth/2fa/challenge can redeem, after the caller proves they hold the TOTP device + // (or a recovery code). This is the gate that makes 2FA actually mandatory rather than + // decorative: skipping straight to AppSessionIssuer here would defeat the whole feature. + private async Task CompleteSignInAsync(ApplicationUser user, bool rememberMe, string provider, CancellationToken cancellationToken) { - var token = await _tokens.CreateAccessTokenAsync(user, cancellationToken); - var secure = Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase); - Response.Cookies.Append(AuthSessionOptions.SessionCookieName, token, AuthSessionOptions.BuildSessionCookie(rememberMe, secure)); - EnsureCsrfCookie(rememberMe, secure); + if (user.TwoFactorEnabled) + { + var pendingToken = _twoFactorPending.IssuePendingToken(user.Id, rememberMe); + return Ok(new TwoFactorRequiredResult(true, pendingToken)); + } + + await AppSessionIssuer.IssueAsync(Request, Response, _tokens, user, rememberMe, cancellationToken); + return Ok(new AuthSessionResult(true, provider)); } private void EnsureCsrfCookie(bool persistent, bool? secureOverride = null) diff --git a/JobTrackerApi/Controllers/TwoFactorController.cs b/JobTrackerApi/Controllers/TwoFactorController.cs new file mode 100644 index 0000000..3530a72 --- /dev/null +++ b/JobTrackerApi/Controllers/TwoFactorController.cs @@ -0,0 +1,272 @@ +using System.Security.Cryptography; +using System.Text; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; +using OtpNet; +using QRCoder; + +namespace JobTrackerApi.Controllers; + +// TOTP 2FA (RFC 6238) + recovery codes. Split out from AuthController (already 700+ lines) +// rather than growing it further; shares the session cookie logic via AppSessionIssuer and the +// pending-token handoff via ITwoFactorPendingTokenService. +[ApiController] +[Route("api/auth/2fa")] +public sealed class TwoFactorController : ControllerBase +{ + private const int RecoveryCodeCount = 10; + + private readonly UserManager _users; + private readonly ITokenService _tokens; + private readonly JobTrackerContext _db; + private readonly ITwoFactorPendingTokenService _pending; + private readonly IDataProtector _protector; + + public TwoFactorController(UserManager users, ITokenService tokens, JobTrackerContext db, ITwoFactorPendingTokenService pending, IDataProtectionProvider protectionProvider) + { + _users = users; + _tokens = tokens; + _db = db; + _pending = pending; + _protector = protectionProvider.CreateProtector("totp-secret-v1"); + } + + public sealed record PasswordConfirmRequest(string CurrentPassword); + public sealed record SetupResult(string ManualEntryKey, string QrCodeDataUrl); + public sealed record VerifySetupRequest(string Code); + 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); + + [HttpPost("setup")] + [Authorize(AuthenticationSchemes = "local")] + [EnableRateLimiting("auth-login")] + public async Task Setup([FromBody] PasswordConfirmRequest request, CancellationToken cancellationToken) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + + if (!await _users.CheckPasswordAsync(user, request.CurrentPassword ?? string.Empty)) + { + return BadRequest("Current password is incorrect."); + } + + var secretBytes = KeyGeneration.GenerateRandomKey(20); + var base32Secret = Base32Encoding.ToString(secretBytes); + + user.TotpPendingSecretEncrypted = _protector.Protect(base32Secret); + var result = await _users.UpdateAsync(user); + if (!result.Succeeded) + { + return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description))); + } + + var issuer = "JobTracker"; + var label = Uri.EscapeDataString($"{issuer}:{user.Email}"); + var otpauthUri = $"otpauth://totp/{label}?secret={base32Secret}&issuer={Uri.EscapeDataString(issuer)}&digits=6&period=30"; + + using var qrGenerator = new QRCodeGenerator(); + using var qrData = qrGenerator.CreateQrCode(otpauthUri, QRCodeGenerator.ECCLevel.Q); + var pngQr = new PngByteQRCode(qrData); + var qrPngBytes = pngQr.GetGraphic(10); + var qrDataUrl = $"data:image/png;base64,{Convert.ToBase64String(qrPngBytes)}"; + + return Ok(new SetupResult(base32Secret, qrDataUrl)); + } + + [HttpPost("verify-setup")] + [Authorize(AuthenticationSchemes = "local")] + [EnableRateLimiting("auth-login")] + public async Task VerifySetup([FromBody] VerifySetupRequest request, CancellationToken cancellationToken) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + + if (string.IsNullOrWhiteSpace(user.TotpPendingSecretEncrypted)) + { + return BadRequest("No pending 2FA setup. Call setup first."); + } + + var base32Secret = _protector.Unprotect(user.TotpPendingSecretEncrypted); + if (!VerifyCode(base32Secret, request.Code)) + { + return Unauthorized(); + } + + user.TotpSecretEncrypted = user.TotpPendingSecretEncrypted; + user.TotpPendingSecretEncrypted = null; + user.TwoFactorEnabled = true; + user.TotpEnabledAtUtc = DateTimeOffset.UtcNow; + + var result = await _users.UpdateAsync(user); + if (!result.Succeeded) + { + return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description))); + } + + var codes = await RegenerateRecoveryCodesAsync(user.Id, cancellationToken); + return Ok(new VerifySetupResult(true, codes)); + } + + [HttpPost("disable")] + [Authorize(AuthenticationSchemes = "local")] + [EnableRateLimiting("auth-login")] + public async Task Disable([FromBody] PasswordConfirmRequest request, CancellationToken cancellationToken) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + + if (!await _users.CheckPasswordAsync(user, request.CurrentPassword ?? string.Empty)) + { + return BadRequest("Current password is incorrect."); + } + + user.TotpSecretEncrypted = null; + user.TotpPendingSecretEncrypted = null; + user.TwoFactorEnabled = false; + user.TotpEnabledAtUtc = null; + + var result = await _users.UpdateAsync(user); + if (!result.Succeeded) + { + return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description))); + } + + await RemoveAllRecoveryCodesAsync(user.Id, cancellationToken); + return NoContent(); + } + + [HttpGet("status")] + [Authorize(AuthenticationSchemes = "local")] + public async Task Status() + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + + return Ok(new StatusResult(user.TwoFactorEnabled, user.TotpEnabledAtUtc)); + } + + [HttpPost("recovery-codes/regenerate")] + [Authorize(AuthenticationSchemes = "local")] + [EnableRateLimiting("auth-login")] + public async Task RegenerateRecoveryCodes([FromBody] PasswordConfirmRequest request, CancellationToken cancellationToken) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + + if (!user.TwoFactorEnabled) + { + return BadRequest("Two-factor authentication is not enabled."); + } + + if (!await _users.CheckPasswordAsync(user, request.CurrentPassword ?? string.Empty)) + { + return BadRequest("Current password is incorrect."); + } + + var codes = await RegenerateRecoveryCodesAsync(user.Id, cancellationToken); + return Ok(new RecoveryCodesResult(codes)); + } + + [HttpPost("challenge")] + [AllowAnonymous] + [EnableRateLimiting("auth-2fa-challenge")] + public async Task Challenge([FromBody] ChallengeRequest request, CancellationToken cancellationToken) + { + var pendingToken = (request.PendingToken ?? string.Empty).Trim(); + var code = (request.Code ?? string.Empty).Trim(); + if (pendingToken.Length == 0 || code.Length == 0) return Unauthorized(); + + // Peek without consuming: only burn the pending token once the code actually checks out, + // so a mistyped code doesn't force the user back through password login. + var session = _pending.Resolve(pendingToken, consume: false); + if (session is null) return Unauthorized(); + + var user = await _users.FindByIdAsync(session.UserId); + if (user is null || !user.TwoFactorEnabled || string.IsNullOrWhiteSpace(user.TotpSecretEncrypted)) + { + return Unauthorized(); + } + + var base32Secret = _protector.Unprotect(user.TotpSecretEncrypted); + var verified = VerifyCode(base32Secret, code) || await TryConsumeRecoveryCodeAsync(user.Id, code, cancellationToken); + if (!verified) return Unauthorized(); + + _pending.Resolve(pendingToken, consume: true); + await AppSessionIssuer.IssueAsync(Request, Response, _tokens, user, session.RememberMe, cancellationToken); + return Ok(new AuthController.AuthSessionResult(true, "local")); + } + + private static bool VerifyCode(string base32Secret, string? code) + { + code = (code ?? string.Empty).Trim(); + if (code.Length == 0) return false; + + var totp = new Totp(Base32Encoding.ToBytes(base32Secret)); + // +-1 step (30s) of drift, the RFC 6238 standard tolerance for clock skew between the + // authenticator app and the server. + return totp.VerifyTotp(code, out _, new VerificationWindow(1, 1)); + } + + private async Task TryConsumeRecoveryCodeAsync(string userId, string code, CancellationToken cancellationToken) + { + var hash = HashRecoveryCode(code); + var match = await _db.TwoFactorRecoveryCodes + .IgnoreQueryFilters() + .FirstOrDefaultAsync(x => x.UserId == userId && x.CodeHash == hash && x.UsedAtUtc == null, cancellationToken); + if (match is null) return false; + + match.UsedAtUtc = DateTimeOffset.UtcNow; + await _db.SaveChangesAsync(cancellationToken); + return true; + } + + private async Task> RegenerateRecoveryCodesAsync(string userId, CancellationToken cancellationToken) + { + await RemoveAllRecoveryCodesAsync(userId, cancellationToken); + + var now = DateTimeOffset.UtcNow; + var plainCodes = new List(RecoveryCodeCount); + var rows = new List(RecoveryCodeCount); + for (var i = 0; i < RecoveryCodeCount; i++) + { + var plain = GenerateRecoveryCode(); + plainCodes.Add(plain); + rows.Add(new TwoFactorRecoveryCode { UserId = userId, CodeHash = HashRecoveryCode(plain), CreatedAtUtc = now }); + } + + _db.TwoFactorRecoveryCodes.AddRange(rows); + await _db.SaveChangesAsync(cancellationToken); + return plainCodes; + } + + private async Task RemoveAllRecoveryCodesAsync(string userId, CancellationToken cancellationToken) + { + var existing = await _db.TwoFactorRecoveryCodes.IgnoreQueryFilters().Where(x => x.UserId == userId).ToListAsync(cancellationToken); + if (existing.Count == 0) return; + _db.TwoFactorRecoveryCodes.RemoveRange(existing); + await _db.SaveChangesAsync(cancellationToken); + } + + private static string GenerateRecoveryCode() + { + var hex = Convert.ToHexString(RandomNumberGenerator.GetBytes(5)).ToLowerInvariant(); // 10 hex chars, 40 bits + return $"{hex[..5]}-{hex[5..]}"; + } + + // ponytail: recovery codes are already random high-entropy tokens (not user-chosen + // passwords), so a plain SHA-256 hash is sufficient -- no per-code salt or PBKDF2 needed. + private static string HashRecoveryCode(string code) + { + var normalized = code.Trim().ToLowerInvariant(); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized))).ToLowerInvariant(); + } +} diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 5f73116..b5aef01 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -183,12 +183,16 @@ builder.Services.AddIdentityCore(options => options.Password.RequireUppercase = false; options.Password.RequireNonAlphanumeric = false; options.Password.RequiredLength = 8; + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); + options.Lockout.MaxFailedAccessAttempts = 5; + options.Lockout.AllowedForNewUsers = true; }) .AddRoles() .AddEntityFrameworkStores() .AddSignInManager(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -378,6 +382,19 @@ builder.Services.AddRateLimiter(options => QueueProcessingOrder = QueueProcessingOrder.OldestFirst, QueueLimit = 0, })); + + // Brute-forcing a 6-digit TOTP code (1e6 space) is far more feasible than a password, so + // this gets a tighter window than auth-login. + options.AddPolicy("auth-2fa-challenge", context => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: $"2fa:{context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 5, + Window = TimeSpan.FromMinutes(5), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 0, + })); }); var app = builder.Build(); diff --git a/JobTrackerApi/Services/AppSessionIssuer.cs b/JobTrackerApi/Services/AppSessionIssuer.cs new file mode 100644 index 0000000..043d0d0 --- /dev/null +++ b/JobTrackerApi/Services/AppSessionIssuer.cs @@ -0,0 +1,21 @@ +using System.Security.Cryptography; +using JobTrackerApi.Models; +using Microsoft.AspNetCore.Http; + +namespace JobTrackerApi.Services; + +// Shared by AuthController (local/Google/Microsoft sign-in) and TwoFactorController (the +// post-challenge sign-in) so the httpOnly session cookie + readable CSRF cookie are always +// issued the same way, from one place. +public static class AppSessionIssuer +{ + public static async Task IssueAsync(HttpRequest request, HttpResponse response, ITokenService tokens, ApplicationUser user, bool rememberMe, CancellationToken cancellationToken) + { + var token = await tokens.CreateAccessTokenAsync(user, cancellationToken); + var secure = request.IsHttps || string.Equals(request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase); + response.Cookies.Append(AuthSessionOptions.SessionCookieName, token, AuthSessionOptions.BuildSessionCookie(rememberMe, secure)); + + var csrf = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); + response.Cookies.Append(AuthSessionOptions.CsrfCookieName, csrf, AuthSessionOptions.BuildCsrfCookie(rememberMe, secure)); + } +} diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index c3cf867..4edcc34 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -244,6 +244,9 @@ public static class StartupInitializationExtensions `MicrosoftSubject` longtext NULL, `MicrosoftEmail` longtext NULL, `MicrosoftLinkedAt` datetime(6) NULL, + `TotpSecretEncrypted` longtext NULL, + `TotpPendingSecretEncrypted` longtext NULL, + `TotpEnabledAtUtc` datetime(6) NULL, PRIMARY KEY (`Id`) ) CHARACTER SET=utf8mb4; @@ -359,7 +362,10 @@ public static class StartupInitializationExtensions "GoogleLinkedAt" TEXT NULL, "MicrosoftSubject" TEXT NULL, "MicrosoftEmail" TEXT NULL, - "MicrosoftLinkedAt" TEXT NULL + "MicrosoftLinkedAt" TEXT NULL, + "TotpSecretEncrypted" TEXT NULL, + "TotpPendingSecretEncrypted" TEXT NULL, + "TotpEnabledAtUtc" TEXT NULL ); """); @@ -440,6 +446,9 @@ public static class StartupInitializationExtensions EnsureColumn(conn, "AspNetUsers", "MicrosoftSubject", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftSubject TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "MicrosoftEmail", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftEmail TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "MicrosoftLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftLinkedAt TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "TotpSecretEncrypted", "ALTER TABLE AspNetUsers ADD COLUMN TotpSecretEncrypted TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "TotpPendingSecretEncrypted", "ALTER TABLE AspNetUsers ADD COLUMN TotpPendingSecretEncrypted TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "TotpEnabledAtUtc", "ALTER TABLE AspNetUsers ADD COLUMN TotpEnabledAtUtc TEXT NULL;"); static void EnsureUserRuleSettingsTable(DbConnection c) { @@ -623,10 +632,26 @@ public static class StartupInitializationExtensions Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_JobApplicationId" ON "TailoredCvDrafts" ("JobApplicationId");"""); } + static void EnsureTwoFactorRecoveryCodesTable(DbConnection c) + { + Exec(c, """ + CREATE TABLE IF NOT EXISTS "TwoFactorRecoveryCodes" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_TwoFactorRecoveryCodes" PRIMARY KEY AUTOINCREMENT, + "UserId" TEXT NOT NULL, + "CodeHash" TEXT NOT NULL, + "CreatedAtUtc" TEXT NOT NULL, + "UsedAtUtc" TEXT NULL + ); + """); + + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc" ON "TwoFactorRecoveryCodes" ("UserId", "UsedAtUtc");"""); + } + EnsureGmailConnectionsTable(conn); EnsureMicrosoftGraphConnectionsTable(conn); EnsureImapConnectionsTable(conn); EnsureCvTables(conn); + EnsureTwoFactorRecoveryCodesTable(conn); // Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded, // and at least one of the new columns already exists. @@ -769,6 +794,9 @@ public static class StartupInitializationExtensions EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftSubject", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftSubject` longtext NULL;"); EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftEmail` longtext NULL;"); EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftLinkedAt", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftLinkedAt` datetime NULL;"); + EnsureMySqlColumn(conn, "AspNetUsers", "TotpSecretEncrypted", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpSecretEncrypted` longtext NULL;"); + EnsureMySqlColumn(conn, "AspNetUsers", "TotpPendingSecretEncrypted", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpPendingSecretEncrypted` longtext NULL;"); + EnsureMySqlColumn(conn, "AspNetUsers", "TotpEnabledAtUtc", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpEnabledAtUtc` datetime NULL;"); if (!HasMySqlTable(conn, "RuleSettings")) { @@ -977,6 +1005,29 @@ public static class StartupInitializationExtensions cmd.ExecuteNonQuery(); } + if (!HasMySqlTable(conn, "TwoFactorRecoveryCodes")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TwoFactorRecoveryCodes` ( + `Id` int NOT NULL AUTO_INCREMENT, + `UserId` varchar(255) NOT NULL, + `CodeHash` varchar(255) NOT NULL, + `CreatedAtUtc` datetime(6) NOT NULL, + `UsedAtUtc` datetime(6) NULL, + PRIMARY KEY (`Id`) + );"; + cmd.ExecuteNonQuery(); + } + + EnsureMySqlAutoIncrementPrimaryKey(conn, "TwoFactorRecoveryCodes", "Id"); + + if (!MySqlIndexExists(conn, "TwoFactorRecoveryCodes", "IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE INDEX `IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc` ON `TwoFactorRecoveryCodes` (`UserId`, `UsedAtUtc`);"; + cmd.ExecuteNonQuery(); + } + if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId")) { using var cmd = conn.CreateCommand(); diff --git a/JobTrackerApi/Services/TwoFactorPendingTokenService.cs b/JobTrackerApi/Services/TwoFactorPendingTokenService.cs new file mode 100644 index 0000000..58cadd9 --- /dev/null +++ b/JobTrackerApi/Services/TwoFactorPendingTokenService.cs @@ -0,0 +1,47 @@ +using System.Security.Cryptography; +using Microsoft.Extensions.Caching.Memory; + +namespace JobTrackerApi.Services; + +public sealed record PendingTwoFactorSession(string UserId, bool RememberMe); + +public interface ITwoFactorPendingTokenService +{ + string IssuePendingToken(string userId, bool rememberMe); + PendingTwoFactorSession? Resolve(string pendingToken, bool consume); +} + +// ponytail: server-side opaque token in IMemoryCache, deliberately NOT a JWT. A JWT signed +// with the app's normal signing key would be accepted by the "local" JWT bearer auth scheme +// for every other endpoint unless its issuer/audience/claims were carefully kept out of that +// scheme's validation -- an opaque cache-backed token can never be presented as a bearer +// token, so it structurally cannot grant a real session by itself. Single instance is fine: +// this is a short-lived (5 min), single-process dev/prod deployment, same as the rest of this +// app's in-memory state (rate limiter, IMemoryCache already registered in Program.cs). +public sealed class TwoFactorPendingTokenService : ITwoFactorPendingTokenService +{ + private static readonly TimeSpan Ttl = TimeSpan.FromMinutes(5); + private readonly IMemoryCache _cache; + + public TwoFactorPendingTokenService(IMemoryCache cache) + { + _cache = cache; + } + + public string IssuePendingToken(string userId, bool rememberMe) + { + var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); + _cache.Set(CacheKey(token), new PendingTwoFactorSession(userId, rememberMe), Ttl); + return token; + } + + public PendingTwoFactorSession? Resolve(string pendingToken, bool consume) + { + var key = CacheKey(pendingToken); + if (!_cache.TryGetValue(key, out PendingTwoFactorSession? session)) return null; + if (consume) _cache.Remove(key); + return session; + } + + private static string CacheKey(string token) => $"2fa-pending:{token}"; +} diff --git a/JobTrackerBackend/JobTrackerBackend.csproj b/JobTrackerBackend/JobTrackerBackend.csproj index 767977f..e1db921 100644 --- a/JobTrackerBackend/JobTrackerBackend.csproj +++ b/JobTrackerBackend/JobTrackerBackend.csproj @@ -27,6 +27,8 @@ all + + diff --git a/Models/ApplicationUser.cs b/Models/ApplicationUser.cs index 6d835ef..5d5159b 100644 --- a/Models/ApplicationUser.cs +++ b/Models/ApplicationUser.cs @@ -19,4 +19,7 @@ public sealed class ApplicationUser : IdentityUser public string? MicrosoftSubject { get; set; } public string? MicrosoftEmail { get; set; } public DateTimeOffset? MicrosoftLinkedAt { get; set; } + public string? TotpSecretEncrypted { get; set; } + public string? TotpPendingSecretEncrypted { get; set; } + public DateTimeOffset? TotpEnabledAtUtc { get; set; } } diff --git a/Models/TwoFactorRecoveryCode.cs b/Models/TwoFactorRecoveryCode.cs new file mode 100644 index 0000000..fa85119 --- /dev/null +++ b/Models/TwoFactorRecoveryCode.cs @@ -0,0 +1,12 @@ +namespace JobTrackerApi.Models; + +// One-time-use 2FA recovery codes. Plaintext is shown once at generation time and never +// persisted -- only the SHA-256 hash is stored so a DB read can't recover usable codes. +public sealed class TwoFactorRecoveryCode +{ + public int Id { get; set; } + public string UserId { get; set; } = ""; + public string CodeHash { get; set; } = ""; + public DateTimeOffset CreatedAtUtc { get; set; } + public DateTimeOffset? UsedAtUtc { get; set; } +} From b85dc1ffb72addbbe6dbd2cff53d812cddaa753a Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 12 Jul 2026 21:17:09 +0200 Subject: [PATCH 2/7] feat(auth): add 2FA setup UI and login challenge step --- .../src/components/GoogleAuthCard.tsx | 29 +- .../src/components/MicrosoftAuthCard.tsx | 29 +- .../src/components/TwoFactorChallenge.tsx | 77 +++++ .../src/components/TwoFactorSettingsCard.tsx | 278 ++++++++++++++++++ job-tracker-ui/src/i18n/translations.ts | 78 +++++ job-tracker-ui/src/login-page.test.tsx | 56 ++++ .../src/two-factor-settings.test.tsx | 95 ++++++ job-tracker-ui/src/views/LoginPage.tsx | 114 ++++--- job-tracker-ui/src/views/ProfilePage.tsx | 3 + 9 files changed, 702 insertions(+), 57 deletions(-) create mode 100644 job-tracker-ui/src/components/TwoFactorChallenge.tsx create mode 100644 job-tracker-ui/src/components/TwoFactorSettingsCard.tsx create mode 100644 job-tracker-ui/src/two-factor-settings.test.tsx diff --git a/job-tracker-ui/src/components/GoogleAuthCard.tsx b/job-tracker-ui/src/components/GoogleAuthCard.tsx index 35e3d73..48dd685 100644 --- a/job-tracker-ui/src/components/GoogleAuthCard.tsx +++ b/job-tracker-ui/src/components/GoogleAuthCard.tsx @@ -4,6 +4,7 @@ import { Box, Button, Chip, Paper, Typography } from "@mui/material"; import { api, getApiErrorMessage } from "../api"; import { clearAuthClientState, getAuthPersistencePreference } from "../auth"; +import TwoFactorChallenge from "./TwoFactorChallenge"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; @@ -52,6 +53,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void const [me, setMe] = useState(null); const [working, setWorking] = useState(false); const [allowRegistration, setAllowRegistration] = useState(false); + const [pendingToken, setPendingToken] = useState(null); const hostRef = useRef(null); const clientId = (process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "").trim(); @@ -109,10 +111,14 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void toast(res.data?.email ? t("googleLinkedSuccessWithEmail", { email: res.data.email }) : t("googleLinkedSuccess"), "success"); await refreshMe(); } else { - await api.post("/auth/google/exchange", { token: credential, rememberMe: getAuthPersistencePreference() === "local" }); - window.dispatchEvent(new Event("auth-changed")); - toast(t("googleSignedIn"), "success"); - onSignedIn?.(); + const res = await api.post<{ requiresTwoFactor?: boolean; pendingToken?: string }>("/auth/google/exchange", { token: credential, rememberMe: getAuthPersistencePreference() === "local" }); + if (res.data?.requiresTwoFactor && res.data.pendingToken) { + setPendingToken(res.data.pendingToken); + } else { + window.dispatchEvent(new Event("auth-changed")); + toast(t("googleSignedIn"), "success"); + onSignedIn?.(); + } } } catch (e: any) { toast(getApiErrorMessage(e, t("googleAuthFailed")), "error"); @@ -151,7 +157,20 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void )} - {clientId && ( + {clientId && pendingToken && ( + setPendingToken(null)} + onSuccess={() => { + setPendingToken(null); + window.dispatchEvent(new Event("auth-changed")); + toast(t("googleSignedIn"), "success"); + onSignedIn?.(); + }} + /> + )} + + {clientId && !pendingToken && ( diff --git a/job-tracker-ui/src/components/MicrosoftAuthCard.tsx b/job-tracker-ui/src/components/MicrosoftAuthCard.tsx index 8722ea3..8e93cb4 100644 --- a/job-tracker-ui/src/components/MicrosoftAuthCard.tsx +++ b/job-tracker-ui/src/components/MicrosoftAuthCard.tsx @@ -5,6 +5,7 @@ import { PublicClientApplication } from "@azure/msal-browser"; import { api, getApiErrorMessage } from "../api"; import { clearAuthClientState, getAuthPersistencePreference } from "../auth"; +import TwoFactorChallenge from "./TwoFactorChallenge"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; @@ -35,6 +36,7 @@ export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => v const { t } = useI18n(); const [me, setMe] = useState(null); const [working, setWorking] = useState(false); + const [pendingToken, setPendingToken] = useState(null); const clientId = (process.env.REACT_APP_MICROSOFT_CLIENT_ID || "").trim(); const signedIn = Boolean(me?.provider); @@ -78,10 +80,14 @@ export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => v toast(res.data?.email ? t("microsoftLinkedSuccessWithEmail", { email: res.data.email }) : t("microsoftLinkedSuccess"), "success"); await refreshMe(); } else { - await api.post("/auth/microsoft/exchange", { token: idToken, rememberMe: getAuthPersistencePreference() === "local" }); - window.dispatchEvent(new Event("auth-changed")); - toast(t("microsoftSignedIn"), "success"); - onSignedIn?.(); + const res = await api.post<{ requiresTwoFactor?: boolean; pendingToken?: string }>("/auth/microsoft/exchange", { token: idToken, rememberMe: getAuthPersistencePreference() === "local" }); + if (res.data?.requiresTwoFactor && res.data.pendingToken) { + setPendingToken(res.data.pendingToken); + } else { + window.dispatchEvent(new Event("auth-changed")); + toast(t("microsoftSignedIn"), "success"); + onSignedIn?.(); + } } } catch (e: any) { toast(getApiErrorMessage(e, t("microsoftAuthFailed")), "error"); @@ -104,7 +110,20 @@ export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => v )} - {clientId && ( + {clientId && pendingToken && ( + setPendingToken(null)} + onSuccess={() => { + setPendingToken(null); + window.dispatchEvent(new Event("auth-changed")); + toast(t("microsoftSignedIn"), "success"); + onSignedIn?.(); + }} + /> + )} + + {clientId && !pendingToken && ( diff --git a/job-tracker-ui/src/components/TwoFactorChallenge.tsx b/job-tracker-ui/src/components/TwoFactorChallenge.tsx new file mode 100644 index 0000000..7ca302b --- /dev/null +++ b/job-tracker-ui/src/components/TwoFactorChallenge.tsx @@ -0,0 +1,77 @@ +import React, { useState } from "react"; + +import { Alert, Box, Button, TextField, Typography } from "@mui/material"; + +import { api, getApiErrorMessage } from "../api"; +import { useI18n } from "../i18n/I18nProvider"; + +type ChallengeResponse = { authenticated: true; provider: "local" }; + +export default function TwoFactorChallenge({ + pendingToken, + onSuccess, + onCancel, +}: { + pendingToken: string; + onSuccess: (data: ChallengeResponse) => void; + onCancel: () => void; +}) { + const { t } = useI18n(); + const [code, setCode] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function submit() { + setLoading(true); + setError(null); + try { + const res = await api.post("/auth/2fa/challenge", { pendingToken, code }); + onSuccess(res.data); + } catch (e: any) { + if (e?.response?.status === 429) { + setError(t("twoFactorRateLimited")); + } else { + setError(getApiErrorMessage(e, t("twoFactorInvalidCode"))); + } + } finally { + setLoading(false); + } + } + + return ( + { e.preventDefault(); void submit(); }} + sx={{ display: "flex", flexDirection: "column", gap: 1.5 }} + role="group" + aria-label={t("twoFactorTitle")} + > + + {t("twoFactorTitle")} + + + {t("twoFactorHint")} + + + {error ? {error} : null} + + setCode(e.target.value)} + autoComplete="one-time-code" + autoFocus + fullWidth + /> + + + + + + + ); +} diff --git a/job-tracker-ui/src/components/TwoFactorSettingsCard.tsx b/job-tracker-ui/src/components/TwoFactorSettingsCard.tsx new file mode 100644 index 0000000..5da0ad9 --- /dev/null +++ b/job-tracker-ui/src/components/TwoFactorSettingsCard.tsx @@ -0,0 +1,278 @@ +import React, { useEffect, useState } from "react"; + +import { + Alert, + Box, + Button, + Checkbox, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + Paper, + TextField, + Typography, +} from "@mui/material"; + +import { api, getApiErrorMessage } from "../api"; +import { useToast } from "../toast"; +import { useI18n } from "../i18n/I18nProvider"; + +type Status = { enabled: boolean; enabledAtUtc: string | null }; +type SetupResponse = { manualEntryKey: string; qrCodeDataUrl: string }; +type RecoveryCodesResponse = { recoveryCodes: string[] }; + +type Flow = + | "closed" + | "enable-password" + | "enable-qr" + | "enable-recovery" + | "disable-password" + | "regenerate-password" + | "regenerate-recovery"; + +function apiErrorMessage(e: any, t: (k: any) => string) { + if (e?.response?.status === 429) return t("twoFactorRateLimited"); + return getApiErrorMessage(e, t("twoFactorGenericError")); +} + +export default function TwoFactorSettingsCard() { + const { toast } = useToast(); + const { t } = useI18n(); + const [status, setStatus] = useState(null); + const [flow, setFlow] = useState("closed"); + const [password, setPassword] = useState(""); + const [code, setCode] = useState(""); + const [setup, setSetup] = useState(null); + const [recoveryCodes, setRecoveryCodes] = useState([]); + const [savedConfirmed, setSavedConfirmed] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const loadStatus = () => { + api.get("/auth/2fa/status").then((r) => setStatus(r.data)).catch(() => setStatus(null)); + }; + + useEffect(() => { loadStatus(); }, []); + + function closeFlow() { + setFlow("closed"); + setPassword(""); + setCode(""); + setSetup(null); + setRecoveryCodes([]); + setSavedConfirmed(false); + setError(null); + } + + async function submitPassword() { + setLoading(true); + setError(null); + try { + if (flow === "enable-password") { + const res = await api.post("/auth/2fa/setup", { currentPassword: password }); + setSetup(res.data); + setPassword(""); + setFlow("enable-qr"); + } else if (flow === "disable-password") { + await api.post("/auth/2fa/disable", { currentPassword: password }); + toast(t("twoFactorDisabledSuccess"), "success"); + closeFlow(); + loadStatus(); + } else if (flow === "regenerate-password") { + const res = await api.post("/auth/2fa/recovery-codes/regenerate", { currentPassword: password }); + setRecoveryCodes(res.data.recoveryCodes); + setPassword(""); + setFlow("regenerate-recovery"); + } + } catch (e: any) { + setError(e?.response?.status === 400 || e?.response?.status === 401 ? t("twoFactorWrongPassword") : apiErrorMessage(e, t)); + } finally { + setLoading(false); + } + } + + async function submitCode() { + setLoading(true); + setError(null); + try { + const res = await api.post("/auth/2fa/verify-setup", { code }); + setRecoveryCodes(res.data.recoveryCodes); + setCode(""); + setFlow("enable-recovery"); + } catch (e: any) { + setError(e?.response?.status === 401 ? t("twoFactorInvalidCode") : apiErrorMessage(e, t)); + } finally { + setLoading(false); + } + } + + function finishRecovery() { + toast(flow === "enable-recovery" ? t("twoFactorEnabledSuccess") : t("twoFactorRegenerateSuccess"), "success"); + closeFlow(); + loadStatus(); + } + + function copyRecoveryCodes() { + void navigator.clipboard.writeText(recoveryCodes.join("\n")); + toast(t("twoFactorCodesCopied"), "info"); + } + + function downloadRecoveryCodes() { + const blob = new Blob([recoveryCodes.join("\n") + "\n"], { type: "text/plain" }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "jobbjakt-recovery-codes.txt"; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + } + + const isPasswordStep = flow === "enable-password" || flow === "disable-password" || flow === "regenerate-password"; + const isRecoveryStep = flow === "enable-recovery" || flow === "regenerate-recovery"; + + return ( + + + {t("twoFactorSectionTitle")} + + + {status ? ( + + {status.enabled + ? t("twoFactorStatusEnabled", { date: status.enabledAtUtc ? new Date(status.enabledAtUtc).toLocaleDateString() : "" }) + : t("twoFactorStatusDisabled")} + + ) : null} + + + {!status?.enabled ? ( + + ) : ( + <> + + + + )} + + + + {isPasswordStep && ( + { e.preventDefault(); void submitPassword(); }}> + {t("twoFactorPasswordPrompt")} + + {flow === "disable-password" ? {t("twoFactorDisableWarning")} : null} + {flow === "regenerate-password" ? {t("twoFactorRegenerateWarning")} : null} + {error ? {error} : null} + setPassword(e.target.value)} + autoComplete="current-password" + autoFocus + fullWidth + /> + + + + + + + )} + + {flow === "enable-qr" && setup && ( + { e.preventDefault(); void submitCode(); }}> + {t("twoFactorSetupTitle")} + + + {t("twoFactorSetupHint")} + + + {t("twoFactorSetupTitle")} + + { + void navigator.clipboard.writeText(setup.manualEntryKey); + toast(t("twoFactorKeyCopied"), "info"); + }} + > + {t("twoFactorCopyKey")} + + ), + }} + /> + + {t("twoFactorConfirmCodeHint")} + + {error ? {error} : null} + setCode(e.target.value)} + autoComplete="one-time-code" + fullWidth + /> + + + + + + + )} + + {isRecoveryStep && ( + <> + {t("twoFactorRecoveryTitle")} + + {t("twoFactorRecoveryHint")} + + {recoveryCodes.map((rc) => ( +
  • {rc}
  • + ))} +
    + + + + + setSavedConfirmed(e.target.checked)} />} + label={t("twoFactorSavedConfirm")} + /> +
    + + + + + )} +
    +
    + ); +} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index e43bf39..0f48903 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -306,6 +306,45 @@ export const translations = { profileUpdatePassword: "Update password", profilePasswordUpdated: "Password updated.", profilePasswordUpdateFailed: "Failed to change password.", + twoFactorTitle: "Two-factor verification", + twoFactorHint: "Enter the 6-digit code from your authenticator app, or a recovery code.", + twoFactorCodeLabel: "Code", + twoFactorVerify: "Verify", + twoFactorVerifying: "Verifying...", + twoFactorBack: "Back", + twoFactorInvalidCode: "Invalid code. Please try again.", + twoFactorRateLimited: "Too many attempts. Please wait a few minutes and try again.", + twoFactorGenericError: "Something went wrong. Please try again.", + twoFactorSectionTitle: "Two-factor authentication", + twoFactorStatusEnabled: "Enabled since {date}", + twoFactorStatusDisabled: "Not enabled", + twoFactorEnableButton: "Enable 2FA", + twoFactorDisableButton: "Disable 2FA", + twoFactorRegenerateButton: "Regenerate recovery codes", + twoFactorPasswordPrompt: "Confirm your password to continue", + twoFactorPasswordLabel: "Current password", + twoFactorContinue: "Continue", + twoFactorWrongPassword: "Incorrect password.", + twoFactorSetupTitle: "Scan this QR code", + twoFactorSetupHint: "Scan with your authenticator app, or enter the key manually.", + twoFactorManualKeyLabel: "Manual entry key", + twoFactorCopyKey: "Copy key", + twoFactorKeyCopied: "Key copied to clipboard.", + twoFactorConfirmCodeLabel: "6-digit code", + twoFactorConfirmCodeHint: "Enter the code shown by your authenticator app to confirm setup.", + twoFactorConfirmButton: "Confirm", + twoFactorRecoveryTitle: "Save your recovery codes", + twoFactorRecoveryHint: "Each code can be used once if you lose access to your authenticator app. This is the only time these codes will be shown.", + twoFactorCopyAll: "Copy all codes", + twoFactorCodesCopied: "Recovery codes copied.", + twoFactorDownload: "Download as .txt", + twoFactorSavedConfirm: "I've saved my recovery codes", + twoFactorDone: "Done", + twoFactorDisableWarning: "Disabling 2FA will also invalidate your recovery codes.", + twoFactorRegenerateWarning: "This will invalidate your existing recovery codes.", + twoFactorEnabledSuccess: "Two-factor authentication enabled.", + twoFactorDisabledSuccess: "Two-factor authentication disabled.", + twoFactorRegenerateSuccess: "Recovery codes regenerated.", cropDialogTitle: "Crop profile image", cropDialogBody: "Position and zoom your image. The saved avatar will be exported as a 512×512 square.", cropDialogZoom: "Zoom", @@ -1284,6 +1323,45 @@ export const translations = { profileUpdatePassword: "Oppdater passord", profilePasswordUpdated: "Passord oppdatert.", profilePasswordUpdateFailed: "Kunne ikke endre passord.", + twoFactorTitle: "Topunkts bekreftelse", + twoFactorHint: "Skriv inn 6-sifret kode fra autentiseringsappen din, eller en gjenopprettingskode.", + twoFactorCodeLabel: "Kode", + twoFactorVerify: "Bekreft", + twoFactorVerifying: "Bekrefter...", + twoFactorBack: "Tilbake", + twoFactorInvalidCode: "Ugyldig kode. Prøv igjen.", + twoFactorRateLimited: "For mange forsøk. Vent noen minutter og prøv igjen.", + twoFactorGenericError: "Noe gikk galt. Prøv igjen.", + twoFactorSectionTitle: "Topunkts autentisering", + twoFactorStatusEnabled: "Aktivert siden {date}", + twoFactorStatusDisabled: "Ikke aktivert", + twoFactorEnableButton: "Aktiver 2FA", + twoFactorDisableButton: "Deaktiver 2FA", + twoFactorRegenerateButton: "Generer nye gjenopprettingskoder", + twoFactorPasswordPrompt: "Bekreft passordet ditt for å fortsette", + twoFactorPasswordLabel: "Nåværende passord", + twoFactorContinue: "Fortsett", + twoFactorWrongPassword: "Feil passord.", + twoFactorSetupTitle: "Skann denne QR-koden", + twoFactorSetupHint: "Skann med autentiseringsappen din, eller skriv inn nøkkelen manuelt.", + twoFactorManualKeyLabel: "Manuell registreringsnøkkel", + twoFactorCopyKey: "Kopier nøkkel", + twoFactorKeyCopied: "Nøkkel kopiert til utklippstavlen.", + twoFactorConfirmCodeLabel: "6-sifret kode", + twoFactorConfirmCodeHint: "Skriv inn koden som vises i autentiseringsappen din for å bekrefte oppsettet.", + twoFactorConfirmButton: "Bekreft", + twoFactorRecoveryTitle: "Lagre gjenopprettingskodene dine", + twoFactorRecoveryHint: "Hver kode kan brukes én gang hvis du mister tilgang til autentiseringsappen din. Dette er eneste gang disse kodene vises.", + twoFactorCopyAll: "Kopier alle koder", + twoFactorCodesCopied: "Gjenopprettingskoder kopiert.", + twoFactorDownload: "Last ned som .txt", + twoFactorSavedConfirm: "Jeg har lagret gjenopprettingskodene mine", + twoFactorDone: "Ferdig", + twoFactorDisableWarning: "Deaktivering av 2FA vil også ugyldiggjøre gjenopprettingskodene dine.", + twoFactorRegenerateWarning: "Dette vil ugyldiggjøre eksisterende gjenopprettingskoder.", + twoFactorEnabledSuccess: "Topunkts autentisering aktivert.", + twoFactorDisabledSuccess: "Topunkts autentisering deaktivert.", + twoFactorRegenerateSuccess: "Gjenopprettingskoder generert på nytt.", cropDialogTitle: "Beskjær profilbilde", cropDialogBody: "Plasser og zoom bildet. Det lagrede avataren eksporteres som en kvadratisk 512×512-fil.", cropDialogZoom: "Zoom", diff --git a/job-tracker-ui/src/login-page.test.tsx b/job-tracker-ui/src/login-page.test.tsx index 357f6a6..5ce1975 100644 --- a/job-tracker-ui/src/login-page.test.tsx +++ b/job-tracker-ui/src/login-page.test.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import '@testing-library/jest-dom'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router-dom'; @@ -81,4 +82,59 @@ describe('LoginPage', () => { expect(mockNavigate).toHaveBeenCalledWith('/forgot-password?email=person%40example.com'); }); + + it('shows the 2FA code step when login requires two-factor, then proceeds like a normal login on success', async () => { + mockedApi.post.mockImplementation((url: string, payload?: any) => { + if (url === '/auth/login') { + return Promise.resolve({ data: { requiresTwoFactor: true, pendingToken: 'pending-abc' } } as any); + } + if (url === '/auth/2fa/challenge') { + expect(payload).toEqual({ pendingToken: 'pending-abc', code: '123456' }); + return Promise.resolve({ data: { authenticated: true, provider: 'local' } } as any); + } + return Promise.resolve({ data: {} } as any); + }); + mockedApi.get.mockResolvedValueOnce({ data: { roles: [], email: 'person@example.com', userName: 'person' } } as any); + + renderLoginPage(); + await screen.findByLabelText('Email'); + + await userEvent.type(screen.getByLabelText('Email'), 'person@example.com'); + await userEvent.type(screen.getByLabelText('Current password'), 'hunter2'); + await userEvent.click(screen.getByRole('button', { name: 'Sign in' })); + + await screen.findByText('Two-factor verification'); + expect(screen.queryByLabelText('Email')).not.toBeInTheDocument(); + + await userEvent.type(screen.getByLabelText('Code'), '123456'); + await userEvent.click(screen.getByRole('button', { name: 'Verify' })); + + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/2fa/challenge', { pendingToken: 'pending-abc', code: '123456' })); + await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/auth/me')); + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true })); + }); + + it('shows a clear message when the 2FA challenge is rate-limited', async () => { + mockedApi.post.mockImplementation((url: string) => { + if (url === '/auth/login') { + return Promise.resolve({ data: { requiresTwoFactor: true, pendingToken: 'pending-abc' } } as any); + } + if (url === '/auth/2fa/challenge') { + return Promise.reject({ response: { status: 429 } }); + } + return Promise.resolve({ data: {} } as any); + }); + + renderLoginPage(); + await screen.findByLabelText('Email'); + await userEvent.type(screen.getByLabelText('Email'), 'person@example.com'); + await userEvent.type(screen.getByLabelText('Current password'), 'hunter2'); + await userEvent.click(screen.getByRole('button', { name: 'Sign in' })); + + await screen.findByText('Two-factor verification'); + await userEvent.type(screen.getByLabelText('Code'), '123456'); + await userEvent.click(screen.getByRole('button', { name: 'Verify' })); + + expect(await screen.findByRole('alert')).toHaveTextContent('Too many attempts. Please wait a few minutes and try again.'); + }); }); diff --git a/job-tracker-ui/src/two-factor-settings.test.tsx b/job-tracker-ui/src/two-factor-settings.test.tsx new file mode 100644 index 0000000..b3d86d8 --- /dev/null +++ b/job-tracker-ui/src/two-factor-settings.test.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ToastProvider } from './toast'; +import { I18nProvider } from './i18n/I18nProvider'; +import TwoFactorSettingsCard from './components/TwoFactorSettingsCard'; +import { api } from './api'; + +const mockedApi = api as jest.Mocked; + +const writeTextMock = jest.fn(() => Promise.resolve()); +Object.assign(navigator, { clipboard: { writeText: writeTextMock } }); +Object.defineProperty(window.URL, 'createObjectURL', { writable: true, value: jest.fn(() => 'blob:mock') }); +Object.defineProperty(window.URL, 'revokeObjectURL', { writable: true, value: jest.fn() }); + +function renderCard() { + return render( + + + + + , + ); +} + +beforeEach(() => { + jest.clearAllMocks(); + mockedApi.get.mockImplementation((url: string) => { + if (url === '/auth/2fa/status') { + return Promise.resolve({ data: { enabled: false, enabledAtUtc: null } } as any); + } + return Promise.resolve({ data: {} } as any); + }); +}); + +test('shows not-enabled status and walks through the full enable flow to recovery codes', async () => { + mockedApi.post.mockImplementation((url: string) => { + if (url === '/auth/2fa/setup') { + return Promise.resolve({ data: { manualEntryKey: 'ABCD1234', qrCodeDataUrl: 'data:image/png;base64,abc' } } as any); + } + if (url === '/auth/2fa/verify-setup') { + return Promise.resolve({ data: { enabled: true, recoveryCodes: ['aaaaa-11111', 'bbbbb-22222'] } } as any); + } + return Promise.resolve({ data: {} } as any); + }); + + renderCard(); + expect(await screen.findByText('Not enabled')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Enable 2FA' })); + await userEvent.type(await screen.findByLabelText('Current password'), 'hunter2'); + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/2fa/setup', { currentPassword: 'hunter2' })); + expect(await screen.findByAltText('Scan this QR code')).toHaveAttribute('src', 'data:image/png;base64,abc'); + expect(screen.getByDisplayValue('ABCD1234')).toBeInTheDocument(); + + await userEvent.type(screen.getByLabelText('6-digit code'), '654321'); + await userEvent.click(screen.getByRole('button', { name: 'Confirm' })); + + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/2fa/verify-setup', { code: '654321' })); + expect(await screen.findByText('Save your recovery codes')).toBeInTheDocument(); + expect(screen.getByText('aaaaa-11111')).toBeInTheDocument(); + expect(screen.getByText('bbbbb-22222')).toBeInTheDocument(); + + const doneButton = screen.getByRole('button', { name: 'Done' }); + expect(doneButton).toBeDisabled(); + + await userEvent.click(screen.getByLabelText("I've saved my recovery codes")); + expect(doneButton).toBeEnabled(); + await userEvent.click(doneButton); + + await waitFor(() => expect(screen.queryByText('Save your recovery codes')).not.toBeInTheDocument()); +}); + +test('shows wrong-password error on disable and lets the user retry', async () => { + mockedApi.get.mockImplementation((url: string) => { + if (url === '/auth/2fa/status') { + return Promise.resolve({ data: { enabled: true, enabledAtUtc: '2026-01-01T00:00:00Z' } } as any); + } + return Promise.resolve({ data: {} } as any); + }); + mockedApi.post.mockRejectedValueOnce({ response: { status: 401 } }); + + renderCard(); + expect(await screen.findByText(/enabled since/i)).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Disable 2FA' })); + await userEvent.type(await screen.findByLabelText('Current password'), 'wrong'); + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + + expect(await screen.findByText('Incorrect password.')).toBeInTheDocument(); + expect(screen.getByLabelText('Current password')).toBeInTheDocument(); +}); diff --git a/job-tracker-ui/src/views/LoginPage.tsx b/job-tracker-ui/src/views/LoginPage.tsx index 6c2817e..b119c59 100644 --- a/job-tracker-ui/src/views/LoginPage.tsx +++ b/job-tracker-ui/src/views/LoginPage.tsx @@ -8,6 +8,7 @@ import { api, getApiErrorMessage } from "../api"; import { getRememberMePref, setAuthPersistencePreference } from "../auth"; import GoogleAuthCard from "../components/GoogleAuthCard"; import MicrosoftAuthCard from "../components/MicrosoftAuthCard"; +import TwoFactorChallenge from "../components/TwoFactorChallenge"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; @@ -32,6 +33,7 @@ export default function LoginPage() { const [password, setPassword] = useState(""); const [rememberMe, setRememberMe] = useState(() => getRememberMePref()); const [loading, setLoading] = useState(false); + const [pendingToken, setPendingToken] = useState(null); const nextPath = (location?.state?.from as string | undefined) ?? "/dashboard"; @@ -42,15 +44,23 @@ export default function LoginPage() { .catch(() => setCfg(null)); }, []); + async function completeLogin() { + setAuthPersistencePreference(rememberMe ? "local" : "session"); + await api.get("/auth/me"); + toast(t("signedIn"), "success"); + navigate(nextPath, { replace: true }); + } + async function submit(mode: "login" | "register") { setLoading(true); try { const url = mode === "register" ? "/auth/register" : "/auth/login"; - await api.post(url, { email, password, rememberMe }); - setAuthPersistencePreference(rememberMe ? "local" : "session"); - await api.get("/auth/me"); - toast(t("signedIn"), "success"); - navigate(nextPath, { replace: true }); + const res = await api.post<{ requiresTwoFactor?: boolean; pendingToken?: string }>(url, { email, password, rememberMe }); + if (res.data?.requiresTwoFactor && res.data.pendingToken) { + setPendingToken(res.data.pendingToken); + return; + } + await completeLogin(); } catch (e: any) { toast(getApiErrorMessage(e, t("loginFailed")), "error"); } finally { @@ -80,53 +90,63 @@ export default function LoginPage() { {cfg?.requireAuth ? t("authRequired") : t("authOptional")} - setTab(v)} sx={{ mb: 2 }}> - - - - + {pendingToken ? ( + setPendingToken(null)} + onSuccess={() => { setPendingToken(null); void completeLogin(); }} + /> + ) : ( + <> + setTab(v)} sx={{ mb: 2 }}> + + + + - {tab === 0 && ( - { e.preventDefault(); void submit("login"); }} sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}> - setEmail(e.target.value)} autoComplete="email" fullWidth /> - setPassword(e.target.value)} autoComplete={allowReg ? "new-password" : "current-password"} type="password" fullWidth /> + {tab === 0 && ( + { e.preventDefault(); void submit("login"); }} sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}> + setEmail(e.target.value)} autoComplete="email" fullWidth /> + setPassword(e.target.value)} autoComplete={allowReg ? "new-password" : "current-password"} type="password" fullWidth /> - - setRememberMe(e.target.checked)} />} - label={t("rememberMe")} - /> - - + + setRememberMe(e.target.checked)} />} + label={t("rememberMe")} + /> + + - - {rememberMe ? t("rememberMeHelpPersistent") : t("rememberMeHelpSession")} - + + {rememberMe ? t("rememberMeHelpPersistent") : t("rememberMeHelpSession")} + - - {allowReg && ( - - )} - - - + + {allowReg && ( + + )} + + + + )} + + {tab === 1 && { navigate(nextPath, { replace: true }); }} />} + {tab === 2 && { navigate(nextPath, { replace: true }); }} />} + )} - - {tab === 1 && { navigate(nextPath, { replace: true }); }} />} - {tab === 2 && { navigate(nextPath, { replace: true }); }} />}
    ); diff --git a/job-tracker-ui/src/views/ProfilePage.tsx b/job-tracker-ui/src/views/ProfilePage.tsx index 318ce45..1a4fbfe 100644 --- a/job-tracker-ui/src/views/ProfilePage.tsx +++ b/job-tracker-ui/src/views/ProfilePage.tsx @@ -11,6 +11,7 @@ import { api, getApiErrorMessage } from "../api"; import GoogleAuthCard from "../components/GoogleAuthCard"; import MicrosoftAuthCard from "../components/MicrosoftAuthCard"; import AuthStatusCard from "../components/AuthStatusCard"; +import TwoFactorSettingsCard from "../components/TwoFactorSettingsCard"; import EmailProviderConnections from "../components/EmailProviderConnections"; import CropImageDialog from "../components/CropImageDialog"; import { useToast } from "../toast"; @@ -1348,6 +1349,8 @@ export default function ProfilePage() {
    + + {isLocal ? : null} ); } From b914630657abf42980989ccca9f13f864fdc1c55 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 13 Jul 2026 01:02:35 +0200 Subject: [PATCH 3/7] 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; } +} From 0ca2f2b2617803ce1e20c6719719ff19a0e8884f Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 13 Jul 2026 01:02:43 +0200 Subject: [PATCH 4/7] feat(auth): add trusted-device 30-day 2FA skip (frontend) Adds a "Trust this device for 30 days" checkbox to the 2FA challenge step, and a "Trusted devices" section to the 2FA settings card: list devices with a "this device" badge, per-row revoke, and a confirm-gated "sign out all other trusted devices" action. Both flows are opt-in and additive -- default unchecked, so nothing changes for a user who never uses them. --- .../src/components/TwoFactorChallenge.tsx | 10 +- .../src/components/TwoFactorSettingsCard.tsx | 116 +++++++++++++++++- job-tracker-ui/src/i18n/translations.ts | 22 ++++ job-tracker-ui/src/login-page.test.tsx | 4 +- 4 files changed, 146 insertions(+), 6 deletions(-) diff --git a/job-tracker-ui/src/components/TwoFactorChallenge.tsx b/job-tracker-ui/src/components/TwoFactorChallenge.tsx index 7ca302b..bdfe7b6 100644 --- a/job-tracker-ui/src/components/TwoFactorChallenge.tsx +++ b/job-tracker-ui/src/components/TwoFactorChallenge.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; -import { Alert, Box, Button, TextField, Typography } from "@mui/material"; +import { Alert, Box, Button, Checkbox, FormControlLabel, TextField, Typography } from "@mui/material"; import { api, getApiErrorMessage } from "../api"; import { useI18n } from "../i18n/I18nProvider"; @@ -18,6 +18,7 @@ export default function TwoFactorChallenge({ }) { const { t } = useI18n(); const [code, setCode] = useState(""); + const [trustDevice, setTrustDevice] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -25,7 +26,7 @@ export default function TwoFactorChallenge({ setLoading(true); setError(null); try { - const res = await api.post("/auth/2fa/challenge", { pendingToken, code }); + const res = await api.post("/auth/2fa/challenge", { pendingToken, code, trustDevice }); onSuccess(res.data); } catch (e: any) { if (e?.response?.status === 429) { @@ -64,6 +65,11 @@ export default function TwoFactorChallenge({ fullWidth /> + setTrustDevice(e.target.checked)} />} + label={t("twoFactorTrustDevice")} + /> + + ) : null} + + ) : null} + {isPasswordStep && ( { e.preventDefault(); void submitPassword(); }}> @@ -272,6 +368,22 @@ export default function TwoFactorSettingsCard() { )} + + {flow === "revoke-all-confirm" && ( + <> + {t("twoFactorRevokeAllConfirmTitle")} + + {error ? {error} : null} + {t("twoFactorRevokeAllConfirmBody")} + + + + + + + )} ); diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 0f48903..efa0f89 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -345,6 +345,17 @@ export const translations = { twoFactorEnabledSuccess: "Two-factor authentication enabled.", twoFactorDisabledSuccess: "Two-factor authentication disabled.", twoFactorRegenerateSuccess: "Recovery codes regenerated.", + twoFactorTrustDevice: "Trust this device for 30 days", + twoFactorTrustedDevicesTitle: "Trusted devices", + twoFactorTrustedDevicesEmpty: "No trusted devices yet.", + twoFactorTrustedDeviceUnknown: "Unknown device", + twoFactorTrustedDeviceCurrent: "This device", + twoFactorTrustedDeviceMeta: "Last used {lastSeen} · Expires {expires}", + twoFactorRevokeDevice: "Revoke", + twoFactorRevokeAllDevices: "Sign out all other trusted devices", + twoFactorTrustedDevicesRevokedAll: "All trusted devices have been signed out.", + twoFactorRevokeAllConfirmTitle: "Sign out all trusted devices?", + twoFactorRevokeAllConfirmBody: "You'll be asked for a 2FA code the next time you sign in on any device, including this one.", cropDialogTitle: "Crop profile image", cropDialogBody: "Position and zoom your image. The saved avatar will be exported as a 512×512 square.", cropDialogZoom: "Zoom", @@ -1362,6 +1373,17 @@ export const translations = { twoFactorEnabledSuccess: "Topunkts autentisering aktivert.", twoFactorDisabledSuccess: "Topunkts autentisering deaktivert.", twoFactorRegenerateSuccess: "Gjenopprettingskoder generert på nytt.", + twoFactorTrustDevice: "Stol på denne enheten i 30 dager", + twoFactorTrustedDevicesTitle: "Betrodde enheter", + twoFactorTrustedDevicesEmpty: "Ingen betrodde enheter ennå.", + twoFactorTrustedDeviceUnknown: "Ukjent enhet", + twoFactorTrustedDeviceCurrent: "Denne enheten", + twoFactorTrustedDeviceMeta: "Sist brukt {lastSeen} · Utløper {expires}", + twoFactorRevokeDevice: "Fjern tilgang", + twoFactorRevokeAllDevices: "Logg ut alle andre betrodde enheter", + twoFactorTrustedDevicesRevokedAll: "Alle betrodde enheter er logget ut.", + twoFactorRevokeAllConfirmTitle: "Logg ut alle betrodde enheter?", + twoFactorRevokeAllConfirmBody: "Du vil bli bedt om en 2FA-kode neste gang du logger inn på en enhet, inkludert denne.", cropDialogTitle: "Beskjær profilbilde", cropDialogBody: "Plasser og zoom bildet. Det lagrede avataren eksporteres som en kvadratisk 512×512-fil.", cropDialogZoom: "Zoom", diff --git a/job-tracker-ui/src/login-page.test.tsx b/job-tracker-ui/src/login-page.test.tsx index 5ce1975..2973f34 100644 --- a/job-tracker-ui/src/login-page.test.tsx +++ b/job-tracker-ui/src/login-page.test.tsx @@ -89,7 +89,7 @@ describe('LoginPage', () => { return Promise.resolve({ data: { requiresTwoFactor: true, pendingToken: 'pending-abc' } } as any); } if (url === '/auth/2fa/challenge') { - expect(payload).toEqual({ pendingToken: 'pending-abc', code: '123456' }); + expect(payload).toEqual({ pendingToken: 'pending-abc', code: '123456', trustDevice: false }); return Promise.resolve({ data: { authenticated: true, provider: 'local' } } as any); } return Promise.resolve({ data: {} } as any); @@ -109,7 +109,7 @@ describe('LoginPage', () => { await userEvent.type(screen.getByLabelText('Code'), '123456'); await userEvent.click(screen.getByRole('button', { name: 'Verify' })); - await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/2fa/challenge', { pendingToken: 'pending-abc', code: '123456' })); + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/2fa/challenge', { pendingToken: 'pending-abc', code: '123456', trustDevice: false })); await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/auth/me')); await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true })); }); From 904f3a8ec818fe91a8dfe7dde2bc2a1d46794b94 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 13 Jul 2026 01:22:26 +0200 Subject: [PATCH 5/7] feat(auth): add configurable email verification enforcement Auth:RequireEmailVerification (default off) gates whether local register requires confirming email before login. OAuth new-user paths are untouched -- Google/Microsoft already assert a verified email. Adds verify-email and resend-verification-email endpoints, mirroring the existing reset-password enumeration-avoidance and rate-limiting patterns, plus a login-embedded resend affordance and a verify-email landing page on the frontend. Co-Authored-By: Claude Sonnet 5 --- .../AuthAndSystemControllerTests.cs | 203 ++++++++++++++++++ JobTrackerApi/Controllers/AuthController.cs | 103 ++++++++- JobTrackerApi/appsettings.Development.json | 1 + job-tracker-ui/src/App.tsx | 2 + job-tracker-ui/src/i18n/translations.ts | 18 ++ job-tracker-ui/src/login-page.test.tsx | 29 +++ job-tracker-ui/src/verify-email-page.test.tsx | 58 +++++ job-tracker-ui/src/views/LoginPage.tsx | 42 +++- job-tracker-ui/src/views/VerifyEmailPage.tsx | 75 +++++++ 9 files changed, 528 insertions(+), 3 deletions(-) create mode 100644 job-tracker-ui/src/verify-email-page.test.tsx create mode 100644 job-tracker-ui/src/views/VerifyEmailPage.tsx diff --git a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs index 5b17fd2..45b767d 100644 --- a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs +++ b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs @@ -163,6 +163,209 @@ public sealed class AuthAndSystemControllerTests } } + [Fact] + public async Task Register_sets_EmailConfirmed_false_and_sends_verification_email_when_flag_on() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["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(), "password123")) + .Callback((u, _) => created = u) + .ReturnsAsync(IdentityResult.Success); + userManager.Setup(x => x.GenerateEmailConfirmationTokenAsync(It.IsAny())).ReturnsAsync("confirm-token"); + + var tokenService = new Mock(); + tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); + + var emailSender = new Mock(); + + var controller = new AuthController(config, userManager.Object, tokenService.Object, emailSender.Object, Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), 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(result); + Assert.NotNull(created); + Assert.False(created!.EmailConfirmed); + emailSender.Verify(x => x.SendAsync("new.user@example.com", It.IsAny(), It.Is(b => b.Contains("verify-email")), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Register_is_unchanged_when_flag_off() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["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(), "password123")) + .Callback((u, _) => created = u) + .ReturnsAsync(IdentityResult.Success); + + var tokenService = new Mock(); + tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); + + var emailSender = new Mock(); + + var controller = new AuthController(config, userManager.Object, tokenService.Object, emailSender.Object, Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), 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(result); + Assert.NotNull(created); + Assert.True(created!.EmailConfirmed); + emailSender.Verify(x => x.SendAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Login_rejects_unconfirmed_local_account_when_flag_on() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["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(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), 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(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(); + tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny())).ReturnsAsync("app-token"); + + var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), 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(result); + var session = Assert.IsType(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(), Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()); + + var result = await controller.VerifyEmail(new AuthController.VerifyEmailRequest("user-1", "good-token")); + + Assert.IsType(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(); + + var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of(), emailSender.Object, Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), 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(realResult); + Assert.IsType(fakeResult); + emailSender.Verify(x => x.SendAsync("real@example.com", It.IsAny(), It.IsAny(), It.IsAny()), 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 + { + ["Auth:AllowRegistration"] = "true", + ["Auth:RequireEmailVerification"] = "true", + }) + .Build(); + + var userManager = CreateUserManager(); + userManager.Setup(x => x.Users).Returns(new TestAsyncEnumerable(new List())); + userManager.Setup(x => x.FindByEmailAsync("new.hire@example.com")).ReturnsAsync((ApplicationUser?)null); + ApplicationUser? created = null; + userManager + .Setup(x => x.CreateAsync(It.IsAny())) + .Callback(u => created = u) + .ReturnsAsync(IdentityResult.Success); + + var tokenService = new Mock(); + tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); + + var googleValidator = new Mock(); + googleValidator + .Setup(x => x.ValidateAsync("google-token", It.IsAny())) + .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(), googleValidator.Object, Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + + var result = await controller.ExchangeGoogleToken(new AuthController.GoogleTokenRequest("google-token"), CancellationToken.None); + + Assert.IsType(result); + Assert.NotNull(created); + Assert.True(created!.EmailConfirmed); + } + private static JobTrackerContext BuildDb(string dbName, string? currentUserId) { var options = new DbContextOptionsBuilder().UseInMemoryDatabase(dbName).Options; diff --git a/JobTrackerApi/Controllers/AuthController.cs b/JobTrackerApi/Controllers/AuthController.cs index af62956..83937f9 100644 --- a/JobTrackerApi/Controllers/AuthController.cs +++ b/JobTrackerApi/Controllers/AuthController.cs @@ -46,6 +46,7 @@ public sealed class AuthController : ControllerBase var googleEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:GoogleClientId"] ?? string.Empty).Trim()); var microsoftEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:MicrosoftClientId"] ?? string.Empty).Trim()); var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false); + var requireEmailVerification = _cfg.GetValue("Auth:RequireEmailVerification", false); return Ok(new { @@ -54,6 +55,7 @@ public sealed class AuthController : ControllerBase microsoftEnabled, localEnabled = true, allowRegistration, + requireEmailVerification, }); } @@ -113,6 +115,14 @@ public sealed class AuthController : ControllerBase await _users.ResetAccessFailedCountAsync(user); + // Same enumeration-avoidance discipline as the password-check branch above: this only + // runs once the password is already confirmed correct, so it can never be used to probe + // whether an email is registered. + if (_cfg.GetValue("Auth:RequireEmailVerification", false) && !user.EmailConfirmed) + { + return StatusCode(StatusCodes.Status403Forbidden, new { error = "email_not_verified" }); + } + return await CompleteSignInAsync(user, request.RememberMe, "local", cancellationToken); } @@ -133,13 +143,28 @@ public sealed class AuthController : ControllerBase var existing = await _users.FindByEmailAsync(email); if (existing is not null) return BadRequest("User already exists."); - var user = new ApplicationUser { UserName = email, Email = email, EmailConfirmed = true }; + var requireEmailVerification = _cfg.GetValue("Auth:RequireEmailVerification", false); + var user = new ApplicationUser { UserName = email, Email = email, EmailConfirmed = !requireEmailVerification }; var res = await _users.CreateAsync(user, password); if (!res.Succeeded) { return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description))); } + if (requireEmailVerification) + { + try + { + await SendVerificationEmailAsync(user, cancellationToken); + } + catch (Exception ex) + { + // ponytail: don't fail registration over a flaky email send -- the account is + // created either way, the user can request a fresh link via resend-verification-email. + _logger.LogError(ex, "Failed to send verification email to {Email}", user.Email); + } + } + return await CompleteSignInAsync(user, request.RememberMe, "local", cancellationToken); } @@ -662,6 +687,82 @@ public sealed class AuthController : ControllerBase return NoContent(); } + public sealed record VerifyEmailRequest(string UserId, string Token); + + [HttpPost("verify-email")] + [AllowAnonymous] + [EnableRateLimiting("auth-email")] + public async Task VerifyEmail([FromBody] VerifyEmailRequest request) + { + var userId = (request.UserId ?? string.Empty).Trim(); + var token = request.Token ?? string.Empty; + + if (userId.Length == 0) return BadRequest("UserId is required."); + if (token.Length == 0) return BadRequest("Token is required."); + + var user = await _users.FindByIdAsync(userId); + if (user is null) return BadRequest("Invalid or expired link."); + + var res = await _users.ConfirmEmailAsync(user, token); + if (!res.Succeeded) + { + return BadRequest("Invalid or expired link."); + } + + return NoContent(); + } + + public sealed record ResendVerificationEmailRequest(string Email); + + [HttpPost("resend-verification-email")] + [AllowAnonymous] + [EnableRateLimiting("auth-email")] + public async Task ResendVerificationEmail([FromBody] ResendVerificationEmailRequest request, CancellationToken cancellationToken) + { + var email = (request.Email ?? string.Empty).Trim(); + if (email.Length == 0) return NoContent(); + + // Mirrors request-password-reset's enumeration-avoidance: always NoContent, only actually + // send when there's a matching local account that still needs verifying. + var user = await _users.FindByEmailAsync(email); + if (user is null || user.EmailConfirmed || string.IsNullOrWhiteSpace(user.Email) || !await _users.HasPasswordAsync(user)) + { + return NoContent(); + } + + try + { + await SendVerificationEmailAsync(user, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send verification email to {Email}", user.Email); + return EmailDeliveryUnavailable("Verification email could not be sent right now. Please try again later."); + } + + return NoContent(); + } + + private async Task SendVerificationEmailAsync(ApplicationUser user, CancellationToken cancellationToken) + { + var token = await _users.GenerateEmailConfirmationTokenAsync(user); + + var baseUrl = (_cfg["App:PublicBaseUrl"] ?? string.Empty).Trim().TrimEnd('/'); + if (string.IsNullOrWhiteSpace(baseUrl)) + { + baseUrl = $"{Request.Scheme}://{Request.Host}"; + } + + var link = $"{baseUrl}/verify-email?userId={Uri.EscapeDataString(user.Id)}&token={Uri.EscapeDataString(token)}"; + + await _email.SendAsync( + user.Email!, + "Verify your email", + $"Welcome to Jobbjakt! Please verify your email address to finish setting up your account.\n\nVerification link:\n{link}\n\nIf you did not create this account, you can ignore this email.", + cancellationToken + ); + } + private IActionResult EmailDeliveryUnavailable(string detail) { return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: detail); diff --git a/JobTrackerApi/appsettings.Development.json b/JobTrackerApi/appsettings.Development.json index 43d7dcd..e3736e8 100644 --- a/JobTrackerApi/appsettings.Development.json +++ b/JobTrackerApi/appsettings.Development.json @@ -20,6 +20,7 @@ "Auth": { "Require": true, "AllowRegistration": true, + "RequireEmailVerification": false, "JwtKey": "CHANGE_ME_DEV_ONLY_LONG_RANDOM_SECRET", "JwtIssuer": "JobTrackerApi", "JwtAudience": "job-tracker-ui", diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index bb8e0cd..18407a2 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -31,6 +31,7 @@ import LoginPage from "./views/LoginPage"; import LandingPage from "./views/LandingPage"; import ForgotPasswordPage from "./views/ForgotPasswordPage"; import ResetPasswordPage from "./views/ResetPasswordPage"; +import VerifyEmailPage from "./views/VerifyEmailPage"; import RouteErrorPage from "./views/RouteErrorPage"; import { api } from "./api"; import { resolveCaptureUrl } from "./captureUrl"; @@ -366,6 +367,7 @@ export default function App() { { path: "/login", element: , errorElement: }, { path: "/forgot-password", element: , errorElement: }, { path: "/reset-password", element: , errorElement: }, + { path: "/verify-email", element: , errorElement: }, { path: "/*", element: , errorElement: }, ], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode]); diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index efa0f89..adee5f9 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -764,6 +764,15 @@ export const translations = { resetFailed: "Reset failed.", backToLogin: "Back to login", updatePassword: "Update password", + emailNotVerified: "Please verify your email address before signing in.", + resendVerificationEmail: "Resend verification email", + verificationEmailResent: "Verification email sent. Check your inbox.", + registerCheckEmailForVerification: "Check your email to verify your account.", + verifyEmailTitle: "Verify your email", + verifyEmailVerifying: "Verifying your email...", + verifyEmailSuccess: "Your email has been verified. You can now sign in.", + verifyEmailFailed: "This verification link is invalid or has expired.", + missingVerifyLinkInfo: "Missing user/token in link.", jobTableSearch: "Search", jobTableSearchPlaceholder: "Title, company, notes, messages", jobTableStatus: "Status", @@ -1792,6 +1801,15 @@ export const translations = { resetFailed: "Tilbakestilling mislyktes.", backToLogin: "Tilbake til innlogging", updatePassword: "Oppdater passord", + emailNotVerified: "Vennligst bekreft e-postadressen din før du logger inn.", + resendVerificationEmail: "Send bekreftelses-e-post på nytt", + verificationEmailResent: "Bekreftelses-e-post sendt. Sjekk innboksen din.", + registerCheckEmailForVerification: "Sjekk e-posten din for å bekrefte kontoen.", + verifyEmailTitle: "Bekreft e-posten din", + verifyEmailVerifying: "Bekrefter e-posten din...", + verifyEmailSuccess: "E-posten din er bekreftet. Du kan nå logge inn.", + verifyEmailFailed: "Denne bekreftelseslenken er ugyldig eller har utløpt.", + missingVerifyLinkInfo: "Mangler bruker/token i lenken.", jobTableSearch: "Søk", jobTableSearchPlaceholder: "Tittel, selskap, notater, meldinger", jobTableStatus: "Status", diff --git a/job-tracker-ui/src/login-page.test.tsx b/job-tracker-ui/src/login-page.test.tsx index 2973f34..5323e12 100644 --- a/job-tracker-ui/src/login-page.test.tsx +++ b/job-tracker-ui/src/login-page.test.tsx @@ -137,4 +137,33 @@ describe('LoginPage', () => { expect(await screen.findByRole('alert')).toHaveTextContent('Too many attempts. Please wait a few minutes and try again.'); }); + + it('offers a resend-verification action when login reports the account is not verified', async () => { + mockedApi.get.mockResolvedValueOnce({ + data: { requireAuth: false, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: false, requireEmailVerification: true }, + } as any); + mockedApi.post.mockImplementation((url: string) => { + if (url === '/auth/login') { + return Promise.reject({ response: { status: 403, data: { error: 'email_not_verified' } } }); + } + if (url === '/auth/resend-verification-email') { + return Promise.resolve({ data: {} } as any); + } + return Promise.resolve({ data: {} } as any); + }); + + renderLoginPage(); + await screen.findByLabelText('Email'); + + await userEvent.type(screen.getByLabelText('Email'), 'unverified@example.com'); + await userEvent.type(screen.getByLabelText('Current password'), 'hunter2'); + await userEvent.click(screen.getByRole('button', { name: 'Sign in' })); + + expect(await screen.findByText('Please verify your email address before signing in.')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Resend verification email' })); + + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/auth/resend-verification-email', { email: 'unverified@example.com' })); + await screen.findByText('Verification email sent. Check your inbox.'); + }); }); diff --git a/job-tracker-ui/src/verify-email-page.test.tsx b/job-tracker-ui/src/verify-email-page.test.tsx new file mode 100644 index 0000000..4de708e --- /dev/null +++ b/job-tracker-ui/src/verify-email-page.test.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +import VerifyEmailPage from './views/VerifyEmailPage'; +import { I18nProvider } from './i18n/I18nProvider'; +import { api, getApiErrorMessage } from './api'; + +const mockedApi = api as jest.Mocked; +// CRA's jest config sets resetMocks: true, which wipes the initial implementation given to +// jest.fn() in setupTests.ts before every test -- re-arm it here so error-derived text is testable. +const mockedGetApiErrorMessage = getApiErrorMessage as jest.Mock; + +function renderVerifyEmailPage(search: string) { + window.history.pushState({}, '', `/verify-email${search}`); + return render( + + + + + , + ); +} + +describe('VerifyEmailPage', () => { + beforeEach(() => { + mockedApi.post.mockReset(); + mockedGetApiErrorMessage.mockImplementation((e: any, fallback?: string) => { + const data = e?.response?.data; + return typeof data === 'string' && data.trim() ? data.trim() : fallback; + }); + }); + + it('confirms the account and shows success when the link is valid', async () => { + mockedApi.post.mockResolvedValueOnce({ data: {} } as any); + + renderVerifyEmailPage('?userId=user-1&token=good-token'); + + expect(await screen.findByText('Your email has been verified. You can now sign in.')).toBeInTheDocument(); + expect(mockedApi.post).toHaveBeenCalledWith('/auth/verify-email', { userId: 'user-1', token: 'good-token' }); + }); + + it('shows an error when the link is invalid or expired', async () => { + mockedApi.post.mockRejectedValueOnce({ response: { status: 400, data: 'Invalid or expired link.' } }); + + renderVerifyEmailPage('?userId=user-1&token=bad-token'); + + expect(await screen.findByText('Invalid or expired link.')).toBeInTheDocument(); + }); + + it('shows an error without calling the API when the link is missing userId/token', async () => { + renderVerifyEmailPage(''); + + expect(await screen.findByText('Missing user/token in link.')).toBeInTheDocument(); + expect(mockedApi.post).not.toHaveBeenCalled(); + }); +}); diff --git a/job-tracker-ui/src/views/LoginPage.tsx b/job-tracker-ui/src/views/LoginPage.tsx index b119c59..f5dff1d 100644 --- a/job-tracker-ui/src/views/LoginPage.tsx +++ b/job-tracker-ui/src/views/LoginPage.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; -import { Box, Button, Checkbox, FormControlLabel, Paper, Tab, Tabs, TextField, Typography } from "@mui/material"; +import { Alert, Box, Button, Checkbox, FormControlLabel, Paper, Tab, Tabs, TextField, Typography } from "@mui/material"; import { useLocation, useNavigate } from "react-router-dom"; @@ -18,6 +18,7 @@ type AuthConfig = { microsoftEnabled: boolean; localEnabled: boolean; allowRegistration: boolean; + requireEmailVerification: boolean; }; export default function LoginPage() { @@ -34,6 +35,9 @@ export default function LoginPage() { const [rememberMe, setRememberMe] = useState(() => getRememberMePref()); const [loading, setLoading] = useState(false); const [pendingToken, setPendingToken] = useState(null); + const [emailNotVerified, setEmailNotVerified] = useState(false); + const [resendingVerification, setResendingVerification] = useState(false); + const [verificationResent, setVerificationResent] = useState(false); const nextPath = (location?.state?.from as string | undefined) ?? "/dashboard"; @@ -53,6 +57,8 @@ export default function LoginPage() { async function submit(mode: "login" | "register") { setLoading(true); + setEmailNotVerified(false); + setVerificationResent(false); try { const url = mode === "register" ? "/auth/register" : "/auth/login"; const res = await api.post<{ requiresTwoFactor?: boolean; pendingToken?: string }>(url, { email, password, rememberMe }); @@ -61,13 +67,33 @@ export default function LoginPage() { return; } await completeLogin(); + if (mode === "register" && cfg?.requireEmailVerification) { + toast(t("registerCheckEmailForVerification"), "info"); + } } catch (e: any) { - toast(getApiErrorMessage(e, t("loginFailed")), "error"); + if (mode === "login" && e?.response?.data?.error === "email_not_verified") { + setEmailNotVerified(true); + } else { + toast(getApiErrorMessage(e, t("loginFailed")), "error"); + } } finally { setLoading(false); } } + async function resendVerification() { + setResendingVerification(true); + try { + await api.post("/auth/resend-verification-email", { email }); + setVerificationResent(true); + toast(t("verificationEmailResent"), "success"); + } catch (e: any) { + toast(getApiErrorMessage(e, t("verifyEmailFailed")), "error"); + } finally { + setResendingVerification(false); + } + } + const allowReg = cfg?.allowRegistration ?? false; return ( @@ -106,6 +132,18 @@ export default function LoginPage() { {tab === 0 && ( { e.preventDefault(); void submit("login"); }} sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}> + {cfg?.requireEmailVerification && emailNotVerified && ( + void resendVerification()}> + {verificationResent ? t("verificationEmailResent") : t("resendVerificationEmail")} + + } + > + {t("emailNotVerified")} + + )} setEmail(e.target.value)} autoComplete="email" fullWidth /> setPassword(e.target.value)} autoComplete={allowReg ? "new-password" : "current-password"} type="password" fullWidth /> diff --git a/job-tracker-ui/src/views/VerifyEmailPage.tsx b/job-tracker-ui/src/views/VerifyEmailPage.tsx new file mode 100644 index 0000000..2524d0e --- /dev/null +++ b/job-tracker-ui/src/views/VerifyEmailPage.tsx @@ -0,0 +1,75 @@ +import React, { useEffect, useState } from "react"; + +import { Alert, Box, Button, CircularProgress, Paper, Typography } from "@mui/material"; + +import { useNavigate } from "react-router-dom"; + +import { api, getApiErrorMessage } from "../api"; +import { useI18n } from "../i18n/I18nProvider"; + +type Status = "verifying" | "success" | "error"; + +export default function VerifyEmailPage() { + const { t } = useI18n(); + const navigate = useNavigate(); + + const [status, setStatus] = useState("verifying"); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const userId = params.get("userId") || ""; + const token = params.get("token") || ""; + + if (!userId || !token) { + setStatus("error"); + setErrorMessage(t("missingVerifyLinkInfo")); + return; + } + + api + .post("/auth/verify-email", { userId, token }) + .then(() => setStatus("success")) + .catch((e: any) => { + setStatus("error"); + setErrorMessage(getApiErrorMessage(e, t("verifyEmailFailed"))); + }); + }, [t]); + + return ( + + + + {t("verifyEmailTitle")} + + + + {status === "verifying" && ( + + + {t("verifyEmailVerifying")} + + )} + {status === "success" && {t("verifyEmailSuccess")}} + {status === "error" && {errorMessage}} + + + + + + + + ); +} From c6918cbeea219fff270ea45f4d0e2d9e15b3a5ef Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 13 Jul 2026 01:47:31 +0200 Subject: [PATCH 6/7] feat(auth): add server-tracked sessions with view/revoke JWTs were previously fully stateless -- the token alone was the credential until its own expiry, with no way to list or kill a session server-side. Add a UserSession table alongside every JWT issued (AppSessionIssuer), embed its id as a "sid" claim, and check that claim against the DB on every "local" scheme request (Program.cs OnTokenValidated) so a session can actually be revoked before its JWT naturally expires. New /api/auth/sessions endpoints (list, revoke one, revoke-others) plus a Sessions card on the profile page. Fails closed on a missing "sid" claim: every JWT issued going forward has one, so a token without it is either pre-deploy (forces one re-login for already-signed-in users at deploy time, same additive-forward cost the 2FA/trusted-device work on this branch already paid) or forged. --- Data/JobTrackerContext.cs | 13 + .../AuthAndSystemControllerTests.cs | 14 +- .../SessionsControllerTests.cs | 242 ++++++++++++++++++ .../TwoFactorControllerTests.cs | 6 +- JobTrackerApi/Controllers/AuthController.cs | 4 +- .../Controllers/SessionsController.cs | 104 ++++++++ .../Controllers/TwoFactorController.cs | 6 +- JobTrackerApi/Program.cs | 23 +- JobTrackerApi/Services/AppSessionIssuer.cs | 26 +- .../Services/LocalSessionValidator.cs | 30 +++ .../StartupInitializationExtensions.cs | 41 +++ JobTrackerApi/Services/TokenService.cs | 7 +- .../Services/TrustedDeviceService.cs | 3 +- Models/UserSession.cs | 17 ++ .../src/components/SessionsSettingsCard.tsx | 148 +++++++++++ job-tracker-ui/src/i18n/translations.ts | 22 ++ job-tracker-ui/src/views/ProfilePage.tsx | 2 + 17 files changed, 684 insertions(+), 24 deletions(-) create mode 100644 JobTrackerApi.Tests/SessionsControllerTests.cs create mode 100644 JobTrackerApi/Controllers/SessionsController.cs create mode 100644 JobTrackerApi/Services/LocalSessionValidator.cs create mode 100644 Models/UserSession.cs create mode 100644 job-tracker-ui/src/components/SessionsSettingsCard.tsx diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index f0a0fb8..2ef52ed 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -30,6 +30,7 @@ namespace JobTrackerApi.Data public DbSet TailoredCvDrafts => Set(); public DbSet TwoFactorRecoveryCodes => Set(); public DbSet TrustedDevices => Set(); + public DbSet UserSessions => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -164,6 +165,18 @@ namespace JobTrackerApi.Data modelBuilder.Entity() .HasIndex(x => x.TokenHash); + + // No FK to AspNetUsers, same convention as TrustedDevice/TwoFactorRecoveryCode above: the + // OnTokenValidated auth check reads this table before CurrentUserId is meaningfully set + // for the request being validated, via IgnoreQueryFilters(). + modelBuilder.Entity() + .HasKey(x => x.Id); + + modelBuilder.Entity() + .HasQueryFilter(x => CurrentUserId != null && x.UserId == CurrentUserId); + + modelBuilder.Entity() + .HasIndex(x => x.UserId); } } } diff --git a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs index 45b767d..aa66f69 100644 --- a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs +++ b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs @@ -184,7 +184,7 @@ public sealed class AuthAndSystemControllerTests userManager.Setup(x => x.GenerateEmailConfirmationTokenAsync(It.IsAny())).ReturnsAsync("confirm-token"); var tokenService = new Mock(); - tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); + tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); var emailSender = new Mock(); @@ -217,7 +217,7 @@ public sealed class AuthAndSystemControllerTests .ReturnsAsync(IdentityResult.Success); var tokenService = new Mock(); - tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); + tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); var emailSender = new Mock(); @@ -272,7 +272,7 @@ public sealed class AuthAndSystemControllerTests userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success); var tokenService = new Mock(); - tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny())).ReturnsAsync("app-token"); + tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), TestHostFactory.CreateInMemoryDb()) { @@ -347,7 +347,7 @@ public sealed class AuthAndSystemControllerTests .ReturnsAsync(IdentityResult.Success); var tokenService = new Mock(); - tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); + tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); var googleValidator = new Mock(); googleValidator @@ -384,7 +384,7 @@ public sealed class AuthAndSystemControllerTests userManager.Setup(x => x.ResetAccessFailedCountAsync(user)).ReturnsAsync(IdentityResult.Success); var tokenService = new Mock(); - tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny())).ReturnsAsync("app-token"); + tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); db = BuildDb(dbName, null); @@ -468,7 +468,7 @@ public sealed class AuthAndSystemControllerTests userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); var tokenService = new Mock(); - tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny())).ReturnsAsync("app-token"); + tokenService.Setup(x => x.CreateAccessTokenAsync(user, It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); var googleValidator = new Mock(); googleValidator @@ -508,7 +508,7 @@ public sealed class AuthAndSystemControllerTests userManager.Setup(x => x.UpdateAsync(It.IsAny())).ReturnsAsync(IdentityResult.Success); var tokenService = new Mock(); - tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); + tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); var microsoftValidator = new Mock(); microsoftValidator diff --git a/JobTrackerApi.Tests/SessionsControllerTests.cs b/JobTrackerApi.Tests/SessionsControllerTests.cs new file mode 100644 index 0000000..9faf2d1 --- /dev/null +++ b/JobTrackerApi.Tests/SessionsControllerTests.cs @@ -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()).Build(); + + private static SessionsController BuildController(JobTrackerContext db, ApplicationUser user, string? currentSid = null) + { + var userManager = TestHostFactory.CreateUserManager(); + userManager.Setup(x => x.GetUserAsync(It.IsAny())).ReturnsAsync(user); + + var claims = new List { 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(); + tokenService + .Setup(x => x.CreateAccessTokenAsync(user, It.IsAny(), It.IsAny())) + .Callback((_, sid, _) => sessionIdPassedToToken = sid) + .ReturnsAsync("app-token"); + + using var db = TestHostFactory.CreateInMemoryDb(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() } + }; + + 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 { ["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(), "password123")) + .Callback((u, _) => { u.Id = "new-user-1"; created = u; }) + .ReturnsAsync(IdentityResult.Success); + + var tokenService = new Mock(); + tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); + + using var db = TestHostFactory.CreateInMemoryDb(null); + var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of(), Mock.Of(), Mock.Of(), NullLogger.Instance, Mock.Of(), 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())).ReturnsAsync(IdentityResult.Success); + userManager.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user); + userManager.Setup(x => x.GetUserAsync(It.IsAny())).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(); + tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny(), It.IsAny())).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(await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None)); + var setup = Assert.IsType(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(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(await controller.List(CancellationToken.None)); + var list = Assert.IsType>(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(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(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(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")); +} diff --git a/JobTrackerApi.Tests/TwoFactorControllerTests.cs b/JobTrackerApi.Tests/TwoFactorControllerTests.cs index 80e7a72..3655d67 100644 --- a/JobTrackerApi.Tests/TwoFactorControllerTests.cs +++ b/JobTrackerApi.Tests/TwoFactorControllerTests.cs @@ -7,6 +7,7 @@ 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; @@ -23,14 +24,15 @@ public sealed class TwoFactorControllerTests } var tokenService = new Mock(); - tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny())).ReturnsAsync("app-token"); + tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny(), It.IsAny(), It.IsAny())).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 EphemeralDataProtectionProvider(), + new ConfigurationBuilder().AddInMemoryCollection(new Dictionary()).Build()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; diff --git a/JobTrackerApi/Controllers/AuthController.cs b/JobTrackerApi/Controllers/AuthController.cs index 83937f9..f7ee64a 100644 --- a/JobTrackerApi/Controllers/AuthController.cs +++ b/JobTrackerApi/Controllers/AuthController.cs @@ -781,7 +781,7 @@ public sealed class AuthController : ControllerBase // (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); + await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, rememberMe, cancellationToken); return Ok(new AuthSessionResult(true, provider)); } @@ -791,7 +791,7 @@ public sealed class AuthController : ControllerBase return Ok(new TwoFactorRequiredResult(true, pendingToken)); } - await AppSessionIssuer.IssueAsync(Request, Response, _tokens, user, rememberMe, cancellationToken); + await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, rememberMe, cancellationToken); return Ok(new AuthSessionResult(true, provider)); } diff --git a/JobTrackerApi/Controllers/SessionsController.cs b/JobTrackerApi/Controllers/SessionsController.cs new file mode 100644 index 0000000..a531f3f --- /dev/null +++ b/JobTrackerApi/Controllers/SessionsController.cs @@ -0,0 +1,104 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace JobTrackerApi.Controllers; + +// List/revoke the server-tracked UserSession rows behind the JWTs AppSessionIssuer hands out. +// Not 2FA-specific (any local-auth user has sessions, 2FA or not), hence its own small controller +// rather than folding into TwoFactorController. +[ApiController] +[Route("api/auth/sessions")] +[Authorize(AuthenticationSchemes = "local")] +public sealed class SessionsController : ControllerBase +{ + private readonly UserManager _users; + private readonly JobTrackerContext _db; + + public SessionsController(UserManager users, JobTrackerContext db) + { + _users = users; + _db = db; + } + + public sealed record SessionDto(string Id, string? DeviceLabel, DateTimeOffset CreatedAtUtc, DateTimeOffset LastSeenAtUtc, DateTimeOffset ExpiresAtUtc, bool IsCurrentSession); + + private string? CurrentSid => User.FindFirst("sid")?.Value; + + [HttpGet] + public async Task List(CancellationToken cancellationToken) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + + var now = DateTimeOffset.UtcNow; + var currentSid = CurrentSid; + // IgnoreQueryFilters + an explicit UserId filter, same convention as + // TrustedDeviceService/TwoFactorController's device-list queries. + // Equality-only in the DB query, then filter/sort DateTimeOffset client-side: SQLite's EF + // Core provider cannot translate ">" or ORDER BY over DateTimeOffset to SQL ("SQLite does + // not support expressions of type 'DateTimeOffset' in ORDER BY clauses"), so ExpiresAtUtc + // comparison and the LastSeenAtUtc sort have to happen after materializing the (small, + // per-user) row set. + var candidates = await _db.UserSessions.IgnoreQueryFilters() + .Where(x => x.UserId == user.Id && x.RevokedAtUtc == null) + .ToListAsync(cancellationToken); + + var sessions = candidates + .Where(x => x.ExpiresAtUtc > now) + .OrderByDescending(x => x.LastSeenAtUtc) + .Select(x => new SessionDto(x.Id, x.DeviceLabel, x.CreatedAtUtc, x.LastSeenAtUtc, x.ExpiresAtUtc, x.Id == currentSid)) + .ToList(); + + return Ok(sessions); + } + + [HttpDelete("{id}")] + public async Task Revoke(string id, CancellationToken cancellationToken) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + + var session = await _db.UserSessions.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id && x.UserId == user.Id, cancellationToken); + if (session is null) return NotFound(); + + session.RevokedAtUtc = DateTimeOffset.UtcNow; + await _db.SaveChangesAsync(cancellationToken); + + if (string.Equals(id, CurrentSid, StringComparison.Ordinal)) + { + var secure = Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase); + Response.Cookies.Delete(AuthSessionOptions.SessionCookieName, AuthSessionOptions.BuildExpiredCookie(secure)); + } + + return NoContent(); + } + + [HttpPost("revoke-others")] + public async Task RevokeOthers(CancellationToken cancellationToken) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + + var currentSid = CurrentSid; + var now = DateTimeOffset.UtcNow; + var others = await _db.UserSessions.IgnoreQueryFilters() + .Where(x => x.UserId == user.Id && x.RevokedAtUtc == null && x.Id != currentSid) + .ToListAsync(cancellationToken); + + foreach (var session in others) + { + session.RevokedAtUtc = now; + } + if (others.Count > 0) + { + await _db.SaveChangesAsync(cancellationToken); + } + + return NoContent(); + } +} diff --git a/JobTrackerApi/Controllers/TwoFactorController.cs b/JobTrackerApi/Controllers/TwoFactorController.cs index dc73c83..ec2e02f 100644 --- a/JobTrackerApi/Controllers/TwoFactorController.cs +++ b/JobTrackerApi/Controllers/TwoFactorController.cs @@ -28,14 +28,16 @@ public sealed class TwoFactorController : ControllerBase private readonly JobTrackerContext _db; private readonly ITwoFactorPendingTokenService _pending; private readonly IDataProtector _protector; + private readonly IConfiguration _cfg; - public TwoFactorController(UserManager users, ITokenService tokens, JobTrackerContext db, ITwoFactorPendingTokenService pending, IDataProtectionProvider protectionProvider) + public TwoFactorController(UserManager users, ITokenService tokens, JobTrackerContext db, ITwoFactorPendingTokenService pending, IDataProtectionProvider protectionProvider, IConfiguration cfg) { _users = users; _tokens = tokens; _db = db; _pending = pending; _protector = protectionProvider.CreateProtector("totp-secret-v1"); + _cfg = cfg; } public sealed record PasswordConfirmRequest(string CurrentPassword); @@ -202,7 +204,7 @@ public sealed class TwoFactorController : ControllerBase if (!verified) return Unauthorized(); _pending.Resolve(pendingToken, consume: true); - await AppSessionIssuer.IssueAsync(Request, Response, _tokens, user, session.RememberMe, cancellationToken); + await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, session.RememberMe, cancellationToken); if (request.TrustDevice) { diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index b5aef01..e6bfb4a 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -286,16 +286,29 @@ builder.Services.AddAuthentication(options => return Task.CompletedTask; }, - OnTokenValidated = context => + OnTokenValidated = async context => { var userId = LocalAuthIdentity.GetRequiredUserId(context.Principal); - if (userId is not null) + if (userId is null) { - return Task.CompletedTask; + context.Fail("Local tokens must include a subject/nameidentifier claim."); + return; } - context.Fail("Local tokens must include a subject/nameidentifier claim."); - return Task.CompletedTask; + // Resolve a fresh scoped JobTrackerContext for this one lookup -- OnTokenValidated + // runs outside the request's normal DI-constructor scope, so RequestServices (the + // per-request scope) must be used directly rather than a captured/singleton one. + // Fail closed if the session row is missing/revoked/expired (including tokens + // with no "sid" claim at all -- see LocalSessionValidator for why: every JWT + // issued going forward carries one, so a token without it is either pre-deploy + // (forces a single re-login for anyone already signed in when this ships -- + // acceptable, same additive-forward cost the 2FA/trusted-device features on this + // branch already paid) or forged, and either way isn't proof of a live session. + var db = context.HttpContext.RequestServices.GetRequiredService(); + if (!await LocalSessionValidator.IsValidAsync(db, context.Principal, DateTimeOffset.UtcNow)) + { + context.Fail("Session has been revoked or expired."); + } } }; options.TokenValidationParameters = new TokenValidationParameters diff --git a/JobTrackerApi/Services/AppSessionIssuer.cs b/JobTrackerApi/Services/AppSessionIssuer.cs index 043d0d0..e2c30cd 100644 --- a/JobTrackerApi/Services/AppSessionIssuer.cs +++ b/JobTrackerApi/Services/AppSessionIssuer.cs @@ -1,17 +1,37 @@ using System.Security.Cryptography; +using JobTrackerApi.Data; using JobTrackerApi.Models; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; namespace JobTrackerApi.Services; // Shared by AuthController (local/Google/Microsoft sign-in) and TwoFactorController (the // post-challenge sign-in) so the httpOnly session cookie + readable CSRF cookie are always -// issued the same way, from one place. +// issued the same way, from one place. Also the single place a UserSession row is created, so +// every JWT this app ever hands out has a matching server-side row Program.cs can revoke. public static class AppSessionIssuer { - public static async Task IssueAsync(HttpRequest request, HttpResponse response, ITokenService tokens, ApplicationUser user, bool rememberMe, CancellationToken cancellationToken) + public static async Task IssueAsync(HttpRequest request, HttpResponse response, ITokenService tokens, JobTrackerContext db, IConfiguration cfg, ApplicationUser user, bool rememberMe, CancellationToken cancellationToken) { - var token = await tokens.CreateAccessTokenAsync(user, cancellationToken); + var minutes = cfg.GetValue("Auth:JwtExpiresMinutes", 60 * 12); + if (minutes < 5) minutes = 5; + if (minutes > 60 * 24 * 30) minutes = 60 * 24 * 30; + + var now = DateTimeOffset.UtcNow; + var session = new UserSession + { + Id = Guid.NewGuid().ToString("N"), + UserId = user.Id, + DeviceLabel = TrustedDeviceService.DescribeUserAgent(request.Headers["User-Agent"].ToString()), + CreatedAtUtc = now, + LastSeenAtUtc = now, + ExpiresAtUtc = now.AddMinutes(minutes), + }; + db.UserSessions.Add(session); + await db.SaveChangesAsync(cancellationToken); + + var token = await tokens.CreateAccessTokenAsync(user, session.Id, cancellationToken); var secure = request.IsHttps || string.Equals(request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase); response.Cookies.Append(AuthSessionOptions.SessionCookieName, token, AuthSessionOptions.BuildSessionCookie(rememberMe, secure)); diff --git a/JobTrackerApi/Services/LocalSessionValidator.cs b/JobTrackerApi/Services/LocalSessionValidator.cs new file mode 100644 index 0000000..0b22553 --- /dev/null +++ b/JobTrackerApi/Services/LocalSessionValidator.cs @@ -0,0 +1,30 @@ +using System.Security.Claims; +using JobTrackerApi.Data; +using Microsoft.EntityFrameworkCore; + +namespace JobTrackerApi.Services; + +// The actual revocation check behind Program.cs's "local" JwtBearer OnTokenValidated. Pulled out +// of Program.cs so it's unit-testable without standing up a full TestServer/HTTP pipeline. +public static class LocalSessionValidator +{ + public static async Task IsValidAsync(JobTrackerContext db, ClaimsPrincipal? principal, DateTimeOffset now, CancellationToken cancellationToken = default) + { + var sid = principal?.FindFirst("sid")?.Value; + // Fail closed: see the comment on the OnTokenValidated wiring in Program.cs for why a + // missing sid is rejected rather than grandfathered in. + if (string.IsNullOrWhiteSpace(sid)) return false; + + var session = await db.UserSessions.IgnoreQueryFilters() + .FirstOrDefaultAsync(x => x.Id == sid, cancellationToken); + if (session is null || session.RevokedAtUtc is not null || session.ExpiresAtUtc <= now) return false; + + if (now - session.LastSeenAtUtc > TimeSpan.FromMinutes(5)) + { + session.LastSeenAtUtc = now; + await db.SaveChangesAsync(cancellationToken); + } + + return true; + } +} diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index 1f237ae..399a300 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -665,12 +665,30 @@ public static class StartupInitializationExtensions Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TrustedDevices_TokenHash" ON "TrustedDevices" ("TokenHash");"""); } + static void EnsureUserSessionsTable(DbConnection c) + { + Exec(c, """ + CREATE TABLE IF NOT EXISTS "UserSessions" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_UserSessions" PRIMARY KEY, + "UserId" TEXT NOT NULL, + "DeviceLabel" TEXT NULL, + "CreatedAtUtc" TEXT NOT NULL, + "LastSeenAtUtc" TEXT NOT NULL, + "ExpiresAtUtc" TEXT NOT NULL, + "RevokedAtUtc" TEXT NULL + ); + """); + + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_UserSessions_UserId" ON "UserSessions" ("UserId");"""); + } + EnsureGmailConnectionsTable(conn); EnsureMicrosoftGraphConnectionsTable(conn); EnsureImapConnectionsTable(conn); EnsureCvTables(conn); EnsureTwoFactorRecoveryCodesTable(conn); EnsureTrustedDevicesTable(conn); + EnsureUserSessionsTable(conn); // Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded, // and at least one of the new columns already exists. @@ -1079,6 +1097,29 @@ public static class StartupInitializationExtensions cmd.ExecuteNonQuery(); } + if (!HasMySqlTable(conn, "UserSessions")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `UserSessions` ( + `Id` varchar(64) NOT NULL, + `UserId` varchar(255) NOT NULL, + `DeviceLabel` varchar(255) NULL, + `CreatedAtUtc` datetime(6) NOT NULL, + `LastSeenAtUtc` datetime(6) NOT NULL, + `ExpiresAtUtc` datetime(6) NOT NULL, + `RevokedAtUtc` datetime(6) NULL, + PRIMARY KEY (`Id`) + );"; + cmd.ExecuteNonQuery(); + } + + if (!MySqlIndexExists(conn, "UserSessions", "IX_UserSessions_UserId")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE INDEX `IX_UserSessions_UserId` ON `UserSessions` (`UserId`);"; + cmd.ExecuteNonQuery(); + } + if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId")) { using var cmd = conn.CreateCommand(); diff --git a/JobTrackerApi/Services/TokenService.cs b/JobTrackerApi/Services/TokenService.cs index 797a5fb..7392161 100644 --- a/JobTrackerApi/Services/TokenService.cs +++ b/JobTrackerApi/Services/TokenService.cs @@ -10,7 +10,7 @@ namespace JobTrackerApi.Services; public interface ITokenService { - Task CreateAccessTokenAsync(ApplicationUser user, CancellationToken cancellationToken = default); + Task CreateAccessTokenAsync(ApplicationUser user, string? sessionId = null, CancellationToken cancellationToken = default); } public sealed class TokenService : ITokenService @@ -24,7 +24,7 @@ public sealed class TokenService : ITokenService _users = users; } - public async Task CreateAccessTokenAsync(ApplicationUser user, CancellationToken cancellationToken = default) + public async Task CreateAccessTokenAsync(ApplicationUser user, string? sessionId = null, CancellationToken cancellationToken = default) { var jwtKey = (_cfg["Auth:JwtKey"] ?? "").Trim(); if (string.IsNullOrWhiteSpace(jwtKey)) @@ -57,6 +57,9 @@ public sealed class TokenService : ITokenService foreach (var r in roles) claims.Add(new Claim(ClaimTypes.Role, r)); + if (!string.IsNullOrWhiteSpace(sessionId)) + claims.Add(new Claim("sid", sessionId)); + var now = DateTime.UtcNow; var token = new JwtSecurityToken( diff --git a/JobTrackerApi/Services/TrustedDeviceService.cs b/JobTrackerApi/Services/TrustedDeviceService.cs index 468e35b..f3af7ec 100644 --- a/JobTrackerApi/Services/TrustedDeviceService.cs +++ b/JobTrackerApi/Services/TrustedDeviceService.cs @@ -96,7 +96,8 @@ public static class TrustedDeviceService // 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) + // Public: also reused by AppSessionIssuer for UserSession device labels. + public static string? DescribeUserAgent(string? userAgent) { if (string.IsNullOrWhiteSpace(userAgent)) return null; diff --git a/Models/UserSession.cs b/Models/UserSession.cs new file mode 100644 index 0000000..ad62950 --- /dev/null +++ b/Models/UserSession.cs @@ -0,0 +1,17 @@ +namespace JobTrackerApi.Models; + +// Server-side record of a JWT issued via AppSessionIssuer. The JWT carries this row's Id as its +// "sid" claim; Program.cs's "local" JwtBearer OnTokenValidated looks the row up on every request +// so a session can actually be revoked before its JWT naturally expires (previously the JWT alone +// was the credential -- see AppSessionIssuer). Same shape/rationale as TrustedDevice, but this +// tracks the *session* itself rather than a "skip 2FA" cookie. +public sealed class UserSession +{ + public string Id { get; set; } = ""; + public string UserId { get; set; } = ""; + public string? DeviceLabel { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } + public DateTimeOffset LastSeenAtUtc { get; set; } + public DateTimeOffset ExpiresAtUtc { get; set; } + public DateTimeOffset? RevokedAtUtc { get; set; } +} diff --git a/job-tracker-ui/src/components/SessionsSettingsCard.tsx b/job-tracker-ui/src/components/SessionsSettingsCard.tsx new file mode 100644 index 0000000..e480fca --- /dev/null +++ b/job-tracker-ui/src/components/SessionsSettingsCard.tsx @@ -0,0 +1,148 @@ +import React, { useEffect, useState } from "react"; + +import { + Alert, + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + List, + ListItem, + ListItemSecondaryAction, + ListItemText, + Paper, + Typography, +} from "@mui/material"; +import DeleteIcon from "@mui/icons-material/Delete"; + +import { api, getApiErrorMessage } from "../api"; +import { useToast } from "../toast"; +import { useI18n } from "../i18n/I18nProvider"; +import { clearAuthClientState } from "../auth"; + +type Session = { + id: string; + deviceLabel: string | null; + createdAtUtc: string; + lastSeenAtUtc: string; + expiresAtUtc: string; + isCurrentSession: boolean; +}; + +function apiErrorMessage(e: any, t: (k: any) => string) { + return getApiErrorMessage(e, t("twoFactorGenericError")); +} + +export default function SessionsSettingsCard() { + const { toast } = useToast(); + const { t } = useI18n(); + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [confirmRevokeOthers, setConfirmRevokeOthers] = useState(false); + + const loadSessions = () => { + setLoading(true); + setError(null); + api + .get("/auth/sessions") + .then((r) => setSessions(r.data)) + .catch((e) => setError(apiErrorMessage(e, t))) + .finally(() => setLoading(false)); + }; + + useEffect(() => { loadSessions(); }, []); + + async function revokeSession(id: string, isCurrentSession: boolean) { + try { + await api.delete(`/auth/sessions/${id}`); + if (isCurrentSession) { + // Same pattern as AuthStatusCard's sign-out: clearing local auth state emits + // "auth-changed", which App.tsx's listener picks up to refetch /auth/me (now 401, + // since the server already deleted the session cookie) and redirect to /login. + clearAuthClientState(); + return; + } + toast(t("sessionsRevoked"), "success"); + loadSessions(); + } catch (e: any) { + setError(apiErrorMessage(e, t)); + } + } + + async function revokeOthers() { + try { + await api.post("/auth/sessions/revoke-others"); + toast(t("sessionsRevokedOthers"), "success"); + setConfirmRevokeOthers(false); + loadSessions(); + } catch (e: any) { + setError(apiErrorMessage(e, t)); + } + } + + return ( + + + {t("sessionsSectionTitle")} + + + {error ? {error} : null} + {!loading && sessions.length === 0 && !error ? ( + {t("sessionsEmpty")} + ) : null} + + {sessions.length > 0 ? ( + + {sessions.map((s) => ( + + + {s.deviceLabel || t("sessionsUnknownDevice")} + {s.isCurrentSession ? ( + + {t("sessionsCurrentDevice")} + + ) : null} + + } + secondary={t("sessionsMeta", { + lastSeen: new Date(s.lastSeenAtUtc).toLocaleString(), + expires: new Date(s.expiresAtUtc).toLocaleDateString(), + })} + /> + + revokeSession(s.id, s.isCurrentSession)}> + + + + + ))} + + ) : null} + + {sessions.length > 1 ? ( + + ) : null} + + setConfirmRevokeOthers(false)} maxWidth="sm" fullWidth> + {t("sessionsRevokeOthersConfirmTitle")} + + {t("sessionsRevokeOthersConfirmBody")} + + + + + + + + ); +} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index adee5f9..295af21 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -356,6 +356,17 @@ export const translations = { twoFactorTrustedDevicesRevokedAll: "All trusted devices have been signed out.", twoFactorRevokeAllConfirmTitle: "Sign out all trusted devices?", twoFactorRevokeAllConfirmBody: "You'll be asked for a 2FA code the next time you sign in on any device, including this one.", + sessionsSectionTitle: "Sessions", + sessionsEmpty: "No active sessions.", + sessionsUnknownDevice: "Unknown device", + sessionsCurrentDevice: "This device", + sessionsMeta: "Last active {lastSeen} · Expires {expires}", + sessionsRevoke: "Sign out", + sessionsRevoked: "Session signed out.", + sessionsRevokeOthers: "Sign out all other devices", + sessionsRevokedOthers: "All other sessions have been signed out.", + sessionsRevokeOthersConfirmTitle: "Sign out all other devices?", + sessionsRevokeOthersConfirmBody: "Every other session for your account will be signed out immediately. This device stays signed in.", cropDialogTitle: "Crop profile image", cropDialogBody: "Position and zoom your image. The saved avatar will be exported as a 512×512 square.", cropDialogZoom: "Zoom", @@ -1393,6 +1404,17 @@ export const translations = { twoFactorTrustedDevicesRevokedAll: "Alle betrodde enheter er logget ut.", twoFactorRevokeAllConfirmTitle: "Logg ut alle betrodde enheter?", twoFactorRevokeAllConfirmBody: "Du vil bli bedt om en 2FA-kode neste gang du logger inn på en enhet, inkludert denne.", + sessionsSectionTitle: "Økter", + sessionsEmpty: "Ingen aktive økter.", + sessionsUnknownDevice: "Ukjent enhet", + sessionsCurrentDevice: "Denne enheten", + sessionsMeta: "Sist aktiv {lastSeen} · Utløper {expires}", + sessionsRevoke: "Logg ut", + sessionsRevoked: "Økten er logget ut.", + sessionsRevokeOthers: "Logg ut alle andre enheter", + sessionsRevokedOthers: "Alle andre økter er logget ut.", + sessionsRevokeOthersConfirmTitle: "Logg ut alle andre enheter?", + sessionsRevokeOthersConfirmBody: "Alle andre økter for kontoen din blir umiddelbart logget ut. Denne enheten forblir innlogget.", cropDialogTitle: "Beskjær profilbilde", cropDialogBody: "Plasser og zoom bildet. Det lagrede avataren eksporteres som en kvadratisk 512×512-fil.", cropDialogZoom: "Zoom", diff --git a/job-tracker-ui/src/views/ProfilePage.tsx b/job-tracker-ui/src/views/ProfilePage.tsx index 1a4fbfe..504c1cc 100644 --- a/job-tracker-ui/src/views/ProfilePage.tsx +++ b/job-tracker-ui/src/views/ProfilePage.tsx @@ -12,6 +12,7 @@ import GoogleAuthCard from "../components/GoogleAuthCard"; import MicrosoftAuthCard from "../components/MicrosoftAuthCard"; import AuthStatusCard from "../components/AuthStatusCard"; import TwoFactorSettingsCard from "../components/TwoFactorSettingsCard"; +import SessionsSettingsCard from "../components/SessionsSettingsCard"; import EmailProviderConnections from "../components/EmailProviderConnections"; import CropImageDialog from "../components/CropImageDialog"; import { useToast } from "../toast"; @@ -1351,6 +1352,7 @@ export default function ProfilePage() { {isLocal ? : null} + {isLocal ? : null} ); } From fb04088d624afc498880596cf87798e9c1c0447f Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 13 Jul 2026 01:49:25 +0200 Subject: [PATCH 7/7] fix(auth): fix SQLite DateTimeOffset comparison crash in trusted-device checks The sessions unit's live smoke test caught the same bug it fixed in SessionsController also present in TrustedDeviceService and TwoFactorController's device list: SQLite/Pomelo's EF Core provider cannot translate DateTimeOffset relational comparisons or ORDER BY to SQL, so IsDeviceTrustedAsync (the check that skips 2FA for a trusted browser) and ListTrustedDevices would 500 on real SQLite despite passing on EF's InMemory test provider. Same fix: equality-only in the DB query, expiry comparison and sort after materializing. --- JobTrackerApi/Controllers/TwoFactorController.cs | 5 +++-- JobTrackerApi/Services/TrustedDeviceService.cs | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/JobTrackerApi/Controllers/TwoFactorController.cs b/JobTrackerApi/Controllers/TwoFactorController.cs index ec2e02f..059ebcd 100644 --- a/JobTrackerApi/Controllers/TwoFactorController.cs +++ b/JobTrackerApi/Controllers/TwoFactorController.cs @@ -222,13 +222,14 @@ public sealed class TwoFactorController : ControllerBase if (user is null) return Unauthorized(); var currentHash = TrustedDeviceService.CurrentDeviceTokenHash(Request); + // SQLite/Pomelo cannot translate DateTimeOffset ORDER BY to SQL (same issue as the + // expiry check in TrustedDeviceService), so sort after materializing. 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); + return Ok(devices.OrderByDescending(x => x.LastSeenAtUtc).ToList()); } [HttpDelete("trusted-devices/{id:int}")] diff --git a/JobTrackerApi/Services/TrustedDeviceService.cs b/JobTrackerApi/Services/TrustedDeviceService.cs index f3af7ec..1b84c4f 100644 --- a/JobTrackerApi/Services/TrustedDeviceService.cs +++ b/JobTrackerApi/Services/TrustedDeviceService.cs @@ -26,10 +26,13 @@ public static class TrustedDeviceService var hash = HashToken(token); var now = DateTimeOffset.UtcNow; + // SQLite/Pomelo cannot translate DateTimeOffset relational comparisons (>) to SQL, so the + // expiry check has to happen after materializing the row -- fine here since the equality + // filters (UserId, TokenHash) already narrow this to at most one row. var match = await db.TrustedDevices .IgnoreQueryFilters() - .FirstOrDefaultAsync(x => x.UserId == userId && x.TokenHash == hash && x.ExpiresAtUtc > now, cancellationToken); - if (match is null) return false; + .FirstOrDefaultAsync(x => x.UserId == userId && x.TokenHash == hash, cancellationToken); + if (match is null || match.ExpiresAtUtc <= now) return false; match.LastSeenAtUtc = now; await db.SaveChangesAsync(cancellationToken);