diff --git a/cmd/climc/shell/misc/feature.go b/cmd/climc/shell/misc/feature.go index ea8081798d..5325171ed8 100644 --- a/cmd/climc/shell/misc/feature.go +++ b/cmd/climc/shell/misc/feature.go @@ -61,6 +61,7 @@ func init() { "default", "k8s", "monitor", + "bingocloud", } const ( diff --git a/pkg/apis/identity/config.go b/pkg/apis/identity/config.go index edfed1f15e..2e38396d57 100644 --- a/pkg/apis/identity/config.go +++ b/pkg/apis/identity/config.go @@ -102,6 +102,7 @@ const ( IdpTemplateDingtalk = "dingtalk_oauth2" IdpTemplateFeishu = "feishu_oauth2" IdpTemplateQywechat = "qywechat_oauth2" + IdpTemplateBingoIAM = "bingoiam_oauth2" ) var ( @@ -123,6 +124,7 @@ var ( IdpTemplateDingtalk: IdentityDriverOAuth2, IdpTemplateWechat: IdentityDriverOAuth2, IdpTemplateQywechat: IdentityDriverOAuth2, + IdpTemplateBingoIAM: IdentityDriverOAuth2, } ) diff --git a/pkg/keystone/driver/oauth2/bingoiam/bingoiam.go b/pkg/keystone/driver/oauth2/bingoiam/bingoiam.go new file mode 100644 index 0000000000..5792e7a083 --- /dev/null +++ b/pkg/keystone/driver/oauth2/bingoiam/bingoiam.go @@ -0,0 +1,185 @@ +// Copyright 2019 Yunion +// +// 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 bingoiam + +import ( + "context" + "encoding/base64" + "fmt" + "net/http" + "strings" + + "yunion.io/x/jsonutils" + "yunion.io/x/pkg/errors" + "yunion.io/x/pkg/util/httputils" + + "yunion.io/x/onecloud/pkg/apis/identity" + "yunion.io/x/onecloud/pkg/keystone/driver/oauth2" + "yunion.io/x/onecloud/pkg/keystone/models" +) + +type SBingoIAMOAuth2Driver struct { + oauth2.SOAuth2BaseDriver + + idp *models.SIdentityProvider + accessToken string + domains map[string]*models.SDomain + projects map[string]*models.SProject +} + +func NewBingoIAMOAuth2Driver(appId string, secret string) oauth2.IOAuth2Driver { + drv := &SBingoIAMOAuth2Driver{ + domains: map[string]*models.SDomain{}, + projects: map[string]*models.SProject{}, + SOAuth2BaseDriver: oauth2.SOAuth2BaseDriver{ + AppId: appId, + Secret: secret, + }, + } + return drv +} + +func (drv *SBingoIAMOAuth2Driver) getIAMEndpoint(ctx context.Context) string { + endpoint := "" + if config, isOk := ctx.Value("config").(identity.TConfigs); isOk { + endpoint = strings.Trim(config["oauth2"]["iam_endpoint"].String(), `"`) + } + return endpoint +} + +func (drv *SBingoIAMOAuth2Driver) getIAMApiEndpoint(ctx context.Context) string { + endpoint := "" + if config, isOk := ctx.Value("config").(identity.TConfigs); isOk { + endpoint = strings.Trim(config["oauth2"]["iam_api_endpoint"].String(), `"`) + } + return endpoint +} + +func (drv *SBingoIAMOAuth2Driver) GetSsoRedirectUri(ctx context.Context, callbackUrl, state string) (string, error) { + req := map[string]string{ + "client_id": drv.AppId, + "redirect_uri": callbackUrl, + "response_type": "code id_token", + "scope": "openid", + "state": state, + } + authUrl := drv.getIAMEndpoint(ctx) + urlStr := fmt.Sprintf("%s/oauth2/authorize?%s", authUrl, jsonutils.Marshal(req).QueryString()) + return urlStr, nil +} + +func (drv *SBingoIAMOAuth2Driver) Authenticate(ctx context.Context, code string) (map[string][]string, error) { + accessToken, err := drv.fetchAccessToken(ctx, code) + if err != nil { + return nil, errors.Wrap(err, "fetch bingo iam accessToken") + } + userInfo, err := drv.fetchUserInfo(ctx, accessToken) + if err != nil { + return nil, errors.Wrap(err, "fetch bingo iam userInfo") + } + attrs := make(map[string][]string) + attrs["name"] = []string{fmt.Sprintf("%s", userInfo["preferred_username"])} + attrs["display_name"] = []string{fmt.Sprintf("%s", userInfo["name"])} + attrs["user_id"] = []string{fmt.Sprintf("%s", userInfo["sub"])} + attrs["name_en"] = []string{fmt.Sprintf("%s", userInfo["preferred_username"])} + attrs["email"] = []string{fmt.Sprintf("%s", userInfo["email"])} + attrs["org_id"] = []string{fmt.Sprintf("%s", userInfo["org_id"])} + attrs["mobile"] = []string{fmt.Sprintf("%s", userInfo["phone_number"])} + attrs["tenant_id"] = []string{fmt.Sprintf("%s", userInfo["tenant_id"])} + attrs["tenant_name"] = []string{fmt.Sprintf("%s", userInfo["tenant_name"])} + return attrs, nil +} + +func (drv *SBingoIAMOAuth2Driver) Sync(ctx context.Context, idpId string) error { + var err error + drv.idp, err = models.IdentityProviderManager.FetchIdentityProviderById(idpId) + if err != nil { + return err + } + + //err = drv.syncTenants(ctx, drv.idp) + //if err != nil { + // return err + //} + //err = drv.syncProjects(ctx, drv.idp) + //if err != nil { + // return err + //} + //err = drv.syncUsers(ctx, drv.idp) + //if err != nil { + // return err + //} + + return nil +} + +func (drv *SBingoIAMOAuth2Driver) getAccessToken(ctx context.Context) (string, error) { + authUrl := drv.getIAMEndpoint(ctx) + authUrl = fmt.Sprintf("%s/oauth2/token?grant_type=client_credentials", authUrl) + headers := http.Header{} + headers.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%v:%v", drv.AppId, drv.Secret)))) + + httpclient := httputils.GetDefaultClient() + _, resp, err := httputils.JSONRequest(httpclient, ctx, httputils.POST, authUrl, headers, nil, true) + if err != nil { + return "", err + } + var data map[string]interface{} + err = resp.Unmarshal(&data) + if err != nil { + return "", err + } + drv.accessToken = data["access_token"].(string) + + return drv.accessToken, nil +} + +func (drv *SBingoIAMOAuth2Driver) fetchAccessToken(ctx context.Context, code string) (string, error) { + authUrl := drv.getIAMEndpoint(ctx) + authUrl = fmt.Sprintf("%s/oauth2/token?grant_type=authorization_code&code=%s", authUrl, code) + headers := http.Header{} + headers.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%v:%v", drv.AppId, drv.Secret)))) + + httpclient := httputils.GetDefaultClient() + _, resp, err := httputils.JSONRequest(httpclient, ctx, httputils.POST, authUrl, headers, nil, true) + if err != nil { + return "", err + } + var data map[string]interface{} + err = resp.Unmarshal(&data) + if err != nil { + return "", err + } + return data["access_token"].(string), nil +} + +func (drv *SBingoIAMOAuth2Driver) fetchUserInfo(ctx context.Context, accessToken string) (map[string]interface{}, error) { + authUrl := drv.getIAMEndpoint(ctx) + authUrl = fmt.Sprintf("%s/oauth2/userinfo?access_token=%s", authUrl, accessToken) + headers := http.Header{} + headers.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%v:%v", drv.AppId, drv.Secret)))) + + httpclient := httputils.GetDefaultClient() + _, resp, err := httputils.JSONRequest(httpclient, ctx, httputils.GET, authUrl, headers, nil, true) + if err != nil { + return nil, err + } + var data map[string]interface{} + err = resp.Unmarshal(&data) + if err != nil { + return nil, err + } + return data, nil +} diff --git a/pkg/keystone/driver/oauth2/bingoiam/doc.go b/pkg/keystone/driver/oauth2/bingoiam/doc.go new file mode 100644 index 0000000000..a52dddc4c2 --- /dev/null +++ b/pkg/keystone/driver/oauth2/bingoiam/doc.go @@ -0,0 +1,15 @@ +// Copyright 2019 Yunion +// +// 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 bingoiam // import "yunion.io/x/onecloud/pkg/keystone/driver/oauth2/bingo" diff --git a/pkg/keystone/driver/oauth2/bingoiam/factory.go b/pkg/keystone/driver/oauth2/bingoiam/factory.go new file mode 100644 index 0000000000..68b9f2d130 --- /dev/null +++ b/pkg/keystone/driver/oauth2/bingoiam/factory.go @@ -0,0 +1,50 @@ +// Copyright 2019 Yunion +// +// 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 bingoiam + +import ( + api "yunion.io/x/onecloud/pkg/apis/identity" + "yunion.io/x/onecloud/pkg/keystone/driver/oauth2" +) + +type SBingoIAMDriverFactory struct{} + +func (drv SBingoIAMDriverFactory) NewDriver(appId string, secret string) oauth2.IOAuth2Driver { + return NewBingoIAMOAuth2Driver(appId, secret) +} + +func (drv SBingoIAMDriverFactory) TemplateName() string { + return api.IdpTemplateBingoIAM +} + +func (drv SBingoIAMDriverFactory) IdpAttributeOptions() api.SIdpAttributeOptions { + return api.SIdpAttributeOptions{ + DomainNameAttribute: "tenant_name", + DomainIdAttribute: "tenant_id", + UserNameAttribute: "name", + UserIdAttribute: "user_id", + UserDisplaynameAttribtue: "display_name", + ProjectAttribute: "", + DefaultProjectId: "", + } +} + +func (drv SBingoIAMDriverFactory) ValidateConfig(conf api.SOAuth2IdpConfigOptions) error { + return nil +} + +func init() { + oauth2.Register(&SBingoIAMDriverFactory{}) +} diff --git a/pkg/keystone/driver/oauth2/bingoiam/sync.go b/pkg/keystone/driver/oauth2/bingoiam/sync.go new file mode 100644 index 0000000000..ccea278501 --- /dev/null +++ b/pkg/keystone/driver/oauth2/bingoiam/sync.go @@ -0,0 +1,273 @@ +package bingoiam + +import ( + "context" + "database/sql" + "fmt" + "net/http" + "net/url" + "strconv" + "time" + + json "yunion.io/x/jsonutils" + "yunion.io/x/log" + "yunion.io/x/pkg/errors" + "yunion.io/x/pkg/tristate" + "yunion.io/x/pkg/util/httputils" + + "yunion.io/x/onecloud/pkg/keystone/models" +) + +type SBase struct { + Id string `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + Email string `json:"email"` + Mobile string `json:"mobile"` + ParentId string `json:"parentId"` + ExternalId string `json:"externalId"` + Active bool `json:"active"` + IsDeleted bool `json:"isDeleted"` + CreatedAt time.Time `json:"createdAt"` + CreatedBy string `json:"createdBy"` + UpdatedAt time.Time `json:"updatedAt"` + UpdatedBy string `json:"updatedBy"` +} + +type STenant struct { + SBase +} + +type SOrganization struct { + SBase + TenantId string `json:"tenantId"` +} + +type SUser struct { + SBase + OrgId string `json:"orgId"` + UserName string `json:"userName"` + TenantId string `json:"tenantId"` +} + +type SApp struct { + SBase + Name string `json:"title"` + TenantId string `json:"tenantId"` + AppType string `json:"appType"` + ProjectId string `json:"projectId"` + Description string `json:"description"` +} + +type SProject struct { + Id string `json:"id"` + Name string `json:"name"` + TenantId string `json:"tenantId"` +} + +func (drv *SBingoIAMOAuth2Driver) syncTenants(ctx context.Context, idp *models.SIdentityProvider) error { + var count, page = 0, 0 + for { + page++ + total, tenants, err := drv.getTenants(ctx, page, 500) + if err != nil { + return err + } + for _, tenant := range tenants { + domain, err := idp.SyncOrCreateDomain(ctx, tenant.Id, tenant.Name, fmt.Sprintf("Sync from %s", idp.Name), false) + if err != nil { + return errors.Wrap(err, "idp.SyncOrCreateDomain") + } + drv.domains[tenant.Id] = domain + } + count += len(tenants) + if count >= total { + break + } + time.Sleep(time.Millisecond * 200) + } + return nil +} + +func (drv *SBingoIAMOAuth2Driver) syncOrganizations(ctx context.Context, idp *models.SIdentityProvider) error { + var loader func(filters string, orgs chan []*SOrganization) + loader = func(filters string, orgs chan []*SOrganization) { + var page, count = 0, 0 + var childOrgs []*SOrganization + for { + page++ + total, orgs, err := drv.getOrganizations(ctx, filters, page, 500) + if err != nil { + log.Errorf("get organizations %s fail %s", filters, err) + return + } + childOrgs = append(childOrgs, orgs...) + count += len(orgs) + if count >= total { + break + } + time.Sleep(time.Millisecond * 200) + } + if len(childOrgs) > 0 { + orgs <- childOrgs + } + for _, org := range childOrgs { + loader("parentId eq "+org.Id, orgs) + } + } + + var allOrgs = make(chan []*SOrganization) + go func() { + for orgs := range allOrgs { + for _, org := range orgs { + domain := drv.domains[org.TenantId] + if domain == nil { + continue + } + //TODO save to db + } + } + }() + loader("parentId is null", allOrgs) + close(allOrgs) + return nil +} + +func (drv *SBingoIAMOAuth2Driver) syncProjects(ctx context.Context, idp *models.SIdentityProvider) error { + var count, page = 0, 0 + for { + page++ + total, projects, err := drv.getProjects(ctx, page, 500) + if err != nil { + return err + } + for _, project := range projects { + domain := drv.domains[project.TenantId] + if domain == nil { + continue + } + ret, err := models.ProjectManager.FetchProject("", project.Name, domain.Id, "") + if err != nil { + log.Errorf("fetch project %s fail %s", project.Name, err) + if errors.Cause(err) == sql.ErrNoRows { + ret, err = models.ProjectManager.NewProject(ctx, project.Name, fmt.Sprintf("Sync from %s", idp.Name), domain.Id) + if err != nil { + continue + } + } + } + drv.projects[project.Id] = ret + } + count += len(projects) + if count >= total { + break + } + time.Sleep(time.Millisecond * 200) + } + return nil +} + +func (drv *SBingoIAMOAuth2Driver) syncUsers(ctx context.Context, idp *models.SIdentityProvider) error { + var count, page = 0, 0 + for { + page++ + total, users, err := drv.getUsers(ctx, page, 500) + if err != nil { + return err + } + for _, user := range users { + domain := drv.domains[user.TenantId] + if domain == nil { + continue + } + _, err = idp.SyncOrCreateUser(ctx, user.Id, user.UserName, domain.Id, true, func(u *models.SUser) { + u.Id = user.Id + u.Name = user.UserName + u.Displayname = user.Name + u.Description = fmt.Sprintf("Sync from %s", idp.Name) + u.Email = user.Email + u.DomainId = domain.Id + u.Mobile = user.Mobile + u.Enabled = tristate.True + u.SEnabledIdentityBaseResource.Enabled = tristate.True + }) + if err != nil { + return errors.Wrap(err, "idp.SyncOrCreateDomain") + } + } + count += len(users) + if count >= total { + break + } + time.Sleep(time.Millisecond * 200) + } + return nil +} + +func (drv *SBingoIAMOAuth2Driver) getTenants(ctx context.Context, page, pageSize int) (int, []*STenant, error) { + var accessToken = drv.accessToken + + if accessToken == "" { + accessToken, _ = drv.getAccessToken(ctx) + } + + urlStrStr := fmt.Sprintf("%v/tenant?total=true&page_size=%v&page=%v", drv.getIAMApiEndpoint(ctx), pageSize, page) + headers := http.Header{} + headers.Set("Authorization", "Bearer "+accessToken) + + return doRequest[[]*STenant](ctx, urlStrStr, httputils.GET, headers, nil) +} + +func (drv *SBingoIAMOAuth2Driver) getOrganizations(ctx context.Context, filters string, page, pageSize int) (int, []*SOrganization, error) { + var accessToken = drv.accessToken + + if accessToken == "" { + accessToken, _ = drv.getAccessToken(ctx) + } + + urlStr := fmt.Sprintf("%v/organization?total=true&page_size=%v&page=%v&filters=%v", drv.getIAMApiEndpoint(ctx), pageSize, page, url.QueryEscape(filters)) + headers := http.Header{} + headers.Set("Authorization", "Bearer "+accessToken) + + return doRequest[[]*SOrganization](ctx, urlStr, httputils.GET, headers, nil) +} + +func (drv *SBingoIAMOAuth2Driver) getUsers(ctx context.Context, page, pageSize int) (int, []*SUser, error) { + var accessToken = drv.accessToken + + if accessToken == "" { + accessToken, _ = drv.getAccessToken(ctx) + } + + urlStr := fmt.Sprintf("%v/user?total=true&page_size=%v&page=%v", drv.getIAMApiEndpoint(ctx), pageSize, page) + headers := http.Header{} + headers.Set("Authorization", "Bearer "+accessToken) + + return doRequest[[]*SUser](ctx, urlStr, httputils.GET, headers, nil) + +} + +func (drv *SBingoIAMOAuth2Driver) getProjects(ctx context.Context, page, pageSize int) (int, []*SProject, error) { + var accessToken = drv.accessToken + + if accessToken == "" { + accessToken, _ = drv.getAccessToken(ctx) + } + + urlStr := fmt.Sprintf("%v/project?total=true&page_size=%v&page=%v", drv.getIAMApiEndpoint(ctx), pageSize, page) + headers := http.Header{} + headers.Set("Authorization", "Bearer "+accessToken) + + return doRequest[[]*SProject](ctx, urlStr, httputils.GET, headers, nil) +} + +func doRequest[Result any](ctx context.Context, urlStr string, method httputils.THttpMethod, headers http.Header, body json.JSONObject) (total int, result Result, err error) { + httpclient := httputils.GetDefaultClient() + repsHeaders, resp, err := httputils.JSONRequest(httpclient, ctx, method, urlStr, headers, body, true) + if err != nil { + return 0, result, err + } + total, _ = strconv.Atoi(repsHeaders.Get("X-Total-Count")) + err = resp.Unmarshal(&result) + return +} diff --git a/pkg/keystone/driver/oauth2/oauth2.go b/pkg/keystone/driver/oauth2/oauth2.go index fb6cff9120..a337450af6 100644 --- a/pkg/keystone/driver/oauth2/oauth2.go +++ b/pkg/keystone/driver/oauth2/oauth2.go @@ -71,6 +71,7 @@ func (self *SOAuth2Driver) GetSsoRedirectUri(ctx context.Context, callbackUrl, s return "", errors.Wrapf(httperrors.ErrNotSupported, "template %s not supported", self.Template) } driver := factory.NewDriver(self.oauth2Config.AppId, self.oauth2Config.Secret) + ctx = context.WithValue(ctx, "config", self.SBaseIdentityDriver.Config) return driver.GetSsoRedirectUri(ctx, callbackUrl, state) } @@ -82,6 +83,7 @@ func (self *SOAuth2Driver) Authenticate(ctx context.Context, ident mcclient.SAut options := self.oauth2Config.SIdpAttributeOptions options.Update(factory.IdpAttributeOptions()) driver := factory.NewDriver(self.oauth2Config.AppId, self.oauth2Config.Secret) + ctx = context.WithValue(ctx, "config", self.SBaseIdentityDriver.Config) attrs, err := driver.Authenticate(ctx, ident.OAuth2.Code) if err != nil { return nil, errors.Wrapf(err, "driver %s Authenticate", self.Template) @@ -120,6 +122,14 @@ func (self *SOAuth2Driver) Authenticate(ctx context.Context, ident mcclient.SAut } func (self *SOAuth2Driver) Sync(ctx context.Context) error { + factory := findDriverFactory(self.Template) + if factory == nil { + return nil + } + if driver, isOk := factory.NewDriver(self.oauth2Config.AppId, self.oauth2Config.Secret).(IOAuth2Synchronizer); isOk { + ctx = context.WithValue(ctx, "config", self.SBaseIdentityDriver.Config) + return driver.Sync(ctx, self.IdpId) + } return nil } diff --git a/pkg/keystone/driver/oauth2/types.go b/pkg/keystone/driver/oauth2/types.go index e93712d496..dcf7d12a59 100644 --- a/pkg/keystone/driver/oauth2/types.go +++ b/pkg/keystone/driver/oauth2/types.go @@ -32,6 +32,10 @@ type IOAuth2Driver interface { GetSsoRedirectUri(ctx context.Context, callbackUrl, state string) (string, error) } +type IOAuth2Synchronizer interface { + Sync(ctx context.Context, idpId string) error +} + type SOAuth2BaseDriver struct { AppId string Secret string diff --git a/pkg/keystone/service/drivers.go b/pkg/keystone/service/drivers.go index 06d58e055b..dc1c84dac2 100644 --- a/pkg/keystone/service/drivers.go +++ b/pkg/keystone/service/drivers.go @@ -19,6 +19,7 @@ import ( _ "yunion.io/x/onecloud/pkg/keystone/driver/ldap" _ "yunion.io/x/onecloud/pkg/keystone/driver/oauth2" _ "yunion.io/x/onecloud/pkg/keystone/driver/oauth2/alipay" + _ "yunion.io/x/onecloud/pkg/keystone/driver/oauth2/bingoiam" _ "yunion.io/x/onecloud/pkg/keystone/driver/oauth2/dingtalk" _ "yunion.io/x/onecloud/pkg/keystone/driver/oauth2/feishu" _ "yunion.io/x/onecloud/pkg/keystone/driver/oauth2/qywechat"