Сочетание мощи Laravel с реактивными возможностями Vue.js создает мощную комбинацию для современных веб-приложений. Добавьте Sanctum к системе аутентификации, и вы получите полнофункциональное решение, одновременно безопасное и динамичное. В этом руководстве мы рассмотрим процесс настройки Laravel, Sanctum и Vue 3.

О Laravel Sanctum

Laravel Sanctum — это пакет, предоставленный Laravel, который предлагает простой и легкий метод аутентификации API. Это может быть особенно удобно при создании одностраничных приложений (SPA), мобильных приложений или при использовании Laravel только в качестве внутреннего API. Sanctum предоставляет два основных способа аутентификации:

Аутентификация SPA (одностраничное приложение):

Как это работает:

  • При использовании аутентификации SPA Sanctum использует встроенные службы аутентификации сеанса Laravel на основе файлов cookie. Это возможно благодаря тому, как современные браузеры обрабатывают межсайтовые запросы, позволяя SPA и бэкэнду Laravel API действовать так, как будто они являются одним сплоченным приложением.

Настройка и использование:

  • Убедитесь, что SPA и серверная часть Laravel используют один и тот же домен верхнего уровня.
  • Настройте CORS (совместное использование ресурсов между источниками), чтобы распознавать ваш SPA.
  • В своем SPA выполните запрос к маршруту /sanctum/csrf-cookie для инициализации защиты CSRF.
  • После инициализации вы можете отправлять запросы к своему API на базе Laravel так же, как если бы интерфейс и серверная часть были традиционным монолитным приложением Laravel.

Преимущества:

  • Обеспечивает более плавную интеграцию между SPA и серверной частью Laravel.
  • Использует встроенную CSRF-защиту Laravel и обработку сеансов.

Аутентификация API на основе токенов:

Как это работает:

  • Этот метод больше подходит для мобильных приложений или сторонних приложений, которым необходимо взаимодействовать с вашим API. Вместо использования файлов cookie Sanctum предоставляет пользователям простой способ выдачи токенов API (или токенов личного доступа).

Настройка и использование:

  • Для хранения этих токенов создается новая таблица базы данных.
  • Токены могут быть предоставлены, отозваны и перечислены для каждого пользователя.
  • При отправке запросов к вашему API токен предоставляется в заголовке авторизации.

Преимущества:

  • Обеспечивает более гибкий способ управления аутентификацией API, особенно если у вашего API есть сторонние клиенты или мобильные приложения.
  • Предлагает простой способ управления, выпуска и отзыва токенов для каждого пользователя.
  • Поскольку токены не сохраняют состояние (в отличие от сеансов), они могут быть хорошим выбором для распределенных систем или приложений, использующих балансировщики нагрузки без закрепленных сеансов.

Независимо от выбранного метода важно обеспечить безопасность вашего приложения.

В этом руководстве мы создадим одностраничное приложение и реализуем метод аутентификации SPA от Sanctum.

Предпосылки:

  • Базовые знания Laravel и Vue.js.

Шаг 1. Установите Laravel и Sanctum

Если вы используете Mac, как и я, просто выполните следующие команды, чтобы начать:

curl -s "https://laravel.build/laravel-sanctum-vue-example" | bash
cd laravel-sanctum-vue-example && ./vendor/bin/sail up

Самые последние версии Laravel уже включают Laravel Sanctum. Однако, если файл composer.json вашего приложения не содержит laravel/sanctum, вы можете установить Laravel Sanctum через менеджер пакетов Composer:

composer require laravel/sanctum

Далее нам нужно опубликовать файлы конфигурации и миграции Sanctum:

sail artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"

Запустите миграцию, чтобы создать таблицу, в которой будут храниться токены API:

sail artisan migrate

Раскомментируйте строку, чтобы добавить промежуточное программное обеспечение Sanctum в группу промежуточного программного обеспечения api в файле app/Http/Kernel.php вашего приложения:

'api' => [
    \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
    \Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
    \Illuminate\Routing\Middleware\SubstituteBindings::class,
],

Вам нужно будет внести несколько изменений в ваш env-файл:

SESSION_DRIVER=cookie

...

SESSION_DOMAIN=.laravel-sanctum-vue-example.test
SANCTUM_STATEFUL_DOMAINS=laravel-sanctum-vue-example.test

Установите Vite для объединения ресурсов:

sail npm install

Шаг 2. Установите плагин Vue и Vite

Недавно я написал статью Установка Laravel, Vue и Tailwind, так что прочтите ее, если хотите получить более подробное руководство по этому вопросу. Сначала установите Vue и Vite:

