From 2a9a514edfdbf4bac4caeedb7011a82368124a86 Mon Sep 17 00:00:00 2001 From: Huseyin Kutsi Balci <262747299+kutsibalci@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:29:14 +0300 Subject: [PATCH 1/4] Fix .gitignore: the file had no line endings Every rule sat on a single line beginning with ##, so git read the whole file as one comment and ignored nothing. Rewritten with real newlines, plus entries for test results and coverage output. --- .gitignore | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) 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 From 60d8cd74273817fc7a8089953dea78cc7e87bcb4 Mon Sep 17 00:00:00 2001 From: Huseyin Kutsi Balci <262747299+kutsibalci@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:29:14 +0300 Subject: [PATCH 2/4] Close authorization, credential and concurrency defects AdminController carried no [Authorize] attribute, so every action on the management area answered anonymous requests -- including KursSil and EgitmenSil, which delete rows and cascade. It now requires the Admin role. Administrator credentials were compared against string literals in the login action, so the working password shipped with the source. Administrators are now rows in Yoneticiler, seeded from configuration; development generates a random password and logs it once, production refuses to seed without one. Student passwords were stored and compared in clear text. They are now PBKDF2-HMAC-SHA256 with a per-password salt and a fixed-time comparison, with the iteration count embedded in the stored hash so it can be raised later. Cancelling an application took an id and deleted it with no ownership check, letting any signed-in student cancel anyone else's place. Cancellation now compares the owner against the caller's NameIdentifier claim. Capacity was checked by counting applications, comparing against Kontenjan, and then inserting -- concurrent requests could all read the same count. A Kurs.KayitliSayisi counter is now claimed with a conditional UPDATE, so the check and the write are one statement; measured against the old logic, 15 concurrent applications to a capacity-5 course enrolled all 15. Also: identity is read from the NameIdentifier claim instead of the display name, antiforgery validation is global rather than per-action, returnUrl is restricted to local URLs, logout is a POST, registration binds to a view model instead of the entity, and uniqueness checks run in SQL rather than after pulling the table into memory. --- .../Controllers/AccountController.cs | 128 ++++--- .../Controllers/AdminController.cs | 325 ++++++++++-------- .../Controllers/BasvuruController.cs | 81 ++--- .../Controllers/KursController.cs | 129 ++++--- KursKayitSistemi/KursKayitSistemi.csproj | 20 +- ...esapGuvenligiVeKontenjanSayaci.Designer.cs | 210 +++++++++++ ...4161928_HesapGuvenligiVeKontenjanSayaci.cs | 178 ++++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 68 +++- KursKayitSistemi/Models/AppDbContext.cs | 67 +++- KursKayitSistemi/Models/Basvuru.cs | 12 +- KursKayitSistemi/Models/Egitmen.cs | 12 +- KursKayitSistemi/Models/Kurs.cs | 26 +- KursKayitSistemi/Models/Ogrenci.cs | 27 +- KursKayitSistemi/Models/Yonetici.cs | 22 ++ KursKayitSistemi/Program.cs | 40 ++- KursKayitSistemi/Services/AccountService.cs | 94 +++++ .../Services/ClaimsPrincipalExtensions.cs | 17 + KursKayitSistemi/Services/DatabaseSeeder.cs | 66 ++++ KursKayitSistemi/Services/DbErrors.cs | 23 ++ .../Services/EnrollmentService.cs | 116 +++++++ KursKayitSistemi/Services/IAccountService.cs | 29 ++ .../Services/IEnrollmentService.cs | 36 ++ .../Services/IPasswordHashService.cs | 18 + .../Services/Pbkdf2PasswordHashService.cs | 72 ++++ KursKayitSistemi/Services/Roller.cs | 8 + KursKayitSistemi/ViewModels/GirisViewModel.cs | 15 + KursKayitSistemi/ViewModels/KayitViewModel.cs | 32 ++ .../ViewModels/KursBasvuruViewModel.cs | 8 +- .../Views/Account/AccessDenied.cshtml | 18 + KursKayitSistemi/Views/Account/Login.cshtml | 28 +- .../Views/Account/Register.cshtml | 38 +- KursKayitSistemi/Views/Kurs/Index.cshtml | 5 +- KursKayitSistemi/Views/Shared/_Layout.cshtml | 16 +- 33 files changed, 1607 insertions(+), 377 deletions(-) create mode 100644 KursKayitSistemi/Migrations/20260804161928_HesapGuvenligiVeKontenjanSayaci.Designer.cs create mode 100644 KursKayitSistemi/Migrations/20260804161928_HesapGuvenligiVeKontenjanSayaci.cs create mode 100644 KursKayitSistemi/Models/Yonetici.cs create mode 100644 KursKayitSistemi/Services/AccountService.cs create mode 100644 KursKayitSistemi/Services/ClaimsPrincipalExtensions.cs create mode 100644 KursKayitSistemi/Services/DatabaseSeeder.cs create mode 100644 KursKayitSistemi/Services/DbErrors.cs create mode 100644 KursKayitSistemi/Services/EnrollmentService.cs create mode 100644 KursKayitSistemi/Services/IAccountService.cs create mode 100644 KursKayitSistemi/Services/IEnrollmentService.cs create mode 100644 KursKayitSistemi/Services/IPasswordHashService.cs create mode 100644 KursKayitSistemi/Services/Pbkdf2PasswordHashService.cs create mode 100644 KursKayitSistemi/Services/Roller.cs create mode 100644 KursKayitSistemi/ViewModels/GirisViewModel.cs create mode 100644 KursKayitSistemi/ViewModels/KayitViewModel.cs create mode 100644 KursKayitSistemi/Views/Account/AccessDenied.cshtml 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 @@