כניסה באמצעות הודעה מ-Google בלחיצה אחת באתרים

1. מבוא

ה-codelab הזה מבוסס על ה-codelab לחצן הכניסה באמצעות חשבון Google לאתרים, לכן חשוב להשלים אותו קודם.

ב-codelab הזה תשתמשו בספריית JavaScript של Google Identity Services ובהנחיות בלחיצה אחת כדי להוסיף כניסת משתמשים לדפי אינטרנט סטטיים ודינמיים באמצעות ממשקי API של HTML ו-JavaScript.

הנחיה לכניסה בלחיצה אחת

אנחנו גם נגדיר נקודת קצה להתחברות בצד השרת כדי לאמת אסימוני JWT ID.

מה תלמדו

  • איך מגדירים נקודת קצה (endpoint) להתחברות בצד השרת כדי לאמת טוקנים של מזהים
  • איך מוסיפים בקשה לכניסה באמצעות Google One Tap לדף אינטרנט
    • כאל רכיב HTML סטטי, וגם
    • באופן דינמי באמצעות JavaScript.
  • איך פועלת ההנחיה 'הרשמה בלחיצה אחת'

הדרישות

  1. ידע בסיסי ב-HTML, ‏ CSS, ‏ JavaScript ובכלי הפיתוח ל-Chrome (או בכלי מקביל).
  2. מקום לעריכה ולאירוח של קובצי HTML ו-JavaScript.
  3. מזהה הלקוח שהתקבל בסדנת הקוד הקודמת.
  4. סביבה שיכולה להריץ אפליקציית Python בסיסית.

קדימה!

‫2. הגדרת נקודת קצה לכניסה

קודם ניצור סקריפט Python שפועל כשרת אינטרנט בסיסי, ונגדיר את סביבת Python שנדרשת להרצת הסקריפט.

הסקריפט פועל באופן מקומי ומציג לדפדפן את דף הנחיתה, את דפי ה-HTML הסטטיים ואת דפי ה-HTML הדינמיים של 'הצטרפות בלחיצה אחת'. הוא מקבל בקשות POST, מפענח את ה-JWT שמופיע בפרמטר של פרטי הכניסה ומאמת שהוא הונפק על ידי פלטפורמת ה-OAuth של Google Identity.

אחרי שהסקריפט מפענח ומאמת את ה-JWT, הוא מבצע הפניה אוטומטית אל index.html דף הנחיתה כדי להציג את התוצאות.

מעתיקים את הקוד הבא לקובץ בשם simple-server.py:

"""Very basic web server to handle GET and POST requests."""
from http.server import SimpleHTTPRequestHandler
import json
import socketserver
from typing import Dict, Optional, Tuple
import urllib.parse
from urllib.parse import parse_qs

from google.auth.transport import requests as google_auth_requests
from google.oauth2 import id_token


""" NOTE: You'll need to change this """
CLIENT_ID = (
    "PUT_YOUR_WEB_CLIENT_ID_HERE"
)

""" these may change for a Cloud IDE, but good as-is for local termainals """
SERVER_ADDRESS = "0.0.0.0"
PORT = 3000
TARGET_HTML_PAGE_URL = f"http://localhost:{PORT}/"
""" and this is the end of constants you might need to change """

HTTP_STATUS_OK = 200
HTTP_STATUS_BAD_REQUEST = 400
HTTP_STATUS_UNAUTHORIZED = 401
HTTP_STATUS_INTERNAL_SERVER_ERROR = 500
HTTP_STATUS_FOUND = 303  # For redirection after decode and verify
OIDC_SERVER = "accounts.google.com"