npm i vue@next
npm i @vitejs/plugin-vue

Настройте Vite для работы с Vue, обновите vite.config.js:

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue'

export default defineConfig({
    plugins: [
        vue(),
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js'],
            refresh: true,
        }),
    ],
});

Шаг 3. Настройте Laravel для работы с Vue

Создайте App.vue. Это будет точка входа для нашего приложения Vue.

<template>
    <h1>Welcome to the Laravel, Sanctum, Vue Demo</h1>
</template>

<script setup>
</script>
<style>
</style>

Обновление resources/js/app.js

import {createApp} from 'vue'
import App from './App.vue'

createApp(App).mount("#app")

Обновление resources/views/welcome.blade.php. Обычно я переименовываю это в index.blade.php.

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>ًApplication</title>
    @vite('resources/js/app.js')
</head>
<body>
    <div id="app"></div>
</body>
</html>

Наконец обновите маршрутизацию. Обновите routes/web.php следующим образом:

<?php

use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('index');
});

Давайте проведем быстрый тест, чтобы убедиться, что все работает:

sail npm run dev

Выглядит хорошо до сих пор!

Шаг 4. Настройте Laravel Sanctum

Sanctum можно настроить для аутентификации по токену API или аутентификации SPA (одностраничное приложение). В этом уроке мы будем настраивать SPA-аутентификацию, но если вы хотите узнать больше об аутентификации по токену API, обратитесь к документации на веб-сайте Laravel.

Обновите config/cors.phpи добавьте следующее, чтобы гарантировать, что конфигурация CORS вашего приложения возвращает заголовок Access-Control-Allow-Credentials со значением True:

'supports_credentials' => true,

Нам также необходимо включить опцию withCredentials в глобальном экземпляре axios нашего приложения. Откройте resources/js/bootstrap.jsи добавьте следующую строку.

window.axios.defaults.withCredentials = true

Шаг 5. Маршруты аутентификации

Нам понадобятся некоторые маршруты для регистрации, входа в систему и т. д. Я предпочитаю размещать эти маршруты в пространстве имен Auth.

Добавьте следующее в маршруты/api.php:

Route::name('auth.')
    ->prefix('auth')
    ->group(function () {
        Route::group(['middleware' => ['guest']], function () {
            Route::post('login', [AuthController::class, 'store'])->name('login');
            Route::apiResource('register', RegistrationController::class)->only(['store']);
            Route::apiResource('forgot-password', ForgotPasswordController::class)->only(['store']);
            Route::apiResource('reset-password', ResetPasswordController::class)->only(['store']);
        });
        Route::group(['middleware' => ['auth:sanctum']], function () {
            Route::delete('logout', [AuthController::class, 'destroy'])->name('logout');
        });
    });

А еще нам понадобятся четыре контроллера, AuthController будет обрабатывать вход и выход из системы:

<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\StoreAuthRequest;
use App\Http\Resources\UserResource;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class AuthController extends Controller
{
    public function store(StoreAuthRequest $request): JsonResponse
    {
        if (Auth::attempt($request->validated())) {
            $request->session()->regenerate();

            return response()->json([
                'user' => new UserResource(Auth::user()),
            ]);
        }

        return response()->json([
            'message' => 'Invalid credentials',
        ], 401);

    }

    public function destroy(Request $request): JsonResponse
    {
        Auth::guard('web')->logout();

        $request->session()->invalidate();

        $request->session()->regenerateToken();

        return response()->json([
            'message' => 'Logged out'
        ], 200);
    }
}

ForgotPasswordController отправит электронное письмо с токеном:

<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\StorePasswordTokenRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Password;

class ForgotPasswordController extends Controller
{
    public function store(StorePasswordTokenRequest $request): JsonResponse
    {
        $status = Password::sendResetLink([
            'email' => $request->validated('email')
        ]);

        return $status === Password::RESET_LINK_SENT
            ? response()->json(['message' => __($status)])
            : response()->json(['message' => __($status)], 500);
    }
}

RegistrationControllerбудет обрабатывать пользователей, регистрирующихся в нашем приложении.

<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\StoreUserRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;

class RegistrationController extends Controller
{
    public function store(StoreUserRequest $request): Response
    {
        $user = User::create($request->validated());

        Auth::login($user);

        return response([
            'user' => new UserResource($user),
        ], 201);
    }
}

А ResetPasswordController позволит пользователю сбросить свой пароль с помощью токена, отправленного в электронном письме с забытым паролем.

<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\UpdatePasswordTokenRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Password;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Support\Str;

