416 lines
12 KiB
C#
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();
|
|
}
|
|
}
|