Vodiči

Building a Secure PHP API: Token Rotation, Rate Limiting, and Auditing

Izgradnja sigurnog PHP API-ja: rotacija tokena, ograničavanje broja zahtjeva i revizija

Siguran API nije definiran domišljatim formatom tokena. Definiran je onime što se događa nakon što token iscuri, dva zahtjeva za osvježavanje uđu u utrku, klijent preplavi krajnju točku ili operater treba rekonstruirati incident šest tjedana kasnije.

Ovaj vodič gradi kompaktnu PHP 8.3 API aplikaciju s neprozirnim pristupnim tokenima, rotacijom tokena za osvježavanje za jednokratnu upotrebu, otkrivanjem ponovne upotrebe, ograničavanjem brzine utemeljenim na PostgreSQL-u i transakcijskim revizijskim događajima. Primjer izlaže krajnje točke za prijavu, osvježavanje, profil i odjavu iza Nginxa i PHP-FPM-a.

Preduvjeti i arhitektura

Trebaju vam Docker Engine s podrškom za Compose i curl. Spremnici koriste PHP 8.3, Nginx i PostgreSQL. Nisu potrebni upravitelj PHP paketa ni aplikacijska biblioteka treće strane.

API namjerno koristi neprozirne nasumične tokene umjesto JWT-ova. U PostgreSQL stižu samo SHA-256 sažeci tokena, stoga otkrivanje baze podataka ne otkriva odmah vjerodajnice nositelja. Svaki autentificirani zahtjev obavlja pretragu baze podataka, što košta više od lokalne JWT provjere, ali omogućuje trenutačno opozivanje i jednostavno onemogućavanje računa.

Pristupni tokeni vrijede 15 minuta. Tokeni za osvježavanje vrijede 30 dana i smiju se upotrijebiti točno jednom. Svako uspješno osvježavanje stvara zamjenski token u istoj obitelji. Ponovna upotreba starijeg tokena opoziva cijelu obitelj, uključujući njezine pristupne tokene.

Ograničenja brzine koriste atomske PostgreSQL upserte. To održava primjer dosljednim između više PHP replika bez uvođenja Redisa, iako bi API s vrlo velikim prometom brojače obično premjestio u namjensku uslugu niske latencije. Algoritam fiksnog vremenskog prozora namjerno je jednostavan i može dopustiti nalete prometa blizu granice prozora.

Struktura projekta

secure-api/
├── compose.yaml
├── Dockerfile
├── nginx.conf
├── public/
│   └── index.php
├── src/
│   └── bootstrap.php
└── sql/
    └── schema.sql

Stvorite shemu baze podataka

Revizijska tablica orijentirana je na dodavanje zapisa, a ne magično zaštićena od neovlaštenih izmjena. U produkciji vlasnik migracije treba zadržati ovlasti nad shemom, dok aplikacijska uloga prima samo operacije koje su joj potrebne. Izvoz događaja u zasebno kontroliranu pohranu pruža snažniji dokaz od čuvanja jedine kopije uz podatke aplikacije.