class ResetPasswordController extends Controller
{
    public function store(UpdatePasswordTokenRequest $request): JsonResponse
    {
        $status = Password::reset(
            $request->only('email', 'password', 'password_confirmation', 'token'),
            function (User $user, string $password) {
                $user->forceFill([
                    'password' => $password
                ])->setRememberToken(Str::random(60));

                $user->save();

                $user->tokens()->delete();

                event(new PasswordReset($user));
            }
        );

        $user = User::where('email', $request->validated('email'))->first();

        return $status === Password::PASSWORD_RESET
            ? response()->json([
                'message' => __($status),
                'token' => $user->createToken('AppToken')->plainTextToken
            ])
            : response()->json(['message' => __($status)], 500);
    }
}

Обычно я использую DTO (объекты передачи данных) для обработки данных запроса. Подробнее об этом можно прочитать в статье DTO в Laravel. Я бы также рекомендовал использовать шаблон репозитория и удалить запросы из контроллера. Однако я не хочу раскрывать слишком много концепций в одной статье, поэтому, если вы хотите узнать больше об использовании шаблона репозитория в Laravel, перейдите по этой ссылке.

Теперь мы также можем протестировать наши маршруты с помощью Postman. На изображении ниже вы можете увидеть конечную точку регистрации пользователя в действии.

Шаг 6: Напишите несколько тестов

Давайте напишем несколько тестов, чтобы убедиться, что все работает так, как ожидалось.

<?php

namespace Tests\Feature\Auth;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

class AuthApiTest extends TestCase
{
    use WithFaker;
    use RefreshDatabase;

    /** @test */
    public function test_a_user_can_login_with_valid_credentials(): void
    {
        $user = User::factory()->create();

        Sanctum::actingAs(
            $user,
            ['*']
        );

        $this->postJson(route('auth.login'), [
                'email' => $user->email,
                'password' => 'password'
            ],
        )
            ->assertStatus(200);
    }

    /** @test */
    public function test_a_user_cannot_login_with_invalid_credentials(): void
    {
        $user = User::factory()->create();

        $response = $this->post(route('auth.login'), [
            'email' => $user->email,
            'password' => 'WRONG-PASSWORD'
        ]);

        $response->assertStatus(401);
    }

    /** @test */
    public function email_field_is_required(): void
    {
        User::factory()->create();

        $this->postJson(route('auth.login'), [
            'email' => null,
            'password' => 'Password1!'
        ])
            ->assertJsonValidationErrors([
                'email' => 'The email field is required.'
            ])
            ->assertStatus(422);
    }

    /** @test */
    public function email_value_must_be_a_valid_email_address(): void
    {
        User::factory()->create();

        $response = $this->postJson(route('auth.login'), [
            'email' => 'INVALID_EMAIL_ADDRESS',
            'password' => 'password'
        ])
            ->assertStatus(422)
            ->assertJsonValidationErrors([
                'email' => 'The email field must be a valid email address.'
            ]);
    }

    /** @test */
    public function password_field_is_required(): void
    {
        $user = User::factory()->create();

        $this->postJson(route('auth.login'), [
            'email' => $user->email,
            'password' => null
        ])
            ->assertJsonValidationErrors([
                'password' => 'The password field is required.'
            ])
            ->assertStatus(422);
    }

    /** @test */
    public function test_authenticated_users_can_logout(): void
    {
        $user = User::factory()->create();

        Sanctum::actingAs(
            $user,
            ['*']
        );

        $this->deleteJson(
            route('auth.logout'),
            [],
            ['Referer' => env('APP_URL')]
        )
            ->assertOk()
            ->assertJson([
                    'message' => 'Logged out'
                ]);
    }

    /** @test */
    public function unauthenticated_users_cannot_logout(): void
    {
        $this->deleteJson(route('auth.logout'))
            ->assertStatus(401)
            ->assertJson([
                    'message' => 'Unauthenticated.'
                ]);
    }
}
<?php

namespace Tests\Feature\Auth;

use App\Events\PasswordResetFormSubmitted;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;

class ForgotPasswordApiTest extends TestCase
{
    use WithFaker;
    use RefreshDatabase;

    /** @test */
    public function test_a_user_can_request_a_new_password_token(): void
    {
        $this->withoutExceptionHandling();

        Event::fake();

        $user = User::factory()->create();

        $this->post(route('auth.forgot-password.store'), [
            'email' => $user->email
        ])
            ->assertStatus(200);
        $this->assertDatabaseHas('password_reset_tokens', [
            'email' => $user->email
        ]);

        Event::assertDispatched(PasswordResetFormSubmitted::class);
    }

