تسجيل الدخول باستخدام ميزة "نقرة واحدة لتسجيل الدخول باستخدام حساب Google" على الويب

1. مقدمة

يعتمد هذا الدرس التطبيقي على الدرس التطبيقي زر "تسجيل الدخول باستخدام Google" على الويب، لذا احرص على إكماله أولاً.

في هذا الدرس التطبيقي حول الترميز، ستستخدم مكتبة JavaScript الخاصة بخدمات Google Identity وإشعارات One Tap لإضافة ميزة تسجيل الدخول إلى صفحات الويب الثابتة والديناميكية باستخدام واجهات برمجة تطبيقات HTML وJavaScript.

طلب One Tap

سنعمل أيضًا على إعداد نقطة نهاية لتسجيل الدخول من جهة الخادم من أجل التحقّق من رموز التعريف المميزة بتنسيق JWT.

المُعطيات

  • كيفية إعداد نقطة نهاية لتسجيل الدخول من جهة الخادم من أجل التحقّق من رموز التعريف
  • كيفية إضافة إشعار "تسجيل الدخول بنقرة واحدة" إلى صفحة ويب
    • كعنصر HTML ثابت،
    • بشكل ديناميكي باستخدام JavaScript.
  • سلوك طلب One Tap

المتطلبات

  1. معرفة أساسية بلغات HTML وCSS وJavaScript وأدوات مطوّري البرامج في Chrome (أو ما يعادلها)
  2. مساحة لتعديل ملفات HTML وJavaScript واستضافتها
  3. معرّف العميل الذي تم الحصول عليه في الدرس العملي السابق.
  4. بيئة قادرة على تشغيل تطبيق Python أساسي

لنبدأ!

2. إعداد نقطة نهاية لتسجيل الدخول

أولاً، سننشئ نصًا برمجيًا بلغة Python يعمل كخادم ويب أساسي، وسنضبط بيئة Python اللازمة لتشغيله.

عند تشغيل النص البرمجي محليًا، يعرض الصفحة المقصودة وصفحات HTML الثابتة وصفحات One Tap الديناميكية على المتصفّح. يقبل هذا الخادم طلبات 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()

بما أنّنا سنتأكّد من أنّ الرمز المميّز للويب بتنسيق JSON قد تم إصداره لعميلك باستخدام حقل الجمهور (aud) لرمز التعريف، يجب أن يعرف تطبيق Python معرّف العميل الذي يتم استخدامه. لإجراء ذلك، استبدِل PUT_YOUR_WEB_CLIENT_ID_HERE بمعرّف العميل الذي استخدمته في الدرس العملي السابق حول زر "تسجيل الدخول باستخدام حساب Google".

بيئة Python

لنبدأ الآن بإعداد البيئة لتشغيل نص خادم الويب.

ستحتاج إلى الإصدار 3.8 من Python أو إصدار أحدث بالإضافة إلى بعض الحِزم للمساعدة في التحقّق من صحة رموز JWT المميّزة وفك ترميزها.

$ python3 --version

إذا كان إصدار python3 أقل من 3.8، قد تحتاج إلى تغيير مسار shell PATH ليتم العثور على الإصدار المتوقّع أو تثبيت إصدار أحدث من 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/) ...

بيئات التطوير المتكاملة المستندة إلى السحابة الإلكترونية

تم تصميم هذا الدرس العملي ليتم تشغيله في وحدة طرفية محلية وعلى المضيف المحلي، ولكن مع إجراء بعض التغييرات، يمكن استخدامه على منصات مثل Replit أو Glitch. لكل منصة متطلبات إعداد خاصة بها وإعدادات تلقائية لبيئة Python، لذا من المحتمل أن تحتاج إلى تغيير بعض الأمور، مثل TARGET_HTML_PAGE_URL وإعداد Python.

على سبيل المثال، في Glitch، سيظلّ بإمكانك إضافة requirements.txt ولكن يمكنك أيضًا إنشاء ملف باسم start.sh لبدء خادم Python تلقائيًا:

python3 ./simple-server.py

يجب أيضًا تعديل عناوين URL التي يستخدمها برنامج Python النصي وملفات HTML لتصبح عنوان URL خارجيًا لبيئة التطوير المتكاملة المستندة إلى السحابة الإلكترونية. لذا، سيكون لدينا ما يلي: TARGET_HTML_PAGE_URL = f"https://your-project-name.glitch.me/" وبما أنّ ملفات HTML في كل هذا الدرس النموذجي تستخدم أيضًا localhost تلقائيًا، عليك تعديلها باستخدام عنوان URL الخارجي لبيئة التطوير المتكاملة على السحابة الإلكترونية: data-login_uri="https://your-project-name.glitch.me/user-login".

3- إنشاء صفحة مقصودة

