在传统 PHP 应用中为 Firestore 构建安全的自定义令牌服务并集成 Webpack 前端


我们面临一个典型的存量系统维护场景:一个稳定运行多年的PHP单体应用,承载着核心业务逻辑,其用户认证体系是基于传统的 $_SESSION。现在,产品需求要求引入一个高度动态、具备实时协作能力的模块,例如一个内部运营的实时监控仪表盘或一个简单的在线聊天支持功能。Firestore的实时数据库能力、按用量付费的模式以及强大的客户端SDK,使其成为这个新模块的理想技术选型。

问题随之而来:如何在一个基于PHP会话认证的旧体系中,安全、无缝地让前端应用获得访问Firestore的权限?

最直接也是最错误的做法,就是将Firestore的SDK配置,甚至服务账号凭证,暴露给前端。这无异于将数据库的钥匙直接交到用户手中,是绝对不可接受的安全漏洞。正确的路径是利用Firebase提供的自定义认证系统。其核心思想是:在后端一个受信任的环境中,根据当前已登录的用户身份,生成一个短期的、代表该用户身份的自定义令牌(Custom Token),然后将这个令牌安全地传递给前端。前端再使用这个令牌去向Firebase认证,从而获得操作Firestore的相应权限。

在这个架构中,我们原有的PHP应用,就扮演了这个“受信任的环境”和“令牌签发中心”的角色。本文将完整复盘这个集成方案的构建过程,从PHP后端令牌服务的实现,到Webpack前端工程的配置与集成,最终打通整条认证与数据链路。

技术痛点与架构确立

我们的核心挑战是身份的桥接。用户已经通过用户名密码登录了PHP应用,服务器通过session_start()确认了其合法身份。而Firestore在一个完全独立的生态中,它需要知道当前操作者是谁,并依据其安全规则(Firestore Security Rules)判断操作是否合法。

我们的架构设计必须满足以下几个原则:

  1. 信任单点: 唯一信任源是PHP后端的会话系统。只有通过PHP认证的用户,才有资格获取Firestore的访问令牌。
  2. 密钥隔离: 用于签发令牌的Firebase服务账号私钥,绝不能离开服务器环境。
  3. 权限最小化: 前端通过令牌获得的权限,应由Firebase安全规则严格控制。令牌本身可以携带一些自定义的声明(claims),用于在安全规则中做更精细的判断。
  4. 无缝体验: 用户在浏览器中无需二次登录,整个令牌获取和认证过程应对用户透明。

基于此,我们的数据流图如下:

sequenceDiagram
    participant User as 用户浏览器
    participant PHP as PHP后端 (Monolith)
    participant Firebase as Firebase认证服务
    participant Firestore as Firestore数据库

    User->>+PHP: 1. 登录 (用户名/密码)
    PHP->>PHP: 2. 验证凭证, 创建Session
    PHP-->>-User: 3. 登录成功, 返回会话Cookie

    Note over User, PHP: 用户已登录PHP系统

    User->>+PHP: 4. 请求需要Firestore的页面
    PHP-->>-User: 5. 返回HTML及Webpack打包的JS

    User->>User: 6. JS应用启动
    User->>+PHP: 7. (AJAX) 请求Firestore自定义令牌
    PHP->>PHP: 8. 验证Session, 确认用户身份
    PHP->>PHP: 9. 使用服务账号私钥为该用户生成JWT令牌
    PHP-->>-User: 10. 返回生成的自定义令牌

    User->>+Firebase: 11. 使用自定义令牌登录 (signInWithCustomToken)
    Firebase->>Firebase: 12. 验证令牌签名与声明
    Firebase-->>-User: 13. 认证成功, 返回Firebase身份Token

    User->>+Firestore: 14. 携带Firebase身份Token读/写数据
    Firestore->>Firestore: 15. 根据安全规则校验用户权限
    Firestore-->>-User: 16. 返回/写入数据 (实时更新)

这个流程清晰地将认证责任划分开,PHP负责主认证并发放“门票”(自定义令牌),Firebase负责验证“门票”并授予实际的数据库访问权限。

PHP后端:构建安全的令牌签发服务

首先,我们需要在PHP环境中安全地生成JWT格式的自定义令牌。

