Firebase Authenticateに触れてみる②

前回の続き!!

daisuki.nichiyoubi.land

取り急ぎの作業としては

  1. cakephp-jwt-auth の仕事や中身について理解する
  2. 「How to add JWT Authentication to a CakePHP 3 REST API」で説明されているシナリオについて理解する
  3. 実際にFirebase Authenticateを利用したGItHubログインを実装してみる

desu!

・・・の前に、一旦おさらい

qiita.com ありがとうございます、ありがとうございます・・・

こうしたモチベーションというか背景みたいなのを理解した上で、「どの部分を使うのか」っていうのも理解しておかないといけない。
リンクされている記事に、「各フィールドはそれぞれ何なの」という解説があった。

JWTについて簡単にまとめてみた - hiyosi's blog

「署名付きでエンコードされて渡ってくるJSONだよ」みたいなもんなのだけれども、この「検証」「デコード」ができないといけないよね、的な。 で、そのあたりのビジネスロジックを「JWT認証プラグイン」に期待するわけで、手っ取り早くソースコード読んで見る。

cakephp-jwt-authの中身をかいつまんで見る

実態は JwtAuthenticate.php に入っている。*1

cakephp-jwt-auth/JwtAuthenticate.php at master · ADmad/cakephp-jwt-auth · GitHub

多分、関心を持たないと行けないのは

  1. getToken()
  2. _decode()

の2つだな。次点で getUser() があって、 sub フィールドを用いたfind()を打っているのが目に入るけども、これはAuthComponent/AuthenticateというCakePHP一般の話になるので割愛。 subは認証側のユーザー識別子。

    /**
     * Get token from header or query string.
     *
     * @param \Cake\Http\ServerRequest|null $request Request object.
     *
     * @return string|null Token string if found else null.
     */
    public function getToken($request = null)
    /**
     * Decode JWT token.
     *
     * @param string $token JWT token to decode.
     *
     * @return object|null The JWT's payload as a PHP object, null on failure.
     */
    protected function _decode($token)

となると、このクラス自体は「検証」をしていなくて。
鍵を使うとかの話はどこに・・・?っていうのを気にしていくと、 _decodeの中にある。

<?php
         $payload = JWT::decode(
                $token,
                $config['key'] ?: Security::getSalt(),
                $config['allowedAlgs']
            );

となっているので、configに「検証のための情報を渡さなきゃいけないのね!!」っていうのが見て取れて納得感を得られるわけです。

「How to add JWT Authentication to a CakePHP 3 REST API」で説明されているシナリオについて

いったん、こちらの記事に立ち返ります。

www.bravo-kernel.com

行っているのは

  1. ユーザー登録用のエンドポイント /api/user/register -> IDトークンを返す
  2. ログイン用のエンドポイント /api/users/token -> IDトークンを返す
  3. その他の通常のエンドポイント利用時に「トークンの検証を行う」

という感じでしょうか。

我々と前提が違うのは、

  • JWT認証サーバーも兼ねている
    • 我々は、認証についてはFirebaseに任せっきり!
  • 認証を任せていることで、2つの要件が増える
    • ユーザー登録 & tokenを生成・発行する能力が必要
    • 非ログイン→ログイン状態を生み出すための、「ログイン処理」も自前でもつ必要がある

「トークンを生成する」というのは、この部分ですね。
https://github.com/bravo-kernel/application-examples/blob/master/blog-how-to-add-jwt-authentication-to-a-cakephp-3-rest-api/src/Controller/Api/UsersController.php#L27

「salt」を自前で設定したり、 「sub]をセットしたり〜というのは、今回の我々においてはノータッチな部分です。
逆に、認証サーバー側と共通の認識を持った「鍵」を用意する必要があるわけですが。

「ログインをする」というのは、AuthComponentで「Form Authenticate」を設定している部分となります。
email/passのPOSTをしてログイン→IDトークンください!というのがコレ。

f:id:o0h:20190105214233p:plain

あるいは、コード中で言えば次の部分です

https://github.com/bravo-kernel/application-examples/blob/master/blog-how-to-add-jwt-authentication-to-a-cakephp-3-rest-api/src/Controller/Api/UsersController.php#L46-L49

という感じで、全容とそれに対するサンプルアプリケーション実装の対応がつかめてきたかなーという感じです。

実装してみよ!

ということで、我々の場合で言うと

(まずはユーザー登録側)

  1. Firebase Authenticateによるログイン後に
  2. クライアント側で取得したIDトークンを、api/users/signup に投げて
    1. これはHeaderで渡す
  3. Usersレコードを作成後、ログイン状態の確立

まで、まずは行ければOKでしょうか。
(本来はajaxなアプリケーションにして、毎回headerに入れてもらったほうが良い気がするけども。まぁ練習用なので・・・)

認証用に、usersテーブルに2列追加

