Files
jobtrackingapp/JobTrackerApi.Tests/ClientErrorsControllerTests.cs
T
cesnimda c68b49eda0 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>
2026-07-12 20:48:09 +02:00

104 lines
4.1 KiB
C#

using System.Security.Claims;
using System.Text;
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class ClientErrorsControllerTests
{
[Fact]
public void Report_logs_sanitized_payload_instead_of_raw_stacks()
{
var logger = new ListLogger<ClientErrorsController>();
var controller = new ClientErrorsController(logger);
var stack = "TypeError: bad\n at render(App.tsx:10)\nextra-secret-line";
var componentStack = "at Widget\n at Dashboard";
var result = controller.Report(new ClientErrorsController.ClientErrorReport(
ErrorId: " err-1 ",
Message: " boom ",
Stack: stack,
ComponentStack: componentStack,
Url: " https://jobtracker.test/jobs ",
UserAgent: " Browser\nAgent ",
At: " 2026-04-10T18:00:00Z "));
Assert.IsType<NoContentResult>(result);
var entry = Assert.Single(logger.Entries);
Assert.Contains("stackHash=", entry.Message);
Assert.Contains("componentHash=", entry.Message);
Assert.Contains("TypeError: bad | at render(App.tsx:10)", entry.Message);
Assert.DoesNotContain(stack, entry.Message);
Assert.DoesNotContain(componentStack, entry.Message);
Assert.DoesNotContain("extra-secret-line", entry.Message);
Assert.DoesNotContain("Browser\nAgent", entry.Message);
}
[Fact]
public async Task Upload_avatar_rejects_file_when_extension_or_detected_bytes_are_not_supported()
{
var user = new ApplicationUser { Id = "user-1", Email = "person@example.com", UserName = "person@example.com" };
var userManager = TestHostFactory.CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var controller = new AuthController(BuildConfig(), userManager.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), Mock.Of<ILogger<AuthController>>(), Mock.Of<ITwoFactorPendingTokenService>())
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "user-1") }, "local")) }
}
};
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes("not really a png"));
IFormFile file = new FormFile(stream, 0, stream.Length, "file", "avatar.png")
{
Headers = new HeaderDictionary(),
ContentType = "image/png"
};
var result = await controller.UploadAvatar(file);
var badRequest = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Only PNG, JPEG, or WebP images are supported.", badRequest.Value);
userManager.Verify(x => x.UpdateAsync(It.IsAny<ApplicationUser>()), Times.Never);
}
private static IConfiguration BuildConfig()
{
return new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
}
private sealed class ListLogger<T> : ILogger<T>
{
public List<LogEntry> Entries { get; } = new();
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
Entries.Add(new LogEntry(logLevel, formatter(state, exception)));
}
}
private sealed record LogEntry(LogLevel Level, string Message);
private sealed class NullScope : IDisposable
{
public static NullScope Instance { get; } = new();
public void Dispose() { }
}
}