[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 commitaaaece28e4
) (cherry picked from commit639dafabec
) (cherry picked from commitd699c12ceb
) [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. Seee7cb8da2a8
* Always enable caches (#28527) (cherry picked from commit43ded8ee30
) 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 commite35d2af2e5
) (cherry picked from commit03989418a7
) (cherry picked from commitf50e0dfe5e
) (cherry picked from commitcad9184a36
)
This commit is contained in:
parent
b438aed4c1
commit
e2da5d7fe1
6 changed files with 212 additions and 28 deletions
|
@ -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
|
||||||
|
|
|
@ -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())
|
||||||
|
|
||||||
|
|
|
@ -367,7 +367,7 @@ forgot_password_title= Forgot Password
|
||||||
forgot_password = Forgot password?
|
forgot_password = Forgot password?
|
||||||
sign_up_now = Need an account? Register now.
|
sign_up_now = Need an account? Register now.
|
||||||
sign_up_successful = Account was successfully created. Welcome!
|
sign_up_successful = Account was successfully created. Welcome!
|
||||||
confirmation_mail_sent_prompt = A new confirmation email has been sent to <b>%s</b>. Please check your inbox within the next %s to complete the registration process.
|
confirmation_mail_sent_prompt = A new confirmation email has been sent to <b>%s</b>. Please check your inbox within the next %s to complete the registration process. If the email is incorrect, you can log in, and request another confirmation email to be sent to a different address.
|
||||||
must_change_password = Update your password
|
must_change_password = Update your password
|
||||||
allow_password_change = Require user to change password (recommended)
|
allow_password_change = Require user to change password (recommended)
|
||||||
reset_password_mail_sent_prompt = A confirmation email has been sent to <b>%s</b>. Please check your inbox within the next %s to complete the account recovery process.
|
reset_password_mail_sent_prompt = A confirmation email has been sent to <b>%s</b>. Please check your inbox within the next %s to complete the account recovery process.
|
||||||
|
@ -377,6 +377,9 @@ prohibit_login = Sign In Prohibited
|
||||||
prohibit_login_desc = Your account is prohibited from signing in, please contact your site administrator.
|
prohibit_login_desc = Your account is prohibited from signing in, please contact your site administrator.
|
||||||
resent_limit_prompt = You have already requested an activation email recently. Please wait 3 minutes and try again.
|
resent_limit_prompt = You have already requested an activation email recently. Please wait 3 minutes and try again.
|
||||||
has_unconfirmed_mail = Hi %s, you have an unconfirmed email address (<b>%s</b>). If you haven't received a confirmation email or need to resend a new one, please click on the button below.
|
has_unconfirmed_mail = Hi %s, you have an unconfirmed email address (<b>%s</b>). If you haven't received a confirmation email or need to resend a new one, please click on the button below.
|
||||||
|
change_unconfirmed_email_summary = Change the email address activation mail is sent to.
|
||||||
|
change_unconfirmed_email = If you have given the wrong email address during registration, you can change it below, and a confirmation will be sent to the new address instead.
|
||||||
|
change_unconfirmed_email_error = Unable to change the email address: %v
|
||||||
resend_mail = Click here to resend your activation email
|
resend_mail = Click here to resend your activation email
|
||||||
email_not_associate = The email address is not associated with any account.
|
email_not_associate = The email address is not associated with any account.
|
||||||
send_reset_mail = Send Account Recovery Email
|
send_reset_mail = Send Account Recovery Email
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue