📚

Laravel Jetstream+Socialiteでソーシャルログイン

34 min read

はじめに

この記事を読むと、Laravel JetstreamでSocialiteによりソーシャルログインできるようになります。
さらに、パスワード確認を回避できるようになります。

対象者

Socialiteの知識があり、Jetstreamに導入したい方向け

前提

  • Jetstreamのフロントエンド実装としてVueを使用しています
  • チーム機能を有効化しています
  • 多言語化(i18n)対応済みです
  • この記事ではプロバイダの例としてGoogle、GitHub、GitLabを使用しています

多言語化(i18n)については以下の記事をご覧ください。

https://zenn.dev/yamabiko/articles/laravel-jetstream-vite-i18n

バージョン情報

  • PHP: 8.0.11
  • laravel/framework: v8.63
  • laravel/jetstream: v2.4.2
  • laravel/socialite: v5.2.5
  • laravel/sail: v1.11.0
  • vue@3.2.11

Jetstream+Socialiteの問題点

JetstreamにSocialiteを導入して、OAuth認証するだけであればスムーズに実現できると思います。

しかし、最大の問題点はパスワード確認です。
基本的にソーシャルログインした場合、パスワードは不要です。
しかし、Jetstreamは様々な機能がパスワード確認機能と密接に関連しているのです。

例えば、二段階認証を有効化するときにパスワード確認を求められます。
ソーシャルログインしたユーザーはパスワードが無いので困ってしまいますよね。

この記事では、上記のような問題点の解決についても解説します。

方針

  • ひとつのメールアドレスに対して、複数のSNSを許可する。UserとSNSユーザーは1対多の関係となる
  • ソーシャルログインした場合、パスワード確認・登録を不要とする
  • パスワード確認不要の実行確認モーダル画面を表示する。ただし、パスワード確認のみのモーダル画面は表示しない。
  • 二段階認証が有効になっている場合、SNS認証後に二段階認証画面を表示する
  • SNSユーザー情報を全てデータベースに登録する
  • SNSユーザー情報に変更があった場合、SNS認証情報を更新する
  • User削除時に、紐付くSNSユーザーを削除する
  • OAuth2のみを対象とする
  • 通常のユーザーはSocialite導入前と同じようにシステムを利用できる

データベース

ER図

erDiagram
          users ||--o{ social_users : has
          social_users {
              bigint id
              bigint user_id
              string provider_name
              string provider_id
              string nickname
              string name
              string email
              string avatar
              string token
              string refresh_token
              int expires_in
              json social
              timestamp created_at
              timestamp updated_at
          }

usersテーブル

ソーシャルログインした場合、パスワード不要であるため、Userテーブルのpaswordをnull許可にします。

database/migrations/edit_columns_in_users_table.php
<?php

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

class EditColumnsInUsersTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('password')->nullable()->change();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('password')->change();
        });
    }
}

social_usersテーブル

SNSユーザー情報を登録するためSNSユーザーテーブルを新規追加します。
OAuth2認証で使用される全ての項目(Laravel\Socialite\Two\Userクラスの項目)を定義します。
また、OAuth認証サーバから返却された全ての項目をsocialカラムに登録します。

database/migrations/create_social_users_table.php
<?php

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

class CreateSocialUsersTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('social_users', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->index();
            $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
            $table->string('provider_name');
            $table->string('provider_id');
            $table->string('nickname')->nullable();
            $table->string('name')->nullable();
            $table->string('email');
            $table->string('avatar')->nullable();
            $table->string('token')->nullable();
            $table->string('refresh_token')->nullable();
            $table->integer('expires_in')->nullable();
            $table->json('social');
            $table->timestamps();
            $table->unique(['provider_name','provider_id']);
            $table->unique(['user_id','provider_name']);
        });
    }

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

実装

ソーシャルログイン

コールバック後のフローチャート

OAuthプロバイダからコールバックされた後の処理内容をフローチャートにしました。

graph TD
db[(Database)]
    A[開始] -->B[[プロバイダからSNSユーザー情報を取得]]
    B --> C(SNSユーザーを検索)
    C -. read .-> db
    C --> D{SNSユーザー登録済み?}
    D --> |Y|E(SNSユーザー情報を更新)
    D --> |N|F
    E -. update .-> db
    E --> F(Userを取得)
    F -.read .-> db
    F --> H{Userが存在する?}
    H --> |Y|I{新規プロバイダ?}
    I --> |Y|J(SNSユーザー情報を新規登録)
    J -.create.-> db
    J --> L(セッションを再生成)
    I -->|N|L
    H -->|N|K(Userとチームを新規登録)
    M -.create.-> db
    K -.create.-> db
    K --> M(SNSユーザー情報を新規登録)
    M --> N(確認メール送信イベント登録)
    N --> L
    L -->Q(セッションにソーシャルログイン判定フラグを登録)
    Q -->R{二段階認証が有効?}
    R -->|Y|S(二段階認証画面に遷移)
    R -->|N|T(ログインする)
    T -->U(ダッシュボード画面に遷移)
    U -->Z
    S -->Z[終了]

User、SNSユーザー登録・更新の仕様

  • 新規ユーザーの場合⇒User、SNSユーザー、チームを新規登録する
  • 既存ユーザー、かつ、SNSユーザー未登録の場合⇒SNSユーザー情報を新規登録する(emailで紐づけ)
  • 既にOAuth認証済みだが、新たなプロバイダでOAuth認証した場合⇒SNSユーザーを新規登録する(emailで紐づけ)
  • 登録済みSNSユーザーで再ログインした場合⇒SNSユーザー情報に変更があれば更新する

画面遷移の仕様

  • 二段階認証を有効化している場合⇒二段階認証画面に遷移する
  • 二段階認証を無効化している場合⇒ダッシュボードに遷移する

セッションの仕様

  • セッションを再生成する
  • ソーシャルログイン判定フラグを登録する(キー:isSocialLogin、値:true)

確認メール送信の仕様

  • UserクラスがMustVerifyEmailを実装している場合⇒確認メールを送信する

ソーシャルログインコントローラ

ソーシャルログインを実行するコントローラです。

app/Http/Controllers/Auth/SocialLoginController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Actions\JetstreamApp\RedirectIfTwoFactorAuthenticatableForSocial;
use App\Http\Controllers\Controller;
use App\Models\SocialUser;
use App\Models\User;
use Exception;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Routing\Pipeline;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Laravel\Fortify\Contracts\LoginResponse;
use Laravel\Fortify\Features;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as TwoUser;

/**
 * ソーシャルログインコントローラ
 */
class SocialLoginController extends Controller
{
    /**
     * OAuth認証を許可するプロバイダ
     *
     * @var array
     */
    protected array $providers = ['google', 'github', 'gitlab'];

    /**
     * $providerに基づいて認証ページにリダイレクトします.
     * 
     * @param string $provider
     * @return \Illuminate\Http\Response
     */
    public function redirectToProvider(string $provider)
    {
        try {
            Log::debug("start.");
            if (!in_array($provider, $this->providers)) {
                Log::warning("プロバイダが不正です:[${provider}]");
                abort(404);
            }

            $scopes = config("services.$provider.scopes") ?? [];
            Log::debug("[${provider}]認証ページにリダイレクト start.");
            if (count($scopes) === 0) {
                $response = Socialite::driver($provider)->redirect();
            } else {
                $response =  Socialite::driver($provider)->scopes($scopes)->redirect();
            }
            Log::debug("認証ページにリダイレクト end.");
            Log::debug("end.");
            return $response;
        } catch (Exception $e) {
            Log::error($e->getMessage());
            return redirect('login')->withErrors(['authentication_deny' => trans('auth.oauth')]);
        }
    }