class OIDCJWTReceiver(SimpleHTTPRequestHandler):
  """Request handler to securely process a Google ID token response."""

  def _validate_csrf(self, request_parameters: Dict) -> Tuple[bool, str]:
    """Validates the g_csrf_token to protect against CSRF attacks."""
    csrf_token_body = request_parameters.get("g_csrf_token")
    if not csrf_token_body:
      return False, "g_csrf_token not found in POST body."

    csrf_token_cookie = None
    cookie_header = self.headers.get("Cookie")
    if cookie_header:
      cookie_pairs = (c.split("=", 1) for c in cookie_header.split(";"))
      cookies = {k.strip(): v.strip() for k, v in cookie_pairs}
      csrf_token_cookie = cookies.get("g_csrf_token")
    if not csrf_token_cookie:
      return False, "g_csrf_token not found in cookie."

    if csrf_token_body != csrf_token_cookie:
      return False, "CSRF token mismatch."

    return True, "CSRF token validated successfully."

  def _parse_and_validate_credential(
      self, request_parameters: Dict
  ) -> Optional[Tuple[Optional[Dict], str]]:
    """Parse POST data, extract, decode and validate user credential."""
    credential = request_parameters.get("credential")
    if not credential:
      return None, "Credential not provided"

    try:
      id_info = id_token.verify_oauth2_token(
          credential, google_auth_requests.Request(), CLIENT_ID
      )
      return id_info, ""
    except ValueError as e:
      return None, f"Error during JWT decode: {e}"
    except Exception as e:
      return None, f"Unexpected error during credential validation: {e}"

  def _redirect_to_html(self, response_data: Dict) -> None:
    """Redirect to the target HTML page with data in the URL fragment."""
    try:
      json_data = json.dumps(response_data)
      encoded_data = urllib.parse.quote(json_data)
      redirect_url = f"http://localhost:{PORT}/#data={encoded_data}"
      self.send_response(HTTP_STATUS_FOUND)
      self.send_header("Location", redirect_url)
      self.send_header("Connection", "close")
      self.end_headers()
    except Exception as e:
      print(f"An error occurred during redirection: {e}")
      self.send_response(HTTP_STATUS_INTERNAL_SERVER_ERROR)
      self.send_header("Content-type", "text/plain")
      self.send_header("Connection", "close")
      self.end_headers()
      self.wfile.write(f"A redirect error occurred: {e}".encode("utf-8"))

  def _send_bad_request(self, message: str) -> None:
    """Sends a 400 Bad Request response."""
    self.send_response(HTTP_STATUS_BAD_REQUEST)
    self.send_header("Content-type", "text/plain")
    self.send_header("Connection", "close")
    self.end_headers()
    self.wfile.write(message.encode("utf-8"))

  def do_POST(self):
    """Handle POST requests for the /user-login path."""
    if self.path != "/user-login":
      self.send_error(404, "File not found")
      return

    try:
      content_length = int(self.headers.get("Content-Length", 0))
      post_data_bytes = self.rfile.read(content_length)
      post_data_str = post_data_bytes.decode("utf-8")
      request_parameters = {
          key: val[0]
          for key, val in parse_qs(post_data_str).items()
          if len(val) == 1
      }

      csrf_valid, csrf_message = self._validate_csrf(request_parameters)
      if not csrf_valid:
        print(f"CSRF verify failure: {csrf_message}")
        self._send_bad_request(f"CSRF verify failure: {csrf_message}")
        return

      decoded_id_token, error_message = self._parse_and_validate_credential(
          request_parameters
      )

      response_data = {}
      if decoded_id_token:
        response_data["status"] = "success"
        response_data["message"] = decoded_id_token
      elif error_message:
        response_data["status"] = "error"
        response_data["message"] = error_message
      else:
        response_data["status"] = "error"
        response_data["message"] = "Unknown error during JWT validation"

      self._redirect_to_html(response_data)

    except Exception as e:
      self._redirect_to_html(
          {"status": "error", "error_message": f"Internal server error: {e}"}
      )


with socketserver.TCPServer(("", PORT), OIDCJWTReceiver) as httpd:
  print(
      f"Serving HTTP on {SERVER_ADDRESS} port"
      f" {PORT} (http://{SERVER_ADDRESS}:{PORT}/)"
  )
  httpd.serve_forever()

אנחנו הולכים לאמת שה-JWT הונפק ללקוח שלכם באמצעות שדה הקהל (aud) של טוקן הזהות, ולכן אפליקציית Python צריכה לדעת באיזה מזהה לקוח נעשה שימוש. כדי לעשות את זה, מחליפים את PUT_YOUR_WEB_CLIENT_ID_HERE במזהה הלקוח שבו השתמשתם ב-codelab הקודם של כניסה באמצעות חשבון Google.

סביבת Python

בואו נגדיר את הסביבה להרצת הסקריפט של שרת האינטרנט.

