mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-29 10:57:44 +09:00 
			
		
		
		
	Support for email addresses containing uppercase characters when activating user account (#32998)
Fix #32807 --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
		| @@ -357,8 +357,8 @@ func VerifyActiveEmailCode(ctx context.Context, code, email string) *EmailAddres | |||||||
| 	if user := GetVerifyUser(ctx, code); user != nil { | 	if user := GetVerifyUser(ctx, code); user != nil { | ||||||
| 		// time limit code | 		// time limit code | ||||||
| 		prefix := code[:base.TimeLimitCodeLength] | 		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) { | 		if base.VerifyTimeLimitCode(time.Now(), data, setting.Service.ActiveCodeLives, prefix) { | ||||||
| 			emailAddress := &EmailAddress{UID: user.ID, Email: email} | 			emailAddress := &EmailAddress{UID: user.ID, Email: email} | ||||||
| 			if has, _ := db.GetEngine(ctx).Get(emailAddress); has { | 			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 | 	// Activate/deactivate a user's primary email address and account | ||||||
| 	if addr.IsPrimary { | 	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 { | 		if err != nil { | ||||||
| 			return err | 			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) | 			return fmt.Errorf("no user with ID: %d and Email: %s", userID, email) | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|   | |||||||
| @@ -181,7 +181,8 @@ func (u *User) BeforeUpdate() { | |||||||
| 		u.MaxRepoCreation = -1 | 		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) | 	u.Email = strings.ToLower(u.Email) | ||||||
| 	if !u.IsOrganization() { | 	if !u.IsOrganization() { | ||||||
| 		if len(u.AvatarEmail) == 0 { | 		if len(u.AvatarEmail) == 0 { | ||||||
| @@ -310,17 +311,6 @@ func (u *User) OrganisationLink() string { | |||||||
| 	return setting.AppSubURL + "/org/" + url.PathEscape(u.Name) | 	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. | // GetUserFollowers returns range of user's followers. | ||||||
| func GetUserFollowers(ctx context.Context, u, viewer *User, listOptions db.ListOptions) ([]*User, int64, error) { | func GetUserFollowers(ctx context.Context, u, viewer *User, listOptions db.ListOptions) ([]*User, int64, error) { | ||||||
| 	sess := db.GetEngine(ctx). | 	sess := db.GetEngine(ctx). | ||||||
| @@ -863,12 +853,38 @@ func GetVerifyUser(ctx context.Context, code string) (user *User) { | |||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  |  | ||||||
| // VerifyUserActiveCode verifies active code when active account | type TimeLimitCodePurpose string | ||||||
| func VerifyUserActiveCode(ctx context.Context, code string) (user *User) { |  | ||||||
|  | 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 { | 	if user = GetVerifyUser(ctx, code); user != nil { | ||||||
| 		// time limit code | 		// time limit code | ||||||
| 		prefix := code[:base.TimeLimitCodeLength] | 		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) { | 		if base.VerifyTimeLimitCode(time.Now(), data, setting.Service.ActiveCodeLives, prefix) { | ||||||
| 			return user | 			return user | ||||||
| 		} | 		} | ||||||
|   | |||||||
| @@ -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 | 	// 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 | 	if user == nil { // if code is wrong | ||||||
| 		renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.invalid_code")) | 		renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.invalid_code")) | ||||||
| 		return | 		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 | 	// 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 | 	if user == nil { // if code is wrong | ||||||
| 		renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.invalid_code")) | 		renderActivationPromptMessage(ctx, ctx.Locale.Tr("auth.invalid_code")) | ||||||
| 		return | 		return | ||||||
|   | |||||||
| @@ -113,7 +113,7 @@ func commonResetPassword(ctx *context.Context) (*user_model.User, *auth.TwoFacto | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// Fail early, don't frustrate the user | 	// 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 { | 	if u == nil { | ||||||
| 		ctx.Flash.Error(ctx.Tr("auth.invalid_code_forgot_password", fmt.Sprintf("%s/user/forgot_password", setting.AppSubURL)), true) | 		ctx.Flash.Error(ctx.Tr("auth.invalid_code_forgot_password", fmt.Sprintf("%s/user/forgot_password", setting.AppSubURL)), true) | ||||||
| 		return nil, nil | 		return nil, nil | ||||||
|   | |||||||
| @@ -93,7 +93,8 @@ func SendActivateAccountMail(locale translation.Locale, u *user_model.User) { | |||||||
| 		// No mail service configured | 		// No mail service configured | ||||||
| 		return | 		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 | // SendResetPasswordMail sends a password reset mail to the user | ||||||
| @@ -103,7 +104,8 @@ func SendResetPasswordMail(u *user_model.User) { | |||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 	locale := translation.NewLocale(u.Language) | 	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 | // SendActivateEmailMail sends confirmation email to confirm new email address | ||||||
| @@ -113,11 +115,12 @@ func SendActivateEmailMail(u *user_model.User, email string) { | |||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 	locale := translation.NewLocale(u.Language) | 	locale := translation.NewLocale(u.Language) | ||||||
|  | 	opts := &user_model.TimeLimitCodeOptions{Purpose: user_model.TimeLimitCodeActivateEmail, NewEmail: email} | ||||||
| 	data := map[string]any{ | 	data := map[string]any{ | ||||||
| 		"locale":          locale, | 		"locale":          locale, | ||||||
| 		"DisplayName":     u.DisplayName(), | 		"DisplayName":     u.DisplayName(), | ||||||
| 		"ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale), | 		"ActiveCodeLives": timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, locale), | ||||||
| 		"Code":            u.GenerateEmailActivateCode(email), | 		"Code":            user_model.GenerateUserTimeLimitCode(opts, u), | ||||||
| 		"Email":           email, | 		"Email":           email, | ||||||
| 		"Language":        locale.Language(), | 		"Language":        locale.Language(), | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -274,7 +274,8 @@ func TestOrgTeamEmailInviteRedirectsNewUserWithActivation(t *testing.T) { | |||||||
| 	user, err := user_model.GetUserByName(db.DefaultContext, "doesnotexist") | 	user, err := user_model.GetUserByName(db.DefaultContext, "doesnotexist") | ||||||
| 	assert.NoError(t, err) | 	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{ | 	req = NewRequestWithValues(t, "POST", activateURL, map[string]string{ | ||||||
| 		"password": "examplePassword!1", | 		"password": "examplePassword!1", | ||||||
| 	}) | 	}) | ||||||
|   | |||||||
| @@ -9,6 +9,7 @@ import ( | |||||||
| 	"strings" | 	"strings" | ||||||
| 	"testing" | 	"testing" | ||||||
|  |  | ||||||
|  | 	"code.gitea.io/gitea/models/db" | ||||||
| 	"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" | ||||||
| @@ -99,34 +100,39 @@ func TestSignupEmailActive(t *testing.T) { | |||||||
|  |  | ||||||
| 	// try to sign up and send the activation email | 	// try to sign up and send the activation email | ||||||
| 	req := NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{ | 	req := NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{ | ||||||
| 		"user_name": "test-user-1", | 		"user_name": "Test-User-1", | ||||||
| 		"email":     "email-1@example.com", | 		"email":     "EmAiL-1@example.com", | ||||||
| 		"password":  "password1", | 		"password":  "password1", | ||||||
| 		"retype":    "password1", | 		"retype":    "password1", | ||||||
| 	}) | 	}) | ||||||
| 	resp := MakeRequest(t, req, http.StatusOK) | 	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 | 	// access "user/activate" means trying to re-send the activation email | ||||||
| 	session := loginUserWithPassword(t, "test-user-1", "password1") | 	session := loginUserWithPassword(t, "test-user-1", "password1") | ||||||
| 	resp = session.MakeRequest(t, NewRequest(t, "GET", "/user/activate"), http.StatusOK) | 	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") | 	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) | 	resp = session.MakeRequest(t, NewRequest(t, "GET", "/user/issues"), http.StatusOK) | ||||||
| 	assert.Contains(t, resp.Body.String(), `<input id="change-email" name="change_email" `) | 	assert.Contains(t, resp.Body.String(), `<input id="change-email" name="change_email" `) | ||||||
|  |  | ||||||
| 	// post to "user/activate" with a new 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) | 	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) | 	assert.Equal(t, "email-changed@example.com", user.Email) | ||||||
| 	email := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{Email: "email-changed@example.com"}) | 	email := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{Email: "email-changed@example.com"}) | ||||||
| 	assert.False(t, email.IsActivated) | 	assert.False(t, email.IsActivated) | ||||||
| 	assert.True(t, email.IsPrimary) | 	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 | 	// 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) | 	resp = session.MakeRequest(t, NewRequest(t, "GET", "/user/activate?code="+activationCode), http.StatusOK) | ||||||
| 	assert.Contains(t, resp.Body.String(), `<input id="verify-password"`) | 	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) | 	resp = session.MakeRequest(t, req, http.StatusOK) | ||||||
| 	assert.Contains(t, resp.Body.String(), `Your password does not match`) | 	assert.Contains(t, resp.Body.String(), `Your password does not match`) | ||||||
| 	assert.Contains(t, resp.Body.String(), `<input id="verify-password"`) | 	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) | 	assert.False(t, user.IsActive) | ||||||
|  |  | ||||||
| 	// then use a correct password, the user should be activated | 	// 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) | 	resp = session.MakeRequest(t, req, http.StatusSeeOther) | ||||||
| 	assert.Equal(t, "/", test.RedirectURL(resp)) | 	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) | 	assert.True(t, user.IsActive) | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user