Создание безопасных API с токенной аутентификацией и OpenAPI
Зачем мне понадобились API-токены
Я разрабатывал веб-приложение с панелью управления для работы с контентом. Все отлично работало через пользовательский интерфейс, но я задумался об автоматизации. Что, если я захочу использовать ChatGPT для управления контентом? Или автоматизировать повторяющиеся задачи с помощью скриптов?
Мне понадобился API с надлежащей аутентификацией. Что-то безопасное, простое в использовании и с хорошей документацией.
Вот что я создал и чему научился.
Требования
Прежде чем углубляться в код, я определил, что мне действительно нужно:
- Безопасная генерация токенов: Никаких угадываемых шаблонов, криптографически надежные.
- Однократный показ: Показывать токен один раз и никогда больше (как GitHub).
- Никогда не хранить в открытом виде: Хешировать все перед записью в базу данных.
- Поддержка отзыва: Позволять пользователям удалять токены, которые им больше не нужны.
- Отслеживание использования: Показывать, когда и как используются токены.
- Документация OpenAPI: Облегчить понимание API для AI-помощников.
- Интерактивное тестирование: Swagger UI для разработчиков, чтобы пробовать конечные точки.
Звучит как много, не так ли? Позвольте мне рассказать, как работает каждая часть.
Часть 1: Генерация безопасных токенов
Первая задача заключалась в создании токенов, которые были бы одновременно безопасными и удобными для пользователя. Я остановился на следующем формате:
imk_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6Позвольте мне объяснить каждую часть:
imk- Префикс моего продукта (сокращение от "Imerkar")live- Окружение (live или test)a1b2c3...- 32 случайных символа
Этот формат вдохновлен API-ключами Stripe, и на то есть веская причина:
- Сканирование по префиксу - Если кто-то случайно закоммитит токен в GitHub, инструменты смогут обнаружить его, сканируя префикс
imk_. - Разделение окружений - Вы можете с первого взгляда определить, тестовый это ключ или рабочий.
- Удобная идентификация для пользователя - Пользователи могут идентифицировать ключи по их префиксу, даже если полное значение токена скрыто.
Код генерации
Я использовал 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('');
}Когда пользователь создает токен:
- Генерируется случайный токен
- Он хешируется с помощью SHA-256
- Хеш сохраняется в базе данных
- Полный токен возвращается пользователю (только один раз!)
- Полный токен больше никогда не показывается
Часть 3: Промежуточное ПО для аутентификации
Теперь, когда мы можем генерировать и хранить токены, нам нужно фактически аутентифицировать запросы. Я создал промежуточное ПО, которое поддерживает несколько методов аутентификации:
- JWT-токены Clerk - Для пользователей, вошедших в панель управления.
- 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: Пользовательский интерфейс панели управления
Пользователям нужен способ создавать свои токены и управлять ими. Я встроил это на страницу настроек моей панели управления.
Пользовательский опыт
- Перейдите в Настройки → API-ключи
- Нажмите «Создать новый API-ключ»
- Введите имя (например, «Интеграция с Zapier»)
- Нажмите «Создать»
- Увидите полный токен с большим предупреждением:
⚠️ Создан API-ключ
imk_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
Сохраните этот ключ сейчас. Вы больше не сможете его увидеть!- Скопируйте в буфер обмена
- После закрытия окна вы увидите только префикс:
Интеграция с 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
Рабочий процесс прост:
- Откройте документацию по адресу
/api/docs - Нажмите «Авторизоваться»
- Вставьте свой API-ключ:
imk_live_... - Выберите конечную точку (например, GET /api/pages)
- Нажмите «Попробовать» (Try it out)
- Нажмите «Выполнить» (Execute)
- Увидите реальный ответ от вашего API
Это как Postman, но встроено в вашу документацию.
Часть 7: Интеграция с ChatGPT
Вот где все сходится. Имея спецификацию OpenAPI, вы можете создать Пользовательский GPT, который понимает ваш API.
Создание Пользовательского GPT
- Перейдите в ChatGPT → Создать GPT
- Добавить Действие (Action)
- Импорт из URL:
https://app.imerkar.com/api/docs/openapi.json - Установите аутентификацию в Bearer Token
- Добавьте свой API-ключ
- Протестируйте:
Вы: "Перечисли все мои страницы"
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-адресами.
Ресурсы
Если вы создаете что-то подобное, эти ресурсы мне помогли: