如果您已经有一个内部 IT 基础架构,它很可能包含一个 LDAP 服务器来提供用户身份。在许多情况下,最好继续使用该目录,甚至在您的应用程序位于 Bluemix® 上时也这样做。在本教程中,我将展示如何实现此操作,同时还将介绍 LDAP 协议本身的基础知识。
在新的 developerWorks Premium 会员计划中一站式访问强大的开发工具和活动。除了 12 个月的 Bluemix 订阅和 240 美元贷款之外,还包含 Safari Books Online。浏览 500 多册最优秀的技术图书(其中超过 50 册是专门面向安全开发人员的)。
立即注册 。
运行应用程序
获取代码
“ 在本教程中,我将展示如何使用现有的 LDAP 基础架构向 Node.js Bluemix 应用程序提供身份验证和授权决策。 ”
这是一个非常简单的应用程序。它允许您使用一个已提供的 LDAP 服务器或您自己的服务器(如果您有一个可从 Bluemix 服务器访问的服务器)来登录。登录后,您会看到另外两个页面的链接,它们用于演示授权。要访问页面,用户需要是某个特定的 LDAP 组的成员。
LDAP(轻量型目录访问协议)是一个 Internet 标准。除了用于访问该目录的协议之外,LDAP 还定义了 命名约定 来标识实体的,定义了 模式 来指定实体中包含的信息。
LDAP 中的条目存储在一个称为 目录信息树 的树中。该树的根称为 后缀 ,树枝称为 容器 。这些容器可以是组织单元、场所等。树的叶子是各个实体。
可以在下图中看到此结构的一个示例。后缀是 o=simple-tech 。在它之下有一些树枝: ou=people (表示用户)和 ou=groups (表示组)。在用户的树枝下,有两个表示单个用户的实体: uid=alice 和 uid=bicll 。
要获取 区分名 (DN) (实体的完整标识符),可以从实体本身一直到树根,收集所有标识符并使用逗号将它们分开。例如,alice 的区分名是 uid=alice,ou=people,o=simple-tecch 。
模式指定了 属性 ,也就是存储的有关每个实体的信息。每个实体都有的一个属性是 objectClass
,它指定该实体的类型。在大部分情况下,用户信息都存储为对象类 inetOrgPerscon
,组存储为 groupOfNames
。对于每个对象类,一些属性是强制性的,一些属性是可选的。例如,在 inetOrgPerson
中,表示常用名 (cn) 和别名 (sn) 的属性是强制性的。其他属性是可选的,比如表示用户 ID (uid) 和密码 (userPassword) 的属性。
要连接到 LDAP 服务器,可以使用 ldapjs 包。连接到 LDAP 服务器通常需要以下信息:
如果您的 LDAP 服务器无法从互联网访问(因为正常情况下是这样),则需要使用 Bluemix Secure Gateway 服务。有关的说明,请参阅 “ 使用 Bluemix Secure Gateway 服务连接到您的数据中心 ”。
通常,服务器连接信息被存储为配置参数,使操作人员在必要时能够轻松更改它们(参阅 “ 使用 Bluemix 和 MEAN 堆栈构建自助发表 Facebook 信息的应用程序,第 3 部分 ” 中的第 5 步)。但是,出于本教程的目的,我希望为您提供从该应用程序连接到您自己的 LDAP 服务器的能力。为此,我在 Web 表单中包含了一些字段,对于该示例,默认字段是我创建的可公开获得的 LDAP 服务器。在您尝试登录时,可以将这些字段修改为您自己的值。
点击查看大图
关闭 [x]
alice
或 bill
和密码 object00
进行登录。
向 LDAP 确认凭据通常是一个包含两步的过程。首先,程序需要以管理员(或者至少具有读取和搜索用户的特权的用户)身份访问 LDAP 服务器来获取用户信息,包括用户的 DN。然后,它需要尝试使用所提供的密码,以用户的 DN 来访问该服务器。这是详细的解释:
sessionData
引用; sessionData
将在下一步中解释,它会处理会话 manageme // Use LDAP var ldap = require('ldapjs'); . . . // Use the administrative account to find the user with that UID var adminClient = ldap.createClient({ url: sessionData.ldap.url });
// Bind as the administrator (or a read-only user), to get the DN for // the user attempting to authenticate adminClient.bind(sessionData.ldap.dn, sessionData.ldap.passwd, function(err) { // If there is an error, tell the user about it. Normally we would // log the incident, but in this application the user is really an LDAP // administrator. if (err != null) res.send("Error: " + err); else
uid
属性中。LDAP 过滤器的最简单形式是 (<attribute>=<value>)
。此代码构建该过滤器并在搜索中使用它。除了过滤器之外,LDAP 搜索还需要知道起点(后缀还是它之下的一个分支)和范围--即搜索深度。下面的范围( sub
)表示没有深度限制。范围 one
将指定搜索中只应包含起点位置下方的一个实体。 // Search for a user with the correct UID. adminClient.search(req.body.ldap_suffix, { scope: "sub", filter: "(uid=" + sessionData.uid + ")" }, function(err, ldapResult) { if (err != null) throw err;
ldapResult
参数上注册事件处理函数。在收到所有数据后会发出 end 事件。如果没有具有该用户 ID 的用户,则没有数据,会话 DN 将保持为空的。在这种情况下,我们会报告一个问题。 // If we get to the end and there is no DN, it means there is no such user. ldapResult.on("end", function() { if (sessionData.dn === "") res.send("No such user " + sessionData.uid); });
searchEntry
。此应用程序假设只有一个这样的实体(用户 ID 应该是唯一的)。它跟踪两个字段:用户的区分名和用户的常用名 (cn) 属性。用户的 LDAP 属性包含在 entry.object
中,防止您需要其他任何用户。 // If we get a result, then there is such a user. ldapResult.on('searchEntry', function(entry) { sessionData.dn = entry.dn; sessionData.name = entry.object.cn;
// When you have the DN, try to bind with it to check the password var userClient = ldap.createClient({ url: sessionData.ldap.url }); userClient.bind(sessionData.dn, sessionData.passwd, function(err) {
if (err == null) { var sessionID = logon(sessionData); res.setHeader("Set-Cookie", ["sessionID=" + sessionID]); res.redirect("main.html"); } else res.send("You are not " + sessionData.uid); });
备注: 此代码没有遵循安全性最佳实践,因为它为错误的用户 ID 和错误的密码提供了不同的响应。这在像这样的示例应用程序中是可接受的,因为它使得调试变得更容易,但在生产环境中,这是不可接受的。
我们不希望每次用户访问页面时,都要求用户执行身份验证并经历 LDAP 身份验证的整个资源密集型的流程。将用户信息存储在某处并根据需要获取它,这样做要有意义得多。
// Current session information var sessions = {};
authList
字段,该字段将处理授权。 // Data about this session. var sessionData = { // Information required to access the LDAP directory: // URL, suffix, and admin (or read only) credentials. // // In a normal application this would be in the // configuration parameters, but in this application we // want people to be able to use their own LDAP server. ldap: { url: req.body.ldap_url, dn: req.body.ldap_dn, passwd: req.body.ldap_passwd, suffix: req.body.ldap_suffix }, // Information related to the current user uid: req.body.uid, passwd: req.body.passwd, dn: "", // No DN yet // Authorizations we already calculated - none so far authList: {} };
// If we get a result, then there is such a user. ldapResult.on('searchEntry', function(entry) { sessionData.dn = entry.dn; sessionData.name = entry.object.cn;
var sessionID = logon(sessionData); . . . // Function called after the user logs on var logon = function(sessionData) { var sessionID = uuid.v1(); sessions[sessionID] = sessionData; return sessionID; };
res.setHeader("Set-Cookie", ["sessionID=" + sessionID]); res.redirect("main.html");
要使用会话信息,可以导入和使用 cookie 解析器中间件:
// Use cookie-parser to read the session ID cookie var cookieParser = require("cookie-parser"); app.use(cookieParser());
实现 /userData.js 页面的函数展示了如何使用会话信息。读取 sessionID
cookie,如果会话对象中没有相应的实体,则执行以下代码:
// Get user data. This small file allows most of the post-logon user interface to be static. app.get("/userData.js", function(req, res) { var data = {}; if (sessions[req.cookies.sessionID] != undefined) { data.name = sessions[req.cookies.sessionID].name; data.uid = sessions[req.cookies.sessionID].uid; } res.send("var userData = " + JSON.stringify(data) + ";"); });
您不能让会话累积。如果应用程序运行了很长时间,会话对象将增多到无法控制且浪费 RAM。在这个应用程序中,可以接受在一小时后删除会话。
这是一个非常简单的算法,它的执行无需消耗太多内存或 CPU。检查会话列表,如果任何会话拥有 old 标志(它有一个名为 “old” 的字段,而且该字段的值为 true),则删除它。如果它没有,则创建该字段并将它设置为 true。每小时使用 setInterval
函数执行此操作一次。
因为我们每小时运行该操作一次,所以一个会话仅运行 1 秒后就可能获得 “old” 标志,或者它在没有 old 标志的情况下存在接近 1 小时。但是,每个会话都会保留 old 标志一小时,所以没有会话会在运行满 1 小时之前被删除,也没有会话能存活 2 小时。
var sessionLifetime = 60; // In minutes setInterval(function() { for(var sessionID in sessions) if (sessions[sessionID].old) delete sessions[sessionID]; else sessions[sessionID].old = true; }, sessionLifetime * 60 * 1000);
在生产应用程序中,会话通常会保留到用户变得不活动。为此,在您使用此算法时,只要使用会话,就将 old 标志设置为 false。
在许多应用程序中,一些功能只可以用于执行特定的工作角色的用户。在 LDAP 中表达这些工作角色的典型方法是将它们表示为一个组的成员。组对象通常拥有对象类 groupOfNames
和一个多值成员属性。多值属性可以拥有多个值,在本例中,可以拥有组中所有成员的 DN。
在这个应用程序中,有两个提供了有限的访问权的页面 men.html 和 women.html。正如您所想的那样,alice 被禁止访问 men.html,bill 被禁止访问 women.html。这些组是 cn=women,ou=groups,o=simple-tech
和 cn=men,ou=groups,o=simple-tech
。
以下是要求组成员访问某个网页的一种方式:
// Restricted pages, and the filters that identify their groups var restrictedPages = { "/men.html": { groupFilter: "cn=men" }, "/women.html": { groupFilter: "cn=women" } };
authList
。 var sessionData = { . . . // Authorizations we already calculated - none so far authList: {} };
restrictedPages
变量已拥有受限制的页面的列表,您可以使用该变量为它们创建处理函数。 // Create the handlers for the restricted pages for(var path in restrictedPages) { app.get(path, function(req, res) { getRestrictedPage(req, res); }); }
getRestrictedPage
中。它使用来自会话和页面的信息。 // Deal with restricted pages, and verify if the user is authorized or not. var getRestrictedPage = function(req, res) { var sessionData = sessions[req.cookies.sessionID]; var page = restrictedPages[req.path];
// No session if (sessionData == undefined) { res.send("I don't even know who you are."); return ; }
authList
已包含一个决策,则使用它。请注意, sessionData.authList[req.path]
可以拥有以下三个值中的一个: // If we already know the authorization answer, use that if (sessionData.authList[req.path] != undefined) { if (sessionData.authList[req.path]) userAuthorized(sessionData, req, res); else userUnauthorized(sessionData, req, res); return ; }
var ldapClient = ldap.createClient({ url: sessionData.ldap.url }); // Bind as the administrator (or a read-only user) ldapClient.bind(sessionData.ldap.dn, sessionData.ldap.passwd, function(err) { if (err != null) { res.send("LDAP bind error:" + err); return ; } });
restrictedPages
中的信息来寻找组。第二个过滤器检查它是否有一个拥有正确 DN 的成员。 // This filter will only find the group if the logged on user is a member ldapClient.search(sessionData.ldap.suffix, { scope: "sub", filter: "(&(" + page.groupFilter + ")(member=" + sessionData.dn + " ))" }, function(err, ldapResult) { if (err != undefined) { res.send("LDAP search error: " + err); return ; }
ldapResult
对象发出的事件将会识别用户是否是该组的成员。如果用户是该组的成员,则该组符合过滤条件, ldapResult
对象会发出一个 searchEntry
事件。如果用户不是成员,则没有 LDAP 实体符合过滤条件,而且不会发生 searchEntry
事件;发出的第一个事件是一个 end 事件。无论用户是否是成员和是否被授权,都会缓存结果供未来使用。 // If we get a result, then the user is authorized ldapResult.on('searchEntry', function() { sessionData.authList[req.path] = true; userAuthorized(sessionData, req, res); }); // If we get to the end and we did not see the user is authorized, then // the user is not authorized. ldapResult.on("end", function() { if (sessionData.authList[req.path] == undefined) { sessionData.authList[req.path] = false; userUnauthorized(sessionData, req, res); } });
res.sendFile()
。在实际的应用程序中,此函数还会调用合适的函数来生成动态内容,这些函数还可以使用会话数据来生成动态内容。 var userAuthorized = function(sessionData, req, res) { res.sendFile(__dirname + "/restricted" + req.path); };
var userUnauthorized = function(sessionData, req, res) { res.send("You are not authorized."); };
使用本教程中介绍的技术,您应该能够使用内部用户存储库和 LDAP 接口,为 Node.js Bluemix 应用程序或者能访问 LDAP 服务器的任何 Node.js 应用程序提供身份验证和授权决策。
Microsoft™ Active Directory 有一个 LDAP 接口,但也有一些细微的区别。在未来的教程中,我将介绍如何使用 Active Directory 作为您的 Node.js 应用程序的存储库。
相关主题: MEAN ldapjs 包 Bluemix Secure Gateway