Назад к блогу

Создание безопасных API с токенной аутентификацией и OpenAPI

2025-11-2312 min read

Зачем мне понадобились API-токены

Я разрабатывал веб-приложение с панелью управления для работы с контентом. Все отлично работало через пользовательский интерфейс, но я задумался об автоматизации. Что, если я захочу использовать ChatGPT для управления контентом? Или автоматизировать повторяющиеся задачи с помощью скриптов?

Мне понадобился API с надлежащей аутентификацией. Что-то безопасное, простое в использовании и с хорошей документацией.

Вот что я создал и чему научился.

Требования

Прежде чем углубляться в код, я определил, что мне действительно нужно:

  • Безопасная генерация токенов: Никаких угадываемых шаблонов, криптографически надежные.
  • Однократный показ: Показывать токен один раз и никогда больше (как GitHub).
  • Никогда не хранить в открытом виде: Хешировать все перед записью в базу данных.
  • Поддержка отзыва: Позволять пользователям удалять токены, которые им больше не нужны.
  • Отслеживание использования: Показывать, когда и как используются токены.
  • Документация OpenAPI: Облегчить понимание API для AI-помощников.
  • Интерактивное тестирование: Swagger UI для разработчиков, чтобы пробовать конечные точки.

Звучит как много, не так ли? Позвольте мне рассказать, как работает каждая часть.

Часть 1: Генерация безопасных токенов

Первая задача заключалась в создании токенов, которые были бы одновременно безопасными и удобными для пользователя. Я остановился на следующем формате:

imk_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6

Позвольте мне объяснить каждую часть:

  • imk - Префикс моего продукта (сокращение от "Imerkar")
  • live - Окружение (live или test)
  • a1b2c3... - 32 случайных символа

Этот формат вдохновлен API-ключами Stripe, и на то есть веская причина:

  1. Сканирование по префиксу - Если кто-то случайно закоммитит токен в GitHub, инструменты смогут обнаружить его, сканируя префикс imk_.
  2. Разделение окружений - Вы можете с первого взгляда определить, тестовый это ключ или рабочий.
  3. Удобная идентификация для пользователя - Пользователи могут идентифицировать ключи по их префиксу, даже если полное значение токена скрыто.

Код генерации

Я использовал Web Crypto API для безопасной генерации случайных значений:

async function generateApiKey(): Promise<string> {
// Генерируем 24 байта (192 бита) случайных данных
const randomBytes = new Uint8Array(24);
crypto.getRandomValues(randomBytes);

// Преобразуем в кодировку base64url (безопасную для URL, без дополнения)
const randomString = btoa(String.fromCharCode(...randomBytes))
.replace(/\+/g, '-')
.replace(/\/g, '_')
.replace(/=/g, '');

return `imk_live_${randomString}`;
}

Это генерирует криптографически безопасные случайные токены. Функция crypto.getRandomValues() использует источник энтропии операционной системы, делая токены непредсказуемыми. Я использую кодировку base64url, чтобы гарантировать, что токен безопасен для URL и сохраняет полную энтропию.

Часть 2: Безопасное хранение токенов

Золотое правило: никогда не храните API-токены в открытом виде.

Подумайте об этом как о паролях. Если ваша база данных будет скомпрометирована, вы не захотите, чтобы злоумышленники получили рабочие API-ключи. Поэтому, как и с паролями, мы хешируем токены перед сохранением.

Схема базы данных

CREATE TABLE api_keys (
id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
name TEXT NOT NULL,
key_prefix TEXT NOT NULL, -- Префикс для отображения (imk_live_xxx)
key_hash TEXT NOT NULL, -- Хеш SHA-256
scopes TEXT NOT NULL, -- Массив разрешений в формате JSON
last_used_at TEXT, -- Временная метка в формате ISO 8601
last_used_ip TEXT,
expires_at TEXT, -- Временная метка в формате ISO 8601
revoked INTEGER DEFAULT 0,
created_at TEXT NOT NULL -- Временная метка в формате ISO 8601
);

-- Индекс для быстрого поиска при аутентификации
CREATE INDEX idx_api_keys_hash ON api_keys(key_hash);

-- Индекс для отображения ключей клиента
CREATE INDEX idx_api_keys_customer ON api_keys(customer_id);

Обратите внимание, что я храню key_prefix (читаемую часть, например imk_live_abc) отдельно. Это позволяет мне показывать пользователям что-то вроде:

imk_live_a1b2c3d4... (Создан 15 янв. 2025 г.)

Они могут определить, какой токен какой, не видя полного значения.

Хеширование токенов

async function hashApiKey(apiKey: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(apiKey);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}

