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; private readonly foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit _audit; public TwoFactorController( UserManager users, AppDbContext db, IConfiguration cfg, foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit audit) { _users = users; _db = db; _cfg = cfg; _audit = audit; } 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); // Sprint 13: audit-log запись о включении 2FA. await _audit.LogAsync( action: "TwoFactorEnroll", entityType: "AppUser", entityId: user.Id, payload: new { email = user.Email, fullName = user.FullName }, ct: HttpContext.RequestAborted); 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); // Sprint 13: audit-log запись об отключении 2FA — это важный security // event (юзер ослабил защиту своей учётки). await _audit.LogAsync( action: "TwoFactorDisable", entityType: "AppUser", entityId: user.Id, payload: new { email = user.Email, fullName = user.FullName }, ct: HttpContext.RequestAborted); return Ok(new { enabled = false }); } }