food-market/tests/food-market.IntegrationTests/TwoFactorTests.cs
nns 7b7a7091b9 feat(auth): TOTP 2FA для админов через AuthenticatorTokenProvider (P2-4)
ASP.NET Identity AuthenticatorTokenProvider (RFC 6238 — Google
Authenticator, Authy, 1Password OTP). TwoFactorEnabled и SecurityStamp
уже были в users-таблице из Identity-схемы.

Endpoints (Bearer-auth):
- GET /api/me/2fa/status — { enabled: bool }.
- POST /api/me/2fa/enroll — генерирует SecretKey (если ещё нет),
  возвращает otpauth-URI для QR + сам shared-key. Пока 2FA включён,
  enroll возвращает alreadyEnabled=true без секрета.
- POST /api/me/2fa/verify { code } — валидирует и включает 2FA.
- POST /api/me/2fa/disable { code } — выключает + ResetAuthenticatorKeyAsync.
  Требует текущий code как защиту от случайного отключения.

AuthorizationController.Exchange (password grant): после успеха проверки
пароля смотрит TwoFactorEnabledAsync; если true и нет otp_code в
запросе — возвращает invalid_grant с error_description="2fa_required";
если otp_code невалиден — "2fa_invalid"; иначе токен выдаётся.

Опционально для всех ролей — User самостоятельно решает включать или нет.
Для админов рекомендуется (отдельная политика — следующий шаг).

Тесты: 4 интеграционных (enroll+verify+status, неверный code → 400,
token-endpoint require otp_code, disable с code). Тесты сами генерируют
TOTP через ручную RFC 6238 имплементацию (HMAC-SHA1, 30-сек step).

Bonus: добавлены DI-заглушки UnusedSupplyWriter / UnusedRetailSalePoster
для CQRS-handler'ов из TD-1 — handler'ы пока не подключены к
контроллерам, заглушки нужны чтобы DI-validation на старте не падала.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:57:32 +05:00

163 lines
7.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>Enroll возвращает SharedKey + otpauth-URI; Verify с правильным
/// кодом включает 2FA; Status показывает enabled.</summary>
[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<JsonElement>();
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();
}
/// <summary>Wrong code → 400.</summary>
[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);
}
/// <summary>После включения 2FA password-grant без otp_code → invalid_grant
/// с описанием "2fa_required". С правильным otp_code — token выдаётся.</summary>
[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<JsonElement>();
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<string, string>
{
["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<string, string>
{
["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<JsonElement>()).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();
}
/// <summary>Generate RFC 6238 TOTP for given base32-encoded shared key.
/// 30-second time step, 6 digits, SHA1 (default for AuthenticatorTokenProvider).</summary>
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<byte>();
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();
}
}