Skip to content

Latest commit

 

History

History
452 lines (365 loc) · 16.2 KB

File metadata and controls

452 lines (365 loc) · 16.2 KB

Student Management System

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.


Overview

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.


Architecture

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
Loading

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_idGradeRepository runs a parameterized INSERT ... ON DUPLICATE KEY UPDATE → a redirect (POST/Redirect/GET) prevents resubmission.


Folder Structure

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

Database Schema

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;

Implementation

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>

Testing

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().


Security Review

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

Deployment

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.


Exercises

  1. Audit log: add an append-only audit_events table recording every grade write with actor, target, and timestamp; expose it read-only to admins.
  2. Rate-limit login: implement a per-IP/per-account throttle (e.g. 5 attempts / 15 min) backed by Redis to blunt credential stuffing.
  3. Grade export privacy: build a CSV export for teachers that redacts other teachers' courses and never emits raw user.id.
  4. 2FA for admins: layer TOTP on top of the password login for the admin role only.
  5. 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.

References

  • 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.

Related