Skip to content

Commit

Permalink
Merge pull request #54 from duosecurity/add_v2_authlog_url
Browse files Browse the repository at this point in the history
Add support for v2 authlog url
  • Loading branch information
xdesai authored Jun 15, 2018
2 parents f0fe991 + ea6189c commit 9892f70
Show file tree
Hide file tree
Showing 3 changed files with 217 additions and 13 deletions.
143 changes: 131 additions & 12 deletions duo_client/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@
from . import client
import six
import warnings
import time

USER_STATUS_ACTIVE = 'active'
USER_STATUS_BYPASS = 'bypass'
Expand All @@ -168,6 +169,22 @@
TOKEN_HOTP_8 = 'h8'
TOKEN_YUBIKEY = 'yk'

VALID_AUTHLOG_REQUEST_PARAMS = [
'mintime',
'maxtime',
'limit',
'sort',
'next_offset',
'event_types',
'reasons',
'results',
'users',
'applications',
'groups',
'factors',
'api_version'
]


class Admin(client.Client):
account_id = None
Expand Down Expand Up @@ -237,11 +254,19 @@ def get_administrator_log(self,
row['host'] = self.host
return response

def get_authentication_log(self,
mintime=0):
def get_authentication_log(self, api_version=1, **kwargs):
"""
Returns authentication log events.
api_version - The api version of the handler to use. Currently, the
default api version is v1, but the v1 api will be
deprecated in a future version of the Duo Admin API.
Please migrate to the v2 api at your earliest convenience.
For details on the differences between v1 and v2,
please see Duo's Admin API documentation. (Optional)
API Version v1:
mintime - Fetch events only >= mintime (to avoid duplicate
records that have already been fetched)
Expand All @@ -250,7 +275,6 @@ def get_authentication_log(self,
{'timestamp': <int:unix timestamp>,
'eventtype': "authentication",
'host': <str:host>,
'device': <str:device>,
'username': <str:username>,
'factor': <str:factor>,
'result': <str:result>,
Expand All @@ -264,21 +288,116 @@ def get_authentication_log(self,
}
}]
Raises RuntimeError on error.
API Version v2:
mintime (required) - Unix timestamp in ms; fetch records >= mintime
maxtime (required) - Unix timestamp in ms; fetch records <= mintime
limit - Number of results to limit to
next_offset - Used to grab the next set of results from a previous response
sort - Sort order to be applied
users - List of user ids to filter on
groups - List of group ids to filter on
applications - List of application ids to filter on
results - List of results to filter to filter on
reasons - List of reasons to filter to filter on
factors - List of factors to filter on
event_types - List of event_types to filter on
Returns:
{
"authlogs": [
{
"access_device": {
"ip": <str:ip address>,
"location": {
"city": <str:city>,
"state": <str:state>,
"country": <str:country
}
},
"application": {
"key": <str:application id>,
"name": <str:application name>
},
"auth_device": {
"ip": <str:ip address>,
"location": {
"city": <str:city>,
"state": <str:state>,
"country": <str:country
},
"name": <str:device name>
},
"event_type": <str:type>,
"factor": <str:factor,
"reason": <str:reason>,
"result": <str:result>,
"timestamp": <int:unix timestamp>,
"user": {
"key": <str:user id>,
"name": <str:user name>
}
}
],
"metadata": {
"next_offset": [
<str>,
<str>
],
"total_objects": <int>
}
}
Raises RuntimeError on error.
"""
# Sanity check mintime as unix timestamp, then transform to string
mintime = str(int(mintime))
params = {
'mintime': mintime,
}

if api_version not in [1,2]:
raise ValueError("Invalid API Version")

params = {}

if api_version == 1: #v1
params['mintime'] = kwargs['mintime'] if 'mintime' in kwargs else 0;
# Sanity check mintime as unix timestamp, then transform to string
params['mintime'] = '{:d}'.format(int(params['mintime']))
warnings.warn(
'The v1 Admin API for retrieving authentication log events '
'will be deprecated in a future release of the Duo Admin API. '
'Please migrate to the v2 API.',
DeprecationWarning)
else: #v2
for k in kwargs:
if kwargs[k] is not None and k in VALID_AUTHLOG_REQUEST_PARAMS:
params[k] = kwargs[k]

if 'mintime' not in params:
params['mintime'] = (int(time.time()) - 86400) * 1000
# Sanity check mintime as unix timestamp, then transform to string
params['mintime'] = '{:d}'.format(int(params['mintime']))


if 'maxtime' not in params:
params['maxtime'] = int(time.time()) * 1000
# Sanity check maxtime as unix timestamp, then transform to string
params['maxtime'] = '{:d}'.format(int(params['maxtime']))


response = self.json_api_call(
'GET',
'/admin/v1/logs/authentication',
'/admin/v{}/logs/authentication'.format(api_version),
params,
)
for row in response:
row['eventtype'] = 'authentication'
row['host'] = self.host

if api_version == 1:
for row in response:
row['eventtype'] = 'authentication'
row['host'] = self.host
else:
for row in response['authlogs']:
row['eventtype'] = 'authentication'
row['host'] = self.host
return response

def get_telephony_log(self,
Expand Down
32 changes: 32 additions & 0 deletions tests/test_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ def setUp(self):
self.client_list._connect = \
lambda: util.MockHTTPConnection(data_response_should_be_list=True)

# if you are wanting to get a response from a call to get
# authentication logs
self.client_authlog = duo_client.admin.Admin(
'test_ikey', 'test_akey', 'example.com')
self.client_authlog.account_id = 'DA012345678901234567'
self.client_authlog._connect = \
lambda: util.MockHTTPConnection(data_response_from_get_authlog=True)

# GET with no params
def test_get_users(self):
response = self.client.get_users()
Expand Down Expand Up @@ -185,6 +193,30 @@ def test_delete_bypass_code_by_id(self):
self.assertEqual(util.params_to_dict(args),
{'account_id': [self.client.account_id]})

def test_get_authentication_log_v1(self):
""" Test to get authentication log on version 1 api.
"""
response = self.client_list.get_authentication_log(api_version=1)[0]
uri, args = response['uri'].split('?')

self.assertEqual(response['method'], 'GET')
self.assertEqual(uri, '/admin/v1/logs/authentication')
self.assertEqual(
util.params_to_dict(args)['account_id'],
[self.client_list.account_id])

def test_get_authentication_log_v2(self):
""" Test to get authentication log on version 1 api.
"""
response = self.client_authlog.get_authentication_log(api_version=2)
uri, args = response['uri'].split('?')

self.assertEqual(response['method'], 'GET')
self.assertEqual(uri, '/admin/v2/logs/authentication')
self.assertEqual(
util.params_to_dict(args)['account_id'],
[self.client_authlog.account_id])

def test_get_groups(self):
""" Test for getting list of all groups
"""
Expand Down
55 changes: 54 additions & 1 deletion tests/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@ class MockHTTPConnection(object):
"""
status = 200 # success!

def __init__(self, data_response_should_be_list=False):
def __init__(self, data_response_should_be_list=False, data_response_from_get_authlog=False):
# if a response object should be a list rather than
# a dict, then set this flag to true
self.data_response_should_be_list = data_response_should_be_list
self.data_response_from_get_authlog = data_response_from_get_authlog

def dummy(self):
return self
Expand All @@ -40,6 +41,9 @@ def read(self):
if self.data_response_should_be_list:
response = [self.__dict__]

if self.data_response_from_get_authlog:
response['authlogs'] = []

return json.dumps({"stat":"OK", "response":response},
cls=MockObjectJsonEncoder)

Expand All @@ -56,3 +60,52 @@ def request(self, method, uri, body, headers):
v = v.decode('ascii')
self.headers[k] = v


class MockJsonObject(object):
def to_json(self):
return {'id': id(self)}

class CountingClient(duo_client.client.Client):
def __init__(self, *args, **kwargs):
super(CountingClient, self).__init__(*args, **kwargs)
self.counter = 0

def _make_request(self, *args, **kwargs):
self.counter += 1
return super(CountingClient, self)._make_request(*args, **kwargs)


class MockPagingHTTPConnection(MockHTTPConnection):
def __init__(self, objects=None):
if objects is not None:
self.objects = objects

def dummy(self):
return self

_connect = _disconnect = close = getresponse = dummy

def read(self):
metadata = {}
metadata['total_objects'] = len(self.objects)
if self.offset + self.limit < len(self.objects):
metadata['next_offset'] = self.offset + self.limit
if self.offset > 0:
metadata['prev_offset'] = max(self.offset-self.limit, 0)

return json.dumps(
{"stat":"OK",
"response": self.objects[self.offset: self.offset+self.limit],
"metadata": metadata},
cls=MockObjectJsonEncoder)

def request(self, method, uri, body, headers):
self.method = method
self.uri = uri
self.body = body
self.headers = headers
parsed = six.moves.urllib.parse.urlparse(uri)
params = six.moves.urllib.parse.parse_qs(parsed.query)

self.limit = int(params['limit'][0])
self.offset = int(params['offset'][0])

0 comments on commit 9892f70

Please sign in to comment.