diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..bd496af --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,33 @@ +version: 2 +jobs: + build: + docker: + - image: golang:1.11-alpine + environment: + - CGO_ENABLED: 0 + + working_directory: /go/src/github.com/springload/lp-aws-saml + + steps: + - run: + name: update and install tool dependencies + command: |- + apk update && apk add --no-cache git openssh-client + + - checkout + + - run: + name: Install go dep + command: go get -u github.com/golang/dep/cmd/dep + + - run: + name: install go deps + command: dep ensure -vendor-only + + - run: + name: build + command: go build + + - run: + name: print help + command: ./lp-aws-saml -h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..015f9b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.envrc +lp-aws-saml +*.log +dist/ +vendor/ diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..0e010b0 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,25 @@ +# .goreleaser.yml +builds: + - binary: lp-aws-saml + goos: + - darwin + - linux + - windows + goarch: + - amd64 +nfpm: + vendor: Springload + homepage: https://springload.co.nz + + maintainer: DevOps team + description: Temporary Credentials for AWS CLI for LastPass SAML login + license: Apache 2.0 + formats: + - deb + - rpm +brew: + name: lp-aws-saml + github: + owner: springload + name: homebrew-tools + folder: Formula diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 0000000..1291b76 --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,309 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + branch = "master" + digest = "1:291beb8f57fbad1919bb8d1187c905898975aa3f36032fa6da8087d9ff92d907" + name = "github.com/antchfx/xmlquery" + packages = ["."] + pruneopts = "UT" + revision = "07935b1c0f2e6f0efa02c98cd70e223d70218955" + +[[projects]] + branch = "master" + digest = "1:607794e3030e79527e11cba224fb6db52d53c602d57879d8771c19c0e256a59d" + name = "github.com/antchfx/xpath" + packages = ["."] + pruneopts = "UT" + revision = "3de91f3991a1af6e495d49c9218318b5544b20e3" + +[[projects]] + digest = "1:029e55f27964c49f1f41ae5c63134bead6363aaa223b2fc8458277dc702dd613" + name = "github.com/aws/aws-sdk-go" + packages = [ + "aws", + "aws/awserr", + "aws/awsutil", + "aws/client", + "aws/client/metadata", + "aws/corehandlers", + "aws/credentials", + "aws/credentials/ec2rolecreds", + "aws/credentials/endpointcreds", + "aws/credentials/stscreds", + "aws/csm", + "aws/defaults", + "aws/ec2metadata", + "aws/endpoints", + "aws/request", + "aws/session", + "aws/signer/v4", + "internal/sdkio", + "internal/sdkrand", + "internal/sdkuri", + "internal/shareddefaults", + "private/protocol", + "private/protocol/query", + "private/protocol/query/queryutil", + "private/protocol/rest", + "private/protocol/xml/xmlutil", + "service/sts", + ] + pruneopts = "UT" + revision = "66832f7f150914a46ffbfc03210f3b9cb0e4c005" + version = "v1.15.57" + +[[projects]] + digest = "1:abeb38ade3f32a92943e5be54f55ed6d6e3b6602761d74b4aab4c9dd45c18abd" + name = "github.com/fsnotify/fsnotify" + packages = ["."] + pruneopts = "UT" + revision = "c2828203cd70a50dcccfb2761f8b1f8ceef9a8e9" + version = "v1.4.7" + +[[projects]] + digest = "1:15e27372d379b45b18ac917b9dafc45c45485239490ece18cca97a12f9591146" + name = "github.com/go-ini/ini" + packages = ["."] + pruneopts = "UT" + revision = "9c8236e659b76e87bf02044d06fde8683008ff3e" + version = "v1.39.0" + +[[projects]] + digest = "1:c0d19ab64b32ce9fe5cf4ddceba78d5bc9807f0016db6b1183599da3dcc24d10" + name = "github.com/hashicorp/hcl" + packages = [ + ".", + "hcl/ast", + "hcl/parser", + "hcl/printer", + "hcl/scanner", + "hcl/strconv", + "hcl/token", + "json/parser", + "json/scanner", + "json/token", + ] + pruneopts = "UT" + revision = "8cb6e5b959231cc1119e43259c4a608f9c51a241" + version = "v1.0.0" + +[[projects]] + digest = "1:870d441fe217b8e689d7949fef6e43efbc787e50f200cb1e70dbca9204a1d6be" + name = "github.com/inconshreveable/mousetrap" + packages = ["."] + pruneopts = "UT" + revision = "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75" + version = "v1.0" + +[[projects]] + digest = "1:e22af8c7518e1eab6f2eab2b7d7558927f816262586cd6ed9f349c97a6c285c4" + name = "github.com/jmespath/go-jmespath" + packages = ["."] + pruneopts = "UT" + revision = "0b12d6b5" + +[[projects]] + branch = "master" + digest = "1:9c37e3c7a2dc5e5ec5748c0955ef9bf48bf2a02e81454164ba431c6fce9cb004" + name = "github.com/juju/go4" + packages = ["lock"] + pruneopts = "UT" + revision = "40d72ab9641a2a8c36a9c46a51e28367115c8e59" + +[[projects]] + digest = "1:c568d7727aa262c32bdf8a3f7db83614f7af0ed661474b24588de635c20024c7" + name = "github.com/magiconair/properties" + packages = ["."] + pruneopts = "UT" + revision = "c2353362d570a7bfa228149c62842019201cfb71" + version = "v1.8.0" + +[[projects]] + digest = "1:53bc4cd4914cd7cd52139990d5170d6dc99067ae31c56530621b18b35fc30318" + name = "github.com/mitchellh/mapstructure" + packages = ["."] + pruneopts = "UT" + revision = "3536a929edddb9a5b34bd6861dc4a9647cb459fe" + version = "v1.1.2" + +[[projects]] + digest = "1:95741de3af260a92cc5c7f3f3061e85273f5a81b5db20d4bd68da74bd521675e" + name = "github.com/pelletier/go-toml" + packages = ["."] + pruneopts = "UT" + revision = "c01d1270ff3e442a8a57cddc1c92dc1138598194" + version = "v1.2.0" + +[[projects]] + digest = "1:6a4a11ba764a56d2758899ec6f3848d24698d48442ebce85ee7a3f63284526cd" + name = "github.com/spf13/afero" + packages = [ + ".", + "mem", + ] + pruneopts = "UT" + revision = "d40851caa0d747393da1ffb28f7f9d8b4eeffebd" + version = "v1.1.2" + +[[projects]] + digest = "1:516e71bed754268937f57d4ecb190e01958452336fa73dbac880894164e91c1f" + name = "github.com/spf13/cast" + packages = ["."] + pruneopts = "UT" + revision = "8965335b8c7107321228e3e3702cab9832751bac" + version = "v1.2.0" + +[[projects]] + digest = "1:645cabccbb4fa8aab25a956cbcbdf6a6845ca736b2c64e197ca7cbb9d210b939" + name = "github.com/spf13/cobra" + packages = ["."] + pruneopts = "UT" + revision = "ef82de70bb3f60c65fb8eebacbb2d122ef517385" + version = "v0.0.3" + +[[projects]] + digest = "1:68ea4e23713989dc20b1bded5d9da2c5f9be14ff9885beef481848edd18c26cb" + name = "github.com/spf13/jwalterweatherman" + packages = ["."] + pruneopts = "UT" + revision = "4a4406e478ca629068e7768fc33f3f044173c0a6" + version = "v1.0.0" + +[[projects]] + digest = "1:c1b1102241e7f645bc8e0c22ae352e8f0dc6484b6cb4d132fa9f24174e0119e2" + name = "github.com/spf13/pflag" + packages = ["."] + pruneopts = "UT" + revision = "298182f68c66c05229eb03ac171abe6e309ee79a" + version = "v1.0.3" + +[[projects]] + digest = "1:214775c11fd26da94a100111a62daa25339198a4f9c57cb4aab352da889f5b93" + name = "github.com/spf13/viper" + packages = ["."] + pruneopts = "UT" + revision = "2c12c60302a5a0e62ee102ca9bc996277c2f64f5" + version = "v1.2.1" + +[[projects]] + branch = "master" + digest = "1:c7c44c95bdb015bd77bf6dde863344fd49f34db76d3beeda0be0878c62c7d4af" + name = "github.com/vinhjaxt/persistent-cookiejar" + packages = ["."] + pruneopts = "UT" + revision = "9ac0896f6195e4f187b7438a0d9174f599534faf" + +[[projects]] + branch = "master" + digest = "1:cb77e5934866333fa0784326a57e64c4da128001c94fbd1d29819d79bd3b1087" + name = "golang.org/x/crypto" + packages = [ + "pbkdf2", + "ssh/terminal", + ] + pruneopts = "UT" + revision = "0c41d7ab0a0ee717d4590a44bcb987dfd9e183eb" + +[[projects]] + branch = "master" + digest = "1:80a7ec454d4d84b077f3d44275236ecd797ea84f65c8562051da7cc39c15c559" + name = "golang.org/x/net" + packages = [ + "html", + "html/atom", + "html/charset", + "idna", + "publicsuffix", + ] + pruneopts = "UT" + revision = "04a2e542c03f1d053ab3e4d6e5abcd4b66e2be8e" + +[[projects]] + branch = "master" + digest = "1:f5aa274a0377f85735edc7fedfb0811d3cbc20af91633797cb359e29c3272271" + name = "golang.org/x/sys" + packages = [ + "unix", + "windows", + ] + pruneopts = "UT" + revision = "fa43e7bc11baaae89f3f902b2b4d832b68234844" + +[[projects]] + digest = "1:436b24586f8fee329e0dd65fd67c817681420cda1d7f934345c13fe78c212a73" + name = "golang.org/x/text" + packages = [ + "collate", + "collate/build", + "encoding", + "encoding/charmap", + "encoding/htmlindex", + "encoding/internal", + "encoding/internal/identifier", + "encoding/japanese", + "encoding/korean", + "encoding/simplifiedchinese", + "encoding/traditionalchinese", + "encoding/unicode", + "internal/colltab", + "internal/gen", + "internal/tag", + "internal/triegen", + "internal/ucd", + "internal/utf8internal", + "language", + "runes", + "secure/bidirule", + "transform", + "unicode/bidi", + "unicode/cldr", + "unicode/norm", + "unicode/rangetable", + ] + pruneopts = "UT" + revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" + version = "v0.3.0" + +[[projects]] + digest = "1:9f0c81ca4b497d3723d0a66495d8a1efe277068b77ef3ad2d6460e480bf09bb3" + name = "gopkg.in/errgo.v1" + packages = ["."] + pruneopts = "UT" + revision = "b20caedf0710d0988e92b5f2d76843ad1f231f2d" + version = "v1.0.0" + +[[projects]] + digest = "1:ff998ce43d38d5498c7eee06edee2f0f698db62aa929cabcfe4879b7bc6d35bb" + name = "gopkg.in/retry.v1" + packages = ["."] + pruneopts = "UT" + revision = "87155f248cf6ea9e38ae7613f9ea1e5bb397ac83" + version = "v1.0.2" + +[[projects]] + digest = "1:342378ac4dcb378a5448dd723f0784ae519383532f5e70ade24132c4c8693202" + name = "gopkg.in/yaml.v2" + packages = ["."] + pruneopts = "UT" + revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183" + version = "v2.2.1" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + input-imports = [ + "github.com/antchfx/xmlquery", + "github.com/aws/aws-sdk-go/aws", + "github.com/aws/aws-sdk-go/aws/session", + "github.com/aws/aws-sdk-go/service/sts", + "github.com/go-ini/ini", + "github.com/spf13/cobra", + "github.com/spf13/viper", + "github.com/vinhjaxt/persistent-cookiejar", + "golang.org/x/crypto/pbkdf2", + "golang.org/x/crypto/ssh/terminal", + "golang.org/x/net/html", + ] + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 0000000..18273e2 --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,40 @@ +# Gopkg.toml +# +# Refer to https://golang.github.io/dep/docs/Gopkg.toml.html +# for detailed Gopkg.toml documentation. + +[[constraint]] + branch = "master" + name = "github.com/antchfx/xmlquery" + +[[constraint]] + name = "github.com/aws/aws-sdk-go" + version = "1.15.57" + +[[constraint]] + name = "github.com/go-ini/ini" + version = "1.39.0" + +[[constraint]] + name = "github.com/spf13/cobra" + version = "0.0.3" + +[[constraint]] + name = "github.com/spf13/viper" + version = "1.2.1" + +[[constraint]] + branch = "master" + name = "github.com/vinhjaxt/persistent-cookiejar" + +[[constraint]] + branch = "master" + name = "golang.org/x/crypto" + +[[constraint]] + branch = "master" + name = "golang.org/x/net" + +[prune] + go-tests = true + unused-packages = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3e63414 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +### LastPass x AWS x SAML = Headache for awscli? + +`lp-aws-saml` allows you to use the `awscli` on your machine when your login to the aws console is via LastPass SAML login only. + +It even supports 2FA and Yubikey OTP for your LastPass login and will store your LastPass session in `~/.aws/lp_cookies` +so you do not have to type the password every time you need new credentials. + +``` +$ lp-aws-saml -h +Get temporary AWS credentials when using LastPass as a SAML login for AWS + +Usage: + lp-aws-saml [flags] + +Flags: + -d, --duration int Duration (in seconds) for AWS credentials to be valid (default 3600) + -h, --help help for lp-aws-saml + -p, --profile_name string AWS profile to set in ~/.aws/credentials (default "default") + -q, --quiet Silence output unless error + -s, --saml_config_id string LastPass saml config ID + -u, --username string LastPass username +``` + +All flags can be specified in a configuration file `~/.aws/lp_config.toml` + +```toml +username = "email@example.com" +saml_config_id = "12345" +``` + +``` +$ lp-aws-saml +Logging in with: email@example.com +Password: +OTP: +A new AWS CLI profile 'default' has been added. +You may now invoke the aws CLI tool as follows: + + aws --profile default [...] + +This token expires in 1 hours. +``` + +You now have a new or updated entry in `~/.aws/credentials` + +```ini +[default] +aws_access_key_id = {YOUR_ACCESS_KEY_ID} +aws_secret_access_key = {YOUR_SECRET_ACCESS_KEY} +aws_session_token = {YOUR_SESSION_TOKEN} +``` + +### Installation + +There are `deb` and `rpm` packages and binaries for those who don't use packages. Just head up to the [releases](https://github.com/springload/lp-aws-saml/releases/latest) page. diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..c9ed910 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,135 @@ +package cmd + +import ( + "fmt" + "net/http" + "os" + "syscall" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/springload/lp-aws-saml/lastpassaws" + "github.com/vinhjaxt/persistent-cookiejar" + "golang.org/x/crypto/ssh/terminal" +) + +var rootCmd = &cobra.Command{ + Use: "lp-aws-saml", + Short: "Temporary Credentials for AWS CLI for LastPass SAML login", + Long: "Get temporary AWS credentials when using LastPass as a SAML login for AWS", + Run: func(cmd *cobra.Command, args []string) { + + quiet := viper.GetBool("quiet") + samlConfigID := viper.GetString("saml_config_id") + + username := viper.GetString("username") + if !quiet { + fmt.Println("Logging in with: ", username) + } + + options := cookiejar.Options{ + Filename: fmt.Sprintf("%s/.aws/lp_cookies", lastpassaws.HomeDir()), + } + jar, _ := cookiejar.New(&options) + session := &http.Client{ + Jar: jar, + } + + // Attempt to use stored cookies + assertion, err := lastpassaws.SamlToken(session, username, samlConfigID) + + if err != nil { + fmt.Print("Password: ") + bytePassword, _ := terminal.ReadPassword(int(syscall.Stdin)) + fmt.Println() + fmt.Print("OTP: ") + byteOtp, _ := terminal.ReadPassword(int(syscall.Stdin)) + fmt.Println() + + password := string(bytePassword) + otp := string(byteOtp) + + err := lastpassaws.Login(session, username, password, otp) + if err != nil { + fmt.Println("Invalid Credentials") + os.Exit(1) + return + } + + jar.Save() + assertion, err = lastpassaws.SamlToken(session, username, samlConfigID) + if err != nil { + fmt.Println(err) + os.Exit(1) + return + } + } + + roles := lastpassaws.SamlRoles(assertion) + if len(roles) == 0 { + fmt.Printf("No roles available for %s!\n", username) + os.Exit(1) + return + } + role := lastpassaws.PromptForRole(roles) + + profileName := viper.GetString("profile_name") + duration := viper.GetInt("duration") + + response, err := lastpassaws.AssumeAWSRole(assertion, role[0], role[1], duration) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + lastpassaws.SetAWSProfile(profileName, response) + + if !quiet { + fmt.Printf("A new AWS CLI profile '%s' has been added.\n", profileName) + fmt.Println("You may now invoke the aws CLI tool as follows:") + fmt.Println() + fmt.Printf(" aws --profile %s [...] \n", profileName) + fmt.Println() + fmt.Printf("This token expires in %.2d hours.\n", (duration / 60 / 60)) + } + }, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +func init() { + cobra.OnInitialize(initConfig) + + // Here you will define your flags and configuration settings. + // Cobra supports persistent flags, which, if defined here, + // will be global for your application. + + rootCmd.PersistentFlags().StringP("username", "u", "", "LastPass username") + rootCmd.PersistentFlags().StringP("saml_config_id", "s", "", "LastPass saml config ID") + rootCmd.PersistentFlags().StringP("profile_name", "p", "default", "AWS profile to set in ~/.aws/credentials") + rootCmd.PersistentFlags().IntP("duration", "d", 3600, "Duration (in seconds) for AWS credentials to be valid") + rootCmd.PersistentFlags().BoolP("quiet", "q", false, "Silence output unless error") + + viper.BindPFlag("username", rootCmd.PersistentFlags().Lookup("username")) + viper.BindPFlag("saml_config_id", rootCmd.PersistentFlags().Lookup("saml_config_id")) + viper.BindPFlag("profile_name", rootCmd.PersistentFlags().Lookup("profile_name")) + viper.BindPFlag("duration", rootCmd.PersistentFlags().Lookup("duration")) + viper.BindPFlag("quiet", rootCmd.PersistentFlags().Lookup("quiet")) +} + +func initConfig() { + viper.SetConfigName("lp_config") + viper.SetConfigType("toml") + viper.AddConfigPath(fmt.Sprintf("%s/.aws/", lastpassaws.HomeDir())) + + if err := viper.ReadInConfig(); err != nil { + fmt.Println("Can't read config:", err) + os.Exit(1) + } +} diff --git a/example_lp_config.toml b/example_lp_config.toml new file mode 100644 index 0000000..51ee585 --- /dev/null +++ b/example_lp_config.toml @@ -0,0 +1,5 @@ +username = "email@example.com" +saml_config_id = "12345" +profile_name = "default" +duration = 7200 +quiet = true \ No newline at end of file diff --git a/lastpassaws/aws.go b/lastpassaws/aws.go new file mode 100644 index 0000000..4b73c0f --- /dev/null +++ b/lastpassaws/aws.go @@ -0,0 +1,50 @@ +package lastpassaws + +import ( + "fmt" + "os" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/sts" + "github.com/go-ini/ini" +) + +// AssumeAWSRole returns a response from assuming a role on AWS STS +// and includes the required credentials +func AssumeAWSRole(assertion, roleArn, principalArn string, duration int) (*sts.AssumeRoleWithSAMLOutput, error) { + + input := sts.AssumeRoleWithSAMLInput{ + RoleArn: aws.String(roleArn), + PrincipalArn: aws.String(principalArn), + SAMLAssertion: aws.String(assertion), + DurationSeconds: aws.Int64(int64(duration)), + } + + sess, err := session.NewSession() + + sts := sts.New(sess) + resp, err := sts.AssumeRoleWithSAML(&input) + if err != nil { + fmt.Println("Error assuming role: ", err) + return nil, err + } + return resp, nil +} + +// SetAWSProfile saves the role credentials into ~/.aws/credentials +func SetAWSProfile(profileName string, response *sts.AssumeRoleWithSAMLOutput) { + filename := fmt.Sprintf("%s/.aws/credentials", HomeDir()) + cfg, err := ini.Load(filename) + if err != nil { + fmt.Printf("Fail to read file: %v", err) + os.Exit(1) + } + + sec := cfg.Section(profileName) + sec.Key("aws_access_key_id").SetValue(aws.StringValue(response.Credentials.AccessKeyId)) + sec.Key("aws_secret_access_key").SetValue(aws.StringValue(response.Credentials.SecretAccessKey)) + sec.Key("aws_session_token").SetValue(aws.StringValue(response.Credentials.SessionToken)) + + cfg.SaveTo(filename) +} diff --git a/lastpassaws/lastpass.go b/lastpassaws/lastpass.go new file mode 100644 index 0000000..ee6952a --- /dev/null +++ b/lastpassaws/lastpass.go @@ -0,0 +1,39 @@ +package lastpassaws + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/url" +) + +// Login will submit the credentials to LastPass to login and create the +// session for future use +func Login(session *http.Client, username, password, otp string) error { + + iterations := iterations(session, username) + + lpLoginPage := lastPassServer + "/login.php" + + params := url.Values{ + "method": {"web"}, + "xml": {"1"}, + "username": {username}, + "hash": {string(makeHash(username, password, iterations))}, + "iterations": {fmt.Sprint(iterations)}, + } + + if otp != "" { + params.Add("otp", otp) + } + + resp, err := session.PostForm(lpLoginPage, params) + if err != nil { + fmt.Print("Err", err) + return err + } + defer resp.Body.Close() + + _, err = ioutil.ReadAll(resp.Body) + return err +} diff --git a/lastpassaws/saml.go b/lastpassaws/saml.go new file mode 100644 index 0000000..fe7a593 --- /dev/null +++ b/lastpassaws/saml.go @@ -0,0 +1,113 @@ +package lastpassaws + +import ( + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/antchfx/xmlquery" + "golang.org/x/net/html" +) + +// SamlToken uses a LastPass login session to get a SAML token for assuming roles +func SamlToken(session *http.Client, username, samlConfigID string) (string, error) { + idpLoginPath := lastPassServer + "/saml/launch/cfg/" + samlConfigID + + resp, err := session.Get(idpLoginPath) + if err != nil { + fmt.Print("Err", err) + return "", err + } + defer resp.Body.Close() + + action, fields := extractForm(resp.Body) + + if action == "" { + // Error with account + return "", errors.New("Not logged into LastPass") + } + + return fields["SAMLResponse"], nil +} + +// SamlRoles returns a list of roles a user can assume +func SamlRoles(assertion string) [][]string { + decoded := decodeBase64(assertion) + path := ".//saml:Attribute[@Name='https://aws.amazon.com/SAML/Attributes/Role']/saml:AttributeValue" + doc, _ := xmlquery.Parse(strings.NewReader(decoded)) + + list := xmlquery.Find(doc, path) + + roles := make([][]string, len(list)) + for i, role := range list { + roles[i] = strings.Split(role.InnerText(), ",") + } + + return roles +} + +// PromptForRole asks the user to choose a role if there are multiple +func PromptForRole(roles [][]string) []string { + if len(roles) == 1 { + return roles[0] + } + + fmt.Println("Select a Role:") + for i, role := range roles { + fmt.Println(" " + fmt.Sprint(i+1) + ") " + role[0]) + } + choice := 0 + for choice < 1 || choice > len(roles)+1 { + fmt.Print("Choice: ") + _, _ = fmt.Scan(&choice) + } + return roles[choice-1] +} + +func extractForm(data io.ReadCloser) (string, map[string]string) { + fields := make(map[string]string) + action := "" + + z := html.NewTokenizer(data) + for { + tt := z.Next() + switch { + case tt == html.ErrorToken: + // fmt.Println("End") + return action, fields + case tt == html.StartTagToken: + t := z.Token() + switch { + case t.Data == "h2": + // fmt.Println("Error getting saml") + return "", nil + case t.Data == "form": + for _, a := range t.Attr { + if a.Key == "action" { + action = a.Val + break + } + } + } + case tt == html.SelfClosingTagToken: + t := z.Token() + switch { + case t.Data == "input": + name := "" + value := "" + for _, a := range t.Attr { + if a.Key == "value" { + value = a.Val + } else if a.Key == "name" { + name = a.Val + } + } + if name != "" && value != "" { + fields[name] = value + } + } + } + } +} diff --git a/lastpassaws/utils.go b/lastpassaws/utils.go new file mode 100644 index 0000000..b2563e8 --- /dev/null +++ b/lastpassaws/utils.go @@ -0,0 +1,83 @@ +package lastpassaws + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "io/ioutil" + "net/http" + "net/url" + "os" + "path/filepath" + "runtime" + "strconv" + + "golang.org/x/crypto/pbkdf2" +) + +const lastPassServer = "https://lastpass.com" + +func iterations(session *http.Client, username string) int { + iterations := 5000 + + lpIterationPage := lastPassServer + "/iterations.php" + + params := url.Values{ + "email": {username}, + } + resp, err := session.PostForm(lpIterationPage, params) + if err == nil { + defer resp.Body.Close() + contents, _ := ioutil.ReadAll(resp.Body) + iterations, _ = strconv.Atoi(string(contents)) + } + + return iterations +} + +func makeKey(username, password string, iterationCount int) []byte { + if iterationCount == 1 { + b := sha256.Sum256([]byte(username + password)) + return b[:] + } + return pbkdf2.Key([]byte(password), []byte(username), iterationCount, 32, sha256.New) +} + +func makeHash(username, password string, iterationCount int) []byte { + key := makeKey(username, password, iterationCount) + if iterationCount == 1 { + b := sha256.Sum256([]byte(string(encodeHex(key)) + password)) + return encodeHex(b[:]) + } + return encodeHex(pbkdf2.Key([]byte(key), []byte(password), 1, 32, sha256.New)) +} + +func encodeHex(b []byte) []byte { + d := make([]byte, len(b)*2) + n := hex.Encode(d, b) + return d[:n] +} + +func decodeHex(b []byte) []byte { + d := make([]byte, len(b)) + n, _ := hex.Decode(d, b) + return d[:n] +} + +func decodeBase64(b string) string { + decoded, _ := base64.StdEncoding.DecodeString(b) + return string(decoded) +} + +func encodeBase64(b string) string { + encoded := base64.StdEncoding.EncodeToString([]byte(b)) + return encoded +} + +// HomeDir returns the user's home directory +func HomeDir() string { + if runtime.GOOS == "windows" { + return filepath.Join(os.Getenv("HOMEDRIVE"), os.Getenv("HOMEPATH")) + } + return os.Getenv("HOME") +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..9822296 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "github.com/springload/lp-aws-saml/cmd" + +func main() { + cmd.Execute() +} \ No newline at end of file