רישום של מפתח גישה בצד השרת

סקירה כללית

לפניכם סקירה כללית של השלבים העיקריים שנדרשים ברישום של מפתח גישה:

תהליך הרישום של מפתח הגישה

  • להגדיר אפשרויות ליצירת מפתח גישה. צריך לשלוח אותם ללקוח כדי שתוכלו להעביר אותם לקריאה ליצירת מפתח גישה: קריאה ל-WebAuthn API navigator.credentials.create באינטרנט ו-credentialManager.createCredential ב-Android. אחרי שהמשתמש יאשר את היצירה של מפתח הגישה, הקריאה ליצירת מפתח הגישה תבוטל ותחזיר את פרטי הכניסה PublicKeyCredential.
  • יש לאמת את פרטי הכניסה ולאחסן אותם בשרת.

בקטעים הבאים מתוארים הפרטים הספציפיים של כל שלב.

יצירת אפשרויות ליצירת פרטי כניסה

השלב הראשון שצריך לבצע בשרת הוא ליצור אובייקט PublicKeyCredentialCreationOptions.

כדי לעשות זאת, צריך להיעזר בספריית FIDO בצד השרת. בדרך כלל הוא מציע פונקציית שירות שיכולה ליצור את האפשרויות האלה עבורכם. מוצרי SimpleWebAuthn, לדוגמה, generateRegistrationOptions.

PublicKeyCredentialCreationOptions צריך לכלול את כל מה שצריך כדי ליצור מפתח גישה: מידע על המשתמש, על הגורם המוגבל (RP) והגדרה של המאפיינים של פרטי הכניסה שיוצרים. אחרי שמגדירים את כל אלה, מעבירים אותם לפי הצורך לפונקציה בספרייה בצד השרת של ה-FIDO שאחראית ליצירת האובייקט PublicKeyCredentialCreationOptions.

חלק מ-PublicKeyCredentialCreationOptions יכולים להיות קבועים. אחרות צריכות להיות מוגדרות באופן דינמי בשרת:

  • rpId: כדי לאכלס את מזהה הגורם המוגבל (RP) בשרת, משתמשים בפונקציות או במשתנים בצד השרת שמספקים את שם המארח של אפליקציית האינטרנט שלכם, כמו example.com.
  • user.name ו-user.displayName: כדי לאכלס את השדות האלה, צריך להשתמש בפרטי הסשן של המשתמש שמחובר לחשבון (או בפרטי חשבון המשתמש החדש, אם המשתמש יוצר מפתח גישה במהלך ההרשמה). user.name היא בדרך כלל כתובת אימייל והיא ייחודית לגורם המוגבל. user.displayName הוא שם ידידותי למשתמש. חשוב לדעת שלא כל הפלטפורמות ישתמשו ב-displayName.
  • user.id: מחרוזת אקראית וייחודית שנוצרה במהלך יצירת החשבון. הוא אמור להיות קבוע, בשונה משם משתמש שניתן לערוך. מזהה המשתמש משמש לזיהוי חשבון, אבל אסור לו לכלול פרטים אישיים מזהים (PII). סביר להניח שכבר יש לכם מזהה משתמש במערכת, אבל אם צריך, כדאי ליצור מזהה משתמש ספציפי למפתחות גישה כדי שלא יהיו בו פרטים אישיים מזהים.
  • excludeCredentials: רשימה של פרטי הכניסה הקיימים מזהים שמונעים כפילות של מפתח גישה מהספק של מפתח הגישה. כדי לאכלס את השדה הזה, צריך לחפש את פרטי הכניסה הקיימים של המשתמש הזה במסד הנתונים. פרטים נוספים זמינים במאמר מניעת יצירה של מפתח גישה חדש, אם יש כזה.
  • challenge: לצורך רישום של פרטי כניסה, האתגר לא רלוונטי אלא אם משתמשים באימות – שיטה מתקדמת יותר לאימות הזהות של ספק מפתח הגישה והנתונים שהוא משדר. עם זאת, גם אם אתם לא משתמשים באימות, האתגר הוא עדיין שדה חובה. במקרה כזה, אפשר להגדיר את האתגר הזה ל-0 אחד כדי לשמור על פשטות. ההוראות ליצירת אתגר מאובטח לאימות זמינות באימות מפתח גישה בצד השרת.

קידוד ופענוח

PublicKeyCredentialCreationOptions נשלח על ידי השרת
PublicKeyCredentialCreationOptions נשלח על ידי השרת. challenge, user.id ו-excludeCredentials.credentials חייבים להיות מקודדים בצד השרת ל-base64URL כדי שניתן יהיה להעביר את PublicKeyCredentialCreationOptions באמצעות HTTPS.

PublicKeyCredentialCreationOptions כוללים שדות שהם ArrayBuffer, ולכן הם לא נתמכים על ידי JSON.stringify(). המשמעות היא שבשלב הזה, כדי לספק PublicKeyCredentialCreationOptions ב-HTTPS, צריך לקודד חלק מהשדות באופן ידני בשרת באמצעות base64URL ואז לפענח אותם אצל הלקוח.

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

קוד לדוגמה: יצירת אפשרויות ליצירת פרטי כניסה

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

import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';

router.post('/registerRequest', csrfCheck, sessionCheck, async (req, res) => {
  const { user } = res.locals;
  // Ensure you nest verification function calls in try/catch blocks.
  // If something fails, throw an error with a descriptive error message.
  // Return that message with an appropriate error code to the client.
  try {
    // `excludeCredentials` prevents users from re-registering existing
    // credentials for a given passkey provider
    const excludeCredentials = [];
    const credentials = Credentials.findByUserId(user.id);
    if (credentials.length > 0) {
      for (const cred of credentials) {
        excludeCredentials.push({
          id: isoBase64URL.toBuffer(cred.id),
          type: 'public-key',
          transports: cred.transports,
        });
      }
    }

    // Generate registration options for WebAuthn create
    const options = generateRegistrationOptions({
      rpName: process.env.RP_NAME,
      rpID: process.env.HOSTNAME,
      userID: user.id,
      userName: user.username,
      userDisplayName: user.displayName || '',
      attestationType: 'none',
      excludeCredentials,
      authenticatorSelection: {
        authenticatorAttachment: 'platform',
        requireResidentKey: true
      },
    });

    // Keep the challenge in the session
    req.session.challenge = options.challenge;

    return res.json(options);
  } catch (e) {
    console.error(e);
    return res.status(400).send({ error: e.message });
  }
});

אחסון המפתח הציבורי

PublicKeyCredentialCreationOptions נשלח על ידי השרת
navigator.credentials.create מחזירה אובייקט PublicKeyCredential.

אם הפרמטר navigator.credentials.create מפוענח בהצלחה אצל הלקוח, המשמעות היא שמפתח הגישה נוצר בהצלחה. מוחזר אובייקט PublicKeyCredential.

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

שולחים את האובייקט PublicKeyCredential לשרת. אחרי שקיבלת את ההודעה, עליך לאמת אותה.

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

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

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

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

