From 7069580caf3f7cadb1826fca5e8f4bb59355a956 Mon Sep 17 00:00:00 2001 From: Fred Cox Date: Wed, 3 Mar 2021 15:50:20 +0200 Subject: [PATCH 1/2] Allow to login to google using the browser --- README.rst | 46 ++++++++++++++++++++---------- aws_google_auth/__init__.py | 21 +++++++++++++- aws_google_auth/configuration.py | 10 +++++++ aws_google_auth/google.py | 19 ++++++++++++ aws_google_auth/login_server.py | 35 +++++++++++++++++++++++ aws_google_auth/redirect_server.py | 25 ++++++++++++++++ aws_google_auth/util.py | 16 +++++++++++ 7 files changed, 156 insertions(+), 16 deletions(-) create mode 100644 aws_google_auth/login_server.py create mode 100755 aws_google_auth/redirect_server.py diff --git a/README.rst b/README.rst index 9c7fb86..268f03f 100644 --- a/README.rst +++ b/README.rst @@ -57,6 +57,21 @@ You can find the ``GOOGLE_IDP_ID``, again from the admin console, via string like ``https://accounts.google.com/o/saml2/idp?idpid=aBcD01AbC`` where the last bit (after the ``=``) is the IDP ID. +Browser Login +~~~~~~~~~~~~~~ + +To enable browser based login, you will need to host the redirect server +somewhere, for example + +.. code:: shell + + gcloud run deploy --image cevoaustralia/aws-google-auth --args=--redirect-server --platform managed + +Then change your SAML settings so the ``ACS URL`` points to the redirect server. + +You will also need to change the Trust Relationship of your IAM Role to allow ``SAML:aud`` +to be the host of your redirect server. + Installation ------------ @@ -114,13 +129,11 @@ Usage .. code:: shell $ aws-google-auth -h - usage: aws-google-auth [-h] [-u USERNAME] [-I IDP_ID] [-S SP_ID] [-R REGION] - [-d DURATION] [-p PROFILE] [-D] [-q] - [--bg-response BG_RESPONSE] - [--saml-assertion SAML_ASSERTION] [--no-cache] - [--print-creds] [--resolve-aliases] - [--save-failure-html] [--save-saml-flow] [-a | -r ROLE_ARN] [-k] - [-l {debug,info,warn}] [-V] + usage: aws-google-auth [-h] [-u USERNAME | -b | --redirect-server] [-I IDP_ID] [-S SP_ID] [-R REGION] + [-d DURATION | --auto-duration] [-p PROFILE] [-A ACCOUNT] [-D] [-q] [--bg-response BG_RESPONSE] + [--saml-assertion SAML_ASSERTION] [--no-cache] [--print-creds] [--resolve-aliases] + [--save-failure-html] [--save-saml-flow] [--port PORT] [-a | -r ROLE_ARN] [-k] + [-l {debug,info,warn}] [-V] Acquire temporary AWS credentials via Google SSO @@ -128,6 +141,8 @@ Usage -h, --help show this help message and exit -u USERNAME, --username USERNAME Google Apps username ($GOOGLE_USERNAME) + -b, --browser Google login in the browser (Requires SAML redirect server) + --redirect-server Run the redirect server on port ($PORT) -I IDP_ID, --idp-id IDP_ID Google SSO IDP identifier ($GOOGLE_IDP_ID) -S SP_ID, --sp-id SP_ID @@ -135,26 +150,27 @@ Usage -R REGION, --region REGION AWS region endpoint ($AWS_DEFAULT_REGION) -d DURATION, --duration DURATION - Credential duration (defaults to value of $DURATION, then - falls back to 43200) + Credential duration in seconds (defaults to value of $DURATION, then falls back to 43200) + --auto-duration Tries to use the longest allowed duration ($AUTO_DURATION) -p PROFILE, --profile PROFILE - AWS profile (defaults to value of $AWS_PROFILE, then - falls back to 'sts') + AWS profile (defaults to value of $AWS_PROFILE, then falls back to 'sts') + -A ACCOUNT, --account ACCOUNT + Filter for specific AWS account. -D, --disable-u2f Disable U2F functionality. -q, --quiet Quiet output --bg-response BG_RESPONSE - Override default bgresponse challenge token ($GOOGLE_BG_RESPONSE). + Override default bgresponse challenge token. --saml-assertion SAML_ASSERTION Base64 encoded SAML assertion to use. --no-cache Do not cache the SAML Assertion. --print-creds Print Credentials. --resolve-aliases Resolve AWS account aliases. - --save-failure-html Write HTML failure responses to file for - troubleshooting. + --save-failure-html Write HTML failure responses to file for troubleshooting. --save-saml-flow Write all GET and PUT requests and HTML responses to/from Google to files for troubleshooting. + --port PORT Port for the redirect server ($PORT) -a, --ask-role Set true to always pick the role -r ROLE_ARN, --role-arn ROLE_ARN - The ARN of the role to assume ($AWS_ROLE_ARN) + The ARN of the role to assume -k, --keyring Use keyring for storing the password. -l {debug,info,warn}, --log {debug,info,warn} Select log level (default: warn) diff --git a/aws_google_auth/__init__.py b/aws_google_auth/__init__.py index 107f974..b3d52bd 100644 --- a/aws_google_auth/__init__.py +++ b/aws_google_auth/__init__.py @@ -24,7 +24,10 @@ def parse_args(args): description="Acquire temporary AWS credentials via Google SSO", ) - parser.add_argument('-u', '--username', help='Google Apps username ($GOOGLE_USERNAME)') + google_group = parser.add_mutually_exclusive_group() + google_group.add_argument('-u', '--username', help='Google Apps username ($GOOGLE_USERNAME)') + google_group.add_argument('-b', '--browser', action='store_true', help='Google login in the browser (Requires SAML redirect server) ($GOOGLE_BROWSER=1)') + google_group.add_argument('--redirect-server', action='store_true', help='Run the redirect server on port ($PORT)') parser.add_argument('-I', '--idp-id', help='Google SSO IDP identifier ($GOOGLE_IDP_ID)') parser.add_argument('-S', '--sp-id', help='Google SSO SP identifier ($GOOGLE_SP_ID)') parser.add_argument('-R', '--region', help='AWS region endpoint ($AWS_DEFAULT_REGION)') @@ -42,6 +45,7 @@ def parse_args(args): parser.add_argument('--resolve-aliases', action='store_true', help='Resolve AWS account aliases.') parser.add_argument('--save-failure-html', action='store_true', help='Write HTML failure responses to file for troubleshooting.') parser.add_argument('--save-saml-flow', action='store_true', help='Write all GET and PUT requests and HTML responses to/from Google to files for troubleshooting.') + parser.add_argument('--port', type=int, help='Port for the redirect server ($PORT)') role_group = parser.add_mutually_exclusive_group() role_group.add_argument('-a', '--ask-role', action='store_true', help='Set true to always pick the role') @@ -160,6 +164,8 @@ def resolve_config(args): os.getenv('RESOLVE_AWS_ALIASES'), config.resolve_aliases) + config.browser = args.browser or os.getenv('GOOGLE_BROWSER') != None + # Username (Option priority = ARGS, ENV_VAR, DEFAULT) config.username = coalesce( args.username, @@ -190,6 +196,11 @@ def resolve_config(args): os.getenv('GOOGLE_BG_RESPONSE'), config.bg_response) + config.port = int(coalesce( + args.port, + os.getenv('PORT'), + config.port)) + return config @@ -197,6 +208,11 @@ def process_auth(args, config): # Set up logging logging.getLogger().setLevel(getattr(logging, args.log_level.upper(), None)) + if args.redirect_server: + from aws_google_auth.redirect_server import start_redirect_server + start_redirect_server(config.port) + return + if config.region is None: config.region = util.Util.get_input("AWS Region: ") logging.debug('%s: region is: %s', __name__, config.region) @@ -211,6 +227,9 @@ def process_auth(args, config): elif args.saml_cache and config.saml_cache: saml_xml = config.saml_cache logging.info('%s: SAML cache found', __name__) + elif config.browser: + google_client = google.Google(config, save_failure=args.save_failure_html, save_flow=args.save_saml_flow) + saml_xml = google_client.do_browser_saml() else: # No cache, continue without. logging.info('%s: SAML cache not found', __name__) diff --git a/aws_google_auth/configuration.py b/aws_google_auth/configuration.py index 41c9c46..d9b341e 100644 --- a/aws_google_auth/configuration.py +++ b/aws_google_auth/configuration.py @@ -39,6 +39,8 @@ def __init__(self, **kwargs): self.quiet = False self.bg_response = None self.account = "" + self.browser = False + self.port = 8000 # For the "~/.aws/config" file, we use the format "[profile testing]" # for the 'testing' profile. The credential file will just be "[testing]" @@ -146,6 +148,9 @@ def raise_if_invalid(self): # account assert (self.account.__class__ is str), "Expected account to be string. Got {}".format(self.account.__class__) + # port + assert (self.port.__class__ is int), "Expected port to be an integer. Got {}.".format(self.port.__class__) + # Write the configuration (and credentials) out to disk. This allows for # regular AWS tooling (aws cli and boto) to use the credentials in the # profile the user specified. @@ -173,6 +178,7 @@ def write(self, amazon_object): config_parser.set(profile, 'google_config.u2f_disabled', self.u2f_disabled) config_parser.set(profile, 'google_config.google_username', self.username) config_parser.set(profile, 'google_config.bg_response', self.bg_response) + config_parser.set(profile, 'google_config.browser', self.browser) with open(self.config_file, 'w+') as f: config_parser.write(f) @@ -271,6 +277,10 @@ def read(self, profile): read_account = unicode_to_string(config_parser[profile_string].get('account', None)) self.account = coalesce(read_account, self.account) + # Browser + read_browser = config_parser[profile_string].getboolean('google_config.browser', None) + self.browser = coalesce(read_browser, self.browser) + # SAML Cache try: with open(self.saml_cache_file, 'r') as f: diff --git a/aws_google_auth/google.py b/aws_google_auth/google.py index 398eff5..a1fdaef 100644 --- a/aws_google_auth/google.py +++ b/aws_google_auth/google.py @@ -9,6 +9,7 @@ import os import re import sys +import webbrowser import requests from PIL import Image @@ -20,6 +21,7 @@ from six.moves import urllib_parse, input from aws_google_auth import _version +from aws_google_auth.login_server import LoginServerHandler, LoginServer # The U2F USB Library is optional, if it's there, include it. try: @@ -59,6 +61,23 @@ def __init__(self, config, save_failure, save_flow=False): self.save_flow_dir = "aws-google-auth-" + datetime.now().strftime('%Y-%m-%dT%H%M%S') os.makedirs(self.save_flow_dir, exist_ok=True) + def do_browser_saml(self): + logging.info('Opening url %s', self.login_url) + webbrowser.open(self.login_url) + saml_text = self._catch_saml() + + return base64.b64decode(saml_text) + + @staticmethod + def _catch_saml(port=4589): + server_address = ('', port) + httpd = LoginServer(server_address, LoginServerHandler) + logging.info('Starting http handler...\n') + httpd.handle_request() + + assert ("SAMLResponse" in httpd.post_data), "Expected post data to contain SAMLResponse." + return httpd.post_data["SAMLResponse"][0] + @property def login_url(self): return self.base_url + "/o/saml2/initsso?idpid={}&spid={}&forceauthn=false".format( diff --git a/aws_google_auth/login_server.py b/aws_google_auth/login_server.py new file mode 100644 index 0000000..8f645f7 --- /dev/null +++ b/aws_google_auth/login_server.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +""" +This HTTP server for capturing the SAMLResponse that is redirected to 127.0.0.1 +""" + +from http.server import BaseHTTPRequestHandler, HTTPServer +import logging + +from aws_google_auth import util + +class LoginServer(HTTPServer): + post_data = {} + + +class LoginServerHandler(BaseHTTPRequestHandler): + def _set_response(self): + self.send_response(200) + self.send_header('content-type', 'text/html') + self.end_headers() + self.wfile.write(""" + + Success + + Check your console + + + + """.encode("utf-8")) + + def do_POST(self): + self.server.post_data = util.Util.parse_post(self) + logging.debug("POST request,\nPath: %s\nHeaders:\n%s\n\nBody:\n%s\n", + str(self.path), str(self.headers), self.server.post_data) + + self._set_response() diff --git a/aws_google_auth/redirect_server.py b/aws_google_auth/redirect_server.py new file mode 100755 index 0000000..438ed5d --- /dev/null +++ b/aws_google_auth/redirect_server.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python +""" +This HTTP server can be run on a server, and redirects the SAMLResponse to 127.0.0.1 so the command can capture it +""" + +from http.server import BaseHTTPRequestHandler, HTTPServer +import logging + +class RedirectServerHandler(BaseHTTPRequestHandler): + def do_POST(self): + self.send_response(307) + self.send_header('location', 'http://127.0.0.1:4589/') + self.end_headers() + +def start_redirect_server(port): + logging.basicConfig(level=logging.INFO) + server_address = ('', port) + httpd = HTTPServer(server_address, RedirectServerHandler) + logging.info('Starting http redirect server on: %s', port) + try: + httpd.serve_forever() + except KeyboardInterrupt: + pass + httpd.server_close() + logging.info('Stopping http redirect server') diff --git a/aws_google_auth/util.py b/aws_google_auth/util.py index 4aacac6..73721e9 100644 --- a/aws_google_auth/util.py +++ b/aws_google_auth/util.py @@ -6,6 +6,8 @@ import os import sys from collections import OrderedDict +from urllib.parse import parse_qs +from cgi import parse_header, parse_multipart from six.moves import input from tabulate import tabulate @@ -100,3 +102,17 @@ def get_password(prompt): password = sys.stdin.readline() print("") return password + + @staticmethod + def parse_post(handler): + if 'content-type' not in handler.headers: + return {} + ctype, pdict = parse_header(handler.headers['content-type']) + if ctype == 'multipart/form-data': + postvars = parse_multipart(handler.rfile, pdict) + elif ctype == 'application/x-www-form-urlencoded': + length = int(handler.headers['content-length']) + postvars = parse_qs(handler.rfile.read(length).decode('utf-8'), keep_blank_values=1) + else: + postvars = {} + return postvars From c14374df83da60f800c7bff3d86018fa0ca6ec2f Mon Sep 17 00:00:00 2001 From: Fred Cox Date: Wed, 3 Mar 2021 16:35:23 +0200 Subject: [PATCH 2/2] Fix boolean flags envvars --- README.rst | 12 ++++++------ aws_google_auth/__init__.py | 27 +++++++-------------------- 2 files changed, 13 insertions(+), 26 deletions(-) diff --git a/README.rst b/README.rst index 268f03f..fb961fe 100644 --- a/README.rst +++ b/README.rst @@ -132,8 +132,8 @@ Usage usage: aws-google-auth [-h] [-u USERNAME | -b | --redirect-server] [-I IDP_ID] [-S SP_ID] [-R REGION] [-d DURATION | --auto-duration] [-p PROFILE] [-A ACCOUNT] [-D] [-q] [--bg-response BG_RESPONSE] [--saml-assertion SAML_ASSERTION] [--no-cache] [--print-creds] [--resolve-aliases] - [--save-failure-html] [--save-saml-flow] [--port PORT] [-a | -r ROLE_ARN] [-k] - [-l {debug,info,warn}] [-V] + [--save-failure-html] [--save-saml-flow] [--port PORT] [-a | -r ROLE_ARN] [-k] [-l {debug,info,warn}] + [-V] Acquire temporary AWS credentials via Google SSO @@ -141,7 +141,7 @@ Usage -h, --help show this help message and exit -u USERNAME, --username USERNAME Google Apps username ($GOOGLE_USERNAME) - -b, --browser Google login in the browser (Requires SAML redirect server) + -b, --browser Google login in the browser (Requires SAML redirect server) ($GOOGLE_BROWSER=1) --redirect-server Run the redirect server on port ($PORT) -I IDP_ID, --idp-id IDP_ID Google SSO IDP identifier ($GOOGLE_IDP_ID) @@ -151,7 +151,7 @@ Usage AWS region endpoint ($AWS_DEFAULT_REGION) -d DURATION, --duration DURATION Credential duration in seconds (defaults to value of $DURATION, then falls back to 43200) - --auto-duration Tries to use the longest allowed duration ($AUTO_DURATION) + --auto-duration Tries to use the longest allowed duration ($AUTO_DURATION=1) -p PROFILE, --profile PROFILE AWS profile (defaults to value of $AWS_PROFILE, then falls back to 'sts') -A ACCOUNT, --account ACCOUNT @@ -164,11 +164,11 @@ Usage Base64 encoded SAML assertion to use. --no-cache Do not cache the SAML Assertion. --print-creds Print Credentials. - --resolve-aliases Resolve AWS account aliases. + --resolve-aliases Resolve AWS account aliases. ($RESOLVE_AWS_ALIASES=1) --save-failure-html Write HTML failure responses to file for troubleshooting. --save-saml-flow Write all GET and PUT requests and HTML responses to/from Google to files for troubleshooting. --port PORT Port for the redirect server ($PORT) - -a, --ask-role Set true to always pick the role + -a, --ask-role Set true to always pick the role ($AWS_ASK_ROLE=1) -r ROLE_ARN, --role-arn ROLE_ARN The ARN of the role to assume -k, --keyring Use keyring for storing the password. diff --git a/aws_google_auth/__init__.py b/aws_google_auth/__init__.py index b3d52bd..3bd8e8c 100644 --- a/aws_google_auth/__init__.py +++ b/aws_google_auth/__init__.py @@ -33,7 +33,7 @@ def parse_args(args): parser.add_argument('-R', '--region', help='AWS region endpoint ($AWS_DEFAULT_REGION)') duration_group = parser.add_mutually_exclusive_group() duration_group.add_argument('-d', '--duration', type=int, help='Credential duration in seconds (defaults to value of $DURATION, then falls back to 43200)') - duration_group.add_argument('--auto-duration', action='store_true', help='Tries to use the longest allowed duration ($AUTO_DURATION)') + duration_group.add_argument('--auto-duration', action='store_true', help='Tries to use the longest allowed duration ($AUTO_DURATION=1)') parser.add_argument('-p', '--profile', help='AWS profile (defaults to value of $AWS_PROFILE, then falls back to \'sts\')') parser.add_argument('-A', '--account', help='Filter for specific AWS account.') parser.add_argument('-D', '--disable-u2f', action='store_true', help='Disable U2F functionality.') @@ -42,13 +42,13 @@ def parse_args(args): parser.add_argument('--saml-assertion', dest="saml_assertion", help='Base64 encoded SAML assertion to use.') parser.add_argument('--no-cache', dest="saml_cache", action='store_false', help='Do not cache the SAML Assertion.') parser.add_argument('--print-creds', action='store_true', help='Print Credentials.') - parser.add_argument('--resolve-aliases', action='store_true', help='Resolve AWS account aliases.') + parser.add_argument('--resolve-aliases', action='store_true', help='Resolve AWS account aliases. ($RESOLVE_AWS_ALIASES=1)') parser.add_argument('--save-failure-html', action='store_true', help='Write HTML failure responses to file for troubleshooting.') parser.add_argument('--save-saml-flow', action='store_true', help='Write all GET and PUT requests and HTML responses to/from Google to files for troubleshooting.') parser.add_argument('--port', type=int, help='Port for the redirect server ($PORT)') role_group = parser.add_mutually_exclusive_group() - role_group.add_argument('-a', '--ask-role', action='store_true', help='Set true to always pick the role') + role_group.add_argument('-a', '--ask-role', action='store_true', help='Set true to always pick the role ($AWS_ASK_ROLE=1)') role_group.add_argument('-r', '--role-arn', help='The ARN of the role to assume') parser.add_argument('-k', '--keyring', action='store_true', help='Use keyring for storing the password.') parser.add_argument('-l', '--log', dest='log_level', choices=['debug', @@ -110,10 +110,7 @@ def resolve_config(args): config.read(config.profile) # Ask Role (Option priority = ARGS, ENV_VAR, DEFAULT) - config.ask_role = bool(coalesce( - args.ask_role, - os.getenv('AWS_ASK_ROLE'), - config.ask_role)) + config.ask_role = args.ask_role or os.getenv('AWS_ASK_ROLE') != None # Duration (Option priority = ARGS, ENV_VAR, DEFAULT) config.duration = int(coalesce( @@ -122,11 +119,7 @@ def resolve_config(args): config.duration)) # Automatic duration (Option priority = ARGS, ENV_VAR, DEFAULT) - config.auto_duration = coalesce( - args.auto_duration, - os.getenv('AUTO_DURATION'), - config.auto_duration - ) + config.auto_duration = args.auto_duration or os.getenv('AUTO_DURATION') != None # IDP ID (Option priority = ARGS, ENV_VAR, DEFAULT) config.idp_id = coalesce( @@ -153,16 +146,10 @@ def resolve_config(args): config.sp_id) # U2F Disabled (Option priority = ARGS, ENV_VAR, DEFAULT) - config.u2f_disabled = coalesce( - args.disable_u2f, - os.getenv('U2F_DISABLED'), - config.u2f_disabled) + config.u2f_disabled = args.disable_u2f or os.getenv('U2F_DISABLED') != None # Resolve AWS aliases enabled (Option priority = ARGS, ENV_VAR, DEFAULT) - config.resolve_aliases = coalesce( - args.resolve_aliases, - os.getenv('RESOLVE_AWS_ALIASES'), - config.resolve_aliases) + config.resolve_aliases = args.resolve_aliases or os.getenv('RESOLVE_AWS_ALIASES') != None config.browser = args.browser or os.getenv('GOOGLE_BROWSER') != None