はじめてのSocket.io #3 チャット編「ユーザー間でのなりすましを防ぐ」

Socket.ioでチャット開発するシリーズも3回目。 前回はSocket.ioから送信されてきた発言内容が自分の物かを判定するプログラムを書きましたが、この仕様だと簡単に他人へのなりすましが出来てしまいます。今回はこれを防ぐ簡易的な方法を紹介しつつ、チャットルームへの入退室などいくつかの機能追加を行います。


www.youtube.com

基本的な説明

前回トークンの値をチャットに参加している全員に送信していましたが、これだと「セッションハイジャック」と同様の手口で簡単に乗っ取られてしまいます。そこで本人にだけ通知する「秘密のトーク」と、他のユーザーに渡す「公開用のトーク」の2つを使い分けることでこの問題を回避します。

1. サーバへ接続する

クライアントがSocket.ioサーバへ接続すると、サーバは2種類のトークンを作成しサーバ内の変数などに保存してます。秘密トークンは難しい物を、公開トークンは何でも構いませんが秘密トークンと重複しない書式にします。

2. チャットルームへ入室する

Socket.ioサーバへ無事に接続できたら早速チャットルームへ入室します。秘密トークンをサーバが受け取り正しいトークンであると判定できれば、現在の入室者一覧を返却します。それと同時に現在入室中のユーザーに新しい入室者があった通知を行いますが、ここで通知されるのは公開用のトークンであるという点です。

このときサーバから渡された公開トークンとユーザー名の組み合わせは、クライアント内の変数などに保存しておきます。

3. チャットルームで発言する

ここからは応用です。発言する際には秘密トークンを付けてサーバに発言内容を送ります。サーバからは本人に対しては秘密トークンを、それ以外のユーザーには公開トークンを付けて共有します。

サーバからは公開(秘密)トークンしか送られてきませんので、クライアントで名前を表示する場合はSTEP2で受け取ったリストと照合します。このやり方だと毎回ユーザー名を送信するムダな通信を行う必要がなくなります。

4. チャットルームから退室する

これは発言時と原理は同じです。退室者は秘密トークンを付けて退室する意思をサーバに伝えます。先ほどと同様にサーバからは本人に対しては秘密トークンを、それ以外のユーザーには公開トークンを付けて情報を共有します。

秘密トークンの書式は桁数が長くなればなるほど解析しずらくなりますが、それと比例して通信量も増えてしまいます。現実的には英数字で6〜8桁程度にするか、もしくはバイナリなどに置き換えても良いかもしれません。

サンプルコード

前回のコードを修正しています。登場人物は以下の5名。CSSを外部ファイルにしたので1人増えました。

serve.js
Socket.ioサーバ。Node.js上で動かします。
public/index.html, style.css
ユーザーからの入力を受け取りSocket.ioサーバと通信を行います。いわゆるクライアントです。今回からCSSを別ファイルにしました。
public/app.js, util.js
index.htmlから呼び出される2つのJavaScript。実際の処理を担当しブラウザ上で動作します。

ソースはGitHub上からも参照できます。 github.com

前回と違う部分の背景色を変更しています。

serve.js

Node.jsで実行するためのJavaScriptです(Webブラウザ上で実行はしません)。

/**
 * Socket.ioチャット
 *
 * @author M.Katsube <katsubemakito@gmail.com>
 */

//-----------------------------------------------
// モジュール
//-----------------------------------------------
const crypto = require("crypto");
const app  = require("express")();
const http = require("http").createServer(app);
const io   = require("socket.io")(http);

//-----------------------------------------------
// 定数
//-----------------------------------------------
// HTMLやJSなどを配置するディレクトリ
const DOCUMENT_ROOT = __dirname + "/public";

// トークンを作成する際の秘密鍵
const SECRET_TOKEN = "abcdefghijklmn12345";

//-----------------------------------------------
// グローバル変数
//-----------------------------------------------
// チャット参加者一覧
const MEMBER = {};
  // ↑以下のような内容のデータが入る
  // {
  //   "socket.id": {token:"abcd", name:"foo", count:1},
  //   "socket.id": {token:"efgh", name:"bar", count:2}
  // }

