Danyloff

🔑 Аутентификация запросов API


  • ·

· ·

На сегодняшний день существует множество форматов API, протоколов обмена информацией, и у всего этого есть одна общая составляющая компонента — безопасность. Безопасность состоит из аутентификации, авторизации, шифрования и т.д. В этой статье я расскажу про метод аутентификации запросов через цифровую подпись(Digital Signature).

Что это такое и зачем это нужно?

Аутентификация представляет собой процесс проверки подлинности пользователей или систем, обеспечивая доверенность и безопасность в рамках сервиса.

Источник

Наиболее важный аспект который нас интересует — определение источника запроса. Речь о том что мы должны определить пользователя данного запроса, что приводит нас к концепции подлинности. То есть если наш сервис получает запрос от пользователя с идентификатором 100, то запрос должен иметь некие подтверждение личности. Реализовать это можно разными способами, например через API ключи(API Keys), токены доступа(Access Tokens), HTTP Basic, Цифровая подпись(Digital Signature) и так далее.

Целостность

Целостность представляет собой важный аспект безопасности, направленный на обеспечение неприкосновенности данных в процессе их передачи между клиентом и сервером. Меры целостности обеспечиваются с использованием различных механизмов, таких как хэширование, цифровые подписи и методы проверки целостности тела запроса. Хэширование позволяет создать уникальный «отпечаток» данных, который передается вместе с запросом, а сервер, в свою очередь, проверяет, не были ли данные изменены в пути. Цифровые подписи предоставляют еще более надежный метод, где отправитель подписывает запрос своим секретным ключом, и сервер может проверить подпись с использованием открытого ключа отправителя. Обеспечивая целостность запросов, сервис устойчиво защищает передаваемую информацию от возможных изменений, манипуляций или повреждений в процессе обмена данными.

Реализация

Реализация состоит из множество этапов с участием пользователя и сервиса API.

Генерация учетный данных

Первым этапом для пользователя является генерация пары ключей и создание аккаунта в сервисе. Ключи состоят из открытого и закрытого ключа, см. Криптосистема с открытым ключом. Открытый ключ передается сервису при создании аккаунта, а закрытый ключ должен храниться у пользователя.

Внимание! Крайне важно хранить закрытый ключ в совершенном секрете так как этот ключ является доказательством вашей личности.

Выбор стороны клиента для генерации ключей не случаен, это оправдано по нескольким причинам:

  • Закрытый ключ не передается по сети.
  • Пользователь не сможет отрицать запросы на сервис, так как аутентификацию сможет пройти только запрос с закрытым ключем.
PHP
/**
 * @param int $keySize Размер закрытого ключа
 * @return array{
 *     privateKey: string,
 *     publicKey: string
 * }
 */
function generateKeyPair(int $keySize = 2048): array {
    $keyPair = openssl_pkey_new([
        'private_key_bits' => $keySize,
        'private_key_type' => OPENSSL_KEYTYPE_RSA
    ]);
    $privateKey = null;
    openssl_pkey_export($keyPair, $privateKey);
    $details = openssl_pkey_get_details($keyPair);
    $publicKey = $details['key'];

    return compact('privateKey', 'publicKey');
}

Функция generateKeyPair генерирует пару-ключей типа RSA, и возвращает массив с этими ключами.

Регистрация пользователя

Регистрация производится через методы API с передачей публичного ключа. Закрытый ключ остается на стороне клиента.

POST /users 
{
    "publicKey": "{Сгенерированый открытый ключ}"
    ...
}

Генерация и верификация цифровых подписей

Цифровая подпись представляет собой особое число которые можно вычислить с помощью закрытого ключа. Открытый ключ позволяет проверить правильность цифровой подписи но, сгенерировать подпись открытым ключем не получится.

Чтобы вычислить цифровую подпись мы используем тело запроса с закрытым ключем.

PHP
/**
 * @param string $data Строка для которой формируется цифровая подпись
 * @param string $privateKey Закрытый ключ который мы сгенерировали
 * @return string Цифровая подпись
 */
function generateSignature(string $data, string $privateKey): string {
    $signature = null;
    openssl_sign(
        $data,
        $signature,
        $privateKey,
        OPENSSL_ALGO_SHA256
    );

    return $signature;
}

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

PHP
/**
 * @param string $data // Данные для проверки корректности цифровой подписи
 * @param string $signature // Цифровая подпись
 * @param string $publicKey // Открытый ключ
 * @return bool
 */
function verifySignature(string $data, string $signature, string $publicKey): bool {
    $result = openssl_verify(
        $data,
        $signature,
        $publicKey,
        OPENSSL_ALGO_SHA256
    );

    return $result === 1;
}

Цифровая подпись для запроса

Процесс определения контента для вычисления цифровой подписи не так прост как кажется на первый взгляд. Например для POST запросов можно взять тело запроса, а что делать для запросов GET?! По этому следует разработать шаблон который будет включать данные запроса в контент, и так же этот шаблон передать обрабатывающему серверу чтобы был единный формат генерации отпечатка для подписи.

Данные для подписи можно взять из заголовков HTTP.

ПолеОписаниеПример
MethodМетод запросаGET
PathПуть запроса/posts/1
HostХостdanyloff.tech
ContentТело запроса{«a»: 1, «b»: 2}
DateВремя создания запросаSun, 04 Feb 2024 18:28:06 GMT

С телом запроса тоже не все так просто, ведь

{"a": 1, "b": 2} ИЛИ {"b": 2, "a": 1}

семантически равны, но байтовое представление у них разные. По этой причине сервер может неправильно верифицировать подпись, так как зависит от реализации парсера JSON.

Чтобы решить данную проблему, необходимо на стороне клиента вычислять хеш тела запроса и добавить его в заголовок.

