From a3c4cef648701558b37617c01868edb614856198 Mon Sep 17 00:00:00 2001 From: Jeff Malnick Date: Tue, 31 Jan 2023 10:07:10 -0800 Subject: [PATCH] Use primary auth method if no auth method ID is provided (#2725) * feat: default to using primary auth method with authenticating * Add ability to specify scope ID flag in authenticate method --------- Co-authored-by: Jeff Mitchell --- .../cmd/commands/authenticate/authenticate.go | 83 ++++++++++++++++++- internal/cmd/commands/authenticate/funcs.go | 25 ++++++ internal/cmd/commands/authenticate/oidc.go | 44 ++++++++-- .../cmd/commands/authenticate/password.go | 82 +++++++++++++----- internal/cmd/common/option.go | 36 ++++++++ internal/cmd/common/option_test.go | 29 +++++++ 6 files changed, 268 insertions(+), 31 deletions(-) create mode 100644 internal/cmd/common/option.go create mode 100644 internal/cmd/common/option_test.go diff --git a/internal/cmd/commands/authenticate/authenticate.go b/internal/cmd/commands/authenticate/authenticate.go index 846ed7ac4..a8bea7f21 100644 --- a/internal/cmd/commands/authenticate/authenticate.go +++ b/internal/cmd/commands/authenticate/authenticate.go @@ -1,7 +1,16 @@ package authenticate import ( + "errors" + "fmt" + "strings" + + "github.com/hashicorp/boundary/api/authmethods" + "github.com/hashicorp/boundary/internal/auth/oidc" + "github.com/hashicorp/boundary/internal/auth/password" "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/hashicorp/boundary/internal/cmd/common" + "github.com/hashicorp/boundary/internal/types/scope" "github.com/mitchellh/cli" "github.com/mitchellh/go-wordwrap" ) @@ -22,18 +31,84 @@ func (c *Command) Help() string { "", " This command authenticates the Boundary commandline client using a specified auth method. Examples:", "", - " Authenticate with a password auth method:", + " Authenticate with the primary auth method in the global scope:", "", - " $ boundary authenticate password -auth-method-id ampw_1234567890 -login-name foo", + " $ boundary authenticate", "", - " Authenticate with an OIDC auth method:", + " Authenticate using the primary auth method in a specific scope", + "", + " $ boundary authenticate password -scope-id o_1234567890", + "", + " Authenticate with a password auth method using a specific auth method ID:", + "", + " $ boundary authenticate password -auth-method-id ampw_1234567890", + "", + " Authenticate with an OIDC auth method using a specific auth method ID:", "", " $ boundary authenticate oidc -auth-method-id amoidc_1234567890", "", " Please see the auth method subcommand help for detailed usage information.", + }) + c.Flags().Help() +} + +func (c *Command) Flags() *base.FlagSets { + set := c.FlagSet(base.FlagSetHTTP | base.FlagSetClient | base.FlagSetOutputFormat) + f := set.NewFlagSet("Command Options") + + f.StringVar(&base.StringVar{ + Name: "scope-id", + EnvVar: "BOUNDARY_SCOPE_ID", + Target: &c.FlagScopeId, + Usage: "The scope to use for the operation", }) + + return set } func (c *Command) Run(args []string) int { - return cli.RunResultHelp + client, err := c.Client(base.WithNoTokenScope(), base.WithNoTokenValue()) + if c.WrapperCleanupFunc != nil { + defer func() { + if err := c.WrapperCleanupFunc(); err != nil { + c.PrintCliError(fmt.Errorf("Error cleaning kms wrapper: %w", err)) + } + }() + } + if err != nil { + c.PrintCliError(fmt.Errorf("Error creating API client: %w", err)) + return base.CommandCliError + } + + f := c.Flags() + + if err := f.Parse(args); err != nil { + c.PrintCliError(err) + return base.CommandUserError + } + + // Lookup the primary auth method ID in the global scope + aClient := authmethods.NewClient(client) + pri, err := getPrimaryAuthMethodId(c.Context, aClient, scope.Global.String(), "") + if err != nil { + c.PrintCliError(errors.New("Error looking up primary auth method ID for the global scope. Try setting a primary auth method for the global scope, or use an auth method subcommand (see 'boundary authenticate -h' for available sub command usage).")) + return base.CommandUserError + } + + c.FlagAuthMethodId = pri + + switch { + case strings.HasPrefix(c.FlagAuthMethodId, password.AuthMethodPrefix): + cmd := PasswordCommand{Command: c.Command, Opts: []common.Option{common.WithSkipScopeIdFlag(true)}} + cmd.Run([]string{}) + + case strings.HasPrefix(c.FlagAuthMethodId, oidc.AuthMethodPrefix): + cmd := OidcCommand{Command: c.Command, Opts: []common.Option{common.WithSkipScopeIdFlag(true)}} + cmd.Run([]string{}) + + default: + c.PrintCliError(fmt.Errorf("The primary auth method was of an unsupported type. The given ID was %s; only 'ampw' (password) and 'amoidc' (OIDC) auth method prefixes are supported.", c.FlagAuthMethodId)) + return cli.RunResultHelp + } + + return 0 } diff --git a/internal/cmd/commands/authenticate/funcs.go b/internal/cmd/commands/authenticate/funcs.go index d1f8c890e..94edfd592 100644 --- a/internal/cmd/commands/authenticate/funcs.go +++ b/internal/cmd/commands/authenticate/funcs.go @@ -1,9 +1,11 @@ package authenticate import ( + "context" "encoding/base64" "encoding/json" "fmt" + "strings" "time" "github.com/hashicorp/boundary/api/authmethods" @@ -100,3 +102,26 @@ func saveAndOrPrintToken(c *base.Command, result *authmethods.AuthenticateResult return base.CommandSuccess } + +// getPrimaryAuthMethodId iterates over client.List() to find the primary auth method ID for the +// given scopeId. If scope ID is empty or no primary auth method is found, it returns an error. +func getPrimaryAuthMethodId(ctx context.Context, client *authmethods.Client, scopeId, amType string) (string, error) { + if scopeId == "" { + return "", fmt.Errorf("Must pass a non empty scope ID string to GetPrimaryAuthMethodId()") + } + authMethodListResult, err := client.List(ctx, scopeId) + if err != nil { + return "", err + } + + for _, m := range authMethodListResult.GetItems() { + if m.IsPrimary { + if !strings.HasPrefix(m.Id, amType) { + return "", fmt.Errorf("Error looking up primary auth method in scope '%s': got '%s' but the command requires an auth method prefix of '%s'. Make sure the sub command you're using matches the primary auth method type in the scope being used. For example, if using the password sub command the primary auth method must have a prefix of 'ampw'.\n\nSee 'boundary authenticate -h' for available sub command usage.", scopeId, m.Id, amType) + } + + return m.Id, nil + } + } + return "", fmt.Errorf("Primary auth method not found for scope ID: '%s'. Please set a primary auth method on this scope or pass one explicitly using an authenticate sub command (see 'boundary authenticate -h') along with the -auth-method-id flag.", scopeId) +} diff --git a/internal/cmd/commands/authenticate/oidc.go b/internal/cmd/commands/authenticate/oidc.go index c94e66706..678e3f97a 100644 --- a/internal/cmd/commands/authenticate/oidc.go +++ b/internal/cmd/commands/authenticate/oidc.go @@ -11,6 +11,8 @@ import ( "github.com/hashicorp/boundary/api" "github.com/hashicorp/boundary/api/authmethods" "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/hashicorp/boundary/internal/cmd/common" + "github.com/hashicorp/boundary/internal/types/scope" "github.com/hashicorp/cap/util" "github.com/mitchellh/cli" "github.com/mitchellh/go-wordwrap" @@ -24,6 +26,9 @@ var ( type OidcCommand struct { *base.Command + + Opts []common.Option + parsedOpts *common.Options } func (c *OidcCommand) Synopsis() string { @@ -53,6 +58,15 @@ func (c *OidcCommand) Flags() *base.FlagSets { Usage: "The auth-method resource to use for the operation", }) + if c.parsedOpts == nil || !c.parsedOpts.WithSkipScopeIdFlag { + f.StringVar(&base.StringVar{ + Name: "scope-id", + EnvVar: "BOUNDARY_SCOPE_ID", + Target: &c.FlagScopeId, + Usage: "The scope ID to use for the operation.", + }) + } + return set } @@ -65,6 +79,13 @@ func (c *OidcCommand) AutocompleteFlags() complete.Flags { } func (c *OidcCommand) Run(args []string) int { + opts, err := common.GetOpts(c.Opts...) + if err != nil { + c.PrintCliError(err) + return base.CommandCliError + } + c.parsedOpts = opts + f := c.Flags() if err := f.Parse(args); err != nil { @@ -72,12 +93,6 @@ func (c *OidcCommand) Run(args []string) int { return base.CommandUserError } - switch { - case c.FlagAuthMethodId == "": - c.PrintCliError(errors.New("Auth method ID must be provided via -auth-method-id")) - return base.CommandUserError - } - client, err := c.Client(base.WithNoTokenScope(), base.WithNoTokenValue()) if c.WrapperCleanupFunc != nil { defer func() { @@ -92,6 +107,23 @@ func (c *OidcCommand) Run(args []string) int { } aClient := authmethods.NewClient(client) + + // if auth method ID isn't passed on the CLI, try looking up the primary auth method ID + if c.FlagAuthMethodId == "" { + // if flag for scope is empty try looking up global + if c.FlagScopeId == "" { + c.FlagScopeId = scope.Global.String() + } + + pri, err := getPrimaryAuthMethodId(c.Context, aClient, c.FlagScopeId, "amoidc") + if err != nil { + c.PrintCliError(err) + return base.CommandUserError + } + + c.FlagAuthMethodId = pri + } + result, err := aClient.Authenticate(c.Context, c.FlagAuthMethodId, "start", nil) if err != nil { if apiErr := api.AsServerError(err); apiErr != nil { diff --git a/internal/cmd/commands/authenticate/password.go b/internal/cmd/commands/authenticate/password.go index 988d06201..c4503c74e 100644 --- a/internal/cmd/commands/authenticate/password.go +++ b/internal/cmd/commands/authenticate/password.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/boundary/api" "github.com/hashicorp/boundary/api/authmethods" "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/hashicorp/boundary/internal/cmd/common" "github.com/hashicorp/go-secure-stdlib/parseutil" "github.com/hashicorp/go-secure-stdlib/password" "github.com/mitchellh/cli" @@ -31,6 +32,9 @@ type PasswordCommand struct { flagLoginName string flagPassword string + + Opts []common.Option + parsedOpts *common.Options } func (c *PasswordCommand) Synopsis() string { @@ -74,6 +78,15 @@ func (c *PasswordCommand) Flags() *base.FlagSets { Usage: "The auth-method resource to use for the operation.", }) + if c.parsedOpts == nil || !c.parsedOpts.WithSkipScopeIdFlag { + f.StringVar(&base.StringVar{ + Name: "scope-id", + EnvVar: "BOUNDARY_SCOPE_ID", + Target: &c.FlagScopeId, + Usage: "The scope ID to use for the operation.", + }) + } + return set } @@ -86,6 +99,13 @@ func (c *PasswordCommand) AutocompleteFlags() complete.Flags { } func (c *PasswordCommand) Run(args []string) int { + opts, err := common.GetOpts(c.Opts...) + if err != nil { + c.PrintCliError(err) + return base.CommandCliError + } + c.parsedOpts = opts + f := c.Flags() if err := f.Parse(args); err != nil { @@ -93,13 +113,47 @@ func (c *PasswordCommand) Run(args []string) int { return base.CommandUserError } - switch { - case c.flagLoginName == "": - c.PrintCliError(errors.New("Login name must be provided via -login-name")) - return base.CommandUserError - case c.FlagAuthMethodId == "": - c.PrintCliError(errors.New("Auth method ID must be provided via -auth-method-id")) - return base.CommandUserError + client, err := c.Client(base.WithNoTokenScope(), base.WithNoTokenValue()) + if c.WrapperCleanupFunc != nil { + defer func() { + if err := c.WrapperCleanupFunc(); err != nil { + c.PrintCliError(fmt.Errorf("Error cleaning kms wrapper: %w", err)) + } + }() + } + if err != nil { + c.PrintCliError(fmt.Errorf("Error creating API client: %w", err)) + return base.CommandCliError + } + + aClient := authmethods.NewClient(client) + + // if auth method ID isn't passed on the CLI, try looking up the primary auth method ID + if c.FlagAuthMethodId == "" { + // if flag for scope is empty try looking up global + if c.FlagScopeId == "" { + c.FlagScopeId = "global" + } + + pri, err := getPrimaryAuthMethodId(c.Context, aClient, c.FlagScopeId, "ampw") + if err != nil { + c.PrintCliError(err) + return base.CommandUserError + } + + c.FlagAuthMethodId = pri + } + + switch c.flagLoginName { + case "": + fmt.Print("Please enter the login name (it will be hidden): ") + value, err := password.Read(os.Stdin) + fmt.Print("\n") + if err != nil { + c.UI.Error(fmt.Sprintf("An error occurred attempting to read the login name. The raw error message is shown below but usually this is because you attempted to pipe a value into the command or you are executing outside of a terminal (TTY). The raw error was:\n\n%s", err.Error())) + return base.CommandUserError + } + c.flagLoginName = strings.TrimSpace(value) } switch c.flagPassword { @@ -127,20 +181,6 @@ func (c *PasswordCommand) Run(args []string) int { c.flagPassword = password } - client, err := c.Client(base.WithNoTokenScope(), base.WithNoTokenValue()) - if c.WrapperCleanupFunc != nil { - defer func() { - if err := c.WrapperCleanupFunc(); err != nil { - c.PrintCliError(fmt.Errorf("Error cleaning kms wrapper: %w", err)) - } - }() - } - if err != nil { - c.PrintCliError(fmt.Errorf("Error creating API client: %w", err)) - return base.CommandCliError - } - - aClient := authmethods.NewClient(client) result, err := aClient.Authenticate(c.Context, c.FlagAuthMethodId, "login", map[string]any{ "login_name": c.flagLoginName, diff --git a/internal/cmd/common/option.go b/internal/cmd/common/option.go new file mode 100644 index 000000000..0fd011edd --- /dev/null +++ b/internal/cmd/common/option.go @@ -0,0 +1,36 @@ +package common + +// GetOpts - iterate the inbound Options and return a struct. +func GetOpts(opt ...Option) (*Options, error) { + opts := getDefaultOptions() + for _, o := range opt { + if o == nil { + continue + } + if err := o(opts); err != nil { + return nil, err + } + } + return opts, nil +} + +// Option - how Options are passed as arguments. +type Option func(*Options) error + +// Options - how Options are represented. +type Options struct { + WithSkipScopeIdFlag bool +} + +func getDefaultOptions() *Options { + return &Options{} +} + +// WithSkipScopeIdFlag tells a command to not create a scope ID flag (usually +// because it's already been defined) +func WithSkipScopeIdFlag(with bool) Option { + return func(o *Options) error { + o.WithSkipScopeIdFlag = with + return nil + } +} diff --git a/internal/cmd/common/option_test.go b/internal/cmd/common/option_test.go new file mode 100644 index 000000000..e84efea9e --- /dev/null +++ b/internal/cmd/common/option_test.go @@ -0,0 +1,29 @@ +package common + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test_GetOpts provides unit tests for GetOpts and all the options +func Test_GetOpts(t *testing.T) { + t.Parallel() + t.Run("nil-options", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + opts, err := GetOpts(nil, nil) + require.NoError(err) + testOpts := getDefaultOptions() + assert.Equal(opts, testOpts) + }) + + t.Run("WithSkipScopeIdFlag", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + opts, err := GetOpts(WithSkipScopeIdFlag(true)) + require.NoError(err) + testOpts := getDefaultOptions() + testOpts.WithSkipScopeIdFlag = true + assert.Equal(opts, testOpts) + }) +}