diff --git a/azure/backend.go b/azure/backend.go index f1fedddff..173378237 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 000000000..eac0bc917 --- /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(` + + +
+ + +You can close this window, or we will redirect you to the Azure CLI documents in 10 seconds.
+ + + `)) + } else { + w.Write([]byte(` + + + + +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 000000000..3cefdda9b --- /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 45b460971..b76ae6f58 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 7ee9a49b4..4e113834a 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 88be54489..b5326381a 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 000000000..0eecce22d --- /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 e167191af..b9ce20dd5 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 be5addf88..3997d3235 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 78d3d2d5d..e44b75c65 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 b9c1304cd..66fc52bdc 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,