CREATE TABLE users (
    id text PRIMARY KEY,
    email text NOT NULL UNIQUE,
    password_hash text NOT NULL,
    enabled boolean NOT NULL DEFAULT true,
    created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE access_tokens (
    id text PRIMARY KEY,
    user_id text NOT NULL REFERENCES users(id),
    family_id text NOT NULL,
    token_hash char(64) NOT NULL UNIQUE,
    expires_at timestamptz NOT NULL,
    revoked_at timestamptz,
    created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX access_tokens_user_idx
    ON access_tokens (user_id, expires_at);

CREATE TABLE refresh_tokens (
    id text PRIMARY KEY,
    user_id text NOT NULL REFERENCES users(id),
    family_id text NOT NULL,
    parent_id text REFERENCES refresh_tokens(id),
    token_hash char(64) NOT NULL UNIQUE,
    expires_at timestamptz NOT NULL,
    used_at timestamptz,
    revoked_at timestamptz,
    created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX refresh_tokens_family_idx
    ON refresh_tokens (family_id);

CREATE TABLE rate_buckets (
    subject text NOT NULL,
    route text NOT NULL,
    window_start bigint NOT NULL,
    hits integer NOT NULL,
    PRIMARY KEY (subject, route, window_start)
);

CREATE TABLE audit_events (
    id bigserial PRIMARY KEY,
    occurred_at timestamptz NOT NULL DEFAULT now(),
    actor_user_id text REFERENCES users(id),
    event_type text NOT NULL,
    request_id text NOT NULL,
    source_ip inet,
    metadata jsonb NOT NULL DEFAULT '{}'::jsonb
);

CREATE INDEX audit_events_actor_time_idx
    ON audit_events (actor_user_id, occurred_at DESC);

Implementirajte sigurnosnu jezgru

Stvorite src/bootstrap.php. PostgreSQL prima eksplicitna vremenska ograničenja za naredbe i zaključavanja putem opcija povezivanja. Samo vremensko ograničenje povezivanja ne bi ograničilo spor upit, stoga su oba aspekta zasebno konfigurirana.

<?php
declare(strict_types=1);

const ACCESS_TTL = 900;
const REFRESH_TTL = 2592000;
const RATE_WINDOW = 60;

function db(): PDO
{
    static $pdo;
    if ($pdo instanceof PDO) {
        return $pdo;
    }

    $pdo = new PDO(
        (string) getenv('DATABASE_DSN'),
        (string) getenv('DATABASE_USER'),
        (string) getenv('DATABASE_PASSWORD'),
        [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false,
            PDO::ATTR_TIMEOUT => 3,
        ]
    );
    $pdo->exec("SET statement_timeout = '2000ms'");
    $pdo->exec("SET lock_timeout = '500ms'");
    return $pdo;
}

function identifier(): string
{
    return bin2hex(random_bytes(16));
}

function opaqueToken(): string
{
    return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
}

function tokenHash(string $token): string
{
    return hash('sha256', $token);
}

function requestId(): string
{
    static $id;
    return $id ??= identifier();
}

function clientIp(): string
{
    return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
}

function input(): array
{
    $raw = file_get_contents('php://input');
    $value = json_decode($raw === '' ? '{}' : $raw, true, 32, JSON_THROW_ON_ERROR);
    if (!is_array($value)) {
        throw new InvalidArgumentException('JSON object required');
    }
    return $value;
}

function respond(int $status, array $payload): never
{
    http_response_code($status);
    header('Content-Type: application/json');
    header('X-Request-ID: ' . requestId());
    echo json_encode($payload, JSON_THROW_ON_ERROR);
    exit;
}

function audit(?string $userId, string $type, array $metadata = []): void
{
    $sql = 'INSERT INTO audit_events
        (actor_user_id, event_type, request_id, source_ip, metadata)
        VALUES (:u, :t, :r, CAST(:m AS jsonb))';
    db()->prepare($sql)->execute([
        'u' => $userId,
        't' => $type,
        'r' => requestId(),
        'm' => json_encode($metadata, JSON_THROW_ON_ERROR),
    ]);
}

function rateLimit(string $subject, string $route, int $maximum): void
{
    $bucket = intdiv(time(), RATE_WINDOW);
    $sql = 'INSERT INTO rate_buckets
        (subject, route, window_start, hits)
        VALUES (:s, :r, :w, 1)
        ON CONFLICT (subject, route, window_start)
        DO UPDATE SET hits = rate_buckets.hits + 1
        WHERE rate_buckets.hits < :maximum
        RETURNING hits';

    $statement = db()->prepare($sql);
    $statement->execute([
        's' => $subject,
        'r' => $route,
        'w' => $bucket,
        'maximum' => $maximum,
    ]);

    header('X-RateLimit-Limit: ' . $maximum);
    if ($statement->fetchColumn() === false) {
        header('Retry-After: ' . (RATE_WINDOW - (time() % RATE_WINDOW)));
        respond(429, ['error' => 'rate_limit_exceeded']);
    }
}

function issuePair(string $userId, string $familyId, ?string $parentId): array
{
    $access = opaqueToken();
    $refresh = opaqueToken();
    $accessId = identifier();
    $refreshId = identifier();

    db()->prepare(
        "INSERT INTO access_tokens
         (id, user_id, family_id, token_hash, expires_at)
         VALUES (:id, :u, :f, :h, now() + interval '15 minutes')"
    )->execute([
        'id' => $accessId, 'u' => $userId,
        'f' => $familyId, 'h' => tokenHash($access),
    ]);

    db()->prepare(
        "INSERT INTO refresh_tokens
         (id, user_id, family_id, parent_id, token_hash, expires_at)
         VALUES (:id, :u, :f, :p, :h, now() + interval '30 days')"
    )->execute([
        'id' => $refreshId, 'u' => $userId, 'f' => $familyId,
        'p' => $parentId, 'h' => tokenHash($refresh),
    ]);

    return [
        'access_token' => $access,
        'token_type' => 'Bearer',
        'expires_in' => ACCESS_TTL,
        'refresh_token' => $refresh,
        'refresh_expires_in' => REFRESH_TTL,
    ];
}

function rotate(string $token): ?array
{
    $pdo = db();
    $pdo->beginTransaction();

    $statement = $pdo->prepare(
        'SELECT * FROM refresh_tokens WHERE token_hash = :h FOR UPDATE'
    );
    $statement->execute(['h' => tokenHash($token)]);
    $row = $statement->fetch();

    if (!$row) {
        $pdo->rollBack();
        audit(null, 'refresh_rejected', ['reason' => 'unknown']);
        return null;
    }

    if ($row['used_at'] !== null || $row['revoked_at'] !== null) {
        $pdo->prepare(
            'UPDATE refresh_tokens SET revoked_at = COALESCE(revoked_at, now())
             WHERE family_id = :f'
        )->execute(['f' => $row['family_id']]);
        $pdo->prepare(
            'UPDATE access_tokens SET revoked_at = COALESCE(revoked_at, now())
             WHERE family_id = :f'
        )->execute(['f' => $row['family_id']]);
        audit($row['user_id'], 'refresh_reuse_detected');
        $pdo->commit();
        return null;
    }

    if (strtotime($row['expires_at']) <= time()) {
        $pdo->prepare(
            'UPDATE refresh_tokens SET revoked_at = now() WHERE id = :id'
        )->execute(['id' => $row['id']]);
        audit($row['user_id'], 'refresh_expired');
        $pdo->commit();
        return null;
    }

    $pdo->prepare(
        'UPDATE refresh_tokens SET used_at = now() WHERE id = :id'
    )->execute(['id' => $row['id']]);

    $pair = issuePair($row['user_id'], $row['family_id'], $row['id']);
    audit($row['user_id'], 'refresh_rotated');
    $pdo->commit();
    return $pair;
}

function authenticate(): array
{
    $header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
    if (!preg_match('/^Bearer ([A-Za-z0-9_-]+)$/', $header, $matches)) {
        respond(401, ['error' => 'missing_bearer_token']);
    }

    $sql = 'SELECT a.id AS access_id, a.user_id, u.email
            FROM access_tokens a
            JOIN users u ON u.id = a.user_id
            WHERE a.token_hash = :h
              AND a.expires_at > now()
              AND a.revoked_at IS NULL
              AND u.enabled = true';
    $statement = db()->prepare($sql);
    $statement->execute(['h' => tokenHash($matches[1])]);
    $identity = $statement->fetch();

    if (!$identity) {
        respond(401, ['error' => 'invalid_access_token']);
    }
    return $identity;
}

Zaključavanje retka u rotate() serijalizira istodobne upotrebe istog tokena za osvježavanje. Jedan zahtjev uspijeva; sljedeći vidi used_at i opoziva obitelj. Ovo je namjerno strogo: klijent mora serijalizirati pokušaje osvježavanja i trajno zamijeniti pohranjeni token za osvježavanje.

Dodajte HTTP rute

Stvorite public/index.php. Neuspjesi autentifikacije koriste generički odgovor kako pozivatelji ne bi mogli nabrajati račune. Sigurnosne izmjene i njihovi uspješni revizijski zapisi dijele transakcije.

<?php
declare(strict_types=1);

require __DIR__ . '/../src/bootstrap.php';

try {
    $method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
    $path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);

    if ($method === 'POST' && $path === '/login') {
        rateLimit(clientIp(), 'login', 10);
        $body = input();
        $email = strtolower(trim((string) ($body['email'] ?? '')));
        $password = (string) ($body['password'] ?? '');

        $statement = db()->prepare(
            'SELECT id, password_hash FROM users
             WHERE email = :email AND enabled = true'
        );
        $statement->execute(['email' => $email]);
        $user = $statement->fetch();

        if (!$user || !password_verify($password, $user['password_hash'])) {
            audit($user['id'] ?? null, 'login_failed');
            respond(401, ['error' => 'invalid_credentials']);
        }

        db()->beginTransaction();
        $pair = issuePair($user['id'], identifier(), null);
        audit($user['id'], 'login_succeeded');
        db()->commit();
        respond(200, $pair);
    }

    if ($method === 'POST' && $path === '/refresh') {
        rateLimit(clientIp(), 'refresh', 20);
        $token = (string) (input()['refresh_token'] ?? '');
        if ($token === '') {
            respond(400, ['error' => 'refresh_token_required']);
        }
        $pair = rotate($token);
        $pair === null
            ? respond(401, ['error' => 'invalid_refresh_token'])
            : respond(200, $pair);
    }

    if ($method === 'GET' && $path === '/me') {
        $identity = authenticate();
        rateLimit($identity['user_id'], 'me', 60);
        respond(200, [
            'id' => $identity['user_id'],
            'email' => $identity['email'],
        ]);
    }

    if ($method === 'POST' && $path === '/logout') {
        $identity = authenticate();
        rateLimit($identity['user_id'], 'logout', 20);

        db()->beginTransaction();
        db()->prepare(
            'UPDATE access_tokens SET revoked_at = now() WHERE id = :id'
        )->execute(['id' => $identity['access_id']]);
        audit($identity['user_id'], 'logout');
        db()->commit();
        respond(204, []);
    }

    respond(404, ['error' => 'not_found']);
} catch (JsonException | InvalidArgumentException) {
    respond(400, ['error' => 'malformed_request']);
} catch (Throwable $error) {
    if (db()->inTransaction()) {
        db()->rollBack();
    }
    error_log(requestId() . ' ' . $error->getMessage());
    respond(500, ['error' => 'internal_error']);
}

Stavite API u spremnike

PHP slici treba samo PDO PostgreSQL proširenje prve strane. Stvorite sljedeći Dockerfile:

FROM php:8.3-fpm-bookworm
RUN apt-get update \
    && apt-get install -y --no-install-recommends libpq-dev \
    && docker-php-ext-install pdo_pgsql \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /var/www
COPY public/ public/
COPY src/ src/
USER www-data
CMD ["php-fpm", "-F"]

Stvorite nginx.conf:

server {
    listen 8080;
    server_name _;
    root /var/www/public;

    client_max_body_size 32k;
    client_body_timeout 5s;
    send_timeout 10s;

    location / {
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME /var/www/public/index.php;
        fastcgi_param HTTP_AUTHORIZATION $http_authorization;
        fastcgi_pass app:9000;
        fastcgi_connect_timeout 2s;
        fastcgi_read_timeout 5s;
    }
}

Stvorite compose.yaml:

services:
  db:
    image: postgres:16-bookworm
    environment:
      POSTGRES_DB: api
      POSTGRES_USER: api
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./sql/schema.sql:/docker-entrypoint-initdb.d/001-schema.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U api -d api"]
      interval: 5s
      timeout: 3s
      retries: 10

  app:
    build: .
    environment:
      DATABASE_DSN: pgsql:host=db;port=5432;dbname=api
      DATABASE_USER: api
      DATABASE_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
    depends_on:
      db:
        condition: service_healthy

  nginx:
    image: nginx:1.26-alpine
    ports:
      - "127.0.0.1:8080:8080"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - app

volumes:
  pgdata:

Stvorite lokalnu tajnu bez prepisivanja postojeće datoteke, pokrenite skup spremnika i dodijelite prvi račun. Upit za lozinku izbjegava stavljanje lozinke računa izravno u povijest naredbi.

test ! -e .env || { echo ".env already exists"; exit 1; }
umask 077
printf 'POSTGRES_PASSWORD=%s\n' "$(openssl rand -hex 32)" > .env
docker compose up --build -d

read -rsp 'Initial API password: ' API_PASSWORD
echo
docker compose exec -e API_PASSWORD="$API_PASSWORD" app php -r '
require "/var/www/src/bootstrap.php";
$hash = password_hash(getenv("API_PASSWORD"), PASSWORD_ARGON2ID);
db()->prepare(
    "INSERT INTO users (id, email, password_hash)
     VALUES (:id, :email, :hash)"
)->execute([
    "id" => bin2hex(random_bytes(16)),
    "email" => "[email protected]",
    "hash" => $hash
]);
'
unset API_PASSWORD

Testirajte rotaciju, ponovnu upotrebu i opozivanje

Prijavite se, pozovite zaštićenu rutu, rotirajte token za osvježavanje, a zatim namjerno ponovno upotrijebite stari token za osvježavanje:

read -rsp 'API password: ' API_PASSWORD
echo
PAIR=$(curl -fsS http://127.0.0.1:8080/login \
  -H 'Content-Type: application/json' \
  --data "{\"email\":\"[email protected]\",\"password\":\"$API_PASSWORD\"}")
unset API_PASSWORD

ACCESS=$(printf '%s' "$PAIR" | php -r \
  '$j=json_decode(stream_get_contents(STDIN),true); echo $j["access_token"];')
REFRESH=$(printf '%s' "$PAIR" | php -r \
  '$j=json_decode(stream_get_contents(STDIN),true); echo $j["refresh_token"];')

curl -i http://127.0.0.1:8080/me \
  -H "Authorization: Bearer $ACCESS"

ROTATED=$(curl -fsS http://127.0.0.1:8080/refresh \
  -H 'Content-Type: application/json' \
  --data "{\"refresh_token\":\"$REFRESH\"}")

curl -i http://127.0.0.1:8080/refresh \
  -H 'Content-Type: application/json' \
  --data "{\"refresh_token\":\"$REFRESH\"}"

Završni zahtjev trebao bi vratiti 401. Budući da ponovna upotreba opoziva obitelj, pristupni token u ROTATED također mora naknadno biti neuspješan. Ponovljeni pozivi za prijavu trebali bi na kraju vratiti 429 s Retry-After.

Sigurnost i promatranje produkcije

Završite TLS na ojačanom obrnutom proxyju ili raspodjeljivaču opterećenja i javno izložite samo portove 80 i 443. Port 8080 namjerno je vezan na povratnu petlju. Ne izlažite PostgreSQL kroz vatrozid glavnog računala.

Ako se drugi proxy nalazi ispred Nginxa, konfigurirajte eksplicitan popis pouzdanih proxyja prije prihvaćanja proslijeđenih adresa klijenta. Slijepo vjerovanje zaglavlju X-Forwarded-For omogućuje pozivateljima zaobilaženje IP ograničenja i krivotvorenje revizijskih podataka.

Klijenti preglednika trebaju čuvati tokene za osvježavanje u sigurnim kolačićima samo za HTTP i istog mjesta te dodati CSRF zaštite za osvježavanje i odjavu. Izvorni i poslužiteljski klijenti trebaju koristiti spremište vjerodajnica operacijskog sustava. Tokeni nositelja ne pripadaju URL-ovima, zapisnicima, analitičkim događajima ni lokalnoj pohrani preglednika.

Izvozite strukturirane aplikacijske zapisnike koristeći ID zahtjeva vraćen u X-Request-ID. Postavite upozorenja za ponovne upotrebe tokena za osvježavanje, trajne neuspjehe prijave, pogreške vremenskog ograničenja baze podataka i neuobičajene stope 429. Revizijske IP adrese osobni su podaci u mnogim jurisdikcijama, stoga definirajte pravila zadržavanja i pristupa umjesto da ih čuvate neograničeno.

Zakažite ograničene serije čišćenja za istekle tokene i stare spremnike ograničenja brzine. Velika neograničena brisanja stvaraju zaključavanja, pojačanje pisanja i pritisak na replikaciju. Revizijske tablice velikog volumena imaju koristi od particioniranja po vremenu i asinkronog izvoza, dok promjene stanja autentifikacije trebaju zadržati svoj lokalni transakcijski revizijski događaj.

Česti problemi

  • Legitimni zahtjevi za osvježavanje pokreću otkrivanje ponovne upotrebe: klijent istodobno osvježava ili ponavlja pokušaj nakon gubitka uspješnog odgovora. Serijalizirajte osvježavanja i postojano spremite zamjenu prije oslobađanja zahtjeva na čekanju.
  • Čini se da svaki zahtjev ima jednu IP adresu: lanac proxyja ne prosljeđuje ili ne razrješava sigurno izvornu adresu.
  • Veze s bazom podataka se gomilaju: broj PHP-FPM radnika premašuje kapacitet PostgreSQL-a. Neizravno ograničite skup putem dimenzioniranja FPM-a ili dodajte proxy za objedinjavanje transakcija.
  • Zahtjevi ne uspijevaju nakon dvije sekunde: pregledajte planove upita prije povećanja statement_timeout. Nedostajući indeksi i sukob oko zaključavanja obično su važniji od većeg proračuna.
  • Promjene sheme ne pokreću se nakon uređivanja SQL datoteke: PostgreSQL skripte za inicijalizaciju izvršavaju se samo za novi podatkovni volumen. Produkcijskim implementacijama trebaju eksplicitne, verzionirane migracije.

Kontrolni popis za konačnu provjeru

  • Odgovori na neuspješnu prijavu ne otkrivaju postoji li e-pošta.
  • U PostgreSQL-u i zapisnicima pojavljuju se samo sažeci tokena, nikada tokeni nositelja.
  • Token za osvježavanje uspijeva jednom, a ponovna upotreba opoziva njegovu potpunu obitelj.
  • Odjava odmah poništava predočeni pristupni token.
  • Ažuriranja ograničenja brzine ostaju atomska između više PHP replika.
  • Ograničenja povezivanja, upita, zaključavanja, čitanja proxyja i tijela zahtjeva neovisno su ograničena.
  • Izmjene autentifikacije i njihove revizije uspjeha potvrđuju se zajedno.
  • TLS, pravila vatrozida, ubrizgavanje tajni, sigurnosne kopije, zadržavanje i upozoravanje definirani su prije javnog izlaganja.

Trajna je pouka da je autentifikacija životni ciklus, a ne provjera potpisa. Kratkotrajni pristup, stroga rotacija, ograničena zloupotreba i korisni dokazi pretvaraju neizbježne pogreške s vjerodajnicama u ograničene incidente. To ograničavanje stvarna je sigurnosna značajka.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.