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 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0562a46d7f
commit
7916d1d95f
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
325
src/backend/Adapters/Driven/Persistence/Migrations/20260108135459_AddUsersTable.Designer.cs
generated
Normal file
325
src/backend/Adapters/Driven/Persistence/Migrations/20260108135459_AddUsersTable.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) { }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue