Closed14

Laravel SocialiteでTwitterのログイン認証を作る

himawarichanhimawarichan

【目標】
Laravel Socialiteを使ってTwitterでログインしツイートができるWebアプリを作る。
できればログイン認証はOAuth2.0PKCEでやる。

【環境】
Laravel 8.83.14 ※laradockで構築済み
mysql 8.0.28
Laravel Socialite 5.5.2
TwitterOAuth 4.0.0

認証はSocialite、ツイート機能はTwitterOAuthを使用。
分けてるのはTwitterOAuthが現時点ではOAuth2.0に対応してなさそうなため。

Twitterのデベロッパーアカウントは取得済み。

himawarichanhimawarichan

とりあえずLaravel SocialiteとTwitterOAuthをcomposerでインストール

composer require abraham/twitteroauth
composer require laravel/socialite
himawarichanhimawarichan

使うサービスごとにアダプタというものが別途必要らしい。
4系だと必須とのことだが、5系はどうなのだろうか。
とりあえず入れる。

composer require socialiteproviders/twitter

ファサードの設定と、アダプタも登録がいるらしい。

config/app.php
'providers' => [
  //~~略~~
    \SocialiteProviders\Manager\ServiceProvider::class, 
],
app/Providers/EventServiceProvider.php
    protected $listen = [
        SocialiteWasCalled::class => [
            'SocialiteProviders\\Twitter\\TwitterExtendSocialite@handle',
        ],
    ];
himawarichanhimawarichan

Twitter developerでクライアントキーとクライアントシークレットを発行する。
アプリの設定画面から「User authentication settings」のEditをクリック。

変更点:
・OAuth2.0をONにする
・「Type of Web」で該当するものを選択。最終的に自動でつぶやくbotにしたいので「Automated App or bot」を選択。
・「Callback URI / Redirect URL」に認証画面からリダイレクト後戻ってくるURLを入力。自分で試す分にはlocalhostでも大丈夫。
・「Website URL」にAPIを使うサイト?のURLを入力。localhostやhttp://127.0.0.1/にすると怒られたが、APIのリクエスト時にチェックが入るようではなさそう?なので自分のTwitterアカウントのURLとかでも大丈夫みたい。

Saveをクリックするとクライアントキーとクライアントとシークレットが発行される。
再確認はできないので必ず控えておくこと。

控えるのを忘れてももう一回Saveすれば新しいものを発行してくれるようだが。

ちなみに「Type of Web」の選択肢の横に薄く「Confidential Client」もしくは「Public Client」と書いてある。これによってアクセストークン交換時の処理が若干変わってくる。下記の記事が詳しい。
https://zenn.dev/kg0r0/articles/8b1cfe654a1cee#authorization-request-%2F-response

ベーシック認証をするかしないかの差っぽい?

himawarichanhimawarichan

今更だが今回の構成。

/twitter/login:Twitterにログインするページ。ログインボタンだけ設置しており、ボタンをクリックすると認証ページへリダイレクトする
/twitter/callback:Twitterの認証画面からコールバックするページ
/twitter/mypage:アクセストークンを発行し認証完了後にリダイレクトするページ。このページにツイート機能を設置する。

himawarichanhimawarichan

Laravelに戻って先ほど発行したクライアントキーなどを設定する。

.env
TWITTER_CLIENT_ID=<自分のクライアントキー>
TWITTER_CLIENT_SECRET=<自分のクライアントシークレット>
TWITTER_CLIENT_CALLBACK=<認証後のコールバックURL>
config/services.php
'twitter' => [
    'client_id'       => env('TWITTER_CLIENT_ID'),
    'client_secret'   => env('TWITTER_CLIENT_SECRET'),
    'redirect'        => env('TWITTER_CLIENT_CALLBACK'),
    'oauth'           => 2,
]

OAuth2.0で認証する場合は「oauth=>2」と書く必要がある。
逆に1で認証するなら不要。これに気づかなくてちょっとハマった。

himawarichanhimawarichan

実装していく。とりあえず「ログインボタン押す→認証画面へ→コールバックへ戻ってくる」とこまで確認する。

app/Http/Controllers/Twitter/MyPageController.php
use Laravel\Socialite\Facades\Socialite;
class MyPageController extends Controller
{
    public function login(Request $request)
    {
        if(!$request->isMethod('post')){
            return view('twitter.mypage.login');
        }
        //ログインボタンを押したらTwitterの認証ページへ
        return Socialite::driver('twitter')->redirect();
    }
    public function callback(Request $request)
    {
        dd('ok');
    }
}

viewは割愛。えっこれだけでいいのか…。
とりあえず認証画面が表示されcallbackに到達したことまでは確認できた

