From 07412698e8828bff3e1894d57356d92bb0063665 Mon Sep 17 00:00:00 2001 From: Earl Warren Date: Tue, 27 Jun 2023 18:58:02 +0200 Subject: [PATCH] [F3] promote F3 users to matching OAuth2 users on first sign-in (cherry picked from commit bd7fef7496c6f50e1559eac5922ec3280745864d) --- models/auth/source.go | 8 ++ routers/web/auth/oauth.go | 44 ++++++++--- services/auth/source/f3/source.go | 33 ++++++++ services/f3/promote.go | 110 ++++++++++++++++++++++++++ tests/integration/f3_test.go | 63 +++++++++++++++ tests/integration/integration_test.go | 16 ++++ 6 files changed, 261 insertions(+), 13 deletions(-) create mode 100644 services/auth/source/f3/source.go create mode 100644 services/f3/promote.go diff --git a/models/auth/source.go b/models/auth/source.go index 8dd1cd4759..537cdde70b 100644 --- a/models/auth/source.go +++ b/models/auth/source.go @@ -33,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] @@ -51,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 @@ -179,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) 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/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 ceb5cc7c8b..c27e1fd08d 100644 --- a/tests/integration/integration_test.go +++ b/tests/integration/integration_test.go @@ -35,6 +35,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/f3" "code.gitea.io/gitea/services/auth/source/oauth2" user_service "code.gitea.io/gitea/services/user" "code.gitea.io/gitea/tests" @@ -294,6 +295,21 @@ func authSourcePayloadOIDC(name string) map[string]string { 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)