From eea84cd487b48a05a1775b929e177093eb7cd685 Mon Sep 17 00:00:00 2001 From: Guillaume Tardif Date: Mon, 11 May 2020 14:39:43 +0200 Subject: [PATCH 1/9] move context cmd to its own folder ; initial `docker context login` command --- cli/cmd/context/context.go | 2 ++ cli/cmd/context/login/login.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 cli/cmd/context/login/login.go diff --git a/cli/cmd/context/context.go b/cli/cmd/context/context.go index aea5e7bf..25209193 100644 --- a/cli/cmd/context/context.go +++ b/cli/cmd/context/context.go @@ -28,6 +28,7 @@ package context import ( + "github.com/docker/api/cli/cmd/context/login" "github.com/spf13/cobra" cliopts "github.com/docker/api/cli/options" @@ -45,6 +46,7 @@ func Command(opts *cliopts.GlobalOpts) *cobra.Command { listCommand(), removeCommand(), useCommand(opts), + login.Command(), ) return cmd diff --git a/cli/cmd/context/login/login.go b/cli/cmd/context/login/login.go new file mode 100644 index 00000000..7ee9a49b --- /dev/null +++ b/cli/cmd/context/login/login.go @@ -0,0 +1,28 @@ +package login + +import ( + "github.com/spf13/cobra" +) + +// Command returns the compose command with its child commands +func Command() *cobra.Command { + command := &cobra.Command{ + Short: "Cloud login for docker contexts", + Use: "login", + } + command.AddCommand( + azureLoginCommand(), + ) + return command +} + +func azureLoginCommand() *cobra.Command { + azureLoginCmd := &cobra.Command{ + Use: "azure", + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, + } + + return azureLoginCmd +} From 1e19d977e045edbe798978c13019512ed2c21554 Mon Sep 17 00:00:00 2001 From: Guillaume Tardif Date: Tue, 12 May 2020 13:37:28 +0200 Subject: [PATCH 2/9] Initial functional login command : added Cloud API with generic Login() --- azure/backend.go | 15 ++ azure/login/login.go | 311 +++++++++++++++++++++++++++++++++ azure/login/tokenStore.go | 43 +++++ backend/backend.go | 2 + cli/cmd/context/login/login.go | 10 +- client/client.go | 6 + context/cloud/api.go | 9 + example/backend.go | 5 + go.mod | 4 + go.sum | 1 + moby/backend.go | 5 + 11 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 azure/login/login.go create mode 100644 azure/login/tokenStore.go create mode 100644 context/cloud/api.go diff --git a/azure/backend.go b/azure/backend.go index f1fedddf..17337823 100644 --- a/azure/backend.go +++ b/azure/backend.go @@ -3,6 +3,7 @@ package azure import ( "context" "fmt" + "github.com/docker/api/context/cloud" "io" "net/http" "strconv" @@ -15,6 +16,7 @@ import ( "github.com/sirupsen/logrus" "github.com/docker/api/azure/convert" + "github.com/docker/api/azure/login" "github.com/docker/api/backend" "github.com/docker/api/compose" "github.com/docker/api/containers" @@ -67,12 +69,14 @@ func getAciAPIService(cgc containerinstance.ContainerGroupsClient, aciCtx store. containerGroupsClient: cgc, ctx: aciCtx, }, + aciCloudService: aciCloudService{}, } } type aciAPIService struct { aciContainerService aciComposeService + aciCloudService } func (a *aciAPIService) ContainerService() containers.Service { @@ -89,6 +93,10 @@ func (a *aciAPIService) ComposeService() compose.Service { } } +func (a *aciAPIService) CloudService() cloud.Service { + return &aciCloudService{} +} + type aciContainerService struct { containerGroupsClient containerinstance.ContainerGroupsClient ctx store.AciContext @@ -266,3 +274,10 @@ func (cs *aciComposeService) Down(ctx context.Context, opts compose.ProjectOptio return err } + +type aciCloudService struct { +} + +func (cs *aciCloudService) Login(ctx context.Context, params map[string]string) error { + return login.Login() +} diff --git a/azure/login/login.go b/azure/login/login.go new file mode 100644 index 00000000..eac0bc91 --- /dev/null +++ b/azure/login/login.go @@ -0,0 +1,311 @@ +package login + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "math/rand" + "net/http" + "net/http/httputil" + "net/url" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "time" + + "github.com/Azure/go-autorest/autorest" + "github.com/Azure/go-autorest/autorest/adal" + "github.com/Azure/go-autorest/autorest/azure/cli" + "github.com/Azure/go-autorest/autorest/date" + "golang.org/x/oauth2" + + "github.com/pkg/errors" +) + +//go login process, derived from code sample provided by MS at https://github.com/devigned/go-az-cli-stuff +const ( + authorizeFormat = "https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize?response_type=code&client_id=%s&redirect_uri=%s&state=%s&prompt=select_account&response_mode=query&scope=%s" + tokenEndpoint = "https://login.microsoftonline.com/%s/oauth2/v2.0/token" + // scopes for a multi-tenant app works for openid, email, other common scopes, but fails when trying to add a token + // v1 scope like "https://management.azure.com/.default" for ARM access + scopes = "offline_access https://management.azure.com/.default" + clientID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" // Azure CLI client id +) + +type ( + Token struct { + Type string `json:"token_type"` + Scope string `json:"scope"` + ExpiresIn int `json:"expires_in"` + ExtExpiresIn int `json:"ext_expires_in"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Foci string `json:"foci"` + } + + TenantResult struct { + Value []TenantValue `json:"value"` + } + TenantValue struct { + TenantID string `json:"tenantId"` + } +) + +//AzureLogin login through browser +func Login() error { + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + + queryCh := make(chan url.Values, 1) + queryHandler := func(w http.ResponseWriter, r *http.Request) { + queryCh <- r.URL.Query() + _, hasCode := r.URL.Query()["code"] + if hasCode { + w.Write([]byte(` + + + + + + Login successfully + + +

You have logged into Microsoft Azure!

+

You can close this window, or we will redirect you to the Azure CLI documents in 10 seconds.

+ + + `)) + } else { + w.Write([]byte(` + + + + + Login failed + + +

Some failures occurred during the authentication

+

You can log an issue at Azure CLI GitHub Repository and we will assist you in resolving it.

+ + +`)) + } + } + + mux := http.NewServeMux() + mux.HandleFunc("/", queryHandler) + server := &http.Server{Addr: ":8401", Handler: mux} + go func() { + if err := server.ListenAndServe(); err != nil { + fmt.Println(fmt.Errorf("error starting http server with: %w", err)) + os.Exit(1) + } + }() + + state := RandomString("", 10) + //nonce := RandomString("", 10) + authURL := fmt.Sprintf(authorizeFormat, clientID, "http://localhost:8401", state, scopes) + openbrowser(authURL) + + select { + case <-sigs: + return nil + case qsValues := <-queryCh: + code, hasCode := qsValues["code"] + if !hasCode { + return fmt.Errorf("Authentication Error : Login failed") + } + data := url.Values{ + "grant_type": []string{"authorization_code"}, + "client_id": []string{clientID}, + "code": code, + "scope": []string{scopes}, + "redirect_uri": []string{"http://localhost:8401"}, + } + token, err := queryToken(data, "organizations") + if err != nil { + return errors.Wrap(err, "Access token request failed") + } + + req, err := http.NewRequest(http.MethodGet, "https://management.azure.com/tenants?api-version=2019-11-01", nil) + if err != nil { + return err + } + + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken)) + res, err := http.DefaultClient.Do(req) + if err != nil { + return errors.Wrap(err, "Authentication Error") + } + + bits, err := ioutil.ReadAll(res.Body) + if err != nil { + return errors.Wrap(err, "Authentication Error") + } + + if res.StatusCode == 200 { + var tenantResult TenantResult + if err := json.Unmarshal(bits, &tenantResult); err != nil { + return errors.Wrap(err, "Authentication Error") + } + tenantID := tenantResult.Value[0].TenantID + tenantToken, err := refreshToken(token.RefreshToken, tenantID) + if err != nil { + return errors.Wrap(err, "Authentication Error") + } + loginInfo := LoginInfo{TenantID: tenantID, Token: tenantToken} + + store := NewTokenStore(getTokenPath()) + err = store.writeLoginInfo(loginInfo) + + if err != nil { + return errors.Wrap(err, "Authentication Error") + } + fmt.Println("Successfully logged in") + + return nil + } + + bits, err = httputil.DumpResponse(res, true) + if err != nil { + return errors.Wrap(err, "Authentication Error") + } + + return fmt.Errorf("Authentication Error: \n" + string(bits)) + } +} + +func queryToken(data url.Values, tenantID string) (token Token, err error) { + res, err := http.Post(fmt.Sprintf(tokenEndpoint, tenantID), "application/x-www-form-urlencoded", strings.NewReader(data.Encode())) + if err != nil { + return token, err + } + if res.StatusCode != 200 { + return token, err + } + bits, err := ioutil.ReadAll(res.Body) + if err != nil { + return token, err + } + if err := json.Unmarshal(bits, &token); err != nil { + return token, err + } + return token, nil +} + +func toOAuthToken(token Token) oauth2.Token { + expireTime := time.Now().Add(time.Duration(token.ExtExpiresIn) * time.Second) + oauthToken := oauth2.Token{ + RefreshToken: token.RefreshToken, + AccessToken: token.AccessToken, + Expiry: expireTime, + TokenType: token.Type, + } + return oauthToken +} + +const tokenFilename = "dockerAccessToken.json" + +func getTokenPath() string { + cliPath, _ := cli.AccessTokensPath() + + return filepath.Join(filepath.Dir(cliPath), tokenFilename) +} + +func NewAuthorizerFromLogin() (autorest.Authorizer, error) { + oauthToken, err := GetValidToken() + if err != nil { + return nil, err + } + + difference := oauthToken.Expiry.Sub(date.UnixEpoch()) + + token := adal.Token{ + AccessToken: oauthToken.AccessToken, + Type: oauthToken.TokenType, + ExpiresIn: "3600", + ExpiresOn: json.Number(strconv.Itoa(int(difference.Seconds()))), + RefreshToken: "", + Resource: "", + } + + return autorest.NewBearerAuthorizer(&token), nil +} + +func GetValidToken() (token oauth2.Token, err error) { + store := NewTokenStore(getTokenPath()) + loginInfo, err := store.readToken() + if err != nil { + return token, err + } + token = loginInfo.Token + if token.Valid() { + return token, nil + } + tenantID := loginInfo.TenantID + token, err = refreshToken(token.RefreshToken, tenantID) + if err != nil { + return token, errors.Wrap(err, "Access token request failed. Maybe you need to login to azure again.") + } + err = store.writeLoginInfo(LoginInfo{TenantID: tenantID, Token: token}) + if err != nil { + return token, err + } + return token, nil +} + +func refreshToken(currentRefreshToken string, tenantID string) (oauthToken oauth2.Token, err error) { + data := url.Values{ + "grant_type": []string{"refresh_token"}, + "client_id": []string{clientID}, + "scope": []string{scopes}, + "refresh_token": []string{currentRefreshToken}, + } + token, err := queryToken(data, tenantID) + if err != nil { + return oauthToken, err + } + + return toOAuthToken(token), nil +} + +func openbrowser(url string) { + var err error + + switch runtime.GOOS { + case "linux": + err = exec.Command("xdg-open", url).Start() + case "windows": + err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + err = exec.Command("open", url).Start() + default: + err = fmt.Errorf("unsupported platform") + } + if err != nil { + log.Fatal(err) + } +} + +var ( + letterRunes = []rune("abcdefghijklmnopqrstuvwxyz123456789") +) + +func init() { + rand.Seed(time.Now().Unix()) +} + +// RandomString generates a random string with prefix +func RandomString(prefix string, length int) string { + b := make([]rune, length) + for i := range b { + b[i] = letterRunes[rand.Intn(len(letterRunes))] + } + return prefix + string(b) +} diff --git a/azure/login/tokenStore.go b/azure/login/tokenStore.go new file mode 100644 index 00000000..3cefdda9 --- /dev/null +++ b/azure/login/tokenStore.go @@ -0,0 +1,43 @@ +package login + +import ( + "encoding/json" + "io/ioutil" + + "golang.org/x/oauth2" +) + +type TokenStore struct { + filePath string +} + +type LoginInfo struct { + Token oauth2.Token `json:"oauthToken"` + TenantID string `json:"tenantId"` +} + +func NewTokenStore(filePath string) TokenStore { + return TokenStore{ + filePath: filePath, + } +} + +func (store TokenStore) writeLoginInfo(info LoginInfo) error { + bytes, err := json.MarshalIndent(info, "", " ") + if err != nil { + return err + } + ioutil.WriteFile(store.filePath, bytes, 0644) + return nil +} + +func (store TokenStore) readToken() (loginInfo LoginInfo, err error) { + bytes, err := ioutil.ReadFile(store.filePath) + if err != nil { + return loginInfo, err + } + if err := json.Unmarshal(bytes, &loginInfo); err != nil { + return loginInfo, err + } + return loginInfo, nil +} diff --git a/backend/backend.go b/backend/backend.go index 45b46097..b76ae6f5 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -9,6 +9,7 @@ import ( "github.com/docker/api/compose" "github.com/docker/api/containers" + "github.com/docker/api/context/cloud" ) var ( @@ -33,6 +34,7 @@ var backends = struct { type Service interface { ContainerService() containers.Service ComposeService() compose.Service + CloudService() cloud.Service } // Register adds a typed backend to the registry diff --git a/cli/cmd/context/login/login.go b/cli/cmd/context/login/login.go index 7ee9a49b..4e113834 100644 --- a/cli/cmd/context/login/login.go +++ b/cli/cmd/context/login/login.go @@ -2,6 +2,9 @@ package login import ( "github.com/spf13/cobra" + "github.com/pkg/errors" + "github.com/docker/api/client" + apicontext "github.com/docker/api/context" ) // Command returns the compose command with its child commands @@ -20,7 +23,12 @@ func azureLoginCommand() *cobra.Command { azureLoginCmd := &cobra.Command{ Use: "azure", RunE: func(cmd *cobra.Command, args []string) error { - return nil + ctx := apicontext.WithCurrentContext(cmd.Context(), "aci") + c, err := client.New(ctx) + if err != nil { + return errors.Wrap(err, "cannot connect to backend") + } + return c.CloudService().Login(ctx, nil) }, } diff --git a/client/client.go b/client/client.go index 88be5448..b5326381 100644 --- a/client/client.go +++ b/client/client.go @@ -29,6 +29,7 @@ package client import ( "context" + "github.com/docker/api/context/cloud" "github.com/docker/api/backend" backendv1 "github.com/docker/api/backend/v1" @@ -84,3 +85,8 @@ func (c *Client) ContainerService() containers.Service { func (c *Client) ComposeService() compose.Service { return c.bs.ComposeService() } + +// CloudService returns the backend service for the current context +func (c *Client) CloudService() cloud.Service { + return c.bs.CloudService() +} diff --git a/context/cloud/api.go b/context/cloud/api.go new file mode 100644 index 00000000..0eecce22 --- /dev/null +++ b/context/cloud/api.go @@ -0,0 +1,9 @@ +package cloud + +import "context" + +type Service interface { + // Login login to cloud provider + Login(ctx context.Context, params map[string]string) error +} + diff --git a/example/backend.go b/example/backend.go index e167191a..b9ce20dd 100644 --- a/example/backend.go +++ b/example/backend.go @@ -3,6 +3,7 @@ package example import ( "context" "fmt" + "github.com/docker/api/context/cloud" "io" "github.com/docker/api/backend" @@ -23,6 +24,10 @@ func (a *apiService) ComposeService() compose.Service { return &a.composeService } +func (a *apiService) CloudService() cloud.Service { + return nil +} + func init() { backend.Register("example", "example", func(ctx context.Context) (backend.Service, error) { return &apiService{}, nil diff --git a/go.mod b/go.mod index be5addf8..3997d323 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,10 @@ require ( github.com/Azure/azure-sdk-for-go v42.0.0+incompatible github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 // indirect github.com/Azure/go-autorest/autorest v0.10.0 + github.com/Azure/go-autorest/autorest/adal v0.8.2 github.com/Azure/go-autorest/autorest/azure/auth v0.4.2 + github.com/Azure/go-autorest/autorest/azure/cli v0.3.1 + github.com/Azure/go-autorest/autorest/date v0.2.0 github.com/Azure/go-autorest/autorest/to v0.3.0 github.com/Azure/go-autorest/autorest/validation v0.2.0 // indirect github.com/Microsoft/go-winio v0.4.14 // indirect @@ -35,6 +38,7 @@ require ( github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.5.1 golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0 + golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be golang.org/x/text v0.3.2 // indirect google.golang.org/grpc v1.29.1 google.golang.org/protobuf v1.21.0 diff --git a/go.sum b/go.sum index 78d3d2d5..e44b75c6 100644 --- a/go.sum +++ b/go.sum @@ -275,6 +275,7 @@ golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0 h1:Jcxah/M+oLZ/R4/z5RzfPzGbPXnVDPkEDtf2JnuxN+U= golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/moby/backend.go b/moby/backend.go index b9c1304c..66fc52bd 100644 --- a/moby/backend.go +++ b/moby/backend.go @@ -2,6 +2,7 @@ package moby import ( "context" + "github.com/docker/api/context/cloud" "io" "github.com/docker/docker/api/types" @@ -45,6 +46,10 @@ func (ms *mobyService) ComposeService() compose.Service { return nil } +func (ms *mobyService) CloudService() cloud.Service { + return nil +} + func (ms *mobyService) List(ctx context.Context) ([]containers.Container, error) { css, err := ms.apiClient.ContainerList(ctx, types.ContainerListOptions{ All: false, From 69f10fe80ce0e0d7a97944aaa2d002f43d8ba402 Mon Sep 17 00:00:00 2001 From: Guillaume Tardif Date: Tue, 12 May 2020 17:26:11 +0200 Subject: [PATCH 3/9] Extract interface / types to allow unit tests / mock --- .golangci.yml | 2 +- azure/backend.go | 22 ++-- azure/login/login.go | 234 ++++++++++++++++++++------------- azure/login/login_test.go | 113 ++++++++++++++++ azure/login/tokenStore.go | 18 +-- cli/cmd/context/login/login.go | 1 + errdefs/errors.go | 2 + go.sum | 4 + 8 files changed, 278 insertions(+), 118 deletions(-) create mode 100644 azure/login/login_test.go diff --git a/.golangci.yml b/.golangci.yml index cf8a7151..1267dab9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -2,7 +2,7 @@ linters: run: concurrency: 2 skip-dirs: - - composefiles + - tests/composefiles enable-all: false disable-all: true enable: diff --git a/azure/backend.go b/azure/backend.go index 17337823..14cb8189 100644 --- a/azure/backend.go +++ b/azure/backend.go @@ -3,12 +3,13 @@ package azure import ( "context" "fmt" - "github.com/docker/api/context/cloud" "io" "net/http" "strconv" "strings" + "github.com/docker/api/context/cloud" + "github.com/Azure/azure-sdk-for-go/services/containerinstance/mgmt/2018-10-01/containerinstance" "github.com/Azure/go-autorest/autorest/azure/auth" "github.com/compose-spec/compose-go/types" @@ -69,7 +70,9 @@ func getAciAPIService(cgc containerinstance.ContainerGroupsClient, aciCtx store. containerGroupsClient: cgc, ctx: aciCtx, }, - aciCloudService: aciCloudService{}, + aciCloudService: aciCloudService{ + loginService: login.NewAzureLoginService(), + }, } } @@ -80,21 +83,15 @@ type aciAPIService struct { } func (a *aciAPIService) ContainerService() containers.Service { - return &aciContainerService{ - containerGroupsClient: a.aciContainerService.containerGroupsClient, - ctx: a.aciContainerService.ctx, - } + return &a.aciContainerService } func (a *aciAPIService) ComposeService() compose.Service { - return &aciComposeService{ - containerGroupsClient: a.aciComposeService.containerGroupsClient, - ctx: a.aciComposeService.ctx, - } + return &a.aciComposeService } func (a *aciAPIService) CloudService() cloud.Service { - return &aciCloudService{} + return &a.aciCloudService } type aciContainerService struct { @@ -276,8 +273,9 @@ func (cs *aciComposeService) Down(ctx context.Context, opts compose.ProjectOptio } type aciCloudService struct { + loginService login.AzureLoginService } func (cs *aciCloudService) Login(ctx context.Context, params map[string]string) error { - return login.Login() + return cs.loginService.Login() } diff --git a/azure/login/login.go b/azure/login/login.go index eac0bc91..f671574d 100644 --- a/azure/login/login.go +++ b/azure/login/login.go @@ -19,6 +19,8 @@ import ( "syscall" "time" + "github.com/docker/api/errdefs" + "github.com/Azure/go-autorest/autorest" "github.com/Azure/go-autorest/autorest/adal" "github.com/Azure/go-autorest/autorest/azure/cli" @@ -28,6 +30,10 @@ import ( "github.com/pkg/errors" ) +func init() { + rand.Seed(time.Now().Unix()) +} + //go login process, derived from code sample provided by MS at https://github.com/devigned/go-az-cli-stuff const ( authorizeFormat = "https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize?response_type=code&client_id=%s&redirect_uri=%s&state=%s&prompt=select_account&response_mode=query&scope=%s" @@ -39,7 +45,7 @@ const ( ) type ( - Token struct { + azureToken struct { Type string `json:"token_type"` Scope string `json:"scope"` ExpiresIn int `json:"expires_in"` @@ -49,67 +55,65 @@ type ( Foci string `json:"foci"` } - TenantResult struct { - Value []TenantValue `json:"value"` + tenantResult struct { + Value []tenantValue `json:"value"` } - TenantValue struct { + tenantValue struct { TenantID string `json:"tenantId"` } ) -//AzureLogin login through browser -func Login() error { +// AzureLoginService Service to log into azure and get authentifier for azure APIs +type AzureLoginService struct { + tokenStore tokenStore + apiHelper apiHelper +} + +const tokenFilename = "dockerAccessToken.json" + +func getTokenStorePath() string { + cliPath, _ := cli.AccessTokensPath() + return filepath.Join(filepath.Dir(cliPath), tokenFilename) +} + +// NewAzureLoginService creates a NewAzureLoginService +func NewAzureLoginService() AzureLoginService { + return newAzureLoginServiceFromPath(getTokenStorePath(), azureAPIHelper{}) +} + +func newAzureLoginServiceFromPath(tokenStorePath string, helper apiHelper) AzureLoginService { + return AzureLoginService{ + tokenStore: tokenStore{ + filePath: tokenStorePath, + }, + apiHelper: helper, + } +} + +type apiHelper interface { + queryToken(data url.Values, tenantID string) (token azureToken, err error) +} + +type azureAPIHelper struct{} + +//Login perform azure login through browser +func (login AzureLoginService) Login() error { sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) queryCh := make(chan url.Values, 1) - queryHandler := func(w http.ResponseWriter, r *http.Request) { - queryCh <- r.URL.Query() - _, hasCode := r.URL.Query()["code"] - if hasCode { - w.Write([]byte(` - - - - - - Login successfully - - -

You have logged into Microsoft Azure!

-

You can close this window, or we will redirect you to the Azure CLI documents in 10 seconds.

- - - `)) - } else { - w.Write([]byte(` - - - - - Login failed - - -

Some failures occurred during the authentication

-

You can log an issue at Azure CLI GitHub Repository and we will assist you in resolving it.

- - -`)) - } - } - mux := http.NewServeMux() - mux.HandleFunc("/", queryHandler) + mux.HandleFunc("/", queryHandler(queryCh)) server := &http.Server{Addr: ":8401", Handler: mux} go func() { if err := server.ListenAndServe(); err != nil { - fmt.Println(fmt.Errorf("error starting http server with: %w", err)) - os.Exit(1) + queryCh <- url.Values{ + "error": []string{fmt.Sprintf("error starting http server with: %v", err)}, + } } }() - state := RandomString("", 10) - //nonce := RandomString("", 10) + state := randomString("", 10) authURL := fmt.Sprintf(authorizeFormat, clientID, "http://localhost:8401", state, scopes) openbrowser(authURL) @@ -117,9 +121,13 @@ func Login() error { case <-sigs: return nil case qsValues := <-queryCh: + errorMsg, hasError := qsValues["error"] + if hasError { + return fmt.Errorf("login failed : %s", errorMsg) + } code, hasCode := qsValues["code"] if !hasCode { - return fmt.Errorf("Authentication Error : Login failed") + return errdefs.ErrLoginFailed } data := url.Values{ "grant_type": []string{"authorization_code"}, @@ -128,7 +136,7 @@ func Login() error { "scope": []string{scopes}, "redirect_uri": []string{"http://localhost:8401"}, } - token, err := queryToken(data, "organizations") + token, err := login.apiHelper.queryToken(data, "organizations") if err != nil { return errors.Wrap(err, "Access token request failed") } @@ -141,53 +149,78 @@ func Login() error { req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken)) res, err := http.DefaultClient.Do(req) if err != nil { - return errors.Wrap(err, "Authentication Error") + return errors.Wrap(err, "login failed") } bits, err := ioutil.ReadAll(res.Body) if err != nil { - return errors.Wrap(err, "Authentication Error") + return errors.Wrap(err, "login failed") } if res.StatusCode == 200 { - var tenantResult TenantResult + var tenantResult tenantResult if err := json.Unmarshal(bits, &tenantResult); err != nil { - return errors.Wrap(err, "Authentication Error") + return errors.Wrap(err, "login failed") } tenantID := tenantResult.Value[0].TenantID - tenantToken, err := refreshToken(token.RefreshToken, tenantID) + tenantToken, err := login.refreshToken(token.RefreshToken, tenantID) if err != nil { - return errors.Wrap(err, "Authentication Error") + return errors.Wrap(err, "login failed") } - loginInfo := LoginInfo{TenantID: tenantID, Token: tenantToken} + loginInfo := TokenInfo{TenantID: tenantID, Token: tenantToken} - store := NewTokenStore(getTokenPath()) - err = store.writeLoginInfo(loginInfo) + err = login.tokenStore.writeLoginInfo(loginInfo) if err != nil { - return errors.Wrap(err, "Authentication Error") + return errors.Wrap(err, "login failed") } - fmt.Println("Successfully logged in") + fmt.Println("Login Succeeded") return nil } bits, err = httputil.DumpResponse(res, true) if err != nil { - return errors.Wrap(err, "Authentication Error") + return errors.Wrap(err, "login failed") } - return fmt.Errorf("Authentication Error: \n" + string(bits)) + return fmt.Errorf("login failed: \n" + string(bits)) } } -func queryToken(data url.Values, tenantID string) (token Token, err error) { +func queryHandler(queryCh chan url.Values) func(w http.ResponseWriter, r *http.Request) { + queryHandler := func(w http.ResponseWriter, r *http.Request) { + _, hasCode := r.URL.Query()["code"] + if hasCode { + _, err := w.Write([]byte(successfullLoginHTML)) + if err != nil { + queryCh <- url.Values{ + "error": []string{err.Error()}, + } + } else { + queryCh <- r.URL.Query() + } + } else { + _, err := w.Write([]byte(loginFailedHTML)) + if err != nil { + queryCh <- url.Values{ + "error": []string{err.Error()}, + } + } else { + queryCh <- r.URL.Query() + } + } + } + return queryHandler +} + +func (helper azureAPIHelper) queryToken(data url.Values, tenantID string) (token azureToken, err error) { res, err := http.Post(fmt.Sprintf(tokenEndpoint, tenantID), "application/x-www-form-urlencoded", strings.NewReader(data.Encode())) if err != nil { return token, err } if res.StatusCode != 200 { - return token, err + return token, errors.Errorf("error while renewing access token, status : %s", res.Status) } bits, err := ioutil.ReadAll(res.Body) if err != nil { @@ -199,8 +232,8 @@ func queryToken(data url.Values, tenantID string) (token Token, err error) { return token, nil } -func toOAuthToken(token Token) oauth2.Token { - expireTime := time.Now().Add(time.Duration(token.ExtExpiresIn) * time.Second) +func toOAuthToken(token azureToken) oauth2.Token { + expireTime := time.Now().Add(time.Duration(token.ExpiresIn) * time.Second) oauthToken := oauth2.Token{ RefreshToken: token.RefreshToken, AccessToken: token.AccessToken, @@ -210,27 +243,18 @@ func toOAuthToken(token Token) oauth2.Token { return oauthToken } -const tokenFilename = "dockerAccessToken.json" - -func getTokenPath() string { - cliPath, _ := cli.AccessTokensPath() - - return filepath.Join(filepath.Dir(cliPath), tokenFilename) -} - -func NewAuthorizerFromLogin() (autorest.Authorizer, error) { - oauthToken, err := GetValidToken() +// NewAuthorizerFromLogin creates an authorizer based on login access token +func (login AzureLoginService) NewAuthorizerFromLogin() (autorest.Authorizer, error) { + oauthToken, err := login.GetValidToken() if err != nil { return nil, err } - difference := oauthToken.Expiry.Sub(date.UnixEpoch()) - token := adal.Token{ AccessToken: oauthToken.AccessToken, Type: oauthToken.TokenType, - ExpiresIn: "3600", - ExpiresOn: json.Number(strconv.Itoa(int(difference.Seconds()))), + ExpiresIn: json.Number(strconv.Itoa(int(oauthToken.Expiry.Sub(time.Now()).Seconds()))), + ExpiresOn: json.Number(strconv.Itoa(int(oauthToken.Expiry.Sub(date.UnixEpoch()).Seconds()))), RefreshToken: "", Resource: "", } @@ -238,9 +262,9 @@ func NewAuthorizerFromLogin() (autorest.Authorizer, error) { return autorest.NewBearerAuthorizer(&token), nil } -func GetValidToken() (token oauth2.Token, err error) { - store := NewTokenStore(getTokenPath()) - loginInfo, err := store.readToken() +// GetValidToken returns an access token. Refresh token if needed +func (login AzureLoginService) GetValidToken() (token oauth2.Token, err error) { + loginInfo, err := login.tokenStore.readToken() if err != nil { return token, err } @@ -249,25 +273,25 @@ func GetValidToken() (token oauth2.Token, err error) { return token, nil } tenantID := loginInfo.TenantID - token, err = refreshToken(token.RefreshToken, tenantID) + token, err = login.refreshToken(token.RefreshToken, tenantID) if err != nil { - return token, errors.Wrap(err, "Access token request failed. Maybe you need to login to azure again.") + return token, errors.Wrap(err, "access token request failed. Maybe you need to login to azure again.") } - err = store.writeLoginInfo(LoginInfo{TenantID: tenantID, Token: token}) + err = login.tokenStore.writeLoginInfo(TokenInfo{TenantID: tenantID, Token: token}) if err != nil { return token, err } return token, nil } -func refreshToken(currentRefreshToken string, tenantID string) (oauthToken oauth2.Token, err error) { +func (login AzureLoginService) refreshToken(currentRefreshToken string, tenantID string) (oauthToken oauth2.Token, err error) { data := url.Values{ "grant_type": []string{"refresh_token"}, "client_id": []string{clientID}, "scope": []string{scopes}, "refresh_token": []string{currentRefreshToken}, } - token, err := queryToken(data, tenantID) + token, err := login.apiHelper.queryToken(data, tenantID) if err != nil { return oauthToken, err } @@ -297,15 +321,39 @@ var ( letterRunes = []rune("abcdefghijklmnopqrstuvwxyz123456789") ) -func init() { - rand.Seed(time.Now().Unix()) -} - -// RandomString generates a random string with prefix -func RandomString(prefix string, length int) string { +func randomString(prefix string, length int) string { b := make([]rune, length) for i := range b { b[i] = letterRunes[rand.Intn(len(letterRunes))] } return prefix + string(b) } + +const loginFailedHTML = ` + + + + + Login failed + + +

Some failures occurred during the authentication

+

You can log an issue at Azure CLI GitHub Repository and we will assist you in resolving it.

+ + + ` + +const successfullLoginHTML = ` + + + + + + Login successfully + + +

You have logged into Microsoft Azure!

+

You can close this window, or we will redirect you to the Azure CLI documents in 10 seconds.

+ + + ` diff --git a/azure/login/login_test.go b/azure/login/login_test.go new file mode 100644 index 00000000..0295d505 --- /dev/null +++ b/azure/login/login_test.go @@ -0,0 +1,113 @@ +package login + +import ( + "io/ioutil" + "net/url" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "golang.org/x/oauth2" + + . "github.com/onsi/gomega" +) + +type LoginSuiteTest struct { + suite.Suite + dir string + mockHelper MockAzureHelper + azureLogin AzureLoginService +} + +func (suite *LoginSuiteTest) BeforeTest(suiteName, testName string) { + dir, err := ioutil.TempDir("", "test_store") + require.Nil(suite.T(), err) + + suite.dir = dir + suite.mockHelper = MockAzureHelper{} + //nolint copylocks + suite.azureLogin = newAzureLoginServiceFromPath(filepath.Join(dir, tokenFilename), suite.mockHelper) +} + +func (suite *LoginSuiteTest) AfterTest(suiteName, testName string) { + err := os.RemoveAll(suite.dir) + require.Nil(suite.T(), err) +} + +func (suite *LoginSuiteTest) TestRefreshInValidToken() { + data := url.Values{ + "grant_type": []string{"refresh_token"}, + "client_id": []string{clientID}, + "scope": []string{scopes}, + "refresh_token": []string{"refreshToken"}, + } + suite.mockHelper.On("queryToken", data, "123456").Return(azureToken{ + RefreshToken: "newRefreshToken", + AccessToken: "newAccessToken", + ExpiresIn: 3600, + Foci: "1", + }, nil) + + //nolint copylocks + suite.azureLogin = newAzureLoginServiceFromPath(filepath.Join(suite.dir, tokenFilename), suite.mockHelper) + err := suite.azureLogin.tokenStore.writeLoginInfo(TokenInfo{ + TenantID: "123456", + Token: oauth2.Token{ + AccessToken: "accessToken", + RefreshToken: "refreshToken", + Expiry: time.Now().Add(-1 * time.Hour), + TokenType: "Bearer", + }, + }) + Expect(err).To(BeNil()) + + token, _ := suite.azureLogin.GetValidToken() + + Expect(token.AccessToken).To(Equal("newAccessToken")) + Expect(token.Expiry).To(BeTemporally(">", time.Now().Add(3500*time.Second))) + + storedToken, _ := suite.azureLogin.tokenStore.readToken() + Expect(storedToken.Token.AccessToken).To(Equal("newAccessToken")) + Expect(storedToken.Token.RefreshToken).To(Equal("newRefreshToken")) + Expect(storedToken.Token.Expiry).To(BeTemporally(">", time.Now().Add(3500*time.Second))) +} + +func (suite *LoginSuiteTest) TestDoesNotRefreshValidToken() { + expiryDate := time.Now().Add(1 * time.Hour) + err := suite.azureLogin.tokenStore.writeLoginInfo(TokenInfo{ + TenantID: "123456", + Token: oauth2.Token{ + AccessToken: "accessToken", + RefreshToken: "refreshToken", + Expiry: expiryDate, + TokenType: "Bearer", + }, + }) + Expect(err).To(BeNil()) + + token, _ := suite.azureLogin.GetValidToken() + + Expect(suite.mockHelper.Calls).To(BeEmpty()) + Expect(token.AccessToken).To(Equal("accessToken")) +} + +func TestLoginSuite(t *testing.T) { + RegisterTestingT(t) + suite.Run(t, new(LoginSuiteTest)) +} + +type MockAzureHelper struct { + mock.Mock +} + +//nolint copylocks +func (s MockAzureHelper) queryToken(data url.Values, tenantID string) (token azureToken, err error) { + args := s.Called(data, tenantID) + return args.Get(0).(azureToken), args.Error(1) +} diff --git a/azure/login/tokenStore.go b/azure/login/tokenStore.go index 3cefdda9..8e0b7dac 100644 --- a/azure/login/tokenStore.go +++ b/azure/login/tokenStore.go @@ -7,31 +7,25 @@ import ( "golang.org/x/oauth2" ) -type TokenStore struct { +type tokenStore struct { filePath string } -type LoginInfo struct { +// TokenInfo data stored in tokenStore +type TokenInfo struct { Token oauth2.Token `json:"oauthToken"` TenantID string `json:"tenantId"` } -func NewTokenStore(filePath string) TokenStore { - return TokenStore{ - filePath: filePath, - } -} - -func (store TokenStore) writeLoginInfo(info LoginInfo) error { +func (store tokenStore) writeLoginInfo(info TokenInfo) error { bytes, err := json.MarshalIndent(info, "", " ") if err != nil { return err } - ioutil.WriteFile(store.filePath, bytes, 0644) - return nil + return ioutil.WriteFile(store.filePath, bytes, 0644) } -func (store TokenStore) readToken() (loginInfo LoginInfo, err error) { +func (store tokenStore) readToken() (loginInfo TokenInfo, err error) { bytes, err := ioutil.ReadFile(store.filePath) if err != nil { return loginInfo, err diff --git a/cli/cmd/context/login/login.go b/cli/cmd/context/login/login.go index 4e113834..7ab0d51b 100644 --- a/cli/cmd/context/login/login.go +++ b/cli/cmd/context/login/login.go @@ -3,6 +3,7 @@ package login import ( "github.com/spf13/cobra" "github.com/pkg/errors" + "github.com/docker/api/client" apicontext "github.com/docker/api/context" ) diff --git a/errdefs/errors.go b/errdefs/errors.go index 1bd21d9f..e01ac4ef 100644 --- a/errdefs/errors.go +++ b/errdefs/errors.go @@ -40,6 +40,8 @@ var ( ErrForbidden = errors.New("forbidden") // ErrUnknown is returned when the error type is unmapped ErrUnknown = errors.New("unknown") + // ErrLoginFailed is returned when login failed + ErrLoginFailed = errors.New("login failed") ) // IsNotFoundError returns true if the unwrapped error is ErrNotFound diff --git a/go.sum b/go.sum index e44b75c6..84dad30e 100644 --- a/go.sum +++ b/go.sum @@ -3,10 +3,12 @@ github.com/Azure/azure-sdk-for-go v42.0.0+incompatible h1:yz6sFf5bHZ+gEOQVuK5JhP github.com/Azure/azure-sdk-for-go v42.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= +github.com/Azure/go-autorest v14.1.0+incompatible h1:qROrS0rWxAXGfFdNOI33we8553d7T8v78jXf/8tjLBM= github.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI= github.com/Azure/go-autorest/autorest v0.9.3/go.mod h1:GsRuLYvwzLjjjRoWEIyMUaYq8GNUx2nRB378IPt/1p0= github.com/Azure/go-autorest/autorest v0.10.0 h1:mvdtztBqcL8se7MdrUweNieTNi4kfNG6GOJuurQJpuY= github.com/Azure/go-autorest/autorest v0.10.0/go.mod h1:/FALq9T/kS7b5J5qsQ+RSTUdAmGFqi0vUdVNNx8q630= +github.com/Azure/go-autorest/autorest v0.10.1 h1:uaB8A32IZU9YKs9v50+/LWIWTDHJk2vlGzbfd7FfESI= github.com/Azure/go-autorest/autorest/adal v0.5.0/go.mod h1:8Z9fGy2MpX0PvDjB1pEgQTmVqjGhiHBW7RJJEciWzS0= github.com/Azure/go-autorest/autorest/adal v0.8.0/go.mod h1:Z6vX6WXXuyieHAXwMj0S6HY6e6wcHn37qQMBQlvY3lc= github.com/Azure/go-autorest/autorest/adal v0.8.1/go.mod h1:ZjhuQClTqx435SRJ2iMlOxPYt3d2C/T/7TiQCVZSn3Q= @@ -235,6 +237,7 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -311,6 +314,7 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55 h1:gSJIx1SDwno+2ElGhA4+qG2zF97qiUzTM+rQ0klBOcE= From bd5e3af2d4ed3c7062242af22c514fbccdef07f5 Mon Sep 17 00:00:00 2001 From: Guillaume Tardif Date: Tue, 12 May 2020 23:00:58 +0200 Subject: [PATCH 4/9] Plug new authorizer and remove az dependency --- azure/aci.go | 20 ++++++-------------- azure/backend.go | 9 +++------ azure/login/login.go | 2 +- 3 files changed, 10 insertions(+), 21 deletions(-) diff --git a/azure/aci.go b/azure/aci.go index 964a2ca1..252a80e2 100644 --- a/azure/aci.go +++ b/azure/aci.go @@ -6,14 +6,14 @@ import ( "io" "io/ioutil" "net/http" - "os" "strings" "time" + "github.com/docker/api/azure/login" + "github.com/Azure/azure-sdk-for-go/profiles/2019-03-01/resources/mgmt/resources" "github.com/Azure/azure-sdk-for-go/profiles/preview/preview/subscription/mgmt/subscription" "github.com/Azure/azure-sdk-for-go/services/containerinstance/mgmt/2018-10-01/containerinstance" - "github.com/Azure/azure-sdk-for-go/services/keyvault/auth" "github.com/Azure/go-autorest/autorest" "github.com/Azure/go-autorest/autorest/to" tm "github.com/buger/goterm" @@ -24,14 +24,6 @@ import ( "github.com/docker/api/context/store" ) -func init() { - // required to get auth.NewAuthorizerFromCLI() to work, otherwise getting "The access token has been obtained for wrong audience or resource 'https://vault.azure.net'." - err := os.Setenv("AZURE_KEYVAULT_RESOURCE", "https://management.azure.com") - if err != nil { - panic("unable to set environment variable AZURE_KEYVAULT_RESOURCE") - } -} - func createACIContainers(ctx context.Context, aciContext store.AciContext, groupDefinition containerinstance.ContainerGroup) error { containerGroupsClient, err := getContainerGroupsClient(aciContext.SubscriptionID) if err != nil { @@ -243,7 +235,7 @@ func getACIContainerLogs(ctx context.Context, aciContext store.AciContext, conta } func getContainerGroupsClient(subscriptionID string) (containerinstance.ContainerGroupsClient, error) { - auth, err := auth.NewAuthorizerFromCLI() + auth, err := login.NewAzureLoginService().NewAuthorizerFromLogin() if err != nil { return containerinstance.ContainerGroupsClient{}, err } @@ -256,7 +248,7 @@ func getContainerGroupsClient(subscriptionID string) (containerinstance.Containe } func getContainerClient(subscriptionID string) (containerinstance.ContainerClient, error) { - auth, err := auth.NewAuthorizerFromCLI() + auth, err := login.NewAzureLoginService().NewAuthorizerFromLogin() if err != nil { return containerinstance.ContainerClient{}, err } @@ -267,7 +259,7 @@ func getContainerClient(subscriptionID string) (containerinstance.ContainerClien func getSubscriptionsClient() subscription.SubscriptionsClient { subc := subscription.NewSubscriptionsClient() - authorizer, _ := auth.NewAuthorizerFromCLI() + authorizer, _ := login.NewAzureLoginService().NewAuthorizerFromLogin() subc.Authorizer = authorizer return subc } @@ -275,7 +267,7 @@ func getSubscriptionsClient() subscription.SubscriptionsClient { // GetGroupsClient ... func GetGroupsClient(subscriptionID string) resources.GroupsClient { groupsClient := resources.NewGroupsClient(subscriptionID) - authorizer, _ := auth.NewAuthorizerFromCLI() + authorizer, _ := login.NewAzureLoginService().NewAuthorizerFromLogin() groupsClient.Authorizer = authorizer return groupsClient } diff --git a/azure/backend.go b/azure/backend.go index 14cb8189..1e15200d 100644 --- a/azure/backend.go +++ b/azure/backend.go @@ -11,7 +11,6 @@ import ( "github.com/docker/api/context/cloud" "github.com/Azure/azure-sdk-for-go/services/containerinstance/mgmt/2018-10-01/containerinstance" - "github.com/Azure/go-autorest/autorest/azure/auth" "github.com/compose-spec/compose-go/types" "github.com/pkg/errors" "github.com/sirupsen/logrus" @@ -53,7 +52,7 @@ func New(ctx context.Context) (backend.Service, error) { } aciContext, _ := metadata.Metadata.Data.(store.AciContext) - auth, _ := auth.NewAuthorizerFromCLI() + auth, _ := login.NewAzureLoginService().NewAuthorizerFromLogin() containerGroupsClient := containerinstance.NewContainerGroupsClient(aciContext.SubscriptionID) containerGroupsClient.Authorizer = auth @@ -67,8 +66,7 @@ func getAciAPIService(cgc containerinstance.ContainerGroupsClient, aciCtx store. ctx: aciCtx, }, aciComposeService: aciComposeService{ - containerGroupsClient: cgc, - ctx: aciCtx, + ctx: aciCtx, }, aciCloudService: aciCloudService{ loginService: login.NewAzureLoginService(), @@ -236,8 +234,7 @@ func (cs *aciContainerService) Delete(ctx context.Context, containerID string, _ } type aciComposeService struct { - containerGroupsClient containerinstance.ContainerGroupsClient - ctx store.AciContext + ctx store.AciContext } func (cs *aciComposeService) Up(ctx context.Context, opts compose.ProjectOptions) error { diff --git a/azure/login/login.go b/azure/login/login.go index f671574d..06d9b55a 100644 --- a/azure/login/login.go +++ b/azure/login/login.go @@ -253,7 +253,7 @@ func (login AzureLoginService) NewAuthorizerFromLogin() (autorest.Authorizer, er token := adal.Token{ AccessToken: oauthToken.AccessToken, Type: oauthToken.TokenType, - ExpiresIn: json.Number(strconv.Itoa(int(oauthToken.Expiry.Sub(time.Now()).Seconds()))), + ExpiresIn: json.Number(strconv.Itoa(int(time.Until(oauthToken.Expiry).Seconds()))), ExpiresOn: json.Number(strconv.Itoa(int(oauthToken.Expiry.Sub(date.UnixEpoch()).Seconds()))), RefreshToken: "", Resource: "", From d49773e3484b3fa9bf1206d2c8fd12ee56790635 Mon Sep 17 00:00:00 2001 From: Guillaume Tardif Date: Wed, 13 May 2020 16:58:00 +0200 Subject: [PATCH 5/9] Use cli context for login --- azure/backend.go | 2 +- azure/login/login.go | 13 ++++--------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/azure/backend.go b/azure/backend.go index 1e15200d..8cda586e 100644 --- a/azure/backend.go +++ b/azure/backend.go @@ -274,5 +274,5 @@ type aciCloudService struct { } func (cs *aciCloudService) Login(ctx context.Context, params map[string]string) error { - return cs.loginService.Login() + return cs.loginService.Login(ctx) } diff --git a/azure/login/login.go b/azure/login/login.go index 06d9b55a..dc4b727f 100644 --- a/azure/login/login.go +++ b/azure/login/login.go @@ -1,6 +1,7 @@ package login import ( + "context" "encoding/json" "fmt" "io/ioutil" @@ -9,14 +10,11 @@ import ( "net/http" "net/http/httputil" "net/url" - "os" "os/exec" - "os/signal" "path/filepath" "runtime" "strconv" "strings" - "syscall" "time" "github.com/docker/api/errdefs" @@ -91,16 +89,13 @@ func newAzureLoginServiceFromPath(tokenStorePath string, helper apiHelper) Azure } type apiHelper interface { - queryToken(data url.Values, tenantID string) (token azureToken, err error) + queryToken(data url.Values, tenantID string) (azureToken, error) } type azureAPIHelper struct{} //Login perform azure login through browser -func (login AzureLoginService) Login() error { - sigs := make(chan os.Signal, 1) - signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) - +func (login AzureLoginService) Login(ctx context.Context) error { queryCh := make(chan url.Values, 1) mux := http.NewServeMux() mux.HandleFunc("/", queryHandler(queryCh)) @@ -118,7 +113,7 @@ func (login AzureLoginService) Login() error { openbrowser(authURL) select { - case <-sigs: + case <-ctx.Done(): return nil case qsValues := <-queryCh: errorMsg, hasError := qsValues["error"] From 8b116b7c73a53e86fac770f13acf05a1b32c64e8 Mon Sep 17 00:00:00 2001 From: Guillaume Tardif Date: Wed, 13 May 2020 17:12:23 +0200 Subject: [PATCH 6/9] get an available port for login localhost server, instead of hardcoded port --- azure/login/login.go | 48 +++++++++++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/azure/login/login.go b/azure/login/login.go index dc4b727f..f14ddf93 100644 --- a/azure/login/login.go +++ b/azure/login/login.go @@ -7,6 +7,7 @@ import ( "io/ioutil" "log" "math/rand" + "net" "net/http" "net/http/httputil" "net/url" @@ -97,20 +98,13 @@ type azureAPIHelper struct{} //Login perform azure login through browser func (login AzureLoginService) Login(ctx context.Context) error { queryCh := make(chan url.Values, 1) - mux := http.NewServeMux() - mux.HandleFunc("/", queryHandler(queryCh)) - server := &http.Server{Addr: ":8401", Handler: mux} - go func() { - if err := server.ListenAndServe(); err != nil { - queryCh <- url.Values{ - "error": []string{fmt.Sprintf("error starting http server with: %v", err)}, - } - } - }() + serverPort, err := startLoginServer(queryCh) + if err != nil { + return err + } - state := randomString("", 10) - authURL := fmt.Sprintf(authorizeFormat, clientID, "http://localhost:8401", state, scopes) - openbrowser(authURL) + redirectURL := "http://localhost:" + strconv.Itoa(serverPort) + openAzureLoginPage(redirectURL) select { case <-ctx.Done(): @@ -129,7 +123,7 @@ func (login AzureLoginService) Login(ctx context.Context) error { "client_id": []string{clientID}, "code": code, "scope": []string{scopes}, - "redirect_uri": []string{"http://localhost:8401"}, + "redirect_uri": []string{redirectURL}, } token, err := login.apiHelper.queryToken(data, "organizations") if err != nil { @@ -183,6 +177,32 @@ func (login AzureLoginService) Login(ctx context.Context) error { } } +func startLoginServer(queryCh chan url.Values) (int, error) { + mux := http.NewServeMux() + mux.HandleFunc("/", queryHandler(queryCh)) + listener, err := net.Listen("tcp", ":0") + if err != nil { + return 0, err + } + + availablePort := listener.Addr().(*net.TCPAddr).Port + server := &http.Server{Handler: mux} + go func() { + if err := server.Serve(listener); err != nil { + queryCh <- url.Values{ + "error": []string{fmt.Sprintf("error starting http server with: %v", err)}, + } + } + }() + return availablePort, nil +} + +func openAzureLoginPage(redirectURL string) { + state := randomString("", 10) + authURL := fmt.Sprintf(authorizeFormat, clientID, redirectURL, state, scopes) + openbrowser(authURL) +} + func queryHandler(queryCh chan url.Values) func(w http.ResponseWriter, r *http.Request) { queryHandler := func(w http.ResponseWriter, r *http.Request) { _, hasCode := r.URL.Query()["code"] From 146dd3e63901b973e0f8f51c8858ada4a8922968 Mon Sep 17 00:00:00 2001 From: Guillaume Tardif Date: Wed, 13 May 2020 23:33:16 +0200 Subject: [PATCH 7/9] Fix tokenStore not creating ~/.azure folder if not exist --- azure/aci.go | 8 ++--- azure/backend.go | 14 +++++---- azure/login/login.go | 51 ++++++++++++++++++-------------- azure/login/login_test.go | 15 +++++----- azure/login/tokenStore.go | 31 +++++++++++++++++-- azure/login/tokenStore_test.go | 54 ++++++++++++++++++++++++++++++++++ 6 files changed, 132 insertions(+), 41 deletions(-) create mode 100644 azure/login/tokenStore_test.go diff --git a/azure/aci.go b/azure/aci.go index 252a80e2..79907d3b 100644 --- a/azure/aci.go +++ b/azure/aci.go @@ -235,7 +235,7 @@ func getACIContainerLogs(ctx context.Context, aciContext store.AciContext, conta } func getContainerGroupsClient(subscriptionID string) (containerinstance.ContainerGroupsClient, error) { - auth, err := login.NewAzureLoginService().NewAuthorizerFromLogin() + auth, err := login.NewAuthorizerFromLogin() if err != nil { return containerinstance.ContainerGroupsClient{}, err } @@ -248,7 +248,7 @@ func getContainerGroupsClient(subscriptionID string) (containerinstance.Containe } func getContainerClient(subscriptionID string) (containerinstance.ContainerClient, error) { - auth, err := login.NewAzureLoginService().NewAuthorizerFromLogin() + auth, err := login.NewAuthorizerFromLogin() if err != nil { return containerinstance.ContainerClient{}, err } @@ -259,7 +259,7 @@ func getContainerClient(subscriptionID string) (containerinstance.ContainerClien func getSubscriptionsClient() subscription.SubscriptionsClient { subc := subscription.NewSubscriptionsClient() - authorizer, _ := login.NewAzureLoginService().NewAuthorizerFromLogin() + authorizer, _ := login.NewAuthorizerFromLogin() subc.Authorizer = authorizer return subc } @@ -267,7 +267,7 @@ func getSubscriptionsClient() subscription.SubscriptionsClient { // GetGroupsClient ... func GetGroupsClient(subscriptionID string) resources.GroupsClient { groupsClient := resources.NewGroupsClient(subscriptionID) - authorizer, _ := login.NewAzureLoginService().NewAuthorizerFromLogin() + authorizer, _ := login.NewAuthorizerFromLogin() groupsClient.Authorizer = authorizer return groupsClient } diff --git a/azure/backend.go b/azure/backend.go index 8cda586e..1819b99b 100644 --- a/azure/backend.go +++ b/azure/backend.go @@ -52,14 +52,18 @@ func New(ctx context.Context) (backend.Service, error) { } aciContext, _ := metadata.Metadata.Data.(store.AciContext) - auth, _ := login.NewAzureLoginService().NewAuthorizerFromLogin() + auth, _ := login.NewAuthorizerFromLogin() containerGroupsClient := containerinstance.NewContainerGroupsClient(aciContext.SubscriptionID) containerGroupsClient.Authorizer = auth - return getAciAPIService(containerGroupsClient, aciContext), nil + return getAciAPIService(containerGroupsClient, aciContext) } -func getAciAPIService(cgc containerinstance.ContainerGroupsClient, aciCtx store.AciContext) *aciAPIService { +func getAciAPIService(cgc containerinstance.ContainerGroupsClient, aciCtx store.AciContext) (*aciAPIService, error) { + service, err := login.NewAzureLoginService() + if err != nil { + return nil, err + } return &aciAPIService{ aciContainerService: aciContainerService{ containerGroupsClient: cgc, @@ -69,9 +73,9 @@ func getAciAPIService(cgc containerinstance.ContainerGroupsClient, aciCtx store. ctx: aciCtx, }, aciCloudService: aciCloudService{ - loginService: login.NewAzureLoginService(), + loginService: service, }, - } + }, nil } type aciAPIService struct { diff --git a/azure/login/login.go b/azure/login/login.go index f14ddf93..a011a413 100644 --- a/azure/login/login.go +++ b/azure/login/login.go @@ -68,25 +68,27 @@ type AzureLoginService struct { apiHelper apiHelper } -const tokenFilename = "dockerAccessToken.json" +const tokenStoreFilename = "dockerAccessToken.json" func getTokenStorePath() string { cliPath, _ := cli.AccessTokensPath() - return filepath.Join(filepath.Dir(cliPath), tokenFilename) + return filepath.Join(filepath.Dir(cliPath), tokenStoreFilename) } // NewAzureLoginService creates a NewAzureLoginService -func NewAzureLoginService() AzureLoginService { +func NewAzureLoginService() (AzureLoginService, error) { return newAzureLoginServiceFromPath(getTokenStorePath(), azureAPIHelper{}) } -func newAzureLoginServiceFromPath(tokenStorePath string, helper apiHelper) AzureLoginService { - return AzureLoginService{ - tokenStore: tokenStore{ - filePath: tokenStorePath, - }, - apiHelper: helper, +func newAzureLoginServiceFromPath(tokenStorePath string, helper apiHelper) (AzureLoginService, error) { + store, err := newTokenStore(tokenStorePath) + if err != nil { + return AzureLoginService{}, err } + return AzureLoginService{ + tokenStore: store, + apiHelper: helper, + }, nil } type apiHelper interface { @@ -229,20 +231,21 @@ func queryHandler(queryCh chan url.Values) func(w http.ResponseWriter, r *http.R return queryHandler } -func (helper azureAPIHelper) queryToken(data url.Values, tenantID string) (token azureToken, err error) { +func (helper azureAPIHelper) queryToken(data url.Values, tenantID string) (azureToken, error) { res, err := http.Post(fmt.Sprintf(tokenEndpoint, tenantID), "application/x-www-form-urlencoded", strings.NewReader(data.Encode())) if err != nil { - return token, err + return azureToken{}, err } if res.StatusCode != 200 { - return token, errors.Errorf("error while renewing access token, status : %s", res.Status) + return azureToken{}, errors.Errorf("error while renewing access token, status : %s", res.Status) } bits, err := ioutil.ReadAll(res.Body) if err != nil { - return token, err + return azureToken{}, err } + token := azureToken{} if err := json.Unmarshal(bits, &token); err != nil { - return token, err + return azureToken{}, err } return token, nil } @@ -259,7 +262,11 @@ func toOAuthToken(token azureToken) oauth2.Token { } // NewAuthorizerFromLogin creates an authorizer based on login access token -func (login AzureLoginService) NewAuthorizerFromLogin() (autorest.Authorizer, error) { +func NewAuthorizerFromLogin() (autorest.Authorizer, error) { + login, err := NewAzureLoginService() + if err != nil { + return nil, err + } oauthToken, err := login.GetValidToken() if err != nil { return nil, err @@ -278,28 +285,28 @@ func (login AzureLoginService) NewAuthorizerFromLogin() (autorest.Authorizer, er } // GetValidToken returns an access token. Refresh token if needed -func (login AzureLoginService) GetValidToken() (token oauth2.Token, err error) { +func (login AzureLoginService) GetValidToken() (oauth2.Token, error) { loginInfo, err := login.tokenStore.readToken() if err != nil { - return token, err + return oauth2.Token{}, err } - token = loginInfo.Token + token := loginInfo.Token if token.Valid() { return token, nil } tenantID := loginInfo.TenantID token, err = login.refreshToken(token.RefreshToken, tenantID) if err != nil { - return token, errors.Wrap(err, "access token request failed. Maybe you need to login to azure again.") + return oauth2.Token{}, errors.Wrap(err, "access token request failed. Maybe you need to login to azure again.") } err = login.tokenStore.writeLoginInfo(TokenInfo{TenantID: tenantID, Token: token}) if err != nil { - return token, err + return oauth2.Token{}, err } return token, nil } -func (login AzureLoginService) refreshToken(currentRefreshToken string, tenantID string) (oauthToken oauth2.Token, err error) { +func (login AzureLoginService) refreshToken(currentRefreshToken string, tenantID string) (oauth2.Token, error) { data := url.Values{ "grant_type": []string{"refresh_token"}, "client_id": []string{clientID}, @@ -308,7 +315,7 @@ func (login AzureLoginService) refreshToken(currentRefreshToken string, tenantID } token, err := login.apiHelper.queryToken(data, tenantID) if err != nil { - return oauthToken, err + return oauth2.Token{}, err } return toOAuthToken(token), nil diff --git a/azure/login/login_test.go b/azure/login/login_test.go index 0295d505..81ea2234 100644 --- a/azure/login/login_test.go +++ b/azure/login/login_test.go @@ -8,8 +8,6 @@ import ( "testing" "time" - "github.com/stretchr/testify/require" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" @@ -27,17 +25,18 @@ type LoginSuiteTest struct { func (suite *LoginSuiteTest) BeforeTest(suiteName, testName string) { dir, err := ioutil.TempDir("", "test_store") - require.Nil(suite.T(), err) + Expect(err).To(BeNil()) suite.dir = dir suite.mockHelper = MockAzureHelper{} //nolint copylocks - suite.azureLogin = newAzureLoginServiceFromPath(filepath.Join(dir, tokenFilename), suite.mockHelper) + suite.azureLogin, err = newAzureLoginServiceFromPath(filepath.Join(dir, tokenStoreFilename), suite.mockHelper) + Expect(err).To(BeNil()) } func (suite *LoginSuiteTest) AfterTest(suiteName, testName string) { err := os.RemoveAll(suite.dir) - require.Nil(suite.T(), err) + Expect(err).To(BeNil()) } func (suite *LoginSuiteTest) TestRefreshInValidToken() { @@ -55,8 +54,10 @@ func (suite *LoginSuiteTest) TestRefreshInValidToken() { }, nil) //nolint copylocks - suite.azureLogin = newAzureLoginServiceFromPath(filepath.Join(suite.dir, tokenFilename), suite.mockHelper) - err := suite.azureLogin.tokenStore.writeLoginInfo(TokenInfo{ + azureLogin, err := newAzureLoginServiceFromPath(filepath.Join(suite.dir, tokenStoreFilename), suite.mockHelper) + Expect(err).To(BeNil()) + suite.azureLogin = azureLogin + err = suite.azureLogin.tokenStore.writeLoginInfo(TokenInfo{ TenantID: "123456", Token: oauth2.Token{ AccessToken: "accessToken", diff --git a/azure/login/tokenStore.go b/azure/login/tokenStore.go index 8e0b7dac..d6a7c59b 100644 --- a/azure/login/tokenStore.go +++ b/azure/login/tokenStore.go @@ -2,7 +2,10 @@ package login import ( "encoding/json" + "errors" "io/ioutil" + "os" + "path/filepath" "golang.org/x/oauth2" ) @@ -17,6 +20,27 @@ type TokenInfo struct { TenantID string `json:"tenantId"` } +func newTokenStore(path string) (tokenStore, error) { + parentFolder := filepath.Dir(path) + dir, err := os.Stat(parentFolder) + if os.IsNotExist(err) { + err = os.MkdirAll(parentFolder, 0700) + if err != nil { + return tokenStore{}, err + } + dir, err = os.Stat(parentFolder) + } + if err != nil { + return tokenStore{}, err + } + if !dir.Mode().IsDir() { + return tokenStore{}, errors.New("cannot use path " + path + " ; " + parentFolder + " already exists and is not a directory") + } + return tokenStore{ + filePath: path, + }, nil +} + func (store tokenStore) writeLoginInfo(info TokenInfo) error { bytes, err := json.MarshalIndent(info, "", " ") if err != nil { @@ -25,13 +49,14 @@ func (store tokenStore) writeLoginInfo(info TokenInfo) error { return ioutil.WriteFile(store.filePath, bytes, 0644) } -func (store tokenStore) readToken() (loginInfo TokenInfo, err error) { +func (store tokenStore) readToken() (TokenInfo, error) { bytes, err := ioutil.ReadFile(store.filePath) if err != nil { - return loginInfo, err + return TokenInfo{}, err } + loginInfo := TokenInfo{} if err := json.Unmarshal(bytes, &loginInfo); err != nil { - return loginInfo, err + return TokenInfo{}, err } return loginInfo, nil } diff --git a/azure/login/tokenStore_test.go b/azure/login/tokenStore_test.go new file mode 100644 index 00000000..cd1818fd --- /dev/null +++ b/azure/login/tokenStore_test.go @@ -0,0 +1,54 @@ +package login + +import ( + "errors" + "io/ioutil" + "os" + "path/filepath" + "testing" + + . "github.com/onsi/gomega" + "github.com/stretchr/testify/suite" +) + +type tokenStoreTestSuite struct { + suite.Suite +} + +func (suite *tokenStoreTestSuite) TestCreateStoreFromExistingFolder() { + existingDir, err := ioutil.TempDir("", "test_store") + Expect(err).To(BeNil()) + + storePath := filepath.Join(existingDir, tokenStoreFilename) + store, err := newTokenStore(storePath) + Expect(err).To(BeNil()) + Expect((store.filePath)).To(Equal(storePath)) +} + +func (suite *tokenStoreTestSuite) TestCreateStoreFromNonExistingFolder() { + existingDir, err := ioutil.TempDir("", "test_store") + Expect(err).To(BeNil()) + + storePath := filepath.Join(existingDir, "new", tokenStoreFilename) + store, err := newTokenStore(storePath) + Expect(err).To(BeNil()) + Expect((store.filePath)).To(Equal(storePath)) + + newDir, err := os.Stat(filepath.Join(existingDir, "new")) + Expect(err).To(BeNil()) + Expect(newDir.Mode().IsDir()).To(BeTrue()) +} + +func (suite *tokenStoreTestSuite) TestErrorIfParentFolderIsAFile() { + existingDir, err := ioutil.TempFile("", "test_store") + Expect(err).To(BeNil()) + + storePath := filepath.Join(existingDir.Name(), tokenStoreFilename) + _, err = newTokenStore(storePath) + Expect(err).To(MatchError(errors.New("cannot use path " + storePath + " ; " + existingDir.Name() + " already exists and is not a directory"))) +} + +func TestTokenStoreSuite(t *testing.T) { + RegisterTestingT(t) + suite.Run(t, new(tokenStoreTestSuite)) +} From 7edc6659a2c1c9b347a4799cdd8c0532d45756ff Mon Sep 17 00:00:00 2001 From: Guillaume Tardif Date: Thu, 14 May 2020 22:54:57 +0200 Subject: [PATCH 8/9] Add unit tests for login process --- azure/login/login.go | 51 ++++++------ azure/login/login_test.go | 148 ++++++++++++++++++++++++++++++--- cli/cmd/context/context.go | 3 +- cli/cmd/context/login/login.go | 2 +- client/client.go | 1 + context/cloud/api.go | 2 +- example/backend.go | 3 +- moby/backend.go | 3 +- 8 files changed, 173 insertions(+), 40 deletions(-) diff --git a/azure/login/login.go b/azure/login/login.go index a011a413..7ebc3751 100644 --- a/azure/login/login.go +++ b/azure/login/login.go @@ -9,7 +9,6 @@ import ( "math/rand" "net" "net/http" - "net/http/httputil" "net/url" "os/exec" "path/filepath" @@ -35,8 +34,9 @@ func init() { //go login process, derived from code sample provided by MS at https://github.com/devigned/go-az-cli-stuff const ( - authorizeFormat = "https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize?response_type=code&client_id=%s&redirect_uri=%s&state=%s&prompt=select_account&response_mode=query&scope=%s" - tokenEndpoint = "https://login.microsoftonline.com/%s/oauth2/v2.0/token" + authorizeFormat = "https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize?response_type=code&client_id=%s&redirect_uri=%s&state=%s&prompt=select_account&response_mode=query&scope=%s" + tokenEndpoint = "https://login.microsoftonline.com/%s/oauth2/v2.0/token" + authorizationURL = "https://management.azure.com/tenants?api-version=2019-11-01" // scopes for a multi-tenant app works for openid, email, other common scopes, but fails when trying to add a token // v1 scope like "https://management.azure.com/.default" for ARM access scopes = "offline_access https://management.azure.com/.default" @@ -93,6 +93,8 @@ func newAzureLoginServiceFromPath(tokenStorePath string, helper apiHelper) (Azur type apiHelper interface { queryToken(data url.Values, tenantID string) (azureToken, error) + openAzureLoginPage(redirectURL string) + queryAuthorizationAPI(authorizationURL string, authorizationHeader string) ([]byte, int, error) } type azureAPIHelper struct{} @@ -106,7 +108,7 @@ func (login AzureLoginService) Login(ctx context.Context) error { } redirectURL := "http://localhost:" + strconv.Itoa(serverPort) - openAzureLoginPage(redirectURL) + login.apiHelper.openAzureLoginPage(redirectURL) select { case <-ctx.Done(): @@ -132,23 +134,12 @@ func (login AzureLoginService) Login(ctx context.Context) error { return errors.Wrap(err, "Access token request failed") } - req, err := http.NewRequest(http.MethodGet, "https://management.azure.com/tenants?api-version=2019-11-01", nil) - if err != nil { - return err - } - - req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken)) - res, err := http.DefaultClient.Do(req) + bits, statusCode, err := login.apiHelper.queryAuthorizationAPI(authorizationURL, fmt.Sprintf("Bearer %s", token.AccessToken)) if err != nil { return errors.Wrap(err, "login failed") } - bits, err := ioutil.ReadAll(res.Body) - if err != nil { - return errors.Wrap(err, "login failed") - } - - if res.StatusCode == 200 { + if statusCode == 200 { var tenantResult tenantResult if err := json.Unmarshal(bits, &tenantResult); err != nil { return errors.Wrap(err, "login failed") @@ -170,12 +161,7 @@ func (login AzureLoginService) Login(ctx context.Context) error { return nil } - bits, err = httputil.DumpResponse(res, true) - if err != nil { - return errors.Wrap(err, "login failed") - } - - return fmt.Errorf("login failed: \n" + string(bits)) + return fmt.Errorf("login failed : " + string(bits)) } } @@ -199,12 +185,29 @@ func startLoginServer(queryCh chan url.Values) (int, error) { return availablePort, nil } -func openAzureLoginPage(redirectURL string) { +func (helper azureAPIHelper) openAzureLoginPage(redirectURL string) { state := randomString("", 10) authURL := fmt.Sprintf(authorizeFormat, clientID, redirectURL, state, scopes) openbrowser(authURL) } +func (helper azureAPIHelper) queryAuthorizationAPI(authorizationURL string, authorizationHeader string) ([]byte, int, error) { + req, err := http.NewRequest(http.MethodGet, authorizationURL, nil) + if err != nil { + return nil, 0, err + } + req.Header.Add("Authorization", authorizationHeader) + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, 0, err + } + bits, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, 0, err + } + return bits, res.StatusCode, nil +} + func queryHandler(queryCh chan url.Values) func(w http.ResponseWriter, r *http.Request) { queryHandler := func(w http.ResponseWriter, r *http.Request) { _, hasCode := r.URL.Query()["code"] diff --git a/azure/login/login_test.go b/azure/login/login_test.go index 81ea2234..a2903301 100644 --- a/azure/login/login_test.go +++ b/azure/login/login_test.go @@ -1,10 +1,14 @@ package login import ( + "context" + "errors" "io/ioutil" + "net/http" "net/url" "os" "path/filepath" + "reflect" "testing" "time" @@ -19,7 +23,7 @@ import ( type LoginSuiteTest struct { suite.Suite dir string - mockHelper MockAzureHelper + mockHelper *MockAzureHelper azureLogin AzureLoginService } @@ -28,8 +32,7 @@ func (suite *LoginSuiteTest) BeforeTest(suiteName, testName string) { Expect(err).To(BeNil()) suite.dir = dir - suite.mockHelper = MockAzureHelper{} - //nolint copylocks + suite.mockHelper = &MockAzureHelper{} suite.azureLogin, err = newAzureLoginServiceFromPath(filepath.Join(dir, tokenStoreFilename), suite.mockHelper) Expect(err).To(BeNil()) } @@ -40,12 +43,7 @@ func (suite *LoginSuiteTest) AfterTest(suiteName, testName string) { } func (suite *LoginSuiteTest) TestRefreshInValidToken() { - data := url.Values{ - "grant_type": []string{"refresh_token"}, - "client_id": []string{clientID}, - "scope": []string{scopes}, - "refresh_token": []string{"refreshToken"}, - } + data := refreshTokenData("refreshToken") suite.mockHelper.On("queryToken", data, "123456").Return(azureToken{ RefreshToken: "newRefreshToken", AccessToken: "newAccessToken", @@ -98,6 +96,126 @@ func (suite *LoginSuiteTest) TestDoesNotRefreshValidToken() { Expect(token.AccessToken).To(Equal("accessToken")) } +func (suite *LoginSuiteTest) TestInvalidLogin() { + suite.mockHelper.On("openAzureLoginPage", mock.AnythingOfType("string")).Run(func(args mock.Arguments) { + redirectURL := args.Get(0).(string) + err := queryKeyValue(redirectURL, "error", "access denied") + Expect(err).To(BeNil()) + }) + + //nolint copylocks + azureLogin, err := newAzureLoginServiceFromPath(filepath.Join(suite.dir, tokenStoreFilename), suite.mockHelper) + Expect(err).To(BeNil()) + + err = azureLogin.Login(context.TODO()) + Expect(err).To(MatchError(errors.New("login failed : [access denied]"))) +} + +func (suite *LoginSuiteTest) TestValidLogin() { + var redirectURL string + suite.mockHelper.On("openAzureLoginPage", mock.AnythingOfType("string")).Run(func(args mock.Arguments) { + redirectURL = args.Get(0).(string) + err := queryKeyValue(redirectURL, "code", "123456879") + Expect(err).To(BeNil()) + }) + + suite.mockHelper.On("queryToken", mock.MatchedBy(func(data url.Values) bool { + //Need a matcher here because the value of redirectUrl is not known until executing openAzureLoginPage + return reflect.DeepEqual(data, url.Values{ + "grant_type": []string{"authorization_code"}, + "client_id": []string{clientID}, + "code": []string{"123456879"}, + "scope": []string{scopes}, + "redirect_uri": []string{redirectURL}, + }) + }), "organizations").Return(azureToken{ + RefreshToken: "firstRefreshToken", + AccessToken: "firstAccessToken", + ExpiresIn: 3600, + Foci: "1", + }, nil) + + authBody := `{"value":[{"id":"/tenants/12345a7c-c56d-43e8-9549-dd230ce8a038","tenantId":"12345a7c-c56d-43e8-9549-dd230ce8a038"}]}` + + suite.mockHelper.On("queryAuthorizationAPI", authorizationURL, "Bearer firstAccessToken").Return([]byte(authBody), 200, nil) + data := refreshTokenData("firstRefreshToken") + suite.mockHelper.On("queryToken", data, "12345a7c-c56d-43e8-9549-dd230ce8a038").Return(azureToken{ + RefreshToken: "newRefreshToken", + AccessToken: "newAccessToken", + ExpiresIn: 3600, + Foci: "1", + }, nil) + //nolint copylocks + azureLogin, err := newAzureLoginServiceFromPath(filepath.Join(suite.dir, tokenStoreFilename), suite.mockHelper) + Expect(err).To(BeNil()) + + err = azureLogin.Login(context.TODO()) + Expect(err).To(BeNil()) + + loginToken, err := suite.azureLogin.tokenStore.readToken() + Expect(err).To(BeNil()) + Expect(loginToken.Token.AccessToken).To(Equal("newAccessToken")) + Expect(loginToken.Token.RefreshToken).To(Equal("newRefreshToken")) + Expect(loginToken.Token.Expiry).To(BeTemporally(">", time.Now().Add(3500*time.Second))) + Expect(loginToken.TenantID).To(Equal("12345a7c-c56d-43e8-9549-dd230ce8a038")) + Expect(loginToken.Token.Type()).To(Equal("Bearer")) +} + +func (suite *LoginSuiteTest) TestLoginAuthorizationFailed() { + var redirectURL string + suite.mockHelper.On("openAzureLoginPage", mock.AnythingOfType("string")).Run(func(args mock.Arguments) { + redirectURL = args.Get(0).(string) + err := queryKeyValue(redirectURL, "code", "123456879") + Expect(err).To(BeNil()) + }) + + suite.mockHelper.On("queryToken", mock.MatchedBy(func(data url.Values) bool { + //Need a matcher here because the value of redirectUrl is not known until executing openAzureLoginPage + return reflect.DeepEqual(data, url.Values{ + "grant_type": []string{"authorization_code"}, + "client_id": []string{clientID}, + "code": []string{"123456879"}, + "scope": []string{scopes}, + "redirect_uri": []string{redirectURL}, + }) + }), "organizations").Return(azureToken{ + RefreshToken: "firstRefreshToken", + AccessToken: "firstAccessToken", + ExpiresIn: 3600, + Foci: "1", + }, nil) + + authBody := `[access denied]` + + suite.mockHelper.On("queryAuthorizationAPI", authorizationURL, "Bearer firstAccessToken").Return([]byte(authBody), 400, nil) + + azureLogin, err := newAzureLoginServiceFromPath(filepath.Join(suite.dir, tokenStoreFilename), suite.mockHelper) + Expect(err).To(BeNil()) + + err = azureLogin.Login(context.TODO()) + Expect(err).To(MatchError(errors.New("login failed : [access denied]"))) +} + +func refreshTokenData(refreshToken string) url.Values { + return url.Values{ + "grant_type": []string{"refresh_token"}, + "client_id": []string{clientID}, + "scope": []string{scopes}, + "refresh_token": []string{refreshToken}, + } +} + +func queryKeyValue(redirectURL string, key string, value string) error { + req, err := http.NewRequest("GET", redirectURL, nil) + Expect(err).To(BeNil()) + q := req.URL.Query() + q.Add(key, value) + req.URL.RawQuery = q.Encode() + client := &http.Client{} + _, err = client.Do(req) + return err +} + func TestLoginSuite(t *testing.T) { RegisterTestingT(t) suite.Run(t, new(LoginSuiteTest)) @@ -107,8 +225,16 @@ type MockAzureHelper struct { mock.Mock } -//nolint copylocks -func (s MockAzureHelper) queryToken(data url.Values, tenantID string) (token azureToken, err error) { +func (s *MockAzureHelper) queryToken(data url.Values, tenantID string) (token azureToken, err error) { args := s.Called(data, tenantID) return args.Get(0).(azureToken), args.Error(1) } + +func (s *MockAzureHelper) queryAuthorizationAPI(authorizationURL string, authorizationHeader string) ([]byte, int, error) { + args := s.Called(authorizationURL, authorizationHeader) + return args.Get(0).([]byte), args.Int(1), args.Error(2) +} + +func (s *MockAzureHelper) openAzureLoginPage(redirectURL string) { + s.Called(redirectURL) +} diff --git a/cli/cmd/context/context.go b/cli/cmd/context/context.go index 25209193..29ceb7d4 100644 --- a/cli/cmd/context/context.go +++ b/cli/cmd/context/context.go @@ -28,9 +28,10 @@ package context import ( - "github.com/docker/api/cli/cmd/context/login" "github.com/spf13/cobra" + "github.com/docker/api/cli/cmd/context/login" + cliopts "github.com/docker/api/cli/options" ) diff --git a/cli/cmd/context/login/login.go b/cli/cmd/context/login/login.go index 7ab0d51b..4cbd6a7c 100644 --- a/cli/cmd/context/login/login.go +++ b/cli/cmd/context/login/login.go @@ -1,8 +1,8 @@ package login import ( - "github.com/spf13/cobra" "github.com/pkg/errors" + "github.com/spf13/cobra" "github.com/docker/api/client" apicontext "github.com/docker/api/context" diff --git a/client/client.go b/client/client.go index b5326381..9508e633 100644 --- a/client/client.go +++ b/client/client.go @@ -29,6 +29,7 @@ package client import ( "context" + "github.com/docker/api/context/cloud" "github.com/docker/api/backend" diff --git a/context/cloud/api.go b/context/cloud/api.go index 0eecce22..448c6537 100644 --- a/context/cloud/api.go +++ b/context/cloud/api.go @@ -2,8 +2,8 @@ package cloud import "context" +// Service cloud specific services type Service interface { // Login login to cloud provider Login(ctx context.Context, params map[string]string) error } - diff --git a/example/backend.go b/example/backend.go index b9ce20dd..67497263 100644 --- a/example/backend.go +++ b/example/backend.go @@ -3,9 +3,10 @@ package example import ( "context" "fmt" - "github.com/docker/api/context/cloud" "io" + "github.com/docker/api/context/cloud" + "github.com/docker/api/backend" "github.com/docker/api/compose" "github.com/docker/api/containers" diff --git a/moby/backend.go b/moby/backend.go index 66fc52bd..e766c1b0 100644 --- a/moby/backend.go +++ b/moby/backend.go @@ -2,9 +2,10 @@ package moby import ( "context" - "github.com/docker/api/context/cloud" "io" + "github.com/docker/api/context/cloud" + "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/client" From 7cf2309ca65f952574c9abd2d4c91b920eb16930 Mon Sep 17 00:00:00 2001 From: Guillaume Tardif Date: Fri, 15 May 2020 10:40:25 +0200 Subject: [PATCH 9/9] Separate azure login bits in LocalServer + Helper (mocked part) --- azure/login/login.go | 147 +------------------------------ azure/login/loginHelper.go | 75 ++++++++++++++++ azure/login/logingLocalServer.go | 83 +++++++++++++++++ 3 files changed, 161 insertions(+), 144 deletions(-) create mode 100644 azure/login/loginHelper.go create mode 100644 azure/login/logingLocalServer.go diff --git a/azure/login/login.go b/azure/login/login.go index 7ebc3751..3c13c543 100644 --- a/azure/login/login.go +++ b/azure/login/login.go @@ -4,17 +4,13 @@ import ( "context" "encoding/json" "fmt" - "io/ioutil" "log" "math/rand" - "net" - "net/http" "net/url" "os/exec" "path/filepath" "runtime" "strconv" - "strings" "time" "github.com/docker/api/errdefs" @@ -70,11 +66,6 @@ type AzureLoginService struct { const tokenStoreFilename = "dockerAccessToken.json" -func getTokenStorePath() string { - cliPath, _ := cli.AccessTokensPath() - return filepath.Join(filepath.Dir(cliPath), tokenStoreFilename) -} - // NewAzureLoginService creates a NewAzureLoginService func NewAzureLoginService() (AzureLoginService, error) { return newAzureLoginServiceFromPath(getTokenStorePath(), azureAPIHelper{}) @@ -91,14 +82,6 @@ func newAzureLoginServiceFromPath(tokenStorePath string, helper apiHelper) (Azur }, nil } -type apiHelper interface { - queryToken(data url.Values, tenantID string) (azureToken, error) - openAzureLoginPage(redirectURL string) - queryAuthorizationAPI(authorizationURL string, authorizationHeader string) ([]byte, int, error) -} - -type azureAPIHelper struct{} - //Login perform azure login through browser func (login AzureLoginService) Login(ctx context.Context) error { queryCh := make(chan url.Values, 1) @@ -165,92 +148,9 @@ func (login AzureLoginService) Login(ctx context.Context) error { } } -func startLoginServer(queryCh chan url.Values) (int, error) { - mux := http.NewServeMux() - mux.HandleFunc("/", queryHandler(queryCh)) - listener, err := net.Listen("tcp", ":0") - if err != nil { - return 0, err - } - - availablePort := listener.Addr().(*net.TCPAddr).Port - server := &http.Server{Handler: mux} - go func() { - if err := server.Serve(listener); err != nil { - queryCh <- url.Values{ - "error": []string{fmt.Sprintf("error starting http server with: %v", err)}, - } - } - }() - return availablePort, nil -} - -func (helper azureAPIHelper) openAzureLoginPage(redirectURL string) { - state := randomString("", 10) - authURL := fmt.Sprintf(authorizeFormat, clientID, redirectURL, state, scopes) - openbrowser(authURL) -} - -func (helper azureAPIHelper) queryAuthorizationAPI(authorizationURL string, authorizationHeader string) ([]byte, int, error) { - req, err := http.NewRequest(http.MethodGet, authorizationURL, nil) - if err != nil { - return nil, 0, err - } - req.Header.Add("Authorization", authorizationHeader) - res, err := http.DefaultClient.Do(req) - if err != nil { - return nil, 0, err - } - bits, err := ioutil.ReadAll(res.Body) - if err != nil { - return nil, 0, err - } - return bits, res.StatusCode, nil -} - -func queryHandler(queryCh chan url.Values) func(w http.ResponseWriter, r *http.Request) { - queryHandler := func(w http.ResponseWriter, r *http.Request) { - _, hasCode := r.URL.Query()["code"] - if hasCode { - _, err := w.Write([]byte(successfullLoginHTML)) - if err != nil { - queryCh <- url.Values{ - "error": []string{err.Error()}, - } - } else { - queryCh <- r.URL.Query() - } - } else { - _, err := w.Write([]byte(loginFailedHTML)) - if err != nil { - queryCh <- url.Values{ - "error": []string{err.Error()}, - } - } else { - queryCh <- r.URL.Query() - } - } - } - return queryHandler -} - -func (helper azureAPIHelper) queryToken(data url.Values, tenantID string) (azureToken, error) { - res, err := http.Post(fmt.Sprintf(tokenEndpoint, tenantID), "application/x-www-form-urlencoded", strings.NewReader(data.Encode())) - if err != nil { - return azureToken{}, err - } - if res.StatusCode != 200 { - return azureToken{}, errors.Errorf("error while renewing access token, status : %s", res.Status) - } - bits, err := ioutil.ReadAll(res.Body) - if err != nil { - return azureToken{}, err - } - token := azureToken{} - if err := json.Unmarshal(bits, &token); err != nil { - return azureToken{}, err - } - return token, nil +func getTokenStorePath() string { + cliPath, _ := cli.AccessTokensPath() + return filepath.Join(filepath.Dir(cliPath), tokenStoreFilename) } func toOAuthToken(token azureToken) oauth2.Token { @@ -341,44 +241,3 @@ func openbrowser(url string) { log.Fatal(err) } } - -var ( - letterRunes = []rune("abcdefghijklmnopqrstuvwxyz123456789") -) - -func randomString(prefix string, length int) string { - b := make([]rune, length) - for i := range b { - b[i] = letterRunes[rand.Intn(len(letterRunes))] - } - return prefix + string(b) -} - -const loginFailedHTML = ` - - - - - Login failed - - -

Some failures occurred during the authentication

-

You can log an issue at Azure CLI GitHub Repository and we will assist you in resolving it.

- - - ` - -const successfullLoginHTML = ` - - - - - - Login successfully - - -

You have logged into Microsoft Azure!

-

You can close this window, or we will redirect you to the Azure CLI documents in 10 seconds.

- - - ` diff --git a/azure/login/loginHelper.go b/azure/login/loginHelper.go new file mode 100644 index 00000000..866116e8 --- /dev/null +++ b/azure/login/loginHelper.go @@ -0,0 +1,75 @@ +package login + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "math/rand" + "net/http" + "net/url" + "strings" + + "github.com/pkg/errors" +) + +type apiHelper interface { + queryToken(data url.Values, tenantID string) (azureToken, error) + openAzureLoginPage(redirectURL string) + queryAuthorizationAPI(authorizationURL string, authorizationHeader string) ([]byte, int, error) +} + +type azureAPIHelper struct{} + +func (helper azureAPIHelper) openAzureLoginPage(redirectURL string) { + state := randomString("", 10) + authURL := fmt.Sprintf(authorizeFormat, clientID, redirectURL, state, scopes) + openbrowser(authURL) +} + +func (helper azureAPIHelper) queryAuthorizationAPI(authorizationURL string, authorizationHeader string) ([]byte, int, error) { + req, err := http.NewRequest(http.MethodGet, authorizationURL, nil) + if err != nil { + return nil, 0, err + } + req.Header.Add("Authorization", authorizationHeader) + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, 0, err + } + bits, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, 0, err + } + return bits, res.StatusCode, nil +} + +func (helper azureAPIHelper) queryToken(data url.Values, tenantID string) (azureToken, error) { + res, err := http.Post(fmt.Sprintf(tokenEndpoint, tenantID), "application/x-www-form-urlencoded", strings.NewReader(data.Encode())) + if err != nil { + return azureToken{}, err + } + if res.StatusCode != 200 { + return azureToken{}, errors.Errorf("error while renewing access token, status : %s", res.Status) + } + bits, err := ioutil.ReadAll(res.Body) + if err != nil { + return azureToken{}, err + } + token := azureToken{} + if err := json.Unmarshal(bits, &token); err != nil { + return azureToken{}, err + } + return token, nil +} + +var ( + letterRunes = []rune("abcdefghijklmnopqrstuvwxyz123456789") +) + +func randomString(prefix string, length int) string { + b := make([]rune, length) + for i := range b { + b[i] = letterRunes[rand.Intn(len(letterRunes))] + } + return prefix + string(b) +} diff --git a/azure/login/logingLocalServer.go b/azure/login/logingLocalServer.go new file mode 100644 index 00000000..753d6913 --- /dev/null +++ b/azure/login/logingLocalServer.go @@ -0,0 +1,83 @@ +package login + +import ( + "fmt" + "net" + "net/http" + "net/url" +) + +const loginFailedHTML = ` + + + + + Login failed + + +

Some failures occurred during the authentication

+

You can log an issue at Azure CLI GitHub Repository and we will assist you in resolving it.

+ + + ` + +const successfullLoginHTML = ` + + + + + + Login successfully + + +

You have logged into Microsoft Azure!

+

You can close this window, or we will redirect you to the Azure CLI documents in 10 seconds.

+ + + ` + +func startLoginServer(queryCh chan url.Values) (int, error) { + mux := http.NewServeMux() + mux.HandleFunc("/", queryHandler(queryCh)) + listener, err := net.Listen("tcp", ":0") + if err != nil { + return 0, err + } + + availablePort := listener.Addr().(*net.TCPAddr).Port + server := &http.Server{Handler: mux} + go func() { + if err := server.Serve(listener); err != nil { + queryCh <- url.Values{ + "error": []string{fmt.Sprintf("error starting http server with: %v", err)}, + } + } + }() + return availablePort, nil +} + +func queryHandler(queryCh chan url.Values) func(w http.ResponseWriter, r *http.Request) { + queryHandler := func(w http.ResponseWriter, r *http.Request) { + _, hasCode := r.URL.Query()["code"] + if hasCode { + _, err := w.Write([]byte(successfullLoginHTML)) + if err != nil { + queryCh <- url.Values{ + "error": []string{err.Error()}, + } + } else { + queryCh <- r.URL.Query() + } + } else { + _, err := w.Write([]byte(loginFailedHTML)) + if err != nil { + queryCh <- url.Values{ + "error": []string{err.Error()}, + } + } else { + queryCh <- r.URL.Query() + } + } + } + return queryHandler +}