もはや我々は「どうすればJWTで渡ってきた情報をこちらの情報と同定できるのか」を把握していますので、それを達成するためにDBスキーマを揃えます

usersテーブルに、 sub は必須そうなので加えておくのと、 「どのサービスからのログインか」を見るために provider も足しておきます。こちらは github.com などの情報が入る想定。(直接つなぐなら、 iss なんだろうなー)((Firebase Authenticate的には uid の方が好ましいのかな?))

bin/cake migrations create AddJwtAuthColumnsToUsers
<?php
use Migrations\AbstractMigration;

class AddJwtAuthColumnsToUsers extends AbstractMigration
{
    /**
     * Change Method.
     *
     * More information on this method is available here:
     * http://docs.phinx.org/en/latest/migrations.html#the-change-method
     * @return void
     */
    public function change()
    {
        $this->table('users')
            ->addColumn('sub', 'string', [
                'default' => null,
                'limit' => 128,
                'null' => false,
                'after' => 'avatar_url'
            ])
            ->addColumn('provider', 'string', [
                'default' => null,
                'limit' => 32,
                'null' => false,
                'after' => 'sub'
            ])
            ->update();
    }
}

どんなものを実装すればいいのか(認証周り・サーバー)

やりたいことは

  • ヘッダー中にIDトークンを入れて投げれば良い
    • デフォルトだと authorization フィールドになっている
  • IDトークンのデコードができれば良い
  • 引き出した subフィールドで、usersテーブルから持ってこれれば良い
  • 引っ張ってきたuser情報をAuth->setUser()で噛ませれば良い

だと思います。

AppController::initialize() の中身を、こんな感じか・・・?

