LineソーシャルログインをPHPで
以前、Googleでソーシャルログイン(OAuth2.0)の実装をしました。今回はLineでそれをしながら、前回の記述の補足(リフレッシュトークンやPKCEの実装)をしていきたいと思います。
OpenID Connect
OpenID Connectは OAuth2.0プロトコルを基盤とした認証プロトコルで、シングルサインオンで広く利用されています。
これを利用するための設定が各プロバイダーにより公開されており、それを取得するためのAPIポイントは 「 OpenID Provider Configuration Endpoint 」や「 Well Known Endpoint 」と呼ばれます。
たとえば、Lineの Well Known Endpointは、https://access.line.me/.well-known/openid-configuration となっており、ここにアクセスすると次のようなJSONオブジェクトを取得できます。
well known endpoint
{
"issuer": "https://access.line.me",
"authorization_endpoint": "https://access.line.me/oauth2/v2.1/authorize",
"token_endpoint": "https://api.line.me/oauth2/v2.1/token",
"revocation_endpoint": "https://api.line.me/oauth2/v2.1/revoke",
"userinfo_endpoint": "https://api.line.me/oauth2/v2.1/userinfo",
"scopes_supported": ["openid", "profile", "email"],
"jwks_uri": "https://api.line.me/oauth2/v2.1/certs",
"response_types_supported": ["code"],
"subject_types_supported": ["pairwise"],
"id_token_signing_alg_values_supported": ["ES256"],
"code_challenge_methods_supported": ["S256"]
}
これらは頻繁に変更されるものではありませんが、API利用時は定期的に状態を確認した方が安定したソーシャルログインの運用が望めます。
上記の項目を説明していきます。
- issuer
認証サービスの発行者です。セキュリティの観点から、この値と後から取得するIDトークンのiss(issuer)と一致するか確認する必要があります。
- authorization_endpoint
ページにログインする際に、ユーザーの認証(ログインIDとパスワード入力)と、ユーザー情報提供の承諾を得るためのページのアドレスです。
- token_endpoint
サーバーがユーザー情報を取得する為のトークンを取得するためのAPIエンドポイントのURLです。
- revocation_endpoint
トークンを破棄するためのAPIポイントのURLです。
- userinfo_endpoint
サーバーがトークンを使ってユーザー情報を問い合わせする際のAPIエンドポイントです。
- scopes_supported
APIがサポートしている、スコープ(情報の範囲)のリストです。
- jwks_uri
APIポイントの公開鍵に関する情報取得用のAPIエンドポイントです。
- response_types_supported
APIがサポートしているレスポンスタイプのリストです。
- subject_types_supported
APIがサポートしているsub(subject)値のタイプです。
この値が pairwise だとユーザー識別子の生成が、問い合わせ元毎に異なる値であることを示します。
pulic だと、ひとつのユーザー識別子を複数の問い合わせ元が共有します。
- id_token_signing_alg_values_supported"
IDトークンの署名時にサポートするアルゴリズムです。
- code_challenge_methods_supported
セキュリティ拡張機能であるProof Key for Code Exchange で、サポートされるコードチャレンジのハッシュアルゴリズムです。
コードチャレンジは、クライアントが認証リクエストを送信する際に送られます。S256の場合、コードチャレンジをSHA-256でハッシュ化した後にBase64 URLエンコードする必要があります。
Base64 URLエンコードは、Base64エンコード後URLに影響のある+を-に/を_に置換し最後のパディングの=を除去したものになります。
事前準備
Lineアカウント認証は無料で利用可能です(執筆時点)。
ここでは大まかな、Lineアカウント認証登録の流れを紹介します。
- アカウント作成
まず、Line Developersのアカウントを作ります。
携帯にLineが登録されていればそのIDを利用して Developers のアカウントを作成できます。
- プロバイダー作成
プロバイダーとはサービスの提供者を意味します。
- チャンネルの作成
チャンネルの作成、チャンネルはプロバイダーが提供するサービスです。ここで、チャンネルの種類として「 Lineログイン 」を選択します。
チャンネルを作成すると、「 チャンネルID 」と「 チャンネルシークレット 」が割り振られます。これらが後のOAuth認証に使う「 クライアントID 」と「 クライアントシークレット 」に相当します。
- チャンネル基本設定の入力
チャンネル名や、所在地、説明を記入します。プライバシーポリシーは作成して自身のページにアップロードした後で、URLを入力します。
- コールバックアドレスの設定
コールバックアドレスは、ユーザーがLINEログイン後に戻ってくる、自身のサーバーのアドレスを指します。
後のコードでも「 コールバックURL 」を設定してOAuthサーバーに送信しますが、それがここに事前登録されたアドレスでないとエラーになります。
チャンネル設定の内の「 LINEログイン設定 」にコールバックアドレスを入力する箇所があります。
これでおおよその設定ができました。チャンネル生成直後は非公開となっており、開発者のID以外はLineログインを利用できない状態となっています。ログイン機構が完成したら、公開することにより一般のユーザーのログインを受け付けられるようになります。
実装
LineログインをPHPで実装していきます。
まず、先に説明したように、LineのOpenID(OAuth)に関する情報を取得します。LINE_AUTH_CONF_URL 定数にはLineの Well Known Endpoint のアドレスが入ります。
$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(LINE_AUTH_CONF_URL, false, stream_context_create($options)));
アクセスしてきたユーザーをLineログインのページにリダイレクトさせるためには次のようにします。
定数の LINE_AUTH_CLIENT_ID にはLine Developersで取得したクライアントID、LINE_AUTH_CALLBACK_URL には認証ページから戻ってくるページを指定します。
クロスサイトリクエストフォージェリ対策(リクエストとレスポンスの整合性の確認)にstateと、リプレイアタック対策(IDトークンの再利用禁止と正確性の確認)にnonceを設定します。
形状を変えていますがともにセキュアにランダムな値なら何でもいいようです。
function gotoLineAuth() {
//セッション開始
session_start();
$_SESSION['state']=bin2hex(random_bytes(30));
$_SESSION['nonce']=(string)random_int(1000000, 9999999).'-'.(string)random_int(1000000, 9999999).'-'.(string)random_int(1000000, 9999999);
$params = array(
'client_id' => LINE_AUTH_CLIENT_ID,
'response_type' => 'code',
'scope' => 'openid profile',
'state' => $_SESSION['state'],
'nonce' => $_SESSION['nonce'],
'redirect_uri' => LINE_AUTH_CALLBACK_URL,
);
header('Location: '.$_SESSION['doc']->authorization_endpoint.'?'.http_build_query($params));
exit();
}
先のLineログイン管理ツールでコールバックURLとして登録するページ(LINE_AUTH_CALLBACK_URL)のコードを生成します。コードの流れは、APIサーバーから得られたレスポンスが正常なものかチェックした後で、アクセストークンを取得します。その後得られたアクセストークンを使ってユーザー情報の問い合わせをする事ができますが、ログイン後のアクセストークン取得時のレスポンスにはJWTとしてユーザー情報も含まれるので、ここではそれを利用しています。
LINE_AUTH_CLIENT_ID には自身のLine Developersで発行されたチャンネルID、LINE_AUTH_CLIENT_SECRET にはチャンネルシークレットを設定してある前提となっています。
function lineAuthCallBack() {
//lineログインからのコールバック
//セッション開始
session_start();
// state値チェック
if ($_GET['state'] !== $_SESSION['state']) {
//不一致
die();
}
//アクセストークン取得用パラメータ
$params = array(
'code' => $_GET['code'],
'client_id' => INE_AUTH_CLIENT_ID,
'client_secret' => LINE_AUTH_CLIENT_SECRET,
'redirect_uri' => LINE_AUTH_CALLBACK_URL,
'grant_type' => 'authorization_code',
);
//HTTPヘッダの定義
$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)));
if(!$response || isset($response->error)){
//アクセストークン取得失敗
die();
}
//JWTからユーザー情報取得
$jwt = explode('.',$response->id_token);
//JWTを.で分割後index=1からユーザー情報が取得可能
$userInfo = json_decode(base64_decode($jwt[1]));
if ($userInfo->exp < time()) {
//期限切れ
die();
}
if ($userInfo->nonce !== $_SESSION['nonce']) {
//ナンス不一致
die();
}
if ($userInfo->iss !== $_SESSION['doc']->issuer) {
//issuer不一致
die();
}
if ($userInfo->aud !== LINE_AUTH_CLIENT_ID) {
//自分のユーザーIDではない
die();
}
//ユーザーIDに対しての処理
//セッション変数にアクセストークンとリフレッシュトークンを保存
$_SESSION['access_token']=$response->access_token;
$_SESSION['refresh_token']=$response->refresh_token;
//ログイン成功
return true;
}
id_tokenプロパティにJWT(JSON WEB TOKEN)が入っていて、ここから目的の情報を取得することができます。JWTの詳細についてはGoogleアカウント認証の記事の方を参考にしていただければ幸いです。
stateとnonce(ナンス)を使って自分がセットしたランダム値がその通りに戻っているかをチェックしています。
似たような形で使用していますがそれぞれ目的が違い、stateはユーザーがログインページから戻ってきた際、ナンスはアクセストークン取得時にチェックするような設計となっています。
もし、JWTから得られたユーザー情報の更新をチェックする等、この後アクセストークンを使ってエンドポイントにユーザー情報を問い合わせに行く場合は次のようなコードになります。
$options = [
'http' => [
'method' => 'POST',
'header' => "Authorization: Bearer ".$_SESSION['access_token'],
],
];
// アクセストークンを使用して保護されたリソースにアクセス
$response = @file_get_contents($_SESSION['doc']->userinfo_endpoint, false, stream_context_create($options));
if ($response === false) {
$error = error_get_last();
$error_message = $error['message'];
if (strpos($error_message, '401') !== false) {
// アクセストークンが無効または期限切れ
} else {
// その他のエラー
}
} else {
// レスポンスのデコード
$userInfo = json_decode($response, true);
}
筆者がLine Loginで試したところ、userinfo_endpointから得られる値は次の通りでした。アイコンが設定してあればその情報も入ると思います。
( [sub] => [ユーザーID] [name] => [ユーザー名] )
リフレッシュトークン
アクセストークンはOAuthサーバーからユーザー情報を取得する為のトークンで、この有効期限は短く設定されている事が多いです。
アクセストークンの有効期限が切れた場合、再度ユーザーにログインを求めるのもひとつの方法ですが、リフレッシュトークンを使ってアクセストークンを再取得すると、ユーザーにログインを促すことなく再度ユーザー情報にアクセスすることができ、ユーザービリティが向上します。
リフレッシュトークンから、アクセストークンを取得する際は grant_type を refresh_token にします。
...
$params = [
'grant_type' => 'refresh_token',
'refresh_token' = > $_SESSION['refresh_token'],
'client_id' => INE_AUTH_CLIENT_ID,
'client_secret' => LINE_AUTH_CLIENT_SECRET,
];
$options = [
'http' => [
'header' => "Content-type: application/x-www-form-urlencoded\r\n",
'method' => 'POST',
'content' => http_build_query($params),
],
];
// リフレッシュトークンを使用して新しいアクセストークンを取得
$response = file_get_contents($_SESSION['doc']->token_endpoint, false, stream_context_create($options));
...以降は前述の処理と同様です
アクセストークンを再取得すると、アクセストークンは新しくなりますが、リフレッシュトークンは変わらない場合があります。
こちらも実験して得られたレスポンスを載せておきます。
( [access_token] => [新しいアクセストークン] [token_type] => Bearer [refresh_token] => [同一値のリフレッシュトークン] [expires_in] => 2592000 [scope] => profile openid )
リフレッシュトークンを使用した場合は、id_token(JWT)は戻ってこないので、新しいアクセストークン取得後エンドポイントにユーザー情報を照会しにいく必要があります。
PKCE
PHPなどのサーバーサイドのアプリではコードがユーザーから秘匿されている為、OAuthで利用されるクライアントシークレットが漏洩する可能性は低いです。
しかし、SPAやモバイルアプリなどクライアントで稼働するコードからOAuthを利用する場合は、コード解析によりクライアントシークレット漏洩する可能性があります。
このような状態のOAuthクライアントを、パブリッククライントと言います。
パブリッククライアント上ではクライアントシークレットを使わずに認証するPKCE(Proof Key for Code Exchange)という手法がとられます。
ここまでのコードをPHPで書いてきたのでサンプルコードもPHPで記述しますが、前述の理由でPHPではPKCE利用は必須ではありません。
-
まず、コードベリファイアを生成します。これは後から利用するので、セッション変数に保存しておきます。
$strCodeVerifier=bin2hex(random_bytes(32)); $_SESSION['verifier']=$strCodeVerifier;
-
次に、コードチャレンジを生成します。これはコードベリファイアをハッシュ化して、Base64 URLエンコードします。
ハッシュ化のアルゴリズムは先に問い合わせで得られたサポートされているアルゴリズムを用います。
もし plain が指定されていた場合は、コードベリファイアをそのままセットします。
$strCodeChallenge = $strCodeChallenge = rtrim(strtr(base64_encode(hash('sha256', $strCodeVerifier, true)),'+/', '-_'),"=");
-
認証ページへ行く際のパラメターに、code_challange と code_challenge_method を加えます。
$params = array( 'client_id' => GC_LINE_AUTH_CLIENT_ID, 'response_type' => 'code', 'scope' => 'openid profile', 'state' => $_SESSION['state'], 'nonce' => makeNonce(), 'redirect_uri' => LINE_AUTH_CALLBACK_URL, 'code_challenge' => $strCodeChallenge, 'code_challenge_method' => 'S256' );
-
認証から戻ってきた後、アクセストークンを取得する際のパラメタからクライアントシークレットを消し、かわりに先に生成したコードベリファイアを渡します。
$params = array( 'code' => $_GET['code'], 'client_id' => GC_LINE_AUTH_CLIENT_ID, //'client_secret' => GC_LINE_AUTH_CLIENT_SECRET, 'redirect_uri' => GC_LINE_AUTH_CALLBACK_URL, 'grant_type' => 'authorization_code', 'code_verifier' => $_SESSION['verifier'] );
先に、ハッシュ+Base64 URLエンコードして渡していた値と、ここで渡すベリファイアの値が違っていたらAPIサーバーはアクセストークンを返しません。
もし、パブリッククライアントでクライアントシークレットでの運用をしていた場合、認証コードを傍受されてしまうと、攻撃者はコード解析で得られたクライアントシークレットを使ってユーザー情報を参照することができてしまいます。
一方、動的に生成されるコードベリファイアを使うPKCEの場合はそれが漏れる可能性は前者よりも低いので、認証コード($_GET['code']部分)が漏れた場合でも攻撃が成立しにくくなります。
実際にパブリッククライアントで運用する場合は、PKCEのコードベリファイアで使う文字列は暗号学的にセキュアな文字にする点にも気を付ける必要があります。