AIと議論して、Slim4などに使えるOauth2認証用ライブラリを設計してもらった。ので、メモとして残しておく。
前提:
- 利用するOauth2ライブラリ:「Php League OAuth2.0 Client」
- DoctrineっぽいORMを使っていると仮定
- UserとUserConnectというエンティティと対応するレポジトリが存在する(裏にはDBテーブルがあるよ)
- コードとそれっぽい説明は、だいたいAIが書いてくれた
あくまで基本ロジックの確認用なので、セキュリティ対策やエラーハンドリングはしっかり対策してください。また動かしてもいないので、コードのエラーもある場合があります。
実装(PHPコードサンプル)
class SocialAuthService
ここが核心部分です。コントローラーから呼び出される「ロジックの塊」です。 GoogleやFacebookから取得したユーザー情報(ResourceOwner)を受け取り、「ログイン」 「連携」 「新規登録」 のいずれかを行って、最終的な User を返します。
namespace App\Service;
use League\OAuth2\Client\Provider\ResourceOwnerInterface;
use League\OAuth2\Client\Provider\GoogleUser;
use App\Repository\UserRepository;
use App\Repository\UserConnectRepository;
use App\Entity\User;
class SocialAuthService
{
public function __construct(
private UserRepository $userRepo,
private UserConnectRepository $connectRepo
) {
}
/**
* ソーシャルログインのコールバック処理を行うメインメソッド
*
* @param string $providerName 'google', 'facebook' など
* @param SocialUser $socialUser 正規化されたユーザーデータ
* @param int|null $currentUserId ログイン中の場合はID、未ログインならNULL
* @return User ログイン対象となるユーザーエンティティ
* @throws Exception メールアドレスが取得できない場合など
*/
public function handleCallback(
string $providerName,
SocialUser $socialUser,
?int $currentUserId = null
): User {
// 正規化されたDTOからデータを取得
$socialId = $socialUser->getId();
$email = $socialUser->getEmail();
$name = $socialUser->getName();
// メールアドレスがない場合は致命的なエラーとする(ビジネス要件による)
if (empty($email)) {
throw new Exception('Social provider did not return an email address.');
}
// --------------------------------------------------------
// パターン1: 既にこのソーシャルアカウントと連携済みのユーザーがいるか? (通常ログイン)
// --------------------------------------------------------
$existingLink = $this->connectRepo
->findByProvider($providerName, $socialId);
if ($existingLink) {
// 既に誰かが使っているアカウントなのに、別のユーザーが連携しようとした場合のエラーチェック
if ($currentUserId &&
$existingLink->getUserId() !== $currentUserId) {
throw new Exception('This social account is already linked to another user.');
}
// 連携済みのユーザーを返す
$user = $this->userRepo
->findById($existingLink->getUserId());
if (!$user) {
// データの不整合(連携情報はあるがユーザー本体が消えている)
throw new Exception('User not found.');
}
return $user;
}
// --------------------------------------------------------
// パターン2: ログイン中のユーザーによる「後から連携」
// --------------------------------------------------------
if ($currentUserId) {
// 現在のユーザーに紐付けを作成
$this->connectRepo
->createLink($currentUserId, $providerName, $socialId);
return $this->userRepo->findById($currentUserId);
}
// --------------------------------------------------------
// パターン3: 未連携だが、メールアドレスが一致するユーザーがいる (自動連携)
// --------------------------------------------------------
if ($socialUser->isEmailVerified()) {
$existingUser = $this->userRepo->findByEmail($email);
if ($existingUser) {
// Verifiedなので、同一人物とみなして自動リンクを作成
$this->connectRepo->createLink(
$existingUser->getId(),
$providerName,
$socialId
);
return $existingUser;
}
}
// --------------------------------------------------------
// パターン4: 完全新規登録
// (Verifiedでなくても、新規登録なら作成してしまってOKというポリシーが一般的)
// もし「Verifiedじゃないと登録させない」場合はここで例外を投げてください。
// --------------------------------------------------------
// 1. Usersテーブルに作成
$newUser = $this->userRepo->create($email, $name);
// 2. SocialLoginsテーブルに紐付け作成
$this->connectRepo->createLink(
$newUser->getId(),
$providerName,
$socialId
);
return $newUser;
}
}
class SocialUser
namespace App\Model; // Entityではなく、単なるデータ構造なのでModelやDTOに配置
namespace App\Model;
class SocialUser
{
private string $id;
private string $email;
private string $name;
private bool $isEmailVerified; // ★追加
private array $raw;
public function __construct(
string $id,
string $email,
string $name,
bool $isEmailVerified, // ★コンストラクタに追加
array $raw = []
) {
$this->id = $id;
$this->email = $email;
$this->name = $name;
$this->isEmailVerified = $isEmailVerified; // ★セット
$this->raw = $raw;
}
public function getId(): string { return $this->id; }
public function getEmail(): string { return $this->email; }
public function getName(): string { return $this->name; }
public function isEmailVerified(): bool { return $this->isEmailVerified; } // ★Getter追加
public function getRaw(): array { return $this->raw; }
}
class SocialUserNormalizer
namespace App\Service;
use League\OAuth2\Client\Provider\ResourceOwnerInterface;
use League\OAuth2\Client\Provider\GoogleUser;
use League\OAuth2\Client\Provider\FacebookUser;
use App\Model\SocialUser;
class SocialUserNormalizer
{
public function normalize(ResourceOwnerInterface $owner, string $providerName): SocialUser
{
// 1. Googleの場合
if ($owner instanceof GoogleUser) {
$data = $owner->toArray();
return new SocialUser(
$owner->getId(),
$owner->getEmail(),
$owner->getName(),
// Googleは 'email_verified' (bool) を返します
$data['email_verified'] ?? false,
$data
);
}
// 2. Facebookの場合
if ($owner instanceof FacebookUser) {
// Facebookからメールが取れている時点で、基本的には検証済みとみなしてOKですが、
// Graph APIのレスポンスや設定によっては厳密な確認が必要な場合もあります。
// ここでは「メールがあればOK」として扱いますが、要件に応じて厳格化してください。
$email = $owner->getEmail();
return new SocialUser(
$owner->getId(),
$email ?? '',
$owner->getName(),
!empty($email), // メールが空でなければTrueとする
$owner->toArray()
);
}
// 3. その他
$data = $owner->toArray();
$email = $data['email'] ?? '';
return new SocialUser(
$owner->getId(),
$email,
$data['name'] ?? 'No Name',
// 汎用: 'verified' というキーがあればそれを使う、なければ安全側に倒してFalse
$data['verified'] ?? $data['email_verified'] ?? false,
$data
);
}
}
Controller/Actionクラス
OAuth2には必ず 「行って(リクエスト)」と「帰ってくる(コールバック)」 の2つのルート(コントローラー)が必要です。
- GoogleLoginAction(行ってらっしゃい): Googleの認証画面へユーザーを送り出す
- GoogleCallbackAction(おかえりなさい): 帰ってきたユーザーを処理する(先ほど議論したもの)
class GoogleLoginAction
「GoogleのURLを作って、セキュリティ用のStateをセッションに保存して、リダイレクトする」だけです。
use League\OAuth2\Client\Provider\Google;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
class GoogleLoginAction
{
private Google $googleProvider;
// DIコンテナからGoogleプロバイダーを受け取る
public function __construct(Google $googleProvider)
{
$this->googleProvider = $googleProvider;
}
public function __invoke(Request $request, Response $response): Response
{
// 1. Googleへの認証用URLを生成する
// (デフォルトで email, profile 等のscopeが含まれます)
$authUrl = $this->googleProvider->getAuthorizationUrl();
// 2. CSRF対策:Stateをセッションに保存する (★これ重要)
// コールバック側で「このState、君が送ったやつ?」と確認するためです
$_SESSION['oauth2state'] = $this->googleProvider->getState();
// 3. Googleへリダイレクトさせる
return $response
->withHeader('Location', $authUrl)
->withStatus(302);
}
}
class GoogleCallbackAction
これで、Slimのコントローラー(Action)は非常にスッキリします。 knpuniversity/oauth2-client-bundle の onAuthenticationSuccess でやっていたことが、明示的に書かれています。
class GoogleCallbackAction
{
public function __construct(
private League\OAuth2\Client\Provider\Google $provider,
private SocialAuthService $service
) {
}
public function __invoke(Request $request, Response $response): Response
{
// ... (CSRFチェックやコード取得の処理は省略) ...
// 1. トークン取得
$token = $this->provider->getAccessToken('authorization_code', [
'code' => $_GET['code']
]);
// 2. Googleからユーザー情報取得
$resourceOwner = $this->provider->getResourceOwner($token);
$normalizer = new SocialUserNormalizer();
$socialUser = $normalizer->normalize($resourceOwner, 'google');
// 3. サービスを呼ぶ (ログイン中ならセッションからIDを渡す)
$currentUserId = $_SESSION['user_id'] ?? null;
try {
// ★ ここで全てのロジックを実行 ★
$user = $this->socialAuthService
->handleCallback('google', $socialUser, $currentUserId);
// 4. セッションに保存してログイン完了
$_SESSION['user_id'] = $user->getId();
return $response
->withHeader('Location', '/dashboard')
->withStatus(302);
} catch (\Exception $e) {
// メールアドレスが取得できない、DBエラーなど
// エラーログを出してログイン画面へ
return $response
->withHeader('Location', '/login?error=auth_failed')
->withStatus(302);
}
}
}
Interfaces
interface UserRepository
ユーザーの検索と新規作成を担当します。
interface UserRepository
{
// メールアドレスでユーザーを探す(自動連携チェック用)
public function findByEmail(string $email): ?User;
// 新規ユーザーを作成して保存し、そのEntity(またはID)を返す
public function create(string $email, string $name): User;
// IDでユーザーを取得(ログイン後のセッション復元などで使用)
public function findById(int $id): ?User;
}
interface UserConnectRepository
(UserSocial や SocialLogin と呼ぶことも多いです) ソーシャル連携情報の検索と保存を担当します。
interface UserConnectRepository
{
// プロバイダー名とID(sub)で連携情報を探す(ログイン判定用)
// 戻り値は UserConnect エンティティ、または連携先の user_id を含む配列など
public function findByProvider(
string $providerName,
string $providerUserId
): ?UserConnect;
// 既存ユーザーとプロバイダーの紐付けを作成保存する
public function createLink(
int $userId,
string $providerName,
string $providerUserId
): void;
}
interface User
ユーザーを一意に識別し、セッション管理やメール連携チェックを行うためのメソッド群です。
interface User
{
/**
* ユーザーの一意なID。
* ログイン成功時に $_SESSION['user_id'] に保存するために必須です。
*/
public function getId(): int;
/**
* メールアドレス。
* Googleログイン時、既存アカウントとの自動連携チェック(findByEmail)で使用します。
*/
public function getEmail(): string;
/**
* 表示名。
* 画面上の「ようこそ、〇〇さん」の表示などに使用します。
*/
public function getName(): ?string;
/**
* パスワードハッシュ。
* ソーシャルログインのみのユーザーは NULL の可能性があるため、nullableにします。
* ※今回は使いませんが、メール/パスワード認証の実装時に必要になります。
*/
public function getPassword(): ?string;
}
interface UserConnect
「どのプロバイダー」の「どのアカウント」が「どのユーザー」に紐付いているかを管理するためのメソッド群です。
interface UserConnect
{
/**
* この連携情報が紐付いているユーザーのID。
* UserConnectが見つかった後、「誰としてログインさせるか」を特定するために必須です。
*/
public function getUserId(): int;
/**
* プロバイダー名 ('google', 'facebook' 等)。
* 複数のソーシャルログインを持つユーザーの識別や管理画面での表示に使います。
*/
public function getProvider(): string;
/**
* プロバイダー側でのユーザーID (sub)。
* Google等が返すユニークIDです。認証時の検索キーになります。
*/
public function getProviderId(): string;
/**
* (任意) アクセストークン。
* ログイン後にGoogle Calendar APIなどを叩く予定があるなら必要です。
* 単なるログイン用途だけなら必須ではありません。
*/
public function getAccessToken(): ?string;
}
DB定義
下記のSQLには、いくつかの重要な設計意図が含まれています。
- パスワードのNULL許可 (password VARCHAR NULL)
これが「メールログイン」と「ソーシャルログイン」を共存させるための最大のポイントです。
メール登録ユーザー: password にハッシュ値が入る。
Google登録ユーザー: password は NULL。
ログイン処理: パスワード認証時は WHERE email = ? で取得後、password が NULL なら「パスワード未設定です(ソーシャルでログインしてください)」とエラーを出せます。
-
プロバイダーIDの型 (provider_id VARCHAR)
初心者がハマりやすいポイントです。GoogleのIDは数字の羅列に見えますが、桁数が非常に多いため INT に入り切らないことがあります(オーバーフロー)。また、将来Appleログインなどを入れた場合、英数字混じりの文字列になります。必ず VARCHAR で定義してください。 -
複合ユニーク制約 (UNIQUE KEY uk_social_provider_id)
provider と provider_id の組み合わせを一意にします。 これにより、「GoogleのID: 12345」というレコードがテーブル全体で1つしか存在できないことをDBレベルで保証します。アプリケーションのバグで二重登録されるのを防ぐ最後の砦です。 -
文字コード (utf8mb4)
最近のSNSユーザー名は絵文字(例: 「Taro 🍺」)が含まれることが当たり前です。 古い utf8 (MySQLでは3バイトまで) だと、絵文字が入った瞬間に保存エラーになったり文字化けしたりします。必ず utf8mb4 を指定してください。
User Table
ユーザーの基本情報を管理します。
CREATE TABLE users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
-- メールアドレスはログインの要。
-- OAuth経由でもメールは取得して保存するため、NOT NULLかつUNIQUEにします。
email VARCHAR(255) NOT NULL,
-- 表示名。絵文字(🍣)対応のため utf8mb4 必須。
name VARCHAR(255) NOT NULL DEFAULT '',
-- ソーシャルログインのみのユーザーはパスワードを持たないため、NULL許可にします。
-- 将来「パスワード設定」をした場合にハッシュ値を入れます。
password VARCHAR(255) NULL DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- 【重要】同じメールアドレスでの多重登録を防ぐ
UNIQUE KEY uk_users_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
social_logins Table
1人のユーザーが複数のプロバイダー(GoogleとFacebookなど)を持てるようにする中間テーブルです。
CREATE TABLE social_logins (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
-- 外部キー。usersテーブルのidと紐付きます。
user_id INT UNSIGNED NOT NULL,
-- 'google', 'facebook', 'twitter' などが入ります。
provider VARCHAR(50) NOT NULL,
-- プロバイダー側のユーザーID(sub)。
-- Googleは数値ですが、Facebookや他サービスは文字列で来ることもあるため、
-- 十分な長さのVARCHARにします。
provider_id VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- 外部キー制約。
-- 本体(users)が削除されたら、連携情報も道連れで削除(CASCADE)します。
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
-- 【重要1】「同じGoogleアカウント」が「複数のユーザー」に紐付くのを防ぐ。
-- これがないと、1つのGoogleアカウントでAさんとBさん両方にログインできてしまうバグの温床になります。
-- また、ログイン時の検索(WHERE provider = ? AND provider_id = ?)も高速化されます。
UNIQUE KEY uk_social_provider_id (provider, provider_id),
-- 【重要2】「同じユーザー」が「同じプロバイダー」を複数登録するのを防ぐ(任意)。
-- 例: 1人のユーザーがGoogleアカウントを2つ紐付けるのを禁止する場合に設定。
-- 一般的なWebサービスでは設定することが多いです。
UNIQUE KEY uk_social_user_provider (user_id, provider)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;