1. 依赖与配置

我们需要 firebase/php-jwt 库来处理JWT的生成。通过Composer安装:

composer require firebase/php-jwt

接下来,是从Firebase控制台获取服务账号(Service Account)的JSON密钥文件。进入你的Firebase项目 -> 项目设置 -> 服务账号,然后生成一个新的私钥。你会下载一个JSON文件。

一个常见的错误是:将这个JSON文件直接放在Web服务器的公开目录下。这是极其危险的。这个文件必须存放在Web根目录之外,确保无法通过HTTP直接访问。例如,如果你的网站根目录是 /var/www/html,你可以将密钥文件放在 /var/www/secure/firebase-service-account.json

2. 令牌服务实现

我们来封装一个 FirestoreTokenService 类,专门负责令牌的生成逻辑。这有利于代码的复用和维护。

<?php

// File: src/Service/FirestoreTokenService.php

// 确保在项目入口处加载了Composer的autoloader
// require_once __DIR__ . '/../../vendor/autoload.php';

use Firebase\JWT\JWT;
use Exception;

class FirestoreTokenService
{
    /**
     * @var string Path to the Firebase service account JSON file.
     *             MUST be stored outside the web root.
     */
    private string $serviceAccountPath;

    /**
     * @var array Decoded service account credentials.
     */
    private array $serviceAccount;

    /**
     * @param string $serviceAccountPath
     * @throws Exception
     */
    public function __construct(string $serviceAccountPath)
    {
        if (!file_exists($serviceAccountPath) || !is_readable($serviceAccountPath)) {
            // 在真实项目中,这里应该记录详细的错误日志,而不是直接抛出通用异常
            error_log("Service account file not found or not readable at: " . $serviceAccountPath);
            throw new Exception("Service account configuration error.");
        }
        $this->serviceAccountPath = $serviceAccountPath;
        $this->serviceAccount = json_decode(file_get_contents($this->serviceAccountPath), true);

        if (json_last_error() !== JSON_ERROR_NONE) {
            error_log("Failed to parse service account JSON from: " . $serviceAccountPath);
            throw new Exception("Service account JSON parsing error.");
        }
    }

    /**
     * Generates a custom Firebase authentication token for the given user ID.
     *
     * @param string $userId The unique identifier for the user in your system.
     * @param array $claims Additional claims to include in the token payload.
     * @param int $expirationInSeconds Token expiration time in seconds from now.
     * @return string The generated custom token.
     * @throws Exception
     */
    public function createCustomToken(string $userId, array $claims = [], int $expirationInSeconds = 3600): string
    {
        if (empty($userId)) {
            throw new InvalidArgumentException("User ID cannot be empty.");
        }

        $privateKey = $this->serviceAccount['private_key'];
        $clientEmail = $this->serviceAccount['client_email'];

        if (empty($privateKey) || empty($clientEmail)) {
            error_log("Missing 'private_key' or 'client_email' in service account file.");
            throw new Exception("Invalid service account credentials.");
        }

        $now = time();
        $expires = $now + $expirationInSeconds;

        $payload = [
            'iss' => $clientEmail,
            'sub' => $clientEmail,
            'aud' => 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit',
            'iat' => $now,
            'exp' => $expires,
            'uid' => $userId, // This is the crucial part that identifies the user
            'claims' => $claims // Optional additional data for security rules
        ];
        
        // 使用 HS256 算法进行签名
        try {
            return JWT::encode($payload, $privateKey, 'RS256');
        } catch (Exception $e) {
            error_log("JWT encoding failed: " . $e->getMessage());
            // 避免向客户端暴露底层错误细节
            throw new Exception("Failed to generate authentication token.");
        }
    }
}

这个类的设计考虑了几个生产环境中的要点:

  • 构造函数健壮性:检查了密钥文件是否存在、是否可读、JSON格式是否正确。在生产环境中,这些检查点应该配合日志系统,方便快速定位配置问题。
  • 参数校验createCustomToken 方法校验了 $userId 不为空,这是Firebase的要求。
  • 清晰的Payload:JWT的payload结构是Firebase认证服务规定的,iss, sub, aud, iat, exp 都是标准字段。最核心的是 uid,它将Firebase的用户与我们PHP系统中的用户关联起来。
  • 错误处理:对JWT编码过程中的异常进行了捕获,并记录日志,同时对客户端返回一个通用的错误信息,避免泄露内部实现细节。

