ניהול מפתחות גישה

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

פעולות בצד השרת

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

  1. הצגת כל פרטי הכניסה של המשתמש: אחזור של כל הרשומות ב-StoredPasskeyCredential שתואמות למזהה של המשתמש שמחובר לחשבון.
  2. עדכון שמות של פרטי כניסה: קבלת כינוי חדש בהתאמה אישית למזהה ספציפי של פרטי כניסה, ושמירת העדכון בשרת.
  3. מחיקת פרטי כניסה: הסרת מזהה ספציפי של פרטי כניסה ממסד הנתונים.
  4. עדכון פרטי חשבון המשתמש: קבלת ערכים מעודכנים של name (שם משתמש) ו-displayName לפרופיל של המשתמש שמחובר לחשבון, כדי שהלקוח יוכל לסנכרן אותם עם ספק מפתחות הגישה.
// Node.js routing example for credential CRUD and user profile rename
router.get('/api/credentials', checkUserAuthenticated, async (req, res) => {
  const list = await db.findCredentialsByUserId(req.user.id);
  return res.json(list);
});

router.put('/api/credential/:id', checkUserAuthenticated, async (req, res) => {
  const { id } = req.params;
  const { name } = req.body;
  const cred = await db.findCredentialById(id);
  if (!cred || cred.passkeyUserId !== req.user.id) {
    return res.status(404).json({ error: 'Credential not found.' });
  }
  cred.name = name;
  await db.saveCredential(cred);
  return res.json(cred);
});

router.delete('/api/credential/:id', checkUserAuthenticated, async (req, res) => {
  const { id } = req.params;
  const cred = await db.findCredentialById(id);
  if (!cred || cred.passkeyUserId !== req.user.id) {
    return res.status(404).json({ error: 'Credential not found.' });
  }
  await db.deleteCredential(id);
  return res.json({ success: true });
});

router.put('/api/user', checkUserAuthenticated, async (req, res) => {
  const { name, displayName } = req.body;
  const updatedUser = await db.updateUser(req.user.id, { name, displayName });
  return res.json(updatedUser);
});

ממשק משתמש לניהול בצד הלקוח

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

  1. הצגת הרשימה השמורה: אחזור הרשימה מנקודת הקצה שלכם והצגת שורות של פרטי כניסה ספציפיים. אם התגובה ריקה, צריך להציג הודעה מועילה על מצב ריק (למשל, "לא נמצאו מפתחות גישה").
  2. מיפוי מטא-נתונים של AAGUID: לכל מפתח גישה, מחפשים את המאפיין aaguid שלו ברישום המקומי כדי להציג את פרטי הספק שלו. פרטים נוספים זמינים בקטע זיהוי ספק מפתח הגישה באמצעות AAGUID.
  3. דרישות ממשק המשתמש לכל פריט: כל שורה בתוך מאגר הרשימה חייבת להציג:
    • סמל הספק: תמונה או URI של נתונים שנגזרו מ-AAGUID.
    • שם ספק/שם בהתאמה אישית: שם שנגזר מ-AAGUID או מחרוזת שהמשתמש שינה את השם שלה.
    • תאריך הרישום: חותמת הזמן הגולמית של התקופה registeredAt שנשמרה במסד הנתונים, בפורמט של תאריך קריא לאנשים שמוצג ללקוח.
    • תאריך השימוש האחרון: חותמת הזמן הגולמית של ראשית הזמן (epoch) lastUsedAt שנשמרה במסד הנתונים, בפורמט של תאריך שקריא לאנשים (אם קיים) לתצוגה של הלקוח.
    • כפתור שינוי השם: מפעיל פעולת שינוי שם כדי לעדכן את הכינוי המותאם אישית של פרטי הכניסה בשרת (PUT /api/credential/:id). הערה: שינוי הכינוי של מפתח גישה ספציפי הוא שינוי תווית בצד השרת, והוא לא מפעיל את signalCurrentUserDetails().
    • כפתור המחיקה: מפעיל את מחיקת פרטי הכניסה (DELETE /api/credential/:id) ומסנכרן את מזהי פרטי הכניסה שנותרו באמצעות signalAllAcceptedCredentials().
  4. ממשק המשתמש לשינוי שם של חשבון משתמש:
    • צריך לספק אמצעי בקרה שמאפשר למשתמש לעדכן את שם המשתמש של החשבון (name) או את displayName (PUT /api/user), ולסנכרן מיד את פרטי המשתמש המעודכנים עם מנהל הסיסמאות באמצעות PublicKeyCredential.signalCurrentUserDetails().
  5. כפתור מותנה ליצירת מפתח גישה:
    • הציעו כפתור בולט להפעלת הרישום של "יצירת מפתח גישה" בדף הניהול. לפני שמעבדים את רכיב ממשק המשתמש הזה, הדף חייב לזהות את היכולות של התכונות באמצעות PublicKeyCredential.getClientCapabilities() כדי לוודא שיש תמיכה במאמת של הפלטפורמה. אם אין תמיכה במפתחות גישה, צריך להסתיר את הכפתור הזה ולעודד את המשתמשים להירשם לאימות רב-שלבי רגיל.
    • אפשר לרשום מפתח אבטחה על ידי השמטת authenticatorSelection.authenticatorAttachment בקריאה ל-navigator.credentials.create().

