// 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" "encoding/base64" "net/http" "net/url" "regexp" "strings" "time" "yunion.io/x/jsonutils" "yunion.io/x/log" "yunion.io/x/pkg/appctx" "yunion.io/x/pkg/errors" "yunion.io/x/pkg/util/httputils" "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/appsrv" "yunion.io/x/onecloud/pkg/httperrors" "yunion.io/x/onecloud/pkg/mcclient" "yunion.io/x/onecloud/pkg/mcclient/auth" modules "yunion.io/x/onecloud/pkg/mcclient/modules/identity" "yunion.io/x/onecloud/pkg/util/netutils2" ) func getSsoBaseCallbackUrl() string { if options.Options.SsoRedirectUrl == "" { return httputils.JoinPath(options.Options.ApiServer, "api/v1/auth/ssologin") } return options.Options.SsoRedirectUrl } func getSsoCallbackUrl(ctx context.Context, req *http.Request, idpId string) string { baseUrl := getSsoBaseCallbackUrl() s := auth.GetAdminSession(ctx, FetchRegion(req)) input := api.GetIdpSsoCallbackUriInput{ RedirectUri: baseUrl, } resp, err := modules.IdentityProviders.GetSpecific(s, idpId, "sso-callback-uri", jsonutils.Marshal(input)) if err != nil { return baseUrl } ret, err := resp.GetString("redirect_uri") if err != nil { return baseUrl } return ret } func getSsoAuthCallbackUrl() string { if options.Options.SsoAuthCallbackUrl == "" { return httputils.JoinPath(options.Options.ApiServer, "auth") } return options.Options.SsoAuthCallbackUrl } func getSsoLinkCallbackUrl() string { if options.Options.SsoLinkCallbackUrl == "" { return httputils.JoinPath(options.Options.ApiServer, "user") } return options.Options.SsoLinkCallbackUrl } func getSsoUserNotFoundCallbackUrl() string { if options.Options.SsoUserNotFoundCallbackUrl == "" { return getSsoAuthCallbackUrl() } return options.Options.SsoUserNotFoundCallbackUrl } func (h *AuthHandlers) getIdpSsoRedirectUri(ctx context.Context, w http.ResponseWriter, req *http.Request) { expires := time.Now().Add(time.Minute * 5) 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(ctx, w, "invalid credential") return } linkuser = t.GetUserId() // authCookie := authToken.GetAuthCookie(t) // saveCookie(w, constants.YUNION_AUTH_COOKIE, authCookie, "", expires, true) } referer := req.Header.Get(http.CanonicalHeaderKey("referer")) if query == nil { query = jsonutils.NewDict() } query.(*jsonutils.JSONDict).Set("idp_nonce", jsonutils.NewString(utils.GenRequestId(4))) state := base64.URLEncoding.EncodeToString([]byte(query.String())) redirectUri := getSsoCallbackUrl(ctx, req, idpId) 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(ctx, w, err) return } redirUrl, _ := resp.GetString("uri") idpDriver, _ := resp.GetString("driver") 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: ([^:]+): UserNotFound`) 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) handleIdpInitSsoLogin(ctx context.Context, w http.ResponseWriter, req *http.Request) { params := appctx.AppContextParams(ctx) idpId := params[""] s := auth.GetAdminSession(ctx, FetchRegion(req)) resp, err := modules.IdentityProviders.Get(s, idpId, nil) if err != nil { httperrors.GeneralServerError(ctx, w, err) return } idpDriver, _ := resp.GetString("driver") h.internalSsoLogin(ctx, w, req, idpId, idpDriver) } func (h *AuthHandlers) handleSsoLogin(ctx context.Context, w http.ResponseWriter, req *http.Request) { h.internalSsoLogin(ctx, w, req, "", "") } func (h *AuthHandlers) internalSsoLogin(ctx context.Context, w http.ResponseWriter, req *http.Request, idpId, idpDriver string) { idpIdC := getCookie(req, "idp_id") idpDriverC := getCookie(req, "idp_driver") idpState := getCookie(req, "idp_state") idpReferer := getCookie(req, "idp_referer") idpLinkUser := getCookie(req, "idp_link_user") if len(idpIdC) > 0 { idpId = idpIdC } if len(idpDriverC) > 0 { idpDriver = idpDriverC } for _, k := range []string{"idp_id", "idp_driver", "idp_state", "idp_referer", "idp_link_user"} { clearCookie(w, k, "") } missing := make([]string, 0) if len(idpId) == 0 { missing = append(missing, "idp_id") } if len(idpDriver) == 0 { missing = append(missing, "idp_driver") } /*if len(idpState) == 0 { missing = append(missing, "idp_state") } if len(idpReferer) == 0 { missing = append(missing, "idp_referer") }*/ if len(missing) > 0 { httperrors.TimeoutError(ctx, w, "session expires, missing %s", strings.Join(missing, ",")) return } var idpStateQs jsonutils.JSONObject if len(idpState) > 0 { idpStateQsBytes, _ := base64.URLEncoding.DecodeString(idpState) idpStateQs, _ = jsonutils.Parse(idpStateQsBytes) log.Debugf("state query sting: %s", idpStateQs) } var body jsonutils.JSONObject var err error switch req.Method { case "GET": body, err = jsonutils.ParseQueryString(req.URL.RawQuery) if err != nil { httperrors.InputParameterError(ctx, w, "parse query string error: %s", err) return } case "POST": formData, err := appsrv.Fetch(req) if err != nil { httperrors.InputParameterError(ctx, w, "fetch form data error: %s", err) } body, err = jsonutils.ParseQueryString(string(formData)) if err != nil { httperrors.InputParameterError(ctx, w, "parse form data error: %s", err) return } default: httperrors.InputParameterError(ctx, 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) var referer string var idpUserId string if len(idpLinkUser) > 0 { // link with existing user err = linkWithExistingUser(ctx, req, idpId, idpLinkUser, body) referer = getSsoLinkCallbackUrl() } else { // ordinary login 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") } else { referer = getSsoUserNotFoundCallbackUrl() } } } if referer == "" { referer = getSsoAuthCallbackUrl() } } refererUrl, _ := url.Parse(referer) if refererUrl == nil && len(idpReferer) > 0 { refererUrl, _ = url.Parse(idpReferer) } if refererUrl == nil { httperrors.InvalidInputError(ctx, w, "empty referer link") return } redirUrl := generateRedirectUrl(refererUrl, idpStateQs, err, idpId, idpUserId) appsrv.SendRedirect(w, redirUrl) } func generateRedirectUrl(originUrl *url.URL, stateQs jsonutils.JSONObject, err error, idpId, idpUserId string) string { var qs jsonutils.JSONObject if len(originUrl.RawQuery) > 0 { qs, _ = jsonutils.ParseQueryString(originUrl.RawQuery) } else { qs = jsonutils.NewDict() } qs.(*jsonutils.JSONDict).Update(stateQs) 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() } msgLen := 100 if len(errDetails) > msgLen { errDetails = errDetails[:msgLen] + "..." } 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_entity_id") } } else { qs.(*jsonutils.JSONDict).Add(jsonutils.NewString("success"), "result") } originUrl.RawQuery = qs.QueryString() return originUrl.String() } func processSsoLoginData(body jsonutils.JSONObject, cliIp string, redirectUri 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: 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: 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) redirectUri := getSsoCallbackUrl(ctx, req, idpId) ntoken, err := processSsoLoginData(body, cliIp, redirectUri) 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(ctx, w, err) return } idpId, _ := body.GetString("idp_id") idpEntityId, _ := body.GetString("idp_entity_id") if len(idpId) == 0 || len(idpEntityId) == 0 { httperrors.InputParameterError(ctx, 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(ctx, w, err) return } appsrv.Send(w, "") } func fetchIdpBasicConfig(ctx context.Context, w http.ResponseWriter, req *http.Request) { params := appctx.AppContextParams(ctx) idpId := params[""] info, err := getIdpBasicConfig(ctx, req, idpId) if err != nil { httperrors.GeneralServerError(ctx, w, err) return } appsrv.SendJSON(w, info) } func getIdpBasicConfig(ctx context.Context, req *http.Request, idpId string) (jsonutils.JSONObject, error) { s := auth.GetAdminSession(ctx, FetchRegion(req)) baseUrl := getSsoBaseCallbackUrl() input := api.GetIdpSsoCallbackUriInput{ RedirectUri: baseUrl, } resp, err := modules.IdentityProviders.GetSpecific(s, idpId, "sso-callback-uri", jsonutils.Marshal(input)) if err != nil { return nil, errors.Wrap(err, "GetSpecific sso-callback-uri") } redir, _ := resp.GetString("redirect_uri") idpDriver, _ := resp.GetString("driver") info := jsonutils.NewDict() switch idpDriver { case api.IdentityDriverSQL: case api.IdentityDriverLDAP: case api.IdentityDriverCAS: info.Add(jsonutils.NewString(redir), "redirect_uri") case api.IdentityDriverSAML: info.Add(jsonutils.NewString(options.Options.ApiServer), "entity_id") info.Add(jsonutils.NewString(redir), "redirect_uri") case api.IdentityDriverOIDC: info.Add(jsonutils.NewString(redir), "redirect_uri") case api.IdentityDriverOAuth2: info.Add(jsonutils.NewString(redir), "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(ctx, req, idpId))) md, err := modules.IdentityProviders.GetSpecific(s, idpId, "saml-metadata", query) if err != nil { httperrors.GeneralServerError(ctx, w, err) return } appsrv.SendJSON(w, md) }