From 1829eb0b974cdb0cd12a07d22a35d249153cf2a0 Mon Sep 17 00:00:00 2001 From: Shiwei Zhang Date: Thu, 11 Apr 2019 14:16:20 +0800 Subject: [PATCH] Support oras login (#53) Supports - `oras login` to log in to a remote registry. - `oras logout` to log out from a remote registry. --- Gopkg.lock | 6 +- cmd/oras/login.go | 141 ++++++++++++++++++++++++++++++++++++ cmd/oras/logout.go | 52 +++++++++++++ cmd/oras/main.go | 2 +- cmd/oras/pull.go | 4 +- cmd/oras/push.go | 4 +- cmd/oras/resolver.go | 43 +++++------ pkg/auth/client.go | 17 +++++ pkg/auth/docker/client.go | 71 ++++++++++++++++++ pkg/auth/docker/login.go | 40 ++++++++++ pkg/auth/docker/logout.go | 9 +++ pkg/auth/docker/resolver.go | 51 +++++++++++++ 12 files changed, 413 insertions(+), 27 deletions(-) create mode 100644 cmd/oras/login.go create mode 100644 cmd/oras/logout.go create mode 100644 pkg/auth/client.go create mode 100644 pkg/auth/docker/client.go create mode 100644 pkg/auth/docker/login.go create mode 100644 pkg/auth/docker/logout.go create mode 100644 pkg/auth/docker/resolver.go diff --git a/Gopkg.lock b/Gopkg.lock index dcae2be2c..7a369a334 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -162,7 +162,7 @@ [[projects]] branch = "master" - digest = "1:efa1ffae5c8d2f00f499708e7acfa16cd9d74b8e69f0f555765043321b523f50" + digest = "1:0a6a6b660e9f624f8a8d2602b12fb1ecfba05cd0314f5bb9daddd8b6d6b2a10b" name = "github.com/docker/docker" packages = [ "api/types", @@ -649,9 +649,13 @@ "github.com/containerd/containerd/remotes", "github.com/containerd/containerd/remotes/docker", "github.com/docker/cli/cli/config", + "github.com/docker/cli/cli/config/configfile", + "github.com/docker/cli/cli/config/credentials", "github.com/docker/distribution/configuration", "github.com/docker/distribution/registry", "github.com/docker/distribution/registry/storage/driver/inmemory", + "github.com/docker/docker/api/types", + "github.com/docker/docker/pkg/term", "github.com/docker/docker/registry", "github.com/opencontainers/go-digest", "github.com/opencontainers/image-spec/specs-go", diff --git a/cmd/oras/login.go b/cmd/oras/login.go new file mode 100644 index 000000000..a76a5b29c --- /dev/null +++ b/cmd/oras/login.go @@ -0,0 +1,141 @@ +package main + +import ( + "bufio" + "context" + "errors" + "fmt" + "io/ioutil" + "os" + "strings" + + auth "github.com/deislabs/oras/pkg/auth/docker" + + "github.com/docker/docker/pkg/term" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +type loginOptions struct { + hostname string + fromStdin bool + + debug bool + configs []string + username string + password string +} + +func loginCmd() *cobra.Command { + var opts loginOptions + cmd := &cobra.Command{ + Use: "login registry", + Short: "Log in to a remote registry", + Long: `Log in to a remote registry + +Example - Login with username and password from command line: + oras login -u username -p password localhost:5000 + +Example - Login with username and password from stdin: + oras login -u username --password-stdin localhost:5000 + +Example - Login with identity token from command line: + oras login -p token localhost:5000 + +Example - Login with identity token from stdin: + oras login --password-stdin localhost:5000 + +Example - Login with username and password by prompt: + oras login localhost:5000 +`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.hostname = args[0] + return runLogin(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.debug, "debug", "d", false, "debug mode") + cmd.Flags().StringArrayVarP(&opts.configs, "config", "c", nil, "auth config path") + cmd.Flags().StringVarP(&opts.username, "username", "u", "", "registry username") + cmd.Flags().StringVarP(&opts.password, "password", "p", "", "registry password or identity token") + cmd.Flags().BoolVarP(&opts.fromStdin, "password-stdin", "", false, "read password or identity token from stdin") + return cmd +} + +func runLogin(opts loginOptions) error { + if opts.debug { + logrus.SetLevel(logrus.DebugLevel) + } + + // Prepare auth client + cli, err := auth.NewClient(opts.configs...) + if err != nil { + return err + } + + // Prompt credential + if opts.fromStdin { + password, err := ioutil.ReadAll(os.Stdin) + if err != nil { + return err + } + opts.password = strings.TrimSuffix(string(password), "\n") + opts.password = strings.TrimSuffix(opts.password, "\r") + } else if opts.password == "" { + if opts.username == "" { + username, err := readLine("Username: ", false) + if err != nil { + return err + } + opts.username = strings.TrimSpace(username) + } + if opts.username == "" { + if opts.password, err = readLine("Token: ", true); err != nil { + return err + } else if opts.password == "" { + return errors.New("token required") + } + } else { + if opts.password, err = readLine("Password: ", true); err != nil { + return err + } else if opts.password == "" { + return errors.New("password required") + } + } + } else { + fmt.Fprintln(os.Stderr, "WARNING! Using --password via the CLI is insecure. Use --password-stdin.") + } + + // Login + if err := cli.Login(context.Background(), opts.hostname, opts.username, opts.password); err != nil { + return err + } + + fmt.Println("Login Succeeded") + return nil +} + +func readLine(prompt string, slient bool) (string, error) { + fmt.Print(prompt) + if slient { + fd := os.Stdin.Fd() + state, err := term.SaveState(fd) + if err != nil { + return "", err + } + term.DisableEcho(fd, state) + defer term.RestoreTerminal(fd, state) + } + + reader := bufio.NewReader(os.Stdin) + line, _, err := reader.ReadLine() + if err != nil { + return "", err + } + if slient { + fmt.Println() + } + + return string(line), nil +} diff --git a/cmd/oras/logout.go b/cmd/oras/logout.go new file mode 100644 index 000000000..7b697067a --- /dev/null +++ b/cmd/oras/logout.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + + auth "github.com/deislabs/oras/pkg/auth/docker" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +type logoutOptions struct { + hostname string + + debug bool + configs []string +} + +func logoutCmd() *cobra.Command { + var opts logoutOptions + cmd := &cobra.Command{ + Use: "logout registry", + Short: "Log out from a remote registry", + Long: `Log out from a remote registry + +Example - Logout: + oras logout localhost:5000 +`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.hostname = args[0] + return runLogout(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.debug, "debug", "d", false, "debug mode") + cmd.Flags().StringArrayVarP(&opts.configs, "config", "c", nil, "auth config path") + return cmd +} + +func runLogout(opts logoutOptions) error { + if opts.debug { + logrus.SetLevel(logrus.DebugLevel) + } + + cli, err := auth.NewClient(opts.configs...) + if err != nil { + return err + } + + return cli.Logout(context.Background(), opts.hostname) +} diff --git a/cmd/oras/main.go b/cmd/oras/main.go index 49059f77b..ebae28784 100644 --- a/cmd/oras/main.go +++ b/cmd/oras/main.go @@ -11,7 +11,7 @@ func main() { Use: "oras [command]", SilenceUsage: true, } - cmd.AddCommand(pullCmd(), pushCmd()) + cmd.AddCommand(pullCmd(), pushCmd(), loginCmd(), logoutCmd()) if err := cmd.Execute(); err != nil { os.Exit(1) } diff --git a/cmd/oras/pull.go b/cmd/oras/pull.go index 80a2700e9..1a8fa39f1 100644 --- a/cmd/oras/pull.go +++ b/cmd/oras/pull.go @@ -21,6 +21,7 @@ type pullOptions struct { verbose bool debug bool + configs []string username string password string } @@ -56,6 +57,7 @@ Example - Pull all files, any media type: cmd.Flags().BoolVarP(&opts.verbose, "verbose", "v", false, "verbose output") cmd.Flags().BoolVarP(&opts.debug, "debug", "d", false, "debug mode") + cmd.Flags().StringArrayVarP(&opts.configs, "config", "c", nil, "auth config path") cmd.Flags().StringVarP(&opts.username, "username", "u", "", "registry username") cmd.Flags().StringVarP(&opts.password, "password", "p", "", "registry password") return cmd @@ -71,7 +73,7 @@ func runPull(opts pullOptions) error { opts.allowedMediaTypes = []string{content.DefaultBlobMediaType} } - resolver := newResolver(opts.username, opts.password) + resolver := newResolver(opts.username, opts.password, opts.configs...) store := content.NewFileStore(opts.output) store.DisableOverwrite = opts.keepOldFiles store.AllowPathTraversalOnWrite = opts.pathTraversal diff --git a/cmd/oras/push.go b/cmd/oras/push.go index 96a178d4b..79e6b0e3f 100644 --- a/cmd/oras/push.go +++ b/cmd/oras/push.go @@ -26,6 +26,7 @@ type pushOptions struct { manifestAnnotations string debug bool + configs []string username string password string } @@ -60,6 +61,7 @@ Example - Push file "hi.txt" with the custom manifest config "config.json" of th cmd.Flags().StringVarP(&opts.manifestConfigRef, "manifest-config", "", "", "manifest config file") cmd.Flags().StringVarP(&opts.manifestAnnotations, "manifest-annotations", "", "", "manifest annotation file") cmd.Flags().BoolVarP(&opts.debug, "debug", "d", false, "debug mode") + cmd.Flags().StringArrayVarP(&opts.configs, "config", "c", nil, "auth config path") cmd.Flags().StringVarP(&opts.username, "username", "u", "", "registry username") cmd.Flags().StringVarP(&opts.password, "password", "p", "", "registry password") return cmd @@ -128,7 +130,7 @@ func runPush(opts pushOptions) error { } // ready to push - resolver := newResolver(opts.username, opts.password) + resolver := newResolver(opts.username, opts.password, opts.configs...) return oras.Push(context.Background(), resolver, opts.targetRef, store, files, pushOpts...) } diff --git a/cmd/oras/resolver.go b/cmd/oras/resolver.go index 9bbf4cadd..705bbdedf 100644 --- a/cmd/oras/resolver.go +++ b/cmd/oras/resolver.go @@ -1,35 +1,32 @@ package main import ( + "context" + "fmt" "os" + auth "github.com/deislabs/oras/pkg/auth/docker" + "github.com/containerd/containerd/remotes" "github.com/containerd/containerd/remotes/docker" - "github.com/docker/cli/cli/config" - "github.com/docker/docker/registry" ) -func newResolver(username, password string) remotes.Resolver { - cfg := config.LoadDefaultConfigFile(os.Stderr) - credential := func(hostName string) (string, string, error) { - if hostName == registry.DefaultV2Registry.Host { - hostName = registry.IndexServer - } - auth, err := cfg.GetAuthConfig(hostName) - if err != nil { - return "", "", err - } - if auth.IdentityToken != "" { - return "", auth.IdentityToken, nil - } - return auth.Username, auth.Password, nil - } +func newResolver(username, password string, configs ...string) remotes.Resolver { if username != "" || password != "" { - credential = func(hostName string) (string, string, error) { - return username, password, nil - } + return docker.NewResolver(docker.ResolverOptions{ + Credentials: func(hostName string) (string, string, error) { + return username, password, nil + }, + }) + } + cli, err := auth.NewClient(configs...) + if err != nil { + fmt.Fprintf(os.Stderr, "WARNING: Error loading auth file: %v\n", err) + } + resolver, err := cli.Resolver(context.Background()) + if err != nil { + fmt.Fprintf(os.Stderr, "WARNING: Error loading resolver: %v\n", err) + resolver = docker.NewResolver(docker.ResolverOptions{}) } - return docker.NewResolver(docker.ResolverOptions{ - Credentials: credential, - }) + return resolver } diff --git a/pkg/auth/client.go b/pkg/auth/client.go new file mode 100644 index 000000000..862a241e9 --- /dev/null +++ b/pkg/auth/client.go @@ -0,0 +1,17 @@ +package auth + +import ( + "context" + + "github.com/containerd/containerd/remotes" +) + +// Client provides authentication operations for remotes. +type Client interface { + // Login logs in to a remote server identified by the hostname. + Login(ctx context.Context, hostname, username, secret string) error + // Logout logs out from a remote server identified by the hostname. + Logout(ctx context.Context, hostname string) error + // Resolver returns a new authenticated resolver. + Resolver(ctx context.Context) (remotes.Resolver, error) +} diff --git a/pkg/auth/docker/client.go b/pkg/auth/docker/client.go new file mode 100644 index 000000000..ff7732739 --- /dev/null +++ b/pkg/auth/docker/client.go @@ -0,0 +1,71 @@ +package docker + +import ( + "os" + + "github.com/deislabs/oras/pkg/auth" + + "github.com/docker/cli/cli/config" + "github.com/docker/cli/cli/config/configfile" + "github.com/docker/cli/cli/config/credentials" + "github.com/pkg/errors" +) + +// Client provides authentication operations for docker registries. +type Client struct { + configs []*configfile.ConfigFile +} + +// NewClient creates a new auth client based on provided config paths. +// If not config path is provided, the default path is used. +// Credentials are read from the first config and fall backs to next. +// All changes will only be written to the first config file. +func NewClient(configPaths ...string) (auth.Client, error) { + var configs []*configfile.ConfigFile + for _, path := range configPaths { + cfg, err := loadConfigFile(path) + if err != nil { + return nil, errors.Wrap(err, path) + } + configs = append(configs, cfg) + } + if len(configs) == 0 { + cfg, err := config.Load(config.Dir()) + if err != nil { + return nil, err + } + if !cfg.ContainsAuth() { + cfg.CredentialsStore = credentials.DetectDefaultStore(cfg.CredentialsStore) + } + configs = []*configfile.ConfigFile{cfg} + } + + return &Client{ + configs: configs, + }, nil +} + +func (c *Client) primaryCredentialsStore(hostname string) credentials.Store { + return c.configs[0].GetCredentialsStore(hostname) +} + +// loadConfigFile reads the configuration files from the given path. +func loadConfigFile(path string) (*configfile.ConfigFile, error) { + cfg := configfile.New(path) + if _, err := os.Stat(path); err == nil { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + if err := cfg.LoadFromReader(file); err != nil { + return nil, err + } + } else if !os.IsNotExist(err) { + return nil, err + } + if !cfg.ContainsAuth() { + cfg.CredentialsStore = credentials.DetectDefaultStore(cfg.CredentialsStore) + } + return cfg, nil +} diff --git a/pkg/auth/docker/login.go b/pkg/auth/docker/login.go new file mode 100644 index 000000000..cd578a340 --- /dev/null +++ b/pkg/auth/docker/login.go @@ -0,0 +1,40 @@ +package docker + +import ( + "context" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/registry" +) + +// Login logs in to a docker registry identified by the hostname. +func (c *Client) Login(ctx context.Context, hostname, username, secret string) error { + hostname = resolveHostname(hostname) + cred := types.AuthConfig{ + Username: username, + ServerAddress: hostname, + } + if username == "" { + cred.IdentityToken = secret + } else { + cred.Password = secret + } + + // Login to ensure valid credential + remote, err := registry.NewService(registry.ServiceOptions{ + V2Only: true, + }) + if err != nil { + return err + } + if _, token, err := remote.Auth(ctx, &cred, "oras"); err != nil { + return err + } else if token != "" { + cred.Username = "" + cred.Password = "" + cred.IdentityToken = token + } + + // Store credential + return c.primaryCredentialsStore(hostname).Store(cred) +} diff --git a/pkg/auth/docker/logout.go b/pkg/auth/docker/logout.go new file mode 100644 index 000000000..d762e32ef --- /dev/null +++ b/pkg/auth/docker/logout.go @@ -0,0 +1,9 @@ +package docker + +import "context" + +// Logout logs out from a docker registry identified by the hostname. +func (c *Client) Logout(_ context.Context, hostname string) error { + hostname = resolveHostname(hostname) + return c.primaryCredentialsStore(hostname).Erase(hostname) +} diff --git a/pkg/auth/docker/resolver.go b/pkg/auth/docker/resolver.go new file mode 100644 index 000000000..3eb19c580 --- /dev/null +++ b/pkg/auth/docker/resolver.go @@ -0,0 +1,51 @@ +package docker + +import ( + "context" + + "github.com/containerd/containerd/remotes" + "github.com/containerd/containerd/remotes/docker" + "github.com/docker/docker/api/types" + "github.com/docker/docker/registry" +) + +// Resolver returns a new authenticated resolver. +func (c *Client) Resolver(_ context.Context) (remotes.Resolver, error) { + return docker.NewResolver(docker.ResolverOptions{ + Credentials: c.Credential, + }), nil +} + +// Credential returns the login credential of the request host. +func (c *Client) Credential(hostname string) (string, string, error) { + hostname = resolveHostname(hostname) + var ( + auth types.AuthConfig + err error + ) + for _, cfg := range c.configs { + auth, err = cfg.GetAuthConfig(hostname) + if err != nil { + // fall back to next config + continue + } + if auth.IdentityToken != "" { + return "", auth.IdentityToken, nil + } + if auth.Username == "" && auth.Password == "" { + // fall back to next config + continue + } + return auth.Username, auth.Password, nil + } + return "", "", err +} + +// resolveHostname resolves Docker specific hostnames +func resolveHostname(hostname string) string { + switch hostname { + case registry.IndexHostname, registry.IndexName, registry.DefaultV2Registry.Host: + return registry.IndexServer + } + return hostname +}