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 });
}
}