PHP
function createDigestHeader(string $body): string {
    $bodyHash = hash('sha256', $body, true);

    return 'SHA-256=' . base64_encode($bodyHash);
}

Теперь соберем все вместе

PHP
function createFingerprintForRequest(RequestInterface $request, array $headers = ['request-target', 'host', 'date', 'digest']): string {
    return implode(
        "\n",
        array_map(function (string $header) use ($request) {
            return match ($header) {
                'request-target' => "$header: {$request->getMethod()} {$request->getRequestTarget()}",
                'digest' => "$header: {$request->getHeaderLine('Digest')}",
                default => "$header: {$request->getHeaderLine($header)}"
            };
        }, $headers)
    );
}

Пример отпечатка для подписания запроса

request-target: POST /api/users/1
host: danyloff.tech
date: Sun, 04 Feb 2024 23:29:05 GMT
digest: QyWM/3g/5wNtikMDP4MK38YOwDc4JHNUisdCuIgpJ3c=

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

Добавление подписи

Для того чтобы сервер смог верифицировать подпись, он должен иметь возможность вычислить такой же отпечаток. Клиент должен передать метаданные серверу, каким образом сформирован отпечаток, а именно:

  • Список заголовков какие были включены в отпечаток с сохранением порядка.
  • Идентификатор публичного ключа либо каким либо инным образом сообщить серверу о том какой открытый ключ использовать.
  • Цифровая подпись.
  • Алгоритм, который использовался для генерации цифровой подписи.

Этой информации будет достаточно чтобы сервер смог правильно верифицировать подпись.

PHP
function createSignatureMetadataHeader(
    string $fingerprint,
    int $userId,
    string $privateKey,
    array $headers = ['request-target', 'host', 'date', 'digest']
): string {
    $data = [
        'keyId' => $userId,
        'algorithm' => 'rsa-sha256',
        'headers' => implode(' ', $headers),
        'signature' => base64_encode(generateSignature($fingerprint, $privateKey))
    ];

    $variables = [];
    foreach ($data as $key => $value) {
        $variables[] = "$key=\"$value\"";
    }

    return implode(',', $variables);
}

Функция createSignatureMetadataHeader возвращает метаданные которых достаточно для правильной верификации подписи.

Пример значения для заголовка Signature

keyId="999",algorithm="rsa-sha256",headers="request-target host date digest",signature="2C59eQzdK5QuD..."

После того как у нас все составные части для подписания запроса, приведу пример кода который соберет все вместе

PHP
function signRequest(
    RequestInterface $request,
    int $userId,
    string $privateKey
): RequestInterface {
    $request = $request->withHeader('Digest', createDigestHeader($request->getBody()->getContents()));
    $fingerprint = createFingerprintForRequest($request);
    return $request->withHeader('Signature', createSignatureMetadataHeader(
        $fingerprint,
        $userId,
        $privateKey
    ));
}

Функция signRequest добавляет в запрос заголовки Digest, Signature в формате понятном для сервера. Запрос будет выглядеть примерно так:

POST /api/users/1
Host: danyloff.tech
Digest: SHA-256=QyWM/3g/5wNtikMDP4MK38YOwDc4JHNUisdCuIgpJ3c=
Signature: keyId="999",algorithm="rsa-sha256",headers="request-target       host date digest",signature="2C59eQzdK5QuD..."
Date: Sun, 04 Feb 2024 18:28:06 GMT

Аутентификация запроса на стороне сервера

Для того чтобы аутентифицировать запрос на стороне сервера, мы должны собрать информацию из запроса и провести несколько проверок, например проверить целостность передаваемых данных через заголовок Digest, сгенерировать отпечаток, достать из хранилища открытый ключ который клиент ранее передал на сервер и верифицировать сигнатуру по отпечатку.

PHP
function parseSignatureHeader(RequestInterface $request): array {
    $headerLine = $request->getHeaderLine('Signature');
    $rawVariables = explode(',', $headerLine);
    $variables = [];

    foreach ($rawVariables as $variable) {
        list($key, $value) = explode('=', $variable);
        $variables[$key] = trim($value, '" ');
    }

    return $variables;
}

function verifyRequest(RequestInterface $request): bool {
    if ($request->getHeaderLine('Digest') !== createDigestHeader($request->getBody()->getContents())) {
        return false;
    }

    $signatureMetadata = parseSignatureHeader($request);
    $fingerprint = createFingerprintForRequest($request, explode(' ', $signatureMetadata['headers']));
    $publicKey = '{PUBLIC KEY}'; // Получаем открытый ключ который хранится в хранилище на сервере по данным из метаданных сигнатуры
    return verifySignature($fingerprint, base64_decode($signatureMetadata['signature']), $publicKey);
}

Функция parseSignatureHeader парсит значение залоговка Signature и возвращает массив ключ/значение с метаданными о цифровой подписи.

Функция verifyRequest проверяет целостность данных с помощью генерацию хеша тела сообщения и сравнение с заголовком Digest, парсит метаданные цифровой подписи, генерирует отпечаток на основе правил метаданных цифровой подписи, и верифицирует цифровую подпись с сгенерированным отпечаток и открытым ключем на сервере.

Заключение

В свете постоянно растущих вызовов в области кибербезопасности, применение аутентификации через цифровую подпись становится критически важным элементом обеспечения надежности и целостности веб-приложений и API но, поискольку существует множество различных методов аутентификации то при выборе возникают компромиссы. Цифровая подпись является самым безопасным вариантом аутентификации, но она подойдет не для всех типов приложений и для большинства типов приложений окажется чрезмерной.


Так же интересно

LRO ⏰ или длительные операции API

Бывают такие ситуации когда выполнения некоторого метода занимает длительное время и/или использует большое количество вычислительных ресурсов. И требуется...