Когда работаешь в проекте со сторонними апи предоставляющими какой-либо сервис, то необходимо делать к ним запросы с бэкенда и как по мне, делать это с бекэнда бывает не так удобно как с фронтенда. Тем более если нужное апи авторизует запросы по временному токену, который действует только какое-то время (обычно 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);
