mirror of
https://github.com/moby/moby.git
synced 2022-11-09 12:21:53 -05:00
Merge pull request #20107 from calavera/client_auth_store
Client credentials store.
This commit is contained in:
commit
29ce086e38
20 changed files with 888 additions and 51 deletions
|
@ -11,6 +11,7 @@ import (
|
||||||
"github.com/docker/docker/api"
|
"github.com/docker/docker/api"
|
||||||
"github.com/docker/docker/cli"
|
"github.com/docker/docker/cli"
|
||||||
"github.com/docker/docker/cliconfig"
|
"github.com/docker/docker/cliconfig"
|
||||||
|
"github.com/docker/docker/cliconfig/credentials"
|
||||||
"github.com/docker/docker/dockerversion"
|
"github.com/docker/docker/dockerversion"
|
||||||
"github.com/docker/docker/opts"
|
"github.com/docker/docker/opts"
|
||||||
"github.com/docker/docker/pkg/term"
|
"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 {
|
if e != nil {
|
||||||
fmt.Fprintf(cli.err, "WARNING: Error loading config file:%v\n", e)
|
fmt.Fprintf(cli.err, "WARNING: Error loading config file:%v\n", e)
|
||||||
}
|
}
|
||||||
|
if !configFile.ContainsAuth() {
|
||||||
|
credentials.DetectDefaultStore(configFile)
|
||||||
|
}
|
||||||
cli.configFile = configFile
|
cli.configFile = configFile
|
||||||
|
|
||||||
host, err := getServerHost(clientFlags.Common.Hosts, clientFlags.Common.TLSOptions)
|
host, err := getServerHost(clientFlags.Common.Hosts, clientFlags.Common.TLSOptions)
|
||||||
|
|
|
@ -42,7 +42,7 @@ func (cli *DockerCli) pullImageCustomOut(image string, out io.Writer) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
authConfig := cli.resolveAuthConfig(cli.configFile.AuthConfigs, repoInfo.Index)
|
authConfig := cli.resolveAuthConfig(repoInfo.Index)
|
||||||
encodedAuth, err := encodeAuthToBase64(authConfig)
|
encodedAuth, err := encodeAuthToBase64(authConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
@ -9,6 +9,8 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
Cli "github.com/docker/docker/cli"
|
Cli "github.com/docker/docker/cli"
|
||||||
|
"github.com/docker/docker/cliconfig"
|
||||||
|
"github.com/docker/docker/cliconfig/credentials"
|
||||||
flag "github.com/docker/docker/pkg/mflag"
|
flag "github.com/docker/docker/pkg/mflag"
|
||||||
"github.com/docker/docker/pkg/term"
|
"github.com/docker/docker/pkg/term"
|
||||||
"github.com/docker/engine-api/client"
|
"github.com/docker/engine-api/client"
|
||||||
|
@ -50,18 +52,16 @@ func (cli *DockerCli) CmdLogin(args ...string) error {
|
||||||
response, err := cli.client.RegistryLogin(authConfig)
|
response, err := cli.client.RegistryLogin(authConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if client.IsErrUnauthorized(err) {
|
if client.IsErrUnauthorized(err) {
|
||||||
delete(cli.configFile.AuthConfigs, serverAddress)
|
if err2 := eraseCredentials(cli.configFile, authConfig.ServerAddress); err2 != nil {
|
||||||
if err2 := cli.configFile.Save(); err2 != nil {
|
fmt.Fprintf(cli.out, "WARNING: could not save credentials: %v\n", err2)
|
||||||
fmt.Fprintf(cli.out, "WARNING: could not save config file: %v\n", err2)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cli.configFile.Save(); err != nil {
|
if err := storeCredentials(cli.configFile, authConfig); err != nil {
|
||||||
return fmt.Errorf("Error saving config file: %v", err)
|
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 != "" {
|
if response.Status != "" {
|
||||||
fmt.Fprintf(cli.out, "%s\n", 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) {
|
func (cli *DockerCli) configureAuth(flUser, flPassword, flEmail, serverAddress string) (types.AuthConfig, error) {
|
||||||
authconfig, ok := cli.configFile.AuthConfigs[serverAddress]
|
authconfig, err := getCredentials(cli.configFile, serverAddress)
|
||||||
if !ok {
|
if err != nil {
|
||||||
authconfig = types.AuthConfig{}
|
return authconfig, err
|
||||||
}
|
}
|
||||||
|
|
||||||
authconfig.Username = strings.TrimSpace(authconfig.Username)
|
authconfig.Username = strings.TrimSpace(authconfig.Username)
|
||||||
|
|
||||||
if flUser = strings.TrimSpace(flUser); flUser == "" {
|
if flUser = strings.TrimSpace(flUser); flUser == "" {
|
||||||
|
@ -133,11 +134,12 @@ func (cli *DockerCli) configureAuth(flUser, flPassword, flEmail, serverAddress s
|
||||||
flEmail = authconfig.Email
|
flEmail = authconfig.Email
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
authconfig.Username = flUser
|
authconfig.Username = flUser
|
||||||
authconfig.Password = flPassword
|
authconfig.Password = flPassword
|
||||||
authconfig.Email = flEmail
|
authconfig.Email = flEmail
|
||||||
authconfig.ServerAddress = serverAddress
|
authconfig.ServerAddress = serverAddress
|
||||||
cli.configFile.AuthConfigs[serverAddress] = authconfig
|
|
||||||
return authconfig, nil
|
return authconfig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -150,3 +152,33 @@ func readInput(in io.Reader, out io.Writer) string {
|
||||||
}
|
}
|
||||||
return string(line)
|
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)
|
||||||
|
}
|
||||||
|
|
|
@ -56,7 +56,7 @@ func (cli *DockerCli) CmdPull(args ...string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
authConfig := cli.resolveAuthConfig(cli.configFile.AuthConfigs, repoInfo.Index)
|
authConfig := cli.resolveAuthConfig(repoInfo.Index)
|
||||||
requestPrivilege := cli.registryAuthenticationPrivilegedFunc(repoInfo.Index, "pull")
|
requestPrivilege := cli.registryAuthenticationPrivilegedFunc(repoInfo.Index, "pull")
|
||||||
|
|
||||||
if isTrusted() && !ref.HasDigest() {
|
if isTrusted() && !ref.HasDigest() {
|
||||||
|
|
|
@ -44,7 +44,7 @@ func (cli *DockerCli) CmdPush(args ...string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Resolve the Auth config relevant for this server
|
// 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")
|
requestPrivilege := cli.registryAuthenticationPrivilegedFunc(repoInfo.Index, "push")
|
||||||
if isTrusted() {
|
if isTrusted() {
|
||||||
|
|
|
@ -36,7 +36,7 @@ func (cli *DockerCli) CmdSearch(args ...string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
authConfig := cli.resolveAuthConfig(cli.configFile.AuthConfigs, indexInfo)
|
authConfig := cli.resolveAuthConfig(indexInfo)
|
||||||
requestPrivilege := cli.registryAuthenticationPrivilegedFunc(indexInfo, "search")
|
requestPrivilege := cli.registryAuthenticationPrivilegedFunc(indexInfo, "search")
|
||||||
|
|
||||||
encodedAuth, err := encodeAuthToBase64(authConfig)
|
encodedAuth, err := encodeAuthToBase64(authConfig)
|
||||||
|
|
|
@ -238,7 +238,7 @@ func (cli *DockerCli) trustedReference(ref reference.NamedTagged) (reference.Can
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the Auth config relevant for this server
|
// 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)
|
notaryRepo, err := cli.getNotaryRepository(repoInfo, authConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -10,7 +10,6 @@ import (
|
||||||
gosignal "os/signal"
|
gosignal "os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Sirupsen/logrus"
|
"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
|
// resolveAuthConfig is like registry.ResolveAuthConfig, but if using the
|
||||||
// default index, it uses the default index name for the daemon's platform,
|
// default index, it uses the default index name for the daemon's platform,
|
||||||
// not the client'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
|
configKey := index.Name
|
||||||
if index.Official {
|
if index.Official {
|
||||||
configKey = cli.electAuthServer()
|
configKey = cli.electAuthServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
// First try the happy case
|
a, _ := getCredentials(cli.configFile, configKey)
|
||||||
if c, found := authConfigs[configKey]; found || index.Official {
|
return a
|
||||||
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{}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,12 +48,13 @@ func SetConfigDir(dir string) {
|
||||||
|
|
||||||
// ConfigFile ~/.docker/config.json file info
|
// ConfigFile ~/.docker/config.json file info
|
||||||
type ConfigFile struct {
|
type ConfigFile struct {
|
||||||
AuthConfigs map[string]types.AuthConfig `json:"auths"`
|
AuthConfigs map[string]types.AuthConfig `json:"auths"`
|
||||||
HTTPHeaders map[string]string `json:"HttpHeaders,omitempty"`
|
HTTPHeaders map[string]string `json:"HttpHeaders,omitempty"`
|
||||||
PsFormat string `json:"psFormat,omitempty"`
|
PsFormat string `json:"psFormat,omitempty"`
|
||||||
ImagesFormat string `json:"imagesFormat,omitempty"`
|
ImagesFormat string `json:"imagesFormat,omitempty"`
|
||||||
DetachKeys string `json:"detachKeys,omitempty"`
|
DetachKeys string `json:"detachKeys,omitempty"`
|
||||||
filename string // Note: not serialized - for internal use only
|
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'
|
// NewConfigFile initializes an empty configuration file for the given filename 'fn'
|
||||||
|
@ -127,6 +128,13 @@ func (configFile *ConfigFile) LoadFromReader(configData io.Reader) error {
|
||||||
return nil
|
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
|
// LegacyLoadFromReader is a convenience function that creates a ConfigFile object from
|
||||||
// a non-nested reader
|
// a non-nested reader
|
||||||
func LegacyLoadFromReader(configData io.Reader) (*ConfigFile, error) {
|
func LegacyLoadFromReader(configData io.Reader) (*ConfigFile, error) {
|
||||||
|
@ -250,6 +258,10 @@ func (configFile *ConfigFile) Filename() string {
|
||||||
|
|
||||||
// encodeAuth creates a base64 encoded string to containing authorization information
|
// encodeAuth creates a base64 encoded string to containing authorization information
|
||||||
func encodeAuth(authConfig *types.AuthConfig) string {
|
func encodeAuth(authConfig *types.AuthConfig) string {
|
||||||
|
if authConfig.Username == "" && authConfig.Password == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
authStr := authConfig.Username + ":" + authConfig.Password
|
authStr := authConfig.Username + ":" + authConfig.Password
|
||||||
msg := []byte(authStr)
|
msg := []byte(authStr)
|
||||||
encoded := make([]byte, base64.StdEncoding.EncodedLen(len(msg)))
|
encoded := make([]byte, base64.StdEncoding.EncodedLen(len(msg)))
|
||||||
|
@ -259,6 +271,10 @@ func encodeAuth(authConfig *types.AuthConfig) string {
|
||||||
|
|
||||||
// decodeAuth decodes a base64 encoded string and returns username and password
|
// decodeAuth decodes a base64 encoded string and returns username and password
|
||||||
func decodeAuth(authStr string) (string, string, error) {
|
func decodeAuth(authStr string) (string, string, error) {
|
||||||
|
if authStr == "" {
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
|
||||||
decLen := base64.StdEncoding.DecodedLen(len(authStr))
|
decLen := base64.StdEncoding.DecodedLen(len(authStr))
|
||||||
decoded := make([]byte, decLen)
|
decoded := make([]byte, decLen)
|
||||||
authByte := []byte(authStr)
|
authByte := []byte(authStr)
|
||||||
|
|
15
cliconfig/credentials/credentials.go
Normal file
15
cliconfig/credentials/credentials.go
Normal 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
|
||||||
|
}
|
22
cliconfig/credentials/default_store_darwin.go
Normal file
22
cliconfig/credentials/default_store_darwin.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
11
cliconfig/credentials/default_store_unsupported.go
Normal file
11
cliconfig/credentials/default_store_unsupported.go
Normal 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) {
|
||||||
|
}
|
63
cliconfig/credentials/file_store.go
Normal file
63
cliconfig/credentials/file_store.go
Normal 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]
|
||||||
|
}
|
100
cliconfig/credentials/file_store_test.go
Normal file
100
cliconfig/credentials/file_store_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
166
cliconfig/credentials/native_store.go
Normal file
166
cliconfig/credentials/native_store.go
Normal 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
|
||||||
|
}
|
264
cliconfig/credentials/native_store_test.go
Normal file
264
cliconfig/credentials/native_store_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
28
cliconfig/credentials/shell_command.go
Normal file
28
cliconfig/credentials/shell_command.go
Normal 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
|
||||||
|
}
|
|
@ -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`.
|
> **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.
|
||||||
|
|
|
@ -361,3 +361,39 @@ func (s *DockerRegistrySuite) TestPullManifestList(c *check.C) {
|
||||||
|
|
||||||
dockerCmd(c, "rmi", repoName)
|
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)
|
||||||
|
}
|
||||||
|
|
33
integration-cli/fixtures/auth/docker-credential-shell-test
Executable file
33
integration-cli/fixtures/auth/docker-credential-shell-test
Executable 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
|
Loading…
Add table
Reference in a new issue