Когда работаешь в проекте со сторонними апи предоставляющими какой-либо сервис, то необходимо делать к ним запросы с бэкенда и как по мне, делать это с бекэнда бывает не так удобно как с фронтенда. Тем более если нужное апи авторизует запросы по временному токену, который действует только какое-то время (обычно 24 ч.) и потом становится не действительным. В данной статье будет рассмотрен способ автоматического обновления такого токена непосредственно в процессе запроса ресурса удалённого сервиса.

Существует несколько способов аутентификации http запросов. Один из них использование постоянного api-key в заголовке или в query части запроса, который добавляется ко всем запросам требующим авторизации. Так (по заголовоку) например работает api яндекс такси. Другой тип аутентификации, это также использование заголовка Authorization с токеном, который может быть временным и для получения которого используется другой метод авторизации по логин-паролью. Так например, работали ситимобил и гетт. В случае когда такой токен (временный) истекает, то необходимо вновь пройти авторизацию через логин-пароль. Сам токен в свою очередь может быть выпущен в виде jwt (json web token). В целом первый способ называется авторизация по api key, а вторая bearer авторизацией.

В данной статье для примера будем использовать апи сервиса mercuryo. У данного сервиса апи реализовано по видоизменённой схеме с комбинацией api-key и временным jwt в качестве его значения. Для первичной аутентификаци используется запрос к методу sign-in по стандартной api-key схеме с использованием постоянного Sdk-Partner-Token. В ответе приходит временный jwt с помощью, которого уже и делаются запросы к ендпроинтам апи. Кроме того, есть возможность обновить валидный (не истёкший) jwt с помощью метода /refresh-token. В данном случае идёт речь об партнёрском апи, через которое партнёр может подключать своих пользователей к сервису и управлять их действиями из своего интерфейса. Соответственно все методы апи применяются к этому пользователю, а не к партнёру как таковому. В том числе и sign-inи остальные. Идентификация пользователей происходит по jwt. А при первоначальном логине используется почта (или телефон/ууид юзера).

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

В одной из наших предыдущих статей мы использовали общий класс отправитель различных сообщений Sender. Он ничего из себя не представлял, кроме одного метода с вызовом в нём метода send на http клиенте. Взяв его за основу добавим в него функциональность необходимую для получения желаемого от него поведения. Для этого используем код на основе генераторов добавим ему слой middleware.

<?php

namespace app\services\backend\email;

use app\interfaces\MessageInterface;
use app\interfaces\SenderInterface;
use app\models\Email\RestMessage;
use app\services\backend\infrastructure\ClientInterface;
use Generator;
use yii\base\InvalidConfigException;
use yii\httpclient\Client;
use yii\httpclient\Exception;
use yii\httpclient\Response;

class Sender implements SenderInterface
{
    private array $middlewares;
    private int   $currentMiddleware = 0;
    private RestMessage $message;
    /** @var Client $client */
    private $client;
    private Response $response;

    public function __construct(ClientInterface $client)
    {

        $this->client = $client;
    }

    public function send(MessageInterface $message)
    {
        /** @var RestMessage $message */
        $this->message = $message;
        /** @var Generator $gen */
        $gen = $this->trySend($message);
        foreach ($gen as $n => $s) {
            $this->next();
        }
        /** @var Response $res */
        $res = $gen->getReturn();
        return $res;
    }

    /**
     * @throws Exception
     * @throws InvalidConfigException
     */
    public function trySend(MessageInterface $message)
    {
        $statusCode = null;
        $n = 0;
        while ($statusCode != 200 && $n < 2) {
            $res = $this->client->send($this->message);
            $this->response = $res;
            $statusCode = $res->getStatusCode();
            $n++;
            if ($statusCode != 200){
                yield $res;
            }
        }
        return $res;
    }

    public function middleware(array $middlewares): self
    {
        foreach ($middlewares as $closure) {
            $this->middlewares[] = $closure;
        }
        return $this;
    }

    public function next()
    {
        $current = $this->currentMiddleware++;
        if (isset($this->middlewares[$current])) {
            $do = $this->middlewares[$current]($this->message, $this->response, [$this, 'next']);
            if (!$do) {
                $this->currentMiddleware = 0;
            }
        } else {
            $this->currentMiddleware = 0;
        }
    }
}

Теперь в методе Sender::send() создаётся генератор на основе которого в цикле foreach происходят вызовы метода Sender::trySend() через итератор и вызовы middleware через Sender::next() в теле цикла. В методе Sender::trySend() делаются запросы через клиент пока не будет получен ответ со статусом 200 или n < 2. Если статус ответа не 200, то возвращается новый элемент для внешнего цикла foreach в котором выполняется запуск выполнения middleware. Соответственно, после выполнения всех middleware происходит новый запрос через клиента.

Теперь рассмотрим применение middleware для повторного запроса к апи с обновлённым токеном в MercuryoClient::class.

<?php

namespace app\services\backend\finance\crypto;

use app\interfaces\finance\crypto\CryptoClientInterface;
use app\interfaces\SenderInterface;
use app\models\Email\RestMessage;
use app\models\User\User;
use app\services\backend\email\Sender;
use app\services\backend\infrastructure\ClientInterface;
use app\services\backend\infrastructure\RestClient;

class MercuryoClient implements CryptoClientInterface
{
    private Sender     $sender;
    private RestClient $client;
    private string     $ua;
    private string     $token;

    public function __construct(
        SenderInterface $sender,
        ClientInterface $client,
        string          $token,
        string          $userAgent
    )
    {
        $this->sender = $sender;
        $this->client = $client;
        $this->token  = $token;
        $this->ua     = $userAgent;
    }