Когда пользователь создает токен:

  1. Генерируется случайный токен
  2. Он хешируется с помощью SHA-256
  3. Хеш сохраняется в базе данных
  4. Полный токен возвращается пользователю (только один раз!)
  5. Полный токен больше никогда не показывается

Часть 3: Промежуточное ПО для аутентификации

Теперь, когда мы можем генерировать и хранить токены, нам нужно фактически аутентифицировать запросы. Я создал промежуточное ПО, которое поддерживает несколько методов аутентификации:

  1. JWT-токены Clerk - Для пользователей, вошедших в панель управления.
  2. API-токены - Для внешних интеграций.

Поток аутентификации

async function authenticate(c: Context) {
const authHeader = c.req.header('Authorization');

if (!authHeader?.startsWith('Bearer ')) {
return c.json({ error: 'Отсутствует авторизация' }, 401);
}

const token = authHeader.substring(7);

// Попытка аутентификации по API-ключу
if (token.startsWith('imk_')) {
const customerId = await verifyApiKey(token);
if (customerId) {
c.set('customerId', customerId);
return;
}
}

// Попытка аутентификации по JWT
const customerId = await verifyJWT(token);
if (customerId) {
c.set('customerId', customerId);
return;
}

return c.json({ error: 'Неверный токен' }, 401);
}

Проверка API-токенов

Когда приходит API-ключ, происходит следующее:

async function verifyApiKey(apiKey: string): Promise<string | null> {
// Хешируем предоставленный ключ
const keyHash = await hashApiKey(apiKey);

// Ищем хеш в базе данных
const key = await db.query(
'SELECT customer_id, revoked, expires_at FROM api_keys WHERE key_hash = ?',
[keyHash]
);

if (!key) return null;
if (key.revoked) return null;
if (key.expires_at && new Date(key.expires_at) < new Date()) return null;

// Обновляем временную метку последнего использования
await db.execute(
'UPDATE api_keys SET last_used_at = ?, last_used_ip = ? WHERE key_hash = ?',
[new Date().toISOString(), c.req.header('cf-connecting-ip'), keyHash]
);

return key.customer_id;
}

Это быстро, потому что мы индексируем столбец key_hash. Поиск в базе данных имеет сложность O(1) благодаря индексу.

Часть 4: Пользовательский интерфейс панели управления

Пользователям нужен способ создавать свои токены и управлять ими. Я встроил это на страницу настроек моей панели управления.

Пользовательский опыт

  1. Перейдите в Настройки → API-ключи
  2. Нажмите «Создать новый API-ключ»
  3. Введите имя (например, «Интеграция с Zapier»)
  4. Нажмите «Создать»
  5. Увидите полный токен с большим предупреждением:
⚠️ Создан API-ключ

imk_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6

Сохраните этот ключ сейчас. Вы больше не сможете его увидеть!
  1. Скопируйте в буфер обмена
  2. После закрытия окна вы увидите только префикс:
Интеграция с Zapier
imk_live_a1b2c3d4...
Создан: 15 янв. 2025 г.
Последнее использование: 16 янв. 2025 г.
[Отозвать]

Компонент React

Вот упрощенная версия процесса создания ключа:

function ApiKeysTab() {
const [keys, setKeys] = useState([]);
const [showCreateModal, setShowCreateModal] = useState(false);
const [newKey, setNewKey] = useState(null);

async function createKey(name: string) {
const response = await fetch('/api/api-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});

const data = await response.json();
setNewKey(data.key); // Полный ключ, показывается только один раз
setShowCreateModal(false);
}

async function revokeKey(keyId: string) {
await fetch(`/api/api-keys/${keyId}`, { method: 'DELETE' });
loadKeys(); // Обновить список
}

return (
<>
{newKey && (
<Alert>
<AlertTitle>API-ключ создан</AlertTitle>
<AlertDescription>
<code>{newKey}</code>
<p>Сохраните его сейчас. Вы больше его не увидите!</p>
</AlertDescription>
</Alert>
)}

{keys.map(key => (
<KeyRow
key={key.id}
name={key.name}
prefix={key.key_prefix}
lastUsed={key.last_used_at}
onRevoke={() => revokeKey(key.id)}
/>
))}
</>
);
}

Часть 5: Документация OpenAPI

Вот где становится по-настоящему интересно. Как только у вас есть API, вам нужна документация. Но не просто какая-то документация. Машиночитаемая документация, которую могут понять AI-помощники.

Именно для этого и существует OpenAPI (ранее Swagger).

Что такое OpenAPI?

OpenAPI — это стандартный способ описания REST API. Это файл в формате JSON/YAML, который сообщает вам:

  • Какие конечные точки существуют
  • Какие параметры они принимают
  • Что они возвращают
  • Как аутентифицироваться
  • Какие ошибки могут возникнуть

Прелесть в том, что ChatGPT, Claude, Zapier и сотни других инструментов могут читать спецификации OpenAPI и автоматически понимать ваш API.

Создание документа OpenAPI

Я создал спецификацию OpenAPI 3.0 для моего API:

const openAPIDocument = {
openapi: '3.0.0',
info: {
title: 'Imerkar API',
version: '1.0.0',
description: 'Программное создание и управление веб-сайтами',
},
servers: [
{
url: 'https://app.imerkar.com',
description: 'Production server',
},
],
components: {
securitySchemes: {
BearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT or API Key',
description: 'Используйте ваш API-ключ с аутентификацией Bearer',
},
},
},
security: [{ BearerAuth: [] }],
paths: {
'/api/pages': {
get: {
summary: 'Список всех страниц',
tags: ['Pages'],
responses: {
'200': {
description: 'Список страниц',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
pages: {
type: 'array',
items: { $ref: '#/components/schemas/Page' }
}
}
}
}
}
}
}
},
post: {
summary: 'Создать новую страницу',
tags: ['Pages'],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['title'],
properties: {
title: { type: 'string' },
description: { type: 'string' },
}
}
}
}
},
responses: {
'201': {
description: 'Страница создана',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Page' }
}
}
}
}
}
}
}
};

