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

21
Backend/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
EXPOSE 8080
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY PruebaGentle.Core/PruebaGentle.Core.csproj PruebaGentle.Core/
COPY PruebaGentle.Infrastructure/PruebaGentle.Infrastructure.csproj PruebaGentle.Infrastructure/
COPY PruebaGentle.API/PruebaGentle.API.csproj PruebaGentle.API/
RUN dotnet restore PruebaGentle.API/PruebaGentle.API.csproj
COPY . .
WORKDIR /src/PruebaGentle.API
RUN dotnet build -c Release -o /app/build
FROM build AS publish
RUN dotnet publish -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "PruebaGentle.API.dll"]

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

View File

@@ -0,0 +1,7 @@
namespace PruebaGentle.Core.Config;
public class JwtSettings
{
public string Secret { get; set; } = string.Empty;
public int ExpirationHours { get; set; } = 24;
}

View File

@@ -0,0 +1,9 @@
namespace PruebaGentle.Core.DTOs;
public class CreateUserDto
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string NombreCompleto { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,7 @@
namespace PruebaGentle.Core.DTOs;
public class LoginDto
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,7 @@
namespace PruebaGentle.Core.DTOs;
public class LoginResponseDto
{
public string Token { get; set; } = string.Empty;
public DateTime ExpiresAt { get; set; }
}

View File

@@ -0,0 +1,8 @@
namespace PruebaGentle.Core.DTOs;
public class UpdateUserDto
{
public string Username { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string NombreCompleto { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,10 @@
namespace PruebaGentle.Core.DTOs;
public class UserResponseDto
{
public int Id { get; set; }
public string Username { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string NombreCompleto { get; set; } = string.Empty;
public DateTime FechaCreacion { get; set; }
}

View File

@@ -0,0 +1,11 @@
namespace PruebaGentle.Core.Entities;
public class User
{
public int Id { get; set; }
public string Username { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string NombreCompleto { get; set; } = string.Empty;
public DateTime FechaCreacion { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace PruebaGentle.Core.Interfaces;
public interface IPasswordHasher
{
string Hash(string password);
bool Verify(string password, string hash);
}

View File

@@ -0,0 +1,13 @@
using PruebaGentle.Core.Entities;
namespace PruebaGentle.Core.Interfaces;
public interface IUserRepository
{
Task<User> CreateAsync(User user);
Task<User?> GetByIdAsync(int id);
Task<IEnumerable<User>> GetAllAsync();
Task<User?> UpdateAsync(User user);
Task<bool> DeleteAsync(int id);
Task<User?> GetByUsernameAsync(string username);
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\PruebaGentle.Core\PruebaGentle.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.1.0" />
<PackageReference Include="Dapper" Version="2.1.72" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.5" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,89 @@
using System.Data;
using Dapper;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using PruebaGentle.Core.Entities;
using PruebaGentle.Core.Interfaces;
namespace PruebaGentle.Infrastructure.Repositories;
public class UserRepository : IUserRepository
{
private readonly string _connectionString;
public UserRepository(IConfiguration configuration)
{
_connectionString = configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
}
private IDbConnection CreateConnection() => new SqlConnection(_connectionString);
public async Task<User> CreateAsync(User user)
{
using var connection = CreateConnection();
var result = await connection.QuerySingleAsync<User>(
"sp_User_Create",
new
{
user.Username,
user.PasswordHash,
user.Email,
user.NombreCompleto
},
commandType: CommandType.StoredProcedure);
return result;
}
public async Task<User?> GetByIdAsync(int id)
{
using var connection = CreateConnection();
return await connection.QuerySingleOrDefaultAsync<User>(
"sp_User_GetById",
new { Id = id },
commandType: CommandType.StoredProcedure);
}
public async Task<IEnumerable<User>> GetAllAsync()
{
using var connection = CreateConnection();
return await connection.QueryAsync<User>(
"sp_User_GetAll",
commandType: CommandType.StoredProcedure);
}
public async Task<User?> UpdateAsync(User user)
{
using var connection = CreateConnection();
return await connection.QuerySingleOrDefaultAsync<User>(
"sp_User_Update",
new
{
user.Id,
user.Username,
user.Email,
user.NombreCompleto
},
commandType: CommandType.StoredProcedure);
}
public async Task<bool> DeleteAsync(int id)
{
using var connection = CreateConnection();
var affected = await connection.ExecuteAsync(
"sp_User_Delete",
new { Id = id },
commandType: CommandType.StoredProcedure);
return affected > 0;
}
public async Task<User?> GetByUsernameAsync(string username)
{
using var connection = CreateConnection();
return await connection.QuerySingleOrDefaultAsync<User>(
"sp_User_GetByUsername",
new { Username = username },
commandType: CommandType.StoredProcedure);
}
}

View File

@@ -0,0 +1,16 @@
using PruebaGentle.Core.Interfaces;
namespace PruebaGentle.Infrastructure.Services;
public class PasswordHasher : IPasswordHasher
{
public string Hash(string password)
{
return BCrypt.Net.BCrypt.HashPassword(password);
}
public bool Verify(string password, string hash)
{
return BCrypt.Net.BCrypt.Verify(password, hash);
}
}

View File

@@ -0,0 +1,5 @@
<Solution>
<Project Path="PruebaGentle.API/PruebaGentle.API.csproj" />
<Project Path="PruebaGentle.Core/PruebaGentle.Core.csproj" />
<Project Path="PruebaGentle.Infrastructure/PruebaGentle.Infrastructure.csproj" />
</Solution>

View File

@@ -0,0 +1,8 @@
CREATE TABLE Users (
Id INT IDENTITY(1,1) PRIMARY KEY,
Username NVARCHAR(50) NOT NULL UNIQUE,
PasswordHash NVARCHAR(255) NOT NULL,
Email NVARCHAR(100) NOT NULL UNIQUE,
NombreCompleto NVARCHAR(100) NOT NULL,
FechaCreacion DATETIME NOT NULL DEFAULT GETDATE()
);

View File

@@ -0,0 +1,13 @@
CREATE OR ALTER PROCEDURE sp_User_Create
@Username NVARCHAR(50),
@PasswordHash NVARCHAR(255),
@Email NVARCHAR(100),
@NombreCompleto NVARCHAR(100)
AS
BEGIN
SET NOCOUNT ON;
INSERT INTO Users (Username, PasswordHash, Email, NombreCompleto)
OUTPUT INSERTED.Id, INSERTED.Username, INSERTED.Email, INSERTED.NombreCompleto, INSERTED.FechaCreacion
VALUES (@Username, @PasswordHash, @Email, @NombreCompleto);
END

View File

@@ -0,0 +1,9 @@
CREATE OR ALTER PROCEDURE sp_User_Delete
@Id INT
AS
BEGIN
SET NOCOUNT ON;
DELETE FROM Users
WHERE Id = @Id;
END

View File

@@ -0,0 +1,9 @@
CREATE OR ALTER PROCEDURE sp_User_GetAll
AS
BEGIN
SET NOCOUNT ON;
SELECT Id, Username, Email, NombreCompleto, FechaCreacion
FROM Users
ORDER BY FechaCreacion DESC;
END

View File

@@ -0,0 +1,10 @@
CREATE OR ALTER PROCEDURE sp_User_GetById
@Id INT
AS
BEGIN
SET NOCOUNT ON;
SELECT Id, Username, Email, NombreCompleto, FechaCreacion
FROM Users
WHERE Id = @Id;
END

View File

@@ -0,0 +1,10 @@
CREATE OR ALTER PROCEDURE sp_User_GetByUsername
@Username NVARCHAR(50)
AS
BEGIN
SET NOCOUNT ON;
SELECT Id, Username, PasswordHash, Email, NombreCompleto, FechaCreacion
FROM Users
WHERE Username = @Username;
END

View File

@@ -0,0 +1,16 @@
CREATE OR ALTER PROCEDURE sp_User_Update
@Id INT,
@Username NVARCHAR(50),
@Email NVARCHAR(100),
@NombreCompleto NVARCHAR(100)
AS
BEGIN
SET NOCOUNT ON;
UPDATE Users
SET Username = @Username,
Email = @Email,
NombreCompleto = @NombreCompleto
OUTPUT INSERTED.Id, INSERTED.Username, INSERTED.Email, INSERTED.NombreCompleto, INSERTED.FechaCreacion
WHERE Id = @Id;
END