Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

QOLDEV-935 fix sysadmin management via web UI #139

Merged
merged 2 commits into from
Aug 30, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 9 additions & 154 deletions ckanext/qgov/common/intercepts.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,18 @@
""" Monkey-patch CKAN core functions with our own implementations.
"""

import json
from logging import getLogger
import re
import six

import requests

from ckan.lib.navl.dictization_functions import Missing
from ckan.lib.navl.validators import ignore_missing, not_empty
from ckan.lib.redis import connect_to_redis
import ckan.logic
import ckan.logic.action.update
import ckan.logic.schema as schemas
from ckan.logic import validators
from ckan.plugins import toolkit
from ckan.plugins.toolkit import _, abort, c, g, h, get_validator, \
chained_action, redirect_to, request
from ckan.plugins.toolkit import _, get_or_bust, get_validator, \
chained_action

from . import helpers
from .authenticator import unlock_account, LOGIN_THROTTLE_EXPIRY
from .urlm import get_purl_response
from .authenticator import unlock_account
from .user_creation import helpers as user_creation_helpers

LOG = getLogger(__name__)
Expand All @@ -33,8 +24,6 @@
DEFAULT_UPDATE_USER_SCHEMA = schemas.default_update_user_schema()
RESOURCE_SCHEMA = schemas.default_resource_schema()

EMAIL_REGEX = re.compile(r"[^@]+@[^@]+\.[^@]+")


def configure(config):
global password_min_length
Expand All @@ -49,6 +38,9 @@ def configure(config):

def set_intercepts():
""" Monkey-patch to wrap/override core functions with our own.
Theoretically, some of these steps may be redundant,
but to avoid race conditions (eg 'validators' read a value before we patched)
we perform them all.
"""
validators.user_password_validator = user_password_validator

Expand All @@ -60,40 +52,6 @@ def set_intercepts():
schemas.default_resource_schema = default_resource_schema


def set_pylons_intercepts():
from ckan.controllers.user import UserController
from ckan.controllers.package import PackageController
try:
from ckan.controllers.storage import StorageController
storage_enabled = True
except ImportError:
storage_enabled = False
from ckan.lib import base
from ckan.controllers import group, package, user

global LOGGED_IN, PACKAGE_EDIT, RESOURCE_EDIT, RESOURCE_DOWNLOAD, STORAGE_DOWNLOAD, ABORT
LOGGED_IN = UserController.logged_in
PACKAGE_EDIT = PackageController._save_edit
RESOURCE_EDIT = PackageController.resource_edit
RESOURCE_DOWNLOAD = PackageController.resource_download
ABORT = base.abort

UserController.logged_in = logged_in
PackageController._save_edit = save_edit
PackageController.resource_edit = validate_resource_edit

if storage_enabled:
STORAGE_DOWNLOAD = StorageController.file
StorageController.file = storage_download_with_headers
PackageController.resource_download = resource_download_with_headers

# Monkey-patch ourselves into the 404 handler
base.abort = abort_with_purl
group.abort = abort_with_purl
package.abort = abort_with_purl
user.abort = abort_with_purl


def user_password_validator(key, data, errors, context):
""" Strengthen the built-in password validation to require more length and complexity.
"""
Expand All @@ -105,7 +63,7 @@ def user_password_validator(key, data, errors, context):
errors[('password',)].append(_('Passwords must be strings'))
elif value == '':
pass
elif not len(value) >= password_min_length:
elif len(value) < password_min_length:
errors[('password',)].append(
_('Your password must be {min} characters or longer'.format(min=password_min_length))
)
Expand Down Expand Up @@ -183,6 +141,7 @@ def default_update_user_schema():
user_schema = _apply_schema_validator(
user_schema, 'fullname',
validator_name='not_empty', validator=not_empty)
user_schema = user_creation_helpers.add_custom_validator_to_user_schema(user_schema)
return user_schema


