A role-aware PHP 8.2 web application that manages student enrollments and grades behind strict RBAC and per-record privacy controls, built as a secure reference implementation.
The Student Management System (SMS) is a server-rendered PHP application for a small institution. It models three actors — admin, teacher, and student — and enforces that each only sees and mutates what their role permits. Admins manage users and courses; teachers record grades for the courses they teach; students read their own enrollments and grades and nothing else.
Threat model. The app assumes an authenticated but potentially hostile user base: any logged-in account may attempt horizontal privilege escalation (a student reading another student's grades via IDOR), vertical escalation (a teacher hitting an admin endpoint), injection through enrollment/grade fields, CSRF on state-changing forms, and session hijacking. External threats include SQL injection on the login and search paths and stored XSS through student names or comments that later render in another user's dashboard. Out of scope: DDoS, physical access, and supply-chain compromise of Composer dependencies (mitigated operationally by composer audit in CI).
Every trust boundary — HTTP input, session identity, and database row ownership — is checked explicitly. Authorization is decided server-side per request, never inferred from a hidden form field or client role claim.
A thin front controller routes each request through authentication and authorization middleware before it reaches a controller, which uses repositories over a single PDO connection.
flowchart LR
B[Browser] -->|HTTPS| N[nginx]
N -->|FastCGI| F[php-fpm]
F --> R[Router / index.php]
R --> MW[Auth + CSRF Middleware]
MW --> C[Controllers]
C --> Repo[Repositories]
Repo -->|PDO prepared stmts| DB[(MariaDB)]
C --> V[Twig-less PHP views + htmlspecialchars]
V --> B
Data flow for "teacher records a grade": browser POSTs course_id, student_id, grade plus a CSRF token → middleware validates the session and token → GradeController confirms the teacher owns course_id → GradeRepository runs a parameterized INSERT ... ON DUPLICATE KEY UPDATE → a redirect (POST/Redirect/GET) prevents resubmission.
student-management-system/
├── composer.json
├── public/
│ └── index.php # front controller (only web-exposed dir)
├── src/
│ ├── Core/
│ │ ├── Database.php # PDO factory (singleton per request)
│ │ ├── Router.php
│ │ ├── Csrf.php
│ │ └── Auth.php
│ ├── Middleware/
│ │ └── RequireRole.php
│ ├── Controllers/
│ │ ├── AuthController.php
│ │ ├── GradeController.php
│ │ └── EnrollmentController.php
│ ├── Repositories/
│ │ ├── UserRepository.php
│ │ ├── GradeRepository.php
│ │ └── EnrollmentRepository.php
│ └── Views/
│ ├── layout.php
│ └── grades/list.php
├── migrations/
│ └── 001_init.sql
├── tests/
│ └── GradeRepositoryTest.php
├── docker/
│ ├── nginx.conf
│ └── php.ini
├── Dockerfile
└── docker-compose.yml
CREATE TABLE users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL, -- password_hash() output
role ENUM('admin','teacher','student') NOT NULL,
full_name VARCHAR(120) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE courses (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(16) NOT NULL UNIQUE,
title VARCHAR(160) NOT NULL,
teacher_id INT UNSIGNED NOT NULL,
FOREIGN KEY (teacher_id) REFERENCES users(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE enrollments (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
student_id INT UNSIGNED NOT NULL,
course_id INT UNSIGNED NOT NULL,
enrolled_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_enroll (student_id, course_id),
FOREIGN KEY (student_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE grades (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
enrollment_id INT UNSIGNED NOT NULL UNIQUE,
grade DECIMAL(4,1) NOT NULL, -- 0.0 .. 100.0
comment VARCHAR(500) NULL,
graded_by INT UNSIGNED NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (enrollment_id) REFERENCES enrollments(id) ON DELETE CASCADE,
FOREIGN KEY (graded_by) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;PDO data layer — one hardened connection, always prepared statements.
<?php
declare(strict_types=1);
namespace App\Core;
use PDO;
final class Database
{
private static ?PDO $pdo = null;
public static function pdo(): PDO
{
if (self::$pdo === null) {
$dsn = sprintf(
'mysql:host=%s;dbname=%s;charset=utf8mb4',
getenv('DB_HOST') ?: 'db',
getenv('DB_NAME') ?: 'sms'
);
self::$pdo = new PDO($dsn, getenv('DB_USER'), getenv('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // real server-side prepares
]);
}
return self::$pdo;
}
}Session-hardened authentication.
<?php
declare(strict_types=1);
namespace App\Core;
use App\Repositories\UserRepository;
final class Auth
{
public static function start(): void
{
session_set_cookie_params([
'httponly' => true,
'secure' => true,
'samesite' => 'Strict',
]);
session_start();
}
public static function login(string $email, string $password): bool
{
$user = (new UserRepository())->findByEmail($email);
// password_verify is constant-time; the dummy hash blocks user enumeration timing.
$hash = $user['password'] ?? '$2y$12$usesomedummyhashtokeeptimingsteadyxxxxxxxxxxxx';
if (!password_verify($password, $hash) || $user === null) {
return false;
}
session_regenerate_id(true); // defeat session fixation
$_SESSION['uid'] = (int) $user['id'];
$_SESSION['role'] = $user['role'];
return true;
}
public static function user(): ?array
{
return isset($_SESSION['uid'])
? ['id' => $_SESSION['uid'], 'role' => $_SESSION['role']]
: null;
}
}RBAC middleware — vertical access control, decided server-side.
<?php
declare(strict_types=1);
namespace App\Middleware;
use App\Core\Auth;
final class RequireRole
{
/** @param string[] $roles */
public static function check(array $roles): void
{
$user = Auth::user();
if ($user === null || !in_array($user['role'], $roles, true)) {
http_response_code(403);
exit('Forbidden');
}
}
}CSRF tokens — per-session secret compared with hash_equals.
<?php
declare(strict_types=1);
namespace App\Core;
final class Csrf
{
public static function token(): string
{
return $_SESSION['csrf'] ??= bin2hex(random_bytes(32));
}
public static function assert(string $sent): void
{
if (!hash_equals($_SESSION['csrf'] ?? '', $sent)) {
http_response_code(419);
exit('CSRF token mismatch');
}
}
}Controller with ownership check — the fix for horizontal escalation (IDOR). A teacher may grade only a course they own; the check is a WHERE clause, not a hidden field.
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\{Auth, Csrf};
use App\Middleware\RequireRole;
use App\Repositories\GradeRepository;
final class GradeController
{
public function store(): void
{
RequireRole::check(['teacher']);
Csrf::assert($_POST['csrf'] ?? '');
$teacherId = Auth::user()['id'];
$courseId = filter_input(INPUT_POST, 'course_id', FILTER_VALIDATE_INT);
$studentId = filter_input(INPUT_POST, 'student_id', FILTER_VALIDATE_INT);
$grade = filter_input(INPUT_POST, 'grade', FILTER_VALIDATE_FLOAT);
if ($courseId === false || $studentId === false
|| $grade === false || $grade < 0.0 || $grade > 100.0) {
http_response_code(422);
exit('Invalid input');
}
// VULNERABLE: trusting course_id alone lets any teacher grade any course.
// $repo->upsert($courseId, $studentId, $grade);
// FIX: repository confirms teacher_id owns course_id in the same query.
$ok = (new GradeRepository())->upsertOwned($teacherId, $courseId, $studentId, (float) $grade);
http_response_code($ok ? 303 : 403);
header('Location: /grades');
}
}Repository — parameterized, ownership-scoped SQL.
<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Core\Database;
final class GradeRepository
{
public function upsertOwned(int $teacherId, int $courseId, int $studentId, float $grade): bool
{
$sql = 'INSERT INTO grades (enrollment_id, grade, graded_by)
SELECT e.id, :grade, :teacher
FROM enrollments e
JOIN courses c ON c.id = e.course_id
WHERE e.course_id = :course
AND e.student_id = :student
AND c.teacher_id = :teacher
ON DUPLICATE KEY UPDATE grade = VALUES(grade), graded_by = VALUES(graded_by)';
$stmt = Database::pdo()->prepare($sql);
$stmt->execute([
':grade' => $grade,
':teacher' => $teacherId,
':course' => $courseId,
':student' => $studentId,
]);
return $stmt->rowCount() > 0; // 0 rows => teacher does not own course
}
}Output escaping in views — every dynamic value passes through htmlspecialchars to stop stored XSS.
<?php /** @var array $rows */ ?>
<table>
<?php foreach ($rows as $r): ?>
<tr>
<td><?= htmlspecialchars($r['full_name'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= htmlspecialchars((string) $r['grade'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= htmlspecialchars($r['comment'] ?? '', ENT_QUOTES, 'UTF-8') ?></td>
</tr>
<?php endforeach ?>
</table>Test the authorization boundary, not just the happy path: a teacher grading a non-owned course must fail; a student endpoint must return only that student's rows; invalid grades (>100, negative, non-numeric) must be rejected; CSRF assertion must reject a mismatched token; login must reject a wrong password in constant time.
<?php
declare(strict_types=1);
use App\Repositories\GradeRepository;
use PHPUnit\Framework\TestCase;
final class GradeRepositoryTest extends TestCase
{
public function testTeacherCannotGradeCourseTheyDoNotOwn(): void
{
$repo = new GradeRepository();
// teacher #99 does not own course #1
$result = $repo->upsertOwned(teacherId: 99, courseId: 1, studentId: 5, grade: 80.0);
self::assertFalse($result, 'Non-owning teacher must not write a grade');
}
public function testOwningTeacherCanGrade(): void
{
$repo = new GradeRepository();
$result = $repo->upsertOwned(teacherId: 2, courseId: 1, studentId: 5, grade: 91.5);
self::assertTrue($result);
}
}Run against a disposable test database seeded by migrations/001_init.sql; wrap each test in a transaction rolled back in tearDown().
| Risk | Vector | Mitigation | OWASP 2021 |
|---|---|---|---|
| SQL injection | Login, grade, search inputs | PDO prepared statements, EMULATE_PREPARES=false |
A03 Injection |
| Broken access control (IDOR) | Student reads another's grades | Ownership WHERE clause per query, RequireRole middleware |
A01 Broken Access Control |
| Vertical privilege escalation | Teacher hits admin route | Server-side role check, never client-supplied | A01 Broken Access Control |
| Stored XSS | Names/comments rendered later | htmlspecialchars(ENT_QUOTES) on all output |
A03 Injection |
| CSRF | State-changing POST forms | Per-session token, hash_equals compare, SameSite=Strict |
A01 / misconfig |
| Session fixation/hijack | Reused session id, cookie theft | session_regenerate_id(true), HttpOnly + Secure cookies |
A07 Auth Failures |
| Weak credential storage | DB dump exposes passwords | password_hash() bcrypt cost 12, password_verify |
A02 Cryptographic Failures |
| User enumeration | Login timing/error differences | Dummy-hash verify, uniform error message | A07 Auth Failures |
| Security misconfiguration | Verbose errors leak paths | display_errors=Off in prod, generic 500 page |
A05 Misconfiguration |
Three containers via Docker Compose: nginx (TLS termination, static files), php-fpm (the app), and MariaDB. Only public/ is web-exposed; src/ sits above the document root.
FROM php:8.3-fpm-alpine
RUN docker-php-ext-install pdo_mysql opcache
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader
COPY . .server {
listen 443 ssl;
root /app/public;
index index.php;
location / { try_files $uri /index.php?$query_string; }
location ~ \.php$ {
fastcgi_pass php:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\. { deny all; } # block dotfiles
}Bring it up with docker compose up -d, then docker compose exec php php migrations/run.php. Secrets (DB_PASS, session salt) are injected as environment variables, never committed. Set php.ini: display_errors=Off, expose_php=Off, session.cookie_secure=1, opcache.validate_timestamps=0 in production.
- Audit log: add an append-only
audit_eventstable recording every grade write with actor, target, and timestamp; expose it read-only to admins. - Rate-limit login: implement a per-IP/per-account throttle (e.g. 5 attempts / 15 min) backed by Redis to blunt credential stuffing.
- Grade export privacy: build a CSV export for teachers that redacts other teachers' courses and never emits raw
user.id. - 2FA for admins: layer TOTP on top of the password login for the admin role only.
- API surface: expose a read-only JSON endpoint for a student's own grades, secured with a short-lived token instead of the session cookie.
- OWASP Top 10 (2021) — application security risk categories.
- PHP Manual — PDO prepared statements,
password_hash,hash_equals, session configuration. - OWASP Cheat Sheet Series — Authorization, CSRF Prevention, Session Management.
- PSR-4 (autoloading) and PSR-12 (coding style), PHP-FIG.
- Prepared-Statements — the PDO pattern every repository here relies on.
- Role-Based-Access-Control — the admin/teacher/student model this project enforces.
- CSRF-Tokens — the token-and-
hash_equalsscheme used on all POST forms. - Password-Based-Login — the hashing and session-hardening basis of
Auth. - Docker-for-PHP — the nginx/php-fpm/MariaDB deployment stack.
- Login-System — sibling mini-project reused as the authentication front door.