Client credentials store.

This change implements communication with an external credentials store,
ala git-credential-helper. The client falls back the plain text store,
what we're currently using, if there is no remote store configured.

It shells out to helper program when a credential store is
configured. Those programs can be implemented with any language as long as they
follow the convention to pass arguments and information.

There is an implementation for the OS X keychain in https://github.com/calavera/docker-credential-helpers.
That package also provides basic structure to create other helpers.

Signed-off-by: David Calavera <david.calavera@gmail.com>
This commit is contained in:
David Calavera 2016-02-07 19:55:17 -05:00
parent 7eed9a642e
commit cf721c23e7
20 changed files with 888 additions and 51 deletions

View File

@ -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)

View File

@ -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

View File

@ -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)
}

View File

@ -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() {

View File

@ -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() {

View File

@ -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)

View File

@ -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 {

View File

@ -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
}

View File

@ -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)

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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) {
}

View File

@ -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]
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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.

View File

@ -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)
}

View File

@ -0,0 +1,33 @@
#!/bin/bash
set -e
case $1 in
"store")
in=$(</dev/stdin)
server=$(echo "$in" | jq --raw-output ".ServerURL" | sha1sum - | awk '{print $1}')
username=$(echo "$in" | jq --raw-output ".Username")
password=$(echo "$in" | jq --raw-output ".Password")
echo "{ \"Username\": \"${username}\", \"Password\": \"${password}\" }" > $TEMP/$server
;;
"get")
in=$(</dev/stdin)
server=$(echo "$in" | sha1sum - | awk '{print $1}')
if [[ ! -f $TEMP/$server ]]; then
echo "credentials not found in native keychain"
exit 1
fi
payload=$(<$TEMP/$server)
echo "$payload"
;;
"erase")
in=$(</dev/stdin)
server=$(echo "$in" | sha1sum - | awk '{print $1}')
rm -f $TEMP/$server
;;
*)
echo "unknown credential option"
exit 1
;;
esac