GoogleのOAuth2.0でセキュアな認証
きっかけは、Search Consoleでした。「ユーザーログインでフィッシングの可能性を検出」というエラーが出ていいました。
経緯や詳細は書くと長くなるので折り畳みますが、とにかく従来式のAOuth2.0をそのまま使うとセキュリティ的に大きな問題があるようです。
その経緯や詳細
このブログでログイン機能があるのは「firebase で作成したオリジナルのいいねボタン」のユーザー識別のためのGoogleアカウント認証だけだったので、確認してみたところAPIが古くなっていたようでした。エラーはおそらく古くなったAPIにある脆弱性を指摘したものだと思われます。
それを受け、過去にGoogleアカウント認証(OAuth2)を設定した分は大丈夫なのかと確認したところ、@IT:「図解:OAuth 2.0に潜む「5つの脆弱性」と解決法」のような脆弱性があることがわかりました。もう随分と前からOAuth2は認証には使ってはいけないという流れになっていたようで勉強不足でした。@ITでは専門的な解説もありますが、TEC.LAB:「単なるOAUTH 2.0を認証に使うと、車が通れるほどのどでかいセキュリティー・ホールができる」についてでは、そのうちのひとつの脆弱性をわかりやすく解説してくれています。
それらの脆弱性に対しGoogleが放置するわけはないだろうと、さらに調べたところOpenIDコネクトに新しい設定方法が載っていました。
そこで今回はGoogleのOpenID(OAuth2.0)をPHPで実装してみたいと思います。正しく実装すれば、GoogleのOAuth2.0はOpenID Connect仕様に準拠し OpenID認定も受けており、認証と承認の両方に使用できるということです。
GCPへの登録
まず、OAuthを利用するにはGCP(Google Plat Form)に登録しなければなりません。登録自体は簡単ですぐできます。ただしクレジットカードの登録を求められます。この辺りの流れを知りたいならGAE(Google App Engine)の構築をする際に筆者が登録した際の画面を少し載せていますので参考になればと思います。
有料サービスの設定をしなければ課金される事はありませんし、最初はお試し用のクレジットがあるので間違えて料金が発生するような設定にしても、無料で利用できます。
OAuth管理画面の設定
GCPへの登録を終えたら、OAuthを利用するのに、OAuth管理画面の設定をする必要があります。
OAuth管理画面とは「アクセスしてきたユーザーにWebサーバーの管理者にプライベート情報を渡しますがいいですか?」という確認ページです。ここに自分のWebサーバーがユーザーから受け取る項目を記載します。アクセスしてきたユーザーはこのページに書いてある内容を、Webサーバーに渡すことを承諾した際にGoogleアカウントでログインします。
認証IDを作成
次に認証IDを作成します。ここでの認証はGCPでOAuthを利用する際の認証を意味します。つまりWebサーバーが「アクセスしてきたユーザーの認証情報を取得する」APIを利用する為の認証となります。「OAuthクライアントID」を選択して作成します。
アプリケーションの種類は「ウェブアプリケーション」名前は好きな名前にします。今回はPHPで構成しますので、「承認済みの JavaScript生成元」は未設定でかまいません。「承認済みのリダイレクトURI」にユーザーがログイン処理した後に転送するページを指定します。ユーザーがこのページにリダイレクトされる時、Getパラメーターとしてユーザー情報にアクセスするためのコードがが付与されています。PHPでそれを利用して、ユーザー情報を取得します。決まってなければあとからでも設定できるので、空白にして作成を押します。
認証IDを作成すると、OAuth用のクライアントIDと利用時のクライアントシークレット(パスワード)が発行されます。それらはPHPのコードにセットする必要があるのでメモしておきます。
偽造防止状態トークンの作成
ここからは自サイトのコード(PHP)の実装になります。
まずは、偽造防止状態トークンというものを作成します。クロスサイトフォージェリ攻撃を防ぐための後から照合可能な単なる乱数です。
これは暗号学的に安全な乱数生成メソッドを使用して作成された30字程度の文字列を使う方法か、外に知られていないキーを使ってセッション状態をハッシュ化して得た値を使う方法があります。状態トークンの「状態」とはこの「セッション状態」の意味だと思われます。
とりあえず、前者を使うことにします。PHPでは、次の方法で32文字のランダムな文字列が生成できます。後から照合できるようにセッション変数として保存します。
$_SESSION['state']=bin2hex(random_bytes(128/8));
認証リクエストをGoogleに送信
次に、認証リクエストを送信します。これはGoogleのユーザー情報照会のAPIを使うための事前申請で、このレスポンスがそのままユーザー情報になるわけではありません。これに先立ってリクエストを送る宛先アドレスをDiscoveryドキュメントから取得します。ドキュメントがあるアドレスは、執筆時点では「https://accounts.google.com/.well-known/openid-configuration」)となっていました。
ブラウザでも表示できるので見てもらうとわかると思いますが、レスポンスはJSON文字列となっています。その中のauthorization_endpointが目的のアドレスになります。
このJSONの値は他の作業でも利用するので、オブジェクト化ししてセッション変数に入れておきます。
$headers = array('Content-Type: application/x-www-form-urlencoded');
$options = array('http' => array(
'method' => 'GET',
'header' => implode("¥r¥n", $headers),
));
$_SESSION['doc'] = json_decode(file_get_contents('https://accounts.google.com/.well-known/openid-configuration', false, stream_context_create($options)));
リクエスト本体を作成します。今回利用するパラメータは次の通りです。
- client_id(必須)
この値にはGCPで作成したOAuthクライアント情報のIDをセットします。
- response_type(必須)
今回のケースではcodeを指定します。
- scope(必須)
取得したい項目をセットします。ここでは「opnenid email」とします。
- nonce(必須)
ランダムな値をセットすることで、リプレイアタックからの保護が有効になります。
値はなんでもよさそうなのですが、例示では3つの7桁の数値をハイフンで区切った値を使用していたのでそのような文字列を生成しました。
- ridirect_url(必須)
応答の送信先です。GCP側に設定したアドレスにする必要があります。
- state(強く推奨)
stateには先ほど生成した偽造防止用のトークンの値をセットします。他に、「ユーザーがアプリケーションに戻ったときにコンテキストを回復するために必要なその他の情報(開始URLなど)を含める必要があります」とあり、Googleの例示では「security_token=138r5719ru3e1&url=https://oauth2-login-demo.example.com/myHome」をURIエンコードしたものをセットしているようです。筆者は偽造防止用のトークンの値だけをそのままの状態でセットしましたが、稼働に問題はなさそうでした。
stateのこの値を使ってあとから自身が要求したリクエストであることを確認します。前述したようにクライアントのブラウザの状態を示す変数をハッシュ化すると、要求と応答が同じブラウザで行われている事の証明になり、さらに効果が高まります。
ほかにもオプション値がありますので気になるようでしたらOpenIDコネクトドキュメントを確認してみてください。これらの値をURLパラメータとして、先ほど取得したアドレスに付け加えます。そしてエンドユーザーを作成したアドレスにリダイレクトさせます。
エンドユーザーはリダイレクト先でGoogleから自分の情報を公開してもいいかの確認を求められます。承諾する場合はGoogleアカウントでのログインを求められます。
$params = array(
'client_id' => GC_GOOGLE_AUTH_CLIENT_ID, //APIのID
'response_type' => 'code',
'scope' => 'openid email',
'nonce' => makeNonce(),
'redirect_uri' => GC_GOOGLE_AUTH_CALLBACK_URL, //リダイレクトURL
'state' => $_SESSION['state'],
);
header('Location: '.$_SESSION['doc']->authorization_endpoint.'?'.http_build_query($params));
function makeNonce() {
//ナンス生成関数
return (string)random_int(1000000, 9999999).'-'.(string)random_int(1000000, 9999999).'-'(string)random_int(1000000, 9999999);
}
偽造防止状態トークンの確認
エンドユーザーが情報提供に同意しログインに成功すると、ユーザーはredirect_urlに指定したアドレスにリダイレクトで戻ってきます。
このとき、Googleからの必要な応答がURLパラメータに含まれています。
そこからstateの項目を取得して、先に設定していたstateの値と一致するかを確認します。差異があった場合は、不正アクセスとして処理を中断させます。
if ($_GET['state'] !== $_SESSION['state']) {
//不正アクセスの可能性あり
}
IDトークンとアクセストークンの取得
stateの値に問題がなければ、戻ってきたURLパラメーターのcodeにセットされている値を使って再度Googleのサーバーにアクセスします。今度の宛先は先ほど取得したDiscoveryドキュメントの「token_endpoint」の項目に含まれるアドレスになります。また今度は、APIのsecret(パスワード)を送信するので、URLパラメータではなくPOST本文中にパラメータを設定します。
- code
先のレスポンスで取得したcodeパラメーターの値
- client_id
GCPで設定されているclient_id
- client_secret
GCPで設定されているclient_secret
- redirect_url
前の処理で設定したリダイレクト用のURLと同じ値です。パラメータとして渡しますが2回目の処理ではリダイレクトは発生しません。
- grant_type
「authorization_code」という値
これらはサーバー内での処理として実行します。
$params = array(
'code' => $_GET['code'],
'client_id' => GC_GOOGLE_AUTH_CLIENT_ID, // APIのID
'client_secret' => GC_GOOGLE_AUTH_CLIENT_SECRET,//APIのパスワード
'redirect_uri' => GC_GOOGLE_AUTH_CALLBACK_URL,//リダイレクトURL
'grant_type' => 'authorization_code', //固定文字列
);
$headers = array('Content-Type: application/x-www-form-urlencoded',);
$options = array('http' => array(
'method' => 'POST',
'content' => http_build_query($params),
'header' => implode("¥r¥n", $headers),
));
$response = json_decode(file_get_contents($_SESSION['doc']->token_endpoint, false, stream_context_create($options)));
ユーザー情報の取得
先の処理で問題がなければ$responseにオブジェクトが返ってきます。
その中のアクセストークン(access_token)があり、この値を使えば、以前と同じようにそれを利用してユーザー情報を取得することができます。
ユーザー情報を取得する際のアドレスはDiscoveryドキュメントの「userinfo_endpoint」の項目になります。この場合はURLパラメータを使って送信します。
$userInfo = json_decode(file_get_contents($_SESSION['doc']->userinfo_endpoint.'?'.'access_token='.$response->access_token));
ここから新しい話になりますが、戻ってきた$responseデータにはIDトークン(id_token)というものもデータに含まれます。これは署名とBase64エンコードされたJSONオブジェクト(JWT:JSON Web Token)です。わざわざアクセストークンを使ってユーザー情報を取得しないでも、この中にすでに目的のデータが含まれています。
DevelopersIO:「Amazon API Gatewayの新機能「HTTP API」のJWT Authorizersを理解する」によれば、この値は.(半角ピリオド)で区切られてヘッダ、ペイロード、署名の3つのパートになっています。
これを分割して、base64をデコードしてやると、ヘッダに署名のアルゴリズムがJSON形式で、ペイロードには目的の情報がJSON形式で、署名はバイナリ形式で復元されます。
そのペイロードの部分をJSONをオブジェクトにすると、emailプロパティにメールアドレス、subプロパティにユーザー識別子、expプロパティに有効期限等が入ってきます。
ちなみにsubはメールアドレスを変更した後でも同じユーザーなら同じ値になるそうです。
$jwt = explode('.',$response->id_token);
//分割した3つのパートのうちペイロードだけ取得します
$userInfo = json_decode(base64_decode($jwt[1]));
if ($userInfo->exp < time()) return false; //有効期限切れ
if ($userInfo->iss !== 'https://accounts.google.com') return false; //Googleからのレスポンスではない
if ($userInfo->aud !== GC_GOOGLE_AUTH_CLIENT_ID) return false; //自分のAPIクライアントIDではない
echo $userInfo->email;
これでGoogleアカウント認証の工程は終わりです。
Googleアカウントからユーザーをログアウトさせる場合、リクエストを送信する宛先はDiscoveryドキュメントの「revocation_endpoint」の項目になります。また、先ほど取得したアクセストークンが必要です。これはPOSTで送信しないとNot Foundエラーとなってしまうようです。
先のコードには書いてありませんでしたが、ログイン成功時に$_SESSION['google_auth_access_token']にアクセストークンを記憶している前提だと、次のようなコードでログアウト処理ができます。
$params = array(
'token' => $_SESSION['google_auth_access_token'],
);
$headers = array('Content-Type: application/x-www-form-urlencoded',);
$options = array('http' => array(
'method' => 'POST',
'content' => http_build_query($params),
'header' => implode("¥r¥n", $headers),
));
$response = json_decode(file_get_contents($_SESSION['doc']->revocation_endpoint, false, stream_context_create($options)));
JWTの検証
通常はIDトークンを受け取った際、そのデータが本当に意図しているサーバーからきているのかを検証する必要があります。ただし今回の場合はWebサーバーがGoogleのサーバーと直接やり取りをしている上にsecret(パスワード)での認証もしているので、相手が間違いないということで検証は省略できます。
検証をする場合は、デバッグ目的ならhttps://oauth2.googleapis.com/tokeninfo?id_token=の後にJWTの値($response->id_tokenで取得できる文字列)を付け加えて実行することで、復号したデータが得られ、正しく複合できれば、署名は正しいということになります。
コード内で検証する必要がある場合は、ヘッダ.ペイロード部を署名した結果が署名部になるので、先のDiscoveryドキュメントからjwks_uriにあるアドレスにアクセスしGoogleの公開鍵を探して(公開鍵は複数あり、ヘッダ部のKIDと値が一致するものが目的のものです)、署名部を復号して一致するかを検証します。これらのコードを実装する際はJWT.ioなどに便利なライブラリがあるそうです。
参考にさせていただいたサイトの皆様、ありがとうございました。