Это может выглядеть многословно, но это невероятно мощно. С этим одним документом любой инструмент может понять весь ваш API.

Предоставление спецификации OpenAPI

Я создал две конечные точки:

// Предоставление JSON-спецификации
app.get('/api/docs/openapi.json', (c) => {
return c.json(openAPIDocument);
});

// Предоставление YAML-спецификации (некоторые инструменты предпочитают YAML)
app.get('/api/docs/openapi.yaml', (c) => {
const yaml = convertToYAML(openAPIDocument);
return c.text(yaml, 200, {
'Content-Type': 'application/x-yaml'
});
});

Часть 6: Swagger UI для интерактивного тестирования

Спецификации OpenAPI хороши, но разработчики хотят тестировать ваш API, а не просто читать о нем. Вот тут и пригодится Swagger UI.

Swagger UI — это веб-интерфейс, который:

  • Считывает вашу спецификацию OpenAPI
  • Генерирует интерактивную документацию
  • Позволяет разработчикам тестировать конечные точки прямо в браузере
  • Показывает примеры запросов/ответов

Настройка Swagger UI

Я создал простую HTML-страницу, которая загружает Swagger UI из CDN:

app.get('/api/docs', (c) => {
const html = `
<!DOCTYPE html>
<html>
<head>
<title>Документация API Imerkar</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.10.3/swagger-ui.css">
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.10.3/swagger-ui-bundle.js"></script>
<script>
window.onload = function() {
SwaggerUIBundle({
url: '/api/docs/openapi.json',
dom_id: '#swagger-ui',
persistAuthorization: true,
tryItOutEnabled: true,
});
};
</script>
</body>
</html>
`;

return c.html(html);
});

Теперь, когда разработчики заходят на https://app.imerkar.com/api/docs, они видят красивый интерактивный обозреватель API.

Использование Swagger UI

Рабочий процесс прост:

  1. Откройте документацию по адресу /api/docs
  2. Нажмите «Авторизоваться»
  3. Вставьте свой API-ключ: imk_live_...
  4. Выберите конечную точку (например, GET /api/pages)
  5. Нажмите «Попробовать» (Try it out)
  6. Нажмите «Выполнить» (Execute)
  7. Увидите реальный ответ от вашего API

Это как Postman, но встроено в вашу документацию.

Часть 7: Интеграция с ChatGPT

Вот где все сходится. Имея спецификацию OpenAPI, вы можете создать Пользовательский GPT, который понимает ваш API.

Создание Пользовательского GPT

  1. Перейдите в ChatGPT → Создать GPT
  2. Добавить Действие (Action)
  3. Импорт из URL: https://app.imerkar.com/api/docs/openapi.json
  4. Установите аутентификацию в Bearer Token
  5. Добавьте свой API-ключ
  6. Протестируйте:
Вы: "Перечисли все мои страницы"

ChatGPT: Дайте мне проверить ваши страницы...
[Выполняет запрос GET /api/pages]

ChatGPT: "У вас 5 страниц:
1. Главная
2. О нас
3. Услуги
4. Контакты
5. Блог"

Вы: "Создать новую страницу под названием 'Цены' с описанием наших тарифов"

ChatGPT: [Выполняет запрос POST /api/pages]

