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
This commit is contained in:
Andrés Eduardo García Márquez 2026-01-08 09:14:42 -05:00
parent bcfd2ba6f9
commit cf61fb70e3
25 changed files with 1175 additions and 12 deletions

View File

@ -8,6 +8,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\..\Domain\Domain.csproj" /> <ProjectReference Include="..\..\..\Domain\Domain.csproj" />
<ProjectReference Include="..\..\..\Application\Application.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -0,0 +1,38 @@
using Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Adapters.Driven.Persistence.Configurations;
public class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> 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);
}
}

View File

@ -10,6 +10,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
public DbSet<Subject> Subjects => Set<Subject>(); public DbSet<Subject> Subjects => Set<Subject>();
public DbSet<Professor> Professors => Set<Professor>(); public DbSet<Professor> Professors => Set<Professor>();
public DbSet<Enrollment> Enrollments => Set<Enrollment>(); public DbSet<Enrollment> Enrollments => Set<Enrollment>();
public DbSet<User> Users => Set<User>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {

View File

@ -2,6 +2,8 @@ namespace Adapters.Driven.Persistence;
using Adapters.Driven.Persistence.Context; using Adapters.Driven.Persistence.Context;
using Adapters.Driven.Persistence.Repositories; using Adapters.Driven.Persistence.Repositories;
using Adapters.Driven.Persistence.Services;
using Application.Auth;
using Domain.Ports.Repositories; using Domain.Ports.Repositories;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
@ -18,12 +20,18 @@ public static class DependencyInjection
configuration.GetConnectionString("DefaultConnection"), configuration.GetConnectionString("DefaultConnection"),
b => b.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName))); b => b.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName)));
// Repositories
services.AddScoped<IStudentRepository, StudentRepository>(); services.AddScoped<IStudentRepository, StudentRepository>();
services.AddScoped<ISubjectRepository, SubjectRepository>(); services.AddScoped<ISubjectRepository, SubjectRepository>();
services.AddScoped<IProfessorRepository, ProfessorRepository>(); services.AddScoped<IProfessorRepository, ProfessorRepository>();
services.AddScoped<IEnrollmentRepository, EnrollmentRepository>(); services.AddScoped<IEnrollmentRepository, EnrollmentRepository>();
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IUnitOfWork, UnitOfWork>(); services.AddScoped<IUnitOfWork, UnitOfWork>();
// Auth services
services.AddScoped<IJwtService, JwtService>();
services.AddScoped<IPasswordService, PasswordService>();
return services; return services;
} }
} }

View File

@ -0,0 +1,325 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("EnrolledAt")
.HasColumnType("datetime2");
b.Property<int>("StudentId")
.HasColumnType("int");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("nvarchar(150)");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("Credits")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(3);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("datetime2");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<int?>("StudentId")
.HasColumnType("int");
b.Property<string>("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
}
}
}

View File

@ -0,0 +1,57 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Adapters.Driven.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddUsersTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Username = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
PasswordHash = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
Role = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
StudentId = table.Column<int>(type: "int", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
LastLoginAt = table.Column<DateTime>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Users");
}
}
}

View File

@ -220,6 +220,48 @@ namespace Adapters.Driven.Persistence.Migrations
}); });
}); });
modelBuilder.Entity("Domain.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("datetime2");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<int?>("StudentId")
.HasColumnType("int");
b.Property<string>("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 => modelBuilder.Entity("Domain.Entities.Enrollment", b =>
{ {
b.HasOne("Domain.Entities.Student", "Student") b.HasOne("Domain.Entities.Student", "Student")
@ -250,6 +292,16 @@ namespace Adapters.Driven.Persistence.Migrations
b.Navigation("Professor"); 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 => modelBuilder.Entity("Domain.Entities.Professor", b =>
{ {
b.Navigation("Subjects"); b.Navigation("Subjects");

View File

@ -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<User?> GetByIdAsync(int id, CancellationToken ct = default)
{
return await context.Users
.Include(u => u.Student)
.FirstOrDefaultAsync(u => u.Id == id, ct);
}
public async Task<User?> GetByUsernameAsync(string username, CancellationToken ct = default)
{
return await context.Users
.Include(u => u.Student)
.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant(), ct);
}
public async Task<User> AddAsync(User user, CancellationToken ct = default)
{
await context.Users.AddAsync(user, ct);
return user;
}
public async Task<bool> 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;
}
}

View File

@ -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<JwtOptions> 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<Claim>
{
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;
}
}
}

View File

@ -0,0 +1,63 @@
using System.Security.Cryptography;
using Application.Auth;
namespace Adapters.Driven.Persistence.Services;
/// <summary>
/// Password hashing service using PBKDF2 with SHA-256.
/// </summary>
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;
}
}
}

View File

@ -13,6 +13,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="HotChocolate.AspNetCore" Version="*" /> <PackageReference Include="HotChocolate.AspNetCore" Version="*" />
<PackageReference Include="HotChocolate.AspNetCore.Authorization" Version="*" />
<PackageReference Include="HotChocolate.Data" Version="*" /> <PackageReference Include="HotChocolate.Data" Version="*" />
<PackageReference Include="HotChocolate.Data.EntityFramework" Version="*" /> <PackageReference Include="HotChocolate.Data.EntityFramework" Version="*" />
</ItemGroup> </ItemGroup>

View File

@ -3,6 +3,7 @@ namespace Adapters.Driving.Api.Extensions;
using Adapters.Driven.Persistence.DataLoaders; using Adapters.Driven.Persistence.DataLoaders;
using Adapters.Driving.Api.Middleware; using Adapters.Driving.Api.Middleware;
using Adapters.Driving.Api.Types; using Adapters.Driving.Api.Types;
using Adapters.Driving.Api.Types.Auth;
using HotChocolate.Execution.Options; using HotChocolate.Execution.Options;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -18,6 +19,11 @@ public static class GraphQLExtensions
.AddGraphQLServer() .AddGraphQLServer()
.AddQueryType<Query>() .AddQueryType<Query>()
.AddMutationType<Mutation>() .AddMutationType<Mutation>()
// Auth extensions
.AddTypeExtension<AuthQueries>()
.AddTypeExtension<AuthMutations>()
// Authorization
.AddAuthorization()
.AddProjections() .AddProjections()
.AddFiltering() .AddFiltering()
.AddSorting() .AddSorting()

View File

@ -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
{
/// <summary>
/// Authenticates a user and returns a JWT token.
/// </summary>
public async Task<AuthResponse> Login(
LoginRequest input,
[Service] IMediator mediator,
CancellationToken ct)
{
return await mediator.Send(new LoginCommand(input.Username, input.Password), ct);
}
/// <summary>
/// Registers a new user account. Optionally creates a student profile.
/// </summary>
public async Task<AuthResponse> Register(
RegisterRequest input,
[Service] IMediator mediator,
CancellationToken ct)
{
return await mediator.Send(
new RegisterCommand(input.Username, input.Password, input.Name, input.Email),
ct
);
}
}

View File

@ -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
{
/// <summary>
/// Returns the current authenticated user's information.
/// </summary>
[Authorize]
public async Task<UserInfo?> 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
);
}
}

View File

