Laravel6.x + Vue.js2(TypeScript)のSPAでシンプルなAPI認証を実装する

# はじめに

最近個人開発したLaravel5.5のアプリケーションを
Laravel6.x + Vue.js2(TypeScript3.8) + Vuetifyでフルリニューアルリリースしました。

LaravelでのSPA認証にハマりどころがあったので、そこを共有してきます。

## 前提条件

- Laravel6.xの環境構築は解説しません。Laravel6.xのデフォルト環境があること前提で話を進めます
- TypeScriptも導入していますが、TypeScriptとVue.jsの環境構築については一旦スルーします
- 会員登録やログインなどの認証部分のみ画面遷移です

## やること一覧

- api_tokenカラムをusersテーブルに追加する
- bladeにapi_tokenをセッションに設定する
- TypeScriptにプラグインを設定し、Authorizationを設定する
- 設定したプラグインを読み込む
- RegisterController, LoginControllerにそれぞれapi_tokenをセッションに設定する
- config/auth.phpのapiのhashをtrueにする
- Vue.jsから認証情報を取得
- LaravelでのAPI認証のテスト
- その他TypeScriptやLaravel-Mixの設定など
- TypeScript x Vue.jsの型定義ファイル

### api_tokenカラムをusersテーブルに追加する

下記コマンドを実行して、マイグレーションファイルを作成する

```console
$ php artisan make:migration add_api_token_to_users_table --table=users
```

```database/migrations/${date}_add_api_token_to_users_table.php
<?php

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

class AddApiTokenToUsersTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('api_token', 80)->after('password')
                ->unique()
                ->nullable()
                ->default(null);
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn('api_token');
        });
    }
}
```


```console
$ php artisan migrate
```

マイグレーションをお忘れなく。

### bladeにapi_tokenをセッションに設定する

```resources/views/index.blade.php

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
  <head prefix="og: http://ogp.me/ns# fb: http://ogp.me/ns/fb# website: http://ogp.me/ns/website#">
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <meta name="robots" content="index,follow">

~~ メタ情報省略 ~~

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

    /** api_tokenを埋め込む　**/
    <meta name="api-token" content="{{ session()->get('api_token') ?? '' }}">
    /** ここまで追加　**/

    <title>~~</title>
  </head>
    <body>
      <div id="app" >
        <app />
      </div>
      <script src="{{ mix('/js/app.js') }}"></script>
    </body>
</html>
```

### TypeScriptにプラグインを設定し、Authorizationを設定する

```resources/ts/plugins/http.ts
import _Vue from 'vue';
import axios from 'axios';

export default {
  install(Vue: typeof _Vue): void {
    const http = axios.create({
      responseType: "json"
    });

    http.defaults.headers.common['Authorization'] = "Bearer " + document
      .querySelector('meta[name="api-token"]')!
      .getAttribute("content");

    Vue.prototype.$http = http;
  },
};
```

### 設定したhttpプラグインを読み込む

```resources/ts/app.ts
import Vue from "vue";
import App from "./components/App.vue";
import http from './plugins/http';
Vue.use(http);

new Vue({
  components: {
    app: App
  },
}).$mount('#app')
```

### RegisterController, LoginControllerにそれぞれapi_tokenをセッションに設定する

```app/Http/Controllers/Auth/RegisterController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use App\User;
use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;

class RegisterController extends Controller
{
    /*
    |--------------------------------------------------------------------------
    | Register Controller
    |--------------------------------------------------------------------------
    |
    | This controller handles the registration of new users as well as their
    | validation and creation. By default this controller uses a trait to
    | provide this functionality without requiring any additional code.
    |
     */

    use RegistersUsers;

    /**
     * Where to redirect users after registration.
     *
     * @var string
     */
    protected $redirectTo = '/';

    /**
     * Create a new controller instance.
     *
     * @return void
     */
    public function __construct()
    {
        $this->middleware('guest');
    }

    /** このメソッドを追加 **/
    protected function registered(Request $request, $user)
    {
        $token = Str::random(80);

        User::where('id', $user->id)
            ->update(['api_token' => hash('sha256', $token)]);

        session()->put('api_token', $token);
    }
    /** ここまで追加 **/

~~ 省略 ~~
}

```

```app/Http/Controllers/Auth/LoginController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

class LoginController extends Controller
{
    /*
    |--------------------------------------------------------------------------
    | Login Controller
    |--------------------------------------------------------------------------
    |
    | This controller handles authenticating users for the application and
    | redirecting them to your home screen. The controller uses a trait
    | to conveniently provide its functionality to your applications.
    |
     */

    use AuthenticatesUsers;

    /**
     * Where to redirect users after login.
     *
     * @var string
     */
    protected $redirectTo = '/';

    /** このメソッドを追加 **/
    protected function authenticated(Request $request)
    {
        $token = Str::random(80);

        $request->user()->forceFill([
            'api_token' => hash('sha256', $token),
        ])->save();

        session()->put('api_token', $token);
    }
    /** ここまで追加 **/

~~ 省略 ~~
}

```


### config/auth.phpのapiのhashをtrueにする

