Test Failed
Push — master ( 94b1f7...ff0954 )
by Oleksandr
12:55 queued 02:06
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

12 Functions

Rating   Name   Duplication   Size   Complexity  
A handlers.base_handler.BaseHandler.error_out() 0 15 1
B handlers.base_handler.BaseHandler._get_basic_auth_credentials() 0 41 6
B handlers.base_handler.BaseHandler._get_auth_method() 0 63 7
A handlers.base_handler.BaseHandler._validate_basic_auth_credentials() 0 28 3
A handlers.base_handler.BaseHandler.options() 0 4 1
A handlers.base_handler.BaseHandler.fail_with_not_authorized() 0 11 1
A handlers.base_handler.BaseHandler.handle_authentication() 0 31 4
A handlers.base_handler.BaseHandler._get_credentials() 0 26 2
A handlers.base_handler.BaseHandler.initialize() 0 18 1
A handlers.base_handler.BaseHandler._add_CORS_header() 0 19 4
A handlers.base_handler.BaseHandler._validate_credentials() 0 26 2
A handlers.base_handler.BaseHandler.should_fail_with_not_authorized() 0 14 1

7 Methods

Rating   Name   Duplication   Size   Complexity  
A handlers.base_handler.ContextLoggerWrapper.enable_context_logging() 0 12 1
A handlers.base_handler.ContextLoggerWrapper.log() 0 28 3
A handlers.base_handler.ContextLoggerWrapper.__init__() 0 7 1
A handlers.base_handler.ContextLoggerWrapper._generate_call_id() 0 3 1
A handlers.base_handler.ContextLoggerWrapper.set_request() 0 11 1
A handlers.base_handler.ContextLoggerWrapper.set_tabpy_username() 0 2 1
B handlers.base_handler.ContextLoggerWrapper._log_context_info() 0 26 8

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