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 是不是相同?
如何使用 CloudFormation 允许使用用户名和电子邮件登录的 AWS Cognito 配置?