Skip to content

Commit 5236c14

Browse files
committed
fixes more stuff
1 parent b8fd8d0 commit 5236c14

File tree

4 files changed

+169
-78
lines changed

4 files changed

+169
-78
lines changed

supertokens_python/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,6 @@
2828
FDI_KEY_HEADER = "fdi-version"
2929
API_VERSION = "/apiversion"
3030
API_VERSION_HEADER = "cdi-version"
31-
DASHBOARD_VERSION = "0.7"
31+
DASHBOARD_VERSION = "0.13"
3232
ONE_YEAR_IN_MS = 31536000000
3333
RATE_LIMIT_STATUS_CODE = 429

supertokens_python/recipe/emailpassword/api/implementation.py

Lines changed: 141 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -176,11 +176,50 @@ async def generate_and_send_password_reset_token(
176176
None,
177177
)
178178

179-
primary_user_associated_with_email = next(
180-
(u for u in users if u.is_primary_user), None
179+
linking_candidate = next((u for u in users if u.is_primary_user), None)
180+
181+
# first we check if there even exists a primary user that has the input email
182+
log_debug_message(
183+
f"generatePasswordResetTokenPOST: primary linking candidate: {linking_candidate.id if linking_candidate else None}"
184+
)
185+
log_debug_message(
186+
f"generatePasswordResetTokenPOST: linking candidate count {len(users)}"
181187
)
182188

183-
if primary_user_associated_with_email is None:
189+
# If there is no existing primary user and there is a single option to link
190+
# we see if that user can become primary (and a candidate for linking)
191+
if linking_candidate is None and len(users) > 0:
192+
# If the only user that exists with this email is a non-primary emailpassword user, then we can just let them reset their password, because:
193+
# we are not going to link anything and there is no risk of account takeover.
194+
if (
195+
email_password_account is not None
196+
and len(users) == 1
197+
and users[0].login_methods[0].recipe_user_id.get_as_string()
198+
== email_password_account.recipe_user_id.get_as_string()
199+
):
200+
return await generate_and_send_password_reset_token(
201+
email_password_account.recipe_user_id.get_as_string(),
202+
email_password_account.recipe_user_id,
203+
)
204+
205+
oldest_user = min(users, key=lambda u: u.time_joined)
206+
log_debug_message(
207+
f"generatePasswordResetTokenPOST: oldest recipe level-linking candidate: {oldest_user.id} (w/ {'verified' if oldest_user.login_methods[0].verified else 'unverified'} email)"
208+
)
209+
# Otherwise, we check if the user can become primary.
210+
should_become_primary_user = (
211+
await AccountLinkingRecipe.get_instance().should_become_primary_user(
212+
oldest_user, tenant_id, None, user_context
213+
)
214+
)
215+
216+
log_debug_message(
217+
f"generatePasswordResetTokenPOST: recipe level-linking candidate {'can' if should_become_primary_user else 'can not'} become primary"
218+
)
219+
if should_become_primary_user:
220+
linking_candidate = oldest_user
221+
222+
if linking_candidate is None:
184223
if email_password_account is None:
185224
log_debug_message(
186225
f"Password reset email not sent, unknown user email: {email}"
@@ -193,26 +232,41 @@ async def generate_and_send_password_reset_token(
193232

194233
email_verified = any(
195234
lm.has_same_email_as(email) and lm.verified
196-
for lm in primary_user_associated_with_email.login_methods
235+
for lm in linking_candidate.login_methods
197236
)
198237

199238
has_other_email_or_phone = any(
200239
(lm.email is not None and not lm.has_same_email_as(email))
201240
or lm.phone_number is not None
202-
for lm in primary_user_associated_with_email.login_methods
241+
for lm in linking_candidate.login_methods
203242
)
204243

205244
if not email_verified and has_other_email_or_phone:
206245
return GeneratePasswordResetTokenPostNotAllowedResponse(
207246
"Reset password link was not created because of account take over risk. Please contact support. (ERR_CODE_001)"
208247
)
209248

249+
if linking_candidate.is_primary_user and email_password_account is not None:
250+
# If a primary user has the input email as verified or has no other emails then it is always allowed to reset their own password:
251+
# - there is no risk of account takeover, because they have verified this email or haven't linked it to anything else (checked above this block)
252+
# - there will be no linking as a result of this action, so we do not need to check for linking (checked here by seeing that the two accounts are already linked)
253+
are_the_two_accounts_linked = any(
254+
lm.recipe_user_id.get_as_string()
255+
== email_password_account.recipe_user_id.get_as_string()
256+
for lm in linking_candidate.login_methods
257+
)
258+
259+
if are_the_two_accounts_linked:
260+
return await generate_and_send_password_reset_token(
261+
linking_candidate.id, email_password_account.recipe_user_id
262+
)
263+
210264
should_do_account_linking_response = await AccountLinkingRecipe.get_instance().config.should_do_automatic_account_linking(
211265
AccountInfoWithRecipeIdAndUserId.from_account_info_or_login_method(
212266
email_password_account
213267
or AccountInfoWithRecipeId(email=email, recipe_id="emailpassword")
214268
),
215-
primary_user_associated_with_email,
269+
linking_candidate,
216270
None,
217271
tenant_id,
218272
user_context,
@@ -240,40 +294,22 @@ async def generate_and_send_password_reset_token(
240294
)
241295
if is_sign_up_allowed:
242296
return await generate_and_send_password_reset_token(
243-
primary_user_associated_with_email.id, None
297+
linking_candidate.id, None
244298
)
245299
else:
246300
log_debug_message(
247301
f"Password reset email not sent, is_sign_up_allowed returned false for email: {email}"
248302
)
249303
return GeneratePasswordResetTokenPostOkResult()
250304

251-
are_the_two_accounts_linked = any(
252-
lm.recipe_user_id.get_as_string()
253-
== email_password_account.recipe_user_id.get_as_string()
254-
for lm in primary_user_associated_with_email.login_methods
255-
)
256-
257-
if are_the_two_accounts_linked:
258-
return await generate_and_send_password_reset_token(
259-
primary_user_associated_with_email.id,
260-
email_password_account.recipe_user_id,
261-
)
262-
263305
if isinstance(should_do_account_linking_response, ShouldNotAutomaticallyLink):
264306
return await generate_and_send_password_reset_token(
265307
email_password_account.recipe_user_id.get_as_string(),
266308
email_password_account.recipe_user_id,
267309
)
268310

269-
if not should_do_account_linking_response.should_require_verification:
270-
return await generate_and_send_password_reset_token(
271-
primary_user_associated_with_email.id,
272-
email_password_account.recipe_user_id,
273-
)
274-
275311
return await generate_and_send_password_reset_token(
276-
primary_user_associated_with_email.id, email_password_account.recipe_user_id
312+
linking_candidate.id, email_password_account.recipe_user_id
277313
)
278314

279315
async def password_reset_post(
@@ -388,71 +424,100 @@ async def do_update_password_and_verify_email_and_try_link_if_not_primary(
388424
user_id_for_whom_token_was_generated = token_consumption_response.user_id
389425
email_for_whom_token_was_generated = token_consumption_response.email
390426

391-
existing_user = await get_user(token_consumption_response.user_id, user_context)
427+
existing_user = await get_user(
428+
user_id_for_whom_token_was_generated, user_context
429+
)
392430

393431
if existing_user is None:
394432
return PasswordResetTokenInvalidError()
395433

396-
if existing_user.is_primary_user:
397-
email_password_user_is_linked_to_existing_user = any(
398-
lm.recipe_user_id.get_as_string()
399-
== user_id_for_whom_token_was_generated
400-
and lm.recipe_id == "emailpassword"
401-
for lm in existing_user.login_methods
402-
)
434+
token_generated_for_email_password_user = any(
435+
lm.recipe_user_id.get_as_string() == user_id_for_whom_token_was_generated
436+
and lm.recipe_id == "emailpassword"
437+
for lm in existing_user.login_methods
438+
)
403439

404-
if email_password_user_is_linked_to_existing_user:
440+
if token_generated_for_email_password_user:
441+
if not existing_user.is_primary_user:
442+
# If this is a recipe level emailpassword user, we can always allow them to reset their password.
405443
return await do_update_password_and_verify_email_and_try_link_if_not_primary(
406444
RecipeUserId(user_id_for_whom_token_was_generated)
407445
)
408-
else:
409-
create_user_response = (
410-
await api_options.recipe_implementation.create_new_recipe_user(
411-
tenant_id=tenant_id,
412-
email=token_consumption_response.email,
413-
password=new_password,
414-
user_context=user_context,
415-
)
446+
447+
# If the user is a primary user resetting the password of an emailpassword user linked to it
448+
# we need to check for account takeover risk (similar to what we do when generating the token)
449+
450+
# We check if there is any login method in which the input email is verified.
451+
# If that is the case, then it's proven that the user owns the email and we can
452+
# trust linking of the email password account.
453+
email_verified = any(
454+
lm.has_same_email_as(email_for_whom_token_was_generated) and lm.verified
455+
for lm in existing_user.login_methods
456+
)
457+
458+
# finally, we check if the primary user has any other email / phone number
459+
# associated with this account - and if it does, then it means that
460+
# there is a risk of account takeover, so we do not allow the token to be generated
461+
has_other_email_or_phone = any(
462+
(
463+
lm.email is not None
464+
and not lm.has_same_email_as(email_for_whom_token_was_generated)
416465
)
417-
if isinstance(create_user_response, EmailAlreadyExistsError):
418-
return PasswordResetTokenInvalidError()
419-
else:
420-
await mark_email_as_verified(
421-
create_user_response.user.login_methods[0].recipe_user_id,
422-
token_consumption_response.email,
423-
)
424-
updated_user = await get_user(
425-
create_user_response.user.id,
426-
user_context,
427-
)
428-
if updated_user is None:
429-
raise Exception(
430-
"Should never happen - user deleted after during password reset"
431-
)
432-
create_user_response.user = updated_user
433-
link_res = await AccountLinkingRecipe.get_instance().try_linking_by_account_info_or_create_primary_user(
434-
tenant_id=tenant_id,
435-
input_user=create_user_response.user,
436-
session=None,
437-
user_context=user_context,
438-
)
439-
user_after_linking = (
440-
link_res.user
441-
if link_res.status == "OK"
442-
else create_user_response.user
443-
)
444-
assert user_after_linking is not None
445-
return PasswordResetPostOkResult(
446-
user=user_after_linking,
447-
email=token_consumption_response.email,
448-
)
449-
else:
466+
or lm.phone_number is not None
467+
for lm in existing_user.login_methods
468+
)
469+
470+
if not email_verified and has_other_email_or_phone:
471+
# We can return an invalid token error, because in this case the token should not have been created
472+
# whenever they try to re-create it they'll see the appropriate error message
473+
return PasswordResetTokenInvalidError()
474+
475+
# since this doesn't result in linking and there is no risk of account takeover, we can allow the password reset to proceed
450476
return (
451477
await do_update_password_and_verify_email_and_try_link_if_not_primary(
452478
RecipeUserId(user_id_for_whom_token_was_generated)
453479
)
454480
)
455481

482+
create_user_response = (
483+
await api_options.recipe_implementation.create_new_recipe_user(
484+
tenant_id=tenant_id,
485+
email=token_consumption_response.email,
486+
password=new_password,
487+
user_context=user_context,
488+
)
489+
)
490+
if isinstance(create_user_response, EmailAlreadyExistsError):
491+
return PasswordResetTokenInvalidError()
492+
else:
493+
await mark_email_as_verified(
494+
create_user_response.user.login_methods[0].recipe_user_id,
495+
token_consumption_response.email,
496+
)
497+
updated_user = await get_user(
498+
create_user_response.user.id,
499+
user_context,
500+
)
501+
if updated_user is None:
502+
raise Exception(
503+
"Should never happen - user deleted after during password reset"
504+
)
505+
create_user_response.user = updated_user
506+
link_res = await AccountLinkingRecipe.get_instance().try_linking_by_account_info_or_create_primary_user(
507+
tenant_id=tenant_id,
508+
input_user=create_user_response.user,
509+
session=None,
510+
user_context=user_context,
511+
)
512+
user_after_linking = (
513+
link_res.user if link_res.status == "OK" else create_user_response.user
514+
)
515+
assert user_after_linking is not None
516+
return PasswordResetPostOkResult(
517+
user=user_after_linking,
518+
email=token_consumption_response.email,
519+
)
520+
456521
async def sign_in_post(
457522
self,
458523
form_fields: List[FormField],

supertokens_python/recipe/emailpassword/types.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def __init__(
7676
self.email = email
7777

7878
def to_json(self) -> Dict[str, Any]:
79-
return {
79+
resp_json = {
8080
"id": self.id,
8181
"recipeUserId": (
8282
self.recipe_user_id.get_as_string()
@@ -85,6 +85,8 @@ def to_json(self) -> Dict[str, Any]:
8585
),
8686
"email": self.email,
8787
}
88+
# Remove items that are None
89+
return {k: v for k, v in resp_json.items() if v is not None}
8890

8991

9092
class PasswordResetEmailTemplateVars:
@@ -100,6 +102,7 @@ def __init__(
100102

101103
def to_json(self) -> Dict[str, Any]:
102104
return {
105+
"type": "PASSWORD_RESET",
103106
"user": self.user.to_json(),
104107
"passwordResetLink": self.password_reset_link,
105108
"tenantId": self.tenant_id,

tests/test-server/test_functions_mapper.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,29 @@ async def sign_in_up_post(
559559
return tp_override_apis
560560

561561
elif eval_str.startswith("accountlinking.init.shouldDoAutomaticAccountLinking"):
562+
if "onlyLinkIfNewUserVerified" in eval_str:
563+
564+
async def func4(
565+
new_user_account: Any,
566+
existing_user: Any,
567+
session: Any,
568+
tenant_id: Any,
569+
user_context: Dict[str, Any],
570+
) -> Union[ShouldNotAutomaticallyLink, ShouldAutomaticallyLink]:
571+
if user_context.get("DO_NOT_LINK"):
572+
return ShouldNotAutomaticallyLink()
573+
574+
if (
575+
new_user_account.third_party is not None
576+
and existing_user is not None
577+
):
578+
if user_context.get("isVerified"):
579+
return ShouldAutomaticallyLink(should_require_verification=True)
580+
return ShouldNotAutomaticallyLink()
581+
582+
return ShouldAutomaticallyLink(should_require_verification=True)
583+
584+
return func4
562585

563586
async def func(
564587
i: Any, l: Any, o: Any, u: Any, a: Any # pylint: disable=unused-argument

0 commit comments

Comments
 (0)