既存の会員サイトに「Googleでログイン」を追加した。ついでに、まだ会員登録されていないGoogleアカウントなら、そのまま新規会員登録までできるようにしたのでメモ。

既存のメールアドレス+パスワードでのログインは残したまま、Googleログインを追加する形。

1、Google Identity Servicesを使う

Google側はGoogle Identity Servicesを使う。Google CloudでWebアプリケーション用のクライアントIDを作成し、実際にログイン画面を置くドメインを「承認済みのJavaScript生成元」に登録しておく。

ログイン画面にはGoogleのJavaScriptを読み込み、Client IDとログイン後のPOST先を指定する。

フルコードは以下。

<!-- Google Identity Services -->
<script src="https://accounts.google.com/gsi/client" async defer></script>

<p>Googleアカウントでログイン・新規会員登録</p>

<div id="g_id_onload"
     data-client_id="YOUR_WEB_CLIENT_ID.apps.googleusercontent.com"
     data-login_uri="https://www.example.com/google_login.php"
     data-auto_prompt="false">
</div>

<div class="g_id_signin"
     data-type="standard"
     data-shape="rectangular"
     data-theme="outline"
     data-text="continue_with"
     data-size="large"
     data-logo_alignment="left"
     data-width="360">
</div>

Client IDやURLはもちろん自分の環境のものに置き換える。

2、PHP側でID tokenを検証する

Googleログインに成功すると、指定したPHPへcredentialという名前でID tokenがPOSTされる。

ここで重要なのは、ブラウザから渡ってきたメールアドレスなどをそのまま信用しないこと。PHP側でGoogleのライブラリを使ってID tokenを検証してから、その中の情報を使う。

composer require google/apiclient

Googleアカウントを識別するキーにはメールアドレスではなくsubを使う。メールアドレスは変更される可能性があるが、subはGoogleアカウント固有の識別子として使える。

またGoogleからPOSTされるg_csrf_tokenもCookie側とPOST側で一致することを確認する。

3、Googleアカウントと既存会員を紐付ける

自分の場合、既存の会員テーブルはそのまま残し、Googleなど外部認証との対応だけ別テーブルにした。

考え方は単純で、

Googleの sub
        ↓
user_identity
        ↓
既存の会員ID

という対応を保存する。

追加テーブルのサンプルは以下。

-- Existing users table is assumed to be:
--   users(id BIGINT PRIMARY KEY, email TEXT, display_name TEXT, ...)
-- Adapt the foreign key/table names to your application.

CREATE TABLE IF NOT EXISTS user_identity (
    provider      VARCHAR(32) NOT NULL,
    subject       VARCHAR(255) NOT NULL,
    user_id       BIGINT NOT NULL,
    email         VARCHAR(512),
    created_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (provider, subject),
    UNIQUE (provider, user_id)
);

