UDT-001: Login (scaffolding + JWT RS256 end-to-end) #1

Merged
dmolinari merged 14 commits from feature/UDT-001 into main 2026-04-14 14:44:28 +00:00
9 changed files with 269 additions and 0 deletions
Showing only changes of commit 9891f96618 - Show all commits

View File

@@ -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<LoginCommand> _validator;
public AuthController(IDispatcher dispatcher, IValidator<LoginCommand> validator)
{
_dispatcher = dispatcher;
_validator = validator;
}
/// <summary>Authenticates a user and returns a JWT access token.</summary>
/// <response code="200">Returns access token and refresh token.</response>
/// <response code="400">Validation error — missing or empty fields.</response>
/// <response code="401">Invalid credentials.</response>
[HttpPost("login")]
[ProducesResponseType(typeof(LoginResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> 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<LoginCommand, LoginResponseDto>(command);
return Ok(result);
}
}
/// <summary>Login request body — nullable to catch missing field scenarios.</summary>
public sealed record LoginRequest(string? Username, string? Password);

View File

@@ -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<ExceptionFilter> _logger;
public ExceptionFilter(ILogger<ExceptionFilter> 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;
}
}
}

View File

@@ -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<ExceptionFilter>();
});
// OpenAPI / Scalar
builder.Services.AddOpenApi();
// CORS
var allowedOrigins = builder.Configuration
.GetSection("Cors:AllowedOrigins")
.Get<string[]>() ?? [];
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 { }

View File

@@ -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"
}
}
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>SIGCM2.Api</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Sinks.Seq" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Scalar.AspNetCore" />
<PackageReference Include="FluentValidation.AspNetCore" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SIGCM2.Application\SIGCM2.Application.csproj" />
<ProjectReference Include="..\SIGCM2.Infrastructure\SIGCM2.Infrastructure.csproj" />
<ProjectReference Include="..\SIGCM2.Domain\SIGCM2.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@SIGCM2.Api_HostAddress = http://localhost:5212
GET {{SIGCM2.Api_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,13 @@
{
"ConnectionStrings": {
"SqlServer": "Server=<YOUR_SERVER>;Database=SIGCM2;User Id=<YOUR_USER>;Password=<YOUR_PASSWORD>;TrustServerCertificate=True;"
},
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft.AspNetCore": "Information"
}
}
}
}

View File

@@ -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": "*"
}

View File