Skip to content

Commit 42ddff6

Browse files
author
Glenn Snyder
authored
Merge pull request #129 from AR-Calder/authentication-overhaul
Authentication overhaul
2 parents 4debffb + 152d3bd commit 42ddff6

File tree

9 files changed

+429
-15
lines changed

9 files changed

+429
-15
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ celerybeat-schedule
8686
.venv
8787
env/
8888
venv/
89+
virtualenv/
8990
ENV/
9091
env.bak/
9192
venv.bak/

README.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,18 @@ pip install blackduck
1313
```
1414

1515
```python
16-
from blackduck.HubRestApi import HubInstance
16+
from blackduck import Client
1717
import json
1818

19-
username = "sysadmin"
20-
password = "your-password"
21-
urlbase = "https://ec2-34-201-23-208.compute-1.amazonaws.com"
19+
bd = Client(
20+
token=os.environ.get('blackduck_token', 'YOUR TOKEN HERE'),
21+
base_url='https://your.blackduck.url' #!important! no trailing slash
22+
#, verify=False # if required
23+
)
2224

23-
hub = HubInstance(urlbase, username, password, insecure=True)
25+
for project in bd.get_projects():
26+
print(project.get('name')
2427

25-
projects = hub.get_projects()
26-
27-
print(json.dumps(projects.get('items', [])))
2828
```
2929

3030
### Examples

blackduck/Authentication.py

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,95 @@
1-
import logging
1+
'''
2+
3+
Created on Dec 23, 2020
4+
@author: ar-calder
5+
6+
'''
7+
28
import requests
9+
import logging
310
import json
4-
from operator import itemgetter
5-
import urllib.parse
11+
from datetime import datetime, timedelta
12+
13+
logger = logging.getLogger(__name__)
14+
15+
class BearerAuth(requests.auth.AuthBase):
16+
17+
from .Exceptions import http_exception_handler
18+
19+
def __init__(
20+
self,
21+
session=None,
22+
token=None,
23+
base_url=None,
24+
verify=True,
25+
timeout=15,
26+
):
27+
28+
if any(arg == False for arg in (token, base_url)):
29+
raise ValueError(
30+
'token & base_url are required'
31+
)
32+
33+
self.verify=verify
34+
self.client_token = token
35+
self.auth_token = None
36+
self.csrf_token = None
37+
self.valid_until = datetime.utcnow()
38+
39+
self.auth_url = requests.compat.urljoin(base_url, '/api/tokens/authenticate')
40+
self.session = session or requests.session()
41+
self.timeout = timeout
42+
43+
44+
def __call__(self, request):
45+
if not self.auth_token or self.valid_until < datetime.utcnow():
46+
# If authentication token not set or no longer valid
47+
self.authenticate()
48+
49+
request.headers.update({
50+
"authorization" : f"bearer {self.auth_token}",
51+
"X-CSRF-TOKEN" : self.csrf_token
52+
})
53+
54+
return request
55+
56+
57+
def authenticate(self):
58+
if not self.verify:
59+
requests.packages.urllib3.disable_warnings()
60+
# Announce this on every auth attempt, as a little incentive to properly configure certs
61+
logger.warn("ssl verification disabled, connection insecure. do NOT use verify=False in production!")
62+
63+
try:
64+
response = self.session.request(
65+
method='POST',
66+
url=self.auth_url,
67+
headers = {
68+
"Authorization" : f"token {self.client_token}"
69+
},
70+
verify=self.verify,
71+
timeout=self.timeout
72+
)
73+
74+
if response.status_code / 100 != 2:
75+
self.http_exception_handler(
76+
response=response,
77+
name="authenticate"
78+
)
79+
80+
content = response.json()
81+
self.csrf_token = response.headers.get('X-CSRF-TOKEN')
82+
self.auth_token = content.get('bearerToken')
83+
self.valid_until = datetime.utcnow() + timedelta(milliseconds=int(content.get('expiresInMilliseconds', 0)))
684

7-
logger = logging.getLogger(__name__)
85+
# Do not handle exceptions - just just more details as to possible causes
86+
# Thus we do not catch a JsonDecodeError here even though it may occur
87+
# - no futher details to give.
88+
except requests.exceptions.ConnectTimeout as connect_timeout:
89+
logger.critical(f"could not establish a connection within {self.timeout}s, this may be indicative of proxy misconfiguration")
90+
raise connect_timeout
91+
except requests.exceptions.ReadTimeout as read_timeout:
92+
logger.critical(f"slow or unstable connection, consider increasing timeout (currently set to {self.timeout}s)")
93+
raise read_timeout
94+
else:
95+
logger.info(f"success: auth granted until {self.valid_until} UTC")

blackduck/Client.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
'''
2+
Created on Dec 23, 2020
3+
@author: ar-calder
4+
5+
Wrapper for common HUB API queries.
6+
Upon initialization Bearer token is obtained and used for all subsequent calls.
7+
Token will auto-renew on timeout.
8+
'''
9+
10+
from .Utils import find_field, safe_get
11+
from .Authentication import BearerAuth
12+
import logging
13+
import requests
14+
logger = logging.getLogger(__name__)
15+
16+
class Client:
17+
VERSION_DISTRIBUTION=["EXTERNAL", "SAAS", "INTERNAL", "OPENSOURCE"]
18+
VERSION_PHASES = ["PLANNING", "DEVELOPMENT", "PRERELEASE", "RELEASED", "DEPRECATED", "ARCHIVED"]
19+
PROJECT_VERSION_SETTINGS = ['nickname', 'releaseComments', 'versionName', 'phase', 'distribution', 'releasedOn']
20+
21+
from .Exceptions import(
22+
http_exception_handler
23+
)
24+
25+
from .ClientCore import (
26+
_request, _get_items, _get_resource_href, get_resource, list_resources, _get_base_resource_url, get_base_resource, _get_parameter_string
27+
)
28+
29+
def __init__(
30+
self,
31+
*args,
32+
token=None,
33+
base_url=None,
34+
session=None,
35+
auth=None,
36+
verify=True,
37+
timeout=15,
38+
**kwargs):
39+
40+
self.verify=verify
41+
self.timeout=int(timeout)
42+
self.base_url=base_url
43+
self.session = session or requests.session()
44+
self.auth = auth or BearerAuth(
45+
session = self.session,
46+
token=token,
47+
base_url=base_url,
48+
verify=self.verify
49+
)
50+
51+
def print_methods(self):
52+
import inspect
53+
for fn in inspect.getmembers(self, predicate=inspect.ismember):
54+
print(fn[0])
55+
56+
# Example for projects
57+
def get_projects(self, parameters=[], **kwargs):
58+
return self._get_items(
59+
method='GET',
60+
# url unlikely to change hence is_public=false (faster).
61+
url= self._get_base_resource_url('projects', is_public=False),
62+
name="project",
63+
**kwargs
64+
)
65+
66+
def get_project_by_name(self, project_name, **kwargs):
67+
projects = self.get_projects(**kwargs)
68+
return find_field(projects, 'name', project_name)

blackduck/ClientCore.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
'''
2+
Created on Dec 23, 2020
3+
@author: ar-calder
4+
5+
'''
6+
7+
import logging
8+
import requests
9+
import json
10+
11+
from .Utils import find_field, safe_get
12+
logger = logging.getLogger(__name__)
13+
14+
def _request(
15+
self,
16+
method,
17+
url,
18+
name='',
19+
parameters=[],
20+
**kwargs
21+
):
22+
"""[summary]
23+
24+
Args:
25+
method ([type]): [description]
26+
url ([type]): [description]
27+
name (str, optional): name of the reqested resource. Defaults to ''.
28+
29+
Raises:
30+
connect_timeout: often indicative of proxy misconfig
31+
read_timeout: often indicative of slow connection
32+
33+
Returns:
34+
json/dict/list: requested object, json decoded.
35+
"""
36+
37+
headers = {
38+
'accept' : 'application/json'
39+
}
40+
headers.update(kwargs.pop('headers', dict()))
41+
42+
if parameters:
43+
url += self._get_parameter_string(parameters)
44+
45+
try:
46+
response = self.session.request(
47+
method=method,
48+
url=url,
49+
headers=headers,
50+
verify=self.verify,
51+
auth=self.auth,
52+
**kwargs
53+
)
54+
55+
if response.status_code / 100 != 2:
56+
self.http_exception_handler(
57+
response=response,
58+
name=name
59+
)
60+
61+
response_json = response.json()
62+
63+
# Do not handle exceptions - just just more details as to possible causes
64+
# Thus we do not catch a JsonDecodeError here even though it may occur
65+
except requests.exceptions.ConnectTimeout as connect_timeout:
66+
logger.critical(f"could not establish a connection within {self.timeout}s, this may be indicative of proxy misconfiguration")
67+
raise connect_timeout
68+
except requests.exceptions.ReadTimeout as read_timeout:
69+
logger.critical(f"slow or unstable connection, consider increasing timeout (currently set to {self.timeout}s)")
70+
raise read_timeout
71+
else:
72+
return response_json
73+
74+
def _get_items(self, url, method='GET', page_size=100, name='', **kwargs):
75+
"""Utility method to get 'pages' of items
76+
77+
Args:
78+
url (str): [description]
79+
method (str, optional): [description]. Defaults to 'GET'.
80+
page_size (int, optional): [description]. Defaults to 100.
81+
name (str, optional): [description]. Defaults to ''.
82+
83+
Yields:
84+
[type]: [description]
85+
"""
86+
offset = 0
87+
params = kwargs.pop('params', dict())
88+
while True:
89+
params.update({'offset':f"{offset}", 'limit':f"{page_size}"})
90+
items = self._request(
91+
method=method,
92+
url=url,
93+
params=params,
94+
name=name,
95+
**kwargs
96+
).get('items', list())
97+
98+
for item in items:
99+
yield item
100+
101+
if len(items) < page_size:
102+
# This will be true if there are no more 'pages' to view
103+
break
104+
105+
offset += page_size
106+
107+
108+
def _get_resource_href(self, resources, resource_name):
109+
"""Utility function to get url for a given resource_name
110+
111+
Args:
112+
resources (dict/json): [description]
113+
resource_name (str): [description]
114+
115+
Raises:
116+
KeyError: on key not found
117+
118+
Returns:
119+
str: url to named resource
120+
"""
121+
res = find_field(
122+
data_to_filter=safe_get(resources, '_meta', 'links'),
123+
field_name='rel',
124+
field_value=resource_name
125+
)
126+
127+
if None == res:
128+
raise KeyError(f"'{self.get_resource_name(resources)}' object has no such key '{resource_name}'")
129+
return safe_get(res, 'href')
130+
131+
def get_resource(self, bd_object, resource_name, iterable=True, is_public=True, **kwargs):
132+
"""Generic function to facilitate subresource fetching
133+
134+
Args:
135+
bd_object (dict/json): [description]
136+
resource_name (str): [description]
137+
iterable (bool, optional): [description]. Defaults to True.
138+
is_public (bool, optional): [description]. Defaults to True.
139+
140+
Returns:
141+
dict/json: named resource object
142+
"""
143+
url = self._get_resource_href(resources=bd_object, resource_name=resource_name) if is_public else self.get_url(bd_object) + f"/{resource_name}"
144+
fn = self._get_items if iterable else self._request
145+
return fn(
146+
method='GET',
147+
url=url,
148+
name=resource_name,
149+
**kwargs
150+
)
151+
152+
def list_resources(self, bd_object):
153+
return [res.get('rel') for res in safe_get(bd_object, '_meta', 'links')]
154+
155+
def _get_base_resource_url(self, resource_name, is_public=True, **kwargs):
156+
if is_public:
157+
resources = self._request(
158+
method="GET",
159+
url=self.base_url + f"/api/",
160+
name='_get_base_resource_url',
161+
**kwargs
162+
)
163+
return resources.get(resource_name, "")
164+
else:
165+
return self.base_url + f"/api/{resource_name}"
166+
167+
def get_base_resource(self, resource_name, is_public=True, **kwargs):
168+
return self._request(
169+
method='GET',
170+
url=self._get_base_resource_url(resource_name, is_public=is_public, **kwargs),
171+
name='get_base_resource',
172+
**kwargs
173+
)
174+
175+
def _get_parameter_string(self, parameters=list()):
176+
return '?' + '&'.join(parameters) if parameters else ''

blackduck/Exceptions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ class EndpointNotFound(Exception):
3232
class UnacceptableContentType(Exception):
3333
pass
3434

35-
def exception_handler(self, response, name):
35+
def http_exception_handler(self, response, name):
3636
error_codes = {
3737
404 : EndpointNotFound,
3838
406 : UnacceptableContentType

0 commit comments

Comments
 (0)