基于角色的单点登录与 Perl 和 Ruby

作者:Robb Shecter

俄勒冈州波特兰市以明智地管理其资源而自豪。因此,本文描述如何使计算机资源和遗留 CGI 脚本更易于管理也许是很自然的。这是一个优雅、易于构建的系统,可在三个不同领域提供好处。首先,它为程序员提供了一个单行解决方案,用于控制对任何脚本的访问。同时,在后端,它为管理员提供了一个友好的基于 Web 的应用程序来管理访问。最后,也许是最重要的,该系统为最终用户创造了一种逻辑且简单的体验。例如,人们只需在首次尝试访问受保护的脚本时登录一次。之后,如果他们被授权进入,他们将可以不间断地访问任何其他受保护区域。

这里有一些背景信息,可以了解为什么可能需要这种系统。我在刘易斯与克拉克学院工作,该学院坐落在 137 英亩树木茂密的土地上。当我坐在校园的一端,潮湿的花旗松的香味飘过窗户时,我们的 Web 应用程序越来越多地被员工以新的方式和在遥远的地方使用。我们有一个由 IT 部门管理的出色的基于 LDAP 的身份验证系统。人们可以使用一个用户名和密码登录到从丘陵校园的许多地方访问的数十个不同的应用程序。程序员拥有经过良好测试的 Perl 和 PHP 库,可以连接到该系统。

您可能想知道,那么问题是什么?为什么要在一个运行良好的东西之上构建另一层?实际上,很长一段时间以来,没有必要。现有的设置非常好。但是随着时间的推移,我们开始遇到来自多个来源的成长的烦恼。

面向内部用户的 Perl CGI 应用程序的数量一直在稳步增长。这些应用程序越来越多地针对非常特定的任务量身定制,并且旨在仅供一小部分人使用。

这些遗留应用程序是由许多不同的开发人员在多年内开发的。尽管它们都使用了上述 LDAP 系统,但它们以不同的方式处理会话、cookie 和访问。

整套新的脚本需要对某些用户组进行保护访问。我们没有很好的方法来跟踪或管理谁可以访问什么。

作为一名软件工程师,我的第一个想法是创建一个小的可重用库,这样代码就不会重复。我将编写一次登录和会话管理的代码,并在许多地方使用它。但是,在我开始之前,我意识到有几个更深层次的问题我应该解决。

我们应该直接处理和支持角色的概念。到目前为止,我们的软件一直专注于用户,即实际使用该软件的人。但事实上,我们的用户每个人都有许多角色,并且一个角色也可以由许多人执行。

现有的脚本结合了两个不同的功能,最好将它们分开:身份验证和授权。身份验证是确定用户是否是他们所声称的人的过程。授权是决定用户 X 是否应该能够做事情 Y 的过程?

构建解决方案

新系统的计划包含三个独立的部分:一个包含用户及其角色知识的数据库,一个供管理员管理数据库的 Ruby on Rails 应用程序,以及一套用于正在使用的每种应用程序编程环境的适配器库。对于我们的场景,我编写了一个 Perl 模块,将我们的遗留应用程序连接到新框架(图 1)。

Role-Based Single Sign-on with Perl and Ruby

图 1. 系统架构

后端

为这个项目创建一个适当的知识库非常简单。我们使用了 MySQL,但 Ruby on Rails 和 Perl 都支持的任何关系数据库都可以。数据库模式是处理多对多关系的标准解决方案(图 2)。admin_users 表只是用户名列表。简单地包含在表中不会授予用户任何权限。它仅提供该用户与角色关联的可能性。同样,admin_roles 表仅枚举和描述用户可能或可能未被分配到的角色。我包含了一个描述字段,以便管理员可以记录角色的预期用途。在这个简单的模式中,角色名称可能是办公室经理或新闻编辑。

Role-Based Single Sign-on with Perl and Ruby

图 2. 数据库模式

虽然前两个表本质上是静态的,但最后一个表 admin_roles_admin_users 捕获了关于哪些用户被分配到哪些角色的动态信息。对于特定用户拥有特定角色的每个实例,将在此表中创建一个新记录。这种模式非常纯粹和灵活,但缺点是几乎不可能手动输入数据,并且编写应用程序来管理它也有些麻烦。这就是 Ruby on Rails 的用武之地。

前端 #1:管理应用程序

Ruby on Rails (RoR) 在需要提供 CRUD(创建、检索、更新、删除)功能的数据库应用程序领域表现出色。让我们的数据库管理应用程序启动并运行是一项简单而容易的任务(图 3)。有很多关于创建基本 RoR Web 应用程序的优秀教程,因此在本文中,我仅描述必要的自定义项。事实证明,自定义项不多。

