iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
Build on Google AI

零預算 NGO 數位轉型挑戰:30 天打造智慧訂房系統系列 第 16 篇

【Day 16】Google OAuth / SSO 身分驗證、Google Cloud 設定與全功能角色權限控制(RBAC)

  • 分享至 

  • xImage
  •  

在前 15 天裡,我們完成了核心預約系統、Telegram/Email 雙管道通知與第一階段的正式部署。然而,目前系統主要依賴使用者自行填寫 Email,缺乏嚴格的身分查驗與權限控管。

今天我們將為系統導入 Google OAuth 2.0 / SSO(單一簽入)身分驗證,並在後端建立 RBAC(Role-Based Access Control,基於角色的權限控制)架構。配合系統五間指定房間(A1、A2、B1、B2、洽談室)的營運規則,本篇將完整闡述權限機制,並提供 Google Cloud Console OAuth 2.0 Client ID 申請流程。


一、 Google Cloud Console 申請 OAuth 2.0 Client ID 詳細步驟

要在前端 React 應用中啟用 Google SSO 登入,必須先在 Google Cloud Console 建立 OAuth 用戶端憑證。

步驟 1:建立或選擇 GCP 專案

  1. 前往 Google Cloud Console。
  2. 點擊頂部專案下拉選單,選擇 「新增專案 (New Project)」。
  3. 輸入專案名稱(例如:NGO-Room-Booking-System),點擊 「建立 (Create)」。

步驟 2:設定 OAuth 同意畫面 (OAuth Consent Screen)

  1. 在左側選單進入 「API 和服務 (APIs & Services)」 $\rightarrow$ 「OAuth 同意畫面 (OAuth consent screen)」。
  2. 使用者類型 (User Type):
  • 若為 Google Workspace 帳號,選擇 內部 (Internal)(僅限機構內部同仁登入)。
  • 若為一般 Gmail 測試帳號,選擇 外部 (External)。
  1. 點擊 「建立 (Create)」 並填寫基本資訊:
  • 應用程式名稱:房間預約系統
  • 使用者支援電子郵件:你的 Google Email
  • 開發人員聯絡資訊:你的 Google Email
  1. 範圍 (Scopes):點擊「儲存並繼續」,預設授權範疇已包含 openid、email 與 profile。
  2. (若選外部)測試使用者 (Test users):新增測試用的 Google Email 帳號。
  3. 點擊 「儲存並繼續」 完成設定。

