如何使用 Mojolicious 发送验证邮件

每个人都在网站上注册过,网站会通过发送验证邮件来确认你的电子邮件地址。这是一个简单的流程:如果你能回复这封邮件,那么你一定可以访问这个电子邮件地址。然而,尽管这个流程很简单,编写这样的系统可能并不容易。

让我们看看一个例子。我将使用 Mojolicious,因为这是我最喜欢(并且贡献)的Web框架,同时也因为它适合这项任务的环境。如果你想跟随着我的步骤,请查看完成的脚本

用户存储

示例应用程序需要一个持久机制来存储用户信息。在示例和原型设计中,我会使用 DBM::Deep 工具。这是一个基于文件的系统,用于存储Perl数据结构。要使用它,只需创建一个实例(或使用 tie)并将其用作哈希引用(也可以使用数组引用);任何更改都会自动保存!

my $db = DBM::Deep->new('filename.db');

我将在名为 users 的辅助程序中存储此对象。在Mojolicious中,辅助程序是一个可以作为控制器实例或应用程序本身的方法调用的子程序,或者在模板中作为函数调用。它们通常用于应用程序和业务逻辑或模型逻辑之间的链接,尽管在这里它提供数据库访问。当需要访问用户数据时,比如从控制器实例 $c,它就像这样简单

my $user = $c->users->{$username};

同样,要创建一个用户,只需为其赋值

$c->users->{$username} = {
  email     => $email,
  password  => $c->bcrypt($password),
  confirmed => 0,
};

在更完整的应用程序中,将存储更多字段,但这个例子中只需要这些。

密码加密

我使用名为 bcrypt 的加密来存储密码。 Mojolicious::Plugin::Bcrypt 是一个方便的插件,用于与Mojolicious一起使用Bcrypt加密;你只需写入 plugin 'Bcrypt'; 即可加载它。此插件提供了两个辅助程序,bcrypt 用于加密,bcrypt_validate 用于检查另一个值是否有效。

Bcrypt 是许多具有安全特性属性的哈希算法之一。由于这是一个单向算法,因此没有 decrypt 函数。在验证密码时,你能知道的最佳情况是,如果某个未来的输入散列成相同的结果,那么它一定是原始密码。以这种方式存储密码是好的,因为如果黑客获得数据库访问权限,他们不会得到密码,只是散列;它们不能泄露,因为你根本没有它们。

发送电子邮件

CPAN 充满了可以发送电子邮件的模块。对于这个例子,我使用了 Email::Sender,这是当前推荐的模块(例如这个)。由我们的Perl Pumpking Ricardo Signes编写,这个模块使发送电子邮件变得非常容易。

应用程序声明了一个名为 send_email 的辅助程序来发送电子邮件,它巧妙地接受目标电子邮件地址、主题和正文。

Email::Sender 的一个优点是,你可以通过环境指定 传输。出于原型设计的目的,通过设置环境变量,电子邮件将被“发送”到终端。同时,Mojolicious 的 eval 命令是执行应用程序的单行脚本的便捷方式。如果我将这些功能结合起来,我可以用一行命令查看结果邮件的外观

$ EMAIL_SENDER_TRANSPORT=Print ./app.pl eval 'app->send_email(q[me@spam.org], "Care for some SPAM?", "Well how about it?")'

邮件正文

现在应用可以发送电子邮件了,应该发送什么内容呢?请记住,我想发送一个带有超链接的电子邮件,用户可以通过点击这个超链接来确认他们的注册。这个超链接的URL需要能够识别交易,但由于它是以明文发送的,所以知道内容没有被篡改是很重要的。一个JSON Web Token(JWT),可以将数据结构存储为URL安全的字符串,并对其进行签名,以确保它没有被修改。

由于用户不会登录,我需要其他方法来确定要确认哪个用户名!在这个例子中,JWT的有效负载只包含用户名,通过客户端的电子邮件进行往返发送。

如果应用发送的是密码重置令牌,我也想在JWT中包含超时以防止重放攻击。但对于简单的确认来说,这可能不是必要的。

我创建了一个辅助工具,该工具初始化了Mojo::JWT的一个实例,并使用应用程序的主要密钥作为其密钥。JWT也可以使用其他密钥,但这很方便。请注意,示例应用使用的是默认密钥集合,但您应该将其更改为只有您知道的内容。

为了创建确认URL,应用首先设置声明并将其编码为JWT编码的字符串,其中包含数据结构。

my $jwt = $c->jwt->claims({username => $username})->encode;

然后它生成一个指向“confirm”路由的URL,将其设置为绝对URL,并将查询/值对附加到末尾。

my $url = $c->url_for('confirm')->to_abs->query(jwt => $jwt);

稍后当URL被点击时,应用可以通过以下方式从JWT编码的查询参数中检索用户名:

my $username = $c->jwt->decode($c->param('jwt'))->{username};

请注意,如果JWT(包含在查询参数中)在解码时未通过验证,则会抛出异常;这样您就知道如果代码成功,JWT没有被篡改。

然后只需简单地标记用户的账户已确认即可。

工作队列

许多由网络请求引起的任务可能相当慢。发送电子邮件通常是一个缓慢的过程,我不想为了添加电子邮件功能而减慢服务器的速度。Mojolicious内部使用非阻塞的ioloop以提高性能,您绝对不希望长时间阻塞循环。

工作队列是一个系统,您可以将执行慢速工作的实际工作推送到另一个进程。通常,工作队列通过在数据库中插入一条记录来指示要执行的任务及其参数。工作进程知道如何执行该任务,并监视数据库,直到有工作要做。

