1 2
import codecs
2 2
import json
3 2
import logging
4 2
import os
5 2
import pkg_resources
6 2
import re
7 2
import uuid
8

9 2
from urllib.parse import urlencode
10

11 2
import tornado
12

13 2
from bokeh.server.auth_provider import AuthProvider
14 2
from tornado.auth import OAuth2Mixin
15 2
from tornado.httpclient import HTTPRequest, HTTPError
16 2
from tornado.httputil import url_concat
17

18 2
from .config import config
19 2
from .io import state
20 2
from .util import base64url_encode, base64url_decode
21

22 2
log = logging.getLogger(__name__)
23

24 2
STATE_COOKIE_NAME = 'panel-oauth-state'
25

26

27 2
def decode_response_body(response):
28
    """
29
    Decodes the JSON-format response body
30

31
    Arguments
32
    ---------
33
    response: tornado.httpclient.HTTPResponse
34

35
    Returns
36
    -------
37
    Decoded response content
38
    """
39
    # Fix GitHub response.
40 0
    body = codecs.decode(response.body, 'ascii')
41 0
    body = re.sub('"', '\"', body)
42 0
    body = re.sub("'", '"', body)
43 0
    body = json.loads(body)
44 0
    return body
45

46

47 2
def decode_id_token(id_token):
48
    """
49
    Decodes a signed ID JWT token.
50
    """
51 0
    signing_input, _ = id_token.encode('utf-8').rsplit(b".", 1)
52 0
    _, payload_segment = signing_input.split(b".", 1)
53 0
    return json.loads(base64url_decode(payload_segment).decode('utf-8'))
54

55

56 2
def extract_urlparam(name, urlparam):
57
    """
58
    Attempts to extract a url parameter embedded in another URL
59
    parameter.
60
    """
61 0
    if urlparam is None:
62 0
        return None
63 0
    query = name+'='
64 0
    if query in urlparam:
65 0
        split_args = urlparam[urlparam.index(query):].replace(query, '').split('&')
66 0
        return split_args[0] if split_args else None
67
    else:
68 0
        return None
69

70

71 2
class OAuthLoginHandler(tornado.web.RequestHandler):
72

73 2
    _API_BASE_HEADERS = {
74
        'Accept': 'application/json',
75
        'User-Agent': 'Tornado OAuth'
76
    }
77

78 2
    _EXTRA_TOKEN_PARAMS = {}
79

80 2
    _SCOPE = None
81

82 2
    _state_cookie = None
83

84 2
    async def get_authenticated_user(self, redirect_uri, client_id, state,
85
                                     client_secret=None, code=None):
86
        """
87
        Fetches the authenticated user
88

89
        Arguments
90
        ---------
91
        redirect_uri: (str)
92
          The OAuth redirect URI
93
        client_id: (str)
94
          The OAuth client ID
95
        state: (str)
96
          The unguessable random string to protect against
97
          cross-site request forgery attacks
98
        client_secret: (str, optional)
99
          The client secret
100
        code: (str, optional)
101
          The response code from the server
102
        """
103 0
        if code:
104 0
            return await self._fetch_access_token(
105
                code,
106
                redirect_uri,
107
                client_id,
108
                client_secret
109
            )
110

111 0
        params = {
112
            'redirect_uri': redirect_uri,
113
            'client_id':    client_id,
114
            'client_secret': client_secret,
115
            'extra_params': {
116
                'state': state,
117
            },
118
        }
119 0
        if self._SCOPE is not None:
120 0
            params['scope'] = self._SCOPE
121 0
        if 'scope' in config.oauth_extra_params:
122 0
            params['scope'] = config.oauth_extra_params['scope']
123 0
        log.debug("%s making authorize request" % type(self).__name__)
124 0
        self.authorize_redirect(**params)
125

126 2
    async def _fetch_access_token(self, code, redirect_uri, client_id, client_secret):
127
        """
128
        Fetches the access token.
129

130
        Arguments
131
        ---------
132
        code:
133
          The response code from the server
134
        redirect_uri:
135
          The redirect URI
136
        client_id:
137
          The client ID
138
        client_secret:
139
          The client secret
140
        state:
141
          The unguessable random string to protect against cross-site
142
          request forgery attacks
143
        """
144 0
        if not client_secret:
145 0
            raise ValueError('The client secret is undefined.')
146

147 0
        log.debug("%s making access token request." % type(self).__name__)
148

149 0
        params = {
150
            'code':          code,
151
            'redirect_uri':  redirect_uri,
152
            'client_id':     client_id,
153
            'client_secret': client_secret,
154
            **self._EXTRA_TOKEN_PARAMS
155
        }
156

157 0
        http = self.get_auth_http_client()
158

159
        # Request the access token.
160 0
        req = HTTPRequest(
161
            self._OAUTH_ACCESS_TOKEN_URL,
162
            method='POST',
163
            body=urlencode(params),
164
            headers=self._API_BASE_HEADERS
165
        )
166 0
        try:
167 0
            response = await http.fetch(req)
168 0
        except HTTPError as e:
169 0
            return self._on_error(e.response)
170

171 0
        body = decode_response_body(response)
172

173 0
        if not body:
174 0
            return
175

176 0
        if 'access_token' not in body:
177 0
            return self._on_error(response, body)
178

179 0
        user_response = await http.fetch(
180
            '{}{}'.format(
181
                self._OAUTH_USER_URL, body['access_token']
182
            ),
183
            headers=self._API_BASE_HEADERS
184
        )
185

186 0
        user = decode_response_body(user_response)
187

188 0
        if not user:
189 0
            return
190

191 0
        log.debug("%s received user information." % type(self).__name__)
192 0
        return self._on_auth(user, body['access_token'])
193

194 2
    def get_state_cookie(self):
195
        """Get OAuth state from cookies
196
        To be compared with the value in redirect URL
197
        """
198 0
        if self._state_cookie is None:
199 0
            self._state_cookie = (
200
                self.get_secure_cookie(STATE_COOKIE_NAME) or b''
201
            ).decode('utf8', 'replace')
202 0
            self.clear_cookie(STATE_COOKIE_NAME)
203 0
        return self._state_cookie
204

205 2
    def set_state_cookie(self, state):
206 0
        self.set_secure_cookie(STATE_COOKIE_NAME, state, expires_days=1, httponly=True)
207

208 2
    async def get(self):
209 0
        log.debug("%s received login request" % type(self).__name__)
210 0
        if config.oauth_redirect_uri:
211 0
            redirect_uri = config.oauth_redirect_uri
212
        else:
213 0
            redirect_uri = "{0}://{1}".format(
214
                self.request.protocol,
215
                self.request.host
216
            )
217 0
        params = {
218
            'redirect_uri': redirect_uri,
219
            'client_id':    config.oauth_key,
220
        }
221
        # Some OAuth2 backends do not correctly return code
222 0
        next_arg = self.get_argument('next', None)
223 0
        url_state = self.get_argument('state', None)
224 0
        code = self.get_argument('code', extract_urlparam('code', next_arg))
225 0
        url_state = self.get_argument('state', extract_urlparam('state', next_arg))
226

227
        # Seek the authorization
228 0
        cookie_state = self.get_state_cookie()
229 0
        if code:
230 0
            if cookie_state != url_state:
231 0
                log.warning("OAuth state mismatch: %s != %s", cookie_state, url_state)
232 0
                raise HTTPError(400, "OAuth state mismatch")
233

234
            # For security reason, the state value (cross-site token) will be
235
            # retrieved from the query string.
236 0
            params.update({
237
                'client_secret': config.oauth_secret,
238
                'code':  code,
239
                'state': url_state
240
            })
241 0
            user = await self.get_authenticated_user(**params)
242 0
            if user is None:
243 0
                raise HTTPError(403)
244 0
            log.debug("%s authorized user, redirecting to app." % type(self).__name__)
245 0
            self.redirect('/')
246
        else:
247
            # Redirect for user authentication
248 0
            state = uuid.uuid4().hex
249 0
            params['state'] = state
250 0
            self.set_state_cookie(state)
251 0
            await self.get_authenticated_user(**params)
252

253 2
    def _on_auth(self, user_info, access_token):
254 0
        user_key = config.oauth_jwt_user or self._USER_KEY
255 0
        user = user_info[user_key]
256 0
        self.set_secure_cookie('user', user)
257 0
        id_token = base64url_encode(json.dumps(user_info))
258 0
        if state.encryption:
259 0
            access_token = state.encryption.encrypt(access_token.encode('utf-8'))
260 0
            id_token = state.encryption.encrypt(id_token.encode('utf-8'))
261 0
        self.set_secure_cookie('access_token', access_token)
262 0
        self.set_secure_cookie('id_token', id_token)
263 0
        return user
264

265 2
    def _on_error(self, response, body=None):
266 0
        self.clear_all_cookies()
267 0
        try:
268 0
            body = body or decode_response_body(response)
269 0
        except json.decoder.JSONDecodeError:
270 0
            body = body
271 0
        provider = self.__class__.__name__.replace('LoginHandler', '')
272 0
        if response.error:
273 0
            log.error(f"{provider} OAuth provider returned a {response.error} "
274
                      f"error. The full response was: {body}")
275
        else:
276 0
            log.warning(f"{provider} OAuth provider failed to fully "
277
                        f"authenticate returning the following response:"
278
                        f"{body}.")
279 0
        raise HTTPError(500, f"{provider} authentication failed")
280

281

282 2
class GithubLoginHandler(OAuthLoginHandler, OAuth2Mixin):
283
    """GitHub OAuth2 Authentication
284
    To authenticate with GitHub, first register your application at
285
    https://github.com/settings/applications/new to get the client ID and
286
    secret.
287
    """
288

289 2
    _EXTRA_AUTHORIZE_PARAMS = {}
290

291 2
    _OAUTH_ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token'
292 2
    _OAUTH_AUTHORIZE_URL = 'https://github.com/login/oauth/authorize'
293 2
    _OAUTH_USER_URL = 'https://api.github.com/user?access_token='
294

295 2
    _USER_KEY = 'login'
296

297

298 2
class BitbucketLoginHandler(OAuthLoginHandler, OAuth2Mixin):
299

300 2
    _API_BASE_HEADERS = {
301
        "Accept": "application/json",
302
    }
303

304 2
    _EXTRA_TOKEN_PARAMS = {
305
        'grant_type':    'authorization_code'
306
    }
307

308 2
    _OAUTH_ACCESS_TOKEN_URL = "https://bitbucket.org/site/oauth2/access_token"
309 2
    _OAUTH_AUTHORIZE_URL = "https://bitbucket.org/site/oauth2/authorize"
310 2
    _OAUTH_USER_URL = "https://api.bitbucket.org/2.0/user?access_token="
311

312 2
    _USER_KEY = 'username'
313

314

315 2
class GitLabLoginHandler(OAuthLoginHandler, OAuth2Mixin):
316

317 2
    _API_BASE_HEADERS = {
318
        'Accept': 'application/json',
319
    }
320

321 2
    _EXTRA_TOKEN_PARAMS = {
322
        'grant_type':    'authorization_code'
323
    }
324

325 2
    _OAUTH_ACCESS_TOKEN_URL_ = 'https://{0}/oauth/token'
326 2
    _OAUTH_AUTHORIZE_URL_ = 'https://{0}/oauth/authorize'
327 2
    _OAUTH_USER_URL_ = 'https://{0}/api/v4/user'
328

329 2
    _USER_KEY = 'username'
330

331 2
    @property
332
    def _OAUTH_ACCESS_TOKEN_URL(self):
333 0
        url = config.oauth_extra_params.get('url', 'gitlab.com')
334 0
        return self._OAUTH_ACCESS_TOKEN_URL_.format(url)
335

336 2
    @property
337
    def _OAUTH_AUTHORIZE_URL(self):
338 0
        url = config.oauth_extra_params.get('url', 'gitlab.com')
339 0
        return self._OAUTH_AUTHORIZE_URL_.format(url)
340

341 2
    @property
342
    def _OAUTH_USER_URL(self):
343 0
        url = config.oauth_extra_params.get('url', 'gitlab.com')
344 0
        return self._OAUTH_USER_URL_.format(url)
345

346 2
    async def _fetch_access_token(self, code, redirect_uri, client_id, client_secret):
347
        """
348
        Fetches the access token.
349

350
        Arguments
351
        ----------
352
        code:
353
          The response code from the server
354
        redirect_uri:
355
          The redirect URI
356
        client_id:
357
          The client ID
358
        client_secret:
359
          The client secret
360
        state:
361
          The unguessable random string to protect against cross-site
362
          request forgery attacks
363
        """
364 0
        if not client_secret:
365 0
            raise ValueError('The client secret is undefined.')
366

367 0
        log.debug("%s making access token request." % type(self).__name__)
368

369 0
        http = self.get_auth_http_client()
370

371 0
        params = {
372
            'code':          code,
373
            'redirect_uri':  redirect_uri,
374
            'client_id':     client_id,
375
            'client_secret': client_secret,
376
            **self._EXTRA_TOKEN_PARAMS
377
        }
378

379 0
        url = url_concat(self._OAUTH_ACCESS_TOKEN_URL, params)
380

381
        # Request the access token.
382 0
        req = HTTPRequest(
383
            url,
384
            method="POST",
385
            headers=self._API_BASE_HEADERS,
386
            body=''
387
        )
388 0
        try:
389 0
            response = await http.fetch(req)
390 0
        except HTTPError as e:
391 0
            return self._on_error(e.response)
392

393 0
        body = decode_response_body(response)
394

395 0
        if not body:
396 0
            return
397

398 0
        if 'access_token' not in body:
399 0
            return self._on_error(response, body)
400

401 0
        log.debug("%s granted access_token." % type(self).__name__)
402

403 0
        headers = dict(self._API_BASE_HEADERS, **{
404
            "Authorization": "Bearer {}".format(body['access_token']),
405
        })
406

407 0
        user_response = await http.fetch(
408
            self._OAUTH_USER_URL,
409
            method="GET",
410
            headers=headers
411
        )
412

413 0
        user = decode_response_body(user_response)
414

415 0
        if not user:
416 0
            return
417

418 0
        log.debug("%s received user information." % type(self).__name__)
419

420 0
        return self._on_auth(user, body['access_token'])
421

422

423

424 2
class OAuthIDTokenLoginHandler(OAuthLoginHandler):
425

426 2
    _API_BASE_HEADERS = {
427
        'Content-Type':
428
        'application/x-www-form-urlencoded; charset=UTF-8'
429
    }
430

431 2
    _EXTRA_AUTHORIZE_PARAMS = {
432
        'grant_type': 'authorization_code'
433
    }
434

435 2
    async def _fetch_access_token(self, code, redirect_uri, client_id, client_secret):
436
        """
437
        Fetches the access token.
438

439
        Arguments
440
        ----------
441
        code:
442
          The response code from the server
443
        redirect_uri:
444
          The redirect URI
445
        client_id:
446
          The client ID
447
        client_secret:
448
          The client secret
449
        state:
450
          The unguessable random string to protect against cross-site
451
          request forgery attacks
452
        """
453 0
        if not client_secret:
454 0
            raise ValueError('The client secret are undefined.')
455

456 0
        log.debug("%s making access token request." % type(self).__name__)
457

458 0
        http = self.get_auth_http_client()
459

460 0
        params = {
461
            'code':          code,
462
            'redirect_uri':  redirect_uri,
463
            'client_id':     client_id,
464
            'client_secret': client_secret,
465
            **self._EXTRA_AUTHORIZE_PARAMS
466
        }
467

468 0
        data = urlencode(
469
            params, doseq=True, encoding='utf-8', safe='=')
470

471
        # Request the access token.
472 0
        req = HTTPRequest(
473
            self._OAUTH_ACCESS_TOKEN_URL,
474
            method="POST",
475
            headers=self._API_BASE_HEADERS,
476
            body=data
477
        )
478

479 0
        try:
480 0
            response = await http.fetch(req)
481 0
        except HTTPError as e:
482 0
            return self._on_error(e.response)
483

484 0
        body = decode_response_body(response)
485

486 0
        if 'access_token' not in body:
487 0
            return self._on_error(response, body)
488

489 0
        log.debug("%s granted access_token." % type(self).__name__)
490

491 0
        access_token = body['access_token']
492 0
        id_token = body['id_token']
493 0
        return self._on_auth(id_token, access_token)
494

495 2
    def _on_auth(self, id_token, access_token):
496 0
        decoded = decode_id_token(id_token)
497 0
        user_key = config.oauth_jwt_user or self._USER_KEY
498 0
        user = decoded[user_key]
499 0
        self.set_secure_cookie('user', user)
500 0
        if state.encryption:
501 0
            access_token = state.encryption.encrypt(access_token.encode('utf-8'))
502 0
            id_token = state.encryption.encrypt(id_token.encode('utf-8'))
503 0
        self.set_secure_cookie('access_token', access_token)
504 0
        self.set_secure_cookie('id_token', id_token)
505 0
        return user
506

507

508 2
class AzureAdLoginHandler(OAuthIDTokenLoginHandler, OAuth2Mixin):
509

510 2
    _API_BASE_HEADERS = {
511
        'Accept': 'application/json',
512
        'User-Agent': 'Tornado OAuth'
513
    }
514

515 2
    _OAUTH_ACCESS_TOKEN_URL_ = 'https://login.microsoftonline.com/{tenant}/oauth2/token'
516 2
    _OAUTH_AUTHORIZE_URL_ = 'https://login.microsoftonline.com/{tenant}/oauth2/authorize'
517 2
    _OAUTH_USER_URL_ = ''
518

519 2
    _USER_KEY = 'unique_name'
520

521 2
    @property
522
    def _OAUTH_ACCESS_TOKEN_URL(self):
523 0
        tenant = os.environ.get('AAD_TENANT_ID', config.oauth_extra_params.get('tenant', 'common'))
524 0
        return self._OAUTH_ACCESS_TOKEN_URL_.format(tenant=tenant)
525

526 2
    @property
527
    def _OAUTH_AUTHORIZE_URL(self):
528 0
        tenant = os.environ.get('AAD_TENANT_ID', config.oauth_extra_params.get('tenant', 'common'))
529 0
        return self._OAUTH_AUTHORIZE_URL_.format(tenant=tenant)
530

531 2
    @property
532
    def _OAUTH_USER_URL(self):
533 0
        return self._OAUTH_USER_URL_.format(**config.oauth_extra_params)
534

535

536 2
class GoogleLoginHandler(OAuthIDTokenLoginHandler, OAuth2Mixin):
537

538 2
    _API_BASE_HEADERS = {
539
        "Content-Type": "application/x-www-form-urlencoded; charset=utf-8"
540
    }
541

542 2
    _OAUTH_AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth"
543 2
    _OAUTH_ACCESS_TOKEN_URL = "https://accounts.google.com/o/oauth2/token"
544

545 2
    _SCOPE = ['profile', 'email']
546

547 2
    _USER_KEY = 'email'
548

549

550 2
class LogoutHandler(tornado.web.RequestHandler):
551

552 2
    def get(self):
553 0
        self.clear_cookie("user")
554 0
        self.clear_cookie("id_token")
555 0
        self.clear_cookie("access_token")
556 0
        self.redirect("/")
557

558

559 2
class OAuthProvider(AuthProvider):
560

561 2
    @property
562
    def get_user(self):
563 0
        def get_user(request_handler):
564 0
            return request_handler.get_secure_cookie("user")
565 0
        return get_user
566

567 2
    @property
568
    def login_url(self):
569 0
        return '/login'
570

571 2
    @property
572
    def login_handler(self):
573 0
        return AUTH_PROVIDERS[config.oauth_provider]
574

575 2
    @property
576
    def logout_url(self):
577 0
        return "/logout"
578

579 2
    @property
580
    def logout_handler(self):
581 0
        return LogoutHandler
582

583

584 2
AUTH_PROVIDERS = {
585
    'azure': AzureAdLoginHandler,
586
    'bitbucket': BitbucketLoginHandler,
587
    'google': GoogleLoginHandler,
588
    'github': GithubLoginHandler,
589
    'gitlab': GitLabLoginHandler,
590
}
591

592
# Populate AUTH Providers from external extensions
593 2
for entry_point in pkg_resources.iter_entry_points('panel.auth'):
594 0
    AUTH_PROVIDERS[entry_point.name] = entry_point.resolve()
595

596 2
config.param.objects(False)['_oauth_provider'].objects = list(AUTH_PROVIDERS.keys())

Read our documentation on viewing source code .

Loading