// チャット延べ参加者数
let MEMBER_COUNT = 1;

//-----------------------------------------------
// HTTPサーバ (express)
//-----------------------------------------------
/**
 * "/"にアクセスがあったらindex.htmlを返却
 */
app.get("/", (req, res)=>{
  res.sendFile(DOCUMENT_ROOT + "/index.html");
});
/**
 * その他のファイルへのアクセス
 * (app.js, style.cssなど)
 */
app.get("/:file", (req, res)=>{
  res.sendFile(DOCUMENT_ROOT + "/" + req.params.file);
});


//-----------------------------------------------
// Socket.io
//-----------------------------------------------
/**
 * [イベント] ユーザーが接続
 */
io.on("connection", (socket)=>{
  //---------------------------------
  // トークンを返却
  //---------------------------------
  (()=>{
    // トークンを作成
    const token = makeToken(socket.id);

    // ユーザーリストに追加
    MEMBER[socket.id] = {token: token, name:null, count:MEMBER_COUNT};
    MEMBER_COUNT++;

    // 本人にトークンを送付
    io.to(socket.id).emit("token", {token:token});
  })();

  /**
   * [イベント] 入室する
   */
  socket.on("join", (data)=>{
    //--------------------------
    // トークンが正しければ
    //--------------------------
    if( authToken(socket.id, data.token) ){
      // 入室OK + 現在の入室者一覧を通知
      const memberlist = getMemberList();
      io.to(socket.id).emit("join-result", {status: true, list: memberlist});

      // メンバー一覧に追加
      MEMBER[socket.id].name = data.name;

      // 入室通知
      io.to(socket.id).emit("member-join", data);
      socket.broadcast.emit("member-join", {name:data.name, token:MEMBER[socket.id].count});
    }
    //--------------------------
    // トークンが誤っていた場合
    //--------------------------
    else{
      // 本人にNG通知
      io.to(socket.id).emit("join-result", {status: false});
    }
  });

  /**
   * [イベント] 発言を全員に中継
   */
  socket.on("post", (data)=>{
    //--------------------------
    // トークンが正しければ
    //--------------------------
    if( authToken(socket.id, data.token) ){
      // 本人に通知
      io.to(socket.id).emit("member-post", data);

      // 本人以外に通知
      socket.broadcast.emit("member-post", {text:data.text, token:MEMBER[socket.id].count});
    }

    // トークンが誤っていた場合は無視する
  });

  /**
   * [イベント] 退室する
   */
  socket.on("quit", (data)=>{
    //--------------------------
    // トークンが正しければ
    //--------------------------
    if( authToken(socket.id, data.token) ){
      // 本人に通知
      io.to(socket.id).emit("quit-result", {status: true});

      // 本人以外に通知
      socket.broadcast.emit("member-quit", {token:MEMBER[socket.id].count});

      // 削除
      delete MEMBER[socket.id];
    }
    //--------------------------
    // トークンが誤っていた場合
    //--------------------------
    else{
      // 本人にNG通知
      io.to(socket.id).emit("quit-result", {status: false});
    }
  });

});

/**
 * 3000番でサーバを起動する
 */
http.listen(3000, ()=>{
  console.log("listening on *:3000");
});


/**
 * トークンを作成する
 *
 * @param  {string} id - socket.id
 * @return {string}
 */
function makeToken(id){
  const str = SECRET_TOKEN + id;
  return( crypto.createHash("sha1").update(str).digest('hex') );
}

/**
 * 本人からの通信か確認する
 *
 * @param {string} socketid
 * @param {string} token
 * @return {boolean}
 */
function authToken(socketid, token){
  return(
    (socketid in MEMBER) && (token === MEMBER[socketid].token)
  );
}

/**
 * メンバー一覧を作成する
 *
 * @return {array}
 */
function getMemberList(){
  const list = [];
  for( let key in MEMBER ){
    const cur = MEMBER[key];
    if( cur.name !== null ){
      list.push({token:cur.count, name:cur.name});
    }
  }
  return(list);
}

public/index.html

今回はチャットルームの入室者の一覧、退室機能などが追加されています。

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>SocketIOチャット</title>
  <link rel="stylesheet" href="style.css" type="text/css" media="all">
</head>
<body>

<h1>SocketIOチャット</h1>

<!-- STEP1. 接続中 -->
<section id="nowconnecting">
  ...サーバに接続中
</section>
<!-- /接続中 -->

<!-- STEP2. 名前を入力 -->
<section id="inputmyname">
  <p>あなたの名前を入力してください</p>
  <form id="frm-myname" autocomplete="off">
    <input type="text" id="txt-myname">
    <button>入室</button>
  </form>
</section>
<!-- /名前を入力 -->

<!-- STEP3. チャット開始 -->
<section id="chat">
  <!-- 発言フォーム -->
  <form id="frm-post" autocomplete="off">
    <span id="myname"></span><br>
    <input type="text" id="msg">&nbsp;&nbsp;<button>送信</button>
  </form>

  <section id="chat-main">
    <!-- 発言ログ -->
    <ul id="msglist"></ul>

    <!-- メンバー一覧 -->
    <ul id="memberlist"></ul>
  </section>

  <!-- ログアウト -->
  <form id="frm-quit">
    <button>退室する</button>
  </form>
</section>
<!-- /チャット本体 -->

<script src="/socket.io/socket.io.js"></script>
<script src="util.js"></script>
<script src="app.js"></script>

</body>
</html>

public/util.js

こちらは前回からの変更はありません。

/**
 * [Wrapper] document.querySelector
 *
 * @param  {string} selector "#foo", ".bar"
 * @return {object}
 */
function $(selector){
  return( document.querySelector(selector) );
}

public/app.js

300行近いボリュームになりましたが、イベント駆動型のプログラムは上から順番になめるよりも、イベント毎に見ていくと理解しやすいかと思います。Socket.ioとの間で発生するイベントに注目しながらご覧ください。

//自分自身の情報を入れる箱
const IAM = {
  token: null,    // トークン
  name: null,     // 名前
  is_join: false  // 入室中?
};

// メンバー一覧を入れる箱
const MEMBER = {
  0: "マスター"
};

// Socket.ioのクライアント用オブジェクトをセット
const socket = io();


//-------------------------------------
// STEP1. Socket.ioサーバへ接続
//-------------------------------------
/**
 * [イベント] トークンが発行されたら
 */
socket.on("token", (data)=>{
  // トークンを保存
  IAM.token = data.token;

  // 表示を切り替える
  if( ! IAM.is_join ){
    $("#nowconnecting").style.display = "none";   // 「接続中」を非表示
    $("#inputmyname").style.display = "block";    // 名前入力を表示
    $("#txt-myname").focus();
  }
});

//-------------------------------------
// STEP2. 名前の入力
//-------------------------------------
/**
 * [イベント] 名前入力フォームが送信された
 */
$("#frm-myname").addEventListener("submit", (e)=>{
  // 規定の送信処理をキャンセル(画面遷移しないなど)
  e.preventDefault();

  // 入力内容を取得する
  const myname = $("#txt-myname");
  if( myname.value === "" ){
    return(false);
  }

  // 名前をセット
  $("#myname").innerHTML = myname.value;
  IAM.name = myname.value;

  // Socket.ioサーバへ送信
  socket.emit("join", {token:IAM.token, name:IAM.name});

  // ボタンを無効にする
  $("#frm-myname button").setAttribute("disabled", "disabled");
});

/**
 * [イベント] 入室結果が返ってきた
 */
socket.on("join-result", (data)=>{
  //------------------------
  // 正常に入室できた
  //------------------------
  if( data.status ){
    // 入室フラグを立てる
    IAM.is_join = true;

    // すでにログイン中のメンバー一覧を反映
    for(let i=0; i<data.list.length; i++){
      const cur = data.list[i];
      if( ! (cur.token in MEMBER) ){
        addMemberList(cur.token, cur.name);
      }
    }

    // 表示を切り替える
    $("#inputmyname").style.display = "none";   // 名前入力を非表示
    $("#chat").style.display = "block";         // チャットを表示
    $("#msg").focus();
  }
  //------------------------
  // できなかった
  //------------------------
  else{
    alert("入室できませんでした");
  }

  // ボタンを有効に戻す
  $("#frm-myname button").removeAttribute("disabled");
});

//-------------------------------------
// STEP3. チャット開始
//-------------------------------------
/**
 * [イベント] 発言フォームが送信された
 */
$("#frm-post").addEventListener("submit", (e)=>{
  // 規定の送信処理をキャンセル(画面遷移しないなど)
  e.preventDefault();

  // 入力内容を取得する
  const msg = $("#msg");
  if( msg.value === "" ){
    return(false);
  }

  // Socket.ioサーバへ送信
  socket.emit("post", {text: msg.value, token:IAM.token});

  // 発言フォームを空にする
  msg.value = "";
});

/**
 * [イベント] 退室ボタンが押された
 */
$("#frm-quit").addEventListener("submit", (e)=>{
  // 規定の送信処理をキャンセル(画面遷移しないなど)
  e.preventDefault();

  if( confirm("本当に退室しますか?") ){
    // Socket.ioサーバへ送信
    socket.emit("quit", {token:IAM.token});

    // ボタンを無効にする
    $("#frm-quit button").setAttribute("disabled", "disabled");
  }
});

/**
 * [イベント] 退室処理の結果が返ってきた
 */
socket.on("quit-result", (data)=>{
  if( data.status ){
    gotoSTEP1();
  }
  else{
    alert("退室できませんでした");
  }

  // ボタンを有効に戻す
  $("#frm-quit button").removeAttribute("disabled");
});

/**
 * [イベント] 誰かが入室した
 */
socket.on("member-join", (data)=>{
  if( IAM.is_join ){
    addMessageFromMaster(`${data.name}さんが入室しました`);
    addMemberList(data.token, data.name);
  }
});

/**
 * [イベント] 誰かが退室した
 */
socket.on("member-quit", (data)=>{
  if( IAM.is_join ){
    const name = MEMBER[data.token];
    addMessageFromMaster(`${name}さんが退室しました`);
    removeMemberList(data.token);
  }
});

/**
 * [イベント] 誰かが発言した
 */
socket.on("member-post", (msg)=>{
  if( IAM.is_join ){
    const is_me = (msg.token === IAM.token);
    addMessage(msg, is_me);
  }
});


/**
 * 最初の状態にもどす
 *
 * @return {void}
 */
function gotoSTEP1(){
  // NowLoadingから開始
  $("#nowconnecting").style.display = "block";  // NowLoadingを表示
  $("#inputmyname").style.display = "none";     // 名前入力を非表示
  $("#chat").style.display = "none";            // チャットを非表示

  // 自分の情報を初期化
  IAM.token = null;
  IAM.name  = null;
  IAM.is_join = false;

  // メンバー一覧を初期化
  for( let key in MEMBER ){
    if( key !== "0" ){
      delete MEMBER[key];
    }
  }

  // チャット内容を全て消す
  $("#txt-myname").value = "";     // 名前入力欄 STEP2
  $("#myname").innerHTML = "";     // 名前表示欄 STEP3
  $("#msg").value = "";            // 発言入力欄 STEP3
  $("#msglist").innerHTML = "";    // 発言リスト STEP3
  $("#memberlist").innerHTML = ""; // メンバーリスト STEP3

  // Socket.ioサーバへ再接続
  socket.close().open();
}

/**
 * 発言を表示する
 *
 * @param {object}  msg - {token:"abcd", name:"foo"}
 * @param {boolean} [is_me=false]
 * @return {void}
 */
function addMessage(msg, is_me=false){
  const list = $("#msglist");
  const li = document.createElement("li");
  const name = MEMBER[msg.token];

  // マスターの発言
  if( msg.token === 0 ){
    li.innerHTML = `<span class="msg-master"><span class="name">${name}</span>> ${msg.text}</span>`;
  }
  // 自分の発言
  else if( is_me ){
    li.innerHTML = `<span class="msg-me"><span class="name">${name}</span>> ${msg.text}</span>`;
  }
  // それ以外の発言
  else{
    li.innerHTML = `<span class="msg-member"><span class="name">${name}</span>> ${msg.text}</span>`;
  }

  // リストの最初に追加
  list.insertBefore(li, list.firstChild);
}