סנכרון של Signal API

‫Signal API מאפשר לאפליקציה להעביר את מצבי פרטי הכניסה למנהלי הסיסמאות, וכך לשמור על סנכרון בין הכספות המסונכרנות של המשתמש לבין מסד הנתונים של הקצה העורפי.

  • כלל לזיהוי תכונות וקידוד פרמטרים:
    • תמיד צריך לזהות את התכונות של השיטות של Signal API לפני שמפעילים אותן (if (PublicKeyCredential.signalAllAcceptedCredentials) ו-if (PublicKeyCredential.signalCurrentUserDetails)).
    • כל הפרמטרים של userId ומזהה פרטי הכניסה שמועברים ל-methods של Signal API‏ (signalAllAcceptedCredentials,‏ signalCurrentUserDetails) חייבים להיות מחרוזות בקידוד Base64URL.
  • התחלת סנכרון של טעינת הדף:
    • האפליקציה חייבת להפעיל את signalAllAcceptedCredentials() באופן אוטומטי כשדף הניהול נטען (לדוגמה, בתוך רכיב event listener מסוג DOMContentLoaded, אתחול מודול או framework component mount hook כמו useEffect).
  • סנכרון עדכוני ניהול:
    • האפליקציה חייבת להפעיל את signalAllAcceptedCredentials() מיד ב-click handler של מחיקת פרטי הכניסה אחרי האחזור.
    • האפליקציה חייבת להפעיל את signalCurrentUserDetails() מיד ב-click handler של שינוי שם חשבון המשתמש (name או displayName) אחרי האחזור.
// Client-side management synchronization ES module
import { listFetch, renameCredentialFetch, updateUserFetch, deleteFetch } from './api.js';

// Base64URL-encoded User ID string (illustration only)
const base64UrlUserId = "M2YPl-KGnA8";

async function syncAcceptedCredentials(currentCredentialsList) {
  if (!window.PublicKeyCredential || !PublicKeyCredential.signalAllAcceptedCredentials) return;
  try {
    const credentialIds = currentCredentialsList.map(c => c.id); // Array of Base64URL credential ID strings
    
    await PublicKeyCredential.signalAllAcceptedCredentials({
      rpId, // RP ID must match the one defined on the server
      userId: base64UrlUserId, // User ID Base64URL-encoded string
      allAcceptedCredentialIds: credentialIds
    });
  } catch (e) {
    console.error('SignalAllAcceptedCredentials sync failure:', e);
  }
}

async function loadManagementPanel() {
  const response = await listFetch();
  const list = await response.json();
  
  renderUI(list);
  // Sync accepted credentials on initial load
  await syncAcceptedCredentials(list);
}

async function performDelete(credentialId) {
  const response = await deleteFetch(credentialId);
  if (response.ok) {
    const updatedResponse = await listFetch();
    const updatedList = await updatedResponse.json();
    
    renderUI(updatedList);
    // Sync remaining accepted credentials after deletion
    await syncAcceptedCredentials(updatedList);
  }
}

