
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/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 (или мобильного приложения). Для этого:
- Вынесем данные в файл конфигурации (заодно туда же поместим и время жизни токенов, ибо хардкод это плохо)
- Создадим роуты и контроллер, методы которого будут принимать креды пользователя /
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. Но об как-то в следующий раз.
А на сегодня всё. Успехов!