65.9K
CodeProject 正在变化。 阅读更多。
Home

用于 Express 类型应用程序的密码流程中间件

starIconstarIconstarIconstarIconstarIcon

5.00/5 (3投票s)

2021年4月27日

CPOL

13分钟阅读

viewsIcon

9191

downloadIcon

67

authentication-flows-js 是一个强大且高度可定制的 Node.js 中间件,涵盖了任何基于 Express 的认证服务器所需的所有流程。

动机

当您使用 Node.js® 开发 Web 应用程序时,您会发现最简单的方法是使用 Express。Express 支持多种插件(如 session 等)。但是,如果您的应用程序中的某些页面是受保护的并且需要身份验证,那么 Express 在身份验证方面提供的功能非常有限。而且,如果我们提到身份验证,我们还应该指出用户应该能够自我注册(注册)。同样,如果用户忘记了密码或想更改密码,应用程序也应该在这方面提供帮助。

我将这些流程称为“身份验证流程”。每个受保护的 Web 应用程序都应该支持这些流程,除非它将身份验证委托给第三方(如 oAuth2.0),否则我们会发现自己一遍又一遍地编写相同的代码。如果您开发了多个受保护的 Web 应用程序,您可能会发现自己将代码从一个应用程序复制粘贴到另一个应用程序。而且,如果您在实现中发现错误,您必须在所有应用程序中修复它。

我考虑编写一个可重用的包,这样任何受保护的 Web 应用程序都可以将此包添加为依赖项,然后——所有上述流程都将实现,并且配置最少。这样,开发人员就可以专注于开发应用程序的核心,而不是纠缠于那些绝对不是他们业务核心的流程。

我将此模块称为“身份验证流程模块”或简称 **AFM**。

要求(和假设)

首先,我假设应用程序使用了所谓的“本地策略”,这意味着应用程序管理自己的用户名和密码。没有 oAuth,也没有像 AWS Cognito 这样的云解决方案等。

其次,我假设托管 Web 应用程序是基于 Express 的。在应用程序初始化 Express(包括 body-parser 和 express-session)后,将其传递给 AFM,AFM 会向其添加端点。因此,应用程序支持所有端点,如 /login/createAccount/forgotPassword 等。

第三,可以合理地假设应用程序有自己的 UI——每个应用程序都希望其页面保持相同的外观和感觉,这包括登录页面、创建账户页面等。一个应用程序可能使用 ejs,第二个应用程序可能使用简单的 HTML/CSS,第三个应用程序可能使用 React,等等。因此,实现后端部分的 AFM 必须与 UI 完全解耦。

第四,AFM 向用户发送电子邮件:验证电子邮件、解锁账户和恢复(忘记密码)电子邮件。每个应用程序都管理自己的发送电子邮件的方式。也许托管应用程序在其逻辑中发送电子邮件,因此它应该重用相同的电子邮件服务器。在其他情况下,托管应用程序可能更喜欢使用特定的提供商,如 Google、mailgun,或者只是一个 SMTP 服务器,如 SMTP2GO。**AFM 应该足够灵活**,以支持所有这些情况。

在这些电子邮件中,会发送一个链接给用户。这个**链接应该在预配置时间后过期**,并且**只能使用一次**。

第五,每个应用程序使用不同的存储库(或多个存储库)。一种方法是 AFM 选择一个存储库实现——例如 mongoDB——并将数据存储在那里。但缺点是它强制所有托管应用程序都使用 mongoDB。如果某个应用程序不想或不能使用 mongoDB,该怎么办?因此,AFM 应该对不同的存储库实现足够灵活。

第六,AFM 将尽可能具有可扩展性。它将向托管应用程序公开端点,以便某些部分可以扩展。例如,如果托管应用程序只允许来自特定域的电子邮件(用户名),AFM 将公开一个可以在创建帐户流程中实现和执行的 API。

安全。安全。安全。

