Laravel7 + Sanctum でSPAログイン認証機能を実装

:::note warn
2021/08/03 一部文言修正と追記しました。ソースに変更はありません

★フロント側からcsrf-cookieへのHTTPリクエストはGETメソッドで行います
★VueCLIを使用せずにLaravel側にVueをインストールして同一プロジェクトパターンにも使用できます
★401 unauthorizedが発生した際の対処方法いくつか紹介
:::


# まえおき
Laravel7 + VueCLI、Laravel7にVueインストールなどのパターンで使用できる認証です。
今回のSanctum SPA認証はCookieを使用した認証となっており
モバイルアプリの場合はAPIトークンを使った認証となっており今回の記事では対象外です

# プロジェクト作成

#### Laravelインストール

```
composer create-project --prefer-dist laravel/laravel blog "7.*"
```

7.*で指定しないと最新の8で作成されてしまうので注意

# Sanctum導入&設定
#### Sanctumインストール

```
composer require laravel/sanctum
```

#### 設定ファイルとマイグレーションファイルをpublish

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

config/sanctum.phpとdatabase/migrationsにファイルが生成されます

#### CSRF保護のルーティングプレフィックス変更

```php:config/sanctum.php
<?php

return [

    'stateful' => explode(',', env(
        'SANCTUM_STATEFUL_DOMAINS',
        'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1'
    )),

    'expiration' => null,

    'middleware' => [
        'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class,
        'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class,
    ],

    'prefix' => 'api', // 追加
];

```

Sanctumではログイン時にCSRFクッキーを保存する必要があります
その際のルーティングのデフォルトは「http\://localhost:8000/sanctum/csrf-cookie」となり
これを「http\://localhost:8000/api/csrf-cookie」に変更しています
必要なければしなくても良いですが
axiosを使用したフロントでBASE_URLを設定する際に他のAPIと同じ様に
http\://localhost:8000/apiをBASE_URLに設定できるので便利です

#### マイグレーション実行
```
php artisan migrate
```

### apiミドルウェアにSanctumのミドルウェアを追加

```php:Kernel.php
<?php

namespace App\Http;

use Illuminate\Foundation\Http\Kernel as HttpKernel;
use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful; // 追加
class Kernel extends HttpKernel
{
    protected $middleware = [
        // \App\Http\Middleware\TrustHosts::class,
        \App\Http\Middleware\TrustProxies::class,
        \Fruitcake\Cors\HandleCors::class,
        \App\Http\Middleware\CheckForMaintenanceMode::class,
        \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
        \App\Http\Middleware\TrimStrings::class,
        \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
    ];

    protected $middlewareGroups = [
        'web' => [
            \App\Http\Middleware\EncryptCookies::class,
            \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
            \Illuminate\Session\Middleware\StartSession::class,
            // \Illuminate\Session\Middleware\AuthenticateSession::class,
            \Illuminate\View\Middleware\ShareErrorsFromSession::class,
            \App\Http\Middleware\VerifyCsrfToken::class,
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],

        'api' => [
            EnsureFrontendRequestsAreStateful::class,
            'throttle:60,1',
            \Illuminate\Routing\Middleware\SubstituteBindings::class, // 追加
        ],
    ];

    protected $routeMiddleware = [
        'auth' => \App\Http\Middleware\Authenticate::class,
        'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
        'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
        'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
        'can' => \Illuminate\Auth\Middleware\Authorize::class,
        'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
        'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
        'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
        'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
        'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
    ];
}
```

#### CORSホワイトリストとセッションドライバー設定

```ini:.env
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost

LOG_CHANNEL=stack

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=root
DB_PASSWORD=

BROADCAST_DRIVER=log
CACHE_DRIVER=file
QUEUE_CONNECTION=sync
SESSION_DRIVER=cookie // 変更
SESSION_LIFETIME=120
SESSION_DOMAIN=localhost // 追加
SANCTUM_STATEFUL_DOMAINS=localhost:8080 // 追加

REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

MAIL_MAILER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS=null
MAIL_FROM_NAME="${APP_NAME}"

AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=

PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=mt1

MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"

```

SANCTUM_STATEFUL_DOMAINSとSESSION_DOMAINは本番環境にデプロイする際は変更する必要があります。
Laravel側にVueインストールして使用している場合はSANCTUM_STATEFUL_DOMAINSはphp artisan serveで動かしたポートに合わせて設定してください。(php artisan serveの場合はlocalhost:8000)
VueCLIのnpm run serveでフロント動かしている場合はソースのままでlocalhost:8080

#### Cookieの送受信を許可

