[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:
parent
1d77f3afee
commit
f29888d8db
8 changed files with 297 additions and 19 deletions
|
@ -34,6 +34,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]
|
||||
|
@ -52,6 +55,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
|
||||
|
@ -180,6 +184,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)
|
||||
|
|
|
@ -42,7 +42,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, UserTypeF3)
|
||||
} else {
|
||||
cond = builder.Eq{"type": opts.Type}
|
||||
}
|
||||
if opts.IncludeReserved {
|
||||
if opts.Type == UserTypeIndividual {
|
||||
cond = cond.Or(builder.Eq{"type": UserTypeUserReserved}).Or(
|
||||
|
|
|
@ -60,6 +60,9 @@ const (
|
|||
UserTypeRemoteUser
|
||||
)
|
||||
|
||||
// It belongs above but is set explicitly here to avoid conflicts
|
||||
const UserTypeF3 UserType = 128
|
||||
|
||||
const (
|
||||
// EmailNotificationsEnabled indicates that the user would like to receive all email notifications except your own
|
||||
EmailNotificationsEnabled = "enabled"
|
||||
|
@ -225,7 +228,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, UserTypeF3).Find(&users)
|
||||
}
|
||||
|
||||
// IsLocal returns true if user login type is LoginPlain.
|
||||
|
@ -426,6 +429,10 @@ func (u *User) IsBot() bool {
|
|||
return u.Type == UserTypeBot
|
||||
}
|
||||
|
||||
func (u *User) IsF3() bool {
|
||||
return u.Type == UserTypeF3
|
||||
}
|
||||
|
||||
// DisplayName returns full name if it's not empty,
|
||||
// returns username otherwise.
|
||||
func (u *User) DisplayName() string {
|
||||
|
@ -1019,7 +1026,8 @@ func GetUserByName(ctx context.Context, name string) (*User, error) {
|
|||
if len(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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
@ -1055,7 +1063,7 @@ func GetMaileableUsersByIDs(ctx context.Context, ids []int64, isMention bool) ([
|
|||
if isMention {
|
||||
return ous, db.GetEngine(ctx).
|
||||
In("id", ids).
|
||||
Where("`type` = ?", UserTypeIndividual).
|
||||
In("`type`", UserTypeIndividual, UserTypeF3).
|
||||
And("`prohibit_login` = ?", false).
|
||||
And("`is_active` = ?", true).
|
||||
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).
|
||||
In("id", ids).
|
||||
Where("`type` = ?", UserTypeIndividual).
|
||||
In("`type`", UserTypeIndividual, UserTypeF3).
|
||||
And("`prohibit_login` = ?", false).
|
||||
And("`is_active` = ?", true).
|
||||
In("`email_notifications_preference`", EmailNotificationsEnabled, EmailNotificationsAndYourOwn).
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
@ -1210,9 +1211,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 := 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)
|
||||
|
||||
// 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
|
||||
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,
|
||||
}
|
||||
|
@ -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)
|
||||
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,
|
||||
|
@ -1266,27 +1283,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
|
||||
}
|
||||
|
|
33
services/auth/source/f3/source.go
Normal file
33
services/auth/source/f3/source.go
Normal 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
115
services/f3/promote.go
Normal 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
|
||||
}
|
76
tests/integration/f3_test.go
Normal file
76
tests/integration/f3_test.go
Normal 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)
|
||||
}
|
|
@ -36,6 +36,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"
|
||||
user_service "code.gitea.io/gitea/services/user"
|
||||
"code.gitea.io/gitea/tests"
|
||||
|
||||
|
@ -279,6 +280,21 @@ func authSourcePayloadGitLabCustom(name string) map[string]string {
|
|||
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() {
|
||||
user.MustChangePassword = false
|
||||
user.LowerName = strings.ToLower(user.Name)
|
||||
|
|
Loading…
Reference in a new issue