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

סקירה כללית

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

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

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

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

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

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

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

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

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

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

קידוד ופענוח

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

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

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

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

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

  • טבלת Users:
    • user_id: מזהה המשתמש הראשי. מזהה אקראי, ייחודי וקבוע של המשתמש. משתמשים בו כמפתח ראשי לטבלה Users.
    • username. שם משתמש שהוגדר על ידי המשתמש, שאפשר לערוך אותו.
    • passkey_user_id: מזהה המשתמש הספציפי למפתח הגישה שלא כולל פרטים אישיים מזהים, שמיוצג על ידי user.id באפשרויות הרישום. כשמשתמש ינסה לאמת את עצמו בהמשך, אמצעי האימות יספק את passkey_user_id בתגובת האימות שלו ב-userHandle. מומלץ לא להגדיר את passkey_user_id כמפתח ראשי. מפתחות ראשיים נוטים להפוך לפרטים אישיים בפועל במערכות, כי נעשה בהם שימוש נרחב.
  • טבלה של פרטי מפתח ציבורי:
    • 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,
      credentialPublicKey,
      credentialID,
      credentialBackedUp
    } = registrationInfo;

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

    const base64CredentialID = isoBase64URL.fromBuffer(credentialID);
    const base64PublicKey = isoBase64URL.fromBuffer(credentialPublicKey);

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

    // Save the credential
    await Credentials.update({
      id: base64CredentialID,
      passkey_user_id: user.passkey_user_id,
      publicKey: base64PublicKey,
      name,
      aaguid,
      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.attestationObjectcontains two pieces of information:
    • attestationStatement שלא רלוונטי אלא אם משתמשים באימות.
    • authenticatorData הם נתונים כפי שהם מוצגים על ידי ספק מפתח הגישה. כ-RP, קריאת authenticatorDataמאפשרת לכם לגשת לנתונים שספק מפתחות הגישה רואה ומחזיר בזמן בקשת create.

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

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

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

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

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

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

הבא בתור

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