CREATE TABLE IF NOT EXISTS external_login_ticket (
    ticket_hash   CHAR(64) PRIMARY KEY,
    provider      VARCHAR(32) NOT NULL,
    subject       VARCHAR(255) NOT NULL,
    user_id       BIGINT NOT NULL,
    expires_at    TIMESTAMP NOT NULL,
    created_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX external_login_ticket_expires_i01
    ON external_login_ticket(expires_at);

既存会員テーブルそのものへGoogle専用カラムを何個も追加するより、後からLINEなど別の認証方法を足す場合にも扱いやすい。

4、既存会員ならログイン、新規ならその場で会員登録

処理の流れはこんな感じ。

Googleで続行
    ↓
ID tokenをPHPで検証
    ↓
Google subが登録済み?
    ├─ YES → 対応する既存会員でログイン
    └─ NO
         ↓
       同じメールアドレスの既存会員がいる?
         ├─ YES → 安全に確認できる場合だけ紐付け
         └─ NO  → 新規会員を作成して紐付け

ここで少し注意が必要だった。

GmailやGoogle WorkspaceのメールアドレスはGoogleがそのメールアドレスについて権威を持つケースなので、既存会員との自動紐付けに使いやすい。一方、Googleアカウントに他社のメールアドレスを登録している場合は、email_verified=trueだけを理由に既存会員へ無条件で紐付けないほうが安全。

その場合は従来のパスワードやOTPなど、元からある認証方法でもう一度本人確認してから紐付けるようにする。

5、既存のログイン処理へ短命チケットで戻す

今回一番悩んだのがここ。

Google認証部分だけPHPで作ったが、元のサイトには既にログイン後のセッションを作る処理がある。それを全部PHP用に作り直す必要はない。

そこでGoogle認証が終わったら、ランダムな短命チケットを1個発行し、そのチケットだけを既存のログイン処理へ渡すようにした。

Google認証成功
    ↓
256bitのランダムチケットを発行
    ↓
DBにはSHA-256ハッシュだけ保存
    ↓
既存ログイン処理へリダイレクト
    ↓
チケットを一度だけ消費
    ↓
いつものセッションを発行

有効期限も数分ではなく短めにしておけばよい。サンプルでは120秒にしている。

生のチケットをDBへ保存せず、ハッシュだけ保存するのもポイント。

6、PHPのフルコード

実環境のDB名、テーブル名、サーバーパス、Client IDなどを外して一般化したものが以下。

<?php
declare(strict_types=1);

// Public sample for Google Identity Services.
// Environment variables used here:
//   GOOGLE_CLIENT_ID
//   DB_DSN       e.g. pgsql:host=localhost;dbname=example
//   DB_USER
//   DB_PASSWORD
//
// Existing application table assumed by this sample:
//   users(id, email, display_name)
//
// Adjust table/column names and the final redirect to your application.

require_once __DIR__ . '/vendor/autoload.php';

const TICKET_TTL_SECONDS = 120;

function failLogin(string $message): never
{
    http_response_code(400);
    header('Content-Type: text/plain; charset=UTF-8');
    echo $message;
    exit;
}

function googleIsAuthoritativeForEmail(array $payload): bool
{
    $email = strtolower((string)($payload['email'] ?? ''));

    if ($email !== '' && str_ends_with($email, '@gmail.com')) {
        return true;
    }

    return !empty($payload['email_verified']) && !empty($payload['hd']);
}

header('Cache-Control: no-store');
header('Pragma: no-cache');

if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
    failLogin('POST only');
}

// Google Identity Services sends the same CSRF token in a cookie and POST body.
$csrfCookie = $_COOKIE['g_csrf_token'] ?? '';
$csrfPost = $_POST['g_csrf_token'] ?? '';

if (!is_string($csrfCookie) || !is_string($csrfPost) ||
    $csrfCookie === '' || $csrfPost === '' ||
    !hash_equals($csrfCookie, $csrfPost)) {
    failLogin('CSRF check failed');
}

$credential = $_POST['credential'] ?? '';
if (!is_string($credential) || $credential === '' || strlen($credential) > 16384) {
    failLogin('credential is missing');
}

$clientId = getenv('GOOGLE_CLIENT_ID') ?: '';
$dsn = getenv('DB_DSN') ?: '';
$dbUser = getenv('DB_USER') ?: '';
$dbPassword = getenv('DB_PASSWORD') ?: '';

if ($clientId === '' || $dsn === '' || $dbUser === '') {
    throw new RuntimeException('Server configuration is incomplete');
}

// google/apiclient verifies the ID token signature and audience.
$client = new Google\Client(['client_id' => $clientId]);
$payload = $client->verifyIdToken($credential);

if (!is_array($payload)) {
    failLogin('Invalid Google ID token');
}

// Use sub as the permanent Google account identifier. Do not use email as ID.
$subject = (string)($payload['sub'] ?? '');
$email = strtolower((string)($payload['email'] ?? ''));
$emailVerified = !empty($payload['email_verified']);

if ($subject === '' || $email === '' || !$emailVerified) {
    failLogin('Required claims are missing');
}

$pdo = new PDO($dsn, $dbUser, $dbPassword, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES => false,
]);

$pdo->beginTransaction();

