Skip to content

Commit 270336d

Browse files
authored
Merge pull request #64 from Cumulocity-IoT/feature/interactive-sessions
Feature/interactive sessions
2 parents 9cb9acc + bbd17c5 commit 270336d

25 files changed

+1413
-59
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ build*
99
.env*
1010
_version.py
1111
wordlist.txt
12+
.ipynb_checkpoints

c8y_api/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@
66

77
from importlib.metadata import version
88

9-
from c8y_api._base_api import ProcessingMode, CumulocityRestApi, HttpError, UnauthorizedError, AccessDeniedError
9+
from c8y_api._base_api import (
10+
ProcessingMode,
11+
CumulocityRestApi,
12+
HttpError,
13+
UnauthorizedError,
14+
MissingTfaError,
15+
AccessDeniedError,
16+
)
1017
from c8y_api._main_api import CumulocityApi
1118
from c8y_api._registry_api import CumulocityDeviceRegistry
1219
from c8y_api._auth import HTTPBasicAuth, HTTPBearerAuth

c8y_api/_base_api.py

Lines changed: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from c8y_api._auth import HTTPBearerAuth
1818
from c8y_api._jwt import JWT
19+
from c8y_api._util import validate_base_url
1920

2021

2122
class ProcessingMode:
@@ -40,6 +41,12 @@ def __init__(self, method: str, url: str = None, message: str = "Unauthorized.")
4041
super().__init__(method, url, 401, message)
4142

4243

44+
class MissingTfaError(UnauthorizedError):
45+
"""Error raised for unauthorized access."""
46+
def __init__(self, method: str, url: str = None, message: str = "Missing TFA Token."):
47+
super().__init__(method, url, message)
48+
49+
4350
class AccessDeniedError(HttpError):
4451
"""Error raised for denied access."""
4552
def __init__(self, method: str, url: str = None, message: str = "Access denied."):
@@ -69,30 +76,28 @@ class CumulocityRestApi:
6976
CONTENT_MANAGED_OBJECT = 'application/vnd.com.nsn.cumulocity.managedobject+json'
7077
CONTENT_MEASUREMENT_COLLECTION = 'application/vnd.com.nsn.cumulocity.measurementcollection+json'
7178