Role-Based Single Sign-on with Perl and Ruby

图 3. 管理应用程序,角色列表

首先要注意的是,我仔细选择了表和列的名称,以符合 Ruby on Rails 命名约定(图 2)。这有点棘手;我找不到一个单一的来源来了解所有约定及其含义。在这种带有连接表 (admin_roles_admin_users) 的情况下,重要的是按字母顺序连接名称,并且不包含 id 列。

必要的主要自定义项是告诉 RoR 关于多对多关系。这是通过在 admin_role.rb 中添加一行来实现的

class AdminRole < ActiveRecord::Base
  has_and_belongs_to_many :admin_users
end	

以及在 admin_user.rb 中添加等效的一行

class AdminUser < ActiveRecord::Base
  has_and_belongs_to_many :admin_roles
end

通过这些更改,RoR 可以正确地处理数据并维护所有正确的关系。为了实际显示和编辑连接信息,需要在视图和控制器类中进行更多的工作(请参阅资源)。完成后,我有了漂亮的屏幕,如图 4 所示。

Role-Based Single Sign-on with Perl and Ruby

图 4. 管理应用程序,编辑角色

有了管理应用程序,我们就可以开始填充数据库了。但是为了实际使用这些信息,必须为我们的 Perl/CGI 运行时环境编写一个适配器。

前端 #2:Perl/CGI 适配器

我是声明式(相对于过程式)编程的忠实拥护者,如果可以使用它的话。这是什么意思?好吧,一种检查授权的方法可能如下所示

my $username = $auth->currrent_user;
if (! $username) {
  # Handle the login form
} elsif (! $auth->user_has_role($username, news editor)) {
  # Show error message and exit
}

当然,这可以稍微简化一下——例如,通过实现 current_user_has_role() 方法。但这仍然是过程式的,告诉计算机做什么。相反,我们可以通过告诉计算机(声明)我们想要什么来将其简化为一行

$auth->require_role(news editor);

这个 require_role() 方法意味着需要这个角色才能继续进行下去,它给出了一个非常简单的保证:只有当当前用户应该能够执行时,执行才会超出这一点。如果用户 1) 已经登录并且 2) 具有给定的角色,那么 require_role() 将简单地返回,脚本将继续正常执行。否则,$auth 对象将采取任何必要的步骤来首先进行身份验证,然后根据用户的分配角色授予或拒绝用户访问权限。

这使许多事情变得更容易。对于应用程序程序员来说,这意味着他们不必担心 $auth 对象如何完成其工作。他们也不必担心他们是否正确编写了 if 和 elsif。他们只需要担心哪个角色适合该脚本。实现 Auth.pm Perl 模块并看到应用程序程序员只需付出如此少的努力就能完成如此多的工作,这确实是一件非常有趣的事情。图 5 是一个流程图,显示了调用 require_role 时会发生什么。

Role-Based Single Sign-on with Perl and Ruby

图 5. Auth.pm 流程图

具体来说,我的实现只需要四个短文件

  • Auth.pm:系统的守门人。它实现了首先检查身份验证,然后检查授权的业务逻辑。

  • login.tt2(使用 Template Toolkit):渲染一个登录表单,其中嵌入了隐藏值,以跟踪最初请求的目标页面。登录尝试的结果将发送到 auth_login.cgi。

  • auth_error.tt2:渲染一个错误页面,让用户知道他们没有访问脚本所需的授权。

  • auth_login.cgi:负责验证用户身份并重新启动访问检查的简单任务。在我们的例子中,它连接到 LDAP 系统,并查看给定的登录信息是否正确。如果是,则此事实将保存在会话/cookie 中,并且最初请求的 CGI 脚本将被重新执行。