Expand All @@ -204,112 +163,8 @@ def user_update(original_action, context, data_dict):
'''
Unlock an account when the password is reset.
'''
modified_schema = context.get('schema') or default_user_schema()
context['schema'] = user_creation_helpers.add_custom_validator_to_user_schema(modified_schema)
return_value = original_action(context, data_dict)
if u'reset_key' in data_dict:
account_id = ckan.logic.get_or_bust(data_dict, 'id')
account_id = get_or_bust(data_dict, 'id')
unlock_account(account_id)
return return_value


def logged_in(self):
""" Provide a custom error code when login fails due to account lockout.
"""
if not c.user:
# a number of failed login attempts greater than 10 indicates
# that the locked user is associated with the current request
redis_conn = connect_to_redis()

for key in redis_conn.keys('{}.ckanext.qgov.login_attempts.*'.format(g.site_id)):
login_attempts = redis_conn.get(key)
if login_attempts > 10:
redis_conn.set(key, 10, ex=LOGIN_THROTTLE_EXPIRY)
return self.login('account-locked')
return LOGGED_IN(self)


def save_edit(self, name_or_id, context, package_type=None):
'''
Intercept save_edit
Replace author, maintainer, maintainer_email
'''
# Harvest package types do not have 'author_email' in their schema.
if package_type == 'harvest':
return PACKAGE_EDIT(self, name_or_id, context, package_type)

try:
author_email = request.POST.getone('author_email')
except Exception:
return abort(400, _('No author email or multiple author emails provided'))
if not EMAIL_REGEX.match(author_email):
return abort(400, _('Invalid email.'))

if 'author' in request.POST:
request.POST.__delitem__('author')
if 'maintainer' in request.POST:
request.POST.__delitem__('maintainer')
if 'maintainer_email' in request.POST:
request.POST.__delitem__('maintainer_email')

request.POST.add('author', author_email)
request.POST.add('maintainer', author_email)
request.POST.add('maintainer_email', author_email)

return PACKAGE_EDIT(self, name_or_id, context, package_type=None)


def validate_resource_edit(self, id, resource_id,
data=None, errors=None, error_summary=None):
'''
Intercept save_edit
Replace author, maintainer, maintainer_email
'''
if 'validation_schema' in request.POST and 'format' in request.POST:
resource_format = request.POST.getone('format')
validation_schema = request.POST.getone('validation_schema')
if resource_format == 'CSV' and validation_schema and validation_schema != '':
schema_url = helpers.generate_download_url(id, validation_schema)
data_url = helpers.generate_download_url(id, resource_id)
validation_url = "http://goodtables.okfnlabs.org/api/run?format=csv&schema={0}&data={1}&row_limit=100000&report_limit=1000&report_type=grouped".format(schema_url, data_url)
req = requests.get(validation_url, verify=False)
if req.status_code == requests.codes.ok:
response_text = json.loads(req.text)
if response_text['success']:
h.flash_success("CSV was validated successfully against the selected schema")
else:
h.flash_error("CSV was NOT validated against the selected schema")

return RESOURCE_EDIT(self, id, resource_id, data, errors, error_summary)


def _set_download_headers(response):
response.headers['Content-Disposition'] = 'attachment'
response.headers['X-Content-Type-Options'] = 'nosniff'


def storage_download_with_headers(self, label):
""" Add security headers to protect against download-based exploits.
"""
file_download = STORAGE_DOWNLOAD(self, label)
_set_download_headers(toolkit.response)
return file_download


def resource_download_with_headers(self, id, resource_id, filename=None):
""" Add security headers to protect against download-based exploits.
"""
file_download = RESOURCE_DOWNLOAD(self, id, resource_id, filename)
_set_download_headers(toolkit.response)
return file_download


def abort_with_purl(status_code=None, detail='', headers=None, comment=None):
""" Consult PURL about a 404, redirecting if it reports a new URL.
"""
if status_code == 404:
redirect_url = get_purl_response(request.url)
if redirect_url:
return redirect_to(redirect_url, 301)

return ABORT(status_code, detail, headers, comment)
Loading