Работа с сетью в Qt

Безопасность gRPC: TLS и аутентификация токенами

Введение

Все предыдущие примеры цикла использовали InsecureChannelCredentials()/InsecureServerCredentials() — полностью незащищённое соединение, явно отмеченное как неприемлемое для реального использования. В статье разберём настройку TLS-шифрования транспорта и аутентификации через токены, передаваемые в метаданных запроса (статья 328), аналогично концепциям из десктопного и MQTT циклов про безопасность (статьи 215, 318).

Концепция

TLS для gRPC настраивается через SslCredentials (клиент) и SslServerCredentials (сервер), которым передаются сертификаты — аналогично HTTPS, TLS защищает сами передаваемые данные от перехвата и подмены на сетевом уровне. Аутентификация поверх уже зашифрованного TLS-соединения обычно реализуется через CallCredentials — объект, автоматически добавляющий аутентификационный токен (например, JWT, аналогично статье про JWT-токены из основного цикла) в метаданные каждого исходящего запроса, без необходимости вручную добавлять его в каждом отдельном вызове, как это требовалось бы для метаданных из статьи 328.

Пример кода: Безопасность gRPC

// server/telemetry_server.cpp — настройка TLS на сервере
#include <grpcpp/grpcpp.h>
#include <fstream>
#include <sstream>

std::string readFile(const std::string &path)
{
    std::ifstream file(path);
    std::stringstream buffer;
    buffer << file.rdbuf();
    return buffer.str();
}

void runSecureServer()
{
    grpc::SslServerCredentialsOptions sslOptions;
    sslOptions.pem_root_certs = readFile("certs/ca.crt");

    grpc::SslServerCredentialsOptions::PemKeyCertPair keyCertPair;
    keyCertPair.private_key = readFile("certs/server.key");
    keyCertPair.cert_chain = readFile("certs/server.crt");
    sslOptions.pem_key_cert_pairs.push_back(keyCertPair);

    auto credentials = grpc::SslServerCredentials(sslOptions);

    TelemetryServiceImpl service;
    grpc::ServerBuilder builder;
    builder.AddListeningPort("0.0.0.0:50051", credentials);
    builder.RegisterService(&service);

    std::unique_ptr<grpc::Server> server(builder.BuildAndStart());
    server->Wait();
}
// client/telemetryclient.h — настройка TLS-канала и автоматическая вставка токена аутентификации
#pragma once
#include <grpcpp/grpcpp.h>
#include <grpcpp/security/credentials.h>

// CallCredentials — объект, вызываемый библиотекой gRPC перед КАЖДЫМ запросом
// для получения актуальных метаданных аутентификации
class TokenAuthMetadata : public grpc::MetadataCredentialsPlugin
{
public:
    explicit TokenAuthMetadata(const std::string &token) : m_token(token) {}

    grpc::Status GetMetadata(grpc::string_ref serviceUrl, grpc::string_ref methodName,
                              const grpc::AuthContext &channelAuthContext,
                              std::multimap<grpc::string, grpc::string> *metadata) override
    {
        metadata->insert(std::make_pair("authorization", "Bearer " + m_token));
        return grpc::Status::OK;
    }

private:
    std::string m_token;
};

std::shared_ptr<grpc::Channel> createSecureChannel(const std::string &serverAddress,
                                                     const std::string &caCert,
                                                     const std::string &authToken)
{
    grpc::SslCredentialsOptions sslOptions;
    sslOptions.pem_root_certs = caCert;
    auto channelCredentials = grpc::SslCredentials(sslOptions);

    auto callCredentials = grpc::MetadataCredentialsFromPlugin(
        std::make_unique<TokenAuthMetadata>(authToken));

    // Объединение TLS (channel credentials) и аутентификации токеном (call credentials) —
    // оба механизма работают независимо и применяются совместно к каждому запросу
    auto combinedCredentials = grpc::CompositeChannelCredentials(channelCredentials, callCredentials);

    return grpc::CreateChannel(serverAddress, combinedCredentials);
}
// server/telemetry_server.cpp — проверка токена на стороне сервера (через интерсептор или вручную)
grpc::Status GetLatestReading(grpc::ServerContext *context,
                                const telemetry::ReadingRequest *request,
                                telemetry::SensorReading *response) override
{
    const auto &metadata = context->client_metadata();
    auto authHeader = metadata.find("authorization");

    if (authHeader == metadata.end()) {
        return grpc::Status(grpc::StatusCode::UNAUTHENTICATED, "Токен авторизации отсутствует");
    }

    std::string token(authHeader->second.data(), authHeader->second.size());
    if (token.substr(0, 7) != "Bearer " || !isValidToken(token.substr(7))) {
        return grpc::Status(grpc::StatusCode::UNAUTHENTICATED, "Недействительный токен");
    }

    // ... остальная логика метода после успешной проверки токена ...
    return grpc::Status::OK;
}

Пояснения к коду

grpc::SslServerCredentials/grpc::SslCredentials настраиваются через структуры опций, содержащие содержимое сертификатов (а не пути к файлам напрямую, что требует явного чтения файлов через readFile()) — это даёт TLS-защиту транспортного уровня, аналогичную HTTPS, независимо от дальнейшей аутентификации конкретного пользователя/устройства. TokenAuthMetadata, наследующий grpc::MetadataCredentialsPlugin, реализует автоматическую вставку токена в заголовок authorization каждого исходящего запроса через переопределение GetMetadata() — этот механизм вызывается библиотекой gRPC автоматически перед каждым запросом, избавляя от необходимости вручную добавлять метаданные аутентификации в каждом отдельном месте кода, где выполняется вызов, в отличие от примера из статьи 328 с ручным context.AddMetadata().

CompositeChannelCredentials() объединяет два независимых механизма — channelCredentials (TLS-шифрование транспорта) и callCredentials (аутентификация конкретного вызывающего) — это разделение ответственности аналогично двухуровневой защите MQTT из статьи 318 (TLS отдельно от аутентификации по логину/паролю), и оба механизма работают совместно, не заменяя друг друга.

Подводные камни

  • Использование одного из двух механизмов защиты без другого — TLS без аутентификации защищает данные от перехвата по дороге, но не ограничивает, кто может подключиться к серверу вообще; аутентификация без TLS передаёт сам токен в открытом виде, что делает аутентификацию бессмысленной (токен может быть перехвачен и использован злоумышленником) — аналогично выводу из статьи 318, для реальной защиты нужны оба механизма одновременно, а не выбор только одного из них.
  • Хранение токена аутентификации непосредственно в коде клиентского приложения (аналогично проблеме хранения паролей из статей 215, 310, 318) — для production-приложений токен должен быть получен через отдельный, защищённый процесс аутентификации пользователя (логин/пароль, OAuth) и обычно имеет ограниченный срок действия, требующий механизма обновления (refresh), а не быть статично зашитым в код приложения на неопределённо долгий срок.
  • Самоподписанные сертификаты без правильной настройки доверия на стороне клиента (аналогично подводному камню из статьи 318 про MQTT с TLS) — клиент должен явно указать корневой сертификат (pem_root_certs), которому доверяет, через тот же файл ca.crt, что использовался при генерации серверного сертификата, иначе TLS-handshake завершится ошибкой проверки сертификата.
  • Реализация проверки токена внутри каждого отдельного метода сервиса вручную (как в показанном упрощённом примере) — для сервиса с большим количеством методов это приводит к значительному дублированию кода проверки аутентификации; gRPC поддерживает серверные интерсепторы (interceptors), позволяющие централизовать такую проверку в одном месте, выполняемую автоматически для всех вызовов методов сервиса, что аналогично централизованной middleware-логике аутентификации в REST API фреймворках, и предпочтительнее дублирования проверки в каждом отдельном методе.