github.com/containerd/nerdctl/v2@v2.0.0-beta.5.0.20240520001846-b5758f54fa28/pkg/cmd/login/login.go (about)

     1  /*
     2     Copyright The containerd Authors.
     3  
     4     Licensed under the Apache License, Version 2.0 (the "License");
     5     you may not use this file except in compliance with the License.
     6     You may obtain a copy of the License at
     7  
     8         http://www.apache.org/licenses/LICENSE-2.0
     9  
    10     Unless required by applicable law or agreed to in writing, software
    11     distributed under the License is distributed on an "AS IS" BASIS,
    12     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13     See the License for the specific language governing permissions and
    14     limitations under the License.
    15  */
    16  
    17  package login
    18  
    19  import (
    20  	"bufio"
    21  	"context"
    22  	"errors"
    23  	"fmt"
    24  	"io"
    25  	"net/http"
    26  	"net/url"
    27  	"os"
    28  	"strings"
    29  
    30  	"github.com/containerd/containerd/remotes/docker"
    31  	"github.com/containerd/containerd/remotes/docker/config"
    32  	"github.com/containerd/log"
    33  	"github.com/containerd/nerdctl/v2/pkg/api/types"
    34  	"github.com/containerd/nerdctl/v2/pkg/errutil"
    35  	"github.com/containerd/nerdctl/v2/pkg/imgutil/dockerconfigresolver"
    36  	dockercliconfig "github.com/docker/cli/cli/config"
    37  	dockercliconfigtypes "github.com/docker/cli/cli/config/types"
    38  	"github.com/docker/docker/api/types/registry"
    39  	"github.com/docker/docker/errdefs"
    40  	"golang.org/x/net/context/ctxhttp"
    41  	"golang.org/x/term"
    42  )
    43  
    44  const unencryptedPasswordWarning = `WARNING: Your password will be stored unencrypted in %s.
    45  Configure a credential helper to remove this warning. See
    46  https://docs.docker.com/engine/reference/commandline/login/#credentials-store
    47  `
    48  
    49  type isFileStore interface {
    50  	IsFileStore() bool
    51  	GetFilename() string
    52  }
    53  
    54  func Login(ctx context.Context, options types.LoginCommandOptions, stdout io.Writer) error {
    55  	var serverAddress string
    56  	if options.ServerAddress == "" {
    57  		serverAddress = dockerconfigresolver.IndexServer
    58  	} else {
    59  		serverAddress = options.ServerAddress
    60  	}
    61  
    62  	var responseIdentityToken string
    63  	isDefaultRegistry := serverAddress == dockerconfigresolver.IndexServer
    64  
    65  	authConfig, err := GetDefaultAuthConfig(options.Username == "" && options.Password == "", serverAddress, isDefaultRegistry)
    66  	if authConfig == nil {
    67  		authConfig = &registry.AuthConfig{ServerAddress: serverAddress}
    68  	}
    69  	if err == nil && authConfig.Username != "" && authConfig.Password != "" {
    70  		//login With StoreCreds
    71  		responseIdentityToken, err = loginClientSide(ctx, options.GOptions, *authConfig)
    72  	}
    73  
    74  	if err != nil || authConfig.Username == "" || authConfig.Password == "" {
    75  		err = ConfigureAuthentication(authConfig, options.Username, options.Password)
    76  		if err != nil {
    77  			return err
    78  		}
    79  
    80  		responseIdentityToken, err = loginClientSide(ctx, options.GOptions, *authConfig)
    81  		if err != nil {
    82  			return err
    83  		}
    84  	}
    85  
    86  	if responseIdentityToken != "" {
    87  		authConfig.Password = ""
    88  		authConfig.IdentityToken = responseIdentityToken
    89  	}
    90  
    91  	dockerConfigFile, err := dockercliconfig.Load("")
    92  	if err != nil {
    93  		return err
    94  	}
    95  
    96  	creds := dockerConfigFile.GetCredentialsStore(serverAddress)
    97  
    98  	store, isFile := creds.(isFileStore)
    99  	// Display a warning if we're storing the users password (not a token) and credentials store type is file.
   100  	if isFile && authConfig.Password != "" {
   101  		_, err = fmt.Fprintln(stdout, fmt.Sprintf(unencryptedPasswordWarning, store.GetFilename()))
   102  		if err != nil {
   103  			return err
   104  		}
   105  	}
   106  
   107  	if err := creds.Store(dockercliconfigtypes.AuthConfig(*(authConfig))); err != nil {
   108  		return fmt.Errorf("error saving credentials: %w", err)
   109  	}
   110  
   111  	fmt.Fprintln(stdout, "Login Succeeded")
   112  
   113  	return nil
   114  }
   115  
   116  // GetDefaultAuthConfig gets the default auth config given a serverAddress.
   117  // If credentials for given serverAddress exists in the credential store, the configuration will be populated with values in it.
   118  // Code from github.com/docker/cli/cli/command (v20.10.3).
   119  func GetDefaultAuthConfig(checkCredStore bool, serverAddress string, isDefaultRegistry bool) (*registry.AuthConfig, error) {
   120  	if !isDefaultRegistry {
   121  		var err error
   122  		serverAddress, err = convertToHostname(serverAddress)
   123  		if err != nil {
   124  			return nil, err
   125  		}
   126  	}
   127  	var authconfig = dockercliconfigtypes.AuthConfig{}
   128  	if checkCredStore {
   129  		dockerConfigFile, err := dockercliconfig.Load("")
   130  		if err != nil {
   131  			return nil, err
   132  		}
   133  		authconfig, err = dockerConfigFile.GetAuthConfig(serverAddress)
   134  		if err != nil {
   135  			return nil, err
   136  		}
   137  	}
   138  	authconfig.ServerAddress = serverAddress
   139  	authconfig.IdentityToken = ""
   140  	res := registry.AuthConfig(authconfig)
   141  	return &res, nil
   142  }
   143  
   144  func loginClientSide(ctx context.Context, globalOptions types.GlobalCommandOptions, auth registry.AuthConfig) (string, error) {
   145  	host, err := convertToHostname(auth.ServerAddress)
   146  	if err != nil {
   147  		return "", err
   148  	}
   149  	var dOpts []dockerconfigresolver.Opt
   150  	if globalOptions.InsecureRegistry {
   151  		log.G(ctx).Warnf("skipping verifying HTTPS certs for %q", host)
   152  		dOpts = append(dOpts, dockerconfigresolver.WithSkipVerifyCerts(true))
   153  	}
   154  	dOpts = append(dOpts, dockerconfigresolver.WithHostsDirs(globalOptions.HostsDir))
   155  
   156  	authCreds := func(acArg string) (string, string, error) {
   157  		if acArg == host {
   158  			if auth.RegistryToken != "" {
   159  				// Even containerd/CRI does not support RegistryToken as of v1.4.3,
   160  				// so, nobody is actually using RegistryToken?
   161  				log.G(ctx).Warnf("RegistryToken (for %q) is not supported yet (FIXME)", host)
   162  			}
   163  			return auth.Username, auth.Password, nil
   164  		}
   165  		return "", "", fmt.Errorf("expected acArg to be %q, got %q", host, acArg)
   166  	}
   167  
   168  	dOpts = append(dOpts, dockerconfigresolver.WithAuthCreds(authCreds))
   169  	ho, err := dockerconfigresolver.NewHostOptions(ctx, host, dOpts...)
   170  	if err != nil {
   171  		return "", err
   172  	}
   173  	fetchedRefreshTokens := make(map[string]string) // key: req.URL.Host
   174  	// onFetchRefreshToken is called when tryLoginWithRegHost calls rh.Authorizer.Authorize()
   175  	onFetchRefreshToken := func(ctx context.Context, s string, req *http.Request) {
   176  		fetchedRefreshTokens[req.URL.Host] = s
   177  	}
   178  	ho.AuthorizerOpts = append(ho.AuthorizerOpts, docker.WithFetchRefreshToken(onFetchRefreshToken))
   179  	regHosts, err := config.ConfigureHosts(ctx, *ho)(host)
   180  	if err != nil {
   181  		return "", err
   182  	}
   183  	log.G(ctx).Debugf("len(regHosts)=%d", len(regHosts))
   184  	if len(regHosts) == 0 {
   185  		return "", fmt.Errorf("got empty []docker.RegistryHost for %q", host)
   186  	}
   187  	for i, rh := range regHosts {
   188  		err = tryLoginWithRegHost(ctx, rh)
   189  		if err != nil && globalOptions.InsecureRegistry && (errutil.IsErrHTTPResponseToHTTPSClient(err) || errutil.IsErrConnectionRefused(err)) {
   190  			rh.Scheme = "http"
   191  			err = tryLoginWithRegHost(ctx, rh)
   192  		}
   193  		identityToken := fetchedRefreshTokens[rh.Host] // can be empty
   194  		if err == nil {
   195  			return identityToken, nil
   196  		}
   197  		log.G(ctx).WithError(err).WithField("i", i).Error("failed to call tryLoginWithRegHost")
   198  	}
   199  	return "", err
   200  }
   201  
   202  func tryLoginWithRegHost(ctx context.Context, rh docker.RegistryHost) error {
   203  	if rh.Authorizer == nil {
   204  		return errors.New("got nil Authorizer")
   205  	}
   206  	if rh.Path == "/v2" {
   207  		// If the path is using /v2 endpoint but lacks trailing slash add it
   208  		// https://docs.docker.com/registry/spec/api/#detail. Acts as a workaround
   209  		// for containerd issue https://github.com/containerd/containerd/blob/2986d5b077feb8252d5d2060277a9c98ff8e009b/remotes/docker/config/hosts.go#L110
   210  		rh.Path = "/v2/"
   211  	}
   212  	u := url.URL{
   213  		Scheme: rh.Scheme,
   214  		Host:   rh.Host,
   215  		Path:   rh.Path,
   216  	}
   217  	var ress []*http.Response
   218  	for i := 0; i < 10; i++ {
   219  		req, err := http.NewRequest(http.MethodGet, u.String(), nil)
   220  		if err != nil {
   221  			return err
   222  		}
   223  		for k, v := range rh.Header.Clone() {
   224  			for _, vv := range v {
   225  				req.Header.Add(k, vv)
   226  			}
   227  		}
   228  		if err := rh.Authorizer.Authorize(ctx, req); err != nil {
   229  			return fmt.Errorf("failed to call rh.Authorizer.Authorize: %w", err)
   230  		}
   231  		res, err := ctxhttp.Do(ctx, rh.Client, req)
   232  		if err != nil {
   233  			return fmt.Errorf("failed to call rh.Client.Do: %w", err)
   234  		}
   235  		ress = append(ress, res)
   236  		if res.StatusCode == 401 {
   237  			if err := rh.Authorizer.AddResponses(ctx, ress); err != nil && !errdefs.IsNotImplemented(err) {
   238  				return fmt.Errorf("failed to call rh.Authorizer.AddResponses: %w", err)
   239  			}
   240  			continue
   241  		}
   242  		if res.StatusCode/100 != 2 {
   243  			return fmt.Errorf("unexpected status code %d", res.StatusCode)
   244  		}
   245  
   246  		return nil
   247  	}
   248  
   249  	return errors.New("too many 401 (probably)")
   250  }
   251  
   252  func ConfigureAuthentication(authConfig *registry.AuthConfig, username, password string) error {
   253  	authConfig.Username = strings.TrimSpace(authConfig.Username)
   254  	if username = strings.TrimSpace(username); username == "" {
   255  		username = authConfig.Username
   256  	}
   257  	if username == "" {
   258  		fmt.Print("Enter Username: ")
   259  		usr, err := readUsername()
   260  		if err != nil {
   261  			return err
   262  		}
   263  		username = usr
   264  	}
   265  	if username == "" {
   266  		return fmt.Errorf("error: Username is Required")
   267  	}
   268  
   269  	if password == "" {
   270  		fmt.Print("Enter Password: ")
   271  		pwd, err := readPassword()
   272  		fmt.Println()
   273  		if err != nil {
   274  			return err
   275  		}
   276  		password = pwd
   277  	}
   278  	if password == "" {
   279  		return fmt.Errorf("error: Password is Required")
   280  	}
   281  
   282  	authConfig.Username = username
   283  	authConfig.Password = password
   284  
   285  	return nil
   286  }
   287  
   288  func readUsername() (string, error) {
   289  	var fd *os.File
   290  	if term.IsTerminal(int(os.Stdin.Fd())) {
   291  		fd = os.Stdin
   292  	} else {
   293  		return "", fmt.Errorf("stdin is not a terminal (Hint: use `nerdctl login --username=USERNAME --password-stdin`)")
   294  	}
   295  
   296  	reader := bufio.NewReader(fd)
   297  	username, err := reader.ReadString('\n')
   298  	if err != nil {
   299  		return "", fmt.Errorf("error reading username: %w", err)
   300  	}
   301  	username = strings.TrimSpace(username)
   302  
   303  	return username, nil
   304  }
   305  
   306  func convertToHostname(serverAddress string) (string, error) {
   307  	// Ensure that URL contains scheme for a good parsing process
   308  	if strings.Contains(serverAddress, "://") {
   309  		u, err := url.Parse(serverAddress)
   310  		if err != nil {
   311  			return "", err
   312  		}
   313  		serverAddress = u.Host
   314  	} else {
   315  		u, err := url.Parse("https://" + serverAddress)
   316  		if err != nil {
   317  			return "", err
   318  		}
   319  		serverAddress = u.Host
   320  	}
   321  
   322  	return serverAddress, nil
   323  }