diff --git a/models/auth/source.go b/models/auth/source.go index 0a904b7772..537cdde70b 100644 --- a/models/auth/source.go +++ b/models/auth/source.go @@ -5,6 +5,7 @@ package auth import ( + "context" "fmt" "reflect" @@ -32,6 +33,9 @@ const ( SSPI // 7 ) +// This should be in the above list of types but is separated to avoid conflicts with Gitea changes +const F3 Type = 129 + // String returns the string name of the LoginType func (typ Type) String() string { return Names[typ] @@ -50,6 +54,7 @@ var Names = map[Type]string{ PAM: "PAM", OAuth2: "OAuth2", SSPI: "SPNEGO with SSPI", + F3: "F3", } // Config represents login config as far as the db is concerned @@ -178,6 +183,10 @@ func (source *Source) IsSSPI() bool { return source.Type == SSPI } +func (source *Source) IsF3() bool { + return source.Type == F3 +} + // HasTLS returns true of this source supports TLS. func (source *Source) HasTLS() bool { hasTLSer, ok := source.Cfg.(HasTLSer) @@ -306,6 +315,17 @@ func GetSourceByID(id int64) (*Source, error) { return source, nil } +func GetSourceByName(ctx context.Context, name string) (*Source, error) { + source := &Source{} + has, err := db.GetEngine(ctx).Where("name = ?", name).Get(source) + if err != nil { + return nil, err + } else if !has { + return nil, ErrSourceNotExist{} + } + return source, nil +} + // UpdateSource updates a Source record in DB. func UpdateSource(source *Source) error { var originalSource *Source diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index 0ce3bbde00..d4b54c3af4 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -33,6 +33,7 @@ import ( source_service "code.gitea.io/gitea/services/auth/source" "code.gitea.io/gitea/services/auth/source/oauth2" "code.gitea.io/gitea/services/externalaccount" + f3_service "code.gitea.io/gitea/services/f3" "code.gitea.io/gitea/services/forms" user_service "code.gitea.io/gitea/services/user" @@ -1208,9 +1209,21 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model ctx.Redirect(setting.AppSubURL + "/user/two_factor") } -// OAuth2UserLoginCallback attempts to handle the callback from the OAuth2 provider and if successful -// login the user func oAuth2UserLoginCallback(authSource *auth.Source, request *http.Request, response http.ResponseWriter) (*user_model.User, goth.User, error) { + gothUser, err := oAuth2FetchUser(authSource, request, response) + if err != nil { + return nil, goth.User{}, err + } + + if err := f3_service.MaybePromoteF3User(request.Context(), authSource, gothUser.UserID, gothUser.Email); err != nil { + return nil, goth.User{}, err + } + + u, err := oAuth2GothUserToUser(request.Context(), authSource, gothUser) + return u, gothUser, err +} + +func oAuth2FetchUser(authSource *auth.Source, request *http.Request, response http.ResponseWriter) (goth.User, error) { oauth2Source := authSource.Cfg.(*oauth2.Source) // Make sure that the response is not an error response. @@ -1222,10 +1235,10 @@ func oAuth2UserLoginCallback(authSource *auth.Source, request *http.Request, res // Delete the goth session err := gothic.Logout(response, request) if err != nil { - return nil, goth.User{}, err + return goth.User{}, err } - return nil, goth.User{}, errCallback{ + return goth.User{}, errCallback{ Code: errorName, Description: errorDescription, } @@ -1238,24 +1251,28 @@ func oAuth2UserLoginCallback(authSource *auth.Source, request *http.Request, res log.Error("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength) err = fmt.Errorf("OAuth2 Provider %s returned too long a token. Current max: %d. Either increase the [OAuth2] MAX_TOKEN_LENGTH or reduce the information returned from the OAuth2 provider", authSource.Name, setting.OAuth2.MaxTokenLength) } - return nil, goth.User{}, err + return goth.User{}, err } if oauth2Source.RequiredClaimName != "" { claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName] if !has { - return nil, goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} + return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} } if oauth2Source.RequiredClaimValue != "" { groups := claimValueToStringSet(claimInterface) if !groups.Contains(oauth2Source.RequiredClaimValue) { - return nil, goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} + return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} } } } + return gothUser, nil +} + +func oAuth2GothUserToUser(ctx go_context.Context, authSource *auth.Source, gothUser goth.User) (*user_model.User, error) { user := &user_model.User{ LoginName: gothUser.UserID, LoginType: auth.OAuth2, @@ -1264,12 +1281,13 @@ func oAuth2UserLoginCallback(authSource *auth.Source, request *http.Request, res hasUser, err := user_model.GetUser(user) if err != nil { - return nil, goth.User{}, err + return nil, err } if hasUser { - return user, gothUser, nil + return user, nil } + log.Debug("no user found for LoginName %v, LoginSource %v, LoginType %v", user.LoginName, user.LoginSource, user.LoginType) // search in external linked users externalLoginUser := &user_model.ExternalLoginUser{ @@ -1278,13 +1296,13 @@ func oAuth2UserLoginCallback(authSource *auth.Source, request *http.Request, res } hasUser, err = user_model.GetExternalLogin(externalLoginUser) if err != nil { - return nil, goth.User{}, err + return nil, err } if hasUser { - user, err = user_model.GetUserByID(request.Context(), externalLoginUser.UserID) - return user, gothUser, err + user, err = user_model.GetUserByID(ctx, externalLoginUser.UserID) + return user, err } // no user found to login - return nil, gothUser, nil + return nil, nil } diff --git a/services/auth/source/f3/source.go b/services/auth/source/f3/source.go new file mode 100644 index 0000000000..800e4baea3 --- /dev/null +++ b/services/auth/source/f3/source.go @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright the Forgejo contributors +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/modules/json" +) + +type Source struct { + URL string + MatchingSource string + + // reference to the authSource + authSource *auth.Source +} + +func (source *Source) FromDB(bs []byte) error { + return json.UnmarshalHandleDoubleEncode(bs, &source) +} + +func (source *Source) ToDB() ([]byte, error) { + return json.Marshal(source) +} + +func (source *Source) SetAuthSource(authSource *auth.Source) { + source.authSource = authSource +} + +func init() { + auth.RegisterTypeConfig(auth.F3, &Source{}) +} diff --git a/services/auth/source/oauth2/http.go b/services/auth/source/oauth2/http.go new file mode 100644 index 0000000000..1ee52412ae --- /dev/null +++ b/services/auth/source/oauth2/http.go @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: Copyright the Forgejo contributors +// SPDX-License-Identifier: MIT + +package oauth2 + +import ( + "net/http" +) + +var HTTPClient *http.Client diff --git a/services/auth/source/oauth2/providers_custom.go b/services/auth/source/oauth2/providers_custom.go index 65cf538ad7..41570b699e 100644 --- a/services/auth/source/oauth2/providers_custom.go +++ b/services/auth/source/oauth2/providers_custom.go @@ -63,7 +63,9 @@ func init() { if setting.OAuth2Client.EnableAutoRegistration { scopes = append(scopes, "user:email") } - return github.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, custom.EmailURL, scopes...), nil + provider := github.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, custom.EmailURL, scopes...) + provider.HTTPClient = HTTPClient + return provider, nil })) RegisterGothProvider(NewCustomProvider( @@ -73,7 +75,9 @@ func init() { ProfileURL: availableAttribute(gitlab.ProfileURL), }, func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) { scopes = append(scopes, "read_user") - return gitlab.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, scopes...), nil + provider := gitlab.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, scopes...) + provider.HTTPClient = HTTPClient + return provider, nil })) RegisterGothProvider(NewCustomProvider( @@ -83,7 +87,9 @@ func init() { ProfileURL: requiredAttribute(gitea.ProfileURL), }, func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) { - return gitea.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, scopes...), nil + provider := gitea.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, scopes...) + provider.HTTPClient = HTTPClient + return provider, nil })) RegisterGothProvider(NewCustomProvider( @@ -93,7 +99,9 @@ func init() { ProfileURL: requiredAttribute(nextcloud.ProfileURL), }, func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) { - return nextcloud.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, scopes...), nil + provider := nextcloud.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, custom.TokenURL, custom.ProfileURL, scopes...) + provider.HTTPClient = HTTPClient + return provider, nil })) RegisterGothProvider(NewCustomProvider( @@ -101,7 +109,9 @@ func init() { AuthURL: requiredAttribute(mastodon.InstanceURL), }, func(clientID, secret, callbackURL string, custom *CustomURLMapping, scopes []string) (goth.Provider, error) { - return mastodon.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, scopes...), nil + provider := mastodon.NewCustomisedURL(clientID, secret, callbackURL, custom.AuthURL, scopes...) + provider.HTTPClient = HTTPClient + return provider, nil })) RegisterGothProvider(NewCustomProvider( @@ -114,10 +124,12 @@ func init() { azureScopes[i] = azureadv2.ScopeType(scope) } - return azureadv2.New(clientID, secret, callbackURL, azureadv2.ProviderOptions{ + provider := azureadv2.New(clientID, secret, callbackURL, azureadv2.ProviderOptions{ Tenant: azureadv2.TenantType(custom.Tenant), Scopes: azureScopes, - }), nil + }) + provider.HTTPClient = HTTPClient + return provider, nil }, )) } diff --git a/services/auth/source/oauth2/providers_openid.go b/services/auth/source/oauth2/providers_openid.go index 54530ae8a8..5f7ac15828 100644 --- a/services/auth/source/oauth2/providers_openid.go +++ b/services/auth/source/oauth2/providers_openid.go @@ -43,6 +43,7 @@ func (o *OpenIDProvider) CreateGothProvider(providerName, callbackURL string, so if err != nil { log.Warn("Failed to create OpenID Connect Provider with name '%s' with url '%s': %v", providerName, source.OpenIDConnectAutoDiscoveryURL, err) } + provider.HTTPClient = HTTPClient return provider, err } diff --git a/services/f3/promote.go b/services/f3/promote.go new file mode 100644 index 0000000000..b11ff83a19 --- /dev/null +++ b/services/f3/promote.go @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: Copyright the Forgejo contributors +// SPDX-License-Identifier: MIT + +package f3 + +import ( + "context" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + f3_source "code.gitea.io/gitea/services/auth/source/f3" + "code.gitea.io/gitea/services/auth/source/oauth2" +) + +func getUserByLoginName(ctx context.Context, name string) (*user_model.User, error) { + if len(name) == 0 { + return nil, user_model.ErrUserNotExist{Name: name} + } + u := &user_model.User{LoginName: name, LoginType: auth_model.F3, Type: user_model.UserTypeRemoteUser} + has, err := db.GetEngine(ctx).Get(u) + if err != nil { + return nil, err + } else if !has { + return nil, user_model.ErrUserNotExist{Name: name} + } + return u, nil +} + +// The user created by F3 has: +// +// Type UserTypeRemoteUser +// LogingType F3 +// LoginName set to the unique identifier of the originating forge +// LoginSource set to the F3 source that can be matched against a OAuth2 source +// +// If the source from which an authentification happens is OAuth2, a existing +// F3 user will be promoted to an OAuth2 user provided: +// +// user.LoginName is the same as goth.UserID (argument loginName) +// user.LoginSource has a MatchingSource equals to the name of the OAuth2 provider +// +// Once promoted, the user will be logged in without further interaction from the +// user and will own all repositories, issues, etc. associated with it. +func MaybePromoteF3User(ctx context.Context, source *auth_model.Source, loginName, email string) error { + user, err := getF3UserToPromote(ctx, source, loginName, email) + if err != nil { + return err + } + if user != nil { + promote := &user_model.User{ + ID: user.ID, + Type: user_model.UserTypeIndividual, + Email: email, + LoginSource: source.ID, + LoginType: source.Type, + } + log.Debug("promote user %v: LoginName %v => %v, LoginSource %v => %v, LoginType %v => %v, Email %v => %v", user.ID, user.LoginName, promote.LoginName, user.LoginSource, promote.LoginSource, user.LoginType, promote.LoginType, user.Email, promote.Email) + return user_model.UpdateUser(ctx, promote, true, "type", "email", "login_source", "login_type") + } + return nil +} + +func getF3UserToPromote(ctx context.Context, source *auth_model.Source, loginName, email string) (*user_model.User, error) { + if !source.IsOAuth2() { + log.Debug("getF3UserToPromote: source %v is not OAuth2", source) + return nil, nil + } + oauth2Source, ok := source.Cfg.(*oauth2.Source) + if !ok { + log.Error("getF3UserToPromote: source claims to be OAuth2 but really is %v", oauth2Source) + return nil, nil + } + + u, err := getUserByLoginName(ctx, loginName) + if err != nil { + if user_model.IsErrUserNotExist(err) { + log.Debug("getF3UserToPromote: no user with LoginType F3 and LoginName '%s'", loginName) + return nil, nil + } + return nil, err + } + + if u.Email != "" { + log.Debug("getF3UserToPromote: the user email is already set to '%s'", u.Email) + return nil, nil + } + + userSource, err := auth_model.GetSourceByID(u.LoginSource) + if err != nil { + if auth_model.IsErrSourceNotExist(err) { + log.Error("getF3UserToPromote: source id = %v for user %v not found %v", u.LoginSource, u.ID, err) + return nil, nil + } + return nil, err + } + f3Source, ok := userSource.Cfg.(*f3_source.Source) + if !ok { + log.Error("getF3UserToPromote: expected an F3 source but got %T %v", userSource, userSource) + return nil, nil + } + + if oauth2Source.Provider != f3Source.MatchingSource { + log.Debug("getF3UserToPromote: skip OAuth2 source %s because it is different from %s which is the expected match for the F3 source %s", oauth2Source.Provider, f3Source.MatchingSource, f3Source.URL) + return nil, nil + } + + return u, nil +} diff --git a/tests/integration/f3_test.go b/tests/integration/f3_test.go index 6c3ba75c07..3c213fef5c 100644 --- a/tests/integration/f3_test.go +++ b/tests/integration/f3_test.go @@ -4,14 +4,20 @@ package integration import ( "context" + "fmt" + "net/http" "net/url" "testing" auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/services/f3/util" + "code.gitea.io/gitea/tests" + "github.com/markbates/goth" "github.com/stretchr/testify/assert" "lab.forgefriends.org/friendlyforgeformat/gof3" f3_forges "lab.forgefriends.org/friendlyforgeformat/gof3/forges" @@ -115,3 +121,60 @@ func TestF3(t *testing.T) { // f3_util.Command(context.Background(), "cp", "-a", f3.GetDirectory(), "abc") }) } + +func TestMaybePromoteF3User(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // + // OAuth2 authentication source GitLab + // + gitlabName := "gitlab" + _ = addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName)) + // + // F3 authentication source matching the GitLab authentication source + // + f3Name := "f3" + f3 := createF3AuthSource(t, f3Name, "http://mygitlab.eu", gitlabName) + + // + // Create a user as if it had been previously been created by the F3 + // authentication source. + // + gitlabUserID := "5678" + gitlabEmail := "gitlabuser@example.com" + userBeforeSignIn := &user_model.User{ + Name: "gitlabuser", + Type: user_model.UserTypeRemoteUser, + LoginType: auth_model.F3, + LoginSource: f3.ID, + LoginName: gitlabUserID, + } + defer createUser(context.Background(), t, userBeforeSignIn)() + + // + // A request for user information sent to Goth will return a + // goth.User exactly matching the user created above. + // + defer mockCompleteUserAuth(func(res http.ResponseWriter, req *http.Request) (goth.User, error) { + return goth.User{ + Provider: gitlabName, + UserID: gitlabUserID, + Email: gitlabEmail, + }, nil + })() + req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?code=XYZ&state=XYZ", gitlabName)) + resp := MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, "/", test.RedirectURL(resp)) + userAfterSignIn := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userBeforeSignIn.ID}) + + // both are about the same user + assert.Equal(t, userAfterSignIn.ID, userBeforeSignIn.ID) + // the login time was updated, proof the login succeeded + assert.Greater(t, userAfterSignIn.LastLoginUnix, userBeforeSignIn.LastLoginUnix) + // the login type was promoted from F3 to OAuth2 + assert.Equal(t, userBeforeSignIn.LoginType, auth_model.F3) + assert.Equal(t, userAfterSignIn.LoginType, auth_model.OAuth2) + // the OAuth2 email was used to set the missing user email + assert.Equal(t, userBeforeSignIn.Email, "") + assert.Equal(t, userAfterSignIn.Email, gitlabEmail) +} diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go index f3d542511a..c27e1fd08d 100644 --- a/tests/integration/integration_test.go +++ b/tests/integration/integration_test.go @@ -23,6 +23,7 @@ import ( "time" "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" gitea_context "code.gitea.io/gitea/modules/context" @@ -34,10 +35,15 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers" + "code.gitea.io/gitea/services/auth/source/f3" + "code.gitea.io/gitea/services/auth/source/oauth2" user_service "code.gitea.io/gitea/services/user" "code.gitea.io/gitea/tests" "github.com/PuerkitoBio/goquery" + "github.com/markbates/goth" + "github.com/markbates/goth/gothic" + goth_gitlab "github.com/markbates/goth/providers/gitlab" "github.com/stretchr/testify/assert" "github.com/xeipuuv/gojsonschema" ) @@ -241,18 +247,82 @@ func getUserToken(t testing.TB, userName string, scope ...auth.AccessTokenScope) return getTokenForLoggedInUser(t, loginUser(t, userName), scope...) } -func createUser(t testing.TB, userName, email, password string) func() { - u := &user_model.User{ - Name: userName, - Email: email, - Passwd: password, - MustChangePassword: false, - LoginType: auth.Plain, +func mockCompleteUserAuth(mock func(res http.ResponseWriter, req *http.Request) (goth.User, error)) func() { + old := gothic.CompleteUserAuth + gothic.CompleteUserAuth = mock + return func() { + gothic.CompleteUserAuth = old + } +} + +func addAuthSource(t *testing.T, payload map[string]string) *auth.Source { + session := loginUser(t, "user1") + payload["_csrf"] = GetCSRF(t, session, "/admin/auths/new") + req := NewRequestWithValues(t, "POST", "/admin/auths/new", payload) + session.MakeRequest(t, req, http.StatusSeeOther) + source, err := auth.GetSourceByName(context.Background(), payload["name"]) + assert.NoError(t, err) + return source +} + +func authSourcePayloadOAuth2(name string) map[string]string { + return map[string]string{ + "type": fmt.Sprintf("%d", auth.OAuth2), + "name": name, + "is_active": "on", + } +} + +func authSourcePayloadGitLab(name string) map[string]string { + payload := authSourcePayloadOAuth2(name) + payload["oauth2_provider"] = "gitlab" + return payload +} + +func authSourcePayloadGitLabCustom(name string) map[string]string { + payload := authSourcePayloadGitLab(name) + payload["oauth2_use_custom_url"] = "on" + payload["oauth2_auth_url"] = goth_gitlab.AuthURL + payload["oauth2_token_url"] = goth_gitlab.TokenURL + payload["oauth2_profile_url"] = goth_gitlab.ProfileURL + return payload +} + +func authSourcePayloadOIDC(name string) map[string]string { + payload := authSourcePayloadOAuth2(name) + payload["oauth2_provider"] = (&oauth2.OpenIDProvider{}).Name() + payload["open_id_connect_auto_discovery_url"] = codebergURL + "/.well-known/openid-configuration" + return payload +} + +func createF3AuthSource(t *testing.T, name, url, matchingSource string) *auth.Source { + assert.NoError(t, auth.CreateSource(&auth.Source{ + Type: auth.F3, + Name: name, + IsActive: true, + Cfg: &f3.Source{ + URL: url, + MatchingSource: matchingSource, + }, + })) + source, err := auth.GetSourceByName(context.Background(), name) + assert.NoError(t, err) + return source +} + +func createUser(ctx context.Context, t testing.TB, user *user_model.User) func() { + user.MustChangePassword = false + user.LowerName = strings.ToLower(user.Name) + + assert.NoError(t, db.Insert(ctx, user)) + + if len(user.Email) > 0 { + changePrimaryEmail := true + assert.NoError(t, user_model.UpdateUser(ctx, user, changePrimaryEmail)) } - assert.NoError(t, user_model.CreateUser(u, &user_model.CreateUserOverwriteOptions{})) return func() { - assert.NoError(t, user_service.DeleteUser(context.Background(), u, true)) + assert.NoError(t, user_service.DeleteUser(ctx, user, true)) } } diff --git a/tests/integration/linkaccount_test.go b/tests/integration/linkaccount_test.go index e7b9f9c261..a951d79a60 100644 --- a/tests/integration/linkaccount_test.go +++ b/tests/integration/linkaccount_test.go @@ -1,12 +1,15 @@ -// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-FileCopyrightText: Copyright the Forgejo contributors // SPDX-License-Identifier: MIT package integration import ( + "context" "net/http" "testing" + auth_model "code.gitea.io/gitea/models/auth" + user_model "code.gitea.io/gitea/models/user" gitea_context "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/tests" @@ -14,50 +17,125 @@ import ( "github.com/stretchr/testify/assert" ) +const codebergURL = "https://codeberg.org" + func TestLinkAccountChoose(t *testing.T) { defer tests.PrepareTestEnv(t)() - username := "linkaccountuser" - email := "linkaccountuser@example.com" - password := "linkaccountuser" - defer createUser(t, username, email, password)() + ctx := context.Background() + + // Create a OIDC source and a known OAuth2 source + codebergName := "codeberg" + codeberg := addAuthSource(t, authSourcePayloadOIDC(codebergName)) + gitlabName := "gitlab" + gitlab := addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName)) + + // + // A local user + // + localUser := &user_model.User{ + Name: "linkaccountuser", + Email: "linkaccountuser@example.com", + Passwd: "linkaccountuser", + Type: user_model.UserTypeIndividual, + } + defer createUser(ctx, t, localUser)() + + // + // A Codeberg user via OIDC + // + userCodebergUserID := "1234" + userCodeberg := &user_model.User{ + Name: "linkaccountcodeberguser", + Email: "linkaccountcodeberguser@example.com", + Passwd: "linkaccountcodeberguser", + Type: user_model.UserTypeIndividual, + LoginType: auth_model.OAuth2, + LoginSource: codeberg.ID, + LoginName: userCodebergUserID, + } + defer createUser(ctx, t, userCodeberg)() + + // + // A Gitlab user + // + userGitLabUserID := "5678" + userGitLab := &user_model.User{ + Name: "linkaccountgitlabuser", + Email: "linkaccountgitlabuser@example.com", + Passwd: "linkaccountgitlabuser", + Type: user_model.UserTypeIndividual, + LoginType: auth_model.OAuth2, + LoginSource: gitlab.ID, + LoginName: userGitLabUserID, + } + defer createUser(ctx, t, userGitLab)() defer func() { testMiddlewareHook = nil }() for _, testCase := range []struct { + title string gothUser goth.User signupTab string signinTab string }{ { - gothUser: goth.User{}, + title: "No existing user", + gothUser: goth.User{ + Provider: codebergName, + }, signupTab: "item active", signinTab: "item ", }, { + title: "Matched local user", gothUser: goth.User{ - Email: email, + Provider: codebergName, + Email: localUser.Email, + }, + signupTab: "item ", + signinTab: "item active", + }, + { + title: "Matched Codeberg local user", + gothUser: goth.User{ + Provider: codebergName, + UserID: userCodebergUserID, + Email: userCodeberg.Email, + }, + signupTab: "item ", + signinTab: "item active", + }, + { + title: "Matched GitLab local user", + gothUser: goth.User{ + Provider: gitlabName, + UserID: userGitLabUserID, + Email: userGitLab.Email, }, signupTab: "item ", signinTab: "item active", }, } { - testMiddlewareHook = func(ctx *gitea_context.Context) { - ctx.Session.Set("linkAccountGothUser", testCase.gothUser) - } + t.Run(testCase.title, func(t *testing.T) { + testMiddlewareHook = func(ctx *gitea_context.Context) { + ctx.Session.Set("linkAccountGothUser", testCase.gothUser) + } - req := NewRequest(t, "GET", "/user/link_account") - resp := MakeRequest(t, req, http.StatusOK) - assert.Equal(t, resp.Code, http.StatusOK, resp.Body) - doc := NewHTMLParser(t, resp.Body) + req := NewRequest(t, "GET", "/user/link_account") + resp := MakeRequest(t, req, http.StatusOK) + if assert.Equal(t, resp.Code, http.StatusOK, resp.Body) { + doc := NewHTMLParser(t, resp.Body) - class, exists := doc.Find(`.new-menu-inner .item[data-tab="auth-link-signup-tab"]`).Attr("class") - assert.True(t, exists, resp.Body) - assert.Equal(t, testCase.signupTab, class) + class, exists := doc.Find(`.new-menu-inner .item[data-tab="auth-link-signup-tab"]`).Attr("class") + assert.True(t, exists, resp.Body) + assert.Equal(t, testCase.signupTab, class) - class, exists = doc.Find(`.new-menu-inner .item[data-tab="auth-link-signin-tab"]`).Attr("class") - assert.True(t, exists) - assert.Equal(t, testCase.signinTab, class) + class, exists = doc.Find(`.new-menu-inner .item[data-tab="auth-link-signin-tab"]`).Attr("class") + assert.True(t, exists) + assert.Equal(t, testCase.signinTab, class) + } + }) } } diff --git a/tests/integration/oauth_test.go b/tests/integration/oauth_test.go index e9b69f5f14..4a00d73a02 100644 --- a/tests/integration/oauth_test.go +++ b/tests/integration/oauth_test.go @@ -5,15 +5,22 @@ package integration import ( "bytes" + "context" + "fmt" "io" "net/http" "testing" + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/routers/web/auth" "code.gitea.io/gitea/tests" + "github.com/markbates/goth" "github.com/stretchr/testify/assert" ) @@ -419,3 +426,46 @@ func TestRefreshTokenInvalidation(t *testing.T) { assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode)) assert.Equal(t, "token was already used", parsedError.ErrorDescription) } + +func TestSignInOAuthCallbackSignIn(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // + // OAuth2 authentication source GitLab + // + gitlabName := "gitlab" + gitlab := addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName)) + + // + // Create a user as if it had been previously been created by the GitLab + // authentication source. + // + userGitLabUserID := "5678" + userGitLab := &user_model.User{ + Name: "gitlabuser", + Email: "gitlabuser@example.com", + Passwd: "gitlabuserpassword", + Type: user_model.UserTypeIndividual, + LoginType: auth_model.OAuth2, + LoginSource: gitlab.ID, + LoginName: userGitLabUserID, + } + defer createUser(context.Background(), t, userGitLab)() + + // + // A request for user information sent to Goth will return a + // goth.User exactly matching the user created above. + // + defer mockCompleteUserAuth(func(res http.ResponseWriter, req *http.Request) (goth.User, error) { + return goth.User{ + Provider: gitlabName, + UserID: userGitLabUserID, + Email: userGitLab.Email, + }, nil + })() + req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?code=XYZ&state=XYZ", gitlabName)) + resp := MakeRequest(t, req, http.StatusSeeOther) + assert.Equal(t, test.RedirectURL(resp), "/") + userAfterLogin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userGitLab.ID}) + assert.Greater(t, userAfterLogin.LastLoginUnix, userGitLab.LastLoginUnix) +}