AWS Cognito:处理相同用户(使用相同电子邮件地址)从不同身份提供商(Google、Facebook)登录的最佳实践

Posted

技术标签:

【中文标题】AWS Cognito:处理相同用户(使用相同电子邮件地址)从不同身份提供商(Google、Facebook)登录的最佳实践【英文标题】:AWS Cognito: Best practice to handle same user (with same email address) signing in from different identity providers (Google, Facebook) 【发布时间】:2020-04-25 09:02:46 【问题描述】:

当通过 Google 和 Facebook 身份提供商使用相同的电子邮件地址登录用户时,AWS Cognito 会在用户池中创建多个条目,每个身份提供商使用一个条目:

我已使用本教程中提供的示例代码来设置 AWS Cognito:The Complete Guide to User Authentication with the Amplify Framework

如何只创建一个用户而不是多个用户? 是否可以让 AWS Cognito 自动组合(联合)条目 从多个提供商到一个条目,还是应该使用 AWS Lambda 函数来完成此操作?

【问题讨论】:

【参考方案1】:

是的。您可以使用AdminLinkProviderForUser https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_AdminLinkProviderForUser.html 来做到这一点

想法是:

    在 PreSignUp lambda 挂钩中,如果用户已经注册,我们会将提供者链接到用户。例如:
import CognitoIdentityServiceProvider from 'aws-sdk/clients/cognitoidentityserviceprovider'

const cognitoIdp = new CognitoIdentityServiceProvider()
const getUserByEmail = async (userPoolId, email) => 
 const params = 
   UserPoolId: userPoolId,
   Filter: `email = "$email"`
 
 return cognitoIdp.listUsers(params).promise()


const linkProviderToUser = async (username, userPoolId, providerName, providerUserId) => 
 const params = 
   DestinationUser: 
     ProviderAttributeValue: username,
     ProviderName: 'Cognito'
   ,
   SourceUser: 
     ProviderAttributeName: 'Cognito_Subject',
     ProviderAttributeValue: providerUserId,
     ProviderName: providerName
   ,
   UserPoolId: userPoolId
 

 const result = await (new Promise((resolve, reject) => 
   cognitoIdp.adminLinkProviderForUser(params, (err, data) => 
     if (err) 
       reject(err)
       return
     
     resolve(data)
   )
 ))

 return result


exports.handler = async (event, context, callback) => 
 if (event.triggerSource === 'PreSignUp_ExternalProvider') 
   const userRs = await getUserByEmail(event.userPoolId, event.request.userAttributes.email)
   if (userRs && userRs.Users.length > 0) 
     const [ providerName, providerUserId ] = event.userName.split('_') // event userName example: "Facebook_12324325436"
     await linkProviderToUser(userRs.Users[0].Username, event.userPoolId, providerName, providerUserId)
    else 
     console.log('user not found, skip.')
   

 
 return callback(null, event)

    然后当用户通过用户池使用 Facebook/Google 的 OAuth 时,池将返回此用户链接。

注意:您可能会在用户池 UI 中看到 2 条记录,但在访问用户记录详细信息时,它们已经合并。

【讨论】:

这听起来是个不错的解决方案。我也阅读了文档。但是,由于我对 Cognito 很陌生,我应该把代码放在哪里?任何指导将不胜感激。谢谢! 您可以利用 Amazon Cognito 用户池中的 Lambda 触发器来确保根据需要链接用户(具有相同的电子邮件)。 @AlexR 我刚刚为这个想法添加了 PreSignUp Lambda Trigger 示例代码。 我无法为 SAML 提供者完成这项工作,导致用户已存在错误 首次尝试使用社交服务提供商登录会生成“已找到用户名条目”错误。这是 AWS Support 论坛未解决的问题(自 2017 年以来):forums.aws.amazon.com/… 和来自 SO ***.com/questions/47815161/…的详细问题【参考方案2】:

我一直在摆弄同样的问题。接受的答案类型的作品,但不涵盖所有场景。最主要的是,一旦用户使用外部登录名注册,他们将永远无法使用用户名和密码进行注册。目前,Cognito 不允许将 Cognito 用户链接到外部用户。

我的场景如下:

场景

    当用户使用用户名密码注册并注册外部提供商时,链接它们。 当用户通过外部提供商注册时,允许他们使用用户名和密码进行注册。 在所有链接用户之间有一个共同的username,以将其用作其他服务中的唯一 ID。

我建议的解决方案是始终首先创建 Cognito 用户并将所有外部用户链接到它。

建议的解决方案

    用户首先使用用户名/密码注册,然后使用外部用户注册。没有剧情,只需将外部用户与 Cognito 用户关联即可。 用户先用外部用户注册,然后想用用户名/密码注册。在这种情况下,首先创建一个 Cognito 用户,然后将外部用户链接到这个新的 Cognito 用户。如果用户将来尝试使用用户名/密码进行注册,他们将收到 user already exists 错误。在这种情况下,他们可以使用forgot password 流进行恢复然后登录。
const 
  CognitoIdentityServiceProvider
 = require('aws-sdk');


const handler = async event => 
  const userPoolId = event.userPoolId;
  const trigger = event.triggerSource;
  const email = event.request.userAttributes.email;
  const givenName = event.request.userAttributes.given_name;
  const familyName = event.request.userAttributes.family_name;
  const emailVerified = event.request.userAttributes.email_verified;
  const identity = event.userName;
  const client = new CognitoIdentityServiceProvider();

  if (trigger === 'PreSignUp_ExternalProvider') 

    await client.listUsers(
        UserPoolId: userPoolId,
        AttributesToGet: ['email', 'family_name', 'given_name'],
        Filter: `email = "$email"`
      )
      .promise()
      .then((
        Users
      ) => Users.sort((a, b) => (a.UserCreateDate > b.UserCreateDate ? 1 : -1)))
      .then(users => users.length > 0 ? users[0] : null)
      .then(async user => 
        // user with username password already exists, do nothing
        if (user) 
          return user;
        

        // user with username password does not exists, create one
        const newUser = await client.adminCreateUser(
            UserPoolId: userPoolId,
            Username: email,
            MessageAction: 'SUPPRESS', // dont send email to user
            UserAttributes: [
                Name: 'given_name',
                Value: givenName
              ,
              
                Name: 'family_name',
                Value: familyName
              ,
              
                Name: 'email',
                Value: email
              ,
              
                Name: 'email_verified',
                Value: emailVerified
              
            ]
          )
          .promise();
          // gotta set the password, else user wont be able to reset it
          await client.adminSetUserPassword(
              UserPoolId: userPoolId,
              Username: newUser.Username,                                                      
              Password: '<generate random password>',                                                       
              Permanent: true
          ).promise();
    
          return newUser.Username;
      ).then(username => 
        // link external user to cognito user
        const split = identity.split('_');
        const providerValue = split.length > 1 ? split[1] : null;
        const provider = ['Google', 'Facebook'].find(
          val => split[0].toUpperCase() === val.toUpperCase()
        );

        if (!provider || !providerValue) 
          return Promise.reject(new Error('Invalid external user'));
        

        return client.adminLinkProviderForUser(
            UserPoolId: userPoolId,
            DestinationUser: 
              ProviderName: 'Cognito',
              ProviderAttributeValue: username
            ,
            SourceUser: 
              ProviderName: provider,
              ProviderAttributeName: 'Cognito_Subject',
              ProviderAttributeValue: providerValue
            
          )
          .promise()
      );
  

  return event;
;

module.exports = 
  handler
;


【讨论】:

