From 199203354ed079c3592589b06508b08d0c4aaa33 Mon Sep 17 00:00:00 2001 From: Jake Runzer Date: Tue, 20 Oct 2020 15:06:31 -0600 Subject: [PATCH] Browserless Login (#33) --- cmd/login.go | 11 +++- controller/user.go | 125 ++++++++++++++++++++++++++++++++++++++++----- gateway/user.go | 33 ++++++++++++ main.go | 8 ++- 4 files changed, 161 insertions(+), 16 deletions(-) diff --git a/cmd/login.go b/cmd/login.go index f49433769..21ca49c80 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -9,10 +9,17 @@ import ( ) func (h *Handler) Login(ctx context.Context, req *entity.CommandRequest) error { - user, err := h.ctrl.Login(ctx) + isBrowserless, err := req.Cmd.Flags().GetBool("browserless") if err != nil { return err } - ui.StopSpinner(fmt.Sprintf("🎉 Logged in as %s (%s)", ui.Bold(user.Name), user.Email)) + + user, err := h.ctrl.Login(ctx, isBrowserless) + if err != nil { + return err + } + + fmt.Printf(fmt.Sprintf("🎉 Logged in as %s (%s)", ui.Bold(user.Name), user.Email)) + return nil } diff --git a/controller/user.go b/controller/user.go index 2c6898470..b09efe9f6 100644 --- a/controller/user.go +++ b/controller/user.go @@ -10,6 +10,7 @@ import ( "os" "strconv" "sync" + "time" "github.com/pkg/browser" configs "github.com/railwayapp/cli/configs" @@ -32,6 +33,9 @@ type LoginResponse struct { Error string `json:"error,omitempty"` } +const maxAttempts = 2 * 60 +const pollInterval = 1 * time.Second + func (c *Controller) GetUser(ctx context.Context) (*entity.User, error) { userCfg, err := c.cfg.GetUserConfigs() if err != nil { @@ -43,14 +47,17 @@ func (c *Controller) GetUser(ctx context.Context) (*entity.User, error) { return c.gtwy.GetUser(ctx) } -func (c *Controller) Login(ctx context.Context) (*entity.User, error) { +func (c *Controller) browserBasedLogin(ctx context.Context) (*entity.User, error) { var token string var returnedCode string port, err := c.randomizer.Port() + if err != nil { return nil, err } + code := c.randomizer.Code() + wg := &sync.WaitGroup{} wg.Add(1) go func() { @@ -58,6 +65,7 @@ func (c *Controller) Login(ctx context.Context) (*entity.User, error) { srv := &http.Server{Addr: strconv.Itoa(port)} http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", getAPIURL()) + if r.Method == http.MethodGet { w.Header().Set("Content-Type", "application/json") token = r.URL.Query().Get("token") @@ -73,8 +81,10 @@ func (c *Controller) Login(ctx context.Context) (*entity.User, error) { w.Write(byteRes) return } + res := LoginResponse{Status: loginSuccessResponse} byteRes, err := json.Marshal(&res) + if err != nil { fmt.Println(err) } @@ -87,28 +97,103 @@ func (c *Controller) Login(ctx context.Context) (*entity.User, error) { w.WriteHeader(204) return } + wg.Done() + if err := srv.Shutdown(ctx); err != nil { fmt.Println(err) } }) + http.ListenAndServe(fmt.Sprintf("localhost:%d", port), nil) }() - url := getLoginURL(port, code) - confirmBrowserOpen("Logging in...", url) + url := getBrowserBasedLoginURL(port, code) + err = confirmBrowserOpen("Logging in...", url) + + if err != nil { + // Opening the browser failed. Try browserless login + return c.browserlessLogin(ctx) + } wg.Wait() + + if code != returnedCode { + return nil, errors.New("Login failed") + } + + err = c.cfg.SetUserConfigs(&entity.UserConfig{ + Token: token, + }) + if err != nil { + return nil, err + } + + user, err := c.gtwy.GetUser(ctx) + if err != nil { + return nil, err + } + + return user, nil +} + +func (c *Controller) pollForToken(ctx context.Context, code string) (string, error) { + var count = 0 + for count < maxAttempts { + token, err := c.gtwy.ConsumeLoginSession(ctx, code) + + if err != nil { + return "", errors.New("Login failed") + } + + if token != "" { + return token, nil + } + + count++ + time.Sleep(pollInterval) + } + + return "", errors.New("Login timeout") +} + +func (c *Controller) browserlessLogin(ctx context.Context) (*entity.User, error) { + wordCode, err := c.gtwy.CreateLoginSession(ctx) + if err != nil { + return nil, err + } + + url := getBrowserlessLoginURL(wordCode) + + fmt.Printf("Your pairing code is: %s\n", wordCode) + fmt.Printf("To authenticate with Railway, please go to \n %s\n", url) + + token, err := c.pollForToken(ctx, wordCode) + if err != nil { + return nil, err + } + err = c.cfg.SetUserConfigs(&entity.UserConfig{ Token: token, }) if err != nil { return nil, err } - if code == returnedCode { - return c.gtwy.GetUser(ctx) + + user, err := c.gtwy.GetUser(ctx) + if err != nil { + return nil, err + } + + return user, nil +} + +func (c *Controller) Login(ctx context.Context, isBrowserless bool) (*entity.User, error) { + if isBrowserless || isSSH() { + return c.browserlessLogin(ctx) } - return nil, nil + + return c.browserBasedLogin(ctx) } func (c *Controller) Logout(ctx context.Context) error { @@ -138,19 +223,21 @@ func (c *Controller) IsLoggedIn(ctx context.Context) (bool, error) { return isLoggedIn, nil } -func confirmBrowserOpen(spinnerMsg string, url string) { +func confirmBrowserOpen(spinnerMsg string, url string) error { fmt.Printf("Press Enter to open the browser (^C to quit)") fmt.Fscanln(os.Stdin) ui.StartSpinner(&ui.SpinnerCfg{ Message: spinnerMsg, }) + err := browser.OpenURL(url) + if err != nil { - ui.StopSpinner(fmt.Sprintf("Failed to open browser, please go to %s manually.", url)) - ui.StartSpinner(&ui.SpinnerCfg{ - Message: spinnerMsg, - }) + ui.StopSpinner(fmt.Sprintf("Failed to open browser, attempting browserless login.", url)) + return err } + + return nil } func getAPIURL() string { @@ -160,8 +247,22 @@ func getAPIURL() string { return baseRailwayURL } -func getLoginURL(port int, code string) string { +func getBrowserBasedLoginURL(port int, code string) string { buffer := b64.URLEncoding.EncodeToString([]byte(fmt.Sprintf("port=%d&code=%s", port, code))) url := fmt.Sprintf("%s/cli-login?d=%s", getAPIURL(), buffer) return url } + +func getBrowserlessLoginURL(wordCode string) string { + buffer := b64.URLEncoding.EncodeToString([]byte(fmt.Sprintf("wordCode=%s", wordCode))) + url := fmt.Sprintf("%s/cli-login?d=%s", getAPIURL(), buffer) + return url +} + +func isSSH() bool { + if os.Getenv("SSH_TTY") != "" || os.Getenv("SSH_CONNECTION") != "" || os.Getenv("SSH_CLIENT") != "" { + return true + } + + return false +} diff --git a/gateway/user.go b/gateway/user.go index c36020d5f..d4e714289 100644 --- a/gateway/user.go +++ b/gateway/user.go @@ -32,3 +32,36 @@ func (g *Gateway) GetUser(ctx context.Context) (*entity.User, error) { } return resp.User, nil } + +func (g *Gateway) CreateLoginSession(ctx context.Context) (string, error) { + gqlReq := gql.NewRequest(`mutation { createLoginSession } `) + + var resp struct { + Code string `json:"createLoginSession"` + } + + if err := g.gqlClient.Run(ctx, gqlReq, &resp); err != nil { + return "", err + } + + return resp.Code, nil +} + +func (g *Gateway) ConsumeLoginSession(ctx context.Context, code string) (string, error) { + gqlReq := gql.NewRequest(` + mutation($code: String!) { + consumeLoginSession(code: $code) + } + `) + gqlReq.Var("code", code) + + var resp struct { + Token string `json:"consumeLoginSession"` + } + + if err := g.gqlClient.Run(ctx, gqlReq, &resp); err != nil { + return "", err + } + + return resp.Token, nil +} diff --git a/main.go b/main.go index bd2a66a86..2c5484fa4 100644 --- a/main.go +++ b/main.go @@ -42,11 +42,15 @@ func contextualize(fn entity.HandlerFunction) entity.CobraFunction { func init() { // Initializes all commands handler := cmd.New() - rootCmd.AddCommand(&cobra.Command{ + + loginCmd := &cobra.Command{ Use: "login", Short: "Login to Railway", RunE: contextualize(handler.Login), - }) + } + loginCmd.Flags().Bool("browserless", false, "--browserless") + + rootCmd.AddCommand(loginCmd) rootCmd.AddCommand(&cobra.Command{ Use: "logout", Short: "Logout of Railway",