
Laravel Passport: аутентифікація з використанням Password Grant Tokens
11.04.2021 18:51 | Laravel
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 (або мобільного додатка). Для цього:
- Винесемо дані в файл конфігурації (заодно туди ж помістимо і час життя токенів, бо хардкод це погано)
- Створимо роути і контролер, методи якого прийматимуть креди користувача /
refresh_token
, додаватимуть до них ті самі клієнтські дані, і повертатимуть згенеровані токени - Зробимо рефаторинг.
Створимо в .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. Але про це якось наступного разу.
А на сьогодні все. Успіхів!