diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c0784f5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +env: + DOTNET_NOLOGO: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + +jobs: + build-and-test: + name: Build & test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + restore-keys: ${{ runner.os }}-nuget- + + - name: Restore + run: dotnet restore KursKayitSistemi.slnx + + - name: Build + run: dotnet build KursKayitSistemi.slnx --no-restore --configuration Release + + - name: Test + run: > + dotnet test KursKayitSistemi.slnx + --no-build + --configuration Release + --logger "trx;LogFileName=test-results.trx" + --collect:"XPlat Code Coverage" + --results-directory ./TestResults + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: ./TestResults + retention-days: 7 + + vulnerable-packages: + name: Dependency audit + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore + run: dotnet restore KursKayitSistemi.slnx + + # Transitive dependencies are included: the SQLite native bundle reached this project + # through EF Core, not through anything declared here. + - name: Fail on known vulnerable packages + run: | + dotnet list KursKayitSistemi.slnx package --vulnerable --include-transitive 2>&1 | tee audit.log + if grep -q -E '(Yüksek|High|Critical|Moderate)' audit.log; then + echo "::error::Bilinen güvenlik açığı olan paket bulundu." + exit 1 + fi + echo "Açık bulunamadı." diff --git a/.gitignore b/.gitignore index 0cfb362..f58db52 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,38 @@ -## Visual Studio / .NET.vs/bin/obj/*.user*.suo*.userprefs[Dd]ebug/[Rr]elease/[Bb]uild[Ll]og.*## Build results*.dll*.exe*.pdb*.cache## NuGetpackages/*.nupkg## Local database files (generated by EF migrations)*.db*.db-shm*.db-wal*.sqlite*.sqlite3## Environment / secretsappsettings.*.local.json.env \ No newline at end of file +## Visual Studio / .NET +.vs/ +bin/ +obj/ +*.user +*.suo +*.userprefs +[Dd]ebug/ +[Rr]elease/ +[Bb]uild[Ll]og.* + +## Build results +*.dll +*.exe +*.pdb +*.cache + +## Test results +[Tt]est[Rr]esult*/ +coverage/ +*.trx +*.coverage +*.cobertura.xml + +## NuGet +packages/ +*.nupkg + +## Local database files (generated by EF migrations) +*.db +*.db-shm +*.db-wal +*.sqlite +*.sqlite3 + +## Environment / secrets +appsettings.*.local.json +.env diff --git a/KursKayitSistemi.Tests/AccountServiceTests.cs b/KursKayitSistemi.Tests/AccountServiceTests.cs new file mode 100644 index 0000000..d569218 --- /dev/null +++ b/KursKayitSistemi.Tests/AccountServiceTests.cs @@ -0,0 +1,176 @@ +using KursKayitSistemi.Models; +using KursKayitSistemi.Services; +using KursKayitSistemi.Tests.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace KursKayitSistemi.Tests; + +public sealed class AccountServiceTests : IDisposable +{ + private readonly SqliteTestDatabase _db = new(); + private readonly IPasswordHashService _hasher = new Pbkdf2PasswordHashService(iterations: 1_000); + + public void Dispose() => _db.Dispose(); + + private AccountService NewService(AppDbContext ctx) => new(ctx, _hasher); + + // ----------------------------------------------------------------- registration + + [Fact] + public async Task Kayit_sifreyi_hashleyerek_saklar() + { + await using (var ctx = _db.CreateContext()) + { + var result = await NewService(ctx).RegisterAsync("1001", "Ayşe Yılmaz", "ayse@ornek.test", "GucluSifre123"); + Assert.True(result.Success); + } + + await using (var ctx = _db.CreateContext()) + { + var ogrenci = await ctx.Ogrenciler.SingleAsync(); + + Assert.NotEqual("GucluSifre123", ogrenci.SifreHash); + Assert.StartsWith("pbkdf2-sha256$", ogrenci.SifreHash); + Assert.True(_hasher.Verify("GucluSifre123", ogrenci.SifreHash)); + } + } + + [Fact] + public async Task Ayni_ogrenci_numarasi_ikinci_kez_kaydedilemez() + { + await using (var ctx = _db.CreateContext()) + await NewService(ctx).RegisterAsync("1001", "Ayşe", "ayse@ornek.test", "GucluSifre123"); + + await using (var ctx = _db.CreateContext()) + { + var result = await NewService(ctx).RegisterAsync("1001", "Başka Kişi", "baska@ornek.test", "GucluSifre123"); + + Assert.False(result.Success); + Assert.Equal(RegistrationError.DuplicateOgrenciNo, result.Error); + } + } + + [Fact] + public async Task Ayni_email_ikinci_kez_kaydedilemez() + { + await using (var ctx = _db.CreateContext()) + await NewService(ctx).RegisterAsync("1001", "Ayşe", "ayse@ornek.test", "GucluSifre123"); + + await using (var ctx = _db.CreateContext()) + { + var result = await NewService(ctx).RegisterAsync("1002", "Başka Kişi", "ayse@ornek.test", "GucluSifre123"); + + Assert.False(result.Success); + Assert.Equal(RegistrationError.DuplicateEmail, result.Error); + } + } + + [Fact] + public async Task Kayit_bosluklari_temizler() + { + await using (var ctx = _db.CreateContext()) + Assert.True((await NewService(ctx).RegisterAsync(" 1001 ", " Ayşe Yılmaz ", " ayse@ornek.test ", "GucluSifre123")).Success); + + await using (var ctx = _db.CreateContext()) + { + var ogrenci = await ctx.Ogrenciler.SingleAsync(); + Assert.Equal("1001", ogrenci.OgrenciNo); + Assert.Equal("Ayşe Yılmaz", ogrenci.AdSoyad); + Assert.Equal("ayse@ornek.test", ogrenci.Email); + } + } + + // --------------------------------------------------------------- authentication + + [Fact] + public async Task Dogru_bilgilerle_ogrenci_girisi_basarili() + { + await using (var ctx = _db.CreateContext()) + await NewService(ctx).RegisterAsync("1001", "Ayşe Yılmaz", "ayse@ornek.test", "GucluSifre123"); + + await using (var ctx = _db.CreateContext()) + { + var user = await NewService(ctx).AuthenticateAsync("1001", "GucluSifre123"); + + Assert.NotNull(user); + Assert.Equal(Roller.Ogrenci, user.Role); + Assert.Equal("Ayşe Yılmaz", user.AdSoyad); + Assert.NotEqual(0, user.Id); + } + } + + [Fact] + public async Task Yanlis_sifreyle_giris_reddedilir() + { + await using (var ctx = _db.CreateContext()) + await NewService(ctx).RegisterAsync("1001", "Ayşe", "ayse@ornek.test", "GucluSifre123"); + + await using (var ctx = _db.CreateContext()) + Assert.Null(await NewService(ctx).AuthenticateAsync("1001", "YanlisSifre")); + } + + [Fact] + public async Task Olmayan_kullanici_icin_giris_reddedilir() + { + await using var ctx = _db.CreateContext(); + Assert.Null(await NewService(ctx).AuthenticateAsync("boyle-biri-yok", "herhangi")); + } + + [Theory] + [InlineData("", "sifre")] + [InlineData(" ", "sifre")] + [InlineData("1001", "")] + public async Task Bos_kimlik_bilgileri_reddedilir(string kullaniciAdi, string sifre) + { + await using var ctx = _db.CreateContext(); + Assert.Null(await NewService(ctx).AuthenticateAsync(kullaniciAdi, sifre)); + } + + /// + /// The administrator used to be a pair of string literals in the login action, which meant + /// the working credentials were published with the source. Now it is a row like any other. + /// + [Fact] + public async Task Yonetici_girisi_veritabanindaki_hash_uzerinden_dogrulanir() + { + await using (var ctx = _db.CreateContext()) + { + ctx.Yoneticiler.Add(new Yonetici + { + KullaniciAdi = "admin", + AdSoyad = "Sistem Yöneticisi", + SifreHash = _hasher.Hash("BuSifreKaynakKodundaDegil") + }); + await ctx.SaveChangesAsync(); + } + + await using (var ctx = _db.CreateContext()) + { + var user = await NewService(ctx).AuthenticateAsync("admin", "BuSifreKaynakKodundaDegil"); + + Assert.NotNull(user); + Assert.Equal(Roller.Admin, user.Role); + } + + // The credentials that used to be hard-coded must no longer work. + await using (var ctx = _db.CreateContext()) + Assert.Null(await NewService(ctx).AuthenticateAsync("admin", "1234")); + } + + [Fact] + public async Task Ogrenci_admin_rolu_alamaz() + { + await using (var ctx = _db.CreateContext()) + await NewService(ctx).RegisterAsync("1001", "Sistem Yöneticisi", "sahte@ornek.test", "GucluSifre123"); + + await using (var ctx = _db.CreateContext()) + { + // Registering under the administrator's display name grants nothing: the role + // comes from which table the row lives in, not from what the name says. + var user = await NewService(ctx).AuthenticateAsync("1001", "GucluSifre123"); + + Assert.NotNull(user); + Assert.Equal(Roller.Ogrenci, user.Role); + } + } +} diff --git a/KursKayitSistemi.Tests/AuthorizationIntegrationTests.cs b/KursKayitSistemi.Tests/AuthorizationIntegrationTests.cs new file mode 100644 index 0000000..c57d63b --- /dev/null +++ b/KursKayitSistemi.Tests/AuthorizationIntegrationTests.cs @@ -0,0 +1,204 @@ +using System.Net; +using KursKayitSistemi.Models; +using KursKayitSistemi.Tests.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace KursKayitSistemi.Tests; + +/// +/// Regression tests for the missing authorization on the management area. AdminController +/// carried no [Authorize] attribute at all, so every action on it — including the delete +/// endpoints — answered anonymous requests. +/// +public sealed class AuthorizationIntegrationTests : IClassFixture +{ + private readonly KursKayitWebApplicationFactory _factory; + + public AuthorizationIntegrationTests(KursKayitWebApplicationFactory factory) => _factory = factory; + + public static TheoryData YoneticiSayfalari => + [ + "/Admin/BasvuruListesi", + "/Admin/KursYonetimi", + "/Admin/EgitmenYonetimi", + "/Admin/KursEkle", + "/Admin/EgitmenEkle" + ]; + + [Theory] + [MemberData(nameof(YoneticiSayfalari))] + public async Task Anonim_kullanici_yonetim_sayfalarina_erisemez(string url) + { + var client = _factory.CreateTestClient(); + + var response = await client.GetAsync(url); + + Assert.Equal(HttpStatusCode.Found, response.StatusCode); + Assert.Contains("/Account/Login", response.Headers.Location?.OriginalString ?? ""); + } + + [Theory] + [MemberData(nameof(YoneticiSayfalari))] + public async Task Ogrenci_yonetim_sayfalarina_erisemez(string url) + { + await OgrenciKaydetAsync("6001", "OgrenciSifresi123"); + var client = await _factory.CreateAuthenticatedClientAsync("6001", "OgrenciSifresi123"); + + var response = await client.GetAsync(url); + + Assert.Equal(HttpStatusCode.Found, response.StatusCode); + Assert.Contains("AccessDenied", response.Headers.Location?.OriginalString ?? ""); + } + + [Theory] + [MemberData(nameof(YoneticiSayfalari))] + public async Task Yonetici_yonetim_sayfalarina_erisebilir(string url) + { + var client = await _factory.CreateAuthenticatedClientAsync( + KursKayitWebApplicationFactory.AdminKullaniciAdi, + KursKayitWebApplicationFactory.AdminSifre); + + var response = await client.GetAsync(url); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + /// + /// The most damaging case: the delete endpoints were reachable without any credentials. + /// + [Fact] + public async Task Anonim_kullanici_kurs_silemez() + { + int kursId; + await using (var ctx = _factory.CreateDbContext()) + { + var egitmen = new Egitmen { AdSoyad = $"Silme Testi Eğitmeni {Guid.NewGuid():N}" }; + ctx.Egitmenler.Add(egitmen); + await ctx.SaveChangesAsync(); + + var kurs = new Kurs { KursAdi = $"Silinmemeli {Guid.NewGuid():N}", Kontenjan = 5, EgitmenId = egitmen.Id }; + ctx.Kurslar.Add(kurs); + await ctx.SaveChangesAsync(); + kursId = kurs.Id; + } + + var client = _factory.CreateTestClient(); + var response = await client.PostAsync("/Admin/KursSil", + new FormUrlEncodedContent(new Dictionary { ["id"] = kursId.ToString() })); + + Assert.NotEqual(HttpStatusCode.OK, response.StatusCode); + + await using var dogrulama = _factory.CreateDbContext(); + Assert.True(await dogrulama.Kurslar.AnyAsync(k => k.Id == kursId), "Kurs anonim bir istekle silindi."); + } + + [Fact] + public async Task Antiforgery_tokensiz_post_reddedilir() + { + var client = await _factory.CreateAuthenticatedClientAsync( + KursKayitWebApplicationFactory.AdminKullaniciAdi, + KursKayitWebApplicationFactory.AdminSifre); + + var response = await client.PostAsync("/Admin/EgitmenEkle", + new FormUrlEncodedContent(new Dictionary { ["AdSoyad"] = "Token Yok" })); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + await using var ctx = _factory.CreateDbContext(); + Assert.False(await ctx.Egitmenler.AnyAsync(e => e.AdSoyad == "Token Yok")); + } + + [Fact] + public async Task Antiforgery_tokenli_post_kabul_edilir() + { + var client = await _factory.CreateAuthenticatedClientAsync( + KursKayitWebApplicationFactory.AdminKullaniciAdi, + KursKayitWebApplicationFactory.AdminSifre); + + var ad = $"Tokenli Eğitmen {Guid.NewGuid():N}"; + var token = await KursKayitWebApplicationFactory.GetTokenAsync(client, "/Admin/EgitmenEkle"); + + var response = await client.PostAsync("/Admin/EgitmenEkle", + new FormUrlEncodedContent(new Dictionary + { + ["AdSoyad"] = ad, + ["__RequestVerificationToken"] = token + })); + + Assert.Equal(HttpStatusCode.Found, response.StatusCode); + + await using var ctx = _factory.CreateDbContext(); + Assert.True(await ctx.Egitmenler.AnyAsync(e => e.AdSoyad == ad)); + } + + [Fact] + public async Task Kurs_katalogu_anonim_erisime_acik() + { + var client = _factory.CreateTestClient(); + + var response = await client.GetAsync("/Kurs/Index"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Kayit_dolu_olmayan_kursa_giris_yapmadan_basvurulamaz() + { + var client = _factory.CreateTestClient(); + + var response = await client.PostAsync("/Kurs/BasvuruYap", + new FormUrlEncodedContent(new Dictionary { ["id"] = "1" })); + + Assert.NotEqual(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Kaynak_kodundaki_eski_yonetici_sifresi_calismaz() + { + var client = _factory.CreateTestClient(); + var token = await KursKayitWebApplicationFactory.GetTokenAsync(client, "/Account/Login"); + + var response = await client.PostAsync("/Account/Login", + new FormUrlEncodedContent(new Dictionary + { + ["KullaniciAdi"] = "admin", + ["Sifre"] = "1234", + ["__RequestVerificationToken"] = token + })); + + // A failed login re-renders the form (200); a successful one would redirect (302). + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Health_endpointi_calisiyor() + { + var client = _factory.CreateTestClient(); + + var response = await client.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + private async Task OgrenciKaydetAsync(string ogrenciNo, string sifre) + { + await using var ctx = _factory.CreateDbContext(); + if (await ctx.Ogrenciler.AnyAsync(o => o.OgrenciNo == ogrenciNo)) + return; + + var client = _factory.CreateTestClient(); + var token = await KursKayitWebApplicationFactory.GetTokenAsync(client, "/Account/Register"); + + var response = await client.PostAsync("/Account/Register", + new FormUrlEncodedContent(new Dictionary + { + ["OgrenciNo"] = ogrenciNo, + ["AdSoyad"] = $"Öğrenci {ogrenciNo}", + ["Email"] = $"{ogrenciNo}@ornek.test", + ["Sifre"] = sifre, + ["__RequestVerificationToken"] = token + })); + + Assert.Equal(HttpStatusCode.Found, response.StatusCode); + } +} diff --git a/KursKayitSistemi.Tests/EnrollmentServiceTests.cs b/KursKayitSistemi.Tests/EnrollmentServiceTests.cs new file mode 100644 index 0000000..9f0888e --- /dev/null +++ b/KursKayitSistemi.Tests/EnrollmentServiceTests.cs @@ -0,0 +1,248 @@ +using KursKayitSistemi.Models; +using KursKayitSistemi.Services; +using KursKayitSistemi.Tests.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace KursKayitSistemi.Tests; + +public sealed class EnrollmentServiceTests : IDisposable +{ + private readonly SqliteTestDatabase _db = new(); + + public void Dispose() => _db.Dispose(); + + private EnrollmentService NewService(AppDbContext ctx) + => new(ctx, NullLogger.Instance); + + // ------------------------------------------------------------------ happy path + + [Fact] + public async Task Basvuru_kaydi_olusturur_ve_sayaci_arttirir() + { + var (_, kursId) = await TestData.KursOlusturAsync(_db, "Algoritmalar", kontenjan: 10); + var ogrenciId = await TestData.OgrenciOlusturAsync(_db, "1001"); + + await using (var ctx = _db.CreateContext()) + { + var result = await NewService(ctx).ApplyAsync(kursId, ogrenciId); + Assert.True(result.Success); + } + + await using (var ctx = _db.CreateContext()) + { + Assert.Equal(1, await ctx.Basvurular.CountAsync(b => b.KursId == kursId)); + Assert.Equal(1, (await ctx.Kurslar.SingleAsync(k => k.Id == kursId)).KayitliSayisi); + } + } + + [Fact] + public async Task Olmayan_kurs_icin_basvuru_reddedilir() + { + var ogrenciId = await TestData.OgrenciOlusturAsync(_db, "1001"); + + await using var ctx = _db.CreateContext(); + var result = await NewService(ctx).ApplyAsync(kursId: 9999, ogrenciId); + + Assert.False(result.Success); + Assert.Equal(EnrollmentError.KursBulunamadi, result.Error); + } + + [Fact] + public async Task Ayni_kursa_ikinci_basvuru_reddedilir() + { + var (_, kursId) = await TestData.KursOlusturAsync(_db, "Veri Yapıları", kontenjan: 10); + var ogrenciId = await TestData.OgrenciOlusturAsync(_db, "1001"); + + await using (var ctx = _db.CreateContext()) + Assert.True((await NewService(ctx).ApplyAsync(kursId, ogrenciId)).Success); + + await using (var ctx = _db.CreateContext()) + { + var result = await NewService(ctx).ApplyAsync(kursId, ogrenciId); + Assert.False(result.Success); + Assert.Equal(EnrollmentError.ZatenKayitli, result.Error); + } + + // The rejected second attempt must not have consumed a seat. + await using (var ctx = _db.CreateContext()) + Assert.Equal(1, (await ctx.Kurslar.SingleAsync(k => k.Id == kursId)).KayitliSayisi); + } + + // -------------------------------------------------------------------- capacity + + [Fact] + public async Task Kontenjan_dolunca_basvuru_reddedilir() + { + var (_, kursId) = await TestData.KursOlusturAsync(_db, "Küçük Kurs", kontenjan: 2); + + for (var i = 0; i < 2; i++) + { + var id = await TestData.OgrenciOlusturAsync(_db, $"200{i}"); + await using var ctx = _db.CreateContext(); + Assert.True((await NewService(ctx).ApplyAsync(kursId, id)).Success); + } + + var fazladan = await TestData.OgrenciOlusturAsync(_db, "2999"); + await using (var ctx = _db.CreateContext()) + { + var result = await NewService(ctx).ApplyAsync(kursId, fazladan); + Assert.False(result.Success); + Assert.Equal(EnrollmentError.KontenjanDolu, result.Error); + } + + await using (var ctx = _db.CreateContext()) + Assert.Equal(2, await ctx.Basvurular.CountAsync(b => b.KursId == kursId)); + } + + /// + /// Regression test for the original time-of-check/time-of-use bug: the controller counted + /// the applications, compared the count against Kontenjan, and only then inserted. Under + /// concurrency every request could read the same pre-insert count and be admitted. + /// + [Fact] + public async Task Es_zamanli_basvurular_kontenjani_asamaz() + { + const int kontenjan = 5; + const int esZamanliIstek = 15; + + var (_, kursId) = await TestData.KursOlusturAsync(_db, "Popüler Kurs", kontenjan); + + var ogrenciIdler = new List(); + for (var i = 0; i < esZamanliIstek; i++) + ogrenciIdler.Add(await TestData.OgrenciOlusturAsync(_db, $"30{i:D2}")); + + using var kapi = new SemaphoreSlim(0, esZamanliIstek); + + var gorevler = ogrenciIdler.Select(async ogrenciId => + { + await kapi.WaitAsync(); + await using var ctx = _db.CreateContext(); + return await NewService(ctx).ApplyAsync(kursId, ogrenciId); + }).ToList(); + + kapi.Release(esZamanliIstek); // release them all at once + var sonuclar = await Task.WhenAll(gorevler); + + Assert.Equal(kontenjan, sonuclar.Count(r => r.Success)); + Assert.Equal(esZamanliIstek - kontenjan, sonuclar.Count(r => r.Error == EnrollmentError.KontenjanDolu)); + + await using var son = _db.CreateContext(); + Assert.Equal(kontenjan, await son.Basvurular.CountAsync(b => b.KursId == kursId)); + Assert.Equal(kontenjan, (await son.Kurslar.SingleAsync(k => k.Id == kursId)).KayitliSayisi); + } + + // ------------------------------------------------------------------- ownership + + /// + /// Regression test for the original IDOR: cancellation took an application id and deleted + /// it without checking who owned it, so any signed-in student could cancel anyone's place + /// by guessing an id. + /// + [Fact] + public async Task Baskasinin_basvurusu_iptal_edilemez() + { + var (_, kursId) = await TestData.KursOlusturAsync(_db, "Paylaşılan Kurs", kontenjan: 10); + var sahibi = await TestData.OgrenciOlusturAsync(_db, "4001"); + var saldirgan = await TestData.OgrenciOlusturAsync(_db, "4002"); + + await using (var ctx = _db.CreateContext()) + Assert.True((await NewService(ctx).ApplyAsync(kursId, sahibi)).Success); + + int basvuruId; + await using (var ctx = _db.CreateContext()) + basvuruId = (await ctx.Basvurular.SingleAsync(b => b.OgrenciId == sahibi)).Id; + + await using (var ctx = _db.CreateContext()) + { + var result = await NewService(ctx).CancelAsync(basvuruId, saldirgan); + Assert.False(result.Success); + Assert.Equal(EnrollmentError.YetkiYok, result.Error); + } + + await using (var ctx = _db.CreateContext()) + { + Assert.True(await ctx.Basvurular.AnyAsync(b => b.Id == basvuruId)); + Assert.Equal(1, (await ctx.Kurslar.SingleAsync(k => k.Id == kursId)).KayitliSayisi); + } + } + + [Fact] + public async Task Sahibi_kendi_basvurusunu_iptal_edebilir_ve_sayac_azalir() + { + var (_, kursId) = await TestData.KursOlusturAsync(_db, "İptal Edilebilir", kontenjan: 3); + var ogrenciId = await TestData.OgrenciOlusturAsync(_db, "5001"); + + await using (var ctx = _db.CreateContext()) + Assert.True((await NewService(ctx).ApplyAsync(kursId, ogrenciId)).Success); + + int basvuruId; + await using (var ctx = _db.CreateContext()) + basvuruId = (await ctx.Basvurular.SingleAsync()).Id; + + await using (var ctx = _db.CreateContext()) + Assert.True((await NewService(ctx).CancelAsync(basvuruId, ogrenciId)).Success); + + await using (var ctx = _db.CreateContext()) + { + Assert.Empty(await ctx.Basvurular.ToListAsync()); + Assert.Equal(0, (await ctx.Kurslar.SingleAsync(k => k.Id == kursId)).KayitliSayisi); + } + } + + [Fact] + public async Task Iptal_edilen_yer_yeniden_kullanilabilir() + { + var (_, kursId) = await TestData.KursOlusturAsync(_db, "Tek Kişilik", kontenjan: 1); + var ilk = await TestData.OgrenciOlusturAsync(_db, "6001"); + var ikinci = await TestData.OgrenciOlusturAsync(_db, "6002"); + + await using (var ctx = _db.CreateContext()) + Assert.True((await NewService(ctx).ApplyAsync(kursId, ilk)).Success); + + await using (var ctx = _db.CreateContext()) + Assert.Equal(EnrollmentError.KontenjanDolu, (await NewService(ctx).ApplyAsync(kursId, ikinci)).Error); + + int basvuruId; + await using (var ctx = _db.CreateContext()) + basvuruId = (await ctx.Basvurular.SingleAsync()).Id; + + await using (var ctx = _db.CreateContext()) + Assert.True((await NewService(ctx).CancelAsync(basvuruId, ilk)).Success); + + await using (var ctx = _db.CreateContext()) + Assert.True((await NewService(ctx).ApplyAsync(kursId, ikinci)).Success); + } + + [Fact] + public async Task Yonetici_herhangi_bir_basvuruyu_iptal_edebilir() + { + var (_, kursId) = await TestData.KursOlusturAsync(_db, "Yönetici Kursu", kontenjan: 5); + var ogrenciId = await TestData.OgrenciOlusturAsync(_db, "7001"); + + await using (var ctx = _db.CreateContext()) + Assert.True((await NewService(ctx).ApplyAsync(kursId, ogrenciId)).Success); + + int basvuruId; + await using (var ctx = _db.CreateContext()) + basvuruId = (await ctx.Basvurular.SingleAsync()).Id; + + await using (var ctx = _db.CreateContext()) + Assert.True((await NewService(ctx).CancelAsAdminAsync(basvuruId)).Success); + + await using (var ctx = _db.CreateContext()) + Assert.Equal(0, (await ctx.Kurslar.SingleAsync(k => k.Id == kursId)).KayitliSayisi); + } + + [Fact] + public async Task Olmayan_basvurunun_iptali_reddedilir() + { + var ogrenciId = await TestData.OgrenciOlusturAsync(_db, "8001"); + + await using var ctx = _db.CreateContext(); + var result = await NewService(ctx).CancelAsync(basvuruId: 4242, ogrenciId); + + Assert.False(result.Success); + Assert.Equal(EnrollmentError.BasvuruBulunamadi, result.Error); + } +} diff --git a/KursKayitSistemi.Tests/Infrastructure/KursKayitWebApplicationFactory.cs b/KursKayitSistemi.Tests/Infrastructure/KursKayitWebApplicationFactory.cs new file mode 100644 index 0000000..9f0501f --- /dev/null +++ b/KursKayitSistemi.Tests/Infrastructure/KursKayitWebApplicationFactory.cs @@ -0,0 +1,103 @@ +using System.Text.RegularExpressions; +using KursKayitSistemi.Models; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace KursKayitSistemi.Tests.Infrastructure; + +/// +/// Hosts the real application over a throwaway SQLite file. The pipeline is the production +/// one — routing, model binding, cookie authentication, authorization and the antiforgery +/// filter all run — so an authorization test here fails if the attribute is missing, which a +/// direct call to a controller method would not catch. +/// +public sealed class KursKayitWebApplicationFactory : WebApplicationFactory +{ + public const string AdminKullaniciAdi = "test-admin"; + public const string AdminSifre = "TestYoneticiSifresi123!"; + + private readonly string _dbPath = + Path.Combine(Path.GetTempPath(), $"kurskayit-web-{Guid.NewGuid():N}.db"); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment(Environments.Development); + + builder.UseSetting("ConnectionStrings:DefaultConnection", $"Data Source={_dbPath};Default Timeout=30"); + builder.UseSetting("SeedAdmin:KullaniciAdi", AdminKullaniciAdi); + builder.UseSetting("SeedAdmin:Sifre", AdminSifre); + builder.UseSetting("SeedAdmin:AdSoyad", "Test Yöneticisi"); + } + + public AppDbContext CreateDbContext() + => Services.GetRequiredService() + .CreateScope().ServiceProvider + .GetRequiredService(); + + /// A client that keeps cookies and does not follow redirects, so tests can assert on the redirect itself. + public HttpClient CreateTestClient() => CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = true + }); + + /// Signs in through the real login form, including the antiforgery token. + public async Task CreateAuthenticatedClientAsync(string kullaniciAdi, string sifre) + { + var client = CreateTestClient(); + + var loginPage = await client.GetAsync("/Account/Login"); + loginPage.EnsureSuccessStatusCode(); + + var token = ExtractAntiforgeryToken(await loginPage.Content.ReadAsStringAsync()); + + var response = await client.PostAsync("/Account/Login", new FormUrlEncodedContent(new Dictionary + { + ["KullaniciAdi"] = kullaniciAdi, + ["Sifre"] = sifre, + ["__RequestVerificationToken"] = token + })); + + if (response.StatusCode != System.Net.HttpStatusCode.Found) + throw new InvalidOperationException($"Giris basarisiz: {response.StatusCode}"); + + return client; + } + + public static string ExtractAntiforgeryToken(string html) + { + var match = Regex.Match( + html, + """"""); + + if (!match.Success) + throw new InvalidOperationException("Sayfada antiforgery token bulunamadi."); + + return match.Groups[1].Value; + } + + /// Reads a page and returns the antiforgery token embedded in its form. + public static async Task GetTokenAsync(HttpClient client, string url) + { + var page = await client.GetAsync(url); + page.EnsureSuccessStatusCode(); + return ExtractAntiforgeryToken(await page.Content.ReadAsStringAsync()); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (!disposing) return; + + SqliteConnection.ClearAllPools(); + foreach (var file in new[] { _dbPath, _dbPath + "-wal", _dbPath + "-shm" }) + { + try { File.Delete(file); } + catch (IOException) { /* released when the temp folder is cleaned */ } + } + } +} diff --git a/KursKayitSistemi.Tests/Infrastructure/SqliteTestDatabase.cs b/KursKayitSistemi.Tests/Infrastructure/SqliteTestDatabase.cs new file mode 100644 index 0000000..1f59927 --- /dev/null +++ b/KursKayitSistemi.Tests/Infrastructure/SqliteTestDatabase.cs @@ -0,0 +1,50 @@ +using KursKayitSistemi.Models; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace KursKayitSistemi.Tests.Infrastructure; + +/// +/// A throwaway SQLite database on disk, created by running the real migrations. +/// +/// A file rather than the in-memory provider on purpose: the enrolment service relies on +/// transactions and ExecuteUpdate, neither of which the in-memory provider supports, +/// and a test that cannot exercise them would not be testing the thing that broke. +/// +public sealed class SqliteTestDatabase : IDisposable +{ + private readonly string _path; + + public string ConnectionString { get; } + + public SqliteTestDatabase() + { + _path = Path.Combine(Path.GetTempPath(), $"kurskayit-test-{Guid.NewGuid():N}.db"); + + // Default Timeout makes Microsoft.Data.Sqlite retry on SQLITE_BUSY instead of + // failing the moment another writer holds the lock — needed by the concurrency test. + ConnectionString = $"Data Source={_path};Default Timeout=30"; + + using var ctx = CreateContext(); + ctx.Database.Migrate(); + + // WAL lets readers proceed while a writer holds the lock. + ctx.Database.ExecuteSqlRaw("PRAGMA journal_mode=WAL;"); + } + + public AppDbContext CreateContext() + => new(new DbContextOptionsBuilder() + .UseSqlite(ConnectionString) + .Options); + + public void Dispose() + { + SqliteConnection.ClearAllPools(); + + foreach (var file in new[] { _path, _path + "-wal", _path + "-shm" }) + { + try { File.Delete(file); } + catch (IOException) { /* the OS still has a handle; the temp folder will win eventually */ } + } + } +} diff --git a/KursKayitSistemi.Tests/Infrastructure/TestData.cs b/KursKayitSistemi.Tests/Infrastructure/TestData.cs new file mode 100644 index 0000000..c65edc1 --- /dev/null +++ b/KursKayitSistemi.Tests/Infrastructure/TestData.cs @@ -0,0 +1,40 @@ +using KursKayitSistemi.Models; + +namespace KursKayitSistemi.Tests.Infrastructure; + +internal static class TestData +{ + public static async Task<(int EgitmenId, int KursId)> KursOlusturAsync( + SqliteTestDatabase db, string kursAdi, int kontenjan, string egitmenAdi = "Test Eğitmen") + { + await using var ctx = db.CreateContext(); + + var egitmen = new Egitmen { AdSoyad = egitmenAdi }; + ctx.Egitmenler.Add(egitmen); + await ctx.SaveChangesAsync(); + + var kurs = new Kurs { KursAdi = kursAdi, Kontenjan = kontenjan, EgitmenId = egitmen.Id }; + ctx.Kurslar.Add(kurs); + await ctx.SaveChangesAsync(); + + return (egitmen.Id, kurs.Id); + } + + public static async Task OgrenciOlusturAsync(SqliteTestDatabase db, string ogrenciNo) + { + await using var ctx = db.CreateContext(); + + var ogrenci = new Ogrenci + { + OgrenciNo = ogrenciNo, + AdSoyad = $"Öğrenci {ogrenciNo}", + Email = $"{ogrenciNo}@ornek.test", + SifreHash = "pbkdf2-sha256$1000$AAAAAAAAAAAAAAAAAAAAAA==$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }; + + ctx.Ogrenciler.Add(ogrenci); + await ctx.SaveChangesAsync(); + + return ogrenci.Id; + } +} diff --git a/KursKayitSistemi.Tests/KursKayitSistemi.Tests.csproj b/KursKayitSistemi.Tests/KursKayitSistemi.Tests.csproj new file mode 100644 index 0000000..fb3dd78 --- /dev/null +++ b/KursKayitSistemi.Tests/KursKayitSistemi.Tests.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + + + + + + + diff --git a/KursKayitSistemi.Tests/PasswordHashServiceTests.cs b/KursKayitSistemi.Tests/PasswordHashServiceTests.cs new file mode 100644 index 0000000..d896c48 --- /dev/null +++ b/KursKayitSistemi.Tests/PasswordHashServiceTests.cs @@ -0,0 +1,78 @@ +using KursKayitSistemi.Services; + +namespace KursKayitSistemi.Tests; + +public class PasswordHashServiceTests +{ + // Deliberately low so the suite stays fast; production uses the OWASP default. + private readonly Pbkdf2PasswordHashService _hasher = new(iterations: 1_000); + + [Fact] + public void Hash_dogru_sifreyi_dogrular() + { + var hash = _hasher.Hash("KorkuncGizliSifre1!"); + + Assert.True(_hasher.Verify("KorkuncGizliSifre1!", hash)); + } + + [Fact] + public void Hash_yanlis_sifreyi_reddeder() + { + var hash = _hasher.Hash("KorkuncGizliSifre1!"); + + Assert.False(_hasher.Verify("korkuncgizlisifre1!", hash)); + Assert.False(_hasher.Verify("", hash)); + Assert.False(_hasher.Verify("baska", hash)); + } + + [Fact] + public void Hash_ham_sifreyi_icermez() + { + const string sifre = "AcikMetinOlmamali"; + + var hash = _hasher.Hash(sifre); + + Assert.DoesNotContain(sifre, hash, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Ayni_sifre_her_seferinde_farkli_hash_uretir() + { + // Different salts, otherwise identical passwords are visible as identical rows. + var ilk = _hasher.Hash("ayniSifre"); + var ikinci = _hasher.Hash("ayniSifre"); + + Assert.NotEqual(ilk, ikinci); + Assert.True(_hasher.Verify("ayniSifre", ilk)); + Assert.True(_hasher.Verify("ayniSifre", ikinci)); + } + + [Fact] + public void Iterasyon_sayisi_hash_icine_gomulur() + { + var hash = new Pbkdf2PasswordHashService(iterations: 4_321).Hash("x"); + + Assert.Contains("$4321$", hash); + // A hash written with one iteration count must still verify after the default changes. + Assert.True(new Pbkdf2PasswordHashService(iterations: 99_999).Verify("x", hash)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("bozuk-format")] + [InlineData("pbkdf2-sha256$abc$def$ghi")] + [InlineData("pbkdf2-sha256$1000$!!!gecersiz-base64!!!$xxx")] + [InlineData("bcrypt$1000$c2FsdA==$aGFzaA==")] + public void Bozuk_hash_istisna_firlatmadan_false_doner(string storedHash) + { + // A corrupt row must not take the login page down with a 500. + Assert.False(_hasher.Verify("herhangi", storedHash)); + } + + [Fact] + public void Cok_dusuk_iterasyon_reddedilir() + { + Assert.Throws(() => new Pbkdf2PasswordHashService(iterations: 10)); + } +} diff --git a/KursKayitSistemi.Tests/SchemaTests.cs b/KursKayitSistemi.Tests/SchemaTests.cs new file mode 100644 index 0000000..0418dad --- /dev/null +++ b/KursKayitSistemi.Tests/SchemaTests.cs @@ -0,0 +1,101 @@ +using KursKayitSistemi.Models; +using KursKayitSistemi.Tests.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace KursKayitSistemi.Tests; + +public sealed class SchemaTests : IDisposable +{ + private readonly SqliteTestDatabase _db = new(); + + public void Dispose() => _db.Dispose(); + + /// + /// Fails when the entity model has drifted from the migration history — the usual cause + /// being a model change committed without the matching migration, which only shows up as + /// a runtime error against a real database. + /// + [Fact] + public void Model_ile_migrationlar_uyumlu() + { + using var ctx = _db.CreateContext(); + + Assert.False( + ctx.Database.HasPendingModelChanges(), + "Model migration gecmisiyle uyumsuz. 'dotnet ef migrations add ' calistirin."); + } + + [Fact] + public void Tum_migrationlar_uygulanmis() + { + using var ctx = _db.CreateContext(); + + Assert.Empty(ctx.Database.GetPendingMigrations()); + } + + [Fact] + public async Task Ayni_ogrenci_numarasi_veritabani_seviyesinde_engellenir() + { + await using var ctx = _db.CreateContext(); + + ctx.Ogrenciler.Add(new Ogrenci { OgrenciNo = "1001", AdSoyad = "İlk", Email = "ilk@ornek.test", SifreHash = "x" }); + await ctx.SaveChangesAsync(); + + ctx.Ogrenciler.Add(new Ogrenci { OgrenciNo = "1001", AdSoyad = "İkinci", Email = "ikinci@ornek.test", SifreHash = "x" }); + + await Assert.ThrowsAsync(() => ctx.SaveChangesAsync()); + } + + [Fact] + public async Task Ayni_ogrencinin_ayni_kursa_iki_basvurusu_engellenir() + { + var (_, kursId) = await TestData.KursOlusturAsync(_db, "Tekil Kurs", kontenjan: 10); + var ogrenciId = await TestData.OgrenciOlusturAsync(_db, "2001"); + + await using var ctx = _db.CreateContext(); + + ctx.Basvurular.Add(new Basvuru { KursId = kursId, OgrenciId = ogrenciId }); + await ctx.SaveChangesAsync(); + + ctx.Basvurular.Add(new Basvuru { KursId = kursId, OgrenciId = ogrenciId }); + + await Assert.ThrowsAsync(() => ctx.SaveChangesAsync()); + } + + [Fact] + public async Task Kursu_olan_egitmen_silinemez() + { + var (egitmenId, _) = await TestData.KursOlusturAsync(_db, "Bagli Kurs", kontenjan: 5); + + await using var ctx = _db.CreateContext(); + var egitmen = await ctx.Egitmenler.SingleAsync(e => e.Id == egitmenId); + ctx.Egitmenler.Remove(egitmen); + + // Restrict rather than Cascade: deleting an instructor must not quietly take their + // courses — and every application to them — with it. + await Assert.ThrowsAsync(() => ctx.SaveChangesAsync()); + } + + [Fact] + public async Task Kurs_silinince_basvurulari_da_silinir() + { + var (_, kursId) = await TestData.KursOlusturAsync(_db, "Silinecek Kurs", kontenjan: 5); + var ogrenciId = await TestData.OgrenciOlusturAsync(_db, "3001"); + + await using (var ctx = _db.CreateContext()) + { + ctx.Basvurular.Add(new Basvuru { KursId = kursId, OgrenciId = ogrenciId }); + await ctx.SaveChangesAsync(); + } + + await using (var ctx = _db.CreateContext()) + { + var kurs = await ctx.Kurslar.SingleAsync(k => k.Id == kursId); + ctx.Kurslar.Remove(kurs); + await ctx.SaveChangesAsync(); + } + + await using (var ctx = _db.CreateContext()) + Assert.Empty(await ctx.Basvurular.Where(b => b.KursId == kursId).ToListAsync()); + } +} diff --git a/KursKayitSistemi.slnx b/KursKayitSistemi.slnx index a0378ce..c5d993b 100644 --- a/KursKayitSistemi.slnx +++ b/KursKayitSistemi.slnx @@ -1,3 +1,4 @@ + diff --git a/KursKayitSistemi/Controllers/AccountController.cs b/KursKayitSistemi/Controllers/AccountController.cs index 3ee3083..b1975ad 100644 --- a/KursKayitSistemi/Controllers/AccountController.cs +++ b/KursKayitSistemi/Controllers/AccountController.cs @@ -1,91 +1,119 @@ -using KursKayitSistemi.Models; +using System.Security.Claims; +using KursKayitSistemi.Services; +using KursKayitSistemi.ViewModels; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using System.Security.Claims; namespace KursKayitSistemi.Controllers; public class AccountController : Controller { - private readonly AppDbContext _context; + private readonly IAccountService _accounts; + private readonly ILogger _logger; - public AccountController(AppDbContext context) + public AccountController(IAccountService accounts, ILogger logger) { - _context = context; + _accounts = accounts; + _logger = logger; } [HttpGet] - public IActionResult Login() + [AllowAnonymous] + public IActionResult Login(string? returnUrl = null) { - return View(); + ViewData["ReturnUrl"] = returnUrl; + return View(new GirisViewModel()); } [HttpPost] - public async Task Login(string kullaniciAdi, string sifre) + [AllowAnonymous] + [ValidateAntiForgeryToken] + public async Task Login(GirisViewModel model, string? returnUrl = null, CancellationToken ct = default) { - var claims = new List(); + ViewData["ReturnUrl"] = returnUrl; + + if (!ModelState.IsValid) + return View(model); - - if (kullaniciAdi == "admin" && sifre == "1234") + var user = await _accounts.AuthenticateAsync(model.KullaniciAdi, model.Sifre, ct); + + if (user is null) { - claims.Add(new Claim(ClaimTypes.Name, "Sistem Yöneticisi")); - claims.Add(new Claim(ClaimTypes.Role, "Admin")); + // Deliberately does not distinguish "no such user" from "wrong password". + ModelState.AddModelError(string.Empty, "Kullanıcı adı veya şifre hatalı!"); + _logger.LogWarning("Basarisiz giris denemesi: {KullaniciAdi}", model.KullaniciAdi); + return View(model); } - else + + var claims = new List { - - var ogrenci = _context.Ogrenciler.FirstOrDefault(o => o.OgrenciNo == kullaniciAdi && o.Sifre == sifre); - if (ogrenci != null) - { - claims.Add(new Claim(ClaimTypes.Name, ogrenci.AdSoyad)); - claims.Add(new Claim(ClaimTypes.Email, ogrenci.Email)); - claims.Add(new Claim(ClaimTypes.Role, "Ogrenci")); - } - else - { - ViewBag.Hata = "Kullanıcı adı (Öğrenci No) veya şifre hatalı!"; - return View(); - } - } + new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new(ClaimTypes.Name, user.AdSoyad), + new(ClaimTypes.Role, user.Role) + }; + + if (!string.IsNullOrWhiteSpace(user.Email)) + claims.Add(new Claim(ClaimTypes.Email, user.Email)); - var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); - var principal = new ClaimsPrincipal(identity); - await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal); + await HttpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(identity)); - if (claims.Any(c => c.Value == "Admin")) - return RedirectToAction("BasvuruListesi", "Admin"); - else - return RedirectToAction("Index", "Kurs"); + // Only follow a relative return URL — an absolute one turns the login form into + // an open redirect that can be used to make a phishing link look legitimate. + if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl)) + return Redirect(returnUrl); + + return user.Role == Roller.Admin + ? RedirectToAction(nameof(AdminController.BasvuruListesi), "Admin") + : RedirectToAction(nameof(KursController.Index), "Kurs"); } [HttpGet] - public IActionResult Register() - { - return View(); - } + [AllowAnonymous] + public IActionResult Register() => View(new KayitViewModel()); [HttpPost] - public IActionResult Register(Ogrenci yeniOgrenci) + [AllowAnonymous] + [ValidateAntiForgeryToken] + public async Task Register(KayitViewModel model, CancellationToken ct = default) { - if (_context.Ogrenciler.Any(o => o.OgrenciNo == yeniOgrenci.OgrenciNo || o.Email == yeniOgrenci.Email)) + if (!ModelState.IsValid) + return View(model); + + var result = await _accounts.RegisterAsync(model.OgrenciNo, model.AdSoyad, model.Email, model.Sifre, ct); + + if (!result.Success) { - ViewBag.KayitHata = "Bu Öğrenci Numarası veya Email zaten sistemde kayıtlı!"; - return View(yeniOgrenci); - } + var alan = result.Error == RegistrationError.DuplicateEmail + ? nameof(KayitViewModel.Email) + : nameof(KayitViewModel.OgrenciNo); - _context.Ogrenciler.Add(yeniOgrenci); - _context.SaveChanges(); + var mesaj = result.Error == RegistrationError.DuplicateEmail + ? "Bu email adresi zaten kayıtlı." + : "Bu öğrenci numarası zaten kayıtlı."; + + ModelState.AddModelError(alan, mesaj); + return View(model); + } - TempData["KayitBasarili"] = "Kaydınız başarıyla oluşturuldu! Şimdi giriş yapabilirsiniz."; - return RedirectToAction("Login"); + return RedirectToAction(nameof(Login)); } + [HttpPost] + [Authorize] + [ValidateAntiForgeryToken] public async Task Logout() { await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); - return RedirectToAction("Login", "Account"); + return RedirectToAction(nameof(Login)); } -} \ No newline at end of file + + [HttpGet] + [AllowAnonymous] + public IActionResult AccessDenied() => View(); +} diff --git a/KursKayitSistemi/Controllers/AdminController.cs b/KursKayitSistemi/Controllers/AdminController.cs index 00cbd8b..9eac20c 100644 --- a/KursKayitSistemi/Controllers/AdminController.cs +++ b/KursKayitSistemi/Controllers/AdminController.cs @@ -1,58 +1,67 @@ -using Microsoft.AspNetCore.Mvc; +using KursKayitSistemi.Models; +using KursKayitSistemi.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.EntityFrameworkCore; -using KursKayitSistemi.Models; -using System; -using System.Linq; namespace KursKayitSistemi.Controllers; +/// +/// Management area. The class-level role requirement is the whole point of this +/// controller's security: without it every action below — including the delete +/// endpoints — was reachable by an anonymous request. +/// +[Authorize(Roles = Roller.Admin)] public class AdminController : Controller { + private const int PageSize = 15; + private readonly AppDbContext _context; + private readonly IEnrollmentService _enrollment; - public AdminController(AppDbContext context) + public AdminController(AppDbContext context, IEnrollmentService enrollment) { _context = context; + _enrollment = enrollment; } [HttpGet] - public IActionResult BasvuruListesi(int? egitmenId, int? kursId, string arananOgrenci, int page = 1) + public async Task BasvuruListesi( + int? egitmenId, int? kursId, string? arananOgrenci, int page = 1, CancellationToken ct = default) { - int pageSize = 15; + page = Math.Max(1, page); var sorgu = _context.Basvurular + .AsNoTracking() .Include(b => b.Kurs) .ThenInclude(k => k!.Egitmen) .Include(b => b.Ogrenci) .AsQueryable(); if (egitmenId.HasValue) - { sorgu = sorgu.Where(b => b.Kurs!.EgitmenId == egitmenId.Value); - } if (kursId.HasValue) - { sorgu = sorgu.Where(b => b.KursId == kursId.Value); - } - if (!string.IsNullOrEmpty(arananOgrenci)) - { - sorgu = sorgu.Where(b => b.Ogrenci!.OgrenciNo != null && b.Ogrenci.OgrenciNo.Contains(arananOgrenci)); - } + if (!string.IsNullOrWhiteSpace(arananOgrenci)) + sorgu = sorgu.Where(b => b.Ogrenci!.OgrenciNo.Contains(arananOgrenci)); - int totalItems = sorgu.Count(); - int totalPages = (int)Math.Ceiling(totalItems / (double)pageSize); + var totalItems = await sorgu.CountAsync(ct); + var totalPages = (int)Math.Ceiling(totalItems / (double)PageSize); - var basvurular = sorgu + var basvurular = await sorgu .OrderByDescending(b => b.BasvuruTarihi) - .Skip((page - 1) * pageSize) - .Take(pageSize) - .ToList(); + .Skip((page - 1) * PageSize) + .Take(PageSize) + .ToListAsync(ct); + + ViewBag.Egitmenler = new SelectList( + await _context.Egitmenler.AsNoTracking().OrderBy(e => e.AdSoyad).ToListAsync(ct), "Id", "AdSoyad", egitmenId); + ViewBag.Kurslar = new SelectList( + await _context.Kurslar.AsNoTracking().OrderBy(k => k.KursAdi).ToListAsync(ct), "Id", "KursAdi", kursId); - ViewBag.Egitmenler = new SelectList(_context.Egitmenler.ToList(), "Id", "AdSoyad", egitmenId); - ViewBag.Kurslar = new SelectList(_context.Kurslar.ToList(), "Id", "KursAdi", kursId); ViewBag.ArananOgrenci = arananOgrenci; ViewBag.EgitmenId = egitmenId; ViewBag.KursId = kursId; @@ -64,174 +73,218 @@ public IActionResult BasvuruListesi(int? egitmenId, int? kursId, string arananOg } [HttpPost] - public IActionResult BasvuruSil(int id) + [ValidateAntiForgeryToken] + public async Task BasvuruSil(int id, CancellationToken ct = default) { - var basvuru = _context.Basvurular.Find(id); - if (basvuru != null) - { - _context.Basvurular.Remove(basvuru); - _context.SaveChanges(); - } - return RedirectToAction("BasvuruListesi"); + // Goes through the service so the course's seat counter is released too; + // deleting the row directly would leave the course permanently short a seat. + await _enrollment.CancelAsAdminAsync(id, ct); + return RedirectToAction(nameof(BasvuruListesi)); } + // ---------------------------------------------------------------- instructors + [HttpGet] - public IActionResult EgitmenYonetimi() - { - var egitmenler = _context.Egitmenler.ToList(); - return View(egitmenler); - } + public async Task EgitmenYonetimi(CancellationToken ct = default) + => View(await _context.Egitmenler.AsNoTracking().OrderBy(e => e.AdSoyad).ToListAsync(ct)); [HttpGet] - public IActionResult EgitmenEkle() - { - return View(); - } + public IActionResult EgitmenEkle() => View(new Egitmen()); [HttpPost] - public IActionResult EgitmenEkle(Egitmen egitmen) + [ValidateAntiForgeryToken] + public async Task EgitmenEkle(Egitmen egitmen, CancellationToken ct = default) { - if (ModelState.IsValid) + if (!ModelState.IsValid) + return View(egitmen); + + egitmen.AdSoyad = egitmen.AdSoyad.Trim(); + + if (await EgitmenAdiKullanimdaAsync(egitmen.AdSoyad, haricId: null, ct)) { - bool egitmenVarMi = _context.Egitmenler.ToList() - .Any(e => e.AdSoyad.Trim().Equals(egitmen.AdSoyad.Trim(), StringComparison.CurrentCultureIgnoreCase)); - - if (egitmenVarMi) - { - ModelState.AddModelError("AdSoyad", "Bu isimde bir eğitmen sistemde zaten kayıtlı!"); - return View(egitmen); - } - - _context.Egitmenler.Add(egitmen); - _context.SaveChanges(); - return RedirectToAction("EgitmenYonetimi"); + ModelState.AddModelError(nameof(Egitmen.AdSoyad), "Bu isimde bir eğitmen sistemde zaten kayıtlı!"); + return View(egitmen); } - return View(egitmen); + + _context.Egitmenler.Add(egitmen); + await _context.SaveChangesAsync(ct); + return RedirectToAction(nameof(EgitmenYonetimi)); } [HttpGet] - public IActionResult EgitmenDuzenle(int id) + public async Task EgitmenDuzenle(int id, CancellationToken ct = default) { - var egitmen = _context.Egitmenler.Find(id); - if (egitmen == null) return NotFound(); - return View(egitmen); + var egitmen = await _context.Egitmenler.FindAsync([id], ct); + return egitmen is null ? NotFound() : View(egitmen); } [HttpPost] - public IActionResult EgitmenDuzenle(Egitmen egitmen) + [ValidateAntiForgeryToken] + public async Task EgitmenDuzenle(Egitmen egitmen, CancellationToken ct = default) { - if (ModelState.IsValid) + if (!ModelState.IsValid) + return View(egitmen); + + egitmen.AdSoyad = egitmen.AdSoyad.Trim(); + + if (await EgitmenAdiKullanimdaAsync(egitmen.AdSoyad, haricId: egitmen.Id, ct)) { - bool egitmenVarMi = _context.Egitmenler.ToList() - .Any(e => e.AdSoyad.Trim().Equals(egitmen.AdSoyad.Trim(), StringComparison.CurrentCultureIgnoreCase) && e.Id != egitmen.Id); - - if (egitmenVarMi) - { - ModelState.AddModelError("AdSoyad", "Bu isimde başka bir eğitmen zaten kayıtlı!"); - return View(egitmen); - } - - _context.Egitmenler.Update(egitmen); - _context.SaveChanges(); - return RedirectToAction("EgitmenYonetimi"); + ModelState.AddModelError(nameof(Egitmen.AdSoyad), "Bu isimde başka bir eğitmen zaten kayıtlı!"); + return View(egitmen); } - return View(egitmen); + + if (!await _context.Egitmenler.AnyAsync(e => e.Id == egitmen.Id, ct)) + return NotFound(); + + _context.Egitmenler.Update(egitmen); + await _context.SaveChangesAsync(ct); + return RedirectToAction(nameof(EgitmenYonetimi)); } [HttpPost] - public IActionResult EgitmenSil(int id) + [ValidateAntiForgeryToken] + public async Task EgitmenSil(int id, CancellationToken ct = default) { - var egitmen = _context.Egitmenler.Find(id); - if (egitmen != null) + // The foreign key is Restrict, so check first and explain rather than letting + // the database throw an unhandled exception into the user's face. + if (await _context.Kurslar.AnyAsync(k => k.EgitmenId == id, ct)) { - _context.Egitmenler.Remove(egitmen); - _context.SaveChanges(); + TempData["Hata"] = "Bu eğitmene bağlı kurslar olduğu için silinemez. Önce kursları silin veya başka bir eğitmene atayın."; + return RedirectToAction(nameof(EgitmenYonetimi)); } - return RedirectToAction("EgitmenYonetimi"); + + await _context.Egitmenler.Where(e => e.Id == id).ExecuteDeleteAsync(ct); + return RedirectToAction(nameof(EgitmenYonetimi)); } + // -------------------------------------------------------------------- courses + [HttpGet] - public IActionResult KursYonetimi() - { - var kurslar = _context.Kurslar + public async Task KursYonetimi(CancellationToken ct = default) + => View(await _context.Kurslar + .AsNoTracking() .Include(k => k.Egitmen) .Include(k => k.Basvurular) - .ToList(); - return View(kurslar); - } + .OrderBy(k => k.KursAdi) + .ToListAsync(ct)); [HttpGet] - public IActionResult KursEkle() + public async Task KursEkle(CancellationToken ct = default) { - ViewBag.Egitmenler = new SelectList(_context.Egitmenler.ToList(), "Id", "AdSoyad"); - return View(); + await EgitmenListesiDoldurAsync(null, ct); + return View(new Kurs()); } [HttpPost] - public IActionResult KursEkle(Kurs kurs) + [ValidateAntiForgeryToken] + public async Task KursEkle(Kurs kurs, CancellationToken ct = default) { - if (ModelState.IsValid) + if (!ModelState.IsValid) { - bool kursVarMi = _context.Kurslar.ToList() - .Any(k => k.KursAdi.Trim().Equals(kurs.KursAdi.Trim(), StringComparison.CurrentCultureIgnoreCase)); - - if (kursVarMi) - { - ModelState.AddModelError("KursAdi", "Bu isimde bir kurs zaten mevcut!"); - ViewBag.Egitmenler = new SelectList(_context.Egitmenler.ToList(), "Id", "AdSoyad", kurs.EgitmenId); - return View(kurs); - } - - _context.Kurslar.Add(kurs); - _context.SaveChanges(); - return RedirectToAction("KursYonetimi"); + await EgitmenListesiDoldurAsync(kurs.EgitmenId, ct); + return View(kurs); } - ViewBag.Egitmenler = new SelectList(_context.Egitmenler.ToList(), "Id", "AdSoyad", kurs.EgitmenId); - return View(kurs); + + kurs.KursAdi = kurs.KursAdi.Trim(); + + if (await KursAdiKullanimdaAsync(kurs.KursAdi, haricId: null, ct)) + { + ModelState.AddModelError(nameof(Kurs.KursAdi), "Bu isimde bir kurs zaten mevcut!"); + await EgitmenListesiDoldurAsync(kurs.EgitmenId, ct); + return View(kurs); + } + + if (!await _context.Egitmenler.AnyAsync(e => e.Id == kurs.EgitmenId, ct)) + { + ModelState.AddModelError(nameof(Kurs.EgitmenId), "Seçilen eğitmen bulunamadı."); + await EgitmenListesiDoldurAsync(kurs.EgitmenId, ct); + return View(kurs); + } + + kurs.KayitliSayisi = 0; + _context.Kurslar.Add(kurs); + await _context.SaveChangesAsync(ct); + return RedirectToAction(nameof(KursYonetimi)); } [HttpGet] - public IActionResult KursDuzenle(int id) + public async Task KursDuzenle(int id, CancellationToken ct = default) { - var kurs = _context.Kurslar.Find(id); - if (kurs == null) return NotFound(); + var kurs = await _context.Kurslar.FindAsync([id], ct); + if (kurs is null) return NotFound(); - ViewBag.Egitmenler = new SelectList(_context.Egitmenler.ToList(), "Id", "AdSoyad", kurs.EgitmenId); + await EgitmenListesiDoldurAsync(kurs.EgitmenId, ct); return View(kurs); } [HttpPost] - public IActionResult KursDuzenle(Kurs kurs) + [ValidateAntiForgeryToken] + public async Task KursDuzenle(Kurs kurs, CancellationToken ct = default) { - if (ModelState.IsValid) + if (!ModelState.IsValid) { - bool kursVarMi = _context.Kurslar.ToList() - .Any(k => k.KursAdi.Trim().Equals(kurs.KursAdi.Trim(), StringComparison.CurrentCultureIgnoreCase) && k.Id != kurs.Id); - - if (kursVarMi) - { - ModelState.AddModelError("KursAdi", "Bu isimde başka bir kurs zaten mevcut!"); - ViewBag.Egitmenler = new SelectList(_context.Egitmenler.ToList(), "Id", "AdSoyad", kurs.EgitmenId); - return View(kurs); - } - - _context.Kurslar.Update(kurs); - _context.SaveChanges(); - return RedirectToAction("KursYonetimi"); + await EgitmenListesiDoldurAsync(kurs.EgitmenId, ct); + return View(kurs); } - ViewBag.Egitmenler = new SelectList(_context.Egitmenler.ToList(), "Id", "AdSoyad", kurs.EgitmenId); - return View(kurs); + + kurs.KursAdi = kurs.KursAdi.Trim(); + + var mevcut = await _context.Kurslar.FirstOrDefaultAsync(k => k.Id == kurs.Id, ct); + if (mevcut is null) return NotFound(); + + if (await KursAdiKullanimdaAsync(kurs.KursAdi, haricId: kurs.Id, ct)) + { + ModelState.AddModelError(nameof(Kurs.KursAdi), "Bu isimde başka bir kurs zaten mevcut!"); + await EgitmenListesiDoldurAsync(kurs.EgitmenId, ct); + return View(kurs); + } + + // Capacity may not be lowered below the students already enrolled — otherwise the + // course is silently over capacity and no further cancellation can fix it. + if (kurs.Kontenjan < mevcut.KayitliSayisi) + { + ModelState.AddModelError(nameof(Kurs.Kontenjan), + $"Kontenjan mevcut kayıtlı öğrenci sayısından ({mevcut.KayitliSayisi}) küçük olamaz."); + await EgitmenListesiDoldurAsync(kurs.EgitmenId, ct); + return View(kurs); + } + + mevcut.KursAdi = kurs.KursAdi; + mevcut.Kontenjan = kurs.Kontenjan; + mevcut.EgitmenId = kurs.EgitmenId; + + await _context.SaveChangesAsync(ct); + return RedirectToAction(nameof(KursYonetimi)); } [HttpPost] - public IActionResult KursSil(int id) + [ValidateAntiForgeryToken] + public async Task KursSil(int id, CancellationToken ct = default) { - var kurs = _context.Kurslar.Find(id); - if (kurs != null) - { - _context.Kurslar.Remove(kurs); - _context.SaveChanges(); - } - return RedirectToAction("KursYonetimi"); + await _context.Kurslar.Where(k => k.Id == id).ExecuteDeleteAsync(ct); + return RedirectToAction(nameof(KursYonetimi)); + } + + // --------------------------------------------------------------------- helpers + + // Comparison happens in SQL. The old version called .ToList() first, pulling every + // instructor (or course) into memory on each add and edit just to compare names. + private Task EgitmenAdiKullanimdaAsync(string adSoyad, int? haricId, CancellationToken ct) + { + var normalized = adSoyad.ToLower(); + return _context.Egitmenler + .AnyAsync(e => e.AdSoyad.ToLower() == normalized && (haricId == null || e.Id != haricId), ct); } -} \ No newline at end of file + + private Task KursAdiKullanimdaAsync(string kursAdi, int? haricId, CancellationToken ct) + { + var normalized = kursAdi.ToLower(); + return _context.Kurslar + .AnyAsync(k => k.KursAdi.ToLower() == normalized && (haricId == null || k.Id != haricId), ct); + } + + private async Task EgitmenListesiDoldurAsync(int? seciliId, CancellationToken ct) + => ViewBag.Egitmenler = new SelectList( + await _context.Egitmenler.AsNoTracking().OrderBy(e => e.AdSoyad).ToListAsync(ct), + "Id", "AdSoyad", seciliId); +} diff --git a/KursKayitSistemi/Controllers/BasvuruController.cs b/KursKayitSistemi/Controllers/BasvuruController.cs index 1ef0871..e44a7cf 100644 --- a/KursKayitSistemi/Controllers/BasvuruController.cs +++ b/KursKayitSistemi/Controllers/BasvuruController.cs @@ -1,67 +1,62 @@ -using Microsoft.AspNetCore.Authorization; +using KursKayitSistemi.Services; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using KursKayitSistemi.Models; -using System.Linq; namespace KursKayitSistemi.Controllers; -[Authorize] +/// +/// Alternative entry point for enrolment. It used to carry a second, weaker copy of the +/// logic in — it never checked capacity at all — so both now +/// call the same service. +/// +[Authorize(Roles = Roller.Ogrenci)] public class BasvuruController : Controller { - private readonly AppDbContext _context; + private readonly IEnrollmentService _enrollment; - public BasvuruController(AppDbContext context) + public BasvuruController(IEnrollmentService enrollment) { - _context = context; + _enrollment = enrollment; } [HttpPost] - public IActionResult Yap(int kursId) + [ValidateAntiForgeryToken] + public async Task Yap(int kursId, CancellationToken ct = default) { - var kullaniciAdi = User.Identity!.Name; + if (User.GetUserId() is not int ogrenciId) + return Forbid(); - var ogrenci = _context.Ogrenciler.FirstOrDefault(o => o.AdSoyad == kullaniciAdi || o.OgrenciNo == kullaniciAdi); + var result = await _enrollment.ApplyAsync(kursId, ogrenciId, ct); - if (ogrenci != null) - { - var mevcutBasvuru = _context.Basvurular.FirstOrDefault(b => b.KursId == kursId && b.OgrenciId == ogrenci.Id); - - if (mevcutBasvuru == null) - { - var basvuru = new Basvuru - { - KursId = kursId, - OgrenciId = ogrenci.Id - }; + if (result.Success) + return RedirectToAction(nameof(Basarili)); - _context.Basvurular.Add(basvuru); - _context.SaveChanges(); - } - } + TempData["Hata"] = result.Error switch + { + EnrollmentError.KursBulunamadi => "Kurs bulunamadı.", + EnrollmentError.ZatenKayitli => "Bu kursa zaten kayıtlısınız!", + EnrollmentError.KontenjanDolu => "Maalesef bu kursun kontenjanı dolmuştur!", + _ => "Başvuru tamamlanamadı." + }; - return RedirectToAction("Index", "Kurs"); + return RedirectToAction(nameof(KursController.Index), "Kurs"); } - [HttpPost] - public IActionResult IptalEt(int kursId) + [ValidateAntiForgeryToken] + public async Task IptalEt(int basvuruId, CancellationToken ct = default) { - var kullaniciAdi = User.Identity!.Name; + if (User.GetUserId() is not int ogrenciId) + return Forbid(); - var ogrenci = _context.Ogrenciler.FirstOrDefault(o => o.AdSoyad == kullaniciAdi || o.OgrenciNo == kullaniciAdi); + var result = await _enrollment.CancelAsync(basvuruId, ogrenciId, ct); - if (ogrenci != null) - { - var basvuru = _context.Basvurular.FirstOrDefault(b => b.KursId == kursId && b.OgrenciId == ogrenci.Id); - - if (basvuru != null) - { - _context.Basvurular.Remove(basvuru); - _context.SaveChanges(); - } - } + if (result.Error == EnrollmentError.YetkiYok) + return Forbid(); - return RedirectToAction("Index", "Kurs"); + return RedirectToAction(nameof(KursController.Index), "Kurs"); } -} \ No newline at end of file + + [HttpGet] + public IActionResult Basarili() => View(); +} diff --git a/KursKayitSistemi/Controllers/KursController.cs b/KursKayitSistemi/Controllers/KursController.cs index 8c39af9..723c65a 100644 --- a/KursKayitSistemi/Controllers/KursController.cs +++ b/KursKayitSistemi/Controllers/KursController.cs @@ -1,113 +1,100 @@ -using Microsoft.AspNetCore.Mvc; +using KursKayitSistemi.Models; +using KursKayitSistemi.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.EntityFrameworkCore; -using KursKayitSistemi.Models; -using System; -using System.Linq; namespace KursKayitSistemi.Controllers; public class KursController : Controller { private readonly AppDbContext _context; + private readonly IEnrollmentService _enrollment; - public KursController(AppDbContext context) + public KursController(AppDbContext context, IEnrollmentService enrollment) { _context = context; + _enrollment = enrollment; } + /// Public course catalogue. Anonymous visitors see the list without their own enrolments. [HttpGet] - public IActionResult Index(int? egitmenId) + [AllowAnonymous] + public async Task Index(int? egitmenId, CancellationToken ct = default) { var sorgu = _context.Kurslar + .AsNoTracking() .Include(k => k.Egitmen) - .Include(k => k.Basvurular) .AsQueryable(); if (egitmenId.HasValue) - { sorgu = sorgu.Where(k => k.EgitmenId == egitmenId.Value); - } - if (User.Identity != null && User.Identity.IsAuthenticated) + if (User.Identity?.IsAuthenticated == true && User.GetUserId() is int ogrenciId) { - string userName = User.Identity.Name ?? ""; - var ogrenci = _context.Ogrenciler.FirstOrDefault(o => - o.AdSoyad == userName || o.Email == userName || o.OgrenciNo == userName); - - if (ogrenci != null) - { - var kayitliKurslar = _context.Basvurular - .Include(b => b.Kurs) - .ThenInclude(k => k!.Egitmen) - .Where(b => b.OgrenciId == ogrenci.Id) - .ToList(); - - ViewBag.KayitliKurslar = kayitliKurslar; - } + ViewBag.KayitliKurslar = await _context.Basvurular + .AsNoTracking() + .Include(b => b.Kurs) + .ThenInclude(k => k!.Egitmen) + .Where(b => b.OgrenciId == ogrenciId) + .ToListAsync(ct); } - ViewBag.Egitmenler = new SelectList(_context.Egitmenler.ToList(), "Id", "AdSoyad", egitmenId); - return View(sorgu.ToList()); + ViewBag.Egitmenler = new SelectList( + await _context.Egitmenler.AsNoTracking().OrderBy(e => e.AdSoyad).ToListAsync(ct), + "Id", "AdSoyad", egitmenId); + + return View(await sorgu.OrderBy(k => k.KursAdi).ToListAsync(ct)); } [HttpPost] - public IActionResult BasvuruYap(int id) + [Authorize(Roles = Roller.Ogrenci)] + [ValidateAntiForgeryToken] + public async Task BasvuruYap(int id, CancellationToken ct = default) { - if (User.Identity == null || !User.Identity.IsAuthenticated) - { - return RedirectToAction("Login", "Account"); - } - - string userName = User.Identity.Name ?? ""; - var ogrenci = _context.Ogrenciler.FirstOrDefault(o => - o.AdSoyad == userName || o.Email == userName || o.OgrenciNo == userName); - - if (ogrenci == null) - { - TempData["Hata"] = "Öğrenci profili bulunamadı. Lütfen giriş bilgilerinizi kontrol edin."; - return RedirectToAction("Index"); - } - - var zatenKayitliMi = _context.Basvurular.Any(b => b.KursId == id && b.OgrenciId == ogrenci.Id); - if (zatenKayitliMi) - { - TempData["Hata"] = "Bu kursa zaten kayıtlısınız!"; - return RedirectToAction("Index"); - } + if (User.GetUserId() is not int ogrenciId) + return Forbid(); - var kurs = _context.Kurslar.Include(k => k.Basvurular).FirstOrDefault(k => k.Id == id); - if (kurs != null && kurs.Basvurular!.Count < kurs.Kontenjan) - { - var yeniBasvuru = new Basvuru - { - KursId = id, - OgrenciId = ogrenci.Id, - BasvuruTarihi = DateTime.Now - }; - _context.Basvurular.Add(yeniBasvuru); - _context.SaveChanges(); + var result = await _enrollment.ApplyAsync(id, ogrenciId, ct); + if (result.Success) TempData["Basari"] = "Kursa başarıyla kayıt oldunuz!"; - } else - { - TempData["Hata"] = "Maalesef bu kursun kontenjanı dolmuştur!"; - } + TempData["Hata"] = result.Error switch + { + EnrollmentError.KursBulunamadi => "Kurs bulunamadı.", + EnrollmentError.ZatenKayitli => "Bu kursa zaten kayıtlısınız!", + EnrollmentError.KontenjanDolu => "Maalesef bu kursun kontenjanı dolmuştur!", + _ => "Kayıt işlemi tamamlanamadı." + }; - return RedirectToAction("Index"); + return RedirectToAction(nameof(Index)); } [HttpPost] - public IActionResult BasvuruIptal(int id) + [Authorize(Roles = Roller.Ogrenci)] + [ValidateAntiForgeryToken] + public async Task BasvuruIptal(int id, CancellationToken ct = default) { - var basvuru = _context.Basvurular.Find(id); - if (basvuru != null) + if (User.GetUserId() is not int ogrenciId) + return Forbid(); + + // The id identifies an application, not its owner. Without the ownership check + // inside the service, any signed-in student could cancel anyone else's enrolment + // by guessing an id. + var result = await _enrollment.CancelAsync(id, ogrenciId, ct); + + if (result.Success) { - _context.Basvurular.Remove(basvuru); - _context.SaveChanges(); TempData["Basari"] = "Kurs kaydınız başarıyla iptal edildi."; + return RedirectToAction(nameof(Index)); } - return RedirectToAction("Index"); + + if (result.Error == EnrollmentError.YetkiYok) + return Forbid(); + + TempData["Hata"] = "Kayıt bulunamadı."; + return RedirectToAction(nameof(Index)); } -} \ No newline at end of file +} diff --git a/KursKayitSistemi/KursKayitSistemi.csproj b/KursKayitSistemi/KursKayitSistemi.csproj index 50d2d9d..928f01d 100644 --- a/KursKayitSistemi/KursKayitSistemi.csproj +++ b/KursKayitSistemi/KursKayitSistemi.csproj @@ -4,18 +4,32 @@ net10.0 enable enable + + true - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/KursKayitSistemi/Migrations/20260804161928_HesapGuvenligiVeKontenjanSayaci.Designer.cs b/KursKayitSistemi/Migrations/20260804161928_HesapGuvenligiVeKontenjanSayaci.Designer.cs new file mode 100644 index 0000000..be4df0a --- /dev/null +++ b/KursKayitSistemi/Migrations/20260804161928_HesapGuvenligiVeKontenjanSayaci.Designer.cs @@ -0,0 +1,210 @@ +// +using System; +using KursKayitSistemi.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace KursKayitSistemi.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260804161928_HesapGuvenligiVeKontenjanSayaci")] + partial class HesapGuvenligiVeKontenjanSayaci + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("KursKayitSistemi.Models.Basvuru", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BasvuruTarihi") + .HasColumnType("TEXT"); + + b.Property("KursId") + .HasColumnType("INTEGER"); + + b.Property("OgrenciId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OgrenciId"); + + b.HasIndex("KursId", "OgrenciId") + .IsUnique(); + + b.ToTable("Basvurular"); + }); + + modelBuilder.Entity("KursKayitSistemi.Models.Egitmen", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdSoyad") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AdSoyad") + .IsUnique(); + + b.ToTable("Egitmenler"); + }); + + modelBuilder.Entity("KursKayitSistemi.Models.Kurs", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EgitmenId") + .HasColumnType("INTEGER"); + + b.Property("KayitliSayisi") + .HasColumnType("INTEGER"); + + b.Property("Kontenjan") + .HasColumnType("INTEGER"); + + b.Property("KursAdi") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EgitmenId"); + + b.HasIndex("KursAdi") + .IsUnique(); + + b.ToTable("Kurslar"); + }); + + modelBuilder.Entity("KursKayitSistemi.Models.Ogrenci", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdSoyad") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("OgrenciNo") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("SifreHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("OgrenciNo") + .IsUnique(); + + b.ToTable("Ogrenciler"); + }); + + modelBuilder.Entity("KursKayitSistemi.Models.Yonetici", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdSoyad") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("KullaniciAdi") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SifreHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("KullaniciAdi") + .IsUnique(); + + b.ToTable("Yoneticiler"); + }); + + modelBuilder.Entity("KursKayitSistemi.Models.Basvuru", b => + { + b.HasOne("KursKayitSistemi.Models.Kurs", "Kurs") + .WithMany("Basvurular") + .HasForeignKey("KursId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("KursKayitSistemi.Models.Ogrenci", "Ogrenci") + .WithMany("Basvurular") + .HasForeignKey("OgrenciId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Kurs"); + + b.Navigation("Ogrenci"); + }); + + modelBuilder.Entity("KursKayitSistemi.Models.Kurs", b => + { + b.HasOne("KursKayitSistemi.Models.Egitmen", "Egitmen") + .WithMany("Kurslar") + .HasForeignKey("EgitmenId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Egitmen"); + }); + + modelBuilder.Entity("KursKayitSistemi.Models.Egitmen", b => + { + b.Navigation("Kurslar"); + }); + + modelBuilder.Entity("KursKayitSistemi.Models.Kurs", b => + { + b.Navigation("Basvurular"); + }); + + modelBuilder.Entity("KursKayitSistemi.Models.Ogrenci", b => + { + b.Navigation("Basvurular"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/KursKayitSistemi/Migrations/20260804161928_HesapGuvenligiVeKontenjanSayaci.cs b/KursKayitSistemi/Migrations/20260804161928_HesapGuvenligiVeKontenjanSayaci.cs new file mode 100644 index 0000000..c0b08ef --- /dev/null +++ b/KursKayitSistemi/Migrations/20260804161928_HesapGuvenligiVeKontenjanSayaci.cs @@ -0,0 +1,178 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace KursKayitSistemi.Migrations +{ + /// + /// Replaces clear-text credentials with PBKDF2 hashes, moves the administrator out of + /// source code into its own table, and adds the seat counter that makes the capacity + /// check atomic. + /// + /// Not reversible for data: Ogrenciler.Sifre held clear-text passwords and is + /// dropped rather than converted — a hash cannot be derived into the old column, and + /// existing accounts must register again. On a database that already has rows, the new + /// unique indexes will fail if duplicate student numbers, e-mails, course names or + /// instructor names exist; clean those up before applying. + /// + public partial class HesapGuvenligiVeKontenjanSayaci : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Kurslar_Egitmenler_EgitmenId", + table: "Kurslar"); + + migrationBuilder.DropIndex( + name: "IX_Basvurular_KursId", + table: "Basvurular"); + + migrationBuilder.DropColumn( + name: "Sifre", + table: "Ogrenciler"); + + migrationBuilder.AddColumn( + name: "SifreHash", + table: "Ogrenciler", + type: "TEXT", + maxLength: 256, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "KayitliSayisi", + table: "Kurslar", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + // Seed the counter from the applications that already exist. Leaving it at the + // default of 0 would make every existing course look empty, and the capacity + // check would happily enrol students past Kontenjan. + migrationBuilder.Sql(@" + UPDATE Kurslar + SET KayitliSayisi = ( + SELECT COUNT(*) FROM Basvurular WHERE Basvurular.KursId = Kurslar.Id + );"); + + migrationBuilder.CreateTable( + name: "Yoneticiler", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + KullaniciAdi = table.Column(type: "TEXT", maxLength: 50, nullable: false), + SifreHash = table.Column(type: "TEXT", maxLength: 256, nullable: false), + AdSoyad = table.Column(type: "TEXT", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Yoneticiler", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Ogrenciler_Email", + table: "Ogrenciler", + column: "Email", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Ogrenciler_OgrenciNo", + table: "Ogrenciler", + column: "OgrenciNo", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Kurslar_KursAdi", + table: "Kurslar", + column: "KursAdi", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Egitmenler_AdSoyad", + table: "Egitmenler", + column: "AdSoyad", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Basvurular_KursId_OgrenciId", + table: "Basvurular", + columns: new[] { "KursId", "OgrenciId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Yoneticiler_KullaniciAdi", + table: "Yoneticiler", + column: "KullaniciAdi", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_Kurslar_Egitmenler_EgitmenId", + table: "Kurslar", + column: "EgitmenId", + principalTable: "Egitmenler", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Kurslar_Egitmenler_EgitmenId", + table: "Kurslar"); + + migrationBuilder.DropTable( + name: "Yoneticiler"); + + migrationBuilder.DropIndex( + name: "IX_Ogrenciler_Email", + table: "Ogrenciler"); + + migrationBuilder.DropIndex( + name: "IX_Ogrenciler_OgrenciNo", + table: "Ogrenciler"); + + migrationBuilder.DropIndex( + name: "IX_Kurslar_KursAdi", + table: "Kurslar"); + + migrationBuilder.DropIndex( + name: "IX_Egitmenler_AdSoyad", + table: "Egitmenler"); + + migrationBuilder.DropIndex( + name: "IX_Basvurular_KursId_OgrenciId", + table: "Basvurular"); + + migrationBuilder.DropColumn( + name: "SifreHash", + table: "Ogrenciler"); + + migrationBuilder.DropColumn( + name: "KayitliSayisi", + table: "Kurslar"); + + migrationBuilder.AddColumn( + name: "Sifre", + table: "Ogrenciler", + type: "TEXT", + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateIndex( + name: "IX_Basvurular_KursId", + table: "Basvurular", + column: "KursId"); + + migrationBuilder.AddForeignKey( + name: "FK_Kurslar_Egitmenler_EgitmenId", + table: "Kurslar", + column: "EgitmenId", + principalTable: "Egitmenler", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/KursKayitSistemi/Migrations/AppDbContextModelSnapshot.cs b/KursKayitSistemi/Migrations/AppDbContextModelSnapshot.cs index 84935f3..7c07fcf 100644 --- a/KursKayitSistemi/Migrations/AppDbContextModelSnapshot.cs +++ b/KursKayitSistemi/Migrations/AppDbContextModelSnapshot.cs @@ -15,7 +15,7 @@ partial class AppDbContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); modelBuilder.Entity("KursKayitSistemi.Models.Basvuru", b => { @@ -34,10 +34,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("KursId"); - b.HasIndex("OgrenciId"); + b.HasIndex("KursId", "OgrenciId") + .IsUnique(); + b.ToTable("Basvurular"); }); @@ -49,10 +50,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AdSoyad") .IsRequired() + .HasMaxLength(100) .HasColumnType("TEXT"); b.HasKey("Id"); + b.HasIndex("AdSoyad") + .IsUnique(); + b.ToTable("Egitmenler"); }); @@ -65,17 +70,24 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("EgitmenId") .HasColumnType("INTEGER"); + b.Property("KayitliSayisi") + .HasColumnType("INTEGER"); + b.Property("Kontenjan") .HasColumnType("INTEGER"); b.Property("KursAdi") .IsRequired() + .HasMaxLength(150) .HasColumnType("TEXT"); b.HasKey("Id"); b.HasIndex("EgitmenId"); + b.HasIndex("KursAdi") + .IsUnique(); + b.ToTable("Kurslar"); }); @@ -87,25 +99,64 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AdSoyad") .IsRequired() + .HasMaxLength(100) .HasColumnType("TEXT"); b.Property("Email") .IsRequired() + .HasMaxLength(150) .HasColumnType("TEXT"); b.Property("OgrenciNo") .IsRequired() + .HasMaxLength(20) .HasColumnType("TEXT"); - b.Property("Sifre") + b.Property("SifreHash") .IsRequired() + .HasMaxLength(256) .HasColumnType("TEXT"); b.HasKey("Id"); + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("OgrenciNo") + .IsUnique(); + b.ToTable("Ogrenciler"); }); + modelBuilder.Entity("KursKayitSistemi.Models.Yonetici", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdSoyad") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("KullaniciAdi") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SifreHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("KullaniciAdi") + .IsUnique(); + + b.ToTable("Yoneticiler"); + }); + modelBuilder.Entity("KursKayitSistemi.Models.Basvuru", b => { b.HasOne("KursKayitSistemi.Models.Kurs", "Kurs") @@ -115,7 +166,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); b.HasOne("KursKayitSistemi.Models.Ogrenci", "Ogrenci") - .WithMany() + .WithMany("Basvurular") .HasForeignKey("OgrenciId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); @@ -130,7 +181,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasOne("KursKayitSistemi.Models.Egitmen", "Egitmen") .WithMany("Kurslar") .HasForeignKey("EgitmenId") - .OnDelete(DeleteBehavior.Cascade) + .OnDelete(DeleteBehavior.Restrict) .IsRequired(); b.Navigation("Egitmen"); @@ -145,6 +196,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Navigation("Basvurular"); }); + + modelBuilder.Entity("KursKayitSistemi.Models.Ogrenci", b => + { + b.Navigation("Basvurular"); + }); #pragma warning restore 612, 618 } } diff --git a/KursKayitSistemi/Models/AppDbContext.cs b/KursKayitSistemi/Models/AppDbContext.cs index 7e57fd8..aa8a7e6 100644 --- a/KursKayitSistemi/Models/AppDbContext.cs +++ b/KursKayitSistemi/Models/AppDbContext.cs @@ -1,5 +1,4 @@ -using Microsoft.EntityFrameworkCore; -using System.Reflection.Emit; +using Microsoft.EntityFrameworkCore; namespace KursKayitSistemi.Models; @@ -7,10 +6,62 @@ public class AppDbContext : DbContext { public AppDbContext(DbContextOptions options) : base(options) { } - public DbSet Egitmenler { get; set; } - public DbSet Kurslar { get; set; } - public DbSet Ogrenciler { get; set; } - public DbSet Basvurular { get; set; } + public DbSet Egitmenler => Set(); + public DbSet Kurslar => Set(); + public DbSet Ogrenciler => Set(); + public DbSet Basvurular => Set(); + public DbSet Yoneticiler => Set(); - -} \ No newline at end of file + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(e => + { + // The application also checks for duplicates before inserting, but two + // concurrent registrations can both pass that check; only the database can + // settle it. Same reasoning for the instructor and course name indexes below. + e.HasIndex(o => o.OgrenciNo).IsUnique(); + e.HasIndex(o => o.Email).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.HasIndex(y => y.KullaniciAdi).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.HasIndex(x => x.AdSoyad).IsUnique(); + }); + + modelBuilder.Entity(e => + { + e.HasIndex(k => k.KursAdi).IsUnique(); + + e.Ignore(k => k.KontenjanDoluMu); + e.Ignore(k => k.KalanKontenjan); + + // Deleting an instructor must not silently orphan or delete their courses. + e.HasOne(k => k.Egitmen) + .WithMany(x => x.Kurslar) + .HasForeignKey(k => k.EgitmenId) + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity(e => + { + e.HasIndex(b => new { b.KursId, b.OgrenciId }).IsUnique(); + + e.HasOne(b => b.Kurs) + .WithMany(k => k.Basvurular) + .HasForeignKey(b => b.KursId) + .OnDelete(DeleteBehavior.Cascade); + + e.HasOne(b => b.Ogrenci) + .WithMany(o => o.Basvurular) + .HasForeignKey(b => b.OgrenciId) + .OnDelete(DeleteBehavior.Cascade); + }); + } +} diff --git a/KursKayitSistemi/Models/Basvuru.cs b/KursKayitSistemi/Models/Basvuru.cs index 4c6bc2b..344e2df 100644 --- a/KursKayitSistemi/Models/Basvuru.cs +++ b/KursKayitSistemi/Models/Basvuru.cs @@ -1,13 +1,19 @@ -namespace KursKayitSistemi.Models; +namespace KursKayitSistemi.Models; +/// +/// A student's application to a course. The (KursId, OgrenciId) pair is unique at the +/// database level so a duplicate application fails even if two requests race past the +/// application-level check. +/// public class Basvuru { public int Id { get; set; } - public DateTime BasvuruTarihi { get; set; } = DateTime.Now; + + public DateTime BasvuruTarihi { get; set; } = DateTime.UtcNow; public int KursId { get; set; } public Kurs? Kurs { get; set; } public int OgrenciId { get; set; } public Ogrenci? Ogrenci { get; set; } -} \ No newline at end of file +} diff --git a/KursKayitSistemi/Models/Egitmen.cs b/KursKayitSistemi/Models/Egitmen.cs index 8a15849..e7915a6 100644 --- a/KursKayitSistemi/Models/Egitmen.cs +++ b/KursKayitSistemi/Models/Egitmen.cs @@ -1,9 +1,13 @@ -namespace KursKayitSistemi.Models; +using System.ComponentModel.DataAnnotations; + +namespace KursKayitSistemi.Models; public class Egitmen { public int Id { get; set; } - public string AdSoyad { get; set; } - public ICollection? Kurslar { get; set; } -} \ No newline at end of file + [Required, MaxLength(100)] + public string AdSoyad { get; set; } = string.Empty; + + public ICollection Kurslar { get; set; } = new List(); +} diff --git a/KursKayitSistemi/Models/Kurs.cs b/KursKayitSistemi/Models/Kurs.cs index 7503017..758fd34 100644 --- a/KursKayitSistemi/Models/Kurs.cs +++ b/KursKayitSistemi/Models/Kurs.cs @@ -1,13 +1,31 @@ -namespace KursKayitSistemi.Models; +using System.ComponentModel.DataAnnotations; + +namespace KursKayitSistemi.Models; public class Kurs { public int Id { get; set; } - public string KursAdi { get; set; } + + [Required, MaxLength(150)] + public string KursAdi { get; set; } = string.Empty; + + [Range(1, 1000)] public int Kontenjan { get; set; } + /// + /// Number of accepted applications, maintained alongside . + /// Enrolment goes through a single conditional UPDATE + /// (WHERE KayitliSayisi < Kontenjan) so that two concurrent requests + /// cannot both pass the capacity check — counting rows and then inserting is a + /// time-of-check/time-of-use race. + /// + public int KayitliSayisi { get; set; } + public int EgitmenId { get; set; } public Egitmen? Egitmen { get; set; } - public ICollection? Basvurular { get; set; } -} \ No newline at end of file + public ICollection Basvurular { get; set; } = new List(); + + public bool KontenjanDoluMu => KayitliSayisi >= Kontenjan; + public int KalanKontenjan => Math.Max(0, Kontenjan - KayitliSayisi); +} diff --git a/KursKayitSistemi/Models/Ogrenci.cs b/KursKayitSistemi/Models/Ogrenci.cs index 75cc51f..502f005 100644 --- a/KursKayitSistemi/Models/Ogrenci.cs +++ b/KursKayitSistemi/Models/Ogrenci.cs @@ -1,12 +1,27 @@ -namespace KursKayitSistemi.Models; +using System.ComponentModel.DataAnnotations; +namespace KursKayitSistemi.Models; + +/// +/// A registered student. Credentials are never stored in clear text — only the +/// output of is persisted. +/// public class Ogrenci { public int Id { get; set; } - public string OgrenciNo { get; set; } - public string Sifre { get; set; } + [Required, MaxLength(20)] + public string OgrenciNo { get; set; } = string.Empty; + + /// PBKDF2 hash produced by the password hash service, never the raw password. + [Required, MaxLength(256)] + public string SifreHash { get; set; } = string.Empty; + + [Required, MaxLength(100)] + public string AdSoyad { get; set; } = string.Empty; + + [Required, MaxLength(150), EmailAddress] + public string Email { get; set; } = string.Empty; - public string AdSoyad { get; set; } - public string Email { get; set; } -} \ No newline at end of file + public ICollection Basvurular { get; set; } = new List(); +} diff --git a/KursKayitSistemi/Models/Yonetici.cs b/KursKayitSistemi/Models/Yonetici.cs new file mode 100644 index 0000000..d047bbc --- /dev/null +++ b/KursKayitSistemi/Models/Yonetici.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace KursKayitSistemi.Models; + +/// +/// An administrator account. Replaces the credentials that used to be compared +/// against string literals inside AccountController; the initial account is +/// seeded from configuration at startup (see DatabaseSeeder). +/// +public class Yonetici +{ + public int Id { get; set; } + + [Required, MaxLength(50)] + public string KullaniciAdi { get; set; } = string.Empty; + + [Required, MaxLength(256)] + public string SifreHash { get; set; } = string.Empty; + + [Required, MaxLength(100)] + public string AdSoyad { get; set; } = string.Empty; +} diff --git a/KursKayitSistemi/Program.cs b/KursKayitSistemi/Program.cs index 2030893..18c6666 100644 --- a/KursKayitSistemi/Program.cs +++ b/KursKayitSistemi/Program.cs @@ -1,21 +1,41 @@ -using Microsoft.EntityFrameworkCore; using KursKayitSistemi.Models; -using Microsoft.AspNetCore.Authentication.Cookies; +using KursKayitSistemi.Services; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddControllersWithViews(); +// Every POST is antiforgery-validated by default. Opting in per action means a new +// action is insecure until someone remembers the attribute; this way it is the reverse. +builder.Services.AddControllersWithViews(options => +{ + options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()); +}); builder.Services.AddDbContext(options => options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection"))); +builder.Services.AddSingleton(_ => new Pbkdf2PasswordHashService()); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => { - options.LoginPath = "/Account/Login"; - options.Cookie.Name = "AdminLogin"; + options.LoginPath = "/Account/Login"; + options.AccessDeniedPath = "/Account/AccessDenied"; + options.Cookie.Name = "KursKayit.Auth"; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; + options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + options.ExpireTimeSpan = TimeSpan.FromHours(8); + options.SlidingExpiration = true; }); +builder.Services.AddAuthorization(); +builder.Services.AddHealthChecks().AddDbContextCheck(); + var app = builder.Build(); if (!app.Environment.IsDevelopment()) @@ -36,4 +56,12 @@ name: "default", pattern: "{controller=Kurs}/{action=Index}/{id?}"); -app.Run(); \ No newline at end of file +app.MapHealthChecks("/health"); + +// Applies pending migrations and creates the administrator account on first run. +await DatabaseSeeder.SeedAsync(app.Services); + +app.Run(); + +/// Exposed so the integration test project can host the real application through WebApplicationFactory. +public partial class Program { } diff --git a/KursKayitSistemi/Services/AccountService.cs b/KursKayitSistemi/Services/AccountService.cs new file mode 100644 index 0000000..311ad0b --- /dev/null +++ b/KursKayitSistemi/Services/AccountService.cs @@ -0,0 +1,94 @@ +using KursKayitSistemi.Models; +using Microsoft.EntityFrameworkCore; + +namespace KursKayitSistemi.Services; + +public sealed class AccountService : IAccountService +{ + private readonly AppDbContext _db; + private readonly IPasswordHashService _hasher; + + public AccountService(AppDbContext db, IPasswordHashService hasher) + { + _db = db; + _hasher = hasher; + } + + public async Task AuthenticateAsync(string kullaniciAdi, string sifre, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(kullaniciAdi) || string.IsNullOrEmpty(sifre)) + return null; + + kullaniciAdi = kullaniciAdi.Trim(); + + var yonetici = await _db.Yoneticiler + .AsNoTracking() + .FirstOrDefaultAsync(y => y.KullaniciAdi == kullaniciAdi, ct); + + if (yonetici is not null) + { + return _hasher.Verify(sifre, yonetici.SifreHash) + ? new AuthenticatedUser(yonetici.Id, yonetici.AdSoyad, Roller.Admin, null) + : null; + } + + var ogrenci = await _db.Ogrenciler + .AsNoTracking() + .FirstOrDefaultAsync(o => o.OgrenciNo == kullaniciAdi, ct); + + if (ogrenci is null) + { + // Run a verification against a throwaway hash anyway. Returning immediately + // makes "no such user" measurably faster than "wrong password", which turns + // the login form into a user enumeration oracle. + _hasher.Verify(sifre, DummyHash); + return null; + } + + return _hasher.Verify(sifre, ogrenci.SifreHash) + ? new AuthenticatedUser(ogrenci.Id, ogrenci.AdSoyad, Roller.Ogrenci, ogrenci.Email) + : null; + } + + public async Task RegisterAsync( + string ogrenciNo, string adSoyad, string email, string sifre, CancellationToken ct = default) + { + ogrenciNo = ogrenciNo.Trim(); + adSoyad = adSoyad.Trim(); + email = email.Trim(); + + if (await _db.Ogrenciler.AnyAsync(o => o.OgrenciNo == ogrenciNo, ct)) + return RegistrationResult.Fail(RegistrationError.DuplicateOgrenciNo); + + if (await _db.Ogrenciler.AnyAsync(o => o.Email == email, ct)) + return RegistrationResult.Fail(RegistrationError.DuplicateEmail); + + var ogrenci = new Ogrenci + { + OgrenciNo = ogrenciNo, + AdSoyad = adSoyad, + Email = email, + SifreHash = _hasher.Hash(sifre) + }; + + _db.Ogrenciler.Add(ogrenci); + + try + { + await _db.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (DbErrors.IsUniqueViolation(ex)) + { + // Two registrations for the same number can both pass the checks above; the + // unique index is what actually decides, so translate its error rather than 500. + _db.Entry(ogrenci).State = EntityState.Detached; + return RegistrationResult.Fail(RegistrationError.DuplicateOgrenciNo); + } + + return RegistrationResult.Ok(ogrenci.Id); + } + + /// A syntactically valid hash of a value nobody knows, used to equalise login timing. + private const string DummyHash = + "pbkdf2-sha256$210000$AAAAAAAAAAAAAAAAAAAAAA==$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; +} diff --git a/KursKayitSistemi/Services/ClaimsPrincipalExtensions.cs b/KursKayitSistemi/Services/ClaimsPrincipalExtensions.cs new file mode 100644 index 0000000..d8fef4c --- /dev/null +++ b/KursKayitSistemi/Services/ClaimsPrincipalExtensions.cs @@ -0,0 +1,17 @@ +using System.Security.Claims; + +namespace KursKayitSistemi.Services; + +public static class ClaimsPrincipalExtensions +{ + /// + /// Reads the signed-in user's primary key from the NameIdentifier claim. + /// The previous code looked users up by display name, which is not unique — two + /// students with the same name resolved to whichever row came back first. + /// + public static int? GetUserId(this ClaimsPrincipal user) + { + var raw = user.FindFirstValue(ClaimTypes.NameIdentifier); + return int.TryParse(raw, out var id) ? id : null; + } +} diff --git a/KursKayitSistemi/Services/DatabaseSeeder.cs b/KursKayitSistemi/Services/DatabaseSeeder.cs new file mode 100644 index 0000000..8852500 --- /dev/null +++ b/KursKayitSistemi/Services/DatabaseSeeder.cs @@ -0,0 +1,66 @@ +using System.Security.Cryptography; +using KursKayitSistemi.Models; +using Microsoft.EntityFrameworkCore; + +namespace KursKayitSistemi.Services; + +/// +/// Creates the initial administrator account. Replaces the credentials that were +/// previously compared against literals in the login action, so the repository no +/// longer ships a working password. +/// +public static class DatabaseSeeder +{ + public const string DefaultUserName = "admin"; + + public static async Task SeedAsync(IServiceProvider services, CancellationToken ct = default) + { + using var scope = services.CreateScope(); + + var db = scope.ServiceProvider.GetRequiredService(); + var hasher = scope.ServiceProvider.GetRequiredService(); + var config = scope.ServiceProvider.GetRequiredService(); + var env = scope.ServiceProvider.GetRequiredService(); + var logger = scope.ServiceProvider.GetRequiredService().CreateLogger(nameof(DatabaseSeeder)); + + await db.Database.MigrateAsync(ct); + + if (await db.Yoneticiler.AnyAsync(ct)) + return; + + var kullaniciAdi = config["SeedAdmin:KullaniciAdi"] ?? DefaultUserName; + var sifre = config["SeedAdmin:Sifre"]; + + if (string.IsNullOrWhiteSpace(sifre)) + { + if (!env.IsDevelopment()) + { + logger.LogError( + "SeedAdmin:Sifre tanimli degil; yonetici hesabi olusturulmadi. " + + "Uretimde bu degeri ortam degiskeni veya secret store uzerinden saglayin."); + return; + } + + // Generating one beats shipping a default: the password is different on every + // machine and never ends up in the repository. + sifre = Convert.ToBase64String(RandomNumberGenerator.GetBytes(12)); + + logger.LogWarning( + "SeedAdmin:Sifre tanimli degil. Gelistirme icin rastgele bir yonetici sifresi uretildi.\n" + + " Kullanici adi : {KullaniciAdi}\n" + + " Sifre : {Sifre}\n" + + " Bu deger yalnizca simdi gosterilir.", + kullaniciAdi, sifre); + } + + db.Yoneticiler.Add(new Yonetici + { + KullaniciAdi = kullaniciAdi, + AdSoyad = config["SeedAdmin:AdSoyad"] ?? "Sistem Yöneticisi", + SifreHash = hasher.Hash(sifre) + }); + + await db.SaveChangesAsync(ct); + logger.LogInformation("Yonetici hesabi olusturuldu: {KullaniciAdi}", kullaniciAdi); + } +} diff --git a/KursKayitSistemi/Services/DbErrors.cs b/KursKayitSistemi/Services/DbErrors.cs new file mode 100644 index 0000000..a1babd5 --- /dev/null +++ b/KursKayitSistemi/Services/DbErrors.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; + +namespace KursKayitSistemi.Services; + +internal static class DbErrors +{ + /// + /// True when the update failed because it violated a unique index. + /// Matched on the provider message rather than a numeric code so the same check works + /// for SQLite ("UNIQUE constraint failed") and PostgreSQL ("duplicate key value + /// violates unique constraint") without the services referencing a provider package. + /// + public static bool IsUniqueViolation(DbUpdateException ex) + { + for (Exception? e = ex; e is not null; e = e.InnerException) + { + if (e.Message.Contains("unique", StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } +} diff --git a/KursKayitSistemi/Services/EnrollmentService.cs b/KursKayitSistemi/Services/EnrollmentService.cs new file mode 100644 index 0000000..4eea419 --- /dev/null +++ b/KursKayitSistemi/Services/EnrollmentService.cs @@ -0,0 +1,116 @@ +using KursKayitSistemi.Models; +using Microsoft.EntityFrameworkCore; + +namespace KursKayitSistemi.Services; + +/// +/// Owns the two rules that the controllers used to enforce with a read followed by a write: +/// a course cannot exceed its capacity, and a student cannot appear on it twice. +/// +public sealed class EnrollmentService : IEnrollmentService +{ + private readonly AppDbContext _db; + private readonly ILogger _logger; + + public EnrollmentService(AppDbContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + public async Task ApplyAsync(int kursId, int ogrenciId, CancellationToken ct = default) + { + if (!await _db.Kurslar.AnyAsync(k => k.Id == kursId, ct)) + return EnrollmentResult.Fail(EnrollmentError.KursBulunamadi); + + if (await _db.Basvurular.AnyAsync(b => b.KursId == kursId && b.OgrenciId == ogrenciId, ct)) + return EnrollmentResult.Fail(EnrollmentError.ZatenKayitli); + + await using var tx = await _db.Database.BeginTransactionAsync(ct); + + // Claim a seat and check capacity in the same statement. The previous version + // counted the applications, compared against Kontenjan, then inserted — two + // requests could both read the same count and both be let through. + var seatClaimed = await _db.Kurslar + .Where(k => k.Id == kursId && k.KayitliSayisi < k.Kontenjan) + .ExecuteUpdateAsync(s => s.SetProperty(k => k.KayitliSayisi, k => k.KayitliSayisi + 1), ct); + + if (seatClaimed == 0) + { + await tx.RollbackAsync(ct); + return EnrollmentResult.Fail(EnrollmentError.KontenjanDolu); + } + + _db.Basvurular.Add(new Basvuru + { + KursId = kursId, + OgrenciId = ogrenciId, + BasvuruTarihi = DateTime.UtcNow + }); + + try + { + await _db.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (DbErrors.IsUniqueViolation(ex)) + { + // Lost a race against another request for the same (course, student). + // Roll back so the seat we just claimed is released. + await tx.RollbackAsync(ct); + _db.ChangeTracker.Clear(); + return EnrollmentResult.Fail(EnrollmentError.ZatenKayitli); + } + + await tx.CommitAsync(ct); + _db.ChangeTracker.Clear(); + + _logger.LogInformation("Ogrenci {OgrenciId} kurs {KursId} kaydini tamamladi.", ogrenciId, kursId); + return EnrollmentResult.Ok; + } + + public Task CancelAsync(int basvuruId, int ogrenciId, CancellationToken ct = default) + => CancelCoreAsync(basvuruId, ogrenciId, ct); + + public Task CancelAsAdminAsync(int basvuruId, CancellationToken ct = default) + => CancelCoreAsync(basvuruId, sahipOgrenciId: null, ct); + + private async Task CancelCoreAsync(int basvuruId, int? sahipOgrenciId, CancellationToken ct) + { + var basvuru = await _db.Basvurular + .AsNoTracking() + .FirstOrDefaultAsync(b => b.Id == basvuruId, ct); + + if (basvuru is null) + return EnrollmentResult.Fail(EnrollmentError.BasvuruBulunamadi); + + if (sahipOgrenciId is int sahip && basvuru.OgrenciId != sahip) + { + _logger.LogWarning( + "Ogrenci {OgrenciId} kendisine ait olmayan {BasvuruId} numarali basvuruyu silmeye calisti.", + sahip, basvuruId); + return EnrollmentResult.Fail(EnrollmentError.YetkiYok); + } + + await using var tx = await _db.Database.BeginTransactionAsync(ct); + + var silinen = await _db.Basvurular + .Where(b => b.Id == basvuruId) + .ExecuteDeleteAsync(ct); + + if (silinen == 0) + { + await tx.RollbackAsync(ct); + return EnrollmentResult.Fail(EnrollmentError.BasvuruBulunamadi); + } + + // Guarded so a double cancellation cannot drive the counter negative. + await _db.Kurslar + .Where(k => k.Id == basvuru.KursId && k.KayitliSayisi > 0) + .ExecuteUpdateAsync(s => s.SetProperty(k => k.KayitliSayisi, k => k.KayitliSayisi - 1), ct); + + await tx.CommitAsync(ct); + _db.ChangeTracker.Clear(); + + return EnrollmentResult.Ok; + } +} diff --git a/KursKayitSistemi/Services/IAccountService.cs b/KursKayitSistemi/Services/IAccountService.cs new file mode 100644 index 0000000..8963521 --- /dev/null +++ b/KursKayitSistemi/Services/IAccountService.cs @@ -0,0 +1,29 @@ +namespace KursKayitSistemi.Services; + +/// Identity of a successfully authenticated caller, used to build the claims principal. +public sealed record AuthenticatedUser(int Id, string AdSoyad, string Role, string? Email); + +public enum RegistrationError +{ + None = 0, + DuplicateOgrenciNo, + DuplicateEmail +} + +public sealed record RegistrationResult(bool Success, RegistrationError Error, int OgrenciId = 0) +{ + public static RegistrationResult Ok(int id) => new(true, RegistrationError.None, id); + public static RegistrationResult Fail(RegistrationError error) => new(false, error); +} + +public interface IAccountService +{ + /// + /// Resolves a login. Administrators and students share one entry point but live in + /// separate tables; the returned role decides which claims are issued. + /// Returns null when the credentials do not match. + /// + Task AuthenticateAsync(string kullaniciAdi, string sifre, CancellationToken ct = default); + + Task RegisterAsync(string ogrenciNo, string adSoyad, string email, string sifre, CancellationToken ct = default); +} diff --git a/KursKayitSistemi/Services/IEnrollmentService.cs b/KursKayitSistemi/Services/IEnrollmentService.cs new file mode 100644 index 0000000..d039e72 --- /dev/null +++ b/KursKayitSistemi/Services/IEnrollmentService.cs @@ -0,0 +1,36 @@ +namespace KursKayitSistemi.Services; + +public enum EnrollmentError +{ + None = 0, + KursBulunamadi, + ZatenKayitli, + KontenjanDolu, + BasvuruBulunamadi, + /// The application exists but belongs to a different student. + YetkiYok +} + +public sealed record EnrollmentResult(bool Success, EnrollmentError Error) +{ + public static readonly EnrollmentResult Ok = new(true, EnrollmentError.None); + public static EnrollmentResult Fail(EnrollmentError error) => new(false, error); +} + +public interface IEnrollmentService +{ + /// + /// Enrols a student on a course if there is room. Capacity is claimed atomically, so + /// concurrent callers cannot push the course past its Kontenjan. + /// + Task ApplyAsync(int kursId, int ogrenciId, CancellationToken ct = default); + + /// + /// Cancels an application on behalf of a student. Fails with + /// when the application belongs to someone else — the id alone is not authorization. + /// + Task CancelAsync(int basvuruId, int ogrenciId, CancellationToken ct = default); + + /// Cancels any application. Callers must already have enforced the admin role. + Task CancelAsAdminAsync(int basvuruId, CancellationToken ct = default); +} diff --git a/KursKayitSistemi/Services/IPasswordHashService.cs b/KursKayitSistemi/Services/IPasswordHashService.cs new file mode 100644 index 0000000..f18d026 --- /dev/null +++ b/KursKayitSistemi/Services/IPasswordHashService.cs @@ -0,0 +1,18 @@ +namespace KursKayitSistemi.Services; + +/// +/// Hashes and verifies passwords. Behind an interface so the enrolment and account +/// services can be unit tested without depending on a specific KDF, and so the +/// algorithm can be replaced without touching call sites. +/// +public interface IPasswordHashService +{ + /// Produces a self-describing hash string that embeds the salt and iteration count. + string Hash(string password); + + /// + /// Verifies a candidate password against a stored hash. Returns false rather than + /// throwing for malformed or empty stored hashes, so a corrupt row cannot 500 the login page. + /// + bool Verify(string password, string storedHash); +} diff --git a/KursKayitSistemi/Services/Pbkdf2PasswordHashService.cs b/KursKayitSistemi/Services/Pbkdf2PasswordHashService.cs new file mode 100644 index 0000000..12e4a6a --- /dev/null +++ b/KursKayitSistemi/Services/Pbkdf2PasswordHashService.cs @@ -0,0 +1,72 @@ +using System.Security.Cryptography; + +namespace KursKayitSistemi.Services; + +/// +/// PBKDF2-HMAC-SHA256 password hashing. +/// +/// Stored format: pbkdf2-sha256$<iterations>$<base64 salt>$<base64 subkey>. +/// The iteration count travels with the hash so it can be raised later without +/// invalidating existing rows. +/// +public sealed class Pbkdf2PasswordHashService : IPasswordHashService +{ + private const string Prefix = "pbkdf2-sha256"; + private const int SaltSize = 16; // 128-bit + private const int SubkeySize = 32; // 256-bit + private const int DefaultIterations = 210_000; // OWASP guidance for PBKDF2-HMAC-SHA256 + + private readonly int _iterations; + + public Pbkdf2PasswordHashService(int iterations = DefaultIterations) + { + if (iterations < 1_000) + throw new ArgumentOutOfRangeException(nameof(iterations), "Iteration count is too low to be useful."); + + _iterations = iterations; + } + + public string Hash(string password) + { + ArgumentNullException.ThrowIfNull(password); + + var salt = RandomNumberGenerator.GetBytes(SaltSize); + var subkey = Rfc2898DeriveBytes.Pbkdf2(password, salt, _iterations, HashAlgorithmName.SHA256, SubkeySize); + + return $"{Prefix}${_iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(subkey)}"; + } + + public bool Verify(string password, string storedHash) + { + if (password is null || string.IsNullOrWhiteSpace(storedHash)) + return false; + + var parts = storedHash.Split('$'); + if (parts.Length != 4 || parts[0] != Prefix) + return false; + + if (!int.TryParse(parts[1], out var iterations) || iterations < 1) + return false; + + byte[] salt, expectedSubkey; + try + { + salt = Convert.FromBase64String(parts[2]); + expectedSubkey = Convert.FromBase64String(parts[3]); + } + catch (FormatException) + { + return false; + } + + if (salt.Length == 0 || expectedSubkey.Length == 0) + return false; + + var actualSubkey = Rfc2898DeriveBytes.Pbkdf2( + password, salt, iterations, HashAlgorithmName.SHA256, expectedSubkey.Length); + + // Fixed-time comparison — a short-circuiting comparison leaks how much of the + // hash matched, which is enough to reconstruct it byte by byte. + return CryptographicOperations.FixedTimeEquals(actualSubkey, expectedSubkey); + } +} diff --git a/KursKayitSistemi/Services/Roller.cs b/KursKayitSistemi/Services/Roller.cs new file mode 100644 index 0000000..e6f841d --- /dev/null +++ b/KursKayitSistemi/Services/Roller.cs @@ -0,0 +1,8 @@ +namespace KursKayitSistemi.Services; + +/// Role names used in authorization policies and claims. Kept in one place so a typo becomes a compile error rather than a silent authorization bypass. +public static class Roller +{ + public const string Admin = "Admin"; + public const string Ogrenci = "Ogrenci"; +} diff --git a/KursKayitSistemi/ViewModels/GirisViewModel.cs b/KursKayitSistemi/ViewModels/GirisViewModel.cs new file mode 100644 index 0000000..12b70ef --- /dev/null +++ b/KursKayitSistemi/ViewModels/GirisViewModel.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace KursKayitSistemi.ViewModels; + +public class GirisViewModel +{ + [Required(ErrorMessage = "Kullanıcı adı zorunludur.")] + [Display(Name = "Öğrenci Numarası / Kullanıcı Adı")] + public string KullaniciAdi { get; set; } = string.Empty; + + [Required(ErrorMessage = "Şifre zorunludur.")] + [DataType(DataType.Password)] + [Display(Name = "Şifre")] + public string Sifre { get; set; } = string.Empty; +} diff --git a/KursKayitSistemi/ViewModels/KayitViewModel.cs b/KursKayitSistemi/ViewModels/KayitViewModel.cs new file mode 100644 index 0000000..855563d --- /dev/null +++ b/KursKayitSistemi/ViewModels/KayitViewModel.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; + +namespace KursKayitSistemi.ViewModels; + +/// +/// Registration input. Binding to this instead of the Ogrenci entity keeps a crafted +/// request from setting Id or SifreHash directly through model binding. +/// +public class KayitViewModel +{ + [Required(ErrorMessage = "Öğrenci numarası zorunludur.")] + [StringLength(20, MinimumLength = 3, ErrorMessage = "Öğrenci numarası 3-20 karakter olmalıdır.")] + [Display(Name = "Öğrenci Numarası")] + public string OgrenciNo { get; set; } = string.Empty; + + [Required(ErrorMessage = "Ad soyad zorunludur.")] + [StringLength(100, MinimumLength = 3)] + [Display(Name = "Adınız Soyadınız")] + public string AdSoyad { get; set; } = string.Empty; + + [Required(ErrorMessage = "Email zorunludur.")] + [EmailAddress(ErrorMessage = "Geçerli bir email adresi giriniz.")] + [StringLength(150)] + [Display(Name = "Email Adresi")] + public string Email { get; set; } = string.Empty; + + [Required(ErrorMessage = "Şifre zorunludur.")] + [StringLength(128, MinimumLength = 8, ErrorMessage = "Şifre en az 8 karakter olmalıdır.")] + [DataType(DataType.Password)] + [Display(Name = "Sisteme Giriş Şifreniz")] + public string Sifre { get; set; } = string.Empty; +} diff --git a/KursKayitSistemi/ViewModels/KursBasvuruViewModel.cs b/KursKayitSistemi/ViewModels/KursBasvuruViewModel.cs index 5936fbe..b5019e0 100644 --- a/KursKayitSistemi/ViewModels/KursBasvuruViewModel.cs +++ b/KursKayitSistemi/ViewModels/KursBasvuruViewModel.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; namespace KursKayitSistemi.ViewModels; @@ -10,9 +10,9 @@ public class KursBasvuruViewModel public string? EgitmenAdi { get; set; } [Required(ErrorMessage = "Lütfen adınızı ve soyadınızı giriniz.")] - public string OgrenciAdSoyad { get; set; } + public string OgrenciAdSoyad { get; set; } = string.Empty; [Required(ErrorMessage = "Lütfen email adresinizi giriniz.")] [EmailAddress(ErrorMessage = "Geçerli bir email formatı giriniz.")] - public string OgrenciEmail { get; set; } -} \ No newline at end of file + public string OgrenciEmail { get; set; } = string.Empty; +} diff --git a/KursKayitSistemi/Views/Account/AccessDenied.cshtml b/KursKayitSistemi/Views/Account/AccessDenied.cshtml new file mode 100644 index 0000000..5af23f9 --- /dev/null +++ b/KursKayitSistemi/Views/Account/AccessDenied.cshtml @@ -0,0 +1,18 @@ +@{ + ViewData["Title"] = "Yetkisiz Erişim"; +} + +
+
+
+
+
+

