diff --git a/CHANGELOG.md b/CHANGELOG.md index bbbeaad9c..29190143a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - the user has another email address or phone number associated with it - Account linking based on emails now require the email to be verified in both users if `shouldRequireVerification` is set to `true` instead of only requiring it for the recipe user. - The access token cookie expiry has been changed from 100 years to 1 year due to some browsers capping the maximum expiry at 400 days. No action is needed on your part. +- Recipe functions that update the email address of users now call `isEmailChangeAllowed` to check if the email update should be allowed or not. + - This only has an effect if account linking is turned on. + - This is aimed to help you avoid security issues. + - `isEmailChangeAllowed` is now called in functions: + - `updateUser` (Passwordless recipe) + - `updateEmailOrPassword` (EmailPassword recipe) + - `manuallyCreateOrUpdateUser` (ThirdParty recipe) ### Changes diff --git a/lib/build/recipe/passwordless/recipeImplementation.js b/lib/build/recipe/passwordless/recipeImplementation.js index 8a3782d64..15e4e9238 100644 --- a/lib/build/recipe/passwordless/recipeImplementation.js +++ b/lib/build/recipe/passwordless/recipeImplementation.js @@ -6,6 +6,7 @@ var __importDefault = }; Object.defineProperty(exports, "__esModule", { value: true }); const recipe_1 = __importDefault(require("../accountlinking/recipe")); +const recipe_2 = __importDefault(require("../emailverification/recipe")); const normalisedURLPath_1 = __importDefault(require("../../normalisedURLPath")); const logger_1 = require("../../logger"); const user_1 = require("../../user"); @@ -147,6 +148,38 @@ function getRecipeInterface(querier) { return { status: "OK" }; }, updateUser: async function (input) { + const accountLinking = recipe_1.default.getInstance(); + if (input.email) { + const user = await __1.getUser(input.recipeUserId.getAsString(), input.userContext); + if (user === undefined) { + return { status: "UNKNOWN_USER_ID_ERROR" }; + } + const evInstance = recipe_2.default.getInstance(); + let isEmailVerified = false; + if (evInstance) { + isEmailVerified = await evInstance.recipeInterfaceImpl.isEmailVerified({ + recipeUserId: input.recipeUserId, + email: input.email, + userContext: input.userContext, + }); + } + const isEmailChangeAllowed = await accountLinking.isEmailChangeAllowed({ + user, + isVerified: isEmailVerified, + newEmail: input.email, + session: undefined, + userContext: input.userContext, + }); + if (!isEmailChangeAllowed.allowed) { + return { + status: "EMAIL_CHANGE_NOT_ALLOWED_ERROR", + reason: + isEmailChangeAllowed.reason === "ACCOUNT_TAKEOVER_RISK" + ? "New email cannot be applied to existing account because of account takeover risks." + : "New email cannot be applied to existing account because of there is another primary user with the same email address.", + }; + } + } let response = await querier.sendPutRequest( new normalisedURLPath_1.default(`/recipe/user`), copyAndRemoveUserContextAndTenantId(input), diff --git a/lib/ts/recipe/passwordless/recipeImplementation.ts b/lib/ts/recipe/passwordless/recipeImplementation.ts index 71ab078b0..d157ce3b0 100644 --- a/lib/ts/recipe/passwordless/recipeImplementation.ts +++ b/lib/ts/recipe/passwordless/recipeImplementation.ts @@ -1,6 +1,7 @@ import { RecipeInterface } from "./types"; import { Querier } from "../../querier"; import AccountLinking from "../accountlinking/recipe"; +import EmailVerification from "../emailverification/recipe"; import NormalisedURLPath from "../../normalisedURLPath"; import { logDebugMessage } from "../../logger"; import { User } from "../../user"; @@ -161,6 +162,41 @@ export default function getRecipeInterface(querier: Querier): RecipeInterface { return { status: "OK" }; }, updateUser: async function (input) { + const accountLinking = AccountLinking.getInstance(); + if (input.email) { + const user = await getUser(input.recipeUserId.getAsString(), input.userContext); + + if (user === undefined) { + return { status: "UNKNOWN_USER_ID_ERROR" }; + } + + const evInstance = EmailVerification.getInstance(); + + let isEmailVerified = false; + if (evInstance) { + isEmailVerified = await evInstance.recipeInterfaceImpl.isEmailVerified({ + recipeUserId: input.recipeUserId, + email: input.email, + userContext: input.userContext, + }); + } + const isEmailChangeAllowed = await accountLinking.isEmailChangeAllowed({ + user, + isVerified: isEmailVerified, + newEmail: input.email, + session: undefined, + userContext: input.userContext, + }); + if (!isEmailChangeAllowed.allowed) { + return { + status: "EMAIL_CHANGE_NOT_ALLOWED_ERROR", + reason: + isEmailChangeAllowed.reason === "ACCOUNT_TAKEOVER_RISK" + ? "New email cannot be applied to existing account because of account takeover risks." + : "New email cannot be applied to existing account because of there is another primary user with the same email address.", + }; + } + } let response = await querier.sendPutRequest( new NormalisedURLPath(`/recipe/user`), copyAndRemoveUserContextAndTenantId(input), diff --git a/test/test-server/src/passwordless.ts b/test/test-server/src/passwordless.ts index 35a8381d3..cc8c7fd99 100644 --- a/test/test-server/src/passwordless.ts +++ b/test/test-server/src/passwordless.ts @@ -1,4 +1,5 @@ import { Router } from "express"; +import SuperTokens from "../../.."; import Passwordless from "../../../recipe/passwordless"; import { convertRequestSessionToSessionObject, serializeRecipeUserId, serializeUser } from "./utils"; import { logger } from "./logger"; @@ -67,6 +68,24 @@ const router = Router() } catch (e) { next(e); } + }) + .post("/updateuser", async (req, res, next) => { + try { + logDebugMessage("Passwordless:updateUser %j", req.body); + const response = await Passwordless.updateUser({ + recipeUserId: SuperTokens.convertToRecipeUserId(req.body.recipeUserId), + email: req.body.email, + phoneNumber: req.body.phoneNumber, + userContext: req.body.userContext, + }); + res.json({ + ...response, + ...serializeUser(response), + ...serializeRecipeUserId(response), + }); + } catch (e) { + next(e); + } }); export default router;