我这几天一直在与这种流量作斗争。让它发挥作用真是太痛苦了。每次我成功完成此流程并尝试使用社交提供者登录/注册时,我都会收到 invalid_grant 错误。 Cognito是确保真正的原因,因此不可能找到真正的原因。太令人沮丧了! 好吧。我放弃了 Cognito,现在正在使用 auth0。它是没有得到团队任何喜爱的 AWS 产品之一。 我修复了无效授权,我使用了错误的用户名(电子邮件地址而不是应该是 AdminCreateUser 响应的用户名)但是,我的电子邮件地址仍然存在此流程的问题never 正在验证状态,这意味着 Cognito 未发送重置密码的请求。我尝试了所有可以找到的方法来设置已验证的电子邮件地址。这是不可能的。 我在预期的等待字上遇到语法错误 @NigelYong 为函数添加 async 关键字【参考方案3】:

我认为,我创建的解决方案可以处理所有情况。它还解决了 Cognito 的一些常见问题。

如果用户注册的是外部提供商,请将其链接到任何现有帐户,包括 Cognito(用户名/密码)或外部提供商帐户。 链接到现有帐户时,仅链接到最旧的帐户。这一点很重要,因为您有 2 个以上的登录选项。 如果用户使用 Cognito(用户名/密码)注册,如果外部提供商已存在,则拒绝注册并显示自定义错误消息(因为无法关联帐户)。

请注意,在关联帐户时,Cognito 预注册触发器会返回“已找到用户名条目”错误。您的客户端应处理此问题并重新尝试身份验证,或要求用户再次登录。更多信息在这里:

Cognito auth flow fails with "Already found an entry for username Facebook_10155611263153532"

这是我的 lambda,在 Cognito 预注册触发器上执行

const AWS = require("aws-sdk");
const cognito = new AWS.CognitoIdentityServiceProvider();

exports.handler = (event, context, callback) => 

  function checkForExistingUsers(event, linkToExistingUser) 

    console.log("Executing checkForExistingUsers");

    var params = 
      UserPoolId: event.userPoolId,
      AttributesToGet: ['sub', 'email'],
      Filter: "email = \"" + event.request.userAttributes.email + "\""
    ;

    return new Promise((resolve, reject) =>
      cognito.listUsers(params, (err, result) => 
        if (err) 
          reject(err);
          return;
        
        if (result && result.Users && result.Users[0] && result.Users[0].Username && linkToExistingUser) 
          console.log("Found existing users: ", result.Users);
          if (result.Users.length > 1)
            result.Users.sort((a, b) => (a.UserCreateDate > b.UserCreateDate) ? 1 : -1);
            console.log("Found more than one existing users. Ordered by createdDate: ", result.Users);
          
          linkUser(result.Users[0].Username, event).then(result => 
              resolve(result);
            )
            .catch(error => 
              reject(err);
              return;
            );
         else 
          resolve(result);
        

      )
    );

  

  function linkUser(sub, event) 
    console.log("Linking user accounts with target sub: " + sub + "and event: ", event);

    //By default, assume the existing account is a Cognito username/password
    var destinationProvider = "Cognito";
    var destinationSub = sub;
    //If the existing user is in fact an external user (Xero etc), override the the provider
    if (sub.includes("_")) 
      destinationProvider = sub.split("_")[0];
      destinationSub = sub.split("_")[1];
    
    var params = 
      DestinationUser: 
        ProviderAttributeValue: destinationSub,
        ProviderName: destinationProvider
      ,
      SourceUser: 
        ProviderAttributeName: 'Cognito_Subject',
        ProviderAttributeValue: event.userName.split("_")[1],
        ProviderName: event.userName.split("_")[0]
      ,
      UserPoolId: event.userPoolId
    ;
    console.log("Parameters for adminLinkProviderForUser: ", params);
    return new Promise((resolve, reject) =>
      cognito.adminLinkProviderForUser(params, (err, result) => 
        if (err) 
          console.log("Error encountered whilst linking users: ", err);
          reject(err);
          return;
        
        console.log("Successfully linked users.");
        resolve(result);
      )
    );
  

  console.log(JSON.stringify(event));

  if (event.triggerSource == "PreSignUp_SignUp" || event.triggerSource == "PreSignUp_AdminCreateUser") 

    checkForExistingUsers(event, false).then(result => 
        if (result != null && result.Users != null && result.Users[0] != null) 
          console.log("Found at least one existing account with that email address: ", result);
          console.log("Rejecting sign-up");
          //prevent sign-up
          callback("An external provider account alreadys exists for that email address", null);
         else 
          //proceed with sign-up
          callback(null, event);
        
      )
      .catch(error => 
        console.log("Error checking for existing users: ", error);
        //proceed with sign-up
        callback(null, event);
      );

  

  if (event.triggerSource == "PreSignUp_ExternalProvider") 

    checkForExistingUsers(event, true).then(result => 
        console.log("Completed looking up users and linking them: ", result);
        callback(null, event);
      )
      .catch(error => 
        console.log("Error checking for existing users: ", error);
        //proceed with sign-up
        callback(null, event);
      );

  