@ -3,11 +3,18 @@ namespace Adapters.Driving.Api.Types;
using Application.Enrollments.Commands; using Application.Enrollments.Commands;
using Application.Students.Commands; using Application.Students.Commands;
using Application.Students.DTOs; using Application.Students.DTOs;
using HotChocolate.Authorization;
using MediatR; using MediatR;
using System.Security.Claims;
/// <summary>
/// GraphQL mutation operations for the student enrollment system.
/// All mutations require authentication. Some require specific roles.
/// </summary>
public class Mutation public class Mutation
{ {
[GraphQLDescription("Create a new student")] [Authorize]
[GraphQLDescription("Create a new student (requires authentication)")]
public async Task<CreateStudentPayload> CreateStudent( public async Task<CreateStudentPayload> CreateStudent(
CreateStudentInput input, CreateStudentInput input,
[Service] IMediator mediator, [Service] IMediator mediator,
@ -17,18 +24,25 @@ public class Mutation
return new CreateStudentPayload(result); return new CreateStudentPayload(result);
} }
[GraphQLDescription("Update an existing student")] [Authorize]
[GraphQLDescription("Update an existing student (owner or admin only)")]
public async Task<UpdateStudentPayload> UpdateStudent( public async Task<UpdateStudentPayload> UpdateStudent(
int id, int id,
UpdateStudentInput input, UpdateStudentInput input,
ClaimsPrincipal user,
[Service] IMediator mediator, [Service] IMediator mediator,
CancellationToken ct) 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); var result = await mediator.Send(new UpdateStudentCommand(id, input.Name, input.Email), ct);
return new UpdateStudentPayload(result); return new UpdateStudentPayload(result);
} }
[GraphQLDescription("Delete a student")] [Authorize(Roles = ["Admin"])]
[GraphQLDescription("Delete a student (admin only)")]
public async Task<DeleteStudentPayload> DeleteStudent( public async Task<DeleteStudentPayload> DeleteStudent(
int id, int id,
[Service] IMediator mediator, [Service] IMediator mediator,
@ -38,25 +52,56 @@ public class Mutation
return new DeleteStudentPayload(success); 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<EnrollStudentPayload> EnrollStudent( public async Task<EnrollStudentPayload> EnrollStudent(
EnrollStudentInput input, EnrollStudentInput input,
ClaimsPrincipal user,
[Service] IMediator mediator, [Service] IMediator mediator,
CancellationToken ct) 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); var result = await mediator.Send(new EnrollStudentCommand(input.StudentId, input.SubjectId), ct);
return new EnrollStudentPayload(result); 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<UnenrollStudentPayload> UnenrollStudent( public async Task<UnenrollStudentPayload> UnenrollStudent(
int enrollmentId, int enrollmentId,
int studentId,
ClaimsPrincipal user,
[Service] IMediator mediator, [Service] IMediator mediator,
CancellationToken ct) 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); var success = await mediator.Send(new UnenrollStudentCommand(enrollmentId), ct);
return new UnenrollStudentPayload(success); return new UnenrollStudentPayload(success);
} }
/// <summary>
/// Checks if the current user can modify the specified student.
/// Admins can modify any student. Students can only modify their own data.
/// </summary>
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 // Inputs
@ -64,9 +109,65 @@ public record CreateStudentInput(string Name, string Email);
public record UpdateStudentInput(string Name, string Email); public record UpdateStudentInput(string Name, string Email);
public record EnrollStudentInput(int StudentId, int SubjectId); public record EnrollStudentInput(int StudentId, int SubjectId);
// Payloads // Error Types
public record CreateStudentPayload(StudentDto Student); /// <summary>
public record UpdateStudentPayload(StudentDto Student); /// Represents an error that occurred during a mutation operation.
public record DeleteStudentPayload(bool Success); /// </summary>
public record EnrollStudentPayload(EnrollmentDto Enrollment); /// <param name="Code">Machine-readable error code for client handling.</param>
public record UnenrollStudentPayload(bool Success); /// <param name="Message">Human-readable error message.</param>
/// <param name="Field">Optional field name that caused the error.</param>
public record MutationError(string Code, string Message, string? Field = null);
// Payloads with structured errors
/// <summary>
/// Payload for CreateStudent mutation.
/// </summary>
public record CreateStudentPayload(
StudentDto? Student,
IReadOnlyList<MutationError>? Errors = null)
{
public CreateStudentPayload(StudentDto student) : this(student, null) { }
public bool Success => Student is not null && (Errors is null || Errors.Count == 0);
}
/// <summary>
/// Payload for UpdateStudent mutation.
/// </summary>
public record UpdateStudentPayload(
StudentDto? Student,
IReadOnlyList<MutationError>? Errors = null)
{
public UpdateStudentPayload(StudentDto student) : this(student, null) { }
public bool Success => Student is not null && (Errors is null || Errors.Count == 0);
}
/// <summary>
/// Payload for DeleteStudent mutation.
/// </summary>
public record DeleteStudentPayload(
bool Success,
IReadOnlyList<MutationError>? Errors = null)
{
public DeleteStudentPayload(bool success) : this(success, null) { }
}
/// <summary>
/// Payload for EnrollStudent mutation.
/// </summary>
public record EnrollStudentPayload(
EnrollmentDto? Enrollment,
IReadOnlyList<MutationError>? Errors = null)
{
public EnrollStudentPayload(EnrollmentDto enrollment) : this(enrollment, null) { }
public bool Success => Enrollment is not null && (Errors is null || Errors.Count == 0);
}
/// <summary>
/// Payload for UnenrollStudent mutation.
/// </summary>
public record UnenrollStudentPayload(
bool Success,
IReadOnlyList<MutationError>? Errors = null)
{
public UnenrollStudentPayload(bool success) : this(success, null) { }
}

View File

@ -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<AuthResponse>;
public class LoginCommandHandler(
IUserRepository userRepository,
IPasswordService passwordService,
IJwtService jwtService
) : IRequestHandler<LoginCommand, AuthResponse>
{
public async Task<AuthResponse> 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
)
);
}
}

View File

