勉強日記

チラ裏

JWT認証のお勉強

[秋葉原] ゆるふわLaravel勉強会 (認証/JWT)

JWT担当資料


JWT認証の特徴

  • APIアプリケーションでよく使われる
    • OAuthクライアントでの認証・認可などに
  • トークンそのものを検証することで、フロントエンド側で認証可否を判断できる
  • Laravel標準のトークン認証よりもセキュア
    • なぜ?
  • 中身は平文
    • 署名により、「改ざんされていない」ことが担保されるだけ (完全性)
    • base64urlエンコードに暗号強度はない
    • javascriptatob関数とかで普通に復号して覗ける

中身を覗ける例

  • 手元にあったJWTを採用しているWebアプリケーションで認証したときの返却トーク
{"message":"You are successfully logged in!","token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3QvYXBpL2F1dGgvbG9naW4iLCJpYXQiOjE1NTQwOTkzMzgsImV4cCI6MTU1NDEwMjkzOCwibmJmIjoxNTU0MDk5MzM4LCJqdGkiOiJvSnEyZmxPa01WRTRPcnFCIn0.2r1klvbpZx0oe_n0mZzxDJh6R00Dt4S5ivKvf0FKfOs"}
  • .区切りで分割
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
eyJzdWIiOjEsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3QvYXBpL2F1dGgvbG9naW4iLCJpYXQiOjE1NTQwOTkzMzgsImV4cCI6MTU1NDEwMjkzOCwibmJmIjoxNTU0MDk5MzM4LCJqdGkiOiJvSnEyZmxPa01WRTRPcnFCIn0
2r1klvbpZx0oe_n0mZzxDJh6R00Dt4S5ivKvf0FKfOs
{"typ":"JWT","alg":"HS256"}
{"sub":1,"iss":"http://localhost/api/auth/login","iat":1554099338,"exp":1554102938,"nbf":1554099338,"jti":"oJq2flOkMVE4OrqB"}
(署名バイナリ)
  • 「このトークンはいつ有効期限が切れる(expire)」
    とかがフロントエンドのJavaScriptだけでわかる

JWTの仕様をちょっと追ってみる

IETFドラフト

JWTヘッダ

JWTに適用される暗号化オペレーションについて記述しているJSONテキストオブジェクト. JWTがデジタル署名もしくはMACされている場合, JWTヘッダはJWSヘッダである. JWTが暗号化されている場合, JWTヘッダはJWEヘッダである.

  • Signature -> JWS
  • Encryption -> JWE かな?

f:id:wand_ta:20190401144011p:plain

{"typ":"JWT",
"alg":"HS256"}
  • エンコード済オブジェクトがJWTだよ」
  • 「JWTにはHMAC SHA-256アルゴリズムを使ってMACが付与されてるよ」
    • MAC: Message Authentication Code
    • HMAC: Hash-based Message Authentication Code
    • HMAC SHA-256: ハッシュ関数にSHA-256を使ってるHMAC
  • これは暗号化じゃないのでJWSヘッダ

エンコード済JWTヘッダ

eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9
  • JWTヘッダをBase64Urlエンコードしたもの
  • もちろんこれ自体に暗号強度はない

JWTクレームセット

  • JSONテキストオブジェクト
  • 含クレーム

クレーム

  • ある主体に関するひとまとまりの情報
  • 名前-値ペアで表現される

クレーム名

クレーム値

{"iss":"joe",
 "exp":1300819380,
 "http://example.com/is_root":true}
  • 上のまるごとがJWTクレームセット
  • "iss":"joe"とかがクレーム
    • issがクレーム名
    • joeがクレーム値

JWTペイロード

  • JWTクレームセットのUTF-8表現でのオクテット配列
const str = `{"iss":"joe",
 "exp":1300819380,
 "http://example.com/is_root":true}`

Array.from(str).map(ch => ch.charCodeAt(0))
[123, 34, 105, 115, 115, 34, 58, 34, 106, 111, 101, 34, 44, 10, 32, 34, 101, 120, 112, 34, 58, 49, 51, 48, 48, 56, 49, 57, 51, 56, 48, 44, 10, 32, 34, 104, 116, 116, 112, 58, 47, 47, 101, 120, 97, 109, 112, 108, 101, 46, 99, 111, 109, 47, 105, 115, 95, 114, 111, 111, 116, 34, 58, 116, 114, 117, 101, 125]

