diff --git a/Carbon.Application/Contracts/IFacialUseCase.cs b/Carbon.Application/Contracts/IFacialUseCase.cs new file mode 100644 index 0000000..b7e3b77 --- /dev/null +++ b/Carbon.Application/Contracts/IFacialUseCase.cs @@ -0,0 +1,9 @@ +using Carbon.Application.Dtos.Facial; + +namespace Carbon.Application.Contracts; + +public interface IFacialUseCase +{ + Task Register(Guid userId, FaceImageRequestDto request, CancellationToken ct); + Task Verify(Guid userId, FaceImageRequestDto request, CancellationToken ct); +} \ No newline at end of file diff --git a/Carbon.Application/Dtos/Facial/FaceImageRequestDto.cs b/Carbon.Application/Dtos/Facial/FaceImageRequestDto.cs new file mode 100644 index 0000000..8bda51f --- /dev/null +++ b/Carbon.Application/Dtos/Facial/FaceImageRequestDto.cs @@ -0,0 +1,8 @@ +using System.ComponentModel.DataAnnotations; + +namespace Carbon.Application.Dtos.Facial; + +// Foto em base64. Aceita base64 puro ou "data:image/jpeg;base64,..." (formato da webcam) +public record FaceImageRequestDto( + [Required(ErrorMessage = "ImageBase64 field is required!", AllowEmptyStrings = false)] + string ImageBase64); \ No newline at end of file diff --git a/Carbon.Application/Dtos/Facial/FaceResponseDtos.cs b/Carbon.Application/Dtos/Facial/FaceResponseDtos.cs new file mode 100644 index 0000000..aaa1942 --- /dev/null +++ b/Carbon.Application/Dtos/Facial/FaceResponseDtos.cs @@ -0,0 +1,5 @@ +namespace Carbon.Application.Dtos.Facial; + +public record FaceRegisterResponseDto(Guid UserId, DateTime RegisteredAt); + +public record FaceVerificationResponseDto(Guid UserId, bool IsMatch, float Similarity, DateTime VerifiedAt); \ No newline at end of file diff --git a/Carbon.Application/UseCases/FacialUseCase.cs b/Carbon.Application/UseCases/FacialUseCase.cs new file mode 100644 index 0000000..c5a9d8e --- /dev/null +++ b/Carbon.Application/UseCases/FacialUseCase.cs @@ -0,0 +1,55 @@ +using Carbon.Application.Contracts; +using Carbon.Application.Dtos.Facial; +using Carbon.Domain.Contracts.Services.Facial; +using Carbon.Domain.Exceptions; + +namespace Carbon.Application.UseCases; + +public class FacialUseCase : IFacialUseCase +{ + private readonly IFacialService _facialService; + + public FacialUseCase(IFacialService facialService) + { + _facialService = facialService; + } + + public async Task Register(Guid userId, FaceImageRequestDto request, CancellationToken ct = default) + { + var image = DecodeImage(request.ImageBase64); + var result = await _facialService.RegisterFaceAsync(userId, image, ct); + + return new FaceRegisterResponseDto(result.UserId, result.RegisteredAt); + } + + public async Task Verify(Guid userId, FaceImageRequestDto request, CancellationToken ct = default) + { + var image = DecodeImage(request.ImageBase64); + var result = await _facialService.VerifyFaceAsync(userId, image, ct); + + return new FaceVerificationResponseDto(result.UserId, result.IsMatch, result.Similarity, result.VerifiedAt); + } + + private static byte[] DecodeImage(string imageBase64) + { + if (string.IsNullOrWhiteSpace(imageBase64)) + { + throw new BadRequestException("Image is required."); + } + + // Remove o prefixo "data:image/...;base64," se vier da webcam + var commaIndex = imageBase64.IndexOf(','); + var base64 = imageBase64.StartsWith("data:", StringComparison.OrdinalIgnoreCase) && commaIndex >= 0 + ? imageBase64[(commaIndex + 1)..] + : imageBase64; + + try + { + return Convert.FromBase64String(base64.Trim()); + } + catch (FormatException) + { + throw new BadRequestException("Image must be a valid base64 string."); + } + } +} \ No newline at end of file diff --git a/Carbon.Azure.Facial.Provider/Carbon.Azure.Facial.Provider.csproj b/Carbon.Azure.Facial.Provider/Carbon.Azure.Facial.Provider.csproj index 72d7137..2d64bc1 100644 --- a/Carbon.Azure.Facial.Provider/Carbon.Azure.Facial.Provider.csproj +++ b/Carbon.Azure.Facial.Provider/Carbon.Azure.Facial.Provider.csproj @@ -7,4 +7,9 @@ Carbon.Azure.Facial + + + + + diff --git a/Carbon.Azure.Facial.Provider/Class1.cs b/Carbon.Azure.Facial.Provider/Class1.cs deleted file mode 100644 index 74ec65a..0000000 --- a/Carbon.Azure.Facial.Provider/Class1.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Carbon.Azure.Facial; - -public class Class1 -{ -} \ No newline at end of file diff --git a/Carbon.Azure.Facial.Provider/Clients/FaceRecognitionClient.cs b/Carbon.Azure.Facial.Provider/Clients/FaceRecognitionClient.cs new file mode 100644 index 0000000..00395a7 --- /dev/null +++ b/Carbon.Azure.Facial.Provider/Clients/FaceRecognitionClient.cs @@ -0,0 +1,66 @@ +using FaceAiSharp; +using FaceAiSharp.Extensions; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace Carbon.Azure.Facial.Clients; + +// Reconhecimento facial local com a FaceAiSharp. Registrar como Singleton (modelos pesados). +public sealed class FaceRecognitionClient : IFaceRecognitionClient, IDisposable +{ + private readonly IFaceDetectorWithLandmarks _detector; + private readonly IFaceEmbeddingsGenerator _generator; + private readonly object _lock = new(); + + public FaceRecognitionClient() + { + _detector = FaceAiSharpBundleFactory.CreateFaceDetectorWithLandmarks(); + _generator = FaceAiSharpBundleFactory.CreateFaceEmbeddingsGenerator(); + } + + public FaceEmbeddingResult GetEmbedding(byte[] image) + { + Image img; + + try + { + img = Image.Load(image); + } + catch (ImageFormatException) + { + return new FaceEmbeddingResult(FaceEmbeddingStatus.InvalidImage, null); + } + + using (img) + { + lock (_lock) + { + var faces = _detector.DetectFaces(img); + + if (faces.Count == 0) + return new FaceEmbeddingResult(FaceEmbeddingStatus.NoFaceFound, null); + + if (faces.Count > 1) + return new FaceEmbeddingResult(FaceEmbeddingStatus.MultipleFacesFound, null); + + var face = faces.First(); + + if (face.Landmarks is null) + return new FaceEmbeddingResult(FaceEmbeddingStatus.NoFaceFound, null); + + _generator.AlignFaceUsingLandmarks(img, face.Landmarks); + var embedding = _generator.GenerateEmbedding(img); + + return new FaceEmbeddingResult(FaceEmbeddingStatus.Success, embedding); + } + } + } + + public float Compare(float[] first, float[] second) => first.Dot(second); + + public void Dispose() + { + (_detector as IDisposable)?.Dispose(); + (_generator as IDisposable)?.Dispose(); + } +} \ No newline at end of file diff --git a/Carbon.Azure.Facial.Provider/Clients/IFaceRecognitionClient.cs b/Carbon.Azure.Facial.Provider/Clients/IFaceRecognitionClient.cs new file mode 100644 index 0000000..f32b465 --- /dev/null +++ b/Carbon.Azure.Facial.Provider/Clients/IFaceRecognitionClient.cs @@ -0,0 +1,17 @@ +namespace Carbon.Azure.Facial.Clients; + +public interface IFaceRecognitionClient +{ + FaceEmbeddingResult GetEmbedding(byte[] image); + float Compare(float[] first, float[] second); +} + +public enum FaceEmbeddingStatus +{ + Success, + InvalidImage, + NoFaceFound, + MultipleFacesFound +} + +public record FaceEmbeddingResult(FaceEmbeddingStatus Status, float[]? Embedding); \ No newline at end of file diff --git a/Carbon.Core/Controllers/FacialController.cs b/Carbon.Core/Controllers/FacialController.cs new file mode 100644 index 0000000..90fd4a8 --- /dev/null +++ b/Carbon.Core/Controllers/FacialController.cs @@ -0,0 +1,40 @@ +using Carbon.Application.Contracts; +using Carbon.Application.Dtos.Facial; +using Carbon.Domain.Exceptions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Carbon.Core.Controllers; + +[ApiController] +[Authorize] +[Route("Carbon/[controller]")] +public class FacialController : ControllerBase +{ + private readonly IFacialUseCase _useCase; + + public FacialController(IFacialUseCase useCase) + { + _useCase = useCase; + } + + // Cadastra (ou substitui) o rosto do usuário logado + [HttpPost("Register")] + public async Task Register(FaceImageRequestDto request, CancellationToken ct = default) + => await _useCase.Register(GetUserId(), request, ct); + + // Compara a foto com o rosto cadastrado do usuário logado + [HttpPost("Verify")] + public async Task Verify(FaceImageRequestDto request, CancellationToken ct = default) + => await _useCase.Verify(GetUserId(), request, ct); + + // O id do usuário vem do token JWT (claim "sub") + private Guid GetUserId() + { + var sub = User.FindFirst("sub")?.Value; + + return Guid.TryParse(sub, out var userId) + ? userId + : throw new UnauthorizedException("Invalid token: user id not found."); + } +} \ No newline at end of file diff --git a/Carbon.Core/Extensions/ServiceCollectionExtensions.cs b/Carbon.Core/Extensions/ServiceCollectionExtensions.cs index ffaa102..c4b1481 100644 --- a/Carbon.Core/Extensions/ServiceCollectionExtensions.cs +++ b/Carbon.Core/Extensions/ServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using Carbon.Domain.Contracts.Services.Facial; using Carbon.Application.Contracts; using Carbon.Application.UseCases; using Carbon.Core.Contracts; @@ -20,6 +21,7 @@ public static void AddCarbonUseCases(this IServiceCollection services) { services.AddTransient(); services.AddTransient(); + services.AddTransient(); } public static void AddCarbonRateLimiter(this IServiceCollection services) diff --git a/Carbon.Core/Program.cs b/Carbon.Core/Program.cs index 4e0c535..9ba2a5e 100644 --- a/Carbon.Core/Program.cs +++ b/Carbon.Core/Program.cs @@ -13,6 +13,7 @@ builder.Services.AddCarbonUseCases(); builder.Services.AddDataLayer(builder.Configuration); builder.Services.AddTImpact(builder.Configuration); +builder.Services.AddFacialRecognition(); builder.Services.AddExceptionHandler(); builder.Services.AddJwtAuthentication(builder.Configuration); diff --git a/Carbon.Domain/Contracts/Data/Repositories/IUserRepository.cs b/Carbon.Domain/Contracts/Data/Repositories/IUserRepository.cs index 6644493..2cec456 100644 --- a/Carbon.Domain/Contracts/Data/Repositories/IUserRepository.cs +++ b/Carbon.Domain/Contracts/Data/Repositories/IUserRepository.cs @@ -5,4 +5,5 @@ namespace Carbon.Domain.Contracts.Data.Repositories; public interface IUserRepository : ICarbonRepository { Task GetByEmailAsync(string email, CancellationToken ct); + Task UpdateFaceEmbeddingAsync(Guid userId, byte[] faceEmbedding, CancellationToken ct); } \ No newline at end of file diff --git a/Carbon.Domain/Contracts/Providers/Facial/IFacialRecognitionProvider.cs b/Carbon.Domain/Contracts/Providers/Facial/IFacialRecognitionProvider.cs new file mode 100644 index 0000000..e68fc31 --- /dev/null +++ b/Carbon.Domain/Contracts/Providers/Facial/IFacialRecognitionProvider.cs @@ -0,0 +1,7 @@ +namespace Carbon.Domain.Contracts.Providers.Facial; + +public interface IFacialRecognitionProvider +{ + float[] GetFaceEmbedding(byte[] image); + float Compare(float[] first, float[] second); +} \ No newline at end of file diff --git a/Carbon.Domain/Contracts/Services/Facial/FacialContracts.cs b/Carbon.Domain/Contracts/Services/Facial/FacialContracts.cs new file mode 100644 index 0000000..601c85f --- /dev/null +++ b/Carbon.Domain/Contracts/Services/Facial/FacialContracts.cs @@ -0,0 +1,15 @@ +namespace Carbon.Domain.Contracts.Services.Facial +{ + public record FaceRegisterResponse(Guid UserId, DateTime RegisteredAt); + + public record FaceVerificationResponse(Guid UserId, bool IsMatch, float Similarity, DateTime VerifiedAt); + + public static class FacialRules + { + // Similaridade mínima para considerar a mesma pessoa (recomendação da FaceAiSharp) + public const float MatchThreshold = 0.42f; + + // Tamanho máximo da foto (5 MB) + public const int MaxImageBytes = 5 * 1024 * 1024; + } +} \ No newline at end of file diff --git a/Carbon.Domain/Contracts/Services/Facial/IFacialService.cs b/Carbon.Domain/Contracts/Services/Facial/IFacialService.cs new file mode 100644 index 0000000..b5ba29f --- /dev/null +++ b/Carbon.Domain/Contracts/Services/Facial/IFacialService.cs @@ -0,0 +1,7 @@ +namespace Carbon.Domain.Contracts.Services.Facial; + +public interface IFacialService +{ + Task RegisterFaceAsync(Guid userId, byte[] image, CancellationToken ct = default); + Task VerifyFaceAsync(Guid userId, byte[] image, CancellationToken ct = default); +} \ No newline at end of file diff --git a/Carbon.Domain/Models/User.cs b/Carbon.Domain/Models/User.cs index 4d16ea0..fe561dc 100644 --- a/Carbon.Domain/Models/User.cs +++ b/Carbon.Domain/Models/User.cs @@ -12,6 +12,7 @@ public class User public required string Email { get; set; } public required string Password { get; set; } public bool IsActive { get; set; } + public byte[]? FaceEmbedding { get; set; } public DateTime CreatedAt { get; set; } public ICollection Roles { get; set; } = []; public ICollection RefreshTokens = []; diff --git a/Carbon.Domain/Services/FacialService.cs b/Carbon.Domain/Services/FacialService.cs new file mode 100644 index 0000000..0288464 --- /dev/null +++ b/Carbon.Domain/Services/FacialService.cs @@ -0,0 +1,75 @@ +using System.Runtime.InteropServices; +using Carbon.Domain.Contracts.Data; +using Carbon.Domain.Contracts.Providers.Facial; +using Carbon.Domain.Contracts.Services.Facial; +using Carbon.Domain.Exceptions; +using Carbon.Domain.Models; + +namespace Carbon.Domain.Services; + +public class FacialService : IFacialService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IFacialRecognitionProvider _provider; + + public FacialService(IUnitOfWork unitOfWork, IFacialRecognitionProvider provider) + { + _unitOfWork = unitOfWork; + _provider = provider; + } + + public async Task RegisterFaceAsync(Guid userId, byte[] image, CancellationToken ct = default) + { + ValidateImage(image); + await GetUserAsync(userId, ct); + + var embedding = _provider.GetFaceEmbedding(image); + + await _unitOfWork.UserRepository.UpdateFaceEmbeddingAsync(userId, ToBytes(embedding), ct); + + return new FaceRegisterResponse(userId, DateTime.UtcNow); + } + + public async Task VerifyFaceAsync(Guid userId, byte[] image, CancellationToken ct = default) + { + ValidateImage(image); + var user = await GetUserAsync(userId, ct); + + if (user.FaceEmbedding is null || user.FaceEmbedding.Length == 0) + { + throw new BadRequestException("User has no registered face. Register a face before verifying."); + } + + var storedEmbedding = ToFloats(user.FaceEmbedding); + var currentEmbedding = _provider.GetFaceEmbedding(image); + + var similarity = _provider.Compare(storedEmbedding, currentEmbedding); + var isMatch = similarity >= FacialRules.MatchThreshold; + + return new FaceVerificationResponse(userId, isMatch, MathF.Round(similarity, 4), DateTime.UtcNow); + } + + private async Task GetUserAsync(Guid userId, CancellationToken ct) + => await _unitOfWork.UserRepository.GetByIdAsync(userId, ct) + ?? throw new NotFoundException($"User with id: {userId} not found!"); + + private static void ValidateImage(byte[] image) + { + if (image is null || image.Length == 0) + { + throw new BadRequestException("Image is required."); + } + + if (image.Length > FacialRules.MaxImageBytes) + { + throw new BadRequestException($"Image must have at most {FacialRules.MaxImageBytes / (1024 * 1024)} MB."); + } + } + + // O embedding (512 floats) é guardado no banco como bytes + private static byte[] ToBytes(float[] embedding) + => MemoryMarshal.AsBytes(embedding.AsSpan()).ToArray(); + + private static float[] ToFloats(byte[] bytes) + => MemoryMarshal.Cast(bytes.AsSpan()).ToArray(); +} \ No newline at end of file diff --git a/Carbon.Infrastructure/Carbon.Infrastructure.csproj b/Carbon.Infrastructure/Carbon.Infrastructure.csproj index e0ea495..af278f5 100644 --- a/Carbon.Infrastructure/Carbon.Infrastructure.csproj +++ b/Carbon.Infrastructure/Carbon.Infrastructure.csproj @@ -18,6 +18,7 @@ + diff --git a/Carbon.Infrastructure/CarbonInfrastructure.cs b/Carbon.Infrastructure/CarbonInfrastructure.cs index 1d191d4..bfa53e3 100644 --- a/Carbon.Infrastructure/CarbonInfrastructure.cs +++ b/Carbon.Infrastructure/CarbonInfrastructure.cs @@ -1,3 +1,5 @@ +using Carbon.Azure.Facial.Clients; +using Carbon.Domain.Contracts.Providers.Facial; using System.Text; using Carbon.Domain.Contracts.Data; using Carbon.Domain.Contracts.Data.Repositories; @@ -51,6 +53,12 @@ public static void AddTImpact(this IServiceCollection services, IConfiguration c client.BaseAddress = uri; }); } + public static void AddFacialRecognition(this IServiceCollection services) + { + // Singleton: os modelos de IA são pesados e carregam uma vez só + services.AddSingleton(); + services.AddTransient(); + } public static void AddJwtAuthentication(this IServiceCollection services, IConfiguration configuration) { diff --git a/Carbon.Infrastructure/Data/Configuration/UserEntityTypeConfiguration.cs b/Carbon.Infrastructure/Data/Configuration/UserEntityTypeConfiguration.cs index c483dd9..b2419ae 100644 --- a/Carbon.Infrastructure/Data/Configuration/UserEntityTypeConfiguration.cs +++ b/Carbon.Infrastructure/Data/Configuration/UserEntityTypeConfiguration.cs @@ -33,5 +33,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.IsActive) .HasDefaultValue(true); + builder.Property(e => e.FaceEmbedding) + .HasMaxLength(2048); } } \ No newline at end of file diff --git a/Carbon.Infrastructure/Data/Migrations/20260927234500_AddUserFaceEmbedding.cs b/Carbon.Infrastructure/Data/Migrations/20260927234500_AddUserFaceEmbedding.cs new file mode 100644 index 0000000..b11b058 --- /dev/null +++ b/Carbon.Infrastructure/Data/Migrations/20260927234500_AddUserFaceEmbedding.cs @@ -0,0 +1,32 @@ +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Data.Migrations +{ + [DbContext(typeof(CarbonDbContext))] + [Migration("20260927234500_AddUserFaceEmbedding")] + public partial class AddUserFaceEmbedding : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FaceEmbedding", + table: "CarbonUsers", + type: "varbinary(2048)", + maxLength: 2048, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "FaceEmbedding", + table: "CarbonUsers"); + } + } +} \ No newline at end of file diff --git a/Carbon.Infrastructure/Data/Migrations/CarbonDbContextModelSnapshot.cs b/Carbon.Infrastructure/Data/Migrations/CarbonDbContextModelSnapshot.cs index 43c0398..b4ba8a0 100644 --- a/Carbon.Infrastructure/Data/Migrations/CarbonDbContextModelSnapshot.cs +++ b/Carbon.Infrastructure/Data/Migrations/CarbonDbContextModelSnapshot.cs @@ -115,6 +115,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasMaxLength(60) .HasColumnType("nvarchar(60)"); + b.Property("FaceEmbedding") + .HasMaxLength(2048) + .HasColumnType("varbinary(2048)"); b.Property("IsActive") .ValueGeneratedOnAdd() diff --git a/Carbon.Infrastructure/Data/Repositories/UserRepository.cs b/Carbon.Infrastructure/Data/Repositories/UserRepository.cs index e47126a..402385e 100644 --- a/Carbon.Infrastructure/Data/Repositories/UserRepository.cs +++ b/Carbon.Infrastructure/Data/Repositories/UserRepository.cs @@ -33,10 +33,17 @@ public async Task> GetAllAsync() .FirstOrDefaultAsync(e => e.Id == id); } - public Task GetByEmailAsync(string email, CancellationToken ct = default) + public Task GetByEmailAsync(string email, CancellationToken ct = default) { return _context.Users .Include(e => e.Roles) .FirstOrDefaultAsync(e => e.Email == email, ct); } + + public async Task UpdateFaceEmbeddingAsync(Guid userId, byte[] faceEmbedding, CancellationToken ct = default) + { + await _context.Users + .Where(e => e.Id == userId) + .ExecuteUpdateAsync(s => s.SetProperty(e => e.FaceEmbedding, faceEmbedding), ct); + } } \ No newline at end of file diff --git a/Carbon.Infrastructure/Providers/FacialRecognitionProvider.cs b/Carbon.Infrastructure/Providers/FacialRecognitionProvider.cs new file mode 100644 index 0000000..8692b50 --- /dev/null +++ b/Carbon.Infrastructure/Providers/FacialRecognitionProvider.cs @@ -0,0 +1,31 @@ +using Carbon.Azure.Facial.Clients; +using Carbon.Domain.Contracts.Providers.Facial; +using Carbon.Domain.Exceptions; + +namespace Infrastructure.Providers; + +public class FacialRecognitionProvider : IFacialRecognitionProvider +{ + private readonly IFaceRecognitionClient _client; + + public FacialRecognitionProvider(IFaceRecognitionClient client) + { + _client = client; + } + + public float[] GetFaceEmbedding(byte[] image) + { + var result = _client.GetEmbedding(image); + + return result.Status switch + { + FaceEmbeddingStatus.Success when result.Embedding is not null => result.Embedding, + FaceEmbeddingStatus.InvalidImage => throw new BadRequestException("Invalid image. Send a JPG or PNG photo."), + FaceEmbeddingStatus.NoFaceFound => throw new BadRequestException("No face found in the image. Try again with better lighting and your face centered."), + FaceEmbeddingStatus.MultipleFacesFound => throw new BadRequestException("More than one face found in the image. Only one person must appear in the photo."), + _ => throw new InternalServerError("Could not process the face image.") + }; + } + + public float Compare(float[] first, float[] second) => _client.Compare(first, second); +} \ No newline at end of file