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

סקירה כללית

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

תהליך האימות באמצעות מפתח גישה

  • מגדירים את האתגר ואפשרויות אחרות שנדרשות לאימות באמצעות מפתח גישה. שולחים אותם ללקוח, כדי שתוכלו להעביר אותם לקריאת האימות של מפתח הגישה (navigator.credentials.get באינטרנט). אחרי שהמשתמש מאשר את האימות באמצעות מפתח הגישה, הקריאה לאימות באמצעות מפתח הגישה מסתיימת ומוחזרים פרטי כניסה (PublicKeyCredential). פרטי הכניסה מכילים טענת אימות.
  • מאמתים את טענת הנכוֹנוּת (assertion) של האימות.
  • אם טענת האימות תקינה, מאמתים את המשתמש.

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

יצירת האתגר

בפועל, אתגר הוא מערך של בייטים אקראיים, שמיוצג כאובייקט ArrayBuffer.

// Example challenge, base64URL-encoded
weMLPOSx1VfSnMV6uPwDKbjGdKRMaUDGxeDEUTT5VN8

כדי לוודא שהאתגר ממלא את מטרתו, אתם צריכים:

  1. חשוב לוודא שאף פעם לא משתמשים באותו אתגר יותר מפעם אחת. ליצור אתגר חדש בכל ניסיון כניסה. צריך למחוק את האתגר אחרי כל ניסיון כניסה, גם אם הוא הצליח וגם אם הוא נכשל. כדאי גם למחוק את האתגר אחרי משך זמן מסוים. לעולם אל תאשרו את אותו אתגר בתגובה יותר מפעם אחת.
  2. מוודאים שהאתגר מאובטח מבחינה קריפטוגרפית. האתגר צריך להיות כמעט בלתי אפשרי לניחוש. כדי ליצור אתגר מאובטח מבחינה קריפטוגרפית בצד השרת, מומלץ להסתמך על ספריית FIDO בצד השרת שאתם סומכים עליה. אם אתם יוצרים אתגרים משלכם, השתמשו בפונקציונליות הקריפטוגרפית המובנית שזמינה בסטאק התוכנות שלכם, או חפשו ספריות שמיועדות לתרחישי שימוש קריפטוגרפיים. לדוגמה, iso-crypto ב-Node.js או secrets ב-Python. לפי המפרט, האתגר חייב להיות באורך של 16 בייטים לפחות כדי להיחשב מאובטח.

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

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

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

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

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

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

  • ‫rpId: מזהה ה-RP שאתם מצפים שפרטי הכניסה ישויכו אליו, לדוגמה example.com. האימות יצליח רק אם מזהה ה-RP שציינתם כאן תואם למזהה ה-RP שמשויך לפרטי הכניסה. כדי לאכלס את מזהה ה-RP, צריך להשתמש באותו ערך של מזהה ה-RP שהגדרת ב-publicKeyCredentialCreationOptions במהלך רישום פרטי הכניסה.
  • ‫challenge: נתון שספק מפתח הגישה יחתום עליו כדי להוכיח שהמשתמש מחזיק במפתח הגישה בזמן בקשת האימות. פרטים נוספים זמינים בקטע יצירת האתגר.
  • ‫allowCredentials: מערך של פרטי כניסה קבילים לאימות הזה. כדי לאפשר למשתמש לבחור מפתח גישה זמין מתוך רשימה שמוצגת בדפדפן, צריך להעביר מערך ריק. לפרטים נוספים, אפשר לעיין במאמרים Fetch a challenge from the RP server (אחזור אתגר משרת ה-RP) ו-Discoverable credentials deep dive (הסבר מעמיק על פרטי כניסה שניתנים לגילוי).
  • userVerification: מציין אם אימות המשתמש באמצעות נעילת המסך של המכשיר הוא "נדרש", "מועדף" או "לא מומלץ". עיינו בקטע אחזור אתגר משרת ה-RP.
  • ‫timeout: משך הזמן (באלפיות השנייה) שבו המשתמש יכול להשלים את האימות. הוא צריך להיות נדיב באופן סביר, וקצר יותר ממשך החיים של ה-challenge. ערך ברירת המחדל המומלץ הוא 5 דקות, אבל אפשר להגדיל אותו עד 10 דקות, שזה עדיין בטווח המומלץ. הגדרת זמן קצוב ארוך לתפוגה מתאימה אם אתם מצפים שהמשתמשים ישתמשו בתהליך העבודה ההיברידי, שבדרך כלל נמשך קצת יותר זמן. אם הזמן הקצוב לתפוגה של הפעולה יסתיים, תתקבל השגיאה NotAllowedError.

אחרי שיוצרים את publicKeyCredentialRequestOptions, שולחים אותו ללקוח.

‫publicKeyCredentialCreationOptions שנשלח על ידי השרת
אפשרויות שנשלחו על ידי השרת. challenge מתבצע בצד הלקוח.

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

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

import {
  generateAuthenticationOptions,
} from '@simplewebauthn/server';