תצטרכו Python בגרסה 3.8 ואילך, וגם כמה חבילות שיעזרו לכם לאמת ולפענח את ה-JWT.

$ python3 --version

אם גרסת python3 שלכם היא מתחת ל-3.8, יכול להיות שתצטרכו לשנות את נתיב המעטפת כדי שהגרסה הצפויה תימצא, או להתקין גרסה חדשה יותר של Python במערכת.

לאחר מכן, יוצרים קובץ בשם requirements.txt עם רשימת החבילות שצריך בשביל פענוח ואימות של JWT:

google-auth

מריצים את הפקודות האלה באותה ספרייה שבה נמצאים הקבצים simple-server.py ו-requirements.txt כדי ליצור סביבה וירטואלית ולהתקין חבילות רק לאפליקציה הזו:

$ python3 -m venv env
$ source env/bin/activate
(env) $ pip install -r requirements.txt

עכשיו מפעילים את השרת. אם הכול פועל כמו שצריך, יופיע המסך הבא:

(env) $ python3 ./simple-server.py
Serving HTTP on 0.0.0.0 port 3000 (http://0.0.0.0:3000/) ...

סביבות פיתוח משולבות (IDE) מבוססות-ענן

ה-codelab הזה נועד להרצה במסוף מקומי ובמארח מקומי, אבל עם כמה שינויים אפשר להשתמש בו בפלטפורמות כמו Replit או Glitch. לכל פלטפורמה יש דרישות הגדרה משלה וערכי ברירת מחדל משלה לסביבת Python, ולכן סביר להניח שתצטרכו לשנות כמה דברים כמו TARGET_HTML_PAGE_URL והגדרת Python.

לדוגמה, ב-Glitch עדיין מוסיפים requirements.txt אבל גם יוצרים קובץ בשם start.sh כדי להפעיל את שרת Python באופן אוטומטי:

python3 ./simple-server.py

צריך גם לעדכן את כתובות ה-URL שבהן נעשה שימוש בסקריפט Python ובקובצי ה-HTML לכתובת ה-URL החיצונית של סביבת הפיתוח המשולבת (IDE) ב-Cloud. לכן, נקבל משהו כזה: TARGET_HTML_PAGE_URL = f"https://your-project-name.glitch.me/" ומכיוון שקובצי ה-HTML ב-codelab הזה מוגדרים כברירת מחדל לשימוש ב-localhost, תצטרכו לעדכן אותם עם כתובת ה-URL החיצונית של Cloud IDE: data-login_uri="https://your-project-name.glitch.me/user-login".

‫3. יצירת דף נחיתה

בשלב הבא, ניצור דף נחיתה שבו יוצגו התוצאות של הכניסה באמצעות One Tap. בדף מוצג טוקן מזהה JWT מפוענח או שגיאה. אפשר גם להשתמש בטופס בדף כדי לשלוח JWT לנקודת הקצה של ההתחברות בשרת ה-HTTP של Python, שם הוא מפוענח ומאומת. הוא משתמש בקובץ Cookie של שליחה כפולה של CSRF ובפרמטר של בקשת POST כדי שיוכל לעשות שימוש חוזר באותה נקודת קצה (endpoint) של השרת כמו בדוגמאות של HTML ו-JavaScript API ב-codelab.user-logingsi/client

בטרמינל, שומרים את הטקסט הזה בקובץ בשם index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>JWT Verification</title>
    <style>
      body { font-family: sans-serif; margin: 0; }
      .top-nav {
        text-align: center; padding: 15px 0; background-color: #f8f9fa;
        border-bottom: 1px solid #dee2e6; width: 100%;
      }
      .top-nav a {
        margin: 0 15px; text-decoration: none; font-weight: bold;
        color: #007bff; font-size: 1.1em;
      }
      .top-nav a:hover { text-decoration: underline; }
      .page-container { padding: 20px; }
      pre {
        background-color: #f9f9f9; padding: 10px; overflow-x: auto;
        white-space: pre-wrap; word-break: break-all;
      }
      .error { color: red; }
      .success { color: green; }
      #jwt-form { margin-bottom: 20px; flex: 1; }
      fieldset {
        border: 1px solid #ddd; padding: 10px; margin: 0;
        min-width: 0; flex: 1; }
      legend { font-weight: bold; margin-bottom: 5px; }
      textarea { width: 100%; box-sizing: border-box; vertical-align: top; }
      button[type="submit"] { padding: 8px 15px; margin-top: 10px; }
      @media (min-width: 1024px) {
        .main-content { display: flex; gap: 20px; }
        .main-content > #jwt-form,
        .main-content > .result-container {
          flex-basis: 50%; /* Each item takes up 50% of the width */
          margin-bottom: 0; /* Remove bottom margin when side-by-side */
          display: flex; /* Make the result container a flex container */
          flex-direction: column; /* Stack children vertically */
          flex: 1; /* Allows the result container to grow and shrink */
        }
      }
    </style>
  </head>
  <body>
    <nav class="top-nav">
      <a href="static-page.html">One Tap Static Page</a>
      <a href="dynamic-page.html">One Tap Dynamic Page</a>
      <a href="prompt-outcomes.html">Prompt behaviors</a>
    </nav>
    <div class="page-container">
      <h1>JWT Verification</h1>

      <div class="main-content">
        <form id="jwt-form" action="/user-login" method="post">
          <fieldset>
            <legend>Encoded JWT ID Token</legend>
            <textarea id="credential" name="credential" rows="5"
                      cols="50"></textarea>
            <button type="submit">Verify JWT</button>
          </fieldset>
        </form>

        <section class="result-container">
          <fieldset>
            <legend>Decode and verify result</legend>
            <p id="status"></p>
            <pre id="result"></pre>
          </fieldset>
        </section>
      </div>
    </div>
    <script>
      const statusElement = document.getElementById("status");
      const resultElement = document.getElementById("result");

      const handleResponse = (responseData) => {
        const { status, message } = responseData;
        const result = message
          ? status === "success"
            ? JSON.stringify(message, null, 2)
            : message
          : "";

        statusElement.textContent = status;
        resultElement.textContent = result;
        statusElement.className = "";
        if (status === "success") {
          statusElement.classList.add("success");
        } else if (status === "error") {
          statusElement.classList.add("error");
        }
      };

      const getEncodedDataFromHash = (hash) => {
        const urlParams = new URLSearchParams(hash.substring(1));
        return urlParams.get("data");
      };

      const processHashData = (hash) => {
        const encodedData = getEncodedDataFromHash(hash);
        if (encodedData) {
          try {
            const jsonData = JSON.parse(decodeURIComponent(encodedData));
            handleResponse(jsonData);
            history.pushState(
              "",
              document.title,
              window.location.pathname + window.location.search,
            );
          } catch (error) {
            handleResponse({
              status: "error",
              message: "Error parsing data from URL: " + error,
            });
          }
        }
      };

      window.addEventListener("load",
                              () => processHashData(window.location.hash));
      window.addEventListener("hashchange",
                              () => processHashData(window.location.hash));
    </script>

    <script>
      document.addEventListener("DOMContentLoaded", () => {

        const generateRandomString = (length) => {
        const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
                      "abcdefghijklmnopqrstuvwxyz" +
                      "0123456789";
          let result = "";
          for (let i = 0; i < length; i++) {
            result += chars.charAt(Math.floor(Math.random() * chars.length));
          }
          return result;
        };

        const csrfToken = generateRandomString(12);
        document.cookie = `g_csrf_token=${csrfToken};path=/;SameSite=Lax`;
        const form = document.getElementById("jwt-form");
        const hiddenInput = document.createElement("input");
        hiddenInput.setAttribute("type", "hidden");
        hiddenInput.setAttribute("name", "g_csrf_token");
        hiddenInput.setAttribute("value", csrfToken);
        form.appendChild(hiddenInput);
      });
    </script>
  </body>
</html>

בדיקת שרת האינטרנט ופענוח JWT

לפני שננסה לעבוד עם לחיצה אחת, נוודא שסביבת נקודת הקצה של השרת מוגדרת ופועלת.

גולשים לדף הנחיתה, http://localhost:3000/ ולוחצים על הלחצן Verify JWT (אימות JWT).

אתם אמורים לראות את זה

אימות JWT ללא תוכן

לחיצה על הלחצן שולחת POST עם התוכן של שדה ההזנה לסקריפט Python. הסקריפט מצפה שיהיה JWT מקודד בשדה ההזנה, ולכן הוא מנסה לפענח ולאמת את המטען הייעודי (payload). לאחר מכן המערכת מפנה חזרה לדף הנחיתה כדי להציג את התוצאות.

אבל רגע, לא היה JWT… למה זה לא נכשל? כן, אבל בצורה חלקה!

מכיוון שהשדה היה ריק, מוצגת שגיאה. עכשיו נסו להזין טקסט כלשהו בשדה ההזנה וללחוץ שוב על הלחצן. הפעולה נכשלת עם שגיאת פענוח אחרת.

אפשר להדביק אסימון מזהה JWT מקודד שהונפק על ידי Google בשדה ההזנה, ולתת לסקריפט Python לפענח, לאמת ולהציג אותו בשבילכם… או שאפשר להשתמש בכתובת https://jwt.io כדי לבדוק כל JWT מקודד.

4. דף HTML סטטי

עכשיו נגדיר את התכונה 'הצטרפות בלחיצה אחת' כך שתפעל בדפי HTML בלי להשתמש ב-JavaScript. האפשרות הזו יכולה להיות שימושית לאתרים סטטיים או למערכות שמירה במטמון ולרשתות CDN.

מתחילים בהוספת קוד לדוגמה לקובץ בשם static-page.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <script src="https://accounts.google.com/gsi/client" async></script>
    <link rel="icon" href="data:," />
  </head>
  <body>
    <h1>Google One Tap static HTML page</h1>
    <div
      id="g_id_onload"
      data-client_id="PUT_YOUR_WEB_CLIENT_ID_HERE"
      data-ux_mode="redirect"
      data-login_uri="http://localhost:3000/user-login"
    ></div>
  </body>
</html>

לאחר מכן, פותחים את static-page.html ומחליפים את PUT_YOUR_WEB_CLIENT_ID_HERE בClient ID שבו השתמשתם ב-codelab הקודם של לחצן הכניסה באמצעות חשבון Google.

אז מה זה עושה?

כל רכיב HTML עם id של g_id_onload ומאפייני הנתונים שלו משמשים להגדרת ספריית Google Identity Services ‏ (gsi/client). בנוסף, מוצגת ההנחיה 'הצטרפות בלחיצה אחת' כשהמסמך נטען בדפדפן.

מאפיין data-login_uri הוא ה-URI שיקבל בקשת POST מהדפדפן אחרי שהמשתמש ייכנס לחשבון. הבקשה הזו מכילה את ה-JWT המקודד שהונפק על ידי Google.

רשימה מלאה של אפשרויות לשימוש בתכונה 'הצטרפות בלחיצה אחת' זמינה בכלי ליצירת קוד HTML ובהפניית HTML API.

כניסה

לוחצים על http://localhost:3000/static-page.html.

ההנחיה 'בלחיצה אחת' תוצג בדפדפן.

ממשק משתמש של &#39;לחיצה אחת&#39;

לוחצים על המשך בתור כדי להיכנס לחשבון.

אחרי הכניסה לחשבון, Google שולחת בקשת POST לנקודת הקצה של הכניסה לשרת Python. הבקשה מכילה JWT מקודד שחתום על ידי Google.

מכאן, השרת משתמש באחד ממפתחות החתימה הציבוריים של Google כדי לאמת ש-Google יצרה את ה-JWT וחתמה עליו. לאחר מכן המערכת מפענחת את הקהל ומוודאת שהוא תואם למזהה הלקוח. לאחר מכן מתבצעת בדיקת CSRF כדי לוודא שהערך של ה-Cookie וערך פרמטר הבקשה בגוף ה-POST שווים. אם הם לא מופיעים, זה סימן מובהק לבעיה.

לבסוף, בדף הנחיתה מוצג ה-JWT שאומת בהצלחה כפרטי כניסה של משתמש עם אסימון מזהה בפורמט JSON.

JWT מאומת

שגיאות נפוצות

יש כמה סיבות לכך שתהליך הכניסה ייכשל. אלה כמה מהסיבות הנפוצות ביותר:

  • data-client_id חסר או שגוי. במקרה כזה, תופיע שגיאה במסוף כלי הפיתוח וההנחיה לכניסה בלחיצה אחת לא תפעל.
  • האפשרות data-login_uri לא זמינה כי הוזן מזהה URI שגוי, שרת האינטרנט לא הופעל או שהוא מאזין ליציאה שגויה. במקרה כזה, ההנחיה 'הצטרפות בלחיצה אחת' תיראה כאילו היא פועלת, אבל שגיאה תופיע בכרטיסייה 'רשת' בכלי הפיתוח כשהאישורים יוחזרו.
  • שם המארח או היציאה שבהם שרת האינטרנט שלכם משתמש לא מופיעים במקורות ה-JavaScript המורשים של מזהה הלקוח של OAuth. תופיע הודעה במסוף: When fetching the ID assertion endpoint, a 400 HTTP response code was received. (כשמתבצעת אחזור של נקודת הקצה של הצהרת הזהות, מתקבל קוד תגובת HTTP‏ 400). אם אתם רואים את זה במהלך ה-Codelab, בדקו שגם http://localhost/ וגם http://localhost:3000 מופיעים ברשימה.

5. דף דינמי

עכשיו נציג את התכונה 'הצטרפות בלחיצה אחת' באמצעות קריאה של JavaScript. בדוגמה הזו, אנחנו תמיד מציגים את התכונה 'הצטרפות בלחיצה אחת' כשהדף נטען, אבל אתם יכולים לבחור להציג את ההנחיה רק כשצריך. לדוגמה, אפשר לבדוק אם הפעילות של המשתמש נמשכה יותר מ-28 ימים ולהציג שוב את ההנחיה לכניסה לחשבון.

מוסיפים את דוגמת הקוד הזו לקובץ בשם dynamic-page.html.

<!DOCTYPE html>
<html lang="en">
  <head>
    <script src="https://accounts.google.com/gsi/client" async></script>
    <link rel="icon" href="data:," />
  </head>
  <body>
    <h1>Google One Tap dynamic page</h1>
    <script>
      const generateRandomString = (length) => {
        const array = new Uint8Array(length / 2);
        window.crypto.getRandomValues(array);
        return Array.from(array, (byte) =>
          byte.toString(16).padStart(2, "0")
        ).join("");
      };

      const setCookie = (name, value) => {
        document.cookie = '${name}=${value};path=/;SameSite=Lax';
      };

      const getCookie = (name) => {
        const nameEQ = name + "=";
        const ca = document.cookie.split(";");
        for (let i = 0; i < ca.length; i++) {
          let c = ca[i];
          while (c.charAt(0) == " ") c = c.substring(1, c.length);
          if (c.indexOf(nameEQ) == 0)
            return c.substring(nameEQ.length, c.length);
        }
        return null;
      };

      function handleResponse(rsp) {
        console.log("ID Token received from Google: ", rsp.credential);
        console.log("Submitting token to server via dynamic form POST.");

        const form = document.createElement("form");
        form.method = "POST";
        form.action = "http://" + window.location.host + "/user-login";

        // Add the credential and CSRF cookie value asa hidden fields
        const hiddenField = document.createElement("input");
        hiddenField.type = "hidden";
        hiddenField.name = "credential";
        hiddenField.value = rsp.credential;
        form.appendChild(hiddenField);

        const csrfToken = getCookie("g_csrf_token");
        if (csrfToken) {
          console.log("Found g_csrf_token cookie, adding to form.");
          const csrfField = document.createElement("input");
          csrfField.type = "hidden";
          csrfField.name = "g_csrf_token";
          csrfField.value = csrfToken;
          form.appendChild(csrfField);
        } else {
          console.warn(
            "Warning: g_csrf_token cookie not found. POSTing without it."
          );
        }

        document.body.appendChild(form);
        form.submit();
      }

      window.onload = function () {
        const csrfToken = generateRandomString(12);
        setCookie("g_csrf_token", csrfToken);
        console.log("CSRF token cookie set on page load:", csrfToken);

        google.accounts.id.initialize({
          client_id: "PUT_YOUR_WEB_CLIENT_ID_HERE",
          ux_mode: "popup",
          callback: handleResponse,
        });

        google.accounts.id.prompt(); // Display the One Tap prompt
      };
    </script>
  </body>
</html>

פותחים את dynamic-page.html ומחליפים את PUT_YOUR_WEB_CLIENT_ID_HERE במזהה הלקוח שבו השתמשתם ב-codelab הקודם של לחצן הכניסה באמצעות חשבון Google.

הקוד הזה הוא שילוב של HTML ו-JavaScript, והוא מבצע כמה פעולות:

  • מגדיר את ספריית שירותי הזהויות של Google ‏ (gsi/client) על ידי קריאה ל-google.accounts.id.initialize(),
  • יוצר קובץ Cookie של זיוף בקשות בין אתרים (CSRF),
  • מוסיף גורם מטפל בקריאה חוזרת לקבלת אסימון ה-JWT המקודד מ-Google ושליחתו באמצעות POST של טופס לנקודת הקצה של סקריפט Python /user-login, ו
  • ההנחיה 'הצטרפות בלחיצה אחת' מוצגת באמצעות google.accounts.id.prompt().

רשימה מלאה של הגדרות לחיצה אחת זמינה בהפניית JavaScript API.

בואו נתחבר לחשבון!

פותחים את http://localhost:3000/dynamic-page.html בדפדפן.

ההתנהגות של ההנחיה 'לחיצה אחת' זהה לתרחיש של HTML סטטי, אלא שהדף הזה מגדיר גורם מטפל בקריאה חוזרת ב-JavaScript כדי ליצור קובץ Cookie של CSRF, לקבל את ה-JWT מ-Google ולשלוח אותו לנקודת הקצה user-login של שרת Python. ה-API של HTML מבצע את השלבים האלה באופן אוטומטי.

הכרטיסייה &#39;רשת&#39; ב-Chrome

6. התנהגויות של הנחיות

לכן ננסה כמה דברים עם לחיצה אחת, כי בניגוד ללחצן, ההנחיה לא מוצגת תמיד. הדפדפן והמשתמש יכולים לסגור או להשבית אותה.

קודם שומרים את זה בקובץ בשם prompt-outcomes.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Google One Tap Prompt behaviors</title>
    <style>
      body { font-family: sans-serif; padding: 20px; }
      #log {
        border: 1px solid #ccc; background-color: #f0f0f0;
        padding: 15px; margin-top: 20px;
        white-space: pre-wrap; font-family: monospace;
      }
      .success { color: green; }
      .error { color: red; }
      .info { color: blue; }
      .warning { color: orange; }
    </style>
  </head>
  <body>
    <h1>One Tap behaviors</h1>
    <p>Open the developer console to see detailed logs.</p>
    <div id="log">Awaiting events...</div>

    <script src="https://accounts.google.com/gsi/client" async defer></script>

    <script>
      // logging utility to display event and notification info
      const logElement = document.getElementById("log");
      function log(message, type = "info") {
        const timestamp = new Date().toLocaleTimeString();
        logElement.innerHTML +=
          `\n<span class="${type}">[${timestamp}] ${message}</span>`;
        console.log(`[${type.toUpperCase()}] ${message}`);
      }

      function decodeJwt(jwt) {
        try {
          const parts = jwt.split(".");
          if (parts.length !== 3) {
            throw new Error("Invalid JWT structure");
          }
          const base64Url = parts[1];
          const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
          const jsonPayload = decodeURIComponent(
            atob(base64)
              .split("")
              .map(function (c) {
                return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2);
              })
              .join(""),
          );
          return JSON.parse(jsonPayload);
        } catch (e) {
          log(`Error decoding JWT: ${e.message}`, "error");
          return null;
        }
      }

      /* Handles the credential response after a user signs in. */
      function handleCredentialResponse(credentialResponse) {
        log("Credential Response received.", "success");

        const credential = credentialResponse.credential;
        log(`Credential JWT: ${credential.substring(0, 30)}...`);

        // For demonstration, we decode the JWT on the client side.
        // REMEMBER: Always verify the token on your backend server!
        const payload = decodeJwt(credential);
        if (payload) {
          log(`Welcome, ${payload.name}! (Email: ${payload.email})`);
          log("Decoded JWT Payload: " + JSON.stringify(payload, null, 2));
        }
      }

      /* Handles notifications about the One-Tap prompt's UI status. */
      function handlePromptMomentNotification(notification) {
        log(`Prompt Moment Notification received.`, "info");

        if (notification.isNotDisplayed()) {
          const reason = notification.getNotDisplayedReason();
          log(`Prompt not displayed. Reason: <strong>${reason}</strong>`,
              "error");
        }
        if (notification.isSkippedMoment()) {
          const reason = notification.getSkippedReason();
          log(`Prompt was skipped. Reason: <strong>${reason}</strong>`,
              "warning");
          if (reason === "auto_cancel") {
            log("may have called prompt() multiple times in a row.");
          } else if (reason === "user_cancel") {
            log("The user manually closed the prompt.");
          }
        }
        if (notification.isDismissedMoment()) {
          const reason = notification.getDismissedReason();
          log(`Prompt dismissed. Reason: <strong>${reason}</strong>`, "info");
          if (reason === "credential_returned") {
            log("Expected, credential sent to the JS handler.");
          } else if (reason === "cancel_called") {
            log("programmatic call to google.accounts.id.cancel().");
          }
        }
      }

      window.onload = function () {
        try {
          google.accounts.id.initialize({
            client_id: "PUT_YOUR_WEB_CLIENT_ID_HERE",
            callback: handleCredentialResponse,
            ux_mode: "popup",
          });

          google.accounts.id.prompt(handlePromptMomentNotification);
          log("One Tap initialized. Waiting for prompt...");
        } catch (e) {
          log(`Initialization Error: ${e.message}`, "error");
        }
      };
    </script>
  </body>
