Skip to content
Open
Show file tree
Hide file tree
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
5 changes: 4 additions & 1 deletion MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
recursive-include ckanext/security/templates *

recursive-include ckanext/security/plugin/i18n *
include README.md
include LICENSE
include requirements.txt
30 changes: 21 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,18 +141,24 @@ beaker.session.cookie_domain = 192.168.232.65

### ckanext-security configuration options
```ini
## Security
ckanext.security.domain = 192.168.232.65 # Cookie domain
## Security ############################################################
# Cookie domain
ckanext.security.domain = 192.168.232.65

ckanext.security.redis.host = 127.0.0.1
ckanext.security.redis.port = 6379
ckanext.security.redis.db = 1 # ckan uses db 0
ckanext.security.redis.password = StrongPassword # optional password for Redis
# ckan uses db 0
ckanext.security.redis.db = 1
# optional password for Redis
ckanext.security.redis.password = StrongPassword

# 15 minute timeout with 10 attempts
ckanext.security.lock_timeout = 900 # Login throttling lock period
ckanext.security.login_max_count = 10 # Login throttling attempt limit
ckanext.security.brute_force_key = user_name # Detect brute force attempts by username rather than IP address
# Login throttling lock period
ckanext.security.lock_timeout = 900
# Login throttling attempt limit
ckanext.security.login_max_count = 10
# Detect brute force attempts by username rather than IP address
ckanext.security.brute_force_key = user_name

# If using 2.7.7 or recent patches of 2.8, the password reset behaviour has been fixed in CKAN core
# (no longer discloses info about non-existent accounts) and the way this plugin overrides the password
Expand All @@ -161,12 +167,18 @@ ckanext.security.brute_force_key = user_name # Detect brute force attempts
ckanext.security.disable_password_reset_override = true

# Two factor authentication is enabled for all users by default
# optional configuration to disable 2fa
ckanext.security.enable_totp = true # set to false to disable 2fa
# optional configuration to disable 2fa; set to false to disable 2fa
ckanext.security.enable_totp = true

# Provide a help page to allow 2fa users to contact support or get more information
# Shows up as 'Need help?' on the 2fa entry form beside the submit button. Does not display a link if none provided
ckanext.security.mfa_help_link = https://data.govt.nz/catalogue-guide/releasing-data-on-data-govt-nz/how-do-i-set-up-two-factor-authentication/

# Set the minimal password length (optional, default: 10)
ckanext.security.min_password_length = 10

# Set the number of items on the blacklist, set to 0 to disable the blacklist
ckanext.security.blacklist_item_count = 10
```

## How to install?
Expand Down
33 changes: 33 additions & 0 deletions ckanext/security/config_declaration.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# schema version of the config declaration. At the moment, the only valid value is `1`
version: 1

# an array of configuration blocks. Each block has an "annotation", that
# describes the block, and the list of options. These groups help to separate
# config options visually, but they have no extra meaning.
groups:

# short text that describes the group. It can be shown in the config file
# as following:
# ## MyExt settings ##################
# some.option = some.value
# another.option = another.value
- annotation: Security

# an array of actual declarations
options:

# The only required item in the declaration is `key`. `key` defines the
# name of the config option
- key: ckanext.security.domain
- key: ckanext.security.redis.host
- key: ckanext.security.redis.port
- key: ckanext.security.redis.db
- key: ckanext.security.redis.password
- key: ckanext.security.lock_timeout
- key: ckanext.security.login_max_count
- key: ckanext.security.brute_force_key
- key: ckanext.security.min_password_length
- key: ckanext.security.disable_password_reset_override
- key: ckanext.security.enable_totp
- key: ckanext.security.mfa_help_link
- key: ckanext.security.blacklist_item_count
1 change: 1 addition & 0 deletions ckanext/security/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
PLUGIN_EXTRAS_BLACKLIST_KEY = 'password_blacklist'
35 changes: 35 additions & 0 deletions ckanext/security/helpers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,40 @@
from ckan.plugins.toolkit import _
from ckan.plugins.toolkit import asbool, config
import string, secrets