;

【讨论】:

我仍然面临这个问题 [ERROR] 06:54.698 OAuth - 处理身份验证响应时出错。错误:已经+找到+an+entry+for+用户名+google_102979183231414988328+ 我已经找到了用户名 google_**** 的条目。对这个错误有任何想法吗?【参考方案4】:

如果您希望允许用户使用电子邮件和密码继续登录(“Option 1: User Signs Up with Username and Signs In with Username or Alias)”)除了身份提供者(google、facebook 等)那么接受的解决方案是不够的,因为 Cognito 只能验证一封电子邮件

我通过添加Post Confirmation trigger 来解决这个问题,如果需要,它会自动验证用户电子邮件:

const AWS = require('aws-sdk');
const cognitoIdp = new AWS.CognitoIdentityServiceProvider();

const markUserEmailAsVerified = async (username, userPoolId) => 
  console.log('marking email as verified for user with username: ' + username);
  const params = 
    UserAttributes: [
      
        Name: 'email_verified',
        Value: 'true'
      
      // other user attributes like phone_number or email themselves, etc
    ],
    UserPoolId: userPoolId,
    Username: username
  ;

  const result = await new Promise((resolve, reject) => 
    cognitoIdp.adminUpdateUserAttributes(params, (err, data) => 
      if (err) 
        console.log(
          'Failed to mark user email as verified with error:\n' +
            err +
            '\n. Manual action is required to mark user email as verified otherwise he/she cannot login with email & password'
        );
        reject(err);
        return;
      
      resolve(data);
    );
  );

  return result;
;

exports.handler = async (event, context, callback) => 
  console.log('event data:\n' + JSON.stringify(event));

  const isEmailVerified = event.request.userAttributes.email_verified;
  if (isEmailVerified === 'false') 
    await markUserEmailAsVerified(event.userName, event.userPoolId);
  

  return callback(null, event);
;

注意:这似乎不是标准的开发或常见的要求,所以请采纳。

【讨论】:

由于 verify_email 属性在登录期间切换其值,因此应该在 Post Authentication Trigger 中。【参考方案5】:

aws-sdk-js-v3 我使用@subash 方法。我发现当您进行错误回调时,不会创建额外的用户。只是您使用电子邮件创建的那个。

const 
  CognitoIdentityProviderClient,
  ListUsersCommand,
  AdminCreateUserCommand,
  AdminLinkProviderForUserCommand,
  AdminSetUserPasswordCommand,
 = require('@aws-sdk/client-cognito-identity-provider')
const client = new CognitoIdentityProviderClient(
  region: process.env.REGION,
)
const crypto = require("crypto")