    /** @test */
    public function email_field_is_required(): void
    {
        $user = User::factory()->create();

        $this->postJson(route('auth.forgot-password.store'), [])
            ->assertJsonValidationErrors([
                'email' => 'The email field is required.'
            ])
            ->assertStatus(422);

        $this->assertDatabaseMissing('password_reset_tokens', [
            'email' => $user->email
        ]);
    }

    /** @test */
    public function email_field_is_valid(): void
    {
        $user = User::factory()->create();

        $this->postJson(route('auth.forgot-password.store'), [
            'email' => 'WRONG'
        ])
            ->assertJsonValidationErrors([
                'email' => 'The email field must be a valid email address.'
            ])
            ->assertStatus(422);

        $this->assertDatabaseMissing('password_reset_tokens', [
            'email' => $user->email
        ]);
    }

    /** @test */
    public function email_field_exists(): void
    {
        $user = User::factory()->create();

        $this->postJson(route('auth.forgot-password.store'), [
            'email' => '[email protected]'
        ])
            ->assertJsonValidationErrors([
                'email' => 'The selected email is invalid.'
            ])
            ->assertStatus(422);

        $this->assertDatabaseMissing('password_reset_tokens', [
            'email' => $user->email
        ]);
    }
}
<?php

namespace Tests\Feature\Auth;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;

class RegistrationApiTest extends TestCase
{
    use WithFaker;
    use RefreshDatabase;

    /** @test */
    public function a_user_can_register(): void
    {
        $this->withoutExceptionHandling();

        $attributes = $this->getUserAttributes();

        $this->postJson(route('auth.register.store'), $attributes)
            ->assertStatus(201)
            ->assertJson(['user' => [
                'name' => $attributes['name'],
                'email' => $attributes['email']
            ]]);

        $this->assertDatabaseHas('users', ['name' => $attributes['name']]);
        $this->assertDatabaseHas('users', ['email' => $attributes['email']]);
    }

    /** @test */
    public function a_user_cannot_register_with_an_existing_email(): void
    {
        $attributes = $this->getUserAttributes();

        $this->postJson(route('auth.register.store'), $attributes);
        $this->postJson(route('auth.register.store'), $attributes)
            ->assertJsonValidationErrors([
                'email' => 'The email has already been taken.'
            ])
            ->assertStatus(422);

        unset($attributes['password_confirmation']);
        $this->assertDatabaseMissing('users', $attributes);
    }

    /** @test */
    public function name_field_is_required(): void
    {
        $attributes = $this->getUserAttributes();
        $attributes['name'] = null;

        $this->postJson(route('auth.register.store'), $attributes)
            ->assertJsonValidationErrors([
                'name' => 'The name field is required.'
            ])
            ->assertStatus(422);

        unset($attributes['password_confirmation']);
        $this->assertDatabaseMissing('users', $attributes);
    }

    /** @test */
    public function email_field_is_required(): void
    {
        $attributes = $this->getUserAttributes();
        $attributes['email'] = null;

        $this->postJson(route('auth.register.store'), $attributes)
            ->assertJsonValidationErrors([
                'email' => 'The email field is required.'
            ])
            ->assertStatus(422);

        unset($attributes['password_confirmation']);
        $this->assertDatabaseMissing('users', $attributes);
    }

    /** @test */
    public function email_value_must_be_a_valid_email_address(): void
    {
        $attributes = $this->getUserAttributes();
        $attributes['email'] = 'INVALID_EMAIL_ADDRESS';

        $this->postJson(route('auth.register.store'), $attributes)
            ->assertStatus(422)
            ->assertJsonValidationErrors([
                'email' => 'The email field must be a valid email address.'
            ]);

        unset($attributes['password_confirmation']);
        $this->assertDatabaseMissing('users', $attributes);
    }

    /** @test */
    public function password_field_is_required(): void
    {
        $attributes = $this->getUserAttributes();
        $attributes['password'] = null;

        $this->postJson(route('auth.register.store'), $attributes)
            ->assertJsonValidationErrors([
                'password' => 'The password field is required.'
            ])
            ->assertStatus(422);

        unset($attributes['password_confirmation']);
        $this->assertDatabaseMissing('users', $attributes);
    }

    /** @test */
    public function password_fields_must_match(): void
    {
        $attributes = $this->getUserAttributes();
        $attributes['password_confirmation'] = 'SOMETHING_ELSE';

        $this->postJson(route('auth.register.store'), $attributes)
            ->assertJsonValidationErrors([
                'password' => 'The password field confirmation does not match.'
            ])
            ->assertStatus(422);

        unset($attributes['password_confirmation']);
        $this->assertDatabaseMissing('users', $attributes);
    }

