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; } +}