Skip to content

Commit 92d90c9

Browse files
committed
Add option to authentication using private key
The TransIP API also allows the creation of an access token by using a private key. By allowing the user to specify either an access token or private key when initialising a new transip.TransIP client, the client could dynamically generate a new access token when a private key is used. Fixes #14 Signed-off-by: Roald Nefs <info@roaldnefs.com>
1 parent 7771f01 commit 92d90c9

3 files changed

Lines changed: 247 additions & 49 deletions

File tree

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def get_long_description() -> str:
2929
url="https://github.com/roaldnefs/python-transip",
3030
packages=find_packages(),
3131
include_package_data=True,
32-
install_requires=["requests>=2.25.1"],
32+
install_requires=["cryptography>=3.3.1", "requests>=2.25.1"],
3333
python_requires=">=3.6.12",
3434
entry_points={},
3535
classifiers=[

transip/__init__.py

Lines changed: 162 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,29 @@
33
# Copyright (C) 2020, 2021 Roald Nefs <info@roaldnefs.com>
44
#
55
# This file is part of python-transip.
6-
6+
#
77
# python-transip is free software: you can redistribute it and/or modify
88
# it under the terms of the GNU Lesser General Public License as published by
99
# the Free Software Foundation, either version 3 of the License, or
1010
# (at your option) any later version.
11-
11+
#
1212
# python-transip is distributed in the hope that it will be useful,
1313
# but WITHOUT ANY WARRANTY; without even the implied warranty of
1414
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1515
# GNU Lesser General Public License for more details.
16-
16+
#
1717
# You should have received a copy of the GNU Lesser General Public License
1818
# along with python-transip. If not, see <https://www.gnu.org/licenses/>.
1919
"""Wrapper for the TransIP API."""
2020

2121
import importlib
2222
import requests
23+
import base64
2324

2425
from typing import Dict, Optional, Any, Type
2526

2627
from transip.exceptions import TransIPHTTPError, TransIPParsingError
28+
from transip.utils import generate_message_signature, generate_nonce
2729

2830

2931
__title__ = "python-transip"
@@ -38,29 +40,41 @@ class TransIP:
3840
"""Represents a TransIP server connection.
3941
4042
Args:
43+
login (str): The TransIP username
4144
api_version (str): TransIP API version to use
4245
access_token (str): The TransIP API access token
46+
private_key (str): The content of the private key for accessing the
47+
TransIP API
48+
private_key_file (str): Path to the private key for accessing the
49+
TransIP API
4350
"""
4451

4552
def __init__(
4653
self,
54+
login: str = None,
4755
api_version: str = "6",
48-
access_token: Optional[str] = None
56+
access_token: Optional[str] = None,
57+
private_key: Optional[str] = None,
58+
private_key_file: Optional[str] = None,
4959
) -> None:
50-
5160
self._api_version: str = api_version
5261
self._url: str = f"https://api.transip.nl/v{api_version}"
53-
self._access_token: Optional[str] = access_token
5462

5563
# Headers to use when making a request to TransIP
5664
self.headers: Dict[str, str] = {
57-
"User-Agent": f"{__title__}/{__version__}",
58-
"Authorization": f"Bearer {access_token}"
65+
"User-Agent": f"{__title__}/{__version__}"
5966
}
6067

6168
# Initialize a session object for making requests
6269
self.session: requests.Session = requests.Session()
6370

71+
# Set authentication information
72+
self._login: str = login
73+
self._access_token: Optional[str] = access_token
74+
self._private_key: Optional[str] = private_key
75+
self._private_key_file: Optional[str] = private_key_file
76+
self._set_auth_info()
77+
6478
# Dynamically import the services for the specified API version
6579
objects = importlib.import_module(f"transip.v{api_version}.objects")
6680

@@ -91,14 +105,133 @@ def _get_headers(
91105
def _build_url(self, path: str) -> str:
92106
return f"{self._url}{path}"
93107

94-
def _send(
108+
def _request_access_token(self) -> str:
109+
"""
110+
Request an access token using the supplied private key.
111+
112+
Returns:
113+
str: The access token to use for authorization.
114+
115+
Raises:
116+
TransIPParsingError: If the requested access token couldn't be
117+
extracted from the API response.
118+
"""
119+
120+
url: str = self._build_url('/auth')
121+
payload: Dict[str, Any] = {
122+
"login": self._login,
123+
# The TransIP API requires that the length of the nonce is between
124+
# 6 and 32 characters
125+
"nonce": generate_nonce(32),
126+
# TODO(roaldnefs): Allow the creation of read-only access tokens
127+
"read_only": False,
128+
# TODO(roaldnefs): Allow the expiration time of the access token
129+
# to be overwritten
130+
# "expiration_time": "30 minutes",
131+
# TODO(roaldnefs): Allow a custom label to be specified when
132+
# generating a new access token
133+
# "label": "python-transip",
134+
# TODO(roaldnefs): Allow the access token to only be use from
135+
# whitelisted IP-addresses
136+
"global_key": False
137+
}
138+
139+
headers: Dict[str, str] = self.headers.copy()
140+
request: requests.Request = requests.Request("POST", url, headers=headers, json=payload)
141+
prepped: requests.PreparedRequest = self.session.prepare_request(request)
142+
143+
# Get the prepped body for signature generation
144+
body: str = prepped.body
145+
146+
# Generate a signature if the request body
147+
signature: str = generate_message_signature(body, self._private_key)
148+
149+
# Add 'Signature' header to the prepared request
150+
prepped.headers["Signature"] = signature
151+
152+
response: requests.Response = self.session.send(prepped)
153+
data = self._validate_response(response)
154+
155+
# Attempt to extract the access token from the result
156+
try:
157+
return data['token']
158+
except (AttributeError, KeyError) as exc:
159+
raise TransIPParsingError(
160+
"Failed to extract access token from the API response"
161+
) from exc
162+
163+
def _read_private_key(self) -> str:
164+
"""Read the private key from file.
165+
166+
Returns:
167+
str: The private key content
168+
169+
Raises:
170+
RuntimeError: If the private key file doesn't exist
171+
"""
172+
if os.path.exists(self._private_key_file):
173+
try:
174+
with open(self._private_key_file) as keyfile:
175+
return keyfile.read()
176+
except IOError as exc:
177+
raise RuntimeError("The private key couldn't be read") from exc
178+
else:
179+
raise RuntimeError("The private key doesn't exist")
180+
181+
def _set_auth_info(self) -> None:
182+
"""
183+
Set authentication information based upon the defined attributes.
184+
185+
Raises:
186+
ValueError: If the required attributes are not defined.
187+
"""
188+
if not self._access_token and not self._private_key and not self._private_key_file:
189+
raise ValueError(
190+
"At least one of access_token, private_key and "
191+
"private_key_file should be defined"
192+
)
193+
if self._access_token and self._private_key:
194+
raise ValueError(
195+
"Only one of access_token and private_key should be defined"
196+
)
197+
if self._access_token and self._private_key_file:
198+
raise ValueError(
199+
"Only one of access_token and private_key_file should be "
200+
"defined"
201+
)
202+
if self._private_key and self._private_key_file:
203+
raise ValueError(
204+
"Only one of private_key and private_key_file should be "
205+
"defined"
206+
)
207+
if self._private_key and not self._login:
208+
raise ValueError(
209+
"Both private_key and login should be defined"
210+
)
211+
if self._private_key_file and not self._login:
212+
raise ValueError(
213+
"Both private_key_file and login should be defined"
214+
)
215+
216+
# Read the private key from file
217+
if self._private_key_file:
218+
self._private_key = self._read_private_key()
219+
220+
# Use the private key to request a new access token
221+
if self._private_key:
222+
self._access_token = self._request_access_token()
223+
224+
# Set the 'Authorization' header
225+
self.headers["Authorization"] = f"Bearer {self._access_token}"
226+
227+
def request(
95228
self,
96229
method: str,
97230
path: str,
98231
data: Optional[Any] = None,
99232
json: Optional[Any] = None,
100233
params: Optional[Dict[str, Any]] = None
101-
) -> requests.Response:
234+
) -> Any:
102235
"""Make an HTTP request to the TransIP API.
103236
104237
Args:
@@ -109,10 +242,11 @@ def _send(
109242
params (dict): URL parameters to append to the URL
110243
111244
Returns:
112-
A requests response object.
245+
Returns the json-encoded content of a response, if any.
113246
114247
Raises:
115248
TransIPHTTPError: When the return code of the request is not 2xx
249+
TransIPParsingError: When the content couldn't be parsed as JSON
116250
"""
117251
url: str = self._build_url(path)
118252

@@ -131,9 +265,25 @@ def _send(
131265
request
132266
)
133267
response: requests.Response = self.session.send(prepped)
268+
return self._validate_response(response)
134269

270+
def _validate_response(self, response: requests.Response) -> Any:
271+
"""
272+
Validate the API response.
273+
274+
Raises:
275+
TransIPHTTPError: When the return code of the request is not 2xx
276+
TransIPParsingError: When the content couldn't be parsed as JSON
277+
"""
135278
if 200 <= response.status_code < 300:
136-
return response
279+
if response.text:
280+
try:
281+
return response.json()
282+
except Exception:
283+
raise TransIPParsingError(
284+
message="Failed to parse the API response as JSON"
285+
)
286+
return None
137287

138288
error_message = str(response.content)
139289
try:
@@ -148,42 +298,6 @@ def _send(
148298
response_code=response.status_code
149299
)
150300

151-
def request(
152-
self,
153-
method: str,
154-
path: str,
155-
data: Optional[Any] = None,
156-
json: Optional[Any] = None,
157-
params: Optional[Dict[str, Any]] = None
158-
) -> Any:
159-
"""Make an HTTP request to the TransIP API.
160-
161-
Args:
162-
method (str): HTTP method to use
163-
path (str): The path to append to the API URL
164-
data (dict): The body to attach to the request
165-
json (dict): The json body to attach to the request
166-
params (dict): URL parameters to append to the URL
167-
168-
Returns:
169-
Returns the json-encoded content of a response, if any.
170-
171-
Raises:
172-
TransIPHTTPError: When the return code of the request is not 2xx
173-
"""
174-
response: requests.Response = self._send(
175-
method, path, data=data, json=json, params=params
176-
)
177-
178-
if response.text:
179-
try:
180-
return response.json()
181-
except Exception:
182-
raise TransIPParsingError(
183-
message="Failed to parse the API response as JSON"
184-
)
185-
return None
186-
187301
def get(
188302
self,
189303
path: str,

transip/utils.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (C) 2021 Roald Nefs <info@roaldnefs.com>
4+
#
5+
# This file is part of python-transip.
6+
#
7+
# python-transip is free software: you can redistribute it and/or modify
8+
# it under the terms of the GNU Lesser General Public License as published by
9+
# the Free Software Foundation, either version 3 of the License, or
10+
# (at your option) any later version.
11+
#
12+
# python-transip is distributed in the hope that it will be useful,
13+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
# GNU Lesser General Public License for more details.
16+
#
17+
# You should have received a copy of the GNU Lesser General Public License
18+
# along with python-transip. If not, see <https://www.gnu.org/licenses/>.
19+
20+
import base64
21+
import os
22+
import secrets
23+
import string
24+
25+
from cryptography.hazmat.backends import default_backend
26+
from cryptography.hazmat.primitives import serialization
27+
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
28+
from cryptography.hazmat.primitives.hashes import SHA512
29+
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
30+
31+
32+
def load_rsa_private_key(key: str) -> RSAPrivateKey:
33+
"""
34+
Convert the private key string to RSAPrivateKey object.
35+
36+
Returns:
37+
RSAPrivateKey: The private RSA key.
38+
"""
39+
# Convert the key string to bytes
40+
if isinstance(key, str):
41+
key: bytes = key.encode()
42+
43+
return serialization.load_pem_private_key(
44+
key, password=None, backend=default_backend()
45+
)
46+
47+
48+
def generate_message_signature(message: str, private_key: str) -> str:
49+
"""Return the BASE64 encoded SHA514 signature of a message.
50+
51+
Args:
52+
message (str): The message to sign.
53+
private_key (str): The private key content used to sign the message.
54+
55+
Returns:
56+
str: The BASE64 encoded SHA514 signature of a message.
57+
"""
58+
# Convert the message string to bytes
59+
if isinstance(message, str):
60+
message: bytes = message.encode()
61+
62+
# Convert the private key content to a RSAPrivateKey object
63+
if isinstance(private_key, str):
64+
private_key: RSAPrivateKey = load_rsa_private_key(private_key)
65+
66+
# Sign the message using the RSAPrivateKey object
67+
signature: str = private_key.sign(message, PKCS1v15(), SHA512())
68+
69+
# Return the BASE64 encoded SHA512 signature
70+
return base64.b64encode(signature)
71+
72+
73+
def generate_nonce(length: int) -> str:
74+
"""
75+
Generate a nonce.
76+
77+
Args:
78+
length (int): The number of characters to return.
79+
80+
Returns:
81+
str: The nonce of specified characters.
82+
"""
83+
alphabet = string.ascii_letters + string.digits
84+
return ''.join(secrets.choice(alphabet) for i in range(length))

0 commit comments

Comments
 (0)