Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Когда работаешь в проекте со сторонними апи предоставляющими какой-либо сервис, то необходимо делать к ним запросы с бэкенда и как по мне, делать это с бекэнда бывает не так удобно как с фронтенда. Тем более если нужное апи авторизует запросы по временному токену, который действует только какое-то время (обычно 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\MailMessage;
use app\models\Email\RestMessage;
use app\models\Email\UnioneRestMessage;
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\forms\Mercuryo\RefreshTokenResponse;
use app\forms\Mercuryo\SignInResponse;
use app\interfaces\finance\crypto\CryptoClientInterface;
use app\managers\Mercuryo\MercuryoManager;
use app\models\Email\RestMessage;
use app\repositories\Mercuryo\MercuryoRepository;
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 */
$token = $message->getHeaders()->get('b2b-bearer-token');
if (!$token) {
return $next();
}
$mcrepo = new MercuryoRepository();
$mercuryo = $mcrepo->getByToken($token);
if (!$mercuryo) {
return $next();
}
/** @var MercuryoClient $s */
try {
$s = Yii::$container->get(CryptoClientInterface::class);
$res = $s->signIn($mercuryo->user->email);
$data = $res->getData();
$form = new SignInResponse();
$form->load($data['data'], '');
$mcm = new MercuryoManager();
$mcm->setMercuryo($mercuryo);
$mcm->updateFromeSignInResponse($form);
$message->addHeaders([
'b2b-bearer-token' => $form->bearer_token
]);
} catch (Throwable $e) {
Yii::error($e->getMessage(), 'mercuryo');
Yii::error($e->getTraceAsString(), 'mercuryo');
}
}
return $next();
}
}
Собственно говоря, если статус ответа 401, то с помощью MercuryoClient делается запрос MercuryoClient::signIn () на аутентификацию (строка 35), получается и сохраняется новый токен (строки 36-41) и подставляется в заголовок сообщения которое отсылалось изначально (строка 42). Тут нужно отметить важный момент, что Yii::$container->get(CryptoClientInterface::class) возвращает разные объекты MercuryoClient, а не один и тот же. Потому что иначе $this->message в классе Sender перезатирается в методе send.
Таким образом, происходит обновление временного токена (jwt) в процессе запроса данных пользователя через наш класс Sender и middleware ReSignInMiddleware.
Кроме того, для обновления jwt на половине его жизни в методе getUserData используется промежуточный слой RefreshMiddleware который обвновляет jwt делая запрос к методу /refresh-token.
Вот его реализация:
<?php
namespace app\services\backend\finance\crypto;
use app\forms\Mercuryo\RefreshTokenResponse;
use app\interfaces\finance\crypto\CryptoClientInterface;
use app\managers\Mercuryo\MercuryoManager;
use app\models\Email\RestMessage;
use app\repositories\Mercuryo\MercuryoRepository;
use Yii;
class RefreshMiddleware
{
public static function execute($message, $response, $next)
{
/** @var RestMessage $message */
$token = $message->getHeaders()->get('b2b-bearer-token');
if (!$token) {
return $next();
}
$mcrepo = new MercuryoRepository();
$mercuryo = $mcrepo->getByToken($token);
if (!$mercuryo) {
return $next();
}
if ($mercuryo->toBeRefresh()) {
/** @var MercuryoClient $s */
try {
$s = Yii::$container->get(CryptoClientInterface::class);
$res = $s->refreshTokenInMiddleware($token);
$data = $res->getData();
$form = new RefreshTokenResponse();
$form->load($data['data'], '');
$mcm = new MercuryoManager();
$mcm->setMercuryo($mercuryo);
$mcm->updateFromRefreshTokenResponse($form);
} catch (\Throwable $e) {
Yii::error('There is an error while refresh token', 'mercuryo');
Yii::error($e->getMessage(), 'mercuryo');
Yii::error($e->getTraceAsString(), 'mercuryo');
return;
}
}
return $next();
}
}
Здесь в методе $mercuryo->toBeRefresh() (строка 26) проверяется сколько осталось жить токену и если меньше 8 часов то с помощью запроса MercuryoClient::refreshTokenInMiddleware (строка 30) производится запрос нового токена (jwt). осле чего он сохраняется.
Да, собственно сам вызов getUserData выглядит так:
<?php
$user = User::findOne($idUser);
/** @var MercuryoClient $s */
$s = Yii::$container->get(CryptoClientInterface::class);
/** @var Response $r */
$r = $s->getUserData($user);