3. 创建API端点

接下来,创建一个PHP脚本作为API端点,它将受我们现有的会札系统保护。

<?php

// File: api/get_firestore_token.php

// 启动会话,这是保护此端点的关键
session_start();

// 引入依赖和服务类
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../src/Service/FirestoreTokenService.php';

header('Content-Type: application/json');
header('X-Content-Type-Options: nosniff');

// 1. 认证检查:确保用户已登录
if (!isset($_SESSION['user_id'])) {
    http_response_code(401); // Unauthorized
    echo json_encode(['error' => 'Authentication required.']);
    exit;
}

// 2. 准备用户数据
// 在真实项目中,user_id可能是一个更复杂的对象或UUID
$userId = (string) $_SESSION['user_id'];
$userRole = $_SESSION['user_role'] ?? 'guest'; // 假设session中有用户角色信息

// 3. 实例化并调用服务
try {
    // 密钥路径是绝对路径,且在webroot之外
    $serviceAccountPath = '/var/www/secure/firebase-service-account.json';
    $tokenService = new FirestoreTokenService($serviceAccountPath);

    // 我们可以将用户的角色等信息作为附加声明(claims)放入令牌
    // 这样可以在Firestore安全规则中使用
    $claims = ['role' => $userRole, 'premium_user' => true];
    
    // 生成令牌,有效期设置为1小时
    $customToken = $tokenService->createCustomToken($userId, $claims, 3600);

    http_response_code(200);
    echo json_encode(['token' => $customToken]);

} catch (Exception $e) {
    // 捕获服务中可能抛出的所有异常
    // 在生产环境中,应该有一个统一的异常处理器来记录日志
    error_log('Firestore Token Generation Error: ' . $e->getMessage());
    http_response_code(500); // Internal Server Error
    echo json_encode(['error' => 'Failed to generate token due to a server error.']);
}

这个API端点的逻辑很直接:

  1. 会话保护:脚本开头立即调用 session_start() 并检查 $_SESSION['user_id'] 是否存在。这是最关键的一步,确保了只有已登录的合法用户才能请求令牌。
  2. 传递声明:我们从会话中提取了用户的角色信息,并将其作为 claims 传递。这极大地增强了Firestore安全规则的灵活性。例如,我们可以编写规则:“只有 roleadmin 的用户才能写入 config 集合”。
  3. 统一出口:所有令牌生成逻辑都委托给了 FirestoreTokenService,端点本身只负责认证和HTTP响应。

Webpack前端:配置与集成

现在轮到前端部分。我们将使用Webpack来打包我们的JavaScript应用,该应用负责从PHP后端获取令牌,并使用它来初始化Firestore连接。

1. 项目结构与依赖

一个典型的前端项目结构可能如下:

/frontend
|-- /src
|   |-- index.js        # 应用主入口
|   |-- firebase.js     # Firebase初始化模块
|   |-- ui.js           # UI操作模块
|-- package.json
|-- webpack.config.js

安装必要的依赖:

npm install --save firebase
npm install --save-dev webpack webpack-cli webpack-dev-server html-webpack-plugin

2. Webpack配置

一个生产可用的 webpack.config.js 需要处理环境变量。Firebase客户端SDK需要一些项目配置信息(apiKey, projectId等),这些信息是公开的,可以安全地放在前端。但我们不应该硬编码它们。使用Webpack的 DefinePlugin 是一个好方法。

// File: frontend/webpack.config.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');

// 在真实项目中,这些配置应该来自 .env 文件或CI/CD环境变量
// 以避免将它们提交到版本控制中
const FIREBASE_CONFIG = {
    apiKey: "AIzaSy...",
    authDomain: "your-project-id.firebaseapp.com",
    projectId: "your-project-id",
    storageBucket: "your-project-id.appspot.com",
    messagingSenderId: "...",
    appId: "1:...",
};