步驟 3:建立 OAuth 2.0 Client ID 憑證

  1. 進入左側選單的 「憑證 (Credentials)」,點擊頂部的 「+ 建立憑證 (+ CREATE CREDENTIALS)」 $\rightarrow$ 「OAuth 用戶端 ID (OAuth client ID)」。
  2. 應用程式類型 (Application type):選擇 Web 應用程式 (Web application)。
  3. 名稱 (Name):輸入 Room Booking React Frontend。
  4. 已授權的 JavaScript 來源 (Authorized JavaScript origins):
  • 開發環境:http://localhost:5173(或你的 Vite / React 本地 Port)
  • 生產環境:[https://your-app.vercel.app](https://your-app.vercel.app)(你的前端託管網址)
  1. 已授權的重新導向 URI (Authorized redirect URIs):
  • 同樣新增上述前端網址(如 http://localhost:5173)。
  1. 點擊 「建立 (Create)」。
  2. 畫面會彈出 用戶端 ID (Client ID)(格式如 123456789-xxxxxxxx.apps.googleusercontent.com),複製並妥善保存,此 ID 將配置於 React .env 檔案中。

二、 RBAC 權限矩陣與預約規則

系統的角色劃分為 一般職工 (User) 與 管理員 (Admin)。預約邏輯採用 「時段空置即直接預約」 的規則:

  1. 空置時段預約:房間在該時段若完全無人預約,發起預約即可直接成功,狀態為 APPROVED。
  2. 非空置時段:若該時段已被預約,系統不開放預約申請。
功能項目 一般職工 (User) 管理員 (Admin)
瀏覽房間與行事曆 🟩 允許 🟩 允許
發起預約(房間該時段空置) 🟩 狀態為 APPROVED 🟩 狀態為 APPROVED
審核 / 批准 / 駁回預約 🟥 拒絕 🟩 允許
取消他人預約 🟥 拒絕 🟩 允許
查看審計日誌與統計報表 🟥 拒絕 🟩 允許
變更系統設定與權限名單 🟥 拒絕 🟩 允許

三、 Google Sheets 資料表結構設計

除了 Bookings 預約表外,我們新增 User_Roles(角色權限對照表)與 Rooms(房間清單表,使用 A1、A2、B1、B2、洽談室,其中 A1、A2、B1、B2 可容納人數皆為 20 人):

1. 角色對照表 (User_Roles)

Email (主鍵) Role (角色) Department (部門) Status (狀態)
admin@ngo.org ADMIN 行政總務部 (Administration) ACTIVE
socialworker@ngo.org USER 社工服務部 (Social Work Services) ACTIVE
volunteer.coord@ngo.org USER 義工服務部 (Volunteer Services) ACTIVE

2. 房間規格表 (Rooms)

Room_ID Room_Name Capacity Status
ROOM_A1 A1 20 ACTIVE
ROOM_A2 A2 20 ACTIVE
ROOM_B1 B1 20 ACTIVE
ROOM_B2 B2 20 ACTIVE
ROOM_VIP 洽談室 4 ACTIVE

四、 後端認證與空置檢測預約邏輯(AuthAndApprovalService.gs)

包含 Token 驗證、權限檢查以及時段空置檢測 API:

/**
 * 驗證 Google OAuth Token 並取得使用者角色
 */
function verifyAndGetUserProfile(idToken) {
  if (!idToken) throw new Error('UNAUTHORIZED: 缺少認證 Token');

  const authUrl = `https://oauth2.googleapis.com/tokeninfo?id_token=${idToken}`;
  const response = UrlFetchApp.fetch(authUrl, { muteHttpExceptions: true });
  
  if (response.getResponseCode() !== 200) {
    throw new Error('UNAUTHORIZED: Token 無效或已過期');
  }

  const tokenInfo = JSON.parse(response.getContentText());
  const userEmail = tokenInfo.email;

  if (!tokenInfo.email_verified) {
    throw new Error('FORBIDDEN: Email 未通過 Google 驗證');
  }

  const roleInfo = getUserRoleFromSheet(userEmail);

  return {
    email: userEmail,
    name: tokenInfo.name || userEmail.split('@')[0],
    picture: tokenInfo.picture || '',
    role: roleInfo.role, // ADMIN, USER
    department: roleInfo.department,
    status: roleInfo.status
  };
}

/**
 * 從 Sheet 查詢角色
 */
function getUserRoleFromSheet(email) {
  const ss = SpreadsheetApp.openById(getEnvConfig().SPREADSHEET_ID);
  const sheet = ss.getSheetByName('User_Roles');
  if (!sheet) return { role: 'USER', department: 'Social Work Services', status: 'ACTIVE' };

  const data = sheet.getDataRange().getValues();
  for (let i = 1; i < data.length; i++) {
    if (data[i][0].toLowerCase() === email.toLowerCase()) {
      return { role: data[i][1], department: data[i][2], status: data[i][3] };
    }
  }
  return { role: 'USER', department: 'Social Work Services', status: 'ACTIVE' };
}

/**
 * 檢查房間時段是否空置
 */
function isRoomVacant(roomId, startTimeStr, endTimeStr) {
  const ss = SpreadsheetApp.openById(getEnvConfig().SPREADSHEET_ID);
  const sheet = ss.getSheetByName('Bookings');
  if (!sheet) return true;

  const data = sheet.getDataRange().getValues();
  const start = new Date(startTimeStr).getTime();
  const end = new Date(endTimeStr).getTime();

  for (let i = 1; i < data.length; i++) {
    const existingRoom = data[i][1];
    const existingStart = new Date(data[i][3]).getTime();
    const existingEnd = new Date(data[i][4]).getTime();
    const existingStatus = data[i][5]; // APPROVED, REJECTED

    // 僅針對同房間且非 REJECTED 的預約進行檢測
    if (existingRoom === roomId && existingStatus !== 'REJECTED') {
      // 時間重疊檢測條件:(StartA < EndB) && (EndA > StartB)
      if (start < existingEnd && end > existingStart) {
        return false; // 非空置,存在重疊
      }
    }
  }
  return true; // 空置無人預約
}

/**
 * 發起預約 API(僅限時段空置時可預約)
 */
function createBooking(userProfile, bookingData) {
  const ss = SpreadsheetApp.openById(getEnvConfig().SPREADSHEET_ID);
  const bookingSheet = ss.getSheetByName('Bookings');

  // 檢測房間該時段是否空置
  const vacant = isRoomVacant(bookingData.roomId, bookingData.startTime, bookingData.endTime);

  if (!vacant) {
    throw new Error('CONFLICT: 該時段已有預約,不開放預約');
  }

  const bookingId = 'BK_' + new Date().getTime();
  bookingSheet.appendRow([
    bookingId,
    bookingData.roomId,
    userProfile.email,
    bookingData.startTime,
    bookingData.endTime,
    'APPROVED',
    new Date()
  ]);

  return { bookingId, status: 'APPROVED', isVacant: true };
}


五、 React 前端整合 Google SSO 介面

登入與 Google OAuth 封裝 (App.jsx)

import React, { useState } from 'react';
import { GoogleOAuthProvider, GoogleLogin } from '@react-oauth/google';

const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;

export default function App() {
  const [user, setUser] = useState(null);

  const handleLoginSuccess = async (credentialResponse) => {
    const idToken = credentialResponse.credential;
    try {
      const res = await fetch(import.meta.env.VITE_GAS_API_URL, {
        method: 'POST',
        body: JSON.stringify({ action: 'VERIFY_AUTH', idToken })
      });
      const data = await res.json();
      if (data.status === 'SUCCESS') {
        setUser({ ...data.profile, idToken });
      }
    } catch (err) {
      console.error('認證失敗', err);
    }
  };

  return (
    <GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
      <div className="app-container">
        {!user ? (
          <div className="login-box">
            <h2>房間預約系統 - 單一簽入</h2>
            <GoogleLogin onSuccess={handleLoginSuccess} onError={() => console.log('Login Failed')} />
          </div>
        ) : (
          <main>
            <header>
              <h3>歡迎,{user.name} ({user.role}) - 部門:{user.department}</h3>
            </header>
          </main>
        )}
      </div>
    </GoogleOAuthProvider>
  );
}


六、 端到端權限測試矩陣

測試編號 測試情境 測試角色 預期結果
TC-07 預約空置時段(A1/A2/B1/B2/洽談室) USER / ADMIN 預約成功,狀態設為 APPROVED
TC-08 預約已存在其他預約的重疊時段 USER / ADMIN 系統拒絕預約,回傳時段已佔用提示
TC-09 管理員執行取消預約功能 ADMIN 成功取消預約


上一篇
【Day 15】通知機制切換(Email / Telegram Bot)、資安強化與系統階段性部署
下一篇
【Day 17】實戰操作全流程示範:新職工註冊審核、預約週期、權限異動與管理員月結報表
系列文
零預算 NGO 數位轉型挑戰:30 天打造智慧訂房系統 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言