Вернуться в блог

Создание безопасных 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.