Passkeys実装:10年目のエンジニアが語る

Web・アプリ開発

パスワードレス認証(Passkeys)実装:10年目のエンジニアが語る

パスワード、もうウンザリですよね?ユーザーは覚えきれない複雑なパスワードに苦しみ、開発者は漏洩リスクに常に怯える。そんな状況を打破するのがPasskeysです。この記事では、私が10年以上の現場経験を通して培ってきたPasskeysの実装ノウハウを、惜しみなく公開します。単なる技術解説ではなく、実際の現場で直面する課題、アンチパターン、そしてそれを乗り越えるための実践的なコード例まで、徹底的に解説します。この記事を読めば、あなたもPasskeysを安全かつ効率的に実装できるようになるでしょう。

この記事で得られる解決策

  • Passkeysの基本的な仕組みと、従来のパスワード認証との違いを理解できる
  • Passkeysを導入する際の技術選定のポイントがわかる
  • Passkeys実装におけるアンチパターンを回避し、安全な実装ができる
  • 実務レベルで使えるPasskeysの実装コード例を入手できる

Passkeysとは? 基本的な仕組み

Passkeysは、公開鍵暗号方式を用いた新しい認証方式です。従来のパスワード認証とは異なり、ユーザーはパスワードを記憶する必要がありません。代わりに、デバイスに保存された秘密鍵と、サーバーに登録された公開鍵のペアを使用します。

  1. ユーザーがウェブサイトやアプリにアクセスしようとすると、サーバーはPasskeyによる認証を要求します。
  2. ユーザーのデバイス(PC、スマートフォンなど)は、保存された秘密鍵を使用して認証リクエストに署名します。
  3. 署名されたリクエストはサーバーに送信され、サーバーは対応する公開鍵を使用して署名を検証します。
  4. 署名が正しければ、認証は成功し、ユーザーはアクセスを許可されます。

この仕組みの最大の特徴は、パスワードがサーバーに保存されないことです。そのため、パスワード漏洩のリスクを大幅に低減できます。

【重要】よくある失敗とアンチパターン

Passkeysの実装は、一見簡単そうに見えますが、いくつかの落とし穴があります。ここでは、初心者がやりがちなアンチパターンとその修正方法を紹介します。

アンチパターン1:クライアントサイドでの秘密鍵の管理不備

Passkeysの秘密鍵は、ユーザーのデバイスに安全に保管されるべきです。しかし、初心者はクライアントサイドのストレージ(localStorageなど)に秘密鍵をそのまま保存してしまうことがあります。これは非常に危険です。LocalStorageはクロスサイトスクリプティング(XSS)攻撃に対して脆弱であり、秘密鍵が漏洩する可能性があります。

修正方法:WebAuthn APIを使用する。WebAuthn APIは、ブラウザに組み込まれた安全な認証メカニズムであり、秘密鍵をハードウェアセキュリティモジュール(HSM)やTPMなどの安全な環境に保管します。

アンチパターン2:サーバーサイドでの検証ロジックの脆弱性

サーバーサイドでの検証ロジックに脆弱性があると、攻撃者が偽のPasskeyを作成して認証を突破できる可能性があります。例えば、署名の検証を正しく行わなかったり、公開鍵の有効性を確認しなかったりするケースです。

修正方法:信頼できるPasskeysライブラリを使用し、サーバーサイドでの検証ロジックを厳密に実装する。また、公開鍵のローテーションを定期的に行い、古い公開鍵を無効化することで、セキュリティリスクを低減できます。

アンチパターン3:フォールバックの実装不足

Passkeysはまだ比較的新しい技術であり、すべてのブラウザやデバイスでサポートされているわけではありません。そのため、Passkeysが利用できない場合に備えて、フォールバック認証を用意する必要があります。例えば、メールアドレスとOTP(ワンタイムパスワード)を使った認証などが考えられます。

修正方法:Passkeysが利用可能かどうかをクライアントサイドで検出し、利用できない場合はフォールバック認証を表示する。また、フォールバック認証のセキュリティ対策も忘れずに行うことが重要です。

【重要】現場で使われる実践的コード・テクニック

ここでは、Node.jsと`@simplewebauthn/server`ライブラリを使用して、Passkeysの登録と認証を行うサンプルコードを紹介します。エラーハンドリングやパフォーマンスを考慮した、実務に近いコードです。

const express = require('express');
const bodyParser = require('body-parser');
const { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse } = require('@simplewebauthn/server');
const { isoBase64URL } = require('@simplewebauthn/server/helpers');

const app = express();
app.use(bodyParser.json());

// ユーザー情報を保存する(本番環境ではDBを使用)
const users = {};

