Laravel Passport: аутентификация с использованием Password Grant Tokens

Laravel Passport: аутентификация с использованием Password Grant Tokens

Ubuntu: 18.04.5 LTS
PHP: 8.0.3
Laravel: 8.36.2
laravel-passport: v.10.1.3

Хочу уточнить, что мне пришлось делать подобное на существующем проекте, в котором использовался laravel-passport изначально. Если же у Вас проект с нуля, то я бы посоветовал ознакомиться с доками и подумать, нужен ли действительно laravel-passport, или может лучше посмотреть в сторону Sanctum.

Важно: я не буду рассматривать валидацию - необходимую, но довольно банальную вещь. Не забудьте добавить, при желании можете прочитать статью и на эту тему. 

Напишу с чистого листа, на свеже установленном инстансе. Поехали. 

Инсталируем паспорт:

composer require laravel/passport

 

Запускаем миграции:

php artisan migrate

 

И выполняем команду:

php artisan passport:install

 

которая создаст ключи шифрования + клиентов personal access и password grant (нас интересует последний), которые будут использоваться для генерации токенов доступа. В результате увидим в консоли что-то подобное:

Encryption keys generated successfully.
Personal access client created successfully.
Client ID: 1
Client secret: c6CiN1TyEy55lo8eEnm75USme8FZSty7DFCSzp9Z
Password grant client created successfully.
Client ID: 2
Client secret: jgwCn23Q77yE3WVuRx9bZyMDYPjEJIu6p1oisKfK

 

Идём в модель User (app/Models/User.php), и добавляем трейт HasApiTokens:

...
use Laravel\Passport\HasApiTokens;

class User extends Authenticatable
{
    use HasFactory, Notifiable, HasApiTokens;

    ...
}

 

Следующий шаг - добавим маршруты и время истечения токенов (1 час для токена доступа, и 30 дней для рефреш токена) в класс провайдера (app/Providers/AuthServiceProvider.php):

...
use Laravel\Passport\Passport;

class AuthServiceProvider extends ServiceProvider
{
    ...

    public function boot()
    {
        $this->registerPolicies();

        if (! $this->app->routesAreCached()) {
            Passport::routes(null, ['prefix' => 'api/v1/oauth']);
        }

        Passport::tokensExpireIn(now()->addHours());
        Passport::refreshTokensExpireIn(now()->addDays(30));
    }
}

Примечание: предположим, что мы создаём API для SPA, все эндпойнты имеют префикс api/v1, потому добавим идентичный префикс и для роутов паспорта.

 

Открываем config/auth.php и впишем соответствующий драйвер для api:

...
'guards' => [
    'web' => [
        'driver' => 'session',
        'provider' => 'users',
    ],

    'api' => [
        'driver' => 'passport',
        'provider' => 'users',
        'hash' => false,
    ],
],
...

 

Зайдём в tinker, и создадим пользователя для проверки:

php artisan tinker
Psy Shell v0.10.8 (PHP 8.0.0 — cli) by Justin Hileman
>>> User::factory()->create()
[!] Aliasing 'User' to 'App\Models\User' for this Tinker session.
=> App\Models\User {#3419
     name: "Quentin Prosacco",
     email: "renner.dina@example.net",
     email_verified_at: "2021-04-11 18:05:44",
     #password: "$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi",
     #remember_token: "VcjiGzaMBq",
     updated_at: "2021-04-11 18:05:44",
     created_at: "2021-04-11 18:05:44",
     id: 3,
   }
>>> 

 

Нужно ли что-то ещё? Нет - поскольку всё необходимое уже есть в пакете. Т.е., например, если мы постучим из Postman-а на POST эндпойнт api/v1/token/auth со сгенерированными ID и secret клиента + кредами созданного пользователя:

{
    "grant_type": "password",
    "client_id": "2",
    "client_secret": "jgwCn23Q77yE3WVuRx9bZyMDYPjEJIu6p1oisKfK",
    "username": "renner.dina@example.net",
    "password": "password",
    "scope": ""
}

 

то получим ответ в таком формате:

{
    "token_type": "Bearer",
    "expires_in": 3600,
    "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhdWQ...",
    "refresh_token": "def502000315d5534c606e260e8dba72eba35dad445..."
}

 

Для рефреша токена доступа следует звонить всё по тому же эндпойнту (.../api/v1/oauth/token), метод тот же - POST. И, естетсвенно, надо использовать полученный на предыдущем шаге refresh_token. Т.е., будем передавать данные:

{
    "grant_type": "refresh_token",
    "refresh_token": "def502000315d5534c606e260e8dba72eba35dad445...",
    "client_id": "2",
    "client_secret": "jgwCn23Q77yE3WVuRx9bZyMDYPjEJIu6p1oisKfK",
    "scope": ""
}

 