    /**
     * $providerから認証情報を取得します.
     * 
     * @param string $provider
     * @return \Illuminate\Http\Response
     */
    public function handleProviderCallback(Request $request, string $provider)
    {
        try {
            Log::debug("start.");
            Log::debug("SNSユーザー情報取得 start.");
            $user = Socialite::driver($provider)->user();
            Log::debug("SNSユーザー情報取得 end.");

            $authUser = DB::transaction(function () use ($user, $provider) {
                // SNSユーザーを検索する
                $socialUser = SocialUser::where('provider_name', $provider)
                                        ->where('provider_id', $user->getId())
                                        ->lockForUpdate()
                                        ->first();

                if ($socialUser) {
                    Log::debug('SNSユーザー登録済み.');
                    // SNSユーザーが存在する場合、変更があれば更新する
                    $socialUser->updateIfChanged($user);
                    $authUser = $socialUser->user;
                } else {
                    Log::debug('SNSユーザー未登録.');
                    // SNSユーザーのメールアドレスに紐づくUserを検索する
                    $authUser = User::where('email', $user->getEmail())->first();
                }

                if (!$authUser && !$socialUser) {
                    Log::debug('新規User.');
                    // 新規Userの場合、UserとSNSユーザーを登録する
                    $authUser = tap(User::Create([
                        'email' => $user->getEmail(),
                        'name' => $user->getName() ?? $user->getNickName(),
                    ]), function (User $user) {
                        $user->createTeam();
                    });

                    $socialUser = $this->createSocialUser($provider, $user, $authUser->id);
                    // MustVerifyEmail実装Userモデルの場合、確認メールを送信する
                    event(new Registered($authUser));
                } else if ($authUser && !$socialUser) {
                    Log::debug('新規SNS.');
                    // 既にUserが存在する場合、SNSユーザーを登録する
                    $socialUser = $this->createSocialUser($provider, $user, $authUser->id);
                }

                return $authUser;
            });

            /**
             * ソーシャルログインであることを記録するために、isSocialLoginをtrueに設定する.
             */
            session()->regenerate();
            session(['isSocialLogin' => true]);

            if ($authUser->two_factor_secret && Features::enabled(Features::twoFactorAuthentication())) {
                Log::debug('二段階認証が有効.');
                // 二段階認証を有効にしている場合、パスワード確認を回避してUserモデルを取得するために、HTTPリクエストにidをマージする.
                $request->merge(['id' => $authUser->id]);
            } else {
                Log::debug('二段階認証が無効.');
                // 二段階認証を無効化している場合、ログインする
                Auth::login($authUser);
            }

            $pipeline = (new Pipeline(app()))->send($request)->through(array_filter([
                Features::enabled(Features::twoFactorAuthentication()) ? RedirectIfTwoFactorAuthenticatableForSocial::class : null,
            ]));

            Log::debug("end.");

            return $pipeline->then(function ($request) {
                return app(LoginResponse::class);
            });
        } catch (Exception $e) {
            Log::error($e->getMessage());
            return redirect('login')->withErrors(['authentication_deny' => trans('auth.oauth')]);
        }
    }

    /**
     * SNSユーザーを登録する
     *
     * @param string $provider
     * @param TwoUser $user
     * @param integer $user_id
     * @return void
     */
    protected function createSocialUser(string $provider, TwoUser $user, int $user_id)
    {
        return SocialUser::Create([
            'user_id' => $user_id,
            'provider_name' => $provider,
            'provider_id' => $user->getId(),
            'nickname' => $user->getNickname(),
            'name' => $user->getName(),
            'email' => $user->getEmail(),
            'avatar' => $user->getAvatar(),
            'token' => $user->token,
            'refresh_token' => $user->refreshToken,
            'expires_in' => $user->expiresIn,
            'social' => $user->user
        ]);
    }
}

SocialUserクラス

app/Models/SocialUser.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Laravel\Socialite\Two\User as TwoUser;

class SocialUser extends Model
{
    use HasFactory;

    protected $guarded = [];

    protected $casts = [
        'social' => 'array',
    ];

    public function user()
    {
        return $this->belongsTo(User::class);
    }

    /**
     * SNSユーザー情報に変更があった場合、更新する.
     *
     * @param TwoUser $twoUser
     * @return void
     */
    public function updateIfChanged(TwoUser $twoUser) {
        
        if($this->nickname != $twoUser->getNickname()) {
            $hasChanged = true;
            $this->nickname = $twoUser->getNickname();
        }

        if($this->name != $twoUser->getName()) {
            $hasChanged = true;
            $this->name = $twoUser->getName();
        }

        if($this->email != $twoUser->getEmail()) {
            $hasChanged = true;
            $this->email = $twoUser->getEmail();
        }

        if($this->avatar != $twoUser->getAvatar()) {
            $hasChanged = true;
            $this->avatar = $twoUser->getAvatar();
        }

        if($this->token != $twoUser->token) {
            $hasChanged = true;
            $this->token = $twoUser->token;
        }

        if($this->refresh_token != $twoUser->refreshToken) {
            $hasChanged = true;
            $this->refresh_token = $twoUser->refreshToken;
        }

        if($this->expires_in != $twoUser->expiresIn) {
            $hasChanged = true;
            $this->expires_in = $twoUser->expiresIn;
        }

        if(array_diff(array_map('json_encode', $this->social), array_map('json_encode', $twoUser->user))) {
            $hasChanged = true;
            $this->social = $twoUser->user;
        }

        if($hasChanged) {
            $this->save();
        }
    }
}

