Laravel+Socialiteで簡単ソーシャルログイン実装! (テスト付き)

Socialiteを使ってLaravelで簡単にソーシャルログインを実装する方法をテスト付きで紹介したいと思います｡

ソースは[こちら](https://github.com/KeisukeKudo/LaravelSocialite)
※ 上記ソースを `clone` する場合は `php artisan key:generate` を実行してください｡

## 環境
Dockerで環境を用意しています｡  

- Debian 9.8
- PHP 7.3.4
- Laravel 5.8.13
- Socialite 4.1.3
- PHPUnit 7.5.9

## 準備
Laravelのインストール手順などは省略します｡
もし環境が整っていないのであれば､以下を参考にLaravelのプロジェクトを作成してください｡
https://readouble.com/laravel/5.8/ja/installation.html
### クライアントIDとシークレットを取得する
Socialiteが対応しているサービスは以下

- Google
- Facebook
- Twitter
- Linkedin
- GitHub
- GitLab
- Bitbucket

それぞれのサービスに登録してクライアントID､シークレットを取得してください｡
今回はGoogleを利用します｡

Google API Consoleにアクセスします｡
https://console.developers.google.com/project
`プロジェクトを作成`をクリックしてプロジェクトを作成します｡
プロジェクトのダッシュボードへ移動します｡
左上のハンバーガーアイコンをクリック
`APIとサービス > 認証情報 > 認証情報を作成 > OAuth クライアント ID`でクライアントIDを作成します｡
同意画面の設定を入力します｡※ドメインやプライバシーポリシーのURLが必要です｡(ドメインでホストされているURL)
前の画面へ戻り､`アプリケーションの種類 > ウェブアプリケーション`を選択
`承認済みのリダイレクトURI`を入力して保存
クライアントIDとシークレットが発行されます｡(認証情報画面でいつでも確認可)

### アプリケーションにクライアントIDとシークレットを設定する
各ファイルに以下を追加

```text:.env
# 末尾でOK
GOOGLE_CLIENT_ID=上記で取得したID
GOOGLE_CLIENT_SECRET=上記で取得したシークレット
GOOGLE_CALLBACK_URL=承認済みのリダイレクトURIを入れてください
```
```config/services.php
return [

    /**
     * 省略
     */

    'google' => [
        'client_id' => env('GOOGLE_CLIENT_ID'),
        'client_secret' => env('GOOGLE_CLIENT_SECRET'),
        'redirect' => env('GOOGLE_CALLBACK_URL'),
    ],
];
```
### Socialiteをインストール
```bash
$ composer require laravel/socialite
```

### usersテーブルのmigrationファイルを編集する
```database/migrations/XXX_create_users_tabel.php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateUsersTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->bigIncrements('id');
            // 追加
            $table->string('provider_id')->nullable();
            // 追加
            $table->string('provider_name')->nullable();
            $table->string('name');
            $table->string('email')->unique();
            $table->timestamp('email_verified_at')->nullable();
            // null許容に変更
            $table->string('password')->nullable();
            $table->rememberToken();
            $table->timestamps();

            // 複合ユニークキー
            $table->unique(['provider_id', 'provider_name']);
        });
    }

    /**
     * 以下省略
     */
}
```
上記では､各サービスから受け取るIDを格納する`provider_id`カラムと､ログインに利用したサービス名を格納する`provider_name`カラムを追加しています｡
さらに､ソーシャルログインを利用する場合に`password`は不要になるため､`null`許容に設定します｡
### DBにテーブルを作成する
```bash
$ php artisan migrate
```
### PHPUnitを設定する
アプリケーションが使うDBを使用するわけには行かないので､テスト用のDBを読み込ませます｡
今回はインメモリな`sqlite`でテストします｡
もちろん`MySql(MariaDB)`､`PostgreSQL`を設定しても構いません｡

```config/database.php
return [
    /**
     * 省略
     */
    'connections' => [ 
        /**
         * 省略
         */

        // 追加
        'testing' => [
            'driver' => 'sqlite',
            'database' => ':memory:',
            'prefix' => '',
        ],
    ],

    /**
     * 以下省略
     */
];
```
```phpunit.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
         backupStaticAttributes="false"
         bootstrap="vendor/autoload.php"
         colors="true"
         convertErrorsToExceptions="true"
         convertNoticesToExceptions="true"
         convertWarningsToExceptions="true"
         processIsolation="false"
         stopOnFailure="false">

    <!-- 省略 -->

    <php>
        <server name="APP_ENV" value="testing"/>
        <server name="BCRYPT_ROUNDS" value="4"/>
        <server name="CACHE_DRIVER" value="array"/>
        <server name="MAIL_DRIVER" value="array"/>
        <server name="QUEUE_CONNECTION" value="sync"/>
        <server name="SESSION_DRIVER" value="array"/>
        <!-- 追加 -->
        <server name="DB_CONNECTION" value="testing"/>
    </php>
</phpunit>
```
以上で準備は完了です｡

## 実装
### コントローラークラスを作成する
`app/Http/Controllers/Auth/`直下に作成します｡

```bash
$ php artisan make:controller Auth/OAuthController
```
作成したコントローラーをひとまず以下のように編集します｡

```app/Http/Controllers/Auth/OAuthController.php
use App\Http\Controllers\Controller;

class OAuthLoginController extends Controller
{
    /**
     * 各SNSのOAuth認証画面にリダイレクトして認証
     * @param string $provider サービス名
     * @return mixed
     */
    public function socialOAuth(string $provider)
    {
        // TODO あとで実装
    }

    /**
     * 各サイトからのコールバック
     * @param string $provider サービス名
     * @return mixed
     */
    public function handleProviderCallback($provider)
    {
        // TODO あとで実装
    }
}
```

### ルーティングを設定する
```routes/web.php
Route::prefix('auth')->middleware('guest')->group(function() {

   Route::get('/{provider}', 'Auth\OAuthController@socialOAuth')
       ->where('provider','google')
       ->name('socialOAuth');

    Route::get('/{provider}/callback', 'Auth\OAuthController@handleProviderCallback')
        ->where('provider','google')
        ->name('oauthCallback');
});
```
ルーティングが正しく設定されているか確認します｡

`auth/{provider}`
`auth/{provider}/callback`
が設定されていればOKです｡
※Googleのコンソールで入力する`承認済みのリダイレクトURI`は`auth/{provider}/callback`です｡
例: `https://localhost/auth/google/callback`

```bash
$ php artisan route:list
+--------+----------+--------------------------+------------------+------------------------------------------------------------------------+--------------+
| Domain | Method   | URI                      | Name             | Action                                                                 | Middleware   |
+--------+----------+--------------------------+------------------+------------------------------------------------------------------------+--------------+
|        | GET|HEAD | /                        |                  | Closure                                                                | web          |
|        | GET|HEAD | api/user                 |                  | Closure                                                                | api,auth:api |
|        | GET|HEAD | auth/{provider}          | socialOAuth      | App\Http\Controllers\Auth\OAuthController@socialOAuth                  | web,guest    |
|        | GET|HEAD | auth/{provider}/callback | oauthCallback    | App\Http\Controllers\Auth\OAuthController@handleProviderCallback       | web,guest    |
|        | GET|HEAD | home                     | home             | App\Http\Controllers\HomeController@index                              | web,auth     |
|        | GET|HEAD | login                    | login            | App\Http\Controllers\Auth\LoginController@showLoginForm                | web,guest    |
|        | POST     | login                    |                  | App\Http\Controllers\Auth\LoginController@login                        | web,guest    |
|        | POST     | logout                   | logout           | App\Http\Controllers\Auth\LoginController@logout                       | web          |
|        | POST     | password/email           | password.email   | App\Http\Controllers\Auth\ForgotPasswordController@sendResetLinkEmail  | web,guest    |
|        | GET|HEAD | password/reset           | password.request | App\Http\Controllers\Auth\ForgotPasswordController@showLinkRequestForm | web,guest    |
|        | POST     | password/reset           | password.update  | App\Http\Controllers\Auth\ResetPasswordController@reset                | web,guest    |
|        | GET|HEAD | password/reset/{token}   | password.reset   | App\Http\Controllers\Auth\ResetPasswordController@showResetForm        | web,guest    |
|        | GET|HEAD | register                 | register         | App\Http\Controllers\Auth\RegisterController@showRegistrationForm      | web,guest    |
|        | POST     | register                 |                  | App\Http\Controllers\Auth\RegisterController@register                  | web,guest    |
+--------+----------+--------------------------+------------------+------------------------------------------------------------------------+--------------+
```
### テストクラスを作成する
```bash
php artisan make:test OAuthTest
```
```tests/Feature/OAuthTest.php
use Tests\TestCase;

class OAuthTest extends TestCase
{

    public function setUp(): void
    {
        parent::setUp();
        $this->providerName = 'google';
    }

    /**
     * @test
     */
    public function Googleの認証画面を表示できる()
    {
        // URLをコール
        $this->get(route('socialOAuth', ['provider' => $this->providerName]))
            ->assertStatus(200);
    }

    /**
     * @test
     */
    public function Googleアカウントでユーザー登録できる()
    {
        // URLをコール
        $this->get(route('oauthCallback', ['provider' => $this->providerName]))
            ->assertStatus(200);
    }
}
```
特に解説は不要だと思います｡
単純に各URLにGETリクエストしてその応答が成功かどうかを判定しているだけです｡

ひとまずこの状態でテストを実行します｡
以下のように出力されるはずです｡

```bash
$ ./vendor/bin/phpunit --testdox tests/Feature/OAuthTest.php
PHPUnit 7.5.9 by Sebastian Bergmann and contributors.

Feature\OAuth
 ✔ Googleの認証画面を表示できる
 ✔ Googleアカウントでユーザー登録できる

Time: 3.58 seconds, Memory: 16.00 MB

OK (2 tests, 2 assertions)
```

### Googleの認証画面を表示させる
#### まずテストを修正します

```tests/Feature/OAuthTest.php
use Tests\TestCase;

class OAuthTest extends TestCase
{

    /**
     * 省略
     */

    /**
     * @test
     */
    public function Googleの認証画面を表示できる()
    {
        // URLをコール
        $response = $this->get(route('socialOAuth', ['provider' => $this->providerName]));
        $response->assertStatus(302);

        $target = parse_url($response->headers->get('location'));
        // リダイレクト先ドメインの検証
        $this->assertEquals('accounts.google.com', $target['host']);

        // パラメータの検証
        $query = explode('&', $target['query']);
        $this->assertContains('redirect_uri=' . urlencode(config('services.google.redirect')), $query);
        $this->assertContains('client_id=' . config('services.google.client_id'), $query);
    }

    /**
     * 以下省略
     */
}
```
コメントにある通りです｡

- ステータスコードが302(リダイレクト)か
- リダイレクト先のドメインが`accounts.google.com`か
- リダイレクトURLやクライアントIDは期待通りの設定がされているか

を検証します｡
この状態で`PHPUnit`を実行するとテストは失敗するはずです｡
~~※リダイレクト先のドメインを検証する方法もっとスマートな方法ないでしょうか......~~
2019/11/29 修正
[`parse_url`](https://www.php.net/manual/ja/function.parse-url.php) を使って `host` を取得する方法に切り替えました｡
また､パラメータに付与されている値の検証も追加

#### コントローラーを修正します
`Socialite`ファサードを使い､各サービスの認証画面にリダイレクトします｡
`Socialite::driver`の引数にはサービス名を渡します｡

```app/Http/Controllers/Auth/OAuthController.php
// 追加
use Socialite;

class OAuthLoginController extends Controller
{
    /**
     * 各SNSのOAuth認証画面にリダイレクトして認証
     * @param string $provider サービス名
     * @return mixed
     */
    public function socialOAuth(string $provider)
    {
        return Socialite::driver($provider)->redirect();
    }

    /**
     * 以下省略
     */
}
```
#### テストを実行します
今度はすべてのテストをパスするはずです｡

```bash
./vendor/bin/phpunit --testdox tests/Feature/OAuthTest.php
PHPUnit 7.5.9 by Sebastian Bergmann and contributors.

Feature\OAuth
 ✔ Googleの認証画面を表示できる
 ✔ Googleアカウントでユーザー登録できる

Time: 4.07 seconds, Memory: 18.00 MB

OK (2 tests, 3 assertions)
```
ブラウザで`https://localhost/auth/google`に直接アクセスしてみてください｡
Googleの認証画面が表示されると思います｡

### Googleからユーザーデータを受け取ってDBに保存する
#### まずテストを修正します

```tests/Feature/OAuthTest.php
// 以下を追加
use App\User;
use Auth;
use Socialite;
use Mockery;
use Illuminate\Foundation\Testing\RefreshDatabase;

class OAuthTest extends TestCase
{
    use RefreshDatabase;

    public function setUp(): void
    {
        parent::setUp();

        Mockery::getConfiguration()->allowMockingNonExistentMethods(false);

        $this->providerName = 'google';
        
        // モックを作成
        $this->user = Mockery::mock('Laravel\Socialite\Two\User');
        $this->user
            ->shouldReceive('getId')
            ->andReturn(uniqid())
            ->shouldReceive('getEmail')
            ->andReturn(uniqid().'@test.com')
            ->shouldReceive('getNickname')
            ->andReturn('Pseudo');

        $this->provider = Mockery::mock('Laravel\Socialite\Contracts\Provider');
        $this->provider->shouldReceive('user')->andReturn($this->user);
    }

    public static function tearDownAfterClass(): void
    {
        // Mockeryの設定をもとに戻す
        Mockery::getConfiguration()->allowMockingNonExistentMethods(true);
    }

    /**
     * 省略
     */

    /**
     * @test
     */
    public function Googleアカウントでユーザー登録できる()
    {
        Socialite::shouldReceive('driver')->with($this->providerName)->andReturn($this->provider);

        // URLをコール
        $this->get(route('oauthCallback', ['service' => $this->providerName]))
            ->assertStatus(302)
            ->assertRedirect(route('home'));

        // 各データが正しく登録されているかチェック
        $this->assertDatabaseHas('users', [
            'provider_id' => $this->user->getId(),
            'provider_name' => $this->providerName,
            'name' => $this->user->getNickName(),
            'email' => $this->user->getEmail()
        ]);

        // 認証チェック
        $this->assertAuthenticated();
    }
}
```
##### RefreshDataBase
`RefreshDataBase`トレイトを`use`します｡
これにより､テストクラス実行前に`migration`が走り､テスト終了後に`rollback`されます｡

##### Mockery::getConfiguration()->allowMockingNonExistentMethods(false)
Mockに対して､そのクラスに本来存在しないメソッドが追加された場合､エラーとするかどうかを設定できます｡
デフォルトで`true`､つまり存在しないメソッドが追加されることを許可する設定になっています｡
ですが､ライブラリがメソッド名を変更した場合などにエラーになってほしいので､`false`を設定します｡
試しに以下のような処理を追加して実行するとエラーになるのがわかると思います｡

```php:エラー
$this->user->shouldReceive('foo')->andReturn(uniqid())
```
このままでは､他のテストクラスも影響を受けてしまいます｡
デフォルトの挙動を期待して他のテストを書いていることもあると思います｡
最後のテストメソッドが終了したあとに呼ばれる`tearDownAfterClass()`でデフォルト設定に戻しましょう｡

##### Mockery::mock('Laravel\Socialite\Two\User')
そのままです｡
`Socialite`の`User`クラスのモックを作成しています｡

##### shouldReceive()->andReturn()
モックのメソッドの戻り値を設定しています｡

```php
$this->user->shouldReceive('getId')->andReturn(12345)
// 12345が返ってきます
$this->user->getId()
```

##### shouldReceive()->with()
`with()`は`shouldReceive()`で指定したメソッドの引数を設定できます｡
以下の処理が各サービスからコールバックされた動きをモックしています｡

```php
Socialite::shouldReceive('driver')->with($this->providerName)->andReturn($this->provider);
```

以降の処理は､渡したデータが正しくDBに格納されているかの確認と､認証が正しく行われているかの確認になります｡

この状態でPHPUnitを実行するとテストは失敗するはずです｡

#### コントローラーを修正します
```app/Http/Controllers/Auth/OAuthController.php
// 追加
use App\User;
use Auth;

class OAuthLoginController extends Controller
{
    /**
     * 省略
     */

    /**
     * 各サイトからのコールバック
     * @param string $provider サービス名
     * @return mixed
     */
    public function handleProviderCallback($provider)
    {
        $socialUser = Socialite::driver($provider)->user();
        $user = $this->user->firstOrNew(['email' => $socialUser->getEmail()]);

        // すでに会員になっている場合の処理を書く
        // そのままログインさせてもいいかもしれない
        if ($user->exists) {
            abort(403);
        }

        $user->name = $socialUser->getNickname();
        $user->provider_id = $socialUser->getId();
        $user->provider_name = $provider;
        $user->save();

        Auth::login($user);
        
        return redirect()->route('home');
    }
}
```
説明不要だとは思いますが一応
Googleから渡されたユーザーデータから､メールアドレスを取得します｡
すでに登録されている場合は会員登録済みと判断します｡
上記では`403`にしていますが､そのままログインでも良いような気がします｡
そのへんはお好みで

あとは`Users`テーブルに渡されたデータを`INSERT`し､その情報で認証しているだけです｡

#### テストを実行します
今度はすべてのテストをパスするはずです｡

```bash
./vendor/bin/phpunit --testdox tests/Feature/OAuthTest.php
PHPUnit 7.5.9 by Sebastian Bergmann and contributors.

Feature\OAuth
 ✔ Googleの認証画面を表示できる
 ✔ Googleアカウントでユーザー登録できる

Time: 5.05 seconds, Memory: 22.00 MB

OK (2 tests, 19 assertions)
```
ブラウザで`https://localhost/auth/google`に直接アクセスし､Googleの認証画面で認証してください｡
`https://localhost/home`へリダイレクトされると思います｡

以上です｡


## 参考
https://readouble.com/laravel/5.8/ja/socialite.html
https://qiita.com/kite_999/items/bddd62c395f260e745bc
https://readouble.com/mockery/1.0/ja/index.html