设计

存储库

AFM 将用户信息(编码的凭据等)与您的数据一起存储在您的存储解决方案中。它使用存储库层通过 API(接口)进行访问。托管应用程序决定使用哪个存储库,并需要使用所需存储库的实现。例如,如果托管应用程序使用 SQL,它可以利用现有的 SQL 实现。如果它使用当前没有实现的存储库(如 CouchDB 或 Cassandra),它必须实现该存储库的接口。

提供了一个默认的内存实现(主要用于测试)。

最简单、最紧凑的解决方案是在 AFM 中为所有存储库开发实现,但这将导致一个超重的模块,它依赖于所有存储库的客户端。例如,要支持 SQL、elasticsearch 和 mongoDB,AFM 必须在 package.json 中包含 mongoDB 客户端、SQL 客户端等。另一个缺点是,如果我们发现其中一个实现存在错误,我们就必须为整个 AFM 发布一个新版本来修复它。

我们的方法是**为每个实现发布一个单独的模块**。因此,将有一个用于内存实现的模块,另一个用于 mongoDB,依此类推。这样,AFM 本身就更健壮,并且与存储库实现完全解耦,它更轻量级,并且每个实现都独立于其他实现。**因此,托管应用程序将不得不依赖 AFM 以及它使用的存储库实现**。

数据库实现模式

接口 AuthenticationAccountRepository

import { AuthenticationUser } from "../..";

export interface AuthenticationAccountRepository
{
   loadUserByUsername(email: string): Promise<AuthenticationUser>;

   /**
    * Create a new user with the supplied details.
    */
   createUser(authenticationUser: AuthenticationUser): void;

   /**
    * Remove the user with the given login name from the system.
    * @param email
    */
   deleteUser(email: string): void;

   /**
    * Check if a user with the supplied login name exists in the system.
    */
   userExists(username: string): Promise<boolean>;

   setEnabled(email: string);
   setDisabled(email: string);
   isEnabled(email: string): Promise<boolean>;

// boolean changePassword(String username, String newEncodedPassword);
   
   /**
    * 
    * @param email
    */
   decrementAttemptsLeft(email: string);
   setAttemptsLeft(email: string, numAttemptsAllowed: number);

   /**
    * sets a password for a given user
    * @param email - the user's email
    * @param newPassword - new password to set
    */
   setPassword(email: string, newPassword: string);

   getEncodedPassword(username: string): Promise<string>;
   getPasswordLastChangeDate(email: string): Promise<Date>;

   setAuthority(username: string, authority: string);

   // LINKS:

   addLink(username: string, link: string);

   /**
    *
    * @param username- the key in the map to whom the link is attached
    * @return true if link was found (and removed). false otherwise.
    */
   removeLink(username: string): Promise<boolean>;

   getLink(username: string): Promise<{ link: string, date: Date }>;

   /**
    * @param link
    * @throws Error if link was not found for any user
    */
   getUsernameByLink(link: string): Promise<string>;
}

电子邮件

用户注册后,AFM 会向用户发送验证电子邮件。在其他用例中,如果用户忘记了密码,AFM 会向注册的电子邮件发送一个链接,以验证是否是本人。在另一个用例中,当允许的登录尝试次数超过时,帐户将被锁定。

发送电子邮件的方式有很多种,每个应用程序都可以选择其偏好的方式。nodemailer 是一种方便的发送电子邮件方式,但您需要配置一个提供商(Google、Microsoft 等)。SMTP2GO 是一个提供商的例子。

另一个电子邮件选项是mailgun,它允许您通过其 API(而不仅仅是 SMTP)发送电子邮件。

**AFM 允许托管应用程序决定如何发送电子邮件**。默认情况下,AFM 通过 SMTP2GO 使用 nodemailer,但托管应用程序可以实现 MailSender 接口并拥有自己的电子邮件发送实现。它必须使用托管应用程序的凭据进行配置(例如,SMTP2GO 需要用户名/密码,mailgun 需要 APIKEY,依此类推)。

