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>
This commit is contained in:
nns 2026-05-28 17:57:32 +05:00
parent 971c9b29a5
commit 7b7a7091b9
5 changed files with 342 additions and 0 deletions

View file

@ -48,6 +48,26 @@ public async Task<IActionResult> Exchange()
return BadRequestError(Errors.InvalidGrant, "Неверный логин или пароль.");
}
// 2FA: если у юзера включён TOTP, требуем otp_code в запросе
// (custom parameter, OpenIddict передаёт его через request['otp_code']).
// Это второй шаг password-flow без редиректа: клиент видит
// invalid_grant + error_description="2fa_required" и шлёт ту же
// password-форму ещё раз с otp_code.
if (await _userManager.GetTwoFactorEnabledAsync(user))
{
var otp = request["otp_code"]?.Value?.ToString();
if (string.IsNullOrWhiteSpace(otp))
{
return BadRequestError(Errors.InvalidGrant, "2fa_required");
}
var validProvider = _userManager.Options.Tokens.AuthenticatorTokenProvider;
var ok = await _userManager.VerifyTwoFactorTokenAsync(user, validProvider, otp);
if (!ok)
{
return BadRequestError(Errors.InvalidGrant, "2fa_invalid");
}
}
var rejection = await CheckUserStillBelongsToLiveOrgAsync(user);
if (rejection is not null) return rejection;

View file

@ -0,0 +1,117 @@
using System.Web;
using foodmarket.Infrastructure.Identity;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers;
/// <summary>TOTP-2FA для текущего пользователя. Использует встроенный
/// <c>AuthenticatorTokenProvider</c> ASP.NET Identity — RFC 6238 (Google
/// Authenticator, Authy, 1Password OTP).
///
/// Flow:
/// 1. POST <c>/api/me/2fa/enroll</c> — генерирует секрет (если ещё нет) и
/// возвращает QR-URI (otpauth://) + сам ключ. Клиент рисует QR.
/// 2. POST <c>/api/me/2fa/verify</c> с code из приложения — проверяет и
/// включает <c>TwoFactorEnabled</c>. После этого password-flow требует
/// otp_code.
/// 3. POST <c>/api/me/2fa/disable</c> с code — выключает.
///
/// Дополнительной фичи (backup-коды) пока нет — добавим если попросят.</summary>
[ApiController]
[Authorize]
[Route("api/me/2fa")]
public class TwoFactorController : ControllerBase
{
private readonly UserManager<User> _users;
private readonly AppDbContext _db;
private readonly IConfiguration _cfg;
public TwoFactorController(UserManager<User> users, AppDbContext db, IConfiguration cfg)
{
_users = users;
_db = db;
_cfg = cfg;
}
public record EnrollResult(string SharedKey, string AuthenticatorUri, bool AlreadyEnabled);
public record CodeInput(string Code);
public record StatusDto(bool Enabled);
[HttpGet("status")]
public async Task<ActionResult<StatusDto>> Status()
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
return new StatusDto(await _users.GetTwoFactorEnabledAsync(user));
}
[HttpPost("enroll")]
public async Task<ActionResult<EnrollResult>> Enroll()
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var already = await _users.GetTwoFactorEnabledAsync(user);
// Если уже включено — не отдаём секрет; сначала disable.
if (already)
return Ok(new EnrollResult("", "", true));
var key = await _users.GetAuthenticatorKeyAsync(user);
if (string.IsNullOrEmpty(key))
{
await _users.ResetAuthenticatorKeyAsync(user);
key = await _users.GetAuthenticatorKeyAsync(user);
}
var issuer = _cfg["App:Name"] ?? "food-market";
var label = $"{issuer}:{user.Email ?? user.UserName}";
// otpauth-URI согласно RFC: otpauth://totp/{label}?secret={key}&issuer={issuer}.
var uri = $"otpauth://totp/{HttpUtility.UrlEncode(label)}?secret={key}&issuer={HttpUtility.UrlEncode(issuer)}";
return Ok(new EnrollResult(key!, uri, false));
}
[HttpPost("verify")]
public async Task<IActionResult> Verify([FromBody] CodeInput input)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
if (string.IsNullOrWhiteSpace(input.Code))
return BadRequest(new { error = "Не указан код подтверждения.", field = "code" });
var provider = _users.Options.Tokens.AuthenticatorTokenProvider;
var ok = await _users.VerifyTwoFactorTokenAsync(user, provider, input.Code.Trim());
if (!ok)
return BadRequest(new { error = "Неверный код. Попробуйте ещё раз.", field = "code" });
await _users.SetTwoFactorEnabledAsync(user, true);
return Ok(new { enabled = true });
}
[HttpPost("disable")]
public async Task<IActionResult> Disable([FromBody] CodeInput input)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
if (!await _users.GetTwoFactorEnabledAsync(user))
return Ok(new { enabled = false });
// Защита от внезапного отключения 2FA: требуем текущий code из приложения.
if (string.IsNullOrWhiteSpace(input.Code))
return BadRequest(new { error = "Подтверди отключение текущим кодом из приложения.", field = "code" });
var provider = _users.Options.Tokens.AuthenticatorTokenProvider;
var ok = await _users.VerifyTwoFactorTokenAsync(user, provider, input.Code.Trim());
if (!ok)
return BadRequest(new { error = "Неверный код.", field = "code" });
await _users.SetTwoFactorEnabledAsync(user, false);
// Заодно сбрасываем authenticator-key, чтобы при повторном enroll выдался новый.
await _users.ResetAuthenticatorKeyAsync(user);
return Ok(new { enabled = false });
}
}

