diff --git a/MANIFEST.in b/MANIFEST.in index 1867f2d..ce53273 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,5 @@ recursive-include ckanext/security/templates * - +recursive-include ckanext/security/plugin/i18n * +include README.md +include LICENSE +include requirements.txt diff --git a/README.md b/README.md index d9f79b4..8014f24 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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? diff --git a/ckanext/security/config_declaration.yaml b/ckanext/security/config_declaration.yaml new file mode 100644 index 0000000..3343438 --- /dev/null +++ b/ckanext/security/config_declaration.yaml @@ -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 \ No newline at end of file diff --git a/ckanext/security/constants.py b/ckanext/security/constants.py new file mode 100644 index 0000000..0f2901a --- /dev/null +++ b/ckanext/security/constants.py @@ -0,0 +1 @@ +PLUGIN_EXTRAS_BLACKLIST_KEY = 'password_blacklist' diff --git a/ckanext/security/helpers.py b/ckanext/security/helpers.py index af54a54..a8e3e83 100644 --- a/ckanext/security/helpers.py +++ b/ckanext/security/helpers.py @@ -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) diff --git a/ckanext/security/logic/action.py b/ckanext/security/logic/action.py index dbfe497..455ab96 100644 --- a/ckanext/security/logic/action.py +++ b/ckanext/security/logic/action.py @@ -1,7 +1,12 @@ 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, @@ -9,6 +14,7 @@ reset_address_throttle, reset_totp ) +from ckanext.security.constants import PLUGIN_EXTRAS_BLACKLIST_KEY def security_throttle_user_reset(context, data_dict): @@ -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) diff --git a/ckanext/security/plugin/__init__.py b/ckanext/security/plugin/__init__.py index f027db3..c1046e6 100644 --- a/ckanext/security/plugin/__init__.py +++ b/ckanext/security/plugin/__init__.py @@ -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 @@ -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') @@ -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 @@ -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, } diff --git a/ckanext/security/plugin/flask_plugin.py b/ckanext/security/plugin/flask_plugin.py index 720715d..afcf061 100644 --- a/ckanext/security/plugin/flask_plugin.py +++ b/ckanext/security/plugin/flask_plugin.py @@ -27,4 +27,5 @@ def login(self): # Delete session cookie information def logout(self): - session.invalidate() + if hasattr(session, "invalidate"): + session.invalidate() diff --git a/ckanext/security/plugin/i18n/ckanext-security.pot b/ckanext/security/plugin/i18n/ckanext-security.pot new file mode 100755 index 0000000..b8a4dba --- /dev/null +++ b/ckanext/security/plugin/i18n/ckanext-security.pot @@ -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 , 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 \n" +"Language-Team: LANGUAGE \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 "" + diff --git a/ckanext/security/plugin/i18n/de/LC_MESSAGES/ckanext-security.mo b/ckanext/security/plugin/i18n/de/LC_MESSAGES/ckanext-security.mo new file mode 100644 index 0000000..c6aaada Binary files /dev/null and b/ckanext/security/plugin/i18n/de/LC_MESSAGES/ckanext-security.mo differ diff --git a/ckanext/security/plugin/i18n/de/LC_MESSAGES/ckanext-security.po b/ckanext/security/plugin/i18n/de/LC_MESSAGES/ckanext-security.po new file mode 100755 index 0000000..6dfa8fd --- /dev/null +++ b/ckanext/security/plugin/i18n/de/LC_MESSAGES/ckanext-security.po @@ -0,0 +1,44 @@ +# German translations for ckanext-security. +# Copyright (C) 2025 ORGANIZATION +# This file is distributed under the same license as the ckanext-security +# project. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: ckanext-security 3.0.4\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2025-04-07 07:51+0000\n" +"PO-Revision-Date: 2025-04-07 07:52+0000\n" +"Last-Translator: FULL NAME \n" +"Language: de\n" +"Language-Team: de \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\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/helpers.py:6 +msgid "Your password must not be the same as any of your last {} passwords." +msgstr "Dein Passwort darf nicht mit einem deiner letzten {} Passwörter übereinstimmen." + +#: ckanext/security/utils.py:207 +msgid "That's a valid code. Your authenticator app is correctly configured for future use." +msgstr "Der Code ist korrekt. Deine Authenticator-App ist nun richtig konfiguriert." + +#: ckanext/security/utils.py:210 +msgid "That's an incorrect code. Try scanning the QR code again with your authenticator app." +msgstr "Der Code ist falsch. Versuche, den QR-Code erneut mit deiner Authenticator-App zu scannen." + +#: ckanext/security/utils.py:230 +msgid "Successfully updated two factor authentication secret. Make sure you add the new secret to your authenticator app." +msgstr "Das Authentication-Secret wurde erfolgreich aktualisiert. Stelle sicher, dass du das neue Secret in deiner Authenticator-App hinzufügst." + +#: ckanext/security/validators.py:34 +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 "Das Passwort muss {} Zeichen oder länger sein und mindestens drei der folgenden vier Zeichentypen enthalten: Großbuchstaben, Kleinbuchstaben, Ziffern, Satzzeichen / Sonderzeichen ({})." + +#: ckanext/security/validators.py:47 +msgid "Your password is not allowed. Please choose a different one." +msgstr "Dein Passwort ist nicht erlaubt. Bitte wähle ein anderes." diff --git a/ckanext/security/templates/user/edit_user_form.html b/ckanext/security/templates/user/edit_user_form.html index 6f2a017..102b721 100644 --- a/ckanext/security/templates/user/edit_user_form.html +++ b/ckanext/security/templates/user/edit_user_form.html @@ -16,3 +16,23 @@ {% endif %} {% endblock %} + +{% block change_password %} {# Override this block to add the password_rules_hint() helper method #} +
+ {{ _('Change password') }} + {{ form.input('old_password', + type='password', + label=_('Old Password'), + id='field-password-old', + value=data.oldpassword, + error=errors.oldpassword, + classes=['control-medium'], + attrs={'autocomplete': 'off', 'class': 'form-control'} + ) }} + +

