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

סקירה כללית

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

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

  • צריך להגדיר את האתגר ואפשרויות נוספות שנדרשות לאימות באמצעות מפתח גישה. צריך לשלוח אותם ללקוח כדי שתהיה אפשרות להעביר אותם לשיחת האימות של מפתח הגישה (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: מערך של פרטי כניסה קבילים לאימות הזה. צריך להעביר מערך ריק כדי לאפשר למשתמש לבחור מפתח גישה זמין מתוך רשימה שהדפדפן מציג. לקבלת פרטים, ניתן לעיין במאמר אחזור אתגר משרת ה-RP ובניתוח מעמיק של פרטי כניסה שגלויים לכולם.
  • userVerification: מציין אם אימות המשתמש באמצעות נעילת המסך של המכשיר הוא 'חובה', 'מועדף' או 'לא מומלץ'. קוראים את המאמר אחזור אתגר משרת ה-RP.
  • timeout: כמה זמן (באלפיות שנייה) המשתמש יכול להשלים את האימות. החיוב צריך להיות נדיב במידה סבירה וקצר ממשך החיים של challenge. ערך ברירת המחדל המומלץ הוא 5 דקות, אבל אפשר להגדיל אותו – עד 10 דקות, ועדיין במסגרת הטווח המומלץ. פסקי זמן ארוכים הם הגיוניים אם אתם מצפים שהמשתמשים ישתמשו בתהליך העבודה המשולב, שבדרך כלל נמשך קצת יותר זמן. אם הזמן הקצוב לפעולה יסתיים, יוטלה NotAllowedError.

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

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

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

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

import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse
} 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. התוצאה מכילה:

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

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

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

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

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

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

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

import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  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.');
    }
    // Base64URL decode some values
    const authenticator = {
      credentialPublicKey: isoBase64URL.toBuffer(cred.publicKey),
      credentialID: isoBase64URL.toBuffer(cred.id),
      transports: cred.transports,
    };

    // Verify the credential
    const { verified, authenticationInfo } = await verifyAuthenticationResponse({
      response,
      expectedChallenge,
      expectedOrigin,
      expectedRPID,
      authenticator,
      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 });
  }
});

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

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

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