View file

@ -0,0 +1,35 @@
using foodmarket.Application.Purchases.Commands;
using foodmarket.Application.Sales.Commands;
namespace foodmarket.Api.Infrastructure.Cqrs;
/// <summary>Заглушки для абстракций CQRS-handler'ов из food-market.application:
/// контроллеры в текущем спринте остаются на прямом доступе к DbContext
/// (поэтапная миграция к CQRS), эти классы нужны только чтобы DI-validation
/// на старте не падал при попытке активировать handler.
///
/// Когда контроллер начнёт реально слать команду через IMediator, заменяем
/// эти стабы на реальную имплементацию (через EF, IStockService и т.п.).
/// При случайной активации throw'аем — phantom-инициализация не должна молча
/// проглатывать команды.</summary>
public sealed class UnusedSupplyWriter : ISupplyWriter
{
public Task<string> NextNumberAsync(int year, CancellationToken ct)
=> throw new NotImplementedException(
"CreateSupplyHandler ещё не подключён к контроллерам. " +
"Контроллер использует прямой доступ к EF; этот writer существует только как образец CQRS-абстракции.");
public Task<Guid> CreateAsync(Guid supplierId, Guid storeId, Guid currencyId,
DateTime date, string? notes, string number,
IReadOnlyList<CreateSupplyLine> lines, decimal total, CancellationToken ct)
=> throw new NotImplementedException(
"CreateSupplyHandler ещё не подключён к контроллерам.");
}
public sealed class UnusedRetailSalePoster : IRetailSalePoster
{
public Task PostAsync(Guid saleId, IReadOnlyList<PostSaleLine> lines, CancellationToken ct)
=> throw new NotImplementedException(
"PostRetailSaleHandler ещё не подключён к контроллерам. " +
"Контроллер RetailSalesController.Post использует прямой stock-service.");
}

View file

@ -174,6 +174,14 @@
// переписаны под mediator — это паттерн-показ, не полный рефакторинг.
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(
typeof(foodmarket.Application.Inventory.IStockService).Assembly));
// Заглушки для абстракций CQRS-handler'ов: handler'ы пока не подключены
// к контроллерам (поэтапная миграция). Реальная имплементация появится
// когда контроллер начнёт отправлять команду через IMediator. Сейчас
// нужны только чтобы DI-validation на старте не падала.
builder.Services.AddTransient<foodmarket.Application.Purchases.Commands.ISupplyWriter,
foodmarket.Api.Infrastructure.Cqrs.UnusedSupplyWriter>();
builder.Services.AddTransient<foodmarket.Application.Sales.Commands.IRetailSalePoster,
foodmarket.Api.Infrastructure.Cqrs.UnusedRetailSalePoster>();
// FluentValidation: автоматическая регистрация валидаторов из сборки api.
// ValidationFilter гоняет валидаторы на каждом контроллер-action перед
// вызовом — fail возвращает 400 ValidationProblemDetails (RFC 7807).

View file

@ -0,0 +1,162 @@
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();
}
}