סקירה כללית
לפניכם סקירה כללית של השלבים העיקריים שנדרשים ברישום של מפתח גישה:
- להגדיר אפשרויות ליצירת מפתח גישה. צריך לשלוח אותם ללקוח כדי שתוכלו להעביר אותם לקריאה ליצירת מפתח גישה: קריאה ל-WebAuthn API
navigator.credentials.create
באינטרנט ו-credentialManager.createCredential
ב-Android. אחרי שהמשתמש יאשר את היצירה של מפתח הגישה, הקריאה ליצירת מפתח הגישה תבוטל ותחזיר את פרטי הכניסהPublicKeyCredential
. - יש לאמת את פרטי הכניסה ולאחסן אותם בשרת.
בקטעים הבאים מתוארים הפרטים הספציפיים של כל שלב.
יצירת אפשרויות ליצירת פרטי כניסה
השלב הראשון שצריך לבצע בשרת הוא ליצור אובייקט PublicKeyCredentialCreationOptions
.
כדי לעשות זאת, צריך להיעזר בספריית FIDO בצד השרת. בדרך כלל הוא מציע פונקציית שירות שיכולה ליצור את האפשרויות האלה עבורכם. מוצרי SimpleWebAuthn, לדוגמה, generateRegistrationOptions
.
PublicKeyCredentialCreationOptions
צריך לכלול את כל מה שצריך כדי ליצור מפתח גישה: מידע על המשתמש, על הגורם המוגבל (RP) והגדרה של המאפיינים של פרטי הכניסה שיוצרים. אחרי שמגדירים את כל אלה, מעבירים אותם לפי הצורך לפונקציה בספרייה בצד השרת של ה-FIDO שאחראית ליצירת האובייקט PublicKeyCredentialCreationOptions
.
חלק מ-PublicKeyCredentialCreationOptions
יכולים להיות קבועים. אחרות צריכות להיות מוגדרות באופן דינמי בשרת:
rpId
: כדי לאכלס את מזהה הגורם המוגבל (RP) בשרת, משתמשים בפונקציות או במשתנים בצד השרת שמספקים את שם המארח של אפליקציית האינטרנט שלכם, כמוexample.com
.user.name
ו-user.displayName
: כדי לאכלס את השדות האלה, צריך להשתמש בפרטי הסשן של המשתמש שמחובר לחשבון (או בפרטי חשבון המשתמש החדש, אם המשתמש יוצר מפתח גישה במהלך ההרשמה).user.name
היא בדרך כלל כתובת אימייל והיא ייחודית לגורם המוגבל.user.displayName
הוא שם ידידותי למשתמש. חשוב לדעת שלא כל הפלטפורמות ישתמשו ב-displayName
.user.id
: מחרוזת אקראית וייחודית שנוצרה במהלך יצירת החשבון. הוא אמור להיות קבוע, בשונה משם משתמש שניתן לערוך. מזהה המשתמש משמש לזיהוי חשבון, אבל אסור לו לכלול פרטים אישיים מזהים (PII). סביר להניח שכבר יש לכם מזהה משתמש במערכת, אבל אם צריך, כדאי ליצור מזהה משתמש ספציפי למפתחות גישה כדי שלא יהיו בו פרטים אישיים מזהים.excludeCredentials
: רשימה של פרטי הכניסה הקיימים מזהים שמונעים כפילות של מפתח גישה מהספק של מפתח הגישה. כדי לאכלס את השדה הזה, צריך לחפש את פרטי הכניסה הקיימים של המשתמש הזה במסד הנתונים. פרטים נוספים זמינים במאמר מניעת יצירה של מפתח גישה חדש, אם יש כזה.challenge
: לצורך רישום של פרטי כניסה, האתגר לא רלוונטי אלא אם משתמשים באימות – שיטה מתקדמת יותר לאימות הזהות של ספק מפתח הגישה והנתונים שהוא משדר. עם זאת, גם אם אתם לא משתמשים באימות, האתגר הוא עדיין שדה חובה. במקרה כזה, אפשר להגדיר את האתגר הזה ל-0
אחד כדי לשמור על פשטות. ההוראות ליצירת אתגר מאובטח לאימות זמינות באימות מפתח גישה בצד השרת.
קידוד ופענוח
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 = 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 });
}
});
אחסון המפתח הציבורי
אם הפרמטר navigator.credentials.create
מפוענח בהצלחה אצל הלקוח, המשמעות היא שמפתח הגישה נוצר בהצלחה. מוחזר אובייקט PublicKeyCredential
.
האובייקט PublicKeyCredential
מכיל אובייקט AuthenticatorAttestationResponse
, שמייצג את התשובה של הספק של מפתח הגישה להוראות של הלקוח ליצור מפתח גישה. הקוד מכיל מידע על פרטי הכניסה החדשים שדרושים לכם כ-RP כדי לאמת את המשתמש מאוחר יותר. מידע נוסף על AuthenticatorAttestationResponse
זמין בנספח: AuthenticatorAttestationResponse
.
שולחים את האובייקט PublicKeyCredential
לשרת. אחרי שקיבלת את ההודעה, עליך לאמת אותה.
מעבירים את שלב האימות הזה לספרייה בצד השרת של ה-FIDO. בדרך כלל הוא יציע פונקציית שירות למטרה הזו. מוצרי SimpleWebAuthn, לדוגמה, verifyRegistrationResponse
. מידע נוסף על הבעיה זמין בנספח: אימות התגובה לרישום.
לאחר שהאימות יושלם בהצלחה, יש לאחסן את פרטי פרטי הכניסה במסד הנתונים כדי שהמשתמשים יוכלו לבצע אימות מאוחר יותר באמצעות מפתח הגישה המשויך לפרטי הכניסה האלה.
שימוש בטבלה ייעודית לפרטי הכניסה של מפתחות ציבוריים שמשויכים למפתחות גישה. למשתמש יכולה להיות רק סיסמה אחת, אבל יכולים להיות לו כמה מפתחות גישה – לדוגמה: מפתח גישה שמסונכרן דרך 'צרור המפתחות של iCloud' של Apple ומפתח אחד דרך מנהל הסיסמאות של Google.
לפניכם סכימה לדוגמה שבה אפשר להשתמש כדי לאחסן פרטים של פרטי כניסה:
- טבלת משתמשים:
user_id
: מזהה המשתמש הראשי. מזהה אקראי, ייחודי וקבוע של המשתמש. אתם יכולים להשתמש בו כמפתח ראשי לטבלה משתמשים.username
שם משתמש שהוגדר על ידי המשתמש, עם אפשרות לעריכה.passkey_user_id
: מזהה המשתמש הספציפי למפתח גישה ללא פרטים אישיים מזהים (PII), שמיוצג על ידיuser.id
באפשרויות הרישום. כשהמשתמש ינסה לבצע אימות מאוחר יותר, המאמת יהפוך אתpasskey_user_id
לזמין בתגובת האימות שלו ב-userHandle
. מומלץ לא להגדיר אתpasskey_user_id
כמפתח ראשי. מפתחות ראשיים נוטים להפוך לפרטים אישיים מזהים בפועל במערכות, כי יש בהם שימוש נרחב.
- הטבלה פרטי כניסה של מפתח ציבורי:
id
: מזהה פרטי הכניסה. משתמשים בו כמפתח ראשי בטבלה פרטי הכניסה של מפתח ציבורי.public_key
: המפתח הציבורי של פרטי הכניסה.passkey_user_id
: משתמשים במפתח הזה כמפתח זר כדי ליצור קישור לטבלה משתמשים.backed_up
: מפתח גישה מגובה אם הוא מסונכרן על ידי הספק של מפתח הגישה. כדאי לאחסן את מצב הגיבוי אם רוצים למסור סיסמאות בעתיד למשתמשים שמחזיקים מפתחות גישה מסוגbacked_up
. כדי לבדוק אם מפתח הגישה מגובה על ידי בחינת הדגלים ב-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 { credentialPublicKey, credentialID } = registrationInfo;
// Existing, signed-in user
const { user } = res.locals;
// Save the credential
await Credentials.update({
id: base64CredentialID,
publicKey: base64PublicKey,
// Optional: set the platform as a default name for the credential
// (example: "Pixel 7")
name: req.useragent.platform,
transports: response.response.transports,
passkey_user_id: user.passkey_user_id,
backed_up: registrationInfo.credentialBackedUp
});
// 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 של נתוני לקוח, שבאינטרנט הם נתונים כפי שהם רואים בדפדפן. היא כוללת את מקור הגורם המוגבל, את האתגר ואתandroidPackageName
אם הלקוח הוא אפליקציה ל-Android. בתור גורם מוגבל,clientDataJSON
מספקת גישה למידע שהדפדפן ראה בזמן בקשת ה-create
.response.attestationObject
מכיל שני פריטי מידע:attestationStatement
– זה לא רלוונטי אלא אם משתמשים באימות (attestation).authenticatorData
הם נתונים כפי שמופיע אצל הספק של מפתח הגישה. בתור גורם מוגבל, קריאה שלauthenticatorData
מספקת לך גישה לנתונים שספק מפתח הגישה קיבל ומוחזרים בזמן בקשת ה-create
.
authenticatorData
מכיל מידע חיוני על פרטי הכניסה של המפתח הציבורי שמשויכים למפתח הגישה החדש שנוצר:
- פרטי הכניסה של המפתח הציבורי עצמו, ומזהה פרטי הכניסה הייחודי שלו.
- מזהה הגורם המוגבל (RP) שמשויך לפרטי הכניסה.
- דגלים שמתארים את סטטוס המשתמש כשמפתח הגישה נוצר: אם משתמש היה נוכח בפועל ואם המשתמש אומת בהצלחה (מידע נוסף זמין ב-
userVerification
). - AAGUID, שמזהה את הספק של מפתח הגישה. הצגה של הספק של מפתחות הגישה יכולה להועיל למשתמשים, במיוחד אם יש להם מפתח גישה שרשום לשירות שלכם בכמה ספקים של מפתחות גישה.
למרות ש-authenticatorData
נמצא בתוך attestationObject
, המידע שהוא מכיל נחוץ להטמעה של מפתח הגישה גם אם לא משתמשים באימות. authenticatorData
מקודד ומכיל שדות שמקודדים בפורמט בינארי. הספרייה בצד השרת תטפל בדרך כלל בניתוח ובפענוח. אם לא משתמשים בספרייה בצד השרת, כדאי להשתמש ב-getAuthenticatorData()
בצד הלקוח כדי לחסוך זמן ניתוח ופענוח של העבודה בצד השרת.
נספח: אימות התגובה לרישום
מאחורי הקלעים, אימות התגובה להרשמה כולל את הבדיקות הבאות:
- מוודאים שמזהה הגורם המוגבל (RP) תואם לאתר שלכם.
- יש לוודא שמקור הבקשה הוא מקור צפוי של האתר שלך (כתובת ה-URL הראשית של האתר, אפליקציה ל-Android).
- אם צריך לאמת את המשתמש, יש לוודא שהסימון
authenticatorData.uv
לאימות המשתמש הואtrue
. בודקים שהסימון של נוכחות המשתמשauthenticatorData.up
הואtrue
, כי נוכחות המשתמשים תמיד נדרשת למפתחות גישה. - בודקים שהלקוח הצליח לספק את האתגר שציינתם. אם לא משתמשים באימות (attestation), הבדיקה הזו לא חשובה. עם זאת, מומלץ להטמיע את הבדיקה הזו כדי להבטיח שהקוד יהיה מוכן אם תחליטו להשתמש באימות בעתיד.
- חשוב לוודא שמזהה פרטי הכניסה עדיין לא רשום עבור אף משתמש.
- מוודאים שהאלגוריתם שהספק של מפתח הגישה משתמש בו כדי ליצור את פרטי הכניסה הוא אלגוריתם שפירטתם (בכל שדה
alg
ב-publicKeyCredentialCreationOptions.pubKeyCredParams
, שמוגדר בדרך כלל בספרייה בצד השרת ולא גלוי לכם). כך תוכלו להבטיח שהמשתמשים יוכלו להירשם רק באמצעות אלגוריתמים שבחרתם לאפשר.
לקבלת מידע נוסף, אפשר לעיין בקוד המקור של verifyRegistrationResponse
ב-SimpleWebAuthn או לעיין ברשימת האימותים המלאה במפרט.