// 登録開始
app.post('/register/begin', async (req, res) => {
  const { username } = req.body;
  if (!username) {
    return res.status(400).json({ error: 'Username is required' });
  }

  const user = users[username] || { id: generateRandomId() };
  users[username] = user;

  const options = await generateRegistrationOptions({
    rpName: 'My Awesome App',
    rpID: 'localhost',
    userID: user.id,
    userName: username,
    userDisplayName: username,
    attestation: 'direct',
    authenticatorSelection: {
        residentKey: 'required',
        userVerification: 'required',
        requireResidentKey: true,
    }
  });

  user.currentChallenge = options.challenge;
  res.json(options);
});

// 登録完了
app.post('/register/complete', async (req, res) => {
  const { username, registrationInfo } = req.body;
  const user = users[username];

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  if (user.currentChallenge !== registrationInfo.challenge) {
    return res.status(400).json({ error: 'Challenge mismatch' });
  }

  try {
    const verification = await verifyRegistrationResponse({
      response: registrationInfo,
      expectedChallenge: user.currentChallenge,
      expectedOrigin: `http://localhost:3000`, // 適切なオリジンを設定
      expectedRPID: 'localhost',
    });

    if (verification.verified) {
      user.verified = true;
      user.credentialID = isoBase64URL.fromBuffer(verification.registrationInfo.credentialID);
      user.publicKey = isoBase64URL.fromBuffer(verification.registrationInfo.credentialPublicKey);
      user.counter = verification.registrationInfo.counter;

      console.log('Registration success:', user);
      res.json({ success: true });
    } else {
      res.status(400).json({ error: 'Registration failed' });
    }
  } catch (error) {
    console.error('Registration verification error:', error);
    res.status(500).json({ error: 'Registration verification failed' });
  }
});

// ログイン開始
app.post('/login/begin', async (req, res) => {
  const { username } = req.body;
  const user = users[username];

  if (!user || !user.publicKey) {
    return res.status(404).json({ error: 'User not found or not registered with Passkey' });
  }

  const options = await generateAuthenticationOptions({
    allowCredentials: [{
        id: isoBase64URL.toBuffer(user.credentialID),
        type: 'public-key'
    }],
    userVerification: 'required'
  });

  user.currentChallenge = options.challenge;
  res.json(options);
});

// ログイン完了
app.post('/login/complete', async (req, res) => {
  const { username, authenticationInfo } = req.body;
  const user = users[username];

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  if (user.currentChallenge !== authenticationInfo.challenge) {
    return res.status(400).json({ error: 'Challenge mismatch' });
  }

  try {
    const verification = await verifyAuthenticationResponse({
      response: authenticationInfo,
      expectedChallenge: user.currentChallenge,
      expectedOrigin: `http://localhost:3000`, // 適切なオリジンを設定
      expectedRPID: 'localhost',
      authenticator: {
        credentialPublicKey: isoBase64URL.toBuffer(user.publicKey),
        credentialID: isoBase64URL.toBuffer(user.credentialID),
        counter: user.counter
      }
    });

    if (verification.verified) {
      user.counter = verification.authenticationInfo.newCounter;
      console.log('Authentication success:', user);
      res.json({ success: true });
    } else {
      res.status(400).json({ error: 'Authentication failed' });
    }
  } catch (error) {
    console.error('Authentication verification error:', error);
    res.status(500).json({ error: 'Authentication verification failed' });
  }
});

function generateRandomId() {
  return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}

const port = 3000;
app.listen(port, () => {
  console.log(`Server listening on port ${port}`);
});

このコードは、エラーハンドリング、チャレンジの検証、オリジンの検証など、本番環境で考慮すべき点を網羅しています。ただし、あくまでサンプルコードですので、本番環境で使用する際は、セキュリティレビューを必ず実施してください。

類似技術との比較

Passkeys以外にも、パスワードレス認証を実現する技術はいくつか存在します。ここでは、代表的な技術との比較を行います。

技術 メリット デメリット
Passkeys パスワード漏洩リスクの低減、フィッシング耐性の向上、ユーザーエクスペリエンスの向上 比較的新しい技術、対応ブラウザ・デバイスが限定的
OTP (One-Time Password) 実装が比較的容易、広く普及している ユーザーがOTPを生成する必要がある、中間者攻撃のリスクがある
Magic Link ユーザーエクスペリエンスの向上、パスワードを覚える必要がない メールアドレスの乗っ取りリスク、メールの遅延・不達のリスク
生体認証 (指紋認証、顔認証) ユーザーエクスペリエンスの向上、セキュリティの向上 生体情報の登録が必要、なりすましのリスクがある

まとめ

Passkeysは、パスワード認証の課題を解決し、より安全で使いやすい認証体験を提供する可能性を秘めた技術です。この記事では、Passkeysの基本的な仕組みから、アンチパターン、実践的なコード例まで、幅広く解説しました。Passkeysの導入は、セキュリティの向上だけでなく、ユーザーエクスペリエンスの向上にも繋がります。ぜひ、この記事を参考に、Passkeysの実装に挑戦してみてください。

コメント

タイトルとURLをコピーしました