转载

使用 LDAP 实现 Node.js Bluemix 应用程序中的身份验证和授权

如果您已经有一个内部 IT 基础架构,它很可能包含一个 LDAP 服务器来提供用户身份。在许多情况下,最好继续使用该目录,甚至在您的应用程序位于 Bluemix® 上时也这样做。在本教程中,我将展示如何实现此操作,同时还将介绍 LDAP 协议本身的基础知识。

构建您的应用程序需要做的准备工作

学习、开发和联系

在新的 developerWorks Premium 会员计划中一站式访问强大的开发工具和活动。除了 12 个月的 Bluemix 订阅和 240 美元贷款之外,还包含 Safari Books Online。浏览 500 多册最优秀的技术图书(其中超过 50 册是专门面向安全开发人员的)。

立即注册

  • 一个Bluemix 帐户。
  • HTML 和 JavaScript 的知识。
  • MEAN 应用程序堆栈(至少包括 Node.js 和 Express)的知识。如果不熟悉它,可以查阅 “ 使用 Bluemix 和 MEAN 堆栈构建自助发表 Facebook 信息的应用程序 ” 来了解它,这是 developerWorks 上的一个由 3 部分组成的教程。
  • 一个可以将 Node.js 应用程序上传到 Bluemix 的开发环境,比如 Eclipse。
  • ldapjs 包。

运行应用程序

获取代码

在本教程中,我将展示如何使用现有的 LDAP 基础架构向 Node.js Bluemix 应用程序提供身份验证和授权决策。

演示应用程序

这是一个非常简单的应用程序。它允许您使用一个已提供的 LDAP 服务器或您自己的服务器(如果您有一个可从 Bluemix 服务器访问的服务器)来登录。登录后,您会看到另外两个页面的链接,它们用于演示授权。要访问页面,用户需要是某个特定的 LDAP 组的成员。

LDAP

LDAP(轻量型目录访问协议)是一个 Internet 标准。除了用于访问该目录的协议之外,LDAP 还定义了 命名约定 来标识实体的,定义了 模式 来指定实体中包含的信息。

命名约定

LDAP 中的条目存储在一个称为 目录信息树 的树中。该树的根称为 后缀 ,树枝称为 容器 。这些容器可以是组织单元、场所等。树的叶子是各个实体。

可以在下图中看到此结构的一个示例。后缀是 o=simple-tech 。在它之下有一些树枝: ou=people (表示用户)和 ou=groups (表示组)。在用户的树枝下,有两个表示单个用户的实体: uid=aliceuid=bicll

使用 LDAP 实现 Node.js Bluemix 应用程序中的身份验证和授权

要获取 区分名 (DN) (实体的完整标识符),可以从实体本身一直到树根,收集所有标识符并使用逗号将它们分开。例如,alice 的区分名是 uid=alice,ou=people,o=simple-tecch

模式

模式指定了 属性 ,也就是存储的有关每个实体的信息。每个实体都有的一个属性是 objectClass ,它指定该实体的类型。在大部分情况下,用户信息都存储为对象类 inetOrgPerscon ,组存储为 groupOfNames 。对于每个对象类,一些属性是强制性的,一些属性是可选的。例如,在 inetOrgPerson 中,表示常用名 (cn) 和别名 (sn) 的属性是强制性的。其他属性是可选的,比如表示用户 ID (uid) 和密码 (userPassword) 的属性。

第 1 步. 连接到一个 LDAP 服务器

要连接到 LDAP 服务器,可以使用 ldapjs 包。连接到 LDAP 服务器通常需要以下信息:

  • 服务器 URL,它包含主机名、端口,以及通信是否加密。
  • 后缀,它是存储的信息所在的树。
  • 向服务器执行身份验证的用户的区分名 (DN)。
  • 该用户的密码。

如果您的 LDAP 服务器无法从互联网访问(因为正常情况下是这样),则需要使用 Bluemix Secure Gateway 服务。有关的说明,请参阅 “ 使用 Bluemix Secure Gateway 服务连接到您的数据中心 ”。

通常,服务器连接信息被存储为配置参数,使操作人员在必要时能够轻松更改它们(参阅 “ 使用 Bluemix 和 MEAN 堆栈构建自助发表 Facebook 信息的应用程序,第 3 部分 ” 中的第 5 步)。但是,出于本教程的目的,我希望为您提供从该应用程序连接到您自己的 LDAP 服务器的能力。为此,我在 Web 表单中包含了一些字段,对于该示例,默认字段是我创建的可公开获得的 LDAP 服务器。在您尝试登录时,可以将这些字段修改为您自己的值。

使用 LDAP 实现 Node.js Bluemix 应用程序中的身份验证和授权

点击查看大图

关闭 [x]

使用 LDAP 实现 Node.js Bluemix 应用程序中的身份验证和授权

如果您使用的是我的 LDAP 服务器,可以使用 alicebill 和密码 object00

进行登录。

第 2 步. 登录

