From cf61fb70e3691d4448d7345d8575ae8a3d47589d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Eduardo=20Garc=C3=ADa=20M=C3=A1rquez?= Date: Thu, 8 Jan 2026 09:14:42 -0500 Subject: [PATCH] feat(backend): implement JWT authentication and authorization - Add User entity with roles (Admin, Student) - Create JWT service for token generation/validation - Create password service using PBKDF2 - Add login and register GraphQL mutations - Apply [Authorize] attributes to protected mutations - DeleteStudent requires Admin role - UpdateStudent/Enroll/Unenroll require owner or admin - Add admin user creation on startup --- .../Adapters.Driven.Persistence.csproj | 1 + .../Configurations/UserConfiguration.cs | 38 ++ .../Persistence/Context/AppDbContext.cs | 1 + .../Driven/Persistence/DependencyInjection.cs | 8 + .../20260108135459_AddUsersTable.Designer.cs | 325 ++++++++++++++++++ .../20260108135459_AddUsersTable.cs | 57 +++ .../Migrations/AppDbContextModelSnapshot.cs | 52 +++ .../Repositories/UserRepository.cs | 41 +++ .../Driven/Persistence/Services/JwtService.cs | 72 ++++ .../Persistence/Services/PasswordService.cs | 63 ++++ .../Driving/Api/Adapters.Driving.Api.csproj | 1 + .../Api/Extensions/GraphQLExtensions.cs | 6 + .../Driving/Api/Types/Auth/AuthMutations.cs | 34 ++ .../Driving/Api/Types/Auth/AuthQueries.cs | 36 ++ .../Adapters/Driving/Api/Types/Mutation.cs | 123 ++++++- .../Application/Auth/Commands/LoginCommand.cs | 42 +++ .../Auth/Commands/RegisterCommand.cs | 78 +++++ src/backend/Application/Auth/DTOs/AuthDtos.cs | 20 ++ src/backend/Application/Auth/IJwtService.cs | 12 + .../Application/Auth/IPasswordService.cs | 10 + src/backend/Application/Auth/JwtOptions.cs | 15 + src/backend/Domain/Entities/User.cs | 61 ++++ .../Ports/Repositories/IUserRepository.cs | 15 + src/backend/Host/Host.csproj | 1 + src/backend/Host/Program.cs | 75 +++- 25 files changed, 1175 insertions(+), 12 deletions(-) create mode 100644 src/backend/Adapters/Driven/Persistence/Configurations/UserConfiguration.cs create mode 100644 src/backend/Adapters/Driven/Persistence/Migrations/20260108135459_AddUsersTable.Designer.cs create mode 100644 src/backend/Adapters/Driven/Persistence/Migrations/20260108135459_AddUsersTable.cs create mode 100644 src/backend/Adapters/Driven/Persistence/Repositories/UserRepository.cs create mode 100644 src/backend/Adapters/Driven/Persistence/Services/JwtService.cs create mode 100644 src/backend/Adapters/Driven/Persistence/Services/PasswordService.cs create mode 100644 src/backend/Adapters/Driving/Api/Types/Auth/AuthMutations.cs create mode 100644 src/backend/Adapters/Driving/Api/Types/Auth/AuthQueries.cs create mode 100644 src/backend/Application/Auth/Commands/LoginCommand.cs create mode 100644 src/backend/Application/Auth/Commands/RegisterCommand.cs create mode 100644 src/backend/Application/Auth/DTOs/AuthDtos.cs create mode 100644 src/backend/Application/Auth/IJwtService.cs create mode 100644 src/backend/Application/Auth/IPasswordService.cs create mode 100644 src/backend/Application/Auth/JwtOptions.cs create mode 100644 src/backend/Domain/Entities/User.cs create mode 100644 src/backend/Domain/Ports/Repositories/IUserRepository.cs diff --git a/src/backend/Adapters/Driven/Persistence/Adapters.Driven.Persistence.csproj b/src/backend/Adapters/Driven/Persistence/Adapters.Driven.Persistence.csproj index 4a9ad77..50dd540 100644 --- a/src/backend/Adapters/Driven/Persistence/Adapters.Driven.Persistence.csproj +++ b/src/backend/Adapters/Driven/Persistence/Adapters.Driven.Persistence.csproj @@ -8,6 +8,7 @@ + diff --git a/src/backend/Adapters/Driven/Persistence/Configurations/UserConfiguration.cs b/src/backend/Adapters/Driven/Persistence/Configurations/UserConfiguration.cs new file mode 100644 index 0000000..9299a5f --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/Configurations/UserConfiguration.cs @@ -0,0 +1,38 @@ +using Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Adapters.Driven.Persistence.Configurations; + +public class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Users"); + + builder.HasKey(u => u.Id); + + builder.Property(u => u.Username) + .IsRequired() + .HasMaxLength(50); + + builder.HasIndex(u => u.Username) + .IsUnique(); + + builder.Property(u => u.PasswordHash) + .IsRequired() + .HasMaxLength(256); + + builder.Property(u => u.Role) + .IsRequired() + .HasMaxLength(20); + + builder.Property(u => u.CreatedAt) + .IsRequired(); + + builder.HasOne(u => u.Student) + .WithMany() + .HasForeignKey(u => u.StudentId) + .OnDelete(DeleteBehavior.SetNull); + } +} diff --git a/src/backend/Adapters/Driven/Persistence/Context/AppDbContext.cs b/src/backend/Adapters/Driven/Persistence/Context/AppDbContext.cs index 10a568a..59446ba 100644 --- a/src/backend/Adapters/Driven/Persistence/Context/AppDbContext.cs +++ b/src/backend/Adapters/Driven/Persistence/Context/AppDbContext.cs @@ -10,6 +10,7 @@ public class AppDbContext(DbContextOptions options) : DbContext(op public DbSet Subjects => Set(); public DbSet Professors => Set(); public DbSet Enrollments => Set(); + public DbSet Users => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/src/backend/Adapters/Driven/Persistence/DependencyInjection.cs b/src/backend/Adapters/Driven/Persistence/DependencyInjection.cs index c58fcbc..2c43b87 100644 --- a/src/backend/Adapters/Driven/Persistence/DependencyInjection.cs +++ b/src/backend/Adapters/Driven/Persistence/DependencyInjection.cs @@ -2,6 +2,8 @@ namespace Adapters.Driven.Persistence; using Adapters.Driven.Persistence.Context; using Adapters.Driven.Persistence.Repositories; +using Adapters.Driven.Persistence.Services; +using Application.Auth; using Domain.Ports.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -18,12 +20,18 @@ public static class DependencyInjection configuration.GetConnectionString("DefaultConnection"), b => b.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName))); + // Repositories services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); + // Auth services + services.AddScoped(); + services.AddScoped(); + return services; } } diff --git a/src/backend/Adapters/Driven/Persistence/Migrations/20260108135459_AddUsersTable.Designer.cs b/src/backend/Adapters/Driven/Persistence/Migrations/20260108135459_AddUsersTable.Designer.cs new file mode 100644 index 0000000..1821f32 --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/Migrations/20260108135459_AddUsersTable.Designer.cs @@ -0,0 +1,325 @@ +// +using System; +using Adapters.Driven.Persistence.Context; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Adapters.Driven.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260108135459_AddUsersTable")] + partial class AddUsersTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.Enrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EnrolledAt") + .HasColumnType("datetime2"); + + b.Property("StudentId") + .HasColumnType("int"); + + b.Property("SubjectId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SubjectId"); + + b.HasIndex("StudentId", "SubjectId") + .IsUnique(); + + b.ToTable("Enrollments", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Professor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.ToTable("Professors", (string)null); + + b.HasData( + new + { + Id = 1, + Name = "Dr. García" + }, + new + { + Id = 2, + Name = "Dra. Martínez" + }, + new + { + Id = 3, + Name = "Dr. López" + }, + new + { + Id = 4, + Name = "Dra. Rodríguez" + }, + new + { + Id = 5, + Name = "Dr. Hernández" + }); + }); + + modelBuilder.Entity("Domain.Entities.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Email") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Students", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Subject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Credits") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ProfessorId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProfessorId"); + + b.ToTable("Subjects", (string)null); + + b.HasData( + new + { + Id = 1, + Credits = 3, + Name = "Matemáticas I", + ProfessorId = 1 + }, + new + { + Id = 2, + Credits = 3, + Name = "Matemáticas II", + ProfessorId = 1 + }, + new + { + Id = 3, + Credits = 3, + Name = "Física I", + ProfessorId = 2 + }, + new + { + Id = 4, + Credits = 3, + Name = "Física II", + ProfessorId = 2 + }, + new + { + Id = 5, + Credits = 3, + Name = "Programación I", + ProfessorId = 3 + }, + new + { + Id = 6, + Credits = 3, + Name = "Programación II", + ProfessorId = 3 + }, + new + { + Id = 7, + Credits = 3, + Name = "Base de Datos I", + ProfessorId = 4 + }, + new + { + Id = 8, + Credits = 3, + Name = "Base de Datos II", + ProfessorId = 4 + }, + new + { + Id = 9, + Credits = 3, + Name = "Redes I", + ProfessorId = 5 + }, + new + { + Id = 10, + Credits = 3, + Name = "Redes II", + ProfessorId = 5 + }); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("LastLoginAt") + .HasColumnType("datetime2"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("StudentId") + .HasColumnType("int"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Enrollment", b => + { + b.HasOne("Domain.Entities.Student", "Student") + .WithMany("Enrollments") + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.Subject", "Subject") + .WithMany("Enrollments") + .HasForeignKey("SubjectId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Student"); + + b.Navigation("Subject"); + }); + + modelBuilder.Entity("Domain.Entities.Subject", b => + { + b.HasOne("Domain.Entities.Professor", "Professor") + .WithMany("Subjects") + .HasForeignKey("ProfessorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Professor"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.HasOne("Domain.Entities.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Domain.Entities.Professor", b => + { + b.Navigation("Subjects"); + }); + + modelBuilder.Entity("Domain.Entities.Student", b => + { + b.Navigation("Enrollments"); + }); + + modelBuilder.Entity("Domain.Entities.Subject", b => + { + b.Navigation("Enrollments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/backend/Adapters/Driven/Persistence/Migrations/20260108135459_AddUsersTable.cs b/src/backend/Adapters/Driven/Persistence/Migrations/20260108135459_AddUsersTable.cs new file mode 100644 index 0000000..5a36941 --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/Migrations/20260108135459_AddUsersTable.cs @@ -0,0 +1,57 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Adapters.Driven.Persistence.Migrations +{ + /// + public partial class AddUsersTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Username = table.Column(type: "nvarchar(50)", maxLength: 50, nullable: false), + PasswordHash = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + Role = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: false), + StudentId = table.Column(type: "int", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + LastLoginAt = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + table.ForeignKey( + name: "FK_Users_Students_StudentId", + column: x => x.StudentId, + principalTable: "Students", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "IX_Users_StudentId", + table: "Users", + column: "StudentId"); + + migrationBuilder.CreateIndex( + name: "IX_Users_Username", + table: "Users", + column: "Username", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/src/backend/Adapters/Driven/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/backend/Adapters/Driven/Persistence/Migrations/AppDbContextModelSnapshot.cs index 9a1c90e..122287f 100644 --- a/src/backend/Adapters/Driven/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/backend/Adapters/Driven/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -220,6 +220,48 @@ namespace Adapters.Driven.Persistence.Migrations }); }); + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("LastLoginAt") + .HasColumnType("datetime2"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("StudentId") + .HasColumnType("int"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + modelBuilder.Entity("Domain.Entities.Enrollment", b => { b.HasOne("Domain.Entities.Student", "Student") @@ -250,6 +292,16 @@ namespace Adapters.Driven.Persistence.Migrations b.Navigation("Professor"); }); + modelBuilder.Entity("Domain.Entities.User", b => + { + b.HasOne("Domain.Entities.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Student"); + }); + modelBuilder.Entity("Domain.Entities.Professor", b => { b.Navigation("Subjects"); diff --git a/src/backend/Adapters/Driven/Persistence/Repositories/UserRepository.cs b/src/backend/Adapters/Driven/Persistence/Repositories/UserRepository.cs new file mode 100644 index 0000000..009dd8f --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/Repositories/UserRepository.cs @@ -0,0 +1,41 @@ +using Adapters.Driven.Persistence.Context; +using Domain.Entities; +using Domain.Ports.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace Adapters.Driven.Persistence.Repositories; + +public class UserRepository(AppDbContext context) : IUserRepository +{ + public async Task GetByIdAsync(int id, CancellationToken ct = default) + { + return await context.Users + .Include(u => u.Student) + .FirstOrDefaultAsync(u => u.Id == id, ct); + } + + public async Task GetByUsernameAsync(string username, CancellationToken ct = default) + { + return await context.Users + .Include(u => u.Student) + .FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant(), ct); + } + + public async Task AddAsync(User user, CancellationToken ct = default) + { + await context.Users.AddAsync(user, ct); + return user; + } + + public async Task ExistsAsync(string username, CancellationToken ct = default) + { + return await context.Users + .AnyAsync(u => u.Username == username.ToLowerInvariant(), ct); + } + + public Task UpdateAsync(User user, CancellationToken ct = default) + { + context.Users.Update(user); + return Task.CompletedTask; + } +} diff --git a/src/backend/Adapters/Driven/Persistence/Services/JwtService.cs b/src/backend/Adapters/Driven/Persistence/Services/JwtService.cs new file mode 100644 index 0000000..c589616 --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/Services/JwtService.cs @@ -0,0 +1,72 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Application.Auth; +using Domain.Entities; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace Adapters.Driven.Persistence.Services; + +public class JwtService(IOptions options) : IJwtService +{ + private readonly JwtOptions _options = options.Value; + + public string GenerateToken(User user) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SecretKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new List + { + new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new(ClaimTypes.Name, user.Username), + new(ClaimTypes.Role, user.Role), + new("userId", user.Id.ToString()) + }; + + if (user.StudentId.HasValue) + { + claims.Add(new Claim("studentId", user.StudentId.Value.ToString())); + } + + var token = new JwtSecurityToken( + issuer: _options.Issuer, + audience: _options.Audience, + claims: claims, + expires: DateTime.UtcNow.AddMinutes(_options.ExpirationMinutes), + signingCredentials: credentials + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public int? ValidateToken(string token) + { + try + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SecretKey)); + var handler = new JwtSecurityTokenHandler(); + + var parameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = _options.Issuer, + ValidAudience = _options.Audience, + IssuerSigningKey = key + }; + + var principal = handler.ValidateToken(token, parameters, out _); + var userIdClaim = principal.FindFirst(ClaimTypes.NameIdentifier); + + return userIdClaim != null ? int.Parse(userIdClaim.Value) : null; + } + catch + { + return null; + } + } +} diff --git a/src/backend/Adapters/Driven/Persistence/Services/PasswordService.cs b/src/backend/Adapters/Driven/Persistence/Services/PasswordService.cs new file mode 100644 index 0000000..8fd4159 --- /dev/null +++ b/src/backend/Adapters/Driven/Persistence/Services/PasswordService.cs @@ -0,0 +1,63 @@ +using System.Security.Cryptography; +using Application.Auth; + +namespace Adapters.Driven.Persistence.Services; + +/// +/// Password hashing service using PBKDF2 with SHA-256. +/// +public class PasswordService : IPasswordService +{ + private const int SaltSize = 16; + private const int HashSize = 32; + private const int Iterations = 100000; + + public string HashPassword(string password) + { + var salt = RandomNumberGenerator.GetBytes(SaltSize); + var hash = Rfc2898DeriveBytes.Pbkdf2( + password, + salt, + Iterations, + HashAlgorithmName.SHA256, + HashSize + ); + + // Combine salt and hash: [salt][hash] + var combined = new byte[SaltSize + HashSize]; + Buffer.BlockCopy(salt, 0, combined, 0, SaltSize); + Buffer.BlockCopy(hash, 0, combined, SaltSize, HashSize); + + return Convert.ToBase64String(combined); + } + + public bool VerifyPassword(string password, string storedHash) + { + try + { + var combined = Convert.FromBase64String(storedHash); + + if (combined.Length != SaltSize + HashSize) + return false; + + var salt = new byte[SaltSize]; + var storedHashBytes = new byte[HashSize]; + Buffer.BlockCopy(combined, 0, salt, 0, SaltSize); + Buffer.BlockCopy(combined, SaltSize, storedHashBytes, 0, HashSize); + + var computedHash = Rfc2898DeriveBytes.Pbkdf2( + password, + salt, + Iterations, + HashAlgorithmName.SHA256, + HashSize + ); + + return CryptographicOperations.FixedTimeEquals(computedHash, storedHashBytes); + } + catch + { + return false; + } + } +} diff --git a/src/backend/Adapters/Driving/Api/Adapters.Driving.Api.csproj b/src/backend/Adapters/Driving/Api/Adapters.Driving.Api.csproj index f775099..49d179f 100644 --- a/src/backend/Adapters/Driving/Api/Adapters.Driving.Api.csproj +++ b/src/backend/Adapters/Driving/Api/Adapters.Driving.Api.csproj @@ -13,6 +13,7 @@ + diff --git a/src/backend/Adapters/Driving/Api/Extensions/GraphQLExtensions.cs b/src/backend/Adapters/Driving/Api/Extensions/GraphQLExtensions.cs index 0774c7a..db30d9b 100644 --- a/src/backend/Adapters/Driving/Api/Extensions/GraphQLExtensions.cs +++ b/src/backend/Adapters/Driving/Api/Extensions/GraphQLExtensions.cs @@ -3,6 +3,7 @@ namespace Adapters.Driving.Api.Extensions; using Adapters.Driven.Persistence.DataLoaders; using Adapters.Driving.Api.Middleware; using Adapters.Driving.Api.Types; +using Adapters.Driving.Api.Types.Auth; using HotChocolate.Execution.Options; using Microsoft.Extensions.DependencyInjection; @@ -18,6 +19,11 @@ public static class GraphQLExtensions .AddGraphQLServer() .AddQueryType() .AddMutationType() + // Auth extensions + .AddTypeExtension() + .AddTypeExtension() + // Authorization + .AddAuthorization() .AddProjections() .AddFiltering() .AddSorting() diff --git a/src/backend/Adapters/Driving/Api/Types/Auth/AuthMutations.cs b/src/backend/Adapters/Driving/Api/Types/Auth/AuthMutations.cs new file mode 100644 index 0000000..bf8c0a1 --- /dev/null +++ b/src/backend/Adapters/Driving/Api/Types/Auth/AuthMutations.cs @@ -0,0 +1,34 @@ +using Application.Auth.Commands; +using Application.Auth.DTOs; +using MediatR; + +namespace Adapters.Driving.Api.Types.Auth; + +[ExtendObjectType(typeof(Mutation))] +public class AuthMutations +{ + /// + /// Authenticates a user and returns a JWT token. + /// + public async Task Login( + LoginRequest input, + [Service] IMediator mediator, + CancellationToken ct) + { + return await mediator.Send(new LoginCommand(input.Username, input.Password), ct); + } + + /// + /// Registers a new user account. Optionally creates a student profile. + /// + public async Task Register( + RegisterRequest input, + [Service] IMediator mediator, + CancellationToken ct) + { + return await mediator.Send( + new RegisterCommand(input.Username, input.Password, input.Name, input.Email), + ct + ); + } +} diff --git a/src/backend/Adapters/Driving/Api/Types/Auth/AuthQueries.cs b/src/backend/Adapters/Driving/Api/Types/Auth/AuthQueries.cs new file mode 100644 index 0000000..d8754a8 --- /dev/null +++ b/src/backend/Adapters/Driving/Api/Types/Auth/AuthQueries.cs @@ -0,0 +1,36 @@ +using Application.Auth.DTOs; +using Domain.Ports.Repositories; +using HotChocolate.Authorization; +using System.Security.Claims; + +namespace Adapters.Driving.Api.Types.Auth; + +[ExtendObjectType(typeof(Query))] +public class AuthQueries +{ + /// + /// Returns the current authenticated user's information. + /// + [Authorize] + public async Task Me( + ClaimsPrincipal user, + [Service] IUserRepository userRepository, + CancellationToken ct) + { + var userIdClaim = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (userIdClaim is null || !int.TryParse(userIdClaim, out var userId)) + return null; + + var dbUser = await userRepository.GetByIdAsync(userId, ct); + if (dbUser is null) + return null; + + return new UserInfo( + dbUser.Id, + dbUser.Username, + dbUser.Role, + dbUser.StudentId, + dbUser.Student?.Name + ); + } +} diff --git a/src/backend/Adapters/Driving/Api/Types/Mutation.cs b/src/backend/Adapters/Driving/Api/Types/Mutation.cs index 95001e2..b5b28bd 100644 --- a/src/backend/Adapters/Driving/Api/Types/Mutation.cs +++ b/src/backend/Adapters/Driving/Api/Types/Mutation.cs @@ -3,11 +3,18 @@ namespace Adapters.Driving.Api.Types; using Application.Enrollments.Commands; using Application.Students.Commands; using Application.Students.DTOs; +using HotChocolate.Authorization; using MediatR; +using System.Security.Claims; +/// +/// GraphQL mutation operations for the student enrollment system. +/// All mutations require authentication. Some require specific roles. +/// public class Mutation { - [GraphQLDescription("Create a new student")] + [Authorize] + [GraphQLDescription("Create a new student (requires authentication)")] public async Task CreateStudent( CreateStudentInput input, [Service] IMediator mediator, @@ -17,18 +24,25 @@ public class Mutation return new CreateStudentPayload(result); } - [GraphQLDescription("Update an existing student")] + [Authorize] + [GraphQLDescription("Update an existing student (owner or admin only)")] public async Task UpdateStudent( int id, UpdateStudentInput input, + ClaimsPrincipal user, [Service] IMediator mediator, CancellationToken ct) { + // Check if user can modify this student + if (!CanModifyStudent(user, id)) + return new UpdateStudentPayload(null, [new MutationError("FORBIDDEN", "No tienes permiso para modificar este estudiante")]); + var result = await mediator.Send(new UpdateStudentCommand(id, input.Name, input.Email), ct); return new UpdateStudentPayload(result); } - [GraphQLDescription("Delete a student")] + [Authorize(Roles = ["Admin"])] + [GraphQLDescription("Delete a student (admin only)")] public async Task DeleteStudent( int id, [Service] IMediator mediator, @@ -38,25 +52,56 @@ public class Mutation return new DeleteStudentPayload(success); } - [GraphQLDescription("Enroll a student in a subject")] + [Authorize] + [GraphQLDescription("Enroll a student in a subject (owner or admin only)")] public async Task EnrollStudent( EnrollStudentInput input, + ClaimsPrincipal user, [Service] IMediator mediator, CancellationToken ct) { + // Check if user can enroll this student + if (!CanModifyStudent(user, input.StudentId)) + return new EnrollStudentPayload(null, [new MutationError("FORBIDDEN", "No tienes permiso para inscribir a este estudiante")]); + var result = await mediator.Send(new EnrollStudentCommand(input.StudentId, input.SubjectId), ct); return new EnrollStudentPayload(result); } - [GraphQLDescription("Unenroll a student from a subject")] + [Authorize] + [GraphQLDescription("Unenroll a student from a subject (owner or admin only)")] public async Task UnenrollStudent( int enrollmentId, + int studentId, + ClaimsPrincipal user, [Service] IMediator mediator, CancellationToken ct) { + // Check if user can modify this student's enrollments + if (!CanModifyStudent(user, studentId)) + return new UnenrollStudentPayload(false, [new MutationError("FORBIDDEN", "No tienes permiso para desinscribir a este estudiante")]); + var success = await mediator.Send(new UnenrollStudentCommand(enrollmentId), ct); return new UnenrollStudentPayload(success); } + + /// + /// Checks if the current user can modify the specified student. + /// Admins can modify any student. Students can only modify their own data. + /// + private static bool CanModifyStudent(ClaimsPrincipal user, int studentId) + { + // Admins can modify anyone + if (user.IsInRole("Admin")) + return true; + + // Check if this is the user's own student record + var studentIdClaim = user.FindFirst("studentId")?.Value; + if (studentIdClaim != null && int.TryParse(studentIdClaim, out var userStudentId)) + return userStudentId == studentId; + + return false; + } } // Inputs @@ -64,9 +109,65 @@ public record CreateStudentInput(string Name, string Email); public record UpdateStudentInput(string Name, string Email); public record EnrollStudentInput(int StudentId, int SubjectId); -// Payloads -public record CreateStudentPayload(StudentDto Student); -public record UpdateStudentPayload(StudentDto Student); -public record DeleteStudentPayload(bool Success); -public record EnrollStudentPayload(EnrollmentDto Enrollment); -public record UnenrollStudentPayload(bool Success); +// Error Types +/// +/// Represents an error that occurred during a mutation operation. +/// +/// Machine-readable error code for client handling. +/// Human-readable error message. +/// Optional field name that caused the error. +public record MutationError(string Code, string Message, string? Field = null); + +// Payloads with structured errors +/// +/// Payload for CreateStudent mutation. +/// +public record CreateStudentPayload( + StudentDto? Student, + IReadOnlyList? Errors = null) +{ + public CreateStudentPayload(StudentDto student) : this(student, null) { } + public bool Success => Student is not null && (Errors is null || Errors.Count == 0); +} + +/// +/// Payload for UpdateStudent mutation. +/// +public record UpdateStudentPayload( + StudentDto? Student, + IReadOnlyList? Errors = null) +{ + public UpdateStudentPayload(StudentDto student) : this(student, null) { } + public bool Success => Student is not null && (Errors is null || Errors.Count == 0); +} + +/// +/// Payload for DeleteStudent mutation. +/// +public record DeleteStudentPayload( + bool Success, + IReadOnlyList? Errors = null) +{ + public DeleteStudentPayload(bool success) : this(success, null) { } +} + +/// +/// Payload for EnrollStudent mutation. +/// +public record EnrollStudentPayload( + EnrollmentDto? Enrollment, + IReadOnlyList? Errors = null) +{ + public EnrollStudentPayload(EnrollmentDto enrollment) : this(enrollment, null) { } + public bool Success => Enrollment is not null && (Errors is null || Errors.Count == 0); +} + +/// +/// Payload for UnenrollStudent mutation. +/// +public record UnenrollStudentPayload( + bool Success, + IReadOnlyList? Errors = null) +{ + public UnenrollStudentPayload(bool success) : this(success, null) { } +} diff --git a/src/backend/Application/Auth/Commands/LoginCommand.cs b/src/backend/Application/Auth/Commands/LoginCommand.cs new file mode 100644 index 0000000..ecffa99 --- /dev/null +++ b/src/backend/Application/Auth/Commands/LoginCommand.cs @@ -0,0 +1,42 @@ +using Application.Auth.DTOs; +using Domain.Ports.Repositories; +using MediatR; + +namespace Application.Auth.Commands; + +public record LoginCommand(string Username, string Password) : IRequest; + +public class LoginCommandHandler( + IUserRepository userRepository, + IPasswordService passwordService, + IJwtService jwtService +) : IRequestHandler +{ + public async Task Handle(LoginCommand request, CancellationToken ct) + { + var user = await userRepository.GetByUsernameAsync(request.Username.ToLowerInvariant(), ct); + + if (user is null) + return new AuthResponse(false, Error: "Usuario o contrasena incorrectos"); + + if (!passwordService.VerifyPassword(request.Password, user.PasswordHash)) + return new AuthResponse(false, Error: "Usuario o contrasena incorrectos"); + + user.UpdateLastLogin(); + await userRepository.UpdateAsync(user, ct); + + var token = jwtService.GenerateToken(user); + + return new AuthResponse( + Success: true, + Token: token, + User: new UserInfo( + user.Id, + user.Username, + user.Role, + user.StudentId, + user.Student?.Name + ) + ); + } +} diff --git a/src/backend/Application/Auth/Commands/RegisterCommand.cs b/src/backend/Application/Auth/Commands/RegisterCommand.cs new file mode 100644 index 0000000..5d54ef2 --- /dev/null +++ b/src/backend/Application/Auth/Commands/RegisterCommand.cs @@ -0,0 +1,78 @@ +using Application.Auth.DTOs; +using Domain.Entities; +using Domain.Ports.Repositories; +using Domain.ValueObjects; +using MediatR; + +namespace Application.Auth.Commands; + +public record RegisterCommand( + string Username, + string Password, + string? Name = null, + string? Email = null +) : IRequest; + +public class RegisterCommandHandler( + IUserRepository userRepository, + IStudentRepository studentRepository, + IPasswordService passwordService, + IJwtService jwtService, + IUnitOfWork unitOfWork +) : IRequestHandler +{ + public async Task Handle(RegisterCommand request, CancellationToken ct) + { + // Check if username already exists + if (await userRepository.ExistsAsync(request.Username, ct)) + return new AuthResponse(false, Error: "El nombre de usuario ya existe"); + + // Validate password strength + if (request.Password.Length < 6) + return new AuthResponse(false, Error: "La contrasena debe tener al menos 6 caracteres"); + + // Create student if name and email are provided + Student? student = null; + if (!string.IsNullOrWhiteSpace(request.Name) && !string.IsNullOrWhiteSpace(request.Email)) + { + try + { + var email = Email.Create(request.Email); + student = new Student(request.Name, email); + studentRepository.Add(student); + await unitOfWork.SaveChangesAsync(ct); // Save to get the student ID + } + catch (Exception ex) + { + return new AuthResponse(false, Error: ex.Message); + } + } + + // Create user + var passwordHash = passwordService.HashPassword(request.Password); + var user = User.Create( + request.Username, + passwordHash, + UserRoles.Student, + student?.Id + ); + + await userRepository.AddAsync(user, ct); + await unitOfWork.SaveChangesAsync(ct); + + // Generate token + var token = jwtService.GenerateToken(user); + + return new AuthResponse( + Success: true, + Token: token, + User: new UserInfo( + user.Id, + user.Username, + user.Role, + user.StudentId, + student?.Name + ) + ); + } +} diff --git a/src/backend/Application/Auth/DTOs/AuthDtos.cs b/src/backend/Application/Auth/DTOs/AuthDtos.cs new file mode 100644 index 0000000..4ac632f --- /dev/null +++ b/src/backend/Application/Auth/DTOs/AuthDtos.cs @@ -0,0 +1,20 @@ +namespace Application.Auth.DTOs; + +public record LoginRequest(string Username, string Password); + +public record RegisterRequest(string Username, string Password, string? Name = null, string? Email = null); + +public record AuthResponse( + bool Success, + string? Token = null, + UserInfo? User = null, + string? Error = null +); + +public record UserInfo( + int Id, + string Username, + string Role, + int? StudentId, + string? StudentName +); diff --git a/src/backend/Application/Auth/IJwtService.cs b/src/backend/Application/Auth/IJwtService.cs new file mode 100644 index 0000000..82ae9ec --- /dev/null +++ b/src/backend/Application/Auth/IJwtService.cs @@ -0,0 +1,12 @@ +using Domain.Entities; + +namespace Application.Auth; + +/// +/// Service for JWT token generation and validation. +/// +public interface IJwtService +{ + string GenerateToken(User user); + int? ValidateToken(string token); +} diff --git a/src/backend/Application/Auth/IPasswordService.cs b/src/backend/Application/Auth/IPasswordService.cs new file mode 100644 index 0000000..56bbe55 --- /dev/null +++ b/src/backend/Application/Auth/IPasswordService.cs @@ -0,0 +1,10 @@ +namespace Application.Auth; + +/// +/// Service for password hashing and verification. +/// +public interface IPasswordService +{ + string HashPassword(string password); + bool VerifyPassword(string password, string hash); +} diff --git a/src/backend/Application/Auth/JwtOptions.cs b/src/backend/Application/Auth/JwtOptions.cs new file mode 100644 index 0000000..b6c6e01 --- /dev/null +++ b/src/backend/Application/Auth/JwtOptions.cs @@ -0,0 +1,15 @@ +namespace Application.Auth; + +/// +/// Configuration options for JWT authentication. +/// +public class JwtOptions +{ + public const string SectionName = "Jwt"; + + public string SecretKey { get; set; } = string.Empty; + public string Issuer { get; set; } = "StudentEnrollmentApi"; + public string Audience { get; set; } = "StudentEnrollmentApp"; + public int ExpirationMinutes { get; set; } = 60; + public int RefreshExpirationDays { get; set; } = 7; +} diff --git a/src/backend/Domain/Entities/User.cs b/src/backend/Domain/Entities/User.cs new file mode 100644 index 0000000..4653fb5 --- /dev/null +++ b/src/backend/Domain/Entities/User.cs @@ -0,0 +1,61 @@ +namespace Domain.Entities; + +/// +/// Represents a system user for authentication and authorization. +/// +public class User +{ + public int Id { get; private set; } + public string Username { get; private set; } = string.Empty; + public string PasswordHash { get; private set; } = string.Empty; + public string Role { get; private set; } = UserRoles.Student; + public int? StudentId { get; private set; } + public DateTime CreatedAt { get; private set; } + public DateTime? LastLoginAt { get; private set; } + + // Navigation property + public Student? Student { get; private set; } + + private User() { } + + public static User Create(string username, string passwordHash, string role, int? studentId = null) + { + if (string.IsNullOrWhiteSpace(username)) + throw new ArgumentException("Username cannot be empty", nameof(username)); + + if (string.IsNullOrWhiteSpace(passwordHash)) + throw new ArgumentException("Password hash cannot be empty", nameof(passwordHash)); + + if (!UserRoles.IsValid(role)) + throw new ArgumentException($"Invalid role: {role}", nameof(role)); + + return new User + { + Username = username.ToLowerInvariant(), + PasswordHash = passwordHash, + Role = role, + StudentId = studentId, + CreatedAt = DateTime.UtcNow + }; + } + + public void UpdateLastLogin() + { + LastLoginAt = DateTime.UtcNow; + } + + public bool IsAdmin => Role == UserRoles.Admin; + public bool IsStudent => Role == UserRoles.Student; +} + +/// +/// Constants for user roles. +/// +public static class UserRoles +{ + public const string Admin = "Admin"; + public const string Student = "Student"; + + public static bool IsValid(string role) => + role == Admin || role == Student; +} diff --git a/src/backend/Domain/Ports/Repositories/IUserRepository.cs b/src/backend/Domain/Ports/Repositories/IUserRepository.cs new file mode 100644 index 0000000..f3d87a7 --- /dev/null +++ b/src/backend/Domain/Ports/Repositories/IUserRepository.cs @@ -0,0 +1,15 @@ +using Domain.Entities; + +namespace Domain.Ports.Repositories; + +/// +/// Repository interface for User entity operations. +/// +public interface IUserRepository +{ + Task GetByIdAsync(int id, CancellationToken ct = default); + Task GetByUsernameAsync(string username, CancellationToken ct = default); + Task AddAsync(User user, CancellationToken ct = default); + Task ExistsAsync(string username, CancellationToken ct = default); + Task UpdateAsync(User user, CancellationToken ct = default); +} diff --git a/src/backend/Host/Host.csproj b/src/backend/Host/Host.csproj index 0a693e5..bf7bf8a 100644 --- a/src/backend/Host/Host.csproj +++ b/src/backend/Host/Host.csproj @@ -14,6 +14,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/backend/Host/Program.cs b/src/backend/Host/Program.cs index bbc5ea3..81be94f 100644 --- a/src/backend/Host/Program.cs +++ b/src/backend/Host/Program.cs @@ -2,14 +2,18 @@ using Adapters.Driven.Persistence; using Adapters.Driven.Persistence.Context; using Adapters.Driving.Api.Extensions; using Application; +using Application.Auth; using dotenv.net; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.ResponseCompression; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.IdentityModel.Tokens; using Serilog; using System.IO.Compression; +using System.Text; using System.Text.Json; using System.Threading.RateLimiting; @@ -82,7 +86,40 @@ try options.AddDefaultPolicy(policy => policy.WithOrigins(corsOrigins) .AllowAnyHeader() - .AllowAnyMethod()); + .AllowAnyMethod() + .AllowCredentials()); + }); + + // JWT Authentication + var jwtSecretKey = Environment.GetEnvironmentVariable("JWT_SECRET_KEY") + ?? "SuperSecretKeyForDevelopmentOnly_ChangeInProduction_AtLeast32Chars!"; + builder.Services.Configure(opt => + { + opt.SecretKey = jwtSecretKey; + opt.Issuer = Environment.GetEnvironmentVariable("JWT_ISSUER") ?? "StudentEnrollmentApi"; + opt.Audience = Environment.GetEnvironmentVariable("JWT_AUDIENCE") ?? "StudentEnrollmentApp"; + opt.ExpirationMinutes = int.TryParse(Environment.GetEnvironmentVariable("JWT_EXPIRATION_MINUTES"), out var exp) ? exp : 60; + }); + + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = Environment.GetEnvironmentVariable("JWT_ISSUER") ?? "StudentEnrollmentApi", + ValidAudience = Environment.GetEnvironmentVariable("JWT_AUDIENCE") ?? "StudentEnrollmentApp", + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecretKey)) + }; + }); + + builder.Services.AddAuthorization(options => + { + options.AddPolicy("AdminOnly", policy => policy.RequireRole("Admin")); + options.AddPolicy("StudentOrAdmin", policy => policy.RequireRole("Student", "Admin")); }); // Output caching for read-heavy operations @@ -121,6 +158,9 @@ try // Verify database connection and apply migrations await VerifyDatabaseConnectionAsync(app); + // Create admin user if not exists + await CreateAdminUserAsync(app); + async Task VerifyDatabaseConnectionAsync(WebApplication app) { var maxRetries = 10; @@ -172,6 +212,37 @@ try } } + async Task CreateAdminUserAsync(WebApplication app) + { + try + { + using var scope = app.Services.CreateScope(); + var userRepo = scope.ServiceProvider.GetRequiredService(); + var passwordService = scope.ServiceProvider.GetRequiredService(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + var adminUsername = Environment.GetEnvironmentVariable("ADMIN_USERNAME") ?? "admin"; + var adminPassword = Environment.GetEnvironmentVariable("ADMIN_PASSWORD") ?? "admin123"; + + if (!await userRepo.ExistsAsync(adminUsername)) + { + var passwordHash = passwordService.HashPassword(adminPassword); + var adminUser = Domain.Entities.User.Create(adminUsername, passwordHash, Domain.Entities.UserRoles.Admin); + await userRepo.AddAsync(adminUser); + await unitOfWork.SaveChangesAsync(); + Log.Information("Admin user '{Username}' created successfully", adminUsername); + } + else + { + Log.Information("Admin user '{Username}' already exists", adminUsername); + } + } + catch (Exception ex) + { + Log.Warning(ex, "Could not create admin user: {Message}", ex.Message); + } + } + // Security headers (OWASP recommended) app.Use(async (context, next) => { @@ -223,6 +294,8 @@ try // Middleware order matters for performance app.UseResponseCompression(); app.UseCors(); + app.UseAuthentication(); + app.UseAuthorization(); app.UseRateLimiter(); app.UseOutputCache();