بعد ذلك، سننشئ صفحة مقصودة تعرض نتائج تسجيل الدخول باستخدام ميزة "نقرة واحدة". تعرض الصفحة الرمز المميّز للمعرّف بتنسيق JWT الذي تم فك ترميزه أو تعرض خطأً. يمكن أيضًا استخدام نموذج في الصفحة لإرسال رمز JWT إلى نقطة نهاية تسجيل الدخول على خادم HTTP المستند إلى Python، حيث يتم فك ترميزه والتحقّق منه. يستخدم هذا التطبيق ملف تعريف ارتباط لإرسال نموذج CSRF مرّتين ومَعلمة طلب POST، ما يتيح له إعادة استخدام نقطة نهاية الخادم user-login نفسها كما في أمثلة gsi/client HTML وJavaScript API في الدرس العملي.

في الوحدة الطرفية، احفظ هذا في ملف باسم 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/ واضغط على الزر التحقّق من رمز JWT.

من المفترض أن يظهر لك ما يلي

التحقّق من صحة رمز JWT بدون محتوى

يؤدي الضغط على الزر إلى إرسال POST مع محتوى حقل الإدخال إلى نص Python البرمجي. يتوقّع النص البرمجي توفّر JWT مرمّز في حقل الإدخال، لذا يحاول فك ترميز الحمولة والتحقّق منها. بعد ذلك، تتم إعادة التوجيه إلى الصفحة المقصودة لعرض النتائج.

ولكن انتظر، لم يكن هناك رمز JWT... ألم يكن من المفترض أن يحدث خطأ؟ نعم، ولكن بطريقة مناسبة

بما أنّ الحقل كان فارغًا، يتم عرض خطأ. الآن، حاوِل إدخال بعض النص (أي نص تريده) في حقل الإدخال والضغط على الزر مرة أخرى. ويحدث خطأ مختلف في فك الترميز.

يمكنك لصق رمز مميّز لرمز ويب JSON صادر عن Google في حقل الإدخال، وسيتمكّن نص Python البرمجي من فك ترميزه والتحقّق منه وعرضه لك... أو يمكنك استخدام https://jwt.io لفحص أي رمز ويب JSON مرمّز.

4. صفحة HTML ثابتة

حسنًا، سنعدّ الآن ميزة "النقرة الواحدة" لتعمل على صفحات HTML بدون استخدام أي JavaScript. يمكن أن يكون ذلك مفيدًا للمواقع الإلكترونية الثابتة أو لأنظمة التخزين المؤقت وشبكات توصيل المحتوى.

ابدأ بإضافة نموذج الرمز هذا إلى ملف باسم 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 بمعرّف العميل الذي استخدمته في برنامج codelab السابق الخاص بزر "تسجيل الدخول باستخدام حساب Google".

ما هي وظيفة هذا الخيار؟

يتم استخدام أي عنصر HTML يتضمّن id بقيمة g_id_onload وسمات البيانات الخاصة به لإعداد مكتبة "خدمات هوية Google" (gsi/client). كما يعرض أيضًا طلب One Tap عند تحميل المستند في المتصفّح.

السمة data-login_uri هي معرّف الموارد المنتظم (URI) الذي سيتلقّى طلب POST من المتصفّح بعد أن يسجّل المستخدم الدخول. يحتوي هذا الطلب على رمز JWT مشفّر صادر عن Google.

للحصول على قائمة كاملة بخيارات One Tap، يمكنك الاطّلاع على أداة إنشاء رموز HTML ومرجع HTML API.

تسجيل الدخول

انقر على http://localhost:3000/static-page.html.

من المفترض أن يظهر طلب One Tap في المتصفّح.

واجهة مستخدم ميزة &quot;نقرة واحدة&quot;

اضغط على المتابعة باسم لتسجيل الدخول.

بعد تسجيل الدخول، ترسل Google طلب POST إلى نقطة نهاية تسجيل الدخول في خادم Python. يحتوي الطلب على رمز JWT مشفّر وموقّع من Google.

بعد ذلك، يستخدم الخادم أحد مفاتيح التوقيع العامة من Google للتحقّق من أنّ Google أنشأت رمز JWT ووقّعته. بعد ذلك، يتم فك ترميزها والتحقّق من أنّ شريحة الجمهور تطابق معرّف العميل. بعد ذلك، يتم إجراء عملية تحقّق من CSRF للتأكّد من أنّ قيمة ملف تعريف الارتباط وقيمة مَعلمة الطلب في نص POST متساويتان. وإذا لم تكن كذلك، فهذه علامة أكيدة على وجود مشكلة.

أخيرًا، تعرض الصفحة المقصودة رمز JWT الذي تم التحقّق منه بنجاح كبيانات اعتماد مستخدم لرمز تعريف بتنسيق JSON.

رمز JWT تم التحقّق منه

الأخطاء الشائعة