{{ h.password_rules_hint() }}

+ {{ form.input('password1', type='password', label=_('Password'), id='field-password', value=data.password1, error=errors.password1, classes=['control-medium'], attrs={'autocomplete': 'off', 'class': 'form-control'} ) }} + + {{ form.input('password2', type='password', label=_('Confirm Password'), id='field-password-confirm', value=data.password2, error=errors.password2, classes=['control-medium'], attrs={'autocomplete': 'off', 'class': 'form-control'}) }} +
+{% endblock %} diff --git a/ckanext/security/tests/__init__.py b/ckanext/security/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ckanext/security/tests/test_action.py b/ckanext/security/tests/test_action.py new file mode 100644 index 0000000..8543da4 --- /dev/null +++ b/ckanext/security/tests/test_action.py @@ -0,0 +1,62 @@ +import ckan.model as model +import ckan.tests.factories as factories +import ckanext.security.logic.action as action +import pytest +from ckan.plugins import toolkit as tk +from ckanext.security.constants import PLUGIN_EXTRAS_BLACKLIST_KEY +from passlib.hash import pbkdf2_sha512 + + +class TestAction(object): + + @pytest.mark.ckan_config(u'ckanext.security.blacklist_item_count', u'2') + def test_append_password(self): + """ + Check if the password is appended to the list of forbidden passwords + AND the list is limited to 2 entries. + """ + + # GIVEN + user = factories.Sysadmin() + user_obj = model.User.get(user["name"]) + context = {"user": user["name"], "ignore_auth": True} + + # WHEN + user_obj.password = "new_password1" + action._append_password(context, user_obj) # 1st entry in blacklist + + user_obj.password = "new_password2" + action._append_password(context, user_obj) # 2nd entry in blacklist + + user_obj.password = "new_password3" + action._append_password(context, user_obj) # will replace the last entry in blacklist + + # THEN + actual_user = tk.get_action('user_show')(context, {'id': user['name'], 'include_plugin_extras': True}) + assert 'plugin_extras' in actual_user + assert PLUGIN_EXTRAS_BLACKLIST_KEY in actual_user['plugin_extras'] + assert len(actual_user['plugin_extras'][PLUGIN_EXTRAS_BLACKLIST_KEY]) == 2 + assert pbkdf2_sha512.verify("new_password3", actual_user['plugin_extras'][PLUGIN_EXTRAS_BLACKLIST_KEY][0]) + assert pbkdf2_sha512.verify("new_password2", actual_user['plugin_extras'][PLUGIN_EXTRAS_BLACKLIST_KEY][1]) + + @pytest.mark.usefixtures("with_plugins") + @pytest.mark.ckan_config("ckan.plugins", "security") + @pytest.mark.ckan_config(u'ckanext.security.blacklist_item_count', u'10') + def test_user_update_password_append_is_called(self): + """Check if when updating the password, the append function is called.""" + + # GIVEN + user = factories.Sysadmin(password="ckan4Password") + context = {"user": user["name"]} + + # WHEN + tk.get_action('user_patch')(context, {'id': user['name'], 'password': 'ckan4Password2'}) + + # THEN + actual_user = tk.get_action('user_show')(context, {'id': user['name'], 'include_plugin_extras': True}) + + assert 'plugin_extras' in actual_user + assert actual_user['plugin_extras'] is not None + assert PLUGIN_EXTRAS_BLACKLIST_KEY in actual_user['plugin_extras'] + # make sure there are 2 entries: 1st: user_create, 2nd: user_update + assert len(actual_user['plugin_extras'][PLUGIN_EXTRAS_BLACKLIST_KEY]) == 2 \ No newline at end of file diff --git a/ckanext/security/tests/test_validators.py b/ckanext/security/tests/test_validators.py new file mode 100644 index 0000000..c62bee7 --- /dev/null +++ b/ckanext/security/tests/test_validators.py @@ -0,0 +1,112 @@ +import ckan.model as model +import ckan.tests.factories as factories +import pytest +from ckan.lib.navl.dictization_functions import Invalid +from ckan.plugins import toolkit as tk +from passlib.handlers.pbkdf2 import pbkdf2_sha512 + +import ckanext.security.validators as validators +from ckanext.security.constants import PLUGIN_EXTRAS_BLACKLIST_KEY + + +class TestValidators(object): + + def test_password_length_default(self): + """Check if the default password length is set to 10""" + # WHEN + actual = validators._min_password_length() + # THEN + assert actual == 10 + + @pytest.mark.ckan_config(u'ckanext.security.min_password_length', u'12') + def test_password_length_config(self): + """Check if the password length is set to 12 when set via config""" + # WHEN + actual = validators._min_password_length() + # THEN + assert actual == 12 + + @pytest.mark.ckan_config(u'ckanext.security.min_password_length', u'12') + def test_valid_password_length_is_ok(self): + """Check if the password length is validated correctly""" + # WHEN + THEN + validators.user_password_validator("pw", {"pw": "Aa_123456789"}, None, None) + + @pytest.mark.ckan_config(u'ckanext.security.min_password_length', u'12') + def test_invalid_password_length_is_too_short(self): + """Check if the password length is validated correctly""" + # WHEN + THEN + with pytest.raises(Invalid): + validators.user_password_validator("pw", {"pw": "Aa_12345678"}, None, None) + + # Check if one missing character class still validates. + def test_valid_no_uppercase_letters(self): + # WHEN + THEN + validators.user_password_validator("pw", {"pw": "aa_123456789"}, None, None) + + def test_valid_no_lowercase_letters(self): + # WHEN + THEN + validators.user_password_validator("pw", {"pw": "AA_123456789"}, None, None) + + def test_valid_no_special_chars(self): + # WHEN + THEN + validators.user_password_validator("pw", {"pw": "Aa0123456789"}, None, None) + + def test_valid_no_digits(self): + # WHEN + THEN + validators.user_password_validator("pw", {"pw": "Aa_abcdefghij"}, None, None) + + # Some checks to test character class validation. + # We have to test in combinations, since one class missing is accepted and we want a fail. + def test_invalid_no_uppercase_letters_and_no_lowercase_letters(self): + # WHEN + THEN + with pytest.raises(Invalid): + validators.user_password_validator("pw", {"pw": "___123456789"}, None, None) + + def test_invalid_no_lowercase_letters_and_no_special_chars(self): + # WHEN + THEN + with pytest.raises(Invalid): + validators.user_password_validator("pw", {"pw": "AAA123456789"}, None, None) + + def test_invalid_no_special_chars_and_no_digits(self): + # WHEN + THEN + with pytest.raises(Invalid): + validators.user_password_validator("pw", {"pw": "Aaaaaaaaaaaaa"}, None, None) + + def test_invalid_no_digits_and_no_uppercase_letters(self): + # WHEN + THEN + with pytest.raises(Invalid): + validators.user_password_validator("pw", {"pw": "aa_abcdefghij"}, None, None) + + @pytest.mark.usefixtures("with_plugins") + @pytest.mark.ckan_config("ckan.plugins", "security") + @pytest.mark.ckan_config(u'ckanext.security.blacklist_item_count', u'10') + def test_password_on_blacklist_should_fail(self): + """ If password is on blacklist, validator should throw an exception. """ + # GIVEN + PASSWORD = "ckan4Password" + user = factories.Sysadmin(password=PASSWORD) + user_obj = model.User.get(user["name"]) + print(user) + context = {"user": user["name"], 'model': model, 'user_obj': user_obj} + + # WHEN + THEN + with pytest.raises(Invalid): + validators.user_password_validator("pw", {"pw": PASSWORD}, None, context) + + def test_password_not_on_blacklist_should_succeed(self): + """ If password is not on blacklist, validator should not throw an exception. """ + + # GIVEN + PASSWORD = "ckan4Password" + hashed_password = str(pbkdf2_sha512.encrypt(PASSWORD)) + user = factories.User(password=PASSWORD, + plugin_extras={PLUGIN_EXTRAS_BLACKLIST_KEY: [hashed_password]}) + user_obj = model.User.get(user["name"]) + context = {"user": user["name"], 'model': model, 'user_obj': user_obj} + + # WHEN + THEN + try: + validators.user_password_validator("pw", {"pw": "ckan4Password2"}, None, context) + except Invalid: + pytest.fail("Validator threw an exception, but it should not have.") diff --git a/ckanext/security/validators.py b/ckanext/security/validators.py index 77ba480..3955d6e 100644 --- a/ckanext/security/validators.py +++ b/ckanext/security/validators.py @@ -1,17 +1,32 @@ # encoding: utf-8 -import six import string +import six from ckan import authz -from ckan.common import _ +from ckan.common import _, config from ckan.lib.navl.dictization_functions import Missing, Invalid +from ckan.plugins import toolkit as tk +from ckanext.security.constants import PLUGIN_EXTRAS_BLACKLIST_KEY +from passlib.hash import pbkdf2_sha512 + +DEFAULT_MIN_PASSWORD_LENGTH = 10 + +PASSWORD_ERROR = ("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 ({}).") + + +def _min_password_length(): + return int(config.get('ckanext.security.min_password_length', + DEFAULT_MIN_PASSWORD_LENGTH)) + -MIN_PASSWORD_LENGTH = 10 -MIN_LEN_ERROR = ( - 'Your password must be {} characters or longer, and consist of at least ' - 'three of the following character sets: uppercase characters, lowercase ' - 'characters, digits, punctuation & special characters.' -) +def _user_is_editing_self(context): + # user "default" is used when doing administrative things from CLI. + if 'user_obj' in context: + return context['user'] != 'default' and (context['user'] == context['user_obj'].name) + + return False def user_password_validator(key, data, errors, context): @@ -31,8 +46,30 @@ def user_password_validator(key, data, errors, context): any(x.isdigit() for x in value), any(x in string.punctuation for x in value) ] - if len(value) < MIN_PASSWORD_LENGTH or sum(rules) < 3: - raise Invalid(_(MIN_LEN_ERROR.format(MIN_PASSWORD_LENGTH))) + if len(value) < _min_password_length() or sum(rules) < 3: + raise Invalid(_(PASSWORD_ERROR).format(_min_password_length(), string.punctuation)) + + # Check that the new password is not on the blacklist + blacklist_item_count = tk.asint(config.get('ckanext.security.blacklist_item_count', 0)) + + # feature enabled? Check needed, because feature could be disabled later + if blacklist_item_count > 0 and _user_is_editing_self(context): + if _password_in_blacklist(context, value): + raise Invalid(_("Your password is not allowed. Please choose a different one.")) + + +def _password_in_blacklist(context, new_password_plain): + """Return True, if the new password can verify any hash from the blacklist -> then it must be used before.""" + model = context['model'] + user_obj = model.User.get(context['user']) + + if user_obj.plugin_extras and PLUGIN_EXTRAS_BLACKLIST_KEY in user_obj.plugin_extras: + password_blacklist = user_obj.plugin_extras[PLUGIN_EXTRAS_BLACKLIST_KEY] + for entry in password_blacklist: + if entry and pbkdf2_sha512.verify(new_password_plain, entry): + return True + + return False def old_username_validator(key, data, errors, context): diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..6057ea1 --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,4 @@ +mock +pytest-ckan +pytest-cov +pytest-pretty diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..e36b928 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,28 @@ +[tool:pytest] +filterwarnings = + ignore::sqlalchemy.exc.SADeprecationWarning + ignore::sqlalchemy.exc.SAWarning + ignore::DeprecationWarning +addopts = --ckan-ini test.ini + +[extract_messages] +keywords = translate isPlural +add_comments = TRANSLATORS: +output_file = ckanext/security/plugin/i18n/ckanext-security.pot +width = 80 + +[init_catalog] +domain = ckanext-security +input_file = ckanext/security/plugin/i18n/ckanext-security.pot +output_dir = ckanext/security/plugin/i18n + +[update_catalog] +domain = ckanext-security +input_file = ckanext/security/plugin/i18n/ckanext-security.pot +output_dir = ckanext/security/plugin/i18n +previous = true + +[compile_catalog] +domain = ckanext-security +directory = ckanext/security/plugin/i18n +statistics = true diff --git a/test.ini b/test.ini new file mode 100644 index 0000000..f8a40b5 --- /dev/null +++ b/test.ini @@ -0,0 +1,49 @@ +[DEFAULT] +debug = false +smtp_server = localhost +error_email_from = paste@localhost + +[server:main] +use = egg:Paste#http +host = 0.0.0.0 +port = 5000 + +[app:main] +use = config:../ckan/test-core.ini + +# Insert any custom config settings to be used when running your extension's +# tests here. + + +# Logging configuration +[loggers] +keys = root, ckan, sqlalchemy + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_ckan] +qualname = ckan +handlers = +level = INFO + +[logger_sqlalchemy] +handlers = +qualname = sqlalchemy.engine +level = WARN + +[handler_console] +class = StreamHandler +args = (sys.stdout,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s] %(message)s