Bu sayfaya erişim yetkiniz yok

+

+ Giriş yapmış olabilirsiniz, ancak bu bölüm farklı bir rol gerektiriyor. +

+ Kurslara Dön +
+
+
+
diff --git a/KursKayitSistemi/Views/Account/Login.cshtml b/KursKayitSistemi/Views/Account/Login.cshtml index 04be42e..0fcf848 100644 --- a/KursKayitSistemi/Views/Account/Login.cshtml +++ b/KursKayitSistemi/Views/Account/Login.cshtml @@ -1,4 +1,5 @@ -@{ +@model GirisViewModel +@{ ViewData["Title"] = "Giriş Yap"; } @@ -9,23 +10,24 @@

🔒 Sisteme Giriş Yap

- @if (ViewBag.Hata != null) - { -
@ViewBag.Hata
- } @if (TempData["KayitBasarili"] != null) {
@TempData["KayitBasarili"]
} -
+ + +
- - + + +
- - + + +
@@ -37,4 +39,8 @@
- \ No newline at end of file + + +@section Scripts { + +} diff --git a/KursKayitSistemi/Views/Account/Register.cshtml b/KursKayitSistemi/Views/Account/Register.cshtml index cccfb22..ff6ed32 100644 --- a/KursKayitSistemi/Views/Account/Register.cshtml +++ b/KursKayitSistemi/Views/Account/Register.cshtml @@ -1,4 +1,5 @@ -@{ +@model KayitViewModel +@{ ViewData["Title"] = "Kayıt Ol"; } @@ -9,27 +10,30 @@

📝 Yeni Öğrenci Kaydı

- @if (ViewBag.KayitHata != null) - { -
@ViewBag.KayitHata
- } -
+ +
- - + + +
- - + + +
- - + + +
- - + + + +
En az 8 karakter.
@@ -41,4 +45,8 @@
- \ No newline at end of file + + +@section Scripts { + +} diff --git a/KursKayitSistemi/Views/Kurs/Index.cshtml b/KursKayitSistemi/Views/Kurs/Index.cshtml index 4e46309..02885db 100644 --- a/KursKayitSistemi/Views/Kurs/Index.cshtml +++ b/KursKayitSistemi/Views/Kurs/Index.cshtml @@ -59,8 +59,9 @@ @item.Egitmen?.AdSoyad @{ - int basvuruSayisi = item.Basvurular != null ? item.Basvurular.Count : 0; - int kalan = item.Kontenjan - basvuruSayisi; + // Read from the maintained counter instead of loading + // every application row just to call .Count on it. + int kalan = item.KalanKontenjan; } @if (kalan > 0) { diff --git a/KursKayitSistemi/Views/Shared/_Layout.cshtml b/KursKayitSistemi/Views/Shared/_Layout.cshtml index fde227b..8f247fb 100644 --- a/KursKayitSistemi/Views/Shared/_Layout.cshtml +++ b/KursKayitSistemi/Views/Shared/_Layout.cshtml @@ -26,8 +26,10 @@