Userクラス

CreateNewUserクラスから新規チーム作成処理(createTeamメソッド)を移管しました。
ソーシャルログインコントローラでも新規チーム作成処理が必要になったためです。

app/Models/User.php
    public function socialUsers()
    {
        return $this->hasMany(SocialUser::class);
    }

    /**
     * Create a personal team for the user.
     *
     * @return void
     */
    public function createTeam()
    {
        $this->ownedTeams()->save(Team::forceCreate([
            'user_id' => $this->id,
            'name' => explode(' ', $this->name, 2)[0]."'s Team",
            'personal_team' => true,
        ]));
    }

CreateNewUserクラス

前出の新規チーム作成処理(createTeamメソッド)をUserクラスに移管しました。

app/Actions/Fortify/CreateNewUser.php
    /**
     * Create a newly registered user.
     *
     * @param  array  $input
     * @return \App\Models\User
     */
    public function create(array $input)
    {
        Validator::make($input, [
            'name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
            'password' => $this->passwordRules(),
            'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['required', 'accepted'] : '',
        ])->validate();

        return DB::transaction(function () use ($input) {
            return tap(User::create([
                'name' => $input['name'],
                'email' => $input['email'],
                'password' => Hash::make($input['password']),
            ]), function (User $user) {
+                $user->createTeam();
-                $this->createTeam($user);
            });
        });

-    /**
-     * Create a personal team for the user.
-     *
-     * @param  \App\Models\User  $user
-     * @return void
-     */
-    protected function createTeam(User $user)
-    {
-        $user->ownedTeams()->save(Team::forceCreate([
-            'user_id' => $user->id,
-            'name' => explode(' ', $user->name, 2)[0]."'s Team",
-            'personal_team' => true,
-        ]));
-    }
    }

ルーティング

SNSへのリダイレクトと、SNSからのコールバックを定義しています。

routes/web.php
Route::get('/login/{provider}', [SocialLoginController::class, 'redirectToProvider']);
    
Route::get('/login/{provider}/callback', [SocialLoginController::class, 'handleProviderCallback']);

OAuthプロバイダ定義

この記事では、Google、GitHub、GitLabを定義しています。

config/services.php
    'google' => [
        'client_id' => env('GOOGLE_CLIENT_ID'),
        'client_secret' => env('GOOGLE_CLIENT_SECRET'),
        'redirect' => env('GOOGLE_REDIRECT'),
    ],
    'github' => [
        'client_id' => env('GITHUB_CLIENT_ID'),
        'client_secret' => env('GITHUB_CLIENT_SECRET'),
        'redirect' => env('GITHUB_REDIRECT'),
    ],
    'gitlab' => [
        'client_id' => env('GITLAB_CLIENT_ID'),
        'client_secret' => env('GITLAB_CLIENT_SECRET'),
        'redirect' => env('GITLAB_REDIRECT'),
        'scopes' => ['read_user'],
    ],

ソーシャルログイン時のパスワード確認を無効化

Jetstreamでは、「他のすべての端末からのログアウト」、「アカウント削除」機能でLaravel Fortifyのパスワード確認機能を使用しています。
ソーシャルログインした場合、Fortifyのパスワード確認機能を無効化するための対応が必要になります。
パスワードの確認方法のカスタマイズはJetstreamServiceProviderクラスに定義します。

