Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add auth support via command #129

Merged
merged 10 commits into from
Sep 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.`,
}
radulucut marked this conversation as resolved.
Show resolved Hide resolved

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:")
radulucut marked this conversation as resolved.
Show resolved Hide resolved
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.")
}
radulucut marked this conversation as resolved.
Show resolved Hide resolved
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