Skip to content

Latest commit

 

History

History
353 lines (292 loc) · 16.4 KB

File metadata and controls

353 lines (292 loc) · 16.4 KB

REST API

A small, security-first JSON REST API in plain PHP — stateless bearer-token authentication, PDO prepared statements, strict input validation, per-client rate limiting, correct status codes and security headers, and error responses that never leak internals.

Overview

This project demonstrates the API-specific security concerns that a browser-form project does not face: there is no session cookie and no CSRF token, so authentication moves into an Authorization: Bearer <token> header, and every response is machine-readable JSON. The same server-side discipline still applies — parameterized queries, validated input, least-privilege authorization, and no stack traces on the wire.

Threat model and the concepts it demonstrates:

  • Broken authentication — every protected route requires a bearer token; tokens are stored hashed (hash('sha256', ...)) so a database leak does not hand over live credentials.
  • Broken object-level authorization (BOLA/IDOR) — a row is only returned/mutated when it belongs to the authenticated user.
  • SQL injection — PDO prepared statements everywhere; PDO::ATTR_EMULATE_PREPARES => false.
  • Injection via body — JSON is parsed with JSON_THROW_ON_ERROR and each field is type- and range-checked before use.
  • Abuse / brute force — a per-token (or per-IP) rate limit returns 429 with a Retry-After header.
  • Information disclosure — a global handler returns generic JSON errors and correct status codes; details go to the log, never the client.

Learning Objectives

  • Authenticate stateless API requests with an Authorization: Bearer <token> header instead of a session cookie.
  • Store API tokens hashed (sha256) so a database leak yields no usable credentials.
  • Scope every query by user_id to defend against IDOR/BOLA.
  • Safely decode and strictly validate JSON request bodies (JSON_THROW_ON_ERROR + per-field type/length checks).
  • Enforce per-client rate limiting and return the correct HTTP status codes (401/404/422/429).
  • Return generic JSON errors and set security headers so responses never leak internals.

Architecture

Client ──HTTP + Authorization: Bearer <token>──▶ public/index.php (front controller)
                                                    │
              ┌─────────────────────────────────────┼─────────────────────────────────────┐
              ▼                    ▼                 ▼                 ▼                      ▼
       Security headers      Auth::user()      RateLimiter        Router (method+path)   JSON body parse
       (CSP/HSTS/nosniff)  (hash token → row)  (429 + Retry-After)  → Controller         (JSON_THROW_ON_ERROR)
              └─────────────────────────────────────┴─────────────────────────────────────┘
                                                    ▼
                            Controller → PDO prepared statements (scoped to user_id)
                                                    ▼
                                    Response::json($data, $status)  (+ correct status code)

Source Structure

rest-api/
├── public/
│   └── index.php          # front controller; the ONLY served file
├── src/
│   ├── Database.php       # PDO connection factory
│   ├── Auth.php           # bearer-token verification
│   ├── RateLimiter.php    # per-token throttle
│   ├── Response.php       # JSON responses + security headers
│   └── NoteController.php # example resource (CRUD, scoped to owner)
├── config/
│   └── config.php         # DSN + credentials (outside public/)
└── sql/
    └── schema.sql

Route all requests to public/index.php (Apache .htaccess shown below); nothing else is web-reachable.

# public/.htaccess — send every request to the front controller
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [QSA,L]

Database Schema

