Laravel(6.x)でのセキュアそうなAPI認証を実装する

こんにちは。M2です。
[Laravel Advent Calendar 2020](https://qiita.com/advent-calendar/2020/laravel)の4日目を担当させていただきます。

# 概要

Laravelのtokenを使ったAPI認証でなるべくセキュアそうな認証方法を実装してみたので、備忘録がてらに共有します。

Laravelパスポートを使いたいところですが、使わない方向です。

## 環境

- Laravel 6.x
- フロント側は(Vue.js 3)だけでど何でも良い

## 方向性

- DBにapi_tokenを用意して、それをユーザ認証用のtokenとする
- DBに保存しているapi_tokenはHash化して保存する
- ブラウザ側ではapi_tokenを暗号化してCookieに保存する
- JavaScript側でapi_tokenにアクセスできないように、Laravel側でhttponlyに設定する
- ずっと同じapi_tokenも嫌なので、ログイン毎に更新する

## api_tokenカラムの導入

```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
```

## DBに保存しているapi_tokenはHash化して保存する

```config/auth.php
<?php

return [

~~ 省略 ~~ 

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

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

~~ 省略 ~~ 
```

## ブラウザ側ではapi_tokenを暗号化してCookieに保存する

```app/Http/Controllers/Auth/RegisterController.php
  /**
   * Create a new user instance after a valid registration.
   *
   * @param  array  $data
   * @return \App\User
   */
  protected function create(array $data)
  {
    $token = $this->generateApiTokenAndSetCookie();

    return User::create([
.
.
.
      'api_token' => hash('sha256', $token),
    ]);
  }
}
```



```php
//$this->generateApiTokenAndSetCookie();の中身

use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Cookie;

.
.
.

  public function generateApiTokenAndSetCookie()
  {
    $token = Str::random(80);

    $encryptToken = Crypt::encrypt($token);
    Cookie::queue(Cookie::forget('api_token'));
    Cookie::queue('api_token', $encryptToken, '10000000');

    return $token;
  }
```

## api_tokenの暗号化は手動で設定しておく

```app/Http/Middleware/EncryptCookies.php
<?php

namespace App\Http\Middleware;

use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;

class EncryptCookies extends Middleware
{
    /**
     * The names of the cookies that should not be encrypted.
     *
     * @var array
     */
    protected $except = [
        'api_token'
    ];
}
```

## JavaScript側でapi_tokenにアクセスできないように、Laravel側でhttponlyに設定する


```
# .env
.
.
.

SESSION_SECURE_COOKIE=true
```

## ずっと同じapi_tokenも嫌なので、ログイン毎に更新する

```app/Http/Controllers/Auth/LoginController.php
.
.
.
  public function login(Request $request)
  {
.
.
.


    if ($this->attemptLogin($request)) {
      // Cookieのapi_tokenの更新して、DBのapi_tokenも更新する
      $token = $this->generateApiTokenAndSetCookie();
      User::where('email', $request->email)
        ->update(['api_token' => hash('sha256', $token)]);
     
      return $this->sendLoginResponse($request);
    }
.
.
.

  }

```

## いざAPI認証

```HogeService.php
// Serviceクラスでも何でも好きなところで...
<?php

namespace App\Services;

use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Cookie;

.
.
.

  public function getUser(string $url): ?string
  {
    $client = new \GuzzleHttp\Client();
    if (Cookie::get('api_token') === null) {
      return null;
    }
    $decryptToken = Crypt::decrypt(Cookie::get('api_token'), true);
    $response = $client->request(
      'GET',
      $url, 
      ['verify' => true] 
    );
    $response = $response->getBody()->getContents();
    $result = json_decode($response);
    if (json_last_error() === JSON_ERROR_NONE) {
      return json_encode($result);
    } else {
      return null;
    }
  }
}
```

## Controllerから上記のAPIにアクセスする

```HogeGetUserController.php
<?php
.
.
.
.
use App\Services\AuthApi;

/**
 * @group UserInfo API
 *
 * APIs 会員登録系
 */
class HogeGetUserController extends Controller
{

  protected $authApi;

  public function __construct(AuthApi $authApi)
  {
    $this->authApi = $authApi;
  }

.
.
.
  public function userInfo(Request $request)
  {
    $json = $this->authApi->getUser(route(('api.requestUser')));
    return $json;
  }

  public function requestUser(Request $request)
  {
    return $request->user();
  }

```

## ルーティングの設定

```routes/api.php
.
.
.

  Route::get('userInfo', 'HogeGetUserController@userInfo');

  Route::group(['middleware' => 'auth:api'], function () {
    Route::get('requestUser', 'HogeGetUserController@requestUser')->name('api.requestUser');
  });
```

これで、ログイン後に/api/userInfoにアクセスすれば、Userの情報が取得できる。

## Vue.jsから呼び出す

```hoge.vue
<template>
.
.
.
</template>

<script lang="ts">
axios.get('/api/userInfo')

</script>
```

## テストする

```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/requestUser?api_token={$token}");
        $json = json_decode($response->getContent());

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

```


## 問題点とか

- JavaScriptからCookieにアクセスできなくても、正しいapi_tokenを叩けば、誰でも認証情報にアクセスできちゃう...
- ログイン毎にapi_tokenが更新されるとはいえ、誰か良い方法を教えてクレメンス

## 参考

[公式:Laravel 6.x API認証](https://readouble.com/laravel/6.x/ja/api-authentication.html)
[Laravel6.x + Vue.js2(TypeScript)のSPAでシンプルなAPI認証を実装する](https://qiita.com/ProjectEuropa/items/60a07a376a2a975bb2b3)