链接

在前面提到的电子邮件中,有一些链接需要点击。例如,通过点击激活链接,用户确认注册的电子邮件是有效的。当服务器响应链接时,它需要知道哪个用户点击了它。

一种方法是将用户名与链接的过期日期一起加密,然后编码并发送给用户。但由于 AFM 确保链接只使用一次,因此它存储在数据库中。创建链接时,它存储在数据库中,使用时,它会被删除。如果再次点击链接,AFM 将在存储库中找不到它,并将引发错误。

另一种方法是生成一个 UUID(又名“令牌)并将其发送给用户。此令牌存储在用户名的同一记录的存储库中,因此无需加密和编码(因此无需私钥/公钥)。当用户点击链接时,AFM 在存储库中搜索它,并找到用户是谁。

无论如何,AFM 需要在存储库中存储*某些*内容。AFM 使用第二种方法,并将生成的令牌以及创建链接的时间一起存储,以跟踪过期。这存储在用户表中。

可扩展性和定制性

AFM 可以通过拦截器类进行扩展。这些方法在流程的关键点由 AFM 调用。例如,可以扩展 CreateAccountInterceptor 类并重写其方法。因此,可以实现 postCreateAccount 方法,以便在创建帐户后由 AFM 调用。

实现

创建帐户

用户填写创建帐户表单并点击“**提交**”。服务器(AFM)会验证多项内容。例如,它会验证电子邮件是否有效,重输入的密码是否与密码匹配,以及密码是否符合密码约束(长度等)。托管应用程序可以通过扩展 CreateAccountInterceptor 类来添加更多验证。请注意,某些验证可以在(也应该)由 UI 检查,但服务器应该采取保护措施,以防托管 AFM 的 UI 疏忽。

密码将被哈希(sha-256)并编码(base64)。这是为了确保数据库中存储的密码不会被泄露。

接下来,我们在数据库中检查具有相同电子邮件的用户是否已存在且已启用。在这种情况下,会引发错误。否则,将创建一个新用户并将其存储在数据库中,然后向用户发送一封包含唯一字符串的电子邮件。该字符串与用户一起存储在同一行/文档/记录中。

用户在收件箱中收到电子邮件,通过点击链接,他们确认他们创建了帐户。如果有人试图使用他人的电子邮件创建帐户,该链接将不会被点击,因此帐户将不会被激活。

当用户点击链接时,AFM 会从数据库中删除此链接(以便无法再次使用),并激活帐户(通过启用它)。

创建帐户序列图
async createAccount(email: string, password: string, retypedPassword: string, 
                    firstName: string, lastName: string, serverPath: string) {
    //validate the input:
    AuthenticationFlowsProcessor.validateEmail(email);

    this.validatePassword(password);

    AuthenticationFlowsProcessor.validateRetypedPassword(password, retypedPassword);

    //encrypt the password:
    const encodedPassword: string = shaString(password);

    //make any other additional chackes. This let applications override this impl 
    //and add their custom functionality:
    this.createAccountEndpoint.additionalValidations(email, password);

    email = email.toLowerCase();      // issue #23 : username is case-sensitive
    debug('createAccount() for user ' + email);
    debug('encoded password: ' + encodedPassword);

    let authUser: AuthenticationUser = null;
    try
    {
        authUser = await this._authenticationAccountRepository.loadUserByUsername( email );
    }
    catch(unfe)
    {
        //basically do nothing - we expect user not to be found.
    }
    debug(`oauthUser: ${authUser}`);

    //if user exists, but is not activated - we allow re-registration:
    if(authUser)
    {
        if( !authUser.isEnabled())
        {
            await this._authenticationAccountRepository.deleteUser( email );
        }
        else
        {
            //error - user already exists and active
            //log.error( "cannot create account - user " + email + " already exist." );
            debug( "cannot create account - user " + email + " already exist." );
            throw new AuthenticationFlowsError( USER_ALREADY_EXIST );
        }
    }

    const authorities: string[] = this.setAuthorities();      //set authorities
    authUser = new AuthenticationUserImpl(
        email, encodedPassword,
        false,             //start as de-activated
        this._authenticationPolicyRepository.
              getDefaultAuthenticationPolicy().getMaxPasswordEntryAttempts(),
        null,              //set by the repo-impl
        firstName,
        lastName,
        authorities);

    debug(`authUser: ${authUser}`);

    await this._authenticationAccountRepository.createUser(authUser);

    await this.createAccountEndpoint.postCreateAccount( email );

    const token: string = randomString();
    const activationUrl: string = serverPath + ACTIVATE_ACCOUNT_ENDPOINT +
        "/" + token;
    //persist the "uts", so this activation link will be single-used:
    await this._authenticationAccountRepository.addLink( email, token );

    debug("sending registration email to " + email + "; activationUrl: " + activationUrl);

    await this._mailSender.sendEmail(email,
        AUTHENTICATION_MAIL_SUBJECT,
        activationUrl );
}

