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>
163 lines
7.2 KiB
C#
163 lines
7.2 KiB
C#
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();
|
||
}
|
||
}
|