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

סקירה כללית

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

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

  • הגדרת אפשרויות ליצירת מפתח גישה. צריך לשלוח אותם ללקוח כדי להעביר אותם לשיחה ליצירת מפתח הגישה: הקריאה ל-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 הוא בדרך כלל כתובת אימייל, והוא ייחודי ל-RP. user.displayName הוא שם ידידותי למשתמש. חשוב לדעת שלא כל הפלטפורמות ישתמשו ב-displayName.
  • user.id: מחרוזת אקראית וייחודית שנוצרת במהלך יצירת החשבון. השם צריך להיות קבוע, בניגוד לשם משתמש שעשוי להיות ניתן לעריכה. מזהה המשתמש הוא המזהה של החשבון, אבל אסור לכלול בו פרטים אישיים מזהים (PII). סביר להניח שכבר יש לכם מזהה משתמש במערכת, אבל במקרה הצורך, כדאי ליצור מזהה ספציפי למפתחות גישה כדי שלא יהיו פרטים אישיים מזהים (PII).
  • excludeCredentials: רשימה של המזהים הקיימים של פרטי הכניסה, כדי למנוע כפילות של מפתח גישה מספק מפתחות הגישה. כדי לאכלס את השדה הזה, יש לחפש במסד הנתונים את פרטי הכניסה הקיימים של המשתמש הזה. אפשר לקרוא פרטים נוספים במאמר בנושא איך למנוע יצירה של מפתח גישה חדש אם כבר קיים מפתח גישה חדש.
  • challenge: לרישום פרטי כניסה, האתגר לא רלוונטי אלא אם משתמשים באימות (attestation), שיטה מתקדמת יותר לאימות הזהות של ספק מפתחות הגישה והנתונים שהוא פולט. עם זאת, גם אם לא משתמשים באימות, האתגר עדיין יהיה שדה חובה. במקרה כזה, כדי לפשט את העניינים, אפשר להגדיר את האתגר הזה ל-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, שמייצג את התגובה של ספק מפתח הגישה להוראות הלקוח ליצור מפתח גישה. ההודעה מכילה מידע על פרטי הכניסה החדשים שנדרשים כגורם מוגבל כדי לאמת את המשתמש מאוחר יותר. מידע נוסף על AuthenticatorAttestationResponse זמין בנספח: AuthenticatorAttestationResponse.

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

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

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

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

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

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

  • טבלת משתמשים:
    • user_id: מזהה המשתמש הראשי. מזהה אקראי, ייחודי וקבוע של המשתמש. ניתן להשתמש בו כמפתח ראשי לטבלה משתמשים.
    • username. שם משתמש בהגדרת המשתמש, עם אפשרות לערוך אותו.
    • passkey_user_id: מזהה משתמש ללא פרטים אישיים מזהים (PII) ספציפי למפתחות גישה, שמוצג על ידי user.id באפשרויות הרישום. כשהמשתמש ינסה לבצע את האימות מאוחר יותר, המאמת יהפוך את passkey_user_id הזה לזמין בתגובת האימות שלו ב-userHandle. מומלץ לא להגדיר את passkey_user_id כמפתח ראשי. מפתחות עיקריים הופכים בפועל כפרטים אישיים מזהים (PII) במערכות, כי נעשה בהם שימוש נרחב.
  • הטבלה של פרטי הכניסה למפתח ציבורי:
    • 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 של נתוני לקוח, שבאינטרנט הם נתונים כפי שהם נראים על ידי הדפדפן. הוא מכיל את המקור של הגורם המוגבל (RP) ואת האתגר, ואת הערך androidPackageName אם הלקוח הוא אפליקציה ל-Android. בתור גורם מוגבל, הקריאה clientDataJSONנותנת לכם גישה למידע שהדפדפן ראה בזמן בקשת create.
  • response.attestationObjectמכיל שתי פריטי מידע:
    • attestationStatement. הערך הזה לא רלוונטי אלא אם משתמשים באימות.
    • authenticatorData הם הנתונים כפי שהם מוצגים על ידי ספק מפתח הגישה. בתור RP, קריאה של authenticatorData מעניקה לך גישה לנתונים שהוצגו על ידי ספק מפתחות הגישה ומוחזרים בזמן הבקשה של create.

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

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

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

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

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

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

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

הבא

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