Merge pull request #22988 from calavera/use_client_credentials_library

Move native credentials lookup to the client library.
This commit is contained in:
Vincent Demeester 2016-06-05 16:27:15 +02:00
commit 0f13b69fe2
11 changed files with 360 additions and 148 deletions

View file

@ -1,14 +1,8 @@
package credentials
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"github.com/Sirupsen/logrus"
"github.com/docker/docker-credential-helpers/client"
"github.com/docker/docker-credential-helpers/credentials"
"github.com/docker/docker/cliconfig/configfile"
"github.com/docker/engine-api/types"
)
@ -18,50 +12,27 @@ const (
tokenUsername = "<token>"
)
// 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
Secret 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
Secret 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
programFunc client.ProgramFunc
fileStore Store
}
// NewNativeStore creates a new native store that
// uses a remote helper program to manage credentials.
func NewNativeStore(file *configfile.ConfigFile) Store {
name := remoteCredentialsPrefix + file.CredentialsStore
return &nativeStore{
commandFn: shellCommandFn(file.CredentialsStore),
fileStore: NewFileStore(file),
programFunc: client.NewShellProgramFunc(name),
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 {
if err := client.Erase(c.programFunc, serverAddress); err != nil {
return err
}
@ -115,8 +86,7 @@ func (c *nativeStore) Store(authConfig types.AuthConfig) error {
// 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{
creds := &credentials.Credentials{
ServerURL: config.ServerAddress,
Username: config.Username,
Secret: config.Password,
@ -127,70 +97,30 @@ func (c *nativeStore) storeCredentialsInStore(config types.AuthConfig) error {
creds.Secret = config.IdentityToken
}
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
return client.Store(c.programFunc, creds)
}
// 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()
creds, err := client.Get(c.programFunc, serverAddress)
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() {
if credentials.IsErrCredentialsNotFound(err) {
// do not return an error if the credentials are not
// in the keyckain. Let docker ask for new credentials.
return ret, nil
}
logrus.Debugf("error getting 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
}
if resp.Username == tokenUsername {
ret.IdentityToken = resp.Secret
if creds.Username == tokenUsername {
ret.IdentityToken = creds.Secret
} else {
ret.Password = resp.Secret
ret.Username = resp.Username
ret.Password = creds.Secret
ret.Username = creds.Username
}
ret.ServerAddress = serverAddress
return ret, nil
}
// eraseCredentialsFromStore executes the command to remove the server credentails 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 erasing credentials - err: %v, out: `%s`", err, t)
return fmt.Errorf(t)
}
return nil
}

View file

@ -8,6 +8,8 @@ import (
"strings"
"testing"
"github.com/docker/docker-credential-helpers/client"
"github.com/docker/docker-credential-helpers/credentials"
"github.com/docker/engine-api/types"
)
@ -43,7 +45,7 @@ func (m *mockCommand) Output() ([]byte, error) {
case validServerAddress:
return nil, nil
default:
return []byte("error erasing credentials"), errCommandExited
return []byte("program failed"), errCommandExited
}
case "get":
switch inS {
@ -52,21 +54,21 @@ func (m *mockCommand) Output() ([]byte, error) {
case validServerAddress2:
return []byte(`{"Username": "<token>", "Secret": "abcd1234"}`), nil
case missingCredsAddress:
return []byte(errCredentialsNotFound.Error()), errCommandExited
return []byte(credentials.NewErrCredentialsNotFound().Error()), errCommandExited
case invalidServerAddress:
return []byte("error getting credentials"), errCommandExited
return []byte("program failed"), errCommandExited
}
case "store":
var c credentialsRequest
var c credentials.Credentials
err := json.NewDecoder(strings.NewReader(inS)).Decode(&c)
if err != nil {
return []byte("error storing credentials"), errCommandExited
return []byte("program failed"), errCommandExited
}
switch c.ServerURL {
case validServerAddress:
return nil, nil
default:
return []byte("error storing credentials"), errCommandExited
return []byte("program failed"), errCommandExited
}
}
@ -78,7 +80,7 @@ func (m *mockCommand) Input(in io.Reader) {
m.input = in
}
func mockCommandFn(args ...string) command {
func mockCommandFn(args ...string) client.Program {
return &mockCommand{
arg: args[0],
}
@ -89,8 +91,8 @@ func TestNativeStoreAddCredentials(t *testing.T) {
f.CredentialsStore = "mock"
s := &nativeStore{
commandFn: mockCommandFn,
fileStore: NewFileStore(f),
programFunc: mockCommandFn,
fileStore: NewFileStore(f),
}
err := s.Store(types.AuthConfig{
Username: "foo",
@ -133,8 +135,8 @@ func TestNativeStoreAddInvalidCredentials(t *testing.T) {
f.CredentialsStore = "mock"
s := &nativeStore{
commandFn: mockCommandFn,
fileStore: NewFileStore(f),
programFunc: mockCommandFn,
fileStore: NewFileStore(f),
}
err := s.Store(types.AuthConfig{
Username: "foo",
@ -147,8 +149,8 @@ func TestNativeStoreAddInvalidCredentials(t *testing.T) {
t.Fatal("expected error, got nil")
}
if err.Error() != "error storing credentials" {
t.Fatalf("expected `error storing credentials`, got %v", err)
if !strings.Contains(err.Error(), "program failed") {
t.Fatalf("expected `program failed`, got %v", err)
}
if len(f.AuthConfigs) != 0 {
@ -165,8 +167,8 @@ func TestNativeStoreGet(t *testing.T) {
f.CredentialsStore = "mock"
s := &nativeStore{
commandFn: mockCommandFn,
fileStore: NewFileStore(f),
programFunc: mockCommandFn,
fileStore: NewFileStore(f),
}
a, err := s.Get(validServerAddress)
if err != nil {
@ -196,8 +198,8 @@ func TestNativeStoreGetIdentityToken(t *testing.T) {
f.CredentialsStore = "mock"
s := &nativeStore{
commandFn: mockCommandFn,
fileStore: NewFileStore(f),
programFunc: mockCommandFn,
fileStore: NewFileStore(f),
}
a, err := s.Get(validServerAddress2)
if err != nil {
@ -230,8 +232,8 @@ func TestNativeStoreGetAll(t *testing.T) {
f.CredentialsStore = "mock"
s := &nativeStore{
commandFn: mockCommandFn,
fileStore: NewFileStore(f),
programFunc: mockCommandFn,
fileStore: NewFileStore(f),
}
as, err := s.GetAll()
if err != nil {
@ -277,8 +279,8 @@ func TestNativeStoreGetMissingCredentials(t *testing.T) {
f.CredentialsStore = "mock"
s := &nativeStore{
commandFn: mockCommandFn,
fileStore: NewFileStore(f),
programFunc: mockCommandFn,
fileStore: NewFileStore(f),
}
_, err := s.Get(missingCredsAddress)
if err != nil {
@ -296,16 +298,16 @@ func TestNativeStoreGetInvalidAddress(t *testing.T) {
f.CredentialsStore = "mock"
s := &nativeStore{
commandFn: mockCommandFn,
fileStore: NewFileStore(f),
programFunc: 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)
if !strings.Contains(err.Error(), "program failed") {
t.Fatalf("expected `program failed`, got %v", err)
}
}
@ -318,8 +320,8 @@ func TestNativeStoreErase(t *testing.T) {
f.CredentialsStore = "mock"
s := &nativeStore{
commandFn: mockCommandFn,
fileStore: NewFileStore(f),
programFunc: mockCommandFn,
fileStore: NewFileStore(f),
}
err := s.Erase(validServerAddress)
if err != nil {
@ -340,15 +342,15 @@ func TestNativeStoreEraseInvalidAddress(t *testing.T) {
f.CredentialsStore = "mock"
s := &nativeStore{
commandFn: mockCommandFn,
fileStore: NewFileStore(f),
programFunc: 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)
if !strings.Contains(err.Error(), "program failed") {
t.Fatalf("expected `program failed`, got %v", err)
}
}

View file

@ -1,28 +0,0 @@
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

@ -108,7 +108,7 @@ clean() {
go list -e -tags "$buildTags" -f '{{join .Deps "\n"}}' "${packages[@]}"
go list -e -tags "$buildTags" -f '{{join .TestImports "\n"}}' "${packages[@]}"
done
done | grep -vE "^${PROJECT}" | sort -u
done | grep -vE "^${PROJECT}/" | sort -u
) )
imports=( $(go list -e -f '{{if not .Standard}}{{.ImportPath}}{{end}}' "${imports[@]}") )
unset IFS

View file

@ -131,6 +131,9 @@ clone git golang.org/x/oauth2 2baa8a1b9338cf13d9eeb27696d761155fa480be https://g
clone git google.golang.org/api dc6d2353af16e2a2b0ff6986af051d473a4ed468 https://code.googlesource.com/google-api-go-client
clone git google.golang.org/cloud dae7e3d993bc3812a2185af60552bb6b847e52a0 https://code.googlesource.com/gocloud
# native credentials
clone git github.com/docker/docker-credential-helpers v0.3.0
# containerd
clone git github.com/docker/containerd 57b7c3da915ebe943bd304c00890959b191e5264

View file

@ -0,0 +1,20 @@
Copyright (c) 2016 David Calavera
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -0,0 +1,70 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"github.com/docker/docker-credential-helpers/credentials"
)
// Store uses an external program to save credentials.
func Store(program ProgramFunc, credentials *credentials.Credentials) error {
cmd := program("store")
buffer := new(bytes.Buffer)
if err := json.NewEncoder(buffer).Encode(credentials); err != nil {
return err
}
cmd.Input(buffer)
out, err := cmd.Output()
if err != nil {
t := strings.TrimSpace(string(out))
return fmt.Errorf("error storing credentials - err: %v, out: `%s`", err, t)
}
return nil
}
// Get executes an external program to get the credentials from a native store.
func Get(program ProgramFunc, serverURL string) (*credentials.Credentials, error) {
cmd := program("get")
cmd.Input(strings.NewReader(serverURL))
out, err := cmd.Output()
if err != nil {
t := strings.TrimSpace(string(out))
if credentials.IsErrCredentialsNotFoundMessage(t) {
return nil, credentials.NewErrCredentialsNotFound()
}
return nil, fmt.Errorf("error getting credentials - err: %v, out: `%s`", err, t)
}
resp := &credentials.Credentials{
ServerURL: serverURL,
}
if err := json.NewDecoder(bytes.NewReader(out)).Decode(resp); err != nil {
return nil, err
}
return resp, nil
}
// Erase executes a program to remove the server credentails from the native store.
func Erase(program ProgramFunc, serverURL string) error {
cmd := program("erase")
cmd.Input(strings.NewReader(serverURL))
out, err := cmd.Output()
if err != nil {
t := strings.TrimSpace(string(out))
return fmt.Errorf("error erasing credentials - err: %v, out: `%s`", err, t)
}
return nil
}

View file

@ -0,0 +1,37 @@
package client
import (
"io"
"os/exec"
)
// Program is an interface to execute external programs.
type Program interface {
Output() ([]byte, error)
Input(in io.Reader)
}
// ProgramFunc is a type of function that initializes programs based on arguments.
type ProgramFunc func(args ...string) Program
// NewShellProgramFunc creates programs that are executed in a Shell.
func NewShellProgramFunc(name string) ProgramFunc {
return func(args ...string) Program {
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

@ -0,0 +1,129 @@
package credentials
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"strings"
)
// Credentials holds the information shared between docker and the credentials store.
type Credentials struct {
ServerURL string
Username string
Secret string
}
// Serve initializes the credentials helper and parses the action argument.
// This function is designed to be called from a command line interface.
// It uses os.Args[1] as the key for the action.
// It uses os.Stdin as input and os.Stdout as output.
// This function terminates the program with os.Exit(1) if there is an error.
func Serve(helper Helper) {
var err error
if len(os.Args) != 2 {
err = fmt.Errorf("Usage: %s <store|get|erase>", os.Args[0])
}
if err == nil {
err = HandleCommand(helper, os.Args[1], os.Stdin, os.Stdout)
}
if err != nil {
fmt.Fprintf(os.Stdout, "%v\n", err)
os.Exit(1)
}
}
// HandleCommand uses a helper and a key to run a credential action.
func HandleCommand(helper Helper, key string, in io.Reader, out io.Writer) error {
switch key {
case "store":
return Store(helper, in)
case "get":
return Get(helper, in, out)
case "erase":
return Erase(helper, in)
}
return fmt.Errorf("Unknown credential action `%s`", key)
}
// Store uses a helper and an input reader to save credentials.
// The reader must contain the JSON serialization of a Credentials struct.
func Store(helper Helper, reader io.Reader) error {
scanner := bufio.NewScanner(reader)
buffer := new(bytes.Buffer)
for scanner.Scan() {
buffer.Write(scanner.Bytes())
}
if err := scanner.Err(); err != nil && err != io.EOF {
return err
}
var creds Credentials
if err := json.NewDecoder(buffer).Decode(&creds); err != nil {
return err
}
return helper.Add(&creds)
}
// Get retrieves the credentials for a given server url.
// The reader must contain the server URL to search.
// The writer is used to write the JSON serialization of the credentials.
func Get(helper Helper, reader io.Reader, writer io.Writer) error {
scanner := bufio.NewScanner(reader)
buffer := new(bytes.Buffer)
for scanner.Scan() {
buffer.Write(scanner.Bytes())
}
if err := scanner.Err(); err != nil && err != io.EOF {
return err
}
serverURL := strings.TrimSpace(buffer.String())
username, secret, err := helper.Get(serverURL)
if err != nil {
return err
}
resp := Credentials{
Username: username,
Secret: secret,
}
buffer.Reset()
if err := json.NewEncoder(buffer).Encode(resp); err != nil {
return err
}
fmt.Fprint(writer, buffer.String())
return nil
}
// Erase removes credentials from the store.
// The reader must contain the server URL to remove.
func Erase(helper Helper, reader io.Reader) error {
scanner := bufio.NewScanner(reader)
buffer := new(bytes.Buffer)
for scanner.Scan() {
buffer.Write(scanner.Bytes())
}
if err := scanner.Err(); err != nil && err != io.EOF {
return err
}
serverURL := strings.TrimSpace(buffer.String())
return helper.Delete(serverURL)
}

View file

@ -0,0 +1,37 @@
package credentials
// ErrCredentialsNotFound standarizes the not found error, so every helper returns
// the same message and docker can handle it properly.
const errCredentialsNotFoundMessage = "credentials not found in native keychain"
// errCredentialsNotFound represents an error
// raised when credentials are not in the store.
type errCredentialsNotFound struct{}
// Error returns the standard error message
// for when the credentials are not in the store.
func (errCredentialsNotFound) Error() string {
return errCredentialsNotFoundMessage
}
// NewErrCredentialsNotFound creates a new error
// for when the credentials are not in the store.
func NewErrCredentialsNotFound() error {
return errCredentialsNotFound{}
}
// IsErrCredentialsNotFound returns true if the error
// was caused by not having a set of credentials in a store.
func IsErrCredentialsNotFound(err error) bool {
_, ok := err.(errCredentialsNotFound)
return ok
}
// IsErrCredentialsNotFoundMessage returns true if the error
// was caused by not having a set of credentials in a store.
//
// This function helps to check messages returned by an
// external program via its standard output.
func IsErrCredentialsNotFoundMessage(err string) bool {
return err == errCredentialsNotFoundMessage
}

View file

@ -0,0 +1,12 @@
package credentials
// Helper is the interface a credentials store helper must implement.
type Helper interface {
// Add appends credentials to the store.
Add(*Credentials) error
// Delete removes credentials from the store.
Delete(serverURL string) error
// Get retrieves credentials from the store.
// It returns username and secret as strings.
Get(serverURL string) (string, string, error)
}