WebSocket通信における認証方式の設計と実装

WebSocketはステートフルな全二重通信を提供するため、HTTPのようなリクエスト・レスポンス型の認証フローをそのまま適用できない。接続確立前にクライアントの正当性を検証し、後続の双方向通信を安全に保つための仕組みが必要になる。本稿では、実務でよく使われるWebSocket認証方式を、設計思想と実装例の両面から解説する。

適用シーン

WebSocket認証が特に重要になるのは以下のようなケースである。

  • インスタントメッセージングやチャットアプリ
  • 共同編集ドキュメントやホワイトボード
  • 株価・IoTセンサーータなどのリアルタイムダッシュボード
  • プッシュ通知基盤
  • マルチプレイヤーゲームのサーバー接続

これらのシーンでは、未認証の接続を排除し、かつ接続後も継続的な権限管理が求められる。

方式1:トークン認証

最も汎用的な方式。接続時にクライアントがトークンを提示し、サーバー側で検証する。JWTの場合、Sec-WebSocket-Protocolヘッダーやクエリパラメータを経由させることが多い。

const { WebSocketServer } = require('ws');

const wss = new WebSocketServer({ port: 8080 });

function verifyToken(token) {
  // 実際にはJWT検証ロジックを実装する
  return typeof token === 'string' && token.startsWith('Bearer ');
}

wss.on('connection', (ws, request) => {
  const token = request.headers['sec-websocket-protocol'];

  if (!verifyToken(token)) {
    ws.close(1008, 'Authentication failed');
    return;
  }

  ws.send('Connection accepted');
});

方式2:署名認証

クライアントが共有秘密鍵を使ってリクエスト内容の署名を生成し、サーー側でHMAC等を用いて検証する。トークンを発行できないマシン間通信や、一時的な接続URLを発行するケースに適する。

const { WebSocketServer } = require('ws');
const crypto = require('crypto');

const wss = new WebSocketServer({ port: 8080 });
const SHARED_SECRET = 'REPLACE_WITH_STRONG_SECRET';

wss.on('connection', (ws, request) => {
  const signature = request.headers['x-request-signature'];
  const nonce = request.headers['x-request-nonce'];
  const timestamp = request.headers['x-request-timestamp'];

  const payload = `${request.url}:${nonce}:${timestamp}`;
  const expected = crypto
    .createHmac('sha256', SHARED_SECRET)
    .update(payload)
    .digest('hex');

  const expired = Date.now() - Number(timestamp) > 5 * 60 * 1000;

  if (signature !== expected || expired) {
    ws.close(1008, 'Invalid signature');
    return;
  }

  ws.send('Signature verified');
});

方式3:IPホワイトリスト

接続元IPアドレスを限定したい企業内システムや、特定サービス間の専用通信で有効。ただし、NATやプロキシ環境ではX-Forwarded-For等の扱いに注意が必要。

const { WebSocketServer } = require('ws');

const wss = new WebSocketServer({ port: 8080 });

const allowedRanges = ['127.0.0.1', '10.0.0.', '192.168.'];

wss.on('connection', (ws, request) => {
  const remoteAddress = request.socket.remoteAddress || '';

  const isAllowed = allowedRanges.some((prefix) =>
    remoteAddress.startsWith(prefix)
  );

  if (!isAllowed) {
    ws.close(1008, 'Address not allowed');
    return;
  }

  ws.send('Allowed by IP whitelist');
});

方式4:OAuth 2.0 / OIDC 連携

既存のID基盤を活用する場合、接続時にアクセストークンを受け取り、認証サーバーへのIntrospectionエンドポイントで有効性を確認する。

const { WebSocketServer } = require('ws');
const axios = require('axios');

const wss = new WebSocketServer({ port: 8080 });
const INTROSPECTION_URL = 'https://auth.example.com/oauth2/introspect';

wss.on('connection', async (ws, request) => {
  const authHeader = request.headers['authorization'];

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    ws.close(1008, 'Missing token');
    return;
  }

  try {
    const accessToken = authHeader.slice(7);
    const response = await axios.post(
      INTROSPECTION_URL,
      new URLSearchParams({ token: accessToken }),
      {
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
          Authorization: `Bearer ${process.env.INTROSPECTION_TOKEN}`
        }
      }
    );

    if (!response.data.active) {
      ws.close(1008, 'Token inactive');
      return;
    }

    ws.send('OAuth verification succeeded');
  } catch (err) {
    ws.close(1008, 'Verification error');
  }
});

実装のポイント

  • 認証失敗時は、可能な限り理由を含めた上で接続を切断する。ブラウザ側のWebSocket APIではカスタム理由が見えない場合があるため、サーバー側ログでの記録も重要。
  • トークンはクエリパラメータに含める場合、ログや履歴に残りやすい。機密性が高い場合はSec-WebSocket-Protocolヘッダーや、接続後の最初のメッセージで送信する方式を検討する。
  • 接続後の権限変更にも対応する。例えば、トークンの有効期限が切れた場合はサーバー側から再接続を要求する。
  • TLS(wss://)を必須とし、平文通信を避ける。
  • 接続後のメッセージにも、操作単位での権限チェックを実施する。

まとめ

WebSocket認証は、接続確立前の一次認証と、接続存続中の継続的な権限管理の両方を設計する必要がある。トークン認証の汎用性、署名認証のマシン間通信への適合性、IPホワイトリストの域化、OAuth連携の既存ID基盤活用など、それぞれの特性を理解し、用途に応じて選択または組み合わせることが推奨される。

タグ: websocket JWT OAuth2 Node.js ws

8月20日 03:20 投稿