Laravel API SNSログイン機能を実装する

## Laravel Socialite

Laravelの公式からOAuthプロバイダで認証するための便利なライブラリが提供されています。

- https://readouble.com/laravel/9.x/ja/socialite.html
- https://github.com/laravel/socialite

## サポートプラットフォーム

Socialiteは現在下記のプロバイダをサポートしています。

- Facebook
- Twitter
- LinkedIn
- Google
- GitHub
- GitLab
- Bitbucket

この他にもコミュニティで管理されている150以上のプロバイダがあります。

https://socialiteproviders.com

## Socialiteのインストール

```shell-session
$ composer require laravel/socialite
```

## 環境

- PHP: 8.1.5
- Laravel: 9.9.0
- [laravel/socialite](https://github.com/laravel/socialite): 5.5.2

## 実装

GitHubとSNSログインする場合を例に実装します。

### .env

```.env
APP_URL=http://localhost

SESSION_DRIVER=cookie
SESSION_LIFETIME=120

GITHUB_CLIENT_ID=1234567890abcdefghij
GITHUB_CLIENT_SECRET=1234567890abcdefghijklmnopqrstuvwxyz
```

`GITHUB_CLIENT_ID` と `GITHUB_CLIENT_SECRET` の取得手順は後述します。

### config/services.php

```php:config/services.php
<?php declare(strict_types=1);

return [
    // ...

    'github' => [
        'client_id' => env('GITHUB_CLIENT_ID'),
        'client_secret' => env('GITHUB_CLIENT_SECRET'),
        'redirect' => env('APP_URL') . '/oauth/github/callback',
    ],
];
```

### マイグレーション

デフォルトの `users` テーブルを利用します。

```php:database/migrations/2014_10_12_000000_create_users_table.php
    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            // ...
            $table->string('name')->nullable();
            $table->string('email')->unique()->nullable();
            // ...
        });
    }
```

SNS連携からユーザー登録する場合、名前やメールアドレスを取得できないプロバイダーがある場合があります。
`name` や `email` はNULL許可にしておきます。(ここは要件に応じてケースバイケースで変えてください。)

```shell-session
$ php artisan migrate:fresh
```

ソーシャルプロバイダーを管理する `social_accounts` テーブルを定義します。
連携するプロバイダーが一つだけとか決まっている場合は `users` テーブルにカラムを設けてもいいかもしれません。

```shell-session
$ php artisan make:migration create_social_accounts_table
```

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('social_accounts', function (Blueprint $table) {
            $table->foreignId('user_id')->constrained();
            $table->string('provider_name');
            $table->string('provider_id');
            $table->string('provider_token');
            $table->string('provider_refresh_token')->nullable();
            $table->timestamps();
            $table->unique(['user_id', 'provider_name']);
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('social_accounts');
    }
};
```

```shell-session
$ php artisan migrate
```

### モデル

```shell-session
$ php artisan make:model SocialAccount
```

```php:app/Models/SocialAccount.php
<?php declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

final class SocialAccount extends Model
{
    use HasFactory;

    public $incrementing = false;

    protected $primaryKey = ['user_id', 'provider_name'];
    protected $guarded = ['created_at', 'updated_at'];

    /**
     * @return BelongsTo
     */
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}
```

`app/Models/User.php` に追記します。

```php:app/Models/User.php
use Illuminate\Database\Eloquent\Relations\HasMany;

final class User extends Authenticatable
{
    // ...

    /**
     * @return HasMany
     */
    public function socialAccounts(): HasMany
    {
        return $this->hasMany(SocialAccount::class);
    }
}
```

### 対応するプロバイダーのEnumを作る

```shell-session
$ mkdir app/Enums
$ touch app/Enums/SocialProvider.php
```

```php:app/Enums/SocialProvider.php
<?php declare(strict_types=1);

namespace App\Enums;

enum SocialProvider: string
{
    case GitHub = 'github';
}
```

対応するプロバイダーを増やしたい時はここのEnumを増やします。
コントローラで暗黙のEnumバインディングとして受け取れます。

存在しないプロバイダーが指定された時は404エラーになります。

https://readouble.com/laravel/9.x/ja/routing.html#implicit-enum-binding

### コントローラ

```shell-session
$ php artisan make:controller -i OAuth/RedirectToProviderController
$ php artisan make:controller -i OAuth/CallbackFromProviderController
```

```php:app/Http/Controllers/OAuth/RedirectToProviderController.php
<?php declare(strict_types=1);

namespace App\Http\Controllers\OAuth;

use App\Enums\SocialProvider;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Laravel\Socialite\Facades\Socialite;

final class RedirectToProviderController extends Controller
{
    /**
     * @param SocialProvider $provider
     * @return RedirectResponse
     */
    public function __invoke(SocialProvider $provider): RedirectResponse
    {
        return Socialite::driver($provider->value)->redirect();
    }
}
```

```php:app/Http/Controllers/OAuth/CallbackFromProviderController.php
<?php declare(strict_types=1);

namespace App\Http\Controllers\OAuth;

use App\Enums\SocialProvider;
use App\Http\Controllers\Controller;
use App\Models\SocialAccount;
use App\Models\User;
use Illuminate\Auth\AuthManager;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteTwoUser;

final class CallbackFromProviderController extends Controller
{
    /**
     * @param AuthManager $auth
     */
    public function __construct(
        private AuthManager $auth,
    ) {
    }

    /**
     * @param Request $request
     * @param SocialProvider $provider
     * @return JsonResponse
     */
    public function __invoke(Request $request, SocialProvider $provider): JsonResponse
    {
        /** @var SocialiteTwoUser $socialiteTwoUser */
        $socialiteTwoUser = Socialite::driver($provider->value)->user();

        $socialAccount = SocialAccount::where([
            'provider_name' => $provider->name,
            'provider_id' => $socialiteTwoUser->getId(),
        ])->first();

        if ($socialAccount) {
            $this->auth->guard()->login($socialAccount->user);
            $request->session()->regenerate();

            return new JsonResponse([
                'data' => [
                    'id' => $socialAccount->user->id,
                    'name' => $socialAccount->user->name,
                    'email' => $socialAccount->user->email,
                ],
                'message' => 'Already social linked.',
            ]);
        }

        $user = User::firstOrCreate([
            'email' => $socialiteTwoUser->getEmail(),
        ], [
            'name' => $socialiteTwoUser->getName(),
            'password' => Hash::make(Str::random()),
        ]);

        $user->socialAccounts()->create([
            'provider_name' => $provider->value,
            'provider_id' => $socialiteTwoUser->getId(),
            'provider_token' => $socialiteTwoUser->token,
            'provider_refresh_token' => $socialiteTwoUser->refreshToken,
        ]);

        $this->auth->guard()->login($user);
        $request->session()->regenerate();

        return new JsonResponse([
            'data' => [
                'id' => $user->id,
                'name' => $user->name,
                'email' => $user->email,
            ],
            'message' => 'Successful social link.',
        ]);
    }
}
```

- 既にSocialAccount作成済みの場合は認証させて正常レスポンスを返す
- Userが存在しなければ連携先の情報を元にUserを作成する
- SocialAccountを作成してUserを認証させて正常レスポンスを返す

この辺りの実装は仕様によると思います。
お好みで書き換えてください。

### ルーティング

```php:routes/web.php
<?php declare(strict_types=1);

use App\Http\Controllers\OAuth\CallbackFromProviderController;
use App\Http\Controllers\OAuth\RedirectToProviderController;

Route::get('/oauth/{provider}/redirect', RedirectToProviderController::class)->name('oauth.redirect');
Route::get('/oauth/{provider}/callback', CallbackFromProviderController::class)->name('oauth.callback');
```

### GitHub OAuth 設定手順

Settings > Developer settings > OAuth Apps > Register a new application にアクセスします。

https://github.com/settings/developers

![ScreenShot 2022-05-07 0.41.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/411071b0-befc-3ce7-35b6-bae29cebcd7c.png)

![ScreenShot 2022-05-07 0.48.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/6bce0e15-1a38-7b45-eccf-bc685c36ee4d.png)

![ScreenShot 2022-05-07 0.49.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/211b2066-9616-7d37-279f-87b23b43ad3c.png)

- Application name
- Homepage URL
- Authorization callback URL

を入力します。

![ScreenShot 2022-05-08 4.24.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/3108d62d-7405-6e1c-1a6c-98e92ccdeb79.png)

アプリケーションを作成するとClient IDが表示されることを確認します。

![ScreenShot 2022-05-07 0.57.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/7d0a80ed-b13c-969d-30fe-da052a6fe44f.png)

Client secrets の `Generate a new client secret` をクリックします。

![ScreenShot 2022-05-07 1.09.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/9c460cb5-5489-053c-b187-7ff8bc658004.png)

Client secrets が表示されることを確認します。
(次回以降表示されなくなるのでコピー忘れずに。)

.envのGITHUB_CLIENT_IDとCLIENT_SECRETに追記します。

```.env
GITHUB_CLIENT_ID=1234567890abcdefghij
GITHUB_CLIENT_SECRET=1234567890abcdefghijklmnopqrstuvwxyz
```

詳しくはGitHubの公式ドキュメントを参照してください。

https://docs.github.com/ja/developers/apps/building-oauth-apps/authorizing-oauth-apps

環境変数の設定確認します。

```shell-session
$ php artisan tinker

config('services.github');
```

### 動作確認

http://localhost/oauth/github/redirect

![ScreenShot 2022-05-08 4.35.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/304ee25e-f09c-8d6f-39ef-cfbf4edb5780.png)

![ScreenShot 2022-05-08 4.36.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/f98613f3-b620-c53a-1adc-558850a94a47.png)

認証するとコールバックのルートへリダイレクトされます。

```
http://localhost/oauth/github/callback?code=c721fe70faa13d59b942&state=aL0mn7MC49WOEENGFunqupoJ6gKI7iqvFHdJPZLa
```

成功すると下記のJSONレスポンスが返ります。

```json
{
	"data": {
		"id": 1,
		"name": "your-name",
		"email": "your-name@example.com"
	},
	"message": "Successful social link."
}
```

SQLを実行してテーブルにデータが登録されていることを確認します。

```sql
> select * from users\G;
*************************** 1. row ***************************
               id: 1
             name: ucan-lab
            email: your-name@example.com
email_verified_at: NULL
         password: $2y$10$qIbmZs/4QRNFuM8xSrXe3.T5S0fTjDwQe9qHecR.MFFGdGqvyHDJW
   remember_token: NULL
       created_at: 2022-05-07 19:15:38
       updated_at: 2022-05-07 19:15:38
1 row in set (0.00 sec)

> select * from social_accounts\G;
*************************** 1. row ***************************
               user_id: 1
         provider_name: github
           provider_id: 123456789
        provider_token: 123456789abcdefghijklmnopqrstuvwxyz
provider_refresh_token: NULL
            created_at: 2022-05-07 19:15:38
            updated_at: 2022-05-07 19:15:38
1 row in set (0.00 sec)
```

## 他のプロバイダーを追加したい場合

下記の手順で追加できます。

- 各種プロバイダーでアプリケーションを作成
- `.env` に各種クライアントIDとクライアントシークレットを追加
- `config/services.php` に各種クライアントの設定を追加
- `app/Enums/Provider.php` に各種クライアント名を追加

## LINEプロバイダーを追加する手順

LINEは公式ではなくコミュニティで管理されているので、コミュニティのプロバイダーを追加する手順をご紹介します。

https://socialiteproviders.com/Line/

追加手順は各種プロバイダーのインストール手順を参考にして進めます。

```shell-session
$ composer require socialiteproviders/line
```

`config/services.php` にLINEプロバイダーを追加します。

```php:config/services.php
    'line' => [    
        'client_id' => env('LINE_CLIENT_ID'),  
        'client_secret' => env('LINE_CLIENT_SECRET'),  
        'redirect' => env('APP_URL') . '/oauth/line/callback',
    ],
```

```php:app/Providers/EventServiceProvider.php
    protected $listen = [
        \SocialiteProviders\Manager\SocialiteWasCalled::class => [
            // ... other providers
            \SocialiteProviders\Line\LineExtendSocialite::class . '@handle',
        ],
    ];
```

```php:app/Enums/Provider.php
<?php declare(strict_types=1);

namespace App\Enums;

enum Provider: string
{
    case GitHub = 'github';
    case LINE = 'line';
}
```

環境変数の設定確認します。

```shell-session
$ php artisan tinker

config('services.line');
```

### LINE Developers

https://developers.line.biz/ja

![ScreenShot 2022-05-09 22.30.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/dcf21c53-e92b-c858-55f6-41a17624cc1c.png)

LINEアカウントもしくはビジネスアカウントを作成してログインします。
今回はLINEアカウントを利用します。仕事で使う場合はビジネスアカウントを作成しましょう。

LINEアカウントでログインすると開発用アカウントの登録を求められます。

![ScreenShot 2022-05-09 22.32.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/8386c180-7a07-649c-09e6-eb7e02c296b9.png)

開発者登録が完了すると開発コンソール画面が表示されます。

![ScreenShot 2022-05-09 22.33.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/bad22f50-ef53-4a91-591d-22ccf2cfd24c.png)

まずはプロバイダーを作成します。

![ScreenShot 2022-05-09 22.34.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/c88c2188-dc8a-0ac5-285b-0ef02f22c1e0.png)

適当にアプリケーション名を入れてプロバイダーを作成します。

![ScreenShot 2022-05-09 22.34.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/08ddf4a5-44fc-5457-7a87-e51a056207fe.png)

次に `Create a LINE Login channel` からログインチャンネルを作成します。

![ScreenShot 2022-05-09 22.34.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/94335540-46dc-77be-d7ce-ac7c0305437b.png)

### Create a new channel

チャンネルの作成では下記の項目の入力を求められます。

- Channel type: LINE Login
- Provider: 作成したプロバイダー名を選択
- Region: Japan
- Company or owner's country or region: Japan
- Channel icon: 任意
- Channel name: 適当
- Channel description: 適当
- App types: Web app (今回はMobile appは選択しない)
- Email address: your-email@example.com
- Privacy policy URL: 任意
- Terms of use URL: 任意

![ScreenShot 2022-05-09 22.45.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/e0c49e5b-74f3-5a9f-9173-988338eee2fa.png)

![ScreenShot 2022-05-09 22.45.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/6a6d4090-de14-04d3-03ab-1e6a781e2091.png)

作成されたチャンネル画面から `Channel ID` と `Channel secret` の値を `.env` に設定します。

```.env
LINE_CLIENT_ID=1234567890
LINE_CLIENT_SECRET=1234567890abcdefghijklmnopqrstuv
```

`LINE Login` タブから `Web app` の `Callback URL` を設定します。

 ![ScreenShot 2022-05-09 22.48.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/167b3875-85ca-8b15-0fe6-2ccd5b201ae1.png)

コールバックURL: `http://localhost/oauth/line/callback`

【2022.09.17 追記】

`localhost` はコールバックURLとして設定できなくなっていました。

![ScreenShot 2022-09-17 18.16.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/a62bfcc7-9ec5-61c6-7cc0-eb534d2bd81a.png)

`/etc/hosts` ファイルを書き換えて

```/etc/hosts
127.0.0.1    example.com
```

コールバックURL: `https://example.com/oauth/line/callback`
と指定するとLINEログインできます。(一応ローカルSSLしなくても大丈夫みたいです。)

https://qiita.com/ucan-lab/items/c7f0690227ce7da4a172

もしくは[ngrok](https://ngrok.com)を使ってngrokの生成したURLを使用します。
前にngrokの記事を書いているのでよければ参考にしてください。

https://qiita.com/ucan-lab/items/196cce2f1c78d7ae6f53

### 動作確認

http://localhost/oauth/line/redirect

![ScreenShot 2022-05-09 23.24.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/7d1fbf51-dd91-1058-a8a7-29b349c93bda.png)

![ScreenShot 2022-05-09 23.25.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/52879/c8ac4445-b300-8161-eb80-dfc1c3b00b92.png)

正常レスポンスが返ってきたので成功です。