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();