github.com/kiali/kiali@v1.84.0/business/authentication/openshift_auth_controller.go (about)

     1  package authentication
     2  
     3  import (
     4  	"fmt"
     5  	"net/http"
     6  	"net/url"
     7  	"strings"
     8  	"time"
     9  
    10  	"github.com/gorilla/mux"
    11  	"golang.org/x/oauth2"
    12  	"k8s.io/client-go/tools/clientcmd/api"
    13  
    14  	"github.com/kiali/kiali/business"
    15  	"github.com/kiali/kiali/config"
    16  	"github.com/kiali/kiali/log"
    17  	"github.com/kiali/kiali/util"
    18  )
    19  
    20  // openshiftSessionPayload holds the data that will be persisted in the SessionStore
    21  // in order to be able to maintain the session of the user across requests.
    22  type openshiftSessionPayload struct {
    23  	oauth2.Token
    24  }
    25  
    26  // OpenshiftAuthController contains the backing logic to implement
    27  // Kiali's "openshift" authentication strategy. This authentication
    28  // strategy is basically an implementation of OAuth's authorization
    29  // code flow with the specifics of OpenShift.
    30  //
    31  // Alternatively, it is possible that 3rd-parties are controlling
    32  // the session. For these cases, Kiali can receive an OpenShift token
    33  // via the "Authorization" HTTP Header or via the "oauth_token"
    34  // URL parameter. Token received from 3rd parties are not persisted
    35  // with the active Kiali's persistor, because that would collide and
    36  // replace an existing Kiali session. So, it is assumed that the 3rd-party
    37  // has its own persistence system (similarly to how 'header' auth works).
    38  type OpenshiftAuthController struct {
    39  	conf           *config.Config
    40  	openshiftOAuth *business.OpenshiftOAuthService
    41  	// SessionStore persists the session between HTTP requests.
    42  	SessionStore SessionPersistor
    43  }
    44  
    45  // NewOpenshiftAuthController initializes a new controller for handling OpenShift authentication, with the
    46  // given persistor and the given businessInstantiator. The businessInstantiator can be nil and
    47  // the initialized contoller will use the business.Get function.
    48  func NewOpenshiftAuthController(persistor SessionPersistor, openshiftOAuth *business.OpenshiftOAuthService, conf *config.Config) (*OpenshiftAuthController, error) {
    49  	return &OpenshiftAuthController{
    50  		conf:           conf,
    51  		openshiftOAuth: openshiftOAuth,
    52  		// TODO: Multi-cluster support.
    53  		SessionStore: persistor,
    54  	}, nil
    55  }
    56  
    57  // PostRoutes adds the additional endpoints needed on the Kiali's router
    58  // in order to properly enable Openshift authentication. Only one new route is added to
    59  // do a redirection from Kiali to the Openshift OAuth server to initiate authentication.
    60  func (c OpenshiftAuthController) PostRoutes(router *mux.Router) {
    61  	// swagger:route GET /auth/openshift_redirect auth openshiftRedirect
    62  	// ---
    63  	// Endpoint to redirect the browser of the user to the authentication
    64  	// endpoint of the configured openshift provider.
    65  	//
    66  	//     Consumes:
    67  	//     - application/json
    68  	//
    69  	//     Produces:
    70  	//     - application/html
    71  	//
    72  	//     Schemes: http, https
    73  	//
    74  	// responses:
    75  	//      500: internalError
    76  	//      200: noContent
    77  	router.
    78  		Methods("GET").
    79  		Path("/api/auth/openshift_redirect").
    80  		Name("OpenShiftAuthRedirect").
    81  		HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    82  			verifier := oauth2.GenerateVerifier() // Store in the session cookie
    83  
    84  			// Redirect user to consent page to ask for permission
    85  			// for the scopes specified above.
    86  			// TODO: Include cluster
    87  			url, err := c.openshiftOAuth.AuthCodeURL(verifier, c.conf.KubernetesConfig.ClusterName)
    88  			if err != nil {
    89  				log.Errorf("Error getting the AuthCodeURL: %v", err)
    90  				http.Error(w, "Internal error", http.StatusInternalServerError)
    91  				return
    92  			}
    93  
    94  			oAuthConfig, err := c.openshiftOAuth.OAuthConfig(c.conf.KubernetesConfig.ClusterName)
    95  			if err != nil {
    96  				log.Errorf("Error getting the OAuthConfig: %v", err)
    97  				http.Error(w, "Internal error", http.StatusInternalServerError)
    98  				return
    99  			}
   100  
   101  			// If redirect url is https, then we can assume that the endpoint is accepting https traffic
   102  			// and the cookie should be secure.
   103  			secureFlag := c.conf.IsServerHTTPS() || strings.HasPrefix(url, "https:")
   104  
   105  			nowTime := util.Clock.Now()
   106  			expirationTime := nowTime.Add(time.Duration(oAuthConfig.TokenAgeInSeconds) * time.Second)
   107  
   108  			// nonce cookie stores the verifier.
   109  			nonceCookie := http.Cookie{
   110  				Expires:  expirationTime,
   111  				HttpOnly: true,
   112  				Secure:   secureFlag,
   113  				Name:     OpenIdNonceCookieName,
   114  				Path:     c.conf.Server.WebRoot,
   115  				// TODO: Can this be strict?
   116  				SameSite: http.SameSiteLaxMode,
   117  				// TODO: Possibly store cluster and any other state we need about the request in the cookie.
   118  				Value: verifier,
   119  			}
   120  			http.SetCookie(w, &nonceCookie)
   121  			http.Redirect(w, r, url, http.StatusFound)
   122  		})
   123  }
   124  
   125  // GetAuthCallbackHandler will attempt to extract the nonce cookie and the code from the request.
   126  // If neither one is present then it is assumed that the request is not a callback from the OAuth provider
   127  // and the fallbackHandler is called instead.
   128  // TODO: Supporting a separate login route for Kiali would obviate the need for the fallbackHandler.
   129  func (c OpenshiftAuthController) GetAuthCallbackHandler(fallbackHandler http.Handler) http.Handler {
   130  	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   131  		nonceCookie, err := r.Cookie(OpenIdNonceCookieName)
   132  		if err != nil {
   133  			log.Debugf("Not handling OAuth code flow authentication: could not get the nonce cookie: %v", err)
   134  			fallbackHandler.ServeHTTP(w, r)
   135  			return
   136  		}
   137  
   138  		code := r.FormValue("code")
   139  		if code == "" {
   140  			log.Debugf("Not handling OAuth code flow authentication: could not get the code: %v", err)
   141  			fallbackHandler.ServeHTTP(w, r)
   142  			return
   143  		}
   144  
   145  		// If we get here then the request IS a callback from the OpenId provider.
   146  
   147  		webRoot := c.conf.Server.WebRoot
   148  		webRootWithSlash := webRoot + "/"
   149  
   150  		// TODO: We probably need the redirect from the oauth server to include the cluster name in the path.
   151  		tok, err := c.openshiftOAuth.Exchange(r.Context(), code, nonceCookie.Value, c.conf.KubernetesConfig.ClusterName)
   152  		if err != nil {
   153  			log.Errorf("Authentication rejected: Unable to exchange the code for a token: %v", err)
   154  			http.Redirect(w, r, fmt.Sprintf("%s?openshift_error=%s", webRootWithSlash, url.QueryEscape(err.Error())), http.StatusFound)
   155  			return
   156  		}
   157  
   158  		if err := c.SessionStore.CreateSession(r, w, config.AuthStrategyOpenshift, tok.Expiry, tok); err != nil {
   159  			log.Errorf("Authentication rejected: Could not create the session: %v", err)
   160  			http.Redirect(w, r, fmt.Sprintf("%s?openshift_error=%s", webRootWithSlash, url.QueryEscape(err.Error())), http.StatusFound)
   161  			return
   162  		}
   163  
   164  		// Delete the nonce cookie since we no longer need it.
   165  		deleteNonceCookie := http.Cookie{
   166  			Expires:  time.Unix(0, 0),
   167  			HttpOnly: true,
   168  			Name:     OpenIdNonceCookieName,
   169  			Path:     c.conf.Server.WebRoot,
   170  			Secure:   nonceCookie.Secure,
   171  			SameSite: http.SameSiteStrictMode,
   172  			Value:    "",
   173  		}
   174  		http.SetCookie(w, &deleteNonceCookie)
   175  
   176  		// Use the authorization code that is pushed to the redirect
   177  		// Let's redirect (remove the openid params) to let the Kiali-UI to boot
   178  		http.Redirect(w, r, webRootWithSlash, http.StatusFound)
   179  	})
   180  }
   181  
   182  // Authenticate handles an HTTP request that contains the access_token, expires_in URL parameters. The access_token
   183  // should be the token that was obtained from the OpenShift OAuth server and expires_in is the expiration date-time
   184  // of the token. The token is validated by obtaining the information user tied to it. Although RBAC is always assumed
   185  // when using OpenShift, privileges are not checked here.
   186  func (o OpenshiftAuthController) Authenticate(r *http.Request, w http.ResponseWriter) (*UserSessionData, error) {
   187  	return nil, fmt.Errorf("support for OAuth's implicit flow has been removed")
   188  }
   189  
   190  // ValidateSession restores a session previously created by the Authenticate function. The user token (access_token)
   191  // is revalidated by re-fetching user info from the cluster, to ensure that the token hasn't been revoked.
   192  // If the session is still valid, a populated UserSessionData is returned. Otherwise, nil is returned.
   193  func (o OpenshiftAuthController) ValidateSession(r *http.Request, w http.ResponseWriter) (*UserSessionData, error) {
   194  	var token string
   195  	var expires time.Time
   196  
   197  	// In OpenShift auth, it is possible that a session is started by a 3rd party. If that's the case, Kiali
   198  	// can receive the OpenShift token of the session via HTTP Headers of via a URL Query string parameter.
   199  	// HTTP Headers have priority over URL parameters. If a token is received via some of these means,
   200  	// then the received session has priority over the Kiali initiated session (stored in cookies).
   201  	if authHeader := r.Header.Get("Authorization"); len(authHeader) != 0 && strings.HasPrefix(authHeader, "Bearer ") {
   202  		token = strings.TrimPrefix(authHeader, "Bearer ")
   203  		expires = util.Clock.Now().Add(time.Second * time.Duration(config.Get().LoginToken.ExpirationSeconds))
   204  	} else if authToken := r.URL.Query().Get("oauth_token"); len(authToken) != 0 {
   205  		token = strings.TrimSpace(authToken)
   206  		expires = util.Clock.Now().Add(time.Second * time.Duration(config.Get().LoginToken.ExpirationSeconds))
   207  	} else {
   208  		sPayload := openshiftSessionPayload{}
   209  		sData, err := o.SessionStore.ReadSession(r, w, &sPayload)
   210  		if err != nil {
   211  			log.Warningf("Could not read the openshift session: %v", err)
   212  			return nil, nil
   213  		}
   214  		if sData == nil {
   215  			return nil, nil
   216  		}
   217  
   218  		// The Openshift token must be present
   219  		if len(sPayload.AccessToken) == 0 {
   220  			log.Warning("Session is invalid: the Openshift token is absent")
   221  			return nil, nil
   222  		}
   223  
   224  		token = sPayload.AccessToken
   225  		expires = sData.ExpiresOn
   226  	}
   227  
   228  	user, err := o.openshiftOAuth.GetUserInfo(r.Context(), token)
   229  	if err == nil {
   230  		// Internal header used to propagate the subject of the request for audit purposes
   231  		r.Header.Add("Kiali-User", user.Name)
   232  		return &UserSessionData{
   233  			ExpiresOn: expires,
   234  			Username:  user.Name,
   235  			AuthInfo:  &api.AuthInfo{Token: token},
   236  		}, nil
   237  	}
   238  
   239  	log.Warningf("Token error: %v", err)
   240  	return nil, nil
   241  }
   242  
   243  // TerminateSession session created by the Authenticate function.
   244  // To properly clean the session, the OpenShift access_token is revoked/deleted by making a call
   245  // to the relevant OpenShift API. If this process fails, the session is not cleared and an error
   246  // is returned.
   247  // The cleanup is done assuming the access_token was issued to be used only in Kiali.
   248  func (o OpenshiftAuthController) TerminateSession(r *http.Request, w http.ResponseWriter) error {
   249  	sPayload := openshiftSessionPayload{}
   250  	sData, err := o.SessionStore.ReadSession(r, w, &sPayload)
   251  	if err != nil {
   252  		return TerminateSessionError{
   253  			Message:    fmt.Sprintf("There is no active openshift session: %v", err),
   254  			HttpStatus: http.StatusUnauthorized,
   255  		}
   256  	}
   257  	if sData == nil {
   258  		return TerminateSessionError{
   259  			Message:    "logout problem: no session exists.",
   260  			HttpStatus: http.StatusInternalServerError,
   261  		}
   262  	}
   263  
   264  	// The Openshift token must be present
   265  	if len(sPayload.AccessToken) == 0 {
   266  		return TerminateSessionError{
   267  			Message:    "Cannot logout: the Openshift token is absent from the session",
   268  			HttpStatus: http.StatusInternalServerError,
   269  		}
   270  	}
   271  
   272  	// TODO: Support multi-cluster. A single logout should termiante all sessions.
   273  	err = o.openshiftOAuth.Logout(r.Context(), sPayload.AccessToken, o.conf.KubernetesConfig.ClusterName)
   274  	if err != nil {
   275  		return TerminateSessionError{
   276  			Message:    fmt.Sprintf("Could not log out of OpenShift: %v", err),
   277  			HttpStatus: http.StatusInternalServerError,
   278  		}
   279  	}
   280  
   281  	o.SessionStore.TerminateSession(r, w)
   282  	return nil
   283  }