line-gestao-api/Controllers/AuthController.cs

416 lines
12 KiB
C#

using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using line_gestao_api.Data;
using line_gestao_api.Dtos;
using line_gestao_api.Models;
using line_gestao_api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
namespace line_gestao_api.Controllers;
[ApiController]
[Route("auth")]
public class AuthController : ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly AppDbContext _db;
private readonly ITenantProvider _tenantProvider;
private readonly IConfiguration _config;
private readonly ILogger<AuthController> _logger;
public AuthController(
UserManager<ApplicationUser> userManager,
AppDbContext db,
ITenantProvider tenantProvider,
IConfiguration config,
ILogger<AuthController> logger)
{
_userManager = userManager;
_db = db;
_tenantProvider = tenantProvider;
_config = config;
_logger = logger;
}
[HttpPost("register")]
[Authorize(Roles = "sysadmin")]
public async Task<IActionResult> Register(RegisterRequest req)
{
if (req.Password != req.ConfirmPassword)
return BadRequest("As senhas não conferem.");
var tenantId = _tenantProvider.TenantId;
if (tenantId == null || tenantId == Guid.Empty)
return Unauthorized("Tenant inválido.");
var email = req.Email.Trim().ToLowerInvariant();
var normalizedEmail = _userManager.NormalizeEmail(email);
var exists = await _userManager.Users
.AnyAsync(u => u.NormalizedEmail == normalizedEmail && u.TenantId == tenantId);
if (exists) return BadRequest("E-mail já cadastrado.");
var user = new ApplicationUser
{
Name = req.Name.Trim(),
Email = email,
UserName = email,
TenantId = tenantId.Value,
IsActive = true,
EmailConfirmed = true,
LockoutEnabled = true
};
var createResult = await _userManager.CreateAsync(user, req.Password);
if (!createResult.Succeeded)
return BadRequest(createResult.Errors.Select(e => e.Description).ToList());
await _userManager.AddToRoleAsync(user, AppRoles.Cliente);
var effectiveTenantId = await EnsureValidTenantIdAsync(user);
if (!effectiveTenantId.HasValue)
return Unauthorized("Tenant inválido.");
await EnsureClientTenantDataBoundAsync(user, effectiveTenantId.Value);
var token = await GenerateJwtAsync(user, effectiveTenantId.Value);
return Ok(new AuthResponse(token));
}
[HttpPost("login")]
[EnableRateLimiting("auth-login")]
public async Task<IActionResult> Login(LoginRequest req)
{
var email = (req.Email ?? "").Trim().ToLowerInvariant();
var password = req.Password ?? "";
var normalizedEmail = _userManager.NormalizeEmail(email);
var tenantId = ResolveTenantId(req);
ApplicationUser? user;
if (tenantId == null)
{
var users = await _userManager.Users
.IgnoreQueryFilters()
.Where(u => u.NormalizedEmail == normalizedEmail)
.ToListAsync();
if (users.Count == 0)
return Unauthorized("Credenciais inválidas.");
if (users.Count > 1)
return BadRequest("Informe o tenant para realizar o login.");
user = users[0];
}
else
{
user = await _userManager.Users
.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail && u.TenantId == tenantId);
}
if (user == null)
return Unauthorized("Credenciais inválidas.");
if (!user.IsActive)
return Unauthorized("Usuário desativado.");
if (await _userManager.IsLockedOutAsync(user))
return Unauthorized("Usuário temporariamente bloqueado por tentativas inválidas. Tente novamente em alguns minutos.");
var valid = await _userManager.CheckPasswordAsync(user, password);
if (!valid)
{
if (user.LockoutEnabled)
{
await _userManager.AccessFailedAsync(user);
}
return Unauthorized("Credenciais inválidas.");
}
if (user.LockoutEnabled)
{
await _userManager.ResetAccessFailedCountAsync(user);
}
var effectiveTenantId = await EnsureValidTenantIdAsync(user);
if (!effectiveTenantId.HasValue)
return Unauthorized("Tenant inválido.");
await EnsureClientTenantDataBoundAsync(user, effectiveTenantId.Value);
var token = await GenerateJwtAsync(user, effectiveTenantId.Value);
return Ok(new AuthResponse(token));
}
private Guid? ResolveTenantId(LoginRequest req)
{
if (req.TenantId.HasValue)
return req.TenantId.Value;
var headerValue = Request.Headers["X-Tenant-Id"].FirstOrDefault();
return Guid.TryParse(headerValue, out var parsed) ? parsed : null;
}
private async Task<string> GenerateJwtAsync(ApplicationUser user, Guid tenantId)
{
var key = _config["Jwt:Key"]!;
var issuer = _config["Jwt:Issuer"]!;
var audience = _config["Jwt:Audience"]!;
var expiresMinutes = int.Parse(_config["Jwt:ExpiresMinutes"]!);
var roles = await _userManager.GetRolesAsync(user);
var displayName = user.Name ?? user.Email ?? string.Empty;
var userEmail = user.Email ?? string.Empty;
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new(JwtRegisteredClaimNames.Email, userEmail),
new("name", displayName),
new("tenantId", tenantId.ToString())
};
claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r)));
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key));
var creds = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: issuer,
audience: audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(expiresMinutes),
signingCredentials: creds
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
private async Task<Guid?> EnsureValidTenantIdAsync(ApplicationUser user)
{
if (user.TenantId == Guid.Empty)
{
return null;
}
var existsAndActive = await _db.Tenants
.AsNoTracking()
.AnyAsync(t => t.Id == user.TenantId && t.Ativo);
if (!existsAndActive)
{
return null;
}
return user.TenantId;
}
private async Task EnsureClientTenantDataBoundAsync(ApplicationUser user, Guid tenantId)
{
var roles = await _userManager.GetRolesAsync(user);
if (!roles.Any(r => string.Equals(r, AppRoles.Cliente, StringComparison.OrdinalIgnoreCase)))
{
return;
}
try
{
await RebindMobileLinesToTenantBySourceKeyAsync(tenantId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Falha ao sincronizar linhas para tenant {TenantId} durante login de cliente.", tenantId);
}
}
private async Task<int> RebindMobileLinesToTenantBySourceKeyAsync(Guid tenantId)
{
if (tenantId == Guid.Empty)
{
return 0;
}
var tenant = await _db.Tenants
.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == tenantId && t.Ativo && !t.IsSystem);
if (tenant == null)
{
return 0;
}
if (!string.Equals(
tenant.SourceType,
SystemTenantConstants.MobileLinesClienteSourceType,
StringComparison.OrdinalIgnoreCase))
{
return 0;
}
var normalizedKeys = new HashSet<string>(StringComparer.Ordinal);
AddNormalizedTenantKey(normalizedKeys, tenant.SourceKey);
AddNormalizedTenantKey(normalizedKeys, tenant.NomeOficial);
if (normalizedKeys.Count == 0)
{
return 0;
}
var candidates = await _db.MobileLines
.IgnoreQueryFilters()
.Where(x => x.TenantId != tenant.Id)
.Where(x => x.Cliente != null && x.Cliente != string.Empty)
.ToListAsync();
if (candidates.Count == 0)
{
return 0;
}
var hasAnyTenantLine = await _db.MobileLines
.IgnoreQueryFilters()
.AsNoTracking()
.AnyAsync(x => x.TenantId == tenant.Id);
var now = DateTime.UtcNow;
var reassigned = ReassignByMatcher(
candidates,
normalizedKeys,
tenant.Id,
now,
isRelaxedMatch: false);
if (reassigned == 0 && !hasAnyTenantLine)
{
reassigned = ReassignByMatcher(
candidates,
normalizedKeys,
tenant.Id,
now,
isRelaxedMatch: true);
}
if (reassigned > 0)
{
await _db.SaveChangesAsync();
}
return reassigned;
}
private static int ReassignByMatcher(
IReadOnlyList<MobileLine> candidates,
IReadOnlyCollection<string> normalizedKeys,
Guid tenantId,
DateTime now,
bool isRelaxedMatch)
{
if (normalizedKeys.Count == 0)
{
return 0;
}
var keys = isRelaxedMatch
? normalizedKeys.Where(k => k.Length >= 6).ToList()
: normalizedKeys.ToList();
if (keys.Count == 0)
{
return 0;
}
var reassigned = 0;
foreach (var line in candidates)
{
var normalizedClient = NormalizeTenantKey(line.Cliente ?? string.Empty);
if (string.IsNullOrWhiteSpace(normalizedClient) ||
string.Equals(normalizedClient, "RESERVA", StringComparison.Ordinal))
{
continue;
}
var matches = !isRelaxedMatch
? keys.Contains(normalizedClient, StringComparer.Ordinal)
: keys.Any(k =>
normalizedClient.Contains(k, StringComparison.Ordinal) ||
k.Contains(normalizedClient, StringComparison.Ordinal));
if (!matches)
{
continue;
}
line.TenantId = tenantId;
line.UpdatedAt = now;
reassigned++;
}
return reassigned;
}
private static void AddNormalizedTenantKey(ISet<string> keys, string? rawKey)
{
var normalized = NormalizeTenantKey(rawKey ?? string.Empty);
if (string.IsNullOrWhiteSpace(normalized))
{
return;
}
if (string.Equals(normalized, "RESERVA", StringComparison.Ordinal))
{
return;
}
keys.Add(normalized);
}
private static string NormalizeTenantKey(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var normalized = value.Trim().Normalize(NormalizationForm.FormD);
var sb = new StringBuilder(normalized.Length);
var previousWasSpace = false;
foreach (var ch in normalized)
{
var category = CharUnicodeInfo.GetUnicodeCategory(ch);
if (category == UnicodeCategory.NonSpacingMark)
{
continue;
}
if (char.IsWhiteSpace(ch))
{
if (!previousWasSpace)
{
sb.Append(' ');
previousWasSpace = true;
}
continue;
}
sb.Append(char.ToUpperInvariant(ch));
previousWasSpace = false;
}
return sb.ToString().Trim();
}
}