diff --git a/src/food-market.api/Controllers/AuthorizationController.cs b/src/food-market.api/Controllers/AuthorizationController.cs index eb032ba..ceb996e 100644 --- a/src/food-market.api/Controllers/AuthorizationController.cs +++ b/src/food-market.api/Controllers/AuthorizationController.cs @@ -48,6 +48,26 @@ public async Task 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; diff --git a/src/food-market.api/Controllers/TwoFactorController.cs b/src/food-market.api/Controllers/TwoFactorController.cs new file mode 100644 index 0000000..28cbc85 --- /dev/null +++ b/src/food-market.api/Controllers/TwoFactorController.cs @@ -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; + +/// TOTP-2FA для текущего пользователя. Использует встроенный +/// AuthenticatorTokenProvider ASP.NET Identity — RFC 6238 (Google +/// Authenticator, Authy, 1Password OTP). +/// +/// Flow: +/// 1. POST /api/me/2fa/enroll — генерирует секрет (если ещё нет) и +/// возвращает QR-URI (otpauth://) + сам ключ. Клиент рисует QR. +/// 2. POST /api/me/2fa/verify с code из приложения — проверяет и +/// включает TwoFactorEnabled. После этого password-flow требует +/// otp_code. +/// 3. POST /api/me/2fa/disable с code — выключает. +/// +/// Дополнительной фичи (backup-коды) пока нет — добавим если попросят. +[ApiController] +[Authorize] +[Route("api/me/2fa")] +public class TwoFactorController : ControllerBase +{ + private readonly UserManager _users; + private readonly AppDbContext _db; + private readonly IConfiguration _cfg; + + public TwoFactorController(UserManager 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> 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> 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 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 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 }); + } +} diff --git a/src/food-market.api/Infrastructure/Cqrs/UnusedImplementations.cs b/src/food-market.api/Infrastructure/Cqrs/UnusedImplementations.cs new file mode 100644 index 0000000..0528b50 --- /dev/null +++ b/src/food-market.api/Infrastructure/Cqrs/UnusedImplementations.cs @@ -0,0 +1,35 @@ +using foodmarket.Application.Purchases.Commands; +using foodmarket.Application.Sales.Commands; + +namespace foodmarket.Api.Infrastructure.Cqrs; + +/// Заглушки для абстракций CQRS-handler'ов из food-market.application: +/// контроллеры в текущем спринте остаются на прямом доступе к DbContext +/// (поэтапная миграция к CQRS), эти классы нужны только чтобы DI-validation +/// на старте не падал при попытке активировать handler. +/// +/// Когда контроллер начнёт реально слать команду через IMediator, заменяем +/// эти стабы на реальную имплементацию (через EF, IStockService и т.п.). +/// При случайной активации throw'аем — phantom-инициализация не должна молча +/// проглатывать команды. +public sealed class UnusedSupplyWriter : ISupplyWriter +{ + public Task NextNumberAsync(int year, CancellationToken ct) + => throw new NotImplementedException( + "CreateSupplyHandler ещё не подключён к контроллерам. " + + "Контроллер использует прямой доступ к EF; этот writer существует только как образец CQRS-абстракции."); + + public Task CreateAsync(Guid supplierId, Guid storeId, Guid currencyId, + DateTime date, string? notes, string number, + IReadOnlyList lines, decimal total, CancellationToken ct) + => throw new NotImplementedException( + "CreateSupplyHandler ещё не подключён к контроллерам."); +} + +public sealed class UnusedRetailSalePoster : IRetailSalePoster +{ + public Task PostAsync(Guid saleId, IReadOnlyList lines, CancellationToken ct) + => throw new NotImplementedException( + "PostRetailSaleHandler ещё не подключён к контроллерам. " + + "Контроллер RetailSalesController.Post использует прямой stock-service."); +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 6321a4e..d08cbff 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -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(); + builder.Services.AddTransient(); // FluentValidation: автоматическая регистрация валидаторов из сборки api. // ValidationFilter гоняет валидаторы на каждом контроллер-action перед // вызовом — fail возвращает 400 ValidationProblemDetails (RFC 7807). diff --git a/tests/food-market.IntegrationTests/TwoFactorTests.cs b/tests/food-market.IntegrationTests/TwoFactorTests.cs new file mode 100644 index 0000000..ac7bdcf --- /dev/null +++ b/tests/food-market.IntegrationTests/TwoFactorTests.cs @@ -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; + + /// 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(); + } +}