diff --git a/api/client/cli.go b/api/client/cli.go index fd76fc9dbb..e49c5351d5 100644 --- a/api/client/cli.go +++ b/api/client/cli.go @@ -11,6 +11,7 @@ import ( "github.com/docker/docker/api" "github.com/docker/docker/cli" "github.com/docker/docker/cliconfig" + "github.com/docker/docker/cliconfig/credentials" "github.com/docker/docker/dockerversion" "github.com/docker/docker/opts" "github.com/docker/docker/pkg/term" @@ -125,6 +126,9 @@ func NewDockerCli(in io.ReadCloser, out, err io.Writer, clientFlags *cli.ClientF if e != nil { fmt.Fprintf(cli.err, "WARNING: Error loading config file:%v\n", e) } + if !configFile.ContainsAuth() { + credentials.DetectDefaultStore(configFile) + } cli.configFile = configFile host, err := getServerHost(clientFlags.Common.Hosts, clientFlags.Common.TLSOptions) diff --git a/api/client/create.go b/api/client/create.go index d0417322e9..0c19145463 100644 --- a/api/client/create.go +++ b/api/client/create.go @@ -42,7 +42,7 @@ func (cli *DockerCli) pullImageCustomOut(image string, out io.Writer) error { return err } - authConfig := cli.resolveAuthConfig(cli.configFile.AuthConfigs, repoInfo.Index) + authConfig := cli.resolveAuthConfig(repoInfo.Index) encodedAuth, err := encodeAuthToBase64(authConfig) if err != nil { return err diff --git a/api/client/login.go b/api/client/login.go index 18ce831911..0d0588b389 100644 --- a/api/client/login.go +++ b/api/client/login.go @@ -9,6 +9,8 @@ import ( "strings" Cli "github.com/docker/docker/cli" + "github.com/docker/docker/cliconfig" + "github.com/docker/docker/cliconfig/credentials" flag "github.com/docker/docker/pkg/mflag" "github.com/docker/docker/pkg/term" "github.com/docker/engine-api/client" @@ -50,18 +52,16 @@ func (cli *DockerCli) CmdLogin(args ...string) error { response, err := cli.client.RegistryLogin(authConfig) if err != nil { if client.IsErrUnauthorized(err) { - delete(cli.configFile.AuthConfigs, serverAddress) - if err2 := cli.configFile.Save(); err2 != nil { - fmt.Fprintf(cli.out, "WARNING: could not save config file: %v\n", err2) + if err2 := eraseCredentials(cli.configFile, authConfig.ServerAddress); err2 != nil { + fmt.Fprintf(cli.out, "WARNING: could not save credentials: %v\n", err2) } } return err } - if err := cli.configFile.Save(); err != nil { - return fmt.Errorf("Error saving config file: %v", err) + if err := storeCredentials(cli.configFile, authConfig); err != nil { + return fmt.Errorf("Error saving credentials: %v", err) } - fmt.Fprintf(cli.out, "WARNING: login credentials saved in %s\n", cli.configFile.Filename()) if response.Status != "" { fmt.Fprintf(cli.out, "%s\n", response.Status) @@ -78,10 +78,11 @@ func (cli *DockerCli) promptWithDefault(prompt string, configDefault string) { } func (cli *DockerCli) configureAuth(flUser, flPassword, flEmail, serverAddress string) (types.AuthConfig, error) { - authconfig, ok := cli.configFile.AuthConfigs[serverAddress] - if !ok { - authconfig = types.AuthConfig{} + authconfig, err := getCredentials(cli.configFile, serverAddress) + if err != nil { + return authconfig, err } + authconfig.Username = strings.TrimSpace(authconfig.Username) if flUser = strings.TrimSpace(flUser); flUser == "" { @@ -133,11 +134,12 @@ func (cli *DockerCli) configureAuth(flUser, flPassword, flEmail, serverAddress s flEmail = authconfig.Email } } + authconfig.Username = flUser authconfig.Password = flPassword authconfig.Email = flEmail authconfig.ServerAddress = serverAddress - cli.configFile.AuthConfigs[serverAddress] = authconfig + return authconfig, nil } @@ -150,3 +152,33 @@ func readInput(in io.Reader, out io.Writer) string { } return string(line) } + +// getCredentials loads the user credentials from a credentials store. +// The store is determined by the config file settings. +func getCredentials(c *cliconfig.ConfigFile, serverAddress string) (types.AuthConfig, error) { + s := loadCredentialsStore(c) + return s.Get(serverAddress) +} + +// storeCredentials saves the user credentials in a credentials store. +// The store is determined by the config file settings. +func storeCredentials(c *cliconfig.ConfigFile, auth types.AuthConfig) error { + s := loadCredentialsStore(c) + return s.Store(auth) +} + +// eraseCredentials removes the user credentials from a credentials store. +// The store is determined by the config file settings. +func eraseCredentials(c *cliconfig.ConfigFile, serverAddress string) error { + s := loadCredentialsStore(c) + return s.Erase(serverAddress) +} + +// loadCredentialsStore initializes a new credentials store based +// in the settings provided in the configuration file. +func loadCredentialsStore(c *cliconfig.ConfigFile) credentials.Store { + if c.CredentialsStore != "" { + return credentials.NewNativeStore(c) + } + return credentials.NewFileStore(c) +} diff --git a/api/client/pull.go b/api/client/pull.go index cd15caaa4f..29d9677e95 100644 --- a/api/client/pull.go +++ b/api/client/pull.go @@ -56,7 +56,7 @@ func (cli *DockerCli) CmdPull(args ...string) error { return err } - authConfig := cli.resolveAuthConfig(cli.configFile.AuthConfigs, repoInfo.Index) + authConfig := cli.resolveAuthConfig(repoInfo.Index) requestPrivilege := cli.registryAuthenticationPrivilegedFunc(repoInfo.Index, "pull") if isTrusted() && !ref.HasDigest() { diff --git a/api/client/push.go b/api/client/push.go index f06a9892ef..29f26c4673 100644 --- a/api/client/push.go +++ b/api/client/push.go @@ -44,7 +44,7 @@ func (cli *DockerCli) CmdPush(args ...string) error { return err } // Resolve the Auth config relevant for this server - authConfig := cli.resolveAuthConfig(cli.configFile.AuthConfigs, repoInfo.Index) + authConfig := cli.resolveAuthConfig(repoInfo.Index) requestPrivilege := cli.registryAuthenticationPrivilegedFunc(repoInfo.Index, "push") if isTrusted() { diff --git a/api/client/search.go b/api/client/search.go index 2e1fcdafd3..c7196a74cf 100644 --- a/api/client/search.go +++ b/api/client/search.go @@ -36,7 +36,7 @@ func (cli *DockerCli) CmdSearch(args ...string) error { return err } - authConfig := cli.resolveAuthConfig(cli.configFile.AuthConfigs, indexInfo) + authConfig := cli.resolveAuthConfig(indexInfo) requestPrivilege := cli.registryAuthenticationPrivilegedFunc(indexInfo, "search") encodedAuth, err := encodeAuthToBase64(authConfig) diff --git a/api/client/trust.go b/api/client/trust.go index 753bcd6fc7..18f8231010 100644 --- a/api/client/trust.go +++ b/api/client/trust.go @@ -235,7 +235,7 @@ func (cli *DockerCli) trustedReference(ref reference.NamedTagged) (reference.Can } // Resolve the Auth config relevant for this server - authConfig := cli.resolveAuthConfig(cli.configFile.AuthConfigs, repoInfo.Index) + authConfig := cli.resolveAuthConfig(repoInfo.Index) notaryRepo, err := cli.getNotaryRepository(repoInfo, authConfig) if err != nil { diff --git a/api/client/utils.go b/api/client/utils.go index 026a16818c..73dbb673b7 100644 --- a/api/client/utils.go +++ b/api/client/utils.go @@ -10,7 +10,6 @@ import ( gosignal "os/signal" "path/filepath" "runtime" - "strings" "time" "github.com/Sirupsen/logrus" @@ -185,38 +184,12 @@ func copyToFile(outfile string, r io.Reader) error { // resolveAuthConfig is like registry.ResolveAuthConfig, but if using the // default index, it uses the default index name for the daemon's platform, // not the client's platform. -func (cli *DockerCli) resolveAuthConfig(authConfigs map[string]types.AuthConfig, index *registrytypes.IndexInfo) types.AuthConfig { +func (cli *DockerCli) resolveAuthConfig(index *registrytypes.IndexInfo) types.AuthConfig { configKey := index.Name if index.Official { configKey = cli.electAuthServer() } - // First try the happy case - if c, found := authConfigs[configKey]; found || index.Official { - return c - } - - convertToHostname := func(url string) string { - stripped := url - if strings.HasPrefix(url, "http://") { - stripped = strings.Replace(url, "http://", "", 1) - } else if strings.HasPrefix(url, "https://") { - stripped = strings.Replace(url, "https://", "", 1) - } - - nameParts := strings.SplitN(stripped, "/", 2) - - return nameParts[0] - } - - // Maybe they have a legacy config file, we will iterate the keys converting - // them to the new format and testing - for registry, ac := range authConfigs { - if configKey == convertToHostname(registry) { - return ac - } - } - - // When all else fails, return an empty auth config - return types.AuthConfig{} + a, _ := getCredentials(cli.configFile, configKey) + return a } diff --git a/cliconfig/config.go b/cliconfig/config.go index f5b2be8a40..df06944639 100644 --- a/cliconfig/config.go +++ b/cliconfig/config.go @@ -47,12 +47,13 @@ func SetConfigDir(dir string) { // ConfigFile ~/.docker/config.json file info type ConfigFile struct { - AuthConfigs map[string]types.AuthConfig `json:"auths"` - HTTPHeaders map[string]string `json:"HttpHeaders,omitempty"` - PsFormat string `json:"psFormat,omitempty"` - ImagesFormat string `json:"imagesFormat,omitempty"` - DetachKeys string `json:"detachKeys,omitempty"` - filename string // Note: not serialized - for internal use only + AuthConfigs map[string]types.AuthConfig `json:"auths"` + HTTPHeaders map[string]string `json:"HttpHeaders,omitempty"` + PsFormat string `json:"psFormat,omitempty"` + ImagesFormat string `json:"imagesFormat,omitempty"` + DetachKeys string `json:"detachKeys,omitempty"` + CredentialsStore string `json:"credsStore,omitempty"` + filename string // Note: not serialized - for internal use only } // NewConfigFile initializes an empty configuration file for the given filename 'fn' @@ -126,6 +127,13 @@ func (configFile *ConfigFile) LoadFromReader(configData io.Reader) error { return nil } +// ContainsAuth returns whether there is authentication configured +// in this file or not. +func (configFile *ConfigFile) ContainsAuth() bool { + return configFile.CredentialsStore != "" || + (configFile.AuthConfigs != nil && len(configFile.AuthConfigs) > 0) +} + // LegacyLoadFromReader is a convenience function that creates a ConfigFile object from // a non-nested reader func LegacyLoadFromReader(configData io.Reader) (*ConfigFile, error) { @@ -249,6 +257,10 @@ func (configFile *ConfigFile) Filename() string { // encodeAuth creates a base64 encoded string to containing authorization information func encodeAuth(authConfig *types.AuthConfig) string { + if authConfig.Username == "" && authConfig.Password == "" { + return "" + } + authStr := authConfig.Username + ":" + authConfig.Password msg := []byte(authStr) encoded := make([]byte, base64.StdEncoding.EncodedLen(len(msg))) @@ -258,6 +270,10 @@ func encodeAuth(authConfig *types.AuthConfig) string { // decodeAuth decodes a base64 encoded string and returns username and password func decodeAuth(authStr string) (string, string, error) { + if authStr == "" { + return "", "", nil + } + decLen := base64.StdEncoding.DecodedLen(len(authStr)) decoded := make([]byte, decLen) authByte := []byte(authStr) diff --git a/cliconfig/credentials/credentials.go b/cliconfig/credentials/credentials.go new file mode 100644 index 0000000000..a0cfd7d33e --- /dev/null +++ b/cliconfig/credentials/credentials.go @@ -0,0 +1,15 @@ +package credentials + +import ( + "github.com/docker/engine-api/types" +) + +// Store is the interface that any credentials store must implement. +type Store interface { + // Erase removes credentials from the store for a given server. + Erase(serverAddress string) error + // Get retrieves credentials from the store for a given server. + Get(serverAddress string) (types.AuthConfig, error) + // Store saves credentials in the store. + Store(authConfig types.AuthConfig) error +} diff --git a/cliconfig/credentials/default_store_darwin.go b/cliconfig/credentials/default_store_darwin.go new file mode 100644 index 0000000000..ad76187f6e --- /dev/null +++ b/cliconfig/credentials/default_store_darwin.go @@ -0,0 +1,22 @@ +package credentials + +import ( + "os/exec" + + "github.com/docker/docker/cliconfig" +) + +const defaultCredentialsStore = "osxkeychain" + +// DetectDefaultStore sets the default credentials store +// if the host includes the default store helper program. +func DetectDefaultStore(c *cliconfig.ConfigFile) { + if c.CredentialsStore != "" { + // user defined + return + } + + if _, err := exec.LookPath(remoteCredentialsPrefix + c.CredentialsStore); err == nil { + c.CredentialsStore = defaultCredentialsStore + } +} diff --git a/cliconfig/credentials/default_store_unsupported.go b/cliconfig/credentials/default_store_unsupported.go new file mode 100644 index 0000000000..724d598865 --- /dev/null +++ b/cliconfig/credentials/default_store_unsupported.go @@ -0,0 +1,11 @@ +// +build !darwin + +package credentials + +import "github.com/docker/docker/cliconfig" + +// DetectDefaultStore sets the default credentials store +// if the host includes the default store helper program. +// This operation is only supported in Darwin. +func DetectDefaultStore(c *cliconfig.ConfigFile) { +} diff --git a/cliconfig/credentials/file_store.go b/cliconfig/credentials/file_store.go new file mode 100644 index 0000000000..99461c1aa5 --- /dev/null +++ b/cliconfig/credentials/file_store.go @@ -0,0 +1,63 @@ +package credentials + +import ( + "strings" + + "github.com/docker/docker/cliconfig" + "github.com/docker/engine-api/types" +) + +// fileStore implements a credentials store using +// the docker configuration file to keep the credentials in plain text. +type fileStore struct { + file *cliconfig.ConfigFile +} + +// NewFileStore creates a new file credentials store. +func NewFileStore(file *cliconfig.ConfigFile) Store { + return &fileStore{ + file: file, + } +} + +// Erase removes the given credentials from the file store. +func (c *fileStore) Erase(serverAddress string) error { + delete(c.file.AuthConfigs, serverAddress) + return c.file.Save() +} + +// Get retrieves credentials for a specific server from the file store. +func (c *fileStore) Get(serverAddress string) (types.AuthConfig, error) { + authConfig, ok := c.file.AuthConfigs[serverAddress] + if !ok { + // Maybe they have a legacy config file, we will iterate the keys converting + // them to the new format and testing + for registry, ac := range c.file.AuthConfigs { + if serverAddress == convertToHostname(registry) { + return ac, nil + } + } + + authConfig = types.AuthConfig{} + } + return authConfig, nil +} + +// Store saves the given credentials in the file store. +func (c *fileStore) Store(authConfig types.AuthConfig) error { + c.file.AuthConfigs[authConfig.ServerAddress] = authConfig + return c.file.Save() +} + +func convertToHostname(url string) string { + stripped := url + if strings.HasPrefix(url, "http://") { + stripped = strings.Replace(url, "http://", "", 1) + } else if strings.HasPrefix(url, "https://") { + stripped = strings.Replace(url, "https://", "", 1) + } + + nameParts := strings.SplitN(stripped, "/", 2) + + return nameParts[0] +} diff --git a/cliconfig/credentials/file_store_test.go b/cliconfig/credentials/file_store_test.go new file mode 100644 index 0000000000..ed00f24df3 --- /dev/null +++ b/cliconfig/credentials/file_store_test.go @@ -0,0 +1,100 @@ +package credentials + +import ( + "io/ioutil" + "testing" + + "github.com/docker/docker/cliconfig" + "github.com/docker/engine-api/types" +) + +func newConfigFile(auths map[string]types.AuthConfig) *cliconfig.ConfigFile { + tmp, _ := ioutil.TempFile("", "docker-test") + name := tmp.Name() + tmp.Close() + + c := cliconfig.NewConfigFile(name) + c.AuthConfigs = auths + return c +} + +func TestFileStoreAddCredentials(t *testing.T) { + f := newConfigFile(make(map[string]types.AuthConfig)) + + s := NewFileStore(f) + err := s.Store(types.AuthConfig{ + Auth: "super_secret_token", + Email: "foo@example.com", + ServerAddress: "https://example.com", + }) + + if err != nil { + t.Fatal(err) + } + + if len(f.AuthConfigs) != 1 { + t.Fatalf("expected 1 auth config, got %d", len(f.AuthConfigs)) + } + + a, ok := f.AuthConfigs["https://example.com"] + if !ok { + t.Fatalf("expected auth for https://example.com, got %v", f.AuthConfigs) + } + if a.Auth != "super_secret_token" { + t.Fatalf("expected auth `super_secret_token`, got %s", a.Auth) + } + if a.Email != "foo@example.com" { + t.Fatalf("expected email `foo@example.com`, got %s", a.Email) + } +} + +func TestFileStoreGet(t *testing.T) { + f := newConfigFile(map[string]types.AuthConfig{ + "https://example.com": { + Auth: "super_secret_token", + Email: "foo@example.com", + ServerAddress: "https://example.com", + }, + }) + + s := NewFileStore(f) + a, err := s.Get("https://example.com") + if err != nil { + t.Fatal(err) + } + if a.Auth != "super_secret_token" { + t.Fatalf("expected auth `super_secret_token`, got %s", a.Auth) + } + if a.Email != "foo@example.com" { + t.Fatalf("expected email `foo@example.com`, got %s", a.Email) + } +} + +func TestFileStoreErase(t *testing.T) { + f := newConfigFile(map[string]types.AuthConfig{ + "https://example.com": { + Auth: "super_secret_token", + Email: "foo@example.com", + ServerAddress: "https://example.com", + }, + }) + + s := NewFileStore(f) + err := s.Erase("https://example.com") + if err != nil { + t.Fatal(err) + } + + // file store never returns errors, check that the auth config is empty + a, err := s.Get("https://example.com") + if err != nil { + t.Fatal(err) + } + + if a.Auth != "" { + t.Fatalf("expected empty auth token, got %s", a.Auth) + } + if a.Email != "" { + t.Fatalf("expected empty email, got %s", a.Email) + } +} diff --git a/cliconfig/credentials/native_store.go b/cliconfig/credentials/native_store.go new file mode 100644 index 0000000000..37b045ae68 --- /dev/null +++ b/cliconfig/credentials/native_store.go @@ -0,0 +1,166 @@ +package credentials + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "github.com/Sirupsen/logrus" + "github.com/docker/docker/cliconfig" + "github.com/docker/engine-api/types" +) + +const remoteCredentialsPrefix = "docker-credential-" + +// Standarize the not found error, so every helper returns +// the same message and docker can handle it properly. +var errCredentialsNotFound = errors.New("credentials not found in native keychain") + +// command is an interface that remote executed commands implement. +type command interface { + Output() ([]byte, error) + Input(in io.Reader) +} + +// credentialsRequest holds information shared between docker and a remote credential store. +type credentialsRequest struct { + ServerURL string + Username string + Password string +} + +// credentialsGetResponse is the information serialized from a remote store +// when the plugin sends requests to get the user credentials. +type credentialsGetResponse struct { + Username string + Password string +} + +// nativeStore implements a credentials store +// using native keychain to keep credentials secure. +// It piggybacks into a file store to keep users' emails. +type nativeStore struct { + commandFn func(args ...string) command + fileStore Store +} + +// NewNativeStore creates a new native store that +// uses a remote helper program to manage credentials. +func NewNativeStore(file *cliconfig.ConfigFile) Store { + return &nativeStore{ + commandFn: shellCommandFn(file.CredentialsStore), + fileStore: NewFileStore(file), + } +} + +// Erase removes the given credentials from the native store. +func (c *nativeStore) Erase(serverAddress string) error { + if err := c.eraseCredentialsFromStore(serverAddress); err != nil { + return err + } + + // Fallback to plain text store to remove email + return c.fileStore.Erase(serverAddress) +} + +// Get retrieves credentials for a specific server from the native store. +func (c *nativeStore) Get(serverAddress string) (types.AuthConfig, error) { + // load user email if it exist or an empty auth config. + auth, _ := c.fileStore.Get(serverAddress) + + creds, err := c.getCredentialsFromStore(serverAddress) + if err != nil { + return auth, err + } + auth.Username = creds.Username + auth.Password = creds.Password + + return auth, nil +} + +// Store saves the given credentials in the file store. +func (c *nativeStore) Store(authConfig types.AuthConfig) error { + if err := c.storeCredentialsInStore(authConfig); err != nil { + return err + } + authConfig.Username = "" + authConfig.Password = "" + + // Fallback to old credential in plain text to save only the email + return c.fileStore.Store(authConfig) +} + +// storeCredentialsInStore executes the command to store the credentials in the native store. +func (c *nativeStore) storeCredentialsInStore(config types.AuthConfig) error { + cmd := c.commandFn("store") + creds := &credentialsRequest{ + ServerURL: config.ServerAddress, + Username: config.Username, + Password: config.Password, + } + + buffer := new(bytes.Buffer) + if err := json.NewEncoder(buffer).Encode(creds); err != nil { + return err + } + cmd.Input(buffer) + + out, err := cmd.Output() + if err != nil { + t := strings.TrimSpace(string(out)) + logrus.Debugf("error adding credentials - err: %v, out: `%s`", err, t) + return fmt.Errorf(t) + } + + return nil +} + +// getCredentialsFromStore executes the command to get the credentials from the native store. +func (c *nativeStore) getCredentialsFromStore(serverAddress string) (types.AuthConfig, error) { + var ret types.AuthConfig + + cmd := c.commandFn("get") + cmd.Input(strings.NewReader(serverAddress)) + + out, err := cmd.Output() + if err != nil { + t := strings.TrimSpace(string(out)) + + // do not return an error if the credentials are not + // in the keyckain. Let docker ask for new credentials. + if t == errCredentialsNotFound.Error() { + return ret, nil + } + + logrus.Debugf("error adding credentials - err: %v, out: `%s`", err, t) + return ret, fmt.Errorf(t) + } + + var resp credentialsGetResponse + if err := json.NewDecoder(bytes.NewReader(out)).Decode(&resp); err != nil { + return ret, err + } + + ret.Username = resp.Username + ret.Password = resp.Password + ret.ServerAddress = serverAddress + return ret, nil +} + +// eraseCredentialsFromStore executes the command to remove the server redentails from the native store. +func (c *nativeStore) eraseCredentialsFromStore(serverURL string) error { + cmd := c.commandFn("erase") + cmd.Input(strings.NewReader(serverURL)) + + out, err := cmd.Output() + if err != nil { + t := strings.TrimSpace(string(out)) + logrus.Debugf("error adding credentials - err: %v, out: `%s`", err, t) + return fmt.Errorf(t) + } + + return nil +} diff --git a/cliconfig/credentials/native_store_test.go b/cliconfig/credentials/native_store_test.go new file mode 100644 index 0000000000..cb59bda4a8 --- /dev/null +++ b/cliconfig/credentials/native_store_test.go @@ -0,0 +1,264 @@ +package credentials + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "strings" + "testing" + + "github.com/docker/engine-api/types" +) + +const ( + validServerAddress = "https://index.docker.io/v1" + invalidServerAddress = "https://foobar.example.com" + missingCredsAddress = "https://missing.docker.io/v1" +) + +var errCommandExited = fmt.Errorf("exited 1") + +// mockCommand simulates interactions between the docker client and a remote +// credentials helper. +// Unit tests inject this mocked command into the remote to control execution. +type mockCommand struct { + arg string + input io.Reader +} + +// Output returns responses from the remote credentials helper. +// It mocks those reponses based in the input in the mock. +func (m *mockCommand) Output() ([]byte, error) { + in, err := ioutil.ReadAll(m.input) + if err != nil { + return nil, err + } + inS := string(in) + + switch m.arg { + case "erase": + switch inS { + case validServerAddress: + return nil, nil + default: + return []byte("error erasing credentials"), errCommandExited + } + case "get": + switch inS { + case validServerAddress: + return []byte(`{"Username": "foo", "Password": "bar"}`), nil + case missingCredsAddress: + return []byte(errCredentialsNotFound.Error()), errCommandExited + case invalidServerAddress: + return []byte("error getting credentials"), errCommandExited + } + case "store": + var c credentialsRequest + err := json.NewDecoder(strings.NewReader(inS)).Decode(&c) + if err != nil { + return []byte("error storing credentials"), errCommandExited + } + switch c.ServerURL { + case validServerAddress: + return nil, nil + default: + return []byte("error storing credentials"), errCommandExited + } + } + + return []byte("unknown argument"), errCommandExited +} + +// Input sets the input to send to a remote credentials helper. +func (m *mockCommand) Input(in io.Reader) { + m.input = in +} + +func mockCommandFn(args ...string) command { + return &mockCommand{ + arg: args[0], + } +} + +func TestNativeStoreAddCredentials(t *testing.T) { + f := newConfigFile(make(map[string]types.AuthConfig)) + f.CredentialsStore = "mock" + + s := &nativeStore{ + commandFn: mockCommandFn, + fileStore: NewFileStore(f), + } + err := s.Store(types.AuthConfig{ + Username: "foo", + Password: "bar", + Email: "foo@example.com", + ServerAddress: validServerAddress, + }) + + if err != nil { + t.Fatal(err) + } + + if len(f.AuthConfigs) != 1 { + t.Fatalf("expected 1 auth config, got %d", len(f.AuthConfigs)) + } + + a, ok := f.AuthConfigs[validServerAddress] + if !ok { + t.Fatalf("expected auth for %s, got %v", validServerAddress, f.AuthConfigs) + } + if a.Auth != "" { + t.Fatalf("expected auth to be empty, got %s", a.Auth) + } + if a.Username != "" { + t.Fatalf("expected username to be empty, got %s", a.Username) + } + if a.Password != "" { + t.Fatalf("expected password to be empty, got %s", a.Password) + } + if a.Email != "foo@example.com" { + t.Fatalf("expected email `foo@example.com`, got %s", a.Email) + } +} + +func TestNativeStoreAddInvalidCredentials(t *testing.T) { + f := newConfigFile(make(map[string]types.AuthConfig)) + f.CredentialsStore = "mock" + + s := &nativeStore{ + commandFn: mockCommandFn, + fileStore: NewFileStore(f), + } + err := s.Store(types.AuthConfig{ + Username: "foo", + Password: "bar", + Email: "foo@example.com", + ServerAddress: invalidServerAddress, + }) + + if err == nil { + t.Fatal("expected error, got nil") + } + + if err.Error() != "error storing credentials" { + t.Fatalf("expected `error storing credentials`, got %v", err) + } + + if len(f.AuthConfigs) != 0 { + t.Fatalf("expected 0 auth config, got %d", len(f.AuthConfigs)) + } +} + +func TestNativeStoreGet(t *testing.T) { + f := newConfigFile(map[string]types.AuthConfig{ + validServerAddress: { + Email: "foo@example.com", + }, + }) + f.CredentialsStore = "mock" + + s := &nativeStore{ + commandFn: mockCommandFn, + fileStore: NewFileStore(f), + } + a, err := s.Get(validServerAddress) + if err != nil { + t.Fatal(err) + } + + if a.Username != "foo" { + t.Fatalf("expected username `foo`, got %s", a.Username) + } + if a.Password != "bar" { + t.Fatalf("expected password `bar`, got %s", a.Password) + } + if a.Email != "foo@example.com" { + t.Fatalf("expected email `foo@example.com`, got %s", a.Email) + } +} + +func TestNativeStoreGetMissingCredentials(t *testing.T) { + f := newConfigFile(map[string]types.AuthConfig{ + validServerAddress: { + Email: "foo@example.com", + }, + }) + f.CredentialsStore = "mock" + + s := &nativeStore{ + commandFn: mockCommandFn, + fileStore: NewFileStore(f), + } + _, err := s.Get(missingCredsAddress) + if err != nil { + // missing credentials do not produce an error + t.Fatal(err) + } +} + +func TestNativeStoreGetInvalidAddress(t *testing.T) { + f := newConfigFile(map[string]types.AuthConfig{ + validServerAddress: { + Email: "foo@example.com", + }, + }) + f.CredentialsStore = "mock" + + s := &nativeStore{ + commandFn: mockCommandFn, + fileStore: NewFileStore(f), + } + _, err := s.Get(invalidServerAddress) + if err == nil { + t.Fatal("expected error, got nil") + } + + if err.Error() != "error getting credentials" { + t.Fatalf("expected `error getting credentials`, got %v", err) + } +} + +func TestNativeStoreErase(t *testing.T) { + f := newConfigFile(map[string]types.AuthConfig{ + validServerAddress: { + Email: "foo@example.com", + }, + }) + f.CredentialsStore = "mock" + + s := &nativeStore{ + commandFn: mockCommandFn, + fileStore: NewFileStore(f), + } + err := s.Erase(validServerAddress) + if err != nil { + t.Fatal(err) + } + + if len(f.AuthConfigs) != 0 { + t.Fatalf("expected 0 auth configs, got %d", len(f.AuthConfigs)) + } +} + +func TestNativeStoreEraseInvalidAddress(t *testing.T) { + f := newConfigFile(map[string]types.AuthConfig{ + validServerAddress: { + Email: "foo@example.com", + }, + }) + f.CredentialsStore = "mock" + + s := &nativeStore{ + commandFn: mockCommandFn, + fileStore: NewFileStore(f), + } + err := s.Erase(invalidServerAddress) + if err == nil { + t.Fatal("expected error, got nil") + } + + if err.Error() != "error erasing credentials" { + t.Fatalf("expected `error erasing credentials`, got %v", err) + } +} diff --git a/cliconfig/credentials/shell_command.go b/cliconfig/credentials/shell_command.go new file mode 100644 index 0000000000..fa481b195d --- /dev/null +++ b/cliconfig/credentials/shell_command.go @@ -0,0 +1,28 @@ +package credentials + +import ( + "io" + "os/exec" +) + +func shellCommandFn(storeName string) func(args ...string) command { + name := remoteCredentialsPrefix + storeName + return func(args ...string) command { + return &shell{cmd: exec.Command(name, args...)} + } +} + +// shell invokes shell commands to talk with a remote credentials helper. +type shell struct { + cmd *exec.Cmd +} + +// Output returns responses from the remote credentials helper. +func (s *shell) Output() ([]byte, error) { + return s.cmd.Output() +} + +// Input sets the input to send to a remote credentials helper. +func (s *shell) Input(in io.Reader) { + s.cmd.Stdin = in +} diff --git a/docs/reference/commandline/login.md b/docs/reference/commandline/login.md index faf3615a00..b20fb6cd96 100644 --- a/docs/reference/commandline/login.md +++ b/docs/reference/commandline/login.md @@ -38,3 +38,77 @@ credentials. When you log in, the command stores encoded credentials in > **Note**: When running `sudo docker login` credentials are saved in `/root/.docker/config.json`. > + +## Credentials store + +The Docker Engine can keep user credentials in an external credentials store, +such as the native keychain of the operating system. Using an external store +is more secure than storing credentials in the Docker configuration file. + +To use a credentials store, you need an external helper program to interact +with a specific keychain or external store. Docker requires the helper +program to be in the client's host `$PATH`. + +This is the list of currently available credentials helpers and where +you can download them from: + +- Apple OS X keychain: https://github.com/docker/docker-credential-helpers/releases +- Microsoft Windows Credential Manager: https://github.com/docker/docker-credential-helpers/releases + +### Usage + +You need to speficy the credentials store in `HOME/.docker/config.json` +to tell the docker engine to use it: + +```json +{ + "credsStore": "osxkeychain" +} +``` + +If you are currently logged in, run `docker logout` to remove +the credentials from the file and run `docker login` again. + +### Protocol + +Credential helpers can be any program or script that follows a very simple protocol. +This protocol is heavily inspired by Git, but it differs in the information shared. + +The helpers always use the first argument in the command to identify the action. +There are only three possible values for that argument: `store`, `get`, and `erase`. + +The `store` command takes a JSON payload from the standard input. That payload carries +the server address, to identify the credential, the user name and the password. +This is an example of that payload: + +```json +{ + "ServerURL": "https://index.docker.io/v1", + "Username": "david", + "Password": "passw0rd1" +} +``` + +The `store` command can write error messages to `STDOUT` that the docker engine +will show if there was an issue. + +The `get` command takes a string payload from the standard input. That payload carries +the server address that the docker engine needs credentials for. This is +an example of that payload: `https://index.docker.io/v1`. + +The `get` command writes a JSON payload to `STDOUT`. Docker reads the user name +and password from this payload: + +```json +{ + "Username": "david", + "Password": "passw0rd1" +} +``` + +The `erase` command takes a string payload from `STDIN`. That payload carries +the server address that the docker engine wants to remove credentials for. This is +an example of that payload: `https://index.docker.io/v1`. + +The `erase` command can write error messages to `STDOUT` that the docker engine +will show if there was an issue. diff --git a/integration-cli/docker_cli_pull_local_test.go b/integration-cli/docker_cli_pull_local_test.go index 8846fecea2..96388b18e8 100644 --- a/integration-cli/docker_cli_pull_local_test.go +++ b/integration-cli/docker_cli_pull_local_test.go @@ -361,3 +361,39 @@ func (s *DockerRegistrySuite) TestPullManifestList(c *check.C) { dockerCmd(c, "rmi", repoName) } + +func (s *DockerRegistryAuthSuite) TestPullWithExternalAuth(c *check.C) { + osPath := os.Getenv("PATH") + defer os.Setenv("PATH", osPath) + + workingDir, err := os.Getwd() + c.Assert(err, checker.IsNil) + absolute, err := filepath.Abs(filepath.Join(workingDir, "fixtures", "auth")) + c.Assert(err, checker.IsNil) + testPath := fmt.Sprintf("%s%c%s", osPath, filepath.ListSeparator, absolute) + + os.Setenv("PATH", testPath) + + repoName := fmt.Sprintf("%v/dockercli/busybox:authtest", privateRegistryURL) + + tmp, err := ioutil.TempDir("", "integration-cli-") + c.Assert(err, checker.IsNil) + + externalAuthConfig := `{ "credsStore": "shell-test" }` + + configPath := filepath.Join(tmp, "config.json") + err = ioutil.WriteFile(configPath, []byte(externalAuthConfig), 0644) + c.Assert(err, checker.IsNil) + + dockerCmd(c, "--config", tmp, "login", "-u", s.reg.username, "-p", s.reg.password, "-e", s.reg.email, privateRegistryURL) + + b, err := ioutil.ReadFile(configPath) + c.Assert(err, checker.IsNil) + c.Assert(string(b), checker.Not(checker.Contains), "\"auth\":") + c.Assert(string(b), checker.Contains, "email") + + dockerCmd(c, "--config", tmp, "tag", "busybox", repoName) + dockerCmd(c, "--config", tmp, "push", repoName) + + dockerCmd(c, "--config", tmp, "pull", repoName) +} diff --git a/integration-cli/fixtures/auth/docker-credential-shell-test b/integration-cli/fixtures/auth/docker-credential-shell-test new file mode 100755 index 0000000000..0c94bcd216 --- /dev/null +++ b/integration-cli/fixtures/auth/docker-credential-shell-test @@ -0,0 +1,33 @@ +#!/bin/bash + +set -e + +case $1 in + "store") + in=$( $TEMP/$server + ;; + "get") + in=$(