feat: Sistema de Usuarios - Backend CRUD + JWT Auth (Issue #1)

Implementación fundacional del proyecto PruebaGentle:
- Arquitectura Clean/Hexagonal: Core, Infrastructure, API
- 6 Stored Procedures para CRUD + autenticación
- JWT authentication con BCrypt password hashing
- Docker Compose (SQL Server + Backend)
- Solución .NET 10 con Dapper + SqlClient

Closes #1
This commit is contained in:
2026-03-31 17:36:04 -03:00
commit 21e7e7b044
34 changed files with 795 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using PruebaGentle.Core.Config;
using PruebaGentle.Core.DTOs;
using PruebaGentle.Core.Interfaces;
namespace PruebaGentle.API.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
private readonly IUserRepository _userRepository;
private readonly IPasswordHasher _passwordHasher;
private readonly JwtSettings _jwtSettings;
public AuthController(
IUserRepository userRepository,
IPasswordHasher passwordHasher,
IOptions<JwtSettings> jwtSettings)
{
_userRepository = userRepository;
_passwordHasher = passwordHasher;
_jwtSettings = jwtSettings.Value;
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginDto dto)
{
var user = await _userRepository.GetByUsernameAsync(dto.Username);
if (user == null)
return Unauthorized(new { error = "Credenciales inválidas." });
if (!_passwordHasher.Verify(dto.Password, user.PasswordHash))
return Unauthorized(new { error = "Credenciales inválidas." });
var expiresAt = DateTime.UtcNow.AddHours(_jwtSettings.ExpirationHours);
var token = GenerateJwtToken(user.Id, user.Username, user.Email, expiresAt);
return Ok(new LoginResponseDto
{
Token = token,
ExpiresAt = expiresAt
});
}
private string GenerateJwtToken(int userId, string username, string email, DateTime expiresAt)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.Secret));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
new Claim(JwtRegisteredClaimNames.UniqueName, username),
new Claim(JwtRegisteredClaimNames.Email, email),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
var token = new JwtSecurityToken(
issuer: "PruebaGentle",
audience: "PruebaGentle",
claims: claims,
expires: expiresAt,
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}

View File

@@ -0,0 +1,106 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PruebaGentle.Core.DTOs;
using PruebaGentle.Core.Entities;
using PruebaGentle.Core.Interfaces;
namespace PruebaGentle.API.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class UsersController : ControllerBase
{
private readonly IUserRepository _userRepository;
private readonly IPasswordHasher _passwordHasher;
public UsersController(IUserRepository userRepository, IPasswordHasher passwordHasher)
{
_userRepository = userRepository;
_passwordHasher = passwordHasher;
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateUserDto dto)
{
var existingUser = await _userRepository.GetByUsernameAsync(dto.Username);
if (existingUser != null)
return BadRequest(new { error = "El username ya existe." });
var user = new User
{
Username = dto.Username,
PasswordHash = _passwordHasher.Hash(dto.Password),
Email = dto.Email,
NombreCompleto = dto.NombreCompleto
};
var created = await _userRepository.CreateAsync(user);
return CreatedAtAction(nameof(GetById), new { id = created.Id }, MapToResponse(created));
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
var users = await _userRepository.GetAllAsync();
return Ok(users.Select(MapToResponse));
}
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
{
var user = await _userRepository.GetByIdAsync(id);
if (user == null)
return NotFound(new { error = "Usuario no encontrado." });
return Ok(MapToResponse(user));
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, [FromBody] UpdateUserDto dto)
{
var existing = await _userRepository.GetByIdAsync(id);
if (existing == null)
return NotFound(new { error = "Usuario no encontrado." });
// Check username/email conflicts with other users
var duplicateUser = await _userRepository.GetByUsernameAsync(dto.Username);
if (duplicateUser != null && duplicateUser.Id != id)
return BadRequest(new { error = "El username ya está en uso por otro usuario." });
var user = new User
{
Id = id,
Username = dto.Username,
Email = dto.Email,
NombreCompleto = dto.NombreCompleto
};
var updated = await _userRepository.UpdateAsync(user);
return Ok(MapToResponse(updated!));
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
var existing = await _userRepository.GetByIdAsync(id);
if (existing == null)
return NotFound(new { error = "Usuario no encontrado." });
await _userRepository.DeleteAsync(id);
return NoContent();
}
private static UserResponseDto MapToResponse(User user)
{
return new UserResponseDto
{
Id = user.Id,
Username = user.Username,
Email = user.Email,
NombreCompleto = user.NombreCompleto,
FechaCreacion = user.FechaCreacion
};
}
}

View File

@@ -0,0 +1,74 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using PruebaGentle.Core.Config;
using PruebaGentle.Core.Interfaces;
using PruebaGentle.Infrastructure.Repositories;
using PruebaGentle.Infrastructure.Services;
var builder = WebApplication.CreateBuilder(args);
// Bind JwtSettings
builder.Services.Configure<JwtSettings>(
builder.Configuration.GetSection("JwtSettings"));
// Dependency Injection
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddSingleton<IPasswordHasher, PasswordHasher>();
// JWT Authentication
var jwtSettings = builder.Configuration.GetSection("JwtSettings");
var secretKey = jwtSettings["Secret"] ?? throw new InvalidOperationException("JWT Secret not configured.");
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = "PruebaGentle",
ValidAudience = "PruebaGentle",
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey))
};
});
builder.Services.AddAuthorization();
// Controllers
builder.Services.AddControllers();
// OpenAPI (native .NET 10)
builder.Services.AddOpenApi();
// CORS
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
var app = builder.Build();
// Middleware pipeline
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

View File

@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5082",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PruebaGentle.Core\PruebaGentle.Core.csproj" />
<ProjectReference Include="..\PruebaGentle.Infrastructure\PruebaGentle.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@PruebaGentle.API_HostAddress = http://localhost:5082
GET {{PruebaGentle.API_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,16 @@
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=PruebaGentle;User Id=sa;Password=YourStrong@Password123;TrustServerCertificate=true;"
},
"JwtSettings": {
"Secret": "ThisIsA32CharacterLongSecretKey!!",
"ExpirationHours": 24
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}