סכימה של מסד נתונים למפתחות גישה

  • טבלת משתמשים:
    • user_id: מזהה המשתמש הראשי. מזהה אקראי, ייחודי וקבוע של המשתמש. אתם יכולים להשתמש בו כמפתח ראשי לטבלה משתמשים.
    • username שם משתמש שהוגדר על ידי המשתמש, עם אפשרות לעריכה.
    • passkey_user_id: מזהה המשתמש הספציפי למפתח גישה ללא פרטים אישיים מזהים (PII), שמיוצג על ידי user.id באפשרויות הרישום. כשהמשתמש ינסה לבצע אימות מאוחר יותר, המאמת יהפוך את passkey_user_id לזמין בתגובת האימות שלו ב-userHandle. מומלץ לא להגדיר את passkey_user_id כמפתח ראשי. מפתחות ראשיים נוטים להפוך לפרטים אישיים מזהים בפועל במערכות, כי יש בהם שימוש נרחב.
  • הטבלה פרטי כניסה של מפתח ציבורי:
    • id: מזהה פרטי הכניסה. משתמשים בו כמפתח ראשי בטבלה פרטי הכניסה של מפתח ציבורי.
    • public_key: המפתח הציבורי של פרטי הכניסה.
    • passkey_user_id: משתמשים במפתח הזה כמפתח זר כדי ליצור קישור לטבלה משתמשים.
    • backed_up: מפתח גישה מגובה אם הוא מסונכרן על ידי הספק של מפתח הגישה. כדאי לאחסן את מצב הגיבוי אם רוצים למסור סיסמאות בעתיד למשתמשים שמחזיקים מפתחות גישה מסוג backed_up. כדי לבדוק אם מפתח הגישה מגובה על ידי בחינת הדגלים ב-authenticatorData או באמצעות תכונת ספרייה בצד השרת מסוג FIDO, שזמינה בדרך כלל כדי לספק גישה קלה למידע הזה. אחסון הזכאות לגיבוי יכול לעזור לכם לטפל בפניות פוטנציאליות של משתמשים.
    • name: אופציונלי: שם לתצוגה עבור פרטי הכניסה, שיאפשר למשתמשים לתת לפרטי הכניסה שמות בהתאמה אישית.
    • transports: מערך של תחבורה. אחסון ההעברות שימושי לחוויית המשתמש של האימות. כשיש העברות זמינות, הדפדפן יכול להתנהג בהתאם ולהציג ממשק משתמש שתואם להעברה שבה משתמש ספק מפתח הגישה לצורך תקשורת עם לקוחות. במיוחד בתרחישים לדוגמה של אימות מחדש, השדה allowCredentials לא ריק.

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

קוד לדוגמה: אחסון פרטי הכניסה

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

import { isoBase64URL } from '@simplewebauthn/server/helpers';


router.post('/registerResponse', csrfCheck, sessionCheck, async (req, res) => {
  const expectedChallenge = req.session.challenge;
  const expectedOrigin = getOrigin(req.get('User-Agent'));
  const expectedRPID = process.env.HOSTNAME;
  const response = req.body;
  // This sample code is for registering a passkey for an existing,
  // signed-in user

  // Ensure you nest verification function calls in try/catch blocks.
  // If something fails, throw an error with a descriptive error message.
  // Return that message with an appropriate error code to the client.
  try {
    // Verify the credential
    const { verified, registrationInfo } = await verifyRegistrationResponse({
      response,
      expectedChallenge,
      expectedOrigin,
      expectedRPID,
      requireUserVerification: false,
    });

    if (!verified) {
      throw new Error('Verification failed.');
    }

    const { credentialPublicKey, credentialID } = registrationInfo;

    // Existing, signed-in user
    const { user } = res.locals;
    
    // Save the credential
    await Credentials.update({
      id: base64CredentialID,
      publicKey: base64PublicKey,
      // Optional: set the platform as a default name for the credential
      // (example: "Pixel 7")
      name: req.useragent.platform, 
      transports: response.response.transports,
      passkey_user_id: user.passkey_user_id,
      backed_up: registrationInfo.credentialBackedUp
    });

    // Kill the challenge for this session
    delete req.session.challenge;

    return res.json(user);
  } catch (e) {
    delete req.session.challenge;

    console.error(e);
    return res.status(400).send({ error: e.message });
  }
});

נספח: AuthenticatorAttestationResponse

