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 }