忘记密码

用户在忘记密码表单中输入他们的电子邮件,然后点击**提交**。服务器(AFM)会验证帐户是否存在且未被锁定。如果被锁定,AFM 将引发错误。否则,将向用户发送一封包含令牌的电子邮件。该令牌与用户在同一行/文档/记录中存储在数据库中。

用户在收件箱中收到电子邮件,通过点击链接确认他们发起了“忘记密码”流程(以防止恶意方重置另一个帐户的密码)。

当用户点击链接(包含令牌)时,AFM 会检查令牌(是否存在且未过期)。如果有效,它会将用户重定向到“设置新密码”页面。请注意,我们尚未从数据库中删除此令牌。

用户填写“设置新密码”表单并点击**提交**。与“创建帐户”流程一样,服务器(AFM)会验证重输入的密码是否与密码匹配,验证密码是否符合密码约束(length 等),然后将新密码(哈希并编码)存储在数据库中,并删除令牌。

忘记密码序列图
async forgotPassword(email: string, serverPath: string) {
    debug('forgotPassword() for user ' + email);

    AuthenticationFlowsProcessor.validateEmail(email);

    //if account is already locked, no need to ask the user the secret question:
    if( ! await this._authenticationAccountRepository.isEnabled(email) )
    {
        //security note: Even if we don’t find an email address, we return 'ok'. 
        //We don’t want untoward bots figuring out 
        //what emails are real vs not real in our database.
        //throw new Error( ACCOUNT_LOCKED_OR_DOES_NOT_EXIST );
        return;
    }

    await this.sendPasswordRestoreMail(email, serverPath);
}

锁定帐户

当用户超过允许的登录尝试次数时,帐户将被锁定。发生这种情况时,“enabled”标志将设置为 false,并向用户发送重新激活电子邮件。该流程与帐户创建非常相似(参见图表)。

更改密码

在更改密码流程中,用户已登录,因此 AFM 不必通过发送电子邮件来确认用户身份;然而,从安全的角度来看,AFM 仍然在此流程中向用户发送电子邮件,以防止用户离开未受保护的应用程序时,恶意方尝试更改密码。因此,发送电子邮件用于确认,但也用于通知。

链接

在 AFM 的早期版本(0.0.82 之前)(以及 Java 的 authentication-flows 中),它使用密码学来加密用户名和链接的过期时间,因此当点击链接时,AFM 会在服务器端解密它并获得用户名和过期验证。现在,这不再需要了,AFM 可以只在链接中发送一个唯一的字符串作为令牌,该令牌与用户名一起存储在数据库中。当点击链接时,AFM 在数据库中搜索令牌并找到用户名。

如何获取?

如何使用?

示例应用