<?php
public function initialize()
{
// 省略
        $this->loadComponent('Auth', [
            'storage' => 'Memory',
            'authenticate' => [
                'ADmad/JwtAuth.Jwt' => [
                    'key' => /** どこ! **/'', 
                    'userModel' => 'Users',
                    'fields' => [
                        'username' => 'sub'
                    ],
                    'queryDatasource' => true
                ]
            ],
            'unauthorizedRedirect' => false,
            'checkAuthIn' => 'Controller.initialize'
        ]);

ってことで、鍵情報を探しましょう

Firebase Authenticateで利用する「鍵」

これは公式Docに書いてある気がするので、当たってみる。

Verify ID Tokens  |  Firebase

最後に、トークンの kid 要件に対応する秘密鍵によって ID トークンが署名されたことを確認します。https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com から公開鍵を取得し、JWT ライブラリを使用して署名を確認します。該当エンドポイントからのレスポンスの Cache-Control ヘッダーに含まれる max-age の値を使用して、公開鍵を更新する時期を確認します。

これかな・・・?

https://github.com/search?q=https%3A%2F%2Fwww.googleapis.com%2Frobot%2Fv1%2Fmetadata%2Fx509%2Fsecuretoken%40system.gserviceaccount.com&type=Code

ふーむ。

というのと、JWT SDKを見ると「公開鍵のコンテンツを入れろ」であり、パスを指定するんじゃないのか。

f:id:o0h:20190105231743p:plain
vendor/firebase/php-jwt/src/JWT.php

(まぁ、 Security::salt() の値を入れるか?の択一なのだから、そうか)

ということで、一旦、ものすごく雑に

  • オンザフライで鍵情報をセットしてみる
  • それをもとにしてデコードができるかを確かめる

までをスコープとして、話を進めてみる。

やるのが

  1. Authenticate objectに渡す設定の完成
  2. AuthComponentで/api/users/signup.json の解放(非ログイン状態のアクセス許可)
  3. curlで叩く

というもの。

Authenticateの設定

  1. 鍵を入れる
  2. アルゴリズムの設定

をやる必要があった。

最終的に修正したのが、以下。

diff --git a/api/src/Controller/AppController.php b/api/src/Controller/AppController.php
index 8288dfd..f0de2d8 100644
--- a/api/src/Controller/AppController.php
+++ b/api/src/Controller/AppController.php
@@ -16,6 +16,7 @@ namespace App\Controller;

 use Cake\Controller\Controller;
 use Cake\Event\Event;
+use http\Client;

 /**
  * Application Controller
@@ -46,11 +47,15 @@ class AppController extends Controller
         ]);
         $this->loadComponent('Flash');

+        $keys = (new \Cake\Http\Client())
+            ->get('https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com')
+            ->getJson();
         $this->loadComponent('Auth', [
             'storage' => 'Memory',
             'authenticate' => [
                 'ADmad/JwtAuth.Jwt' => [
-                    'key' => /** どこ! */ null,
+                    'key' => $keys,
+                    'allowedAlgs' => ['RS256'],
                     'userModel' => 'Users',
                     'fields' => [
                         'username' => 'sub'

/api/users/signup.json の解放

これをしないと、ログインする前にログインを求められる地獄が出来上がりますからね・・
ついでなので、この時点で「デコードされた結果」まで露呈させるようにしちゃおう

diff --git a/api/src/Controller/Api/UsersController.php b/api/src/Controller/Api/UsersController.php
index d3682f0..75f4fa0 100644
--- a/api/src/Controller/Api/UsersController.php
+++ b/api/src/Controller/Api/UsersController.php
@@ -2,15 +2,24 @@

 namespace App\Controller\Api;

+use ADmad\JwtAuth\Auth\JwtAuthenticate;
 use App\Controller\AppController;

 class UsersController extends AppController
 {
+    public function initialize()
+    {
+        parent::initialize();
+        $this->Auth->allow(['add']);
+    }

     public function add()
     {
+        $this->Auth->identify();
+        /** @var JwtAuthenticate $auth */
+        $auth = $this->Auth->getAuthenticate('ADmad/JwtAuth.Jwt');
         $this->set('_serialize', 'data');
-        $this->set('data', ['status' => 'OK']);
+        $this->set('data', $auth->getPayload());
     }

 }

実際にリクエストしてみる

IDトークンのとり方はすでに触れているので割愛するけれど、それを利用して次のような形でアクセスする。

curl -X "POST" "http://localhost:8101/api/users/signup.json" \
     -H 'Authorization: bearer ${実際にブラウザとかでとったIDトークン} ' \
     -H 'Content-Type: application/json'

これで上手くいってると、ちゃ〜んと情報が入ってきた!! f:id:o0h:20190105235353p:plain

もし、ココでそのまま $this->Auth->setUser((array)$auth->getPayload()); としてあげれば、ログイン状態の確立はそのままできちゃいそうかな? *2

clientとつなぐ

では、実際にJS側から叩いてみます。
corsとか面倒くさいので、 github.htmlはもうwebroot/下に置いてしまいましょう。 その上で、次のようなコードを書き加えてみました。

const config = {
    // いつもの
};
firebase.initializeApp(config);

const provider = new firebase.auth.GithubAuthProvider();
firebase.auth().signInWithPopup(provider).then((result) => {
  firebase.auth().currentUser.getIdToken(true).then((idToken) => {
    const xhr = new XMLHttpRequest()
    xhr.open('POST', 'http://localhost:8101/api/users/signup.json')
    xhr.setRequestHeader('Authorization', `Bearer ${idToken}`)
    xhr.send()
  })
}).catch((error) => {
  alert(error.message)
})

これをやると、「勝手にgithubのpopupが開いて」「勝手にsignup.jsonを叩いて」という挙動を起こします。
成功・失敗の見分けがつきにくいのですが、ChromeのNetworkパネルを開いておいてxhrが200を返していたら成功です。
もしくは、Auth::setUser()などに JWTAuthnticateから取得したpayloadをそのまま突っ込んでおくと、ログイン状態が成立しますので、 例えばその後に /users などを開くと、「要ログイン」状態にあったものを突破できます・・・!!

userの登録

あとはもう、なんてことなくて「普通にCakeかくぞ〜」という感じです。

例えば、 add()アクションの内部でこんな感じのメソッドでも呼んであげると良いのでないでしょうか。

<?php
$this->Auth->identify();
$auth = $this->Auth->getAuthenticate('ADmad/JwtAuth.Jwt');
$this->registerUser($auth->getPayload());
<?php
    private function registerUser($auth)
    {
        $user = $this->Users->newEntity([
            'sub' => $auth->sub,
            'provider' => $auth->firebase->sign_in_provider,
            'avatar_url' => $auth->picture,
            'name' => $auth->name,
        ]);

        return $this->Users->saveOrFail($user);
    }

これで usersテーブルへのsubフィールドの探索は実行されるので問題なさそ、という感じです。

残todoとまとめ

処理の流れを掴むため〜という目的の記事だったので、(あの酷いJS部分を差し引いても)実用できるレベルにはありません。
少なくとも、以下の点は必要だと思います。

  1. そもそも既存ユーザーログイン実装してないし!
  2. 取得した情報の、タイムスタンプ他の検証をしていない
  3. ache-Control ヘッダーに含まれる max-age の値を使用して、公開鍵を更新する時期を確認し てないので、非効率だし迷惑

など。まぁ、概念はつかめたと思うので、やってしまえば問題ないかな〜とは思います。

いずれにせよ何にせよ、あの面倒くさいユーザー情報管理があっという間にパワフルに完成するぞ!!すごいぞ!!!!!という気持ちになりました、使うぞ!


できたもの

github.com

*1:cakeの認証オブジェクトについてはhttps://book.cakephp.org/3.0/ja/controllers/components/authentication.html#id12 など

*2:コントローラーの中からAuthenticate Objectにメッセージを直接吐かせるのって、どうなんだろうな〜。他のSocial Loginとかの場合の実装ってどうしてんだろ