Структура респонса будет такая же, но с обновлёнными токенами. Например:

{
    "token_type": "Bearer",
    "expires_in": 3600,
    "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhdWQi...",
    "refresh_token": "def5020064ab8980471c51dc5367ade1ffa5e87909c5..."
}

 

Всё работает, фактически надо передать фронтенд разработчикам client_id и client_secret и дело сделано. Но есть одно но. Если покопаемся в сети, то найдём, например это:

The client_secret for an OAuth 2.0 application should never be included in any client side code. It is completely vulnerable there.

Уязвимость - всегда нехорошо. Избавимся от необходимости получать идентификатор и секретный ключ из SPA (или мобильного приложения). Для этого:

  1. Вынесем данные в файл конфигурации (заодно туда же поместим и время жизни токенов, ибо хардкод это плохо)
  2. Создадим роуты и контроллер, методы которого будут принимать креды пользователя / refresh_token, добавлять к ним те самые клиентские данные и отдавать сгенерированные токены
  3. Отрефакторим код.

Создадим в .env следующие переменные с нашими данными:

PASSPORT_GRANT_PASSWORD_CLIENT_ID=2
PASSPORT_GRANT_PASSWORD_CLIENT_SECRET=jgwCn23Q77yE3WVuRx9bZyMDYPjEJIu6p1oisKfK
PASSPORT_ACCESS_TOKEN_LIFETIME_IN_MINUTES=60
PASSPORT_REFRESH_TOKEN_LIFETIME_IN_DAYS=30

 

Опубликуем файл конфигурации пакета:

php artisan vendor:publish --tag=passport-config

 

Заходим в опубликованный конфиг и добавляем следующее: 

...
'password_grant_client' => [
    'id' => env('PASSPORT_GRANT_PASSWORD_CLIENT_ID'),
    'secret' => env('PASSPORT_GRANT_PASSWORD_CLIENT_SECRET')
],

'tokens_lifetime' => [
    'minutes_for_access' => env('PASSPORT_ACCESS_TOKEN_LIFETIME_IN_MINUTES'),
    'days_for_refresh' => env('PASSPORT_REFRESH_TOKEN_LIFETIME_IN_DAYS'),
],
...

 

Вернёмся в AuthServiceProvider, заменим хардкод и удалим добавленный ранее префикс из маршрутов паспорта - теперь это внутренняя кухня, и в префиксе нет необходимости:

class AuthServiceProvider extends ServiceProvider
{
    ...

    public function boot()
    {
        $this->registerPolicies();

        if (! $this->app->routesAreCached()) {
            Passport::routes();
        }

        Passport::tokensExpireIn(now()->addMinutes(config('passport.tokens_lifetime.minutes_for_access')));
        Passport::refreshTokensExpireIn(now()->addDays(config('passport.tokens_lifetime.days_for_refresh')));
    }
}

 

Создаём контроллер: 

php artisan make:controller Api/v1/OAuthController

 

И добавляем в него следующий код:

<?php

namespace App\Http\Controllers\Api\v1;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;

class OAuthController extends Controller
{
    public function token(Request $request)
    {
        $response = Http::asForm()->post(config('app.url').'/oauth/token', [
            'grant_type' => 'password',
            'client_id' => config('passport.password_grant_client.id'),
            'client_secret' => config('passport.password_grant_client.secret'),
            'username' => $request->get('email'),
            'password' => $request->get('password'),
            'scope' => '',
        ]);

        return $response->json();
    }

    public function refresh(Request $request)
    {
        $response = Http::asForm()->post(config('app.url').'/oauth/token', [
            'grant_type' => 'refresh_token',
            'refresh_token' => $request->get('refresh_token'),
            'client_id' => config('passport.password_grant_client.id'),
            'client_secret' => config('passport.password_grant_client.secret'),
            'scope' => '',
        ]);

        return $response->json();
    }
}

Примечание: проследите, чтобы в .env переменная APP_URL указывала на Ваше приложение

Далее открываем файл с routes/api.php и определим соответствующие роуты:

<?php

use App\Http\Controllers\Api\v1\OAuthController;

...

Route::group(['prefix' => 'v1/oauth'], function () {
    Route::post('token', [OAuthController::class, 'token'])->name('token');
    Route::post('refresh', [OAuthController::class, 'refresh'])->name('refresh');
});

 

Теперь в реквесте на эндпойнт получения токена (your-domain.app/api/v1/oauth/token) можем передавать только почту и пароль пользователя:

{
    "email": "renner.dina@example.net",
    "password": "password"
}

 

А на эндпойнт обновления токена (your-domain.app/api/v1/oauth/refresh) достаточно передавать только рефреш токен:

{
    "refresh_token": "def50200eb7b5ece9ac12121f615f11f53a96a00..."
}

 

При проверке должно всё работать. Но дублирование кода в контроллере мне не нравится. Плюс, если мы передадим неверную почту или пароль, то получим:

{
    "error": "invalid_grant",
    "error_description": "The provided authorization grant (e.g., authorization code, resource owner credentials) or refresh token is invalid, expired, revoked...",
    "hint": "",
    "message": "The provided authorization grant (e.g., authorization code, resource owner credentials) or refresh token is invalid, expired, revoked..."
}

 

А я хотел бы получать стандартное сообщение об ошибке. Отрефакторим код. Создадим в приложении директорию app/Contracts, и в ней интерфейс AuthTokenGenerator:

<?php

namespace App\Contracts;

use Illuminate\Http\Client\Response as ClientResponse;

interface AuthTokenGenerator
{
    /**
     * Generate access and refresh tokens.
     *
     * @param array $data
     * @param string $type
     *
     * @return ClientResponse
     */
    public function generateTokens(array $data, string $type): ClientResponse;
}

 

Также создадим директорию app/Services и в ней класс AuthService, который реализовывает интерфейс: 

<?php

namespace App\Services;

use App\Contracts\AuthTokenGenerator;
use Illuminate\Http\Client\Response as ClientResponse;
use Illuminate\Support\Facades\Http;

class AuthService implements AuthTokenGenerator
{
    public function generateTokens(array $data, string $type): ClientResponse
    {
        $data = array_merge($data, $this->getClientData(), ['grant_type' => $type, 'scope' => '']);

        return Http::asForm()->post(config('app.url').'/oauth/token', $data);
    }

    private function getClientData(): array
    {
        return [
            'client_id' => config('passport.password_grant_client.id'),
            'client_secret' => config('passport.password_grant_client.secret'),
        ];
    }
}

 

То бишь метод generateToken принимает данные пользователя или рефреш токен, пытается сгенерировать новые токены и отдаёт ответ. Теперь привяжем  интерфейс к реализации, для чего зайдём в app/Providers/AppServiceProvider.php и добавим:

<?php

namespace App\Providers;

use App\Contracts\AuthTokenGenerator;
use App\Services\AuthService;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->bind(AuthTokenGenerator::class, AuthService::class);
    }

    ...
}

 

Вернёмся к контроллеру, внедрим сервис (поскольку мы на PHP 8, можем использовать в конструкторе новую фичу - property promotion) и модифицируем код. В итоге контроллер будет выглядеть так:

<?php

namespace App\Http\Controllers\Api\v1;

use App\Contracts\AuthTokenGenerator;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Validation\ValidationException;

class OAuthController extends Controller
{
    public const TYPE_PASSWORD = 'password';

    public const TYPE_REFRESH = 'refresh_token';

    public function __construct(private AuthTokenGenerator $tokenGenerator) {}

    public function token(Request $request): array
    {
        $response = $this->tokenGenerator
            ->generateTokens($this->credentials($request), static::TYPE_PASSWORD);

        if ($response->status() !== Response::HTTP_OK) {
            throw ValidationException::withMessages(['email' => [trans('auth.failed')]]);
        }

        return $response->json();
    }
    
    public function refresh(Request $request): array
    {
        return $this->tokenGenerator
            ->generateTokens($request->only('refresh_token'), static::TYPE_REFRESH)
            ->json();
    }

    private function credentials(Request $request): array
    {
        return [
            'username' => $request->get('email'),
            'password' => $request->get('password'),
        ];
    }
}

 

Проверяем - всё ок. И в случае неверных кредов, будем получать привычное:

{
    "message": "The given data was invalid.",
    "errors": {
        "email": [
            "These credentials do not match our records."
        ]
    }
}

 

Напоследок замечу, что можно было поступить и немного по-другому - ведь client_id и client_secret хранятся в базе. Поэтому можно было бы, например, в .env вместо идентификатора и секретного ключа добавить параметр PASSPORT_GRANT_CLIENT_NAME, затем определить этот параметр в конфиге паспорта, скажем как: 

'password_grant_client' => [
    'name' => env('PASSPORT_GRANT_CLIENT_NAME'),
],

 

и потом в сервисе получать нужные данные следующим образом: 

use Laravel\Passport\Client;

...

private function getClientData(): array
{
    $client = Client::where('name', config('passport.password_grant_client.name'))->latest()->first();

    return [
        'client_id' => $client->id,
        'client_secret' => $client->secret,
    ];
}

 

но это уже дело вкуса.

Важно: роуты следует защитить от брут форса, другими словами, ограничить количество запросов по времени, и в Laravel 8 есть довольно хорошая вещь - RateLimiter. Но об как-то в следующий раз.

А на сегодня всё. Успехов!