ChatGPT: "Готово! Я создал новую страницу под названием 'Цены' с вашим описанием."

Это просто работает. ChatGPT считывает вашу спецификацию OpenAPI и знает, как использовать ваш API.

Часть 8: Отслеживание использования и ограничение скорости

Как только ваши API-токены попадут в мир, вам нужно отслеживать, как они используются, и предотвращать злоупотребления.

Журналирование использования

Я создал простую таблицу журналов использования:

CREATE TABLE api_key_usage_logs (
id TEXT PRIMARY KEY,
api_key_id TEXT NOT NULL,
endpoint TEXT NOT NULL,
method TEXT NOT NULL,
status_code INTEGER NOT NULL,
ip_address TEXT,
created_at TEXT NOT NULL -- Временная метка в формате ISO 8601
);

-- Индекс для запросов ограничения скорости
CREATE INDEX idx_usage_logs_key_time ON api_key_usage_logs(api_key_id, created_at);

Каждый API-запрос регистрируется:

async function logApiUsage(keyId: string, req: Request, status: number) {
await db.execute(
`INSERT INTO api_key_usage_logs (id, api_key_id, endpoint, method, status_code, ip_address, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
generateId(),
keyId,
new URL(req.url).pathname,
req.method,
status,
req.headers.get('cf-connecting-ip'),
new Date().toISOString()
]
);
}

Ограничение скорости (Rate Limiting)

Я реализовал простое ограничение скорости:

  • 30 запросов в минуту на API-ключ
  • 1000 запросов в день
async function checkRateLimit(keyId: string): Promise<boolean> {
const oneMinuteAgo = new Date(Date.now() - 60000).toISOString();

const count = await db.query(
'SELECT COUNT(*) as count FROM api_key_usage_logs WHERE api_key_id = ? AND created_at > ?',
[keyId, oneMinuteAgo]
);

return count.count < 30;
}

Если лимит превышен, возвращается полезная ошибка:

{
"error": "Превышен лимит запросов",
"code": "RATE_LIMIT_EXCEEDED",
"details": {
"limit": 30,
"window": "1 минута",
"retryAfter": 42
}
}

Чему я научился

Создание этой функции многому меня научило:

1. Безопасность — это не опция

Никогда не храните токены в открытом виде. Хешируйте все. Используйте crypto.subtle для случайности. Подумайте, что произойдет, если ваша база данных утечет.

2. UX важен даже для инструментов разработчика

API-токены тоже нуждаются в хорошем UX. Показывайте их один раз с большим предупреждением. Позвольте пользователям называть свои токены. Показывайте, когда они использовались в последний раз. Сделайте отзыв токена простым.

3. OpenAPI — это суперсила

Написание спецификации OpenAPI поначалу кажется рутиной, но это невероятно ценно. Это обеспечивает интеграцию с ChatGPT, Swagger UI, генерацию клиентских SDK и автоматизацию тестирования.

4. Начинайте с простого, итерируйте

Я не строил все это сразу. Версия 1 включала только генерацию токенов и аутентификацию. Затем я добавил пользовательский интерфейс панели управления. Затем документацию OpenAPI. Затем отслеживание использования. Каждая итерация делала систему лучше.

5. Индексы базы данных имеют решающее значение

Индексирование столбца key_hash делает аутентификацию быстрой. Без него каждый запрос сканировал бы всю таблицу. С индексом поиск мгновенен.

Технологический стек

Для справки, вот что я использовал:

  • Backend: Cloudflare Workers + фреймворк Hono
  • Database: Cloudflare D1 (SQLite)
  • Frontend: React + Tailwind CSS
  • Auth (сессии): Clerk
  • OpenAPI: Спецификация, написанная вручную (можно использовать @hono/zod-openapi для типобезопасности)
  • Язык: TypeScript

Следующие шаги

Я все еще хочу добавить:

  • Разрешения на основе областей (Scope-based permissions) - Сейчас у токенов полный доступ. Я хочу добавить области, такие как pages:read, pages:write, media:read и т.д.
  • Поддержка Webhook - Позволить пользователям подписываться на события (page.created, page.published и т.д.).
  • Генерация SDK - Использовать спецификацию OpenAPI для автоматической генерации клиентских библиотек на Python, JavaScript, PHP и т.д.
  • Улучшенная аналитика - Более подробная статистика использования с графиками и тенденциями.
  • Белый список IP-адресов - Позволить пользователям ограничивать токены определенными IP-адресами.

Ресурсы

Если вы создаете что-то подобное, эти ресурсы мне помогли:

Оставайтесь в курсе

Получайте последние статьи и идеи в свой почтовый ящик.

Unsubscribe anytime. No spam, ever.