ゲストOS上のApache+PHP環境にて、ソーシャルログイン(LINEログイン)してログインユーザー情報を取得してみました。

ソーシャルログイン(LINEログイン)を触ってみました。
より現実的なソースとするため、セキュリティ対策系の処理も実施しています。
本実装でLINEログイン後に返却される情報は、
ログインユーザーのユーザーID、ユーザーの表示名、プロフィール画像のURLです。
以下の通り、サーバー側はゲストOS上のApache+PHP環境です。

# 本記事の環境
ホストOS：Windows10 Pro 64bit 8GB
ゲストOS：Red Hat Enterprise Linux release 8.3 (Ootpa)
Oracle VM VirtualBox：6.0.4a
vagrant：Vagrant 2.2.14
Apache：2.4.37
PHP：PHP 7.4.15
Tera Term：4.105
Google Chrome：96.0.4664.110
コマンドプロンプト
ホストOS側に実ソース
LINEログイン v2.1

# 注意事項等

* レート制限  
LINEログインのAPIに対して短時間に大量のリクエストを送信し、
LINEプラットフォームの動作に影響を与えると判断された場合、
一時的にリクエストを制限することがあります。
負荷テストを含め、いかなる目的でも大量のリクエストを送信しないでください。
LINEログインのAPIにおけるレート制限のしきい値は開示していません。  

https://developers.line.biz/ja/reference/line-login/#rate-limits

* ステータスコード
 
| ステータスコード | 説明 |
|:-----------|:------------|
|200 OK|リクエストが成功しました。|
|400 Bad Request|リクエストに問題があります。リクエストパラメータとJSONの形式を確認してください。|
|401 Unauthorized|Authorizationヘッダーを正しく送信していることを確認してください。|
|403 Forbidden|APIを使用する権限がありません。ご契約中のプランやアカウントに付与されている権限を確認してください。|
|413 Payload Too Large|リクエストのサイズが上限の2MBを超えています。リクエストのサイズを2MB以下にしてリクエストしなおしてください。|
|429 Too Many Requests|大量のリクエストでレート制限を超過したため、一時的にリクエストを制限しています。|
|500 Internal Server Error|APIサーバーの一時的なエラーです。|
https://developers.line.biz/ja/reference/line-login/#status-codes

* LINEログインバージョン情報

| バージョン | ステータス | 説明 |
|:-----------|:------------|:------------|
|LINEログイン v2.1|アクティブ|2017年9月28日にリリースされました。|
|LINEログイン v2.0|非推奨|2017年1月24日にリリースされました。時期は未定ですが廃止を予定。なお廃止時期の告知から、実際の廃止までは一定の猶予期間を置く予定。|
|LINEログイン v1 |廃止|2018年6月30日にすべての機能の提供を終了しました。|

https://developers.line.biz/ja/docs/line-login/overview/#versions

# 事前準備

* 作業用のLINEアカウントを用意する。※私は私用のLINEアカウントを使用しました。
* LINEログイン後に遷移する画面、処理(=コールバックURL、リダイレクト先URL)を決める。

# 構築手順

## LINE側の手順

