mirror of
1
Fork 0

[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 <forgejo@gergo.csillger.hu>
(cherry picked from commit aaaece28e4)
(cherry picked from commit 639dafabec)
(cherry picked from commit d699c12ceb)

[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 e7cb8da2a8 * Always enable caches (#28527)

(cherry picked from commit 43ded8ee30)

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 #2040.

Signed-off-by: Gergely Nagy <forgejo@gergo.csillger.hu>
(cherry picked from commit e35d2af2e5)
(cherry picked from commit 03989418a7)
(cherry picked from commit f50e0dfe5e)
(cherry picked from commit cad9184a36)
(cherry picked from commit e2da5d7fe1)
This commit is contained in:
Gergely Nagy 2023-12-07 12:57:16 +01:00 committed by Earl Warren
parent 90b36f2e67
commit 3a80534d4d
No known key found for this signature in database
GPG Key ID: 0579CB2928A78A00
5 changed files with 208 additions and 27 deletions

View File

@ -375,31 +375,7 @@ func updateActivation(ctx context.Context, email *EmailAddress, activate bool) e
return UpdateUserCols(ctx, user, "rands") return UpdateUserCols(ctx, user, "rands")
} }
// MakeEmailPrimary sets primary email address of given user. func makeEmailPrimary(ctx context.Context, user *User, email *EmailAddress) error {
func MakeEmailPrimary(ctx context.Context, email *EmailAddress) error {
has, err := db.GetEngine(ctx).Get(email)
if err != nil {
return err
} else if !has {
return ErrEmailAddressNotExist{Email: email.Email}
}
if !email.IsActivated {
return ErrEmailNotActivated
}
user := &User{}
has, err = db.GetEngine(ctx).ID(email.UID).Get(user)
if err != nil {
return err
} else if !has {
return ErrUserNotExist{
UID: email.UID,
Name: "",
KeyID: 0,
}
}
ctx, committer, err := db.TxContext(ctx) ctx, committer, err := db.TxContext(ctx)
if err != nil { if err != nil {
return err return err
@ -429,6 +405,57 @@ func MakeEmailPrimary(ctx context.Context, email *EmailAddress) error {
return committer.Commit() return committer.Commit()
} }
// ReplaceInactivePrimaryEmail replaces the primary email of a given user, even if the primary is not yet activated.
func ReplaceInactivePrimaryEmail(ctx context.Context, oldEmail string, email *EmailAddress) error {
user := &User{}
has, err := db.GetEngine(ctx).ID(email.UID).Get(user)
if err != nil {
return err
} else if !has {
return ErrUserNotExist{
UID: email.UID,
Name: "",
KeyID: 0,
}
}
err = AddEmailAddress(ctx, email)
if err != nil {
return err
}
err = makeEmailPrimary(ctx, user, email)
if err != nil {
return err
}
return DeleteEmailAddress(ctx, &EmailAddress{UID: email.UID, Email: oldEmail})
}
// MakeEmailPrimary sets primary email address of given user.
func MakeEmailPrimary(ctx context.Context, email *EmailAddress) error {
has, err := db.GetEngine(ctx).Get(email)
if err != nil {
return err
} else if !has {
return ErrEmailAddressNotExist{Email: email.Email}
}
if !email.IsActivated {
return ErrEmailNotActivated
}
user := &User{}
has, err = db.GetEngine(ctx).ID(email.UID).Get(user)
if err != nil {
return err
} else if !has {
return ErrUserNotExist{UID: email.UID}
}
return makeEmailPrimary(ctx, user, email)
}
// VerifyActiveEmailCode verifies active email code when active account // VerifyActiveEmailCode verifies active email code when active account
func VerifyActiveEmailCode(ctx context.Context, code, email string) *EmailAddress { func VerifyActiveEmailCode(ctx context.Context, code, email string) *EmailAddress {
minutes := setting.Service.ActiveCodeLives minutes := setting.Service.ActiveCodeLives

View File

@ -167,6 +167,28 @@ func TestMakeEmailPrimary(t *testing.T) {
assert.Equal(t, "user101@example.com", user.Email) assert.Equal(t, "user101@example.com", user.Email)
} }
func TestReplaceInactivePrimaryEmail(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
email := &user_model.EmailAddress{
Email: "user9999999@example.com",
UID: 9999999,
}
err := user_model.ReplaceInactivePrimaryEmail(db.DefaultContext, "user10@example.com", email)
assert.Error(t, err)
assert.True(t, user_model.IsErrUserNotExist(err))
email = &user_model.EmailAddress{
Email: "user201@example.com",
UID: 10,
}
err = user_model.ReplaceInactivePrimaryEmail(db.DefaultContext, "user10@example.com", email)
assert.NoError(t, err)
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 10})
assert.Equal(t, "user201@example.com", user.Email)
}
func TestActivate(t *testing.T) { func TestActivate(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())

View File

@ -647,13 +647,22 @@ func Activate(ctx *context.Context) {
} }
// Resend confirmation email. // Resend confirmation email.
if setting.Service.RegisterEmailConfirm { if setting.Service.RegisterEmailConfirm {
if ctx.Cache.IsExist("MailResendLimit_" + ctx.Doer.LowerName) { var cacheKey string
if ctx.Cache.IsExist("MailChangedJustNow_" + ctx.Doer.LowerName) {
cacheKey = "MailChangedLimit_"
if err := ctx.Cache.Delete("MailChangedJustNow_" + ctx.Doer.LowerName); err != nil {
log.Error("Delete cache(MailChangedJustNow) fail: %v", err)
}
} else {
cacheKey = "MailResendLimit_"
}
if ctx.Cache.IsExist(cacheKey + ctx.Doer.LowerName) {
ctx.Data["ResendLimited"] = true ctx.Data["ResendLimited"] = true
} else { } else {
ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale) ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale)
mailer.SendActivateAccountMail(ctx.Locale, ctx.Doer) mailer.SendActivateAccountMail(ctx.Locale, ctx.Doer)
if err := ctx.Cache.Put("MailResendLimit_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil { if err := ctx.Cache.Put(cacheKey+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil {
log.Error("Set cache(MailResendLimit) fail: %v", err) log.Error("Set cache(MailResendLimit) fail: %v", err)
} }
} }
@ -687,6 +696,43 @@ func Activate(ctx *context.Context) {
func ActivatePost(ctx *context.Context) { func ActivatePost(ctx *context.Context) {
code := ctx.FormString("code") code := ctx.FormString("code")
if len(code) == 0 { if len(code) == 0 {
email := ctx.FormString("email")
if len(email) > 0 {
ctx.Data["IsActivatePage"] = true
if ctx.Doer == nil || ctx.Doer.IsActive {
ctx.NotFound("invalid user", nil)
return
}
// Change the primary email
if setting.Service.RegisterEmailConfirm {
if ctx.Cache.IsExist("MailChangeLimit_" + ctx.Doer.LowerName) {
ctx.Data["ResendLimited"] = true
} else {
ctx.Data["ActiveCodeLives"] = timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale)
err := user_model.ReplaceInactivePrimaryEmail(ctx, ctx.Doer.Email, &user_model.EmailAddress{
UID: ctx.Doer.ID,
Email: email,
})
if err != nil {
ctx.Data["IsActivatePage"] = false
log.Error("Couldn't replace inactive primary email of user %d: %v", ctx.Doer.ID, err)
ctx.RenderWithErr(ctx.Tr("auth.change_unconfirmed_email_error", err), TplActivate, nil)
return
}
if err := ctx.Cache.Put("MailChangeLimit_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil {
log.Error("Set cache(MailChangeLimit) fail: %v", err)
}
if err := ctx.Cache.Put("MailChangedJustNow_"+ctx.Doer.LowerName, ctx.Doer.LowerName, 180); err != nil {
log.Error("Set cache(MailChangedJustNow) fail: %v", err)
}
// Confirmation mail will be re-sent after the redirect to `/user/activate` below.
}
} else {
ctx.Data["ServiceNotEnabled"] = true
}
}
ctx.Redirect(setting.AppSubURL + "/user/activate") ctx.Redirect(setting.AppSubURL + "/user/activate")
return return
} }

View File

@ -39,6 +39,16 @@
{{else}} {{else}}
<p>{{ctx.Locale.Tr "auth.has_unconfirmed_mail" (.SignedUser.Name|Escape) (.SignedUser.Email|Escape) | Str2html}}</p> <p>{{ctx.Locale.Tr "auth.has_unconfirmed_mail" (.SignedUser.Name|Escape) (.SignedUser.Email|Escape) | Str2html}}</p>
<div class="divider"></div> <div class="divider"></div>
<details class="inline field">
<summary>{{ctx.Locale.Tr "auth.change_unconfirmed_email_summary"}}</summary>
<p>{{ctx.Locale.Tr "auth.change_unconfirmed_email"}}</p>
<div class="inline field">
<label for="email">{{ctx.Locale.Tr "email"}}</label>
<input id="email" name="email" type="email" autocomplete="on">
</div>
</details>
<div class="text right"> <div class="text right">
<button class="ui primary button">{{ctx.Locale.Tr "auth.resend_mail"}}</button> <button class="ui primary button">{{ctx.Locale.Tr "auth.resend_mail"}}</button>
</div> </div>

View File

@ -12,6 +12,7 @@ import (
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
@ -91,3 +92,78 @@ func TestSignupEmail(t *testing.T) {
} }
} }
} }
func TestSignupEmailChangeForInactiveUser(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// Disable the captcha & enable email confirmation for registrations
defer test.MockVariableValue(&setting.Service.EnableCaptcha, false)()
defer test.MockVariableValue(&setting.Service.RegisterEmailConfirm, true)()
// Create user
req := NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{
"user_name": "exampleUserX",
"email": "wrong-email@example.com",
"password": "examplePassword!1",
"retype": "examplePassword!1",
})
MakeRequest(t, req, http.StatusOK)
session := loginUserWithPassword(t, "exampleUserX", "examplePassword!1")
// Verify that the initial e-mail is the wrong one.
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "exampleUserX"})
assert.Equal(t, "wrong-email@example.com", user.Email)
// Change the email address
req = NewRequestWithValues(t, "POST", "/user/activate", map[string]string{
"email": "fine-email@example.com",
})
session.MakeRequest(t, req, http.StatusSeeOther)
// Verify that the email was updated
user = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "exampleUserX"})
assert.Equal(t, "fine-email@example.com", user.Email)
// Try to change the email again
req = NewRequestWithValues(t, "POST", "/user/activate", map[string]string{
"email": "wrong-again@example.com",
})
session.MakeRequest(t, req, http.StatusSeeOther)
// Verify that the email was NOT updated
user = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "exampleUserX"})
assert.Equal(t, "fine-email@example.com", user.Email)
}
func TestSignupEmailChangeForActiveUser(t *testing.T) {
defer tests.PrepareTestEnv(t)()
// Disable the captcha & enable email confirmation for registrations
defer test.MockVariableValue(&setting.Service.EnableCaptcha, false)()
defer test.MockVariableValue(&setting.Service.RegisterEmailConfirm, false)()
// Create user
req := NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{
"user_name": "exampleUserY",
"email": "wrong-email-2@example.com",
"password": "examplePassword!1",
"retype": "examplePassword!1",
})
MakeRequest(t, req, http.StatusSeeOther)
session := loginUserWithPassword(t, "exampleUserY", "examplePassword!1")
// Verify that the initial e-mail is the wrong one.
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "exampleUserY"})
assert.Equal(t, "wrong-email-2@example.com", user.Email)
// Changing the email for a validated address is not available
req = NewRequestWithValues(t, "POST", "/user/activate", map[string]string{
"email": "fine-email-2@example.com",
})
session.MakeRequest(t, req, http.StatusNotFound)
// Verify that the email remained unchanged
user = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "exampleUserY"})
assert.Equal(t, "wrong-email-2@example.com", user.Email)
}