from ckanext.security.validators import _min_password_length, PASSWORD_ERROR

BLACKLIST_HINT = "Your password must not be the same as any of your last {} passwords."


def security_enable_totp():
return asbool(config.get('ckanext.security.enable_totp', True))


def password_rules_hint():
""" Return a description of the password rules """
min_password_length = _min_password_length()
password_hint = _(PASSWORD_ERROR).format(min_password_length, string.punctuation)

# if enabled, add a hint about blacklist passwords
blacklist_item_count = config.get('ckanext.security.blacklist_item_count')
if blacklist_item_count and int(blacklist_item_count) > 0:
return password_hint + " " + _(BLACKLIST_HINT).format(blacklist_item_count)

return password_hint


def generate_password():
""" Generate a random password that complies with the password rules """

# draft a new password of the minimum length
min_password_length = _min_password_length()
alphabet = string.ascii_letters + string.digits + string.punctuation
password = ''.join(secrets.choice(alphabet) for i in range(min_password_length))

# enhance password to absolutely comply with password rules
return password + \
secrets.choice(string.ascii_lowercase) + \
secrets.choice(string.ascii_uppercase) + \
secrets.choice(string.digits) + \
secrets.choice(string.punctuation)
75 changes: 72 additions & 3 deletions ckanext/security/logic/action.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
from ckan.plugins.toolkit import (
get_action,
chained_action,
check_access, get_or_bust)
check_access,
get_or_bust,
asint,
config
)

from ckanext.security.authenticator import (
get_user_throttle,
get_address_throttle,
reset_user_throttle,
reset_address_throttle,
reset_totp
)
from ckanext.security.constants import PLUGIN_EXTRAS_BLACKLIST_KEY


