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

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

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

  • 一个 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=alice 和 uid=bicll。

在这里插入图片描述
要获取区分名 (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 服务器,可以使用 alice 或 bill 和密码 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
});
  1. 接下来,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
  1. 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;
  1. 您可以在所有信息可用之前调用该回调函数。因此,为了实际获取该信息,我们将在 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);
});
  1. 对于收到的每个实体,会发出另一个事件 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;
  1. 使用用户的 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) {
  1. 如果绑定成功,则意味着用户信息是正确的,您可以开始新会话。如果绑定失败,则密码是错误的(该 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 = {};
  1. 在检查一次用户登录时,构建一个结构来托管会话信息。正常情况下,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: {}
};
  1. 如果您在登录期间获得了用户的更多有用信息,可以将它们存储在会话数据变量中。
// 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;
  1. 确定用户是真实的时,您会获得一个唯一标识符(最好是一个随机标识符,因为任何能够猜出会话标识符的攻击者都可以伪装成合法用户)。将会话数据存储在这个键之下。
var sessionID = logon(sessionData);
.
.
.
// Function called after the user logs on
var logon = function(sessionData) {
    var sessionID = uuid.v1();
    sessions[sessionID] = sessionData;
 
    return sessionID;
};
  1. 将会话 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-tech 和 cn=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"
    }
};
  1. 要缓存授权,可以在每个会话的数据中添加一个用于授权决策的字段(关联数组)。我使用了 authList。
var sessionData = {
    .
    .
    .
    // Authorizations we already calculated - none so far
    authList: {}
};
  1. restrictedPages 变量已拥有受限制的页面的列表,您可以使用该变量为它们创建处理函数。
// Create the handlers for the restricted pages
for(var path in restrictedPages) {
    app.get(path, function(req, res) {
        getRestrictedPage(req, res);
    });
}
  1. 大部分身份验证逻辑包含在函数 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];

  1. 因此,如果没有会话,则意味着用户未知,无法制定授权决策。
// No session
if (sessionData == undefined) {
    res.send("I don't even know who you are.");
    return ;
}
  1. 如果会话的 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 ;
}
  1. 如果我们不知道答案,则需要打开一个新的 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 ;
    }
 
});
  1. 这是 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 ;
        }
  1. 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);
    }
});
  1. 在这个应用程序中,所有需要授权的页面都是静态的。要将这样一个页面发送给用户,可以使用 res.sendFile()。在实际的应用程序中,此函数还会调用合适的函数来生成动态内容,这些函数还可以使用会话数据来生成动态内容。
var userAuthorized = function(sessionData, req, res) {
    res.sendFile(__dirname + "/restricted" + req.path);
};
  1. 此函数会在用户未被授权时调用。它是一个非常常见的错误消息。在实际的应用程序中,该消息可能包含一条对内容为什么受到限制的解释,以及帮助用户联系某人来获得授权的信息。
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 应用程序的存储库。

转载至

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值