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
+38 -20
View File
@@ -21,8 +21,9 @@ public sealed class AuthController : ControllerBase
private readonly IGoogleTokenValidator _googleTokens;
private readonly IMicrosoftTokenValidator _microsoftTokens;
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;
_users = users;
@@ -31,6 +32,7 @@ public sealed class AuthController : ControllerBase
_googleTokens = googleTokens;
_microsoftTokens = microsoftTokens;
_logger = logger;
_twoFactorPending = twoFactorPending;
}
[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 RegisterRequest(string Email, string Password, bool RememberMe = true);
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 MicrosoftLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
public sealed record MeResult(
@@ -83,7 +86,7 @@ public sealed class AuthController : ControllerBase
[HttpPost("login")]
[AllowAnonymous]
[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 password = request.Password ?? string.Empty;
@@ -94,17 +97,26 @@ public sealed class AuthController : ControllerBase
var user = await _users.FindByEmailAsync(email) ?? await _users.FindByNameAsync(email);
if (user is null) return Unauthorized();
var ok = await _users.CheckPasswordAsync(user, password);
if (!ok) return Unauthorized();
// Same generic 401 whether the account doesn't exist, is locked out, or the password is
// 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);
return Ok(new AuthSessionResult(true, "local"));
var ok = await _users.CheckPasswordAsync(user, password);
if (!ok)
{
await _users.AccessFailedAsync(user);
return Unauthorized();
}
await _users.ResetAccessFailedCountAsync(user);
return await CompleteSignInAsync(user, request.RememberMe, "local", cancellationToken);
}
[HttpPost("register")]
[AllowAnonymous]
[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);
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)));
}
await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken);
return Ok(new AuthSessionResult(true, "local"));
return await CompleteSignInAsync(user, request.RememberMe, "local", cancellationToken);
}
[HttpPost("google/exchange")]
[AllowAnonymous]
[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();
if (token.Length == 0) return BadRequest("Google token is required.");
@@ -193,14 +204,13 @@ public sealed class AuthController : ControllerBase
await _users.UpdateAsync(user);
}
await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken);
return Ok(new AuthSessionResult(true, "google"));
return await CompleteSignInAsync(user, request.RememberMe, "google", cancellationToken);
}
[HttpPost("microsoft/exchange")]
[AllowAnonymous]
[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();
if (token.Length == 0) return BadRequest("Microsoft token is required.");
@@ -261,8 +271,7 @@ public sealed class AuthController : ControllerBase
await _users.UpdateAsync(user);
}
await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken);
return Ok(new AuthSessionResult(true, "microsoft"));
return await CompleteSignInAsync(user, request.RememberMe, "microsoft", cancellationToken);
}
[HttpPost("logout")]
@@ -655,12 +664,21 @@ public sealed class AuthController : ControllerBase
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);
var secure = Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase);
Response.Cookies.Append(AuthSessionOptions.SessionCookieName, token, AuthSessionOptions.BuildSessionCookie(rememberMe, secure));
EnsureCsrfCookie(rememberMe, secure);
if (user.TwoFactorEnabled)
{
var pendingToken = _twoFactorPending.IssuePendingToken(user.Id, rememberMe);
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)
@@ -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();
}
}