sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/githuboauth/githuboauth.go (about)

     1  /*
     2  Copyright 2018 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package githuboauth
    18  
    19  import (
    20  	"crypto/subtle"
    21  	"encoding/gob"
    22  	"encoding/hex"
    23  	"fmt"
    24  	"net/http"
    25  	"net/url"
    26  	"time"
    27  
    28  	"sigs.k8s.io/prow/pkg/flagutil"
    29  
    30  	"github.com/gorilla/sessions"
    31  	"github.com/sirupsen/logrus"
    32  	"golang.org/x/net/context"
    33  	"golang.org/x/net/xsrftoken"
    34  	"golang.org/x/oauth2"
    35  	"sigs.k8s.io/prow/pkg/github"
    36  )
    37  
    38  const (
    39  	loginSession       = "github_login"
    40  	tokenSession       = "access-token-session"
    41  	tokenKey           = "access-token"
    42  	oauthSessionCookie = "oauth-session"
    43  	stateKey           = "state"
    44  )
    45  
    46  // Config is a config for requesting users access tokens from GitHub API. It also has
    47  // a Cookie Store that retains user credentials deriving from GitHub API.
    48  type Config struct {
    49  	ClientID     string   `json:"client_id"`
    50  	ClientSecret string   `json:"client_secret"`
    51  	RedirectURL  string   `json:"redirect_url"`
    52  	Scopes       []string `json:"scopes,omitempty"`
    53  
    54  	CookieStore *sessions.CookieStore `json:"-"`
    55  }
    56  
    57  // InitGitHubOAuthConfig creates an OAuthClient using GitHubOAuth config and a Cookie Store
    58  // to retain user credentials.
    59  func (c *Config) InitGitHubOAuthConfig(cookie *sessions.CookieStore) {
    60  	// The `oauth2.Token` needs to be stored in the CookieStore with a specific encoder.
    61  	// Since we are using `gorilla/sessions` which uses `gorilla/securecookie`,
    62  	// it has to be registered to `encoding/gob`.
    63  	//
    64  	// See https://github.com/gorilla/securecookie/blob/master/doc.go#L56-L59
    65  	gob.Register(&oauth2.Token{})
    66  	c.CookieStore = cookie
    67  }
    68  
    69  // AuthenticatedUserIdentifier knows how to get the identity of an authenticated user
    70  type AuthenticatedUserIdentifier interface {
    71  	LoginForRequester(requester, token string) (string, error)
    72  }
    73  
    74  func NewAuthenticatedUserIdentifier(options *flagutil.GitHubOptions) AuthenticatedUserIdentifier {
    75  	return &authenticatedUserIdentifier{clientFactory: options.GitHubClientWithAccessToken}
    76  }
    77  
    78  type authenticatedUserIdentifier struct {
    79  	clientFactory func(accessToken string) (github.Client, error)
    80  }
    81  
    82  func (a *authenticatedUserIdentifier) LoginForRequester(requester, token string) (string, error) {
    83  	client, err := a.clientFactory(token)
    84  	if err != nil {
    85  		return "", err
    86  	}
    87  	user, err := client.ForSubcomponent(requester).BotUser()
    88  	if err != nil {
    89  		return "", err
    90  	}
    91  	return user.Login, nil
    92  }
    93  
    94  // OAuthClient is an interface for a GitHub OAuth client.
    95  type OAuthClient interface {
    96  	WithFinalRedirectURL(url string) (OAuthClient, error)
    97  	// Exchanges code from GitHub OAuth redirect for user access token.
    98  	Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error)
    99  	// Returns a URL to GitHub's OAuth 2.0 consent page. The state is a token to protect the user
   100  	// from an XSRF attack.
   101  	AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string
   102  }
   103  
   104  type client struct {
   105  	*oauth2.Config
   106  }
   107  
   108  func NewClient(config *oauth2.Config) client {
   109  	return client{
   110  		config,
   111  	}
   112  }
   113  
   114  func (cli client) WithFinalRedirectURL(path string) (OAuthClient, error) {
   115  	parsedURL, err := url.Parse(cli.RedirectURL)
   116  	if err != nil {
   117  		return nil, err
   118  	}
   119  	q := parsedURL.Query()
   120  	q.Set("dest", path)
   121  	parsedURL.RawQuery = q.Encode()
   122  	return NewClient(
   123  		&oauth2.Config{
   124  			ClientID:     cli.ClientID,
   125  			ClientSecret: cli.ClientSecret,
   126  			RedirectURL:  parsedURL.String(),
   127  			Scopes:       cli.Scopes,
   128  			Endpoint:     cli.Endpoint,
   129  		},
   130  	), nil
   131  }
   132  
   133  // Agent represents an agent that takes care GitHub authentication process such as handles
   134  // login request from users or handles redirection from GitHub OAuth server.
   135  type Agent struct {
   136  	gc     *Config
   137  	logger *logrus.Entry
   138  }
   139  
   140  // NewAgent returns a new GitHub OAuth Agent.
   141  func NewAgent(config *Config, logger *logrus.Entry) *Agent {
   142  	return &Agent{
   143  		gc:     config,
   144  		logger: logger,
   145  	}
   146  }
   147  
   148  // HandleLogin handles GitHub login request from front-end. It starts a new git oauth session and
   149  // redirect user to GitHub OAuth end-point for authentication.
   150  func (ga *Agent) HandleLogin(client OAuthClient, secure bool) http.HandlerFunc {
   151  	return func(w http.ResponseWriter, r *http.Request) {
   152  		destPage := r.URL.Query().Get("dest")
   153  		stateToken := xsrftoken.Generate(ga.gc.ClientSecret, "", "")
   154  		state := hex.EncodeToString([]byte(stateToken))
   155  		oauthSession, err := ga.gc.CookieStore.New(r, oauthSessionCookie)
   156  		oauthSession.Options.Secure = secure
   157  		oauthSession.Options.HttpOnly = true
   158  		if err != nil {
   159  			ga.serverErrorAndPrint(w, "Creating new OAuth session", err)
   160  			return
   161  		}
   162  		oauthSession.Options.MaxAge = 10 * 60
   163  		oauthSession.Values[stateKey] = state
   164  
   165  		if err := oauthSession.Save(r, w); err != nil {
   166  			ga.serverErrorAndPrint(w, "Save oauth session", err)
   167  			return
   168  		}
   169  		newClient, err := client.WithFinalRedirectURL(destPage)
   170  		if err != nil {
   171  			ga.serverErrorAndPrint(w, "Failed to parse redirect URL", err)
   172  		}
   173  		redirectURL := newClient.AuthCodeURL(state, oauth2.ApprovalForce, oauth2.AccessTypeOnline)
   174  		http.Redirect(w, r, redirectURL, http.StatusFound)
   175  	}
   176  }
   177  
   178  // GetLogin returns the username of the already authenticated GitHub user.
   179  func (ga *Agent) GetLogin(r *http.Request, identifier AuthenticatedUserIdentifier) (string, error) {
   180  	session, err := ga.gc.CookieStore.Get(r, tokenSession)
   181  	if err != nil {
   182  		return "", err
   183  	}
   184  	token, ok := session.Values[tokenKey].(*oauth2.Token)
   185  	if !ok || !token.Valid() {
   186  		return "", fmt.Errorf("Could not find GitHub token")
   187  	}
   188  	login, err := identifier.LoginForRequester("rerun", token.AccessToken)
   189  	if err != nil {
   190  		return "", err
   191  	}
   192  	return login, nil
   193  }
   194  
   195  // HandleLogout handles GitHub logout request from front-end. It invalidates cookie sessions and
   196  // redirect back to the front page.
   197  func (ga *Agent) HandleLogout(client OAuthClient) http.HandlerFunc {
   198  	return func(w http.ResponseWriter, r *http.Request) {
   199  		accessTokenSession, err := ga.gc.CookieStore.Get(r, tokenSession)
   200  		if err != nil {
   201  			ga.serverErrorAndPrint(w, "get cookie", err)
   202  			return
   203  		}
   204  		// Clear session
   205  		accessTokenSession.Options.MaxAge = -1
   206  		if err := accessTokenSession.Save(r, w); err != nil {
   207  			ga.serverErrorAndPrint(w, "Save invalidated session on log out", err)
   208  			return
   209  		}
   210  		loginCookie, err := r.Cookie(loginSession)
   211  		if err == nil {
   212  			loginCookie.MaxAge = -1
   213  			loginCookie.Expires = time.Now().Add(-time.Hour * 24)
   214  			http.SetCookie(w, loginCookie)
   215  		}
   216  		http.Redirect(w, r, r.URL.Host, http.StatusFound)
   217  	}
   218  }
   219  
   220  // HandleRedirect handles the redirection from GitHub. It exchanges the code from redirect URL for
   221  // user access token. The access token is then saved to the cookie and the page is redirected to
   222  // the final destination in the config, which should be the front-end.
   223  func (ga *Agent) HandleRedirect(client OAuthClient, identifier AuthenticatedUserIdentifier, secure bool) http.HandlerFunc {
   224  	return func(w http.ResponseWriter, r *http.Request) {
   225  		// This is string manipulation for clarity, and to avoid surprising parse mismatches.
   226  		scheme := "http"
   227  		if secure {
   228  			scheme = "https"
   229  		}
   230  		finalRedirectURL := scheme + "://" + r.Host + "/" + r.URL.Query().Get("dest")
   231  
   232  		state := r.FormValue("state")
   233  		stateTokenRaw, err := hex.DecodeString(state)
   234  		if err != nil {
   235  			ga.serverErrorAndPrint(w, "Decode state", fmt.Errorf("error with decoding state"))
   236  		}
   237  		stateToken := string(stateTokenRaw)
   238  		// Check if the state token is still valid or not.
   239  		if !xsrftoken.Valid(stateToken, ga.gc.ClientSecret, "", "") {
   240  			ga.serverErrorAndPrint(w, "Validate state", fmt.Errorf("state token has expired"))
   241  			return
   242  		}
   243  
   244  		oauthSession, err := ga.gc.CookieStore.Get(r, oauthSessionCookie)
   245  		if err != nil {
   246  			ga.serverErrorAndPrint(w, "Get cookie", err)
   247  			return
   248  		}
   249  		secretState, ok := oauthSession.Values[stateKey].(string)
   250  		if !ok {
   251  			ga.serverErrorAndPrint(w, "Get secret state", fmt.Errorf("empty string or cannot convert to string. this probably means the options passed to GitHub don't match what was expected"))
   252  			return
   253  		}
   254  		// Validate the state parameter to prevent cross-site attack.
   255  		if state == "" || subtle.ConstantTimeCompare([]byte(state), []byte(secretState)) != 1 {
   256  			ga.serverErrorAndPrint(w, "Validate state", fmt.Errorf("invalid state"))
   257  			return
   258  		}
   259  
   260  		// Exchanges the code for user access token.
   261  		code := r.FormValue("code")
   262  		token, err := client.Exchange(context.Background(), code)
   263  		if err != nil {
   264  			if gherror := r.FormValue("error"); len(gherror) > 0 {
   265  				gherrorDescription := r.FormValue("error_description")
   266  				gherrorURI := r.FormValue("error_uri")
   267  				fields := logrus.Fields{
   268  					"gh_error":             gherror,
   269  					"gh_error_description": gherrorDescription,
   270  					"gh_error_uri":         gherrorURI,
   271  				}
   272  				if gherror == "access_denied" { // User error
   273  					ga.logger.WithFields(fields).Debug("GitHub passed errors in callback, token is not present")
   274  				} else {
   275  					ga.logger.WithFields(fields).Error("GitHub passed errors in callback, token is not present")
   276  				}
   277  				ga.serverError(w, "OAuth authentication with GitHub", fmt.Errorf(gherror))
   278  			} else {
   279  				ga.serverErrorAndPrint(w, "Exchange code for token", err)
   280  			}
   281  			return
   282  		}
   283  
   284  		// New session that stores the token.
   285  		session, err := ga.gc.CookieStore.New(r, tokenSession)
   286  		session.Options.Secure = secure
   287  		session.Options.HttpOnly = true
   288  		if err != nil {
   289  			ga.serverErrorAndPrint(w, "Create new session", err)
   290  			return
   291  		}
   292  
   293  		session.Values[tokenKey] = token
   294  		if err := session.Save(r, w); err != nil {
   295  			ga.serverErrorAndPrint(w, "Save session", err)
   296  			return
   297  		}
   298  		user, err := identifier.LoginForRequester("oauth", token.AccessToken)
   299  		if err != nil {
   300  			ga.serverErrorAndPrint(w, "Get user login", err)
   301  			return
   302  		}
   303  		http.SetCookie(w, &http.Cookie{
   304  			Name:    loginSession,
   305  			Value:   user,
   306  			Path:    "/",
   307  			Expires: time.Now().Add(time.Hour * 24 * 30),
   308  			Secure:  secure,
   309  		})
   310  		http.Redirect(w, r, finalRedirectURL, http.StatusFound)
   311  	}
   312  }
   313  
   314  // Handles server errors.
   315  func (ga *Agent) serverError(w http.ResponseWriter, action string, err error) {
   316  	msg := fmt.Sprintf("500 Internal server error %s: %v", action, err)
   317  	http.Error(w, msg, http.StatusInternalServerError)
   318  }
   319  
   320  // Handles server errors.
   321  func (ga *Agent) serverErrorAndPrint(w http.ResponseWriter, action string, err error) {
   322  	ga.logger.WithError(err).Errorf("Error %s.", action)
   323  	ga.serverError(w, action, err)
   324  }