masalibの日記

システム開発、運用と猫の写真ブログです

React Firebase入門 FirebaseAuthのToken(jwt)の発行と認証(php)

Firebaseのみで完結させてもいいのですが やっぱりRDBが使いたい!!mysqlが好き!!graphqlが使いたい人がいます

そう、私です。

Firebaseは便利なんだけど、StorageやFirestoreとか一癖も二癖もある作りになっています。 またFirebaseの学習コストもそこそこかかります。それを他の人に学習を強いるのは、組織としてはいけていないです。

外部のシステムと連携して作るプログラムを作りました。 普通の人はNode.jsやGoで作るらしいのですが私は底辺プログラマーなのでPHPで作りました。 (公式のサンプルソースpython、node.js、Go、JavaC#なので次は別の言語で作る。)

この記事はReact Firebase入門シリーズです 1-1-1・ Firebase初期設定とFirebaseAuthのSignUp
1-1-2・ 「react-hook-form」を入れてみた
1-1-3・ AuthのSignUpの通信エラー対応
1-2 ・ FirebaseAuthのログイン処理
1-3 ・ FirebaseAuthのログイン認証とログアウト処理
1-4 ・ FirebaseAuthのパスワード初期化処理
1-5 ・ FirebaseAuthのメールアドレスの有効化
1-6 ・ FirebaseAuthのメールアドレスとパスワード変更
1-7 ・ FirebaseAuthの表示名変更
1-8 ・ FirebaseAuthの拡張項目追加
1-9-1 ・ FirebaseAuthのTwitter認証
1-9-2 ・ FirebaseAuthのTwitter認証(既存ユーザー向け)と解除
1-10 ・ FirebaseAuthのGoogle認証(既存ユーザー含む)と解除
1-11 ・ FirebaseAuthのToken(jwt)の発行と認証 今ここ
2-1・ FirebaseStorageのファイルアップ:基礎
2-2・ FirebaseStorageのファイルアップ前に画像の切り抜き
2-3・ FirebaseStorageのファイルアップの移動
2-4・ FirebaseStorageのファイル圧縮
3-1・FirestoreのCRUD
3-2・Firestoreのデータ取得補足
3-3・Firestoreのページネーション処理
3-4・Firestoreのコレクション(テーブル)のJOIN
4-1・Realtime Databaseでchatアプリ(書き込み)
4-2・Realtime Databaseでchatアプリ(一覧取得)
5・ FirebaseのHosting(デプロイ)
6-1・ Cloud FunctionsでHello,world
6-2・ Cloud Functions(エミュレータ)でHello,world
6-3・ ユーザーの作成時にCloud Functionsを実行
6-4・ Cloud Functionsでメール送信
6-5・ Cloud Functionsでslackに通知する
よかったら他の記事も見てください。

やりたい事

  • 他のシステムとの接続。単純に取得するのではなくログインしているユーザーのみデータを取得できるようにする

大雑把ですが以下の図になります

Token発行

ぶっちゃけると超~簡単です。 ログインをしているとcurrentuserというオブジェクトと中に getIdTokenという関数があるのでそちらを実行するだけです

firebase.auth().currentUser.getIdToken

Firebase側がTokenを発行してくれるのでプログラマーは他に何もしなくてもいいのです。

このTokenはバックエンドの言語が Firebase Admin SDK でサポートされていない場合でも確認できます この部分を利用してPHPで認証します

トークンの内容は

IDトーク 内容
exp 有効期限:将来の時点であることが必要です。この時間は、UNIX エポック時刻からの秒数です。
iat 発行時:過去の時点であることが必要です。この時間は、UNIX エポック時刻からの秒数です。
aud 対象:Firebase プロジェクトの ID
iss 発行元:"https://securetoken.google.com/"
sub 件名:空ではない文字列、またはユーザーまたはデバイスの uid であることが必要です。
auth_time 認証時間:過去の時点であることが必要です。ユーザーが認証を行った時間です。
email 登録されているemail
email_verified メール有効化
picture 登録されている画像(Token付き)
firebase プロバイダー情報(Google認証でログインされた場合の情報など)

Token認証

Token認証はCORSの設定部分と認証にわかれます

CORSの設定

javascriptで外部のシステムを取得するときに必要な設定なのですが・・・

詳しくは以下のサイトをみてください

javascript.keicode.com

//CORSの設定 
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept');

$method = "";
$headers = "";

if(isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD'])) {
    $method = $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD'];
}
if(isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'])) {
    $headers = $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'];
}

header('Access-Control-Allow-Method: ' . $method);
header('Access-Control-Allow-Headers: ' . $headers);

if($_SERVER["REQUEST_METHOD"] == "OPTIONS"){
    http_response_code(204) ;
    exit();
}
  • 本番だとOriginの設定とかちゃんとやったほうがいいと思うけど、面倒くさい。
  • 実際のデータ取得の通信以外にOPTIONSというメソッドがきます。そちらはパラメータなどが設定されていないのでヘッダーだけ返してステータスコード返す形になっています。

Token認証

TokenはヘッダーのBearer Tokenにいれています。なんでここに入れるのかはTwitterAPIなどがこの部分にいれているのでそちらと同じようにいれています

Bearerトークンは、セキュリティトークンのうちその利用可否が「トークンの所有」のみで決定されるものである[1]。持参人トークン・署名なしトークンとも呼ばれる。

実際にいれた通信

const {data}  = await axios.get('http://localhost:20001/openid/index.php', {
    headers: {
        Authorization: `Bearer ${idToken}`,
    }
})

phpでToken(jwt)を取得する処理

//AuthorizationのBearer Tokenからjwtを取得
$headers = apache_request_headers();
if (!isset($headers["Authorization"])) {
    http_response_code( 401 ) ;
    exit();
} else {
    $AuthorizationData = $headers["Authorization"];
    $Authorization_array = explode(" ", $AuthorizationData);
    $jwt = $Authorization_array[1];
}

認証用のモジュールをインストール

composer require firebase/php-jwt

環境にもよるのですが、herokuで動かす場合はcomposer.jsonのファイルがいるみたいなのでご注意

{
    "require": {
        "firebase/php-jwt": "^5.2"
    }
}

認証関数

関数は

stackoverflow.com

をもとに作られています。本当に感謝です。

この関数を使う場合に設定するのは3つです。 「認証した時のファイル」、「キャッシュ」、「ProjectID」

ファイルとキャッシュに関しては動かすプログラムやフォルダのパーミッションによるので気をつけてください。 herokuは特に/tmp/のフォルダしか書き込めない。注意。

$keys_file = "/tmp/securetoken.json"; // the file for the downloaded public keys
$cache_file = "/tmp/pkeys.cache"; // this file contains the next time the system has to revalidate the keys
$fbProjectId = "learn-firebase-masalib";   //  MUST REPLACE <YOUR FIREBASE PROJECTID> with your own!

認証の関数のざっくりとした内容は

  • 証明書を取りに行って、そちらをファイル保存しています。
  • そのファイルをもとにTokenを検査してToken内容を返します
  • もしキャッシュがある場合はそちらでチェックする、キャッシュがきれている場合は再度証明書を取りに行ってキャッシュに保存する。
  • エラーなどが発生すると連想配列にerrorを作って返す  
/////// FROM THIS POINT, YOU CAN COPY/PASTE - NO CHANGES REQUIRED
///  (though read through for various comments!)
function verify_firebase_token($token = '')
{
    global $fbProjectId;
    $return = array();
    $userId = $deviceId = "";
    checkKeys();
    $pkeys_raw = getKeys();
    if (!empty($pkeys_raw)) {
        $pkeys = json_decode($pkeys_raw, true);
        try {
            $decoded = \Firebase\JWT\JWT::decode($token, $pkeys, ["RS256"]);
            if (!empty($_GET['debug'])) {
                echo "<hr>BOTTOM LINE - the decoded data<br>";
                print_r($decoded);
                echo "<hr>";
            }
            if (!empty($decoded)) {
                // do all the verifications Firebase says to do as per https://firebase.google.com/docs/auth/admin/verify-id-tokens
                // exp must be in the future
                $exp = $decoded->exp > time();
                // ist must be in the past
                $iat = $decoded->iat < time();
                // aud must be your Firebase project ID
                $aud = $decoded->aud == $fbProjectId;
                // iss must be "https://securetoken.google.com/<projectId>"
                $iss = $decoded->iss == "https://securetoken.google.com/$fbProjectId";
                // sub must be non-empty and is the UID of the user or device
                $sub = $decoded->sub;
                if ($exp && $iat && $aud && $iss && !empty($sub)) {
                    // we have a confirmed Firebase user!
                    // build an array with data we need for further processing
                    $return['UID'] = $sub;
                    $return['email'] = $decoded->email;
                    $return['email_verified'] = $decoded->email_verified;
                    $return['name'] = $decoded->name;
                    $return['photo'] = $decoded->photo;
                    $return['picture'] = $decoded->picture;
                    //var_dump($decoded);
                } else {
                    if (!empty($_GET['debug'])) {
                        echo "NOT ALL THE THINGS WERE TRUE!<br>";
                        echo "exp is $exp<br>ist is $iat<br>aud is $aud<br>iss is $iss<br>sub is $sub<br>";
                    }
                    /////// DO FURTHER PROCESSING IF YOU NEED TO
                    // (if $sub is false you may want to still return the data or even enter the verified user into the database at this point.)
                }
            }
        } catch (\UnexpectedValueException $unexpectedValueException) {
            $return['error'] = $unexpectedValueException->getMessage();
            if (!empty($_GET['debug'])) {
                echo "<hr>ERROR! " . $unexpectedValueException->getMessage() . "<hr>";
            }
        }
    }
    return $return;
}
/**
* Checks whether new keys should be downloaded, and retrieves them, if needed.
*/
function checkKeys()
{
    global $cache_file;
    if (file_exists($cache_file)) {
        $fp = fopen($cache_file, "r+");
        if (flock($fp, LOCK_SH)) {
            $contents = fread($fp, filesize($cache_file));
            if ($contents > time()) {
                flock($fp, LOCK_UN);
            } elseif (flock($fp, LOCK_EX)) { // upgrading the lock to exclusive (write)
                // here we need to revalidate since another process could've got to the LOCK_EX part before this
                if (fread($fp, filesize($cache_file)) <= time()) 
                {
                    refreshKeys($fp);
                }
                flock($fp, LOCK_UN);
            } else {
                throw new \RuntimeException('Cannot refresh keys: file lock upgrade error.');
            }
        } else {
            // you need to handle this by signaling error
        throw new \RuntimeException('Cannot refresh keys: file lock error.');
        }
        fclose($fp);
    } else {
        refreshKeys();
    }
}

/**
 * Downloads the public keys and writes them in a file. This also sets the new cache revalidation time.
 * @param null $fp the file pointer of the cache time file
 */
function refreshKeys($fp = null)
{
    global $keys_file;
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com");
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($ch, CURLOPT_HEADER, 1);
    $data = curl_exec($ch);
    $header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
    $headers = trim(substr($data, 0, $header_size));
    $raw_keys = trim(substr($data, $header_size));
    if (preg_match('/age:[ ]+?(\d+)/i', $headers, $age_matches) === 1) 
    {
        $age = $age_matches[1];
        if (preg_match('/cache-control:.+?max-age=(\d+)/i', $headers, $max_age_matches) === 1) {
            $valid_for = $max_age_matches[1] - $age;
            $fp = fopen($keys_file, "w");
            ftruncate($fp, 0);
            fwrite($fp, "" . (time() + $valid_for));
            fflush($fp);
            // $fp will be closed outside, we don't have to
            $fp_keys = fopen($keys_file, "w");
            if (flock($fp_keys, LOCK_EX)) {
                fwrite($fp_keys, $raw_keys);
                fflush($fp_keys);
                flock($fp_keys, LOCK_UN);
            }
            fclose($fp_keys);
        }
    }
}

/**
 * Retrieves the downloaded keys.
 * This should be called anytime you need the keys (i.e. for decoding / verification).
 * @return null|string
 */
function getKeys()
{
   global $keys_file;
    $fp = fopen($keys_file, "r");
    $keys = null;
    if (flock($fp, LOCK_SH)) {
        $keys = fread($fp, filesize($keys_file));
        flock($fp, LOCK_UN);
    }
    fclose($fp);
    return $keys;
}

プログラム全文

私の説明だとわかりにくいので全文をのせます

クリックすると展開されます(長文なので注意)

<?php
require_once "vendor/autoload.php";
use \Firebase\JWT\JWT;

//CORSの設定 
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept');

$method = "";
$headers = "";

if(isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD'])) {
    $method = $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD'];
}
if(isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'])) {
    $headers = $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'];
}

header('Access-Control-Allow-Method: ' . $method);
header('Access-Control-Allow-Headers: ' . $headers);

if($_SERVER["REQUEST_METHOD"] == "OPTIONS"){
    http_response_code(204) ;
    exit();
}

//AuthorizationのBearer Tokenからjwtを取得
$headers = apache_request_headers();
if (!isset($headers["Authorization"])) {
    //throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key:'. $header->kid);
    http_response_code( 401 ) ;
    exit();
} else {
    $AuthorizationData = $headers["Authorization"];
    $Authorization_array = explode(" ", $AuthorizationData);
    $jwt = $Authorization_array[1];
}

//JWTの認証 /tmpはdynoの再起動で消えます(heroku以外の場合はapacheが書き込みがある所を指定する
$keys_file = "/tmp/securetoken.json"; // the file for the downloaded public keys
$cache_file = "/tmp/pkeys.cache"; // this file contains the next time the system has to revalidate the keys
$fbProjectId = "learn-firebase-masalib";   //  MUST REPLACE <YOUR FIREBASE PROJECTID> with your own!
$verified_array = verify_firebase_token($jwt);
if (isset($verified_array["error"])) {
    http_response_code( 401 ) ;
    exit();
}
if (!isset($verified_array["UID"])) {
    http_response_code( 401 ) ;
    exit();
}

//データ取得部分
$json = array(
    array('name'=>'Google', 'url'=>'https://www.google.co.jp/'),
    array('name'=>'Yahoo!', 'url'=>'http://www.yahoo.co.jp/'),
);

 
header("Content-Type: text/javascript; charset=utf-8");
http_response_code(200) ;
echo json_encode($json); // 配列をJSON形式に変換してくれる
//echo json_encode($verified_array); // 配列をJSON形式に変換してくれる


/////// FROM THIS POINT, YOU CAN COPY/PASTE - NO CHANGES REQUIRED
///  (though read through for various comments!)
function verify_firebase_token($token = '')
{
    global $fbProjectId;
    $return = array();
    $userId = $deviceId = "";
    checkKeys();
    $pkeys_raw = getKeys();
    if (!empty($pkeys_raw)) {
        $pkeys = json_decode($pkeys_raw, true);
        try {
            $decoded = \Firebase\JWT\JWT::decode($token, $pkeys, ["RS256"]);
            if (!empty($_GET['debug'])) {
                echo "<hr>BOTTOM LINE - the decoded data<br>";
                print_r($decoded);
                echo "<hr>";
            }
            if (!empty($decoded)) {
                // do all the verifications Firebase says to do as per https://firebase.google.com/docs/auth/admin/verify-id-tokens
                // exp must be in the future
                $exp = $decoded->exp > time();
                // ist must be in the past
                $iat = $decoded->iat < time();
                // aud must be your Firebase project ID
                $aud = $decoded->aud == $fbProjectId;
                // iss must be "https://securetoken.google.com/<projectId>"
                $iss = $decoded->iss == "https://securetoken.google.com/$fbProjectId";
                // sub must be non-empty and is the UID of the user or device
                $sub = $decoded->sub;
                if ($exp && $iat && $aud && $iss && !empty($sub)) {
                    // we have a confirmed Firebase user!
                    // build an array with data we need for further processing
                    $return['UID'] = $sub;
                    $return['email'] = $decoded->email;
                    $return['email_verified'] = $decoded->email_verified;
                    $return['name'] = $decoded->name;
                    $return['photo'] = $decoded->photo;
                    $return['picture'] = $decoded->picture;
                    //var_dump($decoded);
                } else {
                    if (!empty($_GET['debug'])) {
                        echo "NOT ALL THE THINGS WERE TRUE!<br>";
                        echo "exp is $exp<br>ist is $iat<br>aud is $aud<br>iss is $iss<br>sub is $sub<br>";
                    }
                    /////// DO FURTHER PROCESSING IF YOU NEED TO
                    // (if $sub is false you may want to still return the data or even enter the verified user into the database at this point.)
                }
            }
        } catch (\UnexpectedValueException $unexpectedValueException) {
            $return['error'] = $unexpectedValueException->getMessage();
            if (!empty($_GET['debug'])) {
                echo "<hr>ERROR! " . $unexpectedValueException->getMessage() . "<hr>";
            }
        }
    }
    return $return;
}
/**
* Checks whether new keys should be downloaded, and retrieves them, if needed.
*/
function checkKeys()
{
    global $cache_file;
    if (file_exists($cache_file)) {
        $fp = fopen($cache_file, "r+");
        if (flock($fp, LOCK_SH)) {
            $contents = fread($fp, filesize($cache_file));
            if ($contents > time()) {
                flock($fp, LOCK_UN);
            } elseif (flock($fp, LOCK_EX)) { // upgrading the lock to exclusive (write)
                // here we need to revalidate since another process could've got to the LOCK_EX part before this
                if (fread($fp, filesize($cache_file)) <= time()) 
                {
                    refreshKeys($fp);
                }
                flock($fp, LOCK_UN);
            } else {
                throw new \RuntimeException('Cannot refresh keys: file lock upgrade error.');
            }
        } else {
            // you need to handle this by signaling error
        throw new \RuntimeException('Cannot refresh keys: file lock error.');
        }
        fclose($fp);
    } else {
        refreshKeys();
    }
}

/**
 * Downloads the public keys and writes them in a file. This also sets the new cache revalidation time.
 * @param null $fp the file pointer of the cache time file
 */
function refreshKeys($fp = null)
{
    global $keys_file;
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com");
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
    curl_setopt($ch, CURLOPT_HEADER, 1);
    $data = curl_exec($ch);
    $header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
    $headers = trim(substr($data, 0, $header_size));
    $raw_keys = trim(substr($data, $header_size));
    if (preg_match('/age:[ ]+?(\d+)/i', $headers, $age_matches) === 1) 
    {
        $age = $age_matches[1];
        if (preg_match('/cache-control:.+?max-age=(\d+)/i', $headers, $max_age_matches) === 1) {
            $valid_for = $max_age_matches[1] - $age;
            $fp = fopen($keys_file, "w");
            ftruncate($fp, 0);
            fwrite($fp, "" . (time() + $valid_for));
            fflush($fp);
            // $fp will be closed outside, we don't have to
            $fp_keys = fopen($keys_file, "w");
            if (flock($fp_keys, LOCK_EX)) {
                fwrite($fp_keys, $raw_keys);
                fflush($fp_keys);
                flock($fp_keys, LOCK_UN);
            }
            fclose($fp_keys);
        }
    }
}

/**
 * Retrieves the downloaded keys.
 * This should be called anytime you need the keys (i.e. for decoding / verification).
 * @return null|string
 */
function getKeys()
{
   global $keys_file;
    $fp = fopen($keys_file, "r");
    $keys = null;
    if (flock($fp, LOCK_SH)) {
        $keys = fread($fp, filesize($keys_file));
        flock($fp, LOCK_UN);
    }
    fclose($fp);
    return $keys;
}


?>

感想

  • ブログだと簡単そうに書いていますが、20時間ぐらいかかりました。サンプルsourceが理解できず1行1行実行して確認しました。
  • 公式のサンプルがないって本当につらい。公式が関数を用意してくれよ
  • CORSの部分が少し理解できた気がする・・・
  • がんばれば既存のシステムと連動できそう。
  • HerokuでもローカルPCでも問題なくToken認証ができました。他の言語も作っておきたい。