以下是每个文件中最重要的部分

  • auth.pm:这个模块的核心是 require_role() 方法。它包含整个过程的控制逻辑。在我的实现中,我以 OO 样式使用 CGI.pm,所以我将其作为参数传递。请注意,使用 return 与 exit 如何控制用户体验

    sub require_role {
        #
        # Ensure that the user is logged in and has the 
        # specified role.
        #
        my $self = shift;
        my $role = shift;
        my $cgi  = shift;
    	
        if (! $role) {
               confess("No role was specified");
        }
        if (! $cgi) {
               confess("No CGI object was given");
        }
    	
        my $uname = $self->get_authentication_info();
        if ($uname) {
                # The user has been authenticated.
                if ($self->user_has_role($uname, $role)) {
                        # Success - continue.
                        return;
                } else {
                        # Failure - the user does not have 
                        # the specified role.
                        $self->_display_error_page($cgi);
                        exit;
                }
         } else {
    	     # The user has NOT been authenticated.
                 $self->_display_login_page($cgi);
                 exit;
        }
    }
    
  • login.tt2:Template Toolkit 是一种创建 HTML 页面的绝佳方式。我可以使用 Perl 中的 here document 实现相同的目的,但这要干净得多。它还允许从 Auth.pm 和 auth_login.cgi 执行模板。

    <p>Please login to access <b>[% target_page %]</b>:</p>	
    <form method="POST" action="/cgi-bin/auth_login.cgi">
    <table>
      <tr>
        <td>User name:</td><td><input name="username"></td>
      </tr>
      <tr>
       <td>Password:</td><td><input name="password" type="password"></td>
      </tr>
      <tr>
       <td colspan="2" align="right">
        <input type="hidden" name="target_url" value="[% target_url %]">
        <input type="hidden" name="target_page" value="[% target_page %]">
        <input type="submit" value="Login">
       </td>
      </tr>
    </table>
    </form>
    
    [% IF error_message %]
    <p style="color: #ff0000">
      <b>[% error_message %]</b>
    </p>
    [% END %]
    
    
  • auth_login.cgi:最后,这是来自登录表单处理程序的关键部分。这是一个非常简单的脚本

    if (&ldapauth($name, $pass)) {
      # Success: Create a session, and
      # redirect to the target page.
      &create_session($name);
      print "<html><head>";
      print '<meta http-equiv="refresh" content="0;url=' . $target_url . '">';
      print "</head></html>";
    } else {
      # Failure: Re-display the login form with an
      # error message.
      print $q->header;
      &redisplay_page("Login failed: password incorrect.", 
           $target_page, 
           $target_url);
    }
    
    

有了所有这些部件,我们就可以开始了。这是一个简单的 Perl CGI 脚本,我们想尝试保护它

#!/usr/bin/perl
use CGI;
my $q = CGI->new();
print $q->header;

print <<EOF;
<html>
<body bgcolor="#ee3333">
  <p align="center" style="color: white">This 
              is a TOP SECRET page.</p>
</body>
</html>
EOF

它创建了图 6 所示的输出。但是,现在让我们修改它以使用新框架

#!/usr/bin/perl
use CGI;
use Auth;

my $q = CGI->new();

my $a = LC::Auth->new;
$a->require_role( 'top-secret stuff', $q);

print $q->header;
print <<EOF;
<html>
<body bgcolor="#ee3333">
  <p align="center" style="color: white">This 
          is a TOP SECRET page.</p>
</body>
</html>
EOF

Role-Based Single Sign-on with Perl and Ruby

图 6. 未受保护的页面

在进行此简单更改后,重新加载浏览器现在显示相同的 URL,但我们看到的不是绝密内容,而是登录表单(图 7)。正确登录将在眨眼之间完成几件事:将信息发送到 auth_login.cgi,后者将验证信息,然后在会话中存储登录状态;重定向到初始页面,该页面将重新执行 require_role(),require_role() 现在找到会话,使用 MySQL 数据库验证角色成员资格;然后返回,允许脚本显示内容。但是,就用户而言,在提交登录表单后,他们的应用程序就会简单地出现。

Role-Based Single Sign-on with Perl and Ruby

图 7. 登录表单

结论

这个由几个简短的 Web 脚本组成的简单集合提供了一系列令人惊讶的好处。登录功能被分解为一个可重用的 Web 脚本模块。系统现在理解用户和角色。授权与身份验证分离。提供单点登录,因为所有脚本都检查一个会话/cookie。该功能与语言和环境无关。易于添加的自定义登录模板提供了无缝的用户体验。并且,角色分配的更改会实时生效,因为每次调用脚本时都会查询角色数据库。

我认为这是预先投入少量时间来调查问题并计划一个好的解决方案的回报。促成该项目成功的另一个因素是使用 Ruby on Rails 进行后端数据管理。我设想在未来,我们将拥有像这样的应用程序组件套件,以适应前端用户的需求。在幕后,我们将使用 Rails 等工具快速部署管理应用程序。

资源

Jeffrey Hicks 的 Rails 多对多教程 (2005/9/4): jrhicks.net/Projects/rails/has_many_and_belongs_to_many.pdf

Rails 框架文档: api.rubyonrails.com

Robb Shecter 是俄勒冈州波特兰市刘易斯与克拉克学院的软件工程师。他负责 Web 应用程序开发和软件工程流程。他对编程语言和软件设计特别感兴趣。可以通过 robb@lclark.edu 联系他。

加载 Disqus 评论