exports.handler = async(event, context, callback) => 

  try 

    const 
      triggerSource,
      userPoolId,
      userName,
      request: 
        userAttributes:  email, name 
      
     = event

    if (triggerSource === 'PreSignUp_ExternalProvider') 

      const listParam = 
        UserPoolId: userPoolId,
        Filter: `email = "$email"`,
      
      const listData = await client.send(new ListUsersCommand(listParam))


      let [providerName, providerUserId] = userName.split('_')
      providerName = providerName.charAt(0).toUpperCase() + providerName.slice(1)

      let linkParam = 
        SourceUser: 
          ProviderAttributeName: 'Cognito_Subject',
          ProviderAttributeValue: providerUserId,
          ProviderName: providerName,
        ,
        UserPoolId: userPoolId,
      

      if (listData && listData.Users.length > 0) 

        linkParam['DestinationUser'] = 
          ProviderAttributeValue: listData.Users[0].Username,
          ProviderName: 'Cognito',
        

      
      else 

        const createParam = 
          UserPoolId: userPoolId,
          Username: email,
          MessageAction: 'SUPPRESS',
          UserAttributes: [
            //optional name attribute. 
            Name: 'name', 
            Value: name,
          , 
            Name: 'email',
            Value: email,
          , 
            Name: 'email_verified',
            Value: 'true',
          ],
        
        const createData = await client.send(new AdminCreateUserCommand(createParam))

        const pwParam = 
          UserPoolId: userPoolId,
          Username: createData.User.Username,
          Password: crypto.randomBytes(40).toString('hex'),
          Permanent: true,
        
        await client.send(new AdminSetUserPasswordCommand(pwParam))

        linkParam['DestinationUser'] = 
          ProviderAttributeValue: createData.User.Username,
          ProviderName: 'Cognito',
        

      
      await client.send(new AdminLinkProviderForUserCommand(linkParam))
      //throw error to prevent additional user creation
      callback(Error('Social account was set, retry to sign in.'), null)
    
    else 
      callback(null, event)
    
  
  catch (err) 
    console.error(err)
  

但是,这是一个糟糕的用户体验,因为第一次使用联合身份登录只会创建用户,但不允许它进行身份验证。但是,随后使用联合身份登录将不会显示此类问题。如果您对第一次登录有任何其他解决方案,请告诉我。

email_verified 保留为true 也很有用,这样用户就可以恢复他们的密码。如果您使用aws-amplify 身份验证器,则尤其如此。这应该在您的身份验证后触发器中。

const 
  CognitoIdentityProviderClient,
  AdminUpdateUserAttributesCommand,
 = require('@aws-sdk/client-cognito-identity-provider')
const client = new CognitoIdentityProviderClient(
  region: process.env.REGION,
)

exports.handler = async(event, context, callback) => 

  try 

    const 
      userPoolId,
      userName,
      request: 
        userAttributes:  email_verified 
      
     = event

    if (!email_verified) 

      const param = 
        UserPoolId: userPoolId,
        Username: userName,
        UserAttributes: [
          Name: 'email_verified',
          Value: 'true',
        ],
      
      await client.send(new AdminUpdateUserAttributesCommand(param))

    
    callback(null, event)
  
  catch (err) 
    console.error(err)
  

【讨论】:

email_verified 的切换不再发生对我来说很奇怪。身份验证后触发器似乎不再需要。

以上是关于AWS Cognito:处理相同用户(使用相同电子邮件地址)从不同身份提供商(Google、Facebook)登录的最佳实践的主要内容,如果未能解决你的问题,请参考以下文章

用户池允许两个用户使用相同的电子邮件,尽管配置

同一 AWS Cognito 用户池中的多个应用程序对于同一用户来说 cognitoID 是不是相同?

AWS Cognito 用户名/电子邮件登录名区分大小写

如何使用 CloudFormation 允许使用用户名和电子邮件登录的 AWS Cognito 配置?

用户注册后如何在 aws cognito 中自动验证电子邮件。稍后状态后在 Cognito 中验证电子邮件

创建账户状态为“CONFIRMED”且没有电子邮件地址的 AWS Cognito 用户