OAuth2の認証画面、スタイリッシュやね。

himawarichanhimawarichan

※余談
普段はビジネスロジックはControllerではなくServiceに書くのだが、「Serviceでリダイレクト→リダイレクトしなければControllerへ戻ってviewを表示」としたらリダイレクトせずviewを表示してしまった。

app/Http/Controllers/Twitter/MyPageController.php
class MyPageController extends Controller
{
    protected $_service;

    public function __construct(
        MyPageService $MyPageService
    ) {
        $this->_service = $MyPageService;
    }

    public function login(Request $request)
    {
        $this->_service->login($request);
        return view('twitter.mypage.login', $data);

    }

app/Services/Twitter/MyPageService.php
class MyPageService 
{
    public function login($request)
    {
        if(!$request->isMethod('post')){
            return;
        }
        //ログインボタンを押したらTwitterの認証ページへ
        return Socialite::driver('twitter')->redirect();
    }

Socialite::driver('twitter')->redirect();した時点でControllerには戻らないと思っていたのだがそんなことはかった。
Laravelのredirect()であればControllerには戻らずServiceから直接リダイレクトしたはずなので、ちょっとびっくりした。

himawarichanhimawarichan

認証画面からコールバックしたあと、DBにアクセストークンを保存する。
DBに格納したいデータは、
・メールアドレス
・アクセストークン
・アクセストークンの有効期限
・リフレッシュトークン

migration作成

php artisan make:migration create_twitter_table --create=tb_twitter_users

migrationの中身

    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('tb_twitter_users', function (Blueprint $table) {
            $table->increments('id');
            $table->string('mail');
            $table->string('access_token');
            $table->dateTime('token_limit');
            $table->string('refresh_token');
            $table->timestamps();
        });
    }

できあがったテーブルはこちら。

mysql> desc tb_twitter_users;
+---------------+--------------+------+-----+---------+----------------+
| Field         | Type         | Null | Key | Default | Extra          |
+---------------+--------------+------+-----+---------+----------------+
| id            | int unsigned | NO   | PRI | NULL    | auto_increment |
| mail          | varchar(255) | NO   |     | NULL    |                |
| access_token  | varchar(255) | NO   |     | NULL    |                |
| token_limit   | datetime     | NO   |     | NULL    |                |
| refresh_token | varchar(255) | NO   |     | NULL    |                |
| created_at    | timestamp    | YES  |     | NULL    |                |
| updated_at    | timestamp    | YES  |     | NULL    |                |
+---------------+--------------+------+-----+---------+----------------+

migrationには苦手意識があるのであまり使ってこなかったが、使い慣れていった方がいいんだろうな

himawarichanhimawarichan

コールバック処理でメールが取れるか試してみる

app/Services/Twitter/MyPageService.php
    public function callback($request)
    {
        try {
            $user = Socialite::driver('twitter')->user();
        } catch(\Exception $e) {
            return redirect('twitter.mypage.login')->with('oauth_error', '予期せぬエラーが発生しました');
        }

        $email = $user->getEmail();
        dd($email);

        return;
    }

返ってきたのはNULLでした。そんな…。
ちなみにメール以外のユーザー情報は取得できていたので処理自体がおかしいわけではなさそう。
https://tech.innovator.jp.net/entry/2017/08/08/120555

OAuth1.0だとパーミッションの設定が必要と。しかしそんな設定は見当たらないし、ドキュメントのscopeにもそれっぽいものはない。
https://developer.twitter.com/en/docs/authentication/oauth-2-0/authorization-code

結論:OAuth2.0だとメールアドレスは取得できないっぽい
取得できる方法知ってる人いたら教えてください。
https://stackoverflow.com/questions/70915572/retrieving-e-mail-from-twitter-oauth2

仕方がないのでDBにはユーザーID(@○○)を登録することにする。
どのユーザーのトークンかがわかればよかったので、こちらでも問題ない。

himawarichanhimawarichan

ログイン→認証→コールバック→ツイート画面までの処理。
Controllerはリダイレクトもしくはviewを表示するだけで、DBへの登録はServiceで処理。
またDBの呼び出しはRepositoryとRepositoryInterfaceでやってます。
※ModelとRepositoryInterfaceとviewは割愛

app/Http/Controllers/Twitter/MyPageController.php
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Services\Twitter\MyPageService;
use Laravel\Socialite\Facades\Socialite;

class MyPageController extends Controller
{
    protected $_service;

    public function __construct(
        MyPageService $MyPageService
    ) {
        $this->_service = $MyPageService;
    }

