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
@@ -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);
}
}