CakePHP3でTwitterOAuthをComponentとして実装してみる

# 概要

PHPもCakePHPもTwitter OAuth認証も初めてな人間がTwitter OAuth認証を実装した話

# 開発環境

CakePHP 3.5.13
PHP 5.6.30

Twitter認証にはAbraham\TwitterOAuthを使う。
参考：[【CakePHP3】Twitter API ライブラリTwitterOAuthをComposerでインストール – atomicbox](http://atomicbox.tank.jp/website/cakephp/1290/)

# 準備

APIキーの取得は済ませておく。
[Twitter Application Management](https://apps.twitter.com/)
取得方法については調べればいくらでも出てくる。

# Twitter OAuth認証の流れ

図付きの説明が公式にある。（英語）
[Implementing Sign in with Twitter — Twitter Developers](https://dev.twitter.com/web/sign-in/implementing)

https://twitteroauth.com/ にライブラリを使ったPHPの動作デモがあるので分かりやすい。

1. Twitterに**request_token, request_token_secret**を発行してもらう  
  [POST oauth/request\_token — Twitter Developers](https://developer.twitter.com/en/docs/basics/authentication/api-reference/request_token)  
  4で検証に使うので、**セッションに保存**しておく。
2. Twitterに認証URLを発行してもらう  
  [GET oauth/authorize — Twitter Developers](https://developer.twitter.com/en/docs/basics/authentication/api-reference/authorize)  
  [GET oauth/authenticate — Twitter Developers](https://developer.twitter.com/en/docs/basics/authentication/api-reference/authenticate)  
  authorizeのほうだと毎回認証確認され、authenticateのほうだと認証済みの場合はすぐにコールバックされる。
3. 2で発行したURLにリダイレクトする
4. 認証完了後、コールバックで指定したURLに返ってくるのでよしなにする  
  - 1で発行したトークンとコールバックで帰ってきたトークンが同一かどうかチェックする
  - 認証拒否時のリダイレクト処理など
5. **request_token, request_token_secret, oauth_verifier**を用い、Twitterに**access_token, access_token_secret**を発行してもらう  
  [POST oauth/access\_token — Twitter Developers](https://developer.twitter.com/en/docs/basics/authentication/api-reference/access_token)  
  これが投稿などを行う時に必要なキーとなる。**セッションに保存**しておく  
  この段階でOAuth認証が完了した扱いになる。（＝ユーザーの連携アプリ一覧に表示される）
6. 以降、5で取得したトークンを用いてTwitter APIを叩く

# Component実装

やることは分かったのでコンポーネントとして実装する。

- コントローラー側ではTwitter API、セッション管理を意識しないように
  - getAuthenticateUrlを呼びリダイレクト
  - コールバック側でvalidateCallbackを呼び出す
  - その後postTweetを呼び出す
- tokenなどはセッションに保存
  - Twitter.oauth_token
  - Twitter.access_token
  - Twitter.user_id
- 異常系はとりあえずInternalErrorExceptionをthrowする
  - とりあえずtokenは削除する
- とりあえずツイートの投稿のみ実装

```TwitterComponent.php
<?php
namespace App\Controller\Component;

use Cake\Controller\Component;
use Abraham\TwitterOAuth\TwitterOAuth;

use Cake\Network\Exception\InternalErrorException;

class TwitterComponent extends Component {
    // アプリケーションのConsumer Key (API Key)
    const TWITTER_CK = '<Consumer Key (API Key)>';
    // アプリケーションのConsumer Secret (API Secret)
    const TWITTER_CS = '<Consumer Secret (API Secret)>';
    // アプリケーションのCallback URL
    const CALLBACK_URL = '<Callback URL>';

    public function initialize(array $config) {
        $this->controller = $this->_registry->getController();
        $this->session = $this->controller->request->session();
    }

    /**
     * 現在のセッションでTwitter認証が済んでいるかを判定する
     *
     * @return bool
     */
    public function isAuthorized()
    {
        return $this->getAccessToken() !== null;
    }

    /**
     * ユーザー認証URLを取得する
     *
     * @return string
     */
    public function getAuthenticateUrl()
    {
        $connection = new TwitterOAuth(self::TWITTER_CK, self::TWITTER_CS);
        $request_token = $connection->oauth('oauth/request_token', array('oauth_callback' => self::CALLBACK_URL));
        $authenticate_url = $connection->url('oauth/authenticate', array('oauth_token' => $request_token['oauth_token']));

        if (!isset($request_token) || !isset($authenticate_url))
        {
            $this->clearSessionData();
            throw new InternalErrorException('Twitter認証に失敗しました');
        }

        $this->setRequestToken($request_token["oauth_token"], $request_token["oauth_token_secret"]);

        return $authenticate_url;
    }

    /**
     * コールバックで初期化を行う
     */
    public function initializeOnCallback()
    {
        $query = $this->controller->request->query;

        if(isset($query["denied"]))
        {
            $this->clearSessionData();
            throw new InternalErrorException('認証がキャンセルされました');
        }

        // Twitterから返却されたOAuthトークンとセッションに保存されたOAuthトークンを比較
        $return_oauth_token = (isset($query['oauth_token'])) ? $query['oauth_token'] : null;

        $request_token = $this->getRequestToken();
        if (!isset($request_token) || $return_oauth_token != $request_token['token'])
        {
            // セッション削除
            $this->clearSessionData();
            throw new InternalErrorException('OAuthトークンが無効です');
        }

        $this->createAccessToken($query['oauth_verifier']);
    }

    /**
     * ツイートする
     *
     * @param string $message ツイート内容
     */
    public function postTweet($message)
    {
        $connection = $this->createConnection();
        $statues = $connection->post("statuses/update", ["status" => $message]);

        if ($connection->getLastHttpCode() != 200)
        {
            $this->clearSessionData();
            throw new InternalErrorException('ツイートに失敗しました');
        }
    }

    /**
     * アクセストークンの取得を行う
     */
    private function createAccessToken($oauth_verifier)
    {
        $request_token = $this->getRequestToken();

        if (!isset($request_token) || !isset($oauth_verifier))
        {
            $this->clearSessionData();
            throw new InternalErrorException('OAuth認証情報が存在しません');
        }

        $connection = new TwitterOAuth(self::TWITTER_CK, self::TWITTER_CS, $request_token["token"], $request_token["token_secret"]);
        $access_token = $connection->oauth("oauth/access_token", ["oauth_verifier" => $oauth_verifier]);

        if ($connection->getLastHttpCode() != 200)
        {
            $this->clearSessionData();
            throw new InternalErrorException('アクセストークンの取得に失敗しました');
        }

        $this->setAccessToken($access_token["oauth_token"], $access_token["oauth_token_secret"]);
        $this->setUserId($access_token["user_id"]);

        return $this->getAccessToken();
    }

    /**
     * TwitterAPIに接続するためのconnectionを取得する
     *
     * return TwitterOAuth
     */
    private function createConnection()
    {
        $access_token = $this->getAccessToken();

        return new TwitterOAuth(self::TWITTER_CK, self::TWITTER_CS, $access_token["token"], $access_token["token_secret"]);
    }

    /**
     *  セッションにOAuth認証のトークンを保存する
     */
    private function setRequestToken($oauth_token, $oauth_token_secret)
    {
        $this->session->write('Twitter.oauth_token', array("token" => $oauth_token, "token_secret" => $oauth_token_secret));
    }

    /**
     * セッション情報からOAuth認証のトークンを取得する
     *
     * @return null | array["token", "token_secret", ]
     */
    private function getRequestToken()
    {
        return $this->session->read('Twitter.oauth_token');
    }

    /**
     * セッションにTwitterのアクセストークンを保存する
     */
    private function setAccessToken($oauth_token, $oauth_token_secret)
    {
        $this->session->write('Twitter.access_token', array("token" => $oauth_token, "token_secret" => $oauth_token_secret));
    }

    /**
     * セッション情報からTwitterのアクセストークンを取得する
     *
     * @return null | array["token", "token_secret"]
     */
    private function getAccessToken()
    {
        return $this->session->read('Twitter.access_token');
    }

    /**
     * セッションにTwitterのuser_idを保存する
     */
    private function setUserId($user_id)
    {
        $this->session->write('Twitter.user_id', $user_id);
    }

    /**
     * セッション情報からTwitterのuser_idを取得する
     *
     * @return string
     */
    public function getUserId()
    {
        return $this->session->read('Twitter.user_id');
    }

    /**
     * セッションに保存されているOAuth認証情報などをクリアする
     */
    private function clearSessionData()
    {
        $this->session->delete('Twitter.oauth_token');
        $this->session->delete('Twitter.access_token');
        $this->session->delete('Twitter.user_id');
    }
}
```

# アクセストークンの有効期限について

[Oauth FAQ — Twitter Developers](https://developer.twitter.com/en/docs/basics/authentication/guides/oauth-faq)

> How long does an access token last?
Access tokens are not explicitly expired. An access token will be invalidated if a user explicitly revokes an application in the their Twitter account settings, or if Twitter suspends an application. If an application is suspended, there will be a note on the apps.twitter.com page stating that it has been suspended.

> What if an access token becomes invalid?
Assume a user’s access token may become invalid at any time. If this happens, prompt the user to re-authorize the application. Ensuring that this situation is handled gracefully is important for a good user experience.

一度取得したアクセストークンは特に有効期限などは設定されておらず、ユーザーが連携を解除するまでは使える模様。
しかし、ユーザーが連携を解除することでアクセストークンが無効になっている可能性が常に存在するため、エラーハンドリングはよしなにしてくれとの事。

[Response Codes — Twitter Developers](https://developer.twitter.com/en/docs/basics/response-codes)

認証失敗の場合はHTTP Status Code == 401(Unauthorized)が返ってくるはずなので、こちら側でいい感じに制御する。

# まとめ

PHP触ったこと無いけど一通り実装してみた。思ったより簡単だった。普段Railsを触っているのでCakePHPはすんなり理解できた。
PHPのお作法的な所はよく分からないので、微妙な実装をしている所があるかもしれない。
ざざっと書いたので色々と雑。

# 参考

https://qiita.com/michimani/items/a74f8debeace2df63eec
https://qiita.com/frost_star/items/f652a798a211d606d60d