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