Amazon Cognito 是一项出色的服务,可以轻松开始身份验证。它还具有开箱即用的多因素身份验证 (MFA),使用手机发送 SMS 或 TOTP(基于时间的一次性密码)设备,例如 Authy 或 Google Authenticator。我的要求是仅通过电子邮件使用 MFA。我本来希望使用内置的 AWS Cognito MFA 工作流程,但这是该项目的硬性要求。这是在医疗保健行业,他们的电子邮件通常仅限于在医院网络上。因此,我只能通过电子邮件允许 MFA。

本文将介绍如何使用自定义身份验证流程和 Amazon SES(简单电子邮件服务)在 Amazon Cognito 上通过电子邮件实施 MFA。

首先,转到 AWS 控制台并设置 SES。确保您在 Amazon SES 中验证了电子邮件。如果您的账户处于 Amazon SES 的沙盒模式,您需要确保验证_发件人和收件人电子邮件地址_。当帐户处于生产模式时,您无需验证您发送到的电子邮件地址。我指出这一点是因为我花了 2 个小时试图弄清楚为什么由于这个问题发送电子邮件失败。

首先,我们必须通过 Lambda 使用自定义身份验证流程来处理与内置 AWS Cognito MFA 工作流程的这种偏差。 Amazon Cognito 通过 Lambda 函数工作,它们允许不同的钩子自定义身份验证流程:

[Amazon Cognito 生命周期](https://res.cloudinary.com/practicaldev/image/fetch/s--sh1vJ8Nf--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to -uploads.s3.amazonaws.com/uploads/articles/3yl4hpkdlsieeh3e3tfc.png)

Amazon Cognito 生命周期触发器:

  • 定义身份验证挑战:确定自定义身份验证流程中的下一个挑战(告诉 Cognito 我们的自定义挑战)

  • 创建身份验证质询:在自定义身份验证流程中创建质询(发送 MFA 代码)

  • 验证身份验证挑战:确定自定义身份验证流程中的响应是否正确(验证输入的代码是否与我们创建的代码匹配)

我们可以利用几个内置挑战,包括 FORCE_NEW_PASSWORD 和 PASSWORD_VERIFIER。

为了创建一个允许我们通过电子邮件使用 MFA 代码的自定义身份验证流程,我们将使用这些 Lambda 生命周期挂钩来实现它。身份验证流程如下:

1.用户在应用程序上输入他们的电子邮件和密码。

  1. 在我们的安全服务器端 (NodeJS/Express),我们将使用 UpdateUserAttributes Cognito 方法为用户生成一个新的 MFA 代码,并将其作为自定义属性保存到他们的属性中。

3.我们的define auth challenge lambda函数将被命中。如果用户有临时密码,我们将向客户端返回 FORCE_NEW_PASSWORD 质询。

  1. 由于自定义身份验证流程中的错误,用户更改密码后,我们将强制他们重新登录。该错误使我们无法在用户更改密码时遇到自定义挑战

  2. 好的,现在他们回到登录屏幕,他们将在其中输入电子邮件和新密码

  3. PASSWORD_VERIFIER 质询将运行验证用户密码。之后,我们将返回 CUSTOM_CHALLENGE - 我们的 MFA 通过电子邮件挑战!

  4. 我们的 create auth challenge lambda 函数将运行。在此函数中,我们将发送 MFA 代码。我们将拥有用户属性,并使用 SES 将在安全服务器端生成的代码通过电子邮件发送给他们。

  5. 创建挑战并发送电子邮件后,我们将返回当前身份验证流状态 (CUSTOM_CHALLENGE),该状态应更新 UI 以请求 MFA 代码。

  6. 用户输入他们从电子邮件中收到的 MFA 代码。现在我们将该答案发送到验证身份验证挑战。这将验证我们在用户当前 MFA 代码的自定义属性中的答案。我们还将确保有效代码的时间范围尚未过去。

第 1 部分:为 Cognito 生命周期挂钩创建无服务器项目

我将使用 Serverless 框架,因为它易于管理并且我很了解它。但是,如果您想保持简单,您可以只使用 Lambda 函数。

首先,通过 Amazon Cognito 控制台创建一个新池。您还可以使用云形成模板,我在这里提供了一个示例。

接下来,添加一个名为“authChallenge”的自定义属性。这将保存我们的 MFA 代码和时间戳,以确保它没有过期。

[自定义属性](https://res.cloudinary.com/practicaldev/image/fetch/s--WZHKBIKt--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to- uploads.s3.amazonaws.com/uploads/articles/nkwbum1dbly8e4gbucbv.png)

将多因素身份验证 (MFA) 设置为“关闭”,因为我们将通过自定义身份验证流程和 Cognito 生命周期触发器自己实现这一点。

创建池后,创建一个新的无服务器项目

npm install -g serverless     
serverless create -n cognito-mfa-email-example

进入全屏模式 退出全屏模式

定义生命周期函数

Define Cognito Lifecyle Event 将确定自定义身份验证流程中的下一个挑战。这是一个流程,我们将从 PASSWORD_VERIFIER 开始从一个事件转到另一个事件。有几个重要的属性需要返回:issueTokens、failAuthentication 和下一个要运行的 challengeName。一旦密码被确认(通过 Cognito),我们就会运行我们的自定义挑战。 PASSWORD_VERIFIER 是一个内置的 Cognito 质询,用于检查用户的密码。

module.exports.handler = async event => {
  // Kicks off with Secure Remote Password
  if (
    event.request.session.length === 1 &&
    event.request.session[0].challengeName === 'SRP_A' &&
    event.request.session[0].challengeResult === true
  ) {
    event.response.issueTokens = false
    event.response.failAuthentication = false
    event.response.challengeName = 'PASSWORD_VERIFIER'
    return event
  }
  if (event.request.userNotFound) {
    event.response.failAuthentication = true
    event.response.issueTokens = false
    return event
  }

  // Check result of last challenge
  if (
    event.request.session &&
    event.request.session.length > 2 &&
    event.request.session.slice(-1)[0].challengeResult === true
  ) {
    // The user provided the right answer - issue their tokens
    event.response.failAuthentication = false
    event.response.issueTokens = true
    return event
  }

  event.response.issueTokens = false
  event.response.failAuthentication = false
  event.response.challengeName = 'CUSTOM_CHALLENGE'
  return event
}

进入全屏模式 退出全屏模式

创建生命周期函数

Create 函数将运行,创建我们的 CUSTOM_CHALLENGE。我们将使用 AWS SES 在此函数中发送一封带有 MFA 代码的电子邮件。该请求将包含来自 Cognito 的用户属性,我们将使用 authChallenge 自定义属性来获取在我们的服务器端创建的代码,并将该代码通过电子邮件发送给他们将用于完成 MFA 的用户。我们还设置了我们将通过流程传递的 publicChallengeParameters 和 privateChallengeParameters。

const mailer = require('./mailer')
module.exports.handler = async event => {
  const challenge = event.request.userAttributes['custom:authChallenge']
  const [authChallenge, timestamp] = (event.request.userAttributes['custom:authChallenge'] || '').split(',')
  // This is sent back to the client app
  event.response.publicChallengeParameters = {
    email: event.request.userAttributes.email
  }

  // add acceptable answer
  // so it can be verified by the "Verify Auth Challenge Response" trigger
  event.response.privateChallengeParameters = {
    challenge: challenge
  }

  // we want to check and make sure we haven't sent the code before in this login session before sending the code
  if (event.request.session.length < 3 && !event.request.session.find(s => s.challengeName === 'CUSTOM_CHALLENGE'))
    await mailer.send(
      'Your Access Code',
      event.request.userAttributes.email,
      'Please use the code below to login: <br /><br /> <b>' + authChallenge + '</b>'
    )

  return event
}

进入全屏模式 退出全屏模式

验证生命周期功能

在验证生命周期函数中,我们将用户输入的代码与我们在 privateChallengeParameters 中的代码进行比较,以比较它们是否具有正确的代码。 Amazon Cognito 调用此触发器来验证最终用户对自定义身份验证质询的响应是否有效。我们还检查时间戳以确保它没有过期。我们根据他们是否正确回答来设置 answerCorrect 的挑战响应属性。您可能会遇到更多挑战,并且自定义挑战流程循环将重复,直到所有挑战都得到回答。

const LINK_TIMEOUT = 30 * 60

module.exports.handler = async event => {
  // Get challenge and timestamp
  const [authChallenge, timestamp] = (event.request.privateChallengeParameters.challenge || '').split(',')

  // Check if code is equal to what we expect...
  if (event.request.challengeAnswer === authChallenge) {
    // Check if the link hasn't timed out
    if (Number(timestamp) > new Date().valueOf() / 1000 - LINK_TIMEOUT) {
      event.response.answerCorrect = true
      return event
    }
  }

  event.response.answerCorrect = false
  return event
}

进入全屏模式 退出全屏模式

部署项目

我在这里](https://github.com/james-ingold/cognito-mfa-email-example/blob/main/serverless.yml)包含了完整的[示例 serverless.yaml。您将需要以下 IAM 角色功能和以下函数声明。我使用 Amazon Systems Parameter Store 来设置创建生命周期函数中使用的 EMAIL_ADDRESS 变量。

serverless.yaml 示例

iamRoleStatements:
    - Effect: "Allow"
      Action:
        - cognito-idp:AdminGetUser
        - cognito-idp:AdminUpdateUserAttributes
      Resource:
        - {"Fn::GetAtt": [UserPool, Arn]}
    - Effect: "Allow"
      Action:
        - ses:SendEmail
      Resource:
        - "*"

functions:
  define:
    handler: defineAuth.handler
    events:
      - cognitoUserPool:
          pool: ${self:custom.userPoolName}
          trigger: DefineAuthChallenge
          existing: true
  create:
    handler: createAuth.handler
    environment:
      EMAIL_ADDRESS: ${ssm:/${self:provider.stage}_EMAIL_ADDRESS}
    events:
      - cognitoUserPool:
          pool: ${self:custom.userPoolName}
          trigger: CreateAuthChallenge
          existing: true
  verify:
    handler: verify.handler
    events:
      - cognitoUserPool:
          pool: ${self:custom.userPoolName}
          trigger: VerifyAuthChallengeResponse
          existing: true

进入全屏模式 退出全屏模式

第 2 部分:伪后端代码

现在我们已经设置了 Cognito 处理程序和用户池,我们可以运行一些处理身份验证序列的示例服务器端代码。我们从启动身份验证流程的 SRP_A(安全远程密码)质询开始。

登录路径:启动身份验证流程并准备自定义挑战
router.post('/login', async (req, res, next) => {
  try {
    const SRP_A = CognitoHelper.calculateA()
    const userHash = generateHash(req.body.username, this.secretHash, this.clientId)
    const params = {
      UserPoolId: this.poolId,
      AuthFlow: 'CUSTOM_AUTH',
      ClientId: this.clientId,
      AuthParameters: {
        USERNAME: req.body.username,
        PASSWORD: req.body.password,
        SECRET_HASH: userHash,
        SRP_A: SRP_A,
        CHALLENGE_NAME: 'SRP_A'
      }
    }

    try {
      const data = await this.cognitoIdentity.adminInitiateAuth(params).promise()
      // SRP_A response
      const { ChallengeName, ChallengeParameters, Session } = data
      // Set up User for MFA
      const authChallenge = _.map([...Array(8).keys()], n => Math.floor(Math.random() * 10)).join('')
      await this.cognitoIdentity
        .adminUpdateUserAttributes({
          UserAttributes: [
            {
              Name: 'custom:authChallenge',
              Value: `${authChallenge},${Math.round(new Date().valueOf() / 1000)}`
            }
          ],
          UserPoolId: this.poolId,
          Username: req.body.username
        })
        .promise()
      const hkdf = CognitoHelper.getPasswordAuthenticationKey(
        ChallengeParameters.USER_ID_FOR_SRP,
        req.body.password,
        ChallengeParameters.SRP_B,
        ChallengeParameters.SALT,
        this.poolId
      )
      const dateNow = CognitoHelper.getNowString()
      const signatureString = CognitoHelper.calculateSignature(
        hkdf,
        this.poolIdOnly,
        ChallengeParameters.USER_ID_FOR_SRP,
        ChallengeParameters.SECRET_BLOCK,
        dateNow
      )
      const responseParams = {
        ChallengeName: ChallengeName,
        ClientId: this.clientId,
        ChallengeResponses: {
          PASSWORD_CLAIM_SIGNATURE: signatureString,
          PASSWORD_CLAIM_SECRET_BLOCK: ChallengeParameters.SECRET_BLOCK,
          TIMESTAMP: dateNow,
          USERNAME: ChallengeParameters.USER_ID_FOR_SRP,
          SECRET_HASH: generateHash(ChallengeParameters.USERNAME, this.secretHash, this.clientId)
        },
        Session: Session
      }
      // Password verifier response, should be set up for custom mfa challenge now
      const respData = await this.cognitoIdentity.respondToAuthChallenge(responseParams).promise()
      res.status(200).json(respData).end()
    }
  } catch (err) {
    console.log(err)
    if (err.code === 'UserNotFoundException') return res.status(400).send('Invalid Credentials')
    res.sendStatus(500)
  }
})

进入全屏模式 退出全屏模式

验证路由:这处理使用 Cognito 响应我们的身份验证挑战,这将调用我们的验证生命周期处理程序。
router.post('/verify', async (req, res, next) => {
  try {
    const { email, password, confirmPassword, user, code } = req.body
    const params = {
      ChallengeName: user.ChallengeName,
      ClientId: this.clientId,
      ChallengeResponses: {
        USERNAME: email,
        ANSWER: code,
        SECRET_HASH: generateHash(email, this.secretHash, this.clientId)
      },
      Session: user.Session
    }
    const data = await this.cognitoIdentity.respondToAuthChallenge(params).promise()

    // if we failed the custom challenge
    if (!data.AuthenticationResult && user.ChallengeName === 'CUSTOM_CHALLENGE' && code) {
      data.error = 'Invalid Code'
      return res.json({ success: false, data: data })
    } else {
      // add any user claims here
      data.AuthenticationResult.UserClaims = {}
      return res.json({ success: true, data: data })
    }
  } catch (err) {
    console.log(err)
    res.sendStatus(500)
  }
})

进入全屏模式 退出全屏模式

结论

而已!我们现在构建了一个自定义身份验证工作流程,用于使用 Amazon Cognito 通过电子邮件处理 MFA。我们的服务器代码启动身份验证流程,然后提供质询响应,直到我们验证用户拥有有效的 MFA 代码。快乐的编码!

参考文献:

源代码库

Cognito 自定义身份验证 Lambda

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