github.com/web-platform-tests/wpt.fyi@v0.0.0-20240530210107-70cf978996f1/webapp/login.go (about)

     1  // Copyright 2019 The WPT Dashboard Project. All rights reserved.
     2  // Use of this source code is governed by a BSD-style license that can be
     3  // found in the LICENSE file.
     4  
     5  package webapp
     6  
     7  import (
     8  	"context"
     9  	"crypto/rand"
    10  	"encoding/base64"
    11  	"fmt"
    12  	"net/http"
    13  	"net/url"
    14  
    15  	"github.com/web-platform-tests/wpt.fyi/shared"
    16  	"golang.org/x/oauth2"
    17  )
    18  
    19  func loginHandler(w http.ResponseWriter, r *http.Request) {
    20  	ctx := r.Context()
    21  	aeAPI := shared.NewAppEngineAPI(ctx)
    22  	if !aeAPI.IsFeatureEnabled("githubLogin") {
    23  		http.Error(w, "Feature not enabled", http.StatusNotImplemented)
    24  		return
    25  	}
    26  
    27  	githubOauthImp, err := shared.NewGitHubOAuth(ctx)
    28  	if err != nil {
    29  		http.Error(w, "Error creating githuboauthImp", http.StatusInternalServerError)
    30  		return
    31  	}
    32  	handleLogin(githubOauthImp, w, r)
    33  }
    34  
    35  func handleLogin(g shared.GitHubOAuth, w http.ResponseWriter, r *http.Request) {
    36  	ctx := g.Context()
    37  	ds := g.Datastore()
    38  	user, _ := shared.GetUserFromCookie(ctx, ds, r)
    39  	returnURL := r.FormValue("return")
    40  	if returnURL == "" {
    41  		returnURL = "/"
    42  	}
    43  
    44  	redirect := ""
    45  	log := shared.GetLogger(ctx)
    46  	if user == nil {
    47  		log.Infof("Initiating a new user login.")
    48  		g.SetRedirectURL(getCallbackURI(returnURL, r))
    49  		state, err := generateRandomState(32)
    50  		if err != nil {
    51  			log.Errorf("Failed to generate a random state for OAuth: %v", err)
    52  			http.Error(w, "Failed to generate a random state for OAuth", http.StatusInternalServerError)
    53  			return
    54  		}
    55  
    56  		redirect = g.GetAuthCodeURL(state, oauth2.AccessTypeOnline)
    57  		err = setState(ctx, ds, state, w)
    58  		if err != nil {
    59  			log.Errorf("Failed to set state cookie for OAuth: %v", err)
    60  			http.Error(w, "Failed to set state cookie for OAuth", http.StatusInternalServerError)
    61  			return
    62  		}
    63  
    64  		log.Infof("OAuthing with github and returning to %s", returnURL)
    65  	} else {
    66  		if redirect == "" {
    67  			redirect = "/"
    68  		}
    69  		log.Infof("User %s is logged in", user.GitHubHandle)
    70  	}
    71  
    72  	http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
    73  }
    74  
    75  func oauthHandler(w http.ResponseWriter, r *http.Request) {
    76  	ctx := r.Context()
    77  	githuboauthImp, err := shared.NewGitHubOAuth(ctx)
    78  	if err != nil {
    79  		http.Error(w, "Error creating githuboauthImp", http.StatusInternalServerError)
    80  		return
    81  	}
    82  	handleOauth(githuboauthImp, w, r)
    83  }
    84  
    85  func handleOauth(g shared.GitHubOAuth, w http.ResponseWriter, r *http.Request) {
    86  	ctx := g.Context()
    87  	log := shared.GetLogger(ctx)
    88  	ds := g.Datastore()
    89  
    90  	encodedState := r.FormValue("state")
    91  	if encodedState == "" {
    92  		http.Error(w, "Missing URL param \"state\"", http.StatusBadRequest)
    93  		return
    94  	}
    95  
    96  	encryptedState, err := r.Cookie("state")
    97  	if err != nil || encryptedState == nil {
    98  		http.Error(w, "Missing cookie \"state\"", http.StatusBadRequest)
    99  		return
   100  	}
   101  	stateFromCookie, err := decodeState(ctx, ds, encryptedState)
   102  	if err != nil {
   103  		log.Errorf(err.Error())
   104  		http.Error(w, "Failed to decode state from cookies", http.StatusBadRequest)
   105  		return
   106  	}
   107  
   108  	if stateFromCookie == "" {
   109  		http.Error(w, "Failed to get state cookie", http.StatusBadRequest)
   110  		return
   111  	}
   112  
   113  	if encodedState != stateFromCookie {
   114  		http.Error(w, "Failed to verify encoded state", http.StatusBadRequest)
   115  		return
   116  	}
   117  
   118  	oauthCode := r.FormValue("code")
   119  	if oauthCode == "" {
   120  		http.Error(w, "No OAuth code provided", http.StatusBadRequest)
   121  		return
   122  	}
   123  
   124  	client, err := g.NewClient(oauthCode)
   125  	if err != nil {
   126  		log.Errorf("Error creating GitHub client using OAuth code: %v", err)
   127  		http.Error(w, "Error creating GitHub client using OAuth code", http.StatusBadRequest)
   128  		return
   129  	}
   130  
   131  	ghUser, err := g.GetUser(client)
   132  	if err != nil || ghUser == nil {
   133  		log.Errorf("Failed to get authenticated user: %v", err)
   134  		http.Error(w, "Failed to get authenticated user", http.StatusBadRequest)
   135  		return
   136  	}
   137  
   138  	user := &shared.User{
   139  		GitHubHandle: ghUser.GetLogin(),
   140  		GitHubEmail:  ghUser.GetEmail(),
   141  	}
   142  	token := g.GetAccessToken()
   143  	if token == "" {
   144  		http.Error(w, "Got empty OAuth access token", http.StatusBadRequest)
   145  		return
   146  	}
   147  	setSession(ctx, ds, user, token, w)
   148  	if err != nil {
   149  		http.Error(w, "Failed to set credential cookie", http.StatusInternalServerError)
   150  		return
   151  	}
   152  	log.Infof("User %s logged in", user.GitHubHandle)
   153  
   154  	ret := r.FormValue("return")
   155  	http.Redirect(w, r, ret, http.StatusTemporaryRedirect)
   156  }
   157  
   158  func logoutHandler(response http.ResponseWriter, r *http.Request) {
   159  	ctx := r.Context()
   160  	log := shared.GetLogger(ctx)
   161  	clearSession(response)
   162  
   163  	log.Infof("User logged out")
   164  	http.Redirect(response, r, "/", http.StatusFound)
   165  }
   166  
   167  func setSession(ctx context.Context, ds shared.Datastore, user *shared.User, token string, response http.ResponseWriter) error {
   168  	var err error
   169  	value := map[string]interface{}{
   170  		"user":  *user,
   171  		"token": token,
   172  	}
   173  
   174  	sc, err := shared.NewSecureCookie(ds)
   175  	if err != nil {
   176  		return fmt.Errorf("failed to create SecureCookie: %v", err)
   177  	}
   178  
   179  	if encoded, err := sc.Encode("session", value); err == nil {
   180  		cookie := &http.Cookie{
   181  			Name:     "session",
   182  			Value:    encoded,
   183  			Path:     "/",
   184  			MaxAge:   2592000,
   185  			HttpOnly: true,
   186  			Secure:   true,
   187  		}
   188  
   189  		// SameSite=None for http.Cookie is only available in Go.113;
   190  		// see https://github.com/golang/go/issues/32546.
   191  		if v := cookie.String(); v != "" {
   192  			response.Header().Add("Set-Cookie", v+"; SameSite=None")
   193  		}
   194  	} else {
   195  		log := shared.GetLogger(ctx)
   196  		log.Errorf("Failed to set session cookie: %v", err)
   197  	}
   198  
   199  	return err
   200  }
   201  
   202  func setState(ctx context.Context, ds shared.Datastore, state string, response http.ResponseWriter) error {
   203  	var err error
   204  	sc, err := shared.NewSecureCookie(ds)
   205  	if err != nil {
   206  		return fmt.Errorf("failed to create SecureCookie: %v", err)
   207  	}
   208  
   209  	if encoded, err := sc.Encode("state", state); err == nil {
   210  		cookie := &http.Cookie{
   211  			Name:     "state",
   212  			Value:    encoded,
   213  			Path:     "/",
   214  			MaxAge:   600,
   215  			Secure:   true,
   216  			HttpOnly: true,
   217  			SameSite: http.SameSiteLaxMode,
   218  		}
   219  		http.SetCookie(response, cookie)
   220  	}
   221  
   222  	return err
   223  }
   224  
   225  func decodeState(ctx context.Context, ds shared.Datastore, encryptedState *http.Cookie) (string, error) {
   226  	cookieValue := ""
   227  	sc, err := shared.NewSecureCookie(ds)
   228  	if err != nil {
   229  		return "", fmt.Errorf("failed to create SecureCookie: %v", err)
   230  	}
   231  
   232  	if err := sc.Decode("state", encryptedState.Value, &cookieValue); err != nil {
   233  		return "", fmt.Errorf("failed to decode state cookie: %v", err)
   234  	}
   235  	return cookieValue, nil
   236  }
   237  
   238  func clearSession(response http.ResponseWriter) {
   239  	cookie := &http.Cookie{
   240  		Name:   "session",
   241  		Value:  "",
   242  		Path:   "/",
   243  		MaxAge: -1,
   244  	}
   245  	http.SetCookie(response, cookie)
   246  }
   247  
   248  func generateRandomState(size int) (string, error) {
   249  	byteArray := make([]byte, size)
   250  	_, err := rand.Read(byteArray)
   251  	if err != nil {
   252  		return "", err
   253  	}
   254  
   255  	return base64.URLEncoding.EncodeToString(byteArray), nil
   256  }
   257  
   258  func getCallbackURI(ret string, r *http.Request) string {
   259  	callback := url.URL{Scheme: "https", Host: r.Host, Path: "oauth"}
   260  	q := callback.Query()
   261  	q.Set("return", ret)
   262  	callback.RawQuery = q.Encode()
   263  	return callback.String()
   264  }