app/Providers/JetstreamServiceProvider.php
    public function boot(Request $request, StatefulGuard $guard)
    {
        $this->configurePermissions();

        Jetstream::createTeamsUsing(CreateTeam::class);
        Jetstream::updateTeamNamesUsing(UpdateTeamName::class);
        Jetstream::addTeamMembersUsing(AddTeamMember::class);
        Jetstream::inviteTeamMembersUsing(InviteTeamMember::class);
        Jetstream::removeTeamMembersUsing(RemoveTeamMember::class);
        Jetstream::deleteTeamsUsing(DeleteTeam::class);
        Jetstream::deleteUsersUsing(DeleteUser::class);

+        // パスワードの確認方法のカスタマイズ
+        Fortify::confirmPasswordsUsing(function ($user, $password) use($request, $guard) {
+            if(session('isSocialLogin')) {
+                // ソーシャルログイン時、パスワード確認しない
+                return true;
+            } else {
+                // 通常ログイン時、パスワード確認する
+                $username = config('fortify.username');
+                return $guard->validate([
+                    $username => $user->{$username},
+                    'password' => $password,
+                ]);
+            }
+        });
    }

カスタム認証ガード

Jetstreamの「他のすべての端末からのログアウト」機能では、パスワード確認時にユーザーが入力したパスワードでUserテーブルのpasswordカラムをリフレッシュしています。
ここでは、ソーシャルログインした場合、passwordカラムをリフレッシュしないよう、新規にカスタム認証ガードを作成します。
カスタム認証ガードはSessionGuardクラスを拡張して実装します。

app/Services/Auth/CustomGuard.php
<?php

namespace App\Services\Auth;

use Illuminate\Auth\SessionGuard;

class CustomGuard extends SessionGuard
{
    protected function rehashUserPassword($password, $attribute)
    {
        // ソーシャルログイン時、パスワードをリフレッシュしない.
        if(!session('isSocialLogin')){
            parent::rehashUserPassword($password, $attribute);
        }
    }
}

カスタム認証ガードの生成処理。

app/Providers/AuthServiceProvider.php
    public function boot()
    {
        $this->registerPolicies();

        Auth::extend('custom', function ($app, $name, array $config) {
            $provider = Auth::createUserProvider($config['provider'] ?? null);

            $guard = new CustomGuard($name, $provider, $this->app['session.store']);

            if (method_exists($guard, 'setCookieJar')) {
                $guard->setCookieJar($app['cookie']);
            }
    
            if (method_exists($guard, 'setDispatcher')) {
                $guard->setDispatcher($app['events']);
            }
    
            if (method_exists($guard, 'setRequest')) {
                $guard->setRequest($app->refresh('request', $guard, 'setRequest'));
            }

            return $guard;
        });
    }

カスタム認証ガードを登録します。

config/auth.php
    'guards' => [
        'web' => [
            //'driver' => 'session',
            'driver' => 'custom',
            'provider' => 'users',
        ],
    ],

二段階認証画面を表示する際のパスワード回避

二段階認証画面を表示する際にパスワード確認OKの場合にUserモデルを返却するという処理があります。
ここでは、パスワード確認を回避して、Userモデルを返却するよう対応します。
具体的には、RedirectIfTwoFactorAuthenticatableクラスを拡張して、validateCredentialsメソッドを改修します。

app/Actions/JetstreamApp/RedirectIfTwoFactorAuthenticatableForSocial.php
<?php

namespace App\Actions\JetstreamApp;

use App\Models\User;
use Laravel\Fortify\Actions\RedirectIfTwoFactorAuthenticatable;

class RedirectIfTwoFactorAuthenticatableForSocial extends RedirectIfTwoFactorAuthenticatable
{
    protected function validateCredentials($request)
    {
        // ソーシャルログイン時、パスワード確認を回避してUserモデルを返却する.
        return User::find($request->get('id'));
    }
}

パスワード確認画面の表示を回避


パスワード確認画面の表示を回避するために、カスタムのミドルウェアを追加する。

app/Http/Middleware/CustomRequirePassword.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Auth\Middleware\RequirePassword;

class CustomRequirePassword extends RequirePassword
{

    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @param  string|null  $redirectToRoute
     * @return mixed
     */
    public function handle($request, Closure $next, $redirectToRoute = null)
    {
        if (session('isSocialLogin')) {
            // ソーシャルログインした場合、次の処理を実行する
            return $next($request);
        }

        return parent::handle($request, $next, $redirectToRoute);
    }
}

カスタムミドルウェアをKernel.phpに追加する。

