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/oauth/token з 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. Але про це якось наступного разу.

А на сьогодні все. Успіхів!