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
- }
-
@@ -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 @@
@{
- bool isAdmin = User.Identity != null && User.Identity.IsAuthenticated &&
- (User.IsInRole("Admin") || User.Identity.Name == "Sistem Yöneticisi" || User.Identity.Name == "admin");
+ // Role comes from the claim only. Falling back to a display-name
+ // comparison meant any student who set their name to "admin" saw
+ // the management menu.
+ bool isAdmin = User.IsInRole(KursKayitSistemi.Services.Roller.Admin);
}
@if (!isAdmin)
@@ -53,9 +55,13 @@
Hoş geldin, @User.Identity.Name
-
-
- Çıkış
-
+ @* POST rather than a link: a GET logout can be triggered by any
+ third-party page embedding it as an image. *@
+
}
else
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..82a79e7
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Hüseyin Kutsi Balcı
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 5599b69..906cf49 100644
--- a/README.md
+++ b/README.md
@@ -6,10 +6,14 @@
+
+
+
+
-
+
---
@@ -18,70 +22,148 @@
The system separates two roles:
-- **Students** register an account, browse the course catalogue, and submit an application to a course.
-- **Administrators** sign in to a management area where they can create and edit courses, manage the instructor roster, and review incoming applications.
+- **Students** register an account, browse the course catalogue, and apply to a course.
+- **Administrators** sign in to a management area where they create and edit courses, manage
+ the instructor roster, and review incoming applications.
-Data is persisted with **Entity Framework Core** against a **SQLite** database, with schema changes tracked through EF migrations.
+Data is persisted with **Entity Framework Core** against **SQLite**, with schema changes
+tracked through migrations.
-## Features
+## What the test suite found
-| Area | Capability |
+The application worked. Adding tests to it was supposed to be a formality — writing down
+behaviour that already held. Several of the first tests failed, and the reasons were not
+cosmetic.
+
+| Finding | What it meant |
|---|---|
-| Accounts | Registration and login (`AccountController`) |
-| Catalogue | Browse available courses with details (`KursController`) |
-| Applications | Submit an application to a course, with confirmation (`BasvuruController`) |
-| Admin — Courses | Create, edit and list courses (`KursEkle`, `KursDuzenle`, `KursYonetimi`) |
-| Admin — Instructors | Create, edit and list instructors (`EgitmenEkle`, `EgitmenDuzenle`, `EgitmenYonetimi`) |
-| Admin — Applications | Review submitted applications (`BasvuruListesi`) |
+| **`AdminController` had no `[Authorize]`** | Every management action answered anonymous requests. `POST /Admin/KursSil` deleted a course — and cascaded to its applications — with no session at all. |
+| **Administrator credentials were string literals** | `if (kullaniciAdi == "admin" && sifre == "1234")` sat in the login action. The working password shipped with the source. |
+| **Passwords were stored and compared in clear text** | `o.Sifre == sifre`. A read of the database was a read of every account's password. |
+| **Cancelling an application never checked ownership** | The endpoint took an application id and deleted it. Any signed-in student could cancel anyone else's place by incrementing an id. |
+| **Capacity was a read-then-write race** | Applications were counted, compared against `Kontenjan`, and only then inserted. |
+| **Identity was resolved by display name** | `o.AdSoyad == User.Identity.Name`. Two students with the same name resolved to whichever row the database returned first. |
+
+The capacity bug is the one worth measuring. Reproducing the original logic under 15
+concurrent applications to a course with **capacity 5**:
+
+```
+old logic (count → compare → insert): 15 enrolled ← 3× over capacity
+current logic (conditional UPDATE): 5 enrolled
+```
+
+That measurement is what `Es_zamanli_basvurular_kontenjani_asamaz` locks in.
+
+## How capacity is enforced now
-## Tech Stack
+`Kurs` carries a `KayitliSayisi` counter, and enrolment claims a seat and checks capacity in
+a single statement:
-- **ASP.NET Core MVC** (.NET 10) — controllers, Razor views, view models
-- **Entity Framework Core** with **SQLite** — `AppDbContext`, code-first migrations
-- **Razor / Bootstrap** — server-rendered views with a shared layout
+```sql
+UPDATE Kurslar SET KayitliSayisi = KayitliSayisi + 1
+WHERE Id = @id AND KayitliSayisi < Kontenjan
+```
+
+Zero rows affected means the course was full. There is no window between the check and the
+write for a second request to slip through. The insert that follows runs in the same
+transaction, and a unique index on `(KursId, OgrenciId)` settles duplicate applications at
+the database rather than in application code — the application-level check is a nicety for
+the error message, not the thing being relied on.
-## Project Structure
+## Security
+
+| Concern | Approach |
+|---|---|
+| Password storage | PBKDF2-HMAC-SHA256, 210 000 iterations, 128-bit random salt per password. The iteration count is embedded in the stored hash so it can be raised without invalidating existing rows. Comparison is fixed-time. |
+| Administrator account | A row in `Yoneticiler`, seeded from configuration. With no password configured, development generates a random one and logs it once; production refuses to create the account. |
+| Authorization | `[Authorize(Roles = "Admin")]` at the class level on the management area, verified by integration tests that drive the real HTTP pipeline. |
+| CSRF | `AutoValidateAntiforgeryTokenAttribute` registered globally, so a new POST action is protected by default rather than when someone remembers the attribute. |
+| Ownership | Cancellation compares the application's owner against the caller's `NameIdentifier` claim. |
+| Open redirect | `returnUrl` is followed only when `Url.IsLocalUrl` accepts it. |
+| User enumeration | Login does not distinguish "no such user" from "wrong password", and runs a hash verification even when the user does not exist so the two paths take comparable time. |
+| Dependencies | CI fails the build on any package with a known advisory, including transitive ones. |
+
+## Architecture
```
KursKayitSistemi/
-├── Controllers/ Account, Admin, Basvuru (applications), Home, Kurs (courses)
-├── Models/ AppDbContext, Kurs, Ogrenci (student), Egitmen (instructor), Basvuru
-├── ViewModels/ KursBasvuruViewModel
-├── Views/ Razor views per controller + shared layout
-├── Migrations/ EF Core schema history
-└── appsettings.json Connection string
+├── Controllers/ Thin: authorize, bind, delegate, choose a view
+├── Services/ Rules with real invariants — enrolment, accounts, password hashing
+├── Models/ Entities + AppDbContext (indexes, delete behaviour, constraints)
+├── ViewModels/ Binding targets, kept separate from entities to prevent over-posting
+├── Views/ Razor views per controller + shared layout
+└── Migrations/ EF Core schema history
```
-## Data Model
+Not every controller got a service. Enrolment did, because it has invariants that must hold
+under concurrency and is worth testing in isolation. Instructor and course CRUD stayed in
+`AdminController`: wrapping a single `Add` and `SaveChanges` in a service class would add a
+layer without adding a rule.
+
+## Data model
| Entity | Purpose |
|---|---|
-| `Kurs` | A course offered in the catalogue |
-| `Egitmen` | An instructor who can be assigned to courses |
-| `Ogrenci` | A registered student |
-| `Basvuru` | A student's application to a course |
+| `Kurs` | A course, with `Kontenjan` (capacity) and the maintained `KayitliSayisi` |
+| `Egitmen` | An instructor. Deleting one is `Restrict`, not `Cascade` — courses must be dealt with first |
+| `Ogrenci` | A registered student; stores `SifreHash`, never a password |
+| `Basvuru` | A student's application. `(KursId, OgrenciId)` is unique |
+| `Yonetici` | An administrator account |
-## Getting Started
+## Getting started
**Requirements:** [.NET SDK 10.0+](https://dotnet.microsoft.com/download)
```bash
git clone https://github.com/kutsibalci/Course-Registration-System.git
-cd Course-Registration-System/KursKayitSistemi
+cd Course-Registration-System
+
+dotnet run --project KursKayitSistemi
+```
+
+Migrations are applied at startup, so there is no separate `database update` step. On the
+first run the console prints a generated administrator password:
+
+```
+warn: DatabaseSeeder[0]
+ SeedAdmin:Sifre tanimli degil. Gelistirme icin rastgele bir yonetici sifresi uretildi.
+ Kullanici adi : admin
+ Sifre : 7Qk2mZ0pXbNc
+```
+
+To choose it yourself instead:
+
+```bash
+dotnet user-secrets set "SeedAdmin:Sifre" "kendi-sifreniz" --project KursKayitSistemi
+```
+
+> Upgrading an existing database: the migration drops the clear-text `Sifre` column. A hash
+> cannot be recovered from it, so student accounts must be created again.
+
+## Tests
-dotnet restore
-dotnet ef database update # creates KursSistemi.db from the migrations
-dotnet run
+```bash
+dotnet test
```
-The app starts on the URL printed in the console (see `Properties/launchSettings.json`).
+62 tests, no external dependencies — each one creates its own SQLite file and runs the real
+migrations against it. The in-memory provider is deliberately not used: it supports neither
+transactions nor `ExecuteUpdate`, which are exactly the mechanisms the capacity fix depends on.
+
+| Suite | Covers |
+|---|---|
+| `PasswordHashServiceTests` | Round-trip, per-password salting, embedded iteration count, malformed stored hashes |
+| `AccountServiceTests` | Registration, duplicate detection, authentication, role assignment |
+| `EnrollmentServiceTests` | Capacity, duplicates, ownership, seat release, 15-way concurrent enrolment |
+| `AuthorizationIntegrationTests` | The real HTTP pipeline: anonymous, student and administrator against every management route; antiforgery; the retired hard-coded password |
+| `SchemaTests` | Model/migration drift, unique indexes, cascade and restrict behaviour |
## Notes
-Built as a learning project to work through the full ASP.NET Core MVC stack — routing,
-controllers and view models, EF Core migrations, and separating a public area from an
-authenticated admin area.
+Built as a learning project to work through the full ASP.NET Core MVC stack. The most useful
+part was not the first version — it was discovering, by writing tests against something that
+appeared to work, how much of "working" was untested assumption.
---
-Built by Hüseyin Kutsi Balcı
+Built by Hüseyin Kutsi Balcı · MIT licensed
|