SNSを使用したログイン – Google 実装編

投稿者: | 2018年6月29日

各種情報

前提条件

  • 言語はPHPで、バージョンは 5.6 を想定
  • フレームワークに CodeIgniter 3 を使用
  • WebサイトのURLは、https://example.com と想定して説明します

参考URL

多用している CodeIgniter のコマンド

redirect(リダイレクト先, リダイレクトメソッド, HTTPレスポンスコード)

リダイレクト先へリダイレクトします。リダイレクト先に ‘login’ と指定していれば、Loginコントローラーのindexメソッドへリダイレクトします(http://example.com/login/index)。素のPHPであれば、header(‘Location: http://example.com/login/index’); のようになります。

$this->input->get_post('・・・')
$this->input->post('・・・')

$_GET$_POSTから値を取り出します。その値(項目)がないときは、null を返してくれます。get_post() は先に $_GET を調べて、値がなければ $_POST を調べてくれます。post()$_POSTだけを調べます。

$this->load->library('・・・')
$this->load->view('・・・')

ライブラリやビューとして作成した PHP ファイルをインクルードします。require_once '・・・' と同じ意味です。

random_string('md5')

ランダムな文字列を作成する関数です。'md5' を指定しているのはちょうど 32 文字の文字列を返してくれるからで、他意はありません。推測されにくいランダムな英数字の文字列であることが重要です。

Googleログイン用ライブラリの作成(Google_library.php)

Googleログイン専用のライブラリを作成しておきます。CodeIgniterではクラスとして作成します。
ログインにはクライアントIDやクライアントシークレットが必要となるので、変数など(下記コード例ではprivateメンバー)に入れておきます。
Google_library.php

<?php
defined('BASEPATH') or exit('No direct script access allowed');

/**
 * Googleログインライブラリ
 */
class Google_library
{
    /**
     * フィールド
     */
    private $ci;

    // アプリ情報
    private $clientId = '000000000000000000000000000000000.apps.googleusercontent.com';
    private $clientSecret = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';

    // リダイレクトURL
    private $redirectUrl = 'https://example.com/login/google';

    // エンドポイント
    private $apiAuth = 'https://accounts.google.com/o/oauth2/v2/auth?';
    private $apiAccessToken = 'https://www.googleapis.com/oauth2/v4/token';
    private $apiTokenInfo = 'https://www.googleapis.com/oauth2/v3/tokeninfo';
    private $apiOpenidConfiguration = 'https://accounts.google.com/.well-known/openid-configuration';

    // ペイロード検証データ
    private $issText = 'accounts.google.com';

    /**
     * コンストラクタ
     */
    public function __construct()
    {
        $this->ci = &get_instance();

        // cURLのライブラリをロードしておく
        $this->ci->load->library('curl_library');
    }
}

エンドポイントは公式ドキュメントを参照して、正しく入力してください。特に、他のSNSと違ってエンドポイントごとにバージョンが異なっているので、注意が必要です。

Loginコントローラーの準備

Google_libraryライブラリをあらかじめロードしておくように、コンストラクターを修正します。
Login.php

public function __construct()
{
    parent::__construct();
    $this->load->library('line_library');
    $this->load->library('facebook_library');
    $this->load->library('instagram_library');
    
    // 以下の行を追加(Google_libraryライブラリをロードする)
    $this->load->library('google_library');
}

index メソッドに以下のようにコマンドを追加します。
Login.php

public function index()
{
    // 押されたボタンによってログインの処理を分岐
    switch ($this->input->post('submit')) {
        case 'line':
            redirect('login/login_line', 'location', 302);
            break;

        case 'yahoo':
            break;

        case 'twitter':
            break;

        case 'instagram':
            redirect('login/login_instagram', 'location', 302);
            break;

        case 'facebook':
            redirect('login/login_facebook', 'location', 302);
            break;

        case 'google':
            // 以下の行を追加(login_googleメソッドへリダイレクト)
            redirect('login/login_google', 'location', 302);
            break;
    }

Google認証ページへリダイレクトする(login_googleメソッド)

login_googleメソッドを作成します。このメソッドがGoogleログインの最初のステップです。
Google認証ページのエンドポイントへ、必要なパラメーターとともにリダイレクトします。Google認証ページではユーザーがGoogleにログイン(または、キャンセル)します。
Login.php

/**
 * Googleによるログインを開始する
 */
public function login_google()
{
    $endpoint = $this->google_library->getAuthorize();
    header('Location: ' . $endpoint);
    exit;
}

Google_library ライブラリの getAuthorize メソッドでエンドポイントの URL を作成します。

getAuthorizeメソッドの作成(Google_libraryライブラリ)

Google_library ライブラリに getAuthorize メソッドを追加します。
Google_library.php

/**
 * 認証エンドポイントのURLを返す
 * @return  string  認証エンドポイント
 */
public function getAuthorize()
{
    // ここで state を作成して認証エンドポイントへ送信するのがセキュリティ的に推奨されるが、
    // Googleからのレスポンスで state はフラグメントに含まれるため、
    // PHP で検証する手段がない。

    // URLに追加するパラメーター
    $params = [
        'client_id' => $this->clientId,
        'redirect_uri' => $this->redirectUrl,
        'scope' => 'profile email',
        'access_type' => 'offline',
        'prompt' => 'consent',
        'response_type' => 'code',
    ];

    // スペースは %20 にエンコードする必要がある
    return $this->apiAuth . http_build_query($params, '', '&', PHP_QUERY_RFC3986);
}

エンドポイントへ送信するパラメーターは以下のとおりです。

パラメーター必須内容
client_id必須クライアントID
redirect_uri必須リダイレクトURL。登録したURLを指定します。大文字小文字、末尾のスラッシュなど、1字でも違うと認証できません。
scope必須アプリが求める権限。Googleは多くのサービスを提供しているため、スコープが多岐にわたります。ユーザーの基本的な情報を求める場合、'profile'を指定します。また、メールアドレスが必要な場合、'email' を指定します。複数のスコープは空白で区切ります。
access_type任意(推奨)必須ではありませんが、'offline' を指定することをおすすめします。
state任意(推奨)CSRF対策のランダムな文字列。設定することをGoogleが推奨していますが、PHPで検証する手段がありません。
include_granted_scopes任意認証済みのアプリのスコープを拡大したいときに、認証済み部分についての取り扱いを決めます。今回は設定しません。
login_hint任意アプリで会員登録済みの会員が再認証する場合など、既知のユーザーがログインするときに、Googleのログイン画面にあらかじめメールアドレスなどが表示されるようにするためのヒントです。ログインしようとするユーザーのメールアドレスやGoogleでのユーザーIDを指定します。
prompt任意Googleログイン画面でのプロンプトの表示方法を指定します。省略した場合、初めてのログイン時だけプロンプトを表示します。
上記コード例では 'consent' を指定しています。

パラメーターは、空白が %20 になるようにエンコードします。具体的には、以下のように http_build_query の第4引数(enc_type)に PHP_QUERY_RFC3986 を指定します。

http_build_query($params, '', '&', PHP_QUERY_RFC3986)

エンコードしたパラメーターを認証エンドポイントのURLにつなげます。

Google認証のコールバック処理(googleメソッドの作成)

ユーザーが Google 認証画面でログイン、またはキャンセルすると、指定したリダイレクト URL へ処理が戻ってきます。今回のコード例では Login コントローラーの Google メソッドに戻ってくるようにしているので、Google メソッドを作成します。

/**
 * Google認証のコールバックを処理する
 */
public function google()
{
    // レスポンスにerrorが含まれているときはエラー
    // if (isset($_GET['error'])) { エラー処理 }
    if (empty($this->input->get_post('error')) === false) {
        $_SESSION['message'] = 'ログインがキャンセルされました。';
        redirect('login', 'location', 302);
    }

    // 認証コードを取得する
    $code = $this->input->get_post('code');

    // アクセストークンのリクエスト
    $token = $this->google_library->requestAccessToken($code);
    if ($token === false) {
        $_SESSION['message'] = 'アクセストークンの取得に失敗しました。';
        redirect('login', 'location', 302);
    }

    // IDトークンを復号化する
    $user = $this->google_library->decodeIDToken($token['id_token']);
    if ($user === false) {
        $_SESSION['message'] = 'データの不正です。(IDトークンの復号失敗)';
        redirect('login', 'location', 302);
    }

    // アクセストークンのハッシュと有効期限を確認する
    if ($this->google_library->validateToken($token['access_token'], $user) === false) {
        $_SESSION['message'] = 'データの不正です。(アクセストークンのハッシュ、または有効期限のエラー)';
        redirect('login', 'location', 302);
    }

    // 結果の表示
    $viewdata = [
        'message' => 'Googleでログインしました。',
        'data' => [
            'access_token' => $token,
            'user' => $user,
        ],
    ];

    $this->load->view('logined', $viewdata);
}

認証結果のレスポンスは URL にパラメーターとして付いてきます。

ユーザーがログインをキャンセルした場合など、ログインできないときは error パラメーターが含まれます。error パラメーターがあるか確認し、あった場合はログイン処理を中止します。
error がなければ、パラメーターから認証コードを取得します。認証コードは code パラメーターで渡されます。

認証コードを使用して、アクセストークンをリクエストします。アクセストークンのリクエストは、Google_library ライブラリの requestAccessToken メソッドに実装します。このメソッドは後ほど作成します。
アクセストークンの取得に失敗した場合、ログイン処理を中止します。

アクセストークンをリクエストした際、アクセストークンとともにIDトークンを取得できます。IDトークンにはユーザー情報が含まれており、JWT形式でエンコードされています。IDトークンのデコードは Google_library ライブラリの decodeIDToken メソッドに実装します。このメソッドも後ほど作成します。
IDトークンのデコードに失敗した場合、ログイン処理を中止します。

IDトークンにはユーザー情報のほかに、セキュリティに関する情報も含まれます。取得したIDトークンが、アクセストークンと対になっているか確認するために、アクセストークンのハッシュと有効期限を検証します。検証は Google_library ライブラリの validateToken メソッドに実装します。このメソッドも後で作成します。

検証で問題なければ、ログイン完了とします。

アクセストークンのリクエスト(requestAccessTokenメソッドの作成)

Google_library ライブラリに requestAccessToken メソッドを追加します。requestAccessTokenメソッドではトークンをリクエストして、アクセストークン、リフレッシュトークン、IDトークンを取得します。
Google_library.php

/**
 * アクセストークンをリクエストする
 * @param   string  $code   認証コード
 * @return  mixed   取得成功:アクセストークンを含む配列 失敗:false
 */
public function requestAccessToken($code)
{
    // 送信するパラメータ
    $params = [
        'code' => $code,
        'client_id' => $this->clientId,
        'client_secret' => $this->clientSecret,
        'redirect_uri' => $this->redirectUrl,
        'grant_type' => 'authorization_code',
    ];

    // ヘッダーに追加する文字列を指定する
    $options = [
        CURLOPT_HTTPHEADER => [
            'application/x-www-form-urlencoded',
            'Host: www.googleapis.com',
        ],
    ];

    // crulをPOSTで実行
    $response = $this->ci->curl_library->execPostCurl($this->apiAccessToken, $params, $options);
    if (empty($response['response']) || (!isset($response['response']['access_token']))) {
        return false;
    }

    return $response['response'];
}

トークンのリクエストは token エンドポイントへ POST で実行します。リクエストに必要なパラメーターは以下のとおりです。

パラメーター内容
code認証コード
client_idクライアントID
client_secretクライアントシークレット
redirect_uri登録済みのリダイレクトURL。
認証のために必要で、リダイレクトはされません。
grant_type'authorization_code' を指定

curlのヘッダーに以下の文字列を入れる必要があります。

application/x-www-form-urlencoded
Host: www.googleapis.com

curlのレスポンスは、下表の項目を含む JSON形式の文字列となります(execPostCurl メソッドは連想配列で返す)。

項目内容
access_tokenアクセストークン。Googleの各種APIを利用するには、アクセストークンが必要になります。
id_tokenIDトークン。
ログインしたユーザーの情報が含まれます。JWTでエンコードされています。
expires_inアクセストークンの有効秒数
token_typeトークンのタイプ。常に 'Bearer' が返ってきます。
refresh_tokenリフレッシュトークン。アクセストークンの有効期限が切れたとき、アクセストークンの更新に使用します。リフレッシュトークンを使用すると、ログインの手順を踏まずに、アクセストークンの有効期限を延ばすことができます。

IDトークンをデコードする(decodeIDTokenメソッドの作成)

Google_library ライブラリに decodeIDToken メソッドを追加します。IDトークンのデコードを行うメソッドです。

IDトークンは JWT でエンコードされているため、最初に作成した JWT ライブラリでデコードできます。Google のIDトークンの署名は RS256 アルゴリズムが使用されているため、署名の検証には公開鍵が必要です。しかし、Google では公開鍵そのものを取得する手段を用意していないようです(すべてのドキュメントに目を通したわけではないので、絶対とは言い切れないが)。
では、どのように公開鍵を取得するかというと、公開鍵を復元するためのデータを取得できるようにしているので、そこから公開鍵を復元することになります。しかし、私が知る限りでは、PHPに公開鍵を復元するための関数は用意されておらず、何らかのライブラリを利用する必要がありそうです。

Googleでは公開鍵そのものを取得できないが、IDトークンをデコードして、ペイロードを返してくれる API を用意しています。今回はこの API を利用することにしています。
なお、作成した JWT ライブラリでもデコードしたペイロードを取得することは可能ですが、署名を無視することになるのでおすすめできません。
Google_library.php

/**
 * IDトークンを復号化して検証する
 * @param   string  array   $idToken    IDトークン
 * @return  mixed   検証成功:IDトークンのpayload    検証失敗:false
 */
public function decodeIDToken($idToken)
{
    // IDトークンの復号はJWTライブラリを使用する方法もあるが、
    // 署名の検証に必要な公開鍵の復元(取得)が困難であるため、
    // TokenInfoエンドポイントを使用した方法を使う。
    $params = [
        'id_token' => $idToken,
    ];

    // TokenInfoエンドポイントへIDトークンを送信すると、
    // 正しいIDトークンであればデコード結果が返ってくる。
    // GETで送信するため、セキュリティ面では少し問題あり
    $payload = $this->ci->curl_library->execGetCurl($this->apiTokenInfo, $params);

    // ペイロードのissが、Google指定のものと一致(または含まれている)か
    $iss = isset($payload['iss']) ? $payload['iss'] : '';
    if (mb_strpos($iss, $this->issText) === false) {
        return false;
    }

    // ペイロードのazpが、Client ID と一致するか
    $azp = isset($payload['azp']) ? $payload['azp'] : '';
    if ($this->clientId !== $azp) {
        return false;
    }

    // ペイロードのaudが、Client ID と一致するか
    $aud = isset($payload['aud']) ? $payload['aud'] : '';
    if ($this->clientId !== $aud) {
        return false;
    }

    return $payload;
}

IDトークンのデコードは、tokeninfo エンドポイントを使用します。エンドポイントへ送信するパラメーターには IDトークンを指定します。IDトークンが正しければ、デコードされたペイロードが返ってきます。

ペイロードのデータを検証して、送信したIDトークンの結果であるかを確認します。

ペイロードの iss に、IDトークンの発行者が記録されています。Googleの場合、以下のどちらかの値になります。

  • https://accounts.google.com
  • accounts.google.com

ペイロードの azp にはクライアントIDが入っています。アプリのクライアントIDと同じものが入っているか確認します。
ペイロードの aud にもクライアントIDが入っています。こちらも一致するか確認します。

アクセストークンを検証する(validateTokenメソッドの作成)

デコードしたIDトークンのペイロードには、アクセストークンのハッシュと、アクセストークンの有効期限が含まれています。これらを検証することで、IDトークンとアクセストークンが対になっていることを担保します。
Google_library.php

/**
 * アクセストークンのハッシュと有効期限を検証する
 * @param   string  $token      アクセストークン
 * @param   array   $payload    デコードされたペイロード
 * @return  bool    OK:true NG:false
 */
public function validateToken($token, $payload)
{
    // JWTライブラリをロードする
    $this->ci->load->library('jwt');

    // ペイロードに at_hash が含まれていれば、ハッシュを検証する
    if (isset($payload['at_hash'])) {
        // ペイロードのat_hashを取り出す
        $sig = $payload['at_hash'];

        // アクセストークンからハッシュを作成する
        $hash = hash('sha256', $token, true);
        $len = strlen($hash) / 2;
        $halfHash = substr($hash, 0, $len);
        $signature = $this->ci->jwt->encodeBase64Uri($halfHash);

        // ペイロードのat_hashと、アクセストークンから作成したハッシュが一致するか
        if ($sig !== substr($signature, 0, strlen($sig))) {
            return false;
        }
    }

    // ペイロードにexpが含まれていれば、有効期限を検証する
    if (isset($payload['exp'])) {
        $exp = intval($payload['exp']);

        // 現在時刻が有効期限を越えているときはNG
        if ($exp < time()) {
            return false;
        }
    }

    return true;
}

ペイロードの at_hash は、アクセストークンのハッシュです。at_hashと、アクセストークンから作成したハッシュを比較して、一致することを確認します。
ハッシュの作成手順は次のとおりです。

  1. sha256 アルゴリズムでアクセストークンからバイナリハッシュを作成します。
  2. ハッシュ先頭から半分のデータを取り出し、これを BASE64URL 形式にエンコードします。

上記の方法で作成した文字列と、at_hashの文字列が一致することを確認します(本来は一致しなければならないが、環境によっては strlen 関数の結果が変わる可能性があるため、上記のコード例では作成したハッシュの長さを合わせて比較している)。

ペイロードの exp はトークンの有効期限です。UNIX時刻で入っています。現在時刻が有効期限内にあることを確認します。

以上で Google ログインは完成です。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)