    public function login(Request $request)
    {
        if (!$request->isMethod('post')) {
            return view('twitter.mypage.login');
        }
        //ログインボタンを押したらTwitterの認証ページへ
        return Socialite::driver('twitter')
            ->scopes(['tweet.read', 'tweet.write', 'users.read', 'offline.access'])
            ->redirect();
    }

    public function callback(Request $request)
    {
        $this->_service->callback($request);
        return redirect(route('twitter.mypage.index'))->with('flash_message', 'ログインしました。');
    }

    public function index(Request $request)
    {
        return view('twitter.mypage.index');
    }

app/Services/Twitter/MyPageService.php
use Abraham\TwitterOAuth\TwitterOAuth;
use Laravel\Socialite\Facades\Socialite;
use App\Repositories\TbTwitterUsers\TbTwitterUsersRepositoryInterface;
use Carbon\Carbon;

class MyPageService
{
    protected $TbTwitterUsersRepository;

    public function __construct(TbTwitterUsersRepositoryInterface $TbTwitterUsersRepository)
    {
        $this->TbTwitterUsersRepository = $TbTwitterUsersRepository;
    }

    public function callback($request)
    {
        try {
            $user = Socialite::driver('twitter')->user();
        } catch (\Exception $e) {
            return redirect('twitter.mypage.login')->with('oauth_error', '予期せぬエラーが発生しました');
        }

        //トークンが期限切れになる時間(秒単位)
        $expires_in = $user->expiresIn;
        $expire_time = new Carbon('+' . $expires_in . ' seconds');

        $upsert_array = [
            'twitter_id' => $user->getNickname(),
            'access_token' => $user->token,
            'token_limit' => $expire_time,
            'refresh_token' => $user->refreshToken,
        ];
        //TwitterIDを条件にupsert
        $this->TbTwitterUsersRepository->upsert($upsert_array);

        return;
    }

app/Repositories/TbTwitterUsers/TbTwitterUsersRepository.php
use App\Models\TbTwitterUsers;

class TbTwitterUsersRepository implements TbTwitterUsersRepositoryInterface {
    protected $TbTwitterUsers;

    /**
     * @param object $user
     */
    public function __construct(TbTwitterUsers $TbTwitterUsers) {
        $this->TbTwitterUsers = $TbTwitterUsers;
    }

