Skip to content

Commit

Permalink
feat: add auth support via command (#129)
Browse files Browse the repository at this point in the history
Co-authored-by: Martin Kolárik <martin@kolarik.sk>
  • Loading branch information
radulucut and MartinKolarik authored Sep 22, 2024
1 parent e87cb79 commit abf3b23
Show file tree
Hide file tree
Showing 42 changed files with 1,732 additions and 346 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ Measurement Commands:
traceroute Run a traceroute test
Additional Commands:
auth Authenticate with the Globalping API
completion Generate the autocompletion script for the specified shell
help Help about any command
history Display the measurement history of your current session
Expand Down
141 changes: 141 additions & 0 deletions cmd/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package cmd

import (
"errors"
"syscall"

"github.com/jsdelivr/globalping-cli/globalping"
"github.com/spf13/cobra"
)

func (r *Root) initAuth() {
authCmd := &cobra.Command{
Use: "auth",
Short: "Authenticate with the Globalping API",
Long: "Authenticate with the Globalping API for higher measurements limits.",
}

loginCmd := &cobra.Command{
RunE: r.RunAuthLogin,
Use: "login",
Short: "Log in to your Globalping account",
Long: `Log in to your Globalping account for higher measurements limits.`,
}

loginFlags := loginCmd.Flags()
loginFlags.Bool("with-token", false, "authenticate with a token read from stdin instead of the default browser-based flow")

statusCmd := &cobra.Command{
RunE: r.RunAuthStatus,
Use: "status",
Short: "Check the current authentication status",
Long: `Check the current authentication status.`,
}

logoutCmd := &cobra.Command{
RunE: r.RunAuthLogout,
Use: "logout",
Short: "Log out from your Globalping account",
Long: `Log out from your Globalping account.`,
}

authCmd.AddCommand(loginCmd)
authCmd.AddCommand(statusCmd)
authCmd.AddCommand(logoutCmd)

r.Cmd.AddCommand(authCmd)
}

func (r *Root) RunAuthLogin(cmd *cobra.Command, args []string) error {
var err error
oldToken := r.storage.GetProfile().Token
withToken := cmd.Flags().Changed("with-token")
if withToken {
err := r.loginWithToken()
if err != nil {
return err
}
if oldToken != nil {
r.client.RevokeToken(oldToken.RefreshToken)
}
return nil
}
res, err := r.client.Authorize(func(e error) {
defer func() {
r.cancel <- syscall.SIGINT
}()
if e != nil {
err = e
r.Cmd.SilenceUsage = true
return
}
if oldToken != nil {
r.client.RevokeToken(oldToken.RefreshToken)
}
r.printer.Println("Success! You are now authenticated.")
})
if err != nil {
return err
}
r.printer.Println("Please visit the following URL to authenticate:")
r.printer.Println(res.AuthorizeURL)
r.utils.OpenBrowser(res.AuthorizeURL)
r.printer.Println("\nCan't use the browser-based flow? Use \"globalping auth login --with-token\" to read a token from stdin instead.")
<-r.cancel
return err
}

func (r *Root) RunAuthStatus(cmd *cobra.Command, args []string) error {
res, err := r.client.TokenIntrospection("")
if err != nil {
e, ok := err.(*globalping.AuthorizeError)
if ok && e.ErrorType == "not_authorized" {
r.printer.Println("Not logged in.")
return nil
}
return err
}
if res.Active {
r.printer.Printf("Logged in as %s.\n", res.Username)
} else {
r.printer.Println("Not logged in.")
}
return nil
}

func (r *Root) RunAuthLogout(cmd *cobra.Command, args []string) error {
err := r.client.Logout()
if err != nil {
return err
}
r.printer.Println("You are now logged out.")
return nil
}

func (r *Root) loginWithToken() error {
r.printer.Println("Please enter your token:")
token, err := r.printer.ReadPassword()
if err != nil {
return err
}
if token == "" {
return errors.New("empty token")
}
introspection, err := r.client.TokenIntrospection(token)
if err != nil {
return err
}
if !introspection.Active {
return errors.New("invalid token")
}
profile := r.storage.GetProfile()
profile.Token = &globalping.Token{
AccessToken: token,
}
err = r.storage.SaveConfig()
if err != nil {
return errors.New("failed to save token")
}
r.printer.Printf("Logged in as %s.\n", introspection.Username)
return nil
}
157 changes: 157 additions & 0 deletions cmd/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package cmd

import (
"bytes"
"context"
"os"
"syscall"
"testing"

"github.com/jsdelivr/globalping-cli/globalping"
"github.com/jsdelivr/globalping-cli/mocks"
"github.com/jsdelivr/globalping-cli/storage"
"github.com/jsdelivr/globalping-cli/view"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
)

func Test_Auth_Login_WithToken(t *testing.T) {
t.Cleanup(sessionCleanup)

ctrl := gomock.NewController(t)
defer ctrl.Finish()

gbMock := mocks.NewMockClient(ctrl)

w := new(bytes.Buffer)
r := new(bytes.Buffer)
r.WriteString("token\n")
printer := view.NewPrinter(r, w, w)
ctx := createDefaultContext("")
_storage := storage.NewLocalStorage(".test_globalping-cli")
defer _storage.Remove()
err := _storage.Init()
if err != nil {
t.Fatal(err)
}
_storage.GetProfile().Token = &globalping.Token{
AccessToken: "oldToken",
RefreshToken: "oldRefreshToken",
}

root := NewRoot(printer, ctx, nil, nil, gbMock, nil, _storage)

gbMock.EXPECT().TokenIntrospection("token").Return(&globalping.IntrospectionResponse{
Active: true,
Username: "test",
}, nil)
gbMock.EXPECT().RevokeToken("oldRefreshToken").Return(nil)

os.Args = []string{"globalping", "auth", "login", "--with-token"}
err = root.Cmd.ExecuteContext(context.TODO())
assert.NoError(t, err)

assert.Equal(t, `Please enter your token:
Logged in as test.
`, w.String())

profile := _storage.GetProfile()
assert.Equal(t, &storage.Profile{
Token: &globalping.Token{
AccessToken: "token",
},
}, profile)
}

func Test_Auth_Login(t *testing.T) {
t.Cleanup(sessionCleanup)

ctrl := gomock.NewController(t)
defer ctrl.Finish()

gbMock := mocks.NewMockClient(ctrl)
utilsMock := mocks.NewMockUtils(ctrl)

w := new(bytes.Buffer)
printer := view.NewPrinter(nil, w, w)
ctx := createDefaultContext("")
_storage := storage.NewLocalStorage(".test_globalping-cli")
defer _storage.Remove()
err := _storage.Init()
if err != nil {
t.Fatal(err)
}
_storage.GetProfile().Token = &globalping.Token{
AccessToken: "oldToken",
RefreshToken: "oldRefreshToken",
}

root := NewRoot(printer, ctx, nil, utilsMock, gbMock, nil, _storage)

gbMock.EXPECT().Authorize(gomock.Any()).Do(func(_ any) {
root.cancel <- syscall.SIGINT
}).Return(&globalping.AuthorizeResponse{
AuthorizeURL: "http://localhost",
}, nil)
utilsMock.EXPECT().OpenBrowser("http://localhost").Return(nil)

os.Args = []string{"globalping", "auth", "login"}
err = root.Cmd.ExecuteContext(context.TODO())
assert.NoError(t, err)

assert.Equal(t, `Please visit the following URL to authenticate:
http://localhost
Can't use the browser-based flow? Use "globalping auth login --with-token" to read a token from stdin instead.
`, w.String())
}

func Test_AuthStatus(t *testing.T) {
t.Cleanup(sessionCleanup)

ctrl := gomock.NewController(t)
defer ctrl.Finish()

gbMock := mocks.NewMockClient(ctrl)

w := new(bytes.Buffer)
printer := view.NewPrinter(nil, w, w)
ctx := createDefaultContext("")

root := NewRoot(printer, ctx, nil, nil, gbMock, nil, nil)

gbMock.EXPECT().TokenIntrospection("").Return(&globalping.IntrospectionResponse{
Active: true,
Username: "test",
}, nil)

os.Args = []string{"globalping", "auth", "status"}
err := root.Cmd.ExecuteContext(context.TODO())
assert.NoError(t, err)

assert.Equal(t, `Logged in as test.
`, w.String())
}

func Test_Logout(t *testing.T) {
t.Cleanup(sessionCleanup)

ctrl := gomock.NewController(t)
defer ctrl.Finish()

gbMock := mocks.NewMockClient(ctrl)

w := new(bytes.Buffer)
printer := view.NewPrinter(nil, w, w)
ctx := createDefaultContext("")

root := NewRoot(printer, ctx, nil, nil, gbMock, nil, nil)

gbMock.EXPECT().Logout().Return(nil)

os.Args = []string{"globalping", "auth", "logout"}
err := root.Cmd.ExecuteContext(context.TODO())
assert.NoError(t, err)

assert.Equal(t, "You are now logged out.\n", w.String())
}
21 changes: 21 additions & 0 deletions cmd/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/icza/backscanner"
"github.com/jsdelivr/globalping-cli/globalping"
"github.com/jsdelivr/globalping-cli/version"
"github.com/jsdelivr/globalping-cli/view"
"github.com/shirou/gopsutil/process"
)

Expand Down Expand Up @@ -114,6 +115,26 @@ func (r *Root) getLocations() ([]globalping.Locations, error) {
return locations, nil
}

func (r *Root) evaluateError(err error) {
if err == nil {
return
}
e, ok := err.(*globalping.MeasurementError)
if !ok {
return
}
if e.Code == globalping.StatusUnauthorizedWithTokenRefreshed {
r.Cmd.SilenceErrors = true
r.printer.ErrPrintln("Access token successfully refreshed. Try repeating the measurement.")
return
}
if e.Code == http.StatusTooManyRequests && r.ctx.MeasurementsCreated > 0 {
r.Cmd.SilenceErrors = true
r.printer.ErrPrintln(r.printer.Color("> "+e.Message, view.FGBrightYellow))
return
}
}

type TargetQuery struct {
Target string
From string
Expand Down
Loading

0 comments on commit abf3b23

Please sign in to comment.