diff --git a/cmd/climc/shell/identity/identityproviders.go b/cmd/climc/shell/identity/identityproviders.go index deb514395f..3d98773a74 100644 --- a/cmd/climc/shell/identity/identityproviders.go +++ b/cmd/climc/shell/identity/identityproviders.go @@ -23,6 +23,7 @@ import ( "yunion.io/x/onecloud/pkg/mcclient" "yunion.io/x/onecloud/pkg/mcclient/modules" "yunion.io/x/onecloud/pkg/mcclient/options" + "yunion.io/x/onecloud/pkg/util/fileutils2" "yunion.io/x/onecloud/pkg/util/shellutils" ) @@ -55,6 +56,19 @@ func init() { return nil }) + type IdentityProviderUpdateOptions struct { + ID string `help:"Id or name of identity provider to update" json:"-"` + api.IdentityProviderUpdateInput + } + R(&IdentityProviderUpdateOptions{}, "idp-update", "Update a identity provider", func(s *mcclient.ClientSession, args *IdentityProviderUpdateOptions) error { + resp, err := modules.IdentityProviders.Update(s, args.ID, jsonutils.Marshal(args)) + if err != nil { + return err + } + printObject(resp) + return nil + }) + R(&IdentityProviderDetailOptions{}, "idp-config-show", "Show detail of a domain config", func(s *mcclient.ClientSession, args *IdentityProviderDetailOptions) error { conf, err := modules.IdentityProviders.GetSpecific(s, args.ID, "config", nil) if err != nil { @@ -525,4 +539,144 @@ func init() { return nil }) + type IdentityProviderCreateAzureOIDCOptions struct { + NAME string `help:"name of identity provider" json:"-"` + + api.SOIDCAzureConfigOptions + } + R(&IdentityProviderCreateAzureOIDCOptions{}, "idp-create-azure-oidc", "Create an identity provider with Azure AD OpenID Connect", func(s *mcclient.ClientSession, args *IdentityProviderCreateAzureOIDCOptions) error { + params := jsonutils.NewDict() + params.Add(jsonutils.NewString(args.NAME), "name") + params.Add(jsonutils.NewString("oidc"), "driver") + params.Add(jsonutils.NewString(api.IdpTemplateAzureOAuth2), "template") + + params.Add(jsonutils.Marshal(args), "config", "oidc") + + idp, err := modules.IdentityProviders.Create(s, params) + if err != nil { + return err + } + printObject(idp) + return nil + }) + + type IdentityProviderCreateAlipayOAuth2Options struct { + NAME string `help:"name of identity provider"` + APPID string `help:"Alipay app_id"` + KEYFILE string `json:"Alipay app private key file"` + } + R(&IdentityProviderCreateAlipayOAuth2Options{}, "idp-create-alipay-oauth2", "Create an identity provider with Alipay OAuth2.0", func(s *mcclient.ClientSession, args *IdentityProviderCreateAlipayOAuth2Options) error { + opts := api.SOAuth2IdpConfigOptions{} + opts.AppId = args.APPID + var err error + opts.Secret, err = fileutils2.FileGetContents(args.KEYFILE) + if err != nil { + return err + } + params := jsonutils.NewDict() + params.Add(jsonutils.NewString(args.NAME), "name") + params.Add(jsonutils.NewString("oauth2"), "driver") + params.Add(jsonutils.NewString(api.IdpTemplateAlipay), "template") + params.Add(jsonutils.Marshal(opts), "config", "oauth2") + idp, err := modules.IdentityProviders.Create(s, params) + if err != nil { + return err + } + printObject(idp) + return nil + }) + + type IdentityProviderCreateFeishuOAuth2Options struct { + NAME string `help:"name of identity provider"` + + api.SOAuth2IdpConfigOptions + } + R(&IdentityProviderCreateFeishuOAuth2Options{}, "idp-create-feishu-oauth2", "Create an identity provider with Feishu OAuth2.0", func(s *mcclient.ClientSession, args *IdentityProviderCreateFeishuOAuth2Options) error { + params := jsonutils.NewDict() + params.Add(jsonutils.NewString(args.NAME), "name") + params.Add(jsonutils.NewString("oauth2"), "driver") + params.Add(jsonutils.NewString(api.IdpTemplateFeishu), "template") + params.Add(jsonutils.Marshal(args), "config", "oauth2") + idp, err := modules.IdentityProviders.Create(s, params) + if err != nil { + return err + } + printObject(idp) + return nil + }) + + type IdentityProviderCreateDingtalkOAuth2Options struct { + NAME string `help:"name of identity provider"` + + api.SOAuth2IdpConfigOptions + } + R(&IdentityProviderCreateDingtalkOAuth2Options{}, "idp-create-dingtalk-oauth2", "Create an identity provider with Feishu OAuth2.0", func(s *mcclient.ClientSession, args *IdentityProviderCreateDingtalkOAuth2Options) error { + params := jsonutils.NewDict() + params.Add(jsonutils.NewString(args.NAME), "name") + params.Add(jsonutils.NewString("oauth2"), "driver") + params.Add(jsonutils.NewString(api.IdpTemplateDingtalk), "template") + params.Add(jsonutils.Marshal(args), "config", "oauth2") + idp, err := modules.IdentityProviders.Create(s, params) + if err != nil { + return err + } + printObject(idp) + return nil + }) + + type IdentityProviderCreateWechatOAuth2Options struct { + NAME string `help:"name of identity provider"` + + api.SOAuth2IdpConfigOptions + } + R(&IdentityProviderCreateWechatOAuth2Options{}, "idp-create-wechat-oauth2", "Create an identity provider with Wechat OAuth2.0", func(s *mcclient.ClientSession, args *IdentityProviderCreateWechatOAuth2Options) error { + params := jsonutils.NewDict() + params.Add(jsonutils.NewString(args.NAME), "name") + params.Add(jsonutils.NewString("oauth2"), "driver") + params.Add(jsonutils.NewString(api.IdpTemplateWechat), "template") + params.Add(jsonutils.Marshal(args), "config", "oauth2") + idp, err := modules.IdentityProviders.Create(s, params) + if err != nil { + return err + } + printObject(idp) + return nil + }) + + type IdentityProviderCreateQywechatOAuth2Options struct { + api.IdentityProviderCreateInput + CorpId string `help:"corp id of qywechat"` + AgentId string `help:"agent id of app"` + Secret string `help:"secret of qywechat"` + } + R(&IdentityProviderCreateQywechatOAuth2Options{}, "idp-create-qywechat-oauth2", "Create an identity provider with Qiye Wechat OAuth2.0", func(s *mcclient.ClientSession, args *IdentityProviderCreateQywechatOAuth2Options) error { + conf := api.SOAuth2IdpConfigOptions{ + AppId: fmt.Sprintf("%s/%s", args.CorpId, args.AgentId), + Secret: args.Secret, + } + params := jsonutils.Marshal(args).(*jsonutils.JSONDict) + params.Add(jsonutils.NewString("oauth2"), "driver") + params.Add(jsonutils.NewString(api.IdpTemplateQywechat), "template") + params.Add(jsonutils.Marshal(conf), "config", "oauth2") + idp, err := modules.IdentityProviders.Create(s, params) + if err != nil { + return err + } + printObject(idp) + return nil + }) + + type IdpGetRedirectUriOptions struct { + ID string `help:"id or name of idp to query" json:"-"` + + api.GetIdpSsoRedirectUriInput + } + R(&IdpGetRedirectUriOptions{}, "idp-sso-url", "Get sso url of a SSO idp", func(s *mcclient.ClientSession, args *IdpGetRedirectUriOptions) error { + result, err := modules.IdentityProviders.GetSpecific(s, args.ID, "sso-redirect-uri", jsonutils.Marshal(args)) + if err != nil { + return err + } + printObject(result) + return nil + }) } diff --git a/cmd/climc/shell/identity/users.go b/cmd/climc/shell/identity/users.go index dd99c859d0..fcfa8d3fa9 100644 --- a/cmd/climc/shell/identity/users.go +++ b/cmd/climc/shell/identity/users.go @@ -169,6 +169,9 @@ func init() { SystemAccount bool `help:"is a system account?"` NoWebConsole bool `help:"allow web console access"` EnableMfa bool `help:"enable TOTP mfa"` + + IdpId string `help:"Id of identity provider to link with"` + IdpEntityId string `help:"Entity id of identity provider to link with"` } R(&UserCreateOptions{}, "user-create", "Create a user", func(s *mcclient.ClientSession, args *UserCreateOptions) error { params := jsonutils.NewDict() @@ -214,6 +217,11 @@ func init() { params.Add(jsonutils.JSONTrue, "enable_mfa") } + if len(args.IdpId) > 0 { + params.Add(jsonutils.NewString(args.IdpId), "idp_id") + params.Add(jsonutils.NewString(args.IdpEntityId), "idp_entity_id") + } + /*if len(args.DefaultProject) > 0 { projId, err := modules.Projects.GetId(s, args.DefaultProject, nil) if err != nil { @@ -409,4 +417,25 @@ func init() { return nil }) + type UserLinkIdpOptions struct { + USER string `help:"ID or name of user to operate" json:"-"` + IdpId string `help:"Id of identity provider to link with" required:"true" json:"idp_id"` + IdpEntityId string `help:"Id of entity in identity provider to link with" required:"true" json:"idp_entity_id"` + } + R(&UserLinkIdpOptions{}, "user-link-idp", "Link user with an entity in the speicified identity provider", func(s *mcclient.ClientSession, args *UserLinkIdpOptions) error { + result, err := modules.UsersV3.PerformAction(s, args.USER, "link-idp", jsonutils.Marshal(args)) + if err != nil { + return err + } + printObject(result) + return nil + }) + R(&UserLinkIdpOptions{}, "user-unlink-idp", "Unlink user from an entity in the speicified identity provider", func(s *mcclient.ClientSession, args *UserLinkIdpOptions) error { + result, err := modules.UsersV3.PerformAction(s, args.USER, "unlink-idp", jsonutils.Marshal(args)) + if err != nil { + return err + } + printObject(result) + return nil + }) } diff --git a/pkg/apigateway/clientman/authtoken.go b/pkg/apigateway/clientman/authtoken.go index e5ef1d5401..fc81c6228a 100644 --- a/pkg/apigateway/clientman/authtoken.go +++ b/pkg/apigateway/clientman/authtoken.go @@ -1,3 +1,17 @@ +// 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 clientman import ( @@ -41,6 +55,7 @@ type SAuthToken struct { token string verifyTotp bool enableTotp bool + initTotp bool retryCount int // 重试计数器 lockExpireTime uint32 // 锁定时间 } @@ -57,6 +72,11 @@ func (t SAuthToken) encodeBytes() []byte { } else { msg.WriteByte(TotpDisable) } + if t.initTotp { + msg.WriteByte(TotpEnable) + } else { + msg.WriteByte(TotpDisable) + } msg.WriteByte(byte(rand.Int())) msg.WriteByte(byte(t.retryCount)) expBytes := make([]byte, 4) @@ -94,7 +114,7 @@ func Decode(t string) (*SAuthToken, error) { func decodeBytes(tt []byte) (*SAuthToken, error) { ret := SAuthToken{} - if len(tt) < 3 { + if len(tt) < 10 { return nil, errors.Wrap(errors.ErrInvalidStatus, "too short") } if tt[0] == TotpEnable { @@ -107,9 +127,15 @@ func decodeBytes(tt []byte) (*SAuthToken, error) { } else { ret.enableTotp = false } - ret.retryCount = int(tt[3]) - ret.lockExpireTime = binary.LittleEndian.Uint32(tt[4:]) - ret.token = string(tt[8:]) + if tt[2] == TotpEnable { + ret.initTotp = true + } else { + ret.initTotp = false + } + // 3: skip rand number + ret.retryCount = int(tt[4]) + ret.lockExpireTime = binary.LittleEndian.Uint32(tt[5:]) + ret.token = string(tt[9:]) return &ret, nil } @@ -154,6 +180,7 @@ func (t SAuthToken) GetAuthCookie(token mcclient.TokenCredential) string { info := jsonutils.NewDict() info.Add(jsonutils.NewTimeString(token.GetExpires()), "exp") info.Add(jsonutils.NewString(sid), "session") + info.Add(jsonutils.NewBool(t.initTotp), "totp_init") // 是否初始化TOTP密钥 info.Add(jsonutils.NewBool(t.enableTotp), "totp_on") // 用户totp 开启状态。 True(已开启)|False(未开启) info.Add(jsonutils.NewBool(options.Options.EnableTotp), "system_totp_on") // 全局totp 开启状态。 True(已开启)|False(未开启) return info.String() @@ -173,14 +200,23 @@ func (t SAuthToken) IsTotpEnabled() bool { return t.enableTotp } +func (t SAuthToken) IsTotpInitialized() bool { + return t.initTotp +} + +func (t *SAuthToken) SetTotpInitialized() { + t.initTotp = true +} + func (t *SAuthToken) SetToken(tid string) { t.token = tid } -func NewAuthToken(tid string, enableTotp bool) *SAuthToken { +func NewAuthToken(tid string, enableTotp bool, isTotpInit bool) *SAuthToken { return &SAuthToken{ token: tid, enableTotp: enableTotp, + initTotp: isTotpInit, verifyTotp: false, } } diff --git a/pkg/apigateway/clientman/authtoken_test.go b/pkg/apigateway/clientman/authtoken_test.go index 38fe829851..f49339db33 100644 --- a/pkg/apigateway/clientman/authtoken_test.go +++ b/pkg/apigateway/clientman/authtoken_test.go @@ -1,3 +1,17 @@ +// 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 clientman import ( diff --git a/pkg/apigateway/handler/auth.go b/pkg/apigateway/handler/auth.go index 0b830d2d04..3749d8f30a 100644 --- a/pkg/apigateway/handler/auth.go +++ b/pkg/apigateway/handler/auth.go @@ -69,14 +69,18 @@ func (h *AuthHandlers) AddMethods() { // no middleware handler h.AddByMethod(GET, nil, NewHP(h.getRegions, "regions"), + NewHP(h.getIdpSsoRedirectUri, "sso", "redirect", ""), NewHP(h.listTotpRecoveryQuestions, "recovery"), + NewHP(h.handleSsoLogin, "ssologin"), ) h.AddByMethod(POST, nil, + NewHP(h.initTotpSecrets, "initcredential"), NewHP(h.resetTotpSecrets, "credential"), NewHP(h.validatePasscode, "passcode"), NewHP(h.resetTotpRecoveryQuestions, "recovery"), NewHP(h.postLoginHandler, "login"), NewHP(h.postLogoutHandler, "logout"), + NewHP(h.handleSsoLogin, "ssologin"), ) // auth middleware handler @@ -85,11 +89,14 @@ func (h *AuthHandlers) AddMethods() { NewHP(h.getPermissionDetails, "permissions"), NewHP(h.getAdminResources, "admin_resources"), NewHP(h.getResources, "scoped_resources"), + NewHP(fetchIdpBasicConfig, "idp", "", "info"), + NewHP(fetchIdpSAMLMetadata, "idp", "", "saml-metadata"), ) h.AddByMethod(POST, FetchAuthToken, NewHP(h.resetUserPassword, "password"), NewHP(h.getPermissionDetails, "permissions"), NewHP(h.doCreatePolicies, "policies"), + NewHP(handleUnlinkIdp, "unlink-idp"), ) h.AddByMethod(PATCH, FetchAuthToken, NewHP(h.doPatchPolicy, "policies", ""), @@ -105,6 +112,14 @@ func (h *AuthHandlers) Bind(app *appsrv.Application) { } func (h *AuthHandlers) GetRegionsResponse(ctx context.Context, w http.ResponseWriter, req *http.Request) (*jsonutils.JSONDict, error) { + var currentDomain string + var createUser bool + qs, _ := jsonutils.ParseQueryString(req.URL.RawQuery) + if qs != nil { + currentDomain, _ = qs.GetString("domain") + createUser = jsonutils.QueryBoolean(qs, "auto_create_user", true) + } + adminToken := auth.AdminCredential() if adminToken == nil { return nil, errors.Error("failed to get admin credential") @@ -116,6 +131,9 @@ func (h *AuthHandlers) GetRegionsResponse(ctx context.Context, w http.ResponseWr regionsJson := jsonutils.NewStringArray(regions) s := auth.GetAdminSession(ctx, regions[0], "") filters := jsonutils.NewDict() + if len(currentDomain) > 0 { + filters.Add(jsonutils.NewString(currentDomain), "id") + } filters.Add(jsonutils.NewInt(1000), "limit") result, e := modules.Domains.List(s, filters) if e != nil { @@ -135,27 +153,23 @@ func (h *AuthHandlers) GetRegionsResponse(ctx context.Context, w http.ResponseWr resp.Add(regionsJson, "regions") filters = jsonutils.NewDict() - filters.Add(jsonutils.NewStringArray([]string{"cas"}), "driver") filters.Add(jsonutils.JSONTrue, "enabled") + if len(currentDomain) == 0 { + currentDomain = "all" + } + filters.Add(jsonutils.NewString(currentDomain), "sso_domain") + filters.Add(jsonutils.NewString("system"), "scope") filters.Add(jsonutils.NewInt(1000), "limit") + if !createUser { + filters.Add(jsonutils.JSONFalse, "auto_create_user") + } idps, err := modules.IdentityProviders.List(s, filters) if err != nil { return nil, errors.Wrap(err, "list idp") } retIdps := make([]jsonutils.JSONObject, 0) for i := range idps.Data { - retIdp := jsonutils.NewDict() - id, _ := idps.Data[i].GetString("id") - name, _ := idps.Data[i].GetString("name") - driver, _ := idps.Data[i].GetString("driver") - retIdp.Add(jsonutils.NewString(id), "id") - retIdp.Add(jsonutils.NewString(name), "name") - retIdp.Add(jsonutils.NewString(driver), "driver") - conf, err := modules.IdentityProviders.GetSpecific(s, id, "config", nil) - if err != nil { - return nil, errors.Wrap(err, "idp get config spec") - } - retIdp.Update(conf) + retIdp := idps.Data[i].(*jsonutils.JSONDict).CopyIncludes("id", "name", "driver", "template", "icon_uri") retIdps = append(retIdps, retIdp) } @@ -186,20 +200,24 @@ func (h *AuthHandlers) getUser(ctx context.Context, w http.ResponseWriter, req * appsrv.SendJSON(w, body) } +func (h *AuthHandlers) initTotpSecrets(ctx context.Context, w http.ResponseWriter, req *http.Request) { + initTotpSecrets(ctx, w, req) +} + func (h *AuthHandlers) resetTotpSecrets(ctx context.Context, w http.ResponseWriter, req *http.Request) { - ResetTotpSecrets(ctx, w, req) + resetTotpSecrets(ctx, w, req) } func (h *AuthHandlers) validatePasscode(ctx context.Context, w http.ResponseWriter, req *http.Request) { - ValidatePasscodeHandler(ctx, w, req) + validatePasscodeHandler(ctx, w, req) } func (h *AuthHandlers) resetTotpRecoveryQuestions(ctx context.Context, w http.ResponseWriter, req *http.Request) { - ResetTotpRecoveryQuestions(ctx, w, req) + resetTotpRecoveryQuestions(ctx, w, req) } func (h *AuthHandlers) listTotpRecoveryQuestions(ctx context.Context, w http.ResponseWriter, req *http.Request) { - ListTotpRecoveryQuestions(ctx, w, req) + listTotpRecoveryQuestions(ctx, w, req) } // 返回 token及totp验证状态 @@ -227,7 +245,11 @@ func doTenantLogin(ctx context.Context, req *http.Request, body jsonutils.JSONOb func fetchUserInfoFromToken(ctx context.Context, req *http.Request, token mcclient.TokenCredential) (jsonutils.JSONObject, error) { s := auth.GetAdminSession(ctx, FetchRegion(req), "") - return modules.UsersV3.Get(s, token.GetUserId(), nil) + info, err := modules.UsersV3.Get(s, token.GetUserId(), nil) + if err != nil { + return nil, errors.Wrap(err, "UsersV3.Get") + } + return info, nil } func isUserEnableTotp(userInfo jsonutils.JSONObject) bool { @@ -238,7 +260,7 @@ func (h *AuthHandlers) doCredentialLogin(ctx context.Context, req *http.Request, var token mcclient.TokenCredential var err error var tenant string - log.Debugf("doCredentialLogin body: %s", body) + // log.Debugf("doCredentialLogin body: %s", body) cliIp := netutils2.GetHttpRequestIp(req) if body.Contains("username") { uname, _ := body.GetString("username") @@ -266,32 +288,11 @@ func (h *AuthHandlers) doCredentialLogin(ctx context.Context, req *http.Request, // var token mcclient.TokenCredential domain, _ := body.GetString("domain") token, err = auth.Client().AuthenticateWeb(uname, passwd, domain, "", "", cliIp) - } else if body.Contains("cas_ticket") { - ticket, _ := body.GetString("cas_ticket") - if len(ticket) == 0 { - return nil, httperrors.NewInputParameterError("cas_ticket is empty") + } else if body.Contains("idp_driver") { // sso login + token, err = processSsoLoginData(body, cliIp) + if err != nil { + return nil, errors.Wrap(err, "processSsoLoginData") } - token, err = auth.Client().AuthenticateCAS(ticket, "", "", "", cliIp) - } else if body.Contains("saml_response") { - samlResp, _ := body.GetString("saml_response") - if len(samlResp) == 0 { - return nil, httperrors.NewInputParameterError("saml_response is empty") - } - token, err = auth.Client().AuthenticateSAML(samlResp, "", "", "", cliIp) - } else if body.Contains("oidc_code") { - oidcCode, _ := body.GetString("oidc_code") - if len(oidcCode) == 0 { - return nil, httperrors.NewInputParameterError("oidc_code is empty") - } - oidcCliId, _ := body.GetString("oidc_client_id") - if len(oidcCliId) == 0 { - return nil, httperrors.NewInputParameterError("oidc_client_id is empty") - } - oidcRedir, _ := body.GetString("oidc_redirect_uri") - if len(oidcRedir) == 0 { - return nil, httperrors.NewInputParameterError("oidc_redirect_uri is empty") - } - token, err = auth.Client().AuthenticateOIDC(oidcCliId, oidcCode, oidcRedir, "", "", "", cliIp) } else { return nil, httperrors.NewInputParameterError("missing credential") } @@ -416,6 +417,10 @@ func saveCookie(w http.ResponseWriter, name, val, domain string, expire time.Tim } func getCookie(r *http.Request, name string) string { + return getCookie2(r, name, true) +} + +func getCookie2(r *http.Request, name string, base64 bool) string { cookie, err := r.Cookie(name) if err != nil { log.Errorf("Cookie not found %q", name) @@ -424,6 +429,9 @@ func getCookie(r *http.Request, name string) string { // fmt.Println("Cookie expired ", cookie.Expires, time.Now()) // return "" } else { + if !base64 { + return cookie.Value + } val, err := Base64UrlDecode(cookie.Value) if err != nil { log.Errorf("Cookie %q fail to decode: %v", name, err) @@ -454,44 +462,55 @@ func clearAuthCookie(w http.ResponseWriter) { type PreLoginFunc func(ctx context.Context, req *http.Request, uname string, body jsonutils.JSONObject) error func (h *AuthHandlers) postLoginHandler(ctx context.Context, w http.ResponseWriter, req *http.Request) { - body, e := appsrv.FetchJSON(req) - if e != nil { - httperrors.InvalidInputError(w, "fetch json for request: %v", e) + body, err := appsrv.FetchJSON(req) + if err != nil { + httperrors.InvalidInputError(w, "fetch json for request: %v", err) return } + err = h.doLogin(ctx, w, req, body) + if err != nil { + httperrors.GeneralServerError(w, err) + return + } + // normal + appsrv.Send(w, "") +} + +func (h *AuthHandlers) doLogin(ctx context.Context, w http.ResponseWriter, req *http.Request, body jsonutils.JSONObject) error { + var err error var authToken *clientman.SAuthToken var token mcclient.TokenCredential var userInfo jsonutils.JSONObject if body.Contains("tenantId") { // switch project - token, authToken, e = doTenantLogin(ctx, req, body) - if e != nil { - httperrors.GeneralServerError(w, e) - return + token, authToken, err = doTenantLogin(ctx, req, body) + if err != nil { + return errors.Wrap(err, "doTenantLogin") } - userInfo, e = fetchUserInfoFromToken(ctx, req, token) - if e != nil { - httperrors.GeneralServerError(w, e) - return + userInfo, err = fetchUserInfoFromToken(ctx, req, token) + if err != nil { + return errors.Wrap(err, "fetchUserInfoFromToken") } } else { // user/password authenticate - // cas authentication - token, e = h.doCredentialLogin(ctx, req, body) - if e != nil { - httperrors.GeneralServerError(w, e) - return + // SSO authentication + token, err = h.doCredentialLogin(ctx, req, body) + if err != nil { + return errors.Wrap(err, "doCredentialLogin") } - userInfo, e = fetchUserInfoFromToken(ctx, req, token) - if e != nil { - httperrors.GeneralServerError(w, e) - return + userInfo, err = fetchUserInfoFromToken(ctx, req, token) + if err != nil { + return errors.Wrap(err, "fetchUserInfoFromToken") } - authToken = clientman.NewAuthToken(token.GetTokenString(), isUserEnableTotp(userInfo)) + s := auth.GetAdminSession(ctx, FetchRegion(req), "") + isTotpInit, err := isUserTotpCredInitialed(s, token.GetUserId()) + if err != nil { + return errors.Wrap(err, "isUserTotpCredInitialed") + } + authToken = clientman.NewAuthToken(token.GetTokenString(), isUserEnableTotp(userInfo), isTotpInit) } if !isUserAllowWebconsole(userInfo) { - httperrors.ForbiddenError(w, "user forbidden login from web") - return + return errors.Wrap(httperrors.ErrForbidden, "user forbidden login from web") } saveAuthCookie(w, authToken, token) @@ -518,19 +537,7 @@ func (h *AuthHandlers) postLoginHandler(ctx context.Context, w http.ResponseWrit saveCookie(w, "tenant", token.GetProjectId(), "", token.GetExpires(), false) } - // 开启Totp的状态下,如果用户未设置 - qrcode := "" - if !authToken.IsTotpVerified() { - s := auth.GetAdminSession(ctx, FetchRegion(req), "") - var err error - qrcode, err = initializeUserTotpCred(s, token) - if err != nil { - httperrors.GeneralServerError(w, err) - return - } - } - - appsrv.Send(w, qrcode) + return nil } func (h *AuthHandlers) postLogoutHandler(ctx context.Context, w http.ResponseWriter, req *http.Request) { @@ -747,8 +754,9 @@ func getUserInfo(ctx context.Context, req *http.Request) (*jsonutils.JSONDict, e "enabled", "mobile", "allow_web_console", "created_at", "enable_mfa", "is_system_account", "last_active_at", "last_login_ip", - "last_login_source", "idp_driver", + "last_login_source", "password_expires_at", "failed_auth_count", "failed_auth_at", + "idps", } { v, e := usr.Get(k) if e == nil { @@ -904,6 +912,8 @@ func getUserInfo(ctx context.Context, req *http.Request) (*jsonutils.JSONDict, e data.Add(jsonutils.JSONFalse, "non_default_domain_projects") } + data.Add(jsonutils.NewString(getSsoCallbackUrl()), "sso_callback_url") + return data, nil } diff --git a/pkg/apigateway/handler/auth_totp.go b/pkg/apigateway/handler/auth_totp.go index 0fe0600c49..6da143405d 100644 --- a/pkg/apigateway/handler/auth_totp.go +++ b/pkg/apigateway/handler/auth_totp.go @@ -201,8 +201,35 @@ func validateTotpRecoverySecrets(s *mcclient.ClientSession, uid string, question return nil } +// 获取第一次的QR code +func initTotpSecrets(ctx context.Context, w http.ResponseWriter, req *http.Request) { + t, authToken, err := fetchAuthInfo(ctx, req) + if err != nil { + httperrors.InvalidCredentialError(w, "fetchAuthInfo fail %s", err) + return + } + if authToken.IsTotpInitialized() { + resetTotpSecrets(ctx, w, req) + return + } + + s := auth.GetAdminSession(ctx, FetchRegion(req), "") + code, err := doCreateUserTotpCred(s, t) + if err != nil { + httperrors.GeneralServerError(w, err) + return + } + + authToken.SetTotpInitialized() + saveAuthCookie(w, authToken, t) + + resp := jsonutils.NewDict() + resp.Add(jsonutils.NewString(code), "qrcode") + appsrv.SendJSON(w, resp) +} + // 验证OTP -func ValidatePasscodeHandler(ctx context.Context, w http.ResponseWriter, req *http.Request) { +func validatePasscodeHandler(ctx context.Context, w http.ResponseWriter, req *http.Request) { t, authToken, err := fetchAuthInfo(ctx, req) if err != nil { httperrors.InvalidCredentialError(w, "fetchAuthInfo fail %s", err) @@ -241,7 +268,7 @@ func ValidatePasscodeHandler(ctx context.Context, w http.ResponseWriter, req *ht } // 验证OTP credential重置问题.如果答案正确,返回重置后的Qrcode(base64编码,png格式)。 -func ResetTotpSecrets(ctx context.Context, w http.ResponseWriter, req *http.Request) { +func resetTotpSecrets(ctx context.Context, w http.ResponseWriter, req *http.Request) { t, _, err := fetchAuthInfo(ctx, req) if err != nil { httperrors.InvalidCredentialError(w, "fetchAuthInfo fail %s", err) @@ -279,7 +306,7 @@ func ResetTotpSecrets(ctx context.Context, w http.ResponseWriter, req *http.Requ } // 获取OTP 重置密码问题列表。 -func ListTotpRecoveryQuestions(ctx context.Context, w http.ResponseWriter, req *http.Request) { +func listTotpRecoveryQuestions(ctx context.Context, w http.ResponseWriter, req *http.Request) { t, _, err := fetchAuthInfo(ctx, req) if err != nil { httperrors.InvalidCredentialError(w, "fetchAuthInfo fail %s", err) @@ -305,7 +332,7 @@ func ListTotpRecoveryQuestions(ctx context.Context, w http.ResponseWriter, req * } // 提交OTP 重置密码问题。 -func ResetTotpRecoveryQuestions(ctx context.Context, w http.ResponseWriter, req *http.Request) { +func resetTotpRecoveryQuestions(ctx context.Context, w http.ResponseWriter, req *http.Request) { t, _, err := fetchAuthInfo(ctx, req) if err != nil { httperrors.InvalidCredentialError(w, "fetchAuthInfo fail %s", err) diff --git a/pkg/apigateway/handler/idp.go b/pkg/apigateway/handler/idp.go new file mode 100644 index 0000000000..98e91d64d1 --- /dev/null +++ b/pkg/apigateway/handler/idp.go @@ -0,0 +1,380 @@ +// 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 handler + +import ( + "context" + "net/http" + "net/url" + "regexp" + "time" + + "yunion.io/x/jsonutils" + "yunion.io/x/log" + "yunion.io/x/pkg/errors" + "yunion.io/x/pkg/utils" + + "yunion.io/x/onecloud/pkg/apigateway/options" + api "yunion.io/x/onecloud/pkg/apis/identity" + "yunion.io/x/onecloud/pkg/appctx" + "yunion.io/x/onecloud/pkg/appsrv" + "yunion.io/x/onecloud/pkg/httperrors" + "yunion.io/x/onecloud/pkg/mcclient" + "yunion.io/x/onecloud/pkg/mcclient/auth" + "yunion.io/x/onecloud/pkg/mcclient/modules" + "yunion.io/x/onecloud/pkg/util/httputils" + "yunion.io/x/onecloud/pkg/util/netutils2" +) + +func getSsoCallbackUrl() string { + return options.Options.SsoRedirectUrl +} + +func (h *AuthHandlers) getIdpSsoRedirectUri(ctx context.Context, w http.ResponseWriter, req *http.Request) { + params := appctx.AppContextParams(ctx) + idpId := params[""] + query, _ := jsonutils.ParseQueryString(req.URL.RawQuery) + var linkuser string + if query != nil && query.Contains("linkuser") { + t, _, _ := fetchAuthInfo(ctx, req) + if t == nil { + httperrors.InvalidCredentialError(w, "invalid credential") + return + } + linkuser = t.GetUserId() + } + + referer := req.Header.Get(http.CanonicalHeaderKey("referer")) + + state := utils.GenRequestId(16) + redirectUri := getSsoCallbackUrl() + s := auth.GetAdminSession(ctx, FetchRegion(req), "") + input := api.GetIdpSsoRedirectUriInput{ + RedirectUri: redirectUri, + State: state, + } + resp, err := modules.IdentityProviders.GetSpecific(s, idpId, "sso-redirect-uri", jsonutils.Marshal(input)) + if err != nil { + httperrors.GeneralServerError(w, err) + return + } + redirUrl, _ := resp.GetString("uri") + idpDriver, _ := resp.GetString("driver") + expires := time.Now().Add(time.Minute * 5) + saveCookie(w, "idp_id", idpId, "", expires, true) + saveCookie(w, "idp_state", state, "", expires, true) + saveCookie(w, "idp_driver", idpDriver, "", expires, true) + saveCookie(w, "idp_referer", referer, "", expires, true) + saveCookie(w, "idp_link_user", linkuser, "", expires, true) + + appsrv.DisableClientCache(w) + appsrv.SendRedirect(w, redirUrl) +} + +func findExtUserId(input string) string { + pattern := regexp.MustCompile(`idp.SyncOrCreateDomainAndUser: ([^:]+): UserNotFoundError`) + matches := pattern.FindAllStringSubmatch(input, -1) + log.Debugf("%#v", matches) + if len(matches) > 0 && len(matches[0]) > 1 { + return matches[0][1] + } + return "" +} + +func (h *AuthHandlers) handleSsoLogin(ctx context.Context, w http.ResponseWriter, req *http.Request) { + idpId := getCookie(req, "idp_id") + idpDriver := getCookie(req, "idp_driver") + idpState := getCookie(req, "idp_state") + idpReferer := getCookie(req, "idp_referer") + idpLinkUser := getCookie(req, "idp_link_user") + if len(idpId) == 0 || len(idpDriver) == 0 || len(idpState) == 0 || len(idpReferer) == 0 { + httperrors.TimeoutError(w, "session expires") + return + } + + for _, k := range []string{"idp_id", "idp_driver", "idp_state", "idp_referer", "idp_link_user"} { + clearCookie(w, k, "") + } + + var body jsonutils.JSONObject + var err error + switch req.Method { + case "GET": + body, err = jsonutils.ParseQueryString(req.URL.RawQuery) + if err != nil { + httperrors.InputParameterError(w, "parse query string error: %s", err) + return + } + case "POST": + formData, err := appsrv.Fetch(req) + if err != nil { + httperrors.InputParameterError(w, "fetch formdata error: %s", err) + } + body, err = jsonutils.ParseQueryString(string(formData)) + if err != nil { + httperrors.InputParameterError(w, "parse form data error: %s", err) + return + } + default: + httperrors.InputParameterError(w, "invalid request") + return + } + + body.(*jsonutils.JSONDict).Set("idp_id", jsonutils.NewString(idpId)) + body.(*jsonutils.JSONDict).Set("idp_driver", jsonutils.NewString(idpDriver)) + body.(*jsonutils.JSONDict).Set("idp_state", jsonutils.NewString(idpState)) + + appsrv.DisableClientCache(w) + + if len(idpLinkUser) > 0 { + // link with existing user + err := linkWithExistingUser(ctx, req, idpId, idpLinkUser, body) + referer := options.Options.SsoLinkCallbackUrl + refererUrl, _ := url.Parse(referer) + if refererUrl == nil { + refererUrl, _ = url.Parse(idpReferer) + } + if err != nil { + log.Debugf("error: %s", err) + } + redirUrl := generateRedirectUrl(refererUrl, err, "", "") + // success, do redirect + appsrv.SendRedirect(w, redirUrl) + } else { + // ordinary login + referer := options.Options.SsoAuthCallbackUrl + refererUrl, _ := url.Parse(referer) + if refererUrl == nil { + refererUrl, _ = url.Parse(idpReferer) + } + var idpUserId string + err = h.doLogin(ctx, w, req, body) + if err != nil { + if errors.Cause(err) == httperrors.ErrUserNotFound { + idpUserId = findExtUserId(err.Error()) + if len(idpUserId) == 0 { + err = httputils.NewJsonClientError(400, string(httperrors.ErrInputParameter), "empty external user id") + } + } + log.Debugf("error: %s", err) + } + redirUrl := generateRedirectUrl(refererUrl, err, idpId, idpUserId) + appsrv.SendRedirect(w, redirUrl) + } +} + +func generateRedirectUrl(originUrl *url.URL, err error, idpId, idpUserId string) string { + var qs jsonutils.JSONObject + if len(originUrl.RawQuery) > 0 { + qs, _ = jsonutils.ParseQueryString(originUrl.RawQuery) + } else { + qs = jsonutils.NewDict() + } + if err != nil { + var errCls, errDetails string + switch je := err.(type) { + case *httputils.JSONClientError: + errCls = je.Class + errDetails = je.Details + default: + errCls = errors.Cause(err).Error() + errDetails = err.Error() + } + qs.(*jsonutils.JSONDict).Add(jsonutils.NewString(errCls), "error_class") + qs.(*jsonutils.JSONDict).Add(jsonutils.NewString(errDetails), "error_details") + qs.(*jsonutils.JSONDict).Add(jsonutils.NewString("error"), "result") + if len(idpUserId) > 0 { + qs.(*jsonutils.JSONDict).Add(jsonutils.NewString(idpId), "idp_id") + qs.(*jsonutils.JSONDict).Add(jsonutils.NewString(idpUserId), "idp_user_id") + } + } else { + qs.(*jsonutils.JSONDict).Add(jsonutils.NewString("success"), "result") + } + originUrl.RawQuery = qs.QueryString() + return originUrl.String() +} + +func processSsoLoginData(body jsonutils.JSONObject, cliIp string) (mcclient.TokenCredential, error) { + var token mcclient.TokenCredential + var err error + idpDriver, _ := body.GetString("idp_driver") + idpId, _ := body.GetString("idp_id") + idpState, _ := body.GetString("idp_state") + switch idpDriver { + case api.IdentityDriverCAS: + redirectUri := getSsoCallbackUrl() + ticket, _ := body.GetString("ticket") + if len(ticket) == 0 { + return nil, httperrors.NewMissingParameterError("ticket") + } + token, err = auth.Client().AuthenticateCAS(idpId, ticket, redirectUri, "", "", "", cliIp) + case api.IdentityDriverSAML: + samlResp, _ := body.GetString("SAMLResponse") + relayState, _ := body.GetString("RelayState") + if relayState != idpState { + return nil, errors.Wrap(httperrors.ErrInputParameter, "state inconsistent") + } + if len(samlResp) == 0 { + return nil, errors.Wrap(httperrors.ErrMissingParameter, "SAMLResponse") + } + token, err = auth.Client().AuthenticateSAML(idpId, samlResp, "", "", "", cliIp) + case api.IdentityDriverOIDC: + redirectUri := getSsoCallbackUrl() + code, _ := body.GetString("code") + state, _ := body.GetString("state") + if state != idpState { + return nil, errors.Wrap(httperrors.ErrInputParameter, "state inconsistent") + } + if len(code) == 0 { + return nil, errors.Wrap(httperrors.ErrMissingParameter, "code") + } + token, err = auth.Client().AuthenticateOIDC(idpId, code, redirectUri, "", "", "", cliIp) + case api.IdentityDriverOAuth2: + state, _ := body.GetString("state") + if state != idpState { + return nil, errors.Wrap(httperrors.ErrInputParameter, "state inconsistent") + } + code, _ := body.GetString("code") + if len(code) == 0 { + code, _ = body.GetString("auth_code") + if len(code) == 0 { + return nil, errors.Wrap(httperrors.ErrMissingParameter, "code") + } + } + token, err = auth.Client().AuthenticateOAuth2(idpId, code, "", "", "", cliIp) + default: + return nil, errors.Wrapf(httperrors.ErrNotSupported, "SSO driver %s not supported", idpDriver) + } + return token, err +} + +func linkWithExistingUser(ctx context.Context, req *http.Request, idpId, idpLinkUser string, body jsonutils.JSONObject) error { + t, _, _ := fetchAuthInfo(ctx, req) + if t == nil { + return errors.Wrap(httperrors.ErrInvalidCredential, "invalid credential") + } + if t.GetUserId() != idpLinkUser { + return errors.Wrap(httperrors.ErrConflict, "link user id inconsistent with credential") + } + cliIp := netutils2.GetHttpRequestIp(req) + ntoken, err := processSsoLoginData(body, cliIp) + if err != nil { + if errors.Cause(err) != httperrors.ErrUserNotFound { + return errors.Wrap(err, "invalid ssologin result") + } + log.Debugf("error: %s", err) + // not linked, link with user + // fetch userId + jsonErr := err.(*httputils.JSONClientError) + extUserId := findExtUserId(jsonErr.Details) + if len(extUserId) == 0 { + return errors.Wrap(httperrors.ErrInputParameter, "empty external user id") + } + linkInput := api.UserLinkIdpInput{ + IdpId: idpId, + IdpEntityId: extUserId, + } + s := auth.GetAdminSession(ctx, FetchRegion(req), "") + _, err = modules.UsersV3.PerformAction(s, t.GetUserId(), "link-idp", jsonutils.Marshal(linkInput)) + if err != nil { + return errors.Wrap(err, "link-idp") + } + } else { + if ntoken.GetUserId() != t.GetUserId() { + return errors.Wrap(httperrors.ErrConflict, "link user id inconsistent with credential") + } + } + return nil +} + +func handleUnlinkIdp(ctx context.Context, w http.ResponseWriter, req *http.Request) { + t := AppContextToken(ctx) + + body, err := appsrv.FetchJSON(req) + if err != nil { + httperrors.GeneralServerError(w, err) + return + } + + idpId, _ := body.GetString("idp_id") + idpEntityId, _ := body.GetString("idp_entity_id") + + if len(idpId) == 0 || len(idpEntityId) == 0 { + httperrors.InputParameterError(w, "empty idp_id or idp_entity_id") + return + } + + s := auth.GetAdminSession(ctx, FetchRegion(req), "") + input := api.UserUnlinkIdpInput{ + IdpId: idpId, + IdpEntityId: idpEntityId, + } + _, err = modules.UsersV3.PerformAction(s, t.GetUserId(), "unlink-idp", jsonutils.Marshal(input)) + if err != nil { + httperrors.GeneralServerError(w, err) + return + } + appsrv.Send(w, "") +} + +func fetchIdpBasicConfig(ctx context.Context, w http.ResponseWriter, req *http.Request) { + s := auth.GetAdminSession(ctx, FetchRegion(req), "") + params := appctx.AppContextParams(ctx) + idpId := params[""] + info, err := getIdpBasicConfig(s, idpId) + if err != nil { + httperrors.GeneralServerError(w, err) + return + } + appsrv.SendJSON(w, info) +} + +func getIdpBasicConfig(s *mcclient.ClientSession, idpId string) (jsonutils.JSONObject, error) { + idp, err := modules.IdentityProviders.Get(s, idpId, nil) + if err != nil { + return nil, errors.Wrap(err, "Fetch") + } + info := jsonutils.NewDict() + idpDriver, _ := idp.GetString("driver") + switch idpDriver { + case api.IdentityDriverSQL: + case api.IdentityDriverLDAP: + case api.IdentityDriverCAS: + info.Add(jsonutils.NewString(getSsoCallbackUrl()), "redirect_uri") + case api.IdentityDriverSAML: + info.Add(jsonutils.NewString(options.Options.ApiServer), "entity_id") + info.Add(jsonutils.NewString(getSsoCallbackUrl()), "redirect_uri") + case api.IdentityDriverOIDC: + info.Add(jsonutils.NewString(getSsoCallbackUrl()), "redirect_uri") + case api.IdentityDriverOAuth2: + info.Add(jsonutils.NewString(getSsoCallbackUrl()), "redirect_uri") + default: + } + return info, nil +} + +func fetchIdpSAMLMetadata(ctx context.Context, w http.ResponseWriter, req *http.Request) { + s := auth.GetAdminSession(ctx, FetchRegion(req), "") + params := appctx.AppContextParams(ctx) + idpId := params[""] + query := jsonutils.NewDict() + query.Set("redirect_uri", jsonutils.NewString(getSsoCallbackUrl())) + md, err := modules.IdentityProviders.GetSpecific(s, idpId, "saml-metadata", query) + if err != nil { + httperrors.GeneralServerError(w, err) + return + } + appsrv.SendJSON(w, md) +} diff --git a/pkg/apigateway/handler/idp_test.go b/pkg/apigateway/handler/idp_test.go new file mode 100644 index 0000000000..c33cf9bc6c --- /dev/null +++ b/pkg/apigateway/handler/idp_test.go @@ -0,0 +1,35 @@ +// 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 handler + +import "testing" + +func TestFindExtUserId(t *testing.T) { + cases := []struct { + in string + want string + }{ + { + in: "authUserByCASV3: Authenticate: idp.SyncOrCreateDomainAndUser: qiujian: UserNotFoundError", + want: "qiujian", + }, + } + for _, c := range cases { + got := findExtUserId(c.in) + if got != c.want { + t.Errorf("want %s got %s", c.want, got) + } + } +} diff --git a/pkg/apigateway/options/options.go b/pkg/apigateway/options/options.go index 2fcb13e3fb..39018a2afb 100644 --- a/pkg/apigateway/options/options.go +++ b/pkg/apigateway/options/options.go @@ -28,6 +28,10 @@ type GatewayOptions struct { EnableTotp bool `help:"Enable two-factor authentication" default:"true"` + SsoRedirectUrl string `help:"SSO idp redirect URL"` + SsoAuthCallbackUrl string `help:"SSO idp auth callback URL"` + SsoLinkCallbackUrl string `help:"SSO idp link user callback URL"` + common_options.CommonOptions `"request_worker_count->default":"32"` } diff --git a/pkg/apis/identity/cas.go b/pkg/apis/identity/cas.go index ec79a837af..d6539e755c 100644 --- a/pkg/apis/identity/cas.go +++ b/pkg/apis/identity/cas.go @@ -19,7 +19,6 @@ import "yunion.io/x/pkg/tristate" type SCASIdpConfigOptions struct { // https://cas.example.org/cas/ CASServerURL string `json:"cas_server_url"` - Service string `json:"service"` // Deprecated CasProjectAttribute string `json:"cas_project_attribute" "deprecated-by":"project_attribute"` diff --git a/pkg/apis/identity/config.go b/pkg/apis/identity/config.go index 2cbfa2dda9..683d0d9b4c 100644 --- a/pkg/apis/identity/config.go +++ b/pkg/apis/identity/config.go @@ -92,8 +92,15 @@ const ( IdpTemplateSAMLTest = "samltest_saml" IdpTemplateAzureADSAML = "azure_ad_saml" - IdpTemplateDex = "dex_oidc" - IdpTemplateGithub = "github_oidc" + IdpTemplateDex = "dex_oidc" + IdpTemplateGithub = "github_oidc" + IdpTemplateAzureOAuth2 = "azure_oidc" + + IdpTemplateAlipay = "alipay_oauth2" + IdpTemplateWechat = "wechat_oauth2" + IdpTemplateDingtalk = "dingtalk_oauth2" + IdpTemplateFeishu = "feishu_oauth2" + IdpTemplateQywechat = "qywechat_oauth2" ) var ( @@ -105,8 +112,15 @@ var ( IdpTemplateSAMLTest: IdentityDriverSAML, IdpTemplateAzureADSAML: IdentityDriverSAML, - IdpTemplateDex: IdentityDriverOIDC, - IdpTemplateGithub: IdentityDriverOIDC, + IdpTemplateDex: IdentityDriverOIDC, + IdpTemplateGithub: IdentityDriverOIDC, + IdpTemplateAzureOAuth2: IdentityDriverOIDC, + + IdpTemplateAlipay: IdentityDriverOAuth2, + IdpTemplateFeishu: IdentityDriverOAuth2, + IdpTemplateDingtalk: IdentityDriverOAuth2, + IdpTemplateWechat: IdentityDriverOAuth2, + IdpTemplateQywechat: IdentityDriverOAuth2, } ) diff --git a/pkg/apis/identity/consts.go b/pkg/apis/identity/consts.go index 8984ad78f6..173787eeaf 100644 --- a/pkg/apis/identity/consts.go +++ b/pkg/apis/identity/consts.go @@ -36,6 +36,7 @@ const ( AUTH_METHOD_CAS = "cas" AUTH_METHOD_SAML = "saml" AUTH_METHOD_OIDC = "oidc" + AUTH_METHOD_OAuth2 = "oauth2" // AUTH_METHOD_ID_PASSWORD = 1 // AUTH_METHOD_ID_TOKEN = 2 @@ -59,11 +60,12 @@ const ( IdMappingEntityGroup = "group" IdMappingEntityDomain = "domain" - IdentityDriverSQL = "sql" - IdentityDriverLDAP = "ldap" - IdentityDriverCAS = "cas" - IdentityDriverSAML = "saml" - IdentityDriverOIDC = "oidc" // OpenID Connect + IdentityDriverSQL = "sql" + IdentityDriverLDAP = "ldap" + IdentityDriverCAS = "cas" + IdentityDriverSAML = "saml" + IdentityDriverOIDC = "oidc" // OpenID Connect + IdentityDriverOAuth2 = "oauth2" // OAuth2.0 IdentityDriverStatusConnected = "connected" IdentityDriverStatusDisconnected = "disconnected" diff --git a/pkg/apis/identity/identityprovider.go b/pkg/apis/identity/identityprovider.go index 9746342937..a44706f801 100644 --- a/pkg/apis/identity/identityprovider.go +++ b/pkg/apis/identity/identityprovider.go @@ -65,28 +65,54 @@ type IdentityProviderCreateInput struct { apis.EnabledStatusStandaloneResourceCreateInput // 后端驱动名称 - Driver string `json:"driver"` + Driver string `json:"driver" ignore:"true"` // 模板名称 - Template string `json:"template"` + Template string `json:"template" ignore:"true"` // 默认导入用户和组的域 - TargetDomain string `json:"target_domain"` + TargetDomainId string `json:"target_domain_id"` // swagger:ignore // Deprecated - TargetDomainId string `json:"target_domain_id" "yunion:deprecated-by":"target_domain"` + TargetDomain string `json:"target_domain" "yunion:deprecated-by":"target_domain_id"` // 新建域的时候是否自动新建第一个项目 AutoCreateProject *bool `json:"auto_create_project"` + // 当用户不存在时,是否自动新建用户 + AutoCreateUser *bool `json:"auto_create_user"` // 自动同步间隔,单位:秒 SyncIntervalSeconds *int `json:"sync_interval_seconds"` // 配置信息 - Config TConfigs `json:"config"` + Config TConfigs `json:"config" ignore:"true"` + + // 图标URL + IconUri string `json:"icon_uri"` } type GetIdpSamlMetadataInput struct { // 缩进展示SAML sp metadata Pretty *bool `json:"pretty"` + // AssertionConsumer callback URL + RedirectUri string `json:"redirect_uri"` +} + +type GetIdpSamlMetadataOutput struct { + // SAML 2.0 SP metadata + Metadata string `json:"metadata"` +} + +type GetIdpSsoRedirectUriInput struct { + // SSO回调地址 + RedirectUri string `json:"redirect_uri"` + // SSO状态信息 + State string `json:"state"` +} + +type GetIdpSsoRedirectUriOutput struct { + // SSO跳转URI + Uri string `json:"uri"` + // Driver + Driver string `json:"driver"` } diff --git a/pkg/apis/identity/input.go b/pkg/apis/identity/input.go index 13a35e4a47..45ee0248d7 100644 --- a/pkg/apis/identity/input.go +++ b/pkg/apis/identity/input.go @@ -301,6 +301,13 @@ type IdentityProviderListInput struct { // 以同步状态过滤 SyncStatus []string `json:"sync_status"` + + // 过滤支持SSO的认证源,如果值为all,则列出所有的全局认证源,否则可出sso为域ID的域认证源 + // example: all + SsoDomain string `json:"sso"` + + AutoCreateProject *bool `json:"auto_create_project"` + AutoCreateUser *bool `json:"auto_create_user"` } type CredentialListInput struct { @@ -365,11 +372,17 @@ type GroupUpdateInput struct { type IdentityProviderUpdateInput struct { apis.EnabledStatusStandaloneResourceBaseUpdateInput - TargetDomainId string `json:"target_domain_id"` + // TargetDomainId string `json:"target_domain_id"` + // 当认证后用户加入项目不存在时是否自动创建项目 AutoCreateProject *bool `json:"auto_create_project"` + // 当认证后用户不存在时是否自动创建用户 + AutoCreateUser *bool `json:"auto_create_user"` SyncIntervalSeconds *int `json:"sync_interval_seconds"` + + // 图标URL + IconUri string `json:"icon_uri"` } type PolicyUpdateInput struct { @@ -427,6 +440,10 @@ type UserCreateInput struct { Password string `json:"password"` SkipPasswordComplexityCheck *bool `json:"skip_password_complexity_check"` + + IdpId string `json:"idp_id"` + + IdpEntityId string `json:"idp_entity_id"` } type ProjectCreateInput struct { @@ -467,3 +484,10 @@ type PerformGroupRemoveUsersInput struct { // 带删除用户列表(ID或名称) User []string `json:"user"` } + +type UserLinkIdpInput struct { + IdpId string `json:"idp_id"` + IdpEntityId string `json:"idp_entity_id"` +} + +type UserUnlinkIdpInput UserLinkIdpInput diff --git a/pkg/apis/identity/oauth2.go b/pkg/apis/identity/oauth2.go new file mode 100644 index 0000000000..4ee2803d6b --- /dev/null +++ b/pkg/apis/identity/oauth2.go @@ -0,0 +1,21 @@ +// 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 identity + +// OAuth2.0 +type SOAuth2IdpConfigOptions struct { + AppId string `json:"app_id"` + Secret string `json:"secret"` +} diff --git a/pkg/apis/identity/oidc.go b/pkg/apis/identity/oidc.go index d01854ed96..08e19daf59 100644 --- a/pkg/apis/identity/oidc.go +++ b/pkg/apis/identity/oidc.go @@ -41,3 +41,9 @@ type SOIDCGithubConfigOptions struct { ClientId string `json:"client_id"` ClientSecret string `json:"client_secret"` } + +type SOIDCAzureConfigOptions struct { + ClientId string `json:"client_id"` + ClientSecret string `json:"client_secret"` + TenantId string `json:"tenant_id"` +} diff --git a/pkg/apis/identity/user.go b/pkg/apis/identity/user.go index dbaa817281..1fa97ee54b 100644 --- a/pkg/apis/identity/user.go +++ b/pkg/apis/identity/user.go @@ -20,7 +20,7 @@ import ( type UserDetails struct { EnabledIdentityBaseResourceDetails - IdpResourceInfo + // IdpResourceInfo SUser @@ -31,5 +31,7 @@ type UserDetails struct { FailedAuthAt time.Time `json:"failed_auth_at"` PasswordExpiresAt time.Time `json:"password_expires_at"` + Idps []IdpResourceInfo `json:"idps"` + ExternalResourceInfo } diff --git a/pkg/appsrv/send.go b/pkg/appsrv/send.go index 806efc2d16..a740a0bedf 100644 --- a/pkg/appsrv/send.go +++ b/pkg/appsrv/send.go @@ -20,6 +20,7 @@ import ( "io" "net/http" "strconv" + "time" "github.com/pkg/errors" @@ -158,3 +159,14 @@ func SendRedirect(w http.ResponseWriter, redirectUrl string) { w.WriteHeader(301) w.Write([]byte{}) } + +func DisableClientCache(w http.ResponseWriter) { + // disable client cache + // Expires: Tue, 03 Jul 2001 06:00:00 GMT + // Last-Modified: {now} GMT + // Cache-Control: max-age=0, no-cache, must-revalidate, proxy-revalidate + w.Header().Set("Expires", "Tue, 03 Jul 2001 06:00:00 GMT") + cacheSince := time.Now().Format(http.TimeFormat) + w.Header().Set("Last-Modified", cacheSince) + w.Header().Set("Cache-Control", "max-age=0, no-cache, must-revalidate, proxy-revalidate") +} diff --git a/pkg/keystone/driver/cas/cas.go b/pkg/keystone/driver/cas/cas.go index 565037af0e..c0fcaacc14 100644 --- a/pkg/keystone/driver/cas/cas.go +++ b/pkg/keystone/driver/cas/cas.go @@ -75,6 +75,14 @@ func (self *SCASDriver) prepareConfig() error { return nil } +func (cas *SCASDriver) GetSsoRedirectUri(ctx context.Context, callbackUrl, state string) (string, error) { + req := map[string]string{ + "service": callbackUrl, + } + urlStr := fmt.Sprintf("%s?%s", cas.casConfig.CASServerURL, jsonutils.Marshal(req).QueryString()) + return urlStr, nil +} + func (self *SCASDriver) request(ctx context.Context, method httputils.THttpMethod, path string) ([]byte, error) { cli := httputils.GetDefaultClient() urlStr := httputils.JoinPath(self.casConfig.CASServerURL, path) @@ -114,7 +122,7 @@ serviceValidate response: func (self *SCASDriver) Authenticate(ctx context.Context, ident mcclient.SAuthenticationIdentity) (*api.SUserExtended, error) { query := jsonutils.NewDict() query.Set("ticket", jsonutils.NewString(ident.CASTicket.Id)) - query.Set("service", jsonutils.NewString(self.casConfig.Service)) + query.Set("service", jsonutils.NewString(ident.CASTicket.Service)) path := "serviceValidate?" + query.QueryString() resp, err := self.request(ctx, "GET", path) /*if err != nil && httputils.ErrorCode(err) == 404 { @@ -124,7 +132,7 @@ func (self *SCASDriver) Authenticate(ctx context.Context, ident mcclient.SAuthen if err != nil { return nil, errors.Wrap(err, "self.request") } - log.Debugf("%s", resp) + log.Debugf("CAS response: %s qs: %s", resp, query.QueryString()) attrs := fetchAttributes(resp) var usrId, usrName string @@ -150,14 +158,18 @@ func (self *SCASDriver) Authenticate(ctx context.Context, ident mcclient.SAuthen if err != nil { return nil, errors.Wrap(err, "self.GetIdentityProvider") } - domain, err := idp.GetSingleDomain(ctx, api.DefaultRemoteDomainId, self.IdpName, fmt.Sprintf("cas provider %s", self.IdpName), false) + domain, usr, err := idp.SyncOrCreateDomainAndUser(ctx, usrId, usrName) + if err != nil { + return nil, errors.Wrap(err, "idp.SyncOrCreateDomainAndUser") + } + /*domain, err := idp.GetSingleDomain(ctx, api.DefaultRemoteDomainId, self.IdpName, fmt.Sprintf("cas provider %s", self.IdpName), false) if err != nil { return nil, errors.Wrap(err, "idp.GetSingleDomain") } usr, err := idp.SyncOrCreateUser(ctx, usrId, usrName, domain.Id, true, nil) if err != nil { return nil, errors.Wrap(err, "idp.SyncOrCreateUser") - } + }*/ extUser, err := models.UserManager.FetchUserExtended(usr.Id, "", "", "") if err != nil { return nil, errors.Wrap(err, "models.UserManager.FetchUserExtended") diff --git a/pkg/keystone/driver/cas/class.go b/pkg/keystone/driver/cas/class.go index 463042616c..69064af127 100644 --- a/pkg/keystone/driver/cas/class.go +++ b/pkg/keystone/driver/cas/class.go @@ -28,10 +28,22 @@ import ( type SCASDriverClass struct{} -func (self *SCASDriverClass) SingletonInstance() bool { +func (self *SCASDriverClass) IsSso() bool { return true } +func (self *SCASDriverClass) ForceSyncUser() bool { + return false +} + +func (self *SCASDriverClass) GetDefaultIconUri(tmpName string) string { + return "https://www.apereo.org/sites/default/files/styles/project_logo/public/projects/logos/cas_max_logo_0.png" +} + +func (self *SCASDriverClass) SingletonInstance() bool { + return false +} + func (self *SCASDriverClass) SyncMethod() string { return api.IdentityProviderSyncOnAuth } diff --git a/pkg/keystone/driver/driver.go b/pkg/keystone/driver/driver.go index 19602f1383..8d0609a988 100644 --- a/pkg/keystone/driver/driver.go +++ b/pkg/keystone/driver/driver.go @@ -27,10 +27,14 @@ type IIdentityBackendClass interface { Name() string NewDriver(idpId, idpName, template, targetDomainId string, conf api.TConfigs) (IIdentityBackend, error) ValidateConfig(ctx context.Context, userCred mcclient.TokenCredential, conf api.TConfigs) (api.TConfigs, error) + IsSso() bool + GetDefaultIconUri(tmpName string) string + ForceSyncUser() bool } type IIdentityBackend interface { Authenticate(ctx context.Context, identity mcclient.SAuthenticationIdentity) (*api.SUserExtended, error) + GetSsoRedirectUri(ctx context.Context, callbackUrl, state string) (string, error) Sync(ctx context.Context) error Probe(ctx context.Context) error } diff --git a/pkg/keystone/driver/ldap/class.go b/pkg/keystone/driver/ldap/class.go index 5d32632120..2070f6c5eb 100644 --- a/pkg/keystone/driver/ldap/class.go +++ b/pkg/keystone/driver/ldap/class.go @@ -24,6 +24,18 @@ import ( type SLDAPDriverClass struct{} +func (self *SLDAPDriverClass) IsSso() bool { + return false +} + +func (self *SLDAPDriverClass) ForceSyncUser() bool { + return true +} + +func (self *SLDAPDriverClass) GetDefaultIconUri(tmpName string) string { + return "" +} + func (self *SLDAPDriverClass) SingletonInstance() bool { return false } diff --git a/pkg/keystone/driver/ldap/ldap.go b/pkg/keystone/driver/ldap/ldap.go index 0589b3a973..60cca8f21d 100644 --- a/pkg/keystone/driver/ldap/ldap.go +++ b/pkg/keystone/driver/ldap/ldap.go @@ -16,6 +16,7 @@ package ldap import ( "context" + "database/sql" "strconv" "strings" @@ -27,6 +28,7 @@ import ( "yunion.io/x/pkg/utils" api "yunion.io/x/onecloud/pkg/apis/identity" + "yunion.io/x/onecloud/pkg/httperrors" "yunion.io/x/onecloud/pkg/keystone/driver" "yunion.io/x/onecloud/pkg/keystone/models" "yunion.io/x/onecloud/pkg/mcclient" @@ -75,6 +77,10 @@ func (self *SLDAPDriver) prepareConfig() error { return nil } +func (ldap *SLDAPDriver) GetSsoRedirectUri(ctx context.Context, callbackUrl, state string) (string, error) { + return "", errors.Wrap(httperrors.ErrNotSupported, "not a SSO driver") +} + func queryScope(scope string) int { if scope == api.QueryScopeOne { return ldap.ScopeSingleLevel @@ -243,7 +249,7 @@ func (self *SLDAPDriver) Authenticate(ctx context.Context, ident mcclient.SAuthe var userTreeDN string if len(self.ldapConfig.DomainTreeDN) > 0 { // import domains - idMap, err := models.IdmappingManager.FetchEntity(usrExt.DomainId, api.IdMappingEntityDomain) + idMap, err := models.IdmappingManager.FetchFirstEntity(usrExt.DomainId, api.IdMappingEntityDomain) if err != nil { return nil, errors.Wrap(err, "IdmappingManager.FetchEntity for domain") } @@ -259,10 +265,20 @@ func (self *SLDAPDriver) Authenticate(ctx context.Context, ident mcclient.SAuthe userTreeDN = self.getUserTreeDN() } - usrIdmap, err := models.IdmappingManager.FetchEntity(usrExt.Id, api.IdMappingEntityUser) - if err != nil { + usrIdmaps, err := models.IdmappingManager.FetchEntities(usrExt.Id, api.IdMappingEntityUser) + if err != nil && errors.Cause(err) != sql.ErrNoRows { return nil, errors.Wrap(err, "IdmappingManager.FetchEntity for user") } + var usrIdmap *models.SIdmapping + for i := range usrIdmaps { + if usrIdmaps[i].IdpId == self.IdpId { + usrIdmap = &usrIdmaps[i] + break + } + } + if usrIdmap == nil { + return nil, errors.Wrap(httperrors.ErrInvalidCredential, "user not found in identity provider") + } username := usrIdmap.IdpEntityId password := ident.Password.User.Password diff --git a/pkg/keystone/driver/oauth2/alipay/alipay.go b/pkg/keystone/driver/oauth2/alipay/alipay.go new file mode 100644 index 0000000000..c217b7f8eb --- /dev/null +++ b/pkg/keystone/driver/oauth2/alipay/alipay.go @@ -0,0 +1,77 @@ +// 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 alipay + +import ( + "context" + "fmt" + + "yunion.io/x/jsonutils" + "yunion.io/x/pkg/errors" + + "yunion.io/x/onecloud/pkg/keystone/driver/oauth2" + "yunion.io/x/onecloud/pkg/util/alipayclient" +) + +type SAlipayOAuth2Driver struct { + oauth2.SOAuth2BaseDriver +} + +func NewAlipayOAuth2Driver(appId string, secret string) oauth2.IOAuth2Driver { + drv := &SAlipayOAuth2Driver{ + SOAuth2BaseDriver: oauth2.SOAuth2BaseDriver{ + AppId: appId, + Secret: secret, + }, + } + return drv +} + +const ( + AuthUrl = "https://openauth.alipay.com/oauth2/publicAppAuthorize.htm" +) + +func (drv *SAlipayOAuth2Driver) GetSsoRedirectUri(ctx context.Context, callbackUrl, state string) (string, error) { + req := map[string]string{ + "app_id": drv.AppId, + "redirect_uri": callbackUrl, + "scope": "auth_user", + "response_type": "code", + "state": state, + } + urlStr := fmt.Sprintf("%s?%s", AuthUrl, jsonutils.Marshal(req).QueryString()) + return urlStr, nil +} + +func (drv *SAlipayOAuth2Driver) Authenticate(ctx context.Context, code string) (map[string][]string, error) { + alipayCli, err := alipayclient.NewDefaultAlipayClient(drv.AppId, drv.Secret, "", true) + if err != nil { + return nil, errors.Wrap(err, "alipayclient.NewDefaultAlipayClient") + } + resp, err := alipayCli.GetOAuthToken(ctx, code) + if err != nil { + return nil, errors.Wrap(err, "alipayCli.GetOAuthToken") + } + userInfo, err := alipayCli.GetUserInfo(ctx, resp.AccessToken) + if err != nil { + return nil, errors.Wrap(err, "alipayCli.GetUserInfo") + } + attrs := make(map[string][]string) + for k, v := range userInfo { + attrs[k] = []string{v} + } + attrs["user_name"] = []string{fmt.Sprintf("alipay%s", userInfo["user_id"])} + return attrs, nil +} diff --git a/pkg/keystone/driver/oauth2/alipay/doc.go b/pkg/keystone/driver/oauth2/alipay/doc.go new file mode 100644 index 0000000000..91d467fc60 --- /dev/null +++ b/pkg/keystone/driver/oauth2/alipay/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 alipay // import "yunion.io/x/onecloud/pkg/keystone/driver/oauth2/alipay" diff --git a/pkg/keystone/driver/oauth2/alipay/factory.go b/pkg/keystone/driver/oauth2/alipay/factory.go new file mode 100644 index 0000000000..e720d597c4 --- /dev/null +++ b/pkg/keystone/driver/oauth2/alipay/factory.go @@ -0,0 +1,42 @@ +// 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 alipay + +import ( + api "yunion.io/x/onecloud/pkg/apis/identity" + "yunion.io/x/onecloud/pkg/keystone/driver/oauth2" +) + +type SAlipayDriverFactory struct{} + +func (drv SAlipayDriverFactory) NewDriver(appId string, secret string) oauth2.IOAuth2Driver { + return NewAlipayOAuth2Driver(appId, secret) +} + +func (drv SAlipayDriverFactory) TemplateName() string { + return api.IdpTemplateAlipay +} + +func (drv SAlipayDriverFactory) IdpAttributeOptions() api.SIdpAttributeOptions { + return api.SIdpAttributeOptions{ + UserNameAttribute: "user_name", + UserIdAttribute: "user_id", + UserDisplaynameAttribtue: "nick_name", + } +} + +func init() { + oauth2.Register(&SAlipayDriverFactory{}) +} diff --git a/pkg/keystone/driver/oauth2/class.go b/pkg/keystone/driver/oauth2/class.go new file mode 100644 index 0000000000..22fb9b270f --- /dev/null +++ b/pkg/keystone/driver/oauth2/class.go @@ -0,0 +1,73 @@ +// 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 oauth2 + +import ( + "context" + + api "yunion.io/x/onecloud/pkg/apis/identity" + "yunion.io/x/onecloud/pkg/keystone/driver" + "yunion.io/x/onecloud/pkg/mcclient" +) + +type SOAuth2DriverClass struct{} + +func (self *SOAuth2DriverClass) IsSso() bool { + return true +} + +func (self *SOAuth2DriverClass) ForceSyncUser() bool { + return false +} + +func (self *SOAuth2DriverClass) GetDefaultIconUri(tmpName string) string { + switch tmpName { + case api.IdpTemplateDingtalk: + return "https://img.alicdn.com/tfs/TB13Bxnd3oQMeJjy0FoXXcShVXa-80-80.png" + case api.IdpTemplateFeishu: + return "https://sf1-ttcdn-tos.pstatp.com/obj/suite-public-file-cn/feishu-share-icon.png" + case api.IdpTemplateAlipay: + return "https://gw.alipayobjects.com/mdn/member_frontWeb/afts/img/A*h7o9Q4g2KiUAAAAAAAAAAABkARQnAQ" + case api.IdpTemplateWechat: + return "https://open.weixin.qq.com/zh_CN/htmledition/res/assets/res-design-download/icon64_appwx_logo.png" + case api.IdpTemplateQywechat: + return "http://yunioniso.oss-cn-beijing.aliyuncs.com/icons/qywechat_logo.png" + } + return "https://st.fbk.eu/sites/st.fbk.eu/files/styles/threshold-1382/public/oauth2-logo.jpg" +} + +func (self *SOAuth2DriverClass) SingletonInstance() bool { + return false +} + +func (self *SOAuth2DriverClass) SyncMethod() string { + return api.IdentityProviderSyncOnAuth +} + +func (self *SOAuth2DriverClass) NewDriver(idpId, idpName, template, targetDomainId string, conf api.TConfigs) (driver.IIdentityBackend, error) { + return NewOAuth2Driver(idpId, idpName, template, targetDomainId, conf) +} + +func (self *SOAuth2DriverClass) Name() string { + return api.IdentityDriverOAuth2 +} + +func (self *SOAuth2DriverClass) ValidateConfig(ctx context.Context, userCred mcclient.TokenCredential, tconf api.TConfigs) (api.TConfigs, error) { + return tconf, nil +} + +func init() { + driver.RegisterDriverClass(&SOAuth2DriverClass{}) +} diff --git a/pkg/keystone/driver/oauth2/dingtalk/dingtalk.go b/pkg/keystone/driver/oauth2/dingtalk/dingtalk.go new file mode 100644 index 0000000000..15e95a3f84 --- /dev/null +++ b/pkg/keystone/driver/oauth2/dingtalk/dingtalk.go @@ -0,0 +1,124 @@ +// 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 dingtalk + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + "strconv" + "time" + + "yunion.io/x/jsonutils" + "yunion.io/x/pkg/errors" + + "yunion.io/x/onecloud/pkg/keystone/driver/oauth2" + "yunion.io/x/onecloud/pkg/util/httputils" +) + +type SDingtalkOAuth2Driver struct { + oauth2.SOAuth2BaseDriver +} + +func NewDingtalkOAuth2Driver(appId string, secret string) oauth2.IOAuth2Driver { + drv := &SDingtalkOAuth2Driver{ + SOAuth2BaseDriver: oauth2.SOAuth2BaseDriver{ + AppId: appId, + Secret: secret, + }, + } + return drv +} + +const ( + AuthUrl = "https://oapi.dingtalk.com/connect/qrconnect" +) + +func (drv *SDingtalkOAuth2Driver) GetSsoRedirectUri(ctx context.Context, callbackUrl, state string) (string, error) { + req := map[string]string{ + "appid": drv.AppId, + "response_type": "code", + "scope": "snsapi_login", + "state": state, + "redirect_uri": callbackUrl, + } + urlStr := fmt.Sprintf("%s?%s", AuthUrl, jsonutils.Marshal(req).QueryString()) + return urlStr, nil +} + +const ( + UserInfoUrl = "https://oapi.dingtalk.com/sns/getuserinfo_bycode" + AccessTokenUrl = "https://oapi.dingtalk.com/gettoken" +) + +func (drv *SDingtalkOAuth2Driver) signUrl(timestamp string) string { + mac := hmac.New(sha256.New, []byte(drv.Secret)) + mac.Write([]byte(timestamp)) + sigBytes := mac.Sum(nil) + return base64.StdEncoding.EncodeToString(sigBytes) +} + +type sFetchUserInfoQuery struct { + AccessKey string `json:"accessKey"` + Timestamp string `json:"timestamp"` + Signature string `json:"signature"` +} + +type sFetchUserInfoBody struct { + TmpAuthCode string `json:"tmp_auth_code"` +} + +type sFetchUserInfoData struct { + Nick string `json:"nick"` + Openid string `json:"openid"` + Unionid string `json:"unionid"` +} + +func (drv *SDingtalkOAuth2Driver) fetchUserInfo(ctx context.Context, code string) (*sFetchUserInfoData, error) { + httpclient := httputils.GetDefaultClient() + timestamp := strconv.FormatInt(time.Now().UnixNano()/1000000, 10) // microseconds + qs := sFetchUserInfoQuery{ + AccessKey: drv.AppId, + Timestamp: timestamp, + Signature: drv.signUrl(timestamp), + } + body := sFetchUserInfoBody{ + TmpAuthCode: code, + } + urlstr := fmt.Sprintf("%s?%s", UserInfoUrl, jsonutils.Marshal(qs).QueryString()) + _, resp, err := httputils.JSONRequest(httpclient, ctx, httputils.POST, urlstr, nil, jsonutils.Marshal(body), true) + if err != nil { + return nil, errors.Wrap(err, "request access token") + } + data := sFetchUserInfoData{} + err = resp.Unmarshal(&data, "user_info") + if err != nil { + return nil, errors.Wrap(err, "Unmarshal") + } + return &data, nil +} + +func (drv *SDingtalkOAuth2Driver) Authenticate(ctx context.Context, code string) (map[string][]string, error) { + data, err := drv.fetchUserInfo(ctx, code) + if err != nil { + return nil, errors.Wrap(err, "fetchUserInfo") + } + ret := make(map[string][]string) + ret["name"] = []string{data.Nick} + ret["user_id"] = []string{data.Unionid} + return ret, nil +} diff --git a/pkg/keystone/driver/oauth2/dingtalk/doc.go b/pkg/keystone/driver/oauth2/dingtalk/doc.go new file mode 100644 index 0000000000..9d6c91947d --- /dev/null +++ b/pkg/keystone/driver/oauth2/dingtalk/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 dingtalk // import "yunion.io/x/onecloud/pkg/keystone/driver/oauth2/dingtalk" diff --git a/pkg/keystone/driver/oauth2/dingtalk/factory.go b/pkg/keystone/driver/oauth2/dingtalk/factory.go new file mode 100644 index 0000000000..87655545cb --- /dev/null +++ b/pkg/keystone/driver/oauth2/dingtalk/factory.go @@ -0,0 +1,42 @@ +// 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 dingtalk + +import ( + api "yunion.io/x/onecloud/pkg/apis/identity" + "yunion.io/x/onecloud/pkg/keystone/driver/oauth2" +) + +type SDingtalkDriverFactory struct{} + +func (drv SDingtalkDriverFactory) NewDriver(appId string, secret string) oauth2.IOAuth2Driver { + return NewDingtalkOAuth2Driver(appId, secret) +} + +func (drv SDingtalkDriverFactory) TemplateName() string { + return api.IdpTemplateDingtalk +} + +func (drv SDingtalkDriverFactory) IdpAttributeOptions() api.SIdpAttributeOptions { + return api.SIdpAttributeOptions{ + UserNameAttribute: "name", + UserIdAttribute: "user_id", + UserDisplaynameAttribtue: "name", + } +} + +func init() { + oauth2.Register(&SDingtalkDriverFactory{}) +} diff --git a/pkg/keystone/driver/oauth2/doc.go b/pkg/keystone/driver/oauth2/doc.go new file mode 100644 index 0000000000..34f3b356b5 --- /dev/null +++ b/pkg/keystone/driver/oauth2/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 oauth2 // import "yunion.io/x/onecloud/pkg/keystone/driver/oauth2" diff --git a/pkg/keystone/driver/oauth2/feishu/doc.go b/pkg/keystone/driver/oauth2/feishu/doc.go new file mode 100644 index 0000000000..a108fd794c --- /dev/null +++ b/pkg/keystone/driver/oauth2/feishu/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 feishu // import "yunion.io/x/onecloud/pkg/keystone/driver/oauth2/feishu" diff --git a/pkg/keystone/driver/oauth2/feishu/factory.go b/pkg/keystone/driver/oauth2/feishu/factory.go new file mode 100644 index 0000000000..9b5a8157ff --- /dev/null +++ b/pkg/keystone/driver/oauth2/feishu/factory.go @@ -0,0 +1,44 @@ +// 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 feishu + +import ( + api "yunion.io/x/onecloud/pkg/apis/identity" + "yunion.io/x/onecloud/pkg/keystone/driver/oauth2" +) + +type SFeishuDriverFactory struct{} + +func (drv SFeishuDriverFactory) NewDriver(appId string, secret string) oauth2.IOAuth2Driver { + return NewFeishuOAuth2Driver(appId, secret) +} + +func (drv SFeishuDriverFactory) TemplateName() string { + return api.IdpTemplateFeishu +} + +func (drv SFeishuDriverFactory) IdpAttributeOptions() api.SIdpAttributeOptions { + return api.SIdpAttributeOptions{ + UserNameAttribute: "name_en", + UserIdAttribute: "user_id", + UserDisplaynameAttribtue: "name", + UserEmailAttribute: "email", + UserMobileAttribute: "mobile", + } +} + +func init() { + oauth2.Register(&SFeishuDriverFactory{}) +} diff --git a/pkg/keystone/driver/oauth2/feishu/feishu.go b/pkg/keystone/driver/oauth2/feishu/feishu.go new file mode 100644 index 0000000000..cd138f7441 --- /dev/null +++ b/pkg/keystone/driver/oauth2/feishu/feishu.go @@ -0,0 +1,184 @@ +// 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 feishu + +import ( + "context" + "fmt" + "net/http" + + "yunion.io/x/jsonutils" + "yunion.io/x/pkg/errors" + + "yunion.io/x/onecloud/pkg/keystone/driver/oauth2" + "yunion.io/x/onecloud/pkg/util/httputils" +) + +type SFeishuOAuth2Driver struct { + oauth2.SOAuth2BaseDriver +} + +func NewFeishuOAuth2Driver(appId string, secret string) oauth2.IOAuth2Driver { + drv := &SFeishuOAuth2Driver{ + SOAuth2BaseDriver: oauth2.SOAuth2BaseDriver{ + AppId: appId, + Secret: secret, + }, + } + return drv +} + +const ( + AuthUrl = "https://open.feishu.cn/open-apis/authen/v1/index" +) + +func (drv *SFeishuOAuth2Driver) GetSsoRedirectUri(ctx context.Context, callbackUrl, state string) (string, error) { + req := map[string]string{ + "app_id": drv.AppId, + "state": state, + "redirect_uri": callbackUrl, + } + urlStr := fmt.Sprintf("%s?%s", AuthUrl, jsonutils.Marshal(req).QueryString()) + return urlStr, nil +} + +const ( + AppAccessTokenUrl = "https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal/" + AccessTokenUrl = "https://open.feishu.cn/open-apis/authen/v1/access_token" + UserInfoUrl = "https://open.feishu.cn/open-apis/authen/v1/user_info" +) + +type sAccessTokenInput struct { + AppAccessToken string `json:"app_access_token"` + GrantType string `json:"grant_type"` + Code string `json:"code"` +} + +type sAccessTokenData struct { + AccessToken string `json:"access_token"` + AvatarURL string `json:"avatar_url"` + AvatarThumb string `json:"avatar_thumb"` + AvatarMiddle string `json:"avatar_middle"` + AvatarBig string `json:"avatar_big"` + ExpiresIn int64 `json:"expires_in"` + Name string `json:"name"` + EnName string `json:"en_name"` + OpenID string `json:"open_id"` + TenantKey string `json:"tenant_key"` + RefreshExpiresIn int64 `json:"refresh_expires_in"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` +} + +func fetchAccessToken(ctx context.Context, appAccessToken string, code string) (*sAccessTokenData, error) { + httpclient := httputils.GetDefaultClient() + body := sAccessTokenInput{ + AppAccessToken: appAccessToken, + GrantType: "authorization_code", + Code: code, + } + _, resp, err := httputils.JSONRequest(httpclient, ctx, httputils.POST, AccessTokenUrl, nil, jsonutils.Marshal(body), true) + if err != nil { + return nil, errors.Wrap(err, "request access token") + } + data := sAccessTokenData{} + err = resp.Unmarshal(&data, "data") + if err != nil { + return nil, errors.Wrap(err, "unmarshal") + } + return &data, nil +} + +type sUserInfoData struct { + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` + AvatarThumb string `json:"avatar_thumb"` + AvatarMiddle string `json:"avatar_middle"` + AvatarBig string `json:"avatar_big"` + Email string `json:"email"` + UserID string `json:"user_id"` + Mobile string `json:"mobile"` + Status int64 `json:"status"` +} + +func fetchUserInfo(ctx context.Context, accessToken string) (*sUserInfoData, error) { + httpclient := httputils.GetDefaultClient() + header := http.Header{} + header.Set("Authorization", "Bearer "+accessToken) + _, resp, err := httputils.JSONRequest(httpclient, ctx, httputils.GET, UserInfoUrl, header, nil, true) + if err != nil { + return nil, errors.Wrap(err, "request access token") + } + data := sUserInfoData{} + err = resp.Unmarshal(&data, "data") + if err != nil { + return nil, errors.Wrap(err, "Unmarshal") + } + return &data, nil +} + +type sAppAccessTokenInput struct { + AppID string `json:"app_id"` + AppSecret string `json:"app_secret"` +} + +type sAppAccessTokenData struct { + Code int64 `json:"code"` + Msg string `json:"msg"` + AppAccessToken string `json:"app_access_token"` + Expire int64 `json:"expire"` + TenantAccessToken string `json:"tenant_access_token"` +} + +// https://open.feishu.cn/document/ukTMukTMukTM/uADN14CM0UjLwQTN +func fetchAppAccessToken(ctx context.Context, appId, appSecret string) (*sAppAccessTokenData, error) { + httpclient := httputils.GetDefaultClient() + body := sAppAccessTokenInput{ + AppID: appId, + AppSecret: appSecret, + } + _, resp, err := httputils.JSONRequest(httpclient, ctx, httputils.POST, AppAccessTokenUrl, nil, jsonutils.Marshal(body), true) + if err != nil { + return nil, errors.Wrap(err, "request access token") + } + data := sAppAccessTokenData{} + err = resp.Unmarshal(&data) + if err != nil { + return nil, errors.Wrap(err, "unmarshal") + } + return &data, nil +} + +func (drv *SFeishuOAuth2Driver) Authenticate(ctx context.Context, code string) (map[string][]string, error) { + appData, err := fetchAppAccessToken(ctx, drv.AppId, drv.Secret) + if err != nil { + return nil, errors.Wrap(err, "fetchAppAccessToken") + } + accessData, err := fetchAccessToken(ctx, appData.AppAccessToken, code) + if err != nil { + return nil, errors.Wrap(err, "fetchAccessToken") + } + userInfo, err := fetchUserInfo(ctx, accessData.AccessToken) + if err != nil { + return nil, errors.Wrap(err, "fetchUserInfo") + } + ret := make(map[string][]string) + ret["name"] = []string{userInfo.Name} + ret["user_id"] = []string{userInfo.UserID} + ret["name_en"] = []string{accessData.EnName} + ret["email"] = []string{userInfo.Email} + ret["mobile"] = []string{userInfo.Mobile} + return ret, nil +} diff --git a/pkg/keystone/driver/oauth2/oauth2.go b/pkg/keystone/driver/oauth2/oauth2.go new file mode 100644 index 0000000000..7f1c08b85a --- /dev/null +++ b/pkg/keystone/driver/oauth2/oauth2.go @@ -0,0 +1,137 @@ +// 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 oauth2 + +import ( + "context" + + "yunion.io/x/jsonutils" + "yunion.io/x/log" + "yunion.io/x/pkg/errors" + + api "yunion.io/x/onecloud/pkg/apis/identity" + "yunion.io/x/onecloud/pkg/httperrors" + "yunion.io/x/onecloud/pkg/keystone/driver" + "yunion.io/x/onecloud/pkg/keystone/models" + "yunion.io/x/onecloud/pkg/mcclient" +) + +// OAuth2.0 +type SOAuth2Driver struct { + driver.SBaseIdentityDriver + + oauth2Config *api.SOAuth2IdpConfigOptions + + isDebug bool +} + +func NewOAuth2Driver(idpId, idpName, template, targetDomainId string, conf api.TConfigs) (driver.IIdentityBackend, error) { + base, err := driver.NewBaseIdentityDriver(idpId, idpName, template, targetDomainId, conf) + if err != nil { + return nil, errors.Wrap(err, "NewBaseIdentityDriver") + } + drv := SOAuth2Driver{SBaseIdentityDriver: base} + drv.SetVirtualObject(&drv) + err = drv.prepareConfig() + if err != nil { + return nil, errors.Wrap(err, "prepareConfig") + } + return &drv, nil +} + +func (self *SOAuth2Driver) prepareConfig() error { + if self.oauth2Config == nil { + confJson := jsonutils.Marshal(self.Config[api.IdentityDriverOAuth2]) + conf := api.SOAuth2IdpConfigOptions{} + err := confJson.Unmarshal(&conf) + if err != nil { + return errors.Wrap(err, "json.Unmarshal") + } + log.Debugf("%s %s %#v", self.Config, confJson, self.oauth2Config) + self.oauth2Config = &conf + } + return nil +} + +func (self *SOAuth2Driver) GetSsoRedirectUri(ctx context.Context, callbackUrl, state string) (string, error) { + factory := findDriverFactory(self.Template) + if factory == nil { + return "", errors.Wrapf(httperrors.ErrNotSupported, "template %s not supported", self.Template) + } + driver := factory.NewDriver(self.oauth2Config.AppId, self.oauth2Config.Secret) + return driver.GetSsoRedirectUri(ctx, callbackUrl, state) +} + +func (self *SOAuth2Driver) Authenticate(ctx context.Context, ident mcclient.SAuthenticationIdentity) (*api.SUserExtended, error) { + factory := findDriverFactory(self.Template) + if factory == nil { + return nil, errors.Wrapf(httperrors.ErrNotSupported, "template %s not supported", self.Template) + } + options := factory.IdpAttributeOptions() + driver := factory.NewDriver(self.oauth2Config.AppId, self.oauth2Config.Secret) + attrs, err := driver.Authenticate(ctx, ident.OAuth2.Code) + if err != nil { + return nil, errors.Wrapf(err, "driver %s Authenticate", self.Template) + } + + var usrId, usrName string + if v, ok := attrs[options.UserIdAttribute]; ok && len(v) > 0 { + usrId = v[0] + } + if v, ok := attrs[options.UserNameAttribute]; ok && len(v) > 0 { + usrName = v[0] + } + if len(usrId) == 0 && len(usrName) == 0 { + return nil, errors.Wrap(httperrors.ErrUnauthenticated, "empty userId or userName") + } + if len(usrId) == 0 { + usrId = usrName + } else if len(usrName) == 0 { + usrName = usrId + } + + idp, err := models.IdentityProviderManager.FetchIdentityProviderById(self.IdpId) + if err != nil { + return nil, errors.Wrap(err, "self.GetIdentityProvider") + } + domain, usr, err := idp.SyncOrCreateDomainAndUser(ctx, usrId, usrName) + if err != nil { + return nil, errors.Wrap(err, "idp.SyncOrCreateDomainAndUser") + } + /*domain, err := idp.GetSingleDomain(ctx, api.DefaultRemoteDomainId, self.IdpName, fmt.Sprintf("OpenID Connect/OAuth2.0 provider %s", self.IdpName), false) + if err != nil { + return nil, errors.Wrap(err, "idp.GetSingleDomain") + } + usr, err := idp.SyncOrCreateUser(ctx, usrId, usrName, domain.Id, true, nil) + if err != nil { + return nil, errors.Wrap(err, "idp.SyncOrCreateUser") + }*/ + extUser, err := models.UserManager.FetchUserExtended(usr.Id, "", "", "") + if err != nil { + return nil, errors.Wrap(err, "models.UserManager.FetchUserExtended") + } + + idp.TryUserJoinProject(options, ctx, usr, domain.Id, attrs) + + return extUser, nil +} + +func (self *SOAuth2Driver) Sync(ctx context.Context) error { + return nil +} + +func (self *SOAuth2Driver) Probe(ctx context.Context) error { + return nil +} diff --git a/pkg/keystone/driver/oauth2/qywechat/doc.go b/pkg/keystone/driver/oauth2/qywechat/doc.go new file mode 100644 index 0000000000..eb3799dd13 --- /dev/null +++ b/pkg/keystone/driver/oauth2/qywechat/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 qywechat // import "yunion.io/x/onecloud/pkg/keystone/driver/oauth2/qywechat" diff --git a/pkg/keystone/driver/oauth2/qywechat/factory.go b/pkg/keystone/driver/oauth2/qywechat/factory.go new file mode 100644 index 0000000000..689f86d201 --- /dev/null +++ b/pkg/keystone/driver/oauth2/qywechat/factory.go @@ -0,0 +1,44 @@ +// 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 qywechat + +import ( + api "yunion.io/x/onecloud/pkg/apis/identity" + "yunion.io/x/onecloud/pkg/keystone/driver/oauth2" +) + +type SQywxDriverFactory struct{} + +func (drv SQywxDriverFactory) NewDriver(appId string, secret string) oauth2.IOAuth2Driver { + return NewQywxOAuth2Driver(appId, secret) +} + +func (drv SQywxDriverFactory) TemplateName() string { + return api.IdpTemplateQywechat +} + +func (drv SQywxDriverFactory) IdpAttributeOptions() api.SIdpAttributeOptions { + return api.SIdpAttributeOptions{ + UserNameAttribute: "name", + UserIdAttribute: "user_id", + UserDisplaynameAttribtue: "displayname", + UserEmailAttribute: "email", + UserMobileAttribute: "mobile", + } +} + +func init() { + oauth2.Register(&SQywxDriverFactory{}) +} diff --git a/pkg/keystone/driver/oauth2/qywechat/qywechat.go b/pkg/keystone/driver/oauth2/qywechat/qywechat.go new file mode 100644 index 0000000000..880f04dc03 --- /dev/null +++ b/pkg/keystone/driver/oauth2/qywechat/qywechat.go @@ -0,0 +1,252 @@ +// 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 qywechat + +import ( + "context" + "fmt" + "strings" + + "yunion.io/x/jsonutils" + "yunion.io/x/pkg/errors" + + "yunion.io/x/onecloud/pkg/httperrors" + "yunion.io/x/onecloud/pkg/keystone/driver/oauth2" + "yunion.io/x/onecloud/pkg/util/httputils" +) + +type SQywxOAuth2Driver struct { + oauth2.SOAuth2BaseDriver +} + +func NewQywxOAuth2Driver(appId string, secret string) oauth2.IOAuth2Driver { + drv := &SQywxOAuth2Driver{ + SOAuth2BaseDriver: oauth2.SOAuth2BaseDriver{ + AppId: appId, + Secret: secret, + }, + } + return drv +} + +const ( + AuthUrl = "https://open.work.weixin.qq.com/wwopen/sso/qrConnect" +) + +func splitAppId(appId string) (corpId, agentId string, err error) { + slash := strings.LastIndexByte(appId, '/') + if slash < 0 { + err = errors.Wrap(httperrors.ErrInputParameter, "invalid qywx appid") + return + } else { + corpId = appId[:slash] + agentId = appId[slash+1:] + return + } +} + +func (drv *SQywxOAuth2Driver) GetSsoRedirectUri(ctx context.Context, callbackUrl, state string) (string, error) { + corpId, agentId, err := splitAppId(drv.AppId) + if err != nil { + return "", err + } + req := map[string]string{ + "appid": corpId, + "agentid": agentId, + "redirect_uri": callbackUrl, + "state": state, + } + urlStr := fmt.Sprintf("%s?%s", AuthUrl, jsonutils.Marshal(req).QueryString()) + return urlStr, nil +} + +const ( + AccessTokenUrl = "https://qyapi.weixin.qq.com/cgi-bin/gettoken" + UserIdUrl = "https://qyapi.weixin.qq.com/cgi-bin/user/getuserinfo" + UserInfoUrl = "https://qyapi.weixin.qq.com/cgi-bin/user/get" +) + +type sAccessTokenInput struct { + Corpid string `json:"corpid"` + Corpsecret string `json:"corpsecret"` +} + +type SBaseData struct { + Errcode int `json:"errcode"` + Errmsg string `json:"errmsg"` +} + +type sAccessTokenData struct { + SBaseData + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires_in"` +} + +func (drv *SQywxOAuth2Driver) fetchAccessToken(ctx context.Context) (*sAccessTokenData, error) { + corpId, _, err := splitAppId(drv.AppId) + if err != nil { + return nil, err + } + // ?corpid=ID&corpsecret=SECRET + httpclient := httputils.GetDefaultClient() + qs := sAccessTokenInput{ + Corpid: corpId, + Corpsecret: drv.Secret, + } + urlstr := fmt.Sprintf("%s?%s", AccessTokenUrl, jsonutils.Marshal(qs).QueryString()) + _, resp, err := httputils.JSONRequest(httpclient, ctx, httputils.GET, urlstr, nil, nil, true) + if err != nil { + return nil, errors.Wrap(err, "request access token") + } + data := sAccessTokenData{} + err = resp.Unmarshal(&data) + if err != nil { + return nil, errors.Wrap(err, "unmarshal") + } + return &data, nil +} + +type sUserIdInput struct { + AccessToken string `json:"access_token"` + Code string `json:"code"` +} + +type sUserIdData struct { + SBaseData + UserId string `json:"UserId"` +} + +func fetchUserId(ctx context.Context, accessToken, code string) (*sUserIdData, error) { + httpclient := httputils.GetDefaultClient() + qs := sUserIdInput{ + AccessToken: accessToken, + Code: code, + } + urlStr := fmt.Sprintf("%s?%s", UserIdUrl, jsonutils.Marshal(qs).QueryString()) + _, resp, err := httputils.JSONRequest(httpclient, ctx, httputils.GET, urlStr, nil, nil, true) + if err != nil { + return nil, errors.Wrap(err, "request access token") + } + data := sUserIdData{} + err = resp.Unmarshal(&data) + if err != nil { + return nil, errors.Wrap(err, "Unmarshal") + } + return &data, nil +} + +type sUserInfoInput struct { + AccessToken string `json:"access_token"` + Userid string `json:"userid"` +} + +type sUserInfoData struct { + SBaseData + Userid string `json:"userid"` + Name string `json:"name"` + Department []int64 `json:"department"` + Order []int64 `json:"order"` + Position string `json:"position"` + Mobile string `json:"mobile"` + Gender string `json:"gender"` + Email string `json:"email"` + IsLeaderInDept []int64 `json:"is_leader_in_dept"` + Avatar string `json:"avatar"` + ThumbAvatar string `json:"thumb_avatar"` + Telephone string `json:"telephone"` + Alias string `json:"alias"` + Address string `json:"address"` + OpenUserid string `json:"open_userid"` + MainDepartment int64 `json:"main_department"` + Extattr Extattr `json:"extattr"` + Status int64 `json:"status"` + QrCode string `json:"qr_code"` + ExternalPosition string `json:"external_position"` + ExternalProfile ExternalProfile `json:"external_profile"` +} + +type Extattr struct { + Attrs []Attr `json:"attrs"` +} + +type Attr struct { + Type int64 `json:"type"` + Name string `json:"name"` + Text *Text `json:"text,omitempty"` + Web *Web `json:"web,omitempty"` + Miniprogram *Miniprogram `json:"miniprogram,omitempty"` +} + +type Miniprogram struct { + Appid string `json:"appid"` + Pagepath string `json:"pagepath"` + Title string `json:"title"` +} + +type Text struct { + Value string `json:"value"` +} + +type Web struct { + URL string `json:"url"` + Title string `json:"title"` +} + +type ExternalProfile struct { + ExternalCorpName string `json:"external_corp_name"` + ExternalAttr []Attr `json:"external_attr"` +} + +func fetchUserInfo(ctx context.Context, accessToken, userId string) (*sUserInfoData, error) { + // https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token=ACCESS_TOKEN&userid=USERID + httpclient := httputils.GetDefaultClient() + qs := sUserInfoInput{ + AccessToken: accessToken, + Userid: userId, + } + urlStr := fmt.Sprintf("%s?%s", UserInfoUrl, jsonutils.Marshal(qs).QueryString()) + _, resp, err := httputils.JSONRequest(httpclient, ctx, httputils.GET, urlStr, nil, nil, true) + if err != nil { + return nil, errors.Wrap(err, "request user info") + } + data := sUserInfoData{} + err = resp.Unmarshal(&data) + if err != nil { + return nil, errors.Wrap(err, "Unmarshal") + } + return &data, nil +} + +func (drv *SQywxOAuth2Driver) Authenticate(ctx context.Context, code string) (map[string][]string, error) { + accessData, err := drv.fetchAccessToken(ctx) + if err != nil { + return nil, errors.Wrap(err, "fetchAccessToken") + } + userId, err := fetchUserId(ctx, accessData.AccessToken, code) + if err != nil { + return nil, errors.Wrap(err, "fetchUserId") + } + userInfo, err := fetchUserInfo(ctx, accessData.AccessToken, userId.UserId) + if err != nil { + return nil, errors.Wrap(err, "fetchUserInfo") + } + ret := make(map[string][]string) + ret["name"] = []string{userId.UserId} + ret["user_id"] = []string{userId.UserId} + ret["displayname"] = []string{userInfo.Name} + ret["email"] = []string{userInfo.Email} + ret["mobile"] = []string{userInfo.Mobile} + return ret, nil +} diff --git a/pkg/keystone/driver/oauth2/types.go b/pkg/keystone/driver/oauth2/types.go new file mode 100644 index 0000000000..b878260c7f --- /dev/null +++ b/pkg/keystone/driver/oauth2/types.go @@ -0,0 +1,52 @@ +// 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 oauth2 + +import ( + "context" + + api "yunion.io/x/onecloud/pkg/apis/identity" +) + +type IOAuth2DriverFactory interface { + NewDriver(appId string, secret string) IOAuth2Driver + TemplateName() string + IdpAttributeOptions() api.SIdpAttributeOptions +} + +type IOAuth2Driver interface { + Authenticate(ctx context.Context, code string) (map[string][]string, error) + GetSsoRedirectUri(ctx context.Context, callbackUrl, state string) (string, error) +} + +type SOAuth2BaseDriver struct { + AppId string + Secret string +} + +var ( + oauth2DriverFactories = make(map[string]IOAuth2DriverFactory) +) + +func Register(factory IOAuth2DriverFactory) { + oauth2DriverFactories[factory.TemplateName()] = factory +} + +func findDriverFactory(template string) IOAuth2DriverFactory { + if factory, ok := oauth2DriverFactories[template]; ok { + return factory + } + return nil +} diff --git a/pkg/keystone/driver/oauth2/wechat/doc.go b/pkg/keystone/driver/oauth2/wechat/doc.go new file mode 100644 index 0000000000..42bc503586 --- /dev/null +++ b/pkg/keystone/driver/oauth2/wechat/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 wechat // import "yunion.io/x/onecloud/pkg/keystone/driver/oauth2/wechat" diff --git a/pkg/keystone/driver/oauth2/wechat/factory.go b/pkg/keystone/driver/oauth2/wechat/factory.go new file mode 100644 index 0000000000..b9edba7423 --- /dev/null +++ b/pkg/keystone/driver/oauth2/wechat/factory.go @@ -0,0 +1,42 @@ +// 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 wechat + +import ( + api "yunion.io/x/onecloud/pkg/apis/identity" + "yunion.io/x/onecloud/pkg/keystone/driver/oauth2" +) + +type SWechatDriverFactory struct{} + +func (drv SWechatDriverFactory) NewDriver(appId string, secret string) oauth2.IOAuth2Driver { + return NewWechatOAuth2Driver(appId, secret) +} + +func (drv SWechatDriverFactory) TemplateName() string { + return api.IdpTemplateWechat +} + +func (drv SWechatDriverFactory) IdpAttributeOptions() api.SIdpAttributeOptions { + return api.SIdpAttributeOptions{ + UserNameAttribute: "name", + UserIdAttribute: "user_id", + UserDisplaynameAttribtue: "name", + } +} + +func init() { + oauth2.Register(&SWechatDriverFactory{}) +} diff --git a/pkg/keystone/driver/oauth2/wechat/wechat.go b/pkg/keystone/driver/oauth2/wechat/wechat.go new file mode 100644 index 0000000000..f2167a2ee1 --- /dev/null +++ b/pkg/keystone/driver/oauth2/wechat/wechat.go @@ -0,0 +1,154 @@ +// 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 wechat + +import ( + "context" + "fmt" + + "yunion.io/x/jsonutils" + "yunion.io/x/pkg/errors" + + "yunion.io/x/onecloud/pkg/keystone/driver/oauth2" + "yunion.io/x/onecloud/pkg/util/httputils" +) + +type SWechatOAuth2Driver struct { + oauth2.SOAuth2BaseDriver +} + +func NewWechatOAuth2Driver(appId string, secret string) oauth2.IOAuth2Driver { + drv := &SWechatOAuth2Driver{ + SOAuth2BaseDriver: oauth2.SOAuth2BaseDriver{ + AppId: appId, + Secret: secret, + }, + } + return drv +} + +const ( + AuthUrl = "https://open.weixin.qq.com/connect/qrconnect" +) + +func (drv *SWechatOAuth2Driver) GetSsoRedirectUri(ctx context.Context, callbackUrl, state string) (string, error) { + req := map[string]string{ + "appid": drv.AppId, + "redirect_uri": callbackUrl, + "response_type": "code", + "scope": "snsapi_login", + "state": state, + } + urlStr := fmt.Sprintf("%s?%s#wechat_redirect", AuthUrl, jsonutils.Marshal(req).QueryString()) + return urlStr, nil +} + +const ( + AccessTokenUrl = "https://api.weixin.qq.com/sns/oauth2/access_token" + UserInfoUrl = "https://api.weixin.qq.com/sns/userinfo" +) + +type sAccessTokenInput struct { + Appid string `json:"appid"` + Secret string `json:"secret"` + Code string `json:"code"` + GrantType string `json:"grant_type"` +} + +type sAccessTokenData struct { + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + Openid string `json:"openid"` + Scope string `json:"scope"` + Unionid string `json:"unionid"` +} + +func (drv *SWechatOAuth2Driver) fetchAccessToken(ctx context.Context, code string) (*sAccessTokenData, error) { + // ?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code + httpclient := httputils.GetDefaultClient() + qs := sAccessTokenInput{ + Appid: drv.AppId, + Secret: drv.Secret, + Code: code, + GrantType: "authorization_code", + } + urlstr := fmt.Sprintf("%s?%s", AccessTokenUrl, jsonutils.Marshal(qs).QueryString()) + _, resp, err := httputils.JSONRequest(httpclient, ctx, httputils.GET, urlstr, nil, nil, true) + if err != nil { + return nil, errors.Wrap(err, "request access token") + } + data := sAccessTokenData{} + err = resp.Unmarshal(&data) + if err != nil { + return nil, errors.Wrap(err, "unmarshal") + } + return &data, nil +} + +type sUserInfoInput struct { + AccessToken string `json:"access_token"` + Openid string `json:"openid"` + Lang string `json:"lang"` +} + +type sUserInfoData struct { + Openid string `json:"openid"` + Nickname string `json:"nickname"` + Sex int `json:"sex"` + Language string `json:"language"` + City string `json:"city"` + Province string `json:"province"` + Country string `json:"country"` + Headimgurl string `json:"headimgurl"` + Privilege []string `json:"privilege"` + Unionid string `json:"unionid"` +} + +func fetchUserInfo(ctx context.Context, accessToken, openid string) (*sUserInfoData, error) { + // https://api.weixin.qq.com/sns/userinfo?access_token=ACCESS_TOKEN&openid=OPENID&lang=zh_CN + httpclient := httputils.GetDefaultClient() + qs := sUserInfoInput{ + AccessToken: accessToken, + Openid: openid, + Lang: "zh_CN", + } + urlStr := fmt.Sprintf("%s?%s", UserInfoUrl, jsonutils.Marshal(qs).QueryString()) + _, resp, err := httputils.JSONRequest(httpclient, ctx, httputils.GET, urlStr, nil, nil, true) + if err != nil { + return nil, errors.Wrap(err, "request access token") + } + data := sUserInfoData{} + err = resp.Unmarshal(&data) + if err != nil { + return nil, errors.Wrap(err, "Unmarshal") + } + return &data, nil +} + +func (drv *SWechatOAuth2Driver) Authenticate(ctx context.Context, code string) (map[string][]string, error) { + accessData, err := drv.fetchAccessToken(ctx, code) + if err != nil { + return nil, errors.Wrap(err, "fetchAccessToken") + } + userInfo, err := fetchUserInfo(ctx, accessData.AccessToken, accessData.Openid) + if err != nil { + return nil, errors.Wrap(err, "fetchUserInfo") + } + ret := make(map[string][]string) + ret["name"] = []string{userInfo.Nickname} + ret["user_id"] = []string{userInfo.Openid} + return ret, nil +} diff --git a/pkg/keystone/driver/oidc/class.go b/pkg/keystone/driver/oidc/class.go index 09a5920a53..256c982238 100644 --- a/pkg/keystone/driver/oidc/class.go +++ b/pkg/keystone/driver/oidc/class.go @@ -29,6 +29,26 @@ import ( type SOIDCDriverClass struct{} +func (self *SOIDCDriverClass) IsSso() bool { + return true +} + +func (self *SOIDCDriverClass) ForceSyncUser() bool { + return false +} + +func (self *SOIDCDriverClass) GetDefaultIconUri(tmpName string) string { + switch tmpName { + case api.IdpTemplateDex: + return "https://raw.githubusercontent.com/dexidp/dex/master/Documentation/logos/dex-glyph-color.png" + case api.IdpTemplateGithub: + return "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png" + case api.IdpTemplateAzureOAuth2: + return "https://upload.wikimedia.org/wikipedia/commons/a/a8/Microsoft_Azure_Logo.svg" + } + return "https://openid.net/wordpress-content/uploads/2011/09/OPENID_CONNECT_NEW-Logo-1024x474.jpg" +} + func (self *SOIDCDriverClass) SingletonInstance() bool { return false } @@ -64,6 +84,10 @@ func (self *SOIDCDriverClass) ValidateConfig(ctx context.Context, userCred mccli return tconf, errors.Wrap(err, "ValidateConfig") } nconf := make(map[string]jsonutils.JSONObject) + err = confJson.Unmarshal(&nconf) + if err != nil { + return tconf, errors.Wrap(err, "Unmarshal old config") + } err = jsonutils.Marshal(conf).Unmarshal(&nconf) if err != nil { return tconf, errors.Wrap(err, "Unmarshal new config") diff --git a/pkg/keystone/driver/oidc/oidc.go b/pkg/keystone/driver/oidc/oidc.go index d442d32eac..878f663dfa 100644 --- a/pkg/keystone/driver/oidc/oidc.go +++ b/pkg/keystone/driver/oidc/oidc.go @@ -17,6 +17,7 @@ package oidc import ( "context" "fmt" + "strings" "yunion.io/x/jsonutils" "yunion.io/x/log" @@ -64,6 +65,15 @@ func (self *SOIDCDriver) prepareConfig() error { conf = DexOIDCTemplate case api.IdpTemplateGithub: conf = GithubOIDCTemplate + case api.IdpTemplateAzureOAuth2: + conf = AzureADTemplate + tenantId, _ := confJson.GetString("tenant_id") + if len(tenantId) == 0 { + tenantId = "common" + } + conf.AuthUrl = fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/", tenantId) + conf.TokenUrl = fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", tenantId) + conf.UserinfoUrl = "https://graph.microsoft.com/oidc/userinfo" } err := confJson.Unmarshal(&conf) if err != nil { @@ -87,11 +97,29 @@ func (self *SOIDCDriver) getOIDCClient(ctx context.Context) (*client.SOIDCClient return nil, errors.Wrap(err, "FetchConfiguration") } } else { - cli.SetConfig(self.oidcConfig.AuthUrl, self.oidcConfig.TokenUrl, self.oidcConfig.UserinfoUrl) + cli.SetConfig(self.oidcConfig.AuthUrl, self.oidcConfig.TokenUrl, self.oidcConfig.UserinfoUrl, self.oidcConfig.Scopes) } + log.Debugf("Userinfo url: %s", cli.GetConfig().UserinfoEndpoint) return cli, nil } +func (oidc *SOIDCDriver) GetSsoRedirectUri(ctx context.Context, callbackUrl, state string) (string, error) { + cli, err := oidc.getOIDCClient(ctx) + if err != nil { + return "", errors.Wrap(err, "getOIDCClient") + } + conf := cli.GetConfig() + qs := map[string]string{ + "response_type": "code", + "client_id": oidc.oidcConfig.ClientId, + "redirect_uri": callbackUrl, + "state": state, + "scope": strings.Join(conf.ScopesSupported, " "), + } + urlstr := fmt.Sprintf("%s?%s", conf.AuthorizationEndpoint, jsonutils.Marshal(qs).QueryString()) + return urlstr, nil +} + func (self *SOIDCDriver) Authenticate(ctx context.Context, ident mcclient.SAuthenticationIdentity) (*api.SUserExtended, error) { cli, err := self.getOIDCClient(ctx) if err != nil { @@ -99,7 +127,7 @@ func (self *SOIDCDriver) Authenticate(ctx context.Context, ident mcclient.SAuthe } token, err := cli.FetchToken(ctx, ident.OIDCAuth.Code, ident.OIDCAuth.RedirectUri) if err != nil { - return nil, errors.Wrap(err, "OIDCClient.FetchToken") + return nil, errors.Wrapf(err, "OIDCClient.FetchToken %s", self.oidcConfig.TokenUrl) } userAttrs, err := cli.FetchUserInfo(ctx, token.AccessToken) if err != nil { @@ -133,14 +161,18 @@ func (self *SOIDCDriver) Authenticate(ctx context.Context, ident mcclient.SAuthe if err != nil { return nil, errors.Wrap(err, "self.GetIdentityProvider") } - domain, err := idp.GetSingleDomain(ctx, api.DefaultRemoteDomainId, self.IdpName, fmt.Sprintf("cas provider %s", self.IdpName), false) + domain, usr, err := idp.SyncOrCreateDomainAndUser(ctx, usrId, usrName) + if err != nil { + return nil, errors.Wrap(err, "idp.SyncOrCreateDomainAndUser") + } + /*domain, err := idp.GetSingleDomain(ctx, api.DefaultRemoteDomainId, self.IdpName, fmt.Sprintf("OpenID Connect/OAuth2.0 provider %s", self.IdpName), false) if err != nil { return nil, errors.Wrap(err, "idp.GetSingleDomain") } usr, err := idp.SyncOrCreateUser(ctx, usrId, usrName, domain.Id, true, nil) if err != nil { return nil, errors.Wrap(err, "idp.SyncOrCreateUser") - } + }*/ extUser, err := models.UserManager.FetchUserExtended(usr.Id, "", "", "") if err != nil { return nil, errors.Wrap(err, "models.UserManager.FetchUserExtended") diff --git a/pkg/keystone/driver/oidc/template.go b/pkg/keystone/driver/oidc/template.go index 7137e3f6e7..a68df4243f 100644 --- a/pkg/keystone/driver/oidc/template.go +++ b/pkg/keystone/driver/oidc/template.go @@ -19,6 +19,12 @@ import api "yunion.io/x/onecloud/pkg/apis/identity" var ( // map[at_hash:KgtZpGvTuIaud0SVcmmkKQ aud:example-app email:kilgore@kilgore.trout email_verified:true exp:1593434672 groups:["authors"] iat:1593348272 iss:http://127.0.0.1:5556/dex name:Kilgore Trout sub:Cg0wLTM4NS0yODA4OS0wEgRtb2Nr] DexOIDCTemplate = api.SOIDCIdpConfigOptions{ + Scopes: []string{ + "openid", + "email", + "groups", + "profile", + }, SIdpAttributeOptions: api.SIdpAttributeOptions{ UserNameAttribute: "name", UserIdAttribute: "sub", @@ -46,4 +52,20 @@ var ( UserDisplaynameAttribtue: "name", }, } + + AzureADTemplate = api.SOIDCIdpConfigOptions{ + Scopes: []string{ + "user", + "profile", + "email", + "openid", + }, + TimeoutSecs: 60, + SIdpAttributeOptions: api.SIdpAttributeOptions{ + UserIdAttribute: "sub", + UserNameAttribute: "name", + UserEmailAttribute: "email", + UserDisplaynameAttribtue: "name", + }, + } ) diff --git a/pkg/keystone/driver/saml/class.go b/pkg/keystone/driver/saml/class.go index 4c0167a3d9..086139efa4 100644 --- a/pkg/keystone/driver/saml/class.go +++ b/pkg/keystone/driver/saml/class.go @@ -28,6 +28,22 @@ import ( type SSAMLDriverClass struct{} +func (self *SSAMLDriverClass) IsSso() bool { + return true +} + +func (self *SSAMLDriverClass) GetDefaultIconUri(tmpName string) string { + switch tmpName { + case api.IdpTemplateAzureADSAML: + return "https://upload.wikimedia.org/wikipedia/commons/a/a8/Microsoft_Azure_Logo.svg" + } + return "https://www.oasis-open.org/committees/download.php/29723/draft-saml-logo-03.png" +} + +func (self *SSAMLDriverClass) ForceSyncUser() bool { + return false +} + func (self *SSAMLDriverClass) SingletonInstance() bool { return false } diff --git a/pkg/keystone/driver/saml/saml.go b/pkg/keystone/driver/saml/saml.go index 4829d80de8..1227425464 100644 --- a/pkg/keystone/driver/saml/saml.go +++ b/pkg/keystone/driver/saml/saml.go @@ -16,6 +16,7 @@ package saml import ( "context" + "encoding/base64" "fmt" "yunion.io/x/jsonutils" @@ -28,6 +29,8 @@ import ( "yunion.io/x/onecloud/pkg/keystone/models" "yunion.io/x/onecloud/pkg/keystone/saml" "yunion.io/x/onecloud/pkg/mcclient" + "yunion.io/x/onecloud/pkg/util/samlutils" + "yunion.io/x/onecloud/pkg/util/samlutils/sp" ) // SAML 2.0 Service Provider Driver @@ -76,15 +79,38 @@ func (self *SSAMLDriver) prepareConfig() error { return nil } +func (self *SSAMLDriver) GetSsoRedirectUri(ctx context.Context, callbackUrl, state string) (string, error) { + spLoginFunc := func(ctx context.Context, idp *sp.SSAMLIdentityProvider) (sp.SSAMLSpInitiatedLoginRequest, error) { + result := sp.SSAMLSpInitiatedLoginRequest{} + result.RequestID = samlutils.GenerateSAMLId() + result.RelayState = state + return result, nil + } + spInst := sp.NewSpInstance(saml.SAMLInstance(), self.IdpName, nil, spLoginFunc) + spInst.SetAssertionConsumerUri(callbackUrl) + err := spInst.AddIdp(self.samlConfig.EntityId, self.samlConfig.RedirectSSOUrl) + if err != nil { + return "", errors.Wrap(err, "Invalid SAMLIdentityProvider") + } + input := samlutils.SSpInitiatedLoginInput{ + EntityID: self.samlConfig.EntityId, + } + redir, err := spInst.ProcessSpInitiatedLogin(ctx, input) + if err != nil { + return "", errors.Wrap(err, "ProcessSpInitiatedLogin") + } + return redir, nil +} + func (self *SSAMLDriver) Authenticate(ctx context.Context, ident mcclient.SAuthenticationIdentity) (*api.SUserExtended, error) { - // no need to base64 decode and decrypt, just unmarshal XML - /*_, err := samlutils.ValidateXML(ident.SAMLAuth.Response) + samlRespBytes, err := base64.StdEncoding.DecodeString(ident.SAMLAuth.Response) if err != nil { - return nil, errors.Wrap(httperrors.ErrInputParameter, "ValidateXML fail on SAMLResponse") - }*/ - resp, err := saml.SAMLInstance().UnmarshalResponse([]byte(ident.SAMLAuth.Response)) + return nil, errors.Wrap(err, "base64.StdEncoding.DecodeString") + } + + resp, err := saml.SAMLInstance().UnmarshalResponse(samlRespBytes) if err != nil { - return nil, errors.Wrap(err, "SAMLInstance().UnmarshalResponse") + return nil, errors.Wrap(err, "decode SAMLResponse error") } if !resp.IsSuccess() { @@ -113,14 +139,34 @@ func (self *SSAMLDriver) Authenticate(ctx context.Context, ident mcclient.SAuthe if err != nil { return nil, errors.Wrap(err, "self.GetIdentityProvider") } - domain, err := idp.GetSingleDomain(ctx, api.DefaultRemoteDomainId, self.IdpName, fmt.Sprintf("cas provider %s", self.IdpName), false) + + domain, usr, err := idp.SyncOrCreateDomainAndUser(ctx, usrId, usrName) if err != nil { - return nil, errors.Wrap(err, "idp.GetSingleDomain") - } - usr, err := idp.SyncOrCreateUser(ctx, usrId, usrName, domain.Id, true, nil) - if err != nil { - return nil, errors.Wrap(err, "idp.SyncOrCreateUser") + return nil, errors.Wrap(err, "idp.SyncOrCreateDomainAndUser") } + /*if idp.AutoCreateUser.IsTrue() { + domain, err = idp.GetSingleDomain(ctx, api.DefaultRemoteDomainId, self.IdpName, fmt.Sprintf("SAML 2.0 provider %s", self.IdpName), false) + if err != nil { + return nil, errors.Wrap(err, "idp.GetSingleDomain") + } + usr, err = idp.SyncOrCreateUser(ctx, usrId, usrName, domain.Id, true, nil) + if err != nil { + return nil, errors.Wrap(err, "idp.SyncOrCreateUser") + } + } else { + modelUsrId, err := models.IdmappingManager.FetchByIdpAndEntityId(ctx, idp.Id, usrId, api.IdMappingEntityUser) + if err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil, errors.Wrap(httperrors.ErrUserNotFound, usrId) + } + } + usrObj, err := models.UserManager.FetchById(modelUsrId) + if err != nil { + return nil, errors.Wrap(err, "UserManager.FetchById") + } + usr = usrObj.(*models.SUser) + domain = usr.GetDomain() + }*/ extUser, err := models.UserManager.FetchUserExtended(usr.Id, "", "", "") if err != nil { return nil, errors.Wrap(err, "models.UserManager.FetchUserExtended") diff --git a/pkg/keystone/driver/sql/class.go b/pkg/keystone/driver/sql/class.go index 6f2ce5a629..87d76af780 100644 --- a/pkg/keystone/driver/sql/class.go +++ b/pkg/keystone/driver/sql/class.go @@ -24,6 +24,18 @@ import ( type SSQLDriverClass struct{} +func (self *SSQLDriverClass) IsSso() bool { + return false +} + +func (self *SSQLDriverClass) ForceSyncUser() bool { + return true +} + +func (self *SSQLDriverClass) GetDefaultIconUri(tmpName string) string { + return "" +} + func (self *SSQLDriverClass) SingletonInstance() bool { return true } diff --git a/pkg/keystone/driver/sql/sql.go b/pkg/keystone/driver/sql/sql.go index f419fe32bf..2aac5063bc 100644 --- a/pkg/keystone/driver/sql/sql.go +++ b/pkg/keystone/driver/sql/sql.go @@ -41,6 +41,10 @@ func NewSQLDriver(idpId, idpName, template, targetDomainId string, conf api.TCon return &drv, nil } +func (sql *SSQLDriver) GetSsoRedirectUri(ctx context.Context, callbackUrl, state string) (string, error) { + return "", errors.Wrap(httperrors.ErrNotSupported, "not a SSO driver") +} + func (sql *SSQLDriver) Authenticate(ctx context.Context, ident mcclient.SAuthenticationIdentity) (*api.SUserExtended, error) { usrExt, err := models.UserManager.FetchUserExtended( ident.Password.User.Id, diff --git a/pkg/keystone/models/domains.go b/pkg/keystone/models/domains.go index c799643dcf..65b164fc13 100644 --- a/pkg/keystone/models/domains.go +++ b/pkg/keystone/models/domains.go @@ -434,7 +434,7 @@ func (domain *SDomain) Delete(ctx context.Context, userCred mcclient.TokenCreden } func (domain *SDomain) getIdmapping() (*SIdmapping, error) { - return IdmappingManager.FetchEntity(domain.Id, api.IdMappingEntityDomain) + return IdmappingManager.FetchFirstEntity(domain.Id, api.IdMappingEntityDomain) } func (domain *SDomain) IsReadOnly() bool { diff --git a/pkg/keystone/models/expandidps.go b/pkg/keystone/models/expandidps.go index f414163959..91900733f0 100644 --- a/pkg/keystone/models/expandidps.go +++ b/pkg/keystone/models/expandidps.go @@ -27,26 +27,22 @@ import ( func expandIdpAttributes(entType string, idList []string, fields stringutils2.SSortedStrings) []api.IdpResourceInfo { rows := make([]api.IdpResourceInfo, len(idList)) - for i := range rows { - rows[i] = api.IdpResourceInfo{} - } if len(fields) == 0 || fields.Contains("idp_id") || fields.Contains("idp") || fields.Contains("idp_entity_id") || fields.Contains("idp_driver") { idps, err := fetchIdmappings(idList, entType) if err == nil && idps != nil { for i := range idList { if idp, ok := idps[idList[i]]; ok { if len(fields) == 0 || fields.Contains("idp_id") { - rows[i].IdpId = idp.IdpId + rows[i].IdpId = idp[0].IdpId } if len(fields) == 0 || fields.Contains("idp") { - rows[i].Idp = idp.IdpName + rows[i].Idp = idp[0].Idp } if len(fields) == 0 || fields.Contains("idp_entity_id") { - rows[i].IdpEntityId = idp.EntityId + rows[i].IdpEntityId = idp[0].IdpEntityId } if len(fields) == 0 || fields.Contains("idp_driver") { - rows[i].IdpDriver = idp.Driver - rows[i].IdpDriver = idp.Driver + rows[i].IdpDriver = idp[0].IdpDriver } } } @@ -58,21 +54,18 @@ func expandIdpAttributes(entType string, idList []string, fields stringutils2.SS } type sIdpInfo struct { - IdpId string - IdpName string - EntityId string - Driver string + api.IdpResourceInfo PublicId string } -func fetchIdmappings(idList []string, resType string) (map[string]sIdpInfo, error) { +func fetchIdmappings(idList []string, resType string) (map[string][]sIdpInfo, error) { idmappings := IdmappingManager.Query().SubQuery() idps := IdentityProviderManager.Query().SubQuery() q := idmappings.Query(idmappings.Field("domain_id", "idp_id"), - idmappings.Field("local_id", "entity_id"), - idps.Field("name", "idp_name"), - idps.Field("driver"), + idmappings.Field("local_id", "idp_entity_id"), + idps.Field("name", "idp"), + idps.Field("driver", "idp_driver"), idmappings.Field("public_id"), ) q = q.Join(idps, sqlchemy.Equals(idps.Field("id"), idmappings.Field("domain_id"))) @@ -85,9 +78,13 @@ func fetchIdmappings(idList []string, resType string) (map[string]sIdpInfo, erro return nil, errors.Wrap(err, "query") } - ret := make(map[string]sIdpInfo) + ret := make(map[string][]sIdpInfo) for i := range idpInfos { - ret[idpInfos[i].PublicId] = idpInfos[i] + if idpList, ok := ret[idpInfos[i].PublicId]; ok { + ret[idpInfos[i].PublicId] = append(idpList, idpInfos[i]) + } else { + ret[idpInfos[i].PublicId] = []sIdpInfo{idpInfos[i]} + } } return ret, nil } diff --git a/pkg/keystone/models/groups.go b/pkg/keystone/models/groups.go index 19363d7161..d266b94418 100644 --- a/pkg/keystone/models/groups.go +++ b/pkg/keystone/models/groups.go @@ -302,7 +302,7 @@ func (manager *SGroupManager) NamespaceScope() rbacutils.TRbacScope { } func (group *SGroup) getIdmapping() (*SIdmapping, error) { - return IdmappingManager.FetchEntity(group.Id, api.IdMappingEntityGroup) + return IdmappingManager.FetchFirstEntity(group.Id, api.IdMappingEntityGroup) } func (group *SGroup) IsReadOnly() bool { diff --git a/pkg/keystone/models/id_mappings.go b/pkg/keystone/models/id_mappings.go index 3bfeda2972..88523a9c9c 100644 --- a/pkg/keystone/models/id_mappings.go +++ b/pkg/keystone/models/id_mappings.go @@ -23,6 +23,7 @@ import ( "github.com/golang-plus/uuid" "yunion.io/x/pkg/errors" + "yunion.io/x/sqlchemy" "yunion.io/x/onecloud/pkg/cloudcommon/db" "yunion.io/x/onecloud/pkg/cloudcommon/db/lockman" @@ -61,22 +62,30 @@ func init() { type SIdmapping struct { db.SResourceBase - PublicId string `width:"64" charset:"ascii" nullable:"false" primary:"true"` - IdpId string `name:"domain_id" width:"64" charset:"ascii" nullable:"false" index:"true"` - IdpEntityId string `name:"local_id" width:"128" charset:"utf8" nullable:"false"` + PublicId string `width:"64" charset:"ascii" nullable:"false" primary:"false"` + IdpId string `name:"domain_id" width:"64" charset:"ascii" nullable:"false" primary:"true"` + IdpEntityId string `name:"local_id" width:"128" charset:"utf8" nullable:"false" primary:"true"` EntityType string `width:"10" charset:"ascii" nullable:"false"` } +func getIdmapKey(idpId string, entityId string, entityType string) string { + return fmt.Sprintf("%s-%s-%s", entityType, idpId, entityId) +} + +func filterByIdpAndEntityId(q *sqlchemy.SQuery, idpId string, entityId string, entityType string) *sqlchemy.SQuery { + return q.Equals("domain_id", idpId).Equals("local_id", entityId).Equals("entity_type", entityType) +} + func (manager *SIdmappingManager) RegisterIdMap(ctx context.Context, idpId string, entityId string, entityType string) (string, error) { return manager.RegisterIdMapWithId(ctx, idpId, entityId, entityType, "") } func (manager *SIdmappingManager) RegisterIdMapWithId(ctx context.Context, idpId string, entityId string, entityType string, publicId string) (string, error) { - key := fmt.Sprintf("%s-%s-%s", entityType, idpId, entityId) + key := getIdmapKey(idpId, entityId, entityType) lockman.LockRawObject(ctx, manager.Keyword(), key) defer lockman.ReleaseRawObject(ctx, manager.Keyword(), key) - q := manager.RawQuery().Equals("domain_id", idpId).Equals("local_id", entityId).Equals("entity_type", entityType) + q := filterByIdpAndEntityId(manager.RawQuery(), idpId, entityId, entityType) mapping := SIdmapping{} mapping.SetModelManager(manager, &mapping) @@ -101,7 +110,13 @@ func (manager *SIdmappingManager) RegisterIdMapWithId(ctx context.Context, idpId } } else { if mapping.Deleted { + if len(publicId) == 0 { + u1, _ := uuid.NewV4() + u2, _ := uuid.NewV4() + publicId = u1.Format(uuid.StyleWithoutDash) + u2.Format(uuid.StyleWithoutDash) + } _, err = db.Update(&mapping, func() error { + mapping.PublicId = publicId mapping.Deleted = false mapping.DeletedAt = time.Time{} return nil @@ -115,7 +130,35 @@ func (manager *SIdmappingManager) RegisterIdMapWithId(ctx context.Context, idpId return mapping.PublicId, nil } -func (manager *SIdmappingManager) FetchEntity(idStr string, entType string) (*SIdmapping, error) { +func (manager *SIdmappingManager) FetchByIdpAndEntityId(ctx context.Context, idpId string, entityId string, entityType string) (string, error) { + key := getIdmapKey(idpId, entityId, entityType) + lockman.LockRawObject(ctx, manager.Keyword(), key) + defer lockman.ReleaseRawObject(ctx, manager.Keyword(), key) + + q := filterByIdpAndEntityId(manager.Query(), idpId, entityId, entityType) + + mapping := SIdmapping{} + mapping.SetModelManager(manager, &mapping) + err := q.First(&mapping) + if err != nil { + return "", err + } else { + return mapping.PublicId, nil + } +} + +func (manager *SIdmappingManager) FetchEntities(idStr string, entType string) ([]SIdmapping, error) { + q := manager.Query().Equals("public_id", idStr).Equals("entity_type", entType) + idMaps := make([]SIdmapping, 0) + err := db.FetchModelObjects(manager, q, &idMaps) + if err != nil { + return nil, errors.Wrap(err, "FetchModelObjects") + } else { + return idMaps, nil + } +} + +func (manager *SIdmappingManager) FetchFirstEntity(idStr string, entType string) (*SIdmapping, error) { q := manager.Query().Equals("public_id", idStr).Equals("entity_type", entType) idMap := SIdmapping{} idMap.SetModelManager(manager, &idMap) @@ -177,3 +220,23 @@ func (manager *SIdmappingManager) FetchPublicIdsExcludes(idpId string, entityTyp } return ret, nil } + +func (manager *SIdmappingManager) deleteByPublicId(publicId string, entityType string) error { + idmappings, err := manager.FetchEntities(publicId, entityType) + if err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil + } else { + return errors.Wrap(err, "manager.FetchEntities") + } + } + for i := range idmappings { + _, err = db.Update(&idmappings[i], func() error { + return idmappings[i].MarkDelete() + }) + if err != nil { + return errors.Wrap(err, "markdelete") + } + } + return nil +} diff --git a/pkg/keystone/models/identity_provider.go b/pkg/keystone/models/identity_provider.go index 13cf619373..e081caddd8 100644 --- a/pkg/keystone/models/identity_provider.go +++ b/pkg/keystone/models/identity_provider.go @@ -17,7 +17,9 @@ package models import ( "context" "database/sql" + "encoding/xml" "fmt" + "strings" "time" "yunion.io/x/jsonutils" @@ -37,6 +39,7 @@ import ( "yunion.io/x/onecloud/pkg/keystone/saml" "yunion.io/x/onecloud/pkg/mcclient" "yunion.io/x/onecloud/pkg/util/logclient" + "yunion.io/x/onecloud/pkg/util/samlutils/sp" "yunion.io/x/onecloud/pkg/util/stringutils2" ) @@ -78,23 +81,98 @@ type SIdentityProvider struct { db.SDomainizedResourceBase `default:""` - Driver string `width:"32" charset:"ascii" nullable:"false" list:"admin" create:"admin_required"` - Template string `width:"32" charset:"ascii" nullable:"true" list:"admin" create:"admin_optional"` + Driver string `width:"32" charset:"ascii" nullable:"false" list:"domain" create:"domain_required"` + Template string `width:"32" charset:"ascii" nullable:"true" list:"domain" create:"domain_optional"` - TargetDomainId string `width:"64" charset:"ascii" nullable:"true" list:"admin" create:"admin_optional" update:"admin"` + TargetDomainId string `width:"64" charset:"ascii" nullable:"true" list:"domain" create:"admin_optional"` - AutoCreateProject tristate.TriState `default:"true" nullable:"true" list:"admin" create:"admin_optional" update:"admin"` + // 是否自动创建项目 + AutoCreateProject tristate.TriState `default:"true" nullable:"true" list:"domain" create:"domain_optional" update:"domain"` + // 是否自动创建用户 + AutoCreateUser tristate.TriState `nullable:"true" list:"domain" create:"domain_optional" update:"domain"` - ErrorCount int `list:"admin"` + ErrorCount int `list:"domain"` - SyncStatus string `width:"10" charset:"ascii" default:"idle" list:"admin"` - LastSync time.Time `list:"admin"` // = Column(DateTime, nullable=True) - LastSyncEndAt time.Time `list:"admin"` + SyncStatus string `width:"10" charset:"ascii" default:"idle" list:"domain"` + LastSync time.Time `list:"domain"` // = Column(DateTime, nullable=True) + LastSyncEndAt time.Time `list:"domain"` - SyncIntervalSeconds int `create:"admin_optional" update:"admin"` + SyncIntervalSeconds int `create:"domain_optional" update:"domain"` + + // 认证源图标 + IconUri string `width:"256" charset:"utf8" nullable:"true" list:"user" create:"domain_optional" update:"domain"` + // 是否是SSO登录方式 + IsSso tristate.TriState `nullable:"true" list:"domain"` +} + +func (manager *SIdentityProviderManager) initializeAutoCreateUser() error { + q := manager.Query().IsNull("auto_create_user") + idps := make([]SIdentityProvider, 0) + err := db.FetchModelObjects(manager, q, &idps) + if err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil + } else { + return errors.Wrap(err, "FetchModelObjeccts") + } + } + for i := range idps { + drvCls := idps[i].getDriverClass() + _, err := db.Update(&idps[i], func() error { + if drvCls.ForceSyncUser() { + idps[i].AutoCreateUser = tristate.True + } else { + idps[i].AutoCreateUser = tristate.False + } + return nil + }) + if err != nil { + return errors.Wrap(err, "update auto_create_user") + } + } + return nil +} + +func (manager *SIdentityProviderManager) initializeIcon() error { + q := manager.Query().IsNull("is_sso") + idps := make([]SIdentityProvider, 0) + err := db.FetchModelObjects(manager, q, &idps) + if err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil + } else { + return errors.Wrap(err, "FetchModelObjeccts") + } + } + for i := range idps { + drvCls := idps[i].getDriverClass() + _, err := db.Update(&idps[i], func() error { + if drvCls.IsSso() { + idps[i].IsSso = tristate.True + idps[i].IconUri = drvCls.GetDefaultIconUri(idps[i].Template) + } else { + idps[i].IsSso = tristate.False + idps[i].IconUri = drvCls.GetDefaultIconUri(idps[i].Template) + } + return nil + }) + if err != nil { + return errors.Wrap(err, "update is_sso") + } + } + return nil } func (manager *SIdentityProviderManager) InitializeData() error { + err := manager.initializeAutoCreateUser() + if err != nil { + return errors.Wrap(err, "initializeAutoCreateUser") + } + err = manager.initializeIcon() + if err != nil { + return errors.Wrap(err, "initializeIcon") + } + cnt, err := manager.Query().CountWithError() if err != nil { return errors.Wrap(err, "CountWithError") @@ -113,6 +191,10 @@ func (manager *SIdentityProviderManager) InitializeData() error { sqldrv.Status = api.IdentityDriverStatusConnected sqldrv.Driver = api.IdentityDriverSQL sqldrv.Description = "Default sql identity provider" + sqldrv.AutoCreateUser = tristate.True + sqldrv.AutoCreateProject = tristate.False + sqldrv.IsSso = tristate.False + sqldrv.IconUri = "" err = manager.TableSpec().Insert(context.TODO(), &sqldrv) if err != nil { return errors.Wrap(err, "insert default sql driver") @@ -316,7 +398,7 @@ func (manager *SIdentityProviderManager) ValidateCreateData( } } - targetDomainStr := input.TargetDomain + targetDomainStr := input.TargetDomainId if len(targetDomainStr) > 0 { domain, err := DomainManager.FetchDomainByIdOrName(targetDomainStr) if err != nil { @@ -326,7 +408,7 @@ func (manager *SIdentityProviderManager) ValidateCreateData( return input, httperrors.NewGeneralError(err) } } - input.TargetDomain = domain.Id + input.TargetDomainId = domain.Id if domain.Id != ownerId.GetProjectDomainId() && !db.IsAdminAllowCreate(userCred, manager) { return input, errors.Wrap(httperrors.ErrNotSufficientPrivilege, "require system priviliges") @@ -353,6 +435,18 @@ func (ident *SIdentityProvider) CustomizeCreate(ctx context.Context, userCred mc ident.DomainId = ownerId.GetProjectDomainId() ident.TargetDomainId = ownerId.GetProjectDomainId() } + drvCls := ident.getDriverClass() + if drvCls.IsSso() { + ident.IsSso = tristate.True + } else { + ident.IsSso = tristate.False + } + if len(ident.IconUri) == 0 { + ident.IconUri = drvCls.GetDefaultIconUri(ident.Template) + } + if drvCls.ForceSyncUser() { + ident.AutoCreateUser = tristate.True + } return ident.SEnabledStatusStandaloneResourceBase.CustomizeCreate(ctx, userCred, ownerId, query, data) } @@ -373,6 +467,23 @@ func (ident *SIdentityProvider) PostCreate(ctx context.Context, userCred mcclien return } + if len(ident.TargetDomainId) == 0 && ident.AutoCreateUser.IsTrue() && ident.IsSso.IsTrue() { + // SSO driver need to create the target domain immediately + domain, err := ident.SyncOrCreateDomain(ctx, api.DefaultRemoteDomainId, ident.Name, fmt.Sprintf("%s provider %s", ident.Driver, ident.Name), false) + if err != nil { + log.Errorf("create domain fail %s", err) + } else { + // save domain_id into target_domain_id + _, err := db.Update(ident, func() error { + ident.TargetDomainId = domain.Id + return nil + }) + if err != nil { + log.Errorf("save target_domain_id fail: %s", err) + } + } + } + submitIdpSyncTask(ctx, userCred, ident) return } @@ -975,6 +1086,35 @@ func (manager *SIdentityProviderManager) ListItemFilter( if len(query.SyncStatus) > 0 { q = q.In("sync_status", query.SyncStatus) } + if len(query.SsoDomain) > 0 { + q = q.IsTrue("is_sso") + if strings.EqualFold(query.SsoDomain, "all") { + q = q.IsNullOrEmpty("domain_id") + } else if len(query.SsoDomain) > 0 { + q = q.Filter(sqlchemy.OR( + sqlchemy.IsNullOrEmpty(q.Field("domain_id")), + sqlchemy.Equals(q.Field("domain_id"), query.SsoDomain), + )) + q = q.Filter(sqlchemy.OR( + sqlchemy.IsNullOrEmpty(q.Field("target_domain_id")), + sqlchemy.Equals(q.Field("target_domain_id"), query.SsoDomain), + )) + } + } + if query.AutoCreateProject != nil { + if *query.AutoCreateProject { + q = q.IsTrue("auto_create_project") + } else { + q = q.IsFalse("auto_create_project") + } + } + if query.AutoCreateUser != nil { + if *query.AutoCreateUser { + q = q.IsTrue("auto_create_user") + } else { + q = q.IsFalse("auto_create_user") + } + } return q, nil } @@ -1106,24 +1246,91 @@ func (idp *SIdentityProvider) TryUserJoinProject(attrConf api.SIdpAttributeOptio } func (idp *SIdentityProvider) AllowGetDetailsSamlMetadata(ctx context.Context, userCred mcclient.TokenCredential, query api.GetIdpSamlMetadataInput) bool { - return db.IsAdminAllowGetSpec(userCred, idp, "saml-metadata") + return db.IsDomainAllowGetSpec(userCred, idp, "saml-metadata") } -func (idp *SIdentityProvider) GetDetailsSamlMetadata(ctx context.Context, userCred mcclient.TokenCredential, query api.GetIdpSamlMetadataInput) (jsonutils.JSONObject, error) { +func (idp *SIdentityProvider) GetDetailsSamlMetadata(ctx context.Context, userCred mcclient.TokenCredential, query api.GetIdpSamlMetadataInput) (api.GetIdpSamlMetadataOutput, error) { + output := api.GetIdpSamlMetadataOutput{} if !saml.IsSAMLEnabled() { - return nil, errors.Wrap(httperrors.ErrNotSupported, "enable SSL first") + return output, errors.Wrap(httperrors.ErrNotSupported, "enable SSL first") } if idp.Driver != api.IdentityDriverSAML { - return nil, errors.Wrap(httperrors.ErrNotSupported, "not a saml IDP") + return output, errors.Wrap(httperrors.ErrNotSupported, "not a saml IDP") } - pretty := false + if len(query.RedirectUri) == 0 { + return output, errors.Wrap(httperrors.ErrInputParameter, "missing redirect_uri") + } + + spInst := sp.NewSpInstance(saml.SAMLInstance(), idp.Name, nil, nil) + spInst.SetAssertionConsumerUri(query.RedirectUri) + ed := spInst.GetMetadata() + var xmlBytes []byte if query.Pretty != nil && *query.Pretty { - pretty = true + xmlBytes, _ = xml.MarshalIndent(ed, "", " ") + } else { + xmlBytes, _ = xml.Marshal(ed) } - md := struct { - Metadata string `json:"metadata"` - }{ - Metadata: saml.GetMetadata(idp.Name, pretty), + output.Metadata = string(xmlBytes) + return output, nil +} + +func (idp *SIdentityProvider) AllowGetDetailsSsoRedirectUri(ctx context.Context, userCred mcclient.TokenCredential, query api.GetIdpSsoRedirectUriInput) bool { + return db.IsDomainAllowGetSpec(userCred, idp, "sso-redirect-uri") +} + +func (idp *SIdentityProvider) GetDetailsSsoRedirectUri(ctx context.Context, userCred mcclient.TokenCredential, query api.GetIdpSsoRedirectUriInput) (api.GetIdpSsoRedirectUriOutput, error) { + output := api.GetIdpSsoRedirectUriOutput{} + conf, err := GetConfigs(idp, true, nil, nil) + if err != nil { + return output, errors.Wrap(err, "idp.GetConfig") } - return jsonutils.Marshal(md), nil + + backend, err := driver.GetDriver(idp.Driver, idp.Id, idp.Name, idp.Template, idp.TargetDomainId, conf) + if err != nil { + return output, errors.Wrap(err, "driver.GetDriver") + } + + uri, err := backend.GetSsoRedirectUri(ctx, query.RedirectUri, query.State) + if err != nil { + return output, errors.Wrap(err, "backend.GetSsoRedirectUri") + } + + output.Uri = uri + output.Driver = idp.Driver + + return output, nil +} + +func (idp *SIdentityProvider) SyncOrCreateDomainAndUser(ctx context.Context, extUsrId, extUsrName string) (*SDomain, *SUser, error) { + var ( + domain *SDomain + usr *SUser + err error + ) + if idp.AutoCreateUser.IsTrue() { + domain, err = idp.GetSingleDomain(ctx, api.DefaultRemoteDomainId, idp.Name, fmt.Sprintf("%s provider %s", idp.Driver, idp.Name), false) + if err != nil { + return nil, nil, errors.Wrap(err, "idp.GetSingleDomain") + } + usr, err = idp.SyncOrCreateUser(ctx, extUsrId, extUsrName, domain.Id, true, nil) + if err != nil { + return nil, nil, errors.Wrap(err, "idp.SyncOrCreateUser") + } + } else { + modelUsrId, err := IdmappingManager.FetchByIdpAndEntityId(ctx, idp.Id, extUsrId, api.IdMappingEntityUser) + if err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil, nil, errors.Wrap(httperrors.ErrUserNotFound, extUsrId) + } else { + return nil, nil, errors.Wrap(err, "IdmappingManager.FetchByIdpAndEntityId") + } + } + usrObj, err := UserManager.FetchById(modelUsrId) + if err != nil { + return nil, nil, errors.Wrap(err, "UserManager.FetchById") + } + usr = usrObj.(*SUser) + domain = usr.GetDomain() + } + return domain, usr, nil } diff --git a/pkg/keystone/models/users.go b/pkg/keystone/models/users.go index a5e6f17e47..23254b1261 100644 --- a/pkg/keystone/models/users.go +++ b/pkg/keystone/models/users.go @@ -447,7 +447,13 @@ func (manager *SUserManager) FilterByHiddenSystemAttributes(q *sqlchemy.SQuery, return q } -func (manager *SUserManager) ValidateCreateData(ctx context.Context, userCred mcclient.TokenCredential, ownerId mcclient.IIdentityProvider, query jsonutils.JSONObject, input api.UserCreateInput) (api.UserCreateInput, error) { +func (manager *SUserManager) ValidateCreateData( + ctx context.Context, + userCred mcclient.TokenCredential, + ownerId mcclient.IIdentityProvider, + query jsonutils.JSONObject, + input api.UserCreateInput, +) (api.UserCreateInput, error) { var err error if len(input.Password) > 0 && (input.SkipPasswordComplexityCheck == nil || !*input.SkipPasswordComplexityCheck) { err = validatePasswordComplexity(input.Password) @@ -460,6 +466,17 @@ func (manager *SUserManager) ValidateCreateData(ctx context.Context, userCred mc return input, errors.Wrap(err, "SEnabledIdentityBaseResourceManager.ValidateCreateData") } + if len(input.IdpId) > 0 { + _, err := IdentityProviderManager.FetchIdentityProviderById(input.IdpId) + if err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return input, errors.Wrapf(httperrors.ErrResourceNotFound, "%s %s", IdentityProviderManager.Keyword(), input.IdpId) + } else { + return input, errors.Wrap(err, "IdentityProviderManager.FetchIdentityProviderById") + } + } + } + quota := SIdentityQuota{ SBaseDomainQuotaKeys: quotas.SBaseDomainQuotaKeys{DomainId: ownerId.GetProjectDomainId()}, User: 1, @@ -571,10 +588,22 @@ func (manager *SUserManager) FetchCustomizeColumns( rows[i] = userExtra(objs[i].(*SUser), rows[i]) } - idpRows := expandIdpAttributes(api.IdMappingEntityUser, userIds, fields) + idpsMaps, err := fetchIdmappings(userIds, api.IdMappingEntityUser) + if err != nil { + log.Errorf("fetchIdmappings fail %s", err) + return rows + } for i := range rows { - rows[i].IdpResourceInfo = idpRows[i] + if idps, ok := idpsMaps[userIds[i]]; ok { + if len(idps) > 0 { + // rows[i].IdpResourceInfo = idps[0].IdpResourceInfo + rows[i].Idps = make([]api.IdpResourceInfo, len(idps)) + for j := range idps { + rows[i].Idps[j] = idps[j].IdpResourceInfo + } + } + } } return rows @@ -636,6 +665,7 @@ func (user *SUser) initLocalData(passwd string) error { func (user *SUser) PostCreate(ctx context.Context, userCred mcclient.TokenCredential, ownerId mcclient.IIdentityProvider, query jsonutils.JSONObject, data jsonutils.JSONObject) { user.SEnabledIdentityBaseResource.PostCreate(ctx, userCred, ownerId, query, data) + // set password passwd, _ := data.GetString("password") err := user.initLocalData(passwd) if err != nil { @@ -643,6 +673,19 @@ func (user *SUser) PostCreate(ctx context.Context, userCred mcclient.TokenCreden return } + // link idp + idpId, _ := data.GetString("idp_id") + if len(idpId) > 0 { + idpEntityId, _ := data.GetString("idp_entity_id") + if len(idpEntityId) > 0 { + _, err := IdmappingManager.RegisterIdMapWithId(ctx, idpId, idpEntityId, api.IdMappingEntityUser, user.Id) + if err != nil { + log.Errorf("IdmappingManager.RegisterIdMapWithId fail %s", err) + } + } + } + + // clean user quota pendingUsage := &SIdentityQuota{ SBaseDomainQuotaKeys: quotas.SBaseDomainQuotaKeys{DomainId: ownerId.GetProjectDomainId()}, User: 1, @@ -709,6 +752,11 @@ func (user *SUser) Delete(ctx context.Context, userCred mcclient.TokenCredential } } + err = IdmappingManager.deleteByPublicId(user.Id, api.IdMappingEntityUser) + if err != nil { + return errors.Wrap(err, "IdmappingManager.deleteByPublicId") + } + return user.SEnabledIdentityBaseResource.Delete(ctx, userCred) } @@ -816,22 +864,27 @@ func (manager *SUserManager) NamespaceScope() rbacutils.TRbacScope { return rbacutils.ScopeDomain } -func (user *SUser) getIdmapping() (*SIdmapping, error) { - return IdmappingManager.FetchEntity(user.Id, api.IdMappingEntityUser) +func (user *SUser) getIdmappings() ([]SIdmapping, error) { + return IdmappingManager.FetchEntities(user.Id, api.IdMappingEntityUser) } func (user *SUser) IsReadOnly() bool { - idmap, _ := user.getIdmapping() - if idmap != nil { - return true + idmaps, _ := user.getIdmappings() + for i := range idmaps { + idp, _ := IdentityProviderManager.FetchIdentityProviderById(idmaps[i].IdpId) + if idp != nil && idp.Driver == api.IdentityDriverLDAP { + return true + } } return false } func (user *SUser) LinkedWithIdp(idpId string) bool { - idmap, _ := user.getIdmapping() - if idmap != nil && idmap.IdpId == idpId { - return true + idmaps, _ := user.getIdmappings() + for i := range idmaps { + if idmaps[i].IdpId == idpId { + return true + } } return false } @@ -1019,3 +1072,63 @@ func (user *SUser) GetUsages() []db.IUsage { &usage, } } + +func (user *SUser) AllowPerformLinkIdp( + ctx context.Context, + userCred mcclient.TokenCredential, + query jsonutils.JSONObject, + input api.UserLinkIdpInput, +) bool { + return db.IsAdminAllowPerform(userCred, user, "link-idp") +} + +// 用户和IDP的指定entityId关联 +func (user *SUser) PerformLinkIdp( + ctx context.Context, + userCred mcclient.TokenCredential, + query jsonutils.JSONObject, + input api.UserLinkIdpInput, +) (jsonutils.JSONObject, error) { + idp, err := IdentityProviderManager.FetchIdentityProviderById(input.IdpId) + if err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil, errors.Wrapf(httperrors.ErrResourceNotFound, "%s %s", IdentityProviderManager.Keyword(), input.IdpId) + } else { + return nil, errors.Wrap(err, "IdentityProviderManager.FetchIdentityProviderById") + } + } + // check accessibility + if (len(idp.DomainId) > 0 && idp.DomainId != user.DomainId) || (len(idp.TargetDomainId) > 0 && idp.TargetDomainId != user.DomainId) { + return nil, errors.Wrap(httperrors.ErrForbidden, "identity domain not accessible") + } else if len(idp.DomainId) == 0 && len(idp.TargetDomainId) == 0 && idp.AutoCreateUser.IsTrue() { + + } + _, err = IdmappingManager.RegisterIdMapWithId(ctx, input.IdpId, input.IdpEntityId, api.IdMappingEntityUser, user.Id) + if err != nil { + return nil, errors.Wrap(err, "IdmappingManager.RegisterIdMapWithId") + } + return nil, nil +} + +func (user *SUser) AllowPerformUnlinkIdp( + ctx context.Context, + userCred mcclient.TokenCredential, + query jsonutils.JSONObject, + input api.UserUnlinkIdpInput, +) bool { + return db.IsAdminAllowPerform(userCred, user, "unlink-idp") +} + +// 用户和IDP的指定entityId解除关联 +func (user *SUser) PerformUnlinkIdp( + ctx context.Context, + userCred mcclient.TokenCredential, + query jsonutils.JSONObject, + input api.UserUnlinkIdpInput, +) (jsonutils.JSONObject, error) { + err := IdmappingManager.deleteAny(input.IdpId, input.IdpEntityId, user.Id) + if err != nil { + return nil, errors.Wrap(err, "IdmappingManager.deleteAny") + } + return nil, nil +} diff --git a/pkg/keystone/service/drivers.go b/pkg/keystone/service/drivers.go index 8f9a9f7ca1..06d58e055b 100644 --- a/pkg/keystone/service/drivers.go +++ b/pkg/keystone/service/drivers.go @@ -17,6 +17,12 @@ package service import ( _ "yunion.io/x/onecloud/pkg/keystone/driver/cas" _ "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/dingtalk" + _ "yunion.io/x/onecloud/pkg/keystone/driver/oauth2/feishu" + _ "yunion.io/x/onecloud/pkg/keystone/driver/oauth2/qywechat" + _ "yunion.io/x/onecloud/pkg/keystone/driver/oauth2/wechat" _ "yunion.io/x/onecloud/pkg/keystone/driver/oidc" _ "yunion.io/x/onecloud/pkg/keystone/driver/saml" _ "yunion.io/x/onecloud/pkg/keystone/driver/sql" diff --git a/pkg/keystone/tokens/auth.go b/pkg/keystone/tokens/auth.go index 2493b8c761..757d512c20 100644 --- a/pkg/keystone/tokens/auth.go +++ b/pkg/keystone/tokens/auth.go @@ -17,8 +17,6 @@ package tokens import ( "context" "database/sql" - "encoding/base64" - "encoding/xml" "fmt" "time" @@ -105,10 +103,21 @@ func authUserByIdentity(ctx context.Context, ident mcclient.SAuthenticationIdent return nil, errors.Wrap(err, "Query user") } ident.Password.User.Domain.Id = usr.DomainId - idmap, err := models.IdmappingManager.FetchEntity(usr.Id, api.IdMappingEntityUser) + idmaps, err := models.IdmappingManager.FetchEntities(usr.Id, api.IdMappingEntityUser) if err != nil && err != sql.ErrNoRows { return nil, errors.Wrap(err, "IdmappingManager.FetchEntity") } + var idmap *models.SIdmapping + for i := range idmaps { + idp, err := models.IdentityProviderManager.FetchIdentityProviderById(idmaps[i].IdpId) + if err != nil { + return nil, errors.Wrap(err, "IdentityProviderManager.FetchIdentityProviderById") + } + if idp.Driver == api.IdentityDriverLDAP { + idmap = &idmaps[i] + break + } + } if idmap == nil { // sql idpId = api.DEFAULT_IDP_ID } else { @@ -128,7 +137,7 @@ func authUserByIdentity(ctx context.Context, ident mcclient.SAuthenticationIdent if err != nil { return nil, errors.Wrap(err, "DomainManager.FetchDomain") } - mapping, err := models.IdmappingManager.FetchEntity(domain.Id, api.IdMappingEntityDomain) + mapping, err := models.IdmappingManager.FetchFirstEntity(domain.Id, api.IdMappingEntityDomain) if err != nil { return nil, errors.Wrap(err, "IdmappingManager.FetchEntity") } @@ -176,17 +185,31 @@ func authUserByIdentity(ctx context.Context, ident mcclient.SAuthenticationIdent } func authUserByCASV3(ctx context.Context, input mcclient.SAuthenticationInputV3) (*api.SUserExtended, error) { - idps, err := models.IdentityProviderManager.FetchEnabledProviders(api.IdentityDriverCAS) - if err != nil { - return nil, errors.Wrap(err, "models.fetchEnabledProviders") + var idp *models.SIdentityProvider + var err error + if len(input.Auth.Identity.Id) > 0 { + idp, err = models.IdentityProviderManager.FetchIdentityProviderById(input.Auth.Identity.Id) + if err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil, errors.Wrapf(httperrors.ErrResourceNotFound, "idp %s not found", input.Auth.Identity.Id) + } else { + return nil, errors.Wrap(err, "FetchIdentityProviderById") + } + } + } else { + idps, err := models.IdentityProviderManager.FetchEnabledProviders(api.IdentityDriverCAS) + if err != nil { + return nil, errors.Wrap(err, "models.fetchEnabledProviders") + } + if len(idps) == 0 { + return nil, errors.Error("No cas identity provider") + } + if len(idps) > 1 { + return nil, errors.Error("more than 1 cas identity providers?") + } + idp = &idps[0] } - if len(idps) == 0 { - return nil, errors.Error("No cas identity provider") - } - if len(idps) > 1 { - return nil, errors.Error("more than 1 cas identity providers?") - } - idp := &idps[0] + conf, err := models.GetConfigs(idp, true, nil, nil) if err != nil { return nil, errors.Wrap(err, "idp.GetConfig") @@ -214,37 +237,15 @@ func authUserBySAML(ctx context.Context, input mcclient.SAuthenticationInputV3) return nil, errors.Wrap(httperrors.ErrNotSupported, "unsupported SAML backend") } - idps, err := models.IdentityProviderManager.FetchEnabledProviders(api.IdentityDriverSAML) + idp, err := models.IdentityProviderManager.FetchIdentityProviderById(input.Auth.Identity.Id) if err != nil { - return nil, errors.Wrap(err, "models.fetchEnabledProviders") - } - - samlRespBytes, err := base64.StdEncoding.DecodeString(input.Auth.Identity.SAMLAuth.Response) - if err != nil { - return nil, errors.Wrap(err, "base64.StdEncoding.DecodeString") - } - - resp, err := saml.SAMLInstance().UnmarshalResponse(samlRespBytes) - if err != nil { - return nil, errors.Wrapf(httperrors.ErrInputParameter, "decode SAMLResponse error: %s", err) - } - - var idp *models.SIdentityProvider - for i := range idps { - conf, _ := models.GetConfigs(&idps[i], true, nil, nil) - if conf != nil && conf["saml"] != nil && conf["saml"]["entity_id"] != nil { - entityId, _ := conf["saml"]["entity_id"].GetString() - if entityId == resp.Issuer.Issuer { - idp = &idps[i] - break - } + if errors.Cause(err) == sql.ErrNoRows { + return nil, errors.Wrapf(httperrors.ErrResourceNotFound, "idp %s not found", input.Auth.Identity.Id) + } else { + return nil, errors.Wrap(err, "FetchIdentityProviderById") } } - if idp == nil { - return nil, errors.Wrap(httperrors.ErrResourceNotFound, "No matched saml identity provider") - } - conf, err := models.GetConfigs(idp, true, nil, nil) if err != nil { return nil, errors.Wrap(err, "idp.GetConfig") @@ -255,13 +256,6 @@ func authUserBySAML(ctx context.Context, input mcclient.SAuthenticationInputV3) return nil, errors.Wrap(err, "driver.GetDriver") } - respXml, err := xml.Marshal(resp) - if err != nil { - return nil, errors.Wrap(err, "xml.Marshal SAML response") - } - // save to the input - input.Auth.Identity.SAMLAuth.Response = string(respXml) - usr, err := backend.Authenticate(ctx, input.Auth.Identity) if err != nil { return nil, errors.Wrap(err, "Authenticate") @@ -275,25 +269,45 @@ func authUserBySAML(ctx context.Context, input mcclient.SAuthenticationInputV3) } func authUserByOIDC(ctx context.Context, input mcclient.SAuthenticationInputV3) (*api.SUserExtended, error) { - idps, err := models.IdentityProviderManager.FetchEnabledProviders(api.IdentityDriverOIDC) + idp, err := models.IdentityProviderManager.FetchIdentityProviderById(input.Auth.Identity.Id) if err != nil { - return nil, errors.Wrap(err, "models.fetchEnabledProviders") - } - - var idp *models.SIdentityProvider - for i := range idps { - conf, _ := models.GetConfigs(&idps[i], true, nil, nil) - if conf != nil && conf["oidc"] != nil && conf["oidc"]["client_id"] != nil { - clientId, _ := conf["oidc"]["client_id"].GetString() - if clientId == input.Auth.Identity.OIDCAuth.ClientId { - idp = &idps[i] - break - } + if errors.Cause(err) == sql.ErrNoRows { + return nil, errors.Wrapf(httperrors.ErrResourceNotFound, "idp %s not found", input.Auth.Identity.Id) + } else { + return nil, errors.Wrap(err, "FetchIdentityProviderById") } } - if idp == nil { - return nil, errors.Wrap(httperrors.ErrResourceNotFound, "No matched oidc identity provider") + conf, err := models.GetConfigs(idp, true, nil, nil) + if err != nil { + return nil, errors.Wrap(err, "idp.GetConfig") + } + + backend, err := driver.GetDriver(idp.Driver, idp.Id, idp.Name, idp.Template, idp.TargetDomainId, conf) + if err != nil { + return nil, errors.Wrap(err, "driver.GetDriver") + } + + usr, err := backend.Authenticate(ctx, input.Auth.Identity) + if err != nil { + return nil, errors.Wrap(err, "Authenticate") + } + + if idp.Status == api.IdentityDriverStatusDisconnected { + idp.MarkConnected(ctx, models.GetDefaultAdminCred()) + } + + return usr, nil +} + +func authUserByOAuth2(ctx context.Context, input mcclient.SAuthenticationInputV3) (*api.SUserExtended, error) { + idp, err := models.IdentityProviderManager.FetchIdentityProviderById(input.Auth.Identity.Id) + if err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil, errors.Wrapf(httperrors.ErrResourceNotFound, "idp %s not found", input.Auth.Identity.Id) + } else { + return nil, errors.Wrap(err, "FetchIdentityProviderById") + } } conf, err := models.GetConfigs(idp, true, nil, nil) @@ -408,6 +422,12 @@ func AuthenticateV3(ctx context.Context, input mcclient.SAuthenticationInputV3) if err != nil { return nil, errors.Wrap(err, "authUserByOIDC") } + case api.AUTH_METHOD_OAuth2: + // auth by customized OAuth2.0 provider, keystone acts as an OAuth2.0 app + user, err = authUserByOAuth2(ctx, input) + if err != nil { + return nil, errors.Wrap(err, "authUserByOAuth2") + } default: // auth by other methods, password, openid, saml, etc... user, err = authUserByIdentityV3(ctx, input) diff --git a/pkg/keystone/tokens/handlers.go b/pkg/keystone/tokens/handlers.go index 8a8a719f54..0042b57e5b 100644 --- a/pkg/keystone/tokens/handlers.go +++ b/pkg/keystone/tokens/handlers.go @@ -87,7 +87,7 @@ func authenticateTokensV3(ctx context.Context, w http.ResponseWriter, r *http.Re switch errors.Cause(err) { case sqlchemy.ErrDuplicateEntry: httperrors.ConflictError(w, "duplicate username") - case httperrors.ErrTooManyAttempts: + case httperrors.ErrTooManyAttempts, httperrors.ErrUserNotFound: httperrors.GeneralServerError(w, err) default: httperrors.UnauthorizedError(w, "unauthorized %s", err) diff --git a/pkg/mcclient/cas.go b/pkg/mcclient/cas.go index 64b4a88d2d..1a9804afeb 100644 --- a/pkg/mcclient/cas.go +++ b/pkg/mcclient/cas.go @@ -19,22 +19,24 @@ import ( "yunion.io/x/onecloud/pkg/httperrors" ) -func (this *Client) AuthenticateCAS(ticket string, projectId, projectName, projectDomain string, cliIp string) (TokenCredential, error) { +func (this *Client) AuthenticateCAS(idpId string, ticket, redurectUri string, projectId, projectName, projectDomain string, cliIp string) (TokenCredential, error) { aCtx := SAuthContext{ // CAS auth must comes from Web Source: AuthSourceWeb, Ip: cliIp, } - return this.authenticateCASWithContext(ticket, projectId, projectName, projectDomain, aCtx) + return this.authenticateCASWithContext(idpId, ticket, redurectUri, projectId, projectName, projectDomain, aCtx) } -func (this *Client) authenticateCASWithContext(ticket string, projectId, projectName, projectDomain string, aCtx SAuthContext) (TokenCredential, error) { +func (this *Client) authenticateCASWithContext(idpId string, ticket, redirectUri string, projectId, projectName, projectDomain string, aCtx SAuthContext) (TokenCredential, error) { if this.AuthVersion() != "v3" { return nil, httperrors.ErrNotSupported } input := SAuthenticationInputV3{} + input.Auth.Identity.Id = idpId input.Auth.Identity.Methods = []string{api.AUTH_METHOD_CAS} input.Auth.Identity.CASTicket.Id = ticket + input.Auth.Identity.CASTicket.Service = redirectUri if len(projectId) > 0 { input.Auth.Scope.Project.Id = projectId } diff --git a/pkg/mcclient/input.go b/pkg/mcclient/input.go index e48525e058..f5fee3a18e 100644 --- a/pkg/mcclient/input.go +++ b/pkg/mcclient/input.go @@ -69,6 +69,9 @@ type SAuthenticationInputV2 struct { } type SAuthenticationIdentity struct { + // ID of identity provider, optional + // required:false + Id string `json:"id,omitempty"` // 认证方式列表,支持认证方式如下: // // | method | 说明 | @@ -79,6 +82,7 @@ type SAuthenticationIdentity struct { // | cas | 通过SSO统一认证平台CAS认证 | // | saml | 作为SAML 2.0 SP通过IDP认证 | // | oidc | 作为OpenID Connect/OAuth2 Client认证 | + // | oauth2 | OAuth2认证 | // Methods []string `json:"methods,omitempty"` // 当认证方式为password时,通过该字段提供密码认证信息 @@ -110,18 +114,20 @@ type SAuthenticationIdentity struct { // 当认证方式为cas时,通过该字段提供CAS认证的ID // required:false CASTicket struct { - Id string `json:"id,omitempty"` + Id string `json:"id,omitempty"` + Service string `json:"service,omitempty"` } `json:"cas_ticket,omitempty"` // 当认证方式为saml时,通过该字段提供SAML认证的Response信息 SAMLAuth struct { - Response string `json:"response,omitempty"` - RelayState string `json:"relay_state,omitempty"` + Response string `json:"response,omitempty"` } `json:"saml_auth,omitempty"` OIDCAuth struct { - ClientId string `json:"client_id,omitempty"` Code string `json:"code,omitempty"` RedirectUri string `json:"redirect_uri,omitempty"` } `json:"oidc_auth,omitempty"` + OAuth2 struct { + Code string `json:"code,omitempty"` + } } type SAuthenticationInputV3 struct { diff --git a/pkg/mcclient/oauth2.go b/pkg/mcclient/oauth2.go new file mode 100644 index 0000000000..2ad8d3d717 --- /dev/null +++ b/pkg/mcclient/oauth2.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 mcclient + +import ( + api "yunion.io/x/onecloud/pkg/apis/identity" + "yunion.io/x/onecloud/pkg/httperrors" +) + +func (this *Client) AuthenticateOAuth2(idpId, code string, projectId, projectName, projectDomain string, cliIp string) (TokenCredential, error) { + aCtx := SAuthContext{ + // OAuth2 auth must comes from Web + Source: AuthSourceWeb, + Ip: cliIp, + } + return this.authenticateOAuth2WithContext(idpId, code, projectId, projectName, projectDomain, aCtx) +} + +func (this *Client) authenticateOAuth2WithContext(idpId, code string, projectId, projectName, projectDomain string, aCtx SAuthContext) (TokenCredential, error) { + if this.AuthVersion() != "v3" { + return nil, httperrors.ErrNotSupported + } + input := SAuthenticationInputV3{} + input.Auth.Identity.Methods = []string{api.AUTH_METHOD_OAuth2} + input.Auth.Identity.Id = idpId + input.Auth.Identity.OAuth2.Code = code + if len(projectId) > 0 { + input.Auth.Scope.Project.Id = projectId + } + if len(projectName) > 0 { + input.Auth.Scope.Project.Name = projectName + if len(projectDomain) > 0 { + input.Auth.Scope.Project.Domain.Name = projectDomain + } + } + input.Auth.Context = aCtx + return this._authV3Input(input) +} diff --git a/pkg/mcclient/oidc.go b/pkg/mcclient/oidc.go index b350769133..1bbad00db9 100644 --- a/pkg/mcclient/oidc.go +++ b/pkg/mcclient/oidc.go @@ -19,22 +19,22 @@ import ( "yunion.io/x/onecloud/pkg/httperrors" ) -func (this *Client) AuthenticateOIDC(clientId, code, redirectUri string, projectId, projectName, projectDomain string, cliIp string) (TokenCredential, error) { +func (this *Client) AuthenticateOIDC(idpId, code, redirectUri string, projectId, projectName, projectDomain string, cliIp string) (TokenCredential, error) { aCtx := SAuthContext{ - // CAS auth must comes from Web + // OpenID Connect auth must comes from Web Source: AuthSourceWeb, Ip: cliIp, } - return this.authenticateOIDCWithContext(clientId, code, redirectUri, projectId, projectName, projectDomain, aCtx) + return this.authenticateOIDCWithContext(idpId, code, redirectUri, projectId, projectName, projectDomain, aCtx) } -func (this *Client) authenticateOIDCWithContext(clientId, code, redirectUri string, projectId, projectName, projectDomain string, aCtx SAuthContext) (TokenCredential, error) { +func (this *Client) authenticateOIDCWithContext(idpId, code, redirectUri string, projectId, projectName, projectDomain string, aCtx SAuthContext) (TokenCredential, error) { if this.AuthVersion() != "v3" { return nil, httperrors.ErrNotSupported } input := SAuthenticationInputV3{} input.Auth.Identity.Methods = []string{api.AUTH_METHOD_OIDC} - input.Auth.Identity.OIDCAuth.ClientId = clientId + input.Auth.Identity.Id = idpId input.Auth.Identity.OIDCAuth.Code = code input.Auth.Identity.OIDCAuth.RedirectUri = redirectUri if len(projectId) > 0 { diff --git a/pkg/mcclient/saml.go b/pkg/mcclient/saml.go index d52105bb53..3713c0dc3c 100644 --- a/pkg/mcclient/saml.go +++ b/pkg/mcclient/saml.go @@ -19,20 +19,21 @@ import ( "yunion.io/x/onecloud/pkg/httperrors" ) -func (this *Client) AuthenticateSAML(response string, projectId, projectName, projectDomain string, cliIp string) (TokenCredential, error) { +func (this *Client) AuthenticateSAML(idpId string, response string, projectId, projectName, projectDomain string, cliIp string) (TokenCredential, error) { aCtx := SAuthContext{ - // CAS auth must comes from Web + // SAML 2.0 auth must comes from Web Source: AuthSourceWeb, Ip: cliIp, } - return this.authenticateSAMLWithContext(response, projectId, projectName, projectDomain, aCtx) + return this.authenticateSAMLWithContext(idpId, response, projectId, projectName, projectDomain, aCtx) } -func (this *Client) authenticateSAMLWithContext(response string, projectId, projectName, projectDomain string, aCtx SAuthContext) (TokenCredential, error) { +func (this *Client) authenticateSAMLWithContext(idpId string, response string, projectId, projectName, projectDomain string, aCtx SAuthContext) (TokenCredential, error) { if this.AuthVersion() != "v3" { return nil, httperrors.ErrNotSupported } input := SAuthenticationInputV3{} + input.Auth.Identity.Id = idpId input.Auth.Identity.Methods = []string{api.AUTH_METHOD_SAML} input.Auth.Identity.SAMLAuth.Response = response if len(projectId) > 0 { diff --git a/pkg/util/alipayclient/alipay.go b/pkg/util/alipayclient/alipay.go new file mode 100644 index 0000000000..3d3dab8d33 --- /dev/null +++ b/pkg/util/alipayclient/alipay.go @@ -0,0 +1,194 @@ +// 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 alipayclient + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "fmt" + "net/http" + "strings" + "time" + + "yunion.io/x/jsonutils" + "yunion.io/x/pkg/errors" + + "yunion.io/x/onecloud/pkg/util/httputils" + "yunion.io/x/onecloud/pkg/util/seclib2" +) + +const ( + AlipayGatewayUrl = "https://openapi.alipay.com/gateway.do" + AlipayFormat = "json" + AlipayCharset = "UTF-8" + // AlipayCharsetGBK = "GBK" + AlipaySignType = "RSA2" + AlipayVersion = "1.0" +) + +type SAlipayClient struct { + url string + appId string + appPrivateKey *rsa.PrivateKey + format string // always json + charset string // always utf-8 + alipayPubKey string + signType string // always RSA2 + version string + httpClient *http.Client + isDebug bool +} + +func NewDefaultAlipayClient(appId string, appPrivateKey string, alipayPubKey string, isDebug bool) (*SAlipayClient, error) { + return NewAlipayClient(AlipayGatewayUrl, appId, appPrivateKey, AlipayFormat, AlipayCharset, alipayPubKey, AlipaySignType, isDebug) +} + +func NewAlipayClient(url string, appId string, appPrivateKey string, format string, charset string, alipayPubKey string, signType string, isDebug bool) (*SAlipayClient, error) { + privKey, err := seclib2.DecodePrivateKey([]byte(appPrivateKey)) + if err != nil { + return nil, errors.Wrap(err, "Invalid appPrivateKey") + } + httpClient := httputils.GetClient(true, time.Second*15) + httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + cli := &SAlipayClient{ + url: url, + appId: appId, + appPrivateKey: privKey, + format: format, + charset: charset, + alipayPubKey: alipayPubKey, + signType: signType, + version: AlipayVersion, + httpClient: httpClient, + isDebug: isDebug, + } + return cli, nil +} + +type SCommonRequestParameters struct { + AppId string `json:"app_id"` + Method string `json:"method"` + Format string `json:"format"` + Charset string `json:"charset"` + SignType string `json:"sign_type"` + Timestamp string `json:"timestamp"` + Version string `json:"version"` + AppAuthToken string `json:"app_auth_token"` +} + +func (c *SAlipayClient) getCommonParams(method string) SCommonRequestParameters { + return SCommonRequestParameters{ + AppId: c.appId, + Method: method, + Format: c.format, + Charset: c.charset, + SignType: c.signType, + Timestamp: time.Now().Format("2006-01-02 15:04:05"), + Version: c.version, + } +} + +func (c *SAlipayClient) execute(ctx context.Context, method string, params map[string]string) (jsonutils.JSONObject, error) { + req := jsonutils.Marshal(params).(*jsonutils.JSONDict) + req.Update(jsonutils.Marshal(c.getCommonParams(method))) + signedReq, err := c.sign(req) + if err != nil { + return nil, errors.Wrap(err, "sign") + } + urlstr := fmt.Sprintf("%s?%s", c.url, signedReq.QueryString()) + _, resp, err := httputils.JSONRequest(c.httpClient, ctx, httputils.GET, urlstr, nil, nil, c.isDebug) + if err != nil { + return nil, errors.Wrap(err, "JSONRequest") + } + return resp, nil +} + +func signedString(cont *jsonutils.JSONDict) string { + keys := cont.SortedKeys() + segs := make([]string, len(keys)) + for i, key := range keys { + val, _ := cont.GetString(key) + segs[i] = fmt.Sprintf("%s=%s", key, val) + } + return strings.Join(segs, "&") +} + +func (c *SAlipayClient) sign(request *jsonutils.JSONDict) (jsonutils.JSONObject, error) { + content := request.CopyExcludes("sign") + s := sha256.New() + _, err := s.Write([]byte(signedString(content))) + if err != nil { + return nil, errors.Wrap(err, "sha256.Write") + } + signByte, err := c.appPrivateKey.Sign(rand.Reader, s.Sum(nil), crypto.SHA256) + if err != nil { + return nil, errors.Wrap(err, "privateKey.Sign") + } + + signStr := base64.StdEncoding.EncodeToString(signByte) + content.Set("sign", jsonutils.NewString(signStr)) + return content, nil +} + +type SAlipaySystemOAuthTokenResponse struct { + AccessToken string `json:"access_token"` + AlipayUserId string `json:"alipay_user_id"` + ExpiresIn int `json:"expires_in"` + ReExpiresIn int `json:"re_expires_in"` + RefreshToken string `json:"refresh_token"` + UserId string `json:"user_id"` +} + +// {"alipay_system_oauth_token_response":{"access_token":"authusrB9ee9ecc8105e4fc4869b41e8a470dX90","alipay_user_id":"20881023391875409149385062614990","expires_in":1296000, +// "re_expires_in":2592000,"refresh_token":"authusrBe1a9c0bdf8d344e786ee57f2df9d6E90","user_id":"2088002723447908"}, +// "sign":"rXGE/YX12UrmkEae9jw9WD7B2dS13Hs0r+EnqWwKdERGsUiFmP..."} +func (c *SAlipayClient) GetOAuthToken(ctx context.Context, code string) (*SAlipaySystemOAuthTokenResponse, error) { + resp, err := c.execute(ctx, "alipay.system.oauth.token", map[string]string{ + "code": code, + "grant_type": "authorization_code", + }) + if err != nil { + return nil, errors.Wrap(err, "Execute") + } + tokenResp := SAlipaySystemOAuthTokenResponse{} + err = resp.Unmarshal(&tokenResp, "alipay_system_oauth_token_response") + if err != nil { + return nil, errors.Wrap(err, "unmarshal fail") + } + return &tokenResp, nil +} + +// {"alipay_user_info_share_response":{"code":"10000","msg":"Success","city":"北京市","gender":"m","nick_name":"剑","province":"北京","user_id":"2088002723447908"}, +// "sign":"WZ+uBloiHvQYOOxq02aS/Y4MEoZf5+ANBnt1OKQ9Z8hOPmQsw=="} +func (c *SAlipayClient) GetUserInfo(ctx context.Context, authToken string) (map[string]string, error) { + resp, err := c.execute(ctx, "alipay.user.info.share", map[string]string{ + "auth_token": authToken, + }) + if err != nil { + return nil, errors.Wrap(err, "Execute") + } + ret := make(map[string]string) + err = resp.Unmarshal(&ret, "alipay_user_info_share_response") + if err != nil { + return nil, errors.Wrap(err, "Unmarshal map") + } + return ret, nil +} diff --git a/pkg/util/alipayclient/doc.go b/pkg/util/alipayclient/doc.go new file mode 100644 index 0000000000..c74e257ab5 --- /dev/null +++ b/pkg/util/alipayclient/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 alipayclient // import "yunion.io/x/onecloud/pkg/util/alipayclient" diff --git a/pkg/util/httputils/httputils.go b/pkg/util/httputils/httputils.go index beb6c8ec3d..9290353587 100644 --- a/pkg/util/httputils/httputils.go +++ b/pkg/util/httputils/httputils.go @@ -15,6 +15,8 @@ package httputils import ( + "compress/flate" + "compress/gzip" "context" "crypto/tls" "fmt" @@ -424,9 +426,25 @@ func Request(client *http.Client, ctx context.Context, method THttpMethod, urlSt resp, err := client.Do(req) if err != nil { red(err.Error()) - } - if err == nil && clientTrace != nil { - clientTrace.EndClientTraceHeader(resp.Header) + } else { + encoding := resp.Header.Get("Content-Encoding") + switch encoding { + case "", "identity": + // do nothing + case "gzip": + gzipBody, err := gzip.NewReader(resp.Body) + if err != nil { + return nil, errors.Wrap(err, "gzip.NewReader") + } + resp.Body = gzipBody + case "deflate": + resp.Body = flate.NewReader(resp.Body) + default: + return nil, errors.Wrapf(errors.ErrNotSupported, "unsupported content-encoding %s", encoding) + } + if clientTrace != nil { + clientTrace.EndClientTraceHeader(resp.Header) + } } return resp, err } diff --git a/pkg/util/oidcutils/client/client.go b/pkg/util/oidcutils/client/client.go index 01ae060281..65ce9fca00 100644 --- a/pkg/util/oidcutils/client/client.go +++ b/pkg/util/oidcutils/client/client.go @@ -72,11 +72,12 @@ func (cli *SOIDCClient) FetchConfiguration(ctx context.Context, endpoint string) return nil } -func (cli *SOIDCClient) SetConfig(authUrl, tokenUrl, userinfoUrl string) { +func (cli *SOIDCClient) SetConfig(authUrl, tokenUrl, userinfoUrl string, scopes []string) { cli.config = oidcutils.SOIDCConfiguration{ AuthorizationEndpoint: authUrl, TokenEndpoint: tokenUrl, UserinfoEndpoint: userinfoUrl, + ScopesSupported: scopes, } } @@ -141,7 +142,8 @@ func (cli *SOIDCClient) FetchToken(ctx context.Context, code string, redirUri st func (cli *SOIDCClient) FetchUserInfo(ctx context.Context, accessToken string) (map[string]string, error) { header := http.Header{} header.Set("Authorization", "Bearer "+accessToken) - _, body, err := httputils.JSONRequest(cli.httpclient, ctx, httputils.GET, cli.config.UserinfoEndpoint, header, nil, cli.isDebug) + url := cli.config.UserinfoEndpoint + header, body, err := httputils.JSONRequest(cli.httpclient, ctx, httputils.GET, url, header, nil, cli.isDebug) if err != nil { return nil, errors.Wrap(err, "request userinfo") } diff --git a/pkg/util/samlutils/sp/idp.go b/pkg/util/samlutils/sp/idp.go index dce72745f8..69df918127 100644 --- a/pkg/util/samlutils/sp/idp.go +++ b/pkg/util/samlutils/sp/idp.go @@ -24,15 +24,32 @@ import ( ) type SSAMLIdentityProvider struct { - desc samlutils.EntityDescriptor + entityId string + redirectSsoUrl string +} + +func NewSAMLIdp(entityId, redirectSsoUrl string) *SSAMLIdentityProvider { + return &SSAMLIdentityProvider{ + entityId: entityId, + redirectSsoUrl: redirectSsoUrl, + } +} + +func NewSAMLIdpFromDescriptor(desc samlutils.EntityDescriptor) (*SSAMLIdentityProvider, error) { + entityId := desc.EntityId + if desc.IDPSSODescriptor != nil { + return nil, errors.Wrap(httperrors.ErrInputParameter, "missing IDPSSODescriptor") + } + redirectSsoUrl := findSSOUrl(desc, samlutils.BINDING_HTTP_REDIRECT) + return NewSAMLIdp(entityId, redirectSsoUrl), nil } func (idp *SSAMLIdentityProvider) GetEntityId() string { - return idp.desc.EntityId + return idp.entityId } -func (idp *SSAMLIdentityProvider) getSSOUrl(binding string) string { - for _, v := range idp.desc.IDPSSODescriptor.SingleSignOnServices { +func findSSOUrl(desc samlutils.EntityDescriptor, binding string) string { + for _, v := range desc.IDPSSODescriptor.SingleSignOnServices { if v.Binding == binding { return v.Location } @@ -41,13 +58,10 @@ func (idp *SSAMLIdentityProvider) getSSOUrl(binding string) string { } func (idp *SSAMLIdentityProvider) getRedirectSSOUrl() string { - return idp.getSSOUrl(samlutils.BINDING_HTTP_REDIRECT) + return idp.redirectSsoUrl } func (idp *SSAMLIdentityProvider) IsValid() error { - if idp.desc.IDPSSODescriptor == nil { - return errors.Wrap(httperrors.ErrInputParameter, "missing IDPSSODescriptor") - } if len(idp.GetEntityId()) == 0 { return errors.Wrap(httperrors.ErrInputParameter, "empty EntityID") } diff --git a/pkg/util/samlutils/sp/sp.go b/pkg/util/samlutils/sp/sp.go index 67792ec598..cf6b4fdcae 100644 --- a/pkg/util/samlutils/sp/sp.go +++ b/pkg/util/samlutils/sp/sp.go @@ -59,6 +59,8 @@ type SSAMLSpInstance struct { assertionConsumerPath string spInitiatedSSOPath string + assertionConsumerUri string + identityProviders []*SSAMLIdentityProvider onSAMLAssertionConsume OnSAMLAssertionConsume @@ -93,10 +95,24 @@ func (sp *SSAMLSpInstance) AddIdpMetadata(metadata []byte) error { if err != nil { return errors.Wrap(err, "samlutils.ParseMetadata") } - idp := &SSAMLIdentityProvider{desc: ed} + idp, err := NewSAMLIdpFromDescriptor(ed) + if err != nil { + return errors.Wrap(err, "NewSAMLIdpFromDescriptor") + } err = idp.IsValid() if err != nil { - return errors.Wrap(err, "SSAMLIdentityProvider") + return errors.Wrap(err, "Invalid SAMLIdentityProvider") + } + log.Debugf("Register Idp metadata: %s", idp.GetEntityId()) + sp.identityProviders = append(sp.identityProviders, idp) + return nil +} + +func (sp *SSAMLSpInstance) AddIdp(entityId, redirectSsoUrl string) error { + idp := NewSAMLIdp(entityId, redirectSsoUrl) + err := idp.IsValid() + if err != nil { + return errors.Wrap(err, "Invalid SAMLIdentityProvider") } log.Debugf("Register Idp metadata: %s", idp.GetEntityId()) sp.identityProviders = append(sp.identityProviders, idp) @@ -117,11 +133,18 @@ func (sp *SSAMLSpInstance) AddHandlers(app *appsrv.Application, prefix string) { log.Infof("SP initated SSO: %s", sp.getSpInitiatedSSOUrl()) } +func (sp *SSAMLSpInstance) SetAssertionConsumerUri(uri string) { + sp.assertionConsumerUri = uri +} + func (sp *SSAMLSpInstance) getMetadataUrl() string { return httputils.JoinPath(sp.saml.GetEntityId(), sp.metadataPath) } func (sp *SSAMLSpInstance) getAssertionConsumerUrl() string { + if len(sp.assertionConsumerUri) > 0 { + return sp.assertionConsumerUri + } return httputils.JoinPath(sp.saml.GetEntityId(), sp.assertionConsumerPath) } @@ -130,7 +153,7 @@ func (sp *SSAMLSpInstance) getSpInitiatedSSOUrl() string { } func (sp *SSAMLSpInstance) metadataHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) { - desc := sp.getMetadata(ctx) + desc := sp.GetMetadata() appsrv.SendXmlWithIndent(w, nil, desc, true) } @@ -153,7 +176,7 @@ func (sp *SSAMLSpInstance) spInitiatedSSOHandler(ctx context.Context, w http.Res httperrors.InputParameterError(w, "unmarshal input fail %s", err) return } - redirectUrl, err := sp.processSpInitiatedLogin(ctx, input) + redirectUrl, err := sp.ProcessSpInitiatedLogin(ctx, input) if err != nil { httperrors.GeneralServerError(w, err) return @@ -161,7 +184,7 @@ func (sp *SSAMLSpInstance) spInitiatedSSOHandler(ctx context.Context, w http.Res appsrv.SendRedirect(w, redirectUrl) } -func (sp *SSAMLSpInstance) getMetadata(ctx context.Context) samlutils.EntityDescriptor { +func (sp *SSAMLSpInstance) GetMetadata() samlutils.EntityDescriptor { input := samlutils.SSAMLSpMetadataInput{ EntityId: sp.saml.GetEntityId(), CertString: sp.saml.GetCertString(), @@ -254,7 +277,7 @@ func (sp *SSAMLSpInstance) processAssertionConsumer(ctx context.Context, w http. return nil } -func (sp *SSAMLSpInstance) processSpInitiatedLogin(ctx context.Context, input samlutils.SSpInitiatedLoginInput) (string, error) { +func (sp *SSAMLSpInstance) ProcessSpInitiatedLogin(ctx context.Context, input samlutils.SSpInitiatedLoginInput) (string, error) { idp := sp.getIdentityProvider(input.EntityID) if idp == nil { return "", errors.Wrapf(httperrors.ErrResourceNotFound, "issuer %s not found", input.EntityID)