    /**
     * Register user in the mercuryo
     * @param string $email
     * @return mixed
     */
    public function signUp(string $email)
    {
        $message = $this->createMessage(
            'POST',
            'user/sign-up',
            [
                'accept' => true,
                'email' => $email
            ]
        );
        $message->addHeaders(['Sdk-Partner-Token' => $this->token]);
        $res = $this->sender->send($message);
        return $res;
    }

    /**
     * Login user in the mercuryo
     * @param string $email
     * @return mixed
     */
    public function signIn(string $email)
    {
        $message = $this->createMessage(
            'POST',
            'user/sign-in',
            [
                'email' => $email
            ]
        );
        $message->addHeaders(['Sdk-Partner-Token' => $this->token]);
        $res = $this->sender->send($message);
        return $res;
    }

    public function getUserData(User $user)
    {
        $message = $this->createMessage(
            'GET',
            'user/data'
        );
        $token = $user->mercuryo->bearer_token;
        $message->addHeaders(['b2b-bearer-token' => $token]);
        $res = $this->sender
            ->middleware([
                    [ReSignInMiddleware::class, 'execute'],
                    [RefreshMiddleware::class, 'execute']
            ])
            ->send($message);
        return $res;
    }

    public function refreshTokenInMiddleware(string $token)
    {
        /** @var RestMessage $message */
        $message = $this->createMessage('GET', 'user/refresh-token');
        $message->addHeaders(['b2b-bearer-token' => $token]);

        $res = $this->sender->send($message);
        return $res;
    }

    public function createMessage(string $method, string $url, array $data = [])
    {
        $request = new RestMessage($this->client);
        $request->setMethod($method)
        ->setUrl($url)
        ->setHeaders([
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
            'User-Agent' => $this->ua,
        ])
        ;
        if (!empty($data)) {
            $request->setData($data);
        }
        return $request;
    }
}

В методах signIn и signUp аутентификация происходит по постоянному api-keу Sdk-Partner-Token в заголовке. В методах getUserData и refreshTokenInMiddleware аутентификация происходит уже по временному jwt поэтому в случае если он истекает, то используется ReSignInMiddleware::execute middleware для его обновление в процессе запроса данных в методе MercuryoClient::getUserData(). Теперь рассмотрим как устроен класс ReSignInMiddleware который выступает в качестве промежуточного слоя.

<?php

namespace app\services\backend\finance\crypto;

use app\interfaces\finance\crypto\CryptoClientInterface;
use app\models\Email\RestMessage;
use Throwable;
use Yii;
use yii\httpclient\Response;

class ReSignInMiddleware
{
    public static function execute($message, $response, $next)
    {
        /** @var Response $response */
        $status = $response->getStatusCode();
        if ($status == 401) {
            /** @var RestMessage $message */
            /** @var MercuryoClient $s */
            try {
              //...
                $s = Yii::$container->get(CryptoClientInterface::class);
                $res = $s->signIn($mercuryo->user->email);
                $data = $res->getData();
                $message->addHeaders([
                    'b2b-bearer-token' => $data['data']['bearer_token']
                ]);
              // ...
            } catch (Throwable $e) {
                Yii::error($e->getMessage(), 'mercuryo');
                Yii::error($e->getTraceAsString(), 'mercuryo');
            }
        }
        return $next();
    }
}

Собственно говоря, если статус ответа 401, то с помощью MercuryoClient делается запрос MercuryoClient::signIn () на аутентификацию (строка 23), получается и подставляется в заголовок сообщения которое отсылалось изначально (строка 25). Тут нужно отметить важный момент, что Yii::$container->get(CryptoClientInterface::class) возвращает разные объекты MercuryoClient, а не один и тот же. Потому что иначе $this->message в классе Sender перезатирается в методе send.

Таким образом, происходит обновление временного токена (jwt) в процессе запроса данных пользователя через наш класс Sender и middleware ReSignInMiddleware.

Да, собственно сам вызов getUserData выглядит так:

<?php
$user = User::findOne($idUser);
/** @var MercuryoClient $s */
$s = Yii::$container->get(CryptoClientInterface::class);
/** @var Response $r */
$r = $s->getUserData($user);

Комментарии (6)


  1. mozg3000tm Автор
    16.11.2022 16:45

    Есть ещё вариант авторизации, когда в заголовке передают зашифрованную строчку. И авторизация происходит по ней.


  1. Layan
    14.11.2022 21:08
    -1

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


    1. mozg3000tm Автор
      14.11.2022 21:21
      +1

      Из примера по ссылке я не могу понять можно там реализовать аналогичное проведение, но у меня встречный вопрос: "почему в axios используется interceptors, а не эта библиотека?"

      В любом случае спасибо за ссылку.


  1. ilyaplot
    15.11.2022 11:28
    +2

    Жаль, это не пулл реквест, прокомментировал бы. В коде проблема есть почти на каждой строке.


    1. mozg3000tm Автор
      15.11.2022 11:53

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


  1. mozg3000tm Автор
    15.11.2022 12:03

    На использование такого подхода к отправке запросов меня натолкнула библиотека Redux-saga. Меня попросили в одном реакт проекте поправить кое-что и я там увидел использование yield при запросах. Не знаю правильно ли это было настроено, но один запрос там (в консоле) повторялся один за другим без остановки, т.к. ответ приходил не 200. Так вот, мне показалась интересной такая реализация и собственно так я и решил сделать в похожей манере отправку запросов с бэка. Хотя я не преследовал цель реализовать дизайн паттерн сага. Вот статья на хабре о redux-saga.


  1. mozg3000tm Автор
    16.11.2022 16:45

    Есть ещё вариант авторизации, когда в заголовке передают зашифрованную строчку. И авторизация происходит по ней.