* [[公式サイト]](https://developers.line.biz/ja/)を開く。
* 「ログイン」からログインする。
  * ログイン失敗の場合の対策の一例
      * スマホのLINEアプリの「設定」の「アカウント」から「ログイン許可」を実施。
      * 「ログイン」からログインする。
* 言語を「日本語」にする。※任意。
* LINE Developersアカウントを登録する。
* 「LINE Developers コンソール」(=コンソール（ホーム）)を開く。
![20211209_ソーシャルログイン(LINE)01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1009061/fab80831-6908-1ef2-6a90-fb720d3ddc32.png)
* 「新規プロバイダー作成」を選択し、プロバイダーを作成する。
* 作成したプロバイダーの「チャネル設定」の「LINEログイン」を選択し、チャネルを作成する。
![20211209_ソーシャルログイン(LINE)02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1009061/b16917f0-e480-be3f-848f-128af2ab18ee.png)
  * 今回は「アプリタイプ(ウェブアプリ/ネイティブアプリ)」を「ウェブアプリ」にしました。
* 「LINEログイン設定」タブを開く。
![20211209_ソーシャルログイン(LINE)03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1009061/013b7d6e-97c6-976d-ecf9-37f7c70f0413.png)
* 「コールバックURL」の設定を行う。※例：http://XXX.XXX.XXX.XXX/testline/test.php
* 「チャネル基本設定」タブを開く。
* 「チャネルID」をメモする。
* 「チャネルシークレット」をメモする。

## サーバー側(=ソース側)の手順

* セキュリティの観点でPKCE(ピクシー)とnonce(ノンス)を実装する。  
　　※ PKCE(Proof Key for Code Exchange)とは、認可コード横取り攻撃への  
　　　対策を目的としRFC7636で定義されているOAuth2.0拡張仕様。  
　　　ウェブアプリにPKCEの認可フローを導入することで、  
　　　「認可コード横取り攻撃」を防ぐことが可能。  
　　※ nonce(ノンス)は、暗号通信で用いられる使い捨てのランダムな値。  
　　　ノンスは大抵、認証過程で使われリプレイ攻撃を行えないようにする  
　　　働きを担っている。
  * PKCE(ピクシー)を実装するため、code_verifier(ベリファイア(認証、検証、等))とcode_challengeを生成し、code_verifierは後で使用するためセッションへ、code_challengeは即時使用するため画面側へ渡す。  
　　※code_verifier  
　　　半角英数字および記号からなる43〜128文字のランダムな文字列。  
　　　本パラメータを加えることで、LINEプラットフォーム側では  
　　　code_verifierの有効性を検証したうえでアクセストークンを返却します。  
　　※code_challenge  
　　　code_verifierをSHA256で暗号化したうえで、Base64URL形式にエンコードした値。
  * nonce(ノンス)を実装するため、nonceを生成し、セッションへ保存した後、画面へ渡す。  

```php
// code_verifierを生成する。
$rand_num = mt_rand(43, 128);
$byte_length = $rand_num;
$code_verifier = str_replace('=', '', strtr(base64_encode(openssl_random_pseudo_bytes($byte_length)), '+/', '-_'));

// code_verifierからcode_challengeを生成する。
$hash = hash('sha256', $code_verifier, true);
$code_challenge = str_replace('=', '', strtr(base64_encode($hash), '+/', '-_'));

// nonceを生成する。
$nonce = hash('sha512', openssl_random_pseudo_bytes(128));

// LINEログイン後に遷移する画面、処理で使用するため、
// セッションにcode_verifierとnonceを保存する。
session_start();
$_SESSION['user_code'] = $code_verifier;
$_SESSION['nonce'] = $nonce;

// LINEログインボタンのリンク先URL生成に使用するため、code_challengeとnonceを設定する。
$return = [
    'code_challenge'               => $code_challenge,
    'nonce'                        => $_SESSION['nonce'],
];
```

* LINEログインボタンを任意のphpファイルに記載する。
  * 仕様については、[[こちら]](https://developers.line.biz/ja/docs/line-login/integrate-line-login/#making-an-authorization-request)を参照ください。
  * 今回は割愛しましたが、[[LINEログインボタンデザインガイドライン]](https://developers.line.biz/ja/docs/line-login/login-button/)に沿ったLINEログインボタンとすることと、[[自動ログインに失敗した時の対応方法]](https://developers.line.biz/ja/docs/line-login/how-to-handle-auto-login-failure/)への考慮もしたほうが良いです。  

```php
<a href="https://access.line.me/oauth2/v2.1/authorize?response_type=code&client_id=[チャネルID]&redirect_uri=[コールバックURL]&state=<?php echo md5(uniqid()); ?>&scope=profile%20openid&nonce=<?php echo $nonce; ?>&code_challenge=<?php echo $code_challenge; ?>&code_challenge_method=S256" target="_blank">LINEでログイン</a>
```

* 「コールバックURL」に設定した画面、処理にLINEログインユーザー情報取得処理を記載する。  


```php  
<?php
session_start();

// アクセストークン、IDトークンを取得する。
$postData = array();
$postData = array(
    'grant_type'    => 'authorization_code',
    'code'          => $_GET['code'],
    'redirect_uri'  => '[コールバックURL]',
    'client_id'     => '[チャネルID]',
    'client_secret' => '[チャネルシークレット]',
    'code_verifier' => $_SESSION['user_code'],
);
$_SESSION['user_code'] = "";
$ch = curl_init();
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/x-www-form-urlencoded'));
curl_setopt($ch, CURLOPT_URL, 'https://api.line.me/oauth2/v2.1/token');
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($postData));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
$json = json_decode($response);
$json = json_decode(json_encode($json), true);
$accessToken = $json['access_token'];
$idToken = $json['id_token'];

// アクセストークンの有効性を検証する。
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://api.line.me/oauth2/v2.1/verify?access_token='.$accessToken);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
$json = json_decode($response);
$json = json_decode(json_encode($json), true);
if (!empty($json['error'])) {
    error_log("ERROR2 : ".print_r($json, true), 3, '[ログファイルパス]');
} else {
    // アクセストークンを用いて、LINEログインユーザー情報(ユーザーID、ユーザーの表示名、プロフィール画像のURL)を取得する。
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_HTTPHEADER, array('Authorization: Bearer ' . $accessToken));
    curl_setopt($ch, CURLOPT_URL, 'https://api.line.me/v2/profile');
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    $response = curl_exec($ch);
    curl_close($ch);
    $json = json_decode($response);
    $userInfo1 = json_decode(json_encode($json), true);
    error_log("userInfo1 : ".print_r($userInfo1, true), 3, '[ログファイルパス]');
    
    // 取得したIDトークンとユーザーIDを検証し、正規のものであれば、IDトークンと関連情報、LINEログインユーザー情報(ユーザーの表示名、プロフィール画像のURL)を取得する。
    $postData = array();
    $postData = array(
        'id_token'      => $idToken,
        'client_id'     => '[チャネルID]',
        'nonce'         => $_SESSION['nonce'],
        'user_id'       => $userInfo1['userId'],
    );
    $_SESSION['nonce'] = "";
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/x-www-form-urlencoded'));
    curl_setopt($ch, CURLOPT_URL, 'https://api.line.me/oauth2/v2.1/verify');
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
    curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($postData));
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    $response = curl_exec($ch);
    curl_close($ch);
    $json = json_decode($response);
    $userInfo2 = json_decode(json_encode($json), true);
    error_log("userInfo2 : ".print_r($userInfo2, true), 3, '[ログファイルパス]');
    if (!empty($userInfo2['error'])) {
        error_log("ERROR4 : ".print_r($userInfo2, true), 3, '[ログファイルパス]');
    }

    // アクセストークンを取り消す。
    $postData = array();
    $postData = array(
        'access_token'  => $accessToken,
        'client_id'     => '[チャネルID]',
        'client_secret' => '[チャネルシークレット]',
    );
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/x-www-form-urlencoded'));
    curl_setopt($ch, CURLOPT_URL, 'https://api.line.me/oauth2/v2.1/revoke');
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
    curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($postData));
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    $response = curl_exec($ch);
    curl_close($ch);
    $json = json_decode($response);
    $json = json_decode(json_encode($json), true);
    if (!empty($json['error'])) {
        error_log("ERROR5 : ".print_r($json, true), 3, '[ログファイルパス]');
    }
}
?>
```

# 実行手順

* Google Chrome(※任意のWEBブラウザ)上からLINEログインボタンを選択する。
* LINEログインする。
* LINEログイン結果をログファイルから確認する。

```
userInfo1 : Array
(
    [userId] => aaa                                     // ユーザーID
    [displayName] => bbb                                // ユーザーの表示名
    [pictureUrl] => https://profile.line-scdn.net/ccc   // プロフィール画像のURL(「/large」を付加すると200 x 200、「/small」を付加すると51 x 51)
)
userInfo2 : Array
(
    [iss] => https://access.line.me     // IDトークンの生成URL
    [sub] => aaa                        // IDトークンの対象ユーザーID
    [aud] => [チャネルID]               // チャネルID
    [exp] => 1639452143                 // IDトークンの有効期限。UNIXタイムです。
    [iat] => 1639448543                 // IDトークンの生成時間。UNIXタイムです。
    [nonce] => [ノンス]                 // ノンス
    [amr] => Array
        (
            [0] => linesso              // ログイン方法。
        )

    [name] => bbb                                     // ユーザーの表示名
    [picture] => https://profile.line-scdn.net/ccc    // プロフィール画像のURL(「/large」を付加すると200 x 200、「/small」を付加すると51 x 51)
)
```

# トラブルシューティング
|エラー文|説明(※私的見解)|
|:-----------|:------------|
|redirect_uri does not match|redirect_uriがLINE設定側、LINEログインボタンのリンク先URL側、LINEログイン後のIDトークン取得処理側のいずれかで不一致。コピペミスの可能性大。|
|code_verifier does not match|LINEログイン前後(LINEログインボタンのリンク先URL側、LINEログイン後のIDトークン取得処理側)でcode_verifierが不一致。コピペミスかセッション保存ミスかセッション取得ミスの可能性大。|
|The access token not exist|アクセストークンが無効の状態。コピペミスの可能性大。|
|Parameter conditions "refresh_token" OR "access_token" not met for actual request parameters|トークンかチャネルIDかチャネルシークレットが不一致。コピペミスの可能性大。|
|Required request parameter 'id_token'|IDトークンを渡せていない。そもそもIDトークンを取得できているか処理を見直すべき。|