Skip to content

Commit d4fc0d2

Browse files
algernonearl-warren
authored andcommitted
[GITEA] Allow changing the email address before activation
During registration, one may be required to give their email address, to be verified and activated later. However, if one makes a mistake, a typo, they may end up with an account that cannot be activated due to having a wrong email address. They can still log in, but not change the email address, thus, no way to activate it without help from an administrator. To remedy this issue, lets allow changing the email address for logged in, but not activated users. This fixes gitea#17785. Signed-off-by: Gergely Nagy <[email protected]> (cherry picked from commit aaaece28e4c6a8980cef932e224e84933d7c9262) (cherry picked from commit 639dafabec0a5c1f943b44ca02f72c5ba2fc5e10) (cherry picked from commit d699c12) [GITEA] Allow changing the email address before activation (squash) cache is always active This needs to be revisited because the MailResendLimit is not enforced and turns out to not be tested. See e7cb8da * Always enable caches (go-gitea#28527) (cherry picked from commit 43ded8e) Rate limit pre-activation email change separately Changing the email address before any email address is activated should be subject to a different rate limit than the normal activation email resending. If there's only one rate limit for both, then if a newly signed up quickly discovers they gave a wrong email address, they'd have to wait three minutes to change it. With the two separate limits, they don't - but they'll have to wait three minutes before they can change the email address again. The downside of this setup is that a malicious actor can alternate between resending and changing the email address (to something like `user+$idx@domain`, delivered to the same inbox) to effectively halving the rate limit. I do not think there's a better solution, and this feels like such a small attack surface that I'd deem it acceptable. The way the code works after this change is that `ActivatePost` will now check the `MailChangeLimit_user` key rather than `MailResendLimit_user`, and if we're within the limit, it will set `MailChangedJustNow_user`. The `Activate` method - which sends the activation email, whether it is a normal resend, or one following an email change - will check `MailChangedJustNow_user`, and if it is set, it will check the rate limit against `MailChangedLimit_user`, otherwise against `MailResendLimit_user`, and then will delete the `MailChangedJustNow_user` key from the cache. Fixes go-gitea#2040. Signed-off-by: Gergely Nagy <[email protected]> (cherry picked from commit e35d2af2e56f4ecb3a4f6d1109d02c8aa1a6d182) (cherry picked from commit 03989418a70d3445e0edada7fbe5a4151d7836b1) (cherry picked from commit f50e0dfe5e90d6a31c5b59e687580e8b2725c22b) (cherry picked from commit cad9184a3653e6c80de2e006a0d699b816980987) (cherry picked from commit e2da5d7fe13a685606913a131687a94f9f5fcfeb) (cherry picked from commit 3a80534d4db523efe56b368489f81dc1cb2c99f7)
1 parent 6b5bfff commit d4fc0d2

File tree

5 files changed

+205
-24
lines changed

5 files changed

+205
-24
lines changed

models/user/email_address.go

Lines changed: 49 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -332,21 +332,40 @@ func updateActivation(ctx context.Context, email *EmailAddress, activate bool) e
332332
return UpdateUserCols(ctx, user, "rands")
333333
}
334334

