Skip to content

Commit 5ce63bb

Browse files
committed
fix: clear pending phone change when the user's email is replaced
1 parent cf74a14 commit 5ce63bb

2 files changed

Lines changed: 96 additions & 34 deletions

File tree

internal/models/user.go

Lines changed: 44 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -328,44 +328,14 @@ func (u *User) UpdateUserEmailFromIdentities(tx *storage.Connection) error {
328328
if primaryIdentity == nil {
329329
return UserEmailUniqueConflictError{}
330330
}
331-
previousEmail := u.GetEmail()
332331
// default to the first identity's email
333332
if terr := u.SetEmail(tx, primaryIdentity.GetEmail()); terr != nil {
334333
return terr
335334
}
336-
if previousEmail != "" {
337-
// outstanding tokens were sent to the previous email which is no
338-
// longer linked to the user so they can't be trusted anymore
339-
u.ConfirmationToken = ""
340-
u.ConfirmationSentAt = nil
341-
u.RecoveryToken = ""
342-
u.RecoverySentAt = nil
343-
u.EmailChange = ""
344-
u.EmailChangeTokenCurrent = ""
345-
u.EmailChangeTokenNew = ""
346-
u.EmailChangeSentAt = nil
347-
u.EmailChangeConfirmStatus = 0
348-
u.ReauthenticationToken = ""
349-
u.ReauthenticationSentAt = nil
350-
if terr := tx.UpdateOnly(
351-
u,
352-
"confirmation_token",
353-
"confirmation_sent_at",
354-
"recovery_token",
355-
"recovery_sent_at",
356-
"email_change",
357-
"email_change_token_current",
358-
"email_change_token_new",
359-
"email_change_sent_at",
360-
"email_change_confirm_status",
361-
"reauthentication_token",
362-
"reauthentication_sent_at",
363-
); terr != nil {
364-
return terr
365-
}
366-
if terr := ClearAllOneTimeTokensForUser(tx, u.ID); terr != nil {
367-
return terr
368-
}
335+
// outstanding tokens and pending account changes were issued before the
336+
// primary email transition so they can't be trusted anymore
337+
if terr := u.ClearAllPendingTokens(tx); terr != nil {
338+
return terr
369339
}
370340
if primaryIdentity.GetEmail() == "" || !primaryIdentity.IsEmailVerified() {
371341
// the promoted email was never verified by the IdP or ourselves,
@@ -383,6 +353,46 @@ func (u *User) UpdateUserEmailFromIdentities(tx *storage.Connection) error {
383353
return nil
384354
}
385355

356+
// ClearAllPendingTokens revokes all outstanding confirmation, recovery,
357+
// email change, phone change and reauthentication tokens issued for the
358+
// user, together with their one-time token rows.
359+
func (u *User) ClearAllPendingTokens(tx *storage.Connection) error {
360+
u.ConfirmationToken = ""
361+
u.ConfirmationSentAt = nil
362+
u.RecoveryToken = ""
363+
u.RecoverySentAt = nil
364+
u.EmailChange = ""
365+
u.EmailChangeTokenCurrent = ""
366+
u.EmailChangeTokenNew = ""
367+
u.EmailChangeSentAt = nil
368+
u.EmailChangeConfirmStatus = 0
369+
u.PhoneChange = ""
370+
u.PhoneChangeToken = ""
371+
u.PhoneChangeSentAt = nil
372+
u.ReauthenticationToken = ""
373+
u.ReauthenticationSentAt = nil
374+
if terr := tx.UpdateOnly(
375+
u,
376+
"confirmation_token",
377+
"confirmation_sent_at",
378+
"recovery_token",
379+
"recovery_sent_at",
380+
"email_change",
381+
"email_change_token_current",
382+
"email_change_token_new",
383+
"email_change_sent_at",
384+
"email_change_confirm_status",
385+
"phone_change",
386+
"phone_change_token",
387+
"phone_change_sent_at",
388+
"reauthentication_token",
389+
"reauthentication_sent_at",
390+
); terr != nil {
391+
return terr
392+
}
393+
return ClearAllOneTimeTokensForUser(tx, u.ID)
394+
}
395+
386396
// SetEmail sets the user's email
387397
func (u *User) SetEmail(tx *storage.Connection, email string) error {
388398
u.Email = storage.NullString(email)

internal/models/user_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,9 @@ func (ts *UserTestSuite) TestUpdateUserEmailClearsStaleTokens() {
633633
userA.EmailChangeTokenNew = "email-change-new-hash"
634634
userA.EmailChangeSentAt = &now
635635
userA.EmailChangeConfirmStatus = 1
636+
userA.PhoneChange = "123456789"
637+
userA.PhoneChangeToken = "phone-change-token-hash"
638+
userA.PhoneChangeSentAt = &now
636639
userA.ReauthenticationToken = "reauthentication-token-hash"
637640
userA.ReauthenticationSentAt = &now
638641
require.NoError(ts.T(), ts.db.UpdateOnly(
@@ -646,6 +649,9 @@ func (ts *UserTestSuite) TestUpdateUserEmailClearsStaleTokens() {
646649
"email_change_token_new",
647650
"email_change_sent_at",
648651
"email_change_confirm_status",
652+
"phone_change",
653+
"phone_change_token",
654+
"phone_change_sent_at",
649655
"reauthentication_token",
650656
"reauthentication_sent_at",
651657
))
@@ -667,6 +673,9 @@ func (ts *UserTestSuite) TestUpdateUserEmailClearsStaleTokens() {
667673
require.Empty(ts.T(), userA.EmailChangeTokenNew)
668674
require.Nil(ts.T(), userA.EmailChangeSentAt)
669675
require.Equal(ts.T(), 0, userA.EmailChangeConfirmStatus)
676+
require.Empty(ts.T(), userA.PhoneChange)
677+
require.Empty(ts.T(), userA.PhoneChangeToken)
678+
require.Nil(ts.T(), userA.PhoneChangeSentAt)
670679
require.Empty(ts.T(), userA.ReauthenticationToken)
671680
require.Nil(ts.T(), userA.ReauthenticationSentAt)
672681

@@ -675,6 +684,49 @@ func (ts *UserTestSuite) TestUpdateUserEmailClearsStaleTokens() {
675684
require.Error(ts.T(), err)
676685
}
677686

687+
func (ts *UserTestSuite) TestUpdateUserEmailFromEmptyClearsStaleTokens() {
688+
// a user without an email or identities, e.g. an anonymous user
689+
userA, err := NewUser("", "", "", "authenticated", nil)
690+
require.NoError(ts.T(), err)
691+
require.NoError(ts.T(), ts.db.Create(userA))
692+
693+
identity, err := NewIdentity(userA, "google", map[string]any{
694+
"sub": userA.ID.String(),
695+
"email": "bar@example.com",
696+
"email_verified": true,
697+
})
698+
require.NoError(ts.T(), err)
699+
require.NoError(ts.T(), ts.db.Create(identity))
700+
701+
// simulate a phone change requested before the identity was linked
702+
now := time.Now()
703+
userA.PhoneChange = "123456789"
704+
userA.PhoneChangeToken = "phone-change-token-hash"
705+
userA.PhoneChangeSentAt = &now
706+
require.NoError(ts.T(), ts.db.UpdateOnly(
707+
userA,
708+
"phone_change",
709+
"phone_change_token",
710+
"phone_change_sent_at",
711+
))
712+
require.NoError(ts.T(), CreateOneTimeToken(ts.db, userA.ID, userA.PhoneChange, userA.PhoneChangeToken, PhoneChangeToken))
713+
714+
// promoting an identity's email over an empty one is still a primary
715+
// email transition, so outstanding tokens must be revoked
716+
require.NoError(ts.T(), userA.UpdateUserEmailFromIdentities(ts.db))
717+
require.Equal(ts.T(), identity.GetEmail(), userA.GetEmail())
718+
719+
userA, err = FindUserByID(ts.db, userA.ID)
720+
require.NoError(ts.T(), err)
721+
require.Empty(ts.T(), userA.PhoneChange)
722+
require.Empty(ts.T(), userA.PhoneChangeToken)
723+
require.Nil(ts.T(), userA.PhoneChangeSentAt)
724+
725+
_, err = FindOneTimeToken(ts.db, "phone-change-token-hash", PhoneChangeToken)
726+
require.Error(ts.T(), err)
727+
require.True(ts.T(), IsNotFoundError(err))
728+
}
729+
678730
func (ts *UserTestSuite) TestUpdateUserEmailFailure() {
679731
userA, err := NewUser("", "foo@example.com", "", "authenticated", nil)
680732
require.NoError(ts.T(), err)

0 commit comments

Comments
 (0)