Запросы к апи с бэка с повтором если был ответ 401 (UnAuthorized) на примере Mercuryo. PHP, Yii2

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

Источник: https://habr.com/ru/post/699312/


Интересные статьи

Интересные статьи

В последние годы стало очень модно противопоставлять проектному подходу продуктовый. Мол, проекты — это когда зафиксированы результаты, сроки, бюджеты, и потому всё жёстко и неудобно, а продукты&...
Салют, коллеги.В рамках пятничной статьи предлагаю посмотреть на интересный способ создания моков в Kotlin, без использования сторонних библиотек.Я занимаюсь разработкой аддонов для Atlassia...
Можете представить, сколько времени уйдёт на генерацию списка VM среди сотен подписок Azure? Целая вечность. Известно, что портал Azure выводит только первые 1000 подписок, что усложняет запрос ресу...
Продолжение вчерашней статьи, посвящённой fЁлке, ниже. Читать далее
Описывать в общем механику проблем в нашей космической отрасли достаточно сложно, запутанно, и для весьма для многих еще и крайне непонятно. Обычный поток информации на тему космоса – это перечен...