using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Auth; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Shouldly; namespace Cleanuparr.Api.Tests.Features.Auth; /// /// Tests that 2FA disable and regenerate accept a recovery code. /// [Collection("Auth Integration Tests")] [TestCaseOrderer("Cleanuparr.Api.Tests.PriorityOrderer", "Cleanuparr.Api.Tests")] public class AccountControllerTwoFactorTests : IClassFixture { private const string Username = "twofaadmin"; private const string Password = "TwoFactorPassword123!"; private readonly CustomWebApplicationFactory _factory; private readonly HttpClient _client; private static string? _accessToken; private static string _secret = ""; private static List _recoveryCodes = []; public AccountControllerTwoFactorTests(CustomWebApplicationFactory factory) { _factory = factory; _client = factory.CreateClient(); if (_accessToken is not null) { _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken); } } [Fact, TestPriority(0)] public async Task Setup_CreateAccountAndLogin() { var createResponse = await _client.PostAsJsonAsync("/api/auth/setup/account", new { username = Username, password = Password }); createResponse.StatusCode.ShouldBe(HttpStatusCode.Created); var completeResponse = await _client.PostAsJsonAsync("/api/auth/setup/complete", new { }); completeResponse.StatusCode.ShouldBe(HttpStatusCode.OK); var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { username = Username, password = Password }); loginResponse.StatusCode.ShouldBe(HttpStatusCode.OK); var body = await loginResponse.Content.ReadFromJsonAsync(); _accessToken = body.GetProperty("tokens").GetProperty("accessToken").GetString(); _accessToken.ShouldNotBeNullOrEmpty(); _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken); } [Fact, TestPriority(1)] public async Task Enable2fa_WithGeneratedTotpCode_TurnsTwoFactorOn() { await EnableTwoFactor(); (await IsTwoFactorEnabled()).ShouldBeTrue(); } [Fact, TestPriority(2)] public async Task Disable2fa_WithRecoveryCode_Succeeds() { var response = await _client.PostAsJsonAsync("/api/account/2fa/disable", new { password = Password, totpCode = _recoveryCodes[0] }); response.StatusCode.ShouldBe(HttpStatusCode.OK); (await IsTwoFactorEnabled()).ShouldBeFalse(); } [Fact, TestPriority(3)] public async Task Regenerate2fa_WithRecoveryCode_RotatesSecretAndCodes() { await EnableTwoFactor(); var previousSecret = _secret; var previousCodes = _recoveryCodes; var response = await _client.PostAsJsonAsync("/api/account/2fa/regenerate", new { password = Password, totpCode = previousCodes[0] }); response.StatusCode.ShouldBe(HttpStatusCode.OK); var body = await response.Content.ReadFromJsonAsync(); _secret = body.GetProperty("secret").GetString()!; _recoveryCodes = ReadRecoveryCodes(body); _secret.ShouldNotBe(previousSecret); _recoveryCodes.Count.ShouldBe(10); _recoveryCodes.ShouldNotContain(previousCodes[0]); (await ReadUpdatedAt()).ShouldBe(_factory.Clock.GetUtcNow()); } [Fact, TestPriority(4)] public async Task Disable2fa_WithCodeFromRegeneratedBatch_Succeeds() { var response = await _client.PostAsJsonAsync("/api/account/2fa/disable", new { password = Password, totpCode = _recoveryCodes[0] }); response.StatusCode.ShouldBe(HttpStatusCode.OK); (await IsTwoFactorEnabled()).ShouldBeFalse(); } [Fact, TestPriority(5)] public async Task Disable2fa_WithRecoveryCodeAlreadyConsumedAtLogin_IsRejected() { await EnableTwoFactor(); var anonymousClient = _factory.CreateClient(); var loginResponse = await anonymousClient.PostAsJsonAsync("/api/auth/login", new { username = Username, password = Password }); loginResponse.StatusCode.ShouldBe(HttpStatusCode.OK); var loginBody = await loginResponse.Content.ReadFromJsonAsync(); loginBody.GetProperty("requiresTwoFactor").GetBoolean().ShouldBeTrue(); var twoFactorResponse = await anonymousClient.PostAsJsonAsync("/api/auth/login/2fa", new { loginToken = loginBody.GetProperty("loginToken").GetString(), code = _recoveryCodes[0], isRecoveryCode = true }); twoFactorResponse.StatusCode.ShouldBe(HttpStatusCode.OK); var disableResponse = await _client.PostAsJsonAsync("/api/account/2fa/disable", new { password = Password, totpCode = _recoveryCodes[0] }); disableResponse.StatusCode.ShouldBe(HttpStatusCode.BadRequest); (await IsTwoFactorEnabled()).ShouldBeTrue(); await ClearLockout(); } [Fact, TestPriority(6)] public async Task Disable2fa_WithUnknownCode_IsRejected() { var response = await _client.PostAsJsonAsync("/api/account/2fa/disable", new { password = Password, totpCode = "ZZZZ-ZZZZ" }); response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); (await IsTwoFactorEnabled()).ShouldBeTrue(); await ClearLockout(); } [Fact, TestPriority(7)] public async Task Regenerate2fa_WithUnknownCode_IsRejected() { HttpResponseMessage response = await _client.PostAsJsonAsync("/api/account/2fa/regenerate", new { password = Password, totpCode = "ZZZZ-ZZZZ" }); response.StatusCode.ShouldBe(HttpStatusCode.BadRequest); (await IsTwoFactorEnabled()).ShouldBeTrue(); await ClearLockout(); } [Fact, TestPriority(8)] public async Task Disable2fa_WithTotpCode_StillSucceeds() { var response = await _client.PostAsJsonAsync("/api/account/2fa/disable", new { password = Password, totpCode = TotpTestHelper.GenerateTotpCode(_secret) }); response.StatusCode.ShouldBe(HttpStatusCode.OK); (await IsTwoFactorEnabled()).ShouldBeFalse(); } [Fact, TestPriority(9)] public async Task Disable2fa_WithRepeatedBadCodes_EventuallyRateLimits() { await EnableTwoFactor(); try { var first = await _client.PostAsJsonAsync("/api/account/2fa/disable", new { password = Password, totpCode = "ZZZZ-ZZZZ" }); first.StatusCode.ShouldBe(HttpStatusCode.BadRequest); var firstBody = await first.Content.ReadFromJsonAsync(); firstBody.GetProperty("retryAfterSeconds").GetInt32().ShouldBeGreaterThan(0); var second = await _client.PostAsJsonAsync("/api/account/2fa/disable", new { password = Password, totpCode = "ZZZZ-ZZZZ" }); second.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests); (await IsTwoFactorEnabled()).ShouldBeTrue(); } finally { await ClearLockout(); } } [Fact, TestPriority(10)] public async Task Disable2fa_AfterLockoutCleared_ResetsTheCounterOnSuccess() { await SeedFailedAttempts(3); var response = await _client.PostAsJsonAsync("/api/account/2fa/disable", new { password = Password, totpCode = TotpTestHelper.GenerateTotpCode(_secret) }); response.StatusCode.ShouldBe(HttpStatusCode.OK); (await IsTwoFactorEnabled()).ShouldBeFalse(); using var scope = _factory.Services.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); var user = await context.Users.FirstAsync(); user.FailedLoginAttempts.ShouldBe(0); user.LockoutEnd.ShouldBeNull(); } [Fact, TestPriority(11)] public async Task Login_WithTwoFactorEnabled_KeepsTheFailedAttemptCounter() { await EnableTwoFactor(); try { await SeedFailedAttempts(3); await RequestLoginToken(); (await ReadFailedAttempts()).ShouldBe(3); } finally { await ClearLockout(); await DisableTwoFactor(); } } [Fact, TestPriority(12)] public async Task Login_WithConcurrentWrongPasswords_LocksOutTheSecondRequest() { await ClearLockout(); try { HttpResponseMessage[] responses = await Task.WhenAll( _factory.CreateClient().PostAsJsonAsync("/api/auth/login", new { username = Username, password = "WrongPassword123!" }), _factory.CreateClient().PostAsJsonAsync("/api/auth/login", new { username = Username, password = "WrongPassword123!" })); responses.Count(response => response.StatusCode is HttpStatusCode.Unauthorized).ShouldBe(1); responses.Count(response => response.StatusCode is HttpStatusCode.TooManyRequests).ShouldBe(1); } finally { await ClearLockout(); } } [Fact, TestPriority(20)] public async Task Regenerate2fa_WhenIssuedConcurrently_AppliesOnce() { await EnableTwoFactor(); string sharedCode = _recoveryCodes[0]; const int attempts = 8; HttpResponseMessage[] responses = await Task.WhenAll( Enumerable.Range(0, attempts).Select(_ => _client.PostAsJsonAsync("/api/account/2fa/regenerate", new { password = Password, totpCode = sharedCode }))); // Losers are rejected as a spent code or as a lockout, depending on which increment lands first responses.Count(response => response.StatusCode is HttpStatusCode.OK).ShouldBe(1); responses.Count(response => response.StatusCode is not HttpStatusCode.OK).ShouldBe(attempts - 1); (await CountRecoveryCodes()).ShouldBe(10); HttpResponseMessage accepted = responses.First(response => response.StatusCode is HttpStatusCode.OK); JsonElement body = await accepted.Content.ReadFromJsonAsync(); _secret = body.GetProperty("secret").GetString()!; _recoveryCodes = ReadRecoveryCodes(body); await ClearLockout(); } [Fact, TestPriority(21)] public async Task Login2fa_WithSameRecoveryCodeConcurrently_SucceedsOnce() { string sharedCode = _recoveryCodes[0]; string firstToken = await RequestLoginToken(); string secondToken = await RequestLoginToken(); HttpResponseMessage[] responses = await Task.WhenAll( _factory.CreateClient().PostAsJsonAsync("/api/auth/login/2fa", new { loginToken = firstToken, code = sharedCode, isRecoveryCode = true }), _factory.CreateClient().PostAsJsonAsync("/api/auth/login/2fa", new { loginToken = secondToken, code = sharedCode, isRecoveryCode = true })); responses.Count(response => response.StatusCode is HttpStatusCode.OK).ShouldBe(1); await ClearLockout(); } [Fact, TestPriority(22)] public async Task Disable2fa_WithConcurrentBadCodes_LocksOutTheSecondRequest() { if (!await IsTwoFactorEnabled()) { await EnableTwoFactor(); } await ClearLockout(); try { HttpResponseMessage[] responses = await Task.WhenAll( _client.PostAsJsonAsync("/api/account/2fa/disable", new { password = Password, totpCode = "ZZZZ-ZZZZ" }), _client.PostAsJsonAsync("/api/account/2fa/disable", new { password = Password, totpCode = "ZZZZ-ZZZZ" })); responses.Count(response => response.StatusCode is HttpStatusCode.BadRequest).ShouldBe(1); responses.Count(response => response.StatusCode is HttpStatusCode.TooManyRequests).ShouldBe(1); (await IsTwoFactorEnabled()).ShouldBeTrue(); } finally { await ClearLockout(); } } private async Task RequestLoginToken() { HttpResponseMessage response = await _factory.CreateClient().PostAsJsonAsync("/api/auth/login", new { username = Username, password = Password }); response.StatusCode.ShouldBe(HttpStatusCode.OK); JsonElement body = await response.Content.ReadFromJsonAsync(); body.GetProperty("requiresTwoFactor").GetBoolean().ShouldBeTrue(); return body.GetProperty("loginToken").GetString()!; } private async Task ReadUpdatedAt() { using IServiceScope scope = _factory.Services.CreateScope(); UsersContext context = scope.ServiceProvider.GetRequiredService(); return (await context.Users.AsNoTracking().FirstAsync()).UpdatedAt; } private async Task CountRecoveryCodes() { using IServiceScope scope = _factory.Services.CreateScope(); UsersContext context = scope.ServiceProvider.GetRequiredService(); return await context.RecoveryCodes.CountAsync(); } private async Task SeedFailedAttempts(int attempts) { using IServiceScope scope = _factory.Services.CreateScope(); UsersContext context = scope.ServiceProvider.GetRequiredService(); User user = await context.Users.FirstAsync(); user.FailedLoginAttempts = attempts; user.LockoutEnd = null; await context.SaveChangesAsync(); } private async Task ReadFailedAttempts() { using IServiceScope scope = _factory.Services.CreateScope(); UsersContext context = scope.ServiceProvider.GetRequiredService(); return (await context.Users.AsNoTracking().FirstAsync()).FailedLoginAttempts; } private async Task ClearLockout() { using var scope = _factory.Services.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); var user = await context.Users.FirstAsync(); user.FailedLoginAttempts = 0; user.LockoutEnd = null; await context.SaveChangesAsync(); } private async Task EnableTwoFactor() { var enableResponse = await _client.PostAsJsonAsync("/api/account/2fa/enable", new { password = Password }); enableResponse.StatusCode.ShouldBe(HttpStatusCode.OK); var body = await enableResponse.Content.ReadFromJsonAsync(); _secret = body.GetProperty("secret").GetString()!; _recoveryCodes = ReadRecoveryCodes(body); _recoveryCodes.Count.ShouldBe(10); var verifyResponse = await _client.PostAsJsonAsync("/api/account/2fa/enable/verify", new { code = TotpTestHelper.GenerateTotpCode(_secret) }); verifyResponse.StatusCode.ShouldBe(HttpStatusCode.OK); } private async Task DisableTwoFactor() { HttpResponseMessage response = await _client.PostAsJsonAsync("/api/account/2fa/disable", new { password = Password, totpCode = TotpTestHelper.GenerateTotpCode(_secret) }); response.StatusCode.ShouldBe(HttpStatusCode.OK); } private async Task IsTwoFactorEnabled() { var response = await _client.GetAsync("/api/account"); response.StatusCode.ShouldBe(HttpStatusCode.OK); var body = await response.Content.ReadFromJsonAsync(); return body.GetProperty("twoFactorEnabled").GetBoolean(); } private static List ReadRecoveryCodes(JsonElement body) { return body.GetProperty("recoveryCodes") .EnumerateArray() .Select(code => code.GetString()!) .ToList(); } }