From 65a41d4f08346b492c00389d8a995d031247a31b Mon Sep 17 00:00:00 2001 From: Hamid Ghaf <83242695+hghaf099@users.noreply.github.com> Date: Mon, 23 Jan 2023 15:51:22 -0500 Subject: [PATCH] named Login MFA methods (#18610) * named MFA method configurations * fix a test * CL * fix an issue with same config name different ID and add a test * feedback * feedback on test * consistent use of passcode for all MFA methods (#18611) * make use of passcode factor consistent for all MFA types * improved type for MFA factors * add method name to login CLI * minor refactoring * only accept MFA method name with its namespace path in the login request MFA header * fix a bug * fixing an ErrorOrNil return value * more informative error message * Apply suggestions from code review Co-authored-by: Nick Cabatoff * feedback * test refactor a bit * adding godoc for a test * feedback * remove sanitize method name * guard a possbile nil ref Co-authored-by: Nick Cabatoff --- changelog/18610.txt | 4 + command/format.go | 3 + command/login_test.go | 913 ++++++++++-------- helper/testhelpers/testhelpers.go | 22 +- sdk/logical/identity.pb.go | 57 +- sdk/logical/identity.proto | 1 + .../identity/login_mfa_totp_test.go | 104 +- vault/external_tests/mfa/login_mfa_test.go | 122 ++- vault/identity_store.go | 16 + vault/login_mfa.go | 220 ++++- vault/login_mfa_test.go | 61 ++ vault/request_handling.go | 1 + 12 files changed, 1008 insertions(+), 516 deletions(-) create mode 100644 changelog/18610.txt create mode 100644 vault/login_mfa_test.go diff --git a/changelog/18610.txt b/changelog/18610.txt new file mode 100644 index 000000000..bac3add5a --- /dev/null +++ b/changelog/18610.txt @@ -0,0 +1,4 @@ +```release-note:improvement +auth: Allow naming login MFA methods and using those names instead of IDs in satisfying MFA requirement for requests. +Make passcode arguments consistent across login MFA method types. +``` \ No newline at end of file diff --git a/command/format.go b/command/format.go index 6543880fb..cdacaab39 100644 --- a/command/format.go +++ b/command/format.go @@ -557,6 +557,9 @@ func (t TableFormatter) OutputSecret(ui cli.Ui, secret *api.Secret) error { for _, constraint := range constraintSet.Any { out = append(out, fmt.Sprintf("mfa_constraint_%s_%s_id %s %s", k, constraint.Type, hopeDelim, constraint.ID)) out = append(out, fmt.Sprintf("mfa_constraint_%s_%s_uses_passcode %s %t", k, constraint.Type, hopeDelim, constraint.UsesPasscode)) + if constraint.Name != "" { + out = append(out, fmt.Sprintf("mfa_constraint_%s_%s_name %s %s", k, constraint.Type, hopeDelim, constraint.Name)) + } } } } else { // Token information only makes sense if no further MFA requirement (i.e. if we actually have a token) diff --git a/command/login_test.go b/command/login_test.go index 56ed790f3..0d8a0b169 100644 --- a/command/login_test.go +++ b/command/login_test.go @@ -1,8 +1,11 @@ package command import ( + "context" + "regexp" "strings" "testing" + "time" "github.com/mitchellh/cli" @@ -37,414 +40,445 @@ func testLoginCommand(tb testing.TB) (*cli.MockUi, *LoginCommand) { } } -func TestLoginCommand_Run(t *testing.T) { +func TestCustomPath(t *testing.T) { t.Parallel() - t.Run("custom_path", func(t *testing.T) { - t.Parallel() + client, closer := testVaultServer(t) + defer closer() - client, closer := testVaultServer(t) - defer closer() + if err := client.Sys().EnableAuth("my-auth", "userpass", ""); err != nil { + t.Fatal(err) + } + if _, err := client.Logical().Write("auth/my-auth/users/test", map[string]interface{}{ + "password": "test", + "policies": "default", + }); err != nil { + t.Fatal(err) + } - if err := client.Sys().EnableAuth("my-auth", "userpass", ""); err != nil { - t.Fatal(err) - } - if _, err := client.Logical().Write("auth/my-auth/users/test", map[string]interface{}{ - "password": "test", - "policies": "default", - }); err != nil { - t.Fatal(err) - } + ui, cmd := testLoginCommand(t) + cmd.client = client - ui, cmd := testLoginCommand(t) - cmd.client = client + tokenHelper, err := cmd.TokenHelper() + if err != nil { + t.Fatal(err) + } - tokenHelper, err := cmd.TokenHelper() - if err != nil { - t.Fatal(err) - } + // Emulate an unknown token format present in ~/.vault-token, for example + client.SetToken("a.a") - // Emulate an unknown token format present in ~/.vault-token, for example - client.SetToken("a.a") - - code := cmd.Run([]string{ - "-method", "userpass", - "-path", "my-auth", - "username=test", - "password=test", - }) - if exp := 0; code != exp { - t.Errorf("expected %d to be %d", code, exp) - } - - expected := "Success! You are now authenticated." - combined := ui.OutputWriter.String() + ui.ErrorWriter.String() - if !strings.Contains(combined, expected) { - t.Errorf("expected %q to be %q", combined, expected) - } - - storedToken, err := tokenHelper.Get() - if err != nil { - t.Fatal(err) - } - - if l, exp := len(storedToken), minTokenLengthExternal+vault.TokenPrefixLength; l < exp { - t.Errorf("expected token to be %d characters, was %d: %q", exp, l, storedToken) - } + code := cmd.Run([]string{ + "-method", "userpass", + "-path", "my-auth", + "username=test", + "password=test", }) + if exp := 0; code != exp { + t.Errorf("expected %d to be %d", code, exp) + } - t.Run("no_store", func(t *testing.T) { - t.Parallel() + expected := "Success! You are now authenticated." + combined := ui.OutputWriter.String() + ui.ErrorWriter.String() + if !strings.Contains(combined, expected) { + t.Errorf("expected %q to be %q", combined, expected) + } - client, closer := testVaultServer(t) - defer closer() + storedToken, err := tokenHelper.Get() + if err != nil { + t.Fatal(err) + } - secret, err := client.Auth().Token().Create(&api.TokenCreateRequest{ - Policies: []string{"default"}, - TTL: "30m", - }) - if err != nil { - t.Fatal(err) - } - token := secret.Auth.ClientToken + if l, exp := len(storedToken), minTokenLengthExternal+vault.TokenPrefixLength; l < exp { + t.Errorf("expected token to be %d characters, was %d: %q", exp, l, storedToken) + } +} - _, cmd := testLoginCommand(t) - cmd.client = client +// Do not persist the token to the token helper +func TestNoStore(t *testing.T) { + t.Parallel() - tokenHelper, err := cmd.TokenHelper() - if err != nil { - t.Fatal(err) - } + client, closer := testVaultServer(t) + defer closer() - // Ensure we have no token to start - if storedToken, err := tokenHelper.Get(); err != nil || storedToken != "" { - t.Errorf("expected token helper to be empty: %s: %q", err, storedToken) - } - - code := cmd.Run([]string{ - "-no-store", - token, - }) - if exp := 0; code != exp { - t.Errorf("expected %d to be %d", code, exp) - } - - storedToken, err := tokenHelper.Get() - if err != nil { - t.Fatal(err) - } - - if exp := ""; storedToken != exp { - t.Errorf("expected %q to be %q", storedToken, exp) - } + secret, err := client.Auth().Token().Create(&api.TokenCreateRequest{ + Policies: []string{"default"}, + TTL: "30m", }) + if err != nil { + t.Fatal(err) + } + token := secret.Auth.ClientToken - t.Run("stores", func(t *testing.T) { - t.Parallel() + _, cmd := testLoginCommand(t) + cmd.client = client - client, closer := testVaultServer(t) - defer closer() + tokenHelper, err := cmd.TokenHelper() + if err != nil { + t.Fatal(err) + } - secret, err := client.Auth().Token().Create(&api.TokenCreateRequest{ - Policies: []string{"default"}, - TTL: "30m", - }) - if err != nil { - t.Fatal(err) - } - token := secret.Auth.ClientToken + // Ensure we have no token to start + if storedToken, err := tokenHelper.Get(); err != nil || storedToken != "" { + t.Errorf("expected token helper to be empty: %s: %q", err, storedToken) + } - _, cmd := testLoginCommand(t) - cmd.client = client - - tokenHelper, err := cmd.TokenHelper() - if err != nil { - t.Fatal(err) - } - - code := cmd.Run([]string{ - token, - }) - if exp := 0; code != exp { - t.Errorf("expected %d to be %d", code, exp) - } - - storedToken, err := tokenHelper.Get() - if err != nil { - t.Fatal(err) - } - - if storedToken != token { - t.Errorf("expected %q to be %q", storedToken, token) - } + code := cmd.Run([]string{ + "-no-store", + token, }) + if exp := 0; code != exp { + t.Errorf("expected %d to be %d", code, exp) + } - t.Run("token_only", func(t *testing.T) { - t.Parallel() + storedToken, err := tokenHelper.Get() + if err != nil { + t.Fatal(err) + } - client, closer := testVaultServer(t) - defer closer() + if exp := ""; storedToken != exp { + t.Errorf("expected %q to be %q", storedToken, exp) + } +} - if err := client.Sys().EnableAuth("userpass", "userpass", ""); err != nil { - t.Fatal(err) - } - if _, err := client.Logical().Write("auth/userpass/users/test", map[string]interface{}{ - "password": "test", - "policies": "default", - }); err != nil { - t.Fatal(err) - } +func TestStores(t *testing.T) { + t.Parallel() - ui, cmd := testLoginCommand(t) - cmd.client = client + client, closer := testVaultServer(t) + defer closer() - tokenHelper, err := cmd.TokenHelper() - if err != nil { - t.Fatal(err) - } - - code := cmd.Run([]string{ - "-token-only", - "-method", "userpass", - "username=test", - "password=test", - }) - if exp := 0; code != exp { - t.Errorf("expected %d to be %d", code, exp) - } - - // Verify only the token was printed - token := ui.OutputWriter.String() - if l, exp := len(token), minTokenLengthExternal+vault.TokenPrefixLength; l != exp { - t.Errorf("expected token to be %d characters, was %d: %q", exp, l, token) - } - - // Verify the token was not stored - if storedToken, err := tokenHelper.Get(); err != nil || storedToken != "" { - t.Fatalf("expected token to not be stored: %s: %q", err, storedToken) - } + secret, err := client.Auth().Token().Create(&api.TokenCreateRequest{ + Policies: []string{"default"}, + TTL: "30m", }) + if err != nil { + t.Fatal(err) + } + token := secret.Auth.ClientToken - t.Run("failure_no_store", func(t *testing.T) { - t.Parallel() + _, cmd := testLoginCommand(t) + cmd.client = client - client, closer := testVaultServer(t) - defer closer() + tokenHelper, err := cmd.TokenHelper() + if err != nil { + t.Fatal(err) + } - ui, cmd := testLoginCommand(t) - cmd.client = client - - tokenHelper, err := cmd.TokenHelper() - if err != nil { - t.Fatal(err) - } - - code := cmd.Run([]string{ - "not-a-real-token", - }) - if exp := 2; code != exp { - t.Errorf("expected %d to be %d", code, exp) - } - - expected := "Error authenticating: " - combined := ui.OutputWriter.String() + ui.ErrorWriter.String() - if !strings.Contains(combined, expected) { - t.Errorf("expected %q to contain %q", combined, expected) - } - - if storedToken, err := tokenHelper.Get(); err != nil || storedToken != "" { - t.Fatalf("expected token to not be stored: %s: %q", err, storedToken) - } + code := cmd.Run([]string{ + token, }) + if exp := 0; code != exp { + t.Errorf("expected %d to be %d", code, exp) + } - t.Run("wrap_auto_unwrap", func(t *testing.T) { - t.Parallel() + storedToken, err := tokenHelper.Get() + if err != nil { + t.Fatal(err) + } - client, closer := testVaultServer(t) - defer closer() + if storedToken != token { + t.Errorf("expected %q to be %q", storedToken, token) + } +} - if err := client.Sys().EnableAuth("userpass", "userpass", ""); err != nil { - t.Fatal(err) - } - if _, err := client.Logical().Write("auth/userpass/users/test", map[string]interface{}{ - "password": "test", - "policies": "default", - }); err != nil { - t.Fatal(err) - } +func TestTokenOnly(t *testing.T) { + t.Parallel() - _, cmd := testLoginCommand(t) - cmd.client = client + client, closer := testVaultServer(t) + defer closer() - // Set the wrapping ttl to 5s. We can't set this via the flag because we - // override the client object before that particular flag is parsed. - client.SetWrappingLookupFunc(func(string, string) string { return "5m" }) + if err := client.Sys().EnableAuth("userpass", "userpass", ""); err != nil { + t.Fatal(err) + } + if _, err := client.Logical().Write("auth/userpass/users/test", map[string]interface{}{ + "password": "test", + "policies": "default", + }); err != nil { + t.Fatal(err) + } - code := cmd.Run([]string{ - "-method", "userpass", - "username=test", - "password=test", - }) - if exp := 0; code != exp { - t.Errorf("expected %d to be %d", code, exp) - } + ui, cmd := testLoginCommand(t) + cmd.client = client - // Unset the wrapping - client.SetWrappingLookupFunc(func(string, string) string { return "" }) + tokenHelper, err := cmd.TokenHelper() + if err != nil { + t.Fatal(err) + } - tokenHelper, err := cmd.TokenHelper() - if err != nil { - t.Fatal(err) - } - token, err := tokenHelper.Get() - if err != nil || token == "" { - t.Fatalf("expected token from helper: %s: %q", err, token) - } - client.SetToken(token) - - // Ensure the resulting token is unwrapped - secret, err := client.Auth().Token().LookupSelf() - if err != nil { - t.Error(err) - } - if secret == nil { - t.Fatal("secret was nil") - } - - if secret.WrapInfo != nil { - t.Errorf("expected to be unwrapped: %#v", secret) - } + code := cmd.Run([]string{ + "-token-only", + "-method", "userpass", + "username=test", + "password=test", }) + if exp := 0; code != exp { + t.Errorf("expected %d to be %d", code, exp) + } - t.Run("wrap_token_only", func(t *testing.T) { - t.Parallel() + // Verify only the token was printed + token := ui.OutputWriter.String() + if l, exp := len(token), minTokenLengthExternal+vault.TokenPrefixLength; l != exp { + t.Errorf("expected token to be %d characters, was %d: %q", exp, l, token) + } - client, closer := testVaultServer(t) - defer closer() + // Verify the token was not stored + if storedToken, err := tokenHelper.Get(); err != nil || storedToken != "" { + t.Fatalf("expected token to not be stored: %s: %q", err, storedToken) + } +} - if err := client.Sys().EnableAuth("userpass", "userpass", ""); err != nil { - t.Fatal(err) - } - if _, err := client.Logical().Write("auth/userpass/users/test", map[string]interface{}{ - "password": "test", - "policies": "default", - }); err != nil { - t.Fatal(err) - } +func TestFailureNoStore(t *testing.T) { + t.Parallel() - ui, cmd := testLoginCommand(t) - cmd.client = client + client, closer := testVaultServer(t) + defer closer() - // Set the wrapping ttl to 5s. We can't set this via the flag because we - // override the client object before that particular flag is parsed. - client.SetWrappingLookupFunc(func(string, string) string { return "5m" }) + ui, cmd := testLoginCommand(t) + cmd.client = client - code := cmd.Run([]string{ - "-token-only", - "-method", "userpass", - "username=test", - "password=test", - }) - if exp := 0; code != exp { - t.Errorf("expected %d to be %d", code, exp) - } + tokenHelper, err := cmd.TokenHelper() + if err != nil { + t.Fatal(err) + } - // Unset the wrapping - client.SetWrappingLookupFunc(func(string, string) string { return "" }) - - tokenHelper, err := cmd.TokenHelper() - if err != nil { - t.Fatal(err) - } - storedToken, err := tokenHelper.Get() - if err != nil || storedToken != "" { - t.Fatalf("expected token to not be stored: %s: %q", err, storedToken) - } - - token := strings.TrimSpace(ui.OutputWriter.String()) - if token == "" { - t.Errorf("expected %q to not be %q", token, "") - } - - // Ensure the resulting token is, in fact, still wrapped. - client.SetToken(token) - secret, err := client.Logical().Unwrap("") - if err != nil { - t.Error(err) - } - if secret == nil || secret.Auth == nil || secret.Auth.ClientToken == "" { - t.Fatalf("expected secret to have auth: %#v", secret) - } + code := cmd.Run([]string{ + "not-a-real-token", }) + if exp := 2; code != exp { + t.Errorf("expected %d to be %d", code, exp) + } - t.Run("wrap_no_store", func(t *testing.T) { - t.Parallel() + expected := "Error authenticating: " + combined := ui.OutputWriter.String() + ui.ErrorWriter.String() + if !strings.Contains(combined, expected) { + t.Errorf("expected %q to contain %q", combined, expected) + } - client, closer := testVaultServer(t) - defer closer() + if storedToken, err := tokenHelper.Get(); err != nil || storedToken != "" { + t.Fatalf("expected token to not be stored: %s: %q", err, storedToken) + } +} - if err := client.Sys().EnableAuth("userpass", "userpass", ""); err != nil { - t.Fatal(err) - } - if _, err := client.Logical().Write("auth/userpass/users/test", map[string]interface{}{ - "password": "test", - "policies": "default", - }); err != nil { - t.Fatal(err) - } +func TestWrapAutoUnwrap(t *testing.T) { + t.Parallel() - ui, cmd := testLoginCommand(t) - cmd.client = client + client, closer := testVaultServer(t) + defer closer() - // Set the wrapping ttl to 5s. We can't set this via the flag because we - // override the client object before that particular flag is parsed. - client.SetWrappingLookupFunc(func(string, string) string { return "5m" }) + if err := client.Sys().EnableAuth("userpass", "userpass", ""); err != nil { + t.Fatal(err) + } + if _, err := client.Logical().Write("auth/userpass/users/test", map[string]interface{}{ + "password": "test", + "policies": "default", + }); err != nil { + t.Fatal(err) + } - code := cmd.Run([]string{ - "-no-store", - "-method", "userpass", - "username=test", - "password=test", - }) - if exp := 0; code != exp { - t.Errorf("expected %d to be %d", code, exp) - } + _, cmd := testLoginCommand(t) + cmd.client = client - // Unset the wrapping - client.SetWrappingLookupFunc(func(string, string) string { return "" }) + // Set the wrapping ttl to 5s. We can't set this via the flag because we + // override the client object before that particular flag is parsed. + client.SetWrappingLookupFunc(func(string, string) string { return "5m" }) - tokenHelper, err := cmd.TokenHelper() - if err != nil { - t.Fatal(err) - } - storedToken, err := tokenHelper.Get() - if err != nil || storedToken != "" { - t.Fatalf("expected token to not be stored: %s: %q", err, storedToken) - } - - expected := "wrapping_token" - output := ui.OutputWriter.String() + ui.ErrorWriter.String() - if !strings.Contains(output, expected) { - t.Errorf("expected %q to contain %q", output, expected) - } + code := cmd.Run([]string{ + "-method", "userpass", + "username=test", + "password=test", }) + if exp := 0; code != exp { + t.Errorf("expected %d to be %d", code, exp) + } - t.Run("login_mfa_single_phase", func(t *testing.T) { - t.Parallel() + // Unset the wrapping + client.SetWrappingLookupFunc(func(string, string) string { return "" }) - client, closer := testVaultServer(t) - defer closer() + tokenHelper, err := cmd.TokenHelper() + if err != nil { + t.Fatal(err) + } + token, err := tokenHelper.Get() + if err != nil || token == "" { + t.Fatalf("expected token from helper: %s: %q", err, token) + } + client.SetToken(token) - ui, cmd := testLoginCommand(t) + // Ensure the resulting token is unwrapped + secret, err := client.Auth().Token().LookupSelf() + if err != nil { + t.Error(err) + } + if secret == nil { + t.Fatal("secret was nil") + } - userclient, entityID, methodID := testhelpers.SetupLoginMFATOTP(t, client) - cmd.client = userclient + if secret.WrapInfo != nil { + t.Errorf("expected to be unwrapped: %#v", secret) + } +} - enginePath := testhelpers.RegisterEntityInTOTPEngine(t, client, entityID, methodID) +func TestWrapTokenOnly(t *testing.T) { + t.Parallel() + + client, closer := testVaultServer(t) + defer closer() + + if err := client.Sys().EnableAuth("userpass", "userpass", ""); err != nil { + t.Fatal(err) + } + if _, err := client.Logical().Write("auth/userpass/users/test", map[string]interface{}{ + "password": "test", + "policies": "default", + }); err != nil { + t.Fatal(err) + } + + ui, cmd := testLoginCommand(t) + cmd.client = client + + // Set the wrapping ttl to 5s. We can't set this via the flag because we + // override the client object before that particular flag is parsed. + client.SetWrappingLookupFunc(func(string, string) string { return "5m" }) + + code := cmd.Run([]string{ + "-token-only", + "-method", "userpass", + "username=test", + "password=test", + }) + if exp := 0; code != exp { + t.Errorf("expected %d to be %d", code, exp) + } + + // Unset the wrapping + client.SetWrappingLookupFunc(func(string, string) string { return "" }) + + tokenHelper, err := cmd.TokenHelper() + if err != nil { + t.Fatal(err) + } + storedToken, err := tokenHelper.Get() + if err != nil || storedToken != "" { + t.Fatalf("expected token to not be stored: %s: %q", err, storedToken) + } + + token := strings.TrimSpace(ui.OutputWriter.String()) + if token == "" { + t.Errorf("expected %q to not be %q", token, "") + } + + // Ensure the resulting token is, in fact, still wrapped. + client.SetToken(token) + secret, err := client.Logical().Unwrap("") + if err != nil { + t.Error(err) + } + if secret == nil || secret.Auth == nil || secret.Auth.ClientToken == "" { + t.Fatalf("expected secret to have auth: %#v", secret) + } +} + +func TestWrapNoStore(t *testing.T) { + t.Parallel() + + client, closer := testVaultServer(t) + defer closer() + + if err := client.Sys().EnableAuth("userpass", "userpass", ""); err != nil { + t.Fatal(err) + } + if _, err := client.Logical().Write("auth/userpass/users/test", map[string]interface{}{ + "password": "test", + "policies": "default", + }); err != nil { + t.Fatal(err) + } + + ui, cmd := testLoginCommand(t) + cmd.client = client + + // Set the wrapping ttl to 5s. We can't set this via the flag because we + // override the client object before that particular flag is parsed. + client.SetWrappingLookupFunc(func(string, string) string { return "5m" }) + + code := cmd.Run([]string{ + "-no-store", + "-method", "userpass", + "username=test", + "password=test", + }) + if exp := 0; code != exp { + t.Errorf("expected %d to be %d", code, exp) + } + + // Unset the wrapping + client.SetWrappingLookupFunc(func(string, string) string { return "" }) + + tokenHelper, err := cmd.TokenHelper() + if err != nil { + t.Fatal(err) + } + storedToken, err := tokenHelper.Get() + if err != nil || storedToken != "" { + t.Fatalf("expected token to not be stored: %s: %q", err, storedToken) + } + + expected := "wrapping_token" + output := ui.OutputWriter.String() + ui.ErrorWriter.String() + if !strings.Contains(output, expected) { + t.Errorf("expected %q to contain %q", output, expected) + } +} + +func TestCommunicationFailure(t *testing.T) { + t.Parallel() + + client, closer := testVaultServerBad(t) + defer closer() + + ui, cmd := testLoginCommand(t) + cmd.client = client + + code := cmd.Run([]string{ + "token", + }) + if exp := 2; code != exp { + t.Errorf("expected %d to be %d", code, exp) + } + + expected := "Error authenticating: " + combined := ui.OutputWriter.String() + ui.ErrorWriter.String() + if !strings.Contains(combined, expected) { + t.Errorf("expected %q to contain %q", combined, expected) + } +} + +func TestNoTabs(t *testing.T) { + t.Parallel() + + _, cmd := testLoginCommand(t) + assertNoTabs(t, cmd) +} + +func TestLoginMFASinglePhase(t *testing.T) { + t.Parallel() + + client, closer := testVaultServer(t) + defer closer() + + methodName := "foo" + waitPeriod := 5 + userClient, entityID, methodID := testhelpers.SetupLoginMFATOTP(t, client, methodName, waitPeriod) + enginePath := testhelpers.RegisterEntityInTOTPEngine(t, client, entityID, methodID) + + runCommand := func(methodIdentifier string) { + // the time required for the totp engine to generate a new code + time.Sleep(time.Duration(waitPeriod) * time.Second) totpCode := testhelpers.GetTOTPCodeFromEngine(t, client, enginePath) - + ui, cmd := testLoginCommand(t) + cmd.client = userClient // login command bails early for test clients, so we have to explicitly set this - cmd.client.SetMFACreds([]string{methodID + ":" + totpCode}) + cmd.client.SetMFACreds([]string{methodIdentifier + ":" + totpCode}) code := cmd.Run([]string{ "-method", "userpass", "username=testuser1", @@ -462,85 +496,118 @@ func TestLoginCommand_Run(t *testing.T) { if err != nil { t.Fatal(err) } - output = ui.OutputWriter.String() + ui.ErrorWriter.String() - t.Logf("\n%+v", output) + if storedToken == "" { + t.Fatal("expected non-empty stored token") + } + output = ui.OutputWriter.String() if !strings.Contains(output, storedToken) { t.Fatalf("expected stored token: %q, got: %q", storedToken, output) } - }) - - t.Run("login_mfa_two_phase", func(t *testing.T) { - t.Parallel() - - client, closer := testVaultServer(t) - defer closer() - - ui, cmd := testLoginCommand(t) - - userclient, entityID, methodID := testhelpers.SetupLoginMFATOTP(t, client) - cmd.client = userclient - - _ = testhelpers.RegisterEntityInTOTPEngine(t, client, entityID, methodID) - - // clear the MFA creds just to be sure - cmd.client.SetMFACreds([]string{}) - - code := cmd.Run([]string{ - "-method", "userpass", - "username=testuser1", - "password=testpassword", - }) - if exp := 0; code != exp { - t.Errorf("expected %d to be %d", code, exp) - } - - expected := methodID - output = ui.OutputWriter.String() + ui.ErrorWriter.String() - t.Logf("\n%+v", output) - if !strings.Contains(output, expected) { - t.Fatalf("expected stored token: %q, got: %q", expected, output) - } - - tokenHelper, err := cmd.TokenHelper() - if err != nil { - t.Fatal(err) - } - storedToken, err := tokenHelper.Get() - if storedToken != "" { - t.Fatal("expected empty stored token") - } - if err != nil { - t.Fatal(err) - } - }) - - t.Run("communication_failure", func(t *testing.T) { - t.Parallel() - - client, closer := testVaultServerBad(t) - defer closer() - - ui, cmd := testLoginCommand(t) - cmd.client = client - - code := cmd.Run([]string{ - "token", - }) - if exp := 2; code != exp { - t.Errorf("expected %d to be %d", code, exp) - } - - expected := "Error authenticating: " - combined := ui.OutputWriter.String() + ui.ErrorWriter.String() - if !strings.Contains(combined, expected) { - t.Errorf("expected %q to contain %q", combined, expected) - } - }) - - t.Run("no_tabs", func(t *testing.T) { - t.Parallel() - - _, cmd := testLoginCommand(t) - assertNoTabs(t, cmd) - }) + } + runCommand(methodID) + runCommand(methodName) +} + +func TestLoginMFATwoPhase(t *testing.T) { + t.Parallel() + + client, closer := testVaultServer(t) + defer closer() + + ui, cmd := testLoginCommand(t) + + userclient, entityID, methodID := testhelpers.SetupLoginMFATOTP(t, client, "", 5) + cmd.client = userclient + + _ = testhelpers.RegisterEntityInTOTPEngine(t, client, entityID, methodID) + + // clear the MFA creds just to be sure + cmd.client.SetMFACreds([]string{}) + + code := cmd.Run([]string{ + "-method", "userpass", + "username=testuser1", + "password=testpassword", + }) + if exp := 0; code != exp { + t.Errorf("expected %d to be %d", code, exp) + } + + expected := methodID + output = ui.OutputWriter.String() + if !strings.Contains(output, expected) { + t.Fatalf("expected stored token: %q, got: %q", expected, output) + } + + tokenHelper, err := cmd.TokenHelper() + if err != nil { + t.Fatal(err) + } + storedToken, err := tokenHelper.Get() + if storedToken != "" { + t.Fatal("expected empty stored token") + } + if err != nil { + t.Fatal(err) + } +} + +func TestLoginMFATwoPhaseNonInteractiveMethodName(t *testing.T) { + t.Parallel() + + client, closer := testVaultServer(t) + defer closer() + + ui, cmd := testLoginCommand(t) + + methodName := "foo" + waitPeriod := 5 + userclient, entityID, methodID := testhelpers.SetupLoginMFATOTP(t, client, methodName, waitPeriod) + cmd.client = userclient + + engineName := testhelpers.RegisterEntityInTOTPEngine(t, client, entityID, methodID) + + // clear the MFA creds just to be sure + cmd.client.SetMFACreds([]string{}) + + code := cmd.Run([]string{ + "-method", "userpass", + "-non-interactive", + "username=testuser1", + "password=testpassword", + }) + if exp := 0; code != exp { + t.Errorf("expected %d to be %d", code, exp) + } + + output = ui.OutputWriter.String() + + reqIdReg := regexp.MustCompile(`mfa_request_id\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\s+mfa_constraint`) + reqIDRaw := reqIdReg.FindAllStringSubmatch(output, -1) + if len(reqIDRaw) == 0 || len(reqIDRaw[0]) < 2 { + t.Fatal("failed to MFA request ID from output") + } + mfaReqID := reqIDRaw[0][1] + + validateFunc := func(methodIdentifier string) { + // the time required for the totp engine to generate a new code + time.Sleep(time.Duration(waitPeriod) * time.Second) + totpPasscode1 := "passcode=" + testhelpers.GetTOTPCodeFromEngine(t, client, engineName) + + secret, err := cmd.client.Logical().WriteWithContext(context.Background(), "sys/mfa/validate", map[string]interface{}{ + "mfa_request_id": mfaReqID, + "mfa_payload": map[string][]string{ + methodIdentifier: {totpPasscode1}, + }, + }) + if err != nil { + t.Fatalf("mfa validation failed: %v", err) + } + + if secret.Auth == nil || secret.Auth.ClientToken == "" { + t.Fatalf("mfa validation did not return a client token") + } + } + + validateFunc(methodName) } diff --git a/helper/testhelpers/testhelpers.go b/helper/testhelpers/testhelpers.go index 6899dd4cb..624926b81 100644 --- a/helper/testhelpers/testhelpers.go +++ b/helper/testhelpers/testhelpers.go @@ -33,7 +33,7 @@ const ( GenerateRecovery ) -// Generates a root token on the target cluster. +// GenerateRoot generates a root token on the target cluster. func GenerateRoot(t testing.T, cluster *vault.TestCluster, kind GenerateRootKind) string { t.Helper() token, err := GenerateRootWithError(t, cluster, kind) @@ -767,6 +767,21 @@ func SetNonRootToken(client *api.Client) error { return nil } +// RetryUntilAtCadence runs f until it returns a nil result or the timeout is reached. +// If a nil result hasn't been obtained by timeout, calls t.Fatal. +func RetryUntilAtCadence(t testing.T, timeout, sleepTime time.Duration, f func() error) { + t.Helper() + deadline := time.Now().Add(timeout) + var err error + for time.Now().Before(deadline) { + if err = f(); err == nil { + return + } + time.Sleep(sleepTime) + } + t.Fatalf("did not complete before deadline, err: %v", err) +} + // RetryUntil runs f until it returns a nil result or the timeout is reached. // If a nil result hasn't been obtained by timeout, calls t.Fatal. func RetryUntil(t testing.T, timeout time.Duration, f func() error) { @@ -942,7 +957,7 @@ func GetTOTPCodeFromEngine(t testing.T, client *api.Client, enginePath string) s // SetupLoginMFATOTP setups up a TOTP MFA using some basic configuration and // returns all relevant information to the client. -func SetupLoginMFATOTP(t testing.T, client *api.Client) (*api.Client, string, string) { +func SetupLoginMFATOTP(t testing.T, client *api.Client, methodName string, waitPeriod int) (*api.Client, string, string) { t.Helper() // Mount the totp secrets engine SetupTOTPMount(t, client) @@ -956,13 +971,14 @@ func SetupLoginMFATOTP(t testing.T, client *api.Client) (*api.Client, string, st // Configure a default TOTP method totpConfig := map[string]interface{}{ "issuer": "yCorp", - "period": 20, + "period": waitPeriod, "algorithm": "SHA256", "digits": 6, "skew": 1, "key_size": 20, "qr_size": 200, "max_validation_attempts": 5, + "method_name": methodName, } methodID := SetupTOTPMethod(t, client, totpConfig) diff --git a/sdk/logical/identity.pb.go b/sdk/logical/identity.pb.go index 42c722afe..8b52ec16d 100644 --- a/sdk/logical/identity.pb.go +++ b/sdk/logical/identity.pb.go @@ -318,6 +318,7 @@ type MFAMethodID struct { Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` ID string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` UsesPasscode bool `protobuf:"varint,3,opt,name=uses_passcode,json=usesPasscode,proto3" json:"uses_passcode,omitempty"` + Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"` } func (x *MFAMethodID) Reset() { @@ -373,6 +374,13 @@ func (x *MFAMethodID) GetUsesPasscode() bool { return false } +func (x *MFAMethodID) GetName() string { + if x != nil { + return x.Name + } + return "" +} + type MFAConstraintAny struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -537,34 +545,35 @@ var file_sdk_logical_identity_proto_rawDesc = []byte{ 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, - 0x01, 0x22, 0x56, 0x0a, 0x0b, 0x4d, 0x46, 0x41, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x49, 0x44, + 0x01, 0x22, 0x6a, 0x0a, 0x0b, 0x4d, 0x46, 0x41, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x49, 0x44, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x75, 0x73, 0x65, 0x73, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x75, 0x73, 0x65, - 0x73, 0x50, 0x61, 0x73, 0x73, 0x63, 0x6f, 0x64, 0x65, 0x22, 0x3a, 0x0a, 0x10, 0x4d, 0x46, 0x41, - 0x43, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x74, 0x41, 0x6e, 0x79, 0x12, 0x26, 0x0a, - 0x03, 0x61, 0x6e, 0x79, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6c, 0x6f, 0x67, - 0x69, 0x63, 0x61, 0x6c, 0x2e, 0x4d, 0x46, 0x41, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x49, 0x44, - 0x52, 0x03, 0x61, 0x6e, 0x79, 0x22, 0xea, 0x01, 0x0a, 0x0e, 0x4d, 0x46, 0x41, 0x52, 0x65, 0x71, - 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x24, 0x0a, 0x0e, 0x6d, 0x66, 0x61, 0x5f, - 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0c, 0x6d, 0x66, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x54, - 0x0a, 0x0f, 0x6d, 0x66, 0x61, 0x5f, 0x63, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x74, - 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x6c, 0x6f, 0x67, 0x69, 0x63, 0x61, - 0x6c, 0x2e, 0x4d, 0x46, 0x41, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x4d, 0x66, 0x61, 0x43, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x74, 0x73, 0x45, - 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0e, 0x6d, 0x66, 0x61, 0x43, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, - 0x69, 0x6e, 0x74, 0x73, 0x1a, 0x5c, 0x0a, 0x13, 0x4d, 0x66, 0x61, 0x43, 0x6f, 0x6e, 0x73, 0x74, - 0x72, 0x61, 0x69, 0x6e, 0x74, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, - 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2f, 0x0a, - 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6c, - 0x6f, 0x67, 0x69, 0x63, 0x61, 0x6c, 0x2e, 0x4d, 0x46, 0x41, 0x43, 0x6f, 0x6e, 0x73, 0x74, 0x72, - 0x61, 0x69, 0x6e, 0x74, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, - 0x38, 0x01, 0x42, 0x28, 0x5a, 0x26, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, - 0x2f, 0x68, 0x61, 0x73, 0x68, 0x69, 0x63, 0x6f, 0x72, 0x70, 0x2f, 0x76, 0x61, 0x75, 0x6c, 0x74, - 0x2f, 0x73, 0x64, 0x6b, 0x2f, 0x6c, 0x6f, 0x67, 0x69, 0x63, 0x61, 0x6c, 0x62, 0x06, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x33, + 0x73, 0x50, 0x61, 0x73, 0x73, 0x63, 0x6f, 0x64, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, + 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x3a, 0x0a, + 0x10, 0x4d, 0x46, 0x41, 0x43, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x74, 0x41, 0x6e, + 0x79, 0x12, 0x26, 0x0a, 0x03, 0x61, 0x6e, 0x79, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, + 0x2e, 0x6c, 0x6f, 0x67, 0x69, 0x63, 0x61, 0x6c, 0x2e, 0x4d, 0x46, 0x41, 0x4d, 0x65, 0x74, 0x68, + 0x6f, 0x64, 0x49, 0x44, 0x52, 0x03, 0x61, 0x6e, 0x79, 0x22, 0xea, 0x01, 0x0a, 0x0e, 0x4d, 0x46, + 0x41, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x24, 0x0a, 0x0e, + 0x6d, 0x66, 0x61, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6d, 0x66, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x49, 0x64, 0x12, 0x54, 0x0a, 0x0f, 0x6d, 0x66, 0x61, 0x5f, 0x63, 0x6f, 0x6e, 0x73, 0x74, 0x72, + 0x61, 0x69, 0x6e, 0x74, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x6c, 0x6f, + 0x67, 0x69, 0x63, 0x61, 0x6c, 0x2e, 0x4d, 0x46, 0x41, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x66, 0x61, 0x43, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, + 0x6e, 0x74, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0e, 0x6d, 0x66, 0x61, 0x43, 0x6f, 0x6e, + 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x74, 0x73, 0x1a, 0x5c, 0x0a, 0x13, 0x4d, 0x66, 0x61, 0x43, + 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x74, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, + 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, + 0x79, 0x12, 0x2f, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x19, 0x2e, 0x6c, 0x6f, 0x67, 0x69, 0x63, 0x61, 0x6c, 0x2e, 0x4d, 0x46, 0x41, 0x43, 0x6f, + 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x74, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x76, 0x61, 0x6c, + 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x28, 0x5a, 0x26, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, + 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x68, 0x61, 0x73, 0x68, 0x69, 0x63, 0x6f, 0x72, 0x70, 0x2f, 0x76, + 0x61, 0x75, 0x6c, 0x74, 0x2f, 0x73, 0x64, 0x6b, 0x2f, 0x6c, 0x6f, 0x67, 0x69, 0x63, 0x61, 0x6c, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/sdk/logical/identity.proto b/sdk/logical/identity.proto index ea2e373b1..743f85e98 100644 --- a/sdk/logical/identity.proto +++ b/sdk/logical/identity.proto @@ -79,6 +79,7 @@ message MFAMethodID { string type = 1; string id = 2; bool uses_passcode = 3; + string name = 4; } message MFAConstraintAny { diff --git a/vault/external_tests/identity/login_mfa_totp_test.go b/vault/external_tests/identity/login_mfa_totp_test.go index c2a2bd884..bab680074 100644 --- a/vault/external_tests/identity/login_mfa_totp_test.go +++ b/vault/external_tests/identity/login_mfa_totp_test.go @@ -93,16 +93,17 @@ func TestLoginMfaGenerateTOTPTestAuditIncluded(t *testing.T) { // Creating two users in the userpass auth mount userClient1, entityID1, _ := testhelpers.CreateEntityAndAlias(t, client, mountAccessor, entity1, testuser1) userClient2, entityID2, _ := testhelpers.CreateEntityAndAlias(t, client, mountAccessor, entity2, testuser2) - + waitPeriod := 5 totpConfig := map[string]interface{}{ "issuer": "yCorp", - "period": 5, + "period": waitPeriod, "algorithm": "SHA1", "digits": 6, "skew": 1, "key_size": 10, "qr_size": 100, "max_validation_attempts": 3, + "method_name": "foo", } methodID := testhelpers.SetupTOTPMethod(t, client, totpConfig) @@ -123,40 +124,58 @@ func TestLoginMfaGenerateTOTPTestAuditIncluded(t *testing.T) { userpassPath := fmt.Sprintf("auth/userpass/login/%s", testuser1) // MFA single-phase login - time.Sleep(5 * time.Second) - var secret *api.Secret - testhelpers.RetryUntil(t, 20*time.Second, func() error { - var err error - totpPasscode := testhelpers.GetTOTPCodeFromEngine(t, client, enginePath1) + verifyLoginRequest := func(secret *api.Secret) { + userpassToken := secret.Auth.ClientToken + userClient1.SetToken(client.Token()) + secret, err := userClient1.Logical().WriteWithContext(context.Background(), "auth/token/lookup", map[string]interface{}{ + "token": userpassToken, + }) + if err != nil { + t.Fatalf("failed to lookup userpass authenticated token: %v", err) + } - userClient1.AddHeader("X-Vault-MFA", fmt.Sprintf("%s:%s", methodID, totpPasscode)) + entityIDCheck := secret.Data["entity_id"].(string) + if entityIDCheck != entityID1 { + t.Fatalf("different entityID assigned") + } + } + + // helper function to clear the MFA request header + clearMFARequestHeaders := func(c *api.Client) { + headers := c.Headers() + headers.Del("X-Vault-MFA") + c.SetHeaders(headers) + } + + var secret *api.Secret + var err error + var methodIdentifier string + + singlePhaseLoginFunc := func() error { + totpPasscode := testhelpers.GetTOTPCodeFromEngine(t, client, enginePath1) + userClient1.AddHeader("X-Vault-MFA", fmt.Sprintf("%s:%s", methodIdentifier, totpPasscode)) + defer clearMFARequestHeaders(userClient1) secret, err = userClient1.Logical().WriteWithContext(context.Background(), userpassPath, map[string]interface{}{ "password": "testpassword", }) if err != nil { - return fmt.Errorf("MFA failed: %w", err) + return fmt.Errorf("MFA failed for identifier %s: %v", methodIdentifier, err) } return nil - }) - - userpassToken := secret.Auth.ClientToken - userClient1.SetToken(client.Token()) - secret, err := userClient1.Logical().WriteWithContext(context.Background(), "auth/token/lookup", map[string]interface{}{ - "token": userpassToken, - }) - if err != nil { - t.Fatalf("failed to lookup userpass authenticated token: %v", err) } - entityIDCheck := secret.Data["entity_id"].(string) - if entityIDCheck != entityID1 { - t.Fatalf("different entityID assigned") - } + // single phase login for both method name and method ID + methodIdentifier = totpConfig["method_name"].(string) + testhelpers.RetryUntilAtCadence(t, 20*time.Second, 100*time.Millisecond, singlePhaseLoginFunc) + verifyLoginRequest(secret) + + methodIdentifier = methodID + // Need to wait a bit longer to avoid hitting maximum allowed consecutive + // failed TOTP validation + testhelpers.RetryUntilAtCadence(t, 20*time.Second, time.Duration(waitPeriod)*time.Second, singlePhaseLoginFunc) + verifyLoginRequest(secret) // Two-phase login - headers := userClient1.Headers() - headers.Del("X-Vault-MFA") - userClient1.SetHeaders(headers) secret, err = userClient1.Logical().WriteWithContext(context.Background(), userpassPath, map[string]interface{}{ "password": "testpassword", }) @@ -191,26 +210,43 @@ func TestLoginMfaGenerateTOTPTestAuditIncluded(t *testing.T) { } // validation - time.Sleep(5 * time.Second) + var mfaReqID string var totpPasscode1 string - testhelpers.RetryUntil(t, 20*time.Second, func() error { + mfaValidateFunc := func() error { totpPasscode1 = testhelpers.GetTOTPCodeFromEngine(t, client, enginePath1) - secret, err = userClient1.Logical().WriteWithContext(context.Background(), "sys/mfa/validate", map[string]interface{}{ - "mfa_request_id": secret.Auth.MFARequirement.MFARequestID, + "mfa_request_id": mfaReqID, "mfa_payload": map[string][]string{ - methodID: {totpPasscode1}, + methodIdentifier: {totpPasscode1}, }, }) if err != nil { - return fmt.Errorf("MFA failed: %w", err) + return fmt.Errorf("MFA failed: %v", err) + } + if secret.Auth == nil || secret.Auth.ClientToken == "" { + t.Fatalf("successful mfa validation did not return a client token") } return nil - }) - if secret.Auth == nil || secret.Auth.ClientToken == "" { - t.Fatalf("successful mfa validation did not return a client token") } + + methodIdentifier = methodID + mfaReqID = secret.Auth.MFARequirement.MFARequestID + testhelpers.RetryUntilAtCadence(t, 20*time.Second, time.Duration(waitPeriod)*time.Second, mfaValidateFunc) + + // two phase login with method name + secret, err = userClient1.Logical().WriteWithContext(context.Background(), userpassPath, map[string]interface{}{ + "password": "testpassword", + }) + if err != nil { + t.Fatalf("MFA failed: %v", err) + } + + methodIdentifier = totpConfig["method_name"].(string) + mfaReqID = secret.Auth.MFARequirement.MFARequestID + testhelpers.RetryUntilAtCadence(t, 20*time.Second, time.Duration(waitPeriod)*time.Second, mfaValidateFunc) + + // checking audit log if noop.Req == nil { t.Fatalf("no request was logged in audit log") } diff --git a/vault/external_tests/mfa/login_mfa_test.go b/vault/external_tests/mfa/login_mfa_test.go index 0ae821f10..10b917d1b 100644 --- a/vault/external_tests/mfa/login_mfa_test.go +++ b/vault/external_tests/mfa/login_mfa_test.go @@ -13,7 +13,7 @@ import ( "github.com/hashicorp/vault/vault" ) -// TestLoginMFA_Method_CRUD tests creating/reading/updating/deleting a method config for all of the MFA providers +// TestLoginMFA_Method_CRUD tests creating/reading/updating/deleting a method config for all the MFA providers func TestLoginMFA_Method_CRUD(t *testing.T) { cluster := vault.NewTestCluster(t, &vault.CoreConfig{ CredentialBackends: map[string]logical.Factory{ @@ -216,6 +216,126 @@ func TestLoginMFA_Method_CRUD(t *testing.T) { } } +func TestLoginMFAMethodName(t *testing.T) { + cluster := vault.NewTestCluster(t, &vault.CoreConfig{ + CredentialBackends: map[string]logical.Factory{ + "userpass": userpass.Factory, + }, + }, &vault.TestClusterOptions{ + HandlerFunc: vaulthttp.Handler, + }) + cluster.Start() + defer cluster.Cleanup() + + core := cluster.Cores[0].Core + vault.TestWaitActive(t, core) + client := cluster.Cores[0].Client + + // Enable userpass authentication + err := client.Sys().EnableAuthWithOptions("userpass", &api.EnableAuthOptions{ + Type: "userpass", + }) + if err != nil { + t.Fatalf("failed to enable userpass auth: %v", err) + } + + auths, err := client.Sys().ListAuth() + if err != nil { + t.Fatal(err) + } + mountAccessor := auths["userpass/"].Accessor + + testCases := []struct { + methodType string + configData map[string]interface{} + }{ + { + "totp", + map[string]interface{}{ + "issuer": "yCorp", + "method_name": "totp-method", + }, + }, + { + "duo", + map[string]interface{}{ + "mount_accessor": mountAccessor, + "secret_key": "lol-secret", + "integration_key": "integration-key", + "api_hostname": "some-hostname", + "method_name": "duo-method", + }, + }, + { + "okta", + map[string]interface{}{ + "mount_accessor": mountAccessor, + "base_url": "example.com", + "org_name": "my-org", + "api_token": "lol-token", + "method_name": "okta-method", + }, + }, + { + "pingid", + map[string]interface{}{ + "mount_accessor": mountAccessor, + "settings_file_base64": "I0F1dG8tR2VuZXJhdGVkIGZyb20gUGluZ09uZSwgZG93bmxvYWRlZCBieSBpZD1bU1NPXSBlbWFpbD1baGFtaWRAaGFzaGljb3JwLmNvbV0KI1dlZCBEZWMgMTUgMTM6MDg6NDQgTVNUIDIwMjEKdXNlX2Jhc2U2NF9rZXk9YlhrdGMyVmpjbVYwTFd0bGVRPT0KdXNlX3NpZ25hdHVyZT10cnVlCnRva2VuPWxvbC10b2tlbgppZHBfdXJsPWh0dHBzOi8vaWRweG55bDNtLnBpbmdpZGVudGl0eS5jb20vcGluZ2lkCm9yZ19hbGlhcz1sb2wtb3JnLWFsaWFzCmFkbWluX3VybD1odHRwczovL2lkcHhueWwzbS5waW5naWRlbnRpdHkuY29tL3BpbmdpZAphdXRoZW50aWNhdG9yX3VybD1odHRwczovL2F1dGhlbnRpY2F0b3IucGluZ29uZS5jb20vcGluZ2lkL3BwbQ==", + "method_name": "pingid-method", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.methodType, func(t *testing.T) { + // create a new method config + myPath := fmt.Sprintf("identity/mfa/method/%s", tc.methodType) + resp, err := client.Logical().Write(myPath, tc.configData) + if err != nil { + t.Fatal(err) + } + + methodId := resp.Data["method_id"] + if methodId == "" { + t.Fatal("method id is empty") + } + + // creating an MFA config with the same name should not return a new method ID + resp, err = client.Logical().Write(myPath, tc.configData) + if err != nil { + t.Fatal(err) + } + if methodId != resp.Data["method_id"] { + t.Fatal("trying to create a new MFA config with the same name should not result in a new MFA config") + } + + originalName := tc.configData["method_name"] + + // create a new MFA config name + tc.configData["method_name"] = "newName" + resp, err = client.Logical().Write(myPath, tc.configData) + if err != nil { + t.Fatal(err) + } + + myNewPath := fmt.Sprintf("%s/%s", myPath, methodId) + + // Updating an existing MFA config with another config's name + resp, err = client.Logical().Write(myNewPath, tc.configData) + if err == nil { + t.Fatalf("expected a failure for configuring an MFA method with an existing MFA method name, %v", err) + } + + // Create a method with a / in the name + tc.configData["method_name"] = fmt.Sprintf("ns1/%s", originalName) + _, err = client.Logical().Write(myNewPath, tc.configData) + if err != nil { + t.Fatal(err) + } + }) + } +} + // TestLoginMFA_ListAllMFAConfigs tests listing all configs globally func TestLoginMFA_ListAllMFAConfigsGlobally(t *testing.T) { cluster := vault.NewTestCluster(t, &vault.CoreConfig{ diff --git a/vault/identity_store.go b/vault/identity_store.go index 5dc32e306..363dcae4f 100644 --- a/vault/identity_store.go +++ b/vault/identity_store.go @@ -170,6 +170,10 @@ func mfaPaths(i *IdentityStore) []*framework.Path { { Pattern: "mfa/method/totp" + genericOptionalUUIDRegex("method_id"), Fields: map[string]*framework.FieldSchema{ + "method_name": { + Type: framework.TypeString, + Description: `The unique name identifier for this MFA method.`, + }, "method_id": { Type: framework.TypeString, Description: `The unique identifier for this MFA method.`, @@ -298,6 +302,10 @@ func mfaPaths(i *IdentityStore) []*framework.Path { { Pattern: "mfa/method/okta" + genericOptionalUUIDRegex("method_id"), Fields: map[string]*framework.FieldSchema{ + "method_name": { + Type: framework.TypeString, + Description: `The unique name identifier for this MFA method.`, + }, "method_id": { Type: framework.TypeString, Description: `The unique identifier for this MFA method.`, @@ -354,6 +362,10 @@ func mfaPaths(i *IdentityStore) []*framework.Path { { Pattern: "mfa/method/duo" + genericOptionalUUIDRegex("method_id"), Fields: map[string]*framework.FieldSchema{ + "method_name": { + Type: framework.TypeString, + Description: `The unique name identifier for this MFA method.`, + }, "method_id": { Type: framework.TypeString, Description: `The unique identifier for this MFA method.`, @@ -410,6 +422,10 @@ func mfaPaths(i *IdentityStore) []*framework.Path { { Pattern: "mfa/method/pingid" + genericOptionalUUIDRegex("method_id"), Fields: map[string]*framework.FieldSchema{ + "method_name": { + Type: framework.TypeString, + Description: `The unique name identifier for this MFA method.`, + }, "method_id": { Type: framework.TypeString, Description: `The unique identifier for this MFA method.`, diff --git a/vault/login_mfa.go b/vault/login_mfa.go index 411357da7..c98389225 100644 --- a/vault/login_mfa.go +++ b/vault/login_mfa.go @@ -269,6 +269,7 @@ func (i *IdentityStore) handleMFAMethodUpdateCommon(ctx context.Context, req *lo } methodID := d.Get("method_id").(string) + methodName := d.Get("method_name").(string) b := i.mfaBackend b.mfaLock.Lock() @@ -286,6 +287,23 @@ func (i *IdentityStore) handleMFAMethodUpdateCommon(ctx context.Context, req *lo } } + // check if an MFA method configuration exists with that method name + if methodName != "" { + namedMfaConfig, err := b.MemDBMFAConfigByName(ctx, methodName) + if err != nil { + return nil, err + } + if namedMfaConfig != nil { + if mConfig == nil { + mConfig = namedMfaConfig + } else { + if mConfig.ID != namedMfaConfig.ID { + return nil, fmt.Errorf("a login MFA method configuration with the method name %s already exists", methodName) + } + } + } + } + if mConfig == nil { configID, err := uuid.GenerateUUID() if err != nil { @@ -298,6 +316,11 @@ func (i *IdentityStore) handleMFAMethodUpdateCommon(ctx context.Context, req *lo } } + // Updating the method config name + if methodName != "" { + mConfig.Name = methodName + } + mfaNs, err := i.namespacer.NamespaceByID(ctx, mConfig.NamespaceID) if err != nil { return nil, err @@ -647,6 +670,50 @@ func (b *LoginMFABackend) loginMFAMethodExistenceCheck(eConfig *mfa.MFAEnforceme return aggErr.ErrorOrNil() } +// sanitizeMFACredsWithLoginEnforcementMethodIDs updates the MFACred map +// looping through the matched login enforcement configurations, and +// replacing MFA method names with MFA method IDs +func (b *LoginMFABackend) sanitizeMFACredsWithLoginEnforcementMethodIDs(ctx context.Context, mfaCredsMap logical.MFACreds, mfaMethodIDs []string) (logical.MFACreds, error) { + sanitizedMfaCreds := make(logical.MFACreds, 0) + var multiError *multierror.Error + for _, methodID := range mfaMethodIDs { + val, ok := mfaCredsMap[methodID] + if ok { + sanitizedMfaCreds[methodID] = val + continue + } + mConfig, err := b.MemDBMFAConfigByID(methodID) + if err != nil { + return nil, err + } + // method name in the MFACredsMap should be the method full name, + // i.e., namespacePath+name. This is because, a user in a child + // namespace can reference an MFA method ID in a parent namespace + configNS, err := NamespaceByID(ctx, mConfig.NamespaceID, b.Core) + if err != nil { + return nil, err + } + if configNS != nil { + val, ok = mfaCredsMap[configNS.Path+mConfig.Name] + if ok { + sanitizedMfaCreds[mConfig.ID] = val + } else { + multiError = multierror.Append(multiError, fmt.Errorf("failed to find MFA credentials associated with an MFA method ID %v, method name %v", methodID, configNS.Path+mConfig.Name)) + } + } else { + multiError = multierror.Append(multiError, fmt.Errorf("failed to find the namespace associated with an MFA method ID %v", mConfig.ID)) + } + } + + // we don't need to find every MFA method identifiers in the MFA header + // So, don't return errors if that is the case. + if len(sanitizedMfaCreds) > 0 { + return sanitizedMfaCreds, nil + } + + return sanitizedMfaCreds, multiError +} + func (b *LoginMFABackend) handleMFALoginValidate(ctx context.Context, req *logical.Request, d *framework.FieldData) (retResp *logical.Response, retErr error) { // mfaReqID is the ID of the login request mfaReqID := d.Get("mfa_request_id").(string) @@ -655,13 +722,13 @@ func (b *LoginMFABackend) handleMFALoginValidate(ctx context.Context, req *logic } // a map of methodID to passcode - methodIDToPasscodeInterface := d.Get("mfa_payload") - if methodIDToPasscodeInterface == nil { + mfaPayload := d.Get("mfa_payload") + if mfaPayload == nil { return logical.ErrorResponse("missing mfa payload"), nil } var mfaCreds logical.MFACreds - err := mapstructure.Decode(methodIDToPasscodeInterface, &mfaCreds) + err := mapstructure.Decode(mfaPayload, &mfaCreds) if err != nil { return logical.ErrorResponse("invalid mfa payload"), nil } @@ -1574,11 +1641,19 @@ func parseOktaConfig(mConfig *mfa.Config, d *framework.FieldData) error { } func (c *Core) validateLoginMFA(ctx context.Context, eConfig *mfa.MFAEnforcementConfig, entity *identity.Entity, requestConnRemoteAddr string, mfaCredsMap logical.MFACreds) error { + sanitizedMfaCreds, err := c.loginMFABackend.sanitizeMFACredsWithLoginEnforcementMethodIDs(ctx, mfaCredsMap, eConfig.MFAMethodIDs) + if err != nil { + return fmt.Errorf("failed to sanitize MFA creds, %w", err) + } + if len(sanitizedMfaCreds) == 0 && len(eConfig.MFAMethodIDs) > 0 { + return fmt.Errorf("login MFA validation failed for methodID: %v", eConfig.MFAMethodIDs) + } + var retErr error for _, methodID := range eConfig.MFAMethodIDs { // as configID is the same as methodID, and methodID is unique, we can // use it to retrieve the MFACreds - mfaCreds, ok := mfaCredsMap[methodID] + mfaCreds, ok := sanitizedMfaCreds[methodID] if !ok || mfaCreds == nil { continue } @@ -1634,6 +1709,11 @@ func (c *Core) validateLoginMFAInternal(ctx context.Context, methodID string, en } } + mfaFactors, err := parseMfaFactors(mfaCreds) + if err != nil { + return fmt.Errorf("failed to parse MFA factor, %w", err) + } + switch mConfig.Type { case mfaMethodTypeTOTP: // Get the MFA secret data required to validate the supplied credentials @@ -1645,17 +1725,13 @@ func (c *Core) validateLoginMFAInternal(ctx context.Context, methodID string, en return fmt.Errorf("MFA secret for method name %q not present in entity %q", mConfig.Name, entity.ID) } - if mfaCreds == nil { - return fmt.Errorf("MFA credentials not supplied") - } - - return c.validateTOTP(ctx, mfaCreds, entityMFASecret, mConfig.ID, entity.ID, c.loginMFABackend.usedCodes, mConfig.GetTOTPConfig().MaxValidationAttempts) + return c.validateTOTP(ctx, mfaFactors, entityMFASecret, mConfig.ID, entity.ID, c.loginMFABackend.usedCodes, mConfig.GetTOTPConfig().MaxValidationAttempts) case mfaMethodTypeOkta: return c.validateOkta(ctx, mConfig, finalUsername) case mfaMethodTypeDuo: - return c.validateDuo(ctx, mfaCreds, mConfig, finalUsername, reqConnectionRemoteAddress) + return c.validateDuo(ctx, mfaFactors, mConfig, finalUsername, reqConnectionRemoteAddress) case mfaMethodTypePingID: return c.validatePingID(ctx, mConfig, finalUsername) @@ -1764,23 +1840,52 @@ func formatUsername(format string, alias *identity.Alias, entity *identity.Entit return username } -func (c *Core) validateDuo(ctx context.Context, creds []string, mConfig *mfa.Config, username, reqConnectionRemoteAddr string) error { +type MFAFactor struct { + passcode string +} + +func parseMfaFactors(creds []string) (*MFAFactor, error) { + mfaFactor := &MFAFactor{} + + for _, cred := range creds { + switch { + case cred == "": // for the case of push notification + continue + case strings.HasPrefix(cred, "passcode="): + if mfaFactor.passcode != "" { + return nil, fmt.Errorf("found multiple passcodes for the same MFA method") + } + + splits := strings.SplitN(cred, "=", 2) + if splits[1] == "" { + return nil, fmt.Errorf("invalid passcode") + } + + mfaFactor.passcode = splits[1] + case strings.Contains(cred, "="): + return nil, fmt.Errorf("found an invalid MFA cred: %v", cred) + default: + // a non-empty cred that does not match the above + // means it is a passcode + if mfaFactor.passcode != "" { + return nil, fmt.Errorf("found multiple passcodes for the same MFA method") + } + mfaFactor.passcode = cred + } + } + + return mfaFactor, nil +} + +func (c *Core) validateDuo(ctx context.Context, mfaFactors *MFAFactor, mConfig *mfa.Config, username, reqConnectionRemoteAddr string) error { duoConfig := mConfig.GetDuoConfig() if duoConfig == nil { return fmt.Errorf("failed to get Duo configuration for method %q", mConfig.Name) } - passcode := "" - for _, cred := range creds { - if strings.HasPrefix(cred, "passcode") { - splits := strings.SplitN(cred, "=", 2) - if len(splits) != 2 { - return fmt.Errorf("invalid credential %q", cred) - } - if splits[0] == "passcode" { - passcode = splits[1] - } - } + var passcode string + if mfaFactors != nil { + passcode = mfaFactors.passcode } client := duoapi.NewDuoApi( @@ -2229,21 +2334,18 @@ func (c *Core) validatePingID(ctx context.Context, mConfig *mfa.Config, username return nil } -func (c *Core) validateTOTP(ctx context.Context, creds []string, entityMethodSecret *mfa.Secret, configID, entityID string, usedCodes *cache.Cache, maximumValidationAttempts uint32) error { - if len(creds) == 0 { - return fmt.Errorf("missing TOTP passcode") - } - - if len(creds) > 1 { - return fmt.Errorf("more than one TOTP passcode supplied") +func (c *Core) validateTOTP(ctx context.Context, mfaFactors *MFAFactor, entityMethodSecret *mfa.Secret, configID, entityID string, usedCodes *cache.Cache, maximumValidationAttempts uint32) error { + if mfaFactors.passcode == "" { + return fmt.Errorf("MFA credentials not supplied") } + passcode := mfaFactors.passcode totpSecret := entityMethodSecret.GetTOTPSecret() if totpSecret == nil { return fmt.Errorf("entity does not contain the TOTP secret") } - usedName := fmt.Sprintf("%s_%s", configID, creds[0]) + usedName := fmt.Sprintf("%s_%s", configID, passcode) _, ok := usedCodes.Get(usedName) if ok { @@ -2290,7 +2392,7 @@ func (c *Core) validateTOTP(ctx context.Context, creds []string, entityMethodSec Algorithm: otplib.Algorithm(int(totpSecret.Algorithm)), } - valid, err := totplib.ValidateCustom(creds[0], key, time.Now(), validateOpts) + valid, err := totplib.ValidateCustom(passcode, key, time.Now(), validateOpts) if err != nil && err != otplib.ErrValidateInputInvalidLength { return errwrap.Wrapf("failed to validate TOTP passcode: {{err}}", err) } @@ -2340,6 +2442,21 @@ func loginMFAConfigTableSchema() *memdb.TableSchema { Field: "Type", }, }, + "name": { + Name: "name", + Unique: true, + AllowMissing: true, + Indexer: &memdb.CompoundIndex{ + Indexes: []memdb.Indexer{ + &memdb.StringFieldIndex{ + Field: "NamespaceID", + }, + &memdb.StringFieldIndex{ + Field: "Name", + }, + }, + }, + }, }, } } @@ -2487,6 +2604,47 @@ func (b *LoginMFABackend) MemDBMFAConfigByID(mConfigID string) (*mfa.Config, err return b.MemDBMFAConfigByIDInTxn(txn, mConfigID) } +func (b *LoginMFABackend) MemDBMFAConfigByNameInTxn(ctx context.Context, txn *memdb.Txn, mConfigName string) (*mfa.Config, error) { + if mConfigName == "" { + return nil, fmt.Errorf("missing config name") + } + + if txn == nil { + return nil, fmt.Errorf("txn is nil") + } + + ns, err := namespace.FromContext(ctx) + if err != nil { + return nil, err + } + + mConfigRaw, err := txn.First(b.methodTable, "name", ns.ID, mConfigName) + if err != nil { + return nil, fmt.Errorf("failed to fetch MFA config from memdb using name: %w", err) + } + + if mConfigRaw == nil { + return nil, nil + } + + mConfig, ok := mConfigRaw.(*mfa.Config) + if !ok { + return nil, fmt.Errorf("failed to declare the type of fetched MFA config") + } + + return mConfig.Clone() +} + +func (b *LoginMFABackend) MemDBMFAConfigByName(ctx context.Context, name string) (*mfa.Config, error) { + if name == "" { + return nil, fmt.Errorf("missing config name") + } + + txn := b.db.Txn(false) + + return b.MemDBMFAConfigByNameInTxn(ctx, txn, name) +} + func (b *LoginMFABackend) MemDBMFALoginEnforcementConfigByNameAndNamespace(name, namespaceId string) (*mfa.MFAEnforcementConfig, error) { if name == "" { return nil, fmt.Errorf("missing config name") diff --git a/vault/login_mfa_test.go b/vault/login_mfa_test.go new file mode 100644 index 000000000..823350ecc --- /dev/null +++ b/vault/login_mfa_test.go @@ -0,0 +1,61 @@ +package vault + +import ( + "strings" + "testing" +) + +func TestParseFactors(t *testing.T) { + testcases := []struct { + name string + invalidMFAHeaderVal []string + expectedError string + }{ + { + "two headers with passcode", + []string{"passcode", "foo"}, + "found multiple passcodes for the same MFA method", + }, + { + "single header with passcode=", + []string{"passcode="}, + "invalid passcode", + }, + { + "single invalid header", + []string{"foo="}, + "found an invalid MFA cred", + }, + { + "single header equal char", + []string{"=="}, + "found an invalid MFA cred", + }, + { + "two headers with passcode=", + []string{"passcode=foo", "foo"}, + "found multiple passcodes for the same MFA method", + }, + { + "two headers invalid name", + []string{"passcode=foo", "passcode=bar"}, + "found multiple passcodes for the same MFA method", + }, + { + "two headers, two invalid", + []string{"foo", "bar"}, + "found multiple passcodes for the same MFA method", + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + _, err := parseMfaFactors(tc.invalidMFAHeaderVal) + if err == nil { + t.Fatal("nil error returned") + } + if !strings.Contains(err.Error(), tc.expectedError) { + t.Fatalf("expected %s, got %v", tc.expectedError, err) + } + }) + } +} diff --git a/vault/request_handling.go b/vault/request_handling.go index 3183a0273..ba839ec6e 100644 --- a/vault/request_handling.go +++ b/vault/request_handling.go @@ -2059,6 +2059,7 @@ func (c *Core) buildMfaEnforcementResponse(eConfig *mfa.MFAEnforcementConfig) (* Type: mConfig.Type, ID: methodID, UsesPasscode: mConfig.Type == mfaMethodTypeTOTP || duoUsePasscode, + Name: mConfig.Name, } mfaAny.Any = append(mfaAny.Any, mfaMethod) }