diff --git a/passbook/sources/oauth/clients.py b/passbook/sources/oauth/clients.py deleted file mode 100644 index c5f210ccb..000000000 --- a/passbook/sources/oauth/clients.py +++ /dev/null @@ -1,278 +0,0 @@ -"""OAuth Clients""" -import json -from typing import TYPE_CHECKING, Any, Dict, Optional -from urllib.parse import parse_qs, urlencode - -from django.http import HttpRequest -from django.utils.crypto import constant_time_compare, get_random_string -from django.utils.encoding import force_str -from requests import Session -from requests.exceptions import RequestException -from requests_oauthlib import OAuth1 -from structlog import get_logger - -from passbook import __version__ - -LOGGER = get_logger() -if TYPE_CHECKING: - from passbook.sources.oauth.models import OAuthSource - - -class BaseOAuthClient: - """Base OAuth Client""" - - session: Session - source: "OAuthSource" - - def __init__(self, source: "OAuthSource", token=""): # nosec - self.source = source - self.token = token - self.session = Session() - self.session.headers.update({"User-Agent": "passbook %s" % __version__}) - - def get_access_token( - self, request: HttpRequest, callback=None - ) -> Optional[Dict[str, Any]]: - "Fetch access token from callback request." - raise NotImplementedError("Defined in a sub-class") # pragma: no cover - - def get_profile_info(self, token: Dict[str, str]) -> Optional[Dict[str, Any]]: - "Fetch user profile information." - try: - headers = { - "Authorization": f"{token['token_type']} {token['access_token']}" - } - response = self.session.request( - "get", self.source.profile_url, headers=headers, - ) - response.raise_for_status() - except RequestException as exc: - LOGGER.warning("Unable to fetch user profile", exc=exc) - return None - else: - return response.json() - - def get_redirect_args(self, request, callback) -> Dict[str, str]: - "Get request parameters for redirect url." - raise NotImplementedError("Defined in a sub-class") # pragma: no cover - - def get_redirect_url(self, request, callback, parameters=None): - "Build authentication redirect url." - args = self.get_redirect_args(request, callback=callback) - additional = parameters or {} - args.update(additional) - params = urlencode(args) - LOGGER.info("redirect args", **args) - return "{0}?{1}".format(self.source.authorization_url, params) - - def parse_raw_token(self, raw_token): - "Parse token and secret from raw token response." - raise NotImplementedError("Defined in a sub-class") # pragma: no cover - - @property - def session_key(self): - """Return Session Key""" - raise NotImplementedError("Defined in a sub-class") # pragma: no cover - - -class OAuthClient(BaseOAuthClient): - """OAuth1 Client""" - - _default_headers = { - "Accept": "application/json", - } - - def get_access_token( - self, request: HttpRequest, callback=None - ) -> Optional[Dict[str, str]]: - "Fetch access token from callback request." - raw_token = request.session.get(self.session_key, None) - verifier = request.GET.get("oauth_verifier", None) - if raw_token is not None and verifier is not None: - data = { - "oauth_verifier": verifier, - "oauth_callback": callback, - "token": raw_token, - } - try: - response = self.session.request( - "post", - self.source.access_token_url, - data=data, - headers=self._default_headers, - ) - response.raise_for_status() - except RequestException as exc: - LOGGER.warning("Unable to fetch access token", exc=exc) - return None - else: - return response.json() - return None - - def get_request_token(self, request, callback): - "Fetch the OAuth request token. Only required for OAuth 1.0." - callback = force_str(request.build_absolute_uri(callback)) - try: - response = self.session.request( - "post", - self.source.request_token_url, - data={"oauth_callback": callback}, - headers=self._default_headers, - ) - response.raise_for_status() - except RequestException as exc: - LOGGER.warning("Unable to fetch request token", exc=exc) - return None - else: - return response.text - - def get_redirect_args(self, request, callback): - "Get request parameters for redirect url." - callback = force_str(request.build_absolute_uri(callback)) - raw_token = self.get_request_token(request, callback) - token, secret = self.parse_raw_token(raw_token) - if token is not None and secret is not None: - request.session[self.session_key] = raw_token - return { - "oauth_token": token, - "oauth_callback": callback, - } - - def parse_raw_token(self, raw_token): - "Parse token and secret from raw token response." - if raw_token is None: - return (None, None) - query_string = parse_qs(raw_token) - token = query_string.get("oauth_token", [None])[0] - secret = query_string.get("oauth_token_secret", [None])[0] - return (token, secret) - - def request(self, method, url, **kwargs): - "Build remote url request. Constructs necessary auth." - user_token = kwargs.pop("token", self.token) - token, secret = self.parse_raw_token(user_token) - callback = kwargs.pop("oauth_callback", None) - verifier = kwargs.get("data", {}).pop("oauth_verifier", None) - oauth = OAuth1( - resource_owner_key=token, - resource_owner_secret=secret, - client_key=self.source.consumer_key, - client_secret=self.source.consumer_secret, - verifier=verifier, - callback_uri=callback, - ) - kwargs["auth"] = oauth - return super(OAuthClient, self).session.request(method, url, **kwargs) - - @property - def session_key(self): - return "oauth-client-{0}-request-token".format(self.source.name) - - -class OAuth2Client(BaseOAuthClient): - """OAuth2 Client""" - - _default_headers = { - "Accept": "application/json", - } - - # pylint: disable=unused-argument - def check_application_state(self, request, callback): - "Check optional state parameter." - stored = request.session.get(self.session_key, None) - returned = request.GET.get("state", None) - check = False - if stored is not None: - if returned is not None: - check = constant_time_compare(stored, returned) - else: - LOGGER.warning("No state parameter returned by the source.") - else: - LOGGER.warning("No state stored in the sesssion.") - return check - - def get_access_token(self, request, callback=None, **request_kwargs): - "Fetch access token from callback request." - callback = request.build_absolute_uri(callback or request.path) - if not self.check_application_state(request, callback): - LOGGER.warning("Application state check failed.") - return None - if "code" in request.GET: - args = { - "client_id": self.source.consumer_key, - "redirect_uri": callback, - "client_secret": self.source.consumer_secret, - "code": request.GET["code"], - "grant_type": "authorization_code", - } - else: - LOGGER.warning("No code returned by the source") - return None - try: - response = self.session.request( - "post", - self.source.access_token_url, - data=args, - headers=self._default_headers, - **request_kwargs, - ) - response.raise_for_status() - except RequestException as exc: - LOGGER.warning("Unable to fetch access token", exc=exc) - return None - else: - return response.json() - - # pylint: disable=unused-argument - def get_application_state(self, request, callback): - "Generate state optional parameter." - return get_random_string(32) - - def get_redirect_args(self, request, callback): - "Get request parameters for redirect url." - callback = request.build_absolute_uri(callback) - args = { - "client_id": self.source.consumer_key, - "redirect_uri": callback, - "response_type": "code", - } - state = self.get_application_state(request, callback) - if state is not None: - args["state"] = state - request.session[self.session_key] = state - return args - - def parse_raw_token(self, raw_token): - "Parse token and secret from raw token response." - if raw_token is None: - return (None, None) - # Load as json first then parse as query string - try: - token_data = json.loads(raw_token) - except ValueError: - token = parse_qs(raw_token).get("access_token", [None])[0] - else: - token = token_data.get("access_token", None) - return (token, None) - - def request(self, method, url, **kwargs): - "Build remote url request. Constructs necessary auth." - user_token = kwargs.pop("token", self.token) - token, _ = self.parse_raw_token(user_token) - if token is not None: - params = kwargs.get("params", {}) - params["access_token"] = token - kwargs["params"] = params - return super(OAuth2Client, self).session.request(method, url, **kwargs) - - @property - def session_key(self): - return "oauth-client-{0}-request-state".format(self.source.name) - - -def get_client(source, token=""): # nosec - "Return the API client for the given source." - cls = OAuth2Client - if source.request_token_url: - cls = OAuthClient - return cls(source, token) diff --git a/passbook/sources/oauth/clients/__init__.py b/passbook/sources/oauth/clients/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/sources/oauth/clients/base.py b/passbook/sources/oauth/clients/base.py new file mode 100644 index 000000000..285cdbd69 --- /dev/null +++ b/passbook/sources/oauth/clients/base.py @@ -0,0 +1,71 @@ +"""OAuth Clients""" +from typing import TYPE_CHECKING, Any, Dict, Optional +from urllib.parse import urlencode + +from django.http import HttpRequest +from requests import Session +from requests.exceptions import RequestException +from structlog import get_logger + +from passbook import __version__ + +LOGGER = get_logger() +if TYPE_CHECKING: + from passbook.sources.oauth.models import OAuthSource + + +class BaseOAuthClient: + """Base OAuth Client""" + + session: Session + source: "OAuthSource" + + def __init__(self, source: "OAuthSource", token=""): # nosec + self.source = source + self.token = token + self.session = Session() + self.session.headers.update({"User-Agent": "passbook %s" % __version__}) + + def get_access_token( + self, request: HttpRequest, callback=None + ) -> Optional[Dict[str, Any]]: + "Fetch access token from callback request." + raise NotImplementedError("Defined in a sub-class") # pragma: no cover + + def get_profile_info(self, token: Dict[str, str]) -> Optional[Dict[str, Any]]: + "Fetch user profile information." + try: + headers = { + "Authorization": f"{token['token_type']} {token['access_token']}" + } + response = self.session.request( + "get", self.source.profile_url, headers=headers, + ) + response.raise_for_status() + except RequestException as exc: + LOGGER.warning("Unable to fetch user profile", exc=exc) + return None + else: + return response.json() + + def get_redirect_args(self, request, callback) -> Dict[str, str]: + "Get request parameters for redirect url." + raise NotImplementedError("Defined in a sub-class") # pragma: no cover + + def get_redirect_url(self, request: HttpRequest, callback: str, parameters=None): + "Build authentication redirect url." + args = self.get_redirect_args(request, callback=callback) + additional = parameters or {} + args.update(additional) + params = urlencode(args) + LOGGER.info("redirect args", **args) + return "{0}?{1}".format(self.source.authorization_url, params) + + def parse_raw_token(self, raw_token): + "Parse token and secret from raw token response." + raise NotImplementedError("Defined in a sub-class") # pragma: no cover + + @property + def session_key(self): + """Return Session Key""" + raise NotImplementedError("Defined in a sub-class") # pragma: no cover diff --git a/passbook/sources/oauth/clients/oauth1.py b/passbook/sources/oauth/clients/oauth1.py new file mode 100644 index 000000000..338844d8f --- /dev/null +++ b/passbook/sources/oauth/clients/oauth1.py @@ -0,0 +1,111 @@ +"""OAuth Clients""" +from typing import Dict, Optional +from urllib.parse import parse_qs + +from django.http import HttpRequest +from django.utils.encoding import force_str +from requests.exceptions import RequestException +from requests_oauthlib import OAuth1 +from structlog import get_logger + +from passbook import __version__ +from passbook.sources.oauth.clients.base import BaseOAuthClient + +LOGGER = get_logger() + + +class OAuthClient(BaseOAuthClient): + """OAuth1 Client""" + + _default_headers = { + "Accept": "application/json", + } + + def get_access_token( + self, request: HttpRequest, callback=None + ) -> Optional[Dict[str, str]]: + "Fetch access token from callback request." + raw_token = request.session.get(self.session_key, None) + verifier = request.GET.get("oauth_verifier", None) + if raw_token is not None and verifier is not None: + data = { + "oauth_verifier": verifier, + "oauth_callback": callback, + "token": raw_token, + } + try: + response = self.session.request( + "post", + self.source.access_token_url, + data=data, + headers=self._default_headers, + ) + response.raise_for_status() + except RequestException as exc: + LOGGER.warning("Unable to fetch access token", exc=exc) + return None + else: + return response.json() + return None + + def get_request_token(self, request: HttpRequest, callback): + "Fetch the OAuth request token. Only required for OAuth 1.0." + callback = request.build_absolute_uri(callback) + try: + response = self.session.request( + "post", + self.source.request_token_url, + data={ + "oauth_callback": callback, + "oauth_consumer_key": self.source.consumer_key, + }, + headers=self._default_headers, + ) + response.raise_for_status() + except RequestException as exc: + LOGGER.warning("Unable to fetch request token", exc=exc) + return None + else: + return response.text + + def get_redirect_args(self, request: HttpRequest, callback): + "Get request parameters for redirect url." + callback = force_str(request.build_absolute_uri(callback)) + raw_token = self.get_request_token(request, callback) + token, secret = self.parse_raw_token(raw_token) + if token is not None and secret is not None: + request.session[self.session_key] = raw_token + return { + "oauth_token": token, + "oauth_callback": callback, + } + + def parse_raw_token(self, raw_token): + "Parse token and secret from raw token response." + if raw_token is None: + return (None, None) + query_string = parse_qs(raw_token) + token = query_string.get("oauth_token", [None])[0] + secret = query_string.get("oauth_token_secret", [None])[0] + return (token, secret) + + def request(self, method, url, **kwargs): + "Build remote url request. Constructs necessary auth." + user_token = kwargs.pop("token", self.token) + token, secret = self.parse_raw_token(user_token) + callback = kwargs.pop("oauth_callback", None) + verifier = kwargs.get("data", {}).pop("oauth_verifier", None) + oauth = OAuth1( + resource_owner_key=token, + resource_owner_secret=secret, + client_key=self.source.consumer_key, + client_secret=self.source.consumer_secret, + verifier=verifier, + callback_uri=callback, + ) + kwargs["auth"] = oauth + return super(OAuthClient, self).session.request(method, url, **kwargs) + + @property + def session_key(self): + return "oauth-client-{0}-request-token".format(self.source.name) diff --git a/passbook/sources/oauth/clients/oauth2.py b/passbook/sources/oauth/clients/oauth2.py new file mode 100644 index 000000000..dfafdbadc --- /dev/null +++ b/passbook/sources/oauth/clients/oauth2.py @@ -0,0 +1,117 @@ +"""OAuth Clients""" +import json +from typing import TYPE_CHECKING +from urllib.parse import parse_qs + +from django.http import HttpRequest +from django.utils.crypto import constant_time_compare, get_random_string +from requests.exceptions import RequestException +from structlog import get_logger + +from passbook import __version__ +from passbook.sources.oauth.clients.base import BaseOAuthClient + +LOGGER = get_logger() +if TYPE_CHECKING: + from passbook.sources.oauth.models import OAuthSource + + +class OAuth2Client(BaseOAuthClient): + """OAuth2 Client""" + + _default_headers = { + "Accept": "application/json", + } + + # pylint: disable=unused-argument + def check_application_state(self, request: HttpRequest, callback: str): + "Check optional state parameter." + stored = request.session.get(self.session_key, None) + returned = request.GET.get("state", None) + check = False + if stored is not None: + if returned is not None: + check = constant_time_compare(stored, returned) + else: + LOGGER.warning("No state parameter returned by the source.") + else: + LOGGER.warning("No state stored in the sesssion.") + return check + + def get_access_token(self, request: HttpRequest, callback=None, **request_kwargs): + "Fetch access token from callback request." + callback = request.build_absolute_uri(callback or request.path) + if not self.check_application_state(request, callback): + LOGGER.warning("Application state check failed.") + return None + if "code" in request.GET: + args = { + "client_id": self.source.consumer_key, + "redirect_uri": callback, + "client_secret": self.source.consumer_secret, + "code": request.GET["code"], + "grant_type": "authorization_code", + } + else: + LOGGER.warning("No code returned by the source") + return None + try: + response = self.session.request( + "post", + self.source.access_token_url, + data=args, + headers=self._default_headers, + **request_kwargs, + ) + response.raise_for_status() + except RequestException as exc: + LOGGER.warning("Unable to fetch access token", exc=exc) + return None + else: + return response.json() + + # pylint: disable=unused-argument + def get_application_state(self, request: HttpRequest, callback): + "Generate state optional parameter." + return get_random_string(32) + + def get_redirect_args(self, request, callback): + "Get request parameters for redirect url." + callback = request.build_absolute_uri(callback) + args = { + "client_id": self.source.consumer_key, + "redirect_uri": callback, + "response_type": "code", + } + state = self.get_application_state(request, callback) + if state is not None: + args["state"] = state + request.session[self.session_key] = state + return args + + def parse_raw_token(self, raw_token): + "Parse token and secret from raw token response." + if raw_token is None: + return (None, None) + # Load as json first then parse as query string + try: + token_data = json.loads(raw_token) + except ValueError: + token = parse_qs(raw_token).get("access_token", [None])[0] + else: + token = token_data.get("access_token", None) + return (token, None) + + def request(self, method, url, **kwargs): + "Build remote url request. Constructs necessary auth." + user_token = kwargs.pop("token", self.token) + token, _ = self.parse_raw_token(user_token) + if token is not None: + params = kwargs.get("params", {}) + params["access_token"] = token + kwargs["params"] = params + return super(OAuth2Client, self).session.request(method, url, **kwargs) + + @property + def session_key(self): + return "oauth-client-{0}-request-state".format(self.source.name) diff --git a/passbook/sources/oauth/models.py b/passbook/sources/oauth/models.py index ff97d84f2..06d4e42e4 100644 --- a/passbook/sources/oauth/models.py +++ b/passbook/sources/oauth/models.py @@ -8,7 +8,6 @@ from django.utils.translation import gettext_lazy as _ from passbook.core.models import Source, UserSourceConnection from passbook.core.types import UILoginButton, UIUserSettings -from passbook.sources.oauth.clients import get_client class OAuthSource(Source): @@ -96,18 +95,19 @@ class GitHubOAuthSource(OAuthSource): verbose_name_plural = _("GitHub OAuth Sources") -# class TwitterOAuthSource(OAuthSource): -# """Social Login using Twitter.com""" +class TwitterOAuthSource(OAuthSource): + """Social Login using Twitter.com""" -# def form(self) -> Type[ModelForm]: -# from passbook.sources.oauth.forms import TwitterOAuthSourceForm -# return TwitterOAuthSourceForm + def form(self) -> Type[ModelForm]: + from passbook.sources.oauth.forms import TwitterOAuthSourceForm -# class Meta: + return TwitterOAuthSourceForm -# abstract = True -# verbose_name = _("Twitter OAuth Source") -# verbose_name_plural = _("Twitter OAuth Sources") + class Meta: + + abstract = True + verbose_name = _("Twitter OAuth Source") + verbose_name_plural = _("Twitter OAuth Sources") class FacebookOAuthSource(OAuthSource): @@ -195,11 +195,6 @@ class UserOAuthSourceConnection(UserSourceConnection): self.access_token = self.access_token or None super().save(*args, **kwargs) - @property - def api_client(self): - """Get API Client""" - return get_client(self.source, self.access_token or "") - class Meta: verbose_name = _("User OAuth Source Connection") diff --git a/passbook/sources/oauth/types/facebook.py b/passbook/sources/oauth/types/facebook.py index 77da60db4..5bed31164 100644 --- a/passbook/sources/oauth/types/facebook.py +++ b/passbook/sources/oauth/types/facebook.py @@ -3,7 +3,7 @@ from typing import Any, Dict, Optional from facebook import GraphAPI -from passbook.sources.oauth.clients import OAuth2Client +from passbook.sources.oauth.clients.oauth2 import OAuth2Client from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection from passbook.sources.oauth.types.manager import MANAGER, RequestKind from passbook.sources.oauth.views.callback import OAuthCallback diff --git a/passbook/sources/oauth/types/reddit.py b/passbook/sources/oauth/types/reddit.py index d12ad4867..d18cd29a3 100644 --- a/passbook/sources/oauth/types/reddit.py +++ b/passbook/sources/oauth/types/reddit.py @@ -3,7 +3,7 @@ from typing import Any, Dict from requests.auth import HTTPBasicAuth -from passbook.sources.oauth.clients import OAuth2Client +from passbook.sources.oauth.clients.oauth2 import OAuth2Client from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection from passbook.sources.oauth.types.manager import MANAGER, RequestKind from passbook.sources.oauth.views.callback import OAuthCallback diff --git a/passbook/sources/oauth/types/twitter.py b/passbook/sources/oauth/types/twitter.py index 81c0a5e6d..0509ecc81 100644 --- a/passbook/sources/oauth/types/twitter.py +++ b/passbook/sources/oauth/types/twitter.py @@ -2,12 +2,11 @@ from typing import Any, Dict from passbook.sources.oauth.models import OAuthSource, UserOAuthSourceConnection +from passbook.sources.oauth.types.manager import MANAGER, RequestKind from passbook.sources.oauth.views.callback import OAuthCallback -# from passbook.sources.oauth.types.manager import MANAGER, RequestKind - -# @MANAGER.source(kind=RequestKind.callback, name="Twitter") +@MANAGER.source(kind=RequestKind.callback, name="Twitter") class TwitterOAuthCallback(OAuthCallback): """Twitter OAuth2 Callback""" diff --git a/passbook/sources/oauth/views/base.py b/passbook/sources/oauth/views/base.py index 402c489bb..32342b24d 100644 --- a/passbook/sources/oauth/views/base.py +++ b/passbook/sources/oauth/views/base.py @@ -1,7 +1,9 @@ """OAuth Base views""" -from typing import Callable, Optional +from typing import Optional, Type -from passbook.sources.oauth.clients import BaseOAuthClient, get_client +from passbook.sources.oauth.clients.base import BaseOAuthClient +from passbook.sources.oauth.clients.oauth1 import OAuthClient +from passbook.sources.oauth.clients.oauth2 import OAuth2Client from passbook.sources.oauth.models import OAuthSource @@ -9,11 +11,13 @@ from passbook.sources.oauth.models import OAuthSource class OAuthClientMixin: "Mixin for getting OAuth client for a source." - client_class: Optional[Callable] = None + client_class: Optional[Type[BaseOAuthClient]] = None def get_client(self, source: OAuthSource) -> BaseOAuthClient: "Get instance of the OAuth client for this source." if self.client_class is not None: # pylint: disable=not-callable return self.client_class(source) - return get_client(source) + if source.request_token_url: + return OAuthClient(source) + return OAuth2Client(source)