AuthenticatorAttestationResponse מכיל שני אובייקטים חשובים:

  • response.clientDataJSON היא גרסת JSON של נתוני לקוח, שבאינטרנט הם נתונים כפי שהם רואים בדפדפן. היא כוללת את מקור הגורם המוגבל, את האתגר ואת androidPackageName אם הלקוח הוא אפליקציה ל-Android. בתור גורם מוגבל, clientDataJSONמספקת גישה למידע שהדפדפן ראה בזמן בקשת ה-create.
  • response.attestationObjectמכיל שני פריטי מידע:
    • attestationStatement – זה לא רלוונטי אלא אם משתמשים באימות (attestation).
    • authenticatorData הם נתונים כפי שמופיע אצל הספק של מפתח הגישה. בתור גורם מוגבל, קריאה של authenticatorData מספקת לך גישה לנתונים שספק מפתח הגישה קיבל ומוחזרים בזמן בקשת ה-create.

authenticatorDataמכיל מידע חיוני על פרטי הכניסה של המפתח הציבורי שמשויכים למפתח הגישה החדש שנוצר:

  • פרטי הכניסה של המפתח הציבורי עצמו, ומזהה פרטי הכניסה הייחודי שלו.
  • מזהה הגורם המוגבל (RP) שמשויך לפרטי הכניסה.
  • דגלים שמתארים את סטטוס המשתמש כשמפתח הגישה נוצר: אם משתמש היה נוכח בפועל ואם המשתמש אומת בהצלחה (מידע נוסף זמין ב-userVerification).
  • AAGUID, שמזהה את הספק של מפתח הגישה. הצגה של הספק של מפתחות הגישה יכולה להועיל למשתמשים, במיוחד אם יש להם מפתח גישה שרשום לשירות שלכם בכמה ספקים של מפתחות גישה.

למרות ש-authenticatorData נמצא בתוך attestationObject, המידע שהוא מכיל נחוץ להטמעה של מפתח הגישה גם אם לא משתמשים באימות. authenticatorData מקודד ומכיל שדות שמקודדים בפורמט בינארי. הספרייה בצד השרת תטפל בדרך כלל בניתוח ובפענוח. אם לא משתמשים בספרייה בצד השרת, כדאי להשתמש ב-getAuthenticatorData() בצד הלקוח כדי לחסוך זמן ניתוח ופענוח של העבודה בצד השרת.

נספח: אימות התגובה לרישום

מאחורי הקלעים, אימות התגובה להרשמה כולל את הבדיקות הבאות:

  • מוודאים שמזהה הגורם המוגבל (RP) תואם לאתר שלכם.
  • יש לוודא שמקור הבקשה הוא מקור צפוי של האתר שלך (כתובת ה-URL הראשית של האתר, אפליקציה ל-Android).
  • אם צריך לאמת את המשתמש, יש לוודא שהסימון authenticatorData.uv לאימות המשתמש הוא true. בודקים שהסימון של נוכחות המשתמש authenticatorData.up הוא true, כי נוכחות המשתמשים תמיד נדרשת למפתחות גישה.
  • בודקים שהלקוח הצליח לספק את האתגר שציינתם. אם לא משתמשים באימות (attestation), הבדיקה הזו לא חשובה. עם זאת, מומלץ להטמיע את הבדיקה הזו כדי להבטיח שהקוד יהיה מוכן אם תחליטו להשתמש באימות בעתיד.
  • חשוב לוודא שמזהה פרטי הכניסה עדיין לא רשום עבור אף משתמש.
  • מוודאים שהאלגוריתם שהספק של מפתח הגישה משתמש בו כדי ליצור את פרטי הכניסה הוא אלגוריתם שפירטתם (בכל שדה alg ב-publicKeyCredentialCreationOptions.pubKeyCredParams, שמוגדר בדרך כלל בספרייה בצד השרת ולא גלוי לכם). כך תוכלו להבטיח שהמשתמשים יוכלו להירשם רק באמצעות אלגוריתמים שבחרתם לאפשר.

לקבלת מידע נוסף, אפשר לעיין בקוד המקור של verifyRegistrationResponse ב-SimpleWebAuthn או לעיין ברשימת האימותים המלאה במפרט.

השלב הבא

אימות מפתח גישה בצד השרת