app/Http/Kernel.php
    protected $routeMiddleware = [
        'auth' => \App\Http\Middleware\Authenticate::class,
        'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
        'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
        'can' => \Illuminate\Auth\Middleware\Authorize::class,
        'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
+        'password.confirm' => \App\Http\Middleware\CustomRequirePassword::class,
-        'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
        'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
        'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
        'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
    ];

画面の修正

ログイン画面にソーシャルログインリンク(ボタン風リンク)を追加します。
また、パスワード確認を回避するために、以下の画面を修正します。

  • パスワード更新画面
  • パスワード確認画面
  • 他のすべての端末からのログアウト画面
  • アカウント削除画面

ログイン画面


この記事では、Google、GitHub、GitLabを追加します。

resources/js/Pages/Auth/Login.vue
        <div>
            <a href="login/google" class="h-12 mt-3 text-white text-center inline-block w-full py-3 rounded bg-red-600 hover:bg-red-700">Google</a>
        </div>
        <div>
            <a href="login/github" class="h-12 mt-3 text-white text-center inline-block w-full py-3 rounded bg-gray-900 hover:bg-gray-700">GitHub</a>
        </div>
        <div>
            <a href="login/gitlab" class="h-12 mt-3 text-white text-center inline-block w-full py-3 rounded bg-purple-900 hover:bg-purple-700">GitLab</a>
        </div>

Inertia.js Shared data

ソーシャルログインしたことを画面(vueファイル)と共有するために、Inertia.jsのShared dataに「isSocialLogin」を追加します。

app/Http/Middleware/HandleInertiaRequests.php
    public function share(Request $request)
    {
        return array_merge(parent::share($request), [
            'isSocialLogin' => session('isSocialLogin') === true
        ]);
    }

パスワード更新画面


ソーシャルログインした場合、パスワード更新画面を非表示にします。

resources/js/Pages/Profile/Show.vue
<div v-if="$page.props.jetstream.canUpdatePassword && !$page.props.isSocialLogin">
    <update-password-form class="mt-10 sm:mt-0" />

    <jet-section-border />
</div>

パスワード確認画面


二段階認証の「有効化」ボタンを押下すると、パスワード確認画面が表示される。
ソーシャルログイン時に二段階認証の「有効化」ボタンを押下すると、パスワード確認OKを返却するよう修正する。

resources/js/Jetstream/ConfirmsPassword.vue
        methods: {
            startConfirmingPassword() {
+                if(this.$page.props.isSocialLogin) {
+                    // ソーシャルログインした場合、パスワード確認OKを返却する.
+                    this.$emit('confirmed')
+                    return
+                }

                axios.get(route('password.confirmation')).then(response => {
                    if (response.data.confirmed) {
                        this.$emit('confirmed');
                    } else {
                        this.confirmingPassword = true;

                        setTimeout(() => this.$refs.password.focus(), 250)
                    }
                })
            },

他のすべての端末からのログアウト画面


ソーシャルログインした場合、パスワード確認画面ではなく、実行確認画面をモーダルで開くよう修正します。

template

以下を追加する。

resources/js/Pages/Profile/Partials/LogoutOtherBrowserSessionsForm.vue
            <!-- Log Out Other Devices Confirmation Modal For Social -->
            <jet-dialog-modal :show="socialConfirmingLogout" @close="closeModal">
                <template #title>
                    {{ $t('Log Out Other Browser Sessions') }}
                </template>

                <template #content>
                    {{ $t('Would you like to log out of your other browser sessions across all of your devices?') }}
                </template>

                <template #footer>
                    <jet-secondary-button @click="closeModal">
                        {{ $t('Cancel') }}
                    </jet-secondary-button>

                    <jet-button class="ml-2" @click="logoutOtherBrowserSessions" :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
                        {{ $t('Log Out Other Browser Sessions') }}
                    </jet-button>
                </template>
            </jet-dialog-modal>

methods

以下のメソッドを修正する。

resources/js/Pages/Profile/Partials/LogoutOtherBrowserSessionsForm.vue
            confirmLogout() {
+                if(this.$page.props.isSocialLogin) {
+                    this.socialConfirmingLogout = true
+                } else {
+                    this.confirmingLogout = true
+                }
                

                setTimeout(() => this.$refs.password.focus(), 250)
            },
            closeModal() {
+                if(this.$page.props.isSocialLogin) {
+                    this.socialConfirmingLogout = false
+                } else {
+                    this.confirmingLogout = false
+                }

                this.form.reset()
            },
        },

