From e33ff06e4c37ff5508f53f4dc328b64dfb16e83d Mon Sep 17 00:00:00 2001 From: Federico Oldani Date: Thu, 10 Jun 2021 18:04:02 +0200 Subject: [PATCH 1/5] flask and python3 conversion --- INSTALL.md | 2 +- LICENSE.txt | 10 +- ckanext/oauth2/constants.py | 2 +- ckanext/oauth2/controller.py | 6 +- ckanext/oauth2/db.py | 48 +++--- ckanext/oauth2/oauth2.py | 93 +++++++----- ckanext/oauth2/plugin.py | 121 +++++++++------ ckanext/oauth2/tests/test_db.py | 2 +- ckanext/oauth2/tests/test_oauth2.py | 2 +- ckanext/oauth2/tests/test_plugin.py | 20 +-- ckanext/oauth2/utils.py | 223 ++++++++++++++++++++++++++++ ckanext/oauth2/views.py | 93 ++++++++++++ requirements.txt | 2 + setup.py | 8 +- 14 files changed, 507 insertions(+), 125 deletions(-) create mode 100644 ckanext/oauth2/utils.py create mode 100644 ckanext/oauth2/views.py create mode 100644 requirements.txt diff --git a/INSTALL.md b/INSTALL.md index acc5cea..5ceb02d 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -46,7 +46,7 @@ ckan.oauth2.authorization_header = OAUTH2_HEADER > ckan.oauth2.authorization_header = Authorization > ``` > -> And this is an example for using Google as OAuth2 provider: +> And this is an theme for using Google as OAuth2 provider: > > ``` > ## OAuth2 configuration diff --git a/LICENSE.txt b/LICENSE.txt index 2def0e8..492cd2d 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -125,7 +125,7 @@ work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source +which are not part of the work. For theme, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, @@ -311,7 +311,7 @@ fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has +modified object code on the User Product (for theme, the work has been installed in ROM). The requirement to provide Installation Information does not include a @@ -449,7 +449,7 @@ Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may +rights granted or affirmed under this License. For theme, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that @@ -532,7 +532,7 @@ otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you +not convey it at all. For theme, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. @@ -649,7 +649,7 @@ Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its +get its source. For theme, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the 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..35b1ee4 100644 --- a/ckanext/oauth2/controller.py +++ b/ckanext/oauth2/controller.py @@ -18,16 +18,16 @@ # 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 +# 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..c0d3ad0 100644 --- a/ckanext/oauth2/db.py +++ b/ckanext/oauth2/db.py @@ -18,32 +18,42 @@ # along with OAuth2 CKAN Extension. If not, see . import sqlalchemy as sa +import ckan.model.meta as meta +import logging +from ckan.model.domain_object import DomainObject +from sqlalchemy.ext.declarative import declarative_base -UserToken = None +log = logging.getLogger(__name__) +Base = declarative_base() +metadata = Base.metadata -def init_db(model): - global UserToken - if UserToken is None: +class UserToken(Base, DomainObject): + __tablename__ = 'user_token' - class _UserToken(model.DomainObject): + def __init__(self, user_name, access_token, token_type, refresh_token, expires_in): + self.user_name = user_name + self.access_token = access_token + self.token_type = token_type + self.refresh_token = refresh_token + self.expires_in = expires_in - @classmethod - def by_user_name(cls, user_name): - return model.Session.query(cls).filter_by(user_name=user_name).first() + @classmethod + def by_user_name(cls, user_name): + return meta.Session.query(cls).filter_by(user_name=user_name).first() - UserToken = _UserToken - user_token_table = sa.Table('user_token', model.meta.metadata, - sa.Column('user_name', sa.types.UnicodeText, primary_key=True), - sa.Column('access_token', sa.types.UnicodeText), - sa.Column('token_type', sa.types.UnicodeText), - sa.Column('refresh_token', sa.types.UnicodeText), - sa.Column('expires_in', sa.types.UnicodeText) - ) + user_name = sa.Column(sa.types.UnicodeText, primary_key=True) + access_token = sa.Column(sa.types.UnicodeText) + token_type = sa.Column(sa.types.UnicodeText) + refresh_token = sa.Column(sa.types.UnicodeText) + expires_in = sa.Column(sa.types.UnicodeText) - # Create the table only if it does not exist - user_token_table.create(checkfirst=True) - model.meta.mapper(UserToken, user_token_table) + + + # # Create the table only if it does not exist + # user_token_table.create(checkfirst=True) + + # model.meta.mapper(UserToken, user_token_table) diff --git a/ckanext/oauth2/oauth2.py b/ckanext/oauth2/oauth2.py index 28a2724..1d7e554 100644 --- a/ckanext/oauth2/oauth2.py +++ b/ckanext/oauth2/oauth2.py @@ -19,11 +19,11 @@ # along with OAuth2 CKAN Extension. If not, see . -from __future__ import unicode_literals +# from __future__ import unicode_literals import base64 import ckan.model as model -import db +from ckanext.oauth2.db import UserToken import json import logging from six.moves.urllib.parse import urljoin @@ -38,23 +38,24 @@ import jwt -import constants +from .constants import * +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 +80,8 @@ 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) + self.redirect_uri = urljoin(urljoin(toolkit.config.get('ckan.site_url', 'http://localhost:5000'), toolkit.config.get('ckan.root_path')), REDIRECT_URL) - # Init db - db.init_db(model) missing = [key for key in REQUIRED_CONF if getattr(self, key, "") == ""] if missing: @@ -93,11 +92,12 @@ def __init__(self): def challenge(self, came_from_url): # This function is called by the log in function when the user is not logged in state = generate_state(came_from_url) + # log.debug(f'redirect uri: {self.redirect_uri}') oauth = OAuth2Session(self.client_id, redirect_uri=self.redirect_uri, scope=self.scope, state=state) 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,39 +111,45 @@ 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'self.token_endpoint: {self.token_endpoint}') + # log.debug(f'headers: {headers}') + # log.debug(f'authorization_response: {toolkit.request.url}') + # log.debug(f'client_secret: {self.client_secret}') 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) + # verify=self.verify_https + # headers=headers, + # log.debug(f'token: {token}') 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: - try: if self.legacy_idm: profile_response = requests.get(self.profile_api_url + '?access_token=%s' % token['access_token'], verify=self.verify_https) + log.debug(f'profile response: {profile_response}') 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) + log.debug(f'profile response_: {profile_response}') except requests.exceptions.SSLError as e: # TODO search a better way to detect invalid certificates @@ -162,6 +168,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) @@ -214,18 +221,33 @@ 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 + # toolkit.response.status = 302 + # toolkit.response.location = came_from + # return toolkit.redirect_to(came_from) + # log.debug(f'come from: {came_from}') + # 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) + # log.debug(f'user_name: {user_name}') + user_token = UserToken.by_user_name(user_name=user_name) if user_token: return { 'access_token': user_token.access_token, @@ -235,22 +257,25 @@ 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 = UserToken.by_user_name(user_name=user_name) + except AttributeError as e: + user_token = None + # log.debug(f'User_token: {user_token}') # Create the user if it does not exist - if not user_token: - user_token = db.UserToken() - user_token.user_name = user_name # Save the new token - user_token.access_token = token['access_token'] - user_token.token_type = token['token_type'] - user_token.refresh_token = token.get('refresh_token') + access_token = token['access_token'] + token_type = token['token_type'] + refresh_token = token.get('refresh_token') if 'expires_in' in token: - user_token.expires_in = token['expires_in'] + expires_in = token['expires_in'] else: access_token = jwt.decode(user_token.access_token, verify=False) - user_token.expires_in = access_token['exp'] - access_token['iat'] - + expires_in = access_token['exp'] - access_token['iat'] + if not user_token: + user_token = UserToken(user_name, access_token, token_type, refresh_token, expires_in) + log.debug('user addedd') + # log.debug(f'User_token: {user_token}') model.Session.add(user_token) model.Session.commit() diff --git a/ckanext/oauth2/plugin.py b/ckanext/oauth2/plugin.py index 94203a3..afa04a2 100644 --- a/ckanext/oauth2/plugin.py +++ b/ckanext/oauth2/plugin.py @@ -18,17 +18,19 @@ # 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 +# 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 urllib.parse +from ckanext.oauth2.views import get_blueprints +from ckanext.cloudstorage.cli import get_commands log = logging.getLogger(__name__) @@ -62,66 +64,82 @@ 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) +# 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) +# came_from_url_parsed = urllib.parse(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 +# # 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 +# # 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 +# return came_from_url +class _OAuth2Plugin(plugins.SingletonPlugin): + plugins.implements(plugins.IBlueprint) + plugins.implements(plugins.IClick) -class OAuth2Plugin(plugins.SingletonPlugin): + # IBlueprint + def get_blueprint(self): + return get_blueprints() + + # IClick + + def get_commands(self): + return get_commands() + + +class OAuth2Plugin(_OAuth2Plugin, plugins.SingletonPlugin): plugins.implements(plugins.IAuthenticator, inherit=True) plugins.implements(plugins.IAuthFunctions, inherit=True) - plugins.implements(plugins.IRoutes, inherit=True) + # plugins.implements(plugins.IRoutes, inherit=True) plugins.implements(plugins.IConfigurer) + def __init__(self, name=None): '''Store the OAuth 2 client configuration''' log.debug('Init OAuth2 extension') - self.oauth2helper = oauth2.OAuth2Helper() + # init_db(model) + log.debug(f'UserToken: {UserToken}') + self.oauth2helper = OAuth2Helper() - def before_map(self, m): - log.debug('Setting up the redirections to the OAuth2 service') + # 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') + # 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') + # # 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 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.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) + # # Redirect the user to the OAuth service reset page + # if self.edit_url: + # m.redirect('/user/edit/{user}', self.edit_url) - return m + # return m def identify(self): log.debug('identify') @@ -129,12 +147,14 @@ 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 + # log.debug(f'apikey: {apikey}') + # log.debug(f'headers: {toolkit.request.headers}') if self.authorization_header == "authorization": if apikey.startswith('Bearer '): apikey = apikey[7:].strip() @@ -144,22 +164,30 @@ def _refresh_and_save_token(user_name): # This API Key is not the one of CKAN, it's the one provided by the OAuth2 Service if apikey: try: + # log.debug(f'api_key: {apikey}') 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 + # log.debug(f'user_name2: {user_name}') + # log.debug(f'authorization_header: {self.authorization_header}') + # log.debug(f'environ: {environ}') # If the authentication via API fails, we can still log in the user using session. if user_name is None and 'repoze.who.identity' in environ: user_name = environ['repoze.who.identity']['repoze.who.userid'] + # log.debug(f'user_name3: {user_name}') log.info('User %s logged using session' % 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 log.warn('The user is not currently logged...') @@ -175,6 +203,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)) 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_oauth2.py b/ckanext/oauth2/tests/test_oauth2.py index 3c81abe..34d40fe 100644 --- a/ckanext/oauth2/tests/test_oauth2.py +++ b/ckanext/oauth2/tests/test_oauth2.py @@ -291,7 +291,7 @@ def test_challenge(self): request = make_request(False, 'localhost', 'user/login', {}) request.environ = MagicMock() request.headers = {} - came_from = '/came_from_example' + came_from = '/came_from_theme' oauth2.toolkit.request = request diff --git a/ckanext/oauth2/tests/test_plugin.py b/ckanext/oauth2/tests/test_plugin.py index 1ce8932..1d2e05e 100644 --- a/ckanext/oauth2/tests/test_plugin.py +++ b/ckanext/oauth2/tests/test_plugin.py @@ -26,7 +26,7 @@ CUSTOM_AUTHORIZATION_HEADER = 'x-auth-token' OAUTH2_AUTHORIZATION_HEADER = 'authorization' -HOST = 'ckan.example.org' +HOST = 'ckan.theme.org' class PluginTest(unittest.TestCase): @@ -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/utils.py b/ckanext/oauth2/utils.py new file mode 100644 index 0000000..00671dd --- /dev/null +++ b/ckanext/oauth2/utils.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- + +import json +import logging +from six.moves.urllib.parse import urlencode, urlsplit, parse_qs + +import requests + +import ckan.lib.base as base +import ckan.lib.helpers as h +import ckan.logic as logic + +import ckantoolkit as toolkit + +from ckan import plugins as p + + +log = logging.getLogger(__name__) + +GEOJSON_MAX_FILE_SIZE = 25 * 1024 * 1024 + + +MAX_FILE_SIZE = 3 * 1024 * 1024 # 1MB +CHUNK_SIZE = 512 + +# HTTP request parameters that may conflict with OGC services +# protocols and should be excluded from proxied calls +OGC_EXCLUDED_PARAMS = [ + "service", + "version", + "request", + "outputformat", + "typename", + "layers", + "srsname", + "bbox", + "maxfeatures", +] + + +def proxy_service_resource(request, context, data_dict): + """ Chunked proxy for resources. To make sure that the file is not too + large, first, we try to get the content length from the headers. + If the headers to not contain a content length (if it is a chinked + response), we only transfer as long as the transferred data is less + than the maximum file size. """ + resource_id = data_dict["resource_id"] + log.info("Proxify resource {id}".format(id=resource_id)) + resource = logic.get_action("resource_show")(context, {"id": resource_id}) + url = resource["url"] + return proxy_service_url(request, url) + + +def proxy_service_url(req, url): + + parts = urlsplit(url) + if not parts.scheme or not parts.netloc: + base.abort(409, detail="Invalid URL.") + + try: + method = req.environ["REQUEST_METHOD"] + + params = parse_qs(parts.query) + + if not p.toolkit.asbool( + base.config.get( + "ckanext.geoview.forward_ogc_request_params", "False" + ) + ): + # remove query parameters that may conflict with OGC protocols + for key in dict(params): + if key.lower() in OGC_EXCLUDED_PARAMS: + del params[key] + parts = parts._replace(query=urlencode(params)) + + parts = parts._replace(fragment="") # remove potential fragment + url = parts.geturl() + if method == "POST": + length = int(req.environ["CONTENT_LENGTH"]) + headers = {"Content-Type": req.environ["CONTENT_TYPE"]} + body = req.body + r = requests.post(url, data=body, headers=headers, stream=True) + else: + r = requests.get(url, params=req.query_string, stream=True) + + # log.info('Request: {req}'.format(req=r.request.url)) + # log.info('Request Headers: {h}'.format(h=r.request.headers)) + + cl = r.headers.get("content-length") + if cl and int(cl) > MAX_FILE_SIZE: + base.abort( + 409, + ( + """Content is too large to be proxied. Allowed + file size: {allowed}, Content-Length: {actual}. Url: """ + + url + ).format(allowed=MAX_FILE_SIZE, actual=cl), + ) + if toolkit.check_ckan_version("2.9"): + from flask import make_response + + response = make_response() + else: + response = base.response + + response.content_type = r.headers["content-type"] + response.charset = r.encoding + + length = 0 + for chunk in r.iter_content(chunk_size=CHUNK_SIZE): + if toolkit.check_ckan_version("2.9"): + response.data += chunk + else: + response.body_file.write(chunk) + length += len(chunk) + + if length >= MAX_FILE_SIZE: + base.abort( + 409, + ( + """Content is too large to be proxied. Allowed + file size: {allowed}, Content-Length: {actual}. Url: """ + + url + ).format(allowed=MAX_FILE_SIZE, actual=length), + ) + + except requests.exceptions.HTTPError as error: + details = "Could not proxy resource. Server responded with %s %s" % ( + error.response.status_code, + error.response.reason, + ) + base.abort(409, detail=details) + except requests.exceptions.ConnectionError as error: + details = ( + """Could not proxy resource because a + connection error occurred. %s""" + % error + ) + base.abort(502, detail=details) + except requests.exceptions.Timeout as error: + details = "Could not proxy resource because the connection timed out." + base.abort(504, detail=details) + return response + + +def get_common_map_config(): + """Returns a dict with all configuration options related to the common + base map (ie those starting with 'ckanext.spatial.common_map.') + """ + namespace = "ckanext.spatial.common_map." + return dict( + [ + (k.replace(namespace, ""), v) + for k, v in toolkit.config.items() + if k.startswith(namespace) + ] + ) + + +def get_shapefile_viewer_config(): + """ + Returns a dict with all configuration options related to the + Shapefile viewer (ie those starting with 'ckanext.geoview.shp_viewer.') + """ + namespace = "ckanext.geoview.shp_viewer." + return dict( + [ + (k.replace(namespace, ""), v) + for k, v in toolkit.config.items() + if k.startswith(namespace) + ] + ) + + +def get_max_file_size(): + return toolkit.config.get( + "ckanext.geoview.geojson.max_file_size", GEOJSON_MAX_FILE_SIZE + ) + + +def get_openlayers_viewer_config(): + """ + Returns a dict with all configuration options related to the + OpenLayers viewer (ie those starting with 'ckanext.geoview.ol_viewer.') + """ + namespace = "ckanext.geoview.ol_viewer." + return dict( + [ + (k.replace(namespace, ""), v) + for k, v in toolkit.config.items() + if k.startswith(namespace) + ] + ) + + +def load_basemaps(basemapsFile): + + try: + with open(basemapsFile) as config_file: + basemapsConfig = json.load(config_file) + except Exception as inst: + msg = "Couldn't read basemaps config from %r: %s" % ( + basemapsFile, + inst, + ) + raise Exception(msg) + + return basemapsConfig + + +def get_proxified_service_url(data_dict): + """ + :param data_dict: contains a resource and package dict + :type data_dict: dictionary + """ + url = h.url_for( + action="proxy_service", + controller='service_proxy', + id=data_dict["package"]["name"], + resource_id=data_dict["resource"]["id"], + ) + log.debug("Proxified url is {0}".format(url)) + return url diff --git a/ckanext/oauth2/views.py b/ckanext/oauth2/views.py new file mode 100644 index 0000000..fac82ba --- /dev/null +++ b/ckanext/oauth2/views.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- + +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__) + +log = logging.getLogger(__name__) +# service_proxy = Blueprint("service_proxy", __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() + # log.debug(f'token:{token}') + + user_name = oauth2helper.identify(token) + response = oauth2helper.remember(user_name) + # log.debug(f'usr:{user_name}') + # log.debug(f'response remember:{response}') + + 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) + # make_response((content, 302, headers)) + 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..5fe2c6a 100644 --- a/setup.py +++ b/setup.py @@ -67,7 +67,7 @@ def rst(filename): url='https://github.com/conwetlab/ckanext-oauth2', download_url='https://github.com/conwetlab/ckanext-oauth2/tarball/v' + __version__, license='', - packages=find_packages(exclude=['ez_setup', 'examples', 'tests']), + packages=find_packages(exclude=['ez_setup', 'themes', 'tests']), namespace_packages=['ckanext'], include_package_data=True, zip_safe=False, @@ -86,11 +86,11 @@ def rst(filename): entry_points={ 'ckan.plugins': [ 'oauth2 = ckanext.oauth2.plugin:OAuth2Plugin', - ], - 'nose.plugins': [ - 'pylons = pylons.test:PylonsPlugin' ] }, + # 'nose.plugins': [ + # 'pylons = pylons.test:PylonsPlugin' + # ] classifiers=[ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", From 390bdb6933db81c2acbff179d4113f04a0b6354c Mon Sep 17 00:00:00 2001 From: Federico Oldani Date: Thu, 17 Feb 2022 11:09:41 +0100 Subject: [PATCH 2/5] creation DB table fixed, small improvements --- ckanext/oauth2/cli.py | 15 ++++++++++ ckanext/oauth2/db.py | 39 +++++++++++++------------- ckanext/oauth2/oauth2.py | 42 ++++++++++++++-------------- ckanext/oauth2/plugin.py | 59 ++++------------------------------------ ckanext/oauth2/views.py | 4 +-- 5 files changed, 61 insertions(+), 98 deletions(-) create mode 100644 ckanext/oauth2/cli.py diff --git a/ckanext/oauth2/cli.py b/ckanext/oauth2/cli.py new file mode 100644 index 0000000..37c1a8b --- /dev/null +++ b/ckanext/oauth2/cli.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- + +import click +import ckanext.oauth2.utils as utils + + +@click.group() +def oauth2(): + """Oauth2 management commands. + """ + pass + + +def get_commands(): + return [oauth2] diff --git a/ckanext/oauth2/db.py b/ckanext/oauth2/db.py index c0d3ad0..6c317c1 100644 --- a/ckanext/oauth2/db.py +++ b/ckanext/oauth2/db.py @@ -28,32 +28,31 @@ Base = declarative_base() metadata = Base.metadata +UserToken = None +def init_db(model): -class UserToken(Base, DomainObject): - __tablename__ = 'user_token' + global UserToken + if UserToken is None: - def __init__(self, user_name, access_token, token_type, refresh_token, expires_in): - self.user_name = user_name - self.access_token = access_token - self.token_type = token_type - self.refresh_token = refresh_token - self.expires_in = expires_in + class _UserToken(model.DomainObject): - @classmethod - def by_user_name(cls, user_name): - return meta.Session.query(cls).filter_by(user_name=user_name).first() + @classmethod + def by_user_name(cls, user_name): + return model.Session.query(cls).filter_by(user_name=user_name).first() + UserToken = _UserToken - user_name = sa.Column(sa.types.UnicodeText, primary_key=True) - access_token = sa.Column(sa.types.UnicodeText) - token_type = sa.Column(sa.types.UnicodeText) - refresh_token = sa.Column(sa.types.UnicodeText) - expires_in = sa.Column(sa.types.UnicodeText) + user_token_table = sa.Table('user_token', model.meta.metadata, + sa.Column('user_name', sa.types.UnicodeText, primary_key=True), + sa.Column('access_token', sa.types.UnicodeText), + sa.Column('token_type', sa.types.UnicodeText), + sa.Column('refresh_token', sa.types.UnicodeText), + sa.Column('expires_in', sa.types.UnicodeText) + ) + # Create the table only if it does not exist + user_token_table.create(checkfirst=True) + model.meta.mapper(UserToken, user_token_table) - # # Create the table only if it does not exist - # user_token_table.create(checkfirst=True) - - # model.meta.mapper(UserToken, user_token_table) diff --git a/ckanext/oauth2/oauth2.py b/ckanext/oauth2/oauth2.py index 1d7e554..6b84cb1 100644 --- a/ckanext/oauth2/oauth2.py +++ b/ckanext/oauth2/oauth2.py @@ -24,6 +24,7 @@ import base64 import ckan.model as model from ckanext.oauth2.db import UserToken +import ckanext.oauth2.db as db import json import logging from six.moves.urllib.parse import urljoin @@ -82,7 +83,6 @@ def __init__(self): 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: raise ValueError("Missing required oauth2 conf: %s" % ", ".join(missing)) @@ -117,12 +117,12 @@ def get_token(self): try: # log.debug(f'self.token_endpoint: {self.token_endpoint}') # log.debug(f'headers: {headers}') - # log.debug(f'authorization_response: {toolkit.request.url}') + log.debug(f'authorization_response: {toolkit.request.url}') # log.debug(f'client_secret: {self.client_secret}') token = oauth.fetch_token(self.token_endpoint, client_id=self.client_id, client_secret=self.client_secret, - authorization_response=toolkit.request.url) + authorization_response=toolkit.request.url.replace('http:', 'https:', 1)) # verify=self.verify_https # headers=headers, # log.debug(f'token: {token}') @@ -136,11 +136,13 @@ def get_token(self): def identify(self, token): + # log.debug(f'token:{token["access_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: try: if self.legacy_idm: @@ -152,6 +154,7 @@ def identify(self, token): log.debug(f'profile response_: {profile_response}') 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() @@ -168,7 +171,7 @@ def identify(self, token): else: user_data = profile_response.json() user = self.user_json(user_data) - # log.debug(f'user: {user}') + log.debug(f'user: {user}') # Save the user in the database model.Session.add(user) @@ -178,6 +181,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] @@ -230,12 +234,7 @@ 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 - # return toolkit.redirect_to(came_from) - # log.debug(f'come from: {came_from}') - # toolkit.response.status = 302 - # toolkit.response.location = came_from + response = jsonify() response.status_code = 302 for header, value in resp_remember.headers: @@ -247,7 +246,7 @@ def redirect_from_callback(self, resp_remember): def get_stored_token(self, user_name): # log.debug(f'user_name: {user_name}') - user_token = UserToken.by_user_name(user_name=user_name) + user_token = db.UserToken.by_user_name(user_name=user_name) if user_token: return { 'access_token': user_token.access_token, @@ -258,24 +257,23 @@ def get_stored_token(self, user_name): def update_token(self, user_name, token): try: - user_token = UserToken.by_user_name(user_name=user_name) + user_token = db.UserToken.by_user_name(user_name=user_name) except AttributeError as e: user_token = None - # log.debug(f'User_token: {user_token}') # Create the user if it does not exist + if not user_token: + user_token = db.UserToken() + user_token.user_name = user_name # Save the new token - access_token = token['access_token'] - token_type = token['token_type'] - refresh_token = token.get('refresh_token') + user_token.access_token = token['access_token'] + user_token.token_type = token['token_type'] + user_token.refresh_token = token.get('refresh_token') if 'expires_in' in token: - expires_in = token['expires_in'] + user_token.expires_in = token['expires_in'] else: access_token = jwt.decode(user_token.access_token, verify=False) - expires_in = access_token['exp'] - access_token['iat'] - if not user_token: - user_token = UserToken(user_name, access_token, token_type, refresh_token, expires_in) - log.debug('user addedd') - # log.debug(f'User_token: {user_token}') + user_token.expires_in = access_token['exp'] - access_token['iat'] + model.Session.add(user_token) model.Session.commit() diff --git a/ckanext/oauth2/plugin.py b/ckanext/oauth2/plugin.py index afa04a2..d24dee8 100644 --- a/ckanext/oauth2/plugin.py +++ b/ckanext/oauth2/plugin.py @@ -28,9 +28,10 @@ from ckan import plugins from ckan.common import g from ckan.plugins import toolkit +import ckanext.oauth2.db as db import urllib.parse from ckanext.oauth2.views import get_blueprints -from ckanext.cloudstorage.cli import get_commands +from ckanext.oauth2.cli import get_commands log = logging.getLogger(__name__) @@ -64,26 +65,6 @@ 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 = urllib.parse(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.IBlueprint) plugins.implements(plugins.IClick) @@ -110,36 +91,10 @@ def __init__(self, name=None): '''Store the OAuth 2 client configuration''' log.debug('Init OAuth2 extension') - # init_db(model) - log.debug(f'UserToken: {UserToken}') + db.init_db(model) + log.debug(f'Creating UserToken...') self.oauth2helper = OAuth2Helper() - # 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 def identify(self): log.debug('identify') @@ -155,6 +110,7 @@ def _refresh_and_save_token(user_name): # log.debug(f'apikey: {apikey}') # log.debug(f'headers: {toolkit.request.headers}') + if self.authorization_header == "authorization": if apikey.startswith('Bearer '): apikey = apikey[7:].strip() @@ -173,13 +129,9 @@ def _refresh_and_save_token(user_name): log.debug(e) pass - # log.debug(f'user_name2: {user_name}') - # log.debug(f'authorization_header: {self.authorization_header}') - # log.debug(f'environ: {environ}') # If the authentication via API fails, we can still log in the user using session. if user_name is None and 'repoze.who.identity' in environ: user_name = environ['repoze.who.identity']['repoze.who.userid'] - # log.debug(f'user_name3: {user_name}') log.info('User %s logged using session' % user_name) # If we have been able to log in the user (via API or Session) @@ -190,6 +142,7 @@ def _refresh_and_save_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): diff --git a/ckanext/oauth2/views.py b/ckanext/oauth2/views.py index fac82ba..5e4ef95 100644 --- a/ckanext/oauth2/views.py +++ b/ckanext/oauth2/views.py @@ -56,12 +56,10 @@ def callback(): try: token = oauth2helper.get_token() - # log.debug(f'token:{token}') user_name = oauth2helper.identify(token) response = oauth2helper.remember(user_name) - # log.debug(f'usr:{user_name}') - # log.debug(f'response remember:{response}') + log.debug(f'usr:{user_name}') oauth2helper.update_token(user_name, token) response = oauth2helper.redirect_from_callback(response) From 3633240df15112af591fbd7f87739681acf1af5a Mon Sep 17 00:00:00 2001 From: Federico Oldani Date: Thu, 17 Feb 2022 11:15:10 +0100 Subject: [PATCH 3/5] dropped some debug comments --- INSTALL.md | 2 +- ckanext/oauth2/oauth2.py | 11 ----------- ckanext/oauth2/plugin.py | 3 --- 3 files changed, 1 insertion(+), 15 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 5ceb02d..acc5cea 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -46,7 +46,7 @@ ckan.oauth2.authorization_header = OAUTH2_HEADER > ckan.oauth2.authorization_header = Authorization > ``` > -> And this is an theme for using Google as OAuth2 provider: +> And this is an example for using Google as OAuth2 provider: > > ``` > ## OAuth2 configuration diff --git a/ckanext/oauth2/oauth2.py b/ckanext/oauth2/oauth2.py index 6b84cb1..628998e 100644 --- a/ckanext/oauth2/oauth2.py +++ b/ckanext/oauth2/oauth2.py @@ -19,8 +19,6 @@ # along with OAuth2 CKAN Extension. If not, see . -# from __future__ import unicode_literals - import base64 import ckan.model as model from ckanext.oauth2.db import UserToken @@ -115,17 +113,11 @@ def get_token(self): ) try: - # log.debug(f'self.token_endpoint: {self.token_endpoint}') - # log.debug(f'headers: {headers}') log.debug(f'authorization_response: {toolkit.request.url}') - # log.debug(f'client_secret: {self.client_secret}') token = oauth.fetch_token(self.token_endpoint, client_id=self.client_id, client_secret=self.client_secret, authorization_response=toolkit.request.url.replace('http:', 'https:', 1)) - # verify=self.verify_https - # headers=headers, - # log.debug(f'token: {token}') except requests.exceptions.SSLError as e: # TODO search a better way to detect invalid certificates if "verify failed" in six.text_type(e): @@ -135,8 +127,6 @@ def get_token(self): return token def identify(self, token): - - # log.debug(f'token:{token["access_token"]}') if self.jwt_enable: log.debug('jwt_enabled') access_token = bytes(token['access_token']) @@ -245,7 +235,6 @@ def redirect_from_callback(self, resp_remember): def get_stored_token(self, user_name): - # log.debug(f'user_name: {user_name}') user_token = db.UserToken.by_user_name(user_name=user_name) if user_token: return { diff --git a/ckanext/oauth2/plugin.py b/ckanext/oauth2/plugin.py index d24dee8..004f7c2 100644 --- a/ckanext/oauth2/plugin.py +++ b/ckanext/oauth2/plugin.py @@ -108,8 +108,6 @@ def _refresh_and_save_token(user_name): apikey = toolkit.request.headers.get(self.authorization_header, '') user_name = None - # log.debug(f'apikey: {apikey}') - # log.debug(f'headers: {toolkit.request.headers}') if self.authorization_header == "authorization": if apikey.startswith('Bearer '): @@ -120,7 +118,6 @@ def _refresh_and_save_token(user_name): # This API Key is not the one of CKAN, it's the one provided by the OAuth2 Service if apikey: try: - # log.debug(f'api_key: {apikey}') token = {'access_token': apikey} user_name = self.oauth2helper.identify(token) log.debug(f'user_name1: {user_name}') From 223175f74d83812912adb5849039b38f2b1aef4c Mon Sep 17 00:00:00 2001 From: Federico Oldani Date: Fri, 8 Apr 2022 13:01:09 +0200 Subject: [PATCH 4/5] first fix after frafra code review --- LICENSE.txt | 10 +- ckanext/oauth2/cli.py | 2 - ckanext/oauth2/controller.py | 2 - ckanext/oauth2/db.py | 7 - ckanext/oauth2/oauth2.py | 3 - ckanext/oauth2/plugin.py | 2 - ckanext/oauth2/tests/test_oauth2.py | 2 +- ckanext/oauth2/tests/test_plugin.py | 2 +- ckanext/oauth2/utils.py | 223 ---------------------------- ckanext/oauth2/views.py | 3 - setup.py | 5 +- 11 files changed, 8 insertions(+), 253 deletions(-) delete mode 100644 ckanext/oauth2/utils.py diff --git a/LICENSE.txt b/LICENSE.txt index 492cd2d..2def0e8 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -125,7 +125,7 @@ work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but -which are not part of the work. For theme, Corresponding Source +which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, @@ -311,7 +311,7 @@ fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install -modified object code on the User Product (for theme, the work has +modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a @@ -449,7 +449,7 @@ Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For theme, you may +rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that @@ -532,7 +532,7 @@ otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may -not convey it at all. For theme, if you agree to terms that obligate you +not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. @@ -649,7 +649,7 @@ Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to -get its source. For theme, if your program is a web application, its +get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the diff --git a/ckanext/oauth2/cli.py b/ckanext/oauth2/cli.py index 37c1a8b..5623b30 100644 --- a/ckanext/oauth2/cli.py +++ b/ckanext/oauth2/cli.py @@ -1,8 +1,6 @@ # -*- coding: utf-8 -*- import click -import ckanext.oauth2.utils as utils - @click.group() def oauth2(): diff --git a/ckanext/oauth2/controller.py b/ckanext/oauth2/controller.py index 35b1ee4..7900030 100644 --- a/ckanext/oauth2/controller.py +++ b/ckanext/oauth2/controller.py @@ -18,8 +18,6 @@ # 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 from ckanext.oauth2 import constants diff --git a/ckanext/oauth2/db.py b/ckanext/oauth2/db.py index 6c317c1..5ac410a 100644 --- a/ckanext/oauth2/db.py +++ b/ckanext/oauth2/db.py @@ -18,13 +18,8 @@ # along with OAuth2 CKAN Extension. If not, see . import sqlalchemy as sa -import ckan.model.meta as meta -import logging -from ckan.model.domain_object import DomainObject from sqlalchemy.ext.declarative import declarative_base -log = logging.getLogger(__name__) - Base = declarative_base() metadata = Base.metadata @@ -54,5 +49,3 @@ def by_user_name(cls, user_name): user_token_table.create(checkfirst=True) model.meta.mapper(UserToken, user_token_table) - - diff --git a/ckanext/oauth2/oauth2.py b/ckanext/oauth2/oauth2.py index 628998e..0be046c 100644 --- a/ckanext/oauth2/oauth2.py +++ b/ckanext/oauth2/oauth2.py @@ -90,7 +90,6 @@ def __init__(self): def challenge(self, came_from_url): # This function is called by the log in function when the user is not logged in state = generate_state(came_from_url) - # log.debug(f'redirect uri: {self.redirect_uri}') oauth = OAuth2Session(self.client_id, redirect_uri=self.redirect_uri, scope=self.scope, state=state) auth_url, _ = oauth.authorization_url(self.authorization_endpoint) log.debug('Challenge: Redirecting challenge to page {0}'.format(auth_url)) @@ -137,11 +136,9 @@ def identify(self, token): try: if self.legacy_idm: profile_response = requests.get(self.profile_api_url + '?access_token=%s' % token['access_token'], verify=self.verify_https) - log.debug(f'profile response: {profile_response}') else: oauth = OAuth2Session(self.client_id, token=token) profile_response = oauth.get(self.profile_api_url) - log.debug(f'profile response_: {profile_response}') except requests.exceptions.SSLError as e: log.debug('exception identify oauth2') diff --git a/ckanext/oauth2/plugin.py b/ckanext/oauth2/plugin.py index 004f7c2..6e01ae7 100644 --- a/ckanext/oauth2/plugin.py +++ b/ckanext/oauth2/plugin.py @@ -18,8 +18,6 @@ # 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 from .oauth2 import * import os diff --git a/ckanext/oauth2/tests/test_oauth2.py b/ckanext/oauth2/tests/test_oauth2.py index 34d40fe..3c81abe 100644 --- a/ckanext/oauth2/tests/test_oauth2.py +++ b/ckanext/oauth2/tests/test_oauth2.py @@ -291,7 +291,7 @@ def test_challenge(self): request = make_request(False, 'localhost', 'user/login', {}) request.environ = MagicMock() request.headers = {} - came_from = '/came_from_theme' + came_from = '/came_from_example' oauth2.toolkit.request = request diff --git a/ckanext/oauth2/tests/test_plugin.py b/ckanext/oauth2/tests/test_plugin.py index 1d2e05e..5d6646a 100644 --- a/ckanext/oauth2/tests/test_plugin.py +++ b/ckanext/oauth2/tests/test_plugin.py @@ -26,7 +26,7 @@ CUSTOM_AUTHORIZATION_HEADER = 'x-auth-token' OAUTH2_AUTHORIZATION_HEADER = 'authorization' -HOST = 'ckan.theme.org' +HOST = 'ckan.example.org' class PluginTest(unittest.TestCase): diff --git a/ckanext/oauth2/utils.py b/ckanext/oauth2/utils.py deleted file mode 100644 index 00671dd..0000000 --- a/ckanext/oauth2/utils.py +++ /dev/null @@ -1,223 +0,0 @@ -# -*- coding: utf-8 -*- - -import json -import logging -from six.moves.urllib.parse import urlencode, urlsplit, parse_qs - -import requests - -import ckan.lib.base as base -import ckan.lib.helpers as h -import ckan.logic as logic - -import ckantoolkit as toolkit - -from ckan import plugins as p - - -log = logging.getLogger(__name__) - -GEOJSON_MAX_FILE_SIZE = 25 * 1024 * 1024 - - -MAX_FILE_SIZE = 3 * 1024 * 1024 # 1MB -CHUNK_SIZE = 512 - -# HTTP request parameters that may conflict with OGC services -# protocols and should be excluded from proxied calls -OGC_EXCLUDED_PARAMS = [ - "service", - "version", - "request", - "outputformat", - "typename", - "layers", - "srsname", - "bbox", - "maxfeatures", -] - - -def proxy_service_resource(request, context, data_dict): - """ Chunked proxy for resources. To make sure that the file is not too - large, first, we try to get the content length from the headers. - If the headers to not contain a content length (if it is a chinked - response), we only transfer as long as the transferred data is less - than the maximum file size. """ - resource_id = data_dict["resource_id"] - log.info("Proxify resource {id}".format(id=resource_id)) - resource = logic.get_action("resource_show")(context, {"id": resource_id}) - url = resource["url"] - return proxy_service_url(request, url) - - -def proxy_service_url(req, url): - - parts = urlsplit(url) - if not parts.scheme or not parts.netloc: - base.abort(409, detail="Invalid URL.") - - try: - method = req.environ["REQUEST_METHOD"] - - params = parse_qs(parts.query) - - if not p.toolkit.asbool( - base.config.get( - "ckanext.geoview.forward_ogc_request_params", "False" - ) - ): - # remove query parameters that may conflict with OGC protocols - for key in dict(params): - if key.lower() in OGC_EXCLUDED_PARAMS: - del params[key] - parts = parts._replace(query=urlencode(params)) - - parts = parts._replace(fragment="") # remove potential fragment - url = parts.geturl() - if method == "POST": - length = int(req.environ["CONTENT_LENGTH"]) - headers = {"Content-Type": req.environ["CONTENT_TYPE"]} - body = req.body - r = requests.post(url, data=body, headers=headers, stream=True) - else: - r = requests.get(url, params=req.query_string, stream=True) - - # log.info('Request: {req}'.format(req=r.request.url)) - # log.info('Request Headers: {h}'.format(h=r.request.headers)) - - cl = r.headers.get("content-length") - if cl and int(cl) > MAX_FILE_SIZE: - base.abort( - 409, - ( - """Content is too large to be proxied. Allowed - file size: {allowed}, Content-Length: {actual}. Url: """ - + url - ).format(allowed=MAX_FILE_SIZE, actual=cl), - ) - if toolkit.check_ckan_version("2.9"): - from flask import make_response - - response = make_response() - else: - response = base.response - - response.content_type = r.headers["content-type"] - response.charset = r.encoding - - length = 0 - for chunk in r.iter_content(chunk_size=CHUNK_SIZE): - if toolkit.check_ckan_version("2.9"): - response.data += chunk - else: - response.body_file.write(chunk) - length += len(chunk) - - if length >= MAX_FILE_SIZE: - base.abort( - 409, - ( - """Content is too large to be proxied. Allowed - file size: {allowed}, Content-Length: {actual}. Url: """ - + url - ).format(allowed=MAX_FILE_SIZE, actual=length), - ) - - except requests.exceptions.HTTPError as error: - details = "Could not proxy resource. Server responded with %s %s" % ( - error.response.status_code, - error.response.reason, - ) - base.abort(409, detail=details) - except requests.exceptions.ConnectionError as error: - details = ( - """Could not proxy resource because a - connection error occurred. %s""" - % error - ) - base.abort(502, detail=details) - except requests.exceptions.Timeout as error: - details = "Could not proxy resource because the connection timed out." - base.abort(504, detail=details) - return response - - -def get_common_map_config(): - """Returns a dict with all configuration options related to the common - base map (ie those starting with 'ckanext.spatial.common_map.') - """ - namespace = "ckanext.spatial.common_map." - return dict( - [ - (k.replace(namespace, ""), v) - for k, v in toolkit.config.items() - if k.startswith(namespace) - ] - ) - - -def get_shapefile_viewer_config(): - """ - Returns a dict with all configuration options related to the - Shapefile viewer (ie those starting with 'ckanext.geoview.shp_viewer.') - """ - namespace = "ckanext.geoview.shp_viewer." - return dict( - [ - (k.replace(namespace, ""), v) - for k, v in toolkit.config.items() - if k.startswith(namespace) - ] - ) - - -def get_max_file_size(): - return toolkit.config.get( - "ckanext.geoview.geojson.max_file_size", GEOJSON_MAX_FILE_SIZE - ) - - -def get_openlayers_viewer_config(): - """ - Returns a dict with all configuration options related to the - OpenLayers viewer (ie those starting with 'ckanext.geoview.ol_viewer.') - """ - namespace = "ckanext.geoview.ol_viewer." - return dict( - [ - (k.replace(namespace, ""), v) - for k, v in toolkit.config.items() - if k.startswith(namespace) - ] - ) - - -def load_basemaps(basemapsFile): - - try: - with open(basemapsFile) as config_file: - basemapsConfig = json.load(config_file) - except Exception as inst: - msg = "Couldn't read basemaps config from %r: %s" % ( - basemapsFile, - inst, - ) - raise Exception(msg) - - return basemapsConfig - - -def get_proxified_service_url(data_dict): - """ - :param data_dict: contains a resource and package dict - :type data_dict: dictionary - """ - url = h.url_for( - action="proxy_service", - controller='service_proxy', - id=data_dict["package"]["name"], - resource_id=data_dict["resource"]["id"], - ) - log.debug("Proxified url is {0}".format(url)) - return url diff --git a/ckanext/oauth2/views.py b/ckanext/oauth2/views.py index 5e4ef95..22618bf 100644 --- a/ckanext/oauth2/views.py +++ b/ckanext/oauth2/views.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import logging from flask import Blueprint, jsonify, make_response import logging @@ -84,7 +82,6 @@ def callback(): redirect_url = '/' if redirect_url == constants.INITIAL_PAGE else redirect_url response.location = redirect_url helpers.flash_error(error_description) - # make_response((content, 302, headers)) return response def get_blueprints(): diff --git a/setup.py b/setup.py index 5fe2c6a..e409087 100644 --- a/setup.py +++ b/setup.py @@ -67,7 +67,7 @@ def rst(filename): url='https://github.com/conwetlab/ckanext-oauth2', download_url='https://github.com/conwetlab/ckanext-oauth2/tarball/v' + __version__, license='', - packages=find_packages(exclude=['ez_setup', 'themes', 'tests']), + packages=find_packages(exclude=['ez_setup', 'examples', 'tests']), namespace_packages=['ckanext'], include_package_data=True, zip_safe=False, @@ -88,9 +88,6 @@ def rst(filename): 'oauth2 = ckanext.oauth2.plugin:OAuth2Plugin', ] }, - # 'nose.plugins': [ - # 'pylons = pylons.test:PylonsPlugin' - # ] classifiers=[ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", From 9ff99772538015539bcb5c616d3f77abbaa1c0f8 Mon Sep 17 00:00:00 2001 From: Federico Oldani Date: Fri, 15 Apr 2022 14:59:43 +0200 Subject: [PATCH 5/5] minor fix --- ckanext/oauth2/oauth2.py | 2 +- ckanext/oauth2/plugin.py | 41 ++++++++++++++++++++++++++++++++-------- ckanext/oauth2/views.py | 2 -- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/ckanext/oauth2/oauth2.py b/ckanext/oauth2/oauth2.py index 0be046c..6bc3adc 100644 --- a/ckanext/oauth2/oauth2.py +++ b/ckanext/oauth2/oauth2.py @@ -37,7 +37,7 @@ import jwt -from .constants import * +from .constants import CAME_FROM_FIELD, REDIRECT_URL from flask import Flask, request, redirect, session, url_for, jsonify diff --git a/ckanext/oauth2/plugin.py b/ckanext/oauth2/plugin.py index 6e01ae7..1c41df6 100644 --- a/ckanext/oauth2/plugin.py +++ b/ckanext/oauth2/plugin.py @@ -63,9 +63,13 @@ def request_reset(context, data_dict): return _no_permissions(context, msg) -class _OAuth2Plugin(plugins.SingletonPlugin): +class OAuth2Plugin(plugins.SingletonPlugin): + plugins.implements(plugins.IAuthenticator, inherit=True) + plugins.implements(plugins.IAuthFunctions, inherit=True) + plugins.implements(plugins.IConfigurer) plugins.implements(plugins.IBlueprint) plugins.implements(plugins.IClick) + plugins.implements(plugins.IRoutes) # IBlueprint @@ -78,13 +82,6 @@ def get_commands(self): return get_commands() -class OAuth2Plugin(_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) - - def __init__(self, name=None): '''Store the OAuth 2 client configuration''' log.debug('Init OAuth2 extension') @@ -160,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/views.py b/ckanext/oauth2/views.py index 22618bf..7b15cdd 100644 --- a/ckanext/oauth2/views.py +++ b/ckanext/oauth2/views.py @@ -10,8 +10,6 @@ log = logging.getLogger(__name__) -log = logging.getLogger(__name__) -# service_proxy = Blueprint("service_proxy", __name__) oauth2 = Blueprint("oauth2", __name__) oauth2helper = OAuth2Helper()