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:
@@ -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,
|
||||
`MicrosoftEmail` longtext NULL,
|
||||
`MicrosoftLinkedAt` datetime(6) NULL,
|
||||
`TotpSecretEncrypted` longtext NULL,
|
||||
`TotpPendingSecretEncrypted` longtext NULL,
|
||||
`TotpEnabledAtUtc` datetime(6) NULL,
|
||||
PRIMARY KEY (`Id`)
|
||||
) CHARACTER SET=utf8mb4;
|
||||
|
||||
@@ -359,7 +362,10 @@ public static class StartupInitializationExtensions
|
||||
"GoogleLinkedAt" TEXT NULL,
|
||||
"MicrosoftSubject" TEXT NULL,
|
||||
"MicrosoftEmail" TEXT NULL,
|
||||
"MicrosoftLinkedAt" TEXT NULL
|
||||
"MicrosoftLinkedAt" TEXT NULL,
|
||||
"TotpSecretEncrypted" TEXT NULL,
|
||||
"TotpPendingSecretEncrypted" TEXT NULL,
|
||||
"TotpEnabledAtUtc" TEXT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
@@ -440,6 +446,9 @@ public static class StartupInitializationExtensions
|
||||
EnsureColumn(conn, "AspNetUsers", "MicrosoftSubject", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftSubject TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "MicrosoftEmail", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftEmail TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "MicrosoftLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftLinkedAt TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "TotpSecretEncrypted", "ALTER TABLE AspNetUsers ADD COLUMN TotpSecretEncrypted TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "TotpPendingSecretEncrypted", "ALTER TABLE AspNetUsers ADD COLUMN TotpPendingSecretEncrypted TEXT NULL;");
|
||||
EnsureColumn(conn, "AspNetUsers", "TotpEnabledAtUtc", "ALTER TABLE AspNetUsers ADD COLUMN TotpEnabledAtUtc TEXT NULL;");
|
||||
|
||||
static void EnsureUserRuleSettingsTable(DbConnection c)
|
||||
{
|
||||
@@ -623,10 +632,26 @@ public static class StartupInitializationExtensions
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_JobApplicationId" ON "TailoredCvDrafts" ("JobApplicationId");""");
|
||||
}
|
||||
|
||||
static void EnsureTwoFactorRecoveryCodesTable(DbConnection c)
|
||||
{
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "TwoFactorRecoveryCodes" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_TwoFactorRecoveryCodes" PRIMARY KEY AUTOINCREMENT,
|
||||
"UserId" TEXT NOT NULL,
|
||||
"CodeHash" TEXT NOT NULL,
|
||||
"CreatedAtUtc" TEXT NOT NULL,
|
||||
"UsedAtUtc" TEXT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc" ON "TwoFactorRecoveryCodes" ("UserId", "UsedAtUtc");""");
|
||||
}
|
||||
|
||||
EnsureGmailConnectionsTable(conn);
|
||||
EnsureMicrosoftGraphConnectionsTable(conn);
|
||||
EnsureImapConnectionsTable(conn);
|
||||
EnsureCvTables(conn);
|
||||
EnsureTwoFactorRecoveryCodesTable(conn);
|
||||
|
||||
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
|
||||
// and at least one of the new columns already exists.
|
||||
@@ -769,6 +794,9 @@ public static class StartupInitializationExtensions
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftSubject", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftSubject` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftEmail` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftLinkedAt", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftLinkedAt` datetime NULL;");
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "TotpSecretEncrypted", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpSecretEncrypted` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "TotpPendingSecretEncrypted", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpPendingSecretEncrypted` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "TotpEnabledAtUtc", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpEnabledAtUtc` datetime NULL;");
|
||||
|
||||
if (!HasMySqlTable(conn, "RuleSettings"))
|
||||
{
|
||||
@@ -977,6 +1005,29 @@ public static class StartupInitializationExtensions
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!HasMySqlTable(conn, "TwoFactorRecoveryCodes"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TwoFactorRecoveryCodes` (
|
||||
`Id` int NOT NULL AUTO_INCREMENT,
|
||||
`UserId` varchar(255) NOT NULL,
|
||||
`CodeHash` varchar(255) NOT NULL,
|
||||
`CreatedAtUtc` datetime(6) NOT NULL,
|
||||
`UsedAtUtc` datetime(6) NULL,
|
||||
PRIMARY KEY (`Id`)
|
||||
);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "TwoFactorRecoveryCodes", "Id");
|
||||
|
||||
if (!MySqlIndexExists(conn, "TwoFactorRecoveryCodes", "IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE INDEX `IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc` ON `TwoFactorRecoveryCodes` (`UserId`, `UsedAtUtc`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
Reference in New Issue
Block a user