Node.jsにおけるパスキーとWebAuthnを用いたパスワードレス認証
Lukas Schneider
DevOps Engineer · Leapcell

はじめに
今日の相互接続されたデジタル世界において、ユーザー認証は機密情報を保護する重要な障壁となっています。何十年もの間、パスワードはユビキタスなゲートキーパーでしたが、依然として重大な脆弱性の源でもあります。弱いパスワード、再利用されたパスワード、あるいは侵害されたパスワードは、データ侵害の主な原因であり、ユーザーと開発者の両方に大きな負担をかけています。ユーザーは複雑な認証情報を記憶するのに苦労し、開発者は堅牢なパスワードポリシーと安全なストレージメカニズムの実装に奮闘しています。
WebAuthn(Web Authentication)の基盤の上に構築されたパスキーの登場は、この長年のジレンマに対する説得力のあるソリューションを提供します。強力な暗号化技術とデバイス固有のセキュリティ機能を活用することで、パスキーは、パスワード関連の不安が過去のものとなる未来を約束します。この記事では、この最先端のパスワードレス認証方法をNode.jsアプリケーションに統合し、セキュリティを強化し、ユーザーエクスペリエンスを向上させ、開発を簡素化する方法について詳しく説明します。
基本概念の解明
実装の詳細に入る前に、パスキーとWebAuthnの根底にあるコアコンセプトを理解することが重要です。
-
WebAuthn(Web Authentication API): これはW3C標準であり、強力で、証明付きで、スコープが定義された、公開鍵ベースの認証情報を作成するためのAPIを定義します。Webアプリケーションが、ユーザー検証のために、認証器(生体認証センサー、ハードウェアセキュリティキー、統合ソフトウェア認証器など)と対話することを可能にします。パスワードを送信する代わりに、WebAuthnは公開鍵の交換を行い、フィッシングやその他の認証情報ベースの攻撃に対して本質的に耐性があります。
-
認証器: 認証器は、暗号鍵を生成・保存し、暗号操作を実行する責任のあるデバイスまたはソフトウェアコンポーネントです。例としては、ラップトップのTPM(Trusted Platform Module)、電話のセキュアエンクレーブ、顔認識システム、指紋スキャナー、または外部FIDO2セキュリティキーが挙げられます。
-
リライングパーティ(RP): WebAuthnの文脈では、リライングパーティは、ユーザーを認証したいWebアプリケーションまたはサービスです。Node.jsバックエンドがリライングパーティサーバーとして機能します。
-
アテステーション(証明): これはオプションですが強力な機能であり、認証器が認証情報作成時にリライングパーティにその真正性と特性の証明を提供します。RPが認証器が本物であり、特定のセキュリティ基準を満たしていることを検証するのに役立ちます。
-
パスキー: パスキーは、WebAuthnの上に構築されたユーザーフレンドリーな抽象化です。本質的には、ユーザーがパスワードなしでWebサイトやアプリケーションにサインインできる、暗号学的に安全な認証情報です。パスキーは通常、ユーザーのデバイス全体(例:iCloud Keychain、Google Password Manager、Microsoft Authenticator経由)で同期され、広くアクセス可能で便利になります。パスキーを使用するときは、公開鍵/秘密鍵ペアを使用しています。秘密鍵はデバイスから決して移動せず、公開鍵のみがサーバーに送信されます。
基本的な原則は公開鍵暗号化を中心に展開します。登録中、ユーザーのデバイス(認証器)は、アプリケーション用に一意の公開鍵/秘密鍵ペアを生成します。公開鍵は安全にNode.jsサーバーに送信され、保存されます。秘密鍵は、ローカル認証メカニズム(PIN、指紋、顔スキャンなど)によって保護されたユーザーのデバイス上に残ります。後続のログインでは、サーバーはランダムなノンスに署名させることで、秘密鍵の所有権を証明するようにクライアントに要求します。署名が有効であれば、ユーザーは認証されます。
パスワードレス認証の構築
Node.jsアプリケーションでのパスキーベースの認証の実装をステップバイステップで見ていきましょう。npm @simplewebauthn
ライブラリという広く使われているライブラリを使用し、基盤となるWebAuthnの複雑さの多くを抽象化します。
まず、必要なパッケージをインストールします。
npm install @simplewebauthn/server @simplewebauthn/browser express body-parser cookie-parser
サーバーサイド実装(Node.js)
Node.jsサーバーは、登録チャレンジの生成、登録応答の検証、認証チャレンジの生成、および認証応答の検証を処理します。
// 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()); // 本番環境では、これをデータベースに保存することになります。 const users = new Map(); // ユーザー詳細を保存するためのMap const authenticators = new Map(); // ユーザーの登録済み認証器を保存するためのMap // リライングパーティ(RP)設定 const rpName = 'My Awesome App'; const rpID = 'localhost'; // 本番環境ではドメイン(例: 'example.com')にする必要があります。 const origin = `http://localhost:${port}`; // ユーザーセッション(簡略化のため、基本的なセッションオブジェクトを使用します) const sessions = new Map(); // userId -> { challenge: string } // 1. 登録チャレンジ生成 app.post('/register/start', async (req, res) => { const { username } = req.body; if (!username) { return res.status(400).json({ message: 'Username is required' }); } let user = users.get(username); if (!user) { user = { id: username, // 簡略化のため、ユーザー名をIDとして使用 username: username, authenticators: [], // このユーザーの登録済み認証器を保存 }; users.set(username, user); authenticators.set(username, []); // このユーザーの認証器を初期化 } try { const options = await generateRegistrationOptions({ rpName, rpID, userID: user.id, userName: user.username, attestationType: 'none', // ほとんどの場合 'none' で十分です authenticatorSelection: { residentKey: 'required', // Discoverable credential(パスキー)を作成することを保証します userVerification: 'preferred', // ユーザー検証を優先します(PIN、生体認証) }, // 再登録を防ぐために、既存の認証器を除外します excludeCredentials: authenticators.get(username).map((auth) => ({ id: auth.credentialID, type: 'public-key', transports: auth.transports, })), }); // 後で検証するために、チャレンジをセッションに保存します sessions.set(user.id, { challenge: options.challenge }); return res.json(options); } catch (error) { console.error('Error generating registration options:', error); return res.status(500).json({ message: 'Failed to start registration' }); } }); // 2. 登録検証 app.post('/register/finish', async (req, res) => { const { username, attResp } = req.body; if (!username || !attResp) { return res.status(400).json({ message: 'Username and attestation response are required' }); } const user = users.get(username); if (!user) { return res.status(404).json({ message: 'User not found' }); } const session = sessions.get(user.id); if (!session || !session.challenge) { return res.status(400).json({ message: 'No active registration session found' }); } try { const verification = await verifyRegistrationResponse({ response: attResp, expectedChallenge: session.challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: false, // 常に生体認証/PINを要求する場合はtrueに設定します }); const { verified, registrationInfo } = verification; if (verified && registrationInfo) { const { credentialPublicKey, credentialID, counter } = registrationInfo; const newAuthenticator = { credentialID: credentialID, credentialPublicKey: credentialPublicKey, counter: counter, // 将来の登録でexcludeCredentialsに重要です transports: attResp.response.transports, }; const userAuthenticators = authenticators.get(username); userAuthenticators.push(newAuthenticator); authenticators.set(username, userAuthenticators); sessions.delete(user.id); // チャレンジをクリア return res.json({ verified: true, message: 'Registration successful!' }); } else { return res.status(400).json({ verified: false, message: 'Registration failed' }); } } catch (error) { console.error('Error verifying registration response:', error); return res.status(500).json({ message: 'Failed to finish registration' }); } }); // 3. 認証チャレンジ生成 app.post('/login/start', async (req, res) => { const { username } = req.body; // パスキーがdiscoverableな場合は省略可能 let userAuthenticators = []; if (username) { userAuthenticators = authenticators.get(username) || []; } // ユーザー名が提供されない場合、ブラウザはユーザー名プロンプトを表示できます。 // あるいは、discoverable credentialが使用される場合、ユーザーはリストから選択します。 try { const options = await generateAuthenticationOptions({ rpID, // ユーザー名が提供された場合、既知の認証器と照合します allowCredentials: userAuthenticators.map((auth) => ({ id: auth.credentialID, type: 'public-key', transports: auth.transports, })), userVerification: 'preferred', timeout: 60000, }); // 検証のためにチャレンジを保存します sessions.set(username || 'anonymous_login', { challenge: options.challenge }); return res.json(options); } catch (error) { console.error('Error generating authentication options:', error); return res.status(500).json({ message: 'Failed to start login' }); } }); // 4. 認証検証 app.post('/login/finish', async (req, res) => { const { username, authnResp } = req.body; if (!authentnResp) { return res.status(400).json({ message: 'Authentication response is required' }); } // discoverable credentialの場合、認証器はcredential IDを返すことがあります。 // これにより、ユーザーを見つけることができます。 let user; let registeredAuthenticator; // ログインに使用された認証器を見つけます 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 not found for user' }); } const sessionKey = user.id; // セッションキーにはユーザーIDを使用 const session = sessions.get(sessionKey); if (!session || !session.challenge) { return res.status(400).json({ message: 'No active authentication session found' }); }gggg try { const verification = await verifyAuthenticationResponse({ response: authnResp, expectedChallenge: session.challenge, expectedOrigin: origin, expectedRPID: rpID, authenticator: registeredAuthenticator, requireUserVerification: false, }); const { verified, authenticationInfo } = verification; if (verified) { // リプレイ攻撃を防ぐため、認証器のカウンターを更新します registeredAuthenticator.counter = authenticationInfo.newAuthenticatorInfo.counter; // 本番環境では、これをデータベースで更新します sessions.delete(sessionKey); // チャレンジをクリア // ユーザーセッションを確立します(例:Cookieを設定) res.cookie('loggedInUser', user.id, { httpOnly: true, secure: false }); // 本番環境では secure: true を使用します return res.json({ verified: true, message: 'Authentication successful!', userId: user.id }); } else { return res.status(400).json({ verified: false, message: 'Authentication failed' }); } } catch (error) { console.error('Error verifying authentication response:', error); return res.status(500).json({ message: 'Failed to finish login' }); } }); app.get('/dashboard', (req, res) => { const userId = req.cookies.loggedInUser; if (userId) { return res.send(`Welcome to your dashboard, ${userId}!`); } res.status(401).send('Unauthorized'); }); // クライアントサイドの操作のための基本的なHTMLページを提供します app.get('/', (req, res) => { res.sendFile(__dirname + '/index.html'); }); app.listen(port, () => { console.log(`Server listening at http://localhost:${port}`); });
クライアントサイド実装(HTML/JavaScript)
クライアントサイドコードは、@simplewebauthn/browser
を使用してブラウザのWebAuthn APIと対話します。
<!-- 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 Authentication Demo</h1> <div> <h2>Register (Create a Passkey)</h2> <input type="text" id="regUsername" placeholder="Enter username"> <button onclick="startRegistration()">Register</button> <p id="regMessage"></p> </div> <div> <h2>Login (Use your Passkey)</h2> <input type="text" id="loginUsername" placeholder="Enter username (optional for discoverable passkeys)"> <button onclick="startLogin()">Login</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 = 'Please enter a username for registration.'; return; } try { // 1. サーバーから登録オプションを取得 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 = `Error: ${options.message}`; return; } // 2. ブラウザに認証情報を作成するように依頼 const attestationResponse = await startRegistration(options); // 3. 認証情報の検証のためにサーバーに送信 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 = 'Registration successful! You can now log in.'; regUsernameInput.value = ''; } else { regMessage.textContent = `Registration failed: ${result.message}`; } } catch (error) { console.error('Registration error:', error); regMessage.textContent = `Registration failed: ${error.message}`; } }; window.startLogin = async () => { const username = loginUsernameInput.value; // discoverable passkeysの場合オプション try { // 1. サーバーから認証オプションを取得 const resp = await fetch('/login/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }), // 提供された場合はユーザー名を送信 }); const options = await resp.json(); if (resp.status !== 200) { loginMessage.textContent = `Error: ${options.message}`; return; } // 2. ブラウザに認証情報を使用して認証するように依頼 const assertionResponse = await startAuthentication(options); // 3. 認証情報の検証のためにサーバーに送信 const verificationResp = await fetch('/login/finish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.JSON.stringify({ username, authnResp: assertionResponse }), }); const result = await verificationResp.json(); if (result.verified) { loginMessage.textContent = `Login successful! Welcome, ${result.userId}!`; loginUsernameInput.value = ''; // ログイン状態を表示するためにリダイレクトまたはUIを更新します window.location.href = '/dashboard'; } else { loginMessage.textContent = `Login failed: ${result.message}`; } } catch (error) { console.error('Login error:', error); loginMessage.textContent = `Login failed: ${error.message}`; } }; </script> </body> </html>
デモの実行
- Node.jsコードを
server.js
として、HTMLコードをindex.html
として、同じディレクトリに保存します。 - ターミナルから
node server.js
を実行します。 - Webブラウザで
http://localhost:3000
を開きます。
これで、新しいユーザーをパスキー(デバイスの生体認証またはPINを使用する場合があります)で登録し、そのパスキーを使用してログインできるようになります。
主要な側面と考慮事項:
- Discoverable Credentials(Resident Keys): 登録中に
residentKey: 'required'
を設定することにより、認証器に、リライングパーティがユーザー名のヒントなしに検出できるパスキーを作成するように指示します。これにより、ユーザーは「ログイン」をクリックしてアカウントを選択するだけでよい、真のパスワードレスログインエクスペリエンスが可能になります。 - ユーザー検証:
userVerification: 'preferred'
(または'required'
)は、秘密鍵が使用される前に、認証器上でローカルに(例:指紋、顔認識、またはPIN経由で)ユーザーの存在と同意が検証されることを保証します。これは、セキュリティの強力なレイヤーを追加します。 - 認証情報ストレージ: 本番環境では、
users
およびauthenticators
マップは安全なデータベースに置き換えられます。各認証器のcredentialID
、credentialPublicKey
、およびcounter
は非常に重要であり、確実に保存する必要があります。 - カウンターのインクリメント: 認証中に返される
counter
値は、その認証器の以前に保存されたcounter
よりも厳密に大きいことをサーバーが検証する必要があります。これはリプレイ攻撃を防ぎます。 - クロスデバイス同期: パスキーは、互換性のあるパスワードマネージャー(例:Apple Keychain、Google Password Manager)を使用している場合、ユーザーのデバイス全体で自動的に同期され、エコシステム全体でシームレスなエクスペリエンスを提供します。
- セキュリティベストプラクティス:
- 本番環境では、すべてのWebAuthn通信には必ずHTTPSを使用してください。
rpID
とorigin
が本番ドメインと一致していることを確認してください。- サーバーの秘密鍵とシークレットを保護してください。
- 正常な認証後の堅牢なセッション管理を実装してください。
結論
WebAuthnによって強化されたパスキーは、ユーザー認証における記念碑的な飛躍を表しています。最も弱いリンクであるパスワードを排除することにより、フィッシング、クレデンシャルスタッフィング、およびその他の一般的な攻撃に対して優れたセキュリティを提供すると同時に、ユーザーにとって大幅にスムーズでパスワードレスなエクスペリエンスを提供します。
@simplewebauthn
のようなライブラリのおかげで、Node.jsアプリケーションへのパスキーの統合は、現在、合理化されたプロセスになっており、より安全でユーザーフレンドリーなデジタル未来への道を開いています。
パスキーを採用して、堅牢に安全であるだけでなく、使用するのも楽しいアプリケーションを構築してください。