Repository: cevoaustralia/aws-google-auth
Branch: master
Commit: dd42263bbeeb
Files: 37
Total size: 226.8 KB
Directory structure:
gitextract_w3wj90cw/
├── .github/
│ └── workflows/
│ ├── pythonpackage.yml
│ ├── pythonrelease.yml
│ └── rstlint.yml
├── .gitignore
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── Dockerfile
├── Dockerfile.python2
├── LICENSE.txt
├── README.rst
├── aws_google_auth/
│ ├── __init__.py
│ ├── _version.py
│ ├── amazon.py
│ ├── configuration.py
│ ├── google.py
│ ├── tests/
│ │ ├── __init__.py
│ │ ├── google_error.html
│ │ ├── saml-response-expired-before-valid.xml
│ │ ├── saml-response-no-expire.xml
│ │ ├── saml-response-too-late.xml
│ │ ├── saml-response-too-soon.xml
│ │ ├── test_amazon.py
│ │ ├── test_args_parser.py
│ │ ├── test_backwards_compatibility.py
│ │ ├── test_config_parser.py
│ │ ├── test_configuration.py
│ │ ├── test_configuration_persistence.py
│ │ ├── test_google.py
│ │ ├── test_init.py
│ │ ├── test_python_version.py
│ │ ├── test_util.py
│ │ ├── too-many-commas.xml
│ │ └── valid-response.xml
│ ├── u2f.py
│ └── util.py
├── requirements.txt
└── setup.py
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/workflows/pythonpackage.yml
================================================
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
name: Python package
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.6, 3.7, 3.8]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install .
pip install -r requirements.txt
- name: Lint with flake8
run: |
pip install flake8
# stop the build if there are Python syntax errors or undefined names
flake8 --ignore E501,E722
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
env:
COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
run: |
pip install coverage
pip install coveralls
pip install pytest
coverage run --source=aws_google_auth/ --omit=aws_google_auth/tests/* setup.py test
coverage report
coveralls
================================================
FILE: .github/workflows/pythonrelease.yml
================================================
# This workflow will upload a Python Package using Twine when a release is created
# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries
name: Upload Python Package
on:
release:
types: [created]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools wheel twine
- name: Build and publish
env:
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: |
python setup.py sdist bdist_wheel
twine upload dist/*
================================================
FILE: .github/workflows/rstlint.yml
================================================
name: Lint RST
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install Pygments restructuredtext_lint
- name: Lint with rst
run: |
rst-lint README.rst
================================================
FILE: .gitignore
================================================
*.swp
*.egg-info
.eggs/
*.pyc
dist
build/
.idea/
Pipfile
Pipfile.lock
venv/*
================================================
FILE: CODE_OF_CONDUCT.md
================================================
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at opensource@cevo.com.au. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing
Contributions are welcome! The most valuable contributions, in order of preference, are:
1. Pull requests (whether adding a feature, improving an existing feature, or fixing a bug)
1. Opening an issue (bug reports or feature requests)
1. Fork, star, watch, or share this project on your social networks.
## Pull Requests
Pull requests are definitely welcome. In order to be most useful, please try and make sure that:
* the pull request has a clear description of what it's for (new feature, enhancement, or bug fix)
* the code is clean and understandable
* the pull request would merge cleanly
## Issues
Issues are also very welcome! Please try and make sure that:
* bug reports include stack traces, copied and pasted from your terminal
* feature requests include a clear description of _why_ you want that feature, not just what you want
## Thanks!
Thanks for checking out this project. While you're here, have a look at some of the other tools,
bits and pieces we've created under https://github.com/cevoaustralia
================================================
FILE: Dockerfile
================================================
FROM alpine:3.5
RUN apk add --update-cache py3-pip ca-certificates py3-certifi py3-lxml\
python3-dev cython cython-dev libusb-dev build-base \
eudev-dev linux-headers libffi-dev openssl-dev \
jpeg-dev zlib-dev freetype-dev lcms2-dev openjpeg-dev \
tiff-dev tk-dev tcl-dev
COPY setup.py README.rst requirements.txt /build/
RUN pip3 install -r /build/requirements.txt
COPY aws_google_auth /build/aws_google_auth
RUN pip3 install -e /build/[u2f]
ENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
ENTRYPOINT ["aws-google-auth"]
================================================
FILE: Dockerfile.python2
================================================
FROM alpine:3.5
RUN apk add --update-cache py2-pip ca-certificates py2-certifi py2-lxml \
python-dev cython cython-dev libusb-dev build-base \
eudev-dev linux-headers libffi-dev openssl-dev \
jpeg-dev zlib-dev freetype-dev lcms2-dev openjpeg-dev \
tiff-dev tk-dev tcl-dev
ADD . /build/
RUN pip install -e /build/[u2f]
ENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
ENTRYPOINT ["aws-google-auth"]
================================================
FILE: LICENSE.txt
================================================
MIT License
Copyright (c) 2016 Cevo Australia, Pty Ltd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.rst
================================================
aws-google-auth
===============
|github-badge| |docker-badge| |pypi-badge| |coveralls-badge|
.. |github-badge| image:: https://github.com/cevoaustralia/aws-google-auth/workflows/Python%20package/badge.svg
:target: https://github.com/cevoaustralia/aws-google-auth/actions
:alt: GitHub build badge
.. |docker-badge| image:: https://img.shields.io/docker/build/cevoaustralia/aws-google-auth.svg
:target: https://hub.docker.com/r/cevoaustralia/aws-google-auth/
:alt: Docker build status badge
.. |pypi-badge| image:: https://img.shields.io/pypi/v/aws-google-auth.svg
:target: https://pypi.python.org/pypi/aws-google-auth/
:alt: PyPI version badge
.. |coveralls-badge| image:: https://coveralls.io/repos/github/cevoaustralia/aws-google-auth/badge.svg?branch=master
:target: https://coveralls.io/github/cevoaustralia/aws-google-auth?branch=master
This command-line tool allows you to acquire AWS temporary (STS)
credentials using Google Apps as a federated (Single Sign-On, or SSO)
provider.
Setup
-----
You'll first have to set up Google Apps as a SAML identity provider
(IdP) for AWS. There are tasks to be performed on both the Google Apps
and the Amazon sides; these references should help you with those
configurations:
- `How to Set Up Federated Single Sign-On to AWS Using Google
Apps `__
- `Using Google Apps SAML SSO to do one-click login to
AWS `__
If you need a fairly simple way to assign users to roles in AWS
accounts, we have another tool called `Google AWS
Federator `__
that might help you.
Important Data
~~~~~~~~~~~~~~
You will need to know Google's assigned Identity Provider ID, and the ID
that they assign to the SAML service provider.
Once you've set up the SAML SSO relationship between Google and AWS, you
can find the SP ID by drilling into the Google Apps console, under
``Apps > SAML Apps > Settings for AWS SSO`` -- the URL will include a
component that looks like ``...#AppDetails:service=123456789012...`` --
that number is ``GOOGLE_SP_ID``
You can find the ``GOOGLE_IDP_ID``, again from the admin console, via
``Security > Set up single sign-on (SSO)`` -- the ``SSO URL`` includes a
string like ``https://accounts.google.com/o/saml2/idp?idpid=aBcD01AbC``
where the last bit (after the ``=``) is the IDP ID.
Installation
------------
You can install quite easily via ``pip``, if you want to have it on your
local system:
.. code:: shell
# For basic installation
localhost$ sudo pip install aws-google-auth
# For installation with U2F support
localhost$ sudo pip install aws-google-auth[u2f]
*Note* If using ZSH you will need to quote the install, as below:
.. code:: shell
localhost$ sudo pip install "aws-google-auth[u2f]"
If you don't want to have the tool installed on your local system, or if
you prefer to isolate changes, there is a Dockerfile provided, which you
can build with:
.. code:: shell
# Perform local build
localhost$ cd ..../aws-google-auth && docker build -t aws-google-auth .
# Use the Docker Hub version
localhost$ docker pull cevoaustralia/aws-google-auth
Development
-----------
If you want to develop the AWS-Google-Auth tool itself, we thank you! In order
to help you get rolling, you'll want to install locally with pip. Of course,
you can use your own regular workflow, with tools like `virtualenv `__.
.. code:: shell
# Install (without U2F support)
pip install -e .
# Install (with U2F support)
pip install -e .[u2f]
We welcome you to review our `code of conduct `__ and
`contributing `__ documents.
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]
Acquire temporary AWS credentials via Google SSO
optional arguments:
-h, --help show this help message and exit
-u USERNAME, --username USERNAME
Google Apps username ($GOOGLE_USERNAME)
-I IDP_ID, --idp-id IDP_ID
Google SSO IDP identifier ($GOOGLE_IDP_ID)
-S SP_ID, --sp-id SP_ID
Google SSO SP identifier ($GOOGLE_SP_ID)
-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)
-p PROFILE, --profile PROFILE
AWS profile (defaults to value of $AWS_PROFILE, then
falls back to 'sts')
-D, --disable-u2f Disable U2F functionality.
-q, --quiet Quiet output
--bg-response BG_RESPONSE
Override default bgresponse challenge token ($GOOGLE_BG_RESPONSE).
--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-saml-flow Write all GET and PUT requests and HTML responses to/from Google to files for troubleshooting.
-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)
-k, --keyring Use keyring for storing the password.
-l {debug,info,warn}, --log {debug,info,warn}
Select log level (default: warn)
-V, --version show program's version number and exit
**Note** If you want a longer session than the AWS default 3600 seconds (1 hour)
duration, you must also modify the IAM Role to permit this. See
`the AWS documentation `__
for more information.
Native Python
~~~~~~~~~~~~~
1. Execute ``aws-google-auth``
2. You will be prompted to supply each parameter
*Note* You can skip prompts by either passing parameters to the command, or setting the specified Environment variables.
Via Docker
~~~~~~~~~~~~~
1. Set environment variables for anything listed in Usage with ``($VARIABLE)`` after command line option:
``GOOGLE_USERNAME``, ``GOOGLE_IDP_ID``, and ``GOOGLE_SP_ID``
(see above under "Important Data" for how to find the last two; the first one is usually your email address)
``AWS_PROFILE``: Optional profile name you want the credentials set for (default is 'sts')
``ROLE_ARN``: Optional ARN of the role to assume
2. For Docker:
``docker run -it -e GOOGLE_USERNAME -e GOOGLE_IDP_ID -e GOOGLE_SP_ID -e AWS_PROFILE -e ROLE_ARN -v ~/.aws:/root/.aws cevoaustralia/aws-google-auth``
You'll be prompted for your password. If you've set up an MFA token for
your Google account, you'll also be prompted for the current token
value.
If you have a U2F security key added to your Google account, you won't
be able to use this via Docker; the Docker container will not be able to
access any devices connected to the host ports. You will likely see the
following error during runtime: "RuntimeWarning: U2F Device Not Found".
If you have more than one role available to you (and you haven't set up ROLE_ARN),
you'll be prompted to choose the role from a list.
Feeding password from stdin
~~~~~~~~~~~~~~~~~~~~~~~~~~~
To enhance usability when using third party tools for managing passwords (aka password manager) you can feed data in
``aws-google-auth`` from ``stdin``.
When receiving data from ``stdin`` ``aws-google-auth`` disables the interactive prompt and uses ``stdin`` data.
Before `#82 `_, all interactive prompts could be fed from ``stdin`` already apart from the ``Google Password:`` prompt.
Example usage:
```
$ password-manager show password | aws-google-auth
Google Password: MFA token:
Assuming arn:aws:iam::123456789012:role/admin
Credentials Expiration: ...
```
**Note:** this feature is intended for password manager integration, not for passing passwords from command line.
Please use interactive prompt if you need to pass the password manually, as this provide enhanced security avoid
password leakage to shell history.
Storage of profile credentials
------------------------------
Through the use of AWS profiles, using the ``-p`` or ``--profile`` flag, the ``aws-google-auth`` utility will store the supplied username, IDP and SP details in your ``./aws/config`` files.
When re-authenticating using the same profile, the values will be remembered to speed up the re-authentication process.
This enables an approach that enables you to enter your username, IPD and SP values once and then after only need to re-enter your password (and MFA if enabled).
Creating an alias as below can be a quick and easy way to re-authenticate with a simple command shortcut.
```
alias aws-development='unset AWS_PROFILE; aws-google-auth -I $GOOGLE_IDP_ID -S $GOOGLE_SP_ID -u $USERNAME -p aws-dev ; export AWS_PROFILE=aws-dev'
```
Or, if you've alredy established a profile with valid cached values:
```
alias aws-development='unset AWS_PROFILE; aws-google-auth -p aws-dev ; export AWS_PROFILE=aws-dev'
```
Notes on Authentication
-----------------------
Google supports a number of 2-factor authentication schemes. Each of these
results in a slightly different "next" URL, if they're enabled, during ``do_login``
Google controls the preference ordering of these schemes in the case that
you have multiple ones defined.
The varying 2-factor schemes and their representative URL fragments handled
by this tool are:
+------------------+-------------------------------------+
| Method | URL Fragment |
+==================+=====================================+
| No second factor | (none) |
+------------------+-------------------------------------+
| TOTP (eg Google | ``.../signin/challenge/totp/...`` |
| Authenticator | |
| or Authy) | |
+------------------+-------------------------------------+
| SMS (or voice | ``.../signin/challenge/ipp/...`` |
| call) | |
+------------------+-------------------------------------+
| SMS (or voice | ``.../signin/challenge/iap/...`` |
| call) with | |
| number | |
| submission | |
+------------------+-------------------------------------+
| Google Prompt | ``.../signin/challenge/az/...`` |
| (phone app) | |
+------------------+-------------------------------------+
| Security key | ``.../signin/challenge/sk/...`` |
| (eg yubikey) | |
+------------------+-------------------------------------+
| Dual prompt | ``.../signin/challenge/dp/...`` |
| (Validate 2FA ) | |
+------------------+-------------------------------------+
| Backup code | ``... (unknown yet) ...`` |
| (printed codes) | |
+------------------+-------------------------------------+
Acknowledgments
----------------
This work is inspired by `keyme `__
-- their digging into the guts of how Google SAML auth works is what's
enabled it.
The attribute management and credential injection into AWS configuration files
was heavily borrowed from `aws-adfs `
================================================
FILE: aws_google_auth/__init__.py
================================================
#!/usr/bin/env python
from __future__ import print_function
import argparse
import base64
import os
import sys
import logging
import keyring
from six import print_ as print
from tzlocal import get_localzone
from aws_google_auth import _version
from aws_google_auth import amazon
from aws_google_auth import configuration
from aws_google_auth import google
from aws_google_auth import util
def parse_args(args):
parser = argparse.ArgumentParser(
prog="aws-google-auth",
description="Acquire temporary AWS credentials via Google SSO",
)
parser.add_argument('-u', '--username', help='Google Apps username ($GOOGLE_USERNAME)')
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)')
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)')
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.')
parser.add_argument('-q', '--quiet', action='store_true', help='Quiet output')
parser.add_argument('--bg-response', help='Override default bgresponse challenge token.')
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('--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.')
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('-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',
'info', 'warn'], default='warn', help='Select log level (default: %(default)s)')
parser.add_argument('-V', '--version', action='version',
version='%(prog)s {version}'.format(version=_version.__version__))
return parser.parse_args(args)
def exit_if_unsupported_python():
if sys.version_info.major == 2 and sys.version_info.minor < 7:
logging.critical("%s requires Python 2.7 or higher. Please consider "
"upgrading. Support for Python 2.6 and lower was "
"dropped because this tool's dependencies dropped "
"support.", __name__)
logging.critical("For debugging, it appears you're running: %s",
sys.version_info)
logging.critical("For more information, see: "
"https://github.com/cevoaustralia/aws-google-auth/"
"issues/41")
sys.exit(1)
def cli(cli_args):
try:
exit_if_unsupported_python()
args = parse_args(args=cli_args)
config = resolve_config(args)
process_auth(args, config)
except google.ExpectedGoogleException as ex:
print(ex)
sys.exit(1)
except KeyboardInterrupt:
pass
except Exception as ex:
logging.exception(ex)
def resolve_config(args):
# Shortening Convenience functions
coalesce = util.Util.coalesce
# Create a blank configuration object (has the defaults pre-filled)
config = configuration.Configuration()
# Have the configuration update itself via the ~/.aws/config on disk.
# Profile (Option priority = ARGS, ENV_VAR, DEFAULT)
config.profile = coalesce(
args.profile,
os.getenv('AWS_PROFILE'),
config.profile)
# Now that we've established the profile, we can read the configuration and
# fill in all the other variables.
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))
# Duration (Option priority = ARGS, ENV_VAR, DEFAULT)
config.duration = int(coalesce(
args.duration,
os.getenv('DURATION'),
config.duration))
# Automatic duration (Option priority = ARGS, ENV_VAR, DEFAULT)
config.auto_duration = coalesce(
args.auto_duration,
os.getenv('AUTO_DURATION'),
config.auto_duration
)
# IDP ID (Option priority = ARGS, ENV_VAR, DEFAULT)
config.idp_id = coalesce(
args.idp_id,
os.getenv('GOOGLE_IDP_ID'),
config.idp_id)
# Region (Option priority = ARGS, ENV_VAR, DEFAULT)
config.region = coalesce(
args.region,
os.getenv('AWS_DEFAULT_REGION'),
config.region)
# ROLE ARN (Option priority = ARGS, ENV_VAR, DEFAULT)
config.role_arn = coalesce(
args.role_arn,
os.getenv('AWS_ROLE_ARN'),
config.role_arn)
# SP ID (Option priority = ARGS, ENV_VAR, DEFAULT)
config.sp_id = coalesce(
args.sp_id,
os.getenv('GOOGLE_SP_ID'),
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)
# 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)
# Username (Option priority = ARGS, ENV_VAR, DEFAULT)
config.username = coalesce(
args.username,
os.getenv('GOOGLE_USERNAME'),
config.username)
# Account (Option priority = ARGS, ENV_VAR, DEFAULT)
config.account = coalesce(
args.account,
os.getenv('AWS_ACCOUNT'),
config.account)
config.keyring = coalesce(
args.keyring,
config.keyring)
config.print_creds = coalesce(
args.print_creds,
config.print_creds)
# Quiet
config.quiet = coalesce(
args.quiet,
config.quiet)
config.bg_response = coalesce(
args.bg_response,
os.getenv('GOOGLE_BG_RESPONSE'),
config.bg_response)
return config
def process_auth(args, config):
# Set up logging
logging.getLogger().setLevel(getattr(logging, args.log_level.upper(), None))
if config.region is None:
config.region = util.Util.get_input("AWS Region: ")
logging.debug('%s: region is: %s', __name__, config.region)
# If there is a valid cache and the user opted to use it, use that instead
# of prompting the user for input (it will also ignroe any set variables
# such as username or sp_id and idp_id, as those are built into the SAML
# response). The user does not need to be prompted for a password if the
# SAML cache is used.
if args.saml_assertion:
saml_xml = base64.b64decode(args.saml_assertion)
elif args.saml_cache and config.saml_cache:
saml_xml = config.saml_cache
logging.info('%s: SAML cache found', __name__)
else:
# No cache, continue without.
logging.info('%s: SAML cache not found', __name__)
if config.username is None:
config.username = util.Util.get_input("Google username: ")
logging.debug('%s: username is: %s', __name__, config.username)
if config.idp_id is None:
config.idp_id = util.Util.get_input("Google IDP ID: ")
logging.debug('%s: idp is: %s', __name__, config.idp_id)
if config.sp_id is None:
config.sp_id = util.Util.get_input("Google SP ID: ")
logging.debug('%s: sp is: %s', __name__, config.sp_id)
# There is no way (intentional) to pass in the password via the command
# line nor environment variables. This prevents password leakage.
keyring_password = None
if config.keyring:
keyring_password = keyring.get_password("aws-google-auth", config.username)
if keyring_password:
config.password = keyring_password
else:
config.password = util.Util.get_password("Google Password: ")
else:
config.password = util.Util.get_password("Google Password: ")
# Validate Options
config.raise_if_invalid()
google_client = google.Google(config, save_failure=args.save_failure_html, save_flow=args.save_saml_flow)
google_client.do_login()
saml_xml = google_client.parse_saml()
logging.debug('%s: saml assertion is: %s', __name__, saml_xml)
# If we logged in correctly and we are using keyring then store the password
if config.keyring and keyring_password is None:
keyring.set_password(
"aws-google-auth", config.username, config.password)
# We now have a new SAML value that can get cached (If the user asked
# for it to be)
if args.saml_cache:
config.saml_cache = saml_xml
# The amazon_client now has the SAML assertion it needed (Either via the
# cache or freshly generated). From here, we can get the roles and continue
# the rest of the workflow regardless of cache.
amazon_client = amazon.Amazon(config, saml_xml)
roles = amazon_client.roles
# Determine the provider and the role arn (if the the user provided isn't an option)
if config.role_arn in roles and not config.ask_role:
config.provider = roles[config.role_arn]
else:
if config.account and config.resolve_aliases:
aliases = amazon_client.resolve_aws_aliases(roles)
config.role_arn, config.provider = util.Util.pick_a_role(roles, aliases, config.account)
elif config.account:
config.role_arn, config.provider = util.Util.pick_a_role(roles, account=config.account)
elif config.resolve_aliases:
aliases = amazon_client.resolve_aws_aliases(roles)
config.role_arn, config.provider = util.Util.pick_a_role(roles, aliases)
else:
config.role_arn, config.provider = util.Util.pick_a_role(roles)
if not config.quiet:
print("Assuming " + config.role_arn)
print("Credentials Expiration: " + format(amazon_client.expiration.astimezone(get_localzone())))
if config.print_creds:
amazon_client.print_export_line()
if config.profile:
config.write(amazon_client)
def main():
cli_args = sys.argv[1:]
cli(cli_args)
if __name__ == '__main__':
main()
================================================
FILE: aws_google_auth/_version.py
================================================
__version__ = "0.0.38"
================================================
FILE: aws_google_auth/amazon.py
================================================
#!/usr/bin/env python
import base64
import boto3
import os
import re
from datetime import datetime
from threading import Thread
from botocore.exceptions import ClientError, ProfileNotFound
from lxml import etree
from aws_google_auth.google import ExpectedGoogleException
class Amazon:
def __init__(self, config, saml_xml):
self.config = config
self.saml_xml = saml_xml
self.__token = None
@property
def sts_client(self):
try:
profile = os.environ.get('AWS_PROFILE')
if profile is not None:
del os.environ['AWS_PROFILE']
client = boto3.client('sts', region_name=self.config.region)
if profile is not None:
os.environ['AWS_PROFILE'] = profile
return client
except ProfileNotFound as ex:
raise ExpectedGoogleException("Error : {}.".format(ex))
@property
def base64_encoded_saml(self):
return base64.b64encode(self.saml_xml).decode("utf-8")
@property
def token(self):
if self.__token is None:
self.__token = self.assume_role(self.config.role_arn,
self.config.provider,
self.base64_encoded_saml,
self.config.duration)
return self.__token
@property
def access_key_id(self):
return self.token['Credentials']['AccessKeyId']
@property
def secret_access_key(self):
return self.token['Credentials']['SecretAccessKey']
@property
def session_token(self):
return self.token['Credentials']['SessionToken']
@property
def expiration(self):
return self.token['Credentials']['Expiration']
def print_export_line(self):
export_template = "export AWS_ACCESS_KEY_ID='{}' AWS_SECRET_ACCESS_KEY='{}' AWS_SESSION_TOKEN='{}' AWS_SESSION_EXPIRATION='{}'"
formatted = export_template.format(
self.access_key_id,
self.secret_access_key,
self.session_token,
self.expiration.strftime('%Y-%m-%dT%H:%M:%S%z'))
print(formatted)
@property
def roles(self):
doc = etree.fromstring(self.saml_xml)
roles = {}
for x in doc.xpath('//*[@Name = "https://aws.amazon.com/SAML/Attributes/Role"]//text()'):
if "arn:aws:iam:" in x or "arn:aws-us-gov:iam:" in x:
res = x.split(',')
roles[res[0]] = res[1]
return roles
def assume_role(self, role, principal, saml_assertion, duration=None, auto_duration=True):
sts_call_vars = {
'RoleArn': role,
'PrincipalArn': principal,
'SAMLAssertion': saml_assertion
}
# Try the maximum duration of 12 hours, if it fails try to use the
# maximum duration indicated by the error
if self.config.auto_duration and auto_duration:
sts_call_vars['DurationSeconds'] = self.config.max_duration
try:
res = self.sts_client.assume_role_with_saml(**sts_call_vars)
except ClientError as err:
if (err.response.get('Error', []).get('Code') == 'ValidationError' and err.response.get('Error', []).get('Message')):
m = re.search(
'Member must have value less than or equal to ([0-9]{3,5})',
err.response['Error']['Message']
)
if m is not None and m.group(1):
new_duration = int(m.group(1))
return self.assume_role(role, principal,
saml_assertion,
duration=new_duration,
auto_duration=False)
# Unknown error or no max time returned in error message
raise
elif duration:
sts_call_vars['DurationSeconds'] = duration
res = self.sts_client.assume_role_with_saml(**sts_call_vars)
return res
def resolve_aws_aliases(self, roles):
def resolve_aws_alias(role, principal, aws_dict):
session = boto3.session.Session(region_name=self.config.region)
sts = session.client('sts')
saml = sts.assume_role_with_saml(RoleArn=role,
PrincipalArn=principal,
SAMLAssertion=self.base64_encoded_saml)
iam = session.client('iam',
aws_access_key_id=saml['Credentials']['AccessKeyId'],
aws_secret_access_key=saml['Credentials']['SecretAccessKey'],
aws_session_token=saml['Credentials']['SessionToken'])
try:
response = iam.list_account_aliases()
account_alias = response['AccountAliases'][0]
aws_dict[role.split(':')[4]] = account_alias
except:
sts = session.client('sts',
aws_access_key_id=saml['Credentials']['AccessKeyId'],
aws_secret_access_key=saml['Credentials']['SecretAccessKey'],
aws_session_token=saml['Credentials']['SessionToken'])
account_id = sts.get_caller_identity().get('Account')
aws_dict[role.split(':')[4]] = '{}'.format(account_id)
threads = []
aws_id_alias = {}
for number, (role, principal) in enumerate(roles.items()):
t = Thread(target=resolve_aws_alias, args=(role, principal, aws_id_alias))
t.start()
threads.append(t)
for t in threads:
t.join()
return aws_id_alias
@staticmethod
def is_valid_saml_assertion(saml_xml):
if saml_xml is None:
return False
try:
doc = etree.fromstring(saml_xml)
conditions = list(doc.iter(tag='{urn:oasis:names:tc:SAML:2.0:assertion}Conditions'))
not_before_str = conditions[0].get('NotBefore')
not_on_or_after_str = conditions[0].get('NotOnOrAfter')
now = datetime.utcnow()
not_before = datetime.strptime(not_before_str, "%Y-%m-%dT%H:%M:%S.%fZ")
not_on_or_after = datetime.strptime(not_on_or_after_str, "%Y-%m-%dT%H:%M:%S.%fZ")
if not_before <= now < not_on_or_after:
return True
else:
return False
except Exception:
return False
================================================
FILE: aws_google_auth/configuration.py
================================================
#!/usr/bin/env python
import os
import botocore.session
import filelock
try:
from backports import configparser
except ImportError:
import configparser
from aws_google_auth import util
from aws_google_auth import amazon
class Configuration(object):
def __init__(self, **kwargs):
self.options = {}
self.__boto_session = botocore.session.Session()
# Set up some defaults. These can be overridden as fit.
self.ask_role = False
self.keyring = False
self.duration = self.max_duration
self.auto_duration = False
self.idp_id = None
self.password = None
self.profile = "sts"
self.region = None
self.role_arn = None
self.__saml_cache = None
self.sp_id = None
self.u2f_disabled = False
self.resolve_aliases = False
self.username = None
self.print_creds = False
self.quiet = False
self.bg_response = None
self.account = ""
# For the "~/.aws/config" file, we use the format "[profile testing]"
# for the 'testing' profile. The credential file will just be "[testing]"
# in that case. See https://docs.aws.amazon.com/cli/latest/userguide/cli-multiple-profiles.html
# for more information.
@staticmethod
def config_profile(profile):
if str(profile).lower() == 'default':
return profile
else:
return 'profile {}'.format(str(profile))
@property
def max_duration(self):
return 43200
@property
def credentials_file(self):
return os.path.expanduser(self.__boto_session.get_config_variable('credentials_file'))
@property
def config_file(self):
return os.path.expanduser(self.__boto_session.get_config_variable('config_file'))
@property
def saml_cache_file(self):
return self.credentials_file.replace('credentials', 'saml_cache_%s.xml' % self.idp_id)
def ensure_config_files_exist(self):
for file in [self.config_file, self.credentials_file]:
directory = os.path.dirname(file)
if not os.path.exists(directory):
os.mkdir(directory, 0o700)
if not os.path.exists(file):
util.Util.touch(file)
# Will return a SAML cache, ONLY if it's valid. If invalid or not set, will
# return None. If the SAML cache isn't valid, we'll remove it from the
# in-memory object. On the next write(), it will be purged from disk.
@property
def saml_cache(self):
if not amazon.Amazon.is_valid_saml_assertion(self.__saml_cache):
self.__saml_cache = None
return self.__saml_cache
@saml_cache.setter
def saml_cache(self, value):
self.__saml_cache = value
# Will raise exceptions if the configuration is invalid, otherwise returns
# None. Use this at any point to validate the configuration is in a good
# state. There are no checks here regarding SAML caching, as that's just a
# user-performance improvement, and an invalid cache isn't an invalid
# configuration.
def raise_if_invalid(self):
# ask_role
assert (self.ask_role.__class__ is bool), "Expected ask_role to be a boolean. Got {}.".format(self.ask_role.__class__)
# keyring
assert (self.keyring.__class__ is bool), "Expected keyring to be a boolean. Got {}.".format(self.keyring.__class__)
# duration
assert (self.duration.__class__ is int), "Expected duration to be an integer. Got {}.".format(self.duration.__class__)
assert (self.duration >= 900), "Expected duration to be greater than or equal to 900. Got {}.".format(self.duration)
assert (self.duration <= self.max_duration), "Expected duration to be less than or equal to max_duration ({}). Got {}.".format(self.max_duration, self.duration)
# auto_duration
assert (self.auto_duration.__class__ is bool), "Expected auto_duration to be a boolean. Got {}.".format(self.auto_duration.__class__)
# profile
assert (self.profile.__class__ is str), "Expected profile to be a string. Got {}.".format(self.profile.__class__)
# region
assert (self.region.__class__ is str), "Expected region to be a string. Got {}.".format(self.region.__class__)
# idp_id
assert (self.idp_id is not None), "Expected idp_id to be set to non-None value."
# sp_id
assert (self.sp_id is not None), "Expected sp_id to be set to non-None value."
# username
assert (self.username.__class__ is str), "Expected username to be a string. Got {}.".format(self.username.__class__)
# password
try:
assert (type(self.password) in [str, unicode]), "Expected password to be a string. Got {}.".format(
type(self.password))
except NameError:
assert (type(self.password) is str), "Expected password to be a string. Got {}.".format(
type(self.password))
# role_arn (Can be blank, we'll just prompt)
if self.role_arn is not None:
assert (self.role_arn.__class__ is str), "Expected role_arn to be None or a string. Got {}.".format(self.role_arn.__class__)
assert ("arn:aws:iam::" in self.role_arn or "arn:aws-us-gov:iam::" in self.role_arn), "Expected role_arn to contain 'arn:aws:iam::'. Got '{}'.".format(self.role_arn)
# u2f_disabled
assert (self.u2f_disabled.__class__ is bool), "Expected u2f_disabled to be a boolean. Got {}.".format(self.u2f_disabled.__class__)
# quiet
assert (self.quiet.__class__ is bool), "Expected quiet to be a boolean. Got {}.".format(self.quiet.__class__)
# account
assert (self.account.__class__ is str), "Expected account to be string. Got {}".format(self.account.__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.
def write(self, amazon_object):
self.ensure_config_files_exist()
assert (self.profile is not None), "Can not store config/credentials if the AWS_PROFILE is None."
config_file_lock = filelock.FileLock(self.config_file + '.lock')
config_file_lock.acquire()
try:
# Write to the configuration file
profile = Configuration.config_profile(self.profile)
config_parser = configparser.RawConfigParser()
config_parser.read(self.config_file)
if not config_parser.has_section(profile):
config_parser.add_section(profile)
config_parser.set(profile, 'region', self.region)
config_parser.set(profile, 'google_config.ask_role', self.ask_role)
config_parser.set(profile, 'google_config.keyring', self.keyring)
config_parser.set(profile, 'google_config.duration', self.duration)
config_parser.set(profile, 'google_config.google_idp_id', self.idp_id)
config_parser.set(profile, 'google_config.role_arn', self.role_arn)
config_parser.set(profile, 'google_config.google_sp_id', self.sp_id)
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)
with open(self.config_file, 'w+') as f:
config_parser.write(f)
finally:
config_file_lock.release()
# Write to the credentials file (only if we have credentials)
if amazon_object is not None:
credentials_file_lock = filelock.FileLock(self.credentials_file + '.lock')
credentials_file_lock.acquire()
try:
credentials_parser = configparser.RawConfigParser()
credentials_parser.read(self.credentials_file)
if not credentials_parser.has_section(self.profile):
credentials_parser.add_section(self.profile)
credentials_parser.set(self.profile, 'aws_access_key_id', amazon_object.access_key_id)
credentials_parser.set(self.profile, 'aws_secret_access_key', amazon_object.secret_access_key)
credentials_parser.set(self.profile, 'aws_security_token', amazon_object.session_token)
credentials_parser.set(self.profile, 'aws_session_expiration', amazon_object.expiration.strftime('%Y-%m-%dT%H:%M:%S%z'))
credentials_parser.set(self.profile, 'aws_session_token', amazon_object.session_token)
with open(self.credentials_file, 'w+') as f:
credentials_parser.write(f)
finally:
credentials_file_lock.release()
if self.__saml_cache is not None:
saml_cache_file_lock = filelock.FileLock(self.saml_cache_file + '.lock')
saml_cache_file_lock.acquire()
try:
with open(self.saml_cache_file, 'w') as f:
f.write(self.__saml_cache.decode("utf-8"))
finally:
saml_cache_file_lock.release()
# Read from the configuration file and override ALL values currently stored
# in the configuration object. As this is potentially destructive, it's
# important to only run this in the beginning of the object initialization.
# We do not read AWS credentials, as this tool's use case is to obtain
# them.
def read(self, profile):
self.ensure_config_files_exist()
# Shortening Convenience functions
coalesce = util.Util.coalesce
unicode_to_string = util.Util.unicode_to_string_if_needed
profile_string = Configuration.config_profile(profile)
config_parser = configparser.RawConfigParser()
config_parser.read(self.config_file)
if config_parser.has_section(profile_string):
self.profile = profile
# Ask Role
read_ask_role = config_parser[profile_string].getboolean('google_config.ask_role', None)
self.ask_role = coalesce(read_ask_role, self.ask_role)
# Keyring
read_keyring = config_parser[profile_string].getboolean('google_config.keyring', None)
self.keyring = coalesce(read_keyring, self.keyring)
# Duration
read_duration = config_parser[profile_string].getint('google_config.duration', None)
self.duration = coalesce(read_duration, self.duration)
# IDP ID
read_idp_id = unicode_to_string(config_parser[profile_string].get('google_config.google_idp_id', None))
self.idp_id = coalesce(read_idp_id, self.idp_id)
# Region
read_region = unicode_to_string(config_parser[profile_string].get('region', None))
self.region = coalesce(read_region, self.region)
# Role ARN
read_role_arn = unicode_to_string(config_parser[profile_string].get('google_config.role_arn', None))
self.role_arn = coalesce(read_role_arn, self.role_arn)
# SP ID
read_sp_id = unicode_to_string(config_parser[profile_string].get('google_config.google_sp_id', None))
self.sp_id = coalesce(read_sp_id, self.sp_id)
# U2F Disabled
read_u2f_disabled = config_parser[profile_string].getboolean('google_config.u2f_disabled', None)
self.u2f_disabled = coalesce(read_u2f_disabled, self.u2f_disabled)
# Username
read_username = unicode_to_string(config_parser[profile_string].get('google_config.google_username', None))
self.username = coalesce(read_username, self.username)
# bg_response
read_bg_response = unicode_to_string(config_parser[profile_string].get('google_config.bg_response', None))
self.bg_response = coalesce(read_bg_response, self.bg_response)
# Account
read_account = unicode_to_string(config_parser[profile_string].get('account', None))
self.account = coalesce(read_account, self.account)
# SAML Cache
try:
with open(self.saml_cache_file, 'r') as f:
self.__saml_cache = f.read().encode("utf-8")
except IOError:
pass
================================================
FILE: aws_google_auth/google.py
================================================
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import print_function
import base64
import io
import json
import logging
import os
import re
import sys
import requests
from PIL import Image
from datetime import datetime
from distutils.spawn import find_executable
from bs4 import BeautifulSoup
from requests import HTTPError
from six import print_ as print
from six.moves import urllib_parse, input
from aws_google_auth import _version
# The U2F USB Library is optional, if it's there, include it.
try:
from aws_google_auth import u2f
except ImportError:
logging.info("Failed to import U2F libraries, U2F login unavailable. "
"Other methods can still continue.")
class ExpectedGoogleException(Exception):
def __init__(self, *args):
super(ExpectedGoogleException, self).__init__(*args)
class Google:
def __init__(self, config, save_failure, save_flow=False):
"""The Google object holds authentication state
for a given session. You need to supply:
username: FQDN Google username, eg first.last@example.com
password: obvious
idp_id: Google's assigned IdP identifier for your G-suite account
sp_id: Google's assigned SP identifier for your AWS SAML app
Optionally, you can supply:
duration_seconds: number of seconds for the session to be active (max 43200)
"""
self.version = _version.__version__
self.config = config
self.base_url = 'https://accounts.google.com'
self.save_failure = save_failure
self.session_state = None
self.save_flow = save_flow
if save_flow:
self.save_flow_dict = {}
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)
@property
def login_url(self):
return self.base_url + "/o/saml2/initsso?idpid={}&spid={}&forceauthn=false".format(
self.config.idp_id, self.config.sp_id)
def check_for_failure(self, sess):
if isinstance(sess.reason, bytes):
# We attempt to decode utf-8 first because some servers
# choose to localize their reason strings. If the string
# isn't utf-8, we fall back to iso-8859-1 for all other
# encodings. (See PR #3538)
try:
reason = sess.reason.decode('utf-8')
except UnicodeDecodeError:
reason = sess.reason.decode('iso-8859-1')
else:
reason = sess.reason
if sess.status_code == 403:
raise ExpectedGoogleException(u'{} accessing {}'.format(
reason, sess.url))
try:
sess.raise_for_status()
except HTTPError as ex:
if self.save_failure:
logging.exception("Saving failure trace in 'failure.html'", ex)
with open("failure.html", 'w') as out:
out.write(sess.text)
raise ex
return sess
def _save_file_name(self, url):
filename = url.split('://')[1].split('?')[0].replace("accounts.google", "ac.go").replace("/", "~")
file_idx = self.save_flow_dict.get(filename, 1)
self.save_flow_dict[filename] = file_idx + 1
return filename + "_" + str(file_idx)
def _save_request(self, url, method='GET', data=None, json_data=None):
if self.save_flow:
filename = self._save_file_name(url) + "_" + method + ".req"
with open(os.path.join(self.save_flow_dir, filename), 'w', encoding='utf-8') as out:
try:
out.write("params=" + url.split('?')[1])
except IndexError:
out.write("params=None")
out.write(("\ndata: " + json.dumps(data, indent=2)).replace(self.config.password, ''))
out.write(("\njson: " + json.dumps(json_data, indent=2)).replace(self.config.password, ''))
def _save_response(self, url, response):
if self.save_flow:
filename = self._save_file_name(url) + ".html"
with open(os.path.join(self.save_flow_dir, filename), 'w', encoding='utf-8') as out:
out.write(response.text)
def post(self, url, data=None, json_data=None):
try:
self._save_request(url, method='POST', data=data, json_data=json_data)
response = self.check_for_failure(self.session.post(url, data=data, json=json_data))
self._save_response(url, response)
except requests.exceptions.ConnectionError as e:
logging.exception(
'There was a connection error, check your network settings.', e)
sys.exit(1)
except requests.exceptions.Timeout as e:
logging.exception('The connection timed out, please try again.', e)
sys.exit(1)
except requests.exceptions.TooManyRedirects as e:
logging.exception('The number of redirects exceeded the maximum '
'allowed.', e)
sys.exit(1)
return response
def get(self, url):
try:
self._save_request(url)
response = self.check_for_failure(self.session.get(url))
self._save_response(url, response)
except requests.exceptions.ConnectionError as e:
logging.exception(
'There was a connection error, check your network settings.', e)
sys.exit(1)
except requests.exceptions.Timeout as e:
logging.exception('The connection timed out, please try again.', e)
sys.exit(1)
except requests.exceptions.TooManyRedirects as e:
logging.exception('The number of redirects exceeded the maximum '
'allowed.', e)
sys.exit(1)
return response
@staticmethod
def parse_error_message(sess):
response_page = BeautifulSoup(sess.text, 'html.parser')
error = response_page.find('span', {'id': 'errorMsg'})
if error is None:
return None
else:
return error.text
@staticmethod
def find_key_handles(input, challengeTxt):
keyHandles = []
typeOfInput = type(input)
if typeOfInput == dict: # parse down a dict
for item in input:
keyHandles.extend(Google.find_key_handles(input[item], challengeTxt))
elif typeOfInput == list: # looks like we've hit an array - iterate it
array = list(filter(None, input)) # remove any None type objects from the array
for item in array:
typeValue = type(item)
if typeValue == list: # another array - recursive call
keyHandles.extend(Google.find_key_handles(item, challengeTxt))
elif typeValue == int or typeValue == bool: # ints bools etc we don't care
continue
else: # we went a string or unicode here (python 3.x lost unicode global)
try: # keyHandle string will be base64 encoded -
# if its not an exception is thrown and we continue as its not the string we're after
base64UrlEncoded = base64.urlsafe_b64encode(base64.b64decode(item))
if base64UrlEncoded != challengeTxt: # make sure its not the challengeTxt - if it not return it
keyHandles.append(base64UrlEncoded)
except:
pass
return keyHandles
@staticmethod
def find_app_id(inputString):
try:
searchResult = re.search('"appid":"[a-z://.-_] + "', inputString).group()
searchObject = json.loads('{' + searchResult + '}')
return str(searchObject['appid'])
except:
logging.exception('Was unable to find appid value in googles SAML page')
sys.exit(1)
def do_login(self):
self.session = requests.Session()
self.session.headers['User-Agent'] = "AWS Sign-in/{} (aws-google-auth)".format(self.version)
sess = self.get(self.login_url)
# Collect information from the page source
first_page = BeautifulSoup(sess.text, 'html.parser')
# gxf = first_page.find('input', {'name': 'gxf'}).get('value')
self.cont = first_page.find('input', {'name': 'continue'}).get('value')
# page = first_page.find('input', {'name': 'Page'}).get('value')
# sign_in = first_page.find('input', {'name': 'signIn'}).get('value')
form = first_page.find('form', {'id': 'gaia_loginform'})
account_login_url = form.get('action')
payload = {}
for tag in form.find_all('input'):
if tag.get('name') is None:
continue
payload[tag.get('name')] = tag.get('value')
payload['Email'] = self.config.username
if self.config.bg_response:
payload['bgresponse'] = self.config.bg_response
if payload.get('PersistentCookie', None) is not None:
payload['PersistentCookie'] = 'yes'
if payload.get('TrustDevice', None) is not None:
payload['TrustDevice'] = 'on'
# POST to account login info page, to collect profile and session info
sess = self.post(account_login_url, data=payload)
self.session.headers['Referer'] = sess.url
# Collect ProfileInformation, SessionState, signIn, and Password Challenge URL
challenge_page = BeautifulSoup(sess.text, 'html.parser')
# Handle the "old-style" page
if challenge_page.find('form', {'id': 'gaia_loginform'}):
form = challenge_page.find('form', {'id': 'gaia_loginform'})
passwd_challenge_url = form.get('action')
else:
# sometimes they serve up a different page
logging.info("Handling new-style login page")
form = challenge_page.find('form', {'id': 'challenge'})
passwd_challenge_url = 'https://accounts.google.com' + form.get('action')
for tag in form.find_all('input'):
if tag.get('name') is None:
continue
payload[tag.get('name')] = tag.get('value')
# Update the payload
payload['Passwd'] = self.config.password
# Set bg_response in request payload to passwd challenge
if self.config.bg_response:
payload['bgresponse'] = self.config.bg_response
# POST to Authenticate Password
sess = self.post(passwd_challenge_url, data=payload)
response_page = BeautifulSoup(sess.text, 'html.parser')
error = response_page.find(class_='error-msg')
cap = response_page.find('input', {'name': 'identifier-captcha-input'})
# Were there any errors logging in? Could be invalid username or password
# There could also sometimes be a Captcha, which means Google thinks you,
# or someone using the same outbound IP address as you, is a bot.
if error is not None and cap is None:
raise ExpectedGoogleException('Invalid username or password')
if "signin/rejected" in sess.url:
raise ExpectedGoogleException(u'''Default value of parameter `bgresponse` has not accepted.
Please visit login URL {}, open the web inspector and execute document.bg.invoke() in the console.
Then, set --bg-response to the function output.'''.format(self.login_url))
self.check_extra_step(response_page)
# Process Google CAPTCHA verification request if present
if cap is not None:
self.session.headers['Referer'] = sess.url
sess = self.handle_captcha(sess, payload)
response_page = BeautifulSoup(sess.text, 'html.parser')
error = response_page.find(class_='error-msg')
cap = response_page.find('input', {'name': 'logincaptcha'})
# Were there any errors logging in? Could be invalid username or password
# There could also sometimes be a Captcha, which means Google thinks you,
# or someone using the same outbound IP address as you, is a bot.
if error is not None:
raise ExpectedGoogleException('Invalid username or password')
self.check_extra_step(response_page)
if cap is not None:
raise ExpectedGoogleException(
'Invalid captcha')
self.session.headers['Referer'] = sess.url
if "selectchallenge/" in sess.url:
sess = self.handle_selectchallenge(sess)
# Was there an MFA challenge?
if "challenge/totp/" in sess.url:
error_msg = ""
while error_msg is not None:
sess = self.handle_totp(sess)
error_msg = self.parse_error_message(sess)
if error_msg is not None:
logging.error(error_msg)
elif "challenge/ipp/" in sess.url:
sess = self.handle_sms(sess)
elif "challenge/az/" in sess.url:
sess = self.handle_prompt(sess)
elif "challenge/sk/" in sess.url:
sess = self.handle_sk(sess)
elif "challenge/iap/" in sess.url:
sess = self.handle_iap(sess)
elif "challenge/dp/" in sess.url:
sess = self.handle_dp(sess)
elif "challenge/ootp/5" in sess.url:
raise NotImplementedError(
'Offline Google App OOTP not implemented')
# ... there are different URLs for backup codes (printed)
# and security keys (eg yubikey) as well
# save for later
self.session_state = sess
@staticmethod
def check_extra_step(response):
extra_step = response.find(text='This extra step shows that it’s really you trying to sign in')
if extra_step:
if response.find(id='contactAdminMessage'):
raise ValueError(response.find(id='contactAdminMessage').text)
def parse_saml(self):
if self.session_state is None:
raise RuntimeError('You must use do_login() before calling parse_saml()')
parsed = BeautifulSoup(self.session_state.text, 'html.parser')
try:
saml_element = parsed.find('input', {'name': 'SAMLResponse'}).get('value')
except:
if self.save_failure:
logging.error("SAML lookup failed, storing failure page to "
"'saml.html' to assist with debugging.")
with open("saml.html", 'wb') as out:
out.write(self.session_state.text.encode('utf-8'))
raise ExpectedGoogleException('Something went wrong - Could not find SAML response, check your credentials or use --save-failure-html to debug.')
return base64.b64decode(saml_element)
def handle_captcha(self, sess, payload):
response_page = BeautifulSoup(sess.text, 'html.parser')
# Collect ProfileInformation, SessionState, signIn, and Password Challenge URL
profile_information = response_page.find('input', {
'name': 'ProfileInformation'
}).get('value')
session_state = response_page.find('input', {
'name': 'SessionState'
}).get('value')
sign_in = response_page.find('input', {'name': 'signIn'}).get('value')
passwd_challenge_url = response_page.find('form', {
'id': 'gaia_loginform'
}).get('action')
# Update the payload
payload['SessionState'] = session_state
payload['ProfileInformation'] = profile_information
payload['signIn'] = sign_in
payload['Passwd'] = self.config.password
# Get all captcha challenge tokens and urls
captcha_container = response_page.find('div', {'id': 'identifier-captcha'})
captcha_logintoken = captcha_container.find('input', {'id': 'identifier-token'}).get('value')
captcha_img = captcha_container.find('div', {'class': 'captcha-img'})
captcha_url = "https://accounts.google.com" + captcha_img.find('img').get('src')
captcha_logintoken_audio = ''
open_image = True
# Check if there is a display utility installed as Image.open(f).show() do not raise any exception if not
# if neither xv or display are available just display the URL for the user to visit.
if os.name == 'posix' and sys.platform != 'darwin':
if find_executable('xv') is None and find_executable('display') is None:
open_image = False
print("Please visit the following URL to view your CAPTCHA: {}".format(captcha_url))
if open_image:
try:
with requests.get(captcha_url) as url:
with io.BytesIO(url.content) as f:
Image.open(f).show()
except Exception:
pass
try:
captcha_input = raw_input("Captcha (case insensitive): ") or None
except NameError:
captcha_input = input("Captcha (case insensitive): ") or None
# Update the payload
payload['identifier-captcha-input'] = captcha_input
payload['identifiertoken'] = captcha_logintoken
payload['identifiertoken_audio'] = captcha_logintoken_audio
payload['checkedDomains'] = 'youtube'
payload['checkConnection'] = 'youtube:574:1'
payload['Email'] = self.config.username
response = self.post(passwd_challenge_url, data=payload)
newPayload = {}
auth_response_page = BeautifulSoup(response.text, 'html.parser')
form = auth_response_page.find('form')
for tag in form.find_all('input'):
if tag.get('name') is None:
continue
newPayload[tag.get('name')] = tag.get('value')
newPayload['Email'] = self.config.username
newPayload['Passwd'] = self.config.password
if newPayload.get('TrustDevice', None) is not None:
newPayload['TrustDevice'] = 'on'
return self.post(response.url, data=newPayload)
def handle_sk(self, sess):
response_page = BeautifulSoup(sess.text, 'html.parser')
challenge_url = sess.url.split("?")[0]
challenges_txt = response_page.find('input', {
'name': "id-challenge"
}).get('value')
facet_url = urllib_parse.urlparse(challenge_url)
facet = facet_url.scheme + "://" + facet_url.netloc
keyHandleJSField = response_page.find('div', {'jsname': 'C0oDBd'}).get('data-challenge-ui')
startJSONPosition = keyHandleJSField.find('{')
endJSONPosition = keyHandleJSField.rfind('}')
keyHandleJsonPayload = json.loads(keyHandleJSField[startJSONPosition:endJSONPosition + 1])
keyHandles = self.find_key_handles(keyHandleJsonPayload, base64.urlsafe_b64encode(base64.b64decode(challenges_txt)))
appId = self.find_app_id(str(keyHandleJsonPayload))
# txt sent for signing needs to be base64 url encode
# we also have to remove any base64 padding because including including it will prevent google accepting the auth response
challenges_txt_encode_pad_removed = base64.urlsafe_b64encode(base64.b64decode(challenges_txt)).strip('='.encode())
u2f_challenges = [{'version': 'U2F_V2', 'challenge': challenges_txt_encode_pad_removed.decode(), 'appId': appId, 'keyHandle': keyHandle.decode()} for keyHandle in keyHandles]
# Prompt the user up to attempts_remaining times to insert their U2F device.
attempts_remaining = 5
auth_response = None
while True:
try:
auth_response_dict = u2f.u2f_auth(u2f_challenges, facet)
auth_response = json.dumps(auth_response_dict)
break
except RuntimeWarning:
logging.error("No U2F device found. %d attempts remaining",
attempts_remaining)
if attempts_remaining <= 0:
break
else:
input(
"Insert your U2F device and press enter to try again..."
)
attempts_remaining -= 1
# If we exceed the number of attempts, raise an error and let the program exit.
if auth_response is None:
raise ExpectedGoogleException(
"No U2F device found. Please check your setup.")
payload = {
'challengeId':
response_page.find('input', {
'name': 'challengeId'
}).get('value'),
'challengeType':
response_page.find('input', {
'name': 'challengeType'
}).get('value'),
'continue': response_page.find('input', {
'name': 'continue'
}).get('value'),
'scc':
response_page.find('input', {
'name': 'scc'
}).get('value'),
'sarp':
response_page.find('input', {
'name': 'sarp'
}).get('value'),
'checkedDomains':
response_page.find('input', {
'name': 'checkedDomains'
}).get('value'),
'pstMsg': '1',
'TL':
response_page.find('input', {
'name': 'TL'
}).get('value'),
'gxf':
response_page.find('input', {
'name': 'gxf'
}).get('value'),
'id-challenge':
challenges_txt,
'id-assertion':
auth_response,
'TrustDevice':
'on',
}
return self.post(challenge_url, data=payload)
def handle_sms(self, sess):
response_page = BeautifulSoup(sess.text, 'html.parser')
challenge_url = sess.url.split("?")[0]
sms_token = input("Enter SMS token: G-") or None
challenge_form = response_page.find('form')
payload = {}
for tag in challenge_form.find_all('input'):
if tag.get('name') is None:
continue
payload[tag.get('name')] = tag.get('value')
if response_page.find('input', {'name': 'TrustDevice'}) is not None:
payload['TrustDevice'] = 'on'
payload['Pin'] = sms_token
try:
del payload['SendMethod']
except KeyError:
pass
# Submit IPP (SMS code)
return self.post(challenge_url, data=payload)
def handle_prompt(self, sess):
response_page = BeautifulSoup(sess.text, 'html.parser')
challenge_url = sess.url.split("?")[0]
data_key = response_page.find('div', {
'data-api-key': True
}).get('data-api-key')
data_tx_id = response_page.find('div', {
'data-tx-id': True
}).get('data-tx-id')
# Need to post this to the verification/pause endpoint
await_url = "https://content.googleapis.com/cryptauth/v1/authzen/awaittx?alt=json&key={}".format(
data_key)
await_body = {'txId': data_tx_id}
self.check_prompt_code(response_page)
print("Open the Google App, and tap 'Yes' on the prompt to sign in ...")
self.session.headers['Referer'] = sess.url
retry = True
response = None
while retry:
try:
response = self.post(await_url, json_data=await_body)
retry = False
except requests.exceptions.HTTPError as ex:
if not ex.response.status_code == 500:
raise ex
parsed_response = json.loads(response.text)
payload = {
'challengeId':
response_page.find('input', {
'name': 'challengeId'
}).get('value'),
'challengeType':
response_page.find('input', {
'name': 'challengeType'
}).get('value'),
'continue':
response_page.find('input', {
'name': 'continue'
}).get('value'),
'scc':
response_page.find('input', {
'name': 'scc'
}).get('value'),
'sarp':
response_page.find('input', {
'name': 'sarp'
}).get('value'),
'checkedDomains':
response_page.find('input', {
'name': 'checkedDomains'
}).get('value'),
'checkConnection':
'youtube:1295:1',
'pstMsg':
response_page.find('input', {
'name': 'pstMsg'
}).get('value'),
'TL':
response_page.find('input', {
'name': 'TL'
}).get('value'),
'gxf':
response_page.find('input', {
'name': 'gxf'
}).get('value'),
'token':
parsed_response['txToken'],
'action':
response_page.find('input', {
'name': 'action'
}).get('value'),
'TrustDevice':
'on',
}
return self.post(challenge_url, data=payload)
@staticmethod
def check_prompt_code(response):
"""
Sometimes there is an additional numerical code on the response page that needs to be selected
on the prompt from a list of multiple choice. Print it if it's there.
"""
num_code = response.find("div", {"jsname": "EKvSSd"})
if num_code:
print("numerical code for prompt: {}".format(num_code.string))
def handle_totp(self, sess):
response_page = BeautifulSoup(sess.text, 'html.parser')
tl = response_page.find('input', {'name': 'TL'}).get('value')
gxf = response_page.find('input', {'name': 'gxf'}).get('value')
challenge_url = sess.url.split("?")[0]
challenge_id = challenge_url.split("totp/")[1]
mfa_token = input("MFA token: ") or None
if not mfa_token:
raise ValueError(
"MFA token required for {} but none supplied.".format(
self.config.username))
payload = {
'challengeId': challenge_id,
'challengeType': 6,
'continue': self.cont,
'scc': 1,
'sarp': 1,
'checkedDomains': 'youtube',
'pstMsg': 0,
'TL': tl,
'gxf': gxf,
'Pin': mfa_token,
'TrustDevice': 'on',
}
# Submit TOTP
return self.post(challenge_url, data=payload)
def handle_dp(self, sess):
response_page = BeautifulSoup(sess.text, 'html.parser')
input("Check your phone - after you have confirmed response press ENTER to continue.") or None
form = response_page.find('form', {'id': 'challenge'})
challenge_url = 'https://accounts.google.com' + form.get('action')
payload = {}
for tag in form.find_all('input'):
if tag.get('name') is None:
continue
payload[tag.get('name')] = tag.get('value')
# Submit Configuration
return self.post(challenge_url, data=payload)
def handle_iap(self, sess):
response_page = BeautifulSoup(sess.text, 'html.parser')
challenge_url = sess.url.split("?")[0]
phone_number = input('Enter your phone number:') or None
while True:
try:
choice = int(
input(
'Type 1 to receive a code by SMS or 2 for a voice call:'
))
if choice not in [1, 2]:
raise ValueError
except ValueError:
logging.error("Not a valid (integer) option, try again")
continue
else:
if choice == 1:
send_method = 'SMS'
elif choice == 2:
send_method = 'VOICE'
else:
continue
break
payload = {
'challengeId':
response_page.find('input', {
'name': 'challengeId'
}).get('value'),
'challengeType':
response_page.find('input', {
'name': 'challengeType'
}).get('value'),
'continue':
self.cont,
'scc':
response_page.find('input', {
'name': 'scc'
}).get('value'),
'sarp':
response_page.find('input', {
'name': 'sarp'
}).get('value'),
'checkedDomains':
response_page.find('input', {
'name': 'checkedDomains'
}).get('value'),
'pstMsg':
response_page.find('input', {
'name': 'pstMsg'
}).get('value'),
'TL':
response_page.find('input', {
'name': 'TL'
}).get('value'),
'gxf':
response_page.find('input', {
'name': 'gxf'
}).get('value'),
'phoneNumber':
phone_number,
'sendMethod':
send_method,
}
# Submit phone number and desired method (SMS or voice call)
sess = self.post(challenge_url, data=payload)
response_page = BeautifulSoup(sess.text, 'html.parser')
challenge_url = sess.url.split("?")[0]
token = input("Enter " + send_method + " token: G-") or None
payload = {
'challengeId':
response_page.find('input', {
'name': 'challengeId'
}).get('value'),
'challengeType':
response_page.find('input', {
'name': 'challengeType'
}).get('value'),
'continue':
response_page.find('input', {
'name': 'continue'
}).get('value'),
'scc':
response_page.find('input', {
'name': 'scc'
}).get('value'),
'sarp':
response_page.find('input', {
'name': 'sarp'
}).get('value'),
'checkedDomains':
response_page.find('input', {
'name': 'checkedDomains'
}).get('value'),
'pstMsg':
response_page.find('input', {
'name': 'pstMsg'
}).get('value'),
'TL':
response_page.find('input', {
'name': 'TL'
}).get('value'),
'gxf':
response_page.find('input', {
'name': 'gxf'
}).get('value'),
'pin':
token,
}
# Submit SMS/VOICE token
return self.post(challenge_url, data=payload)
def handle_selectchallenge(self, sess):
response_page = BeautifulSoup(sess.text, 'html.parser')
challenges = []
for i in response_page.select('form[data-challengeentry]'):
action = i.attrs.get("action")
if "challenge/totp/" in action:
challenges.append(['TOTP (Google Authenticator)', i.attrs.get("data-challengeentry")])
elif "challenge/ipp/" in action:
challenges.append(['SMS', i.attrs.get("data-challengeentry")])
elif "challenge/iap/" in action:
challenges.append(['SMS other phone', i.attrs.get("data-challengeentry")])
elif "challenge/sk/" in action:
challenges.append(['YubiKey', i.attrs.get("data-challengeentry")])
elif "challenge/az/" in action:
challenges.append(['Google Prompt', i.attrs.get("data-challengeentry")])
print('Choose MFA method from available:')
for i, mfa in enumerate(challenges, start=1):
print("{}: {}".format(i, mfa[0]))
selected_challenge = input("Enter MFA choice number (1): ") or None
if selected_challenge is not None and int(selected_challenge) <= len(challenges):
selected_challenge = int(selected_challenge) - 1
else:
selected_challenge = 0
challenge_id = challenges[selected_challenge][1]
print("MFA Type Chosen: {}".format(challenges[selected_challenge][0]))
# We need the specific form of the challenge chosen
challenge_form = response_page.find(
'form', {'data-challengeentry': challenge_id})
payload = {}
for tag in challenge_form.find_all('input'):
if tag.get('name') is None:
continue
payload[tag.get('name')] = tag.get('value')
if response_page.find('input', {'name': 'TrustDevice'}) is not None:
payload['TrustDevice'] = 'on'
# POST to google with the chosen challenge
return self.post(
self.base_url + challenge_form.get('action'), data=payload)
================================================
FILE: aws_google_auth/tests/__init__.py
================================================
================================================
FILE: aws_google_auth/tests/google_error.html
================================================
Google Accounts
2-step Verification
This extra step shows that it’s really you trying to sign in
Based on your organisation's policy, you need to turn on 2-step verification. Contact your administrator to learn more.