WebRTC - 安全

在本章中,我们将为我们在"WebRTC 信令"一章中创建的信令服务器添加安全功能。将会有两个增强功能 −

  • 使用 Redis 数据库进行用户身份验证
  • 启用安全套接字连接

首先,您应该安装 Redis。

  • http://redis.io/download 下载最新稳定版本(我的情况是 3.05)

  • 解压

  • 在下载的文件夹中运行 sudo make install

  • 安装完成后,运行 make test 检查一切是否正常运行。

Redis 有两个可执行命令 −

  • redis-cli − Redis 的命令行界面(客户端部分)

  • redis-server − Redis 数据存储

要运行 Redis 服务器,请在终端控制台中输入 redis-server。您应该看到以下 −

Redis Server

现在打开一个新的终端窗口并运行 redis-cli 以打开客户端应用程序。

Redis-cli

基本上,Redis 是一个键值数据库。要创建具有字符串值的键,您应该使用 SET 命令。要读取密钥值,您应该使用 GET 命令。让我们为它们添加两个用户和密码。密钥将是用户名,这些密钥的值将是相应的密码。

添加用户和密码

现在我们应该修改我们的信令服务器以添加用户身份验证。将以下代码添加到 server.js 文件的顶部 −

//在 Node.js 中需要 redis 库
var redis = require("redis");

//创建 redis 客户端对象
var redisClient = redis.createClient();

在上面的代码中,我们需要 Node.js 的 Redis 库并为我们的服务器创建一个 redis 客户端。

要添加身份验证,请修改连接对象上的 message 处理程序 −

//当用户连接到我们的服务器时
wss.on('connection', function(connection) { 
   console.log("user connected");
	
   //当服务器收到来自已连接用户的消息时
   connection.on('message', function(message) { 
	
      var data; 
      //仅接受 JSON 消息
      try { 
         data = JSON.parse(message); 
      } catch (e) { 
         console.log("Invalid JSON"); 
         data = {}; 
      }
		
      //检查用户是否已经通过身份验证
      if(data.type != "login") { 
		
         //如果用户没有经过身份验证
         if(!connection.isAuth) { 
            sendTo(connection, { 
               type: "error", 
               message: "You are not authenticated" 
            }); 
            return; 
         } 
      } 
		
      //切换用户消息类型
      switch (data.type) { 
         //当用户尝试登录时
         case "login": 
            console.log("User logged:", data.name); 
            //从 redis 数据库获取此用户名的密码
				
            redisClient.get(data.name, function(err, reply) {  
               //检查密码是否与redis中存储的密码匹配
               var loginSuccess = reply === data.password;
				
               //如果有人用这个用户名或错误的密码登录
                  then refuse 
               if(users[data.name] || !loginSuccess) { 
                  sendTo(connection, { 
                     type: "login", 
                     success: false 
                  }); 
               } else { 
                  //在服务器上保存用户连接 
                  users[data.name] = connection; 
                  connection.name = data.name;
                  connection.isAuth = true; 
						
                  sendTo(connection, { 
                     type: "login", 
                     success: true 
                  }); 
               }  
            }); 
				
            break;
      }
   });
	
}		
		
//... 
//*****other handlers*******

在上面的代码中,如果用户尝试登录,我们会从 Redis 获取他的密码,检查它是否与存储的密码匹配,如果成功,我们会将他的用户名存储在服务器上。我们还将 isAuth 标志添加到连接以检查用户是否经过身份验证。请注意此代码 −

//检查用户是否已经通过身份验证
if(data.type != "login") { 

   //如果用户没有经过身份验证
   if(!connection.isAuth) { 
      sendTo(connection, { 
         type: "error", 
         message: "You are not authenticated" 
      });
		
      return; 
   } 
}

如果未经身份验证的用户尝试发送要约或离开连接,我们只会发回错误。

下一步是启用安全套接字连接。强烈建议 WebRTC 应用程序使用安全套接字连接。PKI(公钥基础设施)是来自 CA(证书颁发机构)的数字签名。然后,用户检查用于签署证书的私钥是否与 CA 证书的公钥匹配。出于开发目的,我们将使用自签名安全证书。

我们将使用 openssl。它是一个实现 SSL(安全套接字层)和 TLS(传输层安全性)协议的开源工具。它通常默认安装在 Unix 系统上。运行 openssl version -a 检查是否已安装。

使用 Openssl

要生成公钥和私钥安全证书密钥,请按照以下步骤操作 −

  • 生成临时服务器密码密钥

openssl genrsa -des3 -passout pass:x -out server.pass.key 2048
临时服务器密码密钥
  • 生成服务器私钥

openssl rsa -passin pass:12345 -in server.pass.key -out server.key
服务器私钥
  • 生成签名请求。系统会询问您有关公司的其他问题。只需一直按"Enter"键即可。

openssl req -new -key server.key -out server.csr
生成签名请求
  • 生成证书

openssl x509 -req -days 1095 -in server.csr -signkey server.key -out server.crt
生成证书

现在您有两个文件,证书(server.crt)和私钥(server.key)。将它们复制到信令服务器根文件夹中。

要启用安全套接字连接,请修改我们的信令服务器。

//需要文件系统模块
var fs = require('fs'); 
var httpServ = require('https');
  
//https://github.com/visionmedia/superagent/issues/205 
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
  
//安全服务器将绑定到端口 9090
var cfg = { 
   port: 9090, 
   ssl_key: 'server.key', 
   ssl_cert: 'server.crt' 
};
  
//如果是 http 请求,只需返回"OK"
var processRequest = function(req, res) { 
   res.writeHead(200); 
   res.end("OK"); 
};
  
//创建启用 SSL 的服务器
var app = httpServ.createServer({ 
   key: fs.readFileSync(cfg.ssl_key), 
   cert: fs.readFileSync(cfg.ssl_cert) 
}, processRequest).listen(cfg.port);
	
//需要我们的 websocket 库
var WebSocketServer = require('ws').Server;

//在端口 9090 处创建一个 websocket 服务器
var wss = new WebSocketServer({server: app});

//所有连接到服务器的用户
var users = {};

//需要 Node.js 中的 redis 库
var redis = require("redis");

//创建 redis 客户端对象
var redisClient = redis.createClient();

//当用户连接到我们的服务器时
wss.on('connection', function(connection){
//...其他代码

在上面的代码中,我们需要 fs 库来读取私钥和证书,使用私钥和证书的绑定端口和路径创建 cfg 对象。然后,我们使用密钥创建一个 HTTPS 服务器,并在端口 9090 上创建一个 WebSocket 服务器。

现在在 Opera 中打开 https://localhost:9090。您应该看到以下 −

Invalid Certification

单击"仍然继续"按钮。您应该看到"OK"消息。

为了测试我们的安全信令服务器,我们将修改我们在"WebRTC Text Demo"教程中创建的聊天应用程序。我们只需要添加一个密码字段。以下是整个 index.html 文件 −

<html>
  
   <head> 
      <title>WebRTC Text Demo</title>  
      <link rel = "stylesheet" href = "node_modules/bootstrap/dist/css/bootstrap.min.css"/>  
   </head> 

   <style>  
      body { 
         background: #eee; 
         padding: 5% 0; 
      }  
   </style>
	
   <body>  
      <div id = "loginPage" class = "container text-center"> 
		
         <div class = "row"> 
            <div class = "col-md-4 col-md-offset-4">  
               <h2>WebRTC Text Demo. Please sign in</h2> 
               <label for = "usernameInput" class = "sr-only">Login</label> 
               <input type = "email" id = "usernameInput" 
                  class = "form-control formgroup" placeholder = "Login" 
                  required = "" autofocus = ""> 
               <input type = "text" id = "passwordInput" 
                  class = "form-control form-group" placeholder = "Password"
                  required = "" autofocus = ""> 
               <button id = "loginBtn" class = "btn btn-lg btn-primary btnblock"
                  >Sign in</button>  
            </div> 
         </div> 
			
      </div> 
		
      <div id = "callPage" class = "call-page container">
		
         <div class = "row"> 
            <div class = "col-md-4 col-md-offset-4 text-center"> 
               <div class = "panel panel-primary"> 
                  <div class = "panel-heading">Text chat</div> 
                  <div id = "chatarea" class = "panel-body text-left"></div> 
               </div> 
            </div> 
         </div>
			
         <div class = "row text-center form-group"> 
            <div class = "col-md-12"> 
               <input id = "callToUsernameInput" type = "text" 
                  placeholder = "username to call" /> 
               <button id = "callBtn" class = "btn-success btn">Call</button> 
               <button id = "hangUpBtn" class = "btn-danger btn">Hang Up</button> 
            </div> 
         </div>
			
         <div class = "row text-center"> 
            <div class = "col-md-12"> 
               <input id = "msgInput" type = "text" placeholder = "message" /> 
               <button id = "sendMsgBtn" class = "btn-success btn">Send</button> 
            </div> 
         </div>
			
      </div>  
		
      <script src = "client.js"></script> 
  
   </body> 
	
</html>

我们还需要通过此行 var conn = new WebSocket('wss://localhost:9090');client.js 文件中启用安全套接字连接。请注意 wss 协议。然后,必须修改登录按钮处理程序以将密码与用户名一起发送 −

loginBtn.addEventListener("click", function (event) { 
   name = usernameInput.value; 
   var pwd = passwordInput.value;
	
   if (name.length > 0) { 
      send({ 
         type: "login", 
         name: name, 
         password: pwd 
      }); 
   } 
	
});

以下是整个 client.js 文件 −

//我们的用户名
var name;
var ConnectedUser;

//连接到我们的信令服务器
var conn = new WebSocket('wss://localhost:9090');

conn.onopen = function () {
console.log("已连接到信令服务器");
};

//当我们从信令服务器收到消息时 
conn.onmessage = function (msg) { 
   console.log("Got message", msg.data);
	
   var data = JSON.parse(msg.data);
	
   switch(data.type) { 
      case "login": 
         handleLogin(data.success); 
         break; 
      //当有人想打电话给我们时
      case "offer":
         handleOffer(data.offer, data.name); 
         break; 
      case "answer": 
         handleAnswer(data.answer); 
         break; 
      //当远程对等方向我们发送 candidate 时
      case "candidate": 
         handleCandidate(data.candidate); 
         break; 
      case "leave": 
         handleLeave(); 
         break; 
      default: 
         break; 
   } 
};
  
conn.onerror = function (err) { 
   console.log("Got error", err); 
};  

//发送 JSON 编码消息的别名
function send(message) { 
   //将其他对等用户名附加到我们的消息中
   if (connectedUser) { 
      message.name = connectedUser; 
   } 
	
   conn.send(JSON.stringify(message)); 
}; 
 
//****** 
//UI selectors block 
//******

var loginPage = document.querySelector('#loginPage'); 
var usernameInput = document.querySelector('#usernameInput'); 
var passwordInput = document.querySelector('#passwordInput'); 
var loginBtn = document.querySelector('#loginBtn'); 

var callPage = document.querySelector('#callPage'); 
var callToUsernameInput = document.querySelector('#callToUsernameInput');
var callBtn = document.querySelector('#callBtn'); 
var hangUpBtn = document.querySelector('#hangUpBtn');
  
var msgInput = document.querySelector('#msgInput'); 
var sendMsgBtn = document.querySelector('#sendMsgBtn'); 
var chatArea = document.querySelector('#chatarea'); 

var yourConn; 
var dataChannel;
  
callPage.style.display = "none";
  
// 当用户点击按钮时登录 
loginBtn.addEventListener("click", function (event) { 
   name = usernameInput.value; 
   var pwd = passwordInput.value;  
	
   if (name.length > 0) { 
      send({ 
         type: "login", 
         name: name, 
         password: pwd 
      }); 
   } 
	
}); 
 
function handleLogin(success) { 
   if (success === false) {
      alert("Ooops...incorrect username or password"); 
   } else { 
      loginPage.style.display = "none"; 
      callPage.style.display = "block";
		
      //********************** 
      //启动对等连接
      //********************** 
		
      //使用Google公共stun服务器
      var configuration = { 
         "iceServers": [{ "url": "stun:stun2.1.google.com:19302" }] 
      }; 
		
      yourConn = new webkitRTCPeerConnection(configuration, {optional: [{RtpDataChannels: true}]}); 
		
      // Setup ice handling 
      yourConn.onicecandidate = function (event) { 
         if (event.candidate) { 
            send({ 
               type: "candidate", 
               candidate: event.candidate 
            }); 
         } 
      };
		
      //creating data channel 
      dataChannel = yourConn.createDataChannel("channel1", {reliable:true}); 
		
      dataChannel.onerror = function (error) { 
         console.log("Ooops...error:", error); 
      };
		
      //当我们收到对方的消息时,将其显示在屏幕上 
      dataChannel.onmessage = function (event) { 
         chatArea.innerHTML += connectedUser + ": " + event.data + "<br />"; 
      }; 
		
      dataChannel.onclose = function () { 
         console.log("data channel is closed"); 
      };  
   } 
	
};
  
//initiating a call 
callBtn.addEventListener("click", function () { 
   var callToUsername = callToUsernameInput.value;
	
   if (callToUsername.length > 0) {
	
      connectedUser = callToUsername;
		
      // create an offer 
      yourConn.createOffer(function (offer) { 
         send({ 
            type: "offer", 
            offer: offer 
         }); 
			
         yourConn.setLocalDescription(offer); 
			
      }, function (error) { 
         alert("Error when creating an offer"); 
      });  
   } 
});
 
//当有人向我们发送 offer 时
function handleOffer(offer, name) { 
   connectedUser = name; 
   yourConn.setRemoteDescription(new RTCSessionDescription(offer));
	
   //创建对 offer 的答复
   yourConn.createAnswer(function (answer) { 
      yourConn.setLocalDescription(answer); 
		
      send({ 
         type: "answer", 
         answer: answer 
      }); 
		
   }, function (error) { 
      alert("Error when creating an answer"); 
   }); 
	
};
  
//当我们收到远程用户的答复时 
function handleAnswer(answer) { 
   yourConn.setRemoteDescription(new RTCSessionDescription(answer)); 
};
  
//when we got an ice candidate from a remote user 
function handleCandidate(candidate) { 
   yourConn.addIceCandidate(new RTCIceCandidate(candidate)); 
};
   
//hang up 
hangUpBtn.addEventListener("click", function () { 

   send({ 
      type: "leave"
   }); 
	
   handleLeave(); 
});
  
function handleLeave() { 
   connectedUser = null; 
   yourConn.close(); 
   yourConn.onicecandidate = null; 
}; 
 
//当用户点击"发送消息"按钮时 
sendMsgBtn.addEventListener("click", function (event) { 
   var val = msgInput.value; 
   chatArea.innerHTML += name + ": " + val + "<br />"; 
	
   //向已连接的对等体发送消息
   dataChannel.send(val); 
   msgInput.value = ""; 
});

现在通过 node server 运行我们的安全信令服务器。在修改后的聊天演示文件夹中运行 node static。在两个浏览器选项卡中打开 localhost:8080。尝试登录。请记住,只有"user1"和"password1"以及"user2"和"password2"才允许登录。然后建立 RTCPeerConnection(呼叫另一个用户)并尝试发送消息。

建立 RTCPeerConnection

以下是我们安全信令服务器的完整代码 −

//require file system module
var fs = require('fs'); 
var httpServ = require('https');

//https://github.com/visionmedia/superagent/issues/205 
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
  
//安全服务器将绑定到端口 9090
var cfg = { 
   port: 9090, 
   ssl_key: 'server.key', 
   ssl_cert: 'server.crt' 
};
  
//如果是 http 请求,只需返回"OK"
var processRequest = function(req, res){ 
   res.writeHead(200); 
   res.end("OK"); 
};
  
//创建启用 SSL 的服务器
var app = httpServ.createServer({ 
   key: fs.readFileSync(cfg.ssl_key), 
   cert: fs.readFileSync(cfg.ssl_cert) 
}, processRequest).listen(cfg.port);
  
//需要我们的 websocket 库
var WebSocketServer = require('ws').Server;

//在端口 9090 处创建一个 websocket 服务器
var wss = new WebSocketServer({server: app});

//所有连接到服务器的用户
var users = {};

//需要 Node.js 中的 redis 库
var redis = require("redis");

//创建 redis 客户端对象
var redisClient = redis.createClient();

//当用户连接到我们的服务器时
wss.on('connection', function(connection) { 
   console.log("user connected"); 
	
   //当服务器收到来自已连接用户的消息时
   connection.on('message', function(message) {  
	
      var data; 
      //仅接受 JSON 消息
      try { 
         data = JSON.parse(message); 
      } catch (e) { 
         console.log("Invalid JSON"); 
         data = {}; 
      } 
		
      //检查用户是否已经通过身份验证
      if(data.type != "login") { 
         //如果用户没有经过身份验证
         if(!connection.isAuth) { 
            sendTo(connection, { 
               type: "error", 
               message: "You are not authenticated" 
            }); 
				
            return; 
         } 
      }
		
      //切换用户消息类型
      switch (data.type) { 
         //当用户尝试登录时
         case "login":
            console.log("User logged:", data.name); 
            //从 redis 数据库获取此用户名的密码
            redisClient.get(data.name, function(err, reply) {
			  
               //检查密码是否与redis中存储的密码匹配
               var loginSuccess = reply === data.password;
				  
               //如果有人用这个用户名或错误的密码登录
                  then refuse 
               if(users[data.name] || !loginSuccess) { 
                  sendTo(connection, { 
                     type: "login", 
                     success: false 
                  }); 
               } else { 
                  //在服务器上保存用户连接 
                  users[data.name] = connection; 
                  connection.name = data.name; 
                  connection.isAuth = true; 
						
                  sendTo(connection, { 
                     type: "login", 
                     success: true 
                  }); 
               }  
            }); 
				
            break;
				
         case "offer": 
            //例如,用户 A 想要呼叫用户 B
            console.log("Sending offer to: ", data.name); 
				
            //如果 UserB 存在则向他发送优惠详情
            var conn = users[data.name];
				
            if(conn != null) { 
               //设置UserA与UserB连接
               connection.otherName = data.name;
					
               sendTo(conn, { 
                  type: "offer", 
                  offer: data.offer, 
                  name: connection.name 
               }); 
            } 
				
            break;
				
         case "answer": 
            console.log("Sending answer to: ", data.name); 
            //例如 UserB 回答 UserA
            var conn = users[data.name]; 
				
            if(conn != null) { 
               connection.otherName = data.name;
					
               sendTo(conn, { 
                  type: "answer", 
                  answer: data.answer 
               }); 
            } 
				
            break;
				
         case "candidate": 
            console.log("Sending candidate to:",data.name); 
            var conn = users[data.name];
				
            if(conn != null) { 
               sendTo(conn, { 
                  type: "candidate", 
                  candidate: data.candidate 
               });
            } 
				
            break;
				
         case "leave": 
            console.log("Disconnecting from", data.name); 
            var conn = users[data.name]; 
            conn.otherName = null; 
				
            //通知其他用户,以便他可以断开他的对等连接
            if(conn != null) { 
               sendTo(conn, { 
                  type: "leave" 
               }); 
            }  
				
            break;
				
         connection.on("close", function() {
			
            if(connection.name) { 
               delete users[connection.name]; 
					
               if(connection.otherName) { 
                  console.log("Disconnecting from ", connection.otherName); 
                  var conn = users[connection.otherName]; 
                  conn.otherName = null;  
						
                  if(conn != null) { 
                     sendTo(conn, { 
                        type: "leave" 
                    }); 
                  } 
						
               } 
            } 
         });
			
         default: 
            sendTo(connection, { 
               type: "error", 
               message: "Command no found: " + data.type 
            }); 
				
            break; 
      }  
   });
	
   //当用户退出时,例如关闭浏览器窗口
   //如果我们仍处于"offer","answer" 或 "candidate" 状态,这可能会有所帮助
   connection.on("close", function() { 
      if(connection.name) { 
         delete users[connection.name]; 
      } 
   });
	
   connection.send("Hello from server"); 
});
  
function sendTo(connection, message) { 
   connection.send(JSON.stringify(message)); 
}

总结

在本章中,我们为信令服务器添加了用户身份验证。我们还学习了如何创建自签名 SSL 证书并在 WebRTC 应用程序范围内使用它们。