Passed
Push — master ( 2d3a5f...3cd106 )
by Oleksandr
13:57 queued 06:14
created

tabpy.tabpy_server.handlers.base_handler   B

Complexity

Total Complexity 51

Size/Duplication

Total Lines 432
Duplicated Lines 0 %

Test Coverage

Coverage 71.59%

Importance

Changes 0
Metric Value
wmc 51
eloc 203
dl 0
loc 432
ccs 126
cts 176
cp 0.7159
rs 7.92
c 0
b 0
f 0

19 Methods

Rating   Name   Duplication   Size   Complexity  
A BaseHandler.error_out() 0 15 1
B BaseHandler._get_auth_method() 0 63 7
A BaseHandler._validate_basic_auth_credentials() 0 28 3
A BaseHandler._get_credentials() 0 26 2
A BaseHandler.fail_with_not_authorized() 0 11 1
A BaseHandler._add_CORS_header() 0 19 4
A BaseHandler.should_fail_with_not_authorized() 0 14 1
A BaseHandler._validate_credentials() 0 26 2
A ContextLoggerWrapper.log() 0 28 3
A ContextLoggerWrapper._generate_call_id() 0 3 1
A ContextLoggerWrapper.__init__() 0 7 1
B BaseHandler._get_basic_auth_credentials() 0 41 6
A BaseHandler.handle_authentication() 0 31 4
A BaseHandler.options() 0 4 1
B ContextLoggerWrapper._log_context_info() 0 26 8
A ContextLoggerWrapper.set_tabpy_username() 0 2 1
A ContextLoggerWrapper.enable_context_logging() 0 12 1
A BaseHandler.initialize() 0 18 1
A ContextLoggerWrapper.set_request() 0 18 3

How to fix   Complexity   

Complexity

Complex classes like tabpy.tabpy_server.handlers.base_handler often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1 1
import base64
2 1
import binascii
3 1
import concurrent
4 1
import json
5 1
import logging
6 1
import tornado.web
7 1
from tabpy.tabpy_server.app.SettingsParameters import SettingsParameters
8 1
from tabpy.tabpy_server.handlers.util import hash_password
9 1
import uuid
10
11
12 1
STAGING_THREAD = concurrent.futures.ThreadPoolExecutor(max_workers=3)
13
14
15 1
class ContextLoggerWrapper:
16
    """
17
    This class appends request context to logged messages.
18
    """
19
20 1
    @staticmethod
21
    def _generate_call_id():
22 1
        return str(uuid.uuid4())
23
24 1
    def __init__(self, request: tornado.httputil.HTTPServerRequest):
25 1
        self.call_id = self._generate_call_id()
26 1
        self.set_request(request)
27
28 1
        self.tabpy_username = None
29 1
        self.log_request_context = False
30 1
        self.request_context_logged = False
31
32 1
    def set_request(self, request: tornado.httputil.HTTPServerRequest):
33
        """
34
        Set HTTP(S) request for logger. Headers will be used to
35
        append request data as client information, Tableau user name, etc.
36
        """
37 1
        self.remote_ip = request.remote_ip
38 1
        self.method = request.method
39 1
        self.url = request.full_url()
40
41 1
        if "TabPy-Client" in request.headers:
42 1
            self.client = request.headers["TabPy-Client"]
43
        else:
44 1
            self.client = None
45
46 1
        if "TabPy-User" in request.headers:
47
            self.tableau_username = request.headers["TabPy-User"]
48
        else:
49 1
            self.tableau_username = None
50
51 1
    def set_tabpy_username(self, tabpy_username: str):
52 1
        self.tabpy_username = tabpy_username
53
54 1
    def enable_context_logging(self, enable: bool):
55
        """
56
        Enable/disable request context information logging.
57
58
        Parameters
59
        ----------
60
        enable: bool
61
            If True request context information will be logged and
62
            every log entry for a request handler will have call ID
63
            with it.
64
        """
65 1
        self.log_request_context = enable
66
67 1
    def _log_context_info(self):
68
        if not self.log_request_context:
69
            return
70
71
        context = f"Call ID: {self.call_id}"
72
73
        if self.remote_ip is not None:
74
            context += f", Caller: {self.remote_ip}"
75
76
        if self.method is not None:
77
            context += f", Method: {self.method}"
78
79
        if self.url is not None:
80
            context += f", URL: {self.url}"
81
82
        if self.client is not None:
83
            context += f", Client: {self.client}"
84
85
        if self.tableau_username is not None:
86
            context += f", Tableau user: {self.tableau_username}"
87
88
        if self.tabpy_username is not None:
89
            context += f", TabPy user: {self.tabpy_username}"
90
91
        logging.getLogger(__name__).log(logging.INFO, context)
92
        self.request_context_logged = True
93
94 1
    def log(self, level: int, msg: str):
95
        """
96
        Log message with or without call ID. If call context is logged and
97
        call ID added to any log entry is specified by if context logging
98
        is enabled (see CallContext.enable_context_logging for more details).
99
100
        Parameters
101
        ----------
102
        level: int
103
            Log level: logging.CRITICAL, ERROR, WARNING, INFO, DEBUG, NOTSET.
104
105
        msg: str
106
            Message format string.
107
108
        args
109
            Same as args in Logger.debug().
110
111
        kwargs
112
            Same as kwargs in Logger.debug().
113
        """
114 1
        extended_msg = msg
115 1
        if self.log_request_context:
116
            if not self.request_context_logged:
117
                self._log_context_info()
118
119
            extended_msg += f", <<call ID: {self.call_id}>>"
120
121 1
        logging.getLogger(__name__).log(level, extended_msg)
122
123
124 1
class BaseHandler(tornado.web.RequestHandler):
125 1
    def initialize(self, app):
126 1
        self.tabpy_state = app.tabpy_state
127
        # set content type to application/json
128 1
        self.set_header("Content-Type", "application/json")
129 1
        self.protocol = self.settings[SettingsParameters.TransferProtocol]
130 1
        self.port = self.settings[SettingsParameters.Port]
131 1
        self.python_service = app.python_service
132 1
        self.credentials = app.credentials
133 1
        self.username = None
134 1
        self.password = None
135 1
        self.eval_timeout = self.settings[SettingsParameters.EvaluateTimeout]
136
137 1
        self.logger = ContextLoggerWrapper(self.request)
138 1
        self.logger.enable_context_logging(
139
            app.settings[SettingsParameters.LogRequestContext]
140
        )
141 1
        self.logger.log(logging.DEBUG, "Checking if need to handle authentication")
142 1
        self.not_authorized = not self.handle_authentication("v1")
143
144 1
    def error_out(self, code, log_message, info=None):
145 1
        self.set_status(code)
146 1
        self.write(json.dumps({"message": log_message, "info": info or {}}))
147
148
        # We want to duplicate error message in console for
149
        # loggers are misconfigured or causing the failure
150
        # themselves
151 1
        print(info)
152 1
        self.logger.log(
153
            logging.ERROR,
154
            'Responding with status={}, message="{}", info="{}"'.format(
155
                code, log_message, info
156
            ),
157
        )
158 1
        self.finish()
159
160 1
    def options(self):
161
        # add CORS headers if TabPy has a cors_origin specified
162
        self._add_CORS_header()
163
        self.write({})
164
165 1
    def _add_CORS_header(self):
166
        """
167
        Add CORS header if the TabPy has attribute _cors_origin
168
        and _cors_origin is not an empty string.
169
        """
170 1
        origin = self.tabpy_state.get_access_control_allow_origin()
171 1
        if len(origin) > 0:
172
            self.set_header("Access-Control-Allow-Origin", origin)
173
            self.logger.log(logging.DEBUG, f"Access-Control-Allow-Origin:{origin}")
174
175 1
        headers = self.tabpy_state.get_access_control_allow_headers()
176 1
        if len(headers) > 0:
177
            self.set_header("Access-Control-Allow-Headers", headers)
178
            self.logger.log(logging.DEBUG, f"Access-Control-Allow-Headers:{headers}")
179
180 1
        methods = self.tabpy_state.get_access_control_allow_methods()
181 1
        if len(methods) > 0:
182
            self.set_header("Access-Control-Allow-Methods", methods)
183
            self.logger.log(logging.DEBUG, f"Access-Control-Allow-Methods:{methods}")
184
185 1
    def _get_auth_method(self, api_version) -> (bool, str):
186
        """
187
        Finds authentication method if provided.
188
189
        Parameters
190
        ----------
191
        api_version : str
192
            API version for authentication.
193
194
        Returns
195
        -------
196
        bool
197
            True if known authentication method is found.
198
            False otherwise.
199
200
        str
201
            Name of authentication method used by client.
202
            If empty no authentication required.
203
204
        (True, '') as result of this function means authentication
205
        is not needed.
206
        """
207 1
        if api_version not in self.settings[SettingsParameters.ApiVersions]:
208
            self.logger.log(logging.CRITICAL, f'Unknown API version "{api_version}"')
209
            return False, ""
210
211 1
        version_settings = self.settings[SettingsParameters.ApiVersions][api_version]
212 1
        if "features" not in version_settings:
213
            self.logger.log(
214
                logging.INFO, f'No features configured for API "{api_version}"'
215
            )
216
            return True, ""
217
218 1
        features = version_settings["features"]
219 1
        if (
220
            "authentication" not in features
221
            or not features["authentication"]["required"]
222
        ):
223 1
            self.logger.log(
224
                logging.INFO,
225
                "Authentication is not a required feature for API " f'"{api_version}"',
226
            )
227 1
            return True, ""
228
229 1
        auth_feature = features["authentication"]
230 1
        if "methods" not in auth_feature:
231
            self.logger.log(
232
                logging.INFO,
233
                "Authentication method is not configured for API " f'"{api_version}"',
234
            )
235
236 1
        methods = auth_feature["methods"]
237 1
        if "basic-auth" in auth_feature["methods"]:
238 1
            return True, "basic-auth"
239
        # Add new methods here...
240
241
        # No known methods were found
242
        self.logger.log(
243
            logging.CRITICAL,
244
            f'Unknown authentication method(s) "{methods}" are configured '
245
            f'for API "{api_version}"',
246
        )
247
        return False, ""
248
249 1
    def _get_basic_auth_credentials(self) -> bool:
250
        """
251
        Find credentials for basic access authentication method. Credentials if
252
        found stored in Credentials.username and Credentials.password.
253
254
        Returns
255
        -------
256
        bool
257
            True if valid credentials were found.
258
            False otherwise.
259
        """
260 1
        self.logger.log(
261
            logging.DEBUG, "Checking request headers for authentication data"
262
        )
263 1
        if "Authorization" not in self.request.headers:
264 1
            self.logger.log(logging.INFO, "Authorization header not found")
265 1
            return False
266
267 1
        auth_header = self.request.headers["Authorization"]
268 1
        auth_header_list = auth_header.split(" ")
269 1
        if len(auth_header_list) != 2 or auth_header_list[0] != "Basic":
270
            self.logger.log(
271
                logging.ERROR, f'Unknown authentication method "{auth_header}"'
272
            )
273
            return False
274
275 1
        try:
276 1
            cred = base64.b64decode(auth_header_list[1]).decode("utf-8")
277
        except (binascii.Error, UnicodeDecodeError) as ex:
278
            self.logger.log(logging.CRITICAL, f"Cannot decode credentials: {str(ex)}")
279
            return False
280
281 1
        login_pwd = cred.split(":")
282 1
        if len(login_pwd) != 2:
283
            self.logger.log(logging.ERROR, "Invalid string in encoded credentials")
284
            return False
285
286 1
        self.username = login_pwd[0]
287 1
        self.logger.set_tabpy_username(self.username)
288 1
        self.password = login_pwd[1]
289 1
        return True
290
291 1
    def _get_credentials(self, method) -> bool:
292
        """
293
        Find credentials for specified authentication method. Credentials if
294
        found stored in self.username and self.password.
295
296
        Parameters
297
        ----------
298
        method: str
299
            Authentication method name.
300
301
        Returns
302
        -------
303
        bool
304
            True if valid credentials were found.
305
            False otherwise.
306
        """
307 1
        if method == "basic-auth":
308 1
            return self._get_basic_auth_credentials()
309
        # Add new methods here...
310
311
        # No known methods were found
312
        self.logger.log(
313
            logging.CRITICAL,
314
            f'Unknown authentication method(s) "{method}" are configured ',
315
        )
316
        return False
317
318 1
    def _validate_basic_auth_credentials(self) -> bool:
319
        """
320
        Validates username:pwd if they are the same as
321
        stored credentials.
322
323
        Returns
324
        -------
325
        bool
326
            True if credentials has key login and
327
            credentials[login] equal SHA3(pwd), False
328
            otherwise.
329
        """
330 1
        login = self.username.lower()
331 1
        self.logger.log(
332
            logging.DEBUG, f'Validating credentials for user name "{login}"'
333
        )
334 1
        if login not in self.credentials:
335 1
            self.logger.log(logging.ERROR, f'User name "{self.username}" not found')
336 1
            return False
337
338 1
        hashed_pwd = hash_password(login, self.password)
339 1
        if self.credentials[login].lower() != hashed_pwd.lower():
340
            self.logger.log(
341
                logging.ERROR, f'Wrong password for user name "{self.username}"'
342
            )
343
            return False
344
345 1
        return True
346
347 1
    def _validate_credentials(self, method) -> bool:
348
        """
349
        Validates credentials according to specified methods if they
350
        are what expected.
351
352
        Parameters
353
        ----------
354
        method: str
355
            Authentication method name.
356
357
        Returns
358
        -------
359
        bool
360
            True if credentials are valid.
361
            False otherwise.
362
        """
363 1
        if method == "basic-auth":
364 1
            return self._validate_basic_auth_credentials()
365
        # Add new methods here...
366
367
        # No known methods were found
368
        self.logger.log(
369
            logging.CRITICAL,
370
            f'Unknown authentication method(s) "{method}" are configured ',
371
        )
372
        return False
373
374 1
    def handle_authentication(self, api_version) -> bool:
375
        """
376
        If authentication feature is configured checks provided
377
        credentials.
378
379
        Parameters
380
        ----------
381
        api_version : str
382
            API version for authentication.
383
384
        Returns
385
        -------
386
        bool
387
            True if authentication is not required.
388
            True if authentication is required and valid
389
            credentials provided.
390
            False otherwise.
391
        """
392 1
        self.logger.log(logging.DEBUG, "Handling authentication")
393 1
        found, method = self._get_auth_method(api_version)
394 1
        if not found:
395
            return False
396
397 1
        if method == "":
398
            # Do not validate credentials
399 1
            return True
400
401 1
        if not self._get_credentials(method):
402 1
            return False
403
404 1
        return self._validate_credentials(method)
405
406 1
    def should_fail_with_not_authorized(self):
407
        """
408
        Checks if authentication is required:
409
        - if it is not returns false, None
410
        - if it is required validates provided credentials
411
412
        Returns
413
        -------
414
        bool
415
            False if authentication is not required or is
416
            required and validation for credentials passes.
417
            True if validation for credentials failed.
418
        """
419 1
        return self.not_authorized
420
421 1
    def fail_with_not_authorized(self):
422
        """
423
        Prepares server 401 response.
424
        """
425 1
        self.logger.log(logging.ERROR, "Failing with 401 for unauthorized request")
426 1
        self.set_status(401)
427 1
        self.set_header("WWW-Authenticate", f'Basic realm="{self.tabpy_state.name}"')
428 1
        self.error_out(
429
            401,
430
            info="Unauthorized request.",
431
            log_message="Invalid credentials provided.",
432
        )
433