From 6763a821b076fc14958383b7d86f474716e92d34 Mon Sep 17 00:00:00 2001 From: Mark Wolfe Date: Tue, 31 Oct 2017 21:39:52 +1100 Subject: [PATCH] doc(README) Updated readme and some more testing and fixes * Fix for ADFS3 MFA being required, this is now optional * Lots of small linting issues fixes * More test coverage --- Makefile | 6 +- README.md | 78 +++++----------- aws_account_test.go | 10 +-- cmd/saml2aws/commands/login.go | 132 +++++++++++++++++----------- cmd/saml2aws/commands/login_test.go | 13 ++- pkg/cfg/cfg.go | 17 ++++ pkg/provider/adfs/adfs.go | 2 +- 7 files changed, 139 insertions(+), 119 deletions(-) diff --git a/Makefile b/Makefile index 9d77937f8..20aaf02c9 100644 --- a/Makefile +++ b/Makefile @@ -42,6 +42,10 @@ compile: deps lint: gometalinter --vendor ./... +# gofmt and goimports all go files +fmt: + find . -name '*.go' -not -wholename './vendor/*' | while read -r file; do gofmt -w -s "$$file"; goimports -w "$$file"; done + install: go install ./cmd/saml2aws @@ -64,4 +68,4 @@ clean: rm ./glide rm -fr ./build -.PHONY: default deps compile lint dist release test clean +.PHONY: default deps compile lint fmt dist release test clean diff --git a/README.md b/README.md index 4b55fff5d..b8f512fb7 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ How to Implement a General Solution for Federated API/CLI Access Using SAML 2.0] The process goes something like this: +* Setup an account alias, either using the default or given a name * Prompt user for credentials * Log in to Identity Provider using form based authentication * Build a SAML assertion containing AWS roles @@ -37,11 +38,17 @@ usage: saml2aws [] [ ...] A command line tool to help with SAML access to the AWS token service. Flags: - --help Show context-sensitive help (also try --help-long and --help-man). - -p, --profile="saml" The AWS profile to save the temporary credentials - -s, --skip-verify Skip verification of server certificate. - -i, --provider="ADFS" The type of SAML IDP provider. - --version Show application version. + --help Show context-sensitive help (also try --help-long and --help-man). + --verbose Enable verbose logging + --version Show application version. + -a, --idp-account="default" The name of the configured IDP account + -p, --profile="saml" The AWS profile to save the temporary credentials + -s, --skip-verify Skip verification of server certificate. + --hostname=HOSTNAME The hostname of the SAML IDP server used to login. + --username=USERNAME The username used to login. + --password=PASSWORD The password used to login. + --role=ROLE The ARN of the role to assume. + --skip-prompt Skip prompting for parameters during login. Commands: help [...] @@ -55,16 +62,11 @@ Commands: exec [...] Exec the supplied command with env vars from STS token. -``` -saml2aws will default to using ADFS 3.x as the Identity Provider. To use another provider, use the `--provider` flag: -| IdP | | -| ------------ | ----------------------- | -| ADFS 2.x | `--provider=ADFS2` | -| PingFederate | `--provider=Ping` | -| JumpCloud | `--provider=JumpCloud` | -| Okta | `--provider=Okta` | -| KeyCloak | `--provider=KeyCloak` | + configure + Configure a new IDP account. + +``` # Install @@ -94,12 +96,12 @@ Install the AWS CLI see https://docs.aws.amazon.com/cli/latest/userguide/install brew install awscli ``` -Configure an empty default profile with your region of choice. +Configure an empty default profile with your region of choice, note the credentials will be overwritten when you first login and are supplied to unsure `~/.aws/credentials` file is created. ``` $ aws configure -AWS Access Key ID [None]: -AWS Secret Access Key [None]: +AWS Access Key ID [None]: test +AWS Secret Access Key [None]: test Default region name [None]: us-west-2 Default output format [None]: ``` @@ -112,40 +114,12 @@ Log into a service. ``` $ saml2aws login -Hostname [id.example.com]: -Username [mark.wolfe@example.com]: -Password: ************ - -ADFS https://id.example.com -Authenticating to ADFS... -Please choose the role you would like to assume: -[ 0 ]: arn:aws:iam::123123123123:role/AWS-Admin-CloudOPSBuild -[ 1 ]: arn:aws:iam::123123123123:role/AWS-Admin-CloudOPSNonProd -Selection: 1 -Selected role: arn:aws:iam::123123123123:role/AWS-Admin-CloudOPSNonProd -Requesting AWS credentials using SAML assertion -Saving credentials -Logged in as: arn:aws:sts::123123123123:assumed-role/AWS-Admin-CloudOPSNonProd/wolfeidau@example.com - -Your new access key pair has been stored in the AWS configuration -Note that it will expire at 2016-09-19 15:59:49 +1000 AEST -To use this credential, call the AWS CLI with the --profile option (e.g. aws --profile saml ec2 describe-instances). -``` - -Run ansible with an expired token present, `exec` verifies the token and requests login. - -``` -$ saml2aws exec --skip-verify -- ansible-playbook -e "aws_region=ap-southeast-2" playbook.yml -Hostname [id.example.com]: +Using IDP Account default to access Ping https://id.example.com +To use saved password just hit enter. Username [mark.wolfe@example.com]: Password: ************ -ADFS https://id.example.com -Authenticating to ADFS... -Please choose the role you would like to assume: -[ 0 ]: arn:aws:iam::123123123123:role/AWS-Admin-CloudOPSBuild -[ 1 ]: arn:aws:iam::123123123123:role/AWS-Admin-CloudOPSNonProd -Selection: 1 +Authenticating as mark.wolfe@example.com ... Selected role: arn:aws:iam::123123123123:role/AWS-Admin-CloudOPSNonProd Requesting AWS credentials using SAML assertion Saving credentials @@ -154,14 +128,6 @@ Logged in as: arn:aws:sts::123123123123:assumed-role/AWS-Admin-CloudOPSNonProd/w Your new access key pair has been stored in the AWS configuration Note that it will expire at 2016-09-19 15:59:49 +1000 AEST To use this credential, call the AWS CLI with the --profile option (e.g. aws --profile saml ec2 describe-instances). - -PLAY [create cloudformation stack] ************************************************* - -... - -PLAY RECAP ********************************************************************* -localhost : ok=2 changed=0 unreachable=0 failed=0 - ``` # Building diff --git a/aws_account_test.go b/aws_account_test.go index a45229e79..9ea4ec11c 100644 --- a/aws_account_test.go +++ b/aws_account_test.go @@ -37,16 +37,16 @@ func TestExtractAWSAccounts(t *testing.T) { func TestAssignPrincipals(t *testing.T) { awsRoles := []*AWSRole{ - &AWSRole{ + { PrincipalARN: "arn:aws:iam::000000000001:saml-provider/test-idp", RoleARN: "arn:aws:iam::000000000001:role/Development", }, } awsAccounts := []*AWSAccount{ - &AWSAccount{ + { Roles: []*AWSRole{ - &AWSRole{ + { RoleARN: "arn:aws:iam::000000000001:role/Development", }, }, @@ -60,11 +60,11 @@ func TestAssignPrincipals(t *testing.T) { func TestLocateRole(t *testing.T) { awsRoles := []*AWSRole{ - &AWSRole{ + { PrincipalARN: "arn:aws:iam::000000000001:saml-provider/test-idp", RoleARN: "arn:aws:iam::000000000001:role/Development", }, - &AWSRole{ + { PrincipalARN: "arn:aws:iam::000000000002:saml-provider/test-idp", RoleARN: "arn:aws:iam::000000000002:role/Development", }, diff --git a/cmd/saml2aws/commands/login.go b/cmd/saml2aws/commands/login.go index 39e5aa30b..c5edf1701 100644 --- a/cmd/saml2aws/commands/login.go +++ b/cmd/saml2aws/commands/login.go @@ -16,6 +16,9 @@ import ( "github.com/versent/saml2aws/pkg/creds" ) +// MaxDurationSeconds the maximum duration in seconds for an STS session +const MaxDurationSeconds = 3600 + // LoginFlags login specific command flags type LoginFlags struct { IdpAccount string @@ -38,28 +41,12 @@ func (lf *LoginFlags) RoleSupplied() bool { // Login login to ADFS func Login(loginFlags *LoginFlags) error { - cfgm, err := cfg.NewConfigManager(cfg.DefaultConfigPath) + account, err := buildIdpAccount(loginFlags) if err != nil { - return errors.Wrap(err, "failed to load configuration") + return errors.Wrap(err, "error building login details") } - account, err := cfgm.LoadVerifyIDPAccount(loginFlags.IdpAccount) - if err != nil { - if cfg.IsErrIdpAccountNotFound(err) { - fmt.Printf("%v\n", err) - os.Exit(1) - } - return errors.Wrap(err, "failed to load idp account") - } - - // update username and hostname if supplied - applyFlagOverrides(loginFlags, account) - - loginDetails := &creds.LoginDetails{Hostname: account.Hostname, Username: account.Username} - - fmt.Printf("Using IDP Account %s to access %s https://%s\n", loginFlags.IdpAccount, account.Provider, account.Hostname) - - err = resolveLoginDetails(loginDetails, loginFlags) + loginDetails, err := resolveLoginDetails(account, loginFlags) if err != nil { fmt.Printf("%+v\n", err) os.Exit(1) @@ -67,14 +54,14 @@ func Login(loginFlags *LoginFlags) error { fmt.Printf("Authenticating as %s ...\n", account.Username) - provider, err := saml2aws.NewSAMLClient(account) + err = loginDetails.Validate() if err != nil { - return errors.Wrap(err, "error building IdP client") + return errors.Wrap(err, "error validating login details") } - err = loginDetails.Validate() + provider, err := saml2aws.NewSAMLClient(account) if err != nil { - return errors.Wrap(err, "error validating login details") + return errors.Wrap(err, "error building IdP client") } samlAssertion, err := provider.Authenticate(loginDetails) @@ -122,53 +109,52 @@ func Login(loginFlags *LoginFlags) error { fmt.Println("Selected role:", role.RoleARN) - sess, err := session.NewSession() + err = loginToStsUsingRole(role, samlAssertion, loginFlags.Profile) if err != nil { - return errors.Wrap(err, "failed to create session") + return errors.Wrap(err, "error logging into aws role using saml assertion") } - svc := sts.New(sess) + return nil +} - params := &sts.AssumeRoleWithSAMLInput{ - PrincipalArn: aws.String(role.PrincipalARN), // Required - RoleArn: aws.String(role.RoleARN), // Required - SAMLAssertion: aws.String(samlAssertion), // Required - DurationSeconds: aws.Int64(3600), // 1 hour +func buildIdpAccount(loginFlags *LoginFlags) (*cfg.IDPAccount, error) { + cfgm, err := cfg.NewConfigManager(cfg.DefaultConfigPath) + if err != nil { + return nil, errors.Wrap(err, "failed to load configuration") } - fmt.Println("Requesting AWS credentials using SAML assertion") - - resp, err := svc.AssumeRoleWithSAML(params) + account, err := cfgm.LoadVerifyIDPAccount(loginFlags.IdpAccount) if err != nil { - return errors.Wrap(err, "error retrieving STS credentials using SAML") + if cfg.IsErrIdpAccountNotFound(err) { + fmt.Printf("%v\n", err) + os.Exit(1) + } + return nil, errors.Wrap(err, "failed to load idp account") } - // fmt.Println("Saving credentials") - - sharedCreds := awsconfig.NewSharedCredentials(loginFlags.Profile) + // update username and hostname if supplied + applyFlagOverrides(loginFlags, account) - err = sharedCreds.Save(aws.StringValue(resp.Credentials.AccessKeyId), aws.StringValue(resp.Credentials.SecretAccessKey), aws.StringValue(resp.Credentials.SessionToken)) + err = account.Validate() if err != nil { - return errors.Wrap(err, "error saving credentials") + return nil, errors.Wrap(err, "failed to validate account") } - fmt.Println("Logged in as:", aws.StringValue(resp.AssumedRoleUser.Arn)) - fmt.Println("") - fmt.Println("Your new access key pair has been stored in the AWS configuration") - fmt.Printf("Note that it will expire at %v\n", resp.Credentials.Expiration.Local()) - fmt.Println("To use this credential, call the AWS CLI with the --profile option (e.g. aws --profile", loginFlags.Profile, "ec2 describe-instances).") - - return nil + return account, nil } -func resolveLoginDetails(loginDetails *creds.LoginDetails, loginFlags *LoginFlags) error { +func resolveLoginDetails(account *cfg.IDPAccount, loginFlags *LoginFlags) (*creds.LoginDetails, error) { // fmt.Printf("loginFlags %+v\n", loginFlags) + loginDetails := &creds.LoginDetails{Hostname: account.Hostname, Username: account.Username} + + fmt.Printf("Using IDP Account %s to access %s https://%s\n", loginFlags.IdpAccount, account.Provider, account.Hostname) + err := credentials.LookupCredentials(loginDetails) if err != nil { if !credentials.IsErrCredentialsNotFound(err) { - return errors.Wrap(err, "error loading saved password") + return nil, errors.Wrap(err, "error loading saved password") } } @@ -188,14 +174,15 @@ func resolveLoginDetails(loginDetails *creds.LoginDetails, loginFlags *LoginFlag // if skip prompt was passed just pass back the flag values if loginFlags.SkipPrompt { - return nil + return loginDetails, nil } err = saml2aws.PromptForLoginDetails(loginDetails) if err != nil { - return errors.Wrap(err, "Error occured accepting input") + return nil, errors.Wrap(err, "Error occurred accepting input") } - return nil + + return loginDetails, nil } func resolveRole(awsRoles []*saml2aws.AWSRole, samlAssertion string, loginFlags *LoginFlags) (*saml2aws.AWSRole, error) { @@ -232,6 +219,47 @@ func resolveRole(awsRoles []*saml2aws.AWSRole, samlAssertion string, loginFlags return role, nil } +func loginToStsUsingRole(role *saml2aws.AWSRole, samlAssertion string, profile string) error { + + sess, err := session.NewSession() + if err != nil { + return errors.Wrap(err, "failed to create session") + } + + svc := sts.New(sess) + + params := &sts.AssumeRoleWithSAMLInput{ + PrincipalArn: aws.String(role.PrincipalARN), // Required + RoleArn: aws.String(role.RoleARN), // Required + SAMLAssertion: aws.String(samlAssertion), // Required + DurationSeconds: aws.Int64(MaxDurationSeconds), // 1 hour + } + + fmt.Println("Requesting AWS credentials using SAML assertion") + + resp, err := svc.AssumeRoleWithSAML(params) + if err != nil { + return errors.Wrap(err, "error retrieving STS credentials using SAML") + } + + // fmt.Println("Saving credentials") + + sharedCreds := awsconfig.NewSharedCredentials(profile) + + err = sharedCreds.Save(aws.StringValue(resp.Credentials.AccessKeyId), aws.StringValue(resp.Credentials.SecretAccessKey), aws.StringValue(resp.Credentials.SessionToken)) + if err != nil { + return errors.Wrap(err, "error saving credentials") + } + + fmt.Println("Logged in as:", aws.StringValue(resp.AssumedRoleUser.Arn)) + fmt.Println("") + fmt.Println("Your new access key pair has been stored in the AWS configuration") + fmt.Printf("Note that it will expire at %v\n", resp.Credentials.Expiration.Local()) + fmt.Println("To use this credential, call the AWS CLI with the --profile option (e.g. aws --profile", profile, "ec2 describe-instances).") + + return nil +} + func applyFlagOverrides(loginFlags *LoginFlags, account *cfg.IDPAccount) { if loginFlags.Hostname != "" { account.Hostname = loginFlags.Hostname diff --git a/cmd/saml2aws/commands/login_test.go b/cmd/saml2aws/commands/login_test.go index 72cb30e05..0ab2ea7c2 100644 --- a/cmd/saml2aws/commands/login_test.go +++ b/cmd/saml2aws/commands/login_test.go @@ -5,6 +5,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/versent/saml2aws" + "github.com/versent/saml2aws/pkg/cfg" "github.com/versent/saml2aws/pkg/creds" ) @@ -12,12 +13,16 @@ func TestResolveLoginDetailsWithFlags(t *testing.T) { loginFlags := &LoginFlags{Hostname: "id.example.com", Username: "wolfeidau", Password: "testtestlol", SkipPrompt: true} - loginDetails := &creds.LoginDetails{Hostname: "id.example.com", Username: ""} - - err := resolveLoginDetails(loginDetails, loginFlags) + idpa := &cfg.IDPAccount{ + Hostname: "id.example.com", + MFA: "none", + Provider: "Ping", + Username: "wolfeidau", + } + loginDetails, err := resolveLoginDetails(idpa, loginFlags) assert.Empty(t, err) - assert.Equal(t, loginDetails, &creds.LoginDetails{Username: "wolfeidau", Password: "testtestlol", Hostname: "id.example.com"}) + assert.Equal(t, &creds.LoginDetails{Username: "wolfeidau", Password: "testtestlol", Hostname: "id.example.com"}, loginDetails) } func TestResolveRoleSingleEntry(t *testing.T) { diff --git a/pkg/cfg/cfg.go b/pkg/cfg/cfg.go index a3c2ef41d..75d619e3f 100644 --- a/pkg/cfg/cfg.go +++ b/pkg/cfg/cfg.go @@ -23,6 +23,23 @@ type IDPAccount struct { Timeout int `ini:"timeout"` } +// Validate validate the required / exptected fields are set +func (ia *IDPAccount) Validate() error { + if ia.Hostname == "" { + return errors.New("Hostname empty in idp account") + } + + if ia.Provider == "" { + return errors.New("Provider empty in idp account") + } + + if ia.MFA == "" { + return errors.New("MFA empty in idp account") + } + + return nil +} + // ConfigManager manage the various IDP account settings type ConfigManager struct { configPath string diff --git a/pkg/provider/adfs/adfs.go b/pkg/provider/adfs/adfs.go index 6d2b82c27..54429d2ad 100644 --- a/pkg/provider/adfs/adfs.go +++ b/pkg/provider/adfs/adfs.go @@ -145,7 +145,7 @@ func (ac *Client) vipMFA(authSubmitURL string, res *http.Response) (*http.Respon vipIndex := doc.Find("input#authMethod[value=VIPAuthenticationProviderWindowsAccountName]").Index() if vipIndex == -1 { - return nil, errors.New("unable to find VIP challenge, no MFA found") + return res, nil // if we didn't find the MFA flag then just continue } var token = ac.prompter.RequestSecurityCode("000000")