| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505 |
- // 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"
- "encoding/binary"
- "fmt"
- "io/ioutil"
- "net/http"
- "net/url"
- "strings"
- "time"
- "github.com/lestrrat-go/jwx/jwa"
- "github.com/lestrrat-go/jwx/jwt"
- "yunion.io/x/jsonutils"
- "yunion.io/x/log"
- "yunion.io/x/pkg/errors"
- "yunion.io/x/pkg/util/httputils"
- "yunion.io/x/pkg/util/netutils"
- "yunion.io/x/onecloud/pkg/apigateway/clientman"
- "yunion.io/x/onecloud/pkg/apigateway/options"
- "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"
- "yunion.io/x/onecloud/pkg/util/oidcutils"
- )
- const (
- // OIDC code expires in 5 minutes
- OIDC_CODE_EXPIRE_SECONDS = 300
- // OIDC token expires in 2 hours
- OIDC_TOKEN_EXPIRE_SECONDS = 7200
- )
- func getLoginCallbackParam() string {
- if options.Options.LoginCallbackParam == "" {
- return "rf"
- }
- return options.Options.LoginCallbackParam
- }
- func addQuery(urlstr string, qs jsonutils.JSONObject) string {
- qsPos := strings.LastIndexByte(urlstr, '?')
- if qsPos < 0 {
- return fmt.Sprintf("%s?%s", urlstr, qs.QueryString())
- }
- oldQs, _ := jsonutils.ParseQueryString(urlstr[qsPos+1:])
- if oldQs != nil {
- oldQs.(*jsonutils.JSONDict).Update(qs)
- return fmt.Sprintf("%s?%s", urlstr[:qsPos], oldQs.QueryString())
- } else {
- return fmt.Sprintf("%s?%s", urlstr[:qsPos], qs.QueryString())
- }
- }
- func handleOIDCAuth(ctx context.Context, w http.ResponseWriter, req *http.Request) {
- ctx, err := fetchAndSetAuthContext(ctx, w, req)
- if err != nil {
- // not login redirect to login page
- qs := jsonutils.NewDict()
- oUrl := req.URL.String()
- if !strings.HasPrefix(oUrl, "http") {
- oUrl = httputils.JoinPath(options.Options.ApiServer, oUrl)
- }
- qs.Set(getLoginCallbackParam(), jsonutils.NewString(oUrl))
- loginUrl := addQuery(getSsoAuthCallbackUrl(), qs)
- appsrv.SendRedirect(w, loginUrl)
- return
- }
- query, _ := jsonutils.ParseQueryString(req.URL.RawQuery)
- auth, code, err := doOIDCAuth(ctx, req, query)
- if err != nil {
- qs := jsonutils.NewDict()
- qs.Set("error", jsonutils.NewString(errors.Cause(err).Error()))
- qs.Set("error_description", jsonutils.NewString(err.Error()))
- errorUrl := addQuery(auth.RedirectUri, qs)
- appsrv.SendRedirect(w, errorUrl)
- return
- }
- qs := jsonutils.NewDict()
- qs.Set("code", jsonutils.NewString(code))
- qs.Set("state", jsonutils.NewString(auth.State))
- redirUrl := addQuery(auth.RedirectUri, qs)
- appsrv.DisableClientCache(w)
- appsrv.SendRedirect(w, redirUrl)
- }
- func fetchOIDCCredential(ctx context.Context, req *http.Request, clientId string) (modules.SOpenIDConnectCredential, error) {
- var oidcSecret modules.SOpenIDConnectCredential
- s := auth.GetAdminSession(ctx, FetchRegion(req))
- secret, err := modules.Credentials.GetById(s, clientId, nil)
- if err != nil {
- return oidcSecret, errors.Wrap(err, "Request Credential")
- }
- oidcSecret, err = modules.DecodeOIDCSecret(secret)
- if err != nil {
- return oidcSecret, errors.Wrap(err, "DecodeOIDCSecret")
- }
- return oidcSecret, nil
- }
- func doOIDCAuth(ctx context.Context, req *http.Request, query jsonutils.JSONObject) (oidcutils.SOIDCAuthRequest, string, error) {
- oidcAuth := oidcutils.SOIDCAuthRequest{}
- if query == nil {
- return oidcAuth, "", errors.Wrap(httperrors.ErrInputParameter, "empty query string")
- }
- err := query.Unmarshal(&oidcAuth)
- if err != nil {
- return oidcAuth, "", errors.Wrap(httperrors.ErrInputParameter, "unmarshal request parameter fail")
- }
- if oidcAuth.ResponseType != oidcutils.OIDC_RESPONSE_TYPE_CODE {
- return oidcAuth, "", errors.Wrapf(httperrors.ErrInputParameter, "invalid resposne type %s", oidcAuth.ResponseType)
- }
- oidcSecret, err := fetchOIDCCredential(ctx, req, oidcAuth.ClientId)
- if err != nil {
- return oidcAuth, "", errors.Wrap(err, "fetchOIDCCredential")
- }
- if oidcSecret.RedirectUri != oidcAuth.RedirectUri {
- return oidcAuth, "", errors.Wrap(httperrors.ErrInvalidCredential, "redirect uri not match")
- }
- token := AppContextToken(ctx)
- cliIp := netutils2.GetHttpRequestIp(req)
- codeInfo := newOIDCClientInfo(token, cliIp, FetchRegion(req))
- code := clientman.EncryptString(codeInfo.toBytes())
- return oidcAuth, code, nil
- }
- func handleOIDCToken(ctx context.Context, w http.ResponseWriter, req *http.Request) {
- resp, err := validateOIDCToken(ctx, req)
- if err != nil {
- httperrors.GeneralServerError(ctx, w, err)
- return
- }
- appsrv.SendJSON(w, jsonutils.Marshal(resp))
- return
- }
- type SOIDCClientInfo struct {
- Timestamp int64
- Ip netutils.IPV4Addr
- UserId string
- ProjectId string
- Region string
- }
- func (i SOIDCClientInfo) toBytes() []byte {
- enc := make([]byte, 12+1+len(i.UserId)+1+len(i.ProjectId)+len(i.Region))
- binary.LittleEndian.PutUint64(enc, uint64(i.Timestamp))
- binary.LittleEndian.PutUint32(enc[8:], uint32(i.Ip))
- enc[12] = byte(len(i.UserId))
- enc[13] = byte(len(i.ProjectId))
- copy(enc[14:], i.UserId)
- copy(enc[14+len(i.UserId):], i.ProjectId)
- copy(enc[14+len(i.UserId)+len(i.ProjectId):], i.Region)
- return enc
- }
- func (i SOIDCClientInfo) isExpired() bool {
- if time.Now().UnixNano()-i.Timestamp > OIDC_CODE_EXPIRE_SECONDS*1000000000 {
- return true
- }
- return false
- }
- func (i SOIDCClientInfo) expiresAt(secs int) time.Time {
- expires := i.Timestamp + int64(secs)*int64(time.Second)
- esecs := expires / int64(time.Second)
- nsecs := expires - esecs*int64(time.Second)
- return time.Unix(esecs, nsecs)
- }
- func decodeOIDCClientInfo(enc []byte) (SOIDCClientInfo, error) {
- info := SOIDCClientInfo{}
- if len(enc) < 8+4+1 {
- return info, errors.Wrap(httperrors.ErrInvalidCredential, "code byte length must be 12")
- }
- info.Timestamp = int64(binary.LittleEndian.Uint64(enc))
- info.Ip = netutils.IPV4Addr(binary.LittleEndian.Uint32(enc[8:]))
- info.UserId = string(enc[14 : 14+int(enc[12])])
- info.ProjectId = string(enc[14+int(enc[12]) : 14+int(enc[12])+int(enc[13])])
- info.Region = string(enc[14+int(enc[12])+int(enc[13]):])
- return info, nil
- }
- func newOIDCClientInfo(token mcclient.TokenCredential, ipstr string, region string) SOIDCClientInfo {
- info := SOIDCClientInfo{}
- info.Timestamp = time.Now().UnixNano()
- info.Ip, _ = netutils.NewIPV4Addr(ipstr)
- info.UserId = token.GetUserId()
- info.ProjectId = token.GetProjectId()
- info.Region = region
- return info
- }
- type SOIDCClientToken struct {
- Info SOIDCClientInfo
- }
- func (t SOIDCClientToken) encode() string {
- json := jsonutils.NewDict()
- json.Add(jsonutils.NewString(string(t.Info.toBytes())), "info")
- return clientman.EncryptString([]byte(json.String()))
- }
- func decodeOIDCClientToken(token string) (SOIDCClientToken, error) {
- ret := SOIDCClientToken{}
- tBytes, err := clientman.DecryptString(token)
- if err != nil {
- return ret, errors.Wrap(err, "DecryptString")
- }
- json, err := jsonutils.Parse(tBytes)
- if err != nil {
- return ret, errors.Wrap(err, "json.Parse")
- }
- info, err := json.GetString("info")
- if err != nil {
- return ret, errors.Wrap(err, "getString(info)")
- }
- ret.Info, err = decodeOIDCClientInfo([]byte(info))
- if err != nil {
- return ret, errors.Wrap(err, "decodeOIDCClientInfo")
- }
- return ret, nil
- }
- func validateOIDCToken(ctx context.Context, req *http.Request) (oidcutils.SOIDCAccessTokenResponse, error) {
- var tokenResp oidcutils.SOIDCAccessTokenResponse
- bodyBytes, err := appsrv.Fetch(req)
- if err != nil {
- return tokenResp, errors.Wrap(err, "Fetch Body")
- }
- log.Debugf("validateOIDCToken body: %s", string(bodyBytes))
- bodyJson, err := jsonutils.ParseQueryString(string(bodyBytes))
- if err != nil {
- return tokenResp, errors.Wrap(err, "Decode body form data")
- }
- authReq := oidcutils.SOIDCAccessTokenRequest{}
- err = bodyJson.Unmarshal(&authReq)
- if err != nil {
- return tokenResp, errors.Wrap(err, "Unmarshal Access Token Request")
- }
- if authReq.GrantType != oidcutils.OIDC_REQUEST_GRANT_TYPE {
- return tokenResp, errors.Wrapf(httperrors.ErrInvalidCredential, "invalid grant type %s", authReq.GrantType)
- }
- codeTimeBytes, err := clientman.DecryptString(authReq.Code)
- if err != nil {
- return tokenResp, errors.Wrapf(httperrors.ErrInvalidCredential, "invalid code %s", authReq.Code)
- }
- codeInfo, err := decodeOIDCClientInfo(codeTimeBytes)
- if err != nil {
- return tokenResp, errors.Wrap(httperrors.ErrInvalidCredential, "fail to decode code")
- }
- if codeInfo.isExpired() {
- return tokenResp, errors.Wrapf(httperrors.ErrInvalidCredential, "code expires")
- }
- authStr := req.Header.Get("Authorization")
- log.Debugf("Authorization: %s", authStr)
- authParts := strings.Split(string(authStr), " ")
- if len(authParts) != 2 {
- return tokenResp, errors.Wrap(httperrors.ErrInvalidCredential, "illegal authorization header")
- }
- if authParts[0] != "Basic" {
- return tokenResp, errors.Wrapf(httperrors.ErrInvalidCredential, "unsupport auth method %s, only Basic supported", authParts)
- }
- authBytes, err := base64.StdEncoding.DecodeString(authParts[1])
- if err != nil {
- return tokenResp, errors.Wrap(err, "Decode Authorization Header")
- }
- log.Debugf("Authorization basic: %s", string(authBytes))
- authParts = strings.Split(string(authBytes), ":")
- if len(authParts) != 2 {
- return tokenResp, errors.Wrap(httperrors.ErrInvalidCredential, "illegal authorization header")
- }
- clientId, _ := url.QueryUnescape(authParts[0])
- clientSecret, _ := url.QueryUnescape(authParts[1])
- log.Debugf("clientId %s clientSecret: %s authReq.ClientId %s", clientId, clientSecret, authReq.ClientId)
- oidcSecret, err := fetchOIDCCredential(ctx, req, clientId)
- if err != nil {
- return tokenResp, errors.Wrap(err, "fetchOIDCCredential")
- }
- if oidcSecret.RedirectUri != authReq.RedirectUri {
- return tokenResp, errors.Wrap(httperrors.ErrInvalidCredential, "redirect uri not match")
- }
- if oidcSecret.Secret != clientSecret {
- return tokenResp, errors.Wrap(httperrors.ErrInvalidCredential, "client secret not match")
- }
- token := SOIDCClientToken{
- Info: codeInfo,
- }
- tokenResp = token2AccessTokenResponse(token, clientId)
- return tokenResp, nil
- }
- func token2AccessTokenResponse(token SOIDCClientToken, clientId string) oidcutils.SOIDCAccessTokenResponse {
- resp := oidcutils.SOIDCAccessTokenResponse{}
- resp.AccessToken = token.encode()
- resp.TokenType = oidcutils.OIDC_BEARER_TOKEN_TYPE
- resp.IdToken, _ = token2IdToken(token, clientId)
- resp.ExpiresIn = int(token.Info.expiresAt(OIDC_TOKEN_EXPIRE_SECONDS).Unix() - time.Now().Unix())
- return resp
- }
- func token2IdToken(token SOIDCClientToken, clientId string) (string, error) {
- jwtToken := jwt.New()
- jwtToken.Set(jwt.IssuerKey, options.Options.ApiServer)
- jwtToken.Set(jwt.SubjectKey, token.Info.UserId)
- jwtToken.Set(jwt.AudienceKey, clientId)
- jwtToken.Set(jwt.ExpirationKey, token.Info.expiresAt(OIDC_TOKEN_EXPIRE_SECONDS).Unix())
- jwtToken.Set(jwt.IssuedAtKey, time.Now().Unix())
- return clientman.SignJWT(jwtToken)
- }
- func handleOIDCConfiguration(ctx context.Context, w http.ResponseWriter, req *http.Request) {
- authUrl := httputils.JoinPath(options.Options.ApiServer, "api/v1/auth/oidc/auth")
- tokenUrl := httputils.JoinPath(options.Options.ApiServer, "api/v1/auth/oidc/token")
- userinfoUrl := httputils.JoinPath(options.Options.ApiServer, "api/v1/auth/oidc/user")
- logoutUrl := httputils.JoinPath(options.Options.ApiServer, "api/v1/auth/oidc/logout")
- jwksUrl := httputils.JoinPath(options.Options.ApiServer, "api/v1/auth/oidc/keys")
- conf := oidcutils.SOIDCConfiguration{
- Issuer: httputils.JoinPath(options.Options.ApiServer, "api/v1/auth/oidc"),
- AuthorizationEndpoint: authUrl,
- TokenEndpoint: tokenUrl,
- UserinfoEndpoint: userinfoUrl,
- EndSessionEndpoint: logoutUrl,
- JwksUri: jwksUrl,
- ResponseTypesSupported: []string{
- oidcutils.OIDC_RESPONSE_TYPE_CODE,
- },
- SubjectTypesSupported: []string{
- "public",
- },
- IdTokenSigningAlgValuesSupported: []string{
- string(jwa.RS256),
- },
- ScopesSupported: []string{
- "user",
- "profile",
- },
- TokenEndpointAuthMethodsSupported: []string{
- "client_secret_basic",
- },
- ClaimsSupported: []string{
- jwt.IssuerKey,
- jwt.SubjectKey,
- jwt.AudienceKey,
- jwt.ExpirationKey,
- jwt.IssuedAtKey,
- },
- }
- appsrv.SendJSON(w, jsonutils.Marshal(conf))
- }
- func handleOIDCJWKeys(ctx context.Context, w http.ResponseWriter, req *http.Request) {
- keyJson, err := clientman.GetJWKs(ctx)
- if err != nil {
- httperrors.GeneralServerError(ctx, w, err)
- return
- }
- appsrv.SendJSON(w, keyJson)
- }
- func handleOIDCUserInfo(ctx context.Context, w http.ResponseWriter, req *http.Request) {
- tokenHdr := getAuthToken(req)
- if len(tokenHdr) == 0 {
- httperrors.InvalidCredentialError(ctx, w, "No token in header")
- return
- }
- token, err := decodeOIDCClientToken(tokenHdr)
- if err != nil {
- log.Errorf("decodeOIDCClientToken %s fail %s", tokenHdr, err)
- httperrors.InvalidCredentialError(ctx, w, "Token in header invalid")
- return
- }
- if token.Info.expiresAt(OIDC_TOKEN_EXPIRE_SECONDS).Before(time.Now()) {
- httperrors.InvalidCredentialError(ctx, w, "Token expired")
- return
- }
- s := auth.GetAdminSession(ctx, token.Info.Region)
- data, err := getUserInfo2(s, token.Info.UserId, token.Info.ProjectId, token.Info.Ip.String())
- if err != nil {
- httperrors.NotFoundError(ctx, w, "%v", err)
- return
- }
- appsrv.SendJSON(w, data)
- }
- type SOIDCRPInitLogoutRequest struct {
- // RECOMMENDED. ID Token previously issued by the OP to the RP passed to the Logout Endpoint
- // as a hint about the End-User's current authenticated session with the Client. This is used
- // as an indication of the identity of the End-User that the RP is requesting be logged out by the OP.
- IdTokenHint string `json:"id_token_hint"`
- // OPTIONAL. Hint to the Authorization Server about the End-User that is logging out. The value
- // and meaning of this parameter is left up to the OP's discretion. For instance, the value might
- // contain an email address, phone number, username, or session identifier pertaining to the RP's
- // session with the OP for the End-User. (This parameter is intended to be analogous to the
- // login_hint parameter defined in Section 3.1.2.1 of OpenID Connect Core 1.0 [OpenID.Core] that
- // is used in Authentication Requests; whereas, logout_hint is used in RP-Initiated Logout Requests.)
- LogoutHint string `json:"logout_hint"`
- // OPTIONAL. OAuth 2.0 Client Identifier valid at the Authorization Server. When both client_id and
- // id_token_hint are present, the OP MUST verify that the Client Identifier matches the one used when
- // issuing the ID Token. The most common use case for this parameter is to specify the Client Identifier
- // when post_logout_redirect_uri is used but id_token_hint is not. Another use is for symmetrically
- // encrypted ID Tokens used as id_token_hint values that require the Client Identifier to be specified
- // by other means, so that the ID Tokens can be decrypted by the OP.
- ClientId string `json:"client_id"`
- // OPTIONAL. URI to which the RP is requesting that the End-User's User Agent be redirected after a
- // logout has been performed. This URI SHOULD use the https scheme and MAY contain port, path, and
- // query parameter components; however, it MAY use the http scheme, provided that the Client Type is
- // confidential, as defined in Section 2.1 of OAuth 2.0 [RFC6749], and provided the OP allows the use
- // of http RP URIs. The URI MAY use an alternate scheme, such as one that is intended to identify a
- // callback into a native application. The value MUST have been previously registered with the OP,
- // either using the post_logout_redirect_uris Registration parameter or via another mechanism. An
- // id_token_hint is also RECOMMENDED when this parameter is included.
- PostLogoutRedirectUri string `json:"post_logout_redirect_uri"`
- // OPTIONAL. Opaque value used by the RP to maintain state between the logout request and the callback
- // to the endpoint specified by the post_logout_redirect_uri parameter. If included in the logout request,
- // the OP passes this value back to the RP using the state parameter when redirecting the User Agent back to the RP.
- State string `json:"state"`
- // OPTIONAL. End-User's preferred languages and scripts for the user interface, represented as a
- // space-separated list of BCP47 [RFC5646] language tag values, ordered by preference. For instance,
- // the value "fr-CA fr en" represents a preference for French as spoken in Canada, then French (without
- // a region designation), followed by English (without a region designation). An error SHOULD NOT result
- // if some or all of the requested locales are not supported by the OpenID Provider.
- UiLocales string `json:"ui_locales"`
- }
- func handleOIDCRPInitLogout(ctx context.Context, w http.ResponseWriter, req *http.Request) {
- params, err := fetchOIDCRPInitLogoutParam(req)
- if err != nil {
- httperrors.GeneralServerError(ctx, w, err)
- return
- }
- doLogout(ctx, w, req)
- var redirUrl string
- if len(params.PostLogoutRedirectUri) > 0 {
- redirUrl = params.PostLogoutRedirectUri
- if len(params.State) > 0 {
- redirUrl = addQuery(redirUrl, jsonutils.Marshal(map[string]string{"state": params.State}))
- }
- } else {
- redirUrl = getSsoAuthCallbackUrl()
- }
- appsrv.SendRedirect(w, redirUrl)
- }
- func fetchOIDCRPInitLogoutParam(req *http.Request) (*SOIDCRPInitLogoutRequest, error) {
- var qs string
- if req.Method == "GET" {
- qs = req.URL.RawQuery
- } else if req.Method == "POST" {
- b, err := req.GetBody()
- if err != nil {
- return nil, errors.Wrap(err, "GetBody")
- }
- defer b.Close()
- qsBytes, err := ioutil.ReadAll(b)
- if err != nil {
- return nil, errors.Wrap(err, "ioutil.ReadAll")
- }
- qs = string(qsBytes)
- }
- params := SOIDCRPInitLogoutRequest{}
- if len(qs) == 0 {
- return ¶ms, nil
- }
- qsJson, err := jsonutils.ParseQueryString(qs)
- if err != nil {
- return nil, errors.Wrap(err, "jsonutils.ParseQueryString")
- }
- err = qsJson.Unmarshal(¶ms)
- if err != nil {
- return nil, errors.Wrap(err, "qsJson.Unmarshal")
- }
- return ¶ms, nil
- }
|