    /**
     * 登録・更新
     *
     * @param array $upsert_array
     * @return object
     */
    public function upsert($upsert_array) {
        return $this->TbTwitterUsers
            ->updateOrCreate(['twitter_id' => $upsert_array['twitter_id'] ?? NULL], $upsert_array);
    }
}

認証時のスコープを指定している。
今回のようにGUIからツイートするのであれば本来は不要だが、bot化することを考えて「offline.access」を追加しリフレッシュトークンを取得できるようにしている。

リフレッシュトークンについて:
https://thinca.hatenablog.com/entry/creating-twitter-bot-memo

himawarichanhimawarichan

ツイート画面からツイートするのにユーザーIDがわからないといけないので、コールバック時にセッションに持たせるようにする。

app/Services/Twitter/MyPageService.php
    public function callback($request)
    {
         ~省略~
        //セッションにTwitterIDを保存
        $request->session()->put('twitter_id', $result->twitter_id);

        return;
    }

ついでにセッションにIDがなければログイン画面へリダイレクトするミドルウェアも作成

app/Http/Middleware/LoginTwitter.php
class LoginTwitter {
    /**
     * Twitterログイン保持チェック
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response|\Illuminate\Http\RedirectResponse)  $next
     * @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse
     */
    public function handle(Request $request, Closure $next) {
        if (!$request->session()->get('twitter_id')) {
            return redirect()->route('twitter.mypage.login');
        }
        return $next($request);
    }
}
routes/route.php
    Route::group(['middleware' => 'twitter'], function () {
        //マイページ
        Route::match(['get', 'post'], 'mypage', [App\Http\Controllers\Twitter\MyPageController::class, 'index'])->name('twitter.mypage.index');
    });
himawarichanhimawarichan

ツイート画面を実装する。ツイートする処理はTwitterOAuthを使う。

app/Http/Controllers/Twitter/MyPageController.php
    public function index(Request $request)
    {
        if (!$request->isMethod('post')) {
            return view('twitter.mypage.index');
        }
        $result_message = $this->_service->index($request);
        return view('twitter.mypage.index', ['flash_message' => $result_message]);
    }

app/Services/Twitter/MyPageService.php
    public function index($request)
    {
        $twitter_id = $request->session()->get('twitter_id');
        //アクセストークンをDBから取得
        $twitter_user = $this->TbTwitterUsersRepository->getFirstRecordByTwitterId($twitter_id);

        $access_token = $twitter_user->access_token;
        $client_id = config('services.twitter.client_id');
        $client_secret = config('services.twitter.client_secret');

        //アクセストークンの期限が切れていたらリフレッシュトークンで取得し直す
        if ($twitter_user->token_limit->lt(Carbon::now())) {
            $url = 'https://api.twitter.com/2/oauth2/token';

            $response = Http::withHeaders([
                'Accept' => 'application/json',
            ])->withBasicAuth($client_id, $client_secret)->post($url, [
                'grant_type' => 'refresh_token',
                'client_id' => $client_id,
                'client_secret' => $client_secret,
                'refresh_token' => $twitter_user->refresh_token,
                'client_id' => $client_id,
            ]);
            $response_array = json_decode($response->getBody(), true);

            //新しいトークンをDBに保存
            $access_token = $response_array['access_token'];
            $expires_in = $response_array['expires_in'];
            $expire_time = new Carbon('+' . $expires_in . ' seconds');

            $upsert_array = [
                'twitter_id' => $twitter_id,
                'access_token' => $access_token,
                'token_limit' => $expire_time,
                'refresh_token' => $response_array['refresh_token'],
            ];
            $this->TbTwitterUsersRepository->upsert($upsert_array);
        }

        //ツイートする
        $twitter = new TwitterOAuth(
            $client_id,
            $client_secret,
            null,
            $access_token,
        );
        $twitter->setApiVersion("2");

        $twitter->post("tweets", ["text" => $request->input('tweet')], true);
        if ($twitter->getLastHttpCode() == 201) {
            $result_message = 'ツイートしました。';
        } else {
            $result_message = 'ツイートに失敗しました。';
        }

        return $result_message;
    }

リフレッシュトークンを使ってアクセストークンを再発行する機能はSocialiteにはなさそうだったので、LaravelのHttpクライアント(Guzzle)で実装。

TwitterOAuthだが、OAuth2.0の場合、インスタンスを作るときアクセストークンを持たせるのは第四引数。
ドキュメントを見る限り第三引数にアクセストークン、第四引数にアクセストークンシークレットを渡すらしいが、OAuth2.0ではアクセストークンシークレットは発行されない。
なので最初は第三引数にアクセストークン、第四引数をnullとしたがそれではうまくいかなかった。

TwitterOAuthのソースを見ると、第四引数($this->bearer)がnullの場合認証をしてるっぽい?ただnullでなければBearerで認証してくれそうなので、第四引数にアクセストークンを持たせた。
TwitterOAuthはOAuth2.0に対応していないので、この使い方であってるのかわからない。対応するまでは使わずに直接API叩いたほうがいいのだろうか…。

vendor/abraham/twitteroauth/src/TwitterOAuth.php
    private function oAuthRequest(
   ~省略~
        if ($this->bearer === null) {
            $request->signRequest(
                $this->signatureMethod,
                $this->consumer,
                $this->token,
            );
            $authorization = $request->toHeader();
            if (array_key_exists('oauth_verifier', $parameters)) {
                // Twitter doesn't always work with oauth in the body and in the header
                // and it's already included in the $authorization header
                unset($parameters['oauth_verifier']);
            }
        } else {
            $authorization = 'Authorization: Bearer ' . $this->bearer;
        }

これで目標としていたログイン→OAuth2.0で認証→ツイートするまで完成!

himawarichanhimawarichan

完走した感想ですが、Socialiteが便利すぎた。もう一生放さない…。

ただ調べても日本語の記事が少なかった気がする。あるにはあるが、少し古かったりなど求めてる情報が日本語では見つからなかった。アダプタ入れなきゃいけないなんてLaravelのドキュメントにも書いてなかったし。
Twitter以外にも聞いたことのないSNSやサービスのAPIにも対応してるので外部API使うときはSocialite、使おう!
あとはリフレッシュトークン使えるようになったら完璧だよ…。

また今回TwitterAPI v2+OAuth2.0という比較的新しい組み合わせでやったので、理解したうえで実装に落とし込むのが大変だった(だからこそわざわざZennのアカウント作って備忘録として残したわけだし)

おそらくPHPでTwitterAPI v2+OAuth2.0使った日本語記事は初では?こんな雑備忘録がそれでは申し訳ないので、時間ができたらもうちょっと丁寧に清書して残したいな…。

おまけ:既に載せたもの以外でSocialiteで参考になった記事
https://www.ritolab.com/entry/207
https://qiita.com/near108/items/820058f690b4f0f4192a

このスクラップは2022/06/01にクローズされました