Passwortlose Authentifizierung in Node.js mit Passkeys und WebAuthn
Lukas Schneider
DevOps Engineer · Leapcell

Einleitung
In der heutigen vernetzten digitalen Welt bildet die Benutzerauthentifizierung eine kritische Barriere, die sensible Informationen schützt. Seit Jahrzehnten sind Passwörter die allgegenwärtigen Gatekeeper, doch sie bleiben auch eine signifikante Schwachstelle. Schwache, wiederverwendete oder kompromittierte Passwörter sind eine Hauptursache für Datenlecks, was sowohl für Benutzer als auch für Entwickler eine erhebliche Belastung darstellt. Benutzer kämpfen damit, sich komplexe Anmeldedaten zu merken, während Entwickler bestrebt sind, robuste Passwortrichtlinien und sichere Speicherungsmechanismen zu implementieren.
Das Aufkommen von Passkeys, die auf dem Fundament von WebAuthn (Web Authentication) aufbauen, bietet eine überzeugende Lösung für dieses langjährige Dilemma. Durch die Nutzung starker kryptografischer Techniken und gerätespezifischer Sicherheitsfunktionen versprechen Passkeys eine Zukunft, in der passwortbezogene Ängste der Vergangenheit angehören. Dieser Artikel befasst sich damit, wie wir diese hochmoderne, passwortlose Authentifizierungsmethode in Node.js-Anwendungen integrieren können, um die Sicherheit zu erhöhen, die Benutzererfahrung zu verbessern und die Entwicklung zu vereinfachen.
Die Grundlagen entschlüsseln
Bevor wir uns mit den Implementierungsdetails befassen, ist es entscheidend, die Kernkonzepte zu verstehen, die Passkeys und WebAuthn untermauern.
- WebAuthn (Web Authentication API): Dies ist ein W3C-Standard, der eine API zur Erstellung starker, attestierter, bereichsbezogener und auf öffentlichen Schlüsseln basierender Anmeldedaten definiert. Er ermöglicht es Webanwendungen, mit Authentifikatoren (wie biometrischen Sensoren, Hardware-Sicherheitsschlüsseln oder integrierten Software-Authentifikatoren) zur Benutzerüberprüfung zu interagieren. Anstatt Passwörter zu übertragen, tauscht WebAuthn kryptografische Schlüssel aus, was es von Natur aus resistent gegen Phishing und andere auf Anmeldedaten basierende Angriffe macht.
- Authenticator: Ein Authentikator ist ein Gerät oder eine Softwarekomponente, die für die Generierung und Speicherung kryptografischer Schlüssel sowie für die Durchführung kryptografischer Operationen verantwortlich ist. Beispiele hierfür sind das TPM (Trusted Platform Module) eines Laptops, die Secure Enclave eines Telefons, Gesichtserkennungssysteme, Fingerabdruckscanner oder externe FIDO2-Sicherheitsschlüssel.
- Relying Party (RP): Im WebAuthn-Kontext ist die Relying Party Ihre Webanwendung oder Ihr Dienst, der einen Benutzer authentifizieren möchte. Ihr Node.js-Backend fungiert als Server der Relying Party.
- Attestation: Dies ist eine optionale, aber leistungsstarke Funktion, bei der der Authentikator während der Erstellung von Anmeldedaten einen Nachweis seiner Authentizität und seiner Eigenschaften an die Relying Party liefert. Sie hilft der RP zu überprüfen, ob der Authentikator echt ist und bestimmte Sicherheitsstandards erfüllt.
- Passkey: Ein Passkey ist eine benutzerfreundliche Abstraktion, die auf WebAuthn aufbaut. Es handelt sich im Wesentlichen um eine kryptografisch sichere Anmeldeinformation, die es Benutzern ermöglicht, sich bei Websites und Anwendungen ohne Passwort anzumelden. Passkeys werden typischerweise geräteübergreifend synchronisiert (z. B. über iCloud Keychain, Google Password Manager oder Microsoft Authenticator), wodurch sie weit verbreitet und praktisch sind. Wenn Sie einen Passkey verwenden, verwenden Sie ein öffentliches/privates Schlüsselpaar. Der private Schlüssel verlässt niemals Ihr Gerät, und nur der öffentliche Schlüssel wird an den Server gesendet.
Das Grundprinzip dreht sich um die Public-Key-Kryptografie. Während der Registrierung generiert das Gerät des Benutzers (Authenticator) ein eindeutiges öffentliches/privates Schlüsselpaar für Ihre Anwendung. Der öffentliche Schlüssel wird sicher an Ihren Node.js-Server gesendet und dort gespeichert. Der private Schlüssel verbleibt auf dem Gerät des Benutzers, geschützt durch einen lokalen Authentifizierungsmechanismus (wie eine PIN, einen Fingerabdruck oder eine Gesichtsscannung). Für nachfolgende Anmeldungen fordert der Server den Client auf, den Besitz des privaten Schlüssels durch Signieren einer zufälligen Nonce nachzuweisen. Wenn die Signatur gültig ist, wird der Benutzer authentifiziert.
Aufbau passwortloser Authentifizierung
Lassen Sie uns die Implementierung der Passkey-basierten Authentifizierung in einer Node.js-Anwendung durchgehen. Wir verwenden die beliebten npm @simplewebauthn
-Bibliotheken, die einen Großteil der zugrunde liegenden WebAuthn-Komplexität abstrahieren.
Installieren Sie zuerst die erforderlichen Pakete:
npm install @simplewebauthn/server @simplewebauthn/browser express body-parser cookie-parser
Serverseitige Implementierung (Node.js)
Unser Node.js-Server ist für die Generierung von Registrierungsaufforderungen, die Überprüfung von Registrierungsimplementierungen, die Generierung von Authentifizierungsaufforderungen und die Überprüfung von Authentifizierungsimplementierungen zuständig.
// server.js const express = require('express'); const bodyParser = require('body-parser'); const cookieParser = require('cookie-parser'); const { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse, } = require('@simplewebauthn/server'); const app = express(); const port = 3000; app.use(bodyParser.json()); app.use(cookieParser()); // In einer echten Anwendung würden Sie dies in einer Datenbank speichern const users = new Map(); // Map zur Speicherung von Benutzerdetails const authenticators = new Map(); // Map zur Speicherung der registrierten Authentifikatoren des Benutzers // Relying Party (RP) Konfiguration const rpName = 'My Awesome App'; const rpID = 'localhost'; // Sollte in der Produktion Ihre Domain sein (z.B. 'example.com') const origin = `http://localhost:${port}`; // Benutzersitzung (vereinfacht, wir verwenden ein Basis-Sitzungsobjekt) const sessions = new Map(); // userId -> { challenge: string } // 1. Generierung der Registrierungsaufforderung app.post('/register/start', async (req, res) => { const { username } = req.body; if (!username) { return res.status(400).json({ message: 'Benutzername ist erforderlich' }); } let user = users.get(username); if (!user) { user = { id: username, // Vereinfacht, Verwendung des Benutzernamens als ID username: username, authenticators: [], // Speicherung der registrierten Authentifikatoren dieses Benutzers }; users.set(username, user); authenticators.set(username, []); // Initialisierung der Authentifikatoren für diesen Benutzer } try { const options = await generateRegistrationOptions({ rpName, rpID, userID: user.id, userName: user.username, attestationType: 'none', // 'none' ist für die meisten Fälle gut authenticatorSelection: { residentKey: 'required', // Sicherstellen, dass der Authentifikator ein entdeckbares Zertifikat (Passkey) erstellt userVerification: 'preferred', // Benutzerüberprüfung bevorzugen (PIN, Biometrie) }, // Vorhandene Authentifikatoren ausschließen, um eine erneute Registrierung zu verhindern excludeCredentials: authenticators.get(username).map((auth) => ({ id: auth.credentialID, type: 'public-key', transports: auth.transports, })), }); // Speichert die Aufforderung in der Sitzung zur späteren Überprüfung sessions.set(user.id, { challenge: options.challenge }); return res.json(options); } catch (error) { console.error('Fehler beim Generieren von Registrierungsoptionen:', error); return res.status(500).json({ message: 'Registrierung konnte nicht gestartet werden' }); } }); // 2. Überprüfung der Registrierung app.post('/register/finish', async (req, res) => { const { username, attResp } = req.body; if (!username || !attResp) { return res.status(400).json({ message: 'Benutzername und Attestierungsantwort sind erforderlich' }); } const user = users.get(username); if (!user) { return res.status(404).json({ message: 'Benutzer nicht gefunden' }); } const session = sessions.get(user.id); if (!session || !session.challenge) { return res.status(400).json({ message: 'Keine aktive Registrierungssitzung gefunden' }); } try { const verification = await verifyRegistrationResponse({ response: attResp, expectedChallenge: session.challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: false, // Auf true setzen, wenn immer Biometrie/PIN erforderlich ist }); const { verified, registrationInfo } = verification; if (verified && registrationInfo) { const { credentialPublicKey, credentialID, counter } = registrationInfo; const newAuthenticator = { credentialID: credentialID, credentialPublicKey: credentialPublicKey, counter: counter, // Diese sind wichtig für excludeCredentials bei zukünftigen Registrierungen transports: attResp.response.transports, }; const userAuthenticators = authenticators.get(username); userAuthenticators.push(newAuthenticator); authenticators.set(username, userAuthenticators); sessions.delete(user.id); // Die Aufforderung löschen return res.json({ verified: true, message: 'Registrierung erfolgreich!' }); } else { return res.status(400).json({ verified: false, message: 'Registrierung fehlgeschlagen' }); } } catch (error) { console.error('Fehler bei der Überprüfung der Registrierungsantwort:', error); return res.status(500).json({ message: 'Registrierung konnte nicht abgeschlossen werden' }); } }); // 3. Generierung der Authentifizierungsaufforderung app.post('/login/start', async (req, res) => { const { username } = req.body; // Kann weggelassen werden, wenn Passkeys entdeckbar sind let userAuthenticators = []; if (username) { userAuthenticators = authenticators.get(username) || []; } // Wenn kein Benutzername angegeben ist, kann der Browser dazu auffordern // Oder, wenn entdeckbare Anmeldedaten verwendet werden, wählt der Benutzer aus einer Liste try { const options = await generateAuthenticationOptions({ rpID, // Wenn ein Benutzername angegeben ist, mit bekannten Anmeldedaten abgleichen allowCredentials: userAuthenticators.map((auth) => ({ id: auth.credentialID, type: 'public-key', transports: auth.transports, })), userVerification: 'preferred', timeout: 60000, }); // Speichert die Aufforderung zur Überprüfung sessions.set(username || 'anonymous_login', { challenge: options.challenge }); return res.json(options); } catch (error) { console.error('Fehler beim Generieren von Authentifizierungsoptionen:', error); return res.status(500).json({ message: 'Login konnte nicht gestartet werden' }); } }); // 4. Überprüfung der Authentifizierung app.post('/login/finish', async (req, res) => { const { username, authnResp } = req.body; if (!authentnResp) { return res.status(400).json({ message: 'Authentifizierungsantwort ist erforderlich' }); } // Bei entdeckbaren Anmeldedaten kann der Authenticator die Anmelde-ID zurücksenden, // die es uns ermöglicht, den Benutzer zu finden. let user; let registeredAuthenticator; // Den für die Anmeldung verwendeten Authentifikator finden let foundAuth = false; for (const [key, auths] of authenticators.entries()) { registeredAuthenticator = auths.find( (auth) => auth.credentialID.toString('base64url') === authnResp.rawId ); if (registeredAuthenticator) { user = users.get(key); foundAuth = true; break; } } if (!foundAuth || !user) { return res.status(400).json({ verified: false, message: 'Authenticator nicht für Benutzer gefunden' }); } const sessionKey = user.id; // Benutzer-ID für den Sitzungsschlüssel verwenden const session = sessions.get(sessionKey); if (!session || !session.challenge) { return res.status(400).json({ message: 'Keine aktive Authentifizierungssitzung gefunden' }); } try { const verification = await verifyAuthenticationResponse({ response: authnResp, expectedChallenge: session.challenge, expectedOrigin: origin, expectedRPID: rpID, authenticator: registeredAuthenticator, requireUserVerification: false, }); const { verified, authenticationInfo } = verification; if (verified) { // Aktualisieren Sie den Zähler des Authentifikators, um Replay-Angriffe zu verhindern registeredAuthenticator.counter = authenticationInfo.newAuthenticatorInfo.counter; // In einer echten App würden Sie dies in Ihrer Datenbank aktualisieren sessions.delete(sessionKey); // Die Aufforderung löschen // Benutzersitzung einrichten (z.B. ein Cookie setzen) res.cookie('loggedInUser', user.id, { httpOnly: true, secure: false }); // secure: true in Produktion verwenden return res.json({ verified: true, message: 'Authentifizierung erfolgreich!', userId: user.id }); } else { return res.status(400).json({ verified: false, message: 'Authentifizierung fehlgeschlagen' }); } } catch (error) { console.error('Fehler bei der Überprüfung der Authentifizierungsantwort:', error); return res.status(500).json({ message: 'Login konnte nicht abgeschlossen werden' }); } }); app.get('/dashboard', (req, res) => { const userId = req.cookies.loggedInUser; if (userId) { return res.send(`Willkommen in Ihrem Dashboard, ${userId}!`) } res.status(401).send('Nicht autorisiert'); }); // Eine einfache HTML-Seite für die clientseitige Interaktion bereitstellen app.get('/', (req, res) => { res.sendFile(__dirname + '/index.html'); }); app.listen(port, () => { console.log(`Server läuft unter http://localhost:${port}`); });
Clientseitige Implementierung (HTML/JavaScript)
Der clientseitige Code interagiert über die WebAuthn-API des Browsers mit Hilfe von @simplewebauthn/browser
.
<!-- 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 Demo</title> <style> body { font-family: sans-serif; margin: 20px; } div { margin-bottom: 10px; } input { padding: 8px; margin-right: 5px; } button { padding: 10px 15px; cursor: pointer; } </style> </head> <body> <h1>Passkey Authentifizierungsdemo</h1> <div> <h2>Registrieren (Passkey erstellen)</h2> <input type="text" id="regUsername" placeholder="Benutzernamen eingeben"> <button onclick="startRegistration()">Registrieren</button> <p id="regMessage"></p> </div> <div> <h2>Anmelden (Passkey verwenden)</h2> <input type="text" id="loginUsername" placeholder="Benutzernamen eingeben (optional für entdeckbare Passkeys)"> <button onclick="startLogin()">Anmelden</button> <p id="loginMessage"></p> </div> <script type="module"> import { startRegistration, startAuthentication } from 'https://unpkg.com/@simplewebauthn/browser@latest/dist/bundle/index.mjs'; const regUsernameInput = document.getElementById('regUsername'); const regMessage = document.getElementById('regMessage'); const loginUsernameInput = document.getElementById('loginUsername'); const loginMessage = document.getElementById('loginMessage'); window.startRegistration = async () => { const username = regUsernameInput.value; if (!username) { regMessage.textContent = 'Bitte geben Sie einen Benutzernamen für die Registrierung ein.'; return; } try { // 1. Registrierungsoptionen vom Server abrufen const resp = await fetch('/register/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }), }); const options = await resp.json(); if (resp.status !== 200) { regMessage.textContent = `Fehler: ${options.message}`; return; } // 2. Den Browser bitten, eine Anmeldeinformation zu erstellen const attestationResponse = await startRegistration(options); // 3. Die Anmeldeinformation zur Überprüfung an den Server zurücksenden const verificationResp = await fetch('/register/finish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, attResp: attestationResponse }), }); const result = await verificationResp.json(); if (result.verified) { regMessage.textContent = 'Registrierung erfolgreich! Sie können sich jetzt anmelden.'; regUsernameInput.value = ''; } else { regMessage.textContent = `Registrierung fehlgeschlagen: ${result.message}`; } } catch (error) { console.error('Registrierungsfehler:', error); regMessage.textContent = `Registrierung fehlgeschlagen: ${error.message}`; } }; window.startLogin = async () => { const username = loginUsernameInput.value; // Optional für entdeckbare Passkeys try { // 1. Authentifizierungsoptionen vom Server abrufen const resp = await fetch('/login/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }), // Benutzernamen senden, falls angegeben }); const options = await resp.json(); if (resp.status !== 200) { loginMessage.textContent = `Fehler: ${options.message}`; return; } // 2. Den Browser bitten, sich mit einer Anmeldeinformation zu authentifizieren const assertionResponse = await startAuthentication(options); // 3. Die Assertion zur Überprüfung an den Server zurücksenden const verificationResp = await fetch('/login/finish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, authnResp: assertionResponse }), }); const result = await verificationResp.json(); if (result.verified) { loginMessage.textContent = `Anmeldung erfolgreich! Willkommen, ${result.userId}!`; loginUsernameInput.value = ''; // Zur Seite weiterleiten oder den angemeldeten Zustand anzeigen window.location.href = '/dashboard'; } else { loginMessage.textContent = `Anmeldung fehlgeschlagen: ${result.message}`; } } catch (error) { console.error('Anmeldungsfehler:', error); loginMessage.textContent = `Anmeldung fehlgeschlagen: ${error.message}`; } }; </script> </body> </html>
Demo ausführen
- Speichern Sie den Node.js-Code als
server.js
und den HTML-Code alsindex.html
im selben Verzeichnis. - Führen Sie
node server.js
im Terminal aus. - Öffnen Sie
http://localhost:3000
in Ihrem Webbrowser.
Sie können nun versuchen, einen neuen Benutzer mit einem Passkey zu registrieren (der möglicherweise die Biometrie oder PIN Ihres Geräts verwendet) und sich dann mit diesem Passkey anzumelden.
Wichtige Aspekte und Überlegungen:
- Entdeckbare Anmeldedaten (Resident Keys): Durch das Setzen von
residentKey: 'required'
während der Registrierung weisen wir den Authentifikator an, einen Passkey zu erstellen, der von der Relying Party entdeckt werden kann, ohne dass ein Benutzernamehinweis erforderlich ist. Dies ermöglicht echte passwortlose Anmeldeerlebnisse, bei denen der Benutzer nur auf "Anmelden" klickt und sein Konto auswählt. - Benutzerüberprüfung:
userVerification: 'preferred'
(oder'required'
) stellt sicher, dass die Anwesenheit und Zustimmung des Benutzers lokal auf dem Authentifikator überprüft wird (z. B. per Fingerabdruck, Gesichtserkennung oder PIN), bevor der private Schlüssel verwendet wird. Dies fügt eine weitere starke Sicherheitsebene hinzu. - Speicherung von Anmeldedaten: In einer Produktionsumgebung würden die
users
- undauthenticators
-Maps durch eine sichere Datenbank ersetzt werden. DiecredentialID
,credentialPublicKey
und dercounter
jedes Authentifikators sind entscheidend und müssen zuverlässig gespeichert werden. - Inkrementierung des Zählers: Der während der Authentifizierung zurückgegebene
counter
-Wert muss vom Server überprüft werden, um sicherzustellen, dass er strikt größer ist als der zuvor für diesen Authentifikator gespeichertecounter
. Dies verhindert Replay-Angriffe. - Geräteübergreifende Synchronisierung: Passkeys werden automatisch über die Geräte eines Benutzers synchronisiert, wenn dieser einen kompatiblen Passwortmanager verwendet (z. B. Apple Keychain, Google Password Manager), was ein nahtloses Erlebnis über sein Ökosystem hinweg bietet.
- Sicherheitsbestpraktiken:
- Verwenden Sie in der Produktion immer HTTPS für die gesamte WebAuthn-Kommunikation.
- Stellen Sie sicher, dass Ihre
rpID
undorigin
mit Ihrer Produktionsdomäne übereinstimmen. - Schützen Sie die privaten Schlüssel und Geheimnisse Ihres Servers.
- Implementieren Sie eine robuste Sitzungsverwaltung nach erfolgreicher Authentifizierung.
Fazit
Passkeys, angetrieben von WebAuthn, stellen einen monumentalen Fortschritt in der Benutzerauthentifizierung dar. Indem sie das schwächste Glied – Passwörter – eliminieren, bieten sie überragende Sicherheit gegen Phishing, Credential Stuffing und andere gängige Angriffe und bieten gleichzeitig ein erheblich reibungsloseres, passwortloses Erlebnis für Benutzer. Die Integration von Passkeys in Node.js-Anwendungen ist dank Bibliotheken wie @simplewebauthn
nun ein optimierter Prozess und ebnet den Weg für eine sicherere und benutzerfreundlichere digitale Zukunft. Nutzen Sie Passkeys, um Anwendungen zu erstellen, die nicht nur robust sicher sind, sondern auch Freude bereiten bei der Nutzung.