/**
 * チャットマスターの発言
 *
 * @param {string} msg
 * @return {void}
 */
function addMessageFromMaster(msg){
  addMessage({token: 0, text: msg});
}


/**
 * メンバーリストに追加
 *
 * @param {string} token
 * @param {string} name
 * @return {void}
 */
function addMemberList(token, name){
  const list = $("#memberlist");
  const li = document.createElement("li");
  li.setAttribute("id", `member-${token}`);
  if( token == IAM.token ){
    li.innerHTML = `<span class="member-me">${name}</span>`;
  }
  else{
    li.innerHTML = name;
  }

  // リストの最後に追加
  list.appendChild(li);

  // 内部変数に保存
  MEMBER[token] = name;
}

/**
 * メンバーリストから削除
 *
 * @param {string} token
 * @return {void}
 */
function removeMemberList(token){
  const id = `#member-${token}`;
  if( $(id) !== null ){
    $(id).parentNode.removeChild( $(id) );
  }

  // 内部変数から削除
  delete MEMBER[token];
}

public/style.css

こちらは本題とあまり関係ありませんので、表示欄の高さを低くしています。スクロールしてご覧(コピペ)ください。

@charset "utf8";

/*-------------------------
 * STEP2
 *-------------------------*/
#inputmyname{
  display:none;
}
#txt-myname{
  width: 300px;
  font-size: 14pt;
  padding: 4px;
}
#frm-myname button{
  width: 80px;
  height: 30px;
  font-size: 12pt;
}

/*-------------------------
 * STEP3
 *-------------------------*/
#chat{
  display:none;
}
#chat-main{
  display:flex;
}
#msglist{
  width: 500px;
  height: 300px;
  overflow-y: scroll;
  list-style-type:none;
  border: 1px solid gray;
  padding: 5px;
}
#myname{
  font-weight: bold;
}
#msg{
  width: 500px;
  font-size: 14pt;
  padding: 4px;
}
#frm-post button{
  width: 80px;
  height: 30px;
  font-size: 12pt;
}

.msg-me{
  color:blue;
}
.msg-master{
  color: red;
}
.name{
  font-weight:bold;
}

#memberlist{
  width: 150px;
  height: 300px;
  overflow-y: scroll;
  list-style-image: url(circle-solid.svg);
  border: 1px solid gray;
  padding: 5px 5px 5px 30px;  /* 上右下左 */
  margin-left: 10px;
}
.member-me{
  font-weight: bold;
}

#frm-quit button{
  width: 100px;
  height: 30px;
  font-size: 12pt;
}

実行する

実行や確認方法は前回までと同じです。

サーバを起動

Terminalなどで先ほどのJavaScriptをnodeに渡すだけです。これでSocket.ioサーバが3000番のポートで起動します。

$ node serve.js
listening on *:3000

ブラウザから確認

ブラウザからhttp://localhost:3000へアクセスします。前回と同様にサーバへ接続中の文字が表示された後、名前を入力するフォームが表示されます。複数のタブまたはウィンドウを開いておいてくださいね。

名前を入力してチャットルームへ入ると、右側に現在ログインしているユーザーの一覧、画面下部に退室するためのボタンが表示されています。発言したり退室を行ってみましょう。

向かって右側のユーザー(コメッコ)を退室させてみました。ユーザー一覧から退室ユーザーがいなくなっているのがわかりますね。

サーバを終了

サーバを落としたいときはCtrl+cキーで強制的に終了させます。

$ node serve.js
listening on *:3000
^C

$ 

解説

サーバ

本人以外に通知する

前回、本人にだけ情報を送りたいときはio.to(id).emit()を利用するとお話しましたが、逆に本人以外の全員に通知したい場合にはsocket.broadcast.emit()を利用してやります。

// 本人に通知
io.to(socket.id).emit("quit-result", {status: true});

// 本人以外に通知
socket.broadcast.emit("member-quit", {token:MEMBER[socket.id].count});

