第八天 - 使用 LDAP 进行身份验证

img

仍然有不少人在生产中使用LDAP,但对于那些不熟悉它的人来说,LDAP是一个具有树结构的目录,该树结构针对非常快速的查找进行了优化。它曾经是非常常见的集中式身份验证系统,如果您使用的是Active Directory,那么您(主要是)使用LDAP。我漫游在身份验证的荒野中,最后是我的解决方案,如何将LDAP身份验证添加到您的应用程序。

这篇文章是基于我在2018年伦敦Perl研讨会上发表的一篇演讲。我们有点乐观地认为他们会在圣诞节之前编辑所有视频,但我们希望能有一种顿悟。 LDAP只是验证周期的一小部分,所以这篇文章对于你必须编写自己的凭证检查器的情况来说相当好。哦,这篇文章的谈话和写作完全符合我的意图和提出的问题,我没有考虑过这些问题。结果,它开始像爱丽丝的餐厅一样,没有完整的编排和五部分和谐。我希望这个警示故事可以帮助你避免陷入同样的​​陷阱。

在此期间,请参加MojoConf 2018的Lightning Talk。

路由 - lib/MyApp.pm

首先,一个忏悔。我从未真正进入过Lite Apps。我知道很容易将它们发展为完整的应用程序,但是当我开始并且从未回到它时,我面临着制定解决方案的压力。结果是这篇文章是关于认证一个完整的应用程序,并不像其他帖子谈论他们的Lite应用程序那样苗条。

直接进入,我们假设您已经在模板中有一个登录页面,并且它有一个将数据发布到/ login的表单。如果你有这样的路线

1
$r->post('/login')->name('do_login')->to('Secure#on_user_login');

将凭据发送到您的控制器。或者,如果你对命名路线很酷,你的模板可能会包含这一行

1
<form action="<%= url_for 'do_login' %>" method="POST">

专业提示:你甚至可以简化它

1
%= form_for 'do_login'

如果路由只处理POST,它会为您完成所有操作,包括方法。

控制器 - lib / MyApp / Controller / Secure.pm

让我们从 Mojolicious Cookbook 中汲取灵感。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package MyApp::Controller::Secure;
use Mojo::Base 'Mojolicious::Controller';

sub on_user_login {
my $self = shift;

my $username = $self->param('username');
my $password = $self->param('password');

if (check_credentials($username, $password)) {
$self->render(text => 'Hello Bender!');
}
else {
$self->render(
text => '<h2>Login failed</h2><a href="/login">Try again</a>',
format => 'html',
status => 401
);
}
}

sub check_credentials {
my ($username, $password) = @_;

return $username eq 'Bender' && $password eq 'rocks';
}

存储密码 - MojoX::Auth::Simple

我们同意硬编码用户名和密码是不可持续的。如果您可以连接到数据库,Perl DBI 模块可以连接的任何数据库,那么您可能会认为 MojoX::Auth::Simple 将解决您的问题。进一步阅读将告诉您它只提供帮助方法 log_inis_logged_inlog_out,这些方法对于身份验证周围的所有内容都很有用,但对身份验证本身不起作用。但是,既然您现在正在使用数据库,那么您可以将 check_credentials 更改为比这更好的东西(wot是在周五下午制作而未经过测试)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sub check_credentials {
my ($username, $password) = @_;

my $statement = <<'SQL'; # NO! Don't do this!
SELECT username FROM user_passwd
WHERE username = ? AND password = ?
SQL

my $sth = $dbh->prepare($statement);
$sth->execute($username, $password) or return;
my @row = $sth->fetchrow_array();
$sth->finish();

return $username eq $row[0];
}

与数据库连接并处理 $dbh 作为练习给读者。是的,你应该在 sub 之外准备 SQL。的 ? 在SQL语句中是绑定参数,占位符使数据库调用更快更安全。

你发现了我犯的巨大错误吗?

从来没有,永远不要以纯文本形式存储密码! (在星期五下午归咎于它)你应该在使用AES或SHA-2等算法存储密码之前加密密码。那么,对于一个更好的未经测试的例子,这怎么样?您可以使用SQL进行加密

1
2
3
4
my $statement = <<'SQL';      # better
SELECT username FROM user_passwd
WHERE username = ? AND password = SHA2(?, 256)
SQL

或使用 Perl 加密

1
2
3
4
5
6
7
8
9
use Crypt::Digest::SHA256 qw/ sha256 /;

