Passed
Push — master ( 227024...1d0698 )
by Oleksandr
02:44
created

tabpy.tabpy_server.handlers.base_handler   B

Complexity

Total Complexity 51

Size/Duplication

Total Lines 435
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
wmc 51
eloc 217
dl 0
loc 435
rs 7.92
c 0
b 0
f 0

19 Methods

Rating   Name   Duplication   Size   Complexity  
A ContextLoggerWrapper.set_request() 0 18 3
A BaseHandler.error_out() 0 14 1
B BaseHandler._get_auth_method() 0 61 7
A BaseHandler._validate_basic_auth_credentials() 0 27 3
A BaseHandler._get_credentials() 0 26 2
A BaseHandler.fail_with_not_authorized() 0 14 1
A BaseHandler._add_CORS_header() 0 22 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 42 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 19 1

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