diff --git a/.golangci.yml b/.golangci.yml index cf8a71519..1267dab95 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/aci.go b/azure/aci.go index 964a2ca17..79907d3b1 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.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.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.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.NewAuthorizerFromLogin() groupsClient.Authorizer = authorizer return groupsClient } diff --git a/azure/backend.go b/azure/backend.go index f1fedddff..1819b99bf 100644 --- a/azure/backend.go +++ b/azure/backend.go @@ -8,13 +8,15 @@ import ( "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" "github.com/pkg/errors" "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" @@ -50,43 +52,48 @@ func New(ctx context.Context) (backend.Service, error) { } aciContext, _ := metadata.Metadata.Data.(store.AciContext) - auth, _ := auth.NewAuthorizerFromCLI() + 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, ctx: aciCtx, }, aciComposeService: aciComposeService{ - containerGroupsClient: cgc, - ctx: aciCtx, + ctx: aciCtx, }, - } + aciCloudService: aciCloudService{ + loginService: service, + }, + }, nil } type aciAPIService struct { aciContainerService aciComposeService + aciCloudService } 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 &a.aciCloudService } type aciContainerService struct { @@ -231,8 +238,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 { @@ -266,3 +272,11 @@ func (cs *aciComposeService) Down(ctx context.Context, opts compose.ProjectOptio return err } + +type aciCloudService struct { + loginService login.AzureLoginService +} + +func (cs *aciCloudService) Login(ctx context.Context, params map[string]string) error { + return cs.loginService.Login(ctx) +} diff --git a/azure/login/login.go b/azure/login/login.go new file mode 100644 index 000000000..3c13c5434 --- /dev/null +++ b/azure/login/login.go @@ -0,0 +1,243 @@ +package login + +import ( + "context" + "encoding/json" + "fmt" + "log" + "math/rand" + "net/url" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "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" + "github.com/Azure/go-autorest/autorest/date" + "golang.org/x/oauth2" + + "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" + 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" + clientID = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" // Azure CLI client id +) + +type ( + azureToken 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"` + } +) + +// AzureLoginService Service to log into azure and get authentifier for azure APIs +type AzureLoginService struct { + tokenStore tokenStore + apiHelper apiHelper +} + +const tokenStoreFilename = "dockerAccessToken.json" + +// NewAzureLoginService creates a NewAzureLoginService +func NewAzureLoginService() (AzureLoginService, error) { + return newAzureLoginServiceFromPath(getTokenStorePath(), azureAPIHelper{}) +} + +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 +} + +//Login perform azure login through browser +func (login AzureLoginService) Login(ctx context.Context) error { + queryCh := make(chan url.Values, 1) + serverPort, err := startLoginServer(queryCh) + if err != nil { + return err + } + + redirectURL := "http://localhost:" + strconv.Itoa(serverPort) + login.apiHelper.openAzureLoginPage(redirectURL) + + select { + case <-ctx.Done(): + 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 errdefs.ErrLoginFailed + } + data := url.Values{ + "grant_type": []string{"authorization_code"}, + "client_id": []string{clientID}, + "code": code, + "scope": []string{scopes}, + "redirect_uri": []string{redirectURL}, + } + token, err := login.apiHelper.queryToken(data, "organizations") + if err != nil { + return errors.Wrap(err, "Access token request failed") + } + + bits, statusCode, err := login.apiHelper.queryAuthorizationAPI(authorizationURL, fmt.Sprintf("Bearer %s", token.AccessToken)) + if err != nil { + return errors.Wrap(err, "login failed") + } + + if statusCode == 200 { + var tenantResult tenantResult + if err := json.Unmarshal(bits, &tenantResult); err != nil { + return errors.Wrap(err, "login failed") + } + tenantID := tenantResult.Value[0].TenantID + tenantToken, err := login.refreshToken(token.RefreshToken, tenantID) + if err != nil { + return errors.Wrap(err, "login failed") + } + loginInfo := TokenInfo{TenantID: tenantID, Token: tenantToken} + + err = login.tokenStore.writeLoginInfo(loginInfo) + + if err != nil { + return errors.Wrap(err, "login failed") + } + fmt.Println("Login Succeeded") + + return nil + } + + return fmt.Errorf("login failed : " + string(bits)) + } +} + +func getTokenStorePath() string { + cliPath, _ := cli.AccessTokensPath() + return filepath.Join(filepath.Dir(cliPath), tokenStoreFilename) +} + +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, + Expiry: expireTime, + TokenType: token.Type, + } + return oauthToken +} + +// NewAuthorizerFromLogin creates an authorizer based on login access token +func NewAuthorizerFromLogin() (autorest.Authorizer, error) { + login, err := NewAzureLoginService() + if err != nil { + return nil, err + } + oauthToken, err := login.GetValidToken() + if err != nil { + return nil, err + } + + token := adal.Token{ + AccessToken: oauthToken.AccessToken, + Type: oauthToken.TokenType, + 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: "", + } + + return autorest.NewBearerAuthorizer(&token), nil +} + +// GetValidToken returns an access token. Refresh token if needed +func (login AzureLoginService) GetValidToken() (oauth2.Token, error) { + loginInfo, err := login.tokenStore.readToken() + if err != nil { + return oauth2.Token{}, err + } + token := loginInfo.Token + if token.Valid() { + return token, nil + } + tenantID := loginInfo.TenantID + token, err = login.refreshToken(token.RefreshToken, tenantID) + if err != nil { + 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 oauth2.Token{}, err + } + return token, nil +} + +func (login AzureLoginService) refreshToken(currentRefreshToken string, tenantID string) (oauth2.Token, error) { + data := url.Values{ + "grant_type": []string{"refresh_token"}, + "client_id": []string{clientID}, + "scope": []string{scopes}, + "refresh_token": []string{currentRefreshToken}, + } + token, err := login.apiHelper.queryToken(data, tenantID) + if err != nil { + return oauth2.Token{}, 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) + } +} diff --git a/azure/login/loginHelper.go b/azure/login/loginHelper.go new file mode 100644 index 000000000..866116e89 --- /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/login_test.go b/azure/login/login_test.go new file mode 100644 index 000000000..a29033017 --- /dev/null +++ b/azure/login/login_test.go @@ -0,0 +1,240 @@ +package login + +import ( + "context" + "errors" + "io/ioutil" + "net/http" + "net/url" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "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") + Expect(err).To(BeNil()) + + suite.dir = dir + suite.mockHelper = &MockAzureHelper{} + 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) + Expect(err).To(BeNil()) +} + +func (suite *LoginSuiteTest) TestRefreshInValidToken() { + data := refreshTokenData("refreshToken") + suite.mockHelper.On("queryToken", data, "123456").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()) + suite.azureLogin = azureLogin + 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 (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)) +} + +type MockAzureHelper struct { + mock.Mock +} + +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/azure/login/logingLocalServer.go b/azure/login/logingLocalServer.go new file mode 100644 index 000000000..753d69130 --- /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 +} diff --git a/azure/login/tokenStore.go b/azure/login/tokenStore.go new file mode 100644 index 000000000..d6a7c59be --- /dev/null +++ b/azure/login/tokenStore.go @@ -0,0 +1,62 @@ +package login + +import ( + "encoding/json" + "errors" + "io/ioutil" + "os" + "path/filepath" + + "golang.org/x/oauth2" +) + +type tokenStore struct { + filePath string +} + +// TokenInfo data stored in tokenStore +type TokenInfo struct { + Token oauth2.Token `json:"oauthToken"` + 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 { + return err + } + return ioutil.WriteFile(store.filePath, bytes, 0644) +} + +func (store tokenStore) readToken() (TokenInfo, error) { + bytes, err := ioutil.ReadFile(store.filePath) + if err != nil { + return TokenInfo{}, err + } + loginInfo := TokenInfo{} + if err := json.Unmarshal(bytes, &loginInfo); err != nil { + 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 000000000..cd1818fd5 --- /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)) +} 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/context.go b/cli/cmd/context/context.go index aea5e7bf3..29ceb7d45 100644 --- a/cli/cmd/context/context.go +++ b/cli/cmd/context/context.go @@ -30,6 +30,8 @@ package context import ( "github.com/spf13/cobra" + "github.com/docker/api/cli/cmd/context/login" + cliopts "github.com/docker/api/cli/options" ) @@ -45,6 +47,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 000000000..4cbd6a7cf --- /dev/null +++ b/cli/cmd/context/login/login.go @@ -0,0 +1,37 @@ +package login + +import ( + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/docker/api/client" + apicontext "github.com/docker/api/context" +) + +// 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 { + 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) + }, + } + + return azureLoginCmd +} diff --git a/client/client.go b/client/client.go index 88be54489..9508e6332 100644 --- a/client/client.go +++ b/client/client.go @@ -30,6 +30,8 @@ package client import ( "context" + "github.com/docker/api/context/cloud" + "github.com/docker/api/backend" backendv1 "github.com/docker/api/backend/v1" cliv1 "github.com/docker/api/cli/v1" @@ -84,3 +86,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..448c6537c --- /dev/null +++ b/context/cloud/api.go @@ -0,0 +1,9 @@ +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/errdefs/errors.go b/errdefs/errors.go index 1bd21d9f8..e01ac4ef4 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/example/backend.go b/example/backend.go index e167191af..67497263e 100644 --- a/example/backend.go +++ b/example/backend.go @@ -5,6 +5,8 @@ import ( "fmt" "io" + "github.com/docker/api/context/cloud" + "github.com/docker/api/backend" "github.com/docker/api/compose" "github.com/docker/api/containers" @@ -23,6 +25,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..84dad30e9 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= @@ -275,6 +278,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= @@ -310,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= diff --git a/moby/backend.go b/moby/backend.go index b9c1304cd..e766c1b07 100644 --- a/moby/backend.go +++ b/moby/backend.go @@ -4,6 +4,8 @@ import ( "context" "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" @@ -45,6 +47,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,