סקירה כללית
ריכזנו כאן סקירה כללית של השלבים העיקריים בתהליך הרישום של מפתחות הגישה:
- הגדרת אפשרויות ליצירת מפתח גישה. צריך לשלוח אותם ללקוח כדי להעביר אותם לשיחה ליצירת מפתח הגישה: הקריאה ל-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
הוא בדרך כלל כתובת אימייל, והוא ייחודי ל-RP.user.displayName
הוא שם ידידותי למשתמש. חשוב לדעת שלא כל הפלטפורמות ישתמשו ב-displayName
.user.id
: מחרוזת אקראית וייחודית שנוצרת במהלך יצירת החשבון. השם צריך להיות קבוע, בניגוד לשם משתמש שעשוי להיות ניתן לעריכה. מזהה המשתמש הוא המזהה של החשבון, אבל אסור לכלול בו פרטים אישיים מזהים (PII). סביר להניח שכבר יש לכם מזהה משתמש במערכת, אבל במקרה הצורך, כדאי ליצור מזהה ספציפי למפתחות גישה כדי שלא יהיו פרטים אישיים מזהים (PII).excludeCredentials
: רשימה של המזהים הקיימים של פרטי הכניסה, כדי למנוע כפילות של מפתח גישה מספק מפתחות הגישה. כדי לאכלס את השדה הזה, יש לחפש במסד הנתונים את פרטי הכניסה הקיימים של המשתמש הזה. אפשר לקרוא פרטים נוספים במאמר בנושא איך למנוע יצירה של מפתח גישה חדש אם כבר קיים מפתח גישה חדש.challenge
: לרישום פרטי כניסה, האתגר לא רלוונטי אלא אם משתמשים באימות (attestation), שיטה מתקדמת יותר לאימות הזהות של ספק מפתחות הגישה והנתונים שהוא פולט. עם זאת, גם אם לא משתמשים באימות, האתגר עדיין יהיה שדה חובה. במקרה כזה, כדי לפשט את העניינים, אפשר להגדיר את האתגר הזה ל-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
, שמייצג את התגובה של ספק מפתח הגישה להוראות הלקוח ליצור מפתח גישה. ההודעה מכילה מידע על פרטי הכניסה החדשים שנדרשים כגורם מוגבל כדי לאמת את המשתמש מאוחר יותר. מידע נוסף על AuthenticatorAttestationResponse
זמין בנספח: AuthenticatorAttestationResponse
.
שלח את האובייקט PublicKeyCredential
לשרת. אחרי שמקבלים אותו, מאמתים אותו.
צריך להעביר את שלב האימות הזה לספרייה בצד השרת של FIDO. לרוב הוא יציע פונקציה שימושית למטרה הזו. מבצעים ב-SimpleWebAuthn, לדוגמה, verifyRegistrationResponse
. כדי לקבל מידע נוסף על התהליך, אפשר לעיין בנספח: אימות התשובה לרישום.
אחרי שהאימות יושלם בהצלחה, מאחסנים את פרטי הכניסה במסד הנתונים כדי שהמשתמש יוכל לבצע אימות באמצעות מפתח הגישה שמשויך לפרטי הכניסה האלה.
אפשר להשתמש בטבלה ייעודית לפרטי הכניסה של המפתח הציבורי שמשויכים למפתחות הגישה. למשתמש יכולים להיות רק סיסמה אחת, אבל יכולים להיות לו כמה מפתחות גישה. למשל, מפתח גישה שמסונכרן באמצעות Apple iCloud Keychain ומפתח גישה אחד דרך מנהל הסיסמאות של Google.
הנה סכימה לדוגמה שניתן להשתמש בה כדי לאחסן פרטים של פרטי כניסה:
- טבלת משתמשים:
user_id
: מזהה המשתמש הראשי. מזהה אקראי, ייחודי וקבוע של המשתמש. ניתן להשתמש בו כמפתח ראשי לטבלה משתמשים.username
. שם משתמש בהגדרת המשתמש, עם אפשרות לערוך אותו.passkey_user_id
: מזהה משתמש ללא פרטים אישיים מזהים (PII) ספציפי למפתחות גישה, שמוצג על ידיuser.id
באפשרויות הרישום. כשהמשתמש ינסה לבצע את האימות מאוחר יותר, המאמת יהפוך אתpasskey_user_id
הזה לזמין בתגובת האימות שלו ב-userHandle
. מומלץ לא להגדיר אתpasskey_user_id
כמפתח ראשי. מפתחות עיקריים הופכים בפועל כפרטים אישיים מזהים (PII) במערכות, כי נעשה בהם שימוש נרחב.
- הטבלה של פרטי הכניסה למפתח ציבורי:
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 של נתוני לקוח, שבאינטרנט הם נתונים כפי שהם נראים על ידי הדפדפן. הוא מכיל את המקור של הגורם המוגבל (RP) ואת האתגר, ואת הערךandroidPackageName
אם הלקוח הוא אפליקציה ל-Android. בתור גורם מוגבל, הקריאהclientDataJSON
נותנת לכם גישה למידע שהדפדפן ראה בזמן בקשתcreate
.response.attestationObject
מכיל שתי פריטי מידע:attestationStatement
. הערך הזה לא רלוונטי אלא אם משתמשים באימות.authenticatorData
הם הנתונים כפי שהם מוצגים על ידי ספק מפתח הגישה. בתור RP, קריאה שלauthenticatorData
מעניקה לך גישה לנתונים שהוצגו על ידי ספק מפתחות הגישה ומוחזרים בזמן הבקשה שלcreate
.
authenticatorData
מכיל מידע חיוני על פרטי הכניסה של המפתח הציבורי שמשויך למפתח הגישה החדש שנוצר:
- פרטי הכניסה של המפתח הציבורי עצמו ומזהה ייחודי לאימות.
- מזהה הגורם המוגבל (RP) שמשויך לפרטי הכניסה.
- סימונים שמתארים את סטטוס המשתמש כשמפתח הגישה נוצר: אם המשתמש היה נוכח בפועל ואם המשתמש אומת בהצלחה (מידע נוסף זמין ב
userVerification
). - AAGUID, שמזהה את ספק מפתח הגישה. הצגת הספק של מפתחות הגישה יכולה להיות שימושית למשתמשים, במיוחד אם יש להם מפתח גישה שרשום בשירות שלכם אצל כמה ספקים של מפתחות גישה.
הקוד authenticatorData
נמצא בתוך attestationObject
, אבל המידע שהוא מכיל נדרש להטמעה של מפתח הגישה, גם אם לא משתמשים באימות (attestation) וגם אם לא. authenticatorData
מקודד ומכיל שדות שמקודדים בפורמט בינארי. הספרייה בצד השרת תטפל בדרך כלל בניתוח ובפענוח. אם אתם לא משתמשים בספרייה בצד השרת, כדאי להשתמש בצד הלקוח של getAuthenticatorData()
כדי לחסוך קצת ניתוח ופענוח קוד בצד השרת.
נספח: אימות של תגובת ההרשמה
אימות של תגובת הרישום כולל את הבדיקות הבאות:
- מוודאים שמזהה הגורם המוגבל (RP) תואם לאתר.
- מוודאים שמקור הבקשה הוא מקור צפוי לאתר (כתובת ה-URL הראשית של האתר, אפליקציה ל-Android).
- אם נדרש אימות של משתמש, חשוב לוודא שסימון אימות המשתמש
authenticatorData.uv
הואtrue
. צריך לבדוק שהסימון של נוכחות המשתמשauthenticatorData.up
הואtrue
, כי נוכחות המשתמש נדרשת תמיד למפתחות גישה. - יש לבדוק שהלקוח הצליח לספק את האתגר שמסרת. אם לא משתמשים באימות (attestation), הבדיקה הזו לא חשובה. עם זאת, מומלץ להטמיע את הבדיקה הזו: היא מבטיחה שהקוד יהיה מוכן אם תחליטו להשתמש באימות (attestation) בעתיד.
- מוודאים שמזהה פרטי הכניסה עדיין לא רשום עבור אף משתמש.
- צריך לוודא שהאלגוריתם שמשמש את ספק מפתחות הגישה כדי ליצור את פרטי הכניסה הוא אלגוריתם שפירטת (בכל שדה
alg
שלpublicKeyCredentialCreationOptions.pubKeyCredParams
, שמוגדר בדרך כלל בספרייה בצד השרת ולא גלוי לך). כך ניתן להבטיח שהמשתמשים יוכלו להירשם רק באמצעות אלגוריתמים שבחרתם לאפשר.
כדי לקבל מידע נוסף, אפשר לעיין בקוד המקור של verifyRegistrationResponse
של SimpleWebAuthn או לעיין ברשימת האימות המלאה במפרט.