login: improve text on already authenticated and on OAuth login

Users have trouble understanding the different login paths on the CLI.
The default login is performed through an OAuth flow with the option to
fallback to a username and PAT login using the docker login -u <username>
option.

This patch improves the text around docker login, indicating:
- The username is shown when already authenticated
- Steps the user can take to switch user accounts are printed when
  authenticated in an info.
- When not authenticated, the OAuth login flow explains the fallback
  clearly to the user in an info.
- The password prompt now explicitly states that it accepts a PAT in an
  info.

Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com>
This commit is contained in:
Alano Terblanche 2025-01-27 12:08:55 +01:00
parent 7c3fa8172b
commit 6d7afd48a4
No known key found for this signature in database
GPG Key ID: 0E8FACD1BA98DE27
5 changed files with 61 additions and 37 deletions

View File

@ -13,8 +13,10 @@ import (
configtypes "github.com/docker/cli/cli/config/types"
"github.com/docker/cli/cli/hints"
"github.com/docker/cli/cli/streams"
"github.com/docker/cli/internal/tui"
registrytypes "github.com/docker/docker/api/types/registry"
"github.com/docker/docker/registry"
"github.com/morikuni/aec"
"github.com/pkg/errors"
)
@ -178,6 +180,9 @@ func PromptUserForCredentials(ctx context.Context, cli Cli, argUser, argPassword
}
}()
out := tui.NewOutput(cli.Err())
out.PrintNote("A Personal Access Token (PAT) can be used instead.\n" +
"To create a PAT, visit " + aec.Underline.Apply("https://app.docker.com/settings") + "\n\n")
argPassword, err = PromptForInput(ctx, cli.In(), cli.Out(), "Password: ")
if err != nil {
return registrytypes.AuthConfig{}, err

View File

@ -14,6 +14,7 @@ import (
"github.com/docker/cli/cli/config/configfile"
configtypes "github.com/docker/cli/cli/config/types"
"github.com/docker/cli/cli/internal/oauth/manager"
"github.com/docker/cli/internal/tui"
registrytypes "github.com/docker/docker/api/types/registry"
"github.com/docker/docker/client"
"github.com/docker/docker/errdefs"
@ -30,7 +31,7 @@ type loginOptions struct {
}
// NewLoginCommand creates a new `docker login` command
func NewLoginCommand(dockerCli command.Cli) *cobra.Command {
func NewLoginCommand(dockerCLI command.Cli) *cobra.Command {
var opts loginOptions
cmd := &cobra.Command{
@ -42,7 +43,7 @@ func NewLoginCommand(dockerCli command.Cli) *cobra.Command {
if len(args) > 0 {
opts.serverAddress = args[0]
}
return runLogin(cmd.Context(), dockerCli, opts)
return runLogin(cmd.Context(), dockerCLI, opts)
},
Annotations: map[string]string{
"category-top": "8",
@ -53,15 +54,15 @@ func NewLoginCommand(dockerCli command.Cli) *cobra.Command {
flags := cmd.Flags()
flags.StringVarP(&opts.user, "username", "u", "", "Username")
flags.StringVarP(&opts.password, "password", "p", "", "Password")
flags.BoolVar(&opts.passwordStdin, "password-stdin", false, "Take the password from stdin")
flags.StringVarP(&opts.password, "password", "p", "", "Password or Personal Access Token (PAT)")
flags.BoolVar(&opts.passwordStdin, "password-stdin", false, "Take the Password or Personal Access Token (PAT) from stdin")
return cmd
}
func verifyLoginOptions(dockerCli command.Cli, opts *loginOptions) error {
func verifyLoginOptions(dockerCLI command.Cli, opts *loginOptions) error {
if opts.password != "" {
_, _ = fmt.Fprintln(dockerCli.Err(), "WARNING! Using --password via the CLI is insecure. Use --password-stdin.")
_, _ = fmt.Fprintln(dockerCLI.Err(), "WARNING! Using --password via the CLI is insecure. Use --password-stdin.")
if opts.passwordStdin {
return errors.New("--password and --password-stdin are mutually exclusive")
}
@ -72,7 +73,7 @@ func verifyLoginOptions(dockerCli command.Cli, opts *loginOptions) error {
return errors.New("Must provide --username with --password-stdin")
}
contents, err := io.ReadAll(dockerCli.In())
contents, err := io.ReadAll(dockerCLI.In())
if err != nil {
return err
}
@ -83,8 +84,8 @@ func verifyLoginOptions(dockerCli command.Cli, opts *loginOptions) error {
return nil
}
func runLogin(ctx context.Context, dockerCli command.Cli, opts loginOptions) error {
if err := verifyLoginOptions(dockerCli, &opts); err != nil {
func runLogin(ctx context.Context, dockerCLI command.Cli, opts loginOptions) error {
if err := verifyLoginOptions(dockerCLI, &opts); err != nil {
return err
}
var (
@ -99,28 +100,38 @@ func runLogin(ctx context.Context, dockerCli command.Cli, opts loginOptions) err
isDefaultRegistry := serverAddress == registry.IndexServer
// attempt login with current (stored) credentials
authConfig, err := command.GetDefaultAuthConfig(dockerCli.ConfigFile(), opts.user == "" && opts.password == "", serverAddress, isDefaultRegistry)
authConfig, err := command.GetDefaultAuthConfig(dockerCLI.ConfigFile(), opts.user == "" && opts.password == "", serverAddress, isDefaultRegistry)
if err == nil && authConfig.Username != "" && authConfig.Password != "" {
msg, err = loginWithStoredCredentials(ctx, dockerCli, authConfig)
msg, err = loginWithStoredCredentials(ctx, dockerCLI, authConfig)
}
// if we failed to authenticate with stored credentials (or didn't have stored credentials),
// prompt the user for new credentials
if err != nil || authConfig.Username == "" || authConfig.Password == "" {
msg, err = loginUser(ctx, dockerCli, opts, authConfig.Username, authConfig.ServerAddress)
msg, err = loginUser(ctx, dockerCLI, opts, authConfig.Username, authConfig.ServerAddress)
if err != nil {
return err
}
}
if msg != "" {
_, _ = fmt.Fprintln(dockerCli.Out(), msg)
_, _ = fmt.Fprintln(dockerCLI.Out(), msg)
}
return nil
}
func loginWithStoredCredentials(ctx context.Context, dockerCLI command.Cli, authConfig registrytypes.AuthConfig) (msg string, _ error) {
_, _ = fmt.Fprintln(dockerCLI.Out(), "Authenticating with existing credentials...")
_, _ = fmt.Fprintf(dockerCLI.Err(), "Authenticating with existing credentials...")
if authConfig.Username != "" {
_, _ = fmt.Fprintf(dockerCLI.Err(), " [Username: %s]", authConfig.Username)
}
_, _ = fmt.Fprint(dockerCLI.Err(), "\n")
out := tui.NewOutput(dockerCLI.Err())
out.PrintNote("To login with a different account, run 'docker logout' followed by 'docker login'")
_, _ = fmt.Fprint(dockerCLI.Err(), "\n\n")
response, err := dockerCLI.Client().RegistryLogin(ctx, authConfig)
if err != nil {
if errdefs.IsUnauthorized(err) {
@ -155,7 +166,7 @@ func isOauthLoginDisabled() bool {
return false
}
func loginUser(ctx context.Context, dockerCli command.Cli, opts loginOptions, defaultUsername, serverAddress string) (msg string, _ error) {
func loginUser(ctx context.Context, dockerCLI command.Cli, opts loginOptions, defaultUsername, serverAddress string) (msg string, _ error) {
// Some links documenting this:
// - https://code.google.com/archive/p/mintty/issues/56
// - https://github.com/docker/docker/issues/15272
@ -163,33 +174,33 @@ func loginUser(ctx context.Context, dockerCli command.Cli, opts loginOptions, de
// Linux will hit this if you attempt `cat | docker login`, and Windows
// will hit this if you attempt docker login from mintty where stdin
// is a pipe, not a character based console.
if (opts.user == "" || opts.password == "") && !dockerCli.In().IsTerminal() {
if (opts.user == "" || opts.password == "") && !dockerCLI.In().IsTerminal() {
return "", errors.Errorf("Error: Cannot perform an interactive login from a non TTY device")
}
// If we're logging into the index server and the user didn't provide a username or password, use the device flow
if serverAddress == registry.IndexServer && opts.user == "" && opts.password == "" && !isOauthLoginDisabled() {
var err error
msg, err = loginWithDeviceCodeFlow(ctx, dockerCli)
msg, err = loginWithDeviceCodeFlow(ctx, dockerCLI)
// if the error represents a failure to initiate the device-code flow,
// then we fallback to regular cli credentials login
if !errors.Is(err, manager.ErrDeviceLoginStartFail) {
return msg, err
}
_, _ = fmt.Fprint(dockerCli.Err(), "Failed to start web-based login - falling back to command line login...\n\n")
_, _ = fmt.Fprint(dockerCLI.Err(), "Failed to start web-based login - falling back to command line login...\n\n")
}
return loginWithUsernameAndPassword(ctx, dockerCli, opts, defaultUsername, serverAddress)
return loginWithUsernameAndPassword(ctx, dockerCLI, opts, defaultUsername, serverAddress)
}
func loginWithUsernameAndPassword(ctx context.Context, dockerCli command.Cli, opts loginOptions, defaultUsername, serverAddress string) (msg string, _ error) {
func loginWithUsernameAndPassword(ctx context.Context, dockerCLI command.Cli, opts loginOptions, defaultUsername, serverAddress string) (msg string, _ error) {
// Prompt user for credentials
authConfig, err := command.PromptUserForCredentials(ctx, dockerCli, opts.user, opts.password, defaultUsername, serverAddress)
authConfig, err := command.PromptUserForCredentials(ctx, dockerCLI, opts.user, opts.password, defaultUsername, serverAddress)
if err != nil {
return "", err
}
response, err := loginWithRegistry(ctx, dockerCli.Client(), authConfig)
response, err := loginWithRegistry(ctx, dockerCLI.Client(), authConfig)
if err != nil {
return "", err
}
@ -198,26 +209,26 @@ func loginWithUsernameAndPassword(ctx context.Context, dockerCli command.Cli, op
authConfig.Password = ""
authConfig.IdentityToken = response.IdentityToken
}
if err = storeCredentials(dockerCli.ConfigFile(), authConfig); err != nil {
if err = storeCredentials(dockerCLI.ConfigFile(), authConfig); err != nil {
return "", err
}
return response.Status, nil
}
func loginWithDeviceCodeFlow(ctx context.Context, dockerCli command.Cli) (msg string, _ error) {
store := dockerCli.ConfigFile().GetCredentialsStore(registry.IndexServer)
authConfig, err := manager.NewManager(store).LoginDevice(ctx, dockerCli.Err())
func loginWithDeviceCodeFlow(ctx context.Context, dockerCLI command.Cli) (msg string, _ error) {
store := dockerCLI.ConfigFile().GetCredentialsStore(registry.IndexServer)
authConfig, err := manager.NewManager(store).LoginDevice(ctx, dockerCLI.Err())
if err != nil {
return "", err
}
response, err := loginWithRegistry(ctx, dockerCli.Client(), registrytypes.AuthConfig(*authConfig))
response, err := loginWithRegistry(ctx, dockerCLI.Client(), registrytypes.AuthConfig(*authConfig))
if err != nil {
return "", err
}
if err = storeCredentials(dockerCli.ConfigFile(), registrytypes.AuthConfig(*authConfig)); err != nil {
if err = storeCredentials(dockerCLI.ConfigFile(), registrytypes.AuthConfig(*authConfig)); err != nil {
return "", err
}

View File

@ -61,14 +61,12 @@ func TestLoginWithCredStoreCreds(t *testing.T) {
}{
{
inputAuthConfig: registrytypes.AuthConfig{},
expectedMsg: "Authenticating with existing credentials...\n",
},
{
inputAuthConfig: registrytypes.AuthConfig{
Username: unknownUser,
},
expectedErr: errUnknownUser,
expectedMsg: "Authenticating with existing credentials...\n",
expectedErrMsg: fmt.Sprintf("Login did not succeed, error: %s\n", errUnknownUser),
},
}
@ -83,7 +81,7 @@ func TestLoginWithCredStoreCreds(t *testing.T) {
assert.NilError(t, err)
}
assert.Check(t, is.Equal(tc.expectedMsg, cli.OutBuffer().String()))
assert.Check(t, is.Equal(tc.expectedErrMsg, cli.ErrBuffer().String()))
assert.Check(t, is.Contains(cli.ErrBuffer().String(), tc.expectedErrMsg))
cli.ErrBuffer().Reset()
cli.OutBuffer().Reset()
}

View File

@ -13,6 +13,8 @@ import (
"github.com/docker/cli/cli/config/types"
"github.com/docker/cli/cli/internal/oauth"
"github.com/docker/cli/cli/internal/oauth/api"
"github.com/docker/cli/cli/streams"
"github.com/docker/cli/internal/tui"
"github.com/docker/docker/registry"
"github.com/morikuni/aec"
"github.com/sirupsen/logrus"
@ -93,7 +95,15 @@ func (m *OAuthManager) LoginDevice(ctx context.Context, w io.Writer) (*types.Aut
}
_, _ = fmt.Fprintln(w, aec.Bold.Apply("\nUSING WEB-BASED LOGIN"))
_, _ = fmt.Fprintln(w, "To sign in with credentials on the command line, use 'docker login -u <username>'")
var out tui.Output
switch stream := w.(type) {
case *streams.Out:
out = tui.NewOutput(stream)
default:
out = tui.NewOutput(streams.NewOut(w))
}
out.PrintNote("To sign in with credentials on the command line, use 'docker login -u <username>'\n")
_, _ = fmt.Fprintf(w, "\nYour one-time device confirmation code is: "+aec.Bold.Apply("%s\n"), state.UserCode)
_, _ = fmt.Fprintf(w, aec.Bold.Apply("Press ENTER")+" to open your browser or submit your device code here: "+aec.Underline.Apply("%s\n"), strings.Split(state.VerificationURI, "?")[0])

View File

@ -6,11 +6,11 @@ Defaults to Docker Hub if no server is specified.
### Options
| Name | Type | Default | Description |
|:---------------------------------------------|:---------|:--------|:-----------------------------|
| `-p`, `--password` | `string` | | Password |
| [`--password-stdin`](#password-stdin) | `bool` | | Take the password from stdin |
| [`-u`](#username), [`--username`](#username) | `string` | | Username |
| Name | Type | Default | Description |
|:---------------------------------------------|:---------|:--------|:------------------------------------------------------------|
| `-p`, `--password` | `string` | | Password or Personal Access Token (PAT) |
| [`--password-stdin`](#password-stdin) | `bool` | | Take the Password or Personal Access Token (PAT) from stdin |
| [`-u`](#username), [`--username`](#username) | `string` | | Username |
<!---MARKER_GEN_END-->