From 9312fde0d4f173df5f0d54c564357bb393169e83 Mon Sep 17 00:00:00 2001 From: Kristinn Date: Tue, 12 Nov 2019 20:50:24 +0000 Subject: [PATCH] Adding OAUTH support to API client. (#1027) * Adding OAUTH lib to dependencies * Adding OAUTH support. * Minor changes. * Making changes to OAUTH connections. * Minor changes. * Minor changes. * Minor CSRF changes. * Making changes to CSRF tokens in client. * Change * Adding error reporting. * Cleaning up error messages. * minor changes * linter * change * fixing error message. * Travis * REmoving an empty line * Making changes after comments. * tests * Fix tests. --- api_client/python/setup.py | 5 +- .../python/timesketch_api_client/client.py | 195 +++++++++++++++--- config/travis/install.sh | 4 +- data/timesketch.conf | 6 + requirements.txt | 3 + timesketch/lib/google_auth.py | 43 ++-- timesketch/lib/google_auth_test.py | 56 ++--- timesketch/views/auth.py | 183 ++++++++++++++-- 8 files changed, 402 insertions(+), 93 deletions(-) diff --git a/api_client/python/setup.py b/api_client/python/setup.py index 99d7a439b4..88d84cd748 100644 --- a/api_client/python/setup.py +++ b/api_client/python/setup.py @@ -21,7 +21,7 @@ setup( name='timesketch-api-client', - version='20191105', + version='20191110', description='Timesketch API client', license='Apache License, Version 2.0', url='http://www.timesketch.org/', @@ -39,6 +39,9 @@ install_requires=frozenset([ 'pandas', 'requests', + 'altair', 'xlrd', + 'google-auth', + 'google_auth_oauthlib', 'beautifulsoup4']), ) diff --git a/api_client/python/timesketch_api_client/client.py b/api_client/python/timesketch_api_client/client.py index 732973e506..effb2723f2 100644 --- a/api_client/python/timesketch_api_client/client.py +++ b/api_client/python/timesketch_api_client/client.py @@ -20,15 +20,31 @@ # pylint: disable=wrong-import-order import bs4 import requests - # pylint: disable=redefined-builtin from requests.exceptions import ConnectionError +import webbrowser + import altair +# pylint: disable-msg=import-error +from google_auth_oauthlib import flow as googleauth_flow +import google.auth.transport.requests import pandas from .definitions import HTTP_STATUS_CODE_20X from . import importer +def _error_message(response, message=None, error=RuntimeError): + """Raise an error using error message extracted from response.""" + if not message: + message = 'Unknown error, with error: ' + soup = bs4.BeautifulSoup(response.text, features='html.parser') + text = '' + if soup.p: + text = soup.p.string + raise error('{0:s}, with error [{1:d}] {2:s} {3:s}'.format( + message, response.status_code, response.reason, text)) + + class TimesketchApi(object): """Timesketch API object @@ -37,11 +53,25 @@ class TimesketchApi(object): session: Authenticated HTTP session. """ + DEFAULT_OAUTH_SCOPE = [ + 'https://www.googleapis.com/auth/userinfo.email', + 'openid', + 'https://www.googleapis.com/auth/userinfo.profile' + ] + + DEFAULT_OAUTH_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' + DEFAULT_OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token' + DEFAULT_OAUTH_PROVIDER_URL = 'https://www.googleapis.com/oauth2/v1/certs' + DEFAULT_OAUTH_OOB_URL = 'urn:ietf:wg:oauth:2.0:oob' + DEFAULT_OAUTH_API_CALLBACK = '/login/api_callback/' + def __init__(self, host_uri, username, - password, + password='', verify=True, + client_id='', + client_secret='', auth_mode='timesketch'): """Initializes the TimesketchApi object. @@ -50,17 +80,32 @@ def __init__(self, username: User username. password: User password. verify: Verify server SSL certificate. + client_id: The client ID if OAUTH auth is used. + client_secret: The OAUTH client secret if OAUTH is used. auth_mode: The authentication mode to use. Defaults to 'timesketch' - Supported values are 'timesketch' (Timesketch login form) and - 'http-basic' (HTTP Basic authentication). + Supported values are 'timesketch' (Timesketch login form), + 'http-basic' (HTTP Basic authentication) and oauth. + + Raises: + ConnectionError: If the Timesketch server is unreachable. + RuntimeError: If the client is unable to authenticate to the + backend. """ self._host_uri = host_uri self.api_root = '{0:s}/api/v1'.format(host_uri) + self._redirect_uri = '{0:s}/login/google_openid_connect/'.format( + host_uri) + self._credentials = None + self._flow = None try: self.session = self._create_session( - username, password, verify=verify, auth_mode=auth_mode) + username, password, verify=verify, client_id=client_id, + client_secret=client_secret, auth_mode=auth_mode) except ConnectionError: raise ConnectionError('Timesketch server unreachable') + except RuntimeError as e: + raise RuntimeError( + 'Unable to connect to server, with error: {0!s}'.format(e)) def _authenticate_session(self, session, username, password): """Post username/password to authenticate the HTTP seesion. @@ -83,33 +128,108 @@ def _set_csrf_token(self, session): # Scrape the CSRF token from the response response = session.get(self._host_uri) soup = bs4.BeautifulSoup(response.text, features='html.parser') - csrf_token = soup.find(id='csrf_token').get('value') + + tag = soup.find(id='csrf_token') + csrf_token = None + if tag: + csrf_token = tag.get('value') + else: + tag = soup.find('meta', attrs={'name': 'csrf-token'}) + if tag: + csrf_token = tag.attrs.get('content') + + if not csrf_token: + return session.headers.update({ 'x-csrftoken': csrf_token, 'referer': self._host_uri }) - def _create_session(self, username, password, verify, auth_mode): + def _create_oauth_session(self, client_id, client_secret): + """Return an OAuth session. + + Args: + client_id: The client ID if OAUTH auth is used. + client_secret: The OAUTH client secret if OAUTH is used. + + Return: + session: Instance of requests.Session. + + Raises: + RuntimeError: if unable to log in to the application. + """ + client_config = { + 'installed': { + 'client_id': client_id, + 'client_secret': client_secret, + 'auth_uri': self.DEFAULT_OAUTH_AUTH_URL, + 'token_uri': self.DEFAULT_OAUTH_TOKEN_URL, + 'auth_provider_x509_cert_url': self.DEFAULT_OAUTH_PROVIDER_URL, + 'redirect_uris': [self.DEFAULT_OAUTH_OOB_URL], + }, + } + + flow = googleauth_flow.InstalledAppFlow.from_client_config( + client_config, self.DEFAULT_OAUTH_SCOPE) + flow.redirect_uri = self.DEFAULT_OAUTH_OOB_URL + auth_url, _ = flow.authorization_url(prompt='select_account') + + open_browser = input('Open the URL in a browser window? [y/N] ') + if open_browser.lower() == 'y' or open_browser.lower() == 'yes': + webbrowser.open(auth_url) + else: + print('Need to manually URL to authenticate: {0:s}'.format( + auth_url)) + + code = input('Enter the token code: ') + + _ = flow.fetch_token(code=code) + session = flow.authorized_session() + self._flow = flow + self._credentials = flow.credentials + + # Authenticate to the Timesketch backend. + login_callback_url = '{0:s}{1:s}'.format( + self._host_uri, self.DEFAULT_OAUTH_API_CALLBACK) + response = session.get(login_callback_url) + + if response.status_code not in HTTP_STATUS_CODE_20X: + _error_message( + response, message='Unable to authenticate', error=RuntimeError) + + self._set_csrf_token(session) + return session + + def _create_session( + self, username, password, verify, client_id, client_secret, + auth_mode): """Create authenticated HTTP session for server communication. Args: username: User to authenticate as. password: User password. verify: Verify server SSL certificate. + client_id: The client ID if OAUTH auth is used. + client_secret: The OAUTH client secret if OAUTH is used. auth_mode: The authentication mode to use. Supported values are - 'timesketch' (Timesketch login form) and 'http-basic' - (HTTP Basic authentication). + 'timesketch' (Timesketch login form), 'http-basic' + (HTTP Basic authentication) and oauth. Returns: Instance of requests.Session. """ + if auth_mode == 'oauth': + return self._create_oauth_session(client_id, client_secret) + session = requests.Session() - session.verify = verify # Depending if SSL cert is verifiable + # If using HTTP Basic auth, add the user/pass to the session if auth_mode == 'http-basic': session.auth = (username, password) + session.verify = verify # Depending if SSL cert is verifiable + # Get and set CSRF token and authenticate the session if appropriate. self._set_csrf_token(session) if auth_mode == 'timesketch': @@ -151,6 +271,16 @@ def create_sketch(self, name, description=None): sketch_id = response_dict['objects'][0]['id'] return self.get_sketch(sketch_id) + def get_oauth_token_status(self): + """Return a dict with OAuth token status, if one exists.""" + if not self._credentials: + return { + 'status': 'No stored credentials.'} + return { + 'expired': self._credentials.expired, + 'expiry_time': self._credentials.expiry.isoformat(), + } + def get_sketch(self, sketch_id): """Get a sketch. @@ -216,7 +346,9 @@ def get_or_create_searchindex(self, response = self.session.post(resource_url, json=form_data) if response.status_code not in HTTP_STATUS_CODE_20X: - raise RuntimeError('Error creating searchindex') + _error_message( + response, message='Error creating searchindex', + error=RuntimeError) response_dict = response.json() metadata_dict = response_dict['meta'] @@ -240,6 +372,13 @@ def list_searchindices(self): indices.append(index_obj) return indices + def refresh_oauth_token(self): + """Refresh an OAUTH token if one is defined.""" + if not self._credentials: + return + request = google.auth.transport.requests.Request() + self._credentials.refresh(request) + class BaseResource(object): """Base resource object.""" @@ -592,7 +731,9 @@ def add_timeline(self, searchindex): response = self.api.session.post(resource_url, json=form_data) if response.status_code not in HTTP_STATUS_CODE_20X: - raise RuntimeError('Failed adding timeline') + _error_message( + response, message='Failed adding timeline', + error=RuntimeError) response_dict = response.json() timeline = response_dict['objects'][0] @@ -678,9 +819,9 @@ def explore(self, response = self.api.session.post(resource_url, json=form_data) if response.status_code != 200: - raise ValueError( - 'Unable to query results, with error: [{0:d}] {1!s}'.format( - response.status_code, response.reason)) + _error_message( + response, message='Unable to query results', + error=ValueError) response_json = response.json() @@ -694,10 +835,9 @@ def explore(self, break more_response = self.api.session.post(resource_url, json=form_data) if more_response.status_code != 200: - raise ValueError(( - 'Unable to query results, with error: ' - '[{0:d}] {1:s}').format( - response.status_code, response.reason)) + _error_message( + response, message='Unable to query results', + error=ValueError) more_response_json = more_response.json() count = len(more_response_json.get('objects', [])) total_count += count @@ -882,10 +1022,9 @@ def store_aggregation( response = self.api.session.post(resource_url, json=form_data) if response.status_code not in HTTP_STATUS_CODE_20X: - raise RuntimeError( - 'Error storing the aggregation, Error message: ' - '[{0:d}] {1:s} {2:s}'.format( - response.status_code, response.reason, response.text)) + _error_message( + response, message='Error storing the aggregation', + error=RuntimeError) response_dict = response.json() @@ -1128,9 +1267,8 @@ def _run_aggregator( response = self.api.session.post(resource_url, json=form_data) if response.status_code != 200: - raise ValueError( - 'Unable to query results, with error: [{0:d}] {1:s}'.format( - response.status_code, response.reason)) + _error_message( + response, message='Unable to query results', error=ValueError) return response.json() @@ -1182,9 +1320,8 @@ def from_explore(self, aggregate_dsl): response = self.api.session.post(resource_url, json=form_data) if response.status_code != 200: - raise ValueError( - 'Unable to query results, with error: [{0:d}] {1:s}'.format( - response.status_code, response.reason)) + _error_message( + response, message='Unable to query results', error=ValueError) self.resource_data = response.json() diff --git a/config/travis/install.sh b/config/travis/install.sh index 5678cfb70a..cc75398a22 100755 --- a/config/travis/install.sh +++ b/config/travis/install.sh @@ -5,11 +5,11 @@ # This file is generated by l2tdevtools update-dependencies.py any dependency # related changes should be made in dependencies.ini. -DPKG_PYTHON2_DEPENDENCIES="python-alembic python-altair python-amqp python-aniso8601 python-asn1crypto python-attr python-bcrypt python-billiard python-blinker python-bs4 python-celery python-certifi python-cffi python-chardet python-click python-configparser python-cryptography python-datasketch python-dateutil python-editor python-elasticsearch python-entrypoints python-enum34 python-flask python-flask-bcrypt python-flask-login python-flask-migrate python-flask-restful python-flask-script python-flask-sqlalchemy python-flask-wtf python-gunicorn python-idna python-ipaddress python-itsdangerous python-jinja2 python-jsonschema python-jwt python-kombu python-mako python-markupsafe python-neo4jrestclient python-numpy python-pandas python-parameterized python-pycparser python-pyrsistent python-redis python-requests python-six python-sqlalchemy python-toolz python-typing python-tz python-urllib3 python-vine python-werkzeug python-wtforms python-yaml"; +DPKG_PYTHON2_DEPENDENCIES="python-alembic python-altair python-amqp python-aniso8601 python-asn1crypto python-attr python-bcrypt python-billiard python-blinker python-bs4 python-celery python-certifi python-cffi python-chardet python-click python-configparser python-cryptography python-datasketch python-dateutil python-editor python-elasticsearch python-entrypoints python-enum34 python-flask python-flask-bcrypt python-flask-login python-flask-migrate python-flask-restful python-flask-script python-flask-sqlalchemy python-flask-wtf python-gunicorn python-idna python-ipaddress python-itsdangerous python-jinja2 python-jsonschema python-jwt python-kombu python-mako python-markupsafe python-neo4jrestclient python-numpy python-pandas python-parameterized python-pycparser python-pyrsistent python-redis python-requests python-six python-sqlalchemy python-toolz python-typing python-tz python-urllib3 python-vine python-werkzeug python-wtforms python-yaml python-oauthlib python-google-auth"; DPKG_PYTHON2_TEST_DEPENDENCIES="python-flask-testing python-funcsigs python-mock python-nose python-pip python-pbr python-setuptools"; -DPKG_PYTHON3_DEPENDENCIES="python3-alembic python3-altair python3-amqp python3-aniso8601 python3-asn1crypto python3-attr python3-bcrypt python3-billiard python3-blinker python3-bs4 python3-celery python3-certifi python3-cffi python3-chardet python3-click python3-cryptography python3-datasketch python3-dateutil python3-editor python3-elasticsearch python3-entrypoints python3-flask python3-flask-bcrypt python3-flask-login python3-flask-migrate python3-flask-restful python3-flask-script python3-flask-sqlalchemy python3-flask-wtf python3-gunicorn python3-idna python3-ipaddress python3-itsdangerous python3-jinja2 python3-jsonschema python3-jwt python3-kombu python3-mako python3-markupsafe python3-neo4jrestclient python3-numpy python3-pandas python3-parameterized python3-pycparser python3-pyrsistent python3-redis python3-requests python3-six python3-sqlalchemy python3-toolz python3-tz python3-urllib3 python3-vine python3-werkzeug python3-wtforms python3-yaml"; +DPKG_PYTHON3_DEPENDENCIES="python3-alembic python3-altair python3-amqp python3-aniso8601 python3-asn1crypto python3-attr python3-bcrypt python3-billiard python3-blinker python3-bs4 python3-celery python3-certifi python3-cffi python3-chardet python3-click python3-cryptography python3-datasketch python3-dateutil python3-editor python3-elasticsearch python3-entrypoints python3-flask python3-flask-bcrypt python3-flask-login python3-flask-migrate python3-flask-restful python3-flask-script python3-flask-sqlalchemy python3-flask-wtf python3-gunicorn python3-idna python3-ipaddress python3-itsdangerous python3-jinja2 python3-jsonschema python3-jwt python3-kombu python3-mako python3-markupsafe python3-neo4jrestclient python3-numpy python3-pandas python3-parameterized python3-pycparser python3-pyrsistent python3-redis python3-requests python3-six python3-sqlalchemy python3-toolz python3-tz python3-urllib3 python3-vine python3-werkzeug python3-wtforms python3-yaml python3-oauthlib python3-google-auth"; DPKG_PYTHON3_TEST_DEPENDENCIES="python3-distutils python3-flask-testing python3-mock python3-nose python3-pip python3-pbr python3-setuptools"; diff --git a/data/timesketch.conf b/data/timesketch.conf index ed04f4c3aa..2bafb384b9 100644 --- a/data/timesketch.conf +++ b/data/timesketch.conf @@ -98,6 +98,12 @@ GOOGLE_OIDC_ENABLED = False GOOGLE_OIDC_CLIENT_ID = None GOOGLE_OIDC_CLIENT_SECRET = None +# If you need to authenticate an API client using OIDC you need to create +# an OAUTH client for "other", or for native applications. +# https://developers.google.com/identity/protocols/OAuth2ForDevices +GOOGLE_OIDC_API_CLIENT_ID = None +GOOGLE_OIDC_API_CLIENT_SECRET = None + # Limit access to a specific Google GSuite domain. GOOGLE_OIDC_HOSTED_DOMAIN = None diff --git a/requirements.txt b/requirements.txt index 3b1068ec17..9581fd6836 100644 --- a/requirements.txt +++ b/requirements.txt @@ -61,3 +61,6 @@ werkzeug==0.14.1 # via flask wrapt==1.10.11 # via astroid wtforms==2.1 # via flask-wtf xlrd==1.2.0 +google_auth_oauthlib==0.4.1 +oauthlib==3.1.0 +google-auth==1.7.0 diff --git a/timesketch/lib/google_auth.py b/timesketch/lib/google_auth.py index a5097143ff..ac6f32d2c5 100644 --- a/timesketch/lib/google_auth.py +++ b/timesketch/lib/google_auth.py @@ -184,36 +184,50 @@ def get_encoded_jwt_over_https(code): return encoded_jwt -def validate_jwt(encoded_jwt, public_key, algorithm, expected_audience, - expected_issuer, expected_domain=None): - """Decode and validate a JSON Web token (JWT). - - Cloud IAP: - https://cloud.google.com/iap/docs/signed-headers-howto - - Google OpenID Connect: - https://developers.google.com/identity/protocols/OpenIDConnect +def decode_jwt(encoded_jwt, public_key, algorithm, expected_audience): + """Decode a JSON Web Token (JWT). Args: encoded_jwt: The contents of the X-Goog-IAP-JWT-Assertion header. public_key: Key to verify signature of the JWT. algorithm: Algorithm used for the key. E.g. ES256, RS256 expected_audience: Expected audience in the JWT. - expected_issuer: Expected issuer of the JWT. - expected_domain: Expected GSuite domain in the JWT (optional). Returns: - Decoded JWT on successful validation. + Decoded JWT as a dict object. + + Raises: + JwtValidationError: if the JWT token cannot be decoded. """ - # Decode the token and verify its payload. try: decoded_jwt = jwt.decode( encoded_jwt, public_key, algorithm=algorithm, audience=expected_audience) + return decoded_jwt except (jwt.exceptions.InvalidTokenError, jwt.exceptions.InvalidKeyError) as e: raise JwtValidationError('JWT validation error: {}'.format(e)) + return None + + +def validate_jwt(decoded_jwt, expected_issuer, expected_domain=None): + """Decode and validate a JSON Web token (JWT). + + Cloud IAP: + https://cloud.google.com/iap/docs/signed-headers-howto + + Google OpenID Connect: + https://developers.google.com/identity/protocols/OpenIDConnect + + Args: + decoded_jwt: A dict object containing the decoded JWT token. + expected_issuer: Expected issuer of the JWT. + expected_domain: Expected GSuite domain in the JWT (optional). + + Raises: + JwtValidationError: If unable to validate the JWT. + """ # Make sure the token is not created in the future or has expired. try: now = int(time.time()) @@ -245,9 +259,6 @@ def validate_jwt(encoded_jwt, public_key, algorithm, expected_audience, except KeyError as e: raise JwtValidationError('Missing domain: {}'.format(e)) - # If everything checks out, return the decoded token. - return decoded_jwt - def get_public_key_for_jwt(encoded_jwt, url): """Get public key for JWT in order to verify the signature. diff --git a/timesketch/lib/google_auth_test.py b/timesketch/lib/google_auth_test.py index 928490128b..89a9c7e0c6 100644 --- a/timesketch/lib/google_auth_test.py +++ b/timesketch/lib/google_auth_test.py @@ -22,6 +22,7 @@ from cryptography.hazmat.backends.openssl.rsa import _RSAPublicKey from timesketch.lib.testlib import BaseTest +from timesketch.lib.google_auth import decode_jwt from timesketch.lib.google_auth import validate_jwt from timesketch.lib.google_auth import get_public_key_for_jwt from timesketch.lib.google_auth import JwtValidationError @@ -210,9 +211,10 @@ def _test_payload_raises_jwt_validation_error(self, payload, domain=None): audience=IAP_VALID_AUDIENCE, issuer=IAP_VALID_ISSUER, payload=payload) public_key = get_public_key_for_jwt(test_jwt, IAP_PUBLIC_KEY_URL) - self.assertRaises( - JwtValidationError, validate_jwt, test_jwt, public_key, - IAP_JWT_ALGORITHM, IAP_VALID_AUDIENCE, IAP_VALID_ISSUER, domain) + with self.assertRaises(JwtValidationError): + test_decoded_jwt = decode_jwt( + test_jwt, public_key, IAP_JWT_ALGORITHM, IAP_VALID_AUDIENCE) + validate_jwt(test_decoded_jwt, IAP_VALID_ISSUER, domain) def _test_header_raises_jwt_validation_error(self, header): """Test JWT with supplied header.""" @@ -220,9 +222,11 @@ def _test_header_raises_jwt_validation_error(self, header): MOCK_EC_PRIVATE_KEY, algorithm=IAP_JWT_ALGORITHM, key_id='iap_1234', audience=IAP_VALID_AUDIENCE, issuer=IAP_VALID_ISSUER, header=header) public_key = get_public_key_for_jwt(test_jwt, IAP_PUBLIC_KEY_URL) - self.assertRaises( - JwtValidationError, validate_jwt, test_jwt, public_key, - IAP_JWT_ALGORITHM, IAP_VALID_AUDIENCE, IAP_VALID_ISSUER) + + with self.assertRaises(JwtValidationError): + test_decoded_jwt = decode_jwt( + test_jwt, public_key, IAP_JWT_ALGORITHM, IAP_VALID_AUDIENCE) + validate_jwt(test_decoded_jwt, IAP_VALID_ISSUER) def test_valid_jwt(self): """Test to validate a valid JWT.""" @@ -230,11 +234,11 @@ def test_valid_jwt(self): MOCK_EC_PRIVATE_KEY, algorithm=IAP_JWT_ALGORITHM, key_id='iap_1234', audience=IAP_VALID_AUDIENCE, issuer=IAP_VALID_ISSUER) public_key = get_public_key_for_jwt(test_jwt, IAP_PUBLIC_KEY_URL) - valid_jwt = validate_jwt( - test_jwt, public_key, IAP_JWT_ALGORITHM, IAP_VALID_AUDIENCE, - IAP_VALID_ISSUER) - self.assertIsInstance(valid_jwt, dict) - self.assertEqual(valid_jwt.get('email'), 'test@example.com') + test_decoded_jwt = decode_jwt( + test_jwt, public_key, IAP_JWT_ALGORITHM, IAP_VALID_AUDIENCE) + validate_jwt(test_decoded_jwt, IAP_VALID_ISSUER) + self.assertIsInstance(test_decoded_jwt, dict) + self.assertEqual(test_decoded_jwt.get('email'), 'test@example.com') def test_invalid_audience_raises_jwt_validation_error(self): """Test to validate a JWT with wrong audience.""" @@ -243,8 +247,8 @@ def test_invalid_audience_raises_jwt_validation_error(self): audience=IAP_VALID_AUDIENCE, issuer=IAP_VALID_ISSUER) public_key = get_public_key_for_jwt(test_jwt, IAP_PUBLIC_KEY_URL) self.assertRaises( - JwtValidationError, validate_jwt, test_jwt, public_key, - IAP_JWT_ALGORITHM, IAP_INVALID_AUDIENCE, IAP_VALID_ISSUER) + JwtValidationError, decode_jwt, test_jwt, public_key, + IAP_JWT_ALGORITHM, IAP_INVALID_AUDIENCE) def test_invalid_algorithm_raises_jwt_validation_error(self): """Test to validate a JWT with invalid algorithm.""" @@ -298,11 +302,11 @@ def test_valid_domain(self): MOCK_EC_PRIVATE_KEY, algorithm=IAP_JWT_ALGORITHM, key_id='iap_1234', audience=IAP_VALID_AUDIENCE, issuer=IAP_VALID_ISSUER) public_key = get_public_key_for_jwt(test_jwt, IAP_PUBLIC_KEY_URL) - valid_jwt = validate_jwt( - test_jwt, public_key, IAP_JWT_ALGORITHM, IAP_VALID_AUDIENCE, - IAP_VALID_ISSUER, valid_domain) - self.assertIsInstance(valid_jwt, dict) - self.assertEqual(valid_jwt.get('hd'), 'example.com') + test_decoded_jwt = decode_jwt( + test_jwt, public_key, IAP_JWT_ALGORITHM, IAP_VALID_AUDIENCE) + validate_jwt(test_decoded_jwt, IAP_VALID_ISSUER, valid_domain) + self.assertIsInstance(test_decoded_jwt, dict) + self.assertEqual(test_decoded_jwt.get('hd'), 'example.com') def test_invalid_domain_raises_jwt_validation_error(self): """Test to validate a JWT with an invalid domain.""" @@ -316,9 +320,8 @@ def test_invalid_public_key_raises_jwt_validation_error(self): MOCK_EC_PRIVATE_KEY, algorithm=IAP_JWT_ALGORITHM, key_id='iap_1234', audience=IAP_VALID_AUDIENCE, issuer=IAP_VALID_ISSUER) self.assertRaises( - JwtValidationError, validate_jwt, test_jwt, - MOCK_INVALID_EC_PUBLIC_KEY, IAP_JWT_ALGORITHM, IAP_VALID_AUDIENCE, - IAP_VALID_ISSUER) + JwtValidationError, decode_jwt, test_jwt, + MOCK_INVALID_EC_PUBLIC_KEY, IAP_JWT_ALGORITHM, IAP_VALID_AUDIENCE) @mock.patch( @@ -342,8 +345,9 @@ def test_valid_oidc_jwt(self): key_id='oidc_1234', audience=OIDC_VALID_AUDIENCE, issuer=OIDC_VALID_ISSUER) public_key = get_public_key_for_jwt(test_jwt, OIDC_PUBLIC_KEY_URL) - valid_jwt = validate_jwt( - test_jwt, public_key, OIDC_JWT_ALGORITHM, OIDC_VALID_AUDIENCE, - OIDC_VALID_ISSUER) - self.assertIsInstance(valid_jwt, dict) - self.assertEqual(valid_jwt.get('email'), 'test@example.com') + test_decoded_jwt = decode_jwt( + test_jwt, public_key, OIDC_JWT_ALGORITHM, OIDC_VALID_AUDIENCE) + validate_jwt(test_decoded_jwt, OIDC_VALID_ISSUER) + + self.assertIsInstance(test_decoded_jwt, dict) + self.assertEqual(test_decoded_jwt.get('email'), 'test@example.com') diff --git a/timesketch/views/auth.py b/timesketch/views/auth.py index 90c1ccb83b..9848a22a31 100644 --- a/timesketch/views/auth.py +++ b/timesketch/views/auth.py @@ -15,6 +15,8 @@ from __future__ import unicode_literals +import requests + from flask import abort from flask import Blueprint from flask import current_app @@ -27,13 +29,17 @@ from flask_login import login_user from flask_login import logout_user +from oauthlib import oauth2 + from timesketch.lib.definitions import HTTP_STATUS_CODE_UNAUTHORIZED from timesketch.lib.definitions import HTTP_STATUS_CODE_BAD_REQUEST +from timesketch.lib.definitions import HTTP_STATUS_CODE_OK from timesketch.lib.forms import UsernamePasswordForm from timesketch.lib.google_auth import get_public_key_for_jwt from timesketch.lib.google_auth import get_oauth2_discovery_document from timesketch.lib.google_auth import get_oauth2_authorize_url from timesketch.lib.google_auth import get_encoded_jwt_over_https +from timesketch.lib.google_auth import decode_jwt from timesketch.lib.google_auth import validate_jwt from timesketch.lib.google_auth import JwtValidationError from timesketch.lib.google_auth import JwtKeyError @@ -47,6 +53,13 @@ # Register flask blueprint auth_views = Blueprint('user_views', __name__) +PROFILE_URI = 'https://www.googleapis.com/oauth2/v3/userinfo' +TOKEN_URI = 'https://www.googleapis.com/oauth2/v3/tokeninfo' +SCOPES = [ + 'https://www.googleapis.com/auth/userinfo.email', + 'openid', + 'https://www.googleapis.com/auth/userinfo.profile'] + @auth_views.route('/login/', methods=['GET', 'POST']) def login(): @@ -80,15 +93,15 @@ def login(): url = current_app.config.get('GOOGLE_IAP_PUBLIC_KEY_URL') try: public_key = get_public_key_for_jwt(encoded_jwt, url) - validated_jwt = validate_jwt( - encoded_jwt, public_key, algorithm, expected_audience, - expected_issuer) - email = validated_jwt.get('email') + decoded_jwt = decode_jwt( + encoded_jwt, public_key, algorithm, expected_audience) + validate_jwt(decoded_jwt, expected_issuer) + email = decoded_jwt.get('email') if email: user = User.get_or_create(username=email, name=email) login_user(user) - except (ImportError, NameError, UnboundLocalError): # pylint: disable=try-except-raise + except (ImportError, NameError, UnboundLocalError): raise except (JwtValidationError, JwtKeyError, Exception) as e: # pylint: disable=broad-except @@ -156,6 +169,124 @@ def logout(): return redirect(url_for('user_views.login')) +@auth_views.route('/login/api_callback/', methods=['GET']) +def validate_api_token(): + """Handler for logging in using an authenticated session for the API. + + Returns: + A simple page indicating the user is authenticated. + """ + try: + token = oauth2.rfc6749.tokens.get_token_from_header(request) + except AttributeError: + token = None + + if not token: + return abort( + HTTP_STATUS_CODE_UNAUTHORIZED, 'Request not authenticated.') + + client_id = current_app.config.get('GOOGLE_OIDC_API_CLIENT_ID') + if not client_id: + return abort( + HTTP_STATUS_CODE_BAD_REQUEST, + 'No OIDC API client ID defined in the configuration file.') + + # Authenticating session, see more details here: + # https://www.oauth.com/oauth2-servers/signing-in-with-google/\ + # verifying-the-user-info/ + # Sending a request to Google to verify that the access token + # is valid, to be able to validate the session. + data = { + 'access_token': token} + token_response = requests.post(TOKEN_URI, data=data) + if token_response.status_code != HTTP_STATUS_CODE_OK: + return abort( + HTTP_STATUS_CODE_BAD_REQUEST, 'Unable to validate access token.') + token_json = token_response.json() + + verified = token_json.get('email_verified', False) + if not verified: + return abort( + HTTP_STATUS_CODE_UNAUTHORIZED, + 'Session not authenticated or account not verified') + + # Get additional information, see more details here: + # https://www.oauth.com/oauth2-servers/signing-in-with-google/\ + # verifying-the-user-info/ + # Getting user information, since JWT session using access token + # lacks some of the fields available if credential token is + # used (which is not available for use here). + authorization_header = request.headers.get('Authorization') + if authorization_header: + header = {'Authorization': authorization_header} + else: + header = {} + + profile_response = requests.get(PROFILE_URI, headers=header) + if profile_response.status_code != HTTP_STATUS_CODE_OK: + return abort( + HTTP_STATUS_CODE_BAD_REQUEST, 'Unable to validate profile.') + + profile = profile_response.json() + token_json['hd'] = profile.get('hd', '') + + expected_issuer = current_app.config.get('GOOGLE_IAP_ISSUER') + try: + validate_jwt(token_json, expected_issuer) + except (ImportError, NameError, UnboundLocalError): + raise + except (JwtValidationError, JwtKeyError, Exception) as e: # pylint: disable=broad-except + return abort( + HTTP_STATUS_CODE_UNAUTHORIZED, + 'Unable to validate the JWT token, with error: {0!s}.'.format(e)) + + read_client_id = token_json.get('aud', '') + if read_client_id != client_id: + return abort( + HTTP_STATUS_CODE_UNAUTHORIZED, + 'Client ID {0:s} does not match server configuration for ' + 'client'.format(read_client_id)) + + read_scopes = token_json.get('scope', '').split() + if not set(read_scopes) == set(SCOPES): + return abort( + HTTP_STATUS_CODE_UNAUTHORIZED, + 'Client scopes differ from what they should be (email, openid, ' + 'profile) = {} VS {}'.format(SCOPES, read_scopes)) + + user_whitelist = current_app.config.get('GOOGLE_OIDC_USER_WHITELIST') + validated_email = token_json.get('email') + + # Check if the authenticating user is part of the allowed domains. + domain_whitelist = current_app.config.get('GOOGLE_OIDC_HOSTED_DOMAIN') + if domain_whitelist: + _, _, domain = validated_email.partition('@') + if domain.lower() != domain_whitelist.lower(): + return abort( + HTTP_STATUS_CODE_UNAUTHORIZED, + 'Domain {0:s} is not allowed to authenticate against this ' + 'instance.'.format(domain)) + + # Check if the authenticating user is on the whitelist. + if user_whitelist: + if validated_email not in user_whitelist: + return abort( + HTTP_STATUS_CODE_UNAUTHORIZED, + 'Unauthorized request, user not in whitelist') + + user = User.get_or_create(username=validated_email, name=validated_email) + login_user(user) + + # Log the user in and setup the session. + if current_user.is_authenticated: + return """ +

Authenticated

+ """ + + return abort( + HTTP_STATUS_CODE_BAD_REQUEST, 'User is not authenticated.') + + @auth_views.route('/login/google_openid_connect/', methods=['GET']) def google_openid_connect(): """Handler for the Google OpenID Connect callback. @@ -170,27 +301,35 @@ def google_openid_connect(): if error: current_app.logger.error('OAuth2 flow error: {}'.format(error)) - return abort(HTTP_STATUS_CODE_BAD_REQUEST) + return abort( + HTTP_STATUS_CODE_BAD_REQUEST, + 'OAuth2 flow error: {0!s}'.format(error)) try: code = request.args['code'] client_csrf_token = request.args.get('state') server_csrf_token = session[CSRF_KEY] - except KeyError: - return abort(HTTP_STATUS_CODE_BAD_REQUEST) + except KeyError as e: + return abort( + HTTP_STATUS_CODE_BAD_REQUEST, + 'Client CSRF error, no CSRF key stored') if client_csrf_token != server_csrf_token: return abort(HTTP_STATUS_CODE_BAD_REQUEST, 'Invalid CSRF token') try: encoded_jwt = get_encoded_jwt_over_https(code) - except JwtFetchError: - return abort(HTTP_STATUS_CODE_BAD_REQUEST) + except JwtFetchError as e: + return abort( + HTTP_STATUS_CODE_BAD_REQUEST, + 'Jwt Fetch error, {0!s}'.format(e)) try: discovery_document = get_oauth2_discovery_document() - except DiscoveryDocumentError: - return abort(HTTP_STATUS_CODE_BAD_REQUEST) + except DiscoveryDocumentError as e: + return abort( + HTTP_STATUS_CODE_BAD_REQUEST, + 'Unable to discover document, with error: {0!s}'.format(e)) algorithm = discovery_document['id_token_signing_alg_values_supported'][0] expected_audience = current_app.config.get('GOOGLE_OIDC_CLIENT_ID') @@ -201,20 +340,25 @@ def google_openid_connect(): try: public_key = get_public_key_for_jwt( encoded_jwt, discovery_document['jwks_uri']) - validated_jwt = validate_jwt( - encoded_jwt, public_key, algorithm, expected_audience, - expected_issuer, expected_domain) + decoded_jwt = decode_jwt( + encoded_jwt, public_key, algorithm, expected_audience) + validate_jwt( + decoded_jwt, expected_issuer, expected_domain) except (JwtValidationError, JwtKeyError) as e: current_app.logger.error('{}'.format(e)) - return abort(HTTP_STATUS_CODE_UNAUTHORIZED) + return abort( + HTTP_STATUS_CODE_UNAUTHORIZED, + 'Unable to validate request, with error: {0!s}'.format(e)) - validated_email = validated_jwt.get('email') + validated_email = decoded_jwt.get('email') user_whitelist = current_app.config.get('GOOGLE_OIDC_USER_WHITELIST') # Check if the authenticating user is on the whitelist. if user_whitelist: if validated_email not in user_whitelist: - return abort(HTTP_STATUS_CODE_UNAUTHORIZED) + return abort( + HTTP_STATUS_CODE_UNAUTHORIZED, + 'Unauthorized request, user not in whitelist') user = User.get_or_create(username=validated_email, name=validated_email) login_user(user) @@ -223,4 +367,5 @@ def google_openid_connect(): if current_user.is_authenticated: return redirect(request.args.get('next') or '/') - return abort(HTTP_STATUS_CODE_BAD_REQUEST) + return abort( + HTTP_STATUS_CODE_BAD_REQUEST, 'User is not authenticated.')