c68b49eda0
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>
48 lines
1.9 KiB
C#
48 lines
1.9 KiB
C#
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}";
|
|
}
|