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