module.exports = {
    mode: 'production', // or 'development'
    entry: './src/index.js',
    output: {
        filename: 'bundle.[contenthash].js',
        path: path.resolve(__dirname, '../public/dist'), // 输出到PHP应用可以访问的目录
        clean: true,
    },
    module: {
        rules: [
            // Add babel-loader here if you use modern JS syntax
        ],
    },
    plugins: [
        new HtmlWebpackPlugin({
            title: 'Real-time Dashboard',
            template: './src/index.html', // 如果需要一个HTML模板
        }),
        // 这是将配置注入代码的关键
        new webpack.DefinePlugin({
            'process.env.FIREBASE_CONFIG': JSON.stringify(FIREBASE_CONFIG),
        }),
    ],
    optimization: {
        splitChunks: {
            chunks: 'all',
        },
    },
};

这里的关键是 webpack.DefinePlugin。它会在编译时创建一个全局常量 process.env.FIREBASE_CONFIG,其值为我们提供的JSON字符串。这样,在我们的JS代码中就可以安全地访问这些配置。输出路径 ../public/dist 假设PHP应用的Web根目录是 public,这样打包后的文件就能被PHP页面引用。

3. 前端代码实现

现在,我们来编写前端的核心逻辑。

首先是Firebase初始化模块,它负责封装与Firebase的交互。

// File: frontend/src/firebase.js

import { initializeApp } from "firebase/app";
import { getAuth, signInWithCustomToken, onAuthStateChanged } from "firebase/auth";
import { getFirestore } from "firebase/firestore";

// 从Webpack DefinePlugin注入的全局变量中获取配置
const firebaseConfig = process.env.FIREBASE_CONFIG;

// 初始化 Firebase
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
const db = getFirestore(app);

/**
 * Fetches a custom token from our PHP backend.
 * @returns {Promise<string>} The custom token.
 */
async function fetchCustomToken() {
    try {
        // 这个API端点受PHP会话保护
        const response = await fetch('/api/get_firestore_token.php');
        
        if (!response.ok) {
            if (response.status === 401) {
                throw new Error('User not authenticated with PHP backend.');
            }
            throw new Error(`Server error: ${response.statusText}`);
        }
        
        const data = await response.json();
        if (!data.token) {
            throw new Error('Token not found in server response.');
        }
        
        return data.token;
    } catch (error) {
        console.error("Failed to fetch custom token:", error);
        throw error;
    }
}

/**
 * Authenticates with Firebase using a custom token from our backend.
 * This function should be called once when the application loads.
 */
async function authenticateWithBackend() {
    try {
        const customToken = await fetchCustomToken();
        await signInWithCustomToken(auth, customToken);
        console.log("Successfully signed in with custom token.");
    } catch (error) {
        console.error("Firebase custom authentication failed:", error);
        // 在这里可以处理UI,比如显示一个错误信息,提示用户刷新或重新登录
    }
}

export { auth, db, authenticateWithBackend, onAuthStateChanged };

主入口文件 index.js 将协调这一切。

// File: frontend/src/index.js

import { auth, db, authenticateWithBackend, onAuthStateChanged } from './firebase';
import { collection, onSnapshot, addDoc, serverTimestamp } from "firebase/firestore";
import { setupUI } from './ui'; // 假设UI逻辑在另一个文件

// 初始化UI元素
const { messagesContainer, messageForm, messageInput } = setupUI();

let unsubscribeFromMessages = null;

// 监听Firebase认证状态的变化
onAuthStateChanged(auth, user => {
    if (user) {
        // 用户已成功登录Firebase
        console.log("Firebase Auth state changed: Logged in as", user.uid);

        // 如果之前有订阅,先取消
        if (unsubscribeFromMessages) {
            unsubscribeFromMessages();
        }

        // 开始实时监听'messages'集合
        const messagesRef = collection(db, "messages");
        unsubscribeFromMessages = onSnapshot(messagesRef, (snapshot) => {
            messagesContainer.innerHTML = ''; // 清空现有消息
            snapshot.docs.forEach(doc => {
                const message = doc.data();
                const messageElement = document.createElement('div');
                messageElement.textContent = `[${message.authorId}]: ${message.text}`;
                messagesContainer.appendChild(messageElement);
            });
        }, (error) => {
            console.error("Error listening to messages:", error);
        });

    } else {
        // 用户未登录或已登出
        console.log("Firebase Auth state changed: Logged out.");
        if (unsubscribeFromMessages) {
            unsubscribeFromMessages();
            unsubscribeFromMessages = null;
        }
        messagesContainer.innerHTML = '<div>Please log in to see messages.</div>';
    }
});

