diff --git a/make/migrations/postgresql/0004_1.8.0_schema.up.sql b/make/migrations/postgresql/0004_1.8.0_schema.up.sql index f36592a33..88e4bea5b 100644 --- a/make/migrations/postgresql/0004_1.8.0_schema.up.sql +++ b/make/migrations/postgresql/0004_1.8.0_schema.up.sql @@ -13,6 +13,25 @@ CREATE TABLE robot ( CREATE TRIGGER robot_update_time_at_modtime BEFORE UPDATE ON robot FOR EACH ROW EXECUTE PROCEDURE update_update_time_at_column(); +CREATE TABLE oidc_user ( + id SERIAL NOT NULL, + user_id int NOT NULL, + secret varchar(255) NOT NULL, + /* + Subject and Issuer + Subject: Subject Identifier. + Issuer: Issuer Identifier for the Issuer of the response. + The sub (subject) and iss (issuer) Claims, used together, are the only Claims that an RP can rely upon as a stable identifier for the End-User + */ + subiss varchar(255) NOT NULL, + creation_time timestamp default CURRENT_TIMESTAMP, + update_time timestamp default CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE (subiss) +); + +CREATE TRIGGER odic_user_update_time_at_modtime BEFORE UPDATE ON oidc_user FOR EACH ROW EXECUTE PROCEDURE update_update_time_at_column(); + /*add master role*/ INSERT INTO role (role_code, name) VALUES ('DRWS', 'master'); diff --git a/src/common/dao/oidc_user.go b/src/common/dao/oidc_user.go new file mode 100644 index 000000000..6045cff36 --- /dev/null +++ b/src/common/dao/oidc_user.go @@ -0,0 +1,168 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dao + +import ( + "fmt" + "strings" + "time" + + "github.com/astaxie/beego/orm" + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/utils/log" + "github.com/pkg/errors" +) + +var ( + // ErrDupUser ... + ErrDupUser = errors.New("sql: duplicate user in harbor_user") + + // ErrRollBackUser ... + ErrRollBackUser = errors.New("sql: transaction roll back error in harbor_user") + + // ErrDupOIDCUser ... + ErrDupOIDCUser = errors.New("sql: duplicate user in oicd_user") + + // ErrRollBackOIDCUser ... + ErrRollBackOIDCUser = errors.New("sql: transaction roll back error in oicd_user") +) + +// GetOIDCUserByID ... +func GetOIDCUserByID(id int64) (*models.OIDCUser, error) { + oidcUser := &models.OIDCUser{ + ID: id, + } + if err := GetOrmer().Read(oidcUser); err != nil { + if err == orm.ErrNoRows { + return nil, nil + } + return nil, err + } + + return oidcUser, nil +} + +// GetUserBySubIss ... +func GetUserBySubIss(sub, issuer string) (*models.User, error) { + var oidcUsers []models.OIDCUser + n, err := GetOrmer().Raw(`select * from oidc_user where subiss = ? `, sub+issuer).QueryRows(&oidcUsers) + if err != nil { + return nil, err + } + if n == 0 { + return nil, nil + } + + user, err := GetUser(models.User{ + UserID: oidcUsers[0].UserID, + }) + if err != nil { + return nil, err + } + if user == nil { + return nil, fmt.Errorf("can not get user %d", oidcUsers[0].UserID) + } + + return user, nil +} + +// GetOIDCUserByUserID ... +func GetOIDCUserByUserID(userID int) (*models.OIDCUser, error) { + var oidcUsers []models.OIDCUser + n, err := GetOrmer().Raw(`select * from oidc_user where user_id = ? `, userID).QueryRows(&oidcUsers) + if err != nil { + return nil, err + } + if n == 0 { + return nil, nil + } + + return &oidcUsers[0], nil +} + +// UpdateOIDCUser ... +func UpdateOIDCUser(oidcUser *models.OIDCUser) error { + oidcUser.UpdateTime = time.Now() + _, err := GetOrmer().Update(oidcUser) + return err +} + +// DeleteOIDCUser ... +func DeleteOIDCUser(id int64) error { + _, err := GetOrmer().QueryTable(&models.OIDCUser{}).Filter("ID", id).Delete() + return err +} + +// OnBoardOIDCUser onboard OIDC user +// For the api caller, should only care about the ErrDupUser. It could lead to http.StatusConflict. +func OnBoardOIDCUser(u *models.User) error { + if u.OIDCUserMeta == nil { + return errors.New("unable to onboard as empty oidc user") + } + + o := orm.NewOrm() + err := o.Begin() + if err != nil { + return err + } + var errInsert error + + // insert user + now := time.Now() + u.CreationTime = now + userID, err := o.Insert(u) + if err != nil { + errInsert = err + log.Errorf("fail to insert user, %v", err) + if strings.Contains(err.Error(), "duplicate key value violates unique constraint") { + errInsert = errors.Wrap(errInsert, ErrDupUser.Error()) + } + err := o.Rollback() + if err != nil { + log.Errorf("fail to rollback when to onboard oidc user, %v", err) + errInsert = errors.Wrap(errInsert, err.Error()) + return errors.Wrap(errInsert, ErrRollBackUser.Error()) + } + return errInsert + + } + u.UserID = int(userID) + u.OIDCUserMeta.UserID = int(userID) + + // insert oidc user + now = time.Now() + u.OIDCUserMeta.CreationTime = now + _, err = o.Insert(u.OIDCUserMeta) + if err != nil { + errInsert = err + log.Errorf("fail to insert oidc user, %v", err) + if strings.Contains(err.Error(), "duplicate key value violates unique constraint") { + errInsert = errors.Wrap(errInsert, ErrDupOIDCUser.Error()) + } + err := o.Rollback() + if err != nil { + errInsert = errors.Wrap(errInsert, err.Error()) + return errors.Wrap(errInsert, ErrRollBackOIDCUser.Error()) + } + return errInsert + } + err = o.Commit() + if err != nil { + log.Errorf("fail to commit when to onboard oidc user, %v", err) + return fmt.Errorf("fail to commit when to onboard oidc user, %v", err) + } + + return nil +} diff --git a/src/common/dao/oidc_user_test.go b/src/common/dao/oidc_user_test.go new file mode 100644 index 000000000..7ec303c9c --- /dev/null +++ b/src/common/dao/oidc_user_test.go @@ -0,0 +1,186 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dao + +import ( + "testing" + + "github.com/goharbor/harbor/src/common/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOIDCUserMetaDaoMethods(t *testing.T) { + + user111 := models.User{ + Username: "user111", + Email: "user111@email.com", + } + user222 := models.User{ + Username: "user222", + Email: "user222@email.com", + } + userEmptyOuMeta := models.User{ + Username: "userEmptyOuMeta", + Email: "userEmptyOuMeta@email.com", + } + ou111 := models.OIDCUser{ + SubIss: "QWE123123RT1", + Secret: "QWEQWE1", + } + ou222 := models.OIDCUser{ + SubIss: "QWE123123RT2", + Secret: "QWEQWE2", + } + + // onboard OIDC ... + user111.OIDCUserMeta = &ou111 + err := OnBoardOIDCUser(&user111) + require.Nil(t, err) + defer CleanUser(int64(user111.UserID)) + user222.OIDCUserMeta = &ou222 + err = OnBoardOIDCUser(&user222) + require.Nil(t, err) + defer CleanUser(int64(user222.UserID)) + + // empty OIDC user meta ... + err = OnBoardOIDCUser(&userEmptyOuMeta) + require.NotNil(t, err) + assert.Equal(t, "unable to onboard as empty oidc user", err.Error()) + + // test get by ID + oidcUser1, err := GetOIDCUserByID(ou111.ID) + require.Nil(t, err) + assert.Equal(t, ou111.UserID, oidcUser1.UserID) + + // test get by userID + oidcUser2, err := GetOIDCUserByUserID(user111.UserID) + require.Nil(t, err) + assert.Equal(t, "QWE123123RT1", oidcUser2.SubIss) + + // test get by sub and iss + userGetBySubIss, err := GetUserBySubIss("QWE123", "123RT1") + require.Nil(t, err) + assert.Equal(t, "user111@email.com", userGetBySubIss.Email) + + // test update + meta3 := &models.OIDCUser{ + ID: ou111.ID, + UserID: ou111.UserID, + SubIss: "newSub", + } + require.Nil(t, UpdateOIDCUser(meta3)) + oidcUser1Update, err := GetOIDCUserByID(ou111.ID) + require.Nil(t, err) + assert.Equal(t, "newSub", oidcUser1Update.SubIss) + + user, err := GetUserBySubIss("new", "Sub") + require.Nil(t, err) + assert.Equal(t, "user111", user.Username) + + // clear data + defer func() { + _, err := GetOrmer().Raw(`delete from oidc_user`).Exec() + require.Nil(t, err) + }() +} + +func TestOIDCOnboard(t *testing.T) { + user333 := models.User{ + Username: "user333", + Email: "user333@email.com", + } + user555 := models.User{ + Username: "user555", + Email: "user555@email.com", + } + user666 := models.User{ + Username: "user666", + Email: "user666@email.com", + } + userDup := models.User{ + Username: "user333", + Email: "userDup@email.com", + } + + ou333 := &models.OIDCUser{ + SubIss: "QWE123123RT3", + Secret: "QWEQWE333", + } + ou555 := &models.OIDCUser{ + SubIss: "QWE123123RT5", + Secret: "QWEQWE555", + } + ouDup := &models.OIDCUser{ + SubIss: "QWE123123RT3", + Secret: "QWEQWE333", + } + ouDupSub := &models.OIDCUser{ + SubIss: "QWE123123RT3", + Secret: "ouDupSub", + } + + // data prepare ... + user333.OIDCUserMeta = ou333 + err := OnBoardOIDCUser(&user333) + require.Nil(t, err) + defer CleanUser(int64(user333.UserID)) + + // duplicate user -- ErrDupRows + // userDup is duplicate with user333 + userDup.OIDCUserMeta = ou555 + err = OnBoardOIDCUser(&userDup) + require.NotNil(t, err) + require.Contains(t, err.Error(), ErrDupUser.Error()) + exist, err := UserExists(userDup, "email") + require.Nil(t, err) + require.False(t, exist) + + // duplicate OIDC user -- ErrDupRows + // ouDup is duplicate with ou333 + user555.OIDCUserMeta = ouDup + err = OnBoardOIDCUser(&user555) + require.NotNil(t, err) + require.Contains(t, err.Error(), ErrDupOIDCUser.Error()) + exist, err = UserExists(user555, "username") + require.Nil(t, err) + require.False(t, exist) + + // success + user555.OIDCUserMeta = ou555 + err = OnBoardOIDCUser(&user555) + require.Nil(t, err) + exist, err = UserExists(user555, "username") + require.Nil(t, err) + require.True(t, exist) + defer CleanUser(int64(user555.UserID)) + + // duplicate OIDC user's sub -- ErrDupRows + // ouDup is duplicate with ou333 + user666.OIDCUserMeta = ouDupSub + err = OnBoardOIDCUser(&user666) + require.NotNil(t, err) + require.Contains(t, err.Error(), ErrDupOIDCUser.Error()) + exist, err = UserExists(user666, "username") + require.Nil(t, err) + require.False(t, exist) + + // clear data + defer func() { + _, err := GetOrmer().Raw(`delete from oidc_user`).Exec() + require.Nil(t, err) + }() + +} diff --git a/src/common/models/base.go b/src/common/models/base.go index 2faeb0a1a..35ad3a97a 100644 --- a/src/common/models/base.go +++ b/src/common/models/base.go @@ -38,5 +38,6 @@ func init() { new(UserGroup), new(AdminJob), new(JobLog), - new(Robot)) + new(Robot), + new(OIDCUser)) } diff --git a/src/common/models/oidc_user.go b/src/common/models/oidc_user.go new file mode 100644 index 000000000..5d6c66c35 --- /dev/null +++ b/src/common/models/oidc_user.go @@ -0,0 +1,20 @@ +package models + +import ( + "time" +) + +// OIDCUser ... +type OIDCUser struct { + ID int64 `orm:"pk;auto;column(id)" json:"id"` + UserID int `orm:"column(user_id)" json:"user_id"` + Secret string `orm:"column(secret)" json:"secret"` + SubIss string `orm:"column(subiss)" json:"subiss"` + CreationTime time.Time `orm:"column(creation_time);auto_now_add" json:"creation_time"` + UpdateTime time.Time `orm:"column(update_time);auto_now" json:"update_time"` +} + +// TableName ... +func (o *OIDCUser) TableName() string { + return "oidc_user" +} diff --git a/src/common/models/user.go b/src/common/models/user.go index c638cff8c..9b224bd80 100644 --- a/src/common/models/user.go +++ b/src/common/models/user.go @@ -41,6 +41,7 @@ type User struct { CreationTime time.Time `orm:"column(creation_time);auto_now_add" json:"creation_time"` UpdateTime time.Time `orm:"column(update_time);auto_now" json:"update_time"` GroupList []*UserGroup `orm:"-" json:"-"` + OIDCUserMeta *OIDCUser `orm:"-" json:"oidc_user_meta,omitempty"` } // UserQuery ... diff --git a/src/core/controllers/controllers_test.go b/src/core/controllers/controllers_test.go index 85cdeec7c..8d442f20b 100644 --- a/src/core/controllers/controllers_test.go +++ b/src/core/controllers/controllers_test.go @@ -138,4 +138,5 @@ func TestAll(t *testing.T) { w = httptest.NewRecorder() beego.BeeApp.Handlers.ServeHTTP(w, r) assert.Equal(int(404), w.Code, "GET v2/noproject/manifests/1.0 should get a 404 response") + }