Градење безбедно PHP API: Ротација на токени, ограничување на стапката и ревизија
Безбеден API не се дефинира со паметен формат на токен. Се дефинира со она што се случува откако ќе протече токен, ќе се натпреваруваат две барања за освежување, клиент ќе преплави крајна точка или оператор ќе треба да реконструира инцидент шест недели подоцна.
Овој туторијал гради компактен PHP 8.3 API со нетранспарентни токени за пристап, ротација на токени за освежување за еднократна употреба, откривање повторна употреба, ограничување на стапката поддржано од PostgreSQL и трансакциски ревизорски настани. Примерот изложува крајни точки за најава, освежување, профил и одјава зад Nginx и PHP-FPM.
Предуслови и архитектура
Потребни ви се Docker Engine со поддршка за Compose и curl. Контејнерите користат PHP 8.3, Nginx и PostgreSQL. Не се потребни PHP менаџер за пакети ниту апликациска библиотека од трета страна.
API намерно користи нетранспарентни случајни токени наместо JWT. До PostgreSQL стигнуваат само SHA-256 дигести на токени, така што откривањето на базата на податоци не открива веднаш акредитиви за носител. Секое автентицирано барање извршува пребарување во база на податоци, што чини повеќе од локална JWT-потврда, но овозможува моментално поништување и едноставно оневозможување на сметки.
Токените за пристап важат 15 минути. Токените за освежување важат 30 дена и може да се користат точно еднаш. Секое успешно освежување создава заменски токен во истото семејство. Повторната употреба на постар токен го поништува целото семејство, вклучително и неговите токени за пристап.
Ограничувањата на стапката користат атомски PostgreSQL upsert-операции. Ова го одржува примерот доследен низ повеќе PHP реплики без воведување Redis, иако API со многу висок сообраќај обично би ги преместил бројачите во наменска услуга со ниска латентност. Алгоритмот со фиксни прозорци е намерно едноставен и може да дозволи скокови близу граница на прозорец.
Структура на проектот
secure-api/
├── compose.yaml
├── Dockerfile
├── nginx.conf
├── public/
│ └── index.php
├── src/
│ └── bootstrap.php
└── sql/
└── schema.sql
Создајте ја шемата на базата на податоци
Ревизорската табела е ориентирана кон додавање записи, наместо магично отпорна на неовластено менување. Во продукција, сопственикот на миграциите треба да ги задржи привилегиите над шемата, додека апликациската улога ги добива само операциите што ѝ се потребни. Извезувањето настани во посебно контролирано складиште обезбедува посилни докази отколку чувањето на единствената копија покрај податоците на апликацијата.
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);
Имплементирајте го безбедносното јадро
Создајте src/bootstrap.php. PostgreSQL добива експлицитни истекувања за изјави и заклучувања преку опциите за поврзување. Само истекувањето на поврзувањето не би ограничило бавно барање, па двете грижи се конфигурираат одделно.
<?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;
}
Заклучувањето на редот во rotate() ги сериски извршува истовремените употреби на истиот токен за освежување. Едно барање успева; следното го забележува used_at и го поништува семејството. Ова е намерно строго: клиентот мора сериски да ги извршува обидите за освежување и трајно да го замени својот зачуван токен за освежување.
Додајте ги HTTP рутите
Создајте public/index.php. Неуспесите при автентикација користат генерички одговор, така што повикувачите не можат да набројуваат сметки. Безбедносните промени и нивните успешни ревизорски записи делат трансакции.
<?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']);
}
Контејнеризирајте го API
На PHP-сликата ѝ е потребна само првостраната PDO PostgreSQL екстензија. Создајте го следниов 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"]
Создајте 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;
}
}
Создајте 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:
Создајте локална тајна без да презапишете постоечка датотека, стартувајте го стекот и обезбедете ја првата сметка. Потсетникот за лозинка избегнува поставување на лозинката на сметката директно во историјата на команди.
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
Тестирајте ротација, повторна употреба и поништување
Најавете се, повикајте ја заштитената рута, ротирајте го токенот за освежување, а потоа намерно повторно употребете го стариот токен за освежување:
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\"}"
Последното барање треба да врати 401. Бидејќи повторната употреба го поништува семејството, токенот за пристап во ROTATED потоа мора исто така да не успее. Повторените повици за најава на крајот треба да вратат 429 со Retry-After.
Продукциска безбедност и набљудливост
Завршете TLS на зацврстен обратен прокси или балансирач на оптоварување и јавно изложете ги само портите 80 и 443. Портата 8080 е намерно врзана за повратната мрежна адреса. Не изложувајте PostgreSQL преку заштитниот ѕид на домаќинот.
Ако друг прокси стои пред Nginx, конфигурирајте експлицитна листа на доверливи проксија пред да прифатите препратени адреси на клиенти. Слепото доверување на X-Forwarded-For им овозможува на повикувачите да ги заобиколат IP-ограничувањата и да фалсификуваат ревизорски податоци.
Клиентите во прелистувач треба да ги чуваат токените за освежување во безбедни, само-HTTP, cookies од иста локација и да додадат CSRF-заштити за освежување и одјава. Домородните и серверските клиенти треба да користат складиште за акредитиви на оперативниот систем. Токените за носител не припаѓаат во URL-адреси, дневници, аналитички настани или локалното складиште на прелистувачот.
Извезувајте структурирани апликациски дневници користејќи го ID на барањето вратено во X-Request-ID. Поставете предупредувања за повторни употреби при освежување, трајни неуспеси при најава, грешки од истекување на базата на податоци и невообичаени стапки на 429. Ревизорските IP-адреси се лични податоци во многу јурисдикции, затоа дефинирајте политики за задржување и пристап наместо да ги чувате неограничено.
Закажете ограничени серии за чистење на истечени токени и стари корпи за стапка. Големите неограничени бришења создаваат заклучувања, засилување на запишувањето и притисок врз репликацијата. Ревизорските табели со голем обем имаат корист од временско партиционирање и асинхрон извоз, додека промените на состојбата на автентикација треба да го задржат својот локален трансакциски ревизорски настан.
Вообичаени неуспеси
- Легитимните барања за освежување активираат откривање повторна употреба: клиентот освежува истовремено или се обидува повторно откако го изгубил успешниот одговор. Сериски извршувајте ги освежувањата и зачувајте ја замената пред да ги ослободите барањата што чекаат.
- Секое барање изгледа дека има една IP-адреса: синџирот на проксија не ја пренесува или безбедно не ја разрешува изворната адреса.
- Се акумулираат врски со базата на податоци: бројот на PHP-FPM работници го надминува капацитетот на PostgreSQL. Ограничете го пулот индиректно преку димензионирање на FPM или додајте прокси за трансакциско групирање.
- Барањата не успеваат по две секунди: проверете ги плановите за барања пред да го зголемите
statement_timeout. Недостасувачките индекси и спорењето за заклучувања обично се поважни од поголем буџет. - Промените на шемата не се извршуваат по уредување на SQL-датотеката: скриптите за иницијализација на PostgreSQL се извршуваат само за нов волумен со податоци. На продукциските распоредувања им се потребни експлицитни, верзионирани миграции.
Конечна контролна листа за проверка
- Одговорите за неуспешна најава не откриваат дали постои е-пошта.
- Во PostgreSQL и дневниците се појавуваат само хашови на токени, никогаш токени за носител.
- Токенот за освежување успева еднаш, а повторната употреба го поништува неговото целосно семејство.
- Одјавата веднаш го поништува прикажаниот токен за пристап.
- Ажурирањата на ограничувањето на стапката остануваат атомски низ повеќе PHP реплики.
- Ограничувањата за врска, барање, заклучување, читање преку прокси и тело на барање се независно ограничени.
- Промените на автентикацијата и нивните успешни ревизии се потврдуваат заедно.
- TLS, правилата на заштитниот ѕид, внесувањето тајни, резервните копии, задржувањето и предупредувањето се дефинирани пред јавно изложување.
Трајната поука е дека автентикацијата е животен циклус, а не проверка на потпис. Краткотрајниот пристап, строгата ротација, ограничената злоупотреба и корисните докази ги претвораат неизбежните грешки со акредитиви во ограничени инциденти. Тоа ограничување е вистинската безбедносна одлика.