アカウント削除画面


ソーシャルログインした場合、パスワード確認画面ではなく、実行確認画面をモーダルで開くよう修正します。

template

以下を追加する。

resources/js/Pages/Profile/Partials/DeleteUserForm.vue
            <!-- Social Delete Account Confirmation Modal -->
            <jet-dialog-modal :show="socialConfirmingUserDeletion" @close="closeModal">
                <template #title>
                    {{ $t('Delete Account') }}
                </template>

                <template #content>
                    {{ $t('Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted.') }}
                </template>

                <template #footer>
                    <jet-secondary-button @click="closeModal">
                        {{ $t('Cancel') }}
                    </jet-secondary-button>

                    <jet-danger-button class="ml-2" @click="deleteUser" :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
                        {{ $t('Delete Account') }}
                    </jet-danger-button>
                </template>
            </jet-dialog-modal>

methods

以下のメソッドを修正する。

resources/js/Pages/Profile/Partials/DeleteUserForm.vue
            confirmUserDeletion() {
+                if(this.$page.props.isSocialLogin) {
+                    this.socialConfirmingUserDeletion = true
+                } else {
+                    this.confirmingUserDeletion = true
+                }

                setTimeout(() => this.$refs.password.focus(), 250)
            },
            closeModal() {
+                if(this.$page.props.isSocialLogin) {
+                    this.socialConfirmingUserDeletion = false
+                } else {
+                    this.confirmingUserDeletion = false
+                }

                this.form.reset()
            },

メッセージファイル

resources/lang/ja.json
"Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted.": "本当にアカウントを削除してもよろしいでしょうか? 一度アカウントを削除するとすべてのリソースとデータは永久に削除されます。",
"Would you like to log out of your other browser sessions across all of your devices?": "すべての端末からログアウトしてよろしいですか?",
resources/lang/ja/auth.php
'oauth' => '認証に失敗しました。再度ログインをお試しください。'
resources/lang/en/auth.php
'oauth' => 'Login failed. Please try again.'
resources/lang/ja/system.php
<?php

return [
'error' => 'システムエラーが発生しました。管理者にお問い合わせください。',
];
resources/lang/en/system.php
<?php

return [
'error' => 'System error has occurred. Please contact the administrator.',
];

動作確認

ソーシャルログインした場合の動作確認

ログイン画面

GitHub認証画面

確認メール画面

確認メール

「メールアドレスを確認してください」ボタンを押下すると、ダッシュボードに遷移する。

ダッシュボード

二段階認証 有効化前

二段階認証 有効化後

パスワード確認モーダルが表示されずに有効化される。

二段階認証画面

他のすべての端末からログアウト画面

実行確認モーダルは表示されるが、パスワード入力を求められない。

アカウント削除画面

実行確認モーダルは表示されるが、パスワード入力を求められない。

通常ログイン時の動作確認

パスワード更新画面

ソーシャルログインした場合はパスワード更新画面が表示されないが、通常ユーザーは表示される。

二段階認証有効化のパスワード確認モーダル

通常ログインの場合、パスワード入力を求められる。

他のすべての端末からログアウトのパスワード確認モーダル

通常ログインの場合、パスワード入力を求められる。

他のすべての端末からログアウトのパスワード確認モーダル

通常ログインの場合、パスワード入力を求められる。

まとめ

Jetstreamは ”パスワードありき” のシステムです。
そのためSocialiteを導入した場合、影響範囲がパスワード使用箇所全般に及ぶため、改修コストは高くつきます。

次に、OAuth認証については、様々な方針が考えられます。
例えば、OAuth認証後にユーザーにパスワード登録を促す、という方法もあるかと思います。
上記の場合、シングルサイオンは失われてしまいますが、パスワード確認対応は不要となります。

また、今回はSNSユーザー情報の全てをデータベースに登録しました。
しかし、個人情報保護の観点から、保存する情報は必要最低限にしたほうがよいかと思います。

皆さんのシステム要件に合わせてJetstreamへのSolialite導入をカスタマイズしていただければと思います。

Discussion

ログインするとコメントできます