CREATE TABLE users (
    id            INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    email         VARCHAR(254) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- API tokens are stored HASHED; the plaintext is shown to the user once at issue time.
CREATE TABLE api_tokens (
    id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    user_id    INT UNSIGNED NOT NULL,
    token_hash CHAR(64)     NOT NULL UNIQUE,        -- sha256 hex of the bearer token
    expires_at DATETIME     NULL,
    created_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_token (token_hash),
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE notes (
    id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    user_id    INT UNSIGNED NOT NULL,               -- owner; every query is scoped by this
    title      VARCHAR(200) NOT NULL,
    body       TEXT         NOT NULL,
    created_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Code Examples

src/Response.php — JSON output + security headers

<?php
final class Response
{
    public static function json(mixed $data, int $status = 200): never
    {
        http_response_code($status);
        header('Content-Type: application/json; charset=utf-8');
        header('X-Content-Type-Options: nosniff');           // no MIME sniffing
        header('Cache-Control: no-store');                   // never cache token-scoped data
        header("Content-Security-Policy: default-src 'none'"); // API returns no HTML
        echo json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public static function error(string $message, int $status): never
    {
        self::json(['error' => $message], $status);   // generic; no internals
    }
}

src/Auth.php — bearer-token verification

<?php
final class Auth
{
    public function __construct(private PDO $db) {}

    /** Returns the authenticated user id, or sends 401 and exits. */
    public function requireUser(): int
    {
        $header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
        if (!preg_match('/^Bearer\s+(\S+)$/', $header, $m)) {
            Response::error('Missing or malformed Authorization header', 401);
        }

        // Look the token up by its HASH — the plaintext is never stored.
        $stmt = $this->db->prepare(
            'SELECT user_id FROM api_tokens
             WHERE token_hash = :h AND (expires_at IS NULL OR expires_at > NOW())
             LIMIT 1'
        );
        $stmt->execute([':h' => hash('sha256', $m[1])]);
        $row = $stmt->fetch();

        if (!$row) {
            Response::error('Invalid or expired token', 401);
        }
        return (int) $row['user_id'];
    }
}

[!warning] Never store API tokens in plaintext A token in the Authorization header is a bearer credential — whoever holds it is the user. Store only hash('sha256', $token) server-side and compare hashes, exactly as you would for a password (though tokens are high-entropy, so a fast hash is acceptable here). If the table leaks, the attacker gets hashes, not usable tokens. Also require HTTPS so the token is never sent in clear text.

src/RateLimiter.php — per-token throttle

<?php
final class RateLimiter
{
    private const MAX = 100;     // requests ...
    private const WINDOW = 60;   // ... per minute, per key

    public function __construct(private PDO $db) {}

    /** Sends 429 with Retry-After when the caller exceeds the budget. */
    public function enforce(string $key): void
    {
        // A production system would use Redis/APCu; this keeps a per-window counter.
        $stmt = $this->db->prepare(
            'INSERT INTO rate_counter (rk, window_start, hits) VALUES (:k, :w, 1)
             ON DUPLICATE KEY UPDATE
               hits = IF(window_start = :w, hits + 1, 1),
               window_start = :w'
        );
        $window = (int) (time() / self::WINDOW) * self::WINDOW;
        $stmt->execute([':k' => hash('sha256', $key), ':w' => $window]);

        $count = (int) $this->db->query(
            'SELECT hits FROM rate_counter WHERE rk = ' .
            $this->db->quote(hash('sha256', $key))
        )->fetchColumn();

        if ($count > self::MAX) {
            header('Retry-After: ' . (($window + self::WINDOW) - time()));
            Response::error('Rate limit exceeded', 429);
        }
    }
}

src/NoteController.php — owner-scoped CRUD

<?php
final class NoteController
{
    public function __construct(private PDO $db, private int $userId) {}

    public function index(): never
    {
        $stmt = $this->db->prepare('SELECT id, title, body, created_at FROM notes WHERE user_id = :u');
        $stmt->execute([':u' => $this->userId]);
        Response::json(['data' => $stmt->fetchAll()]);
    }

    public function show(int $id): never
    {
        // IDOR defence: the WHERE clause is scoped to the owner, so another
        // user's id simply returns nothing (404), never their row.
        $stmt = $this->db->prepare(
            'SELECT id, title, body, created_at FROM notes WHERE id = :id AND user_id = :u'
        );
        $stmt->execute([':id' => $id, ':u' => $this->userId]);
        $note = $stmt->fetch();
        $note ? Response::json(['data' => $note]) : Response::error('Not found', 404);
    }

    public function create(array $input): never
    {
        // Strict validation of the decoded JSON body.
        $title = trim((string) ($input['title'] ?? ''));
        $body  = trim((string) ($input['body'] ?? ''));
        if ($title === '' || mb_strlen($title) > 200 || $body === '') {
            Response::error('Invalid payload', 422);   // Unprocessable Entity
        }

        $stmt = $this->db->prepare(
            'INSERT INTO notes (user_id, title, body) VALUES (:u, :t, :b)'
        );
        $stmt->execute([':u' => $this->userId, ':t' => $title, ':b' => $body]);
        Response::json(['id' => (int) $this->db->lastInsertId()], 201); // Created
    }

    public function delete(int $id): never
    {
        $stmt = $this->db->prepare('DELETE FROM notes WHERE id = :id AND user_id = :u');
        $stmt->execute([':id' => $id, ':u' => $this->userId]);
        Response::json(null, 204); // No Content
    }
}

public/index.php — front controller

<?php
declare(strict_types=1);

// Fail closed: log details, never echo them.
set_exception_handler(static function (Throwable $e): void {
    error_log($e->getMessage());                 // to the server log only
    Response::error('Internal server error', 500);
});

require __DIR__ . '/../src/Database.php';
require __DIR__ . '/../src/Response.php';
require __DIR__ . '/../src/Auth.php';
require __DIR__ . '/../src/RateLimiter.php';
require __DIR__ . '/../src/NoteController.php';

$db  = Database::get();
$uid = (new Auth($db))->requireUser();          // 401 if unauthenticated

// Rate-limit by the presented token (falls back to IP).
(new RateLimiter($db))->enforce($_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['REMOTE_ADDR'] ?? ''));

$method = $_SERVER['REQUEST_METHOD'];
$path   = trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?? '', '/'); // e.g. "notes/5"
$parts  = $path === '' ? [] : explode('/', $path);
$ctrl   = new NoteController($db, $uid);

// Decode a JSON body once, safely.
$input = [];
if (in_array($method, ['POST', 'PUT', 'PATCH'], true)) {
    try {
        $raw   = file_get_contents('php://input');
        $input = $raw === '' ? [] : json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
    } catch (JsonException) {
        Response::error('Malformed JSON', 400);
    }
    if (!is_array($input)) {
        Response::error('Malformed JSON', 400);
    }
}

// Minimal router: /notes and /notes/{id}
match (true) {
    $parts === ['notes'] && $method === 'GET'                  => $ctrl->index(),
    $parts === ['notes'] && $method === 'POST'                 => $ctrl->create($input),
    count($parts) === 2 && $parts[0] === 'notes' && ctype_digit($parts[1]) && $method === 'GET'
        => $ctrl->show((int) $parts[1]),
    count($parts) === 2 && $parts[0] === 'notes' && ctype_digit($parts[1]) && $method === 'DELETE'
        => $ctrl->delete((int) $parts[1]),
    default => Response::error('Not found', 404),
};

[!warning] Do not leak exceptions to the client Returning ['error' => $e->getMessage()] or letting display_errors render a stack trace exposes SQL, file paths, and library versions — a reconnaissance gift. Register a global exception handler that logs the detail server-side and returns a generic {"error":"Internal server error"} with a 500. Set display_errors = Off in production php.ini.

Security Considerations

Risk Mitigation in this project
Broken authentication Authorization: Bearer required on every route; tokens stored as sha256 hashes, checked against expiry.
IDOR / BOLA Every query is scoped by user_id; another user's id yields 404, never their data.
SQL injection PDO prepared statements throughout; PDO::ATTR_EMULATE_PREPARES => false.
Malformed / injected body json_decode(..., JSON_THROW_ON_ERROR) + per-field type/length validation → 400/422.
Brute force / abuse Per-token rate limit returns 429 with Retry-After.
Information disclosure Global exception handler logs detail, returns generic 500; display_errors = Off.
Response tampering / caching X-Content-Type-Options: nosniff, Cache-Control: no-store, restrictive CSP.
Credential interception Requires HTTPS/HSTS so bearer tokens are never sent in clear text.

See the OWASP REST Security cheat sheet and the API Security Top 10.

Improvements

  • Swap opaque tokens for short-lived JWTs plus refresh tokens (validate signature and exp; never trust alg: none).
  • Add scopes/roles for fine-grained authorization beyond ownership.
  • Move rate limiting to Redis/APCu for accuracy under concurrency.
  • Add request/response schema validation (JSON Schema / OpenAPI) and content negotiation.
  • Emit structured audit logs (who, what, when) without recording secrets or bodies.
  • Add pagination, ETag/conditional requests, and 429/503 back-off guidance.

Summary

A secure REST API is the browser-form discipline minus the session: parameterized PDO queries, strict input validation, and owner-scoped authorization stay the same, while authentication shifts to a hashed bearer token and every response is generic JSON carrying security headers and the correct status code. Fail closed — log detail server-side, never on the wire — and require HTTPS so tokens are never exposed in transit.

References

Related