fix(auth): only auto-link the account the external identity points to (#40121)

With `[oauth2_client].ACCOUNT_LINKING = auto`, submitting the link
account sign-up form with a user name or email that already exists
linked the external identity to that existing account and signed in as
it, without checking that the external identity belongs to that account.

Automatic linking now only happens when the existing user matches the
external identity: its email equals the provider email, or its name
equals the user name derived from the provider (per
`[oauth2_client].USERNAME`, same as auto registration). Otherwise the
linking login page is shown, which requires the existing account's
password.

Auto registration is unchanged, since the name and email there already
come from the provider.

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
authored and GitHub committed 2026-10-31 18:20:56 +01:00
1 parent 723095e66d
commit 4ed6f89b60
2 files changed
+54

No files matched your search

+11
View File
@@ -449,6 +449,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()
@@ -649,6 +657,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
+43
View File
@@ -115,6 +115,49 @@ func TestOAuth2AvatarFromPicture(t *testing.T) {
})
}
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{