Questo tutorial spiega come implementare le passkey nella tua app web. Utilizziamo Node.js (TypeScript), SimpleWebAuthn, HTML/JavaScript Vanilla e MySQL.
Vincent
Created: June 17, 2025
Updated: June 24, 2025
We aim to make the Internet a safer place using passkeys. That's why we want to support developers with tutorials on how to implement passkeys.
In questo tutorial, ti aiuteremo nei tuoi sforzi di implementazione delle passkey, offrendo una guida passo-passo su come aggiungere le passkey al tuo sito web.
Avere un'autenticazione moderna, robusta e di facile utilizzo è fondamentale quando si vuole creare un ottimo sito web o un'app. Le passkey sono emerse come la risposta a questa sfida. Fungendo da nuovo standard per gli accessi, promettono un futuro senza gli svantaggi delle password tradizionali, fornendo un'esperienza di accesso veramente senza password (che non è solo sicura ma anche estremamente comoda).
Recent Articles
Ciò che esprime veramente il potenziale delle passkey è il sostegno che hanno raccolto. Tutti i browser più importanti, che si tratti di Chrome, Firefox, Safari o Edge, e tutti i principali produttori di dispositivi (Apple, Microsoft, Google) hanno incorporato il supporto. Questa adozione unanime dimostra che le passkey sono il nuovo standard per gli accessi.
Sì, esistono già tutorial sull'integrazione delle passkey nelle applicazioni web. Che si tratti di framework frontend come React, Vue.js o Next.js, c'è una pletora di guide progettate per mitigare le sfide e accelerare le implementazioni delle passkey. Tuttavia, manca un tutorial end-to-end che rimanga minimalista e di base. Molti sviluppatori ci hanno contattato e chiesto un tutorial che facesse chiarezza sull'implementazione delle passkey per le app web.
Questo è esattamente il motivo per cui abbiamo creato questa guida. Il nostro obiettivo? Creare una configurazione minima funzionante per le passkey, che comprenda il livello frontend, backend e database (quest'ultimo spesso trascurato anche se può causare seri grattacapi).
Alla fine di questo percorso, avrai costruito un'applicazione web minima funzionante, in cui potrai:
Per chi ha fretta o desidera un riferimento, l'intero codice sorgente è disponibile su GitHub.
Curioso di vedere come sarà il risultato finale? Ecco un'anteprima del progetto finale (ammettiamo che sembra molto basilare, ma le cose interessanti sono sotto la superficie):
Siamo pienamente consapevoli che parti del codice e del progetto possono essere realizzate in modo diverso o più sofisticato, ma volevamo concentrarci sull'essenziale. Ecco perché abbiamo intenzionalmente mantenuto le cose semplici e incentrate sulle passkey.
Come aggiungere le passkey al mio sito web di produzione?
Questo è un esempio molto minimale di autenticazione con passkey. I seguenti aspetti NON sono considerati/implementati in questo tutorial o lo sono solo in modo molto basilare:
Ottenere un supporto completo per tutte queste funzionalità richiede uno sforzo di sviluppo enormemente maggiore. Per chi fosse interessato, consigliamo di dare un'occhiata a questo articolo sui malintesi degli sviluppatori riguardo le passkey.
Prima di immergerci nell'implementazione delle passkey, diamo un'occhiata alle competenze e agli strumenti necessari. Ecco cosa ti serve per iniziare:
Una solida conoscenza degli elementi costitutivi del web — HTML, CSS e JavaScript — è essenziale. Abbiamo intenzionalmente mantenuto le cose semplici, astenendoci da qualsiasi framework JavaScript moderno e affidandoci a JavaScript/HTML Vanilla. L'unica cosa più sofisticata che usiamo è la libreria wrapper di WebAuthn @simplewebauthn/browser.
Per il nostro backend, usiamo un server Node.js (Express) scritto
in TypeScript. Abbiamo anche deciso di lavorare con l'implementazione del server WebAuthn
di SimpleWebAuthn (@simplewebauthn/server
insieme a @simplewebauthn/typescript-types
).
Esistono numerose implementazioni di server WebAuthn, quindi puoi ovviamente usare anche
una di queste. Poiché abbiamo optato per il server WebAuthn in TypeScript, sono richieste
conoscenze di base di Node.js e npm.
Tutti i dati utente e le chiavi pubbliche delle passkey sono memorizzati in un database. Abbiamo scelto MySQL come tecnologia di database. Una comprensione fondamentale di MySQL e dei database relazionali è vantaggiosa, anche se ti guideremo attraverso i singoli passaggi.
Di seguito, useremo spesso i termini WebAuthn e passkey in modo intercambiabile, anche se ufficialmente potrebbero non significare la stessa cosa. Per una migliore comprensione, specialmente nella parte del codice, facciamo comunque questa supposizione.
Con questi prerequisiti, sei pronto per immergerti nel mondo delle passkey.
Ben Gould
Head of Engineering
I’ve built hundreds of integrations in my time, including quite a few with identity providers and I’ve never been so impressed with a developer experience as I have been with Corbado.
Oltre 10.000 sviluppatori si fidano di Corbado e rendono Internet più sicuro con le passkey. Hai domande? Abbiamo scritto più di 150 articoli del blog sulle passkey.
Unisciti alla community delle passkeyPrima di entrare nel codice e nelle configurazioni, diamo un'occhiata all'architettura del sistema che vogliamo costruire. Ecco una scomposizione dell'architettura che andremo a configurare:
Con questa panoramica dell'architettura, dovresti avere una mappa concettuale di come funzionano i componenti della nostra applicazione. Man mano che procediamo, approfondiremo ciascuno di questi componenti, dettagliandone la configurazione, l'impostazione e l'interazione.
Il seguente diagramma descrive il flusso del processo durante la registrazione (sign-up):
Il seguente diagramma descrive il flusso del processo durante l'autenticazione (login):
Inoltre, qui trovi la struttura del progetto (solo i file più importanti):
passkeys-tutorial ├── src # Contiene tutto il codice sorgente TypeScript del backend │ ├── controllers # Logica di business per la gestione di tipi specifici di richieste │ │ ├── authentication.ts # Logica di autenticazione con passkey │ │ └── registration.ts # Logica di registrazione con passkey │ ├── middleware │ │ ├── customError.ts # Aggiunge messaggi di errore personalizzati in modo standardizzato │ │ └── errorHandler.ts # Gestore di errori generale │ ├── public │ │ ├── index.html # File HTML principale del frontend │ │ ├── css │ │ │ └── style.css # Stile di base │ │ └── js │ │ └── script.js # Logica JavaScript (incl. API WebAuthn) │ ├── routes # Definizioni delle route API e dei loro gestori │ │ └── routes.ts # Route specifiche per le passkey │ ├── services │ │ ├── credentialService.ts# Interagisce con la tabella delle credenziali │ │ └── userService.ts # Interagisce con la tabella degli utenti │ ├── utils # Funzioni di supporto e utilità │ | ├── constants.ts # Alcune costanti (es. rpID) │ | └── utils.ts # Funzione di supporto │ ├── database.ts # Crea la connessione da Node.js al database MySQL │ ├── index.ts # Punto di ingresso del server Node.js │ └── server.ts # Gestisce tutte le impostazioni del server ├── config.json # Alcune configurazioni per il progetto Node.js ├── docker-compose.yml # Definisce servizi, reti e volumi per i container Docker ├── Dockerfile # Crea un'immagine Docker del progetto ├── init-db.sql # Definisce il nostro schema del database MySQL ├── package.json # Gestisce le dipendenze e gli script del progetto Node.js └── tsconfig.json # Configura come TypeScript compila il tuo codice
Quando si implementano le passkey, la configurazione del database è un componente chiave. Il nostro approccio utilizza un container Docker che esegue MySQL, offrendo un ambiente semplice e isolato, essenziale per test e deployment affidabili.
Il nostro schema del database è intenzionalmente minimalista, con solo due tabelle. Questa semplicità aiuta a una comprensione più chiara e a una manutenzione più facile.
Struttura dettagliata delle tabelle
1. Tabella Credentials: Centrale per l'autenticazione con passkey, questa tabella memorizza le credenziali delle passkey. Colonne critiche:
credential_id
, il tipo di dati e la formattazione appropriati sono cruciali.2. Tabella Users: Collega gli account utente alle loro credenziali corrispondenti.
Nota che abbiamo chiamato la prima tabella credentials
poiché, secondo la nostra
esperienza e ciò che altre librerie raccomandano, è più adatto (contrariamente al
suggerimento di SimpleWebAuthn di chiamarla authenticator
o authenticator_device
).
I tipi di dati per credential_id
e public_key
sono cruciali. Gli errori spesso
derivano da tipi di dati, codifica o formattazione errati (specialmente la differenza tra
Base64 e Base64URL è una causa comune di errori), che possono interrompere l'intero
processo di registrazione (sign-up) o autenticazione (login).
Tutti i comandi SQL necessari per la configurazione di queste tabelle sono contenuti nel
file init-db.sql
. Questo script garantisce un'inizializzazione del database rapida e
senza errori.
Per casi più sofisticati, è possibile aggiungere credential_device_type
o
credential_backed_up
per memorizzare maggiori informazioni sulle credenziali e
migliorare l'esperienza utente. In questo tutorial, tuttavia, ci asteniamo dal farlo.
init-db.sqlCREATE TABLE users ( id VARCHAR(255) PRIMARY KEY, username VARCHAR(255) NOT NULL UNIQUE ); CREATE TABLE credentials ( id INT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(255) NOT NULL, credential_id VARCHAR(255) NOT NULL, public_key TEXT NOT NULL, counter INT NOT NULL, transports VARCHAR(255), FOREIGN KEY (user_id) REFERENCES users (id) );
Dopo aver creato questo file, creiamo un nuovo file docker-compose.yml
a livello radice
del progetto:
docker-compose.ymlversion: "3.1" services: db: image: mysql command: --default-authentication-plugin=mysql_native_password restart: always environment: MYSQL_ROOT_PASSWORD: my-secret-pw MYSQL_DATABASE: webauthn_db ports: - "3306:3306" volumes: - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
Questo file avvia il database MySQL sulla porta 3306 e crea la struttura del database definita. È importante notare che il nome e la password del database utilizzati qui sono mantenuti semplici a scopo dimostrativo. In un ambiente di produzione, dovresti usare credenziali più complesse per una maggiore sicurezza.
Successivamente, passiamo all'esecuzione del nostro container Docker. A questo punto, il
nostro file docker-compose.yml
include solo questo singolo container, ma aggiungeremo
altri componenti in seguito. Per avviare il container, usa il seguente comando:
docker compose up -d
Una volta che il container è attivo e funzionante, dobbiamo verificare se il database funziona come previsto. Apri un terminale ed esegui il seguente comando per interagire con il database MySQL:
docker exec -it <container ID> mysql -uroot -p
Ti verrà richiesto di inserire la password di root, che nel nostro esempio è
my-secret-pw
. Dopo aver effettuato l'accesso, seleziona il database webauthn_db
e
visualizza le tabelle usando questi comandi:
use webauthn_db; show tables;
A questo punto, dovresti vedere le due tabelle definite nel nostro script. Inizialmente, queste tabelle saranno vuote, indicando che la nostra configurazione del database è completa e pronta per i passaggi successivi nell'implementazione delle passkey.
Il backend è il cuore di qualsiasi applicazione con passkey, fungendo da hub centrale per l'elaborazione delle richieste di autenticazione utente dal frontend. Comunica con la libreria del server WebAuthn per gestire le richieste di registrazione (sign-up) e autenticazione (login), e interagisce con il tuo database MySQL per memorizzare e recuperare le credenziali utente. Di seguito, ti guideremo nella configurazione del tuo backend usando Node.js (Express) con TypeScript, che esporrà un'API pubblica per gestire tutte le richieste.
Innanzitutto, crea una nuova directory per il tuo progetto e naviga al suo interno usando il tuo terminale o prompt dei comandi.
Esegui il comando
npx create-express-typescript-application passkeys-tutorial
Questo crea uno scheletro di codice di base di un'app Node.js (Express) scritta in TypeScript che possiamo usare per ulteriori adattamenti.
Il tuo progetto richiede diversi pacchetti chiave che dobbiamo installare in aggiunta:
Spostati nella nuova directory e installali con i seguenti comandi (installiamo anche i tipi TypeScript richiesti):
cd passkeys-tutorial npm install @simplewebauthn/server mysql2 uuid express-session @types/express-session @types/uuid
Per confermare che tutto sia installato correttamente, esegui
npm run dev:nodemon
Questo dovrebbe avviare il tuo server Node.js in modalità di sviluppo con Nodemon, che riavvia automaticamente il server a ogni modifica dei file.
Suggerimento per la risoluzione dei problemi: Se incontri errori, prova ad aggiornare
ts-node
alla versione 10.8.1 nel file package.json
e poi esegui npm i
per installare
gli aggiornamenti.
Il tuo file server.ts
ha la configurazione di base e il middleware per un'applicazione
Express. Per integrare la funzionalità delle passkey, dovrai
aggiungere:
Questi miglioramenti sono fondamentali per abilitare l'autenticazione con passkey nel backend della tua applicazione. Li configureremo più avanti.
Dopo aver creato e avviato il database nella
sezione 4, ora dobbiamo assicurarci che il nostro
backend possa connettersi al database MySQL. Pertanto, creiamo un nuovo file database.ts
nella cartella /src
e aggiungiamo il seguente contenuto:
database.tsimport mysql from "mysql2"; // Create a MySQL pool const pool = mysql.createPool({ host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, waitForConnections: true, connectionLimit: 10, queueLimit: 0, }); // Promisify for Node.js async/await. export const promisePool = pool.promise();
Questo file sarà successivamente utilizzato dal nostro server per accedere al database.
Diamo un'occhiata breve al nostro config.json
, dove sono già definite due variabili: la
porta su cui eseguiamo l'applicazione e l'ambiente:
config.json{ "PORT": 8080, "NODE_ENV": "development" }
package.json
può rimanere così com'è e dovrebbe apparire così:
package.json{ "name": "passkeys-tutorial", "version": "0.0.1", "description": "passkeys-tutorial initialised with create-express-typescript-application.", "main": "src/index.ts", "scripts": { "build": "tsc", "start": "node ./build/src/index.js", "dev": "ts-node ./src/index.ts", "dev:nodemon": "nodemon -w src -e ts,json -x ts-node ./src/index.ts", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": ["express", "typescript"], "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/morgan": "^1.9.9", "@types/node": "^14.18.63", "@typescript-eslint/eslint-plugin": "^4.33.0", "@typescript-eslint/parser": "^4.33.0", "eslint": "^7.32.0", "nodemon": "^2.0.22", "ts-node": "^10.8.1", "typescript": "^4.9.5" }, "dependencies": { "@simplewebauthn/server": "^8.3.5", "@types/express-session": "^1.17.10", "@types/uuid": "^9.0.7", "cors": "^2.8.5", "env-cmd": "^10.1.0", "express": "^4.18.2", "express-session": "^1.17.3", "fs": "^0.0.1-security", "helmet": "^4.6.0", "morgan": "^1.10.0", "mysql2": "^3.6.5", "uuid": "^9.0.1" } }
index.ts
appare così:
index.tsimport app from "./server"; import config from "../config.json"; // Start the application by listening to specific port const port = Number(process.env.PORT || config.PORT || 8080); app.listen(port, () => { console.info("Express application started on port: " + port); });
In server.ts
, dobbiamo adattare alcune altre cose. Inoltre, è necessaria una cache
temporanea di qualche tipo (ad es. redis, memcache o
express-session) per memorizzare le challenge temporanee contro
cui gli utenti possono autenticarsi. Abbiamo deciso di usare express-session
e
dichiariamo il modulo express-session
in cima per far funzionare le cose con
express-session
. Inoltre, semplifichiamo il routing e rimuoviamo per ora la gestione
degli errori (questa sarà aggiunta al middleware in seguito):
server.tsimport express, { Express } from "express"; import morgan from "morgan"; import helmet from "helmet"; import cors from "cors"; import config from "../config.json"; import { router as passkeyRoutes } from "./routes/routes"; import session from "express-session"; const app: Express = express(); declare module "express-session" { interface SessionData { currentChallenge?: string; loggedInUserId?: string; } } /************************************************************************************ * Basic Express Middlewares ***********************************************************************************/ app.set("json spaces", 4); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use( session({ // @ts-ignore secret: process.env.SESSION_SECRET, saveUninitialized: true, resave: false, cookie: { maxAge: 86400000, httpOnly: true, // Ensure to not expose session cookies to clientside scripts }, }), ); // Handle logs in console during development if (process.env.NODE_ENV === "development" || config.NODE_ENV === "development") { app.use(morgan("dev")); app.use(cors()); } // Handle security and origin in production if (process.env.NODE_ENV === "production" || config.NODE_ENV === "production") { app.use(helmet()); } /************************************************************************************ * Register all routes ***********************************************************************************/ app.use("/api/passkey", passkeyRoutes); app.use(express.static("src/public")); export default app;
Per gestire efficacemente i dati nelle nostre due tabelle create, svilupperemo due servizi
distinti in una nuova directory src/services
: authenticatorService.ts
e
userService.ts
.
Ogni servizio incapsulerà i metodi CRUD (Create, Read, Update, Delete), consentendoci di
interagire con il database in modo modulare e organizzato. Questi servizi faciliteranno la
memorizzazione, il recupero e l'aggiornamento dei dati nelle tabelle authenticator
e
user
. Ecco come dovrebbe essere la struttura di questi file richiesti:
userService.ts
appare così:
userService.tsimport { promisePool } from "../database"; // Adjust the import path as necessary import { v4 as uuidv4 } from "uuid"; export const userService = { async getUserById(userId: string) { const [rows] = await promisePool.query("SELECT * FROM users WHERE id = ?", [ userId, ]); // @ts-ignore return rows[0]; }, async getUserByUsername(username: string) { try { const [rows] = await promisePool.query( "SELECT * FROM users WHERE username = ?", [username], ); // @ts-ignore return rows[0]; } catch (error) { return null; } }, async createUser(username: string) { const id = uuidv4(); await promisePool.query("INSERT INTO users (id, username) VALUES (?, ?)", [ id, username, ]); return { id, username }; }, };
credentialService.ts
appare come segue:
credentialService.tsimport { promisePool } from "../database"; import type { AuthenticatorDevice } from "@simplewebauthn/typescript-types"; export const credentialService = { async saveNewCredential( userId: string, credentialId: string, publicKey: string, counter: number, transports: string, ) { try { await promisePool.query( "INSERT INTO credentials (user_id, credential_id, public_key, counter, transports) VALUES (?, ?, ?, ?, ?)", [userId, credentialId, publicKey, counter, transports], ); } catch (error) { console.error("Error saving new credential:", error); throw error; } }, async getCredentialByCredentialId( credentialId: string, ): Promise<AuthenticatorDevice | null> { try { const [rows] = await promisePool.query( "SELECT * FROM credentials WHERE credential_id = ? LIMIT 1", [credentialId], ); // @ts-ignore if (rows.length === 0) return null; // @ts-ignore const row = rows[0]; return { userID: row.user_id, credentialID: row.credential_id, credentialPublicKey: row.public_key, counter: row.counter, transports: row.transports ? row.transports.split(",") : [], } as AuthenticatorDevice; } catch (error) { console.error("Error retrieving credential:", error); throw error; } }, async updateCredentialCounter(credentialId: string, newCounter: number) { try { await promisePool.query( "UPDATE credentials SET counter = ? WHERE credential_id = ?", [newCounter, credentialId], ); } catch (error) { console.error("Error updating credential counter:", error); throw error; } }, };
Per gestire gli errori centralmente e anche per facilitare il debug, aggiungiamo un file
errorHandler.ts
:
errorHandler.tsimport { Request, Response, NextFunction } from "express"; import { CustomError } from "./customError"; interface ErrorWithStatus extends Error { statusCode?: number; } export const handleError = ( err: CustomError, req: Request, res: Response, next: NextFunction, ) => { const statusCode = err.statusCode || 500; const message = err.message || "Internal Server Error"; console.log(message); res.status(statusCode).send({ error: message }); };
Inoltre, aggiungiamo un nuovo file customError.ts
poiché in seguito vorremo essere in
grado di creare errori personalizzati per aiutarci a trovare i bug più rapidamente:
customError.tsexport class CustomError extends Error { statusCode: number; constructor(message: string, statusCode: number = 500) { super(message); this.statusCode = statusCode; Object.setPrototypeOf(this, CustomError.prototype); } }
Nella cartella utils
, creiamo due file constants.ts
e utils.ts
.
constant.ts
contiene alcune informazioni di base del server WebAuthn, come il nome della
relying party, l'ID della
relying party e l'origine:
constant.tsexport const rpName: string = "Passkeys Tutorial"; export const rpID: string = "localhost"; export const origin: string = `http://${rpID}:8080`;
utils.ts
contiene due funzioni che ci serviranno in seguito per la codifica e la
decodifica dei dati:
utils.tsexport const uint8ArrayToBase64 = (uint8Array: Uint8Array): string => Buffer.from(uint8Array).toString("base64"); export const base64ToUint8Array = (base64: string): Uint8Array => new Uint8Array(Buffer.from(base64, "base64"));
Ora, arriviamo al cuore del nostro backend: i controller. Creiamo due controller, uno per
la creazione di una nuova passkey (registration.ts
) e uno per l'accesso con una passkey
(authentication.ts
).
registration.ts
appare così:
registration.tsimport { generateRegistrationOptions, verifyRegistrationResponse, } from "@simplewebauthn/server"; import { uint8ArrayToBase64 } from "../utils/utils"; import { rpName, rpID, origin } from "../utils/constants"; import { credentialService } from "../services/credentialService"; import { userService } from "../services/userService"; import { RegistrationResponseJSON } from "@simplewebauthn/typescript-types"; import { Request, Response, NextFunction } from "express"; import { CustomError } from "../middleware/customError"; export const handleRegisterStart = async ( req: Request, res: Response, next: NextFunction, ) => { const { username } = req.body; if (!username) { return next(new CustomError("Username empty", 400)); } try { let user = await userService.getUserByUsername(username); if (user) { return next(new CustomError("User already exists", 400)); } else { user = await userService.createUser(username); } const options = await generateRegistrationOptions({ rpName, rpID, userID: user.id, userName: user.username, timeout: 60000, attestationType: "direct", excludeCredentials: [], authenticatorSelection: { residentKey: "preferred", }, // Support for the two most common algorithms: ES256, and RS256 supportedAlgorithmIDs: [-7, -257], }); req.session.loggedInUserId = user.id; req.session.currentChallenge = options.challenge; res.send(options); } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } }; export const handleRegisterFinish = async ( req: Request, res: Response, next: NextFunction, ) => { const { body } = req; const { currentChallenge, loggedInUserId } = req.session; if (!loggedInUserId) { return next(new CustomError("User ID is missing", 400)); } if (!currentChallenge) { return next(new CustomError("Current challenge is missing", 400)); } try { const verification = await verifyRegistrationResponse({ response: body as RegistrationResponseJSON, expectedChallenge: currentChallenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true, }); if (verification.verified && verification.registrationInfo) { const { credentialPublicKey, credentialID, counter } = verification.registrationInfo; await credentialService.saveNewCredential( loggedInUserId, uint8ArrayToBase64(credentialID), uint8ArrayToBase64(credentialPublicKey), counter, body.response.transports, ); res.send({ verified: true }); } else { next(new CustomError("Verification failed", 400)); } } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } finally { req.session.loggedInUserId = undefined; req.session.currentChallenge = undefined; } };
Esaminiamo le funzionalità dei nostri controller, che gestiscono i due endpoint chiave nel processo di registrazione (sign-up) di WebAuthn. È qui che si trova una delle maggiori differenze rispetto all'autenticazione basata su password: per ogni tentativo di registrazione (sign-up) o autenticazione (login), sono necessarie due chiamate API al backend, che richiedono un contenuto specifico del frontend nel mezzo. Le password di solito necessitano di un solo endpoint.
1. Endpoint handleRegisterStart:
Questo endpoint viene attivato dal frontend, ricevendo un nome utente per creare una nuova passkey e un nuovo account. In questo esempio, consentiamo la creazione di un nuovo account/passkey solo se non esiste ancora un account. Nelle applicazioni reali, dovresti gestire questo in modo che agli utenti venga detto che una passkey esiste già e che l'aggiunta dallo stesso dispositivo non è possibile (ma l'utente potrebbe aggiungere passkey da un dispositivo diverso dopo una qualche forma di conferma). Per semplicità, in questo tutorial trascuriamo questo aspetto.
Le PublicKeyCredentialCreationOptions
vengono preparate. residentKey
è impostato su
preferred
e attestationType
su direct
, raccogliendo più dati
dall'authenticator per una potenziale memorizzazione nel
database.
In generale, le PublicKeyCredentialCreationOptions
sono composte dai seguenti dati:
dictionary PublicKeyCredentialCreationOptions { required PublicKeyCredentialRpEntity rp; required PublicKeyCredentialUserEntity user; required BufferSource challenge; required sequence<PublicKeyCredentialParameters> pubKeyCredParams; unsigned long timeout; sequence<PublicKeyCredentialDescriptor> excludeCredentials = []; AuthenticatorSelectionCriteria authenticatorSelection; DOMString attestation = "none"; AuthenticationExtensionsClientInputs extensions; };
rp.name
) e il dominio (rp.id
).user.name
, user.id
e
user.displayName
.L'ID utente e la challenge vengono memorizzati in un oggetto di sessione, semplificando il processo a scopo di tutorial. Inoltre, la sessione viene cancellata dopo ogni tentativo di registrazione (sign-up) o autenticazione (login).
2. Endpoint handleRegisterFinish:
Questo endpoint recupera l'ID utente e la challenge impostati in precedenza. Verifica la
RegistrationResponse
con la challenge. Se valida, memorizza una nuova credenziale per
l'utente. Una volta memorizzati nel database, l'ID utente e la challenge vengono rimossi
dalla sessione.
Suggerimento: durante il debug della tua applicazione, consigliamo vivamente di utilizzare Chrome come browser e le sue funzionalità integrate per migliorare l'esperienza di sviluppo di applicazioni basate su passkey, ad esempio l'authenticator WebAuthn virtuale e il log del dispositivo (vedi i nostri consigli per gli sviluppatori di seguito per maggiori informazioni).
Successivamente, passiamo a authentication.ts
, che ha una struttura e una funzionalità
simili.
authentication.ts
appare così:
authentication.tsimport { Request, Response, NextFunction } from "express"; import { generateAuthenticationOptions, verifyAuthenticationResponse, } from "@simplewebauthn/server"; import { uint8ArrayToBase64, base64ToUint8Array } from "../utils/utils"; import { rpID, origin } from "../utils/constants"; import { credentialService } from "../services/credentialService"; import { userService } from "../services/userService"; import { AuthenticatorDevice } from "@simplewebauthn/typescript-types"; import { isoBase64URL } from "@simplewebauthn/server/helpers"; import { VerifiedAuthenticationResponse, VerifyAuthenticationResponseOpts, } from "@simplewebauthn/server/esm"; import { CustomError } from "../middleware/customError"; export const handleLoginStart = async ( req: Request, res: Response, next: NextFunction, ) => { const { username } = req.body; try { const user = await userService.getUserByUsername(username); if (!user) { return next(new CustomError("User not found", 404)); } req.session.loggedInUserId = user.id; // allowCredentials is purposely for this demo left empty. This causes all existing local credentials // to be displayed for the service instead only the ones the username has registered. const options = await generateAuthenticationOptions({ timeout: 60000, allowCredentials: [], userVerification: "required", rpID, }); req.session.currentChallenge = options.challenge; res.send(options); } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } }; export const handleLoginFinish = async ( req: Request, res: Response, next: NextFunction, ) => { const { body } = req; const { currentChallenge, loggedInUserId } = req.session; if (!loggedInUserId) { return next(new CustomError("User ID is missing", 400)); } if (!currentChallenge) { return next(new CustomError("Current challenge is missing", 400)); } try { const credentialID = isoBase64URL.toBase64(body.rawId); const bodyCredIDBuffer = isoBase64URL.toBuffer(body.rawId); const dbCredential: AuthenticatorDevice | null = await credentialService.getCredentialByCredentialId(credentialID); if (!dbCredential) { return next(new CustomError("Credential not registered with this site", 404)); } // @ts-ignore const user = await userService.getUserById(dbCredential.userID); if (!user) { return next(new CustomError("User not found", 404)); } // @ts-ignore dbCredential.credentialID = base64ToUint8Array(dbCredential.credentialID); // @ts-ignore dbCredential.credentialPublicKey = base64ToUint8Array( dbCredential.credentialPublicKey, ); let verification: VerifiedAuthenticationResponse; const opts: VerifyAuthenticationResponseOpts = { response: body, expectedChallenge: currentChallenge, expectedOrigin: origin, expectedRPID: rpID, authenticator: dbCredential, }; verification = await verifyAuthenticationResponse(opts); const { verified, authenticationInfo } = verification; if (verified) { await credentialService.updateCredentialCounter( uint8ArrayToBase64(bodyCredIDBuffer), authenticationInfo.newCounter, ); res.send({ verified: true }); } else { next(new CustomError("Verification failed", 400)); } } catch (error) { next( error instanceof CustomError ? error : new CustomError("Internal Server Error", 500), ); } finally { req.session.currentChallenge = undefined; req.session.loggedInUserId = undefined; } };
Il nostro processo di autenticazione (login) coinvolge due endpoint:
1. Endpoint handleLoginStart:
Questo endpoint viene attivato quando un utente tenta di accedere. Prima controlla se il nome utente esiste nel database, restituendo un errore se non viene trovato. In uno scenario reale, potresti offrire di creare un nuovo account.
Per gli utenti esistenti, recupera l'ID utente dal database, lo memorizza nella sessione e
genera le opzioni PublicKeyCredentialRequestOptions
. allowCredentials
viene lasciato
vuoto per non limitare l'uso delle credenziali. Ecco perché tutte le passkey disponibili
per questa relying party possono essere selezionate nella finestra modale delle passkey.
La challenge generata viene anche memorizzata nella sessione e le
PublicKeyCredentialRequestOptions
vengono inviate al frontend.
Le PublicKeyCredentialRequestOptions
sono composte dai seguenti dati:
dictionary PublicKeyCredentialRequestOptions { required BufferSource challenge; unsigned long timeout; USVString rpId; sequence<PublicKeyCredentialDescriptor> allowCredentials = []; DOMString userVerification = "preferred"; AuthenticationExtensionsClientInputs extensions; };
2. Endpoint handleLoginFinish:
Questo endpoint recupera currentChallenge
e loggedInUserId
dalla sessione.
Interroga il database per la credenziale corretta utilizzando l'ID della credenziale dal
corpo della richiesta. Se la credenziale viene trovata, significa che l'utente associato a
questo ID di credenziale può ora essere autenticato (loggato). Quindi, possiamo
interrogare l'utente dalla tabella degli utenti tramite l'ID utente che otteniamo dalla
credenziale e verificare la authenticationResponse
utilizzando la challenge e il corpo
della richiesta. Se tutto ha successo, mostriamo il messaggio di successo del login. Se
non viene trovata alcuna credenziale corrispondente, viene inviato un errore.
Inoltre, se la verifica ha successo, il contatore della credenziale viene aggiornato, la challenge utilizzata e il loggedInUserId vengono rimossi dalla sessione.
Inoltre, possiamo eliminare le cartelle src/app
e src/constant
insieme a tutti i file
al loro interno.
Nota: la gestione corretta delle sessioni e la protezione delle route, cruciali nelle applicazioni reali, sono omesse qui per semplicità in questo tutorial.
Infine, ma non meno importante, dobbiamo assicurarci che i nostri controller siano
raggiungibili aggiungendo le route appropriate a routes.ts
che si trova in una nuova
directory src/routes
:
routes.tsimport express from "express"; import { handleError } from "../middleware/errorHandler"; import { handleRegisterStart, handleRegisterFinish } from "../controllers/registration"; import { handleLoginStart, handleLoginFinish } from "../controllers/authentication"; const router = express.Router(); router.post("/registerStart", handleRegisterStart); router.post("/registerFinish", handleRegisterFinish); router.post("/loginStart", handleLoginStart); router.post("/loginFinish", handleLoginFinish); router.use(handleError); export { router };
Questa parte del tutorial sulle passkey si concentra su come supportare le passkey nel
frontend della tua applicazione. Abbiamo un frontend molto basilare composto da tre file:
index.html
, styles.css
e script.js
. Tutti e tre i file si trovano in una nuova
cartella src/public
.
Il file index.html
contiene un campo di input per il nome utente e due pulsanti per
registrarsi e accedere. Inoltre, importiamo lo script @simplewebauthn/browser
che
semplifica l'interazione con l'API di autenticazione web del browser nel file
js/script.js
.
index.html
appare così:
index.html<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Passkey Tutorial</title> <link rel="stylesheet" href="css/style.css" /> </head> <body> <div class="container"> <h1>Passkey Tutorial</h1> <div id="message"></div> <div class="input-group"> <input type="text" id="username" placeholder="Enter username" /> <button id="registerButton">Register</button> <button id="loginButton">Login</button> </div> </div> <script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.es5.umd.min.js"></script> <script src="js/script.js"></script> </body> </html>
script.js
appare come segue:
script.jsdocument.getElementById("registerButton").addEventListener("click", register); document.getElementById("loginButton").addEventListener("click", login); function showMessage(message, isError = false) { const messageElement = document.getElementById("message"); messageElement.textContent = message; messageElement.style.color = isError ? "red" : "green"; } async function register() { // Retrieve the username from the input field const username = document.getElementById("username").value; try { // Get registration options from your server. Here, we also receive the challenge. const response = await fetch("/api/passkey/registerStart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username }), }); console.log(response); // Check if the registration options are ok. if (!response.ok) { throw new Error( "User already exists or failed to get registration options from server", ); } // Convert the registration options to JSON. const options = await response.json(); console.log(options); // This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello). // A new attestation is created. This also means a new public-private-key pair is created. const attestationResponse = await SimpleWebAuthnBrowser.startRegistration(options); // Send attestationResponse back to server for verification and storage. const verificationResponse = await fetch("/api/passkey/registerFinish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(attestationResponse), }); if (verificationResponse.ok) { showMessage("Registration successful"); } else { showMessage("Registration failed", true); } } catch (error) { showMessage("Error: " + error.message, true); } } async function login() { // Retrieve the username from the input field const username = document.getElementById("username").value; try { // Get login options from your server. Here, we also receive the challenge. const response = await fetch("/api/passkey/loginStart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: username }), }); // Check if the login options are ok. if (!response.ok) { throw new Error("Failed to get login options from server"); } // Convert the login options to JSON. const options = await response.json(); console.log(options); // This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello). // A new assertionResponse is created. This also means that the challenge has been signed. const assertionResponse = await SimpleWebAuthnBrowser.startAuthentication(options); // Send assertionResponse back to server for verification. const verificationResponse = await fetch("/api/passkey/loginFinish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(assertionResponse), }); if (verificationResponse.ok) { showMessage("Login successful"); } else { showMessage("Login failed", true); } } catch (error) { showMessage("Error: " + error.message, true); } }
In script.js
, ci sono tre funzioni principali:
1. Funzione showMessage:
Questa è una funzione di utilità utilizzata principalmente per visualizzare messaggi di errore, aiutando nel debug.
2. Funzione Register:
Attivata quando l'utente fa clic su "Register". Estrae il nome utente dal campo di input e
lo invia all'endpoint passkeyRegisterStart
. La risposta include
PublicKeyCredentialCreationOptions
, che vengono convertite in JSON e passate a
SimpleWebAuthnBrowser.startRegistration
. Questa chiamata attiva l'authenticator del
dispositivo (come Face ID o Touch ID). Dopo un'autenticazione locale riuscita, la
challenge firmata viene inviata all'endpoint passkeyRegisterFinish
, completando il
processo di creazione della passkey.
Durante il processo di registrazione (sign-up), l'oggetto di attestazione gioca un ruolo cruciale, quindi diamogli un'occhiata più da vicino.
L'oggetto di attestazione è composto principalmente da tre componenti: fmt
, attStmt
e
authData
. L'elemento fmt
indica il formato della dichiarazione di attestazione, mentre
attStmt
rappresenta la dichiarazione di attestazione stessa. In scenari in cui
l'attestazione non è ritenuta necessaria, fmt
sarà designato come "none", portando a un
attStmt
vuoto.
L'attenzione si concentra sul segmento authData
all'interno di questa struttura. Questo
segmento è fondamentale per recuperare elementi essenziali come l'ID della relying party,
i flag, il contatore e i dati della credenziale attestata sul nostro server. Per quanto
riguarda i flag, di particolare interesse sono BS (Backup State) e BE (Backup Eligibility)
che forniscono maggiori informazioni se una passkey è sincronizzata (ad esempio tramite
iCloud Keychain o
1Password). Inoltre, UV (User
Verification) e UP (User Presence) forniscono informazioni più utili.
È importante notare che varie parti dell'oggetto di attestazione, inclusi i dati dell'authenticator, l'ID della relying party e la dichiarazione di attestazione, sono o sottoposte a hash o firmate digitalmente dall'authenticator utilizzando la sua chiave privata. Questo processo è fondamentale per mantenere l'integrità complessiva dell'oggetto di attestazione.
3. Funzione Login:
Attivata quando l'utente fa clic su "Login". Similmente alla funzione di registrazione,
estrae il nome utente e lo invia all'endpoint passkeyLoginStart
. La risposta, contenente
PublicKeyCredentialRequestOptions
, viene convertita in JSON e utilizzata con
SimpleWebAuthnBrowser.startAuthentication
. Questo attiva l'autenticazione locale sul
dispositivo. La challenge firmata viene quindi inviata all'endpoint passkeyLoginFinish
.
Una risposta positiva da questo endpoint indica che l'utente ha effettuato l'accesso
all'app con successo.
Inoltre, il file CSS di accompagnamento fornisce uno stile semplice per l'applicazione:
body { font-family: "Helvetica Neue", Arial, sans-serif; text-align: center; padding: 40px; background-color: #f3f4f6; color: #333; } .container { max-width: 400px; margin: auto; background: white; padding: 20px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); border-radius: 8px; } h1 { color: #007bff; font-size: 24px; margin-bottom: 20px; } .input-group { margin-bottom: 20px; } input[type="text"] { padding: 10px; margin-bottom: 10px; border: 1px solid #ced4da; border-radius: 4px; width: calc(100% - 22px); } button { width: calc(50% - 20px); padding: 10px 0; margin: 5px; font-size: 16px; cursor: pointer; border: none; border-radius: 4px; background-color: #007bff; color: white; } button:hover { background-color: #0056b3; } #message { color: #dc3545; margin: 20px; }
Per vedere la tua applicazione in azione, compila ed esegui il tuo codice TypeScript con:
npm run dev
Il tuo server dovrebbe ora essere attivo e funzionante su http://localhost:8080.
Considerazioni per la produzione:
Ricorda, ciò che abbiamo trattato è uno schema di base. Quando si distribuisce un'applicazione con passkey in un ambiente di produzione, è necessario approfondire:
Abbiamo già configurato un container Docker per il nostro database. Successivamente,
espanderemo la nostra configurazione di Docker Compose per includere il server con sia il
backend che il frontend. Il tuo file docker-compose.yml
dovrebbe essere aggiornato di
conseguenza.
Per containerizzare la nostra applicazione, creiamo un nuovo Dockerfile che installa i pacchetti richiesti e avvia il server di sviluppo:
Docker# Use an official Node runtime as a parent image FROM node:20-alpine # Set the working directory in the container WORKDIR /usr/src/app # Copy package.json and package-lock.json COPY package*.json ./ # Install any needed packages RUN npm install # Bundle your app's source code inside the Docker image COPY . . # Make port 8080 available to the world outside this container EXPOSE 8080 # Define the command to run your app CMD ["npm", "run", "dev"]
Quindi, estendiamo anche il file docker-compose.yml
per avviare questo container:
docker-compose.ymlversion: "3.1" services: db: image: mysql command: --default-authentication-plugin=mysql_native_password restart: always environment: MYSQL_ROOT_PASSWORD: my-secret-pw MYSQL_DATABASE: webauthn_db ports: - "3306:3306" volumes: - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql app: build: . ports: - "8080:8080" environment: - DB_HOST=db - DB_USER=root - DB_PASSWORD=my-secret-pw - DB_NAME=webauthn_db - SESSION_SECRET=secret123 depends_on: - db
Se ora esegui docker compose up
nel tuo terminale e accedi a
http://localhost:8080, dovresti vedere la versione funzionante
della tua app web con passkey (qui in esecuzione su
Windows 11 23H2 + Chrome 119):
Poiché lavoriamo da un po' di tempo con le implementazioni di passkey, abbiamo incontrato un paio di sfide quando si lavora su app con passkey reali:
Inoltre, abbiamo i seguenti suggerimenti per gli sviluppatori per quanto riguarda la parte di implementazione:
Utilizza il Passkeys Debugger
Il Passkeys debugger aiuta a testare diverse impostazioni del server WebAuthn e le risposte del client. Inoltre, fornisce un ottimo parser per le risposte dell'authenticator.
Esegui il debug con la funzione Device Log di Chrome
Usa il log del dispositivo di Chrome (accessibile tramite chrome://device-log/) per monitorare le chiamate FIDO/WebAuthn. Questa funzione fornisce log in tempo reale del processo di autenticazione (login), consentendoti di vedere i dati scambiati e risolvere eventuali problemi che si presentano.
Un'altra scorciatoia molto utile per ottenere tutte le tue passkey in Chrome è usare chrome://settings/passkeys.
Usa l'Authenticator WebAuthn virtuale di Chrome
Per evitare di utilizzare il prompt di Touch ID, Face ID o Windows Hello durante lo sviluppo, Chrome è dotato di un pratico authenticator WebAuthn virtuale che emula un vero authenticator. Consigliamo vivamente di usarlo per accelerare le cose. Trova maggiori dettagli qui.
Testa su diverse piattaforme e browser
Assicurati la compatibilità e la funzionalità su vari browser e piattaforme. WebAuthn si comporta in modo diverso su browser diversi, quindi un test approfondito è fondamentale.
Testa su dispositivi diversi
Qui è particolarmente utile lavorare con strumenti come ngrok, dove puoi rendere la tua applicazione locale raggiungibile su altri dispositivi (mobili).
Imposta la verifica dell'utente su preferred
Quando si definiscono le proprietà per userVerification
nelle
PublicKeyCredentialRequestOptions
, scegli di impostarle su preferred
poiché questo
è un buon compromesso tra usabilità e sicurezza. Ciò significa che i controlli di
sicurezza sono attivi sui dispositivi idonei, mentre la facilità d'uso viene mantenuta sui
dispositivi senza capacità biometriche.
Speriamo che questo tutorial sulle passkey fornisca una chiara comprensione di come implementare le passkey in modo efficace. Durante il tutorial, abbiamo esaminato i passaggi essenziali per creare un'applicazione con passkey, concentrandoci sui concetti fondamentali e sull'implementazione pratica. Sebbene questa guida serva come punto di partenza, c'è molto altro da esplorare e perfezionare nel mondo di WebAuthn.
Incoraggiamo gli sviluppatori ad approfondire le sfumature delle passkey (ad esempio, aggiungendo più passkey, verificando la prontezza dei dispositivi per le passkey o offrendo soluzioni di recupero). È un viaggio che vale la pena intraprendere, che offre sia sfide che immense ricompense nel migliorare l'autenticazione degli utenti. Con le passkey, non stai solo costruendo una funzionalità; stai contribuendo a un mondo digitale più sicuro e facile da usare.
Enjoyed this read?
🤝 Join our Passkeys Community
Share passkeys implementation tips and get support to free the world from passwords.
🚀 Subscribe to Substack
Get the latest news, strategies, and insights about passkeys sent straight to your inbox.
Related Articles
Table of Contents