今回は「本人とは秘密トークン」「本人以外とは公開トークン」でやり取りする関係でこの2つの機能を各イベントで使っているというわけです。

チャットルームのユーザー管理

ユーザーの情報は単純に連想配列(ハッシュ)に入れて管理しています。秘密トークンは前回と同じ要領でsocket.idに文字列を追加しSHA1でハッシュ化した物を利用しています。公開トークンは単なる連番になるため、連番の管理をMEMBER_COUNT変数で行います。

const MEMBER = {};
  // ↑以下のような内容のデータが入る
  // {
  //   "socket.id": {token:"abcd", name:"foo", count:1},
  //   "socket.id": {token:"efgh", name:"bar", count:2}
  // }

// チャット延べ参加者数
let MEMBER_COUNT = 1;

ユーザーが新規に接続してきた場合は、先ほどの連想配列に代入してやります。カウンターの加算も忘れずに。

// トークンを作成
const token = makeToken(socket.id);

// ユーザーリストに追加
MEMBER[socket.id] = {token: token, name:null, count:MEMBER_COUNT};
MEMBER_COUNT++;

ユーザーが退室する場合は、これもシンプルに連想配列から削除するだけです。

// 削除
delete MEMBER[socket.id];

本人からの通信か確認する

クライアントから送られてきた秘密トークンが、サーバ内の連想配列MEMBER内にいるかチェックするだけの簡単仕様になっています。

function authToken(socketid, token){
  return(
    (socketid in MEMBER) && (token === MEMBER[socketid].token)
  );
}

クライアント

ユーザー情報の管理

自分自身の情報の管理は前回と同様にグローバル変数IAMで管理しています。入室中かどうかを判定するフラグを追加しています。

//自分自身の情報を入れる箱
const IAM = {
  token: null,    // トークン
  name: null,     // 名前
  is_join: false  // 入室中?
};

チャットルームへ入室したタイミングで送られてくる部屋にいるユーザー一覧もグローバル変数MEMBERに入れておきます。今回公開トークンが"0"の場合はチャットルームのシステムからの発言用に確保しました。

// メンバー一覧を入れる箱
const MEMBER = {
  0: "マスター"
};

ここにユーザー一覧をもらってくると以下のような配列になるというわけです。今回は処理を簡略化するために自分自身の秘密トークンもここに入ります。

// メンバー一覧を入れる箱
const MEMBER = {
  0: "マスター",
  1: "パンダ",
  2: "ペンギン",
  "awsedrftgy": "アルパカ",
};

メンバー一覧の管理

以下では実際の処理を簡略化していますが、単純にSocket.ioサーバからもらってきたリストをfor文でグルグル回して表示しながら、グローバル変数MEMBERに追加しているだけです。

// メンバー一覧を入れる箱
const MEMBER = {
  0: "マスター"
};

/**
 * [イベント] 入室結果が返ってきた
 */
socket.on("join-result", (data)=>{
  // すでにログイン中のメンバー一覧を反映
  for(let i=0; i<data.list.length; i++){
    const cur = data.list[i];
    addMemberList(cur.token, cur.name);
  }
});


/**
 * メンバー一覧に追加
 */
function addMemberList(token, name){
  // リストのオブジェクトを取得
  const list = $("#memberlist");

  // リストの項目を作成
  const li = document.createElement("li");
  li.setAttribute("id", `member-${token}`);  // id属性
  li.innerHTML = name;                       // ユーザー名

  // リストの最後に追加
  list.appendChild(li);

  // 内部変数に保存
  MEMBER[token] = name;
}

退室はこの逆で、HTMLとグローバル変数MEMBERから削除しているだけになります。

/**
 * [イベント] 誰かが退室した
 */
socket.on("member-quit", (data)=>{
  const name = MEMBER[data.token];
  removeMemberList(data.token);
});


/**
 * メンバー一覧から削除
 */
function removeMemberList(token){
  const id = `#member-${token}`;

  // HTML上に存在していれば削除
  if( $(id) !== null ){
    $(id).parentNode.removeChild( $(id) );  // 削除する
  }

  // 内部変数からも削除
  delete MEMBER[token];
}

続き

※鋭意執筆中

参考ページ