Skip to content

Commit

Permalink
Adding OAUTH support to API client. (#1027)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
kiddinn authored and berggren committed Nov 12, 2019
1 parent 9c92715 commit 9312fde
Show file tree
Hide file tree
Showing 8 changed files with 402 additions and 93 deletions.
5 changes: 4 additions & 1 deletion api_client/python/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/',
Expand All @@ -39,6 +39,9 @@
install_requires=frozenset([
'pandas',
'requests',
'altair',
'xlrd',
'google-auth',
'google_auth_oauthlib',
'beautifulsoup4']),
)
195 changes: 166 additions & 29 deletions api_client/python/timesketch_api_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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':
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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']
Expand All @@ -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."""
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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()

Expand All @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()

Expand Down
4 changes: 2 additions & 2 deletions config/travis/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
6 changes: 6 additions & 0 deletions data/timesketch.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading

0 comments on commit 9312fde

Please sign in to comment.