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:
parent
971c9b29a5
commit
7b7a7091b9
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
117
src/food-market.api/Controllers/TwoFactorController.cs
Normal file
117
src/food-market.api/Controllers/TwoFactorController.cs
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
162
tests/food-market.IntegrationTests/TwoFactorTests.cs
Normal file
162
tests/food-market.IntegrationTests/TwoFactorTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue