在许多情况下,比如 B2C 应用程序,最好让用户自行注册,然后让管理员批准或拒绝帐户或特定的帐户权限。本文将介绍如何实现这样一个系统。
首先,我将介绍该应用程序的一个非常简单的版本来演示该系统,但其中包含大量安全缺陷。然后,我将分析其中的每个安全缺陷,展示如何修复它们。我将逐行分析重要的代码段,确保您理解它们,并可将该知识应用到您自己的应用程序中。
我提供了该应用程序的两个不同版本:一个演示版本和一个安全版本。在本教程的第一部分中,我们将查看演示应用程序。随后,在讨论安全性时,我们将分析它的安全版本。这些版本可通过以下方式进行访问:
运行不安全的应用程序
获取代码
运行安全的应用程序
获取代码
“ 在本教程中,我将介绍如何编写一个应用程序,它允许用户自助注册,然后由管理员来批准或拒绝他们的帐户。我还将分析一些典型的安全漏洞,以及如何防御它们。 ”
打开 演示应用程序 时,会显示 3 个按钮:Login、Request an account 和 Approve accounts。
Login按钮将用户跳转到登录屏幕,用户需要在该屏幕中输入电子邮件地址和密码。
如果用户尚未注册,可以单击 Request an account 按钮进行注册。此按钮会将用户跳转到 Account Request 表单,他可以在该表单中输入其姓名、密码、电子邮件地址、电话和注册理由。然后用户单击 Submit 。
主屏幕上的第 3 个按钮是 Approve Accounts 按钮。此按钮实际应该只由管理员使用,但由于此应用程序只用于演示目的,所以我也提供了它。通过单击此按钮,管理员可以查看所有当前的帐户请求,批准或拒绝它们:
点击查看大图
关闭 [x]
批准一个帐户请求后,用户可以尝试再次打开该应用程序并登录。
在用户单击主屏幕上的 Request an Account 时,他会被重定向到 acct_request.html。这是一个简单的 HTML POST 表单;关于它的一个唯一要点是,它使用了 Bootstrap 主题 (就像该应用程序中的其他 HTML 文件一样)。
该表单被提交到 /acct_req
,后者由这个 app.js 函数处理:
// Deal with new account requests app.post("/acct_req", function(req, res) { acctReqs[req.body.email] = req.body; res.send("Request received, thank you " + req.body.name + "."); });
此函数非常简单,因为它的主要工作就是解析 POST 请求的内容。要让环境为您完成此工作,必须导入和使用 body-parser 包。这需要执行两个操作:
"dependencies": { "express": "4.12.x", "cfenv": "1.0.x", "body-parser": "*" },
// Use body-parser to receive POST form requests var bodyParser = require("body-parser"); app.use(bodyParser.urlencoded({extended:true}));
所有表单字段都在 req.body 中提供,作为一个关联数组(也称为 哈希表 )。要存储此请求,只需将它放在请求数组中即可:
acctReqs[req.body.email] = req.body;
此应用程序使用电子邮件地址作为唯一标识符。这里使用电子邮件地址作为帐户请求数组 acctReqs
的索引。
这是初始化 acctReqs
的代码:
var acctReqs = {"hacker@evil.com": { email: "hacker@evil.com", name: "Bad Guy", justification: "I want to break your stuff.", password: "Object00"}, "niceguy@good.com": { email: "niceguy@good.com", name: "Good Guy", justification: "You can trust me", password: "Object00"} };
可以看到,该代码已包含两个条目: hacker@evil.com
和 niceguy@good.com
。这些条目提供了可在应用程序启动时批准或拒绝的请求。
当经过授权的管理员单击 Approve accounts 按钮时,浏览器将会打开 acct_approval.html。此文件更复杂,因为它使用了 Angular (MEAN 堆栈中的 “A”)作为数据模型与用户界面视图之间的控制器。
要为管理员显示这些请求,网页需要知道有哪些请求。但是, acctReqs
存储在服务器上。可以直接在 app.js 中生成整个 acct_approval.html 文件,包括 acctReqs
变量,但这会使代码变得不容易阅读。将 HTML 文件放在公共目录中要容易得多。
解决方案是将该变量放在一个单独的脚本中,然后将该脚本包含在 acct_approval.html 中:
<script src="acctReqs.js"></script>
此脚本由 app.js 动态生成,这利用了 JSON(JavaScript 对象表示法),JSON 是对象的 JavaScript 表示。
// Request for the list of accounts app.get("/acctReqs.js", function(req, res) { res.send("var acctReqs = " + JSON.stringify(acctReqs) + ";"); });
可以查看 http://approval-req.mybluemix.net/acctReqs.js 来了解当前的请求。
Angular 使用了两个条目:一个应用程序和一个控制器(或多个控制器)。二者都需要关联到一个表示其范围的 HTML 标记。在本例中,二者关联到顶级标记 html
:
<html ng-app="myApp" ng-controller="myCtrl"> <!-- All the ng.. attributes are directives to the Angular library, which is used as the data model →
也必须在一个 script
标记中设置 Angular。此代码首先创建一个应用程序 myApp,然后创建控制器 myCtrl。创建 myCtrl 的调用的一个参数是初始化 $scope
的函数,该函数的结构包含位于控制器(即管理它们的控制器) “范围” 内的变量和函数。
acctReqs
:此变量获得服务器上的相同值。 approve(email)
和 decline(email)
:这些函数将浏览器重定向到一个 URL,该 URL 告诉服务器一个请求已被批准或拒绝。 <script> // The data model var myApp = angular.module("myApp", []); myApp.controller("myCtrl", function($scope) { // The account requests come in a separate script we get from the server $scope.acctReqs = acctReqs; $scope.approve = function(email) { window.location = "approve/" + escape(email); } $scope.decline = function(email) { window.location = "decline/" + escape(email); } }); </script>
在 HTML 中,以两种方式使用 Angular。首先,当 HTML 属性具有以 ng-
开头的标记时,该标记就是一个 Angular 调用。其次,当 HTML 中的表达式放在双大括号内时 {{expression}}
,该表达式在控制器范围内被分析为 JavaScript。
这里第一次使用 Angular 的地方是 ng-repeat
。此属性导致一个标记(在本例中为 <tr>
)对一个数组中的每个元素重复一次。(还可以添加一个过滤器,以便仅使用部分元素。)此语法为 acctReqs
关联数组中的每个元素创建了一个表行。当前行的值存储在一个临时范围变量 req
中。
下一个要使用的是 ng-click
属性。类似于正常的 onClick
,此属性确定在单击该按钮时运行哪段 JavaScript 代码。但是,在本例中,JavaScript 代码在 Angular 范围的上下文中运行,这意味着只有此范围内声明的函数和变量是可用的。这正是我们需要在该范围内声明 approve(email)
和 decline(email)
,而不是在 ng-click
内的每个条目中放入一行代码的原因。
最后, {{req.name}}
、 {{req.email}}
和 {{req.justification}}
显示请求参数,以便管理员可做出决定。
<table class="table table-condensed table-striped"> <tr><th></th><th>Name</th><th>E-mail</th><th>Justification</th></tr> <tr ng-repeat="req in acctReqs"> <td> <button class="btn btn-sm btn-success" ng-click="approve(req.email);"> <span class="glyphicon glyphicon-ok" /> </button> <button class="btn btn-sm btn-danger" ng-click="decline(req.email);"> <span class="glyphicon glyphicon-remove" /> </button> </td> <td>{{req.name}}</td> <td>{{req.email}}</td> <td>{{req.justification}}</td> </tr> </table>
服务器对响应的处理有两个功能。首先,它需要修改 acctReqs
来删除请求(并在请求获得批准的情况下,修改 accts
来添加新帐户)。第二,它需要将浏览器重定向回帐户批准页面。
当通过 app.<HTML verb>
函数处理在路径中使用了 :<urlVar>
的 URL 时,该变量会存储在 req.params.<urlVar>
中。在这里,该变量是被用作哈希表的键的电子邮件地址。它被用于适当地修改哈希表。
最后, res.redirect(<url>)
通过重定向到帐户批准页面来响应浏览器。因为从浏览器的角度讲,响应 “页面” 似乎位于一个子目录中,所以它被重定向回应用程序的主目录。
// Deal with approved accounts app.get("/approve/:email", function(req, res) { // Move the account from request to account list accts[req.params.email] = acctReqs[req.params.email]; delete acctReqs[req.params.email]; // Redirect to the account approval page res.redirect("../acct_approval.html"); }); // Deal with accounts that are not approved app.get("/decline/:email", function(req, res) { // Delete the request delete acctReqs[req.params.email]; // Redirect to the account approval page res.redirect("../acct_approval.html"); });
用户单击 Login 按钮时,他们将被重定向到 login.html。这是另一个简单的 HTML POST 表单,它使用了 Bootstrap。
app.js 中处理登录的函数非常简单。首先,它查看是否有一个包含该电子邮件地址的实际帐户。如果有,那么它会检查密码是否匹配。然后发送适当的响应。如果该帐户不存在,它会检查是否存在至少一个对该帐户的请求。
// Deal with logins app.post("/login_attempt", function(req, res) { if (accts[req.body.email] != undefined) { // The account exists if (accts[req.body.email].password == req.body.password) { res.send("Login successful, welcome " + accts[req.body.email].name); } else { res.send("Wrong password, " + accts[req.body.email].name); } } else { // Account does not exists if (acctReqs[req.body.email] != undefined) { // There is a request res.send("Request not processed yet, " + acctReqs[req.body.email].name); } else { res.send("I don't know you."); } } });
我们编写的这个应用程序非常不安全。例如,目前潜在的黑客可以通过访问 https://approval-req.mybluemix.net/approve/hacker@evil.com 来批准他们自己的帐户。他们还可以访问 https://approval-req.mybluemix.net/acctReqs.js 来获取请求列表,包括密码。此外,他们可以通过查看对拥有错误密码和电子邮件地址未知的不同响应,识别注册的用户的电子邮件地址。
因此,下一步是修复这些安全漏洞,生成一个更安全的应用程序。您可以在 approval-req-sec.mybluemix.net 上访问这个安全的应用程序。
可以通过两种方式确保对 acctReqs.js 的访问不会揭示密码。修复对提供密码的 app.get()
的调用,或者不将密码放在一个可用的表单中的 acctReqs
中(单独存储它或者对它执行加密或哈希运算)。在本文中,我将展示如何实现这两种方式。
要对密码执行哈希运算,可以使用 password-hash 包。
"password-hash":"*",
"dependencies": { "express": "4.12.x", "cfenv": "1.0.x", "password-hash": "*", "body-parser": "*" },
require
调用来使用该包(第 1 行),修改初始 acctReqs
来使用它(第 6 和第 11 行): pwdHash = require("password-hash"); var acctReqs = {"hacker@evil.com": { email: "hacker@evil.com", name: "Bad Guy", justification: "I want to break your stuff.", password: pwdHash.generate("Object00")}, "niceguy@good.com": { email: "niceguy@good.com", name: "Good Guy", justification: "You can trust me", password: pwdHash.generate("Object00")} };
/login_attempt
请求的函数,以便使用经过哈希运算的密码(第 4 行)。 // Deal with logins app.post("/login_attempt", function(req, res) { if (accts[req.body.email] != undefined) { // The account exists if (pwdHash.verify(req.body.password, accts[req.body.email].password)) { res.send("Login successful, welcome " + accts[req.body.email].name); } else { res.send("Wrong password, " + accts[req.body.email].name); } } else { // Account does not exists if (acctReqs[req.body.email] != undefined) { // There is a request res.send("Request not processed yet, " + acctReqs[req.body.email].name); } else { res.send("I don't know you."); } } });
如果可以避免,甚至经过哈希运算的密码也不应泄漏。要保护它们免遭泄露,可以修改用于发出该数据的 app.get()
调用。如果 JSON.stringify()
获得的另一个参数是函数,它会在每个键/值对上调用该函数,让您修改该值。这被形象地称为 替换器 函数。
// Request for the list of accounts app.get("/acctReqs.js", function(req, res) { res.send("var acctReqs = " + JSON.stringify(acctReqs, function(key, val) { if (key == "password") return undefined; else return val; }) + ";"); });
要解决的下一个问题是,黑客可以批准自己的请求。为此,他们只需将 “approve/ <电子邮件地址> ” 附加到应用程序的 URL。预防此攻击的一种方式是使用 cookie。当作为管理员的您读取 acct_approval.html
时,将一个浏览器 cookie 设置为一个随机值。然后,在收到管理员批准或拒绝请求的决定时,应用程序可检查该浏览器 cookie 是否拥有正确的值。这样,只有合法的管理员能够批准或拒绝请求(除非 acct_approval.html 可公开获得,出于培训目的,此应用程序中就是这样编写它的)。
为了生成该 cookie 的值,我们使用了 node-uuid 包。将它添加到 package.json 中,像其他包一样,将这些变量初始化代码添加到靠近 app.js 顶部的地方:
var cookieName = "approval-req"; var cookieVal = require("node-uuid").v4();
备注:为保持简单,此代码生成一个静态值。在生产环境中,您应该定期更改该 cookie 的值,但在管理员正在批准或拒绝请求时,允许在几分钟内使用旧值。
我们无法将对 cookie 的更改添加到将 acct_approval.html 返回到浏览器的函数,因为我们无法控制它。public/ 目录中的文件由 express 包中包含的 express.static
对象处理。我们在该行之上添加了另一个 中间件 ,以便在到达该行之前设置该 cookie。此中间件仅应用于 /
a
cct_approval.html
,最后会调用 next()
来表明它不是处理请求的最后一步。
app.use("/acct_approval.html", function(req, res, next) { res.cookie(cookieName, cookieVal); next(); }); // serve the files out of ./public as our main files app.use(express.static(__dirname + '/public'));
要获取返回的 cookie 的值,可以将包 cookie-parser 添加到 package.json 中并添加下面这行来使用它:
app.use(require("cookie-parser")());
最后,在两个接受响应的 use.get()
调用中,检查该 cookie:
// Deal with approved accounts app.get("/approve/:email", function(req, res) { if(req.cookies[cookieName] == cookieVal) { // Move the account from request to account list accts[req.params.email] = acctReqs[req.params.email]; delete acctReqs[req.params.email]; // Redirect to the account approval page res.redirect("../acct_approval.html"); } else res.send("Nice try"); }); // Deal with accounts that are not approved app.get("/decline/:email", function(req, res) { if(req.cookies[cookieName] == cookieVal) { // Delete the request delete acctReqs[req.params.email]; // Redirect to the account approval page res.redirect("../acct_approval.html"); } else res.send("Nice try"); });
为了避免泄露帐户是否存在,可在处理 /login_attempt
的 app.post()
(第 6-9 行)中对所有故障使用相同的错误消息。这意味着我们不再关心请求是否存在。
// Deal with logins app.post("/login_attempt", function(req, res) { if (accts[req.body.email] != undefined) { // The account exists if (pwdHash.verify(req.body.password, accts[req.body.email].password)) res.send("Login successful, welcome " + accts[req.body.email].name); else res.send("Bad user or password"); } else res.send("Bad user or password"); });
为了将重点放在帐户批准上,我忽略了生产系统中可能存在的其他特性。
accts
和 acctReqs
存储在一个 MongoDB 数据库中,可以参阅 “ 使用 Bluemix 和 MEAN 堆栈构建自助发表 Facebook 信息的应用程序,第 2 部分:将用户信息存储在服务器上 ” 中的介绍。 ng-if
显示一个错误。有关的更多信息,请参阅 Angular 网站上的 ngIf entry 。 您现在应改能够编写非常安全的简单帐户批准系统,并针对更复杂的场景对其进行修改。对于非常复杂的场景,可以使用 IBM Security Identity Manager (ISIM)。ISIM 拥有控制几乎每个常用系统上的帐户的适配器,如果需要的话,它还支持使用复杂的批准工作流来实现复杂的业务流程。
相关主题: MEAN Sendgrid IBM Security Identity Manager