335-
// MakeEmailPrimary sets primary email address of given user.
336-
func MakeEmailPrimary(ctx context.Context, email *EmailAddress) error {
337-
has, err := db.GetEngine(ctx).Get(email)
335+
func makeEmailPrimary(ctx context.Context, user *User, email *EmailAddress) error {
336+
ctx, committer, err := db.TxContext(ctx)
338337
if err != nil {
339338
return err
340-
} else if !has {
341-
return ErrEmailAddressNotExist{Email: email.Email}
342339
}
340+
defer committer.Close()
341+
sess := db.GetEngine(ctx)
343342

344-
if !email.IsActivated {
345-
return ErrEmailNotActivated
343+
// 1. Update user table
344+
user.Email = email.Email
345+
if _, err = sess.ID(user.ID).Cols("email").Update(user); err != nil {
346+
return err
346347
}
347348

349+
// 2. Update old primary email
350+
if _, err = sess.Where("uid=? AND is_primary=?", email.UID, true).Cols("is_primary").Update(&EmailAddress{
351+
IsPrimary: false,
352+
}); err != nil {
353+
return err
354+
}
355+
356+
// 3. update new primary email
357+
email.IsPrimary = true
358+
if _, err = sess.ID(email.ID).Cols("is_primary").Update(email); err != nil {
359+
return err
360+
}
361+
362+
return committer.Commit()
363+
}
364+
365+
// ReplaceInactivePrimaryEmail replaces the primary email of a given user, even if the primary is not yet activated.
366+
func ReplaceInactivePrimaryEmail(ctx context.Context, oldEmail string, email *EmailAddress) error {
348367
user := &User{}
349-
has, err = db.GetEngine(ctx).ID(email.UID).Get(user)
368+
has, err := db.GetEngine(ctx).ID(email.UID).Get(user)
350369
if err != nil {
351370
return err
352371
} else if !has {
@@ -357,33 +376,41 @@ func MakeEmailPrimary(ctx context.Context, email *EmailAddress) error {
357376
}
358377
}
359378

360-
ctx, committer, err := db.TxContext(ctx)
379+
err = AddEmailAddress(ctx, email)
361380
if err != nil {
362381
return err
363382
}
364-
defer committer.Close()
365-
sess := db.GetEngine(ctx)
366383

367-
// 1. Update user table
368-
user.Email = email.Email
369-
if _, err = sess.ID(user.ID).Cols("email").Update(user); err != nil {
384+
err = makeEmailPrimary(ctx, user, email)
385+
if err != nil {
370386
return err
371387
}
372388

373-
// 2. Update old primary email
374-
if _, err = sess.Where("uid=? AND is_primary=?", email.UID, true).Cols("is_primary").Update(&EmailAddress{
375-
IsPrimary: false,
376-
}); err != nil {
389+
return DeleteEmailAddress(ctx, &EmailAddress{UID: email.UID, Email: oldEmail})
390+
}
391+
392+
// MakeEmailPrimary sets primary email address of given user.
393+
func MakeEmailPrimary(ctx context.Context, email *EmailAddress) error {
394+
has, err := db.GetEngine(ctx).Get(email)
395+
if err != nil {
377396
return err
397+
} else if !has {
398+
return ErrEmailAddressNotExist{Email: email.Email}
378399
}
379400

380-
// 3. update new primary email
381-
email.IsPrimary = true
382-
if _, err = sess.ID(email.ID).Cols("is_primary").Update(email); err != nil {
401+
if !email.IsActivated {
402+
return ErrEmailNotActivated
403+
}
404+
405+
user := &User{}
406+
has, err = db.GetEngine(ctx).ID(email.UID).Get(user)
407+
if err != nil {
383408
return err
409+
} else if !has {
410+
return ErrUserNotExist{UID: email.UID}
384411
}
385412

386-
return committer.Commit()
413+
return makeEmailPrimary(ctx, user, email)
387414
}
388415

389416
// VerifyActiveEmailCode verifies active email code when active account

models/user/email_address_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,28 @@ func TestMakeEmailPrimary(t *testing.T) {
7777
assert.Equal(t, "[email protected]", user.Email)
7878
}
7979

80+
func TestReplaceInactivePrimaryEmail(t *testing.T) {
81+
assert.NoError(t, unittest.PrepareTestDatabase())
82+
83+
email := &user_model.EmailAddress{
84+
85+
UID: 9999999,
86+
}
87+
err := user_model.ReplaceInactivePrimaryEmail(db.DefaultContext, "[email protected]", email)
88+
assert.Error(t, err)
89+
assert.True(t, user_model.IsErrUserNotExist(err))
90+
91+
email = &user_model.EmailAddress{
92+
93+
UID: 10,
94+
}
95+
err = user_model.ReplaceInactivePrimaryEmail(db.DefaultContext, "[email protected]", email)
96+
assert.NoError(t, err)
97+
98+
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 10})
99+
assert.Equal(t, "[email protected]", user.Email)
100+
}
101+
80102
func TestActivate(t *testing.T) {
81103
assert.NoError(t, unittest.PrepareTestDatabase())
82104

routers/web/auth/auth.go

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -653,13 +653,22 @@ func Activate(ctx *context.Context) {
653653
}
654654
// Resend confirmation email.
655655
if setting.Service.RegisterEmailConfirm {
656-
if ctx.Cache.IsExist("MailResendLimit_" + ctx.Doer.LowerName) {
656+
var cacheKey string
657+
if ctx.Cache.IsExist("MailChangedJustNow_" + ctx.Doer.LowerName) {
658+
cacheKey = "MailChangedLimit_"
659+
if err := ctx.Cache.Delete("MailChangedJustNow_" + ctx.Doer.LowerName); err != nil {
660+
log.Error("Delete cache(MailChangedJustNow) fail: %v", err)
661+
}
662+
} else {
663+
cacheKey = "MailResendLimit_"
664+
}
665+
if ctx.Cache.IsExist(cacheKey + ctx.Doer.LowerName) {
657666
ctx.Data["ResendLimited"] = true
658667
} else {
659668
ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale)
660669
mailer.SendActivateAccountMail(ctx.Locale, ctx.Doer)
661670

662-
if err := ctx.Cache.Put("MailResendLimit_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil {
671+
if err := ctx.Cache.Put(cacheKey+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil {
663672
log.Error("Set cache(MailResendLimit) fail: %v", err)
664673
}
665674
}
@@ -693,6 +702,43 @@ func Activate(ctx *context.Context) {
693702
func ActivatePost(ctx *context.Context) {
694703
code := ctx.FormString("code")
695704
if len(code) == 0 {
705+
email := ctx.FormString("email")
706+
if len(email) > 0 {
707+
ctx.Data["IsActivatePage"] = true
708+
if ctx.Doer == nil || ctx.Doer.IsActive {
709+
ctx.NotFound("invalid user", nil)
710+
return
711+
}
712+
// Change the primary email
713+
if setting.Service.RegisterEmailConfirm {
714+
if ctx.Cache.IsExist("MailChangeLimit_" + ctx.Doer.LowerName) {
715+
ctx.Data["ResendLimited"] = true
716+
} else {
717+
ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale)
718+
err := user_model.ReplaceInactivePrimaryEmail(ctx, ctx.Doer.Email, &user_model.EmailAddress{
719+
UID: ctx.Doer.ID,
720+
Email: email,
721+
})
722+
if err != nil {
723+
ctx.Data["IsActivatePage"] = false
724+
log.Error("Couldn't replace inactive primary email of user %d: %v", ctx.Doer.ID, err)
725+
ctx.RenderWithErr(ctx.Tr("auth.change_unconfirmed_email_error", err), TplActivate, nil)
726+
return
727+
}
728+
if err := ctx.Cache.Put("MailChangeLimit_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil {
729+
log.Error("Set cache(MailChangeLimit) fail: %v", err)
730+
}
731+
if err := ctx.Cache.Put("MailChangedJustNow_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil {
732+
log.Error("Set cache(MailChangedJustNow) fail: %v", err)
733+
}
734+
735+
// Confirmation mail will be re-sent after the redirect to `/user/activate` below.
736+
}
737+
} else {
738+
ctx.Data["ServiceNotEnabled"] = true
739+
}
740+
}
741+
696742
ctx.Redirect(setting.AppSubURL + "/user/activate")
697743
return
698744
}

templates/user/auth/activate.tmpl

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@
3939
{{else}}
4040
<p>{{ctx.Locale.Tr "auth.has_unconfirmed_mail" (.SignedUser.Name|Escape) (.SignedUser.Email|Escape) | Str2html}}</p>
4141
<div class="divider"></div>
42+
<details class="inline field">
43+
<summary>{{ctx.Locale.Tr "auth.change_unconfirmed_email_summary"}}</summary>
44+
45+
<p>{{ctx.Locale.Tr "auth.change_unconfirmed_email"}}</p>
46+
<div class="inline field">
47+
<label for="email">{{ctx.Locale.Tr "email"}}</label>
48+
<input id="email" name="email" type="email" autocomplete="on">
49+
</div>
50+
</details>
51+
4252
<div class="text right">
4353
<button class="ui primary button">{{ctx.Locale.Tr "auth.resend_mail"}}</button>
4454
</div>

tests/integration/signup_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"code.gitea.io/gitea/models/unittest"
1313
user_model "code.gitea.io/gitea/models/user"
1414
"code.gitea.io/gitea/modules/setting"
15+
"code.gitea.io/gitea/modules/test"
1516
"code.gitea.io/gitea/modules/translation"
1617
"code.gitea.io/gitea/tests"
1718

@@ -91,3 +92,78 @@ func TestSignupEmail(t *testing.T) {
9192
}
9293
}
9394
}
95+
96+
func TestSignupEmailChangeForInactiveUser(t *testing.T) {
97+
defer tests.PrepareTestEnv(t)()
98+
99+
// Disable the captcha & enable email confirmation for registrations
100+
defer test.MockVariableValue(&setting.Service.EnableCaptcha, false)()
101+
defer test.MockVariableValue(&setting.Service.RegisterEmailConfirm, true)()
102+
103+
// Create user
104+
req := NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{
105+
"user_name": "exampleUserX",
106+
"email": "[email protected]",
107+
"password": "examplePassword!1",
108+
"retype": "examplePassword!1",
109+
})
110+
MakeRequest(t, req, http.StatusOK)
111+
112+
session := loginUserWithPassword(t, "exampleUserX", "examplePassword!1")
113+
114+
// Verify that the initial e-mail is the wrong one.
115+
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "exampleUserX"})
116+
assert.Equal(t, "[email protected]", user.Email)
117+
118+
// Change the email address
119+
req = NewRequestWithValues(t, "POST", "/user/activate", map[string]string{
120+
"email": "[email protected]",
121+
})
122+
session.MakeRequest(t, req, http.StatusSeeOther)
123+
124+
// Verify that the email was updated
125+
user = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "exampleUserX"})
126+
assert.Equal(t, "[email protected]", user.Email)
127+
128+
// Try to change the email again
129+
req = NewRequestWithValues(t, "POST", "/user/activate", map[string]string{
130+
"email": "[email protected]",
131+
})
132+
session.MakeRequest(t, req, http.StatusSeeOther)
133+
// Verify that the email was NOT updated
134+
user = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "exampleUserX"})
135+
assert.Equal(t, "[email protected]", user.Email)
136+
}
137+
138+
func TestSignupEmailChangeForActiveUser(t *testing.T) {
139+
defer tests.PrepareTestEnv(t)()
140+
141+
// Disable the captcha & enable email confirmation for registrations
142+
defer test.MockVariableValue(&setting.Service.EnableCaptcha, false)()
143+
defer test.MockVariableValue(&setting.Service.RegisterEmailConfirm, false)()
144+
145+
// Create user
146+
req := NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{
147+
"user_name": "exampleUserY",
148+
"email": "[email protected]",
149+
"password": "examplePassword!1",
150+
"retype": "examplePassword!1",
151+
})
152+
MakeRequest(t, req, http.StatusSeeOther)
153+
154+
session := loginUserWithPassword(t, "exampleUserY", "examplePassword!1")
155+
156+
// Verify that the initial e-mail is the wrong one.
157+
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "exampleUserY"})
158+
assert.Equal(t, "[email protected]", user.Email)
159+
160+
// Changing the email for a validated address is not available
161+
req = NewRequestWithValues(t, "POST", "/user/activate", map[string]string{
162+
"email": "[email protected]",
163+
})
164+
session.MakeRequest(t, req, http.StatusNotFound)
165+
166+
// Verify that the email remained unchanged
167+
user = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "exampleUserY"})
168+
assert.Equal(t, "[email protected]", user.Email)
169+
}

0 commit comments

Comments
 (0)