エンコード済JWTペイロード

eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ

JWTペイロードをbase64urlエンコードしたもの

エンコード済JWT署名

dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
  • サーバー側の秘密鍵が絡むのはここ
    • 「わたしです」という署名を誰でも発行できてはいけないので、サーバー側で秘密鍵を使って発行する

完全なJWT

LaravelでJWTを利用する

tymon/jwt-authのすすめ

導入

composer require tymon/jwt-auth 1.0.0-rc2
  • もっと新しいのあるかも

設定

  • 設定ファイルをconfig/配下に追加
php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"
  • 秘密鍵の生成
    • 署名の発行に使う
php artisan jwt:secret
  • .envJWT_SECRETが追記される
JWT_SECRET=vxMaKy1o...

Userクラス側のJWT認証対応

EloquentモデルのApp\Userでも、自前のクラスでも必要

  1. Tymon\JWTAuth\Contracts\JWTSubjectインタフェースをimplementする
    • 古いバージョンだと無かったりする
      • "tymon/jwt-auth": "^0.5.12" では無かった
    • getJWTIdentifier()getJWTCustomClaims()を実装する

vendor/tymon/jwt-auth/src/Contracts

<?php

/*
 * This file is part of jwt-auth.
 *
 * (c) Sean Tymon <tymon148@gmail.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Tymon\JWTAuth\Contracts;

interface JWTSubject
{
    /**
     * Get the identifier that will be stored in the subject claim of the JWT.
     *
     * @return mixed
     */
    public function getJWTIdentifier();

    /**
     * Return a key value array, containing any custom claims to be added to the JWT.
     *
     * @return array
     */
    public function getJWTCustomClaims();
}

JWTSubject@getJWTIdentifier(): mixed

  • ユーザーを特定できる一意の値を返却するように実装する
    • RDB上の主キーを返すとか
  • JWTクレームセットのsub(Subject)クレームになるっぽい

JWTSubject@getJWTCustomClaims(): array

  • 「クレーム」に関しては、IETFの資料を参照のこと
  • 要するに、JWTペイロードに追加情報を詰め込める
  • 平文同然なので、機密情報を入れてはいけない

JWT認証のガードの設定

config/auth.php

    'defaults' => [
-       'guard' => 'web',
+       'guard' => 'api',
        'passwords' => 'users',
    ],

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

        'api' => [
-           'driver' => 'token',
+           'driver' => 'jwt',
            'provider' => 'users',
        ],
    ],
  • JWT認証はAPI認証の一種
  • \Auth::guard('api')->attempt()というように使いたいので、api guardでjwtドライバを使うようにする
  • さらに\Auth::attempt()というように、デフォルトでapi guardを使うにはdefaults.guardapiにする

トークンを返す処理

  • 下記、時間なく動作未検証
<?php

Route::group(['middleware' => 'api'], function () {
    Route::post('/login', function (Request $request) {
        /** @var JWTGuard */
        $guard = \Auth::guard('api');

        /** @var false|string */
        $token = $guard->attempt([
            'email'    => $request->get('email'),
            'password' => $request->get('password'),
        ]);

        return response()->json([
            'message' => 'successfully logged in!',
            'token' => $token
        ]);
    });

    Route::get('/users', function (Request $request) {
        return \Auth::user();
    })->middleware('auth:api');
});
  • POST /api/login
    • 'api'という名前でguardを取得するとTymon\JWTAuth\JWTGuardインスタンスを得る
    • Tymon\JWTAuth\JWTGuard@attemptは認証に成功したらJWTを返す
    • それをレスポンスする
  • GET /api/users
    • 認証済なら認証済ユーザーを返す
    • 暗黙で、デフォルトの'api' guardが使用されている

【コードリーディング】jwtドライバの登録はどこで行われている?

