diff --git a/ckanext/oauth2/cli.py b/ckanext/oauth2/cli.py new file mode 100644 index 0000000..5623b30 --- /dev/null +++ b/ckanext/oauth2/cli.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- + +import click + +@click.group() +def oauth2(): + """Oauth2 management commands. + """ + pass + + +def get_commands(): + return [oauth2] diff --git a/ckanext/oauth2/constants.py b/ckanext/oauth2/constants.py index 3fe7854..720aea4 100644 --- a/ckanext/oauth2/constants.py +++ b/ckanext/oauth2/constants.py @@ -1,3 +1,3 @@ CAME_FROM_FIELD = 'came_from' -INITIAL_PAGE = '/dashboard' +INITIAL_PAGE = '/' REDIRECT_URL = 'oauth2/callback' diff --git a/ckanext/oauth2/controller.py b/ckanext/oauth2/controller.py index acd965b..7900030 100644 --- a/ckanext/oauth2/controller.py +++ b/ckanext/oauth2/controller.py @@ -18,16 +18,14 @@ # You should have received a copy of the GNU Affero General Public License # along with OAuth2 CKAN Extension. If not, see . -from __future__ import unicode_literals - import logging -import constants +from ckanext.oauth2 import constants from ckan.common import session import ckan.lib.helpers as helpers import ckan.lib.base as base import ckan.plugins.toolkit as toolkit -import oauth2 +from ckanext.oauth2 import oauth2 from ckanext.oauth2.plugin import _get_previous_page diff --git a/ckanext/oauth2/db.py b/ckanext/oauth2/db.py index 013f58f..5ac410a 100644 --- a/ckanext/oauth2/db.py +++ b/ckanext/oauth2/db.py @@ -18,10 +18,12 @@ # along with OAuth2 CKAN Extension. If not, see . import sqlalchemy as sa +from sqlalchemy.ext.declarative import declarative_base -UserToken = None - +Base = declarative_base() +metadata = Base.metadata +UserToken = None def init_db(model): global UserToken diff --git a/ckanext/oauth2/oauth2.py b/ckanext/oauth2/oauth2.py index 28a2724..6bc3adc 100644 --- a/ckanext/oauth2/oauth2.py +++ b/ckanext/oauth2/oauth2.py @@ -19,11 +19,10 @@ # along with OAuth2 CKAN Extension. If not, see . -from __future__ import unicode_literals - import base64 import ckan.model as model -import db +from ckanext.oauth2.db import UserToken +import ckanext.oauth2.db as db import json import logging from six.moves.urllib.parse import urljoin @@ -38,23 +37,24 @@ import jwt -import constants +from .constants import CAME_FROM_FIELD, REDIRECT_URL +from flask import Flask, request, redirect, session, url_for, jsonify + log = logging.getLogger(__name__) def generate_state(url): - return b64encode(bytes(json.dumps({constants.CAME_FROM_FIELD: url}))) + return b64encode(bytes(json.dumps({CAME_FROM_FIELD: url}).encode())) def get_came_from(state): - return json.loads(b64decode(state)).get(constants.CAME_FROM_FIELD, '/') + return json.loads(b64decode(state)).get(CAME_FROM_FIELD, '/') REQUIRED_CONF = ("authorization_endpoint", "token_endpoint", "client_id", "client_secret", "profile_api_url", "profile_api_user_field", "profile_api_mail_field") - class OAuth2Helper(object): def __init__(self): @@ -79,10 +79,7 @@ def __init__(self): self.profile_api_groupmembership_field = six.text_type(os.environ.get('CKAN_OAUTH2_PROFILE_API_GROUPMEMBERSHIP_FIELD', toolkit.config.get('ckan.oauth2.profile_api_groupmembership_field', ''))).strip() self.sysadmin_group_name = six.text_type(os.environ.get('CKAN_OAUTH2_SYSADMIN_GROUP_NAME', toolkit.config.get('ckan.oauth2.sysadmin_group_name', ''))).strip() - self.redirect_uri = urljoin(urljoin(toolkit.config.get('ckan.site_url', 'http://localhost:5000'), toolkit.config.get('ckan.root_path')), constants.REDIRECT_URL) - - # Init db - db.init_db(model) + self.redirect_uri = urljoin(urljoin(toolkit.config.get('ckan.site_url', 'http://localhost:5000'), toolkit.config.get('ckan.root_path')), REDIRECT_URL) missing = [key for key in REQUIRED_CONF if getattr(self, key, "") == ""] if missing: @@ -97,7 +94,7 @@ def challenge(self, came_from_url): auth_url, _ = oauth.authorization_url(self.authorization_endpoint) log.debug('Challenge: Redirecting challenge to page {0}'.format(auth_url)) # CKAN 2.6 only supports bytes - return toolkit.redirect_to(auth_url.encode('utf-8')) + return toolkit.redirect_to(auth_url)#.encode('utf-8')) def get_token(self): oauth = OAuth2Session(self.client_id, redirect_uri=self.redirect_uri, scope=self.scope) @@ -111,41 +108,40 @@ def get_token(self): if self.legacy_idm: # This is only required for Keyrock v6 and v5 headers['Authorization'] = 'Basic %s' % base64.urlsafe_b64encode( - '%s:%s' % (self.client_id, self.client_secret) + (f'{self.client_id}:{self.client_secret}').encode() ) try: + log.debug(f'authorization_response: {toolkit.request.url}') token = oauth.fetch_token(self.token_endpoint, - headers=headers, + client_id=self.client_id, client_secret=self.client_secret, - authorization_response=toolkit.request.url, - verify=self.verify_https) + authorization_response=toolkit.request.url.replace('http:', 'https:', 1)) except requests.exceptions.SSLError as e: # TODO search a better way to detect invalid certificates if "verify failed" in six.text_type(e): raise InsecureTransportError() else: raise - return token def identify(self, token): - if self.jwt_enable: - + log.debug('jwt_enabled') access_token = bytes(token['access_token']) user_data = jwt.decode(access_token, verify=False) user = self.user_json(user_data) - else: + else: try: if self.legacy_idm: profile_response = requests.get(self.profile_api_url + '?access_token=%s' % token['access_token'], verify=self.verify_https) else: oauth = OAuth2Session(self.client_id, token=token) - profile_response = oauth.get(self.profile_api_url, verify=self.verify_https) + profile_response = oauth.get(self.profile_api_url) except requests.exceptions.SSLError as e: + log.debug('exception identify oauth2') # TODO search a better way to detect invalid certificates if "verify failed" in six.text_type(e): raise InsecureTransportError() @@ -162,6 +158,7 @@ def identify(self, token): else: user_data = profile_response.json() user = self.user_json(user_data) + log.debug(f'user: {user}') # Save the user in the database model.Session.add(user) @@ -171,6 +168,7 @@ def identify(self, token): return user.name def user_json(self, user_data): + log.debug(f'user_data: {user_data}') email = user_data[self.profile_api_mail_field] user_name = user_data[self.profile_api_user_field] @@ -214,15 +212,24 @@ def remember(self, user_name): rememberer = self._get_rememberer(environ) identity = {'repoze.who.userid': user_name} headers = rememberer.remember(environ, identity) + response = jsonify() for header, value in headers: - toolkit.response.headers.add(header, value) + response.headers[header] = value + return response - def redirect_from_callback(self): + def redirect_from_callback(self, resp_remember): '''Redirect to the callback URL after a successful authentication.''' state = toolkit.request.params.get('state') came_from = get_came_from(state) - toolkit.response.status = 302 - toolkit.response.location = came_from + + response = jsonify() + response.status_code = 302 + for header, value in resp_remember.headers: + response.headers[header] = value + response.headers['location'] = came_from + response.autocorrect_location_header = False + return response + def get_stored_token(self, user_name): user_token = db.UserToken.by_user_name(user_name=user_name) @@ -235,8 +242,10 @@ def get_stored_token(self, user_name): } def update_token(self, user_name, token): - - user_token = db.UserToken.by_user_name(user_name=user_name) + try: + user_token = db.UserToken.by_user_name(user_name=user_name) + except AttributeError as e: + user_token = None # Create the user if it does not exist if not user_token: user_token = db.UserToken() diff --git a/ckanext/oauth2/plugin.py b/ckanext/oauth2/plugin.py index 94203a3..1c41df6 100644 --- a/ckanext/oauth2/plugin.py +++ b/ckanext/oauth2/plugin.py @@ -18,17 +18,18 @@ # You should have received a copy of the GNU Affero General Public License # along with OAuth2 CKAN Extension. If not, see . -from __future__ import unicode_literals - import logging -import oauth2 +from .oauth2 import * import os from functools import partial from ckan import plugins from ckan.common import g from ckan.plugins import toolkit -from urlparse import urlparse +import ckanext.oauth2.db as db +import urllib.parse +from ckanext.oauth2.views import get_blueprints +from ckanext.oauth2.cli import get_commands log = logging.getLogger(__name__) @@ -62,66 +63,33 @@ def request_reset(context, data_dict): return _no_permissions(context, msg) -def _get_previous_page(default_page): - if 'came_from' not in toolkit.request.params: - came_from_url = toolkit.request.headers.get('Referer', default_page) - else: - came_from_url = toolkit.request.params.get('came_from', default_page) - - came_from_url_parsed = urlparse(came_from_url) - - # Avoid redirecting users to external hosts - if came_from_url_parsed.netloc != '' and came_from_url_parsed.netloc != toolkit.request.host: - came_from_url = default_page - - # When a user is being logged and REFERER == HOME or LOGOUT_PAGE - # he/she must be redirected to the dashboard - pages = ['/', '/user/logged_out_redirect'] - if came_from_url_parsed.path in pages: - came_from_url = default_page - - return came_from_url - - class OAuth2Plugin(plugins.SingletonPlugin): - plugins.implements(plugins.IAuthenticator, inherit=True) plugins.implements(plugins.IAuthFunctions, inherit=True) - plugins.implements(plugins.IRoutes, inherit=True) plugins.implements(plugins.IConfigurer) + plugins.implements(plugins.IBlueprint) + plugins.implements(plugins.IClick) + plugins.implements(plugins.IRoutes) - def __init__(self, name=None): - '''Store the OAuth 2 client configuration''' - log.debug('Init OAuth2 extension') + # IBlueprint - self.oauth2helper = oauth2.OAuth2Helper() + def get_blueprint(self): + return get_blueprints() - def before_map(self, m): - log.debug('Setting up the redirections to the OAuth2 service') + # IClick - m.connect('/user/login', - controller='ckanext.oauth2.controller:OAuth2Controller', - action='login') + def get_commands(self): + return get_commands() - # We need to handle petitions received to the Callback URL - # since some error can arise and we need to process them - m.connect('/oauth2/callback', - controller='ckanext.oauth2.controller:OAuth2Controller', - action='callback') - # Redirect the user to the OAuth service register page - if self.register_url: - m.redirect('/user/register', self.register_url) - - # Redirect the user to the OAuth service reset page - if self.reset_url: - m.redirect('/user/reset', self.reset_url) + def __init__(self, name=None): + '''Store the OAuth 2 client configuration''' + log.debug('Init OAuth2 extension') - # Redirect the user to the OAuth service reset page - if self.edit_url: - m.redirect('/user/edit/{user}', self.edit_url) + db.init_db(model) + log.debug(f'Creating UserToken...') + self.oauth2helper = OAuth2Helper() - return m def identify(self): log.debug('identify') @@ -129,12 +97,13 @@ def identify(self): def _refresh_and_save_token(user_name): new_token = self.oauth2helper.refresh_token(user_name) if new_token: - toolkit.c.usertoken = new_token + toolkit.g.usertoken = new_token environ = toolkit.request.environ apikey = toolkit.request.headers.get(self.authorization_header, '') user_name = None + if self.authorization_header == "authorization": if apikey.startswith('Bearer '): apikey = apikey[7:].strip() @@ -146,7 +115,10 @@ def _refresh_and_save_token(user_name): try: token = {'access_token': apikey} user_name = self.oauth2helper.identify(token) - except Exception: + log.debug(f'user_name1: {user_name}') + except Exception as e: + log.debug(f'Auth error:') + log.debug(e) pass # If the authentication via API fails, we can still log in the user using session. @@ -157,11 +129,12 @@ def _refresh_and_save_token(user_name): # If we have been able to log in the user (via API or Session) if user_name: g.user = user_name - toolkit.c.user = user_name - toolkit.c.usertoken = self.oauth2helper.get_stored_token(user_name) - toolkit.c.usertoken_refresh = partial(_refresh_and_save_token, user_name) + toolkit.g.user = user_name + toolkit.g.usertoken = self.oauth2helper.get_stored_token(user_name) + toolkit.g.usertoken_refresh = partial(_refresh_and_save_token, user_name) else: g.user = None + toolkit.g.user = None log.warn('The user is not currently logged...') def get_auth_functions(self): @@ -175,6 +148,7 @@ def get_auth_functions(self): def update_config(self, config): # Update our configuration + log.debug('update config...') self.register_url = os.environ.get("CKAN_OAUTH2_REGISTER_URL", config.get('ckan.oauth2.register_url', None)) self.reset_url = os.environ.get("CKAN_OAUTH2_RESET_URL", config.get('ckan.oauth2.reset_url', None)) self.edit_url = os.environ.get("CKAN_OAUTH2_EDIT_URL", config.get('ckan.oauth2.edit_url', None)) @@ -183,3 +157,31 @@ def update_config(self, config): # Add this plugin's templates dir to CKAN's extra_template_paths, so # that CKAN will use this plugin's custom templates. plugins.toolkit.add_template_directory(config, 'templates') + + + def before_map(self, m): + log.debug('Setting up the redirections to the OAuth2 service') + + m.connect('/user/login', + controller='ckanext.oauth2.controller:OAuth2Controller', + action='login') + + # We need to handle petitions received to the Callback URL + # since some error can arise and we need to process them + m.connect('/oauth2/callback', + controller='ckanext.oauth2.controller:OAuth2Controller', + action='callback') + + # Redirect the user to the OAuth service register page + if self.register_url: + m.redirect('/user/register', self.register_url) + + # Redirect the user to the OAuth service reset page + if self.reset_url: + m.redirect('/user/reset', self.reset_url) + + # Redirect the user to the OAuth service reset page + if self.edit_url: + m.redirect('/user/edit/{user}', self.edit_url) + + return m \ No newline at end of file diff --git a/ckanext/oauth2/tests/test_db.py b/ckanext/oauth2/tests/test_db.py index b7b1ad8..01889c5 100644 --- a/ckanext/oauth2/tests/test_db.py +++ b/ckanext/oauth2/tests/test_db.py @@ -27,7 +27,7 @@ class DBTest(unittest.TestCase): def setUp(self): # Restart databse initial status - db.UserToken = None + db.UserToken = None # Create mocks self._sa = db.sa diff --git a/ckanext/oauth2/tests/test_plugin.py b/ckanext/oauth2/tests/test_plugin.py index 1ce8932..5d6646a 100644 --- a/ckanext/oauth2/tests/test_plugin.py +++ b/ckanext/oauth2/tests/test_plugin.py @@ -174,9 +174,9 @@ def authenticate_side_effect(identity): plugin.toolkit.request.headers = headers # The identify function must set the user id in this variable - plugin.toolkit.c.user = None - plugin.toolkit.c.usertoken = None - plugin.toolkit.c.usertoken_refresh = None + plugin.toolkit.g.user = None + plugin.toolkit.g.usertoken = None + plugin.toolkit.g.usertoken_refresh = None # Call the function self._plugin.identify() @@ -191,15 +191,15 @@ def authenticate_side_effect(identity): self.assertEquals(0, self._plugin.oauth2helper.identify.call_count) self.assertEquals(expected_user, g_mock.user) - self.assertEquals(expected_user, plugin.toolkit.c.user) + self.assertEquals(expected_user, plugin.toolkit.g.user) if expected_user is None: - self.assertIsNone(plugin.toolkit.c.usertoken) - self.assertIsNone(plugin.toolkit.c.usertoken_refresh) + self.assertIsNone(plugin.toolkit.g.usertoken) + self.assertIsNone(plugin.toolkit.g.usertoken_refresh) else: - self.assertEquals(usertoken, plugin.toolkit.c.usertoken) + self.assertEquals(usertoken, plugin.toolkit.g.usertoken) # method 'usertoken_refresh' should relay on the one provided by the repoze.who module - plugin.toolkit.c.usertoken_refresh() + plugin.toolkit.g.usertoken_refresh() self._plugin.oauth2helper.refresh_token.assert_called_once_with(expected_user) - self.assertEquals(newtoken, plugin.toolkit.c.usertoken) + self.assertEquals(newtoken, plugin.toolkit.g.usertoken) diff --git a/ckanext/oauth2/views.py b/ckanext/oauth2/views.py new file mode 100644 index 0000000..7b15cdd --- /dev/null +++ b/ckanext/oauth2/views.py @@ -0,0 +1,86 @@ +import logging +from flask import Blueprint, jsonify, make_response +import logging +from ckanext.oauth2 import constants +from ckan.common import session +import ckan.lib.helpers as helpers +import ckan.plugins.toolkit as toolkit +import urllib.parse +from ckanext.oauth2.oauth2 import OAuth2Helper + +log = logging.getLogger(__name__) + +oauth2 = Blueprint("oauth2", __name__) + +oauth2helper = OAuth2Helper() + +def _get_previous_page(default_page): + if 'came_from' not in toolkit.request.params: + came_from_url = toolkit.request.headers.get('Referer', default_page) + else: + came_from_url = toolkit.request.params.get('came_from', default_page) + + came_from_url_parsed = urllib.parse.urlparse(came_from_url) + + # Avoid redirecting users to external hosts + if came_from_url_parsed.netloc != '' and came_from_url_parsed.netloc != toolkit.request.host: + came_from_url = default_page + + # When a user is being logged and REFERER == HOME or LOGOUT_PAGE + # he/she must be redirected to the dashboard + pages = ['/', '/user/logged_out_redirect'] + if came_from_url_parsed.path in pages: + came_from_url = default_page + + return came_from_url + +@oauth2.route('/user/login') +def login(): + log.debug('login') + + # Log in attemps are fired when the user is not logged in and they click + # on the log in button + + # Get the page where the user was when the loggin attemp was fired + # When the user is not logged in, he/she should be redirected to the dashboard when + # the system cannot get the previous page + came_from_url = _get_previous_page(constants.INITIAL_PAGE) + return oauth2helper.challenge(came_from_url) + +@oauth2.route('/oauth2/callback') +def callback(): + + try: + token = oauth2helper.get_token() + + user_name = oauth2helper.identify(token) + response = oauth2helper.remember(user_name) + log.debug(f'usr:{user_name}') + + oauth2helper.update_token(user_name, token) + response = oauth2helper.redirect_from_callback(response) + except Exception as e: + + session.save() + + # If the callback is called with an error, we must show the message + error_description = toolkit.request.GET.get('error_description') + if not error_description: + if e.message: + error_description = e.message + elif hasattr(e, 'description') and e.description: + error_description = e.description + elif hasattr(e, 'error') and e.error: + error_description = e.error + else: + error_description = type(e).__name__ + response = jsonify() + response.status_code = 302 + redirect_url = oauth2.get_came_from(toolkit.request.params.get('state')) + redirect_url = '/' if redirect_url == constants.INITIAL_PAGE else redirect_url + response.location = redirect_url + helpers.flash_error(error_description) + return response + +def get_blueprints(): + return [oauth2] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ba45b54 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests-oauthlib==0.8.0 +pyjwt==1.7.1 \ No newline at end of file diff --git a/setup.py b/setup.py index 48c927a..e409087 100644 --- a/setup.py +++ b/setup.py @@ -86,9 +86,6 @@ def rst(filename): entry_points={ 'ckan.plugins': [ 'oauth2 = ckanext.oauth2.plugin:OAuth2Plugin', - ], - 'nose.plugins': [ - 'pylons = pylons.test:PylonsPlugin' ] }, classifiers=[