try {
    // 1. Already linked? Login immediately.
    $st = $pdo->prepare(
        "SELECT user_id FROM user_identity " .
        "WHERE provider='google' AND subject=:subject"
    );
    $st->execute([':subject' => $subject]);
    $identity = $st->fetch();

    $userId = $identity ? (int)$identity['user_id'] : 0;

    if ($userId <= 0) {
        // 2. Look for an existing local account with the same email.
        $st = $pdo->prepare(
            'SELECT id FROM users WHERE lower(email)=lower(:email) ORDER BY id LIMIT 2'
        );
        $st->execute([':email' => $email]);
        $users = $st->fetchAll();

        if (count($users) > 1) {
            throw new RuntimeException('Multiple local accounts use the same email');
        }

        if (count($users) === 1) {
            // For Gmail / Google Workspace, Google can be authoritative for the
            // email address. For other email providers, do not silently link an
            // existing account: ask the user to prove ownership using your
            // existing password/OTP flow instead.
            if (!googleIsAuthoritativeForEmail($payload)) {
                throw new RuntimeException('Existing account requires additional verification');
            }

            $userId = (int)$users[0]['id'];
        } else {
            // 3. No local account: create one.
            // PostgreSQL example. For MySQL, replace RETURNING with lastInsertId().
            $displayName = trim((string)($payload['name'] ?? ''));
            if ($displayName === '') {
                $displayName = strstr($email, '@', true) ?: 'member';
            }

            $st = $pdo->prepare(
                'INSERT INTO users(email, display_name) ' .
                'VALUES(:email, :display_name) RETURNING id'
            );
            $st->execute([
                ':email' => $email,
                ':display_name' => $displayName,
            ]);
            $userId = (int)$st->fetchColumn();
        }

        // 4. Save Google sub <-> local user relation.
        $st = $pdo->prepare(
            "INSERT INTO user_identity(provider, subject, user_id, email) " .
            "VALUES('google', :subject, :user_id, :email)"
        );
        $st->execute([
            ':subject' => $subject,
            ':user_id' => $userId,
            ':email' => $email,
        ]);
    } else {
        $st = $pdo->prepare(
            "UPDATE user_identity SET email=:email, updated_at=CURRENT_TIMESTAMP " .
            "WHERE provider='google' AND subject=:subject"
        );
        $st->execute([':email' => $email, ':subject' => $subject]);
    }

    // 5. Bridge to the application's existing session/login code with a
    // short-lived, one-time ticket. Store only its SHA-256 hash in the DB.
    $ticket = bin2hex(random_bytes(32));
    $ticketHash = hash('sha256', $ticket);

    $st = $pdo->prepare(
        "INSERT INTO external_login_ticket" .
        "(ticket_hash, provider, subject, user_id, expires_at) " .
        "VALUES(:hash, 'google', :subject, :user_id, " .
        "CURRENT_TIMESTAMP + INTERVAL '120 seconds')"
    );
    $st->execute([
        ':hash' => $ticketHash,
        ':subject' => $subject,
        ':user_id' => $userId,
    ]);

    $pdo->commit();

    // This endpoint should atomically consume the ticket, create the normal
    // application session, then delete/invalidate the ticket.
    header('Location: /finish_external_login.php?ticket=' . rawurlencode($ticket), true, 303);
    exit;
} catch (Throwable $e) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }
    error_log('google_login.php: ' . $e->getMessage());
    failLogin('Google login failed');
}

このまま完全にコピペで動くというより、usersテーブルの部分と最後のセッション発行処理を自分の会員システムへ合わせるためのサンプル。

7、やってみると意外と既存システムを壊さず追加できる

最初はGoogleログインを追加すると既存のログイン処理をかなり作り直すことになるかと思ったが、実際には外部IDとの対応表と短命チケットを間に挟むことで、従来のログイン処理をほぼそのまま残せた。

既存のメール・パスワードログインも残るので、Googleログインで何か問題があっても従来の方法でログインできる。

新規登録についても、Google側で取得できるメールアドレスを使えば入力項目をかなり減らせる。会員登録の入口としてはかなり使いやすくなった。