[F3] F3 user type and automate promotion to regular user

An F3 user is a new type to represent a user created because it is
referenced when importing an F3 archive. It is associated with the
forge from which the F3 archive originates. For instance if importing
an archive from GitLab.com, a user will be created as if it was
authenticated using GitLab.com as an OAuth2 authentication source.

When an actual user authenticates to Forejo from the same
authentication source, the F3 user will be promoted to be a regular
user that owns all issues, pull requests, comments etc. created using
the F3 user.
This commit is contained in:
Earl Warren 2024-01-30 14:41:18 +01:00
parent 1d77f3afee
commit f29888d8db
No known key found for this signature in database
GPG key ID: 0579CB2928A78A00
8 changed files with 297 additions and 19 deletions

View file

@ -34,6 +34,9 @@ const (
SSPI // 7 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 // String returns the string name of the LoginType
func (typ Type) String() string { func (typ Type) String() string {
return Names[typ] return Names[typ]
@ -52,6 +55,7 @@ var Names = map[Type]string{
PAM: "PAM", PAM: "PAM",
OAuth2: "OAuth2", OAuth2: "OAuth2",
SSPI: "SPNEGO with SSPI", SSPI: "SPNEGO with SSPI",
F3: "F3",
} }
// Config represents login config as far as the db is concerned // Config represents login config as far as the db is concerned
@ -180,6 +184,10 @@ func (source *Source) IsSSPI() bool {
return source.Type == SSPI return source.Type == SSPI
} }
func (source *Source) IsF3() bool {
return source.Type == F3
}
// HasTLS returns true of this source supports TLS. // HasTLS returns true of this source supports TLS.
func (source *Source) HasTLS() bool { func (source *Source) HasTLS() bool {
hasTLSer, ok := source.Cfg.(HasTLSer) hasTLSer, ok := source.Cfg.(HasTLSer)

View file

@ -42,7 +42,11 @@ type SearchUserOptions struct {
func (opts *SearchUserOptions) toSearchQueryBase(ctx context.Context) *xorm.Session { func (opts *SearchUserOptions) toSearchQueryBase(ctx context.Context) *xorm.Session {
var cond builder.Cond var cond builder.Cond
cond = builder.Eq{"type": opts.Type} if opts.Type == UserTypeIndividual {
cond = builder.In("type", UserTypeIndividual, UserTypeF3)
} else {
cond = builder.Eq{"type": opts.Type}
}
if opts.IncludeReserved { if opts.IncludeReserved {
if opts.Type == UserTypeIndividual { if opts.Type == UserTypeIndividual {
cond = cond.Or(builder.Eq{"type": UserTypeUserReserved}).Or( cond = cond.Or(builder.Eq{"type": UserTypeUserReserved}).Or(

View file

@ -60,6 +60,9 @@ const (
UserTypeRemoteUser UserTypeRemoteUser
) )
// It belongs above but is set explicitly here to avoid conflicts
const UserTypeF3 UserType = 128
const ( const (
// EmailNotificationsEnabled indicates that the user would like to receive all email notifications except your own // EmailNotificationsEnabled indicates that the user would like to receive all email notifications except your own
EmailNotificationsEnabled = "enabled" EmailNotificationsEnabled = "enabled"
@ -225,7 +228,7 @@ func (u *User) GetEmail() string {
// GetAllUsers returns a slice of all individual users found in DB. // GetAllUsers returns a slice of all individual users found in DB.
func GetAllUsers(ctx context.Context) ([]*User, error) { func GetAllUsers(ctx context.Context) ([]*User, error) {
users := make([]*User, 0) 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, UserTypeF3).Find(&users)
} }
// IsLocal returns true if user login type is LoginPlain. // IsLocal returns true if user login type is LoginPlain.
@ -426,6 +429,10 @@ func (u *User) IsBot() bool {
return u.Type == UserTypeBot return u.Type == UserTypeBot
} }
func (u *User) IsF3() bool {
return u.Type == UserTypeF3
}
// DisplayName returns full name if it's not empty, // DisplayName returns full name if it's not empty,
// returns username otherwise. // returns username otherwise.
func (u *User) DisplayName() string { func (u *User) DisplayName() string {
@ -1019,7 +1026,8 @@ func GetUserByName(ctx context.Context, name string) (*User, error) {
if len(name) == 0 { if len(name) == 0 {
return nil, ErrUserNotExist{0, name, 0} return nil, ErrUserNotExist{0, name, 0}
} }
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) has, err := db.GetEngine(ctx).Get(u)
if err != nil { if err != nil {
return nil, err return nil, err
@ -1055,7 +1063,7 @@ func GetMaileableUsersByIDs(ctx context.Context, ids []int64, isMention bool) ([
if isMention { if isMention {
return ous, db.GetEngine(ctx). return ous, db.GetEngine(ctx).
In("id", ids). In("id", ids).
Where("`type` = ?", UserTypeIndividual). In("`type`", UserTypeIndividual, UserTypeF3).
And("`prohibit_login` = ?", false). And("`prohibit_login` = ?", false).
And("`is_active` = ?", true). And("`is_active` = ?", true).
In("`email_notifications_preference`", EmailNotificationsEnabled, EmailNotificationsOnMention, EmailNotificationsAndYourOwn). In("`email_notifications_preference`", EmailNotificationsEnabled, EmailNotificationsOnMention, EmailNotificationsAndYourOwn).
@ -1064,7 +1072,7 @@ func GetMaileableUsersByIDs(ctx context.Context, ids []int64, isMention bool) ([
return ous, db.GetEngine(ctx). return ous, db.GetEngine(ctx).
In("id", ids). In("id", ids).
Where("`type` = ?", UserTypeIndividual). In("`type`", UserTypeIndividual, UserTypeF3).
And("`prohibit_login` = ?", false). And("`prohibit_login` = ?", false).
And("`is_active` = ?", true). And("`is_active` = ?", true).
In("`email_notifications_preference`", EmailNotificationsEnabled, EmailNotificationsAndYourOwn). In("`email_notifications_preference`", EmailNotificationsEnabled, EmailNotificationsAndYourOwn).

View file

@ -33,6 +33,7 @@ import (
source_service "code.gitea.io/gitea/services/auth/source" source_service "code.gitea.io/gitea/services/auth/source"
"code.gitea.io/gitea/services/auth/source/oauth2" "code.gitea.io/gitea/services/auth/source/oauth2"
"code.gitea.io/gitea/services/externalaccount" "code.gitea.io/gitea/services/externalaccount"
f3_service "code.gitea.io/gitea/services/f3"
"code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/forms"
user_service "code.gitea.io/gitea/services/user" user_service "code.gitea.io/gitea/services/user"
@ -1210,9 +1211,21 @@ func handleOAuth2SignIn(ctx *context.Context, source *auth.Source, u *user_model
ctx.Redirect(setting.AppSubURL + "/user/two_factor") 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) { 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 := f3_service.MaybePromoteF3User(ctx, authSource, gothUser.UserID, gothUser.Email); err != nil {
return nil, goth.User{}, err
}
u, err := oAuth2GothUserToUser(ctx, 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) oauth2Source := authSource.Cfg.(*oauth2.Source)
// Make sure that the response is not an error response. // Make sure that the response is not an error response.
@ -1224,10 +1237,10 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ
// Delete the goth session // Delete the goth session
err := gothic.Logout(response, request) err := gothic.Logout(response, request)
if err != nil { if err != nil {
return nil, goth.User{}, err return goth.User{}, err
} }
return nil, goth.User{}, errCallback{ return goth.User{}, errCallback{
Code: errorName, Code: errorName,
Description: errorDescription, Description: errorDescription,
} }
@ -1240,24 +1253,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) 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) 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 != "" { if oauth2Source.RequiredClaimName != "" {
claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName] claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName]
if !has { if !has {
return nil, goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
} }
if oauth2Source.RequiredClaimValue != "" { if oauth2Source.RequiredClaimValue != "" {
groups := claimValueToStringSet(claimInterface) groups := claimValueToStringSet(claimInterface)
if !groups.Contains(oauth2Source.RequiredClaimValue) { 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{ user := &user_model.User{
LoginName: gothUser.UserID, LoginName: gothUser.UserID,
LoginType: auth.OAuth2, LoginType: auth.OAuth2,
@ -1266,27 +1283,28 @@ func oAuth2UserLoginCallback(ctx *context.Context, authSource *auth.Source, requ
hasUser, err := user_model.GetUser(ctx, user) hasUser, err := user_model.GetUser(ctx, user)
if err != nil { if err != nil {
return nil, goth.User{}, err return nil, err
} }
if hasUser { 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 // search in external linked users
externalLoginUser := &user_model.ExternalLoginUser{ externalLoginUser := &user_model.ExternalLoginUser{
ExternalID: gothUser.UserID, ExternalID: gothUser.UserID,
LoginSourceID: authSource.ID, LoginSourceID: authSource.ID,
} }
hasUser, err = user_model.GetExternalLogin(request.Context(), externalLoginUser) hasUser, err = user_model.GetExternalLogin(ctx, externalLoginUser)
if err != nil { if err != nil {
return nil, goth.User{}, err return nil, err
} }
if hasUser { if hasUser {
user, err = user_model.GetUserByID(request.Context(), externalLoginUser.UserID) user, err = user_model.GetUserByID(ctx, externalLoginUser.UserID)
return user, gothUser, err return user, err
} }
// no user found to login // no user found to login
return nil, gothUser, nil return nil, nil
} }

View file

@ -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{})
}

115
services/f3/promote.go Normal file
View file

@ -0,0 +1,115 @@
// 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.UserTypeF3}
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 UserTypeF3
// 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, an 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.IsF3() {
log.Debug("getF3UserToPromote: user %v is not a managed by F3", u)
return nil, nil
}
if u.Email != "" {
log.Debug("getF3UserToPromote: the user email is already set to '%s'", u.Email)
return nil, nil
}
userSource, err := auth_model.GetSourceByID(ctx, 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
}

View file

@ -0,0 +1,76 @@
// 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"
"code.gitea.io/gitea/tests"
"github.com/markbates/goth"
"github.com/stretchr/testify/assert"
)
func TestF3_MaybePromoteUser(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.UserTypeF3,
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)
}

View file

@ -36,6 +36,7 @@ import (
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers" "code.gitea.io/gitea/routers"
"code.gitea.io/gitea/services/auth/source/f3"
user_service "code.gitea.io/gitea/services/user" user_service "code.gitea.io/gitea/services/user"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
@ -279,6 +280,21 @@ func authSourcePayloadGitLabCustom(name string) map[string]string {
return payload return payload
} }
func createF3AuthSource(t *testing.T, name, url, matchingSource string) *auth.Source {
assert.NoError(t, auth.CreateSource(context.Background(), &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() { func createUser(ctx context.Context, t testing.TB, user *user_model.User) func() {
user.MustChangePassword = false user.MustChangePassword = false
user.LowerName = strings.ToLower(user.Name) user.LowerName = strings.ToLower(user.Name)