こういうのが気になって夜眠れなくなるのです(うそ)

  • tymon/jwt-authのサービスプロバイダがある
  • vendor/tymon/jwt-auth/src/Providers/LaravelServiceProvider.php
<?php

namespace Tymon\JWTAuth\Providers;

class LaravelServiceProvider extends AbstractServiceProvider
{
    /**
     * {@inheritdoc}
     */
    public function boot()
    {
        $path = realpath(__DIR__.'/../../config/config.php');

        $this->publishes([$path => config_path('jwt.php')], 'config');
        $this->mergeConfigFrom($path, 'jwt');

        $this->aliasMiddleware();

        $this->extendAuthGuard();
    }
  • Tymon\JWTAuth\Providers\AbstractServiceProvider@extendAuthGuard()を呼んでいる
<?php
    /**
     * Extend Laravel's Auth.
     *
     * @return void
     */
    protected function extendAuthGuard()
    {
        $this->app['auth']->extend('jwt', function ($app, $name, array $config) {
            $guard = new JwtGuard(
                $app['tymon.jwt'],
                $app['auth']->createUserProvider($config['provider']),
                $app['request']
            );

            $app->refresh('request', $guard, 'setRequest');

            return $guard;
        });
    }
  • Illuminate\Auth\AuthManager@extendを呼び、jwtドライバを登録している
<?php

    /**
     * Register a custom driver creator Closure.
     *
     * @param  string  $driver
     * @param  \Closure  $callback
     * @return $this
     */
    public function extend($driver, Closure $callback)
    {
        $this->customCreators[$driver] = $callback;

        return $this;
    }
  • ここで登録されたカスタムドライバがいつ呼ばれるかというと、Illuminate\Auth\AuthManager@guardが呼ばれたとき
  • つまり、\Auth::guard('api')とか呼んだとき
  • \Auth::attempt()とかを呼んで、暗黙裡に\Auth::guard() (デフォルトguard使用)が呼ばれたときもそう
<?php

    /**
     * Attempt to get the guard from the local cache.
     *
     * @param  string  $name
     * @return \Illuminate\Contracts\Auth\Guard|\Illuminate\Contracts\Auth\StatefulGuard
     */
    public function guard($name = null)
    {
        $name = $name ?: $this->getDefaultDriver();

        return $this->guards[$name] ?? $this->guards[$name] = $this->resolve($name);
    }

    /**
     * Resolve the given guard.
     *
     * @param  string  $name
     * @return \Illuminate\Contracts\Auth\Guard|\Illuminate\Contracts\Auth\StatefulGuard
     *
     * @throws \InvalidArgumentException
     */
    protected function resolve($name)
    {
        $config = $this->getConfig($name);

        if (is_null($config)) {
            throw new InvalidArgumentException("Auth guard [{$name}] is not defined.");
        }

        if (isset($this->customCreators[$config['driver']])) {
            return $this->callCustomCreator($name, $config);
        }

        $driverMethod = 'create'.ucfirst($config['driver']).'Driver';

        if (method_exists($this, $driverMethod)) {
            return $this->{$driverMethod}($name, $config);
        }

        throw new InvalidArgumentException("Auth guard driver [{$name}] is not defined.");
    }

フロントエンド側(JavaScript)

  1. 認証APIを叩いてJWTを取得する
  2. 次回以降のHTTPリクエストでは、Authorizationヘッダにトークンをのせる
Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3QvYXBpL2F1dGgvbG9naW4iLCJpYXQiOjE1NTQwOTkzMzgsImV4cCI6MTU1NDEwMjkzOCwibmJmIjoxNTU0MDk5MzM4LCJqdGkiOiJvSnEyZmxPa01WRTRPcnFCIn0.2r1klvbpZx0oe_n0mZzxDJh6R00Dt4S5ivKvf0FKfOs

f:id:wand_ta:20190401172541p:plain

  1. POST /api/login をリクエストする
  2. 認証が済む。レスポンスからJWTを得る
  3. GET /api/user をリクエストする際、HTTPリクエストヘッダに載せる
  4. 認証済Userの情報を得、なんやかんやする
  5. JWTの有効期限が切れていたら、何らかの代替処理を行う
    • 再度ログインを求めるとか