有一个示例应用程序使用了 `authentication-flows-js`,所以这是一个很好的起点。下面是
所需的配置。

存储库适配器

根据设计,托管应用程序选择其工作的存储库,并传递相应的适配器

const app = express();
var authFlows = require('authentication-flows-js');
const authFlowsES = require('authentication-flows-js-elasticsearch');
const esRepo = new authFlowsES.AuthenticationAccountElasticsearchRepository();

authFlows.config({
    user_app: app,
    authenticationAccountRepository: repo,
});

目前,支持以下存储库

Express 服务器对象

此模块*重用*客户端应用程序的 Express 服务器并向其添加了几个端点(例如,`/createAccount`)。
因此,客户端应用程序应将其服务器对象(以上示例)传递给 authentication-flows-js。

密码策略

authentication-flows-js 提供了一组默认的密码策略配置(在 /config/authentication-policy-repository-config.json 中)。托管应用程序可以替换/编辑 JSON 文件,并使用自己首选的值。

密码策略包含以下属性(及其默认值)

{
    passwordMinLength: 6,
    passwordMaxLength: 10,
    passwordMinUpCaseChars: 1,
    passwordMinLoCaseChars: 1,
    passwordMinNumbericDigits: 1,
    passwordMinSpecialSymbols: 1,
    passwordBlackList: ["password", "123456"],
    maxPasswordEntryAttempts: 5,
    passwordLifeInDays: 60
}

上面提到的示例应用程序中可以找到客户端应用程序的示例。

安全注意事项

  • AFM 确保密码匹配并满足最低要求。
  • 为了将凭据(例如 SMTP2GO 的凭据)存储在代码之外,托管应用程序应该使用dotenv,它从本地 .env 文件读取环境变量。这样,当应用程序部署到生产环境时,它可以_使用不同的生产密钥,这些密钥不会在代码中可见,也不会存储在 GitHub 等 SCM 中。
  • 在“忘记密码”流程中,即使 AFM 没有找到电子邮件地址,它也会返回*“ok”*作为状态。我们不希望不当的机器人弄清楚数据库中哪些电子邮件是真实的,哪些不是。
  • 令牌中使用的随机字节越多,被黑客攻击的可能性就越小。AFM 在令牌生成器中使用了 64 个随机字节。
  • AFM 的令牌在 1 小时后过期。这限制了重置令牌有效的时间窗口。
  • AFM 只查找尚未过期且尚未使用的重置令牌。
  • 设置密码后,AFM 会*再次*检查重置令牌,以确保它尚未被使用且未过期。需要再次检查,因为令牌是通过表单由用户发送的。
  • 在重置密码之前,AFM 会将令牌标记为已使用。这样,如果发生任何意外情况(例如服务器崩溃),在令牌仍然有效的情况下,密码不会被重置。

自动化测试

不用说,测试是任何软件开发的关键部分。在 AFM 开发过程中,确保使用多个流程重用的代码不会损坏工作流程非常重要。有一个基于Cucumber单独的*自动化测试*项目

在此不详细解释 Cucumber,但值得一提的是,所有关键流程都经过了自动测试。例如,帐户创建。有一个测试会创建帐户,使用链接激活它,然后检查登录是否有效。

另一个测试,例如,是帐户锁定。该测试会创建一个帐户,激活它,然后登录 5 次失败,直到帐户被锁定。然后,该测试使用链接重新激活帐户,并验证登录是否有效。

所有测试都使用 AFM 的 Web API。因此,随着开发过程中代码的每一次更改,*所有*流程都可以在几秒钟内进行测试,以避免回归。

Cucumber 报告

非常感谢我的一位亲密朋友,**David Goldhar** 帮助我审阅了这篇文章。

历史

  • 2021 年 4 月 26 日:初始版本
  • 2021 年 4 月 27 日:添加了代码片段和 GitHub 项目链接
© . All rights reserved.