72-
def __init__(self, base_url: str, tenant_id: str, username: str = None, password: str = None, tfa_token: str = None,
79+
def __init__(self, base_url: str, tenant_id: str, username: str = None, password: str = None,
7380
auth: AuthBase = None, application_key: str = None, processing_mode: str = None):
7481
"""Build a CumulocityRestApi instance.
7582
76-
One of `auth` or `username/password` must be provided. The TFA token
77-
parameter is only sensible for basic authentication.
83+
One of `auth` or `username/password` must be provided.
7884
7985
Args:
8086
base_url (str): Cumulocity base URL, e.g. https://cumulocity.com
8187
tenant_id (str): The ID of the tenant to connect to
8288
username (str): Username
8389
password (str): User password
84-
tfa_token (str): Currently valid two-factor authorization token
8590
auth (AuthBase): Authentication details
8691
application_key (str): Application ID to include in requests
8792
(for billing/metering purposes).
8893
processing_mode (str); Connection processing mode (see
8994
also https://cumulocity.com/api/core/#processing-mode)
9095
"""
91-
self.base_url = base_url.rstrip('/')
96+
self.base_url = validate_base_url(base_url)
97+
self.is_tls = self.base_url.startswith('https')
9298
self.tenant_id = tenant_id
9399
self.application_key = application_key
94100
self.processing_mode = processing_mode
95-
self.is_tls = self.base_url.startswith('https')
96101

97102
if auth:
98103
self.auth = auth
@@ -103,14 +108,49 @@ def __init__(self, base_url: str, tenant_id: str, username: str = None, password
103108
else:
104109
raise ValueError("One of 'auth' or 'username/password' must be defined.")
105110

106-
self.__default_headers = {}
107-
if tfa_token:
108-
self.__default_headers['tfatoken'] = tfa_token
109-
if self.application_key:
110-
self.__default_headers[self.HEADER_APPLICATION_KEY] = self.application_key
111-
if self.processing_mode:
112-
self.__default_headers[self.HEADER_PROCESSING_MODE] = self.processing_mode
113-
self.session = self._create_session()
111+
self._session = None
112+
113+
@property
114+
def session(self) -> requests.Session:
115+
"""Provide session."""
116+
if not self._session:
117+
self._session = self._create_session()
118+
return self._session
119+
120+
@classmethod
121+
def authenticate(
122+
cls,
123+
base_url: str,
124+
tenant_id: str,
125+
username: str,
126+
password: str,
127+
tfa_token: str = None,
128+
) -> (str, str):
129+
"""Authenticate a user using OAI Secure login method.
130+
131+
Args:
132+
base_url (str): Cumulocity base URL, e.g. https://cumulocity.com
133+
tenant_id (str): The ID of the tenant to connect to
134+
username (str): Username
135+
password (str): User password
136+
tfa_token (str): Currently valid two-factor authorization token
137+
138+
Returns:
139+
A string tuple of JWT auth token and corresponding XRSF token.
140+
"""
141+
url = f'{base_url.rstrip("/")}/tenant/oauth?tenant_id={tenant_id}'
142+
form_data = {'grant_type': 'PASSWORD', 'username': username, 'password': password, 'tfa_token': tfa_token}
143+
response = requests.post(url=url, data=form_data, timeout=60.0)
144+
if response.status_code == 401:
145+
message = response.json()['message'] if response.json() and 'message' in response.json() else None
146+
# 1st request might fail due to missing TFA code
147+
if any(x in response.json()['message'] for x in ['TOTP', 'TFA']):
148+
raise MissingTfaError(cls.METHOD_POST, response.url, message)
149+
raise UnauthorizedError(cls.METHOD_POST, response.url, message)
150+
if response.status_code != 200:
151+
message = response.json()['message'] if 'message' in response.json() else "Invalid request!"
152+
raise HttpError(cls.METHOD_POST, response.url, response.status_code, message)
153+
return response.cookies['authorization'], response.cookies['XSRF-TOKEN']
114154

115155
def _create_session(self) -> requests.Session:
116156
s = requests.Session()
@@ -136,10 +176,8 @@ def prepare_request(self, method: str, resource: str,
136176
Returns:
137177
A PreparedRequest instance
138178
"""
139-
hs = self.__default_headers
140-
if additional_headers:
141-
hs.update(additional_headers)
142-
rq = requests.Request(method=method, url=self.base_url + resource, headers=hs, auth=self.auth)
179+
headers = {**(self.session.headers or {}), **(additional_headers or {})}
180+
rq = requests.Request(method=method, url=self.base_url + resource, headers=headers, auth=self.auth)
143181
if json:
144182
rq.json = json
145183
return rq.prepare()

c8y_api/_main_api.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# and/or its subsidiaries and/or its affiliates and/or their licensors.
44
# Use, reproduction, transfer, publication or disclosure is prohibited except
55
# as specifically provided for in your License Agreement with Software AG.
6+
import contextlib
67

78
from requests.auth import AuthBase
89

@@ -23,7 +24,7 @@
2324
from c8y_api.model.tenants import Tenants
2425

2526

26-
class CumulocityApi(CumulocityRestApi):
27+
class CumulocityApi(CumulocityRestApi, contextlib.AbstractContextManager):
2728
"""Main Cumulocity API.
2829
2930
Provides usage centric access to a Cumulocity instance.
@@ -35,7 +36,6 @@ def __init__(
3536
tenant_id: str,
3637
username: str = None,
3738
password: str = None,
38-
tfa_token: str = None,
3939
auth: AuthBase = None,
4040
application_key: str = None,
4141
processing_mode: str = None,
@@ -45,7 +45,6 @@ def __init__(
4545
tenant_id,
4646
username=username,
4747
password=password,
48-
tfa_token=tfa_token,
4948
auth=auth,
5049
application_key=application_key,
5150
processing_mode=processing_mode,
@@ -169,3 +168,9 @@ def audit_records(self) -> AuditRecords:
169168
def tenants(self) -> Tenants:
170169
"""Provide access to the Audit API."""
171170
return self.__tenants
171+
172+
def __enter__(self) -> 'CumulocityApi':
173+
return self
174+
175+
def __exit__(self, __exc_type, __exc_value, __traceback):
176+
pass

c8y_api/_util.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,18 @@ def c8y_keys() -> Set[str]:
1616
Returns: A set of environment variable names, starting with 'C8Y_'
1717
"""
1818
return set(filter(lambda x: 'C8Y_' in x, os.environ.keys()))
19+
20+
21+
def validate_base_url(url) -> str:
22+
"""Ensure that a given url is a proper Cumulocity base url.
23+
24+
Args:
25+
url (str): A URL string with or without scheme, port, path
26+
27+
Returns:
28+
A URL string with scheme (default is https) and port but
29+
without trailing slash or even path.
30+
"""
31+
i = url.find('://') # scheme separator
32+
j = url.find('/', i + 3) # path separator
33+
return ('https://' if i == -1 else '') + (url if j == -1 else url[:j])

c8y_api/app/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,9 @@ def _build_user_instance(self, auth) -> CumulocityApi:
179179
return CumulocityApi(base_url=self.base_url, tenant_id=self.tenant_id, auth=auth,
180180
application_key=self.application_key, processing_mode=self.processing_mode)
181181

182+
def __enter__(self) -> SimpleCumulocityApp:
183+
return self
184+
182185

183186
class MultiTenantCumulocityApp(_CumulocityAppBase):
184187
"""Multi-tenant enabled Cumulocity application wrapper.
@@ -337,3 +340,9 @@ def _get_tenant_instance(self, tenant_id: str) -> CumulocityApi:
337340
instance = self._create_tenant_instance(tenant_id)
338341
self._tenant_instances[tenant_id] = instance
339342
return instance
343+
344+
def __enter__(self) -> MultiTenantCumulocityApp:
345+
return self
346+
347+
def __exit__(self, __exc_type, __exc_value, __traceback):
348+
pass

c8y_api/model/_base.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,13 @@
77
from __future__ import annotations
88

99
import logging
10-
from urllib.parse import quote_plus
1110
from typing import Any, Iterable, Set
11+
from urllib.parse import quote_plus, urlencode
1212

1313
from collections.abc import MutableMapping
1414
from deprecated import deprecated
15-
from urllib.parse import urlencode
1615

1716
from c8y_api._base_api import CumulocityRestApi
18-
1917
from c8y_api.model._util import _DateUtil, _StringUtil, _QueryUtil
2018

2119

@@ -402,6 +400,29 @@ def __iadd__(self, other):
402400
def __contains__(self, name):
403401
return name in self.fragments
404402

403+
def get(self, path: str, default=None):
404+
"""Get a fragment/value by path.
405+
406+
Args:
407+
path (str): A fragment/value path in dot notation, e.g.
408+
"c8y_Firmware.version"
409+
default: Sensible default if the path is not defined.
410+
411+
Returns:
412+
The fragment/value specified via the path or the default value
413+
if the path is not defined.
414+
"""
415+
segments = path.split('.')
416+
value = self
417+
for segment in segments:
418+
if segment in value:
419+
value = value[segment]
420+
continue
421+
if hasattr(value, segment):
422+
return value.__getattribute__(segment)
423+
return default
424+
return value
425+
405426
@deprecated
406427
def set_attribute(self, name, value):
407428
# pylint: disable=missing-function-docstring
@@ -475,7 +496,6 @@ def build_object_path(self, object_id: int | str) -> str:
475496
"""
476497
return self.resource + '/' + str(object_id)
477498

478-
479499
@staticmethod
480500
def _map_params(
481501
q=None,

c8y_api/model/inventory.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
from __future__ import annotations
99

1010
from typing import Any, Generator, List
11-
from urllib.parse import urlencode, quote_plus
1211

1312
from c8y_api.model._base import CumulocityResource
1413
from c8y_api.model._util import _QueryUtil

c8y_api/model/measurements.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import dataclasses
1010
from datetime import datetime, timedelta
1111
from typing import Type, List, Generator, Sequence
12-
from urllib.parse import urlencode, quote_plus
1312

1413
from c8y_api._base_api import CumulocityRestApi
1514

c8y_tk/interactive/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Copyright (c) 2020 Software AG,
2+
# Darmstadt, Germany and/or Software AG USA Inc., Reston, VA, USA,
3+
# and/or its subsidiaries and/or its affiliates and/or their licensors.
4+
# Use, reproduction, transfer, publication or disclosure is prohibited except
5+
# as specifically provided for in your License Agreement with Software AG.
6+
7+
8+
from c8y_tk.interactive.context import *
9+
10+
__all__ = ['CumulocityContext']

0 commit comments

Comments
 (0)