diff --git a/src/api/SIGCM2.Api/Controllers/AuthController.cs b/src/api/SIGCM2.Api/Controllers/AuthController.cs new file mode 100644 index 0000000..253ba49 --- /dev/null +++ b/src/api/SIGCM2.Api/Controllers/AuthController.cs @@ -0,0 +1,48 @@ +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using SIGCM2.Application.Abstractions; +using SIGCM2.Application.Auth.Login; + +namespace SIGCM2.Api.Controllers; + +[ApiController] +[Route("api/v1/auth")] +public sealed class AuthController : ControllerBase +{ + private readonly IDispatcher _dispatcher; + private readonly IValidator _validator; + + public AuthController(IDispatcher dispatcher, IValidator validator) + { + _dispatcher = dispatcher; + _validator = validator; + } + + /// Authenticates a user and returns a JWT access token. + /// Returns access token and refresh token. + /// Validation error — missing or empty fields. + /// Invalid credentials. + [HttpPost("login")] + [ProducesResponseType(typeof(LoginResponseDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + public async Task Login([FromBody] LoginRequest request) + { + var command = new LoginCommand(request.Username ?? string.Empty, request.Password ?? string.Empty); + + var validation = await _validator.ValidateAsync(command); + if (!validation.IsValid) + { + var errors = validation.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray()); + return BadRequest(new { errors }); + } + + var result = await _dispatcher.Send(command); + return Ok(result); + } +} + +/// Login request body — nullable to catch missing field scenarios. +public sealed record LoginRequest(string? Username, string? Password); diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs new file mode 100644 index 0000000..549e28b --- /dev/null +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -0,0 +1,50 @@ +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using SIGCM2.Domain.Exceptions; + +namespace SIGCM2.Api.Filters; + +public sealed class ExceptionFilter : IExceptionFilter +{ + private readonly ILogger _logger; + + public ExceptionFilter(ILogger logger) + { + _logger = logger; + } + + public void OnException(ExceptionContext context) + { + switch (context.Exception) + { + case InvalidCredentialsException: + context.Result = new ObjectResult(new { error = "Credenciales inválidas" }) + { + StatusCode = StatusCodes.Status401Unauthorized + }; + context.ExceptionHandled = true; + break; + + case ValidationException validationEx: + var errors = validationEx.Errors + .GroupBy(e => e.PropertyName) + .ToDictionary( + g => g.Key, + g => g.Select(e => e.ErrorMessage).ToArray()); + + context.Result = new BadRequestObjectResult(new { errors }); + context.ExceptionHandled = true; + break; + + default: + _logger.LogError(context.Exception, "Unhandled exception"); + context.Result = new ObjectResult(new { error = "Internal server error" }) + { + StatusCode = StatusCodes.Status500InternalServerError + }; + context.ExceptionHandled = true; + break; + } + } +} diff --git a/src/api/SIGCM2.Api/Program.cs b/src/api/SIGCM2.Api/Program.cs new file mode 100644 index 0000000..dfe90f5 --- /dev/null +++ b/src/api/SIGCM2.Api/Program.cs @@ -0,0 +1,69 @@ +using Serilog; +using Scalar.AspNetCore; +using SIGCM2.Application; +using SIGCM2.Infrastructure; +using SIGCM2.Api.Filters; + +// Bootstrap logger — before DI is built +Log.Logger = new LoggerConfiguration() + .WriteTo.Console() + .CreateBootstrapLogger(); + +Log.Information("Starting SIGCM2 API"); + +var builder = WebApplication.CreateBuilder(args); + +// Serilog — reads from appsettings.json "Serilog" section +builder.Host.UseSerilog((ctx, lc) => lc + .ReadFrom.Configuration(ctx.Configuration)); + +// Application + Infrastructure DI +builder.Services.AddApplication(); +builder.Services.AddInfrastructure(builder.Configuration); + +// Controllers with exception filter +builder.Services.AddControllers(opts => +{ + opts.Filters.Add(); +}); + +// OpenAPI / Scalar +builder.Services.AddOpenApi(); + +// CORS +var allowedOrigins = builder.Configuration + .GetSection("Cors:AllowedOrigins") + .Get() ?? []; + +builder.Services.AddCors(opts => +{ + opts.AddDefaultPolicy(policy => + policy.WithOrigins(allowedOrigins) + .AllowAnyHeader() + .AllowAnyMethod()); +}); + +var app = builder.Build(); + +// Middleware pipeline +app.UseSerilogRequestLogging(); + +if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing")) +{ + app.MapOpenApi(); + app.MapScalarApiReference(opts => + { + opts.Title = "SIGCM2 API"; + }); +} + +app.UseHttpsRedirection(); +app.UseCors(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); + +// Exposed for WebApplicationFactory in integration tests +public partial class Program { } diff --git a/src/api/SIGCM2.Api/Properties/launchSettings.json b/src/api/SIGCM2.Api/Properties/launchSettings.json new file mode 100644 index 0000000..108dc0e --- /dev/null +++ b/src/api/SIGCM2.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5212", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7280;http://localhost:5212", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/api/SIGCM2.Api/SIGCM2.Api.csproj b/src/api/SIGCM2.Api/SIGCM2.Api.csproj new file mode 100644 index 0000000..b2bbba3 --- /dev/null +++ b/src/api/SIGCM2.Api/SIGCM2.Api.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + SIGCM2.Api + + + + + + + + + + + + + + + + + + diff --git a/src/api/SIGCM2.Api/SIGCM2.Api.http b/src/api/SIGCM2.Api/SIGCM2.Api.http new file mode 100644 index 0000000..4d999c9 --- /dev/null +++ b/src/api/SIGCM2.Api/SIGCM2.Api.http @@ -0,0 +1,6 @@ +@SIGCM2.Api_HostAddress = http://localhost:5212 + +GET {{SIGCM2.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/src/api/SIGCM2.Api/appsettings.Development.json.example b/src/api/SIGCM2.Api/appsettings.Development.json.example new file mode 100644 index 0000000..11edeb9 --- /dev/null +++ b/src/api/SIGCM2.Api/appsettings.Development.json.example @@ -0,0 +1,13 @@ +{ + "ConnectionStrings": { + "SqlServer": "Server=;Database=SIGCM2;User Id=;Password=;TrustServerCertificate=True;" + }, + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft.AspNetCore": "Information" + } + } + } +} diff --git a/src/api/SIGCM2.Api/appsettings.json b/src/api/SIGCM2.Api/appsettings.json new file mode 100644 index 0000000..8666553 --- /dev/null +++ b/src/api/SIGCM2.Api/appsettings.json @@ -0,0 +1,35 @@ +{ + "ConnectionStrings": { + "SqlServer": "Server=__SET_IN_DEV_OR_ENV__;Database=SIGCM2;User Id=__SET_IN_DEV_OR_ENV__;Password=__SET_IN_DEV_OR_ENV__;TrustServerCertificate=True;" + }, + "Jwt": { + "Issuer": "sigcm2.api", + "Audience": "sigcm2.web", + "AccessTokenMinutes": 60, + "PrivateKeyPath": "keys/private.pem", + "PublicKeyPath": "keys/public.pem", + "PrivateKey": null, + "PublicKey": null + }, + "Cors": { + "AllowedOrigins": [ "http://localhost:5173" ] + }, + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "WriteTo": [ + { "Name": "Console" }, + { + "Name": "Seq", + "Args": { "serverUrl": "http://localhost:5341" } + } + ], + "Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ] + }, + "AllowedHosts": "*" +} diff --git a/src/api/SIGCM2.Api/keys/.gitkeep b/src/api/SIGCM2.Api/keys/.gitkeep new file mode 100644 index 0000000..e69de29