向 LDAP 确认凭据通常是一个包含两步的过程。首先,程序需要以管理员(或者至少具有读取和搜索用户的特权的用户)身份访问 LDAP 服务器来获取用户信息,包括用户的 DN。然后,它需要尝试使用所提供的密码,以用户的 DN 来访问该服务器。这是详细的解释:

  1. 第一步是使用 ldapjs 库创建一个 LDAP 客户端。LDAP 客户端需要一个参数,即服务器的 URL。暂时忽略 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 });
  2. 接下来,LDAP 客户端需要 绑定 ,这是表示身份验证的 LDAP 术语。这需要一次长距离的往返,所以工作流的剩余部分放在一个作为参数提供给绑定函数的函数中。其他 LDAP 操作也是如此。
    // 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
  3. LDAP 模式指定在某个用户 ID 可用时将它存储在一个 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;
  4. 您可以在所有信息可用之前调用该回调函数。因此,为了实际获取该信息,我们将在 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); });
  5. 对于收到的每个实体,会发出另一个事件 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;
  6. 使用用户的 DN,您可尝试使用该用户密码绑定到服务器。
    // 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) {
  7. 如果绑定成功,则意味着用户信息是正确的,您可以开始新会话。如果绑定失败,则密码是错误的(该 uid 已被用,否则该流程将在子步骤 4 中失败)。
    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 和错误的密码提供了不同的响应。这在像这样的示例应用程序中是可接受的,因为它使得调试变得更容易,但在生产环境中,这是不可接受的。

第 3 步. 管理会话

我们不希望每次用户访问页面时,都要求用户执行身份验证并经历 LDAP 身份验证的整个资源密集型的流程。将用户信息存储在某处并根据需要获取它,这样做要有意义得多。

创建会话

  1. 声明一个全局关联数组来存储会话。
    // Current session information var sessions = {};
  2. 在检查一次用户登录时,构建一个结构来托管会话信息。正常情况下,LDAP 信息是全局性的,但因为此应用程序允许用户选择自己的 LDAP 服务器用于测试用途,所以不同的用户可能拥有不同的 LDAP 参数。下一步解释 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: {} };
  3. 如果您在登录期间获得了用户的更多有用信息,可以将它们存储在会话数据变量中。
    // 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;
  4. 确定用户是真实的时,您会获得一个唯一标识符(最好是一个随机标识符,因为任何能够猜出会话标识符的攻击者都可以伪装成合法用户)。将会话数据存储在这个键之下。
    var sessionID = logon(sessionData); . . . // Function called after the user logs on var logon = function(sessionData) {  var sessionID = uuid.v1();  sessions[sessionID] = sessionData;   return sessionID; };
  5. 将会话 ID 存储在浏览器中的 cookie 中。此刻,您通常会将用户重定向到一个包含实际内容的网页。
    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。

第 4 步. 使用组进行授权

在许多应用程序中,一些功能只可以用于执行特定的工作角色的用户。在 LDAP 中表达这些工作角色的典型方法是将它们表示为一个组的成员。组对象通常拥有对象类 groupOfNames 和一个多值成员属性。多值属性可以拥有多个值,在本例中,可以拥有组中所有成员的 DN。

在这个应用程序中,有两个提供了有限的访问权的页面 men.html 和 women.html。正如您所想的那样,alice 被禁止访问 men.html,bill 被禁止访问 women.html。这些组是 cn=women,ou=groups,o=simple-techcn=men,ou=groups,o=simple-tech

以下是要求组成员访问某个网页的一种方式:

  1. 如果拥有任何静态页面,可将它们放在一个单独的目录中。在此应用程序中,我将它们放在 /restricted 中(可公开访问的公开页面放在 /public 中)。
  2. 创建一个受限制的页面列表和一个获取它们的 LDAP 过滤器。在本教程中,我使用了 cn=<group name>。
    // Restricted pages, and the filters that identify their groups var restrictedPages = {  "/men.html": {   groupFilter: "cn=men"  },  "/women.html": {   groupFilter: "cn=women"  } };
  3. 要缓存授权,可以在每个会话的数据中添加一个用于授权决策的字段(关联数组)。我使用了 authList
    var sessionData = {   .   .   .   // Authorizations we already calculated - none so far   authList: {}  };
  4. restrictedPages 变量已拥有受限制的页面的列表,您可以使用该变量为它们创建处理函数。
    // Create the handlers for the restricted pages for(var path in restrictedPages) {  app.get(path, function(req, res) {   getRestrictedPage(req, res);  }); }
  5. 大部分身份验证逻辑包含在函数 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];
  6. 因此,如果没有会话,则意味着用户未知,无法制定授权决策。
    // No session  if (sessionData == undefined) {   res.send("I don't even know who you are.");   return ;  }
  7. 如果会话的 authList 已包含一个决策,则使用它。请注意, sessionData.authList[req.path] 可以拥有以下三个值中的一个:
    • Undefined:如果拥有此会话的用户的授权状态和请求的路径未知。
    • True:如果已知可授权该用户访问该路径。
    • False:如果已知无法授权该用户访问该路径。
    // 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 ;  }
  8. 如果我们不知道答案,则需要打开一个新的 LDAP 连接。
    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 ;   }   });
  9. 这是 LDAP 搜索操作。要将两个或更多 LDAP 过滤器组合到一个过滤器中,并要求它们都匹配,可以使用语法 (& <filter 1><filter 2>…<filter n> )。在本例中,第一个过滤器基于 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 ;    }
  10. 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);     }    });
  11. 在这个应用程序中,所有需要授权的页面都是静态的。要将这样一个页面发送给用户,可以使用 res.sendFile() 。在实际的应用程序中,此函数还会调用合适的函数来生成动态内容,这些函数还可以使用会话数据来生成动态内容。
    var userAuthorized = function(sessionData, req, res) {  res.sendFile(__dirname + "/restricted" + req.path); };
  12. 此函数会在用户未被授权时调用。它是一个非常常见的错误消息。在实际的应用程序中,该消息可能包含一条对内容为什么受到限制的解释,以及帮助用户联系某人来获得授权的信息。
    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

原文  http://www.ibm.com/developerworks/cn/opensource/se-use-ldap-authentication-authorization-node.js-bluemix-application/index.html?ca=drs-
正文到此结束
Loading...