// 表单提交事件
messageForm.addEventListener('submit', async (e) => {
    e.preventDefault();
    const currentUser = auth.currentUser;
    const messageText = messageInput.value.trim();

    if (!currentUser) {
        alert("You must be logged in to send a message.");
        return;
    }

    if (messageText) {
        try {
            const messagesRef = collection(db, "messages");
            await addDoc(messagesRef, {
                text: messageText,
                authorId: currentUser.uid, // 使用Firebase认证后的uid
                createdAt: serverTimestamp()
            });
            messageInput.value = ''; // 清空输入框
        } catch (error) {
            console.error("Error sending message:", error);
            alert("Failed to send message.");
        }
    }
});


// 应用启动入口
async function main() {
    console.log("Application starting...");
    // 页面加载后,立即尝试从后端获取令牌并认证
    await authenticateWithBackend();
}

main();

ui.js 只是简单地获取DOM元素。

// File: frontend/src/ui.js
export function setupUI() {
    document.body.innerHTML = `
        <h1>Real-time Chat</h1>
        <div id="messages-container" style="border: 1px solid #ccc; height: 300px; overflow-y: scroll; padding: 10px; margin-bottom: 10px;"></div>
        <form id="message-form">
            <input type="text" id="message-input" placeholder="Type a message..." style="width: 80%;"/>
            <button type="submit">Send</button>
        </form>
    `;
    return {
        messagesContainer: document.getElementById('messages-container'),
        messageForm: document.getElementById('message-form'),
        messageInput: document.getElementById('message-input'),
    };
}

这套前端代码实现了完整的流程:

  1. 启动认证main 函数调用 authenticateWithBackend,后者触发整个认证流程。
  2. 获取令牌fetchCustomToken 向PHP后端的 /api/get_firestore_token.php 发起请求。
  3. Firebase登录:拿到令牌后,调用 signInWithCustomToken 完成Firebase认证。
  4. 状态驱动onAuthStateChanged 监听认证状态。一旦登录成功,它就启动对Firestore集合的实时监听 (onSnapshot)。如果用户登出,它会清理掉监听器。
  5. 数据交互:表单提交时,使用 auth.currentUser.uid 作为作者ID,这确保了数据的归属权与PHP会话用户一致。

方案的局限性与未来展望

我们成功地在一个传统的PHP应用中,集成了一个由Firestore驱动的现代化实时模块,且没有破坏原有的认证体系。这是一个成本效益很高的渐进式改进方案。

然而,这个方案并非没有缺点,它也存在一些局限和可以迭代的方向:

  1. 令牌刷新机制:当前实现中,令牌有效期为1小时。如果用户在此页面停留超过1小时,令牌会失效,与Firestore的连接会中断。一个更完善的方案需要实现令牌的静默刷新。可以在Firebase SDK的ID令牌过期前(可以通过监听 onIdTokenChanged 实现),主动向PHP后端请求一个新的自定义令牌,并重新登录。这会增加前端的复杂性。

  2. 耦合性:前端模块的正常工作,强依赖于PHP后端的会话状态。这使得前端模块难以被独立测试或部署。长远来看,如果这类现代化模块越来越多,推动整个应用向基于JWT等无状态令牌的API认证体系演进,是更理想的选择。PHP可以演变为一个纯粹的API后端,不再管理会话状态。

  3. 单点瓶颈/api/get_firestore_token.php 这个端点可能会成为性能瓶颈。虽然令牌生成速度很快,但在极高的并发下,每次页面加载都请求一次令牌,仍会给PHP应用带来压力。可以考虑在PHP层面做一些短时缓存,例如对同一个用户ID的令牌请求,在5秒内返回同一个结果,但这会增加系统的复杂性。

尽管存在这些局限,但作为一种将存量系统与现代云服务结合的“粘合剂”架构,该方案在许多现实场景中都具有极高的实践价值。它允许团队在不进行颠覆性重构的前提下,快速引入新技术,满足新的业务需求。


  目录