diff --git a/models/auth/source.go b/models/auth/source.go index 1a3a1b20a6..d03d4975dc 100644 --- a/models/auth/source.go +++ b/models/auth/source.go @@ -33,6 +33,7 @@ const ( DLDAP // 5 OAuth2 // 6 SSPI // 7 + Remote // 8 ) // String returns the string name of the LoginType @@ -53,6 +54,7 @@ var Names = map[Type]string{ PAM: "PAM", OAuth2: "OAuth2", SSPI: "SPNEGO with SSPI", + Remote: "Remote", } // Config represents login config as far as the db is concerned @@ -181,6 +183,10 @@ func (source *Source) IsSSPI() bool { return source.Type == SSPI } +func (source *Source) IsRemote() bool { + return source.Type == Remote +} + // HasTLS returns true of this source supports TLS. func (source *Source) HasTLS() bool { hasTLSer, ok := source.Cfg.(HasTLSer) diff --git a/models/user/fixtures/user.yml b/models/user/fixtures/user.yml new file mode 100644 index 0000000000..b1892f331b --- /dev/null +++ b/models/user/fixtures/user.yml @@ -0,0 +1,36 @@ +- + id: 1041 + lower_name: remote01 + name: remote01 + full_name: Remote01 + email: remote01@example.com + keep_email_private: false + email_notifications_preference: onmention + passwd: ZogKvWdyEx:password + passwd_hash_algo: dummy + must_change_password: false + login_source: 1001 + login_name: 123 + type: 5 + salt: ZogKvWdyEx + max_repo_creation: -1 + is_active: true + is_admin: false + is_restricted: false + allow_git_hook: false + allow_import_local: false + allow_create_organization: true + prohibit_login: true + avatar: avatarremote01 + avatar_email: avatarremote01@example.com + use_custom_avatar: false + num_followers: 0 + num_following: 0 + num_stars: 0 + num_repos: 0 + num_teams: 0 + num_members: 0 + visibility: 0 + repo_admin_change_team_access: false + theme: "" + keep_activity_private: false diff --git a/models/user/search.go b/models/user/search.go index a710b70287..04c434e4fa 100644 --- a/models/user/search.go +++ b/models/user/search.go @@ -45,7 +45,11 @@ type SearchUserOptions struct { func (opts *SearchUserOptions) toSearchQueryBase(ctx context.Context) *xorm.Session { var cond builder.Cond - cond = builder.Eq{"type": opts.Type} + if opts.Type == UserTypeIndividual { + cond = builder.In("type", UserTypeIndividual, UserTypeRemoteUser) + } else { + cond = builder.Eq{"type": opts.Type} + } if opts.IncludeReserved { if opts.Type == UserTypeIndividual { cond = cond.Or(builder.Eq{"type": UserTypeUserReserved}).Or( diff --git a/models/user/user.go b/models/user/user.go index 8452e3c7cf..df129a5efe 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -216,7 +216,7 @@ func (u *User) GetEmail() string { // GetAllUsers returns a slice of all individual users found in DB. func GetAllUsers(ctx context.Context) ([]*User, error) { users := make([]*User, 0) - return users, db.GetEngine(ctx).OrderBy("id").Where("type = ?", UserTypeIndividual).Find(&users) + return users, db.GetEngine(ctx).OrderBy("id").In("type", UserTypeIndividual, UserTypeRemoteUser).Find(&users) } // GetAllAdmins returns a slice of all adminusers found in DB. @@ -416,6 +416,10 @@ func (u *User) IsBot() bool { return u.Type == UserTypeBot } +func (u *User) IsRemote() bool { + return u.Type == UserTypeRemoteUser +} + // DisplayName returns full name if it's not empty, // returns username otherwise. func (u *User) DisplayName() string { @@ -918,7 +922,8 @@ func GetUserByName(ctx context.Context, name string) (*User, error) { if len(name) == 0 { return nil, ErrUserNotExist{Name: name} } - u := &User{LowerName: strings.ToLower(name), Type: UserTypeIndividual} + // adding Type: UserTypeIndividual is a noop because it is zero and discarded + u := &User{LowerName: strings.ToLower(name)} has, err := db.GetEngine(ctx).Get(u) if err != nil { return nil, err diff --git a/models/user/user_test.go b/models/user/user_test.go index 7888c2638f..571b8b2a93 100644 --- a/models/user/user_test.go +++ b/models/user/user_test.go @@ -21,6 +21,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/tests" "github.com/stretchr/testify/assert" ) @@ -33,6 +34,35 @@ func TestOAuth2Application_LoadUser(t *testing.T) { assert.NotNil(t, user) } +func TestGetUserByName(t *testing.T) { + defer tests.AddFixtures("models/user/fixtures/")() + assert.NoError(t, unittest.PrepareTestDatabase()) + + { + _, err := user_model.GetUserByName(db.DefaultContext, "") + assert.True(t, user_model.IsErrUserNotExist(err), err) + } + { + _, err := user_model.GetUserByName(db.DefaultContext, "UNKNOWN") + assert.True(t, user_model.IsErrUserNotExist(err), err) + } + { + user, err := user_model.GetUserByName(db.DefaultContext, "USER2") + assert.NoError(t, err) + assert.Equal(t, user.Name, "user2") + } + { + user, err := user_model.GetUserByName(db.DefaultContext, "org3") + assert.NoError(t, err) + assert.Equal(t, user.Name, "org3") + } + { + user, err := user_model.GetUserByName(db.DefaultContext, "remote01") + assert.NoError(t, err) + assert.Equal(t, user.Name, "remote01") + } +} + func TestGetUserEmailsByNames(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) @@ -61,7 +91,24 @@ func TestCanCreateOrganization(t *testing.T) { assert.False(t, user.CanCreateOrganization()) } +func TestGetAllUsers(t *testing.T) { + defer tests.AddFixtures("models/user/fixtures/")() + assert.NoError(t, unittest.PrepareTestDatabase()) + + users, err := user_model.GetAllUsers(db.DefaultContext) + assert.NoError(t, err) + + found := make(map[user_model.UserType]bool, 0) + for _, user := range users { + found[user.Type] = true + } + assert.True(t, found[user_model.UserTypeIndividual], users) + assert.True(t, found[user_model.UserTypeRemoteUser], users) + assert.False(t, found[user_model.UserTypeOrganization], users) +} + func TestSearchUsers(t *testing.T) { + defer tests.AddFixtures("models/user/fixtures/")() assert.NoError(t, unittest.PrepareTestDatabase()) testSuccess := func(opts *user_model.SearchUserOptions, expectedUserOrOrgIDs []int64) { users, _, err := user_model.SearchUsers(db.DefaultContext, opts) @@ -102,13 +149,13 @@ func TestSearchUsers(t *testing.T) { } testUserSuccess(&user_model.SearchUserOptions{OrderBy: "id ASC", ListOptions: db.ListOptions{Page: 1}}, - []int64{1, 2, 4, 5, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 27, 28, 29, 30, 32, 34, 37, 38, 39, 40}) + []int64{1, 2, 4, 5, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 27, 28, 29, 30, 32, 34, 37, 38, 39, 40, 1041}) testUserSuccess(&user_model.SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsActive: optional.Some(false)}, []int64{9}) testUserSuccess(&user_model.SearchUserOptions{OrderBy: "id ASC", ListOptions: db.ListOptions{Page: 1}, IsActive: optional.Some(true)}, - []int64{1, 2, 4, 5, 8, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 27, 28, 29, 30, 32, 34, 37, 38, 39, 40}) + []int64{1, 2, 4, 5, 8, 10, 11, 12, 13, 14, 15, 16, 18, 20, 21, 24, 27, 28, 29, 30, 32, 34, 37, 38, 39, 40, 1041}) testUserSuccess(&user_model.SearchUserOptions{Keyword: "user1", OrderBy: "id ASC", ListOptions: db.ListOptions{Page: 1}, IsActive: optional.Some(true)}, []int64{1, 10, 11, 12, 13, 14, 15, 16, 18}) @@ -124,7 +171,7 @@ func TestSearchUsers(t *testing.T) { []int64{29}) testUserSuccess(&user_model.SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsProhibitLogin: optional.Some(true)}, - []int64{37}) + []int64{1041, 37}) testUserSuccess(&user_model.SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsTwoFactorEnabled: optional.Some(true)}, []int64{24}) diff --git a/routers/web/auth/oauth.go b/routers/web/auth/oauth.go index f5ca0bda5e..b48345684b 100644 --- a/routers/web/auth/oauth.go +++ b/routers/web/auth/oauth.go @@ -35,6 +35,7 @@ import ( "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/externalaccount" "code.gitea.io/gitea/services/forms" + remote_service "code.gitea.io/gitea/services/remote" user_service "code.gitea.io/gitea/services/user" "gitea.com/go-chi/binding" @@ -1202,9 +1203,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(ctx *context.Context, authSource *auth.Source, request *http.Request, response http.ResponseWriter) (*user_model.User, goth.User, error) { + gothUser, err := oAuth2FetchUser(ctx, authSource, request, response) + if err != nil { + return nil, goth.User{}, err + } + + if _, _, err := remote_service.MaybePromoteRemoteUser(ctx, 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(ctx *context.Context, 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. @@ -1216,10 +1229,10 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ // 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, } @@ -1232,24 +1245,28 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ 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, @@ -1258,27 +1275,28 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ hasUser, err := user_model.GetUser(ctx, 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{ ExternalID: gothUser.UserID, LoginSourceID: authSource.ID, } - hasUser, err = user_model.GetExternalLogin(request.Context(), externalLoginUser) + hasUser, err = user_model.GetExternalLogin(ctx, 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/remote/source.go b/services/auth/source/remote/source.go new file mode 100644 index 0000000000..4165858a56 --- /dev/null +++ b/services/auth/source/remote/source.go @@ -0,0 +1,33 @@ +// Copyright Earl Warren +// SPDX-License-Identifier: MIT + +package remote + +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.Remote, &Source{}) +} diff --git a/services/remote/promote.go b/services/remote/promote.go new file mode 100644 index 0000000000..6709b4cc1d --- /dev/null +++ b/services/remote/promote.go @@ -0,0 +1,133 @@ +// Copyright Earl Warren +// SPDX-License-Identifier: MIT + +package remote + +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" + "code.gitea.io/gitea/services/auth/source/oauth2" + remote_source "code.gitea.io/gitea/services/auth/source/remote" +) + +type Reason int + +const ( + ReasonNoMatch Reason = iota + ReasonNotAuth2 + ReasonBadAuth2 + ReasonLoginNameNotExists + ReasonNotRemote + ReasonEmailIsSet + ReasonNoSource + ReasonSourceWrongType + ReasonCanPromote + ReasonPromoted + ReasonUpdateFail + ReasonErrorLoginName + ReasonErrorGetSource +) + +func NewReason(level log.Level, reason Reason, message string, args ...any) Reason { + log.Log(1, level, message, args...) + return reason +} + +func getUsersByLoginName(ctx context.Context, name string) ([]*user_model.User, error) { + if len(name) == 0 { + return nil, user_model.ErrUserNotExist{Name: name} + } + + users := make([]*user_model.User, 0, 5) + + return users, db.GetEngine(ctx). + Table("user"). + Where("login_name = ? AND login_type = ? AND type = ?", name, auth_model.Remote, user_model.UserTypeRemoteUser). + Find(&users) +} + +// The remote user has: +// +// Type UserTypeRemoteUser +// LogingType Remote +// LoginName set to the unique identifier of the originating authentication source +// LoginSource set to the Remote source that can be matched against an OAuth2 source +// +// If the source from which an authentification happens is OAuth2, an existing +// remote 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 MaybePromoteRemoteUser(ctx context.Context, source *auth_model.Source, loginName, email string) (promoted bool, reason Reason, err error) { + user, reason, err := getRemoteUserToPromote(ctx, source, loginName, email) + if err != nil || user == nil { + return false, reason, err + } + promote := &user_model.User{ + ID: user.ID, + Type: user_model.UserTypeIndividual, + Email: email, + LoginSource: source.ID, + LoginType: source.Type, + } + reason = NewReason(log.DEBUG, ReasonPromoted, "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) + if err := user_model.UpdateUserCols(ctx, promote, "type", "email", "login_source", "login_type"); err != nil { + return false, ReasonUpdateFail, err + } + return true, reason, nil +} + +func getRemoteUserToPromote(ctx context.Context, source *auth_model.Source, loginName, email string) (*user_model.User, Reason, error) { + if !source.IsOAuth2() { + return nil, NewReason(log.DEBUG, ReasonNotAuth2, "source %v is not OAuth2", source), nil + } + oauth2Source, ok := source.Cfg.(*oauth2.Source) + if !ok { + return nil, NewReason(log.ERROR, ReasonBadAuth2, "source claims to be OAuth2 but is not"), nil + } + + users, err := getUsersByLoginName(ctx, loginName) + if err != nil { + return nil, NewReason(log.ERROR, ReasonErrorLoginName, "getUserByLoginName('%s') %v", loginName, err), err + } + if len(users) == 0 { + return nil, NewReason(log.ERROR, ReasonLoginNameNotExists, "no user with LoginType UserTypeRemoteUser and LoginName '%s'", loginName), nil + } + + reason := ReasonNoSource + for _, u := range users { + userSource, err := auth_model.GetSourceByID(ctx, u.LoginSource) + if err != nil { + if auth_model.IsErrSourceNotExist(err) { + reason = NewReason(log.DEBUG, ReasonNoSource, "source id = %v for user %v not found %v", u.LoginSource, u.ID, err) + continue + } + return nil, NewReason(log.ERROR, ReasonErrorGetSource, "GetSourceByID('%s') %v", u.LoginSource, err), err + } + if u.Email != "" { + reason = NewReason(log.DEBUG, ReasonEmailIsSet, "the user email is already set to '%s'", u.Email) + continue + } + remoteSource, ok := userSource.Cfg.(*remote_source.Source) + if !ok { + reason = NewReason(log.DEBUG, ReasonSourceWrongType, "expected a remote source but got %T %v", userSource, userSource) + continue + } + + if oauth2Source.Provider != remoteSource.MatchingSource { + reason = NewReason(log.DEBUG, ReasonNoMatch, "skip OAuth2 source %s because it is different from %s which is the expected match for the remote source %s", oauth2Source.Provider, remoteSource.MatchingSource, remoteSource.URL) + continue + } + + return u, ReasonCanPromote, nil + } + + return nil, reason, nil +} diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go index 61a243e52f..f5b87231f3 100644 --- a/tests/integration/integration_test.go +++ b/tests/integration/integration_test.go @@ -42,6 +42,7 @@ 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/remote" gitea_context "code.gitea.io/gitea/services/context" repo_service "code.gitea.io/gitea/services/repository" files_service "code.gitea.io/gitea/services/repository/files" @@ -53,7 +54,8 @@ import ( gouuid "github.com/google/uuid" "github.com/markbates/goth" "github.com/markbates/goth/gothic" - goth_gitlab "github.com/markbates/goth/providers/gitlab" + goth_gitlab "github.com/markbates/goth/providers/github" + goth_github "github.com/markbates/goth/providers/gitlab" "github.com/santhosh-tekuri/jsonschema/v5" "github.com/stretchr/testify/assert" ) @@ -338,6 +340,36 @@ func authSourcePayloadGitLabCustom(name string) map[string]string { return payload } +func authSourcePayloadGitHub(name string) map[string]string { + payload := authSourcePayloadOAuth2(name) + payload["oauth2_provider"] = "github" + return payload +} + +func authSourcePayloadGitHubCustom(name string) map[string]string { + payload := authSourcePayloadGitHub(name) + payload["oauth2_use_custom_url"] = "on" + payload["oauth2_auth_url"] = goth_github.AuthURL + payload["oauth2_token_url"] = goth_github.TokenURL + payload["oauth2_profile_url"] = goth_github.ProfileURL + return payload +} + +func createRemoteAuthSource(t *testing.T, name, url, matchingSource string) *auth.Source { + assert.NoError(t, auth.CreateSource(context.Background(), &auth.Source{ + Type: auth.Remote, + Name: name, + IsActive: true, + Cfg: &remote.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) diff --git a/tests/integration/remote_test.go b/tests/integration/remote_test.go new file mode 100644 index 0000000000..d905f88a81 --- /dev/null +++ b/tests/integration/remote_test.go @@ -0,0 +1,205 @@ +// Copyright Earl Warren +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "fmt" + "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/test" + remote_service "code.gitea.io/gitea/services/remote" + "code.gitea.io/gitea/tests" + + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" +) + +func TestRemote_MaybePromoteUserSuccess(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + // + // OAuth2 authentication source GitLab + // + gitlabName := "gitlab" + _ = addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName)) + // + // Remote authentication source matching the GitLab authentication source + // + remoteName := "remote" + remote := createRemoteAuthSource(t, remoteName, "http://mygitlab.eu", gitlabName) + + // + // Create a user as if it had previously been created by the remote + // authentication source. + // + gitlabUserID := "5678" + gitlabEmail := "gitlabuser@example.com" + userBeforeSignIn := &user_model.User{ + Name: "gitlabuser", + Type: user_model.UserTypeRemoteUser, + LoginType: auth_model.Remote, + LoginSource: remote.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 Remote to OAuth2 + assert.Equal(t, userBeforeSignIn.LoginType, auth_model.Remote) + 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) +} + +func TestRemote_MaybePromoteUserFail(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + ctx := context.Background() + // + // OAuth2 authentication source GitLab + // + gitlabName := "gitlab" + gitlabSource := addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName)) + // + // Remote authentication source matching the GitLab authentication source + // + remoteName := "remote" + remoteSource := createRemoteAuthSource(t, remoteName, "http://mygitlab.eu", gitlabName) + + { + promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, &auth_model.Source{}, "", "") + assert.NoError(t, err) + assert.False(t, promoted) + assert.Equal(t, remote_service.ReasonNotAuth2, reason) + } + + { + remoteSource.Type = auth_model.OAuth2 + promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, remoteSource, "", "") + assert.NoError(t, err) + assert.False(t, promoted) + assert.Equal(t, remote_service.ReasonBadAuth2, reason) + remoteSource.Type = auth_model.Remote + } + + { + promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, gitlabSource, "unknownloginname", "") + assert.NoError(t, err) + assert.False(t, promoted) + assert.Equal(t, remote_service.ReasonLoginNameNotExists, reason) + } + + { + remoteUserID := "844" + remoteUser := &user_model.User{ + Name: "withmailuser", + Type: user_model.UserTypeRemoteUser, + LoginType: auth_model.Remote, + LoginSource: remoteSource.ID, + LoginName: remoteUserID, + Email: "some@example.com", + } + defer createUser(context.Background(), t, remoteUser)() + promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, gitlabSource, remoteUserID, "") + assert.NoError(t, err) + assert.False(t, promoted) + assert.Equal(t, remote_service.ReasonEmailIsSet, reason) + } + + { + remoteUserID := "7464" + nonexistentloginsource := int64(4344) + remoteUser := &user_model.User{ + Name: "badsourceuser", + Type: user_model.UserTypeRemoteUser, + LoginType: auth_model.Remote, + LoginSource: nonexistentloginsource, + LoginName: remoteUserID, + } + defer createUser(context.Background(), t, remoteUser)() + promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, gitlabSource, remoteUserID, "") + assert.NoError(t, err) + assert.False(t, promoted) + assert.Equal(t, remote_service.ReasonNoSource, reason) + } + + { + remoteUserID := "33335678" + remoteUser := &user_model.User{ + Name: "badremoteuser", + Type: user_model.UserTypeRemoteUser, + LoginType: auth_model.Remote, + LoginSource: gitlabSource.ID, + LoginName: remoteUserID, + } + defer createUser(context.Background(), t, remoteUser)() + promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, gitlabSource, remoteUserID, "") + assert.NoError(t, err) + assert.False(t, promoted) + assert.Equal(t, remote_service.ReasonSourceWrongType, reason) + } + + { + unrelatedName := "unrelated" + unrelatedSource := addAuthSource(t, authSourcePayloadGitHubCustom(unrelatedName)) + assert.NotNil(t, unrelatedSource) + + remoteUserID := "488484" + remoteEmail := "4848484@example.com" + remoteUser := &user_model.User{ + Name: "unrelateduser", + Type: user_model.UserTypeRemoteUser, + LoginType: auth_model.Remote, + LoginSource: remoteSource.ID, + LoginName: remoteUserID, + } + defer createUser(context.Background(), t, remoteUser)() + promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, unrelatedSource, remoteUserID, remoteEmail) + assert.NoError(t, err) + assert.False(t, promoted) + assert.Equal(t, remote_service.ReasonNoMatch, reason) + } + + { + remoteUserID := "5678" + remoteEmail := "gitlabuser@example.com" + remoteUser := &user_model.User{ + Name: "remoteuser", + Type: user_model.UserTypeRemoteUser, + LoginType: auth_model.Remote, + LoginSource: remoteSource.ID, + LoginName: remoteUserID, + } + defer createUser(context.Background(), t, remoteUser)() + promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, gitlabSource, remoteUserID, remoteEmail) + assert.NoError(t, err) + assert.True(t, promoted) + assert.Equal(t, remote_service.ReasonPromoted, reason) + } +}