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 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-12 20:48:09 +02:00
parent 0cd1ba398e
commit c68b49eda0
13 changed files with 699 additions and 30 deletions
+10
View File
@@ -28,6 +28,7 @@ namespace JobTrackerApi.Data
public DbSet<CvUploadArtifact> CvUploadArtifacts => Set<CvUploadArtifact>(); public DbSet<CvUploadArtifact> CvUploadArtifacts => Set<CvUploadArtifact>();
public DbSet<CvExtractionRun> CvExtractionRuns => Set<CvExtractionRun>(); public DbSet<CvExtractionRun> CvExtractionRuns => Set<CvExtractionRun>();
public DbSet<TailoredCvDraft> TailoredCvDrafts => Set<TailoredCvDraft>(); public DbSet<TailoredCvDraft> TailoredCvDrafts => Set<TailoredCvDraft>();
public DbSet<TwoFactorRecoveryCode> TwoFactorRecoveryCodes => Set<TwoFactorRecoveryCode>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -141,6 +142,15 @@ namespace JobTrackerApi.Data
.WithOne(j => j.TailoredCvDraft) .WithOne(j => j.TailoredCvDraft)
.HasForeignKey<TailoredCvDraft>(x => x.JobApplicationId) .HasForeignKey<TailoredCvDraft>(x => x.JobApplicationId)
.OnDelete(DeleteBehavior.Cascade); .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<TwoFactorRecoveryCode>()
.HasQueryFilter(x => CurrentUserId != null && x.UserId == CurrentUserId);
modelBuilder.Entity<TwoFactorRecoveryCode>()
.HasIndex(x => new { x.UserId, x.UsedAtUtc });
} }
} }
} }
@@ -17,6 +17,43 @@ namespace JobTrackerApi.Tests;
public sealed class AuthAndSystemControllerTests public sealed class AuthAndSystemControllerTests
{ {
[Fact]
public async Task Login_locks_account_after_five_failed_attempts_and_rejects_sixth_even_with_correct_password()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
var userManager = CreateUserManager();
userManager.Setup(x => x.FindByEmailAsync("person@example.com")).ReturnsAsync(user);
userManager.Setup(x => x.FindByNameAsync("person@example.com")).ReturnsAsync((ApplicationUser?)null);
var failedCount = 0;
var lockedOut = false;
userManager.Setup(x => x.IsLockedOutAsync(user)).Returns(() => Task.FromResult(lockedOut));
userManager.Setup(x => x.AccessFailedAsync(user))
.Callback(() =>
{
failedCount++;
if (failedCount >= 5) lockedOut = true;
})
.ReturnsAsync(IdentityResult.Success);
userManager.Setup(x => x.CheckPasswordAsync(user, "wrong-password")).ReturnsAsync(false);
userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>());
for (var i = 0; i < 5; i++)
{
var attempt = await controller.Login(new AuthController.LoginRequest("person@example.com", "wrong-password"), CancellationToken.None);
Assert.IsType<UnauthorizedResult>(attempt);
}
Assert.True(lockedOut);
var sixthAttempt = await controller.Login(new AuthController.LoginRequest("person@example.com", "correct-password"), CancellationToken.None);
Assert.IsType<UnauthorizedResult>(sixthAttempt);
userManager.Verify(x => x.CheckPasswordAsync(user, "correct-password"), Times.Never);
}
[Fact] [Fact]
public async Task Update_profile_applies_trimmed_profile_fields() public async Task Update_profile_applies_trimmed_profile_fields()
{ {
@@ -25,7 +62,7 @@ public sealed class AuthAndSystemControllerTests
userManager.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user); userManager.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success); userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance); var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>());
var result = await controller.UpdateProfile(new AuthController.UpdateProfileRequest(" new@example.com ", " newuser ", " Ada ", " Lovelace ", " Ada L. ", null, null)); 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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>())) .Setup(x => x.SendAsync(user.Email!, It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("SMTP unavailable")); .ThrowsAsync(new InvalidOperationException("SMTP unavailable"));
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance) var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), emailSender.Object, Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>())
{ {
ControllerContext = new ControllerContext ControllerContext = new ControllerContext
{ {
@@ -91,7 +128,7 @@ public sealed class AuthAndSystemControllerTests
.Setup(x => x.ValidateAsync("google-token", It.IsAny<CancellationToken>())) .Setup(x => x.ValidateAsync("google-token", It.IsAny<CancellationToken>()))
.ReturnsAsync(new GoogleTokenPrincipal("google-subject", "dj@cesnimda.co.uk", true, "Dan", "Jones", "Dan Jones")); .ReturnsAsync(new GoogleTokenPrincipal("google-subject", "dj@cesnimda.co.uk", true, "Dan", "Jones", "Dan Jones"));
var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), googleValidator.Object, Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance) var controller = new AuthController(BuildConfig(), userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), googleValidator.Object, Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>())
{ {
ControllerContext = new ControllerContext ControllerContext = new ControllerContext
{ {
@@ -101,7 +138,7 @@ public sealed class AuthAndSystemControllerTests
var result = await controller.ExchangeGoogleToken(new AuthController.GoogleTokenRequest("google-token"), CancellationToken.None); var result = await controller.ExchangeGoogleToken(new AuthController.GoogleTokenRequest("google-token"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result); var ok = Assert.IsType<OkObjectResult>(result);
var payload = Assert.IsType<AuthController.AuthSessionResult>(ok.Value); var payload = Assert.IsType<AuthController.AuthSessionResult>(ok.Value);
Assert.True(payload.Authenticated); Assert.True(payload.Authenticated);
Assert.Equal("google", payload.Provider); Assert.Equal("google", payload.Provider);
@@ -135,7 +172,7 @@ public sealed class AuthAndSystemControllerTests
.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:AllowRegistration"] = "true" }) .AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:AllowRegistration"] = "true" })
.Build(); .Build();
var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance) var controller = new AuthController(config, userManager.Object, tokenService.Object, Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>())
{ {
ControllerContext = new ControllerContext ControllerContext = new ControllerContext
{ {
@@ -145,7 +182,7 @@ public sealed class AuthAndSystemControllerTests
var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None); var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result); var ok = Assert.IsType<OkObjectResult>(result);
var payload = Assert.IsType<AuthController.AuthSessionResult>(ok.Value); var payload = Assert.IsType<AuthController.AuthSessionResult>(ok.Value);
Assert.True(payload.Authenticated); Assert.True(payload.Authenticated);
Assert.Equal("microsoft", payload.Provider); Assert.Equal("microsoft", payload.Provider);
@@ -166,7 +203,7 @@ public sealed class AuthAndSystemControllerTests
.Setup(x => x.ValidateAsync("microsoft-token", It.IsAny<CancellationToken>())) .Setup(x => x.ValidateAsync("microsoft-token", It.IsAny<CancellationToken>()))
.ReturnsAsync(new MicrosoftTokenPrincipal("ms-subject", "nobody@example.com", true, null, null, null)); .ReturnsAsync(new MicrosoftTokenPrincipal("ms-subject", "nobody@example.com", true, null, null, null));
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance) var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), microsoftValidator.Object, NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>())
{ {
ControllerContext = new ControllerContext ControllerContext = new ControllerContext
{ {
@@ -176,7 +213,7 @@ public sealed class AuthAndSystemControllerTests
var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None); var result = await controller.ExchangeMicrosoftToken(new AuthController.MicrosoftTokenRequest("microsoft-token"), CancellationToken.None);
Assert.IsType<UnauthorizedObjectResult>(result.Result); Assert.IsType<UnauthorizedObjectResult>(result);
userManager.Verify(x => x.CreateAsync(It.IsAny<ApplicationUser>()), Times.Never); userManager.Verify(x => x.CreateAsync(It.IsAny<ApplicationUser>()), Times.Never);
} }
@@ -51,7 +51,7 @@ public sealed class ClientErrorsControllerTests
var userManager = TestHostFactory.CreateUserManager(); var userManager = TestHostFactory.CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user); userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), Mock.Of<ILogger<AuthController>>()) var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), Mock.Of<ILogger<AuthController>>(), Mock.Of<ITwoFactorPendingTokenService>())
{ {
ControllerContext = new ControllerContext ControllerContext = new ControllerContext
{ {
@@ -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<ApplicationUser>> userManager, JobTrackerApi.Data.JobTrackerContext db, ITwoFactorPendingTokenService? pending = null, ApplicationUser? currentUser = null)
{
if (currentUser is not null)
{
userManager.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(currentUser);
}
var tokenService = new Mock<ITokenService>();
tokenService.Setup(x => x.CreateAccessTokenAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>())).ReturnsAsync("app-token");
var controller = new TwoFactorController(
userManager.Object,
tokenService.Object,
db,
pending ?? new TwoFactorPendingTokenService(new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions())),
new EphemeralDataProtectionProvider())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
return controller;
}
[Fact]
public async Task Setup_then_verify_with_correct_code_enables_2fa_and_returns_recovery_codes()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
var userManager = TestHostFactory.CreateUserManager();
userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
using var db = TestHostFactory.CreateInMemoryDb("user-1");
var controller = BuildController(userManager, db, currentUser: user);
var setupResult = Assert.IsType<OkObjectResult>(await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None));
var setup = Assert.IsType<TwoFactorController.SetupResult>(setupResult.Value);
Assert.NotNull(user.TotpPendingSecretEncrypted);
Assert.False(user.TwoFactorEnabled);
var code = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
var verifyResult = Assert.IsType<OkObjectResult>(await controller.VerifySetup(new TwoFactorController.VerifySetupRequest(code), CancellationToken.None));
var verify = Assert.IsType<TwoFactorController.VerifySetupResult>(verifyResult.Value);
Assert.True(verify.Enabled);
Assert.Equal(10, verify.RecoveryCodes.Count);
Assert.True(user.TwoFactorEnabled);
Assert.Null(user.TotpPendingSecretEncrypted);
Assert.NotNull(user.TotpSecretEncrypted);
Assert.NotNull(user.TotpEnabledAtUtc);
}
[Fact]
public async Task Verify_setup_with_wrong_code_is_rejected_and_does_not_enable_2fa()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
var userManager = TestHostFactory.CreateUserManager();
userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
using var db = TestHostFactory.CreateInMemoryDb("user-1");
var controller = BuildController(userManager, db, currentUser: user);
await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None);
var result = await controller.VerifySetup(new TwoFactorController.VerifySetupRequest("000000"), CancellationToken.None);
Assert.IsType<UnauthorizedResult>(result);
Assert.False(user.TwoFactorEnabled);
}
[Fact]
public async Task Disable_with_wrong_password_is_rejected()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com", TwoFactorEnabled = true, TotpSecretEncrypted = "irrelevant" };
var userManager = TestHostFactory.CreateUserManager();
userManager.Setup(x => x.CheckPasswordAsync(user, "wrong-password")).ReturnsAsync(false);
using var db = TestHostFactory.CreateInMemoryDb("user-1");
var controller = BuildController(userManager, db, currentUser: user);
var result = await controller.Disable(new TwoFactorController.PasswordConfirmRequest("wrong-password"), CancellationToken.None);
Assert.IsType<BadRequestObjectResult>(result);
Assert.True(user.TwoFactorEnabled);
Assert.NotNull(user.TotpSecretEncrypted);
}
[Fact]
public async Task Challenge_with_valid_totp_code_completes_sign_in()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
var userManager = TestHostFactory.CreateUserManager();
userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
userManager.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user);
var pending = new TwoFactorPendingTokenService(new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
using var db = TestHostFactory.CreateInMemoryDb("user-1");
var controller = BuildController(userManager, db, pending, currentUser: user);
var setupResult = Assert.IsType<OkObjectResult>(await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None));
var setup = Assert.IsType<TwoFactorController.SetupResult>(setupResult.Value);
var code = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
await controller.VerifySetup(new TwoFactorController.VerifySetupRequest(code), CancellationToken.None);
var pendingToken = pending.IssuePendingToken("user-1", rememberMe: false);
var challengeCode = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
var challengeResult = Assert.IsType<OkObjectResult>(await controller.Challenge(new TwoFactorController.ChallengeRequest(pendingToken, challengeCode), CancellationToken.None));
var session = Assert.IsType<AuthController.AuthSessionResult>(challengeResult.Value);
Assert.True(session.Authenticated);
// The pending token is single-use.
var reuse = await controller.Challenge(new TwoFactorController.ChallengeRequest(pendingToken, challengeCode), CancellationToken.None);
Assert.IsType<UnauthorizedResult>(reuse);
}
[Fact]
public async Task Challenge_with_recovery_code_consumes_it_and_rejects_reuse()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
var userManager = TestHostFactory.CreateUserManager();
userManager.Setup(x => x.CheckPasswordAsync(user, "correct-password")).ReturnsAsync(true);
userManager.Setup(x => x.UpdateAsync(It.IsAny<ApplicationUser>())).ReturnsAsync(IdentityResult.Success);
userManager.Setup(x => x.FindByIdAsync("user-1")).ReturnsAsync(user);
var pending = new TwoFactorPendingTokenService(new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
using var db = TestHostFactory.CreateInMemoryDb("user-1");
var controller = BuildController(userManager, db, pending, currentUser: user);
var setupResult = Assert.IsType<OkObjectResult>(await controller.Setup(new TwoFactorController.PasswordConfirmRequest("correct-password"), CancellationToken.None));
var setup = Assert.IsType<TwoFactorController.SetupResult>(setupResult.Value);
var code = new Totp(Base32Encoding.ToBytes(setup.ManualEntryKey)).ComputeTotp();
var verifyResult = Assert.IsType<OkObjectResult>(await controller.VerifySetup(new TwoFactorController.VerifySetupRequest(code), CancellationToken.None));
var verify = Assert.IsType<TwoFactorController.VerifySetupResult>(verifyResult.Value);
var recoveryCode = verify.RecoveryCodes[0];
var pendingToken1 = pending.IssuePendingToken("user-1", rememberMe: false);
var challengeResult = Assert.IsType<OkObjectResult>(await controller.Challenge(new TwoFactorController.ChallengeRequest(pendingToken1, recoveryCode), CancellationToken.None));
Assert.True(Assert.IsType<AuthController.AuthSessionResult>(challengeResult.Value).Authenticated);
// Same recovery code can't be used a second time, even against a fresh pending token.
var pendingToken2 = pending.IssuePendingToken("user-1", rememberMe: false);
var reuse = await controller.Challenge(new TwoFactorController.ChallengeRequest(pendingToken2, recoveryCode), CancellationToken.None);
Assert.IsType<UnauthorizedResult>(reuse);
}
[Fact]
public async Task Challenge_with_unknown_pending_token_is_rejected()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
var userManager = TestHostFactory.CreateUserManager();
using var db = TestHostFactory.CreateInMemoryDb("user-1");
var controller = BuildController(userManager, db, currentUser: user);
var result = await controller.Challenge(new TwoFactorController.ChallengeRequest("not-a-real-token", "123456"), CancellationToken.None);
Assert.IsType<UnauthorizedResult>(result);
}
}
+38 -20
View File
@@ -21,8 +21,9 @@ public sealed class AuthController : ControllerBase
private readonly IGoogleTokenValidator _googleTokens; private readonly IGoogleTokenValidator _googleTokens;
private readonly IMicrosoftTokenValidator _microsoftTokens; private readonly IMicrosoftTokenValidator _microsoftTokens;
private readonly ILogger<AuthController> _logger; private readonly ILogger<AuthController> _logger;
private readonly ITwoFactorPendingTokenService _twoFactorPending;
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger<AuthController> logger) public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger<AuthController> logger, ITwoFactorPendingTokenService twoFactorPending)
{ {
_cfg = cfg; _cfg = cfg;
_users = users; _users = users;
@@ -31,6 +32,7 @@ public sealed class AuthController : ControllerBase
_googleTokens = googleTokens; _googleTokens = googleTokens;
_microsoftTokens = microsoftTokens; _microsoftTokens = microsoftTokens;
_logger = logger; _logger = logger;
_twoFactorPending = twoFactorPending;
} }
[HttpGet("config")] [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 LoginRequest(string Email, string Password, bool RememberMe = true);
public sealed record RegisterRequest(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 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 GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
public sealed record MicrosoftLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt); public sealed record MicrosoftLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
public sealed record MeResult( public sealed record MeResult(
@@ -83,7 +86,7 @@ public sealed class AuthController : ControllerBase
[HttpPost("login")] [HttpPost("login")]
[AllowAnonymous] [AllowAnonymous]
[EnableRateLimiting("auth-login")] [EnableRateLimiting("auth-login")]
public async Task<ActionResult<AuthSessionResult>> Login([FromBody] LoginRequest request, CancellationToken cancellationToken) public async Task<IActionResult> Login([FromBody] LoginRequest request, CancellationToken cancellationToken)
{ {
var email = (request.Email ?? string.Empty).Trim(); var email = (request.Email ?? string.Empty).Trim();
var password = request.Password ?? string.Empty; 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); var user = await _users.FindByEmailAsync(email) ?? await _users.FindByNameAsync(email);
if (user is null) return Unauthorized(); if (user is null) return Unauthorized();
var ok = await _users.CheckPasswordAsync(user, password); // Same generic 401 whether the account doesn't exist, is locked out, or the password is
if (!ok) return Unauthorized(); // 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); var ok = await _users.CheckPasswordAsync(user, password);
return Ok(new AuthSessionResult(true, "local")); if (!ok)
{
await _users.AccessFailedAsync(user);
return Unauthorized();
}
await _users.ResetAccessFailedCountAsync(user);
return await CompleteSignInAsync(user, request.RememberMe, "local", cancellationToken);
} }
[HttpPost("register")] [HttpPost("register")]
[AllowAnonymous] [AllowAnonymous]
[EnableRateLimiting("auth-login")] [EnableRateLimiting("auth-login")]
public async Task<ActionResult<AuthSessionResult>> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken) public async Task<IActionResult> Register([FromBody] RegisterRequest request, CancellationToken cancellationToken)
{ {
var allow = _cfg.GetValue("Auth:AllowRegistration", false); var allow = _cfg.GetValue("Auth:AllowRegistration", false);
if (!allow) return StatusCode(403, "Registration is disabled."); 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))); return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
} }
await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken); return await CompleteSignInAsync(user, request.RememberMe, "local", cancellationToken);
return Ok(new AuthSessionResult(true, "local"));
} }
[HttpPost("google/exchange")] [HttpPost("google/exchange")]
[AllowAnonymous] [AllowAnonymous]
[EnableRateLimiting("auth-login")] [EnableRateLimiting("auth-login")]
public async Task<ActionResult<AuthSessionResult>> ExchangeGoogleToken([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken) public async Task<IActionResult> ExchangeGoogleToken([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken)
{ {
var token = (request.Token ?? string.Empty).Trim(); var token = (request.Token ?? string.Empty).Trim();
if (token.Length == 0) return BadRequest("Google token is required."); if (token.Length == 0) return BadRequest("Google token is required.");
@@ -193,14 +204,13 @@ public sealed class AuthController : ControllerBase
await _users.UpdateAsync(user); await _users.UpdateAsync(user);
} }
await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken); return await CompleteSignInAsync(user, request.RememberMe, "google", cancellationToken);
return Ok(new AuthSessionResult(true, "google"));
} }
[HttpPost("microsoft/exchange")] [HttpPost("microsoft/exchange")]
[AllowAnonymous] [AllowAnonymous]
[EnableRateLimiting("auth-login")] [EnableRateLimiting("auth-login")]
public async Task<ActionResult<AuthSessionResult>> ExchangeMicrosoftToken([FromBody] MicrosoftTokenRequest request, CancellationToken cancellationToken) public async Task<IActionResult> ExchangeMicrosoftToken([FromBody] MicrosoftTokenRequest request, CancellationToken cancellationToken)
{ {
var token = (request.Token ?? string.Empty).Trim(); var token = (request.Token ?? string.Empty).Trim();
if (token.Length == 0) return BadRequest("Microsoft token is required."); if (token.Length == 0) return BadRequest("Microsoft token is required.");
@@ -261,8 +271,7 @@ public sealed class AuthController : ControllerBase
await _users.UpdateAsync(user); await _users.UpdateAsync(user);
} }
await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken); return await CompleteSignInAsync(user, request.RememberMe, "microsoft", cancellationToken);
return Ok(new AuthSessionResult(true, "microsoft"));
} }
[HttpPost("logout")] [HttpPost("logout")]
@@ -655,12 +664,21 @@ public sealed class AuthController : ControllerBase
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: detail); 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<IActionResult> CompleteSignInAsync(ApplicationUser user, bool rememberMe, string provider, CancellationToken cancellationToken)
{ {
var token = await _tokens.CreateAccessTokenAsync(user, cancellationToken); if (user.TwoFactorEnabled)
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 pendingToken = _twoFactorPending.IssuePendingToken(user.Id, rememberMe);
EnsureCsrfCookie(rememberMe, secure); 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) private void EnsureCsrfCookie(bool persistent, bool? secureOverride = null)
@@ -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<ApplicationUser> _users;
private readonly ITokenService _tokens;
private readonly JobTrackerContext _db;
private readonly ITwoFactorPendingTokenService _pending;
private readonly IDataProtector _protector;
public TwoFactorController(UserManager<ApplicationUser> 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<string> RecoveryCodes);
public sealed record StatusResult(bool Enabled, DateTimeOffset? EnabledAtUtc);
public sealed record RecoveryCodesResult(IReadOnlyList<string> RecoveryCodes);
public sealed record ChallengeRequest(string PendingToken, string Code);
[HttpPost("setup")]
[Authorize(AuthenticationSchemes = "local")]
[EnableRateLimiting("auth-login")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<bool> 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<IReadOnlyList<string>> RegenerateRecoveryCodesAsync(string userId, CancellationToken cancellationToken)
{
await RemoveAllRecoveryCodesAsync(userId, cancellationToken);
var now = DateTimeOffset.UtcNow;
var plainCodes = new List<string>(RecoveryCodeCount);
var rows = new List<TwoFactorRecoveryCode>(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();
}
}
+17
View File
@@ -183,12 +183,16 @@ builder.Services.AddIdentityCore<ApplicationUser>(options =>
options.Password.RequireUppercase = false; options.Password.RequireUppercase = false;
options.Password.RequireNonAlphanumeric = false; options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredLength = 8; options.Password.RequiredLength = 8;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.AllowedForNewUsers = true;
}) })
.AddRoles<IdentityRole>() .AddRoles<IdentityRole>()
.AddEntityFrameworkStores<JobTrackerContext>() .AddEntityFrameworkStores<JobTrackerContext>()
.AddSignInManager(); .AddSignInManager();
builder.Services.AddScoped<ITokenService, TokenService>(); builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddSingleton<ITwoFactorPendingTokenService, TwoFactorPendingTokenService>();
builder.Services.AddSingleton<UniversalJobParser>(); builder.Services.AddSingleton<UniversalJobParser>();
builder.Services.AddSingleton<IHostAddressResolver, DnsHostAddressResolver>(); builder.Services.AddSingleton<IHostAddressResolver, DnsHostAddressResolver>();
@@ -378,6 +382,19 @@ builder.Services.AddRateLimiter(options =>
QueueProcessingOrder = QueueProcessingOrder.OldestFirst, QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0, 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(); var app = builder.Build();
@@ -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));
}
}
@@ -244,6 +244,9 @@ public static class StartupInitializationExtensions
`MicrosoftSubject` longtext NULL, `MicrosoftSubject` longtext NULL,
`MicrosoftEmail` longtext NULL, `MicrosoftEmail` longtext NULL,
`MicrosoftLinkedAt` datetime(6) NULL, `MicrosoftLinkedAt` datetime(6) NULL,
`TotpSecretEncrypted` longtext NULL,
`TotpPendingSecretEncrypted` longtext NULL,
`TotpEnabledAtUtc` datetime(6) NULL,
PRIMARY KEY (`Id`) PRIMARY KEY (`Id`)
) CHARACTER SET=utf8mb4; ) CHARACTER SET=utf8mb4;
@@ -359,7 +362,10 @@ public static class StartupInitializationExtensions
"GoogleLinkedAt" TEXT NULL, "GoogleLinkedAt" TEXT NULL,
"MicrosoftSubject" TEXT NULL, "MicrosoftSubject" TEXT NULL,
"MicrosoftEmail" 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", "MicrosoftSubject", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftSubject TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "MicrosoftEmail", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftEmail 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", "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) 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");"""); 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); EnsureGmailConnectionsTable(conn);
EnsureMicrosoftGraphConnectionsTable(conn); EnsureMicrosoftGraphConnectionsTable(conn);
EnsureImapConnectionsTable(conn); EnsureImapConnectionsTable(conn);
EnsureCvTables(conn); EnsureCvTables(conn);
EnsureTwoFactorRecoveryCodesTable(conn);
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded, // Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
// and at least one of the new columns already exists. // 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", "MicrosoftSubject", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftSubject` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftEmail` 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", "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")) if (!HasMySqlTable(conn, "RuleSettings"))
{ {
@@ -977,6 +1005,29 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery(); 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")) if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
{ {
using var cmd = conn.CreateCommand(); using var cmd = conn.CreateCommand();
@@ -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}";
}
@@ -27,6 +27,8 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.14" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.14" />
<PackageReference Include="Otp.NET" Version="1.4.1" />
<PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.14.0" /> <PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.14.0" />
<PackageReference Include="MailKit" Version="4.17.0" /> <PackageReference Include="MailKit" Version="4.17.0" />
+3
View File
@@ -19,4 +19,7 @@ public sealed class ApplicationUser : IdentityUser
public string? MicrosoftSubject { get; set; } public string? MicrosoftSubject { get; set; }
public string? MicrosoftEmail { get; set; } public string? MicrosoftEmail { get; set; }
public DateTimeOffset? MicrosoftLinkedAt { get; set; } public DateTimeOffset? MicrosoftLinkedAt { get; set; }
public string? TotpSecretEncrypted { get; set; }
public string? TotpPendingSecretEncrypted { get; set; }
public DateTimeOffset? TotpEnabledAtUtc { get; set; }
} }
+12
View File
@@ -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; }
}