@ -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<AuthResponse>;
public class RegisterCommandHandler(
IUserRepository userRepository,
IStudentRepository studentRepository,
IPasswordService passwordService,
IJwtService jwtService,
IUnitOfWork unitOfWork
) : IRequestHandler<RegisterCommand, AuthResponse>
{
public async Task<AuthResponse> 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
)
);
}
}

View File

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

View File

@ -0,0 +1,12 @@
using Domain.Entities;
namespace Application.Auth;
/// <summary>
/// Service for JWT token generation and validation.
/// </summary>
public interface IJwtService
{
string GenerateToken(User user);
int? ValidateToken(string token);
}

View File

@ -0,0 +1,10 @@
namespace Application.Auth;
/// <summary>
/// Service for password hashing and verification.
/// </summary>
public interface IPasswordService
{
string HashPassword(string password);
bool VerifyPassword(string password, string hash);
}

View File

@ -0,0 +1,15 @@
namespace Application.Auth;
/// <summary>
/// Configuration options for JWT authentication.
/// </summary>
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;
}

View File

@ -0,0 +1,61 @@
namespace Domain.Entities;
/// <summary>
/// Represents a system user for authentication and authorization.
/// </summary>
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;
}
/// <summary>
/// Constants for user roles.
/// </summary>
public static class UserRoles
{
public const string Admin = "Admin";
public const string Student = "Student";
public static bool IsValid(string role) =>
role == Admin || role == Student;
}

View File

@ -0,0 +1,15 @@
using Domain.Entities;
namespace Domain.Ports.Repositories;
/// <summary>
/// Repository interface for User entity operations.
/// </summary>
public interface IUserRepository
{
Task<User?> GetByIdAsync(int id, CancellationToken ct = default);
Task<User?> GetByUsernameAsync(string username, CancellationToken ct = default);
Task<User> AddAsync(User user, CancellationToken ct = default);
Task<bool> ExistsAsync(string username, CancellationToken ct = default);
Task UpdateAsync(User user, CancellationToken ct = default);
}

View File

@ -14,6 +14,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="dotenv.net" Version="*" /> <PackageReference Include="dotenv.net" Version="*" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>

View File

@ -2,14 +2,18 @@ using Adapters.Driven.Persistence;
using Adapters.Driven.Persistence.Context; using Adapters.Driven.Persistence.Context;
using Adapters.Driving.Api.Extensions; using Adapters.Driving.Api.Extensions;
using Application; using Application;
using Application.Auth;
using dotenv.net; using dotenv.net;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.ResponseCompression; using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.IdentityModel.Tokens;
using Serilog; using Serilog;
using System.IO.Compression; using System.IO.Compression;
using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
@ -82,7 +86,40 @@ try
options.AddDefaultPolicy(policy => options.AddDefaultPolicy(policy =>
policy.WithOrigins(corsOrigins) policy.WithOrigins(corsOrigins)
.AllowAnyHeader() .AllowAnyHeader()
.AllowAnyMethod()); .AllowAnyMethod()
.AllowCredentials());
});
// JWT Authentication
var jwtSecretKey = Environment.GetEnvironmentVariable("JWT_SECRET_KEY")
?? "SuperSecretKeyForDevelopmentOnly_ChangeInProduction_AtLeast32Chars!";
builder.Services.Configure<JwtOptions>(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 // Output caching for read-heavy operations
@ -121,6 +158,9 @@ try
// Verify database connection and apply migrations // Verify database connection and apply migrations
await VerifyDatabaseConnectionAsync(app); await VerifyDatabaseConnectionAsync(app);
// Create admin user if not exists
await CreateAdminUserAsync(app);
async Task VerifyDatabaseConnectionAsync(WebApplication app) async Task VerifyDatabaseConnectionAsync(WebApplication app)
{ {
var maxRetries = 10; 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<Domain.Ports.Repositories.IUserRepository>();
var passwordService = scope.ServiceProvider.GetRequiredService<Application.Auth.IPasswordService>();
var unitOfWork = scope.ServiceProvider.GetRequiredService<Domain.Ports.Repositories.IUnitOfWork>();
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) // Security headers (OWASP recommended)
app.Use(async (context, next) => app.Use(async (context, next) =>
{ {
@ -223,6 +294,8 @@ try
// Middleware order matters for performance // Middleware order matters for performance
app.UseResponseCompression(); app.UseResponseCompression();
app.UseCors(); app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter(); app.UseRateLimiter();
app.UseOutputCache(); app.UseOutputCache();