sub check_credentials {
my ($username, $password) = @_;
my $encrypted = sha256($password);

...

$sth->execute($username, $encrypted) or return;

从技术上讲,AES是一种加密算法,SHA-2是一种散列算法,这意味着转换实际上是单向的,更安全。以下是一些使其更简单,更安全的模块:

一个用于处理密码的好模块就是密码。它只是一些其他模块的包装器,它为您提供了一个简单的API,默认情况下将使用 Bcrypt。因此,如果您使用 password_hash 函数对密码进行了哈希处理,并将 $hash 值存储在数据库中,就像这样

1
2
3
4
my $hash = password_hash($initial_password);

my $sth = $dbh->prepare('INSERT INTO user_passwd (username, password) VALUES (?, ?)');
$sth->do($username, $hash);

你应该可以将sub改为

1
2
3
4
5
6
7
8
9
10
11
12
sub check_credentials {
my ($username, $password) = @_;

my $statement = 'SELECT password FROM user_passwd WHERE username = ?';

my $sth = $dbh->prepare($statement);
$sth->execute($username) or return;
my ($encoded) = $sth->fetchrow_array();
$sth->finish();

return password_verify($password, $encoded);
}

Mojolicious::Plugin::Scrypt 将使用Scrypt算法,但也可以使用Argon2(在LPW推荐给我),Bcrypt等等。所以,假设你用我的 my $encoded = $app->scrypt($password); 存储了你的密码; on_user_login sub 变成了

1
2
3
4
5
6
7
8
9
10
11
12
13
sub check_credentials {
my ($username, $password) = @_;

my $statement = 'SELECT password FROM user_passwd WHERE username = ?';

my $sth = $dbh->prepare($statement);
$sth->execute($username) or return;
my ($encoded) = $sth->fetchrow_array();
$sth->finish();

# WAIT! where did $self come from
return $self->scrypt_verify($password, $encoded);
}

噢亲爱的。由于在写作过程中早期做出的设计决定,上述崩溃。我将 check_credentials 调用为普通子,而不是对象的方法。使用插件取决于控制器是否可用,因此需要进行以下更改。

1
2
3
4
5
6
7
8
9
10
11
12
13
sub on_user_login {
my $self = shift;
...

if ($self->check_credentials($username, $password)) {
...
}

sub check_credentials {
my ($self, $username, $password) = @_;
...
return $self->scrypt_verify($password, $encoded);
}

你知道吗,我坐在集团W的长凳上思考……如果我要重新编写整个教程,也许我应该开始使用 Mojolicious::Plugin::Authentication 并带你通过插件中 validate_user 选项所需的代码。但是让我们留下明年。

进一步阅读存储密码:

如何 LDAP

还记得LDAP? …这是关于LDAP的帖子

以下是验证的步骤:

  1. 连接到LDAP服务器
  2. 绑定到服务器
  3. 在LDAP中搜索用户的唯一标识符
  4. 使用密码绑定用户
  5. 检查服务器的结果代码

首先,您需要与LDAP服务器建立网络连接。接下来,绑定到服务器。 “绑定”是LDAP中用于连接LDAP树中特定位置的术语。 LDAP服务器有一个关于它是否允许匿名绑定的设置,并确定是否可以在没有密码的情况下搜索目录,就像我在示例中所做的那样。然后,您在LDAP中搜索用户(因为标识符为loooong),然后您使用他们提供的密码绑定用户。如果此用户连接密码成功,则必须使用正确的密码。 LDAP会将绑定结果作为Net :: LDAP :: Message对象返回成功或失败,因此请检查消息代码以确定是否应对用户进行身份验证。

这是代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package MyApp::Controller::Secure;
use Mojo::Base 'Mojolicious::Controller';
use Net::LDAP qw/LDAP_INVALID_CREDENTIALS/;
use YAML qw/LoadFile/;

my $config_file = 'ldap_config.yml';
my $config = LoadFile($config_file);

my ($LDAP_server, $base_DN, $user_attr, $user_id, )
= @{$config}{ qw/server baseDN username id/ };

...

sub check_credentials {
my ($username, $password) = @_;
return unless $username;

my $ldap = Net::LDAP->new( $LDAP_server )
or warn("Couldn't connect to LDAP server $LDAP_server: $@"), return;
my $message = $ldap->bind( $base_DN );

my $search = $ldap->search( base => $base_DN,
filter => join('=', $user_attr, $username),
attrs => [$user_id],
);
my $user_id = $search->pop_entry();
return unless $user_id; # does this user exist in LDAP?

# this is where we check the password
my $login = $ldap->bind( $user_id, password => $password );

# return 1 on success, 0 on failure with the ternary operator
return $login->code == LDAP_INVALID_CREDENTIALS ? 0
: 1;
}

你在顶级目录中有一个文件 ldap_config.yml 看起来有点像

1
2
3
4
5
# config values for connecting to LDAP
server: ldap.example.com
baseDN: dc=users,dc=example,dc=com
username: userid
id: dn

右侧的值与LDAP模式中的属性匹配。

只是在最后一步中清除逻辑,我从Net :: LDAP导入了一个名为LDAP_INVALID_CREDENTIALS的常量,我使用它来检查来自服务器的结果。

1
2
3
4
5
6
use Net::LDAP qw/LDAP_INVALID_CREDENTIALS/;

...

return ($login->code == LDAP_INVALID_CREDENTIALS) ? 0 : 1;
}

逻辑与三元运算符有点回到前面,但如果我从服务器获取的代码是LDAP_INVALID_CREDENTIALS然后我返回0,一个失败,否则我返回1,这是一个真正的值为if在正文中on_user_login函数。

是的,你再一次正确。我可能应该使用Mojolicious :: Plugin :: Config来处理配置文件。它在我的TODO列表中。

会话

想要更多电源管理会话?那么,你想要MojoX :: Session,它将你的会话存储在一个数据库中,并为你提供一堆访问器来帮助你微调会话管理的方式。您可以强制会话在IP地址上匹配以阻止会话劫持。或者向会话cookie添加更多数据。

它适用于MojoX :: Auth :: Simple。模块文档页面为您提供了一个很好的示例。您只需要确保为url_for指定的名称

1
2
3
4
<% } else { %>
<div>Not logged in; <form action="<%= url_for 'login' %>" method="POST">
<input type="submit" value="Login"></form></div>
<% } %>

(这里是登录名)与您在路线中使用的名称相匹配

1
$r->post('/login')->name('do_login')->to('Secure#on_user_login');

我把它命名为 do_login。使用命名路由使您可以灵活地更改URL而不会有太多麻烦。

下一个在哪里

我在 Mojolicious 会话教程中完成了对身份验证和维护会话的整个过程,这将在新的一年中获得一些更新,以反映我所学到的内容。欢迎捐款!