هناك عدة أسباب قد تؤدي إلى تعذُّر إكمال عملية تسجيل الدخول. في ما يلي بعض الأسباب الأكثر شيوعًا:

  • العنصر data-client_id غير متوفّر أو غير صحيح، وفي هذه الحالة سيظهر لك خطأ في وحدة تحكّم "أدوات مطوّري البرامج" ولن يعمل طلب One Tap.
  • data-login_uri غير متاح لأنّه تم إدخال معرّف موارد منتظم (URI) غير صحيح، أو لم يتم بدء تشغيل خادم الويب، أو أنّه يستمع إلى المنفذ الخاطئ. في هذه الحالة، يبدو أنّ طلب One Tap يعمل، ولكن سيظهر خطأ في علامة التبويب "الشبكة" في "أدوات المطوّرين" عند عرض بيانات الاعتماد.
  • اسم المضيف أو المنفذ الذي يستخدمه خادم الويب غير مُدرَج في مصادر JavaScript المعتمَدة لمعرّف عميل OAuth. ستظهر لك رسالة في وحدة التحكّم: "عند جلب نقطة نهاية تأكيد المعرّف، تم تلقّي رمز استجابة HTTP 400.". إذا ظهرت لك هذه الرسالة أثناء هذا الدرس العملي، تأكَّد من إدراج كل من 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()،
  • تنشئ ملف تعريف ارتباط لتزوير الطلبات من مواقع إلكترونية مختلفة (CSRF).
  • يضيف معالج ردّ الاتصال لتلقّي رمز JWT المشفّر من Google وإرساله باستخدام نموذج POST إلى نقطة نهاية نص Python البرمجي /user-login، و
  • تعرض طلب ميزة "النقرة الواحدة" باستخدام google.accounts.id.prompt().

يمكنك الاطّلاع على القائمة الكاملة لإعدادات One Tap في مرجع JavaScript API.

لنبدأ بتسجيل الدخول.

افتح http://localhost:3000/dynamic-page.html في المتصفّح.

يكون سلوك طلب One Tap هو نفسه سلوك سيناريو HTML الثابت، باستثناء أنّ هذه الصفحة تحدّد معالج معاودة الاتصال بلغة JavaScript لإنشاء ملف تعريف ارتباط CSRF، وتلقّي رمز JWT من Google، وإرساله إلى نقطة النهاية user-login لخادم Python. تنفّذ واجهة برمجة التطبيقات HTML API هذه الخطوات تلقائيًا نيابةً عنك.

علامة تبويب &quot;الشبكة&quot; في 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

نقرات الصفحة

ابدأ بالنقر في أي مكان خارج طلب One Tap. من المفترض أن يظهر لك الخطأ "تم إلغاء الطلب." في كلّ من الصفحة ووحدة التحكّم.

تسجيل الدخول

بعد ذلك، ما عليك سوى تسجيل الدخول كالمعتاد. ستظهر لك تعديلات على التسجيل والإشعارات يمكن استخدامها لتنفيذ إجراءات مثل إنشاء جلسة مستخدم أو إعادة تحميلها.

إغلاق الطلب

أعِد الآن تحميل الصفحة، وبعد ظهور One Tap، انقر على "X" في شريط العنوان. يجب تسجيل هذه الرسالة في وحدة التحكّم:

  • رفض المستخدم الطلب أو أغلقه. تم تفعيل التبريد الأسي لواجهة برمجة التطبيقات."

أثناء الاختبار، ستفعّل عملية التبريد. خلال فترة الانتظار، لا يتم عرض طلب One Tap. أثناء الاختبار، من المحتمل أنّك تريد إعادة ضبطها بدلاً من انتظار إعادة ضبطها تلقائيًا... إلا إذا كنت تريد حقًا الذهاب لتناول القهوة أو العودة إلى المنزل والنوم. لإعادة ضبط فترة الانتظار، اتّبِع الخطوات التالية:

  • انقر على رمز "معلومات الموقع الإلكتروني" في الجانب الأيمن من شريط العناوين في المتصفّح.
  • اضغط على الزرّ "إعادة ضبط الأذونات".
  • أعِد تحميل الصفحة.

بعد إعادة ضبط فترة الانتظار وإعادة تحميل الصفحة، سيظهر طلب One Tap.

7. الخاتمة

في هذا الدرس التطبيقي، تعلّمت بعض الأمور، مثل كيفية عرض ميزة "النقرة الواحدة" باستخدام HTML ثابت فقط أو بشكل ديناميكي باستخدام JavaScript.

لقد أعددت خادم ويب بسيطًا جدًا بلغة Python لإجراء الاختبارات المحلية، وتعرّفت على الخطوات اللازمة لفك ترميز رموز التعريف والتحقّق من صحتها.

لقد جرّبت الطرق الأكثر شيوعًا التي يتفاعل بها المستخدمون مع طلب One Tap ويرفضونه، ولديك صفحة ويب يمكن استخدامها لتصحيح أخطاء سلوك الطلب.

تهانينا!

للحصول على نقاط إضافية، جرِّب الرجوع إلى الخطوات السابقة واستخدام ميزة "نقرة واحدة" في المتصفحات المختلفة التي تتوافق معها.

قد تساعدك هذه الروابط في الخطوات التالية:

الأسئلة الشائعة