From dfdb0438adffa5bc130e88acd2a6b37bc6c260ec Mon Sep 17 00:00:00 2001 From: silverwind Date: Sat, 31 Oct 2026 23:33:10 +0100 Subject: [PATCH] fix(auth): only auto-link the account the external identity points to (#40121) (#40137) Backport https://github.com/go-gitea/gitea/pull/40121 With `[oauth2_client].ACCOUNT_LINKING = auto`, the link account sign-up form linked the external identity to an existing account matching the submitted user name or email and signed in as it. Automatic linking now only happens when the existing user matches the external identity, otherwise the linking login page asks for the account's password. `release/v28` lacks `TestOAuth2AvatarFromPicture`, so only the new test is added. Co-authored-by: bircni Co-authored-by: Giteabot --- routers/web/auth/auth.go | 11 ++++++++ tests/integration/oauth_test.go | 46 +++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/routers/web/auth/auth.go b/routers/web/auth/auth.go index 0fd6bf4967d..4b10b8b0916 100644 --- a/routers/web/auth/auth.go +++ b/routers/web/auth/auth.go @@ -447,6 +447,14 @@ func extractUserNameFromOAuth2(gothUser *goth.User) (string, error) { } } +func oauth2IdentityMatchesUser(gothUser *goth.User, u *user_model.User) bool { + if gothUser.Email != "" && strings.EqualFold(gothUser.Email, u.Email) { + return true + } + uname, err := extractUserNameFromOAuth2(gothUser) + return err == nil && uname != "" && strings.EqualFold(uname, u.Name) +} + // HandleSignOut resets the session and sets the cookies func HandleSignOut(ctx *context.Context) { _ = ctx.Session.Flush() @@ -646,6 +654,9 @@ func createUserInContext(ctx *context.Context, tpl templates.TplName, form any, return false } } + if !oauth2IdentityMatchesUser(&possibleLinkAccountData.GothUser, user) { + break // name or email came from the sign-up form, report the conflict instead of linking + } // TODO: probably we should respect 'remember' user's choice... oauth2LinkAccount(ctx, user, possibleLinkAccountData, true) return false // user is already created here, all redirects are handled diff --git a/tests/integration/oauth_test.go b/tests/integration/oauth_test.go index a28a9f6696e..cda0527b3b1 100644 --- a/tests/integration/oauth_test.go +++ b/tests/integration/oauth_test.go @@ -29,7 +29,10 @@ import ( "gitea.dev/modules/test" "gitea.dev/modules/timeutil" "gitea.dev/modules/util" + "gitea.dev/modules/web" + "gitea.dev/routers/web/auth" "gitea.dev/services/auth/source/oauth2" + "gitea.dev/services/context" "gitea.dev/services/oauth2_provider" "gitea.dev/tests" @@ -41,6 +44,49 @@ import ( "github.com/stretchr/testify/require" ) +func TestOAuth2LinkAccountSignupAutoLinking(t *testing.T) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.OAuth2Client.AccountLinking, setting.OAuth2AccountLinkingAuto)() + defer test.MockVariableValue(&setting.Service.RequireExternalRegistrationPassword, false)() + + addOAuth2Source(t, "test-link-signup", oauth2.Source{}) + authSource, err := auth_model.GetActiveOAuth2SourceByAuthName(t.Context(), "test-link-signup") + require.NoError(t, err) + + signUp := func(t *testing.T, gothUser goth.User, userName, email string, expectedStatus int) (*TestSession, *httptest.ResponseRecorder) { + defer web.RouteMockReset() + web.RouteMock(web.MockAfterMiddlewares, func(ctx *context.Context) { + require.NoError(t, auth.Oauth2SetLinkAccountData(ctx, auth.LinkAccountData{AuthSourceID: authSource.ID, GothUser: gothUser})) + }) + session := emptyTestSession(t) + req := NewRequestWithValues(t, "POST", "/user/link_account_signup", map[string]string{ + "user_name": userName, + "email": email, + }) + return session, session.MakeRequest(t, req, expectedStatus) + } + + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + for _, form := range []struct{ name, userName, email, errMsg string }{ + {"FormNamesOtherUser", user2.Name, "new-user@example.com", "The username is already taken."}, + {"FormUsesOtherUsersEmail", "new-user", user2.Email, "The email address is already used."}, + } { + t.Run(form.name, func(t *testing.T) { + gothUser := goth.User{UserID: form.name, Email: "external@example.com"} + _, resp := signUp(t, gothUser, form.userName, form.email, http.StatusOK) + assert.Contains(t, resp.Body.String(), form.errMsg) + unittest.AssertNotExistsBean(t, &user_model.ExternalLoginUser{ExternalID: gothUser.UserID}, unittest.OrderBy("external_id")) + }) + } + + t.Run("ExternalIdentityMatchesUser", func(t *testing.T) { + gothUser := goth.User{UserID: "user2-sub", Email: user2.Email} + session, _ := signUp(t, gothUser, user2.Name, user2.Email, http.StatusSeeOther) + unittest.AssertExistsAndLoadBean(t, &user_model.ExternalLoginUser{ExternalID: gothUser.UserID, UserID: user2.ID}, unittest.OrderBy("external_id")) + session.MakeRequest(t, NewRequest(t, "GET", "/user/settings"), http.StatusOK) + }) +} + func testOAuth2PrepareTestCode(t *testing.T) { require.NoError(t, db.TruncateBeans(t.Context(), &auth_model.OAuth2AuthorizationCode{})) err := db.Insert(t.Context(), &auth_model.OAuth2AuthorizationCode{