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

סקירה כללית

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

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

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

בקטעים הבאים מפורטים השלבים השונים.

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

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

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

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

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

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

קידוד ופענוח

‫PublicKeyCredentialCreationOptions שנשלח על ידי השרת
PublicKeyCredentialCreationOptions נשלח על ידי השרת. שדות בינאריים כמו challenge ו-user.id מקודדים בצד השרת ל-base64URL כדי שאפשר יהיה להעביר את האפשרויות כ-JSON דרך HTTPS.

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

  • בשרת, ספריית צד השרת של FIDO (כמו SimpleWebAuthn) יוצרת באופן אוטומטי אובייקט אפשרויות שניתן לסדר ב-JSON (למשל PublicKeyCredentialCreationOptionsJSON) עם שדות בקידוד Base64URL.
  • בצד הלקוח, דפדפנים מודרניים תומכים ב-PublicKeyCredential.parseCreationOptionsFromJSON() כדי להמיר את אפשרויות ה-JSON ישירות ל-PublicKeyCredentialCreationOptions. כשמשתמשים בספריות בצד הלקוח כמו @simplewebauthn/browser, פונקציות כמו startRegistration({ optionsJSON: options }) מטפלות בהמרה הזו באופן אוטומטי.

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

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

import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} from '@simplewebauthn/server';
import { isoBase64URL, isoUint8Array } 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: cred.id,
          type: 'public-key',
          transports: cred.transports,
        });
      }
    }

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

    // 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. בנספח: אימות תגובת הרישום מוסבר מה קורה מאחורי הקלעים.

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

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

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

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

  • הטבלה Users:
    • ‫user_id: מזהה המשתמש הראשי. מזהה אקראי, ייחודי וקבוע של המשתמש. אפשר להשתמש בזה כמפתח ראשי בטבלה Users.
    • ‫username. שם משתמש שהוגדר על ידי המשתמש, ואולי אפשר לערוך אותו.
    • ‫passkey_user_id: מזהה המשתמש הספציפי למפתח הגישה, ללא פרטים אישיים מזהים, שמיוצג על ידי user.id באפשרויות הרישום. כשהמשתמש ינסה לבצע אימות בהמשך, המאמת יהפוך את passkey_user_id לזמין בתגובת האימות שלו ב-userHandle. מומלץ לא להגדיר את passkey_user_id כמפתח ראשי. מפתחות ראשיים נוטים להפוך לפרטים אישיים מזהים (PII) בפועל במערכות, כי משתמשים בהם הרבה.
  • הטבלה "פרטי כניסה של מפתח ציבורי":
    • ‫id: מזהה פרטי הכניסה. אפשר להשתמש בו כמפתח ראשי בטבלה Public key credentials.
    • ‫public_key: המפתח הציבורי של פרטי הכניסה.
    • ‫passkey_user_id: משמש כמפתח זר ליצירת קישור לטבלה Users.
    • ‫backed_up: מפתח גישה מגובה אם הוא מסונכרן על ידי ספק מפתח הגישה. אחסון מצב הגיבוי שימושי אם אתם שוקלים להפסיק להשתמש בסיסמאות בעתיד למשתמשים שיש להם מפתחות גישה של backed_up. כדי לבדוק אם מפתח הגישה מגובה, אפשר לבחון את הדגל BE ב-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 {
      aaguid,
      credential,
      credentialBackedUp
    } = registrationInfo;

    // Name the credential based on AAGUID
    const name =
      aaguid === undefined ||
      aaguid === '00000000-0000-0000-0000-000000000000' ?
        req.useragent?.platform : aaguids[aaguid]?.name;

    const base64PublicKey = isoBase64URL.fromBuffer(credential.publicKey);

    // Existing, signed-in user
    const { user } = res.locals;

    // Save the credential
    await Credentials.update({
      id: credential.id,
      passkey_user_id: user.passkey_user_id,
      publicKey: base64PublicKey,
      name,
      aaguid,
      transports: credential.transports || response.response.transports,
      backed_up: credentialBackedUp,
      registered_at: new Date().getTime()
    });

    // 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. כ-RP, קריאת clientDataJSON נותנת לך גישה למידע שהדפדפן ראה בזמן בקשת ה-create.
  • response.attestationObjectמכיל שני פריטי מידע:
    • attestationStatement, שלא רלוונטי אלא אם משתמשים באימות.
    • ‫authenticatorData הם נתונים כפי שהם נראים לספק מפתח הגישה. כ-RP, קריאת authenticatorData נותנת לך גישה לנתונים שספק מפתחות הגישה רואה ומוחזרים בזמן הבקשה של create.

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

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

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

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

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

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

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

הבאים בתור

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