def security_throttle_user_reset(context, data_dict):
Expand Down Expand Up @@ -67,7 +73,70 @@ def user_update(up_func, context, data_dict):
ckanext-security: reset throttling information for updated users
to allow new login attempts after password reset
"""

# Save the current password hash
model = context['model']
user_id = get_or_bust(data_dict, 'id')
current_password_hash = model.User.get(user_id).password

# Call the original user_update function
rval = up_func(context, data_dict)

# Reset the security throttle for the user
get_action('security_throttle_user_reset')(dict(context, ignore_auth=True), {'user': rval['name']})

# If the password was changed, record it in the blacklist
updated_stored_user = model.User.get(user_id)
if current_password_hash != updated_stored_user.password:
_append_password(context, updated_stored_user)

return rval


@chained_action
def user_create(up_func, context, data_dict):
"""
Store the password hash in the blacklist on user creation.
"""

# Call the original user_update function
rval = up_func(context, data_dict)
get_action('security_throttle_user_reset')(
dict(context, ignore_auth=True), {'user': rval['name']})

# Store the password hash in the blacklist
model = context['model']
user_id = get_or_bust(rval, 'id')
updated_stored_user = model.User.get(user_id)
_append_password(context, updated_stored_user)

return rval


def _append_password(context, user_obj):
"""
Append the new password hash to the list of forbidden passwords
"""

blacklist_item_count = asint(config.get('ckanext.security.blacklist_item_count', 0))
if blacklist_item_count == 0:
return # feature is disabled

if not user_obj.password:
return # this can happen if an user is created with an invite link

if user_obj.plugin_extras is None:
user_obj.plugin_extras = {}
if PLUGIN_EXTRAS_BLACKLIST_KEY not in user_obj.plugin_extras:
user_obj.plugin_extras[PLUGIN_EXTRAS_BLACKLIST_KEY] = []

max_appended_elements = int(blacklist_item_count) - 1
new_list = [user_obj.password] + user_obj.plugin_extras[PLUGIN_EXTRAS_BLACKLIST_KEY][:max_appended_elements]
user_obj.plugin_extras[PLUGIN_EXTRAS_BLACKLIST_KEY] = new_list

# user_patch; Own implementation because we need to set the "keep_email" in context for a valid request.
# CKANs user_update needs an email address to update the user.
# CKANs user_patch will do user_show without getting the email address, thus failing on the user_update.
user_dict = get_action('user_show')(dict(context, ignore_auth=True, keep_email=True), {'id': user_obj.id})
patched = dict(user_dict)
patched.pop('display_name', None)
patched.update({'plugin_extras': user_obj.plugin_extras})
get_action('user_update')(dict(context, ignore_auth=True), patched)
16 changes: 15 additions & 1 deletion ckanext/security/plugin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,28 @@
from ckanext.security import schema as ext_schema
from ckan.plugins import toolkit as tk
from ckan.logic import schema as core_schema
from ckan.lib.plugins import DefaultTranslation
from ckanext.security.model import define_security_tables
from ckanext.security.resource_upload_validator import (
validate_upload
)
from ckanext.security.logic import auth, action
from ckanext.security.helpers import security_enable_totp
from ckanext.security.helpers import password_rules_hint
from ckanext.security.helpers import generate_password

from ckanext.security.plugin.flask_plugin import MixinPlugin

log = logging.getLogger(__name__)


class CkanSecurityPlugin(MixinPlugin, p.SingletonPlugin):
class CkanSecurityPlugin(MixinPlugin, p.SingletonPlugin, DefaultTranslation):
p.implements(p.IConfigurer)
p.implements(p.IResourceController, inherit=True)
p.implements(p.IActions)
p.implements(p.IAuthFunctions)
p.implements(p.ITemplateHelpers)
p.implements(p.ITranslation)

# BEGIN Hooks for IConfigurer

Expand All @@ -41,6 +45,13 @@ def update_config(self, config):
core_schema.default_update_user_schema = \
ext_schema.default_update_user_schema

# overwrite password generation on saml2auth helper, if plugin is present
try:
import ckanext.saml2auth.helpers
ckanext.saml2auth.helpers.generate_password = generate_password
except ImportError:
pass

tk.add_template_directory(config, '../templates')
tk.add_resource('../fanstatic', 'security')

Expand Down Expand Up @@ -80,6 +91,8 @@ def get_actions(self):
action.security_reset_totp,
'user_update':
action.user_update,
'user_create':
action.user_create,
}
# END Hooks for IActions

Expand All @@ -106,4 +119,5 @@ def get_helpers(self):
return {
'check_ckan_version': tk.check_ckan_version,
'security_enable_totp': security_enable_totp,
'password_rules_hint': password_rules_hint,
}
3 changes: 2 additions & 1 deletion ckanext/security/plugin/flask_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ def login(self):

# Delete session cookie information
def logout(self):
session.invalidate()
if hasattr(session, "invalidate"):
session.invalidate()
56 changes: 56 additions & 0 deletions ckanext/security/plugin/i18n/ckanext-security.pot
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Translations template for ckanext-security.
# Copyright (C) 2025 ORGANIZATION
# This file is distributed under the same license as the ckanext-security
# project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: ckanext-security 3.0.4\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-04-11 09:58+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.15.0\n"

#: ckanext/security/authenticator.py:181
msgid "Login failed. Bad username or password."
msgstr ""

#: ckanext/security/helpers.py:6
msgid "Your password must not be the same as any of your last {} passwords."
msgstr ""

#: ckanext/security/utils.py:31
msgid "No user specified"
msgstr ""

#: ckanext/security/utils.py:51
msgid "Not authorized to see this page"
msgstr ""

#: ckanext/security/utils.py:207
msgid "That's a valid code. Your authenticator app is correctly configured for future use."
msgstr ""

#: ckanext/security/utils.py:210
msgid "That's an incorrect code. Try scanning the QR code again with your authenticator app."
msgstr ""

#: ckanext/security/utils.py:230
msgid "Successfully updated two factor authentication secret. Make sure you add the new secret to your authenticator app."
msgstr ""

#: ckanext/security/validators.py:14
msgid "Your password must be {} characters or longer, and consist of at least three of the following four character sets: uppercase characters, lowercase characters, digits, punctuation & special characters."
msgstr ""

#: ckanext/security/validators.py:47
msgid "Your password is not allowed. Please choose a different one."
msgstr ""

Binary file not shown.
Loading