router.post('/signinRequest', csrfCheck, async (req, res) => {

  // Ensure you nest 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 {
    // Use the generateAuthenticationOptions function from SimpleWebAuthn
    const options = await generateAuthenticationOptions({
      rpID: process.env.HOSTNAME,
      allowCredentials: [],
    });
    // Save the challenge in the user session
    req.session.challenge = options.challenge;

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

אימות המשתמש וכניסה לחשבון

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

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

ה-response הוא AuthenticatorAssertionResponse. הוא מייצג את התגובה של ספק מפתחות הגישה להוראה של הלקוח ליצור את מה שנדרש כדי לנסות לבצע אימות באמצעות מפתח גישה ב-RP. הוא מכיל:

  • response.authenticatorDataוגםresponse.clientDataJSON, כמו בשלב רישום מפתח הגישה.
  • ‫response.signature שמכיל חתימה על הערכים האלה.

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

בשרת, מבצעים את הפעולות הבאות:

‫
סכימה של מסד נתונים
סכימה מוצעת של מסד נתונים. מידע נוסף על העיצוב הזה זמין במאמר רישום מפתח גישה בצד השרת.
  • אוספים את המידע שצריך כדי לאמת את הטענה ואת המשתמש:
    • מקבלים את האתגר הצפוי ששמרתם בסשן כשיצרתם את אפשרויות האימות.
    • משיגים את המקור ואת מזהה ה-RP הצפויים.
    • מאתרים במסד הנתונים את המשתמש. במקרה של פרטי כניסה שניתנים לגילוי, אתם לא יודעים מי המשתמש ששולח בקשת אימות. יש שתי דרכים לברר את זה:
      • אפשרות 1: משתמשים ב-response.userHandle באובייקט PublicKeyCredential. בטבלה Users, מחפשים את הערך של passkey_user_id שתואם ל-userHandle.
      • אפשרות 2: משתמשים בפרטי הכניסה id שמופיעים באובייקט PublicKeyCredential. בטבלה Public key credentials, מחפשים את פרטי הכניסה id שתואמים לפרטי הכניסה id שמופיעים באובייקט PublicKeyCredential. לאחר מכן, מחפשים את המשתמש המתאים באמצעות המפתח הזר passkey_user_id בטבלה משתמשים.
    • מחפשים במסד הנתונים את פרטי פרטי הכניסה של המפתח הציבורי שתואמים לטענת האימות שקיבלתם. כדי לעשות את זה, בטבלה Public key credentials, מחפשים את פרטי הכניסה id שתואמים לפרטי הכניסה id שמופיעים באובייקט PublicKeyCredential.
  • מאמתים את טענת הנכוֹנוּת (assertion) של האימות. מעבירים את שלב האימות הזה לספריית צד השרת של FIDO, שבדרך כלל תציע פונקציה בסיסית למטרה הזו. לדוגמה, SimpleWebAuthn מציעה את verifyAuthenticationResponse. בנספח: אימות של תגובת האימות מוסבר מה קורה מאחורי הקלעים.

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

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

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

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

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

router.post('/signinResponse', csrfCheck, async (req, res) => {
  const response = req.body;
  const expectedChallenge = req.session.challenge;
  const expectedOrigin = getOrigin(req.get('User-Agent'));
  const expectedRPID = process.env.HOSTNAME;

  // 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 {
    // Find the credential stored to the database by the credential ID
    const cred = Credentials.findById(response.id);
    if (!cred) {
      throw new Error('Credential not found.');
    }
    // Find the user - Here alternatively we could look up the user directly
    // in the Users table via userHandle
    const user = Users.findByPasskeyUserId(cred.passkey_user_id);
    if (!user) {
      throw new Error('User not found.');
    }
    // Prepare credential information for verification
    const credential = {
      id: cred.id,
      publicKey: isoBase64URL.toBuffer(cred.publicKey),
      transports: cred.transports,
    };

    // Verify the credential
    const { verified, authenticationInfo } = await verifyAuthenticationResponse({
      response,
      expectedChallenge,
      expectedOrigin,
      expectedRPID,
      credential,
      requireUserVerification: false,
    });

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

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

    req.session.username = user.username;
    req.session['signed-in'] = 'yes';

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

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

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

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

  • מוודאים שמזהה ה-RP תואם לאתר.
  • מוודאים שמקור הבקשה תואם למקור הכניסה של האתר. לאפליקציות ל-Android, עיינו בקטע אימות המקור.
  • מוודאים שהמכשיר הצליח לספק את האתגר שנתתם לו.
  • מוודאים שבמהלך האימות, המשתמש פעל לפי הדרישות שקבעתם כ-RP. אם אתם דורשים אימות משתמש, ודאו שהדגל uv (משתמש מאומת) ב-authenticatorData הוא true. בודקים שהדגל up (נוכחות המשתמש) ב-authenticatorData הוא true, כי נוכחות המשתמש תמיד נדרשת למפתחות גישה.
  • מאמתים את החתימה. כדי לאמת את החתימה, צריך:
    • החתימה, שהיא האתגר החתום: response.signature
    • המפתח הציבורי, לאימות החתימה.
    • הנתונים החתומים המקוריים. אלה הנתונים שהחתימה שלהם תאומת.
    • האלגוריתם הקריפטוגרפי ששימש ליצירת החתימה.

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