@@ -176,11 +176,50 @@ async def generate_and_send_password_reset_token(
176
176
None ,
177
177
)
178
178
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 )} "
181
187
)
182
188
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 :
184
223
if email_password_account is None :
185
224
log_debug_message (
186
225
f"Password reset email not sent, unknown user email: { email } "
@@ -193,26 +232,41 @@ async def generate_and_send_password_reset_token(
193
232
194
233
email_verified = any (
195
234
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
197
236
)
198
237
199
238
has_other_email_or_phone = any (
200
239
(lm .email is not None and not lm .has_same_email_as (email ))
201
240
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
203
242
)
204
243
205
244
if not email_verified and has_other_email_or_phone :
206
245
return GeneratePasswordResetTokenPostNotAllowedResponse (
207
246
"Reset password link was not created because of account take over risk. Please contact support. (ERR_CODE_001)"
208
247
)
209
248
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
+
210
264
should_do_account_linking_response = await AccountLinkingRecipe .get_instance ().config .should_do_automatic_account_linking (
211
265
AccountInfoWithRecipeIdAndUserId .from_account_info_or_login_method (
212
266
email_password_account
213
267
or AccountInfoWithRecipeId (email = email , recipe_id = "emailpassword" )
214
268
),
215
- primary_user_associated_with_email ,
269
+ linking_candidate ,
216
270
None ,
217
271
tenant_id ,
218
272
user_context ,
@@ -240,40 +294,22 @@ async def generate_and_send_password_reset_token(
240
294
)
241
295
if is_sign_up_allowed :
242
296
return await generate_and_send_password_reset_token (
243
- primary_user_associated_with_email .id , None
297
+ linking_candidate .id , None
244
298
)
245
299
else :
246
300
log_debug_message (
247
301
f"Password reset email not sent, is_sign_up_allowed returned false for email: { email } "
248
302
)
249
303
return GeneratePasswordResetTokenPostOkResult ()
250
304
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
-
263
305
if isinstance (should_do_account_linking_response , ShouldNotAutomaticallyLink ):
264
306
return await generate_and_send_password_reset_token (
265
307
email_password_account .recipe_user_id .get_as_string (),
266
308
email_password_account .recipe_user_id ,
267
309
)
268
310
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
-
275
311
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
277
313
)
278
314
279
315
async def password_reset_post (
@@ -388,71 +424,100 @@ async def do_update_password_and_verify_email_and_try_link_if_not_primary(
388
424
user_id_for_whom_token_was_generated = token_consumption_response .user_id
389
425
email_for_whom_token_was_generated = token_consumption_response .email
390
426
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
+ )
392
430
393
431
if existing_user is None :
394
432
return PasswordResetTokenInvalidError ()
395
433
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
+ )
403
439
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.
405
443
return await do_update_password_and_verify_email_and_try_link_if_not_primary (
406
444
RecipeUserId (user_id_for_whom_token_was_generated )
407
445
)
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 )
416
465
)
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
450
476
return (
451
477
await do_update_password_and_verify_email_and_try_link_if_not_primary (
452
478
RecipeUserId (user_id_for_whom_token_was_generated )
453
479
)
454
480
)
455
481
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
+
456
521
async def sign_in_post (
457
522
self ,
458
523
form_fields : List [FormField ],
0 commit comments