概要
今まではGoogleの認可サーバーを用いてOAuth2.0やOIDCの体験をしてきた。
Googleの認可サーバー利用と平行してMicrosoftの認可サーバーを使う方法を簡単にまとめる。
前提
修正するコードは下記の記事にて修正したから続きで修正する。Googleの認可サーバーを用いた実装が完了しているコードにMicrosoftの認可サーバーも対応させる。今回は各々のメソッドをMicrosoftにも対応させていく流れを取りハイブリットな実装になっている。本来保守性の観点から別にするべきかも知れない。
また、下記の内容が完了していることを前提に話を進める。
筆者はlaravelのローカル開発環境をsailを用いて構築している。
方法
-
Microsoftの認可サーバーはSocialiteの標準サポート外のプロバイダーなので追加パッケージをダウンロード(これをいれることで
Socialite::driver('microsoft')
が初めて動作するイメージ)./vendor/bin/sail composer require socialiteproviders/microsoft-azure
-
下記の内容を.envに記述(MICROSOFT_CLIENT_IDは「アプリケーション (クライアント) ID」、「MICROSOFT_CLIENT_SECRET」はクライアントシークレット)、「MICROSOFT_TENANT_ID」はディレクトリ (テナント) ID
.envMICROSOFT_CLIENT_ID=your_client_id MICROSOFT_CLIENT_SECRET=your_client_secret MICROSOFT_REDIRECT_URI=http://localhost/auth/microsoft/callback MICROSOFT_TENANT_ID=your_tenant_id
-
.envの内容を使用するコードを記述
config/services.php'azure' => [ 'client_id' => env('MICROSOFT_CLIENT_ID'), 'client_secret' => env('MICROSOFT_CLIENT_SECRET'), 'redirect' => env('MICROSOFT_REDIRECT_URI'), 'tenant' => env('MICROSOFT_TENANT_ID', 'common'), ],
-
下記を実行してconfigの再読み込みを実施
./vendor/bin/sail artisan config:clear
-
Microsoft側のアクセストークンの文字数が大きいのでカラムのデータ型を変更、新規カラムの追加
database/migrations/YYYY_MM_DD_XXXXXX_create_oauth_tokens_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. */ public function up(): void { Schema::create('oauth_tokens', function (Blueprint $table) { $table->id(); $table->string('google_id')->nullable()->unique(); $table->string('microsoft_id')->nullable()->unique(); $table->text('access_token'); $table->text('refresh_token')->nullable(); $table->datetime('access_token_expires_at'); $table->datetime('refresh_token_expires_at')->nullable(); $table->timestamps(); }); } /** * Reverse the migrations. */ public function down(): void { Schema::dropIfExists('oauth_tokens'); } };
-
モデルに新規追加カラムの情報を追記
app/Models/OauthToken.php<?php namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Laravel\Sanctum\HasApiTokens; class OauthToken extends Authenticatable { use HasApiTokens; /** @use HasFactory<\Database\Factories\OauthTokenFactory> */ use HasFactory; protected $fillable = [ 'google_id', 'microsoft_id', 'access_token', 'refresh_token', 'access_token_expires_at', 'refresh_token_expires_at', ]; protected function casts(): array { return [ 'access_token_expires_at' => 'datetime', 'refresh_token_expires_at' => 'datetime', ]; } }
-
フレッシュマイグレーション + seeder実行
./vendor/bin/sail artisan migrate:fresh ./vendor/bin/sail artisan db:seed
-
ミドルウェアの修正
app/Http/Middleware/SessionIntegrityCheck.php<?php namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; class SessionIntegrityCheck { public function handle(Request $request, Closure $next) { // GoogleまたはMicrosoftのいずれかのIDが必要 $hasGoogleId = session()->has('google_id'); $hasMicrosoftId = session()->has('microsoft_id'); if (!$hasGoogleId && !$hasMicrosoftId) { Auth::logout(); session()->invalidate(); session()->regenerateToken(); return redirect()->route('home'); } // 基本的なユーザー情報が必要 $requiredKeys = ['user_name', 'user_email']; foreach ($requiredKeys as $key) { if (!session()->has($key)) { Auth::logout(); session()->invalidate(); session()->regenerateToken(); // Googleユーザーの場合はGoogle認証へ、Microsoftユーザーの場合はMicrosoft認証へ if ($hasGoogleId) { return redirect()->route('google.redirect'); } elseif ($hasMicrosoftId) { return redirect()->route('microsoft.redirect'); } else { return redirect()->route('home'); } } } return $next($request); } }
-
コントローラーを下記のように修正
app/Http/Controllers/OauthController.php<?php namespace App\Http\Controllers; use App\Models\OauthToken; use App\Auth\SessionUser; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; use Laravel\Socialite\Facades\Socialite; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; class OauthController extends Controller { public function __construct(protected OauthToken $oauthToken) { } /** * Googleの認証ページへのリダイレクトを行う * * @return RedirectResponse Googleの認証ページへのリダイレクトレスポンス */ public function redirectToGoogle(): RedirectResponse { return Socialite::driver('google') ->scopes(['email', 'profile', 'https://www.googleapis.com/auth/cloud-platform.read-only', 'https://www.googleapis.com/auth/drive.readonly', 'https://www.googleapis.com/auth/userinfo.profile', ]) ->with(['access_type' => 'offline', 'prompt' => 'consent']) ->redirect(); } /** * Googleからのコールバックを受け取る * * @return RedirectResponse リダイレクトレスポンス */ public function handleGoogleCallback(): RedirectResponse { try { $googleUser = Socialite::driver('google')->user(); $this->storeUserSessionData($googleUser); $this->loginSessionUser($googleUser->getId()); $this->saveOauthToken($googleUser); } catch (\Exception $e) { Log::error('Google認証コールバック処理中にエラーが発生しました', ['exception' => $e]); return redirect(route('home'))->with('error', '認証処理中にエラーが発生しました'); } return redirect('/dashboard'); } /** * Microsoftの認証ページへのリダイレクトを行う * * @return RedirectResponse Microsoftの認証ページへのリダイレクトレスポンス */ public function redirectToMicrosoft(): RedirectResponse { return Socialite::driver('azure') ->scopes(['openid', 'profile', 'email', 'User.Read']) ->with(['prompt' => 'consent']) ->redirect(); } /** * Microsoftからのコールバックを受け取る * * @return RedirectResponse リダイレクトレスポンス */ public function handleMicrosoftCallback(): RedirectResponse { try { $microsoftUser = Socialite::driver('azure')->user(); $this->storeMicrosoftUserSessionData($microsoftUser); $this->loginSessionUser($microsoftUser->getId()); $this->saveMicrosoftOauthToken($microsoftUser); } catch (\Exception $e) { Log::error('Microsoft認証コールバック処理中にエラーが発生しました', ['exception' => $e]); return redirect(route('home'))->with('error', '認証処理中にエラーが発生しました'); } return redirect('/dashboard'); } public function googleCloudStorageInfos() { $redirect = $this->verifyOauthToken(); if ($redirect) { return $redirect; } $oauthToken = $this->oauthToken->where('google_id', Auth::user()->getAuthIdentifier())->first(); $accessToken = $oauthToken->access_token; try { $response = Http::withToken($accessToken)->get('https://www.googleapis.com/storage/v1/b', [ 'project' => 'Google CloudのプロジェクトID' // .envで持ちたい ]); if ($response->successful()) { $buckets = $response->json(); return response()->json($buckets); } else { Log::error('Google Cloud API request failed', ['response' => $response->body()]); return response()->json(['error' => 'Failed to retrieve Google Cloud resources'], 500); } } catch (\Exception $e) { Log::error('Exception occurred while fetching Google Cloud resources', ['exception' => $e]); return response()->json(['error' => 'An error occurred while fetching Google Cloud resources'], 500); } } public function googleDriveInfos() { $redirect = $this->verifyOauthToken(); if ($redirect) { return $redirect; } $oauthToken = $this->oauthToken->where('google_id', Auth::user()->getAuthIdentifier())->first(); $accessToken = $oauthToken->access_token; try { $response = Http::withToken($accessToken)->get('https://www.googleapis.com/drive/v3/files?q=\'me\' in owners&fields=files(id, name)'); if ($response->successful()) { $buckets = $response->json(); return response()->json($buckets); } else { Log::error('Google Cloud API request failed', ['response' => $response->body()]); return response()->json(['error' => 'Failed to retrieve Google Cloud resources'], 500); } } catch (\Exception $e) { Log::error('Exception occurred while fetching Google Cloud resources', ['exception' => $e]); return response()->json(['error' => 'An error occurred while fetching Google Cloud resources'], 500); } } /** * セッションにユーザー情報を保存 */ private function storeUserSessionData($googleUser): void { session([ 'google_id' => $googleUser->getId(), 'user_name' => $googleUser->getName(), 'user_email' => $googleUser->getEmail(), 'avatar' => $googleUser->getAvatar(), ]); } /** * セッションにMicrosoftユーザー情報を保存 */ private function storeMicrosoftUserSessionData($microsoftUser): void { session([ 'microsoft_id' => $microsoftUser->getId(), 'user_name' => $microsoftUser->getName(), 'user_email' => $microsoftUser->getEmail(), 'avatar' => $microsoftUser->getAvatar(), ]); } /** * SessionUserでログイン処理 */ private function loginSessionUser(string $userId): void { $sessionUser = new SessionUser($userId); Auth::login($sessionUser); } /** * OAuthトークンをデータベースに保存 */ private function saveOauthToken($googleUser): void { try { DB::beginTransaction(); // 同じgoogle_idのレコードがあったら削除 $this->oauthToken->where('google_id', $googleUser->getId())->delete(); $now = CarbonImmutable::now(); $accessTokenExpiresAt = $now->addSeconds($googleUser->expiresIn); $refreshTokenExpiresAt = null; // NOTE: Googleの認可サーバーはrefresh_tokenの有効期限を返さない // 新しいトークンを挿入 $this->oauthToken->create([ 'google_id' => $googleUser->getId(), 'access_token' => $googleUser->token, 'refresh_token' => $googleUser->refreshToken ?? null, 'access_token_expires_at' => $accessTokenExpiresAt, 'refresh_token_expires_at' => $refreshTokenExpiresAt, ]); DB::commit(); } catch (\Exception $e) { DB::rollBack(); Log::error('トークンの保存中にエラーが発生しました', ['exception' => $e]); throw $e; } } /** * Microsoft OAuthトークンをデータベースに保存 */ private function saveMicrosoftOauthToken($microsoftUser): void { try { DB::beginTransaction(); // 同じmicrosoft_idのレコードがあったら削除 $this->oauthToken->where('microsoft_id', $microsoftUser->getId())->delete(); $now = CarbonImmutable::now(); $accessTokenExpiresAt = $now->addSeconds($microsoftUser->expiresIn); $refreshTokenExpiresAt = null; // NOTE: Microsoftの認可サーバーはrefresh_tokenの有効期限を返さない // 新しいトークンを挿入 $this->oauthToken->create([ 'microsoft_id' => $microsoftUser->getId(), 'access_token' => $microsoftUser->token, 'refresh_token' => $microsoftUser->refreshToken ?? null, 'access_token_expires_at' => $accessTokenExpiresAt, 'refresh_token_expires_at' => $refreshTokenExpiresAt, ]); DB::commit(); } catch (\Exception $e) { DB::rollBack(); Log::error('Microsoftトークンの保存中にエラーが発生しました', ['exception' => $e]); throw $e; } } /** * OAuth系トークンの確認 */ private function verifyOauthToken() { $googleId = session('google_id'); $microsoftId = session('microsoft_id'); // GoogleユーザーまたはMicrosoftユーザーのトークンが存在するかチェック $hasGoogleToken = $googleId && $this->oauthToken->where('google_id', $googleId)->exists(); $hasMicrosoftToken = $microsoftId && $this->oauthToken->where('microsoft_id', $microsoftId)->exists(); if (!$hasGoogleToken && !$hasMicrosoftToken) { Auth::guard('web')->logout(); // Googleユーザーの場合はGoogle認証へ、Microsoftユーザーの場合はMicrosoft認証へ if ($googleId) { return redirect()->route('google.redirect'); } elseif ($microsoftId) { return redirect()->route('microsoft.redirect'); } else { return redirect()->route('home'); } } if ($this->isExpirationAccessToken()) { if ($googleId && $this->oauthToken->where('google_id', $googleId)->whereNotNull('refresh_token')->exists()) { $this->refreshAccessToken(); } elseif ($microsoftId && $this->oauthToken->where('microsoft_id', $microsoftId)->whereNotNull('refresh_token')->exists()) { $this->refreshMicrosoftAccessToken(); } else { Auth::guard('web')->logout(); if ($googleId) { return redirect()->route('google.redirect'); } elseif ($microsoftId) { return redirect()->route('microsoft.redirect'); } else { return redirect()->route('home'); } } } return null; } /** * アクセストークンの有効期限切れ確認 * * @return bool 有効期限切れの場合true、有効期限内の場合false */ private function isExpirationAccessToken(): bool { $googleId = session('google_id'); $microsoftId = session('microsoft_id'); $oauthToken = null; if ($googleId) { $oauthToken = $this->oauthToken->where('google_id', $googleId)->first(); } elseif ($microsoftId) { $oauthToken = $this->oauthToken->where('microsoft_id', $microsoftId)->first(); } if (!$oauthToken) { return true; // トークンが見つからない場合は期限切れとみなす } $accessTokenExpiresAt = $oauthToken->access_token_expires_at; $now = CarbonImmutable::now(); return $now->greaterThan($accessTokenExpiresAt); } /** * Googleアクセストークンの更新 */ private function refreshAccessToken() { $oauthToken = $this->oauthToken->where('google_id', session('google_id'))->first(); $refreshToken = $oauthToken->refresh_token; $response = Http::post('https://oauth2.googleapis.com/token', [ 'client_id' => config('services.google.client_id'), 'client_secret' => config('services.google.client_secret'), 'refresh_token' => $refreshToken, 'grant_type' => 'refresh_token', ]); if ($response->successful()) { $oauthToken->update([ 'access_token' => $response->json()['access_token'], 'access_token_expires_at' => CarbonImmutable::now()->addSeconds($response->json()['expires_in']), ]); } else { Log::error('Failed to refresh Google access token', ['response' => $response->body()]); return redirect()->route('google.redirect'); } } /** * Microsoftアクセストークンの更新 */ private function refreshMicrosoftAccessToken() { $oauthToken = $this->oauthToken->where('microsoft_id', session('microsoft_id'))->first(); $refreshToken = $oauthToken->refresh_token; $response = Http::post('https://login.microsoftonline.com/common/oauth2/v2.0/token', [ 'client_id' => config('services.azure.client_id'), 'client_secret' => config('services.azure.client_secret'), 'refresh_token' => $refreshToken, 'grant_type' => 'refresh_token', ]); if ($response->successful()) { $oauthToken->update([ 'access_token' => $response->json()['access_token'], 'access_token_expires_at' => CarbonImmutable::now()->addSeconds($response->json()['expires_in']), ]); } else { Log::error('Failed to refresh Microsoft access token', ['response' => $response->body()]); return redirect()->route('microsoft.redirect'); } } }
-
ルーティングに下記の内容を追記
routes/web.php// Microsoftの認証ページへのリダイレクト Route::get('/auth/microsoft/redirect', [OauthController::class, 'redirectToMicrosoft'])->name('microsoft.redirect'); // Microsoftからのコールバックを受け取る Route::get('/auth/microsoft/callback', [OauthController::class, 'handleMicrosoftCallback'])->name('microsoft.callback');
-
laravel内部の認証(セッション)プロバイダーをMicrosoftの値も考慮した形に変更
app/Auth/SessionUserProvider.php<?php namespace App\Auth; use Illuminate\Contracts\Auth\UserProvider; use Illuminate\Contracts\Auth\Authenticatable; class SessionUserProvider implements UserProvider { /** * 識別子でユーザーを取得 */ public function retrieveById($identifier) { // セッションにgoogle_idまたはmicrosoft_idが存在し、一致する場合はSessionUserを返す if ((session()->has('google_id') && session('google_id') == $identifier) || (session()->has('microsoft_id') && session('microsoft_id') == $identifier)) { return new SessionUser($identifier); } return null; } /** * トークンでユーザーを取得(remember me機能) */ public function retrieveByToken($identifier, $token) { // 今回は不要(remember me機能) return null; } /** * remember tokenを更新 */ public function updateRememberToken(Authenticatable $user, $token) { // 今回は不要(remember me機能) } /** * 認証情報でユーザーを取得 */ public function retrieveByCredentials(array $credentials) { // 今回は不要(パスワード認証) return null; } /** * ユーザーの認証情報を検証 */ public function validateCredentials(Authenticatable $user, array $credentials) { // 今回は不要(パスワード認証) return false; } /** * ユーザーのパスワードが再ハッシュ化が必要かチェック */ public function rehashPasswordIfRequired(Authenticatable $user, array $credentials, bool $force = false) { // 今回は不要(パスワード認証) } }
-
認証(セッション)プロバイダーの変更と合っていないプロパティ名などを変更
app/Auth/SessionUser.php<?php namespace App\Auth; use Illuminate\Contracts\Auth\Authenticatable; class SessionUser implements Authenticatable { protected $userId; public function __construct(string $userId) { $this->userId = $userId; } // 認証IDとして使う(Auth::id()で取得される) public function getAuthIdentifierName() { return 'user_id'; } public function getAuthIdentifier() { return $this->userId; } // Eloquentモデルとの互換性のためのメソッド public function getKey() { return $this->userId; } public function getAuthPassword() { return ''; // パスワード認証しない } public function getAuthPasswordName() { return 'password'; } public function getRememberToken() { return null; // remember me 不使用 } public function setRememberToken($value) { // remember me 不使用 } public function getRememberTokenName() { return null; } // 任意:名前などをセッションから取得(ビュー用) public function getName() { return session('user_name'); } public function getAvatar() { return session('avatar'); } // TeamとProfile用のダミーメソッド public function currentTeam() { return null; } public function allTeams() { return collect([]); } // プロパティアクセス用のマジックメソッド public function __get($name) { // NOTE: switch caseは型厳密比較ではないが、今回の場合問題にならない switch ($name) { case 'name': return $this->getName(); case 'email': return session('user_email'); case 'avatar': return $this->getAvatar(); case 'profile_photo_url': return $this->getAvatar(); case 'currentTeam': return $this->currentTeam(); case 'id': return $this->userId; default: return null; } } }
-
ここからは動作確認を実施、http://localhost/にアクセスし「Microsoft OAuth Login」をクリック
-
当該ブラウザですでにMicrosoftのアカウントにログインしている場合はアカウント選択画面になるので選択
-
権限委譲確認ウインドウが出るので「組織の代理として同意する」にチェックをいれて「承諾」をクリック ※「組織の代理として同意する」のチェックは必須ではないかも ※現在このウインドウはログイン都度表示されるように設定しており、設定はOauthController.phpの
->with(['prompt' => 'consent'])
の部分である -
ダッシュボードにリダイレクトされたら完了