diff --git a/azure/backend.go b/azure/backend.go index 10f5210af..efa8be10b 100644 --- a/azure/backend.go +++ b/azure/backend.go @@ -340,6 +340,10 @@ func (cs *aciCloudService) Login(ctx context.Context, params map[string]string) return cs.loginService.Login(ctx, params[login.TenantIDLoginParam]) } +func (cs *aciCloudService) Logout(ctx context.Context) error { + return cs.loginService.Logout(ctx) +} + func (cs *aciCloudService) CreateContextData(ctx context.Context, params map[string]string) (interface{}, string, error) { contextHelper := newContextCreateHelper() return contextHelper.createContextData(ctx, params) diff --git a/azure/login/login.go b/azure/login/login.go index 712691854..7117ccb76 100644 --- a/azure/login/login.go +++ b/azure/login/login.go @@ -22,14 +22,13 @@ import ( "fmt" "net/http" "net/url" - "path/filepath" + "os" "strconv" "time" "github.com/Azure/go-autorest/autorest" "github.com/Azure/go-autorest/autorest/adal" auth2 "github.com/Azure/go-autorest/autorest/azure/auth" - "github.com/Azure/go-autorest/autorest/azure/cli" "github.com/Azure/go-autorest/autorest/date" "github.com/pkg/errors" "golang.org/x/oauth2" @@ -80,7 +79,7 @@ const tokenStoreFilename = "dockerAccessToken.json" // NewAzureLoginService creates a NewAzureLoginService func NewAzureLoginService() (AzureLoginService, error) { - return newAzureLoginServiceFromPath(getTokenStorePath(), azureAPIHelper{}) + return newAzureLoginServiceFromPath(GetTokenStorePath(), azureAPIHelper{}) } func newAzureLoginServiceFromPath(tokenStorePath string, helper apiHelper) (AzureLoginService, error) { @@ -120,6 +119,15 @@ func (login AzureLoginService) TestLoginFromServicePrincipal(clientID string, cl return nil } +// Logout remove azure token data +func (login AzureLoginService) Logout(ctx context.Context) error { + err := login.tokenStore.removeData() + if os.IsNotExist(err) { + return errors.New("No Azure login data to be removed") + } + return err +} + // Login performs an Azure login through a web browser func (login AzureLoginService) Login(ctx context.Context, requestedTenantID string) error { queryCh := make(chan localResponse, 1) @@ -208,11 +216,6 @@ func getTenantID(tenantValues []tenantValue, requestedTenantID string) (string, return "", errors.Errorf("could not find requested azure tenant %s", requestedTenantID) } -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{ @@ -241,7 +244,7 @@ func spToOAuthToken(token adal.Token) (oauth2.Token, error) { // NewAuthorizerFromLogin creates an authorizer based on login access token func NewAuthorizerFromLogin() (autorest.Authorizer, error) { - return newAuthorizerFromLoginStorePath(getTokenStorePath()) + return newAuthorizerFromLoginStorePath(GetTokenStorePath()) } func newAuthorizerFromLoginStorePath(storeTokenPath string) (autorest.Authorizer, error) { diff --git a/azure/login/tokenStore.go b/azure/login/tokenStore.go index 44011af8c..925ee7015 100644 --- a/azure/login/tokenStore.go +++ b/azure/login/tokenStore.go @@ -23,6 +23,8 @@ import ( "os" "path/filepath" + "github.com/Azure/go-autorest/autorest/azure/cli" + "golang.org/x/oauth2" ) @@ -57,6 +59,12 @@ func newTokenStore(path string) (tokenStore, error) { }, nil } +// GetTokenStorePath the path for token store +func GetTokenStorePath() string { + cliPath, _ := cli.AccessTokensPath() + return filepath.Join(filepath.Dir(cliPath), tokenStoreFilename) +} + func (store tokenStore) writeLoginInfo(info TokenInfo) error { bytes, err := json.MarshalIndent(info, "", " ") if err != nil { @@ -76,3 +84,7 @@ func (store tokenStore) readToken() (TokenInfo, error) { } return loginInfo, nil } + +func (store tokenStore) removeData() error { + return os.Remove(store.filePath) +} diff --git a/cli/cmd/login/login.go b/cli/cmd/login/login.go index 6ab8c8ffe..79dc912f4 100644 --- a/cli/cmd/login/login.go +++ b/cli/cmd/login/login.go @@ -35,7 +35,7 @@ import ( func Command() *cobra.Command { cmd := &cobra.Command{ Use: "login [OPTIONS] [SERVER]", - Short: "Log in to a Docker registry", + Short: "Log in to a Docker registry or cloud backend", Long: "Log in to a Docker registry or cloud backend.\nIf no registry server is specified, the default is defined by the daemon.", Args: cobra.MaximumNArgs(1), RunE: runLogin, diff --git a/cli/cmd/logout/azurelogout.go b/cli/cmd/logout/azurelogout.go new file mode 100644 index 000000000..478f821cf --- /dev/null +++ b/cli/cmd/logout/azurelogout.go @@ -0,0 +1,42 @@ +package logout + +import ( + "context" + "fmt" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/docker/api/client" + "github.com/docker/api/errdefs" +) + +// AzureLogoutCommand returns the azure logout command +func AzureLogoutCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "azure", + Short: "Logout from Azure", + Args: cobra.MaximumNArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + return cloudLogout(cmd, "aci") + }, + } + return cmd +} + +func cloudLogout(cmd *cobra.Command, backendType string) error { + ctx := cmd.Context() + cs, err := client.GetCloudService(ctx, backendType) + if err != nil { + return errors.Wrap(errdefs.ErrLoginFailed, "cannot connect to backend") + } + err = cs.Logout(ctx) + if errors.Is(err, context.Canceled) { + return errors.New("logout canceled") + } + if err != nil { + return err + } + fmt.Println("Removing login credentials for Azure") + return nil +} diff --git a/cli/cmd/logout/logout.go b/cli/cmd/logout/logout.go new file mode 100644 index 000000000..9c02853e9 --- /dev/null +++ b/cli/cmd/logout/logout.go @@ -0,0 +1,25 @@ +package logout + +import ( + "github.com/spf13/cobra" + + "github.com/docker/api/cli/mobycli" +) + +// Command returns the login command +func Command() *cobra.Command { + cmd := &cobra.Command{ + Use: "logout [SERVER]", + Short: "Log out from a Docker registry or cloud backend", + Long: "Log out from a Docker registry or cloud backend.\nIf no server is specified, the default is defined by the daemon.", + Args: cobra.MaximumNArgs(0), + RunE: runLogout, + } + + cmd.AddCommand(AzureLogoutCommand()) + return cmd +} + +func runLogout(cmd *cobra.Command, args []string) error { + return mobycli.ExecCmd(cmd) +} diff --git a/cli/main.go b/cli/main.go index bc51557b3..03a73698b 100644 --- a/cli/main.go +++ b/cli/main.go @@ -27,6 +27,8 @@ import ( "syscall" "time" + "github.com/docker/api/cli/cmd/logout" + "github.com/docker/api/errdefs" "github.com/pkg/errors" @@ -59,6 +61,7 @@ var ( ownCommands = map[string]struct{}{ "context": {}, "login": {}, + "logout": {}, "serve": {}, "version": {}, } @@ -117,6 +120,7 @@ func main() { cmd.InspectCommand(), compose.Command(), login.Command(), + logout.Command(), cmd.VersionCommand(version), ) diff --git a/context/cloud/api.go b/context/cloud/api.go index 49d4b14c3..d6bb755e7 100644 --- a/context/cloud/api.go +++ b/context/cloud/api.go @@ -26,7 +26,9 @@ import ( type Service interface { // Login login to cloud provider Login(ctx context.Context, params map[string]string) error - // Login login to cloud provider + // Logout logout from cloud provider + Logout(ctx context.Context) error + // CreateContextData create data for cloud context CreateContextData(ctx context.Context, params map[string]string) (contextData interface{}, description string, err error) } @@ -38,6 +40,11 @@ func NotImplementedCloudService() (Service, error) { type notImplementedCloudService struct { } +// Logout login to cloud provider +func (cs notImplementedCloudService) Logout(ctx context.Context) error { + return errdefs.ErrNotImplemented +} + func (cs notImplementedCloudService) Login(ctx context.Context, params map[string]string) error { return errdefs.ErrNotImplemented } diff --git a/tests/aci-e2e/e2e-aci_test.go b/tests/aci-e2e/e2e-aci_test.go index 4e73a4f70..6bced4aa7 100644 --- a/tests/aci-e2e/e2e-aci_test.go +++ b/tests/aci-e2e/e2e-aci_test.go @@ -22,10 +22,13 @@ import ( "math/rand" "net/url" "os" + "os/exec" "strings" "testing" "time" + "github.com/docker/api/errdefs" + "github.com/Azure/azure-sdk-for-go/profiles/2019-03-01/resources/mgmt/resources" azure_storage "github.com/Azure/azure-sdk-for-go/profiles/2019-03-01/storage/mgmt/storage" "github.com/Azure/azure-storage-file-go/azfile" @@ -58,6 +61,25 @@ type E2eACISuite struct { Suite } +func (s *E2eACISuite) TestLoginLogoutCreateContextError() { + s.Step("Logs in azure using service principal credentials", azureLogin) + + s.Step("logout from azure", func() { + output := s.NewDockerCommand("logout", "azure").ExecOrDie() + Expect(output).To(ContainSubstring("")) + _, err := os.Stat(login.GetTokenStorePath()) + Expect(os.IsNotExist(err)).To(BeTrue()) + }) + + s.Step("check context create fails with an explicit error and returns a specific error code", func() { + cmd := exec.Command("docker", "context", "create", "aci", "someContext") + bytes, err := cmd.CombinedOutput() + Expect(err).NotTo(BeNil()) + Expect(string(bytes)).To(ContainSubstring("not logged in to azure, you need to run \"docker login azure\" first")) + Expect(cmd.ProcessState.ExitCode()).To(Equal(errdefs.ExitCodeLoginRequired)) + }) +} + func (s *E2eACISuite) TestACIRunSingleContainer() { resourceGroupName := s.setupTestResourceGroup() defer deleteResourceGroup(resourceGroupName) diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index 12b62d368..f80e39ffa 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -18,17 +18,13 @@ package main import ( "os" - "os/exec" "path/filepath" "runtime" "testing" "time" - "github.com/docker/api/errdefs" - . "github.com/onsi/gomega" "github.com/stretchr/testify/suite" - "gotest.tools/golden" . "github.com/docker/api/tests/framework" @@ -46,14 +42,6 @@ func (s *E2eSuite) TestContextHelp() { Expect(output).To(ContainSubstring("--resource-group")) } -func (s *E2eSuite) TestContextCreateAciExitWithErrorCodeIfLoginRequired() { - cmd := exec.Command("docker", "context", "create", "aci", "someContext") - output, err := cmd.CombinedOutput() - Expect(err).NotTo(BeNil()) - Expect(string(output)).To(ContainSubstring("not logged in to azure, you need to run \"docker login azure\" first")) - Expect(cmd.ProcessState.ExitCode()).To(Equal(errdefs.ExitCodeLoginRequired)) -} - func (s *E2eSuite) TestListAndShowDefaultContext() { output := s.NewDockerCommand("context", "show").ExecOrDie() Expect(output).To(ContainSubstring("default"))