From d3ee49cd5a18d9ff61b9dc5d2ea4ca4b476fa80d Mon Sep 17 00:00:00 2001 From: Toni Kangas Date: Tue, 11 Feb 2025 23:13:35 +0200 Subject: [PATCH] feat(account): add login command --- go.mod | 5 ++ go.sum | 12 +++ internal/commands/account/login.go | 62 ++++++++++++++ .../commands/account/tokenreceiver/server.go | 85 +++++++++++++++++++ internal/commands/all/all.go | 1 + 5 files changed, 165 insertions(+) create mode 100644 internal/commands/account/login.go create mode 100644 internal/commands/account/tokenreceiver/server.go diff --git a/go.mod b/go.mod index 344fedda..2cc68ba8 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/UpCloudLtd/upcloud-go-api/v8 v8.14.0 github.com/adrg/xdg v0.3.2 github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d + github.com/cli/browser v1.3.0 github.com/gemalto/flume v0.12.0 github.com/jedib0t/go-pretty/v6 v6.4.9 github.com/m7shapan/cidr v0.0.0-20200427124835-7eba0889a5d2 @@ -15,6 +16,7 @@ require ( github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.7.1 github.com/stretchr/testify v1.9.0 + github.com/zalando/go-keyring v0.2.6 golang.org/x/crypto v0.31.0 golang.org/x/term v0.27.0 gopkg.in/yaml.v3 v3.0.1 @@ -22,10 +24,13 @@ require ( ) require ( + al.essio.dev/pkg/shellescape v1.5.1 // indirect github.com/ansel1/merry v1.5.0 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fsnotify/fsnotify v1.4.9 // indirect github.com/go-logr/logr v1.2.4 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/gofuzz v1.2.0 // indirect diff --git a/go.sum b/go.sum index 4afdf7d4..4a29dcc0 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= +al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= @@ -37,6 +39,8 @@ github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJm github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= +github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= @@ -44,6 +48,8 @@ github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -74,6 +80,8 @@ github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -105,6 +113,8 @@ github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXi github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= @@ -273,6 +283,8 @@ github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1 github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= +github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= diff --git a/internal/commands/account/login.go b/internal/commands/account/login.go new file mode 100644 index 00000000..c597e15a --- /dev/null +++ b/internal/commands/account/login.go @@ -0,0 +1,62 @@ +package account + +import ( + "fmt" + + "github.com/UpCloudLtd/progress/messages" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands/account/tokenreceiver" + "github.com/UpCloudLtd/upcloud-cli/v3/internal/output" + "github.com/zalando/go-keyring" +) + +// LoginCommand creates the "account login" command +func LoginCommand() commands.Command { + return &loginCommand{ + BaseCommand: commands.New( + "login", + "Configure a authentication token.", + "upctl account login", + ), + } +} + +type loginCommand struct { + *commands.BaseCommand +} + +// ExecuteWithoutArguments implements commands.NoArgumentCommand +func (s *loginCommand) ExecuteWithoutArguments(exec commands.Executor) (output.Output, error) { + msg := "Waiting to receive token from browser." + exec.PushProgressStarted(msg) + + receiver := tokenreceiver.New() + err := receiver.Start() + if err != nil { + return commands.HandleError(exec, msg, err) + } + + err = receiver.OpenBrowser() + if err != nil { + url := receiver.GetLoginURL() + exec.PushProgressUpdate(messages.Update{ + Message: "Failed to open browser.", + Status: messages.MessageStatusError, + Details: fmt.Sprintf("Please open a browser and navigate to %s to continue with the login.", url), + }) + } + + token, err := receiver.Wait(exec.Context()) + if err != nil { + return commands.HandleError(exec, msg, err) + } + + err = keyring.Set("UpCloud", "", token) + if err != nil { + return commands.HandleError(exec, msg, fmt.Errorf("failed to save token to keyring: %w", err)) + } + + exec.PushProgressSuccess(msg) + + return output.None{}, nil +} diff --git a/internal/commands/account/tokenreceiver/server.go b/internal/commands/account/tokenreceiver/server.go new file mode 100644 index 00000000..b1f7b832 --- /dev/null +++ b/internal/commands/account/tokenreceiver/server.go @@ -0,0 +1,85 @@ +package tokenreceiver + +import ( + "context" + "fmt" + "net" + "net/http" + "time" + + "github.com/cli/browser" +) + +type ReceiverServer struct { + server *http.Server + token string + port string +} + +func New() *ReceiverServer { + return &ReceiverServer{} +} + +func getPort(listener net.Listener) string { + _, port, _ := net.SplitHostPort(listener.Addr().String()) + return port +} + +func getURL(target string) string { + return fmt.Sprintf("http://localhost:3000/account/upctl-login/%s", target) +} + +func (s *ReceiverServer) GetLoginURL() string { + return getURL(s.port) +} + +func (s *ReceiverServer) Start() error { + handler := http.NewServeMux() + handler.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + token := req.URL.Query().Get("token") + if token == "" { + http.Redirect(w, req, getURL("error"), http.StatusSeeOther) + return + } + s.token = token + http.Redirect(w, req, getURL("success"), http.StatusSeeOther) + }) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return fmt.Errorf("failed to create receiver server: %w", err) + } + + go func() { + defer listener.Close() + s.server = &http.Server{ + Handler: handler, + ReadHeaderTimeout: time.Second, + } + _ = s.server.Serve(listener) + }() + s.port = getPort(listener) + return nil +} + +func (s *ReceiverServer) OpenBrowser() error { + return browser.OpenURL(s.GetLoginURL()) +} + +func (s *ReceiverServer) Wait(ctx context.Context) (string, error) { + ticker := time.NewTicker(time.Second * 2) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + _ = s.server.Shutdown(context.TODO()) + return "", ctx.Err() + case <-ticker.C: + if s.token != "" { + _ = s.server.Shutdown(context.TODO()) + return s.token, nil + } + } + } +} diff --git a/internal/commands/all/all.go b/internal/commands/all/all.go index 5515caa7..47507a0c 100644 --- a/internal/commands/all/all.go +++ b/internal/commands/all/all.go @@ -115,6 +115,7 @@ func BuildCommands(rootCmd *cobra.Command, conf *config.Config) { // Account accountCommand := commands.BuildCommand(account.BaseAccountCommand(), rootCmd, conf) commands.BuildCommand(account.ShowCommand(), accountCommand.Cobra(), conf) + commands.BuildCommand(account.LoginCommand(), accountCommand.Cobra(), conf) commands.BuildCommand(account.ListCommand(), accountCommand.Cobra(), conf) commands.BuildCommand(account.DeleteCommand(), accountCommand.Cobra(), conf)