Laravel SocialiteでTwitterのログイン認証を作る
【目標】
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のデベロッパーアカウントは取得済み。
とりあえずLaravel SocialiteとTwitterOAuthをcomposerでインストール
composer require abraham/twitteroauth
composer require laravel/socialite
使うサービスごとにアダプタというものが別途必要らしい。
4系だと必須とのことだが、5系はどうなのだろうか。
とりあえず入れる。
composer require socialiteproviders/twitter
ファサードの設定と、アダプタも登録がいるらしい。
'providers' => [
//~~略~~
\SocialiteProviders\Manager\ServiceProvider::class,
],
protected $listen = [
SocialiteWasCalled::class => [
'SocialiteProviders\\Twitter\\TwitterExtendSocialite@handle',
],
];
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」と書いてある。これによってアクセストークン交換時の処理が若干変わってくる。下記の記事が詳しい。
ベーシック認証をするかしないかの差っぽい?
今更だが今回の構成。
/twitter/login:Twitterにログインするページ。ログインボタンだけ設置しており、ボタンをクリックすると認証ページへリダイレクトする
/twitter/callback:Twitterの認証画面からコールバックするページ
/twitter/mypage:アクセストークンを発行し認証完了後にリダイレクトするページ。このページにツイート機能を設置する。
Laravelに戻って先ほど発行したクライアントキーなどを設定する。
TWITTER_CLIENT_ID=<自分のクライアントキー>
TWITTER_CLIENT_SECRET=<自分のクライアントシークレット>
TWITTER_CLIENT_CALLBACK=<認証後のコールバックURL>
'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で認証するなら不要。これに気づかなくてちょっとハマった。
実装していく。とりあえず「ログインボタン押す→認証画面へ→コールバックへ戻ってくる」とこまで確認する。
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の認証画面、スタイリッシュやね。
※余談
普段はビジネスロジックはControllerではなくServiceに書くのだが、「Serviceでリダイレクト→リダイレクトしなければControllerへ戻ってviewを表示」としたらリダイレクトせずviewを表示してしまった。
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);
}
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から直接リダイレクトしたはずなので、ちょっとびっくりした。
認証画面からコールバックしたあと、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には苦手意識があるのであまり使ってこなかったが、使い慣れていった方がいいんだろうな
コールバック処理でメールが取れるか試してみる
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でした。そんな…。
ちなみにメール以外のユーザー情報は取得できていたので処理自体がおかしいわけではなさそう。
OAuth1.0だとパーミッションの設定が必要と。しかしそんな設定は見当たらないし、ドキュメントのscopeにもそれっぽいものはない。
結論:OAuth2.0だとメールアドレスは取得できないっぽい
取得できる方法知ってる人いたら教えてください。
仕方がないのでDBにはユーザーID(@○○)を登録することにする。
どのユーザーのトークンかがわかればよかったので、こちらでも問題ない。
ログイン→認証→コールバック→ツイート画面までの処理。
Controllerはリダイレクトもしくはviewを表示するだけで、DBへの登録はServiceで処理。
またDBの呼び出しはRepositoryとRepositoryInterfaceでやってます。
※ModelとRepositoryInterfaceとviewは割愛
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');
}
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;
}
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」を追加しリフレッシュトークンを取得できるようにしている。
リフレッシュトークンについて:
ツイート画面からツイートするのにユーザーIDがわからないといけないので、コールバック時にセッションに持たせるようにする。
public function callback($request)
{
~省略~
//セッションにTwitterIDを保存
$request->session()->put('twitter_id', $result->twitter_id);
return;
}
ついでにセッションにIDがなければログイン画面へリダイレクトするミドルウェアも作成
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);
}
}
Route::group(['middleware' => 'twitter'], function () {
//マイページ
Route::match(['get', 'post'], 'mypage', [App\Http\Controllers\Twitter\MyPageController::class, 'index'])->name('twitter.mypage.index');
});
ツイート画面を実装する。ツイートする処理はTwitterOAuthを使う。
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]);
}
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叩いたほうがいいのだろうか…。
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で認証→ツイートするまで完成!
完走した感想ですが、Socialiteが便利すぎた。もう一生放さない…。
ただ調べても日本語の記事が少なかった気がする。あるにはあるが、少し古かったりなど求めてる情報が日本語では見つからなかった。アダプタ入れなきゃいけないなんてLaravelのドキュメントにも書いてなかったし。
Twitter以外にも聞いたことのないSNSやサービスのAPIにも対応してるので外部API使うときはSocialite、使おう!
あとはリフレッシュトークン使えるようになったら完璧だよ…。
また今回TwitterAPI v2+OAuth2.0という比較的新しい組み合わせでやったので、理解したうえで実装に落とし込むのが大変だった(だからこそわざわざZennのアカウント作って備忘録として残したわけだし)
おそらくPHPでTwitterAPI v2+OAuth2.0使った日本語記事は初では?こんな雑備忘録がそれでは申し訳ないので、時間ができたらもうちょっと丁寧に清書して残したいな…。
おまけ:既に載せたもの以外でSocialiteで参考になった記事