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
2121import importlib
2222import requests
23+ import base64
2324
2425from typing import Dict , Optional , Any , Type
2526
2627from 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 ,
0 commit comments