diff options
author | Zettat123 <zettat123@gmail.com> | 2024-12-27 19:16:23 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-12-27 19:16:23 +0800 |
commit | 2d1a171dc7c4a350b40e0f64e0314e944dcc1472 (patch) | |
tree | f9d89b59d2ba2646948872131b9e24600069fba9 | |
parent | 3c00e891296caf3ec246e3e9b4800e011b6945c1 (diff) | |
download | gitea-2d1a171dc7c4a350b40e0f64e0314e944dcc1472.tar.gz gitea-2d1a171dc7c4a350b40e0f64e0314e944dcc1472.zip |
Support for email addresses containing uppercase characters when activating user account (#32998)
Fix #32807
---------
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
-rw-r--r-- | models/user/email_address.go | 8 | ||||
-rw-r--r-- | models/user/user.go | 46 | ||||
-rw-r--r-- | routers/web/auth/auth.go | 4 | ||||
-rw-r--r-- | routers/web/auth/password.go | 2 | ||||
-rw-r--r-- | services/mailer/mail.go | 9 | ||||
-rw-r--r-- | tests/integration/org_team_invite_test.go | 3 | ||||
-rw-r--r-- | tests/integration/signup_test.go | 24 |
7 files changed, 61 insertions, 35 deletions
diff --git a/models/user/email_address.go b/models/user/email_address.go index 5c04909ed7..74ba5f617a 100644 --- a/models/user/email_address.go +++ b/models/user/email_address.go @@ -357,8 +357,8 @@ func VerifyActiveEmailCode(ctx context.Context, code, email string) *EmailAddres if user := GetVerifyUser(ctx, code); user != nil { // time limit code prefix := code[:base.TimeLimitCodeLength] - data := fmt.Sprintf("%d%s%s%s%s", user.ID, email, user.LowerName, user.Passwd, user.Rands) - + opts := &TimeLimitCodeOptions{Purpose: TimeLimitCodeActivateEmail, NewEmail: email} + data := makeTimeLimitCodeHashData(opts, user) if base.VerifyTimeLimitCode(time.Now(), data, setting.Service.ActiveCodeLives, prefix) { emailAddress := &EmailAddress{UID: user.ID, Email: email} if has, _ := db.GetEngine(ctx).Get(emailAddress); has { @@ -486,10 +486,10 @@ func ActivateUserEmail(ctx context.Context, userID int64, email string, activate // Activate/deactivate a user's primary email address and account if addr.IsPrimary { - user, exist, err := db.Get[User](ctx, builder.Eq{"id": userID, "email": email}) + user, exist, err := db.Get[User](ctx, builder.Eq{"id": userID}) if err != nil { return err - } else if !exist { + } else if !exist || !strings.EqualFold(user.Email, email) { return fmt.Errorf("no user with ID: %d and Email: %s", userID, email) } diff --git a/models/user/user.go b/models/user/user.go index cf08d26498..19879fbcc7 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -181,7 +181,8 @@ func (u *User) BeforeUpdate() { u.MaxRepoCreation = -1 } - // Organization does not need email + // FIXME: this email doesn't need to be in lowercase, because the emails are mainly managed by the email table with lower_email field + // This trick could be removed in new releases to display the user inputed email as-is. u.Email = strings.ToLower(u.Email) if !u.IsOrganization() { if len(u.AvatarEmail) == 0 { @@ -310,17 +311,6 @@ func (u *User) OrganisationLink() string { return setting.AppSubURL + "/org/" + url.PathEscape(u.Name) } -// GenerateEmailActivateCode generates an activate code based on user information and given e-mail. -func (u *User) GenerateEmailActivateCode(email string) string { - code := base.CreateTimeLimitCode( - fmt.Sprintf("%d%s%s%s%s", u.ID, email, u.LowerName, u.Passwd, u.Rands), - setting.Service.ActiveCodeLives, time.Now(), nil) - - // Add tail hex username - code += hex.EncodeToString([]byte(u.LowerName)) - return code -} - // GetUserFollowers returns range of user's followers. func GetUserFollowers(ctx context.Context, u, viewer *User, listOptions db.ListOptions) ([]*User, int64, error) { sess := db.GetEngine(ctx). @@ -863,12 +853,38 @@ func GetVerifyUser(ctx context.Context, code string) (user *User) { return nil } -// VerifyUserActiveCode verifies active code when active account -func VerifyUserActiveCode(ctx context.Context, code string) (user *User) { +type TimeLimitCodePurpose string + +const ( + TimeLimitCodeActivateAccount TimeLimitCodePurpose = "activate_account" + TimeLimitCodeActivateEmail TimeLimitCodePurpose = "activate_email" + TimeLimitCodeResetPassword TimeLimitCodePurpose = "reset_password" +) + +type TimeLimitCodeOptions struct { + Purpose TimeLimitCodePurpose + NewEmail string +} + +func makeTimeLimitCodeHashData(opts *TimeLimitCodeOptions, u *User) string { + return fmt.Sprintf("%s|%d|%s|%s|%s|%s", opts.Purpose, u.ID, strings.ToLower(util.IfZero(opts.NewEmail, u.Email)), u.LowerName, u.Passwd, u.Rands) +} + +// GenerateUserTimeLimitCode generates a time-limit code based on user information and given e-mail. +// TODO: need to use cache or db to store it to make sure a code can only be consumed once +func GenerateUserTimeLimitCode(opts *TimeLimitCodeOptions, u *User) string { + data := makeTimeLimitCodeHashData(opts, u) + code := base.CreateTimeLimitCode(data, setting.Service.ActiveCodeLives, time.Now(), nil) + code += hex.EncodeToString([]byte(u.LowerName)) // Add tail hex username + return code +} + +// VerifyUserTimeLimitCode verifies the time-limit code +func VerifyUserTimeLimitCode(ctx context.Context, opts *TimeLimitCodeOptions, code string) (user *User) { if user = GetVerifyUser(ctx, code); user != nil { // time limit code prefix := code[:base.TimeLimitCodeLength] - data := fmt.Sprintf("%d%s%s%s%s", user.ID, user.Email, user.LowerName, user.Passwd, user.Rands) + data := makeTimeLimitCodeHashData(opts, user) if base.VerifyTimeLimitCode(time.Now(), data, setting.Service.ActiveCodeLives, prefix) { return user } diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 42736a423f..3fe1d5970e 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -689,7 +689,7 @@ func Activate(ctx *context.Context) { } // TODO: ctx.Doer/ctx.Data["SignedUser"] could be nil or not the same user as the one being activated - user := user_model.VerifyUserActiveCode(ctx, code) + user := user_model.VerifyUserTimeLimitCode(ctx, &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount}, code) if user == nil { // if code is wrong renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.invalid_code")) return @@ -734,7 +734,7 @@ func ActivatePost(ctx *context.Context) { } // TODO: ctx.Doer/ctx.Data["SignedUser"] could be nil or not the same user as the one being activated - user := user_model.VerifyUserActiveCode(ctx, code) + user := user_model.VerifyUserTimeLimitCode(ctx, &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount}, code) if user == nil { // if code is wrong renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.invalid_code")) return diff --git a/routers/web/auth/password.go b/routers/web/auth/password.go index 3812d582e5..614e086f77 100644 --- a/routers/web/auth/password.go +++ b/routers/web/auth/password.go @@ -113,7 +113,7 @@ func commonResetPassword(ctx *context.Context) (*user_model.User, *auth.TwoFacto } // Fail early, don't frustrate the user - u := user_model.VerifyUserActiveCode(ctx, code) + u := user_model.VerifyUserTimeLimitCode(ctx, &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeResetPassword}, code) if u == nil { ctx.Flash.Error(ctx.Tr("auth.invalid_code_forgot_password", fmt.Sprintf("%s/user/forgot_password", setting.AppSubURL)), true) return nil, nil diff --git a/services/mailer/mail.go b/services/mailer/mail.go index a6763e4f03..52e19bde6f 100644 --- a/services/mailer/mail.go +++ b/services/mailer/mail.go @@ -93,7 +93,8 @@ func SendActivateAccountMail(locale translation.Locale, u *user_model.User) { // No mail service configured return } - sendUserMail(locale.Language(), u, mailAuthActivate, u.GenerateEmailActivateCode(u.Email), locale.TrString("mail.activate_account"), "activate account") + opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount} + sendUserMail(locale.Language(), u, mailAuthActivate, user_model.GenerateUserTimeLimitCode(opts, u), locale.TrString("mail.activate_account"), "activate account") } // SendResetPasswordMail sends a password reset mail to the user @@ -103,7 +104,8 @@ func SendResetPasswordMail(u *user_model.User) { return } locale := translation.NewLocale(u.Language) - sendUserMail(u.Language, u, mailAuthResetPassword, u.GenerateEmailActivateCode(u.Email), locale.TrString("mail.reset_password"), "recover account") + opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeResetPassword} + sendUserMail(u.Language, u, mailAuthResetPassword, user_model.GenerateUserTimeLimitCode(opts, u), locale.TrString("mail.reset_password"), "recover account") } // SendActivateEmailMail sends confirmation email to confirm new email address @@ -113,11 +115,12 @@ func SendActivateEmailMail(u *user_model.User, email string) { return } locale := translation.NewLocale(u.Language) + opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateEmail, NewEmail: email} data := map[string]any{ "locale": locale, "DisplayName": u.DisplayName(), "ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale), - "Code": u.GenerateEmailActivateCode(email), + "Code": user_model.GenerateUserTimeLimitCode(opts, u), "Email": email, "Language": locale.Language(), } diff --git a/tests/integration/org_team_invite_test.go b/tests/integration/org_team_invite_test.go index 274fde4085..4c1053702e 100644 --- a/tests/integration/org_team_invite_test.go +++ b/tests/integration/org_team_invite_test.go @@ -274,7 +274,8 @@ func TestOrgTeamEmailInviteRedirectsNewUserWithActivation(t *testing.T) { user, err := user_model.GetUserByName(db.DefaultContext, "doesnotexist") assert.NoError(t, err) - activateURL := fmt.Sprintf("/user/activate?code=%s", user.GenerateEmailActivateCode("doesnotexist@example.com")) + activationCode := user_model.GenerateUserTimeLimitCode(&user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount}, user) + activateURL := fmt.Sprintf("/user/activate?code=%s", activationCode) req = NewRequestWithValues(t, "POST", activateURL, map[string]string{ "password": "examplePassword!1", }) diff --git a/tests/integration/signup_test.go b/tests/integration/signup_test.go index e9a05201ee..e86851352e 100644 --- a/tests/integration/signup_test.go +++ b/tests/integration/signup_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" @@ -99,34 +100,39 @@ func TestSignupEmailActive(t *testing.T) { // try to sign up and send the activation email req := NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{ - "user_name": "test-user-1", - "email": "email-1@example.com", + "user_name": "Test-User-1", + "email": "EmAiL-1@example.com", "password": "password1", "retype": "password1", }) resp := MakeRequest(t, req, http.StatusOK) - assert.Contains(t, resp.Body.String(), `A new confirmation email has been sent to <b>email-1@example.com</b>.`) + assert.Contains(t, resp.Body.String(), `A new confirmation email has been sent to <b>EmAiL-1@example.com</b>.`) // access "user/activate" means trying to re-send the activation email session := loginUserWithPassword(t, "test-user-1", "password1") resp = session.MakeRequest(t, NewRequest(t, "GET", "/user/activate"), http.StatusOK) assert.Contains(t, resp.Body.String(), "You have already requested an activation email recently") - // access anywhere else will see a "Activate Your Account" prompt, and there is a chance to change email + // access anywhere else will see an "Activate Your Account" prompt, and there is a chance to change email resp = session.MakeRequest(t, NewRequest(t, "GET", "/user/issues"), http.StatusOK) assert.Contains(t, resp.Body.String(), `<input id="change-email" name="change_email" `) // post to "user/activate" with a new email session.MakeRequest(t, NewRequestWithValues(t, "POST", "/user/activate", map[string]string{"change_email": "email-changed@example.com"}), http.StatusSeeOther) - user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "test-user-1"}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "Test-User-1"}) assert.Equal(t, "email-changed@example.com", user.Email) email := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{Email: "email-changed@example.com"}) assert.False(t, email.IsActivated) assert.True(t, email.IsPrimary) + // generate an activation code from lower-cased email + activationCode := user_model.GenerateUserTimeLimitCode(&user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateAccount}, user) + // and update the user email to case-sensitive, it shouldn't affect the verification later + _, _ = db.Exec(db.DefaultContext, "UPDATE `user` SET email=? WHERE id=?", "EmAiL-changed@example.com", user.ID) + user = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "Test-User-1"}) + assert.Equal(t, "EmAiL-changed@example.com", user.Email) + // access "user/activate" with a valid activation code, then get the "verify password" page - user = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "test-user-1"}) - activationCode := user.GenerateEmailActivateCode(user.Email) resp = session.MakeRequest(t, NewRequest(t, "GET", "/user/activate?code="+activationCode), http.StatusOK) assert.Contains(t, resp.Body.String(), `<input id="verify-password"`) @@ -138,7 +144,7 @@ func TestSignupEmailActive(t *testing.T) { resp = session.MakeRequest(t, req, http.StatusOK) assert.Contains(t, resp.Body.String(), `Your password does not match`) assert.Contains(t, resp.Body.String(), `<input id="verify-password"`) - user = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "test-user-1"}) + user = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "Test-User-1"}) assert.False(t, user.IsActive) // then use a correct password, the user should be activated @@ -148,6 +154,6 @@ func TestSignupEmailActive(t *testing.T) { }) resp = session.MakeRequest(t, req, http.StatusSeeOther) assert.Equal(t, "/", test.RedirectURL(resp)) - user = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "test-user-1"}) + user = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "Test-User-1"}) assert.True(t, user.IsActive) } |