    private function getUserAttributes(): array
    {
        return User::factory()->raw([
            'password' => 'password',
            'password_confirmation' => 'password'
        ]);
    }
}
<?php

namespace Tests\Feature\Auth;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Support\Facades\Password;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

class ResetPasswordApiTest extends TestCase
{
    use WithFaker;
    use RefreshDatabase;

    /** @test */
    public function test_a_user_can_reset_their_password(): void
    {
        $user = User::factory()->create();

        $token = app('auth.password.broker')->createToken($user);

        $this->assertDatabaseHas('password_reset_tokens', [
            'email' => $user->email
        ]);

        $this->post(route('auth.reset-password.store'), [
            'email' => $user->email,
            'password' => $password = fake()->password(8),
            'password_confirmation' => $password,
            'token' => $token
        ]);

        Sanctum::actingAs(
            $user,
            ['*']
        );

        $this->getJson('/api/user')
            ->assertStatus(200);
    }

    /** @test */
    public function email_field_is_required(): void
    {
        $user = User::factory()->create();

        $token = Password::createToken($user);

        $this->postJson(route('auth.reset-password.store'), [
                'password' => $password = fake()->password,
                'password_confirmation' => $password,
                'token' => $token
            ])
            ->assertJsonValidationErrors([
                'email' => 'The email field is required.'
            ])
            ->assertStatus(422);
    }

    /** @test */
    public function email_field_is_valid(): void
    {
        $user = User::factory()->create();

        $token = Password::createToken($user);

        $this->postJson(route('auth.reset-password.store'), [
                'email' => 'WRONG',
                'password' => $password = fake()->password,
                'password_confirmation' => $password,
                'token' => $token
            ])
            ->assertJsonValidationErrors([
                'email' => 'The email field must be a valid email address.'
            ])
            ->assertStatus(422);
    }

    /** @test */
    public function email_field_exists(): void
    {
        $user = User::factory()->create();

        $token = Password::createToken($user);

        $this->postJson(route('auth.reset-password.store'), [
            'email' => '[email protected]',
            'password' => $password = fake()->password,
            'password_confirmation' => $password,
            'token' => $token
        ])
            ->assertJsonValidationErrors([
                'email' => 'The selected email is invalid.'
            ])
            ->assertStatus(422);
    }

    /** @test */
    public function token_is_required(): void
    {
        $user = User::factory()->create();

        $this->postJson(route('auth.reset-password.store'), [
            'email' => $user->email,
            'password' => $password = fake()->password,
            'password_confirmation' => $password,
        ])
            ->assertJsonValidationErrors([
                'token' => 'The token field is required.'
            ])
            ->assertStatus(422);
    }

    /** @test */
    public function password_is_required(): void
    {
        $user = User::factory()->create();

        $token = Password::createToken($user);

        $this->postJson(route('auth.reset-password.store'), [
            'email' => $user->email,
            'password_confirmation' => fake()->password,
            'token' => $token
        ])
            ->assertJsonValidationErrors([
                'password' => 'The password field is required.'
            ])
            ->assertStatus(422);
    }

    /** @test */
    public function password_confirmation_is_required(): void
    {
        $user = User::factory()->create();

        $token = Password::createToken($user);

        $this->postJson(route('auth.reset-password.store'), [
            'email' => $user->email,
            'password' => fake()->password,
            'token' => $token
        ])
            ->assertJsonValidationErrors([
                'password' => 'The password field confirmation does not match.'
            ])
            ->assertStatus(422);
    }

    /** @test */
    public function passwords_must_match(): void
    {
        $user = User::factory()->create();

        $token = Password::createToken($user);

        $this->postJson(route('auth.reset-password.store'), [
            'email' => $user->email,
            'password' => fake()->password,
            'password_confirmation' => fake()->password,
            'token' => $token
        ])
            ->assertJsonValidationErrors([
                'password' => 'The password field confirmation does not match.'
            ])
            ->assertStatus(422);
    }
}

Если мы запустим наши тесты, мы получим следующее:

Заключение

Теперь у нас есть полностью функционирующий API, который позволяет нам аутентифицироваться. Завершив эту часть настройки, вы теперь можете создавать компоненты Vue и отправлять аутентифицированные запросы к вашему бэкэнду Laravel, мы начнем с этого во второй части.

Ресурсы

Github:https://github.com/shaunthornburgh/laravel-sanctum-vue-example

Почтальон:https://www.postman.com/shaunthornburgh/workspace/laravel-sanctum-vue-example