```php:config/cors.php
<?php

return [

    'paths' => ['api/*'],

    'allowed_methods' => ['*'],

    'allowed_origins' => ['*'],

    'allowed_origins_patterns' => [],

    'allowed_headers' => ['*'],

    'exposed_headers' => [],

    'max_age' => 0,

    'supports_credentials' => true, // 変更

];


```

クライアント側も許可しないといけないので注意ください
axiosを使用している場合はヘッダーにwithCredentials: trueを設定します

# 認証API作成

#### コントローラー作成
```
php artisan make:controller AuthController
```

```php:AuthController
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Facades\Auth;
use App\User;

class AuthController extends Controller
{
    public function login(Request $request)
    {
        $request->validate([
            'email' => ['required'],
            'password' => ['required'],
        ]);

        $credentials = $request->only('email', 'password');

        if (Auth::attempt($credentials)) {
            return response()->json(['name' => Auth::user()->name], 200);
        }

        throw ValidationException::withMessages([
            'email' => ['ログインに失敗しました'],
        ]);
    }

    public function register(Request $request)
    {
        $request->validate([
            'name' => ['required', 'between:1,10', 'unique:users'],
            'email' => ['required', 'email', 'unique:users'],
            'password' => ['required', 'between:6,30', 'confirmed'],
        ]);

        User::create([
            'name' => $request->name,
            'email' => $request->email,
            'password' => bcrypt($request->password)
        ]);
    }

    public function logout(Request $request)
    {
        Auth::logout();
    }
}

```

# ダッシュボード作成(認証保護ルート)

ダッシュボードはログインしていないとアクセスさせないようにします
ログインしていない状態でアクセスして来た場合は401を返します
上記の様にルートを保護するのは後のルーティングの設定で行います

#### コントローラー作成

```
php artisan make:controller DashboardController
```

```php:DashboardController
<?php

namespace App\Http\Controllers;

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

class DashboardController extends Controller
{
    public function index(Request $request)
    {
        return response()->json(['user' => Auth::user()], 200);
    }
}

```

シンプルにログインしているユーザー情報を返しています
名前だけ返すにはAuth::user()->nameとします

# ルーティング設定


```php:api.php
<?php

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::post('/login', 'AuthController@login');
Route::post('/register', 'AuthController@register');
Route::post('/logout', 'AuthController@logout');

Route::group(['middleware' => ['auth:sanctum']], function () {
    Route::post('/dashboard', 'DashboardController@index');
});

```

auth:sanctumミドルウェアでルートを保護します


####

# おまけ


### CSRF-COOKIE初期化

ログインを行う際にCSRFの初期化を行わなければなりません
この記事で紹介している通りに設定したのであれば
「/api/csrf-cookie」にパラメーター無しでGETリクエストする必要があります
認証保護されたAPIと通信を行う度に毎回GETリクエストするわけではなく
あくまでもログイン時の1回のみとなり以降は自動でCookie内のCSRFTOKENが更新されます



### ログイン失敗時にValidationExceptionを使用している理由

他のValidationエラーと同じ様にレスポンスを返すことができるので
フロントで422エラーハンドリングをする際に特別な処理を書かなくて済むためです

**ログイン失敗**

```php
throw ValidationException::withMessages([
     'email' => ['invalid credentials'],
]);
```

**バリデーションエラー**

```php
    $request->validate([
        'email' => ['required'],
        'password' => ['required'],
    ]);
```

上記2パターン共に別々に処理を書かずに1行でエラー取得が可能
**フロントで上記エラーを取得**

```js:
try{
    await axios.post('/login', { email: email, password: password })
}catch(error){
    this.errors = error.response.data.errors // エラー取得
}
```

正確に422エラーをキャッチするにはステータスコードで条件分岐させるのが良いです



### 401 Unauthorized
auth:sanctumで認証保護されたルートにログイン後に発生した場合下記を確認しましょう。
ログイン(login)、会員登録(register)はログインしていない状態のユーザーが使用する機能なので、
sanctumで認証保護する必要はありません。

①.envファイルのSESSION_DOMAINとSANCTUM_STATEFUL_DOMAINSが間違いないか
②.envファイルが反映されていない 解決法(php artisan config:cache)
③Kernel.phpのapi部分が下記のようになっているか

```php:Kernel.php
 'api' => [
            EnsureFrontendRequestsAreStateful::class,
            'throttle:60,1',
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],
```

④php artisan serve停止して再度起動してみる
⑤フロント側のhttpクライアント(axios)にwithcredentials: trueが設定されていない
⑥php artisan route:listでルーティングを確認し叩くAPIのURLに間違いはないか