using System.Net.Http.Json; using System.Security.Cryptography; using System.Text.Json; using FluentAssertions; using foodmarket.IntegrationTests.Support; using Xunit; namespace foodmarket.IntegrationTests; [Collection(ApiCollection.Name)] public class TwoFactorTests { private readonly ApiFactory _factory; public TwoFactorTests(ApiFactory factory) => _factory = factory; /// Enroll возвращает SharedKey + otpauth-URI; Verify с правильным /// кодом включает 2FA; Status показывает enabled. [Fact] public async Task Enroll_verify_status_workflow() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"tfa-{Guid.NewGuid():N}"); // 1) Изначально 2FA выключен. var status1 = await api.GetJsonAsync("/api/me/2fa/status"); status1.GetProperty("enabled").GetBoolean().Should().BeFalse(); // 2) Enroll → секрет. var enroll = await api.Http.PostAsJsonAsync("/api/me/2fa/enroll", new { }); enroll.EnsureSuccessStatusCode(); var enrollResp = await enroll.Content.ReadFromJsonAsync(); var sharedKey = enrollResp.GetProperty("sharedKey").GetString(); var uri = enrollResp.GetProperty("authenticatorUri").GetString(); sharedKey.Should().NotBeNullOrWhiteSpace(); uri.Should().Contain("otpauth://totp/"); uri.Should().Contain(sharedKey); // 3) Сгенерировать TOTP-код по тому же RFC 6238 и Verify. var code = GenerateTotp(sharedKey!); var verify = await api.Http.PostAsJsonAsync("/api/me/2fa/verify", new { code }); verify.EnsureSuccessStatusCode(); // 4) Status теперь enabled. var status2 = await api.GetJsonAsync("/api/me/2fa/status"); status2.GetProperty("enabled").GetBoolean().Should().BeTrue(); } /// Wrong code → 400. [Fact] public async Task Verify_with_wrong_code_returns_400() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"tfa-wrong-{Guid.NewGuid():N}"); await api.Http.PostAsJsonAsync("/api/me/2fa/enroll", new { }); using var r = await api.Http.PostAsJsonAsync("/api/me/2fa/verify", new { code = "000000" }); ((int)r.StatusCode).Should().Be(400); } /// После включения 2FA password-grant без otp_code → invalid_grant /// с описанием "2fa_required". С правильным otp_code — token выдаётся. [Fact] public async Task Token_endpoint_requires_otp_when_2fa_enabled() { var api = new ApiActor(_factory.CreateClient()); var (email, password) = await api.SignupAndLoginAsync($"tfa-tok-{Guid.NewGuid():N}"); var enroll = await api.Http.PostAsJsonAsync("/api/me/2fa/enroll", new { }); var enrollResp = await enroll.Content.ReadFromJsonAsync(); var sharedKey = enrollResp.GetProperty("sharedKey").GetString(); await api.Http.PostAsJsonAsync("/api/me/2fa/verify", new { code = GenerateTotp(sharedKey!) }); // Без otp_code → invalid_grant. using var noOtp = await api.Http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "password", ["username"] = email, ["password"] = password, ["client_id"] = "food-market-web", ["scope"] = "openid profile email roles api offline_access", })); ((int)noOtp.StatusCode).Should().Be(400); var noOtpBody = await noOtp.Content.ReadAsStringAsync(); noOtpBody.Should().Contain("2fa_required"); // С правильным otp_code → success. // TOTP-step 30 секунд; в тестах потенциально надо ждать новый шаг — // используем тот же код, что прошёл при verify (в пределах окна). using var withOtp = await api.Http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary { ["grant_type"] = "password", ["username"] = email, ["password"] = password, ["client_id"] = "food-market-web", ["scope"] = "openid profile email roles api offline_access", ["otp_code"] = GenerateTotp(sharedKey!), })); withOtp.IsSuccessStatusCode.Should().BeTrue( $"token c otp_code должен пройти: {(int)withOtp.StatusCode} {await withOtp.Content.ReadAsStringAsync()}"); } [Fact] public async Task Disable_requires_code() { var api = new ApiActor(_factory.CreateClient()); await api.SignupAndLoginAsync($"tfa-dis-{Guid.NewGuid():N}"); var enroll = await api.Http.PostAsJsonAsync("/api/me/2fa/enroll", new { }); var sharedKey = (await enroll.Content.ReadFromJsonAsync()).GetProperty("sharedKey").GetString(); await api.Http.PostAsJsonAsync("/api/me/2fa/verify", new { code = GenerateTotp(sharedKey!) }); // Disable без code → 400. using var bad = await api.Http.PostAsJsonAsync("/api/me/2fa/disable", new { code = (string?)null }); ((int)bad.StatusCode).Should().Be(400); // Disable с code → 200. using var ok = await api.Http.PostAsJsonAsync("/api/me/2fa/disable", new { code = GenerateTotp(sharedKey!) }); ok.IsSuccessStatusCode.Should().BeTrue(); var status = await api.GetJsonAsync("/api/me/2fa/status"); status.GetProperty("enabled").GetBoolean().Should().BeFalse(); } /// Generate RFC 6238 TOTP for given base32-encoded shared key. /// 30-second time step, 6 digits, SHA1 (default for AuthenticatorTokenProvider). private static string GenerateTotp(string base32Key) { var key = FromBase32(base32Key); var counter = (long)Math.Floor((DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds / 30); var counterBytes = BitConverter.GetBytes(counter); if (BitConverter.IsLittleEndian) Array.Reverse(counterBytes); using var hmac = new HMACSHA1(key); var hash = hmac.ComputeHash(counterBytes); var offset = hash[^1] & 0x0F; var binary = ((hash[offset] & 0x7F) << 24) | ((hash[offset + 1] & 0xFF) << 16) | ((hash[offset + 2] & 0xFF) << 8) | (hash[offset + 3] & 0xFF); var code = binary % 1_000_000; return code.ToString("D6"); } private static byte[] FromBase32(string s) { const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; s = s.Trim().TrimEnd('=').ToUpperInvariant(); var bytes = new List(); int buf = 0, bits = 0; foreach (var c in s) { var v = alphabet.IndexOf(c); if (v < 0) continue; buf = (buf << 5) | v; bits += 5; if (bits >= 8) { bits -= 8; bytes.Add((byte)((buf >> bits) & 0xFF)); } } return bytes.ToArray(); } }