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](/webrtc/images/redis_server.jpg)
现在打开一个新的终端窗口并运行 redis-cli 以打开客户端应用程序。
![Redis-cli](/webrtc/images/redis_cli.jpg)
基本上,Redis 是一个键值数据库。要创建具有字符串值的键,您应该使用 SET 命令。要读取密钥值,您应该使用 GET 命令。让我们为它们添加两个用户和密码。密钥将是用户名,这些密钥的值将是相应的密码。
![添加用户和密码](/webrtc/images/type_username_and_password.jpg)
现在我们应该修改我们的信令服务器以添加用户身份验证。将以下代码添加到 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](/webrtc/images/use_openssl.jpg)
要生成公钥和私钥安全证书密钥,请按照以下步骤操作 −
生成临时服务器密码密钥
openssl genrsa -des3 -passout pass:x -out server.pass.key 2048
![临时服务器密码密钥](/webrtc/images/temporary_password.jpg)
生成服务器私钥
openssl rsa -passin pass:12345 -in server.pass.key -out server.key
![服务器私钥](/webrtc/images/server_private_key.jpg)
生成签名请求。系统会询问您有关公司的其他问题。只需一直按"Enter"键即可。
openssl req -new -key server.key -out server.csr
![生成签名请求](/webrtc/images/signing_request.jpg)
生成证书
openssl x509 -req -days 1095 -in server.csr -signkey server.key -out server.crt
![生成证书](/webrtc/images/generate_certificate.jpg)
现在您有两个文件,证书(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](/webrtc/images/invalid_certificate.jpg)
单击"仍然继续"按钮。您应该看到"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](/webrtc/images/established_rtcpeer_connection.jpg)
以下是我们安全信令服务器的完整代码 −
//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 应用程序范围内使用它们。