Laravel SanctumでBearerトークンによるAPI認証

Laravel Passport以外でシンプルな外部サイトからのAPI認証を調べていたら、laravel6.xの公式ページにあった「API認証」がLaravel7.xから無くなっていました。
どうやらLaravel Sanctum(旧Laravel Airlock)を使うようですね。
Laravel Sanctumは2つの認証を提供します。
①APIトークンを生成しAuthorizationヘッダに有効なAPIトークンを含んでいるかで認証するAPI認証。
②トークンを使用せずクッキーベースのセッション認証サービスを利用し、XSSによる認証情報リークに対する保護と同時に、CSRF保護・セッションの認証を提供するSPA認証。
今回はLaravel Sanctumのインストールと設定、そして①のAPI認証を使ってみます。

開発環境

Laradock v10
Laravel 7
Vue 2.6
Vuetify 2.2

Laravel Sanctumのインストールと準備

インストール

Composerでインストールします。

# composer require laravel/sanctum

Configファイルとmigrationファイルの公開

vendorからプロジェクトに必要ファイルを取り込みます。

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

実行結果

Copied Directory [/vendor/laravel/sanctum/database/migrations] To [/database/migrations]
Copied File [/vendor/laravel/sanctum/config/sanctum.php] To [/config/sanctum.php]
Publishing complete.

マイグレーションファイルと設定ファイルsanctum.phpができますね。

マイグレーションファイルはこんな感じです。

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreatePersonalAccessTokensTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('personal_access_tokens', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->morphs('tokenable');
            $table->string('name');
            $table->string('token', 64)->unique();
            $table->text('abilities')->nullable();
            $table->timestamp('last_used_at')->nullable();
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('personal_access_tokens');
    }
}

データベースマイグレーション実行

# php artisan migrate

personal_access_tokensテーブルが生成されます。

APIトークンの発行

Userモデル変更

UserモデルにHasApiTokensトレイトを使用します。

use Laravel\Sanctum\HasApiTokens;	// 追加

class User extends Authenticatable
{
    use HasApiTokens, Notifiable;	// HasApiTokensを追加
}

トークンの発行

$token = $user->createToken('token-name');

平文の値を取得

$token->plainTextToken;

※新規に発行したときのみ平文値を取得できます。

ユーザの全トークンの取得

foreach ($user->tokens as $token) {
    // 処理
}

アビリティ(能力)付きトークンの発行

$user->createToken('token-name', ['server:update'])->plainTextToken;

そのトークンが特定のアビリティを持っているか評価するのは以下の通り。

if ($user->tokenCan('server:update')) {
  // 処理
}

トークンの破棄

// ユーザの全トークンの破棄
$user->tokens()->delete();

// ユーザの特定トークンの破棄
$user->tokens()->where('id', $id)->delete();
$user->tokens()->where('name', 'token-name')->delete();

ルートの保護

API認証を必要とするルートはsanctum認証ガードを指定する必要があります。

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();
});

CORS対策

クロスオリジンリソースシェアリング(Cross-Origin Resource Sharing)
別ドメインとのリソースの共有ですね。

サーバ側のCORS設定

'supports_credentials' => false,
↓
'supports_credentials' => true,

※Access-Control-Allow-CredentialsヘッダにTrueの値を返します。

クライアント側ではAxiosの設定

window.axios = require('axios');

window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
axios.defaults.withCredentials = true;	// 追加

テスト

以上を踏まえて実装してみましょう。

サーバ側

管理画面でトークン作成とAPIでユーザ情報を返すルートの設定

サーバドメイン:alpha.local

ルート設定

管理画面でトークン作成用のルート設定

Route::middleware('verified')->group(function() {
    Route::get('/user/sanctum', 'User\SanctumController@index');
    Route::post('/user/sanctum/create', 'User\SanctumController@create');
    Route::delete('/user/sanctum/destroy', 'User\SanctumController@destroy');

});

APIでユーザ情報を返すルートの設定

Route::middleware('auth:sanctum')->group(function(){
    Route::get('/user', function(Request $request){
        return $request->user();
    });
});

トークン発行/破棄コントローラー作成

# php artisan make:controller User/SanctumController
<?php

namespace App\Http\Controllers\User;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

