Skip to content

Commit

Permalink
Added browser-based login
Browse files Browse the repository at this point in the history
  • Loading branch information
rykov committed Jul 6, 2024
1 parent cdd8905 commit d02deb5
Show file tree
Hide file tree
Showing 15 changed files with 450 additions and 79 deletions.
32 changes: 31 additions & 1 deletion api/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ func (c *Client) Logout(cc context.Context) error {
return req.doJSON(nil)
}

// Logout deletes the CLI token on the server
// Interactive login generates the CLI token on the server from username/password
func (c *Client) Login(cc context.Context, loginReq *LoginRequest) (*LoginResponse, error) {
req := c.newRequest(cc, "POST", "/login", false)

Expand All @@ -34,3 +34,33 @@ type LoginResponse struct {
Token string `json:"token"`
User AccountResponse `json:"user"`
}

// LoginCreate generates an URL used to approve a CLI login via browser authentication
func (c *Client) LoginCreate(cc context.Context) (*LoginCreateResponse, error) {
req := c.newRequest(cc, "POST", "/cli/auth", false)
resp := &LoginCreateResponse{}
err := req.doJSON(resp)
return resp, err
}

// LoginCreateResponse represents LoginCreate JSON response
type LoginCreateResponse struct {
BrowserURL string `json:"browser_url"`
CLIURL string `json:"cli_url"`
Token string `json:"token"`
}

// LoginGet waits for browser login and retrieves its results (token & user information)
func (c *Client) LoginGet(cc context.Context, create *LoginCreateResponse) (*LoginGetResponse, error) {
req := c.newRequest(cc, "GET", create.CLIURL, false)
req.Header.Set("Authorization", "Bearer "+create.Token)
resp := &LoginGetResponse{}
err := req.doJSON(resp)
return resp, err
}

// LoginGetResponse represents LoginGet JSON response
type LoginGetResponse struct {
Error string `json:"error"`
LoginResponse
}
5 changes: 4 additions & 1 deletion api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"io/ioutil"
"net/http"
"net/url"
"os"
"strings"
)

Expand Down Expand Up @@ -134,7 +135,9 @@ func (r *request) doCommon() (*http.Response, error) {
}

