LaravelのJWT認証でいろいろカスタマイズ

仕事で考えないといけなくなったので、いろいろ試してみました。

# 作るもの
以下の条件を満たす認証機能を作ります。

- JWT 認証
- ユーザテーブルが、Laravel 標準のテーブル定義で **ない**
  - デフォルトの `users` テーブルは使いません
- パスワードのハッシュ化が Laravel 標準の方法で **ない**
  - デフォルトの `bcrypt` ではなく `SHA256` を使います

なお、Laravel 標準が使える環境ならば、そうしたほうが良いと思います。
この記事は **やむを得ず** そうせざるを得ない場合のために書かれています。

# 前提
## バージョン
- Laravel 6.9.0
 - **（2020/09/23追記） 7.x でも動きます。8.x でも動くとは思いますが、確認していません。**
- PHP 7.4.1
- MariaDB 10.3.11

## 扱わないこと
以下については、この記事では触れません。

- Laravel 標準の認証機能について
- [JWT](https://ja.wikipedia.org/wiki/JSON_Web_Token) そのものについて
- JWT 認証の是非について

# 手順
## 非標準のユーザテーブル
Laravel 標準では `users` テーブルを使用しますが、今回は、以下のようなテーブル `t_user` を作成します。

<img width="217" alt="ユーザテーブル.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141299/c3391d59-03b5-407d-5296-fb38e9059f9b.png">

### Eloquent モデル更新
Eloquent モデル `App\User` を編集します。テーブル名や主キーのカラム名を設定します。また、タイムスタンプ（ `created_at` と `updated_at` ）は存在しないので無効にします。

```php
namespace App;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable
{
    use Notifiable;

/* ここから追加 */
    // テーブル名
    protected $table = 't_user';

    // 主キーのカラム名（デフォルトは id なので）
    public $primaryKey = 'user_id';

    // タイムスタンプ無効化（デフォルトで有効なので）
    public $timestamps = false;
/* ここまで追加 */
}
```

## JWT認証
Laravel では標準の認証機能が存在しますが、今回は JWT 認証を行いたいと思います。
JWT 認証のライブラリは、 [tymon/jwt-auth](https://github.com/tymondesigns/jwt-auth) を使います。

### インストール
`composer` でインストールします。
~~バージョン番号まで指定しないと、かなり古いバージョン（0.5.12）がインストールされるので注意が必要です。[^1]~~
**（2020/09/23追記）正式に 1.0 になったため、バージョン番号を指定する必要は無くなりました。**

[^1]: ~~現在入手可能な最新版は `1.0.0-rc5` 。 `1.0.0-rc5` で Laravel 6 に対応したため、6 を使う場合は、このバージョンを使う必要がある。それにしても、いつになったら `1.0.0` になるのだろうか...~~

```shell
composer require tymon/jwt-auth
```

### 設定ファイルの配置
以下のコマンドを実行して、 `config/jwt.php` を作成します。

```shell
php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"
```

### 秘密鍵の生成
以下のコマンドを実行して、秘密鍵を生成します。

```shell
php artisan jwt:secret
```

`.env` に秘密鍵が設定されます。

```
JWT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

### Eloquent モデルの更新
Eloquent モデル `App\User` を編集します。

コントラクト `JWTSubject` にメソッド `getJWTIdentifier()` と `getJWTCustomClaims()` が定義されているので、それらを実装します。

```php
namespace App;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

/* 追加 */
use Tymon\JWTAuth\Contracts\JWTSubject;

/* implements を追加 */
class User extends Authenticatable implements JWTSubject
{
    use Notifiable;

    // テーブル名
    protected $table = 't_user';

    // 主キーのカラム名（デフォルトは id なので）
    public $primaryKey = 'user_id';

    // タイムスタンプ無効化（デフォルトで有効なので）
    public $timestamps = false;

/* ここから追加 */
    // JWT の sub に含める値。主キーを使う
    public function getJWTIdentifier()
    {
        return $this->getKey();
    }

    // JWT のクレームに追加する値。今回は特になし
    public function getJWTCustomClaims()
    {
        return [];
    }
/* ここまで追加 */
}
```

### ガードの設定
`config/auth.php` を編集します。

```php
'defaults' => [
    // web を api へ変更
    // 'guard' => 'web',
    'guard'     => 'api',
    'passwords' => 'users',
],
```

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

    'api' => [
        // token を jwt へ変更
        // 'driver' => 'token',
        'driver' => 'jwt',
        'provider' => 'users',
        // これは変更不要？
        // 'hash' => false,
    ],
],
```

### ルーティングの追加
`routes/api.php` に以下を追加します。

```php
Route::group([
    'middleware' => 'api',
    'prefix' => 'auth'
], function ($router) {
    Route::post('login', 'Api\AuthController@login')->name('login');
    Route::post('logout', 'Api\AuthController@logout');
    Route::post('refresh', 'Api\AuthController@refresh');
    Route::post('me', 'Api\AuthController@me');
});
```

### コントローラの追加
以下のコマンドを実行して、コントローラの雛形を作ります。

```shell
php artisan make:controller Api/AuthController

```

作成された `App\Http\Controllers\Api\AuthController` を編集します。

```php
namespace App\Http\Controllers\Api;

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

class AuthController extends Controller
{
    /**
     * コンストラクタ
     *
     * @return void
     */
    public function __construct()
    {
        $this->middleware('auth:api', ['except' => ['login']]);
    }

    /**
     * ログイン
     * 認証に成功したら、トークンを返却する
     *
     * @return \Illuminate\Http\JsonResponse
     */
    public function login()
    {
        $credentials = request(['email', 'password']);

        if (! $token = auth()->attempt($credentials)) {
            return response()->json(['error' => 'Unauthorized'], 401);
        }

        return $this->respondWithToken($token);
    }

    /**
     * ユーザ情報の取得
     * 認証されたユーザの情報を返却する
     *
     * @return \Illuminate\Http\JsonResponse
     */
    public function me()
    {
        return response()->json(auth()->user());
    }

    /**
     * ログアウト
     * ログアウトし、トークンを無効にする
     *
     * @return \Illuminate\Http\JsonResponse
     */
    public function logout()
    {
        auth()->logout();

        return response()->json(['message' => 'Successfully logged out']);
    }

    /**
     * トークンのリフレッシュ
     * 古いトークンを新しいトークンでリフレッシュする
     *
     * @return \Illuminate\Http\JsonResponse
     */
    public function refresh()
    {
        return $this->respondWithToken(auth()->refresh());
    }

    /**
     * トークン返却
     * トークンに関する情報を配列化してからJSON形式で返却する
     *
     * @param  string $token
     *
     * @return \Illuminate\Http\JsonResponse
     */
    protected function respondWithToken($token)
    {
        return response()->json([
            'access_token' => $token,
            'token_type' => 'bearer',
            'expires_in' => auth()->factory()->getTTL() * 60
        ]);
    }
}
```

## 非標準のパスワードのハッシュ化
Laravel 標準では [bcrypt](https://ja.wikipedia.org/wiki/Bcrypt) によるパスワードのハッシュ化が行われます。[^2]

[^2]: 他にも [Argon2](https://ja.wikipedia.org/wiki/Argon2) がデフォルトで選択可能です。詳しくは、ドキュメントの [ハッシュ](https://readouble.com/laravel/6.x/ja/hashing.html) を参照。

今回は [SHA256](https://ja.wikipedia.org/wiki/SHA-2) によるハッシュ化を行います。

### ハッシュドライバ
ハッシュドライバ `App\Extensions\SHA256Hasher` を作成します。ハッシュドライバでは、ハッシュ作成やチェックなどの処理を行います。

メソッドを4つ実装していますが、実際に必要なのは `make()` と `check()` のみです。[^3]

[^3]: Laravel は `password_hash()` をはじめとする [関数群](https://www.php.net/manual/ja/book.password.php) を使う想定のようです。SHA256 で使う `hash()` には `password_get_info()` などに相当する機能がないので、適当に実装しています。

```php
namespace App\Extensions;

use Illuminate\Contracts\Hashing\Hasher as HasherContract;

class SHA256Hasher implements HasherContract
{
    // ハッシュ作成
    // $value のハッシュを返す
    // password_hash() に相当する
    public function make($value, array $options = [])
    {
        return hash('sha256', $value);
    }

    // ハッシュのチェック
    // $value のハッシュと、与えられたハッシュが一致するかをチェックする
    // password_verify() に相当する
    public function check($value, $hashedValue, array $options = [])
    {
        return $this->make($value) === $hashedValue;
    }

    // ハッシュの再計算が必要かのチェック
    // password_needs_rehash() に相当する
    // SHA256 では不要なので、常に false を返す
    public function needsRehash($hashedValue, array $options = [])
    {
        return false;
    }

    // ハッシュの情報取得
    // password_get_info() に相当する
    // SHA256 では不要なので、常に null を返す
    public function info($hashedValue)
    {
        return null;
    }
}
```

### サービスプロバイダ追加
サービスプロバイダ `App\Providers\SHA256ServiceProvider` を作成します。

ドライバ名が `sha256` の場合は、先程作成したハッシュドライバを使うように `Hash::extend()` で指定しています。[^4]

[^4]: いろいろな記事を見ると、 `config/app.php` で既存のプロバイダをコメントアウトして、新しく作成したプロバイダを追加しているようです。コメントアウトしなくても、 [カスタムガードの追加](https://readouble.com/laravel/6.x/ja/authentication.html#adding-custom-guards) （ `Auth::extend()` ）と同様に追加できないのか？ というのが、そもそものきっかけでした。結果、 `HashManager` （ファサード `Hash` の実体）の親クラス `Illuminate\Support\Manager` に `extend()` が存在しており、 `Hash::extend()` で追加できることがわかりました。

```php
namespace App\Providers;

use App\Extensions\SHA256Hasher;
use Illuminate\Contracts\Support\DeferrableProvider;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Hash;

class SHA256ServiceProvider extends ServiceProvider implements DeferrableProvider
{
    public function boot()
    {
        Hash::extend('sha256', function ($app) {
            return new SHA256Hasher();
        });
    }
}
```

また、 `config/app.php` に `SHA256ServiceProvider` を追加します。

```php
'providers' => [
    /* 省略 */

    Illuminate\Hashing\HashServiceProvider::class,

    /* 省略 */

    App\Providers\EventServiceProvider::class,
    App\Providers\RouteServiceProvider::class,
    App\Providers\SHA256ServiceProvider::class, // 追加
],
```

### ハッシュ設定の変更
`config/hashing.php` を編集します。
`driver` を `bcrypt` から `sha256` へ変更します。[^5]

[^5]: `Illuminate\Support\Manager\extend()` を実行すると、配列 `customCreators` にドライバ名とクロージャが登録されます。 `HashManager` （ファサード `Hash` の実体）の `driver()` を呼び出すと、まず `getDefaultDriver()` が実行されます。 `getDefaultDriver()` は `config/hashing.php` の `driver` に設定されている値を取得します。次いで、取得した値をもとに `Manager\createDriver()` が実行されます。 `createDriver()` は、配列 `customCreators` にドライバ名が登録されているかを確認します。登録されていたら、それに紐付けられているクロージャを呼び出します。こうして、SHA256 によるハッシュ化が行われるわけです。

```php
return [

    /*
    |--------------------------------------------------------------------------
    | Default Hash Driver
    |--------------------------------------------------------------------------
    |
    | This option controls the default hash driver that will be used to hash
    | passwords for your application. By default, the bcrypt algorithm is
    | used; however, you remain free to modify this option if you wish.
    |
    | Supported: "bcrypt", "argon", "argon2id"
    |
    */

    // bcrypt から sha256 へ変更
    // 'driver' => 'bcrypt',
    'driver' => 'sha256',

    /* 省略 */
];
```

# 動作確認
## ログイン
リクエストを送信すると、JWT が返却されます。

### リクエスト

```
POST /api/auth/login?email=test@example.com&password=password HTTP/1.1
Host: test.example.com
```

### レスポンス
**成功時**

```json
{
    "access_token": "xxxxxxxxxx",
    "token_type": "bearer",
    "expires_in": 3600
}
```

**失敗時**

```json
{
    "error": "Unauthorized"
}
```

## ユーザ情報取得
ログインで取得した JWT を `Authorization Bearer` ヘッダ[^6]に指定して、リクエストします。

[^6]: [MDN HTTP認証](https://developer.mozilla.org/ja/docs/Web/HTTP/Authentication)

### リクエスト

```
POST /api/auth/me HTTP/1.1
Host: test.example.com
Accept: application/json
Authorization: Bearer xxxxxxxxxx
```

### レスポンス
**成功時**

```json
{
    "user_id": 1,
    "user_name": "山田太郎",
    "email": "test@example.com",
    "password": "password",
    "create_date": "2020-01-12 21:32:17",
    "update_date": "2020-01-02 18:23:36"
}
```

**失敗時**

```json
{
    "message": "Unauthenticated."
}
```

**注意点**
`Accept` ヘッダ[^7] に `application/json` を指定する必要があります。

[^7]: [MDN Accept](https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Accept)


# まとめ
ここまで書いて、以前チラ見した [【Laravel】JWTを使って認証システムを構築する](https://qiita.com/masa08/items/b9394c3176d1a6c62270) を見たら、ほぼ同じことが（しかもより詳しく）書いてあることに気づいたのでした。

ただ、JWT 認証の事例が基本的なものばかりだったので、 **JWT 認証でも全然カスタマイズできるよ** ということを書いておきたいなと思いました。[^8]

[^8]: それぞれのパーツが（しつこいくらい）抽象化されているので、パーツの中をカスタマイズしても、全体としてみればちゃんと動くようになっているわけですね。なかなかソースコードを追うのは大変でしたが、理解したら「なるほど〜」と唸らされました。

JWT 認証そのものにも、まだまだ調べないといけないことが多いのですが、一旦、今回はここまでということで...[^9]

[^9]: Laravelのソースコードを3日間読んだら、さすがに疲れた...... 明日から仕事？ 先生、本当ですか...

# 参考
- `jwt-auth`
  - [インストール手順](https://jwt-auth.readthedocs.io/en/develop/laravel-installation/)
  - [Quick Start](https://jwt-auth.readthedocs.io/en/develop/quick-start/)
- [APIを使うときはリクエストヘッダにAcceptを入れる（Laravel）](https://qiita.com/zdjjs/items/5d888949f350d0921c2a)
- [aravel tymon/jwt-auth による JWT 認証](https://qiita.com/yh1224/items/3c52dd9df3c4b5be95f2)
- [Laravel5でSHA1のHasherを作成する](https://qiita.com/shunsuke_takahashi/items/fcbe1e08aa94b208c14a)