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:
parent
bcfd2ba6f9
commit
cf61fb70e3
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\Domain\Domain.csproj" />
|
||||
<ProjectReference Include="..\..\..\Application\Application.csproj" />
|
||||
</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<Professor> Professors => Set<Professor>();
|
||||
public DbSet<Enrollment> Enrollments => Set<Enrollment>();
|
||||
public DbSet<User> Users => Set<User>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ namespace Adapters.Driven.Persistence;
|
|||
|
||||
using Adapters.Driven.Persistence.Context;
|
||||
using Adapters.Driven.Persistence.Repositories;
|
||||
using Adapters.Driven.Persistence.Services;
|
||||
using Application.Auth;
|
||||
using Domain.Ports.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
|
@ -18,12 +20,18 @@ public static class DependencyInjection
|
|||
configuration.GetConnectionString("DefaultConnection"),
|
||||
b => b.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName)));
|
||||
|
||||
// Repositories
|
||||
services.AddScoped<IStudentRepository, StudentRepository>();
|
||||
services.AddScoped<ISubjectRepository, SubjectRepository>();
|
||||
services.AddScoped<IProfessorRepository, ProfessorRepository>();
|
||||
services.AddScoped<IEnrollmentRepository, EnrollmentRepository>();
|
||||
services.AddScoped<IUserRepository, UserRepository>();
|
||||
services.AddScoped<IUnitOfWork, UnitOfWork>();
|
||||
|
||||
// Auth services
|
||||
services.AddScoped<IJwtService, JwtService>();
|
||||
services.AddScoped<IPasswordService, PasswordService>();
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("Domain.Entities.Student", "Student")
|
||||
|
|
@ -250,6 +292,16 @@ namespace Adapters.Driven.Persistence.Migrations
|
|||
b.Navigation("Professor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Domain.Entities.User", b =>
|
||||
{
|
||||
b.HasOne("Domain.Entities.Student", "Student")
|
||||
.WithMany()
|
||||
.HasForeignKey("StudentId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Student");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Domain.Entities.Professor", b =>
|
||||
{
|
||||
b.Navigation("Subjects");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<PackageReference Include="HotChocolate.AspNetCore" Version="*" />
|
||||
<PackageReference Include="HotChocolate.AspNetCore.Authorization" Version="*" />
|
||||
<PackageReference Include="HotChocolate.Data" Version="*" />
|
||||
<PackageReference Include="HotChocolate.Data.EntityFramework" Version="*" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ namespace Adapters.Driving.Api.Extensions;
|
|||
using Adapters.Driven.Persistence.DataLoaders;
|
||||
using Adapters.Driving.Api.Middleware;
|
||||
using Adapters.Driving.Api.Types;
|
||||
using Adapters.Driving.Api.Types.Auth;
|
||||
using HotChocolate.Execution.Options;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
|
|
@ -18,6 +19,11 @@ public static class GraphQLExtensions
|
|||
.AddGraphQLServer()
|
||||
.AddQueryType<Query>()
|
||||
.AddMutationType<Mutation>()
|
||||
// Auth extensions
|
||||
.AddTypeExtension<AuthQueries>()
|
||||
.AddTypeExtension<AuthMutations>()
|
||||
// Authorization
|
||||
.AddAuthorization()
|
||||
.AddProjections()
|
||||
.AddFiltering()
|
||||
.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.Students.Commands;
|
||||
using Application.Students.DTOs;
|
||||
using HotChocolate.Authorization;
|
||||
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
|
||||
{
|
||||
[GraphQLDescription("Create a new student")]
|
||||
[Authorize]
|
||||
[GraphQLDescription("Create a new student (requires authentication)")]
|
||||
public async Task<CreateStudentPayload> CreateStudent(
|
||||
CreateStudentInput input,
|
||||
[Service] IMediator mediator,
|
||||
|
|
@ -17,18 +24,25 @@ public class Mutation
|
|||
return new CreateStudentPayload(result);
|
||||
}
|
||||
|
||||
[GraphQLDescription("Update an existing student")]
|
||||
[Authorize]
|
||||
[GraphQLDescription("Update an existing student (owner or admin only)")]
|
||||
public async Task<UpdateStudentPayload> UpdateStudent(
|
||||
int id,
|
||||
UpdateStudentInput input,
|
||||
ClaimsPrincipal user,
|
||||
[Service] IMediator mediator,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Check if user can modify this student
|
||||
if (!CanModifyStudent(user, id))
|
||||
return new UpdateStudentPayload(null, [new MutationError("FORBIDDEN", "No tienes permiso para modificar este estudiante")]);
|
||||
|
||||
var result = await mediator.Send(new UpdateStudentCommand(id, input.Name, input.Email), ct);
|
||||
return new UpdateStudentPayload(result);
|
||||
}
|
||||
|
||||
[GraphQLDescription("Delete a student")]
|
||||
[Authorize(Roles = ["Admin"])]
|
||||
[GraphQLDescription("Delete a student (admin only)")]
|
||||
public async Task<DeleteStudentPayload> DeleteStudent(
|
||||
int id,
|
||||
[Service] IMediator mediator,
|
||||
|
|
@ -38,25 +52,56 @@ public class Mutation
|
|||
return new DeleteStudentPayload(success);
|
||||
}
|
||||
|
||||
[GraphQLDescription("Enroll a student in a subject")]
|
||||
[Authorize]
|
||||
[GraphQLDescription("Enroll a student in a subject (owner or admin only)")]
|
||||
public async Task<EnrollStudentPayload> EnrollStudent(
|
||||
EnrollStudentInput input,
|
||||
ClaimsPrincipal user,
|
||||
[Service] IMediator mediator,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Check if user can enroll this student
|
||||
if (!CanModifyStudent(user, input.StudentId))
|
||||
return new EnrollStudentPayload(null, [new MutationError("FORBIDDEN", "No tienes permiso para inscribir a este estudiante")]);
|
||||
|
||||
var result = await mediator.Send(new EnrollStudentCommand(input.StudentId, input.SubjectId), ct);
|
||||
return new EnrollStudentPayload(result);
|
||||
}
|
||||
|
||||
[GraphQLDescription("Unenroll a student from a subject")]
|
||||
[Authorize]
|
||||
[GraphQLDescription("Unenroll a student from a subject (owner or admin only)")]
|
||||
public async Task<UnenrollStudentPayload> UnenrollStudent(
|
||||
int enrollmentId,
|
||||
int studentId,
|
||||
ClaimsPrincipal user,
|
||||
[Service] IMediator mediator,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Check if user can modify this student's enrollments
|
||||
if (!CanModifyStudent(user, studentId))
|
||||
return new UnenrollStudentPayload(false, [new MutationError("FORBIDDEN", "No tienes permiso para desinscribir a este estudiante")]);
|
||||
|
||||
var success = await mediator.Send(new UnenrollStudentCommand(enrollmentId), ct);
|
||||
return new UnenrollStudentPayload(success);
|
||||
}
|
||||
|
||||
/// <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
|
||||
|
|
@ -64,9 +109,65 @@ public record CreateStudentInput(string Name, string Email);
|
|||
public record UpdateStudentInput(string Name, string Email);
|
||||
public record EnrollStudentInput(int StudentId, int SubjectId);
|
||||
|
||||
// Payloads
|
||||
public record CreateStudentPayload(StudentDto Student);
|
||||
public record UpdateStudentPayload(StudentDto Student);
|
||||
public record DeleteStudentPayload(bool Success);
|
||||
public record EnrollStudentPayload(EnrollmentDto Enrollment);
|
||||
public record UnenrollStudentPayload(bool Success);
|
||||
// Error Types
|
||||
/// <summary>
|
||||
/// Represents an error that occurred during a mutation operation.
|
||||
/// </summary>
|
||||
/// <param name="Code">Machine-readable error code for client handling.</param>
|
||||
/// <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>
|
||||
<PackageReference Include="dotenv.net" Version="*" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="*" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
|
|
|||
|
|
@ -2,14 +2,18 @@ using Adapters.Driven.Persistence;
|
|||
using Adapters.Driven.Persistence.Context;
|
||||
using Adapters.Driving.Api.Extensions;
|
||||
using Application;
|
||||
using Application.Auth;
|
||||
using dotenv.net;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.AspNetCore.ResponseCompression;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Serilog;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.RateLimiting;
|
||||
|
||||
|
|
@ -82,7 +86,40 @@ try
|
|||
options.AddDefaultPolicy(policy =>
|
||||
policy.WithOrigins(corsOrigins)
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod());
|
||||
.AllowAnyMethod()
|
||||
.AllowCredentials());
|
||||
});
|
||||
|
||||
// JWT Authentication
|
||||
var jwtSecretKey = Environment.GetEnvironmentVariable("JWT_SECRET_KEY")
|
||||
?? "SuperSecretKeyForDevelopmentOnly_ChangeInProduction_AtLeast32Chars!";
|
||||
builder.Services.Configure<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
|
||||
|
|
@ -121,6 +158,9 @@ try
|
|||
// Verify database connection and apply migrations
|
||||
await VerifyDatabaseConnectionAsync(app);
|
||||
|
||||
// Create admin user if not exists
|
||||
await CreateAdminUserAsync(app);
|
||||
|
||||
async Task VerifyDatabaseConnectionAsync(WebApplication app)
|
||||
{
|
||||
var maxRetries = 10;
|
||||
|
|
@ -172,6 +212,37 @@ try
|
|||
}
|
||||
}
|
||||
|
||||
async Task CreateAdminUserAsync(WebApplication app)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
var userRepo = scope.ServiceProvider.GetRequiredService<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)
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
|
|
@ -223,6 +294,8 @@ try
|
|||
// Middleware order matters for performance
|
||||
app.UseResponseCompression();
|
||||
app.UseCors();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseRateLimiter();
|
||||
app.UseOutputCache();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue