codekingpro/portable-devtools
114k
1#
2# Copyright 2009 Facebook
3#
4# Licensed under the Apache License, Version 2.0 (the "License"); you may
5# not use this file except in compliance with the License. You may obtain
6# a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13# License for the specific language governing permissions and limitations
14# under the License.
15
16"""This module contains implementations of various third-party
17authentication schemes.
18
19All the classes in this file are class mixins designed to be used with
20the `tornado.web.RequestHandler` class. They are used in two ways:
21
22* On a login handler, use methods such as ``authenticate_redirect()``,
23 ``authorize_redirect()``, and ``get_authenticated_user()`` to
24 establish the user's identity and store authentication tokens to your
25 database and/or cookies.
26* In non-login handlers, use methods such as ``facebook_request()``
27 or ``twitter_request()`` to use the authentication tokens to make
28 requests to the respective services.
29
30They all take slightly different arguments due to the fact all these
31services implement authentication and authorization slightly differently.
32See the individual service classes below for complete documentation.
33
34Example usage for Google OAuth:
35
36.. testsetup::
37
38 import urllib
39
40.. testcode::
41
42 class GoogleOAuth2LoginHandler(tornado.web.RequestHandler,
43 tornado.auth.GoogleOAuth2Mixin):
44 async def get(self):
45 # Google requires an exact match for redirect_uri, so it's
46 # best to get it from your app configuration instead of from
47 # self.request.full_uri().
48 redirect_uri = urllib.parse.urljoin(self.application.settings['redirect_base_uri'],
49 self.reverse_url('google_oauth'))
50 async def get(self):
51 if self.get_argument('code', False):
52 access = await self.get_authenticated_user(
53 redirect_uri=redirect_uri,
54 code=self.get_argument('code'))
55 user = await self.oauth2_request(
56 "https://www.googleapis.com/oauth2/v1/userinfo",
57 access_token=access["access_token"])
58 # Save the user and access token. For example:
59 user_cookie = dict(id=user["id"], access_token=access["access_token"])
60 self.set_signed_cookie("user", json.dumps(user_cookie))
61 self.redirect("/")
62 else:
63 self.authorize_redirect(
64 redirect_uri=redirect_uri,
65 client_id=self.get_google_oauth_settings()['key'],
66 scope=['profile', 'email'],
67 response_type='code',
68 extra_params={'approval_prompt': 'auto'})
69
70"""
71
72import base64
73import binascii
74import hashlib
75import hmac
76import time
77import urllib.parse
78import uuid
79import warnings
80
81from tornado import httpclient
82from tornado import escape
83from tornado.httputil import url_concat
84from tornado.util import unicode_type
85from tornado.web import RequestHandler
86
87from typing import List, Any, Dict, cast, Iterable, Union, Optional
88
89
90class AuthError(Exception):
91 pass
92
93
94class OpenIdMixin:
95 """Abstract implementation of OpenID and Attribute Exchange.
96
97 Class attributes:
98
99 * ``_OPENID_ENDPOINT``: the identity provider's URI.
100 """
101
102 def authenticate_redirect(
103 self,
104 callback_uri: Optional[str] = None,
105 ax_attrs: List[str] = ["name", "email", "language", "username"],
106 ) -> None:
107 """Redirects to the authentication URL for this service.
108
109 After authentication, the service will redirect back to the given
110 callback URI with additional parameters including ``openid.mode``.
111
112 We request the given attributes for the authenticated user by
113 default (name, email, language, and username). If you don't need
114 all those attributes for your app, you can request fewer with
115 the ax_attrs keyword argument.
116
117 .. versionchanged:: 6.0
118
119 The ``callback`` argument was removed and this method no
120 longer returns an awaitable object. It is now an ordinary
121 synchronous function.
122 """
123 handler = cast(RequestHandler, self)
124 callback_uri = callback_uri or handler.request.uri
125 assert callback_uri is not None
126 args = self._openid_args(callback_uri, ax_attrs=ax_attrs)
127 endpoint = self._OPENID_ENDPOINT # type: ignore
128 handler.redirect(endpoint + "?" + urllib.parse.urlencode(args))
129
130 async def get_authenticated_user(
131 self, http_client: Optional[httpclient.AsyncHTTPClient] = None
132 ) -> Dict[str, Any]:
133 """Fetches the authenticated user data upon redirect.
134
135 This method should be called by the handler that receives the
136 redirect from the `authenticate_redirect()` method (which is
137 often the same as the one that calls it; in that case you would
138 call `get_authenticated_user` if the ``openid.mode`` parameter
139 is present and `authenticate_redirect` if it is not).
140
141 The result of this method will generally be used to set a cookie.
142
143 .. versionchanged:: 6.0
144
145 The ``callback`` argument was removed. Use the returned
146 awaitable object instead.
147 """
148 handler = cast(RequestHandler, self)
149 # Verify the OpenID response via direct request to the OP
150 args = {
151 k: v[-1] for k, v in handler.request.arguments.items()
152 } # type: Dict[str, Union[str, bytes]]
153 args["openid.mode"] = "check_authentication"
154 url = self._OPENID_ENDPOINT # type: ignore
155 if http_client is None:
156 http_client = self.get_auth_http_client()
157 resp = await http_client.fetch(
158 url, method="POST", body=urllib.parse.urlencode(args)
159 )
160 return self._on_authentication_verified(resp)
161
162 def _openid_args(
163 self,
164 callback_uri: str,
165 ax_attrs: Iterable[str] = [],
166 oauth_scope: Optional[str] = None,
167 ) -> Dict[str, str]:
168 handler = cast(RequestHandler, self)
169 url = urllib.parse.urljoin(handler.request.full_url(), callback_uri)
170 args = {
171 "openid.ns": "http://specs.openid.net/auth/2.0",
172 "openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select",
173 "openid.identity": "http://specs.openid.net/auth/2.0/identifier_select",
174 "openid.return_to": url,
175 "openid.realm": urllib.parse.urljoin(url, "/"),
176 "openid.mode": "checkid_setup",
177 }
178 if ax_attrs:
179 args.update(
180 {
181 "openid.ns.ax": "http://openid.net/srv/ax/1.0",
182 "openid.ax.mode": "fetch_request",
183 }
184 )
185 ax_attrs = set(ax_attrs)
186 required = [] # type: List[str]
187 if "name" in ax_attrs:
188 ax_attrs -= {"name", "firstname", "fullname", "lastname"}
189 required += ["firstname", "fullname", "lastname"]
190 args.update(
191 {
192 "openid.ax.type.firstname": "http://axschema.org/namePerson/first",
193 "openid.ax.type.fullname": "http://axschema.org/namePerson",
194 "openid.ax.type.lastname": "http://axschema.org/namePerson/last",
195 }
196 )
197 known_attrs = {
198 "email": "http://axschema.org/contact/email",
199 "language": "http://axschema.org/pref/language",
200 "username": "http://axschema.org/namePerson/friendly",
201 }
202 for name in ax_attrs:
203 args["openid.ax.type." + name] = known_attrs[name]
204 required.append(name)
205 args["openid.ax.required"] = ",".join(required)
206 if oauth_scope:
207 args.update(
208 {
209 "openid.ns.oauth": "http://specs.openid.net/extensions/oauth/1.0",
210 "openid.oauth.consumer": handler.request.host.split(":")[0],
211 "openid.oauth.scope": oauth_scope,
212 }
213 )
214 return args
215
216 def _on_authentication_verified(
217 self, response: httpclient.HTTPResponse
218 ) -> Dict[str, Any]:
219 handler = cast(RequestHandler, self)
220 if b"is_valid:true" not in response.body:
221 raise AuthError("Invalid OpenID response: %r" % response.body)
222
223 # Make sure we got back at least an email from attribute exchange
224 ax_ns = None
225 for key in handler.request.arguments:
226 if (
227 key.startswith("openid.ns.")
228 and handler.get_argument(key) == "http://openid.net/srv/ax/1.0"
229 ):
230 ax_ns = key[10:]
231 break
232
233 def get_ax_arg(uri: str) -> str:
234 if not ax_ns:
235 return ""
236 prefix = "openid." + ax_ns + ".type."
237 ax_name = None
238 for name in handler.request.arguments.keys():
239 if handler.get_argument(name) == uri and name.startswith(prefix):
240 part = name[len(prefix) :]
241 ax_name = "openid." + ax_ns + ".value." + part
242 break
243 if not ax_name:
244 return ""
245 return handler.get_argument(ax_name, "")
246
247 email = get_ax_arg("http://axschema.org/contact/email")
248 name = get_ax_arg("http://axschema.org/namePerson")
249 first_name = get_ax_arg("http://axschema.org/namePerson/first")
250 last_name = get_ax_arg("http://axschema.org/namePerson/last")
251 username = get_ax_arg("http://axschema.org/namePerson/friendly")
252 locale = get_ax_arg("http://axschema.org/pref/language").lower()
253 user = dict()
254 name_parts = []
255 if first_name:
256 user["first_name"] = first_name
257 name_parts.append(first_name)
258 if last_name:
259 user["last_name"] = last_name
260 name_parts.append(last_name)
261 if name:
262 user["name"] = name
263 elif name_parts:
264 user["name"] = " ".join(name_parts)
265 elif email:
266 user["name"] = email.split("@")[0]
267 if email:
268 user["email"] = email
269 if locale:
270 user["locale"] = locale
271 if username:
272 user["username"] = username
273 claimed_id = handler.get_argument("openid.claimed_id", None)
274 if claimed_id:
275 user["claimed_id"] = claimed_id
276 return user
277
278 def get_auth_http_client(self) -> httpclient.AsyncHTTPClient:
279 """Returns the `.AsyncHTTPClient` instance to be used for auth requests.
280
281 May be overridden by subclasses to use an HTTP client other than
282 the default.
283 """
284 return httpclient.AsyncHTTPClient()
285
286
287class OAuthMixin:
288 """Abstract implementation of OAuth 1.0 and 1.0a.
289
290 See `TwitterMixin` below for an example implementation.
291
292 Class attributes:
293
294 * ``_OAUTH_AUTHORIZE_URL``: The service's OAuth authorization url.
295 * ``_OAUTH_ACCESS_TOKEN_URL``: The service's OAuth access token url.
296 * ``_OAUTH_VERSION``: May be either "1.0" or "1.0a".
297 * ``_OAUTH_NO_CALLBACKS``: Set this to True if the service requires
298 advance registration of callbacks.
299
300 Subclasses must also override the `_oauth_get_user_future` and
301 `_oauth_consumer_token` methods.
302 """
303
304 async def authorize_redirect(
305 self,
306 callback_uri: Optional[str] = None,
307 extra_params: Optional[Dict[str, Any]] = None,
308 http_client: Optional[httpclient.AsyncHTTPClient] = None,
309 ) -> None:
310 """Redirects the user to obtain OAuth authorization for this service.
311
312 The ``callback_uri`` may be omitted if you have previously
313 registered a callback URI with the third-party service. For
314 some services, you must use a previously-registered callback
315 URI and cannot specify a callback via this method.
316
317 This method sets a cookie called ``_oauth_request_token`` which is
318 subsequently used (and cleared) in `get_authenticated_user` for
319 security purposes.
320
321 This method is asynchronous and must be called with ``await``
322 or ``yield`` (This is different from other ``auth*_redirect``
323 methods defined in this module). It calls
324 `.RequestHandler.finish` for you so you should not write any
325 other response after it returns.
326
327 .. versionchanged:: 3.1
328 Now returns a `.Future` and takes an optional callback, for
329 compatibility with `.gen.coroutine`.
330
331 .. versionchanged:: 6.0
332
333 The ``callback`` argument was removed. Use the returned
334 awaitable object instead.
335
336 """
337 if callback_uri and getattr(self, "_OAUTH_NO_CALLBACKS", False):
338 raise Exception("This service does not support oauth_callback")
339 if http_client is None:
340 http_client = self.get_auth_http_client()
341 assert http_client is not None
342 if getattr(self, "_OAUTH_VERSION", "1.0a") == "1.0a":
343 response = await http_client.fetch(
344 self._oauth_request_token_url(
345 callback_uri=callback_uri, extra_params=extra_params
346 )
347 )
348 else:
349 response = await http_client.fetch(self._oauth_request_token_url())
350 url = self._OAUTH_AUTHORIZE_URL # type: ignore
351 self._on_request_token(url, callback_uri, response)
352
353 async def get_authenticated_user(
354 self, http_client: Optional[httpclient.AsyncHTTPClient] = None
355 ) -> Dict[str, Any]:
356 """Gets the OAuth authorized user and access token.
357
358 This method should be called from the handler for your
359 OAuth callback URL to complete the registration process. We run the
360 callback with the authenticated user dictionary. This dictionary
361 will contain an ``access_key`` which can be used to make authorized
362 requests to this service on behalf of the user. The dictionary will
363 also contain other fields such as ``name``, depending on the service
364 used.
365
366 .. versionchanged:: 6.0
367
368 The ``callback`` argument was removed. Use the returned
369 awaitable object instead.
370 """
371 handler = cast(RequestHandler, self)
372 request_key = escape.utf8(handler.get_argument("oauth_token"))
373 oauth_verifier = handler.get_argument("oauth_verifier", None)
374 request_cookie = handler.get_cookie("_oauth_request_token")
375 if not request_cookie:
376 raise AuthError("Missing OAuth request token cookie")
377 handler.clear_cookie("_oauth_request_token")
378 cookie_key, cookie_secret = (
379 base64.b64decode(escape.utf8(i)) for i in request_cookie.split("|")
380 )
381 if cookie_key != request_key:
382 raise AuthError("Request token does not match cookie")
383 token = dict(
384 key=cookie_key, secret=cookie_secret
385 ) # type: Dict[str, Union[str, bytes]]
386 if oauth_verifier:
387 token["verifier"] = oauth_verifier
388 if http_client is None:
389 http_client = self.get_auth_http_client()
390 assert http_client is not None
391 response = await http_client.fetch(self._oauth_access_token_url(token))
392 access_token = _oauth_parse_response(response.body)
393 user = await self._oauth_get_user_future(access_token)
394 if not user:
395 raise AuthError("Error getting user")
396 user["access_token"] = access_token
397 return user
398
399 def _oauth_request_token_url(
400 self,
401 callback_uri: Optional[str] = None,
402 extra_params: Optional[Dict[str, Any]] = None,
403 ) -> str:
404 handler = cast(RequestHandler, self)
405 consumer_token = self._oauth_consumer_token()
406 url = self._OAUTH_REQUEST_TOKEN_URL # type: ignore
407 args = dict(
408 oauth_consumer_key=escape.to_basestring(consumer_token["key"]),
409 oauth_signature_method="HMAC-SHA1",
410 oauth_timestamp=str(int(time.time())),
411 oauth_nonce=escape.to_basestring(binascii.b2a_hex(uuid.uuid4().bytes)),
412 oauth_version="1.0",
413 )
414 if getattr(self, "_OAUTH_VERSION", "1.0a") == "1.0a":
415 if callback_uri == "oob":
416 args["oauth_callback"] = "oob"
417 elif callback_uri:
418 args["oauth_callback"] = urllib.parse.urljoin(
419 handler.request.full_url(), callback_uri
420 )
421 if extra_params:
422 args.update(extra_params)
423 signature = _oauth10a_signature(consumer_token, "GET", url, args)
424 else:
425 signature = _oauth_signature(consumer_token, "GET", url, args)
426
427 args["oauth_signature"] = signature
428 return url + "?" + urllib.parse.urlencode(args)
429
430 def _on_request_token(
431 self,
432 authorize_url: str,
433 callback_uri: Optional[str],
434 response: httpclient.HTTPResponse,
435 ) -> None:
436 handler = cast(RequestHandler, self)
437 request_token = _oauth_parse_response(response.body)
438 data = (
439 base64.b64encode(escape.utf8(request_token["key"]))
440 + b"|"
441 + base64.b64encode(escape.utf8(request_token["secret"]))
442 )
443 handler.set_cookie("_oauth_request_token", data)
444 args = dict(oauth_token=request_token["key"])
445 if callback_uri == "oob":
446 handler.finish(authorize_url + "?" + urllib.parse.urlencode(args))
447 return
448 elif callback_uri:
449 args["oauth_callback"] = urllib.parse.urljoin(
450 handler.request.full_url(), callback_uri
451 )
452 handler.redirect(authorize_url + "?" + urllib.parse.urlencode(args))
453
454 def _oauth_access_token_url(self, request_token: Dict[str, Any]) -> str:
455 consumer_token = self._oauth_consumer_token()
456 url = self._OAUTH_ACCESS_TOKEN_URL # type: ignore
457 args = dict(
458 oauth_consumer_key=escape.to_basestring(consumer_token["key"]),
459 oauth_token=escape.to_basestring(request_token["key"]),
460 oauth_signature_method="HMAC-SHA1",
461 oauth_timestamp=str(int(time.time())),
462 oauth_nonce=escape.to_basestring(binascii.b2a_hex(uuid.uuid4().bytes)),
463 oauth_version="1.0",
464 )
465 if "verifier" in request_token:
466 args["oauth_verifier"] = request_token["verifier"]
467
468 if getattr(self, "_OAUTH_VERSION", "1.0a") == "1.0a":
469 signature = _oauth10a_signature(
470 consumer_token, "GET", url, args, request_token
471 )
472 else:
473 signature = _oauth_signature(
474 consumer_token, "GET", url, args, request_token
475 )
476
477 args["oauth_signature"] = signature
478 return url + "?" + urllib.parse.urlencode(args)
479
480 def _oauth_consumer_token(self) -> Dict[str, Any]:
481 """Subclasses must override this to return their OAuth consumer keys.
482
483 The return value should be a `dict` with keys ``key`` and ``secret``.
484 """
485 raise NotImplementedError()
486
487 async def _oauth_get_user_future(
488 self, access_token: Dict[str, Any]
489 ) -> Dict[str, Any]:
490 """Subclasses must override this to get basic information about the
491 user.
492
493 Should be a coroutine whose result is a dictionary
494 containing information about the user, which may have been
495 retrieved by using ``access_token`` to make a request to the
496 service.
497
498 The access token will be added to the returned dictionary to make
499 the result of `get_authenticated_user`.
500
501 .. versionchanged:: 5.1
502
503 Subclasses may also define this method with ``async def``.
504
505 .. versionchanged:: 6.0
506
507 A synchronous fallback to ``_oauth_get_user`` was removed.
508 """
509 raise NotImplementedError()
510
511 def _oauth_request_parameters(
512 self,
513 url: str,
514 access_token: Dict[str, Any],
515 parameters: Dict[str, Any] = {},
516 method: str = "GET",
517 ) -> Dict[str, Any]:
518 """Returns the OAuth parameters as a dict for the given request.
519
520 parameters should include all POST arguments and query string arguments
521 that will be sent with the request.
522 """
523 consumer_token = self._oauth_consumer_token()
524 base_args = dict(
525 oauth_consumer_key=escape.to_basestring(consumer_token["key"]),
526 oauth_token=escape.to_basestring(access_token["key"]),
527 oauth_signature_method="HMAC-SHA1",
528 oauth_timestamp=str(int(time.time())),
529 oauth_nonce=escape.to_basestring(binascii.b2a_hex(uuid.uuid4().bytes)),
530 oauth_version="1.0",
531 )
532 args = {}
533 args.update(base_args)
534 args.update(parameters)
535 if getattr(self, "_OAUTH_VERSION", "1.0a") == "1.0a":
536 signature = _oauth10a_signature(
537 consumer_token, method, url, args, access_token
538 )
539 else:
540 signature = _oauth_signature(
541 consumer_token, method, url, args, access_token
542 )
543 base_args["oauth_signature"] = escape.to_basestring(signature)
544 return base_args
545
546 def get_auth_http_client(self) -> httpclient.AsyncHTTPClient:
547 """Returns the `.AsyncHTTPClient` instance to be used for auth requests.
548
549 May be overridden by subclasses to use an HTTP client other than
550 the default.
551 """
552 return httpclient.AsyncHTTPClient()
553
554
555class OAuth2Mixin:
556 """Abstract implementation of OAuth 2.0.
557
558 See `FacebookGraphMixin` or `GoogleOAuth2Mixin` below for example
559 implementations.
560
561 Class attributes:
562
563 * ``_OAUTH_AUTHORIZE_URL``: The service's authorization url.
564 * ``_OAUTH_ACCESS_TOKEN_URL``: The service's access token url.
565 """
566
567 def authorize_redirect(
568 self,
569 redirect_uri: Optional[str] = None,
570 client_id: Optional[str] = None,
571 client_secret: Optional[str] = None,
572 extra_params: Optional[Dict[str, Any]] = None,
573 scope: Optional[List[str]] = None,
574 response_type: str = "code",
575 ) -> None:
576 """Redirects the user to obtain OAuth authorization for this service.
577
578 Some providers require that you register a redirect URL with
579 your application instead of passing one via this method. You
580 should call this method to log the user in, and then call
581 ``get_authenticated_user`` in the handler for your
582 redirect URL to complete the authorization process.
583
584 .. versionchanged:: 6.0
585
586 The ``callback`` argument and returned awaitable were removed;
587 this is now an ordinary synchronous function.
588
589 .. deprecated:: 6.4
590 The ``client_secret`` argument (which has never had any effect)
591 is deprecated and will be removed in Tornado 7.0.
592 """
593 if client_secret is not None:
594 warnings.warn("client_secret argument is deprecated", DeprecationWarning)
595 handler = cast(RequestHandler, self)
596 args = {"response_type": response_type}
597 if redirect_uri is not None:
598 args["redirect_uri"] = redirect_uri
599 if client_id is not None:
600 args["client_id"] = client_id
601 if extra_params:
602 args.update(extra_params)
603 if scope:
604 args["scope"] = " ".join(scope)
605 url = self._OAUTH_AUTHORIZE_URL # type: ignore
606 handler.redirect(url_concat(url, args))
607
608 def _oauth_request_token_url(
609 self,
610 redirect_uri: Optional[str] = None,
611 client_id: Optional[str] = None,
612 client_secret: Optional[str] = None,
613 code: Optional[str] = None,
614 extra_params: Optional[Dict[str, Any]] = None,
615 ) -> str:
616 url = self._OAUTH_ACCESS_TOKEN_URL # type: ignore
617 args = {} # type: Dict[str, str]
618 if redirect_uri is not None:
619 args["redirect_uri"] = redirect_uri
620 if code is not None:
621 args["code"] = code
622 if client_id is not None:
623 args["client_id"] = client_id
624 if client_secret is not None:
625 args["client_secret"] = client_secret
626 if extra_params:
627 args.update(extra_params)
628 return url_concat(url, args)
629
630 async def oauth2_request(
631 self,
632 url: str,
633 access_token: Optional[str] = None,
634 post_args: Optional[Dict[str, Any]] = None,
635 **args: Any,
636 ) -> Any:
637 """Fetches the given URL auth an OAuth2 access token.
638
639 If the request is a POST, ``post_args`` should be provided. Query
640 string arguments should be given as keyword arguments.
641
642 Example usage:
643
644 ..testcode::
645
646 class MainHandler(tornado.web.RequestHandler,
647 tornado.auth.FacebookGraphMixin):
648 @tornado.web.authenticated
649 async def get(self):
650 new_entry = await self.oauth2_request(
651 "https://graph.facebook.com/me/feed",
652 post_args={"message": "I am posting from my Tornado application!"},
653 access_token=self.current_user["access_token"])
654
655 if not new_entry:
656 # Call failed; perhaps missing permission?
657 self.authorize_redirect()
658 return
659 self.finish("Posted a message!")
660
661 .. versionadded:: 4.3
662
663 .. versionchanged::: 6.0
664
665 The ``callback`` argument was removed. Use the returned awaitable object instead.
666 """
667 all_args = {}
668 if access_token:
669 all_args["access_token"] = access_token
670 all_args.update(args)
671
672 if all_args:
673 url += "?" + urllib.parse.urlencode(all_args)
674 http = self.get_auth_http_client()
675 if post_args is not None:
676 response = await http.fetch(
677 url, method="POST", body=urllib.parse.urlencode(post_args)
678 )
679 else:
680 response = await http.fetch(url)
681 return escape.json_decode(response.body)
682
683 def get_auth_http_client(self) -> httpclient.AsyncHTTPClient:
684 """Returns the `.AsyncHTTPClient` instance to be used for auth requests.
685
686 May be overridden by subclasses to use an HTTP client other than
687 the default.
688
689 .. versionadded:: 4.3
690 """
691 return httpclient.AsyncHTTPClient()
692
693
694class TwitterMixin(OAuthMixin):
695 """Twitter OAuth authentication.
696
697 To authenticate with Twitter, register your application with
698 Twitter at http://twitter.com/apps. Then copy your Consumer Key
699 and Consumer Secret to the application
700 `~tornado.web.Application.settings` ``twitter_consumer_key`` and
701 ``twitter_consumer_secret``. Use this mixin on the handler for the
702 URL you registered as your application's callback URL.
703
704 When your application is set up, you can use this mixin like this
705 to authenticate the user with Twitter and get access to their stream:
706
707 .. testcode::
708
709 class TwitterLoginHandler(tornado.web.RequestHandler,
710 tornado.auth.TwitterMixin):
711 async def get(self):
712 if self.get_argument("oauth_token", None):
713 user = await self.get_authenticated_user()
714 # Save the user using e.g. set_signed_cookie()
715 else:
716 await self.authorize_redirect()
717
718 The user object returned by `~OAuthMixin.get_authenticated_user`
719 includes the attributes ``username``, ``name``, ``access_token``,
720 and all of the custom Twitter user attributes described at
721 https://dev.twitter.com/docs/api/1.1/get/users/show
722
723 .. deprecated:: 6.3
724 This class refers to version 1.1 of the Twitter API, which has been
725 deprecated by Twitter. Since Twitter has begun to limit access to its
726 API, this class will no longer be updated and will be removed in the
727 future.
728 """
729
730 _OAUTH_REQUEST_TOKEN_URL = "https://api.twitter.com/oauth/request_token"
731 _OAUTH_ACCESS_TOKEN_URL = "https://api.twitter.com/oauth/access_token"
732 _OAUTH_AUTHORIZE_URL = "https://api.twitter.com/oauth/authorize"
733 _OAUTH_AUTHENTICATE_URL = "https://api.twitter.com/oauth/authenticate"
734 _OAUTH_NO_CALLBACKS = False
735 _TWITTER_BASE_URL = "https://api.twitter.com/1.1"
736
737 async def authenticate_redirect(self, callback_uri: Optional[str] = None) -> None:
738 """Just like `~OAuthMixin.authorize_redirect`, but
739 auto-redirects if authorized.
740
741 This is generally the right interface to use if you are using
742 Twitter for single-sign on.
743
744 .. versionchanged:: 3.1
745 Now returns a `.Future` and takes an optional callback, for
746 compatibility with `.gen.coroutine`.
747
748 .. versionchanged:: 6.0
749
750 The ``callback`` argument was removed. Use the returned
751 awaitable object instead.
752 """
753 http = self.get_auth_http_client()
754 response = await http.fetch(
755 self._oauth_request_token_url(callback_uri=callback_uri)
756 )
757 self._on_request_token(self._OAUTH_AUTHENTICATE_URL, None, response)
758
759 async def twitter_request(
760 self,
761 path: str,
762 access_token: Dict[str, Any],
763 post_args: Optional[Dict[str, Any]] = None,
764 **args: Any,
765 ) -> Any:
766 """Fetches the given API path, e.g., ``statuses/user_timeline/btaylor``
767
768 The path should not include the format or API version number.
769 (we automatically use JSON format and API version 1).
770
771 If the request is a POST, ``post_args`` should be provided. Query
772 string arguments should be given as keyword arguments.
773
774 All the Twitter methods are documented at http://dev.twitter.com/
775
776 Many methods require an OAuth access token which you can
777 obtain through `~OAuthMixin.authorize_redirect` and
778 `~OAuthMixin.get_authenticated_user`. The user returned through that
779 process includes an 'access_token' attribute that can be used
780 to make authenticated requests via this method. Example
781 usage:
782
783 .. testcode::
784
785 class MainHandler(tornado.web.RequestHandler,
786 tornado.auth.TwitterMixin):
787 @tornado.web.authenticated
788 async def get(self):
789 new_entry = await self.twitter_request(
790 "/statuses/update",
791 post_args={"status": "Testing Tornado Web Server"},
792 access_token=self.current_user["access_token"])
793 if not new_entry:
794 # Call failed; perhaps missing permission?
795 await self.authorize_redirect()
796 return
797 self.finish("Posted a message!")
798
799 .. versionchanged:: 6.0
800
801 The ``callback`` argument was removed. Use the returned
802 awaitable object instead.
803 """
804 if path.startswith("http:") or path.startswith("https:"):
805 # Raw urls are useful for e.g. search which doesn't follow the
806 # usual pattern: http://search.twitter.com/search.json
807 url = path
808 else:
809 url = self._TWITTER_BASE_URL + path + ".json"
810 # Add the OAuth resource request signature if we have credentials
811 if access_token:
812 all_args = {}
813 all_args.update(args)
814 all_args.update(post_args or {})
815 method = "POST" if post_args is not None else "GET"
816 oauth = self._oauth_request_parameters(
817 url, access_token, all_args, method=method
818 )
819 args.update(oauth)
820 if args:
821 url += "?" + urllib.parse.urlencode(args)
822 http = self.get_auth_http_client()
823 if post_args is not None:
824 response = await http.fetch(
825 url, method="POST", body=urllib.parse.urlencode(post_args)
826 )
827 else:
828 response = await http.fetch(url)
829 return escape.json_decode(response.body)
830
831 def _oauth_consumer_token(self) -> Dict[str, Any]:
832 handler = cast(RequestHandler, self)
833 handler.require_setting("twitter_consumer_key", "Twitter OAuth")
834 handler.require_setting("twitter_consumer_secret", "Twitter OAuth")
835 return dict(
836 key=handler.settings["twitter_consumer_key"],
837 secret=handler.settings["twitter_consumer_secret"],
838 )
839
840 async def _oauth_get_user_future(
841 self, access_token: Dict[str, Any]
842 ) -> Dict[str, Any]:
843 user = await self.twitter_request(
844 "/account/verify_credentials", access_token=access_token
845 )
846 if user:
847 user["username"] = user["screen_name"]
848 return user
849
850
851class GoogleOAuth2Mixin(OAuth2Mixin):
852 """Google authentication using OAuth2.
853
854 In order to use, register your application with Google and copy the
855 relevant parameters to your application settings.
856
857 * Go to the Google Dev Console at http://console.developers.google.com
858 * Select a project, or create a new one.
859 * Depending on permissions required, you may need to set your app to
860 "testing" mode and add your account as a test user, or go through
861 a verfication process. You may also need to use the "Enable
862 APIs and Services" command to enable specific services.
863 * In the sidebar on the left, select Credentials.
864 * Click CREATE CREDENTIALS and click OAuth client ID.
865 * Under Application type, select Web application.
866 * Name OAuth 2.0 client and click Create.
867 * Copy the "Client secret" and "Client ID" to the application settings as
868 ``{"google_oauth": {"key": CLIENT_ID, "secret": CLIENT_SECRET}}``
869 * You must register the ``redirect_uri`` you plan to use with this class
870 on the Credentials page.
871
872 .. versionadded:: 3.2
873 """
874
875 _OAUTH_AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth"
876 _OAUTH_ACCESS_TOKEN_URL = "https://www.googleapis.com/oauth2/v4/token"
877 _OAUTH_USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo"
878 _OAUTH_NO_CALLBACKS = False
879 _OAUTH_SETTINGS_KEY = "google_oauth"
880
881 def get_google_oauth_settings(self) -> Dict[str, str]:
882 """Return the Google OAuth 2.0 credentials that you created with
883 [Google Cloud
884 Platform](https://console.cloud.google.com/apis/credentials). The dict
885 format is::
886
887 {
888 "key": "your_client_id", "secret": "your_client_secret"
889 }
890
891 If your credentials are stored differently (e.g. in a db) you can
892 override this method for custom provision.
893 """
894 handler = cast(RequestHandler, self)
895 return handler.settings[self._OAUTH_SETTINGS_KEY]
896
897 async def get_authenticated_user(
898 self,
899 redirect_uri: str,
900 code: str,
901 client_id: Optional[str] = None,
902 client_secret: Optional[str] = None,
903 ) -> Dict[str, Any]:
904 """Handles the login for the Google user, returning an access token.
905
906 The result is a dictionary containing an ``access_token`` field
907 ([among others](https://developers.google.com/identity/protocols/OAuth2WebServer#handlingtheresponse)).
908 Unlike other ``get_authenticated_user`` methods in this package,
909 this method does not return any additional information about the user.
910 The returned access token can be used with `OAuth2Mixin.oauth2_request`
911 to request additional information (perhaps from
912 ``https://www.googleapis.com/oauth2/v2/userinfo``)
913
914 Example usage:
915
916 .. testsetup::
917
918 import urllib
919
920 .. testcode::
921
922 class GoogleOAuth2LoginHandler(tornado.web.RequestHandler,
923 tornado.auth.GoogleOAuth2Mixin):
924 async def get(self):
925 # Google requires an exact match for redirect_uri, so it's
926 # best to get it from your app configuration instead of from
927 # self.request.full_uri().
928 redirect_uri = urllib.parse.urljoin(self.application.settings['redirect_base_uri'],
929 self.reverse_url('google_oauth'))
930 async def get(self):
931 if self.get_argument('code', False):
932 access = await self.get_authenticated_user(
933 redirect_uri=redirect_uri,
934 code=self.get_argument('code'))
935 user = await self.oauth2_request(
936 "https://www.googleapis.com/oauth2/v1/userinfo",
937 access_token=access["access_token"])
938 # Save the user and access token. For example:
939 user_cookie = dict(id=user["id"], access_token=access["access_token"])
940 self.set_signed_cookie("user", json.dumps(user_cookie))
941 self.redirect("/")
942 else:
943 self.authorize_redirect(
944 redirect_uri=redirect_uri,
945 client_id=self.get_google_oauth_settings()['key'],
946 scope=['profile', 'email'],
947 response_type='code',
948 extra_params={'approval_prompt': 'auto'})
949
950 .. versionchanged:: 6.0
951
952 The ``callback`` argument was removed. Use the returned awaitable object instead.
953 """ # noqa: E501
954
955 if client_id is None or client_secret is None:
956 settings = self.get_google_oauth_settings()
957 if client_id is None:
958 client_id = settings["key"]
959 if client_secret is None:
960 client_secret = settings["secret"]
961 http = self.get_auth_http_client()
962 body = urllib.parse.urlencode(
963 {
964 "redirect_uri": redirect_uri,
965 "code": code,
966 "client_id": client_id,
967 "client_secret": client_secret,
968 "grant_type": "authorization_code",
969 }
970 )
971
972 response = await http.fetch(
973 self._OAUTH_ACCESS_TOKEN_URL,
974 method="POST",
975 headers={"Content-Type": "application/x-www-form-urlencoded"},
976 body=body,
977 )
978 return escape.json_decode(response.body)
979
980
981class FacebookGraphMixin(OAuth2Mixin):
982 """Facebook authentication using the new Graph API and OAuth2."""
983
984 _OAUTH_ACCESS_TOKEN_URL = "https://graph.facebook.com/oauth/access_token?"
985 _OAUTH_AUTHORIZE_URL = "https://www.facebook.com/dialog/oauth?"
986 _OAUTH_NO_CALLBACKS = False
987 _FACEBOOK_BASE_URL = "https://graph.facebook.com"
988
989 async def get_authenticated_user(
990 self,
991 redirect_uri: str,
992 client_id: str,
993 client_secret: str,
994 code: str,
995 extra_fields: Optional[Dict[str, Any]] = None,
996 ) -> Optional[Dict[str, Any]]:
997 """Handles the login for the Facebook user, returning a user object.
998
999 Example usage:
1000
1001 .. testcode::
1002
1003 class FacebookGraphLoginHandler(tornado.web.RequestHandler,
1004 tornado.auth.FacebookGraphMixin):
1005 async def get(self):
1006 redirect_uri = urllib.parse.urljoin(
1007 self.application.settings['redirect_base_uri'],
1008 self.reverse_url('facebook_oauth'))
1009 if self.get_argument("code", False):
1010 user = await self.get_authenticated_user(
1011 redirect_uri=redirect_uri,
1012 client_id=self.settings["facebook_api_key"],
1013 client_secret=self.settings["facebook_secret"],
1014 code=self.get_argument("code"))
1015 # Save the user with e.g. set_signed_cookie
1016 else:
1017 self.authorize_redirect(
1018 redirect_uri=redirect_uri,
1019 client_id=self.settings["facebook_api_key"],
1020 extra_params={"scope": "user_posts"})
1021
1022 This method returns a dictionary which may contain the following fields:
1023
1024 * ``access_token``, a string which may be passed to `facebook_request`
1025 * ``session_expires``, an integer encoded as a string representing
1026 the time until the access token expires in seconds. This field should
1027 be used like ``int(user['session_expires'])``; in a future version of
1028 Tornado it will change from a string to an integer.
1029 * ``id``, ``name``, ``first_name``, ``last_name``, ``locale``, ``picture``,
1030 ``link``, plus any fields named in the ``extra_fields`` argument. These
1031 fields are copied from the Facebook graph API
1032 `user object <https://developers.facebook.com/docs/graph-api/reference/user>`_
1033
1034 .. versionchanged:: 4.5
1035 The ``session_expires`` field was updated to support changes made to the
1036 Facebook API in March 2017.
1037
1038 .. versionchanged:: 6.0
1039
1040 The ``callback`` argument was removed. Use the returned awaitable object instead.
1041 """
1042 http = self.get_auth_http_client()
1043 args = {
1044 "redirect_uri": redirect_uri,
1045 "code": code,
1046 "client_id": client_id,
1047 "client_secret": client_secret,
1048 }
1049
1050 fields = {"id", "name", "first_name", "last_name", "locale", "picture", "link"}
1051 if extra_fields:
1052 fields.update(extra_fields)
1053
1054 response = await http.fetch(
1055 self._oauth_request_token_url(**args) # type: ignore
1056 )
1057 args = escape.json_decode(response.body)
1058 session = {
1059 "access_token": args.get("access_token"),
1060 "expires_in": args.get("expires_in"),
1061 }
1062 assert session["access_token"] is not None
1063
1064 user = await self.facebook_request(
1065 path="/me",
1066 access_token=session["access_token"],
1067 appsecret_proof=hmac.new(
1068 key=client_secret.encode("utf8"),
1069 msg=session["access_token"].encode("utf8"),
1070 digestmod=hashlib.sha256,
1071 ).hexdigest(),
1072 fields=",".join(fields),
1073 )
1074
1075 if user is None:
1076 return None
1077
1078 fieldmap = {}
1079 for field in fields:
1080 fieldmap[field] = user.get(field)
1081
1082 # session_expires is converted to str for compatibility with
1083 # older versions in which the server used url-encoding and
1084 # this code simply returned the string verbatim.
1085 # This should change in Tornado 5.0.
1086 fieldmap.update(
1087 {
1088 "access_token": session["access_token"],
1089 "session_expires": str(session.get("expires_in")),
1090 }
1091 )
1092 return fieldmap
1093
1094 async def facebook_request(
1095 self,
1096 path: str,
1097 access_token: Optional[str] = None,
1098 post_args: Optional[Dict[str, Any]] = None,
1099 **args: Any,
1100 ) -> Any:
1101 """Fetches the given relative API path, e.g., "/btaylor/picture"
1102
1103 If the request is a POST, ``post_args`` should be provided. Query
1104 string arguments should be given as keyword arguments.
1105
1106 An introduction to the Facebook Graph API can be found at
1107 http://developers.facebook.com/docs/api
1108
1109 Many methods require an OAuth access token which you can
1110 obtain through `~OAuth2Mixin.authorize_redirect` and
1111 `get_authenticated_user`. The user returned through that
1112 process includes an ``access_token`` attribute that can be
1113 used to make authenticated requests via this method.
1114
1115 Example usage:
1116
1117 .. testcode::
1118
1119 class MainHandler(tornado.web.RequestHandler,
1120 tornado.auth.FacebookGraphMixin):
1121 @tornado.web.authenticated
1122 async def get(self):
1123 new_entry = await self.facebook_request(
1124 "/me/feed",
1125 post_args={"message": "I am posting from my Tornado application!"},
1126 access_token=self.current_user["access_token"])
1127
1128 if not new_entry:
1129 # Call failed; perhaps missing permission?
1130 self.authorize_redirect()
1131 return
1132 self.finish("Posted a message!")
1133
1134 The given path is relative to ``self._FACEBOOK_BASE_URL``,
1135 by default "https://graph.facebook.com".
1136
1137 This method is a wrapper around `OAuth2Mixin.oauth2_request`;
1138 the only difference is that this method takes a relative path,
1139 while ``oauth2_request`` takes a complete url.
1140
1141 .. versionchanged:: 3.1
1142 Added the ability to override ``self._FACEBOOK_BASE_URL``.
1143
1144 .. versionchanged:: 6.0
1145
1146 The ``callback`` argument was removed. Use the returned awaitable object instead.
1147 """
1148 url = self._FACEBOOK_BASE_URL + path
1149 return await self.oauth2_request(
1150 url, access_token=access_token, post_args=post_args, **args
1151 )
1152
1153
1154def _oauth_signature(
1155 consumer_token: Dict[str, Any],
1156 method: str,
1157 url: str,
1158 parameters: Dict[str, Any] = {},
1159 token: Optional[Dict[str, Any]] = None,
1160) -> bytes:
1161 """Calculates the HMAC-SHA1 OAuth signature for the given request.
1162
1163 See http://oauth.net/core/1.0/#signing_process
1164 """
1165 parts = urllib.parse.urlparse(url)
1166 scheme, netloc, path = parts[:3]
1167 normalized_url = scheme.lower() + "://" + netloc.lower() + path
1168
1169 base_elems = []
1170 base_elems.append(method.upper())
1171 base_elems.append(normalized_url)
1172 base_elems.append(
1173 "&".join(f"{k}={_oauth_escape(str(v))}" for k, v in sorted(parameters.items()))
1174 )
1175 base_string = "&".join(_oauth_escape(e) for e in base_elems)
1176
1177 key_elems = [escape.utf8(consumer_token["secret"])]
1178 key_elems.append(escape.utf8(token["secret"] if token else ""))
1179 key = b"&".join(key_elems)
1180
1181 hash = hmac.new(key, escape.utf8(base_string), hashlib.sha1)
1182 return binascii.b2a_base64(hash.digest())[:-1]
1183
1184
1185def _oauth10a_signature(
1186 consumer_token: Dict[str, Any],
1187 method: str,
1188 url: str,
1189 parameters: Dict[str, Any] = {},
1190 token: Optional[Dict[str, Any]] = None,
1191) -> bytes:
1192 """Calculates the HMAC-SHA1 OAuth 1.0a signature for the given request.
1193
1194 See http://oauth.net/core/1.0a/#signing_process
1195 """
1196 parts = urllib.parse.urlparse(url)
1197 scheme, netloc, path = parts[:3]
1198 normalized_url = scheme.lower() + "://" + netloc.lower() + path
1199
1200 base_elems = []