```config/auth.php
<?php

return [

~~ 省略 ~~ 

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],

        'api' => [
            'driver' => 'token',
            'provider' => 'users',
            'hash' => true, // ここをtrueにする
        ],
    ],

~~ 省略 ~~ 
```

### Vue.jsから認証情報を取得

```resources/ts/components/App.vue
<template>
~~ 省略 ~~
</template>

<script lang="ts">
import { Vue, Component } from "vue-property-decorator";
import { AxiosError, AxiosResponse } from "axios";

@Component
export default class App extends Vue {

  /**
   * created
   */
  public created() {
    this.getUser();
  }

  public getUser() {
    Vue.prototype.$http
      .get(`/api/user`)
      .then((res: AxiosResponse): void => {
        const user = res.data;
        console.log(user)
      })
      .catch((error: AxiosError): void => {
        console.error("APIの認証でエラーが発生しました");
      });
  }
}
</script>
```

TypeScript(Vue.js含む)をいじったら、コンパイルをお忘れなく

```console
$ npm run dev
```

認証失敗のログ
<img width="1418" alt="スクリーンショット 2020-05-08 13.53.20.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133718/52e4ba05-3449-afb2-022c-dfd97d31c79c.png">

認証成功のログ
<img width="1424" alt="スクリーンショット 2020-05-08 13.55.36.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133718/b67deee5-9730-2d77-f09b-1922ba2f4b7a.png">


### LaravelでのAPI認証のテスト

```tests/Feature/UserTest.php
<?php

namespace Tests\Feature;

use App\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Str;
use Tests\TestCase;

class UserTest extends TestCase
{

    use DatabaseTransactions;

    /**
     *
     * @return void
     */
    public function test_getユーザー取得()
    {
        $token = Str::random(80);

        $user = factory(User::class)->create([
            'api_token' => hash('sha256', $token),
        ]);

        $response = $this->actingAs($user)->json('GET', "/api/user?api_token={$token}");
        $json = json_decode($response->getContent());

        $this->assertObjectHasAttribute('id', $json);
        $this->assertObjectHasAttribute('name', $json);
        $this->assertObjectHasAttribute('email', $json);
    }

    /**
     *
     *
     * @return void
     */
    public function test_postユーザー取得()
    {
        $token = Str::random(80);

        $user = factory(User::class)->create([
            'api_token' => hash('sha256', $token),
        ]);

        $response = $this
            ->actingAs($user)
            ->withHeaders(
                [
                    'Authorization' => "Bearer ${token}",
                ]
            )
            ->post('/api/user');
        $json = json_decode($response->getContent());

        $this->assertObjectHasAttribute('id', $json);
        $this->assertObjectHasAttribute('name', $json);
        $this->assertObjectHasAttribute('email', $json);
    }
```


```routes/api.php
use Illuminate\Http\Request;

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

良いテスト方法があれば教えてクレメンス。

### その他tsconfig.jsonやLaravel-Mixの設定など

```tsconfig.json
{
  "compilerOptions": {
    "outDir": "./public/",
    "types": ["vuetify"], // Vuetify入れているので設定しています
    "sourceMap": true,
    "strict": true,
    "noImplicitReturns": true,
    "noImplicitAny": true,
    "module": "es2015",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "moduleResolution": "node",
    "target": "es5",
    "lib": [
      "es2016",
      "dom"
    ]
  },
  "include": [
    "./resources/ts/**/*"
  ]
}
```

```webpack.mix.js
const mix = require('laravel-mix');

mix.ts("resources/ts/app.ts", "public/js");

mix.webpackConfig({
  resolve: {
    extensions: [".js", ".jsx", ".vue", ".ts", ".tsx"],
    alias: {
      vue$: "vue/dist/vue.esm.js"
    }
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        loader: "ts-loader",
        options: { appendTsSuffixTo: [/\.vue$/] },
        exclude: /node_modules/
      }
    ]
  }
});
```

### TypeScript x Vue.jsの型定義ファイル

```resources/ts/types/index.d.ts
declare module "*.vue" {
  import Vue from "vue";
  export default Vue;
}
```


### 参考リンク

#### 作成したもの

- [Laravel6.x + Vue.js2(TypeScript3.8) + VuetifyのSPAアプリケーションのGitHubリンク（STG環境）](https://github.com/ProjectEuropa/europa-laravel-vuetify-ts)

#### API関連

- [readouble.com Laravel 6.x API認証](https://readouble.com/laravel/6.x/ja/api-authentication.html)

### TypeScript・Vuetify関連

- [How to Install Vuetify 2 in Laravel 6](https://medium.com/@ifkas/how-to-install-vuetify-2-in-laravel-6-863e41ddf8d2)
- [Quick start Vuetify](https://vuetifyjs.com/en/getting-started/quick-start/)
- [Laravel 5.5で簡単！Vue.js + TypeScript](https://www.hypertextcandy.com/laravel-vue-typescript)
- [Laravel mixのVue.jsをTypeScriptにしていく](https://megu-tech.hatenablog.com/entry/2019/08/02/154710)