Mojolicious有一个名为Minion的工作队列衍生项目。它是从工作进程发送电子邮件以保持站点响应的完美工具。Minion自带Postgres后端,但在这个例子中,我将使用CPAN的SQLite后端。(注意:本文的早期版本使用了一个文件后端,后来已删除)。任务被声明为指向add_task的子程序引用,并且可以通过enqueue创建后续作业。

应用声明了一个名为email_task的任务,它是send_email辅助工具的包装器。它还声明了一个名为email的辅助工具,一个很好的Huffman化名称,它将作业排队(并接受相同的参数)。(我之所以将任务命名为email_task,是为了清楚地说明其名称的使用;它也可以简单地称为email,但我不想让名称与辅助工具混淆)。

这个辅助工具是发送电子邮件所需的所有内容,还有一个工作进程。在原型设计过程中,通过在另一个终端运行以下命令启动一个工作进程很有用:

$ EMAIL_SENDER_TRANSPORT=Print ./app.pl minion worker

再次通过将传输设置为Print,结果将在终端中输出。然后可以通过minion命令跟踪作业的进度。

$ ./myapp.pl minion job
$ ./myapp.pl minion job <<id>>

整合一切

Web应用程序的其余部分是一个相当标准的Mojolicious应用程序。我使用的一个功能是,它将重定向到首页(index页面),并可选择接受在重定向后显示的消息。这种消息被称为“闪存”消息,存储在会话cookie中,仅在下一个请求中有效。使用这个助手,我可以轻松地再次启动登录/注册周期,并告诉用户发生了什么,无论是好是坏。因为Mojolicious中的设置器是可链式的,所以这个助手很简单

helper to_index => sub { shift->flash(message => shift)->redirect_to('index') };

在模板中,如果定义了来自上一个请求的闪存消息,则使用它,否则显示默认内容

<p><%= flash('message') || 'Sign in or sign up!' %></p>

例如,如果用户名已被占用,我可以通过以下方式立即停止处理

return $c->to_index("Username $username is taken") if $c->users->{$username};

现在您已经知道了这些组件的工作原理,请查看最终的脚本,或者查看下面的内容。祝您Perl编程愉快!

use Mojolicious::Lite;

use DBM::Deep;
use Mojo::JWT;

plugin 'Bcrypt';
plugin 'Minion' => {SQLite => 'minion.db'};

helper users => sub { state $db = DBM::Deep->new('users.db') };

helper send_email => sub {
  my ($c, $address, $subject, $body) = @_;

  require Email::Simple;
  require Email::Sender::Simple;

  my $email = Email::Simple->create(
    header => [
      To      => $address,
      From    => 'me@nobody.com',
      Subject => $subject,
    ],
    body => $body,
  );
  Email::Sender::Simple->send($email);
};

helper jwt => sub { Mojo::JWT->new(secret => shift->app->secrets->[0] || die) };

app->minion->add_task(email_task => sub { shift->app->send_email(@_) });

helper email => sub { shift->minion->enqueue(email_task => [@_]) };

helper to_index => sub { shift->flash(message => shift)->redirect_to('index') };

any '/' => sub {
  my $c = shift;
  $c->render('logged_in') if $c->session('username');
} => 'index';

any '/logout' => sub { shift->session(expires => 1)->to_index };

post '/sign_in' => sub {
  my $c = shift;
  my $username = $c->param('username');
  return $c->to_index("Username $username not found")
    unless my $user = $c->users->{$username};

  return $c->to_index("Username $username has not been confirmed")
    unless $user->{confirmed};

  return $c->to_index('Password not correct')
    unless $c->bcrypt_validate($c->param('password') || '', $user->{password});

  $c->session(username => $username)->to_index;
};

post '/sign_up' => sub {
  my $c = shift;

  my $username = $c->param('username');
  return $c->to_index("Username $username is taken")
    if $c->users->{$username};

  return $c->to_index('Password cannot be blank')
    unless my $password = $c->param('password');

  return $c->to_index('Email cannot be blank')
    unless my $email = $c->param('email');

  $c->users->{$username} = {
    email     => $email,
    password  => $c->bcrypt($password),
    confirmed => 0,
  };
  my $jwt = $c->jwt->claims({username => $username})->encode;
  my $url = $c->url_for('confirm')->to_abs->query(jwt => $jwt);
  $c->email($email, 'Confirm registration', "Please visit $url to confirm");
  $c->to_index('registration complete, please confirm via email');
};

get '/confirm' => sub {
  my $c = shift;
  my $username = $c->jwt->decode($c->param('jwt'))->{username};
  $c->users->{$username}{confirmed} = 1;
  $c->to_index('registration confirmed, please log in');
};

app->start;

__DATA__

@@ index.html.ep

<p>Hello Guest!</p>
<p><%= flash('message') || 'Sign in or sign up!' %></p>

%= form_for sign_in => begin
  %= label_for username => 'Username'
  %= text_field 'username'

  %= label_for password => 'Password'
  %= password_field 'password'

  %= label_for email => 'Email'
  %= email_field 'email', placeholder => 'sign up only'

  <br>
  %= submit_button 'Sign In'
  %= submit_button 'Sign Up', formaction => url_for('sign_up')
% end

@@ logged_in.html.ep

<p>Welcome back <%= session 'username' %>!</p>
<p><%= link_to 'Log out' => 'logout' %></p>


本文最初发布在PerlTricks.com

标签

Joel Berger

Joel Berger是一位科学家和Perl程序员。他是Mojolicious Web框架的核心贡献者,经常写博客,您可以在Twitter上找到他。

浏览他们的文章

反馈

本文有误?请通过在GitHub上打开问题或拉取请求来帮助我们。