resp, err := r.conduit.Do(r.Request)
if err != nil {
if os.IsTimeout(err) {
return resp, ErrTimeout
} else if err != nil {
return resp, err
}

Expand Down
5 changes: 5 additions & 0 deletions api/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ var (
// ErrFuryServer is the error for 5xx server errors
ErrFuryServer = errors.New("Something went wrong. Please contact support.")

// ErrTimeout is the error for 408 from server or net timeout
ErrTimeout = errors.New("Operation timed out. Try again later.")

// ErrUnauthorized is the error for 401 from server
ErrUnauthorized = errors.New("Authentication failure")

Expand Down Expand Up @@ -60,6 +63,8 @@ func StatusCodeToError(s int) error {
return ErrForbidden
case s == 404:
return ErrNotFound
case s == 408:
return ErrTimeout
case s == 409:
return ErrConflict
case s >= 200 && s < 300:
Expand Down
115 changes: 101 additions & 14 deletions cli/api.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
package cli

import (
"github.com/cenkalti/backoff/v4"
"github.com/gemfury/cli/api"
"github.com/gemfury/cli/internal/ctx"
"github.com/gemfury/cli/pkg/terminal"
"github.com/manifoldco/promptui"
"github.com/spf13/cobra"

"context"
"errors"
"fmt"
"time"
)

// Initialize new Gemfury API client with authentication
Expand Down Expand Up @@ -44,21 +49,113 @@ func contextAuthToken(cc context.Context) (string, error) {

// Hook for root command to ensure user is authenticated or prompt to login
func preRunCheckAuthentication(cmd *cobra.Command, args []string) error {
if n := cmd.Name(); n == "logout" {
if n := cmd.Name(); n == "logout" || n == "login" {
return nil
}

_, err := ensureAuthenticated(cmd)
_, err := ensureAuthenticated(cmd, false)
return err
}

func ensureAuthenticated(cmd *cobra.Command) (*api.AccountResponse, error) {
func ensureAuthenticated(cmd *cobra.Command, interactive bool) (*api.AccountResponse, error) {
cc := cmd.Context()
var err error

// Check whether we have login credentials from environment
if token, err := contextAuthToken(cc); token != "" || err != nil {
return nil, err
}

// Browser or interactive login
var resp *api.LoginResponse
if interactive {
resp, err = interactiveLogin(cmd)
} else {
resp, err = browserLogin(cmd)
}
if err != nil {
return nil, err
}

// Save credentials to .netrc for future commands
err = ctx.Auther(cc).Append(resp.User.Email, resp.Token)
if err != nil {
return nil, err
}

return &resp.User, nil
}

// browserLogin is a challenge/response authentication via browser
func browserLogin(cmd *cobra.Command) (*api.LoginResponse, error) {
cc := cmd.Context()
term := ctx.Terminal(cc)

c, err := newAPIClient(cc)
if err != nil {
return nil, err
}

// Generate authentication URLs
createResp, err := c.LoginCreate(cc)
if err != nil {
return nil, err
} else if createResp.BrowserURL == "" {
return nil, fmt.Errorf("Internal error")
}

// Everything is ready. Confirm opening browser to login
anyKey := "Press any key to login via the browser or q to exit: "
if err := terminal.PromptAnyKeyOrQuit(term, anyKey); err != nil {
return nil, err
}

// Attempt to open the browser to create CLI token
term.Printf("Opening %s\n", createResp.BrowserURL)
if ok := term.OpenBrowser(createResp.BrowserURL); !ok {
term.Printf("Failed to open browser. You can continue CLI login by manually opening the URL\n")
}

// Start/end spinner while waiting for browser auth
onDone := terminal.SpinIfTerminal(term, " Waiting ...")
defer onDone()

// LoginGet will timeout, so we retry until a time limit.
// We do constant backoff with elapsed time limit that
// is shorter than the expiry of all the JWT tokens.
constantBackoff := backoff.NewExponentialBackOff(
backoff.WithMaxElapsedTime(3*time.Minute),
backoff.WithMultiplier(1.0),
)

// Repeatedly hit LoginGet API until results
var resp *api.LoginGetResponse
err = backoff.Retry(func() error {
resp, err = c.LoginGet(cc, createResp)
if !errors.Is(err, api.ErrTimeout) && !errors.Is(err, api.ErrNotFound) {
err = backoff.Permanent(err) // Retry only on timeout or not-found
}
return err
}, backoff.WithContext(constantBackoff, cc))

if resp == nil {
return nil, err
}

if resp.Error != "" {
err = fmt.Errorf(resp.Error)
} else if errors.Is(err, api.ErrNotFound) {
err = api.ErrTimeout
}

return &resp.LoginResponse, err
}

// interactiveLogin is an email/password authentication via terminal
func interactiveLogin(cmd *cobra.Command) (*api.LoginResponse, error) {
cc := cmd.Context()

// Interactive login
term := ctx.Terminal(cc)
term.Println("Please enter your Gemfury credentials.")

Expand All @@ -84,16 +181,6 @@ func ensureAuthenticated(cmd *cobra.Command) (*api.AccountResponse, error) {
if err == api.ErrUnauthorized {
cmd.SilenceErrors = true
cmd.SilenceUsage = true
return nil, err
} else if err != nil {
return nil, err
}

// Save credentials in .netrc
err = ctx.Auther(cc).Append(resp.User.Email, resp.Token)
if err != nil {
return nil, err
}

return &resp.User, nil
return resp, err
}
78 changes: 52 additions & 26 deletions cli/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ package cli

import (
"github.com/gemfury/cli/internal/ctx"
"github.com/gemfury/cli/pkg/terminal"
"github.com/manifoldco/promptui"
"github.com/spf13/cobra"

"context"
"errors"
)

// Machines for Gemfury in .netrc file
Expand All @@ -28,30 +32,12 @@ func NewCmdLogout() *cobra.Command {
return nil
}

prompt := promptui.Prompt{
Label: "Are you sure you want to logout? [y/N]",
Default: "N",
}

result, err := term.RunPrompt(&prompt)
if err != nil {
return err
}

if result != "y" && result != "Y" {
return nil
}

c, err := newAPIClient(cc)
if err != nil {
confirm := "Are you sure you want to logout? [y/N]"
if ok, err := terminal.PromptConfirm(term, confirm); !ok {
return err
}

if err := c.Logout(cc); err != nil {
return err
}

if err := ctx.Auther(cc).Wipe(); err != nil {
if err := logoutCurrent(cc, false); err != nil {
return err
}

Expand All @@ -63,36 +49,76 @@ func NewCmdLogout() *cobra.Command {
return logoutCmd
}

// Deactivates & deletes the saved CLI token, if present
func logoutCurrent(cc context.Context, isForLogin bool) error {
c, err := newAPIClient(cc)
if err != nil {
return err
}

if err := c.Logout(cc); err != nil {
confirm := "Do you want to remove credentials from .netrc anyway? [y/N]"
if isForLogin {
confirm = "Do you want to ignore & continue with your login? [y/N]"
}
term := ctx.Terminal(cc)
term.Printf("Error deactivating your old CLI credentials: %s\n", err)
if ok, _ := terminal.PromptConfirm(term, confirm); !ok {
return err
}
}

return ctx.Auther(cc).Wipe()
}

// NewCmdLogout invalidates session and wipes credentials
func NewCmdLogin() *cobra.Command {
var interactiveFlag bool

loginCmd := &cobra.Command{
Use: "login",
Short: "Authenticate into Gemfury account",
RunE: func(cmd *cobra.Command, args []string) error {
cc := cmd.Context()
auth := ctx.Auther(cc)

user, err := ensureAuthenticated(cmd)
if err != nil {
// Logout previous CLI token, if present in .netrc
if _, token, err := auth.Auth(); err == nil && token != "" {
if err := logoutCurrent(cc, true); err != nil {
return err
}
}

// Start browser or interactive authentication
user, err := ensureAuthenticated(cmd, interactiveFlag)
if errors.Is(err, promptui.ErrAbort) {
return nil // User-cancelled
} else if err != nil {
return err
}

// Verify auth
if user == nil {
user, err = whoAMI(cmd.Context())
user, err = whoAMI(cc)
if err != nil {
return err
}
}

term := ctx.Terminal(cmd.Context())
term := ctx.Terminal(cc)

if ctx.GlobalFlags(cmd.Context()).AuthToken != "" {
term.Printf("API token belongs to %q\n", user.Name)
} else {
term.Printf("You are logged in as %q\n", user.Name)
term.Printf("You are logged in as %q\n", user.Email)
}

return nil
},
}

// Flags and options
loginCmd.Flags().BoolVar(&interactiveFlag, "interactive", false, "Interactive login")

return loginCmd
}
Loading

0 comments on commit d02deb5

Please sign in to comment.