// Renaming a passkey's nickname updates the server record only
async function performCredentialRename(credentialId, newCredentialName) {
  const response = await renameCredentialFetch(credentialId, { name: newCredentialName });
  if (response.ok) {
    const updatedResponse = await listFetch();
    renderUI(await updatedResponse.json());
  }
}

// Renaming the user's account username or displayName triggers signalCurrentUserDetails
async function performUserRename(rpId, userId, updatedName, updatedDisplayName) {
  const response = await updateUserFetch({ name: updatedName, displayName: updatedDisplayName });
  if (response.ok && window.PublicKeyCredential && PublicKeyCredential.signalCurrentUserDetails) {
    try {
      await PublicKeyCredential.signalCurrentUserDetails({
        rpId, // RP ID must match the one defined on the server
        userId, // Base64URL-encoded user ID
        name: updatedName, // Updated username
        displayName: updatedDisplayName // Updated display name
      });
    } catch (e) {
      console.error('SignalCurrentUserDetails sync failure:', e);
    }
  }
}

window.addEventListener('DOMContentLoaded', loadManagementPanel);

זיהוי ספק מפתח הגישה לפי AAGUID

‫AAGUID (מזהה ייחודי גלובלי של אימות מאמת) הוא מזהה של 128 ביט שמייצג את המודל של המאמת, ולא מופע ספציפי. הוא נכלל בנתוני המאמת במהלך הרישום של מפתח הגישה, ואפשר להשתמש בו כדי לקבוע איזה ספק של מפתחות גישה (למשל, מנהל הסיסמאות של Google, צרור המפתחות של iCloud, ‏1Password) יצר את פרטי הכניסה.

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

1. מרשם AAGUID

מיפוי JSON של מזהי AAGUID לשמות וסמלים של ספקים, שמנוהל על ידי הקהילה, זמין בכתובת:

https://raw.githubusercontent.com/passkeydeveloper/passkey-authenticator-aaguids/refs/heads/main/combined_aaguid.json

לכל רשומה יש את הסכימה הבאה:

{
  "<aaguid-uuid>": {
    "name": "Provider Name",
    "icon_light": "data:image/png;base64,...",
    "icon_dark": "data:image/png;base64,..."
  }
}

2. שימוש ב-AAGUID אחרי ההרשמה

אחרי שמאמתים את התגובה לרישום, קוראים את ה-aaguid מתוצאת הרישום ומחפשים אותו במרשם כדי לאכלס את ה-name וה-providerIcon של פרטי הכניסה:

לפני שמחפשים את ה-AAGUID במרשם, צריך לבדוק אם הוא שווה ל-'00000000-0000-0000-0000-000000000000'. אם כן, צריך לדלג על החיפוש במרשם ולהגדיר את name כחלופה (למשל, שם המכשיר מסוכן המשתמש, או "ספק מפתחות גישה לא ידוע") ואת providerIcon כ-undefined. צריך לחפש במרשם רק מזהי AAGUID שלא אופסו.

import aaguids from './aaguids.json' with { type: 'json' };

const { aaguid } = registrationInfo;
if (aaguid === '00000000-0000-0000-0000-000000000000') {
  // use the device name as the passkey provider based on
  // the information derived from the user agent string,
  // or just say "Unknown passkey provider"
} else {
  const provider = aaguids[aaguid];
  const credential = {
    // ...other fields
    aaguid,
    name: provider?.name || 'Unknown passkey provider',
    providerIcon: provider?.icon_light,
  };
}

שיטות חלופיות

זיהוי תכונות של מפתחות גישה – חלופה

תמיד צריך להתקין את webauthn-polyfills ולייבא אותו בהקשר. כל עוד יש תמיכה ב-PublicKeyCredential, יש תמיכה גם ב-PublicKeyCredential.getClientCapabilities.

import 'webauthn-polyfills';

חלופה לסנכרון של Signal API

אם הדפדפן לא תומך ב-PublicKeyCredential.parseRequestOptionsFromJSON, צריך להשתמש ב-"webauthn-polyfills":

<script type="module"> 
  if (!PublicKeyCredential.parseRequestOptionsFromJSON) { 
     await import('https://unpkg.com/webauthn-polyfills'); 
   } 
 </script> 

הפעולה הזו תוסיף גם תמיכה ב-PublicKeyCredential.prototype.toJSON.