PHP
CakePHP
Twitter
cakephp3

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

準備

APIキーの取得は済ませておく。
Twitter Application Management
取得方法については調べればいくらでも出てくる。

Twitter OAuth認証の流れ

図付きの説明が公式にある。(英語)
Implementing Sign in with Twitter — Twitter Developers

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

  1. Twitterにrequest_token, request_token_secretを発行してもらう
    POST oauth/request_token — Twitter Developers
    4で検証に使うので、セッションに保存しておく。
  2. Twitterに認証URLを発行してもらう
    GET oauth/authorize — Twitter Developers
    GET oauth/authenticate — Twitter Developers
    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
    これが投稿などを行う時に必要なキーとなる。セッションに保存しておく
    この段階で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

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

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

まとめ

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

参考

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