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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user