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/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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -238,7 +238,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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -48,12 +48,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'
|
||||
|
@ -127,6 +128,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) {
|
||||
|
@ -250,6 +258,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)))
|
||||
|
@ -259,6 +271,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)
|
||||
|
|
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`.
|
||||
>
|
||||
|
||||
## 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)
|
||||
}
|
||||
|
||||
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…
Reference in a new issue