Cuando un usuario inicia sesión con la misma dirección de correo electrónico a través de los proveedores de identidad de Google y Facebook, AWS Cognito crea varias entradas en el grupo de usuarios, una entrada por proveedor de identidad utilizado:
He utilizado el código de ejemplo proporcionado en este tutorial para configurar AWS Cognito: la guía completa para la autenticación de usuarios con Amplify Framework
Si. Puede hacerlo utilizando AdminLinkProviderForUser https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_AdminLinkProviderForUser.html
La idea es:
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) }Nota: Es posible que vea 2 registros en la interfaz de usuario del grupo de usuarios, pero cuando acceda a los detalles del registro de usuario, ya se fusionaron.
La solución que creé maneja, creo, todos los casos. También aborda algunos problemas comunes con Cognito.
Tenga en cuenta que al vincular cuentas, el activador de registro previo de Cognito devuelve el error "Ya se encontró una entrada para el nombre de usuario". Su cliente debe manejar esto y volver a intentar la autenticación, o pedirle al usuario que inicie sesión nuevamente. Más información sobre esto aquí:
Aquí está mi lambda, ejecutada en el activador de registro previo de 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); }); } };He estado jugando con el mismo problema por un tiempo. La respuesta aceptada funciona pero no cubre todos los escenarios. La principal es que una vez que el usuario se registra con el inicio de sesión externo, nunca podrá registrarse con un nombre de usuario y contraseña. Actualmente, Cognito no permite vincular a los usuarios de Cognito con usuarios externos.
Mis escenarios son los siguientes:
username común entre todos los usuarios vinculados para usarlo como una identificación única en otros servicios.Mi solución propuesta es siempre crear primero el usuario de Cognito y vincular a todos los usuarios externos.
user already exists . En este caso, pueden usar el flujo de forgot password olvidada para recuperarse y luego iniciar sesión. 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 };Si desea permitir que el usuario continúe iniciando sesión con correo electrónico y contraseña (" Opción 1: el usuario se registra con nombre de usuario e inicia sesión con nombre de usuario o alias) ") además del proveedor de identidad (google, facebook, etc.), entonces la solución aceptada no No será suficiente, ya que Cognito solo puede tener un correo electrónico verificado .
Resuelvo esto agregando un activador de confirmación posterior que verifica automáticamente el correo electrónico del usuario si es necesario:
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); };Nota : Esto no parece un desarrollo estándar o un requisito común, así que tómalo como tal.
En aws-sdk-js-v3 estoy usando el enfoque @subash . Descubrí que cuando realiza una devolución de llamada de error, no se crea ningún usuario adicional. Solo el que creas con tu correo electrónico.
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) } }Sin embargo, es una mala UX ya que el primer inicio de sesión con identidad federada solo creará el usuario pero no le permitirá autenticarse. Sin embargo, el inicio de sesión posterior con identidad federada no mostrará tal problema. Avísame si obtienes alguna otra solución para ese primer inicio de sesión .
También es útil mantener email_verified como true para que el usuario pueda recuperar su contraseña. Especialmente cierto si está utilizando el autenticador aws-amplify . Esto debería estar en su activación posterior a la autenticación.
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) } }