</html>

לאחר מכן, פותחים את prompt-outcomes.html, מחליפים את PUT_YOUR_WEB_CLIENT_ID_HERE במזהה הלקוח ושומרים את הקובץ.

בדפדפן, פותחים את http://localhost:3000/prompt-outcomes.html

קליקים בדף

כדי להתחיל, לוחצים במקום כלשהו מחוץ להנחיה של 'הצטרפות בלחיצה אחת'. ההודעה The request has been aborted. (הבקשה בוטלה) אמורה להופיע ביומן גם בדף וגם במסוף.

כניסה

אחרי כן, פשוט נכנסים לחשבון כרגיל. יוצגו לכם עדכונים בנוגע לרישום ביומן ולהתראות, שאפשר להשתמש בהם כדי להפעיל פעולות כמו יצירה או רענון של סשן משתמש.

סגירת ההנחיה

עכשיו טוענים מחדש את הדף, ואחרי שהחלון של'הצטרפות בלחיצה אחת' מוצג, לוחצים על ה-X בסרגל הכותרת שלו. ההודעה הזו אמורה להירשם במסוף:

  • User declined or dismissed prompt. הופעלה השהיה מעריכית לפני ניסיון חוזר (exponential backoff) של API."

במהלך הבדיקה תפעילו את הורדת הדופק. במהלך תקופת הצינון, לא מוצגת בקשת ההרשאה בלחיצה אחת. במהלך הבדיקה, כדאי לאפס את המכשיר במקום לחכות לאיפוס אוטומטי... אלא אם אתם רוצים ללכת לשתות קפה או לחזור הביתה לישון. כדי לאפס את תקופת הצינון:

  • לוחצים על סמל פרטי האתר בצד ימין של סרגל הכתובות בדפדפן,
  • לוחצים על הלחצן 'איפוס הרשאות'
  • לטעון מחדש את הדף.

אחרי איפוס תקופת הצינון וטעינה מחדש של הדף, תוצג ההנחיה 'הצטרפות בלחיצה אחת'.

7. סיכום

ב-Codelab הזה למדתם כמה דברים, כמו איך להציג את התכונה 'הצטרפות בלחיצה אחת' באמצעות HTML סטטי בלבד או באופן דינמי באמצעות JavaScript.

הגדרתם שרת אינטרנט בסיסי מאוד של Python לבדיקה מקומית, ולמדתם את השלבים הדרושים לפענוח ולאימות של טוקנים של מזהים.

התנסיתם בדרכים הנפוצות ביותר שבהן משתמשים יוצרים אינטראקציה עם ההנחיה 'הצטרפות בלחיצה אחת' וסוגרים אותה, ויש לכם דף אינטרנט שאפשר להשתמש בו כדי לנפות באגים בהתנהגות של ההנחיה.

מעולה!

כדי לקבל נקודות בונוס, כדאי לחזור אחורה ולנסות להשתמש בכניסה בלחיצה אחת בדפדפנים השונים שנתמכים.

הקישורים הבאים יכולים לעזור לכם בשלבים הבאים:

שאלות נפוצות