class SanctumController extends Controller
{
    /**
     * Constructor
     * @return void
     */
    public function __construct()
    {
        $this->middleware('auth');
    }

    /**
     * Index
     */
    public function index()
    {
        return view('user.sanctum.index');
    }

    /**
     * create
     */
    public function create()
    {
        $user = \Auth::user();
        // 古いトークン削除
        $user->tokens()->where('name', 'token-name')->delete();
        // 新しいトークン生成
        $token = $user->createToken('token-name')->plainTextToken;
        return redirect('/user/sanctum')->with('token', $token);
    }

    /**
     * destroy
     */
    public function destroy()
    {
        $user = \Auth::user();
        // トークン削除
        $user->tokens()->where('name', 'token-name')->delete();
        return redirect('/user/sanctum')->with('message', 'トークンを破棄しました。');
    }

}

トークン発行/破棄ビュー作成

レイアウト

<!doctype html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <!-- CSRF Token -->
  <meta name="csrf-token" content="{{ csrf_token() }}">

  <title>{{ config('app.name', 'Laravel') }}</title>

  <!-- Scripts -->
  <script src="{{ asset('js/app.js') }}" defer></script>

  <!-- Fonts -->
  <link rel="dns-prefetch" href="//fonts.gstatic.com">
  <link href="https://fonts.googleapis.com/css?family=Nunito" rel="stylesheet">
  <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet">
  <link href="https://cdn.jsdelivr.net/npm/@mdi/font@4.x/css/materialdesignicons.min.css" rel="stylesheet">

  <!-- Styles -->
  <link href="{{ asset('css/app.css') }}" rel="stylesheet">
  <link href="{{ asset('css/user.css') }}" rel="stylesheet">
</head>
<body>
  <div id="app">
    <main class="py-4">
      @yield('content')
    </main>
  </div>
</body>
</html>

ページ

@extends('layouts.user')

@section('content')
<div class="container">
  <h1>Sanctum</h1>

<div class="mb-2">
<form action="/user/sanctum/create" method="post">
  @csrf
  <button class="btn btn-primary">トークン作成</button>
</form>
</div>
@if (Session::has('token'))
<p class="border mt-1 p-1">{{ Session::get('token') }}</p>
@endif

<div class="mb-2">
<form action="/user/sanctum/destroy" method="post">
  @csrf
  @method('delete')
  <button class="btn btn-danger">トークン破棄</button>
</form>
</div>
@if (Session::has('message'))
<p class="border text-danger mt-1 p-1">{{ Session::get('message') }}</p>
@endif

</div>
@endsection
laravel-sanctum-api-1

クライアント作成

APIトークンを利用するリクエストにはAuthrizationヘッダにBearerトークンとして生成されたトークンを含める必要があります。
bearerTokenは直書きしてますが、props等で設定するとして参考程度に。

サーバ:bravo.local

<template>
  <div style="max-width: 600px;">
    <h1>User</h1>

    <table class="table">
      <tr>
        <th>ID</th>
        <td>{{ user.id }}</td>
      </tr>
      <tr>
        <th>Name</th>
        <td>{{ user.name }}</td>
      </tr>
      <tr>
        <th>E-Mail</th>
        <td>{{ user.email }}</td>
      </tr>
    </table>

  </div>
</template>

<script>
    const bearerToken = '12|NuGUEhofivihhx3sQPCllRUN6S7YSQDOZLbKWCVdv6VsGUU6UwD4P4oYkIaNY6EXTlWbyCnN3UDcTcU6'
    export default {
      data() {
        return {
          user: {
            id: '',
            name: '',
            email: '',
          }
        }
      },
      created() {
        axios.get('//alpha.local/api/user', {
          headers: {
            Authorization: `Bearer ${bearerToken}`,
          },
        })
        .then((res) => {
          this.user = res.data
        })
        .catch((err) => {
          //
        })
      },

    }
</script>
laravel-sanctum-api-2

後記

今回はLaravel Sanctumの2つの認証のうち①APIトークンを生成しAuthorizationヘッダに有効なAPIトークンを含んでいるかで認証するAPI認証を取り扱いました。
何をやっているかわかりやすく、管理画面も簡単に作成できました。これでBearerトークンによる外部API認証は容易に構築できますね。