From 5409d3f94deb39bf27a7f42791be794f0b7765ac Mon Sep 17 00:00:00 2001 From: Timo Scheffler Date: Wed, 2 Apr 2025 10:50:59 +0200 Subject: [PATCH 01/12] Add config declaration and fix config example in README --- README.md | 24 +++++++++++------- ckanext/security/config_declaration.yaml | 31 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 9 deletions(-) create mode 100644 ckanext/security/config_declaration.yaml diff --git a/README.md b/README.md index d9f79b4..1931143 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,8 +167,8 @@ 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 diff --git a/ckanext/security/config_declaration.yaml b/ckanext/security/config_declaration.yaml new file mode 100644 index 0000000..e427a09 --- /dev/null +++ b/ckanext/security/config_declaration.yaml @@ -0,0 +1,31 @@ +# 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.disable_password_reset_override + - key: ckanext.security.enable_totp + - key: ckanext.security.mfa_help_link \ No newline at end of file From b30e1889cef81ab723223120880a6f6399dae28b Mon Sep 17 00:00:00 2001 From: Timo Scheffler Date: Thu, 3 Apr 2025 14:28:48 +0200 Subject: [PATCH 02/12] Add test boilerplate --- ckanext/security/tests/__init__.py | 0 ckanext/security/tests/test_utils.py | 11 +++++++ dev-requirements.txt | 4 +++ setup.cfg | 6 ++++ test.ini | 49 ++++++++++++++++++++++++++++ 5 files changed, 70 insertions(+) create mode 100644 ckanext/security/tests/__init__.py create mode 100644 ckanext/security/tests/test_utils.py create mode 100644 dev-requirements.txt create mode 100644 setup.cfg create mode 100644 test.ini 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_utils.py b/ckanext/security/tests/test_utils.py new file mode 100644 index 0000000..92534a4 --- /dev/null +++ b/ckanext/security/tests/test_utils.py @@ -0,0 +1,11 @@ +import pytest + +class TestUtils(object): + + @pytest.mark.ckan_config(u'ckanext.security.min_password_length', u'12') + def test_dummy(self): + """ Basically a dummy test to check if the logger is initialized""" + # WHEN + # TODO dummy + # THEN + assert True 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..abf17ee --- /dev/null +++ b/setup.cfg @@ -0,0 +1,6 @@ +[tool:pytest] +filterwarnings = + ignore::sqlalchemy.exc.SADeprecationWarning + ignore::sqlalchemy.exc.SAWarning + ignore::DeprecationWarning +addopts = --ckan-ini test.ini 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 From 85fbbebba9f020176f41475a0f6dcefc5bdb835c Mon Sep 17 00:00:00 2001 From: Timo Scheffler Date: Thu, 3 Apr 2025 14:52:26 +0200 Subject: [PATCH 03/12] Add adjustable password length --- README.md | 3 +++ ckanext/security/config_declaration.yaml | 1 + ckanext/security/tests/test_utils.py | 11 ----------- ckanext/security/tests/test_validators.py | 20 ++++++++++++++++++++ ckanext/security/validators.py | 22 ++++++++++++---------- 5 files changed, 36 insertions(+), 21 deletions(-) delete mode 100644 ckanext/security/tests/test_utils.py create mode 100644 ckanext/security/tests/test_validators.py diff --git a/README.md b/README.md index 1931143..3ac0f1a 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,9 @@ 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 ``` ## How to install? diff --git a/ckanext/security/config_declaration.yaml b/ckanext/security/config_declaration.yaml index e427a09..a44f339 100644 --- a/ckanext/security/config_declaration.yaml +++ b/ckanext/security/config_declaration.yaml @@ -26,6 +26,7 @@ groups: - 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 \ No newline at end of file diff --git a/ckanext/security/tests/test_utils.py b/ckanext/security/tests/test_utils.py deleted file mode 100644 index 92534a4..0000000 --- a/ckanext/security/tests/test_utils.py +++ /dev/null @@ -1,11 +0,0 @@ -import pytest - -class TestUtils(object): - - @pytest.mark.ckan_config(u'ckanext.security.min_password_length', u'12') - def test_dummy(self): - """ Basically a dummy test to check if the logger is initialized""" - # WHEN - # TODO dummy - # THEN - assert True diff --git a/ckanext/security/tests/test_validators.py b/ckanext/security/tests/test_validators.py new file mode 100644 index 0000000..4d24e85 --- /dev/null +++ b/ckanext/security/tests/test_validators.py @@ -0,0 +1,20 @@ +import pytest +import ckanext.security.validators as validators + +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 + diff --git a/ckanext/security/validators.py b/ckanext/security/validators.py index 77ba480..09b8148 100644 --- a/ckanext/security/validators.py +++ b/ckanext/security/validators.py @@ -1,17 +1,17 @@ # 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 -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.' -) +DEFAULT_MIN_PASSWORD_LENGTH = 10 + + +def _min_password_length(): + return int(config.get('ckanext.security.min_password_length', + DEFAULT_MIN_PASSWORD_LENGTH)) def user_password_validator(key, data, errors, context): @@ -31,8 +31,10 @@ 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(_("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.").format(_min_password_length())) def old_username_validator(key, data, errors, context): From 3ae640b9d2d9b6acad1d01757a889ed472680321 Mon Sep 17 00:00:00 2001 From: Timo Scheffler Date: Fri, 4 Apr 2025 08:46:56 +0200 Subject: [PATCH 04/12] Add tests for password validator --- ckanext/security/tests/test_validators.py | 54 +++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/ckanext/security/tests/test_validators.py b/ckanext/security/tests/test_validators.py index 4d24e85..8191cd2 100644 --- a/ckanext/security/tests/test_validators.py +++ b/ckanext/security/tests/test_validators.py @@ -1,6 +1,9 @@ import pytest +from ckan.lib.navl.dictization_functions import Invalid + import ckanext.security.validators as validators + class TestValidators(object): def test_password_length_default(self): @@ -18,3 +21,54 @@ def test_password_length_config(self): # 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) From 087f386649c5c73ad8794178189aa9fe3bfadc8f Mon Sep 17 00:00:00 2001 From: Timo Scheffler Date: Mon, 7 Apr 2025 10:05:57 +0200 Subject: [PATCH 05/12] Add german translation --- ckanext/security/plugin/__init__.py | 4 +- .../security/plugin/i18n/ckanext-security.pot | 62 ++++++++++++++++++ .../i18n/de/LC_MESSAGES/ckanext-security.mo | Bin 0 -> 1544 bytes .../i18n/de/LC_MESSAGES/ckanext-security.po | 50 ++++++++++++++ setup.cfg | 22 +++++++ 5 files changed, 137 insertions(+), 1 deletion(-) create mode 100755 ckanext/security/plugin/i18n/ckanext-security.pot create mode 100644 ckanext/security/plugin/i18n/de/LC_MESSAGES/ckanext-security.mo create mode 100755 ckanext/security/plugin/i18n/de/LC_MESSAGES/ckanext-security.po diff --git a/ckanext/security/plugin/__init__.py b/ckanext/security/plugin/__init__.py index f027db3..d51e746 100644 --- a/ckanext/security/plugin/__init__.py +++ b/ckanext/security/plugin/__init__.py @@ -4,6 +4,7 @@ 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 @@ -16,12 +17,13 @@ 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 diff --git a/ckanext/security/plugin/i18n/ckanext-security.pot b/ckanext/security/plugin/i18n/ckanext-security.pot new file mode 100755 index 0000000..56b2a3f --- /dev/null +++ b/ckanext/security/plugin/i18n/ckanext-security.pot @@ -0,0 +1,62 @@ +# 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-07 07:59+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/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\n" +" app is correctly configured for future use." +msgstr "" + +#: ckanext/security/utils.py:210 +msgid "" +"That's an incorrect code. Try scanning\n" +" the QR code again with your authenticator app." +msgstr "" + +#: ckanext/security/utils.py:230 +msgid "" +"Successfully updated two factor\n" +" authentication secret. Make sure you add the new secret to\n" +" your authenticator app." +msgstr "" + +#: ckanext/security/validators.py:22 +msgid "Passwords must be strings." +msgstr "" + +#: ckanext/security/validators.py:34 +msgid "" +"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." +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 0000000000000000000000000000000000000000..895f66ca42bcce9726ac139ec7cbc832ba29c148 GIT binary patch literal 1544 zcmZ`(OK)366uzLc@sc%*0}CD^bL*H!t(>N+6T4MZ8$xW5h}cZ-J@+e`k6TmnY1$$pbt(O#= zQU)$5EG$*B2-dY|YseU;Y=SCeP}Y#=!g0uHBxg)MI;N$KBul)XG8tZMEGXC;ZeZnT z%a_HeY4U6`mEj{#k}jlHB^9>hoW6O(_=c~<4v$HB*gMBXKp2HJm8wCgqzcZf7!tYY z`5MEJGMfh*pov?OFQhTb)NeqffWACkS(2=!GPF=(n&Q1Rk`?JjdcUx)q~=_+6s_?JcD5oJNVCK~OdWH&N${1`+Acs2JD|Sygd_*wMd=!L zh4g&=#qR)Nam#I~Y8CLciKYl~UCa;3H(V&Gx8CR3iL=j`>gUrfZ2;}xRlqt&|9 zX?ONS`<`gur}q8s-tMPu3>b=2zEBD9cDv5nZiqCAiIc`_iEi!EldpzD`n)$9(8C+h zk6J@%>L_bKmi+o4CM@Uah8_-wkG0CJs~@#abaYaSC)Ul8S<|eJ{-Co@>+QkEhTcD* z-B0#gqvO%wIyJj_yLDtufJnq-*#IjbQ+FGUj@e%&7R5ShR$3>DVM zBs)lT99V0J6<<>0m1t2aV_Kot$h2{T{w`Soe~q%1ymg`nrA?K&jMdd2wGVmLhshCi z3jGC|B>~QW(ob)1d76w&G~@suRE-B~ovygu^i@gii)l`&>mta4D;r2qtkBqjZJ@ad z|IhO>uI0=IG!fs)mK=T3;#tZMoPs{2c=c=2BE*E)BlI{|reu#wB0, 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/utils.py:207 +msgid "" +"That's a valid code. Your authenticator\n" +" 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\n" +" 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\n" +" authentication secret. Make sure you add the new secret to\n" +" your authenticator app." +msgstr "" +"Das Authetication-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 Password muss {} Zeichen oder länger sein und mindestens drei der folgenden " +"vier Zeichentypen enthalten: Großbuchstaben, Kleinbuchstaben, Ziffern, Satzzeichen / Sonderzeichen." diff --git a/setup.cfg b/setup.cfg index abf17ee..e36b928 100644 --- a/setup.cfg +++ b/setup.cfg @@ -4,3 +4,25 @@ filterwarnings = 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 From 47f6d0025c74dfa7afd76f04b09ce3ec81568c3b Mon Sep 17 00:00:00 2001 From: Timo Scheffler Date: Tue, 29 Apr 2025 12:52:07 +0200 Subject: [PATCH 06/12] Include i18n folder on build --- MANIFEST.in | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 From 66e93cab287cb64af1ab2fb6df7dd8b26bcf1faf Mon Sep 17 00:00:00 2001 From: Timo Scheffler Date: Mon, 28 Apr 2025 16:41:38 +0200 Subject: [PATCH 07/12] Add blacklist for recently used passwords --- ckanext/security/config_declaration.yaml | 3 +- ckanext/security/constants.py | 1 + ckanext/security/helpers.py | 17 ++++ ckanext/security/logic/action.py | 75 +++++++++++++++++- ckanext/security/plugin/__init__.py | 4 + .../security/plugin/i18n/ckanext-security.pot | 30 +++---- .../i18n/de/LC_MESSAGES/ckanext-security.mo | Bin 1544 -> 1802 bytes .../i18n/de/LC_MESSAGES/ckanext-security.po | 34 ++++---- .../templates/user/edit_user_form.html | 20 +++++ ckanext/security/tests/test_action.py | 62 +++++++++++++++ ckanext/security/tests/test_validators.py | 38 +++++++++ ckanext/security/validators.py | 41 +++++++++- 12 files changed, 280 insertions(+), 45 deletions(-) create mode 100644 ckanext/security/constants.py create mode 100644 ckanext/security/tests/test_action.py diff --git a/ckanext/security/config_declaration.yaml b/ckanext/security/config_declaration.yaml index a44f339..5022b1f 100644 --- a/ckanext/security/config_declaration.yaml +++ b/ckanext/security/config_declaration.yaml @@ -29,4 +29,5 @@ groups: - key: ckanext.security.min_password_length - key: ckanext.security.disable_password_reset_override - key: ckanext.security.enable_totp - - key: ckanext.security.mfa_help_link \ No newline at end of file + - key: ckanext.security.mfa_help_link + - key: ckanext.security.tabulist_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..d34ec0f --- /dev/null +++ b/ckanext/security/constants.py @@ -0,0 +1 @@ +PLUGIN_EXTRAS_TABULIST_KEY = 'password_tabu_list' diff --git a/ckanext/security/helpers.py b/ckanext/security/helpers.py index af54a54..8930cdd 100644 --- a/ckanext/security/helpers.py +++ b/ckanext/security/helpers.py @@ -1,5 +1,22 @@ +from ckan.plugins.toolkit import _ from ckan.plugins.toolkit import asbool, config +from ckanext.security.validators import _min_password_length, PASSWORD_ERROR + +TABU_LIST_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 tabu list passwords + tabulist_item_count = config.get('ckanext.security.tabulist_item_count') + if tabulist_item_count and int(tabulist_item_count) > 0: + return password_hint + " " + _(TABU_LIST_HINT).format(tabulist_item_count) + + return password_hint \ No newline at end of file diff --git a/ckanext/security/logic/action.py b/ckanext/security/logic/action.py index dbfe497..87c45e5 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_TABULIST_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 tabu list + 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 tabu list 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 tabu list + 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 + """ + + tabulist_item_count = asint(config.get('ckanext.security.tabulist_item_count', 0)) + if tabulist_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_TABULIST_KEY not in user_obj.plugin_extras: + user_obj.plugin_extras[PLUGIN_EXTRAS_TABULIST_KEY] = [] + + max_appended_elements = int(tabulist_item_count) - 1 + new_list = [user_obj.password] + user_obj.plugin_extras[PLUGIN_EXTRAS_TABULIST_KEY][:max_appended_elements] + user_obj.plugin_extras[PLUGIN_EXTRAS_TABULIST_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 d51e746..341e8d9 100644 --- a/ckanext/security/plugin/__init__.py +++ b/ckanext/security/plugin/__init__.py @@ -11,6 +11,7 @@ ) 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.plugin.flask_plugin import MixinPlugin @@ -82,6 +83,8 @@ def get_actions(self): action.security_reset_totp, 'user_update': action.user_update, + 'user_create': + action.user_create, } # END Hooks for IActions @@ -108,4 +111,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/i18n/ckanext-security.pot b/ckanext/security/plugin/i18n/ckanext-security.pot index 56b2a3f..b8a4dba 100755 --- a/ckanext/security/plugin/i18n/ckanext-security.pot +++ b/ckanext/security/plugin/i18n/ckanext-security.pot @@ -9,7 +9,7 @@ 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:59+0000\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" @@ -22,6 +22,10 @@ msgstr "" 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 "" @@ -31,32 +35,22 @@ msgid "Not authorized to see this page" msgstr "" #: ckanext/security/utils.py:207 -msgid "" -"That's a valid code. Your authenticator\n" -" app is correctly configured for future use." +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\n" -" the QR code again with your authenticator app." +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\n" -" authentication secret. Make sure you add the new secret to\n" -" your authenticator app." +msgid "Successfully updated two factor authentication secret. Make sure you add the new secret to your authenticator app." msgstr "" -#: ckanext/security/validators.py:22 -msgid "Passwords must be strings." +#: 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:34 -msgid "" -"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." +#: 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 index 895f66ca42bcce9726ac139ec7cbc832ba29c148..91d52428c176f6f1ef4d2f4c120ac57c361420eb 100644 GIT binary patch delta 432 zcmY+8F-rqM5QX<7(L%8hth5-cBI2bL6%k2i6G<$z+vN7VmAhTJ-4J33wo$Y=55-<9 z+h7o}5K~$B7qs+02!ijTg)Th4H?y;Er#o>ndGt1wyI?qD&@5EVia+NRK8DVqO4fH6 z+XkxODtHUNf*)WByc=Qc68;lBMxEps+XQFwjO~JZ;4=6OJ}@@UzF@Y{xF1dS=aQ?H z)@%sC&V(90SH)uSO$n)KpZ5H17vpraV`)i1Mv?aP6gr^Mh1oxK>84no(9C- zObiSiK)w0`W$*EK*El5c$nK+-9hf4tg zCa$qY;ZL5(C^?ByT>x1PNcCiO#$ZgrDn`4>Um169{=u|}ar0~z4aUj;ST{52F#rIo CohyO> diff --git a/ckanext/security/plugin/i18n/de/LC_MESSAGES/ckanext-security.po b/ckanext/security/plugin/i18n/de/LC_MESSAGES/ckanext-security.po index 8871b90..163d5de 100755 --- a/ckanext/security/plugin/i18n/de/LC_MESSAGES/ckanext-security.po +++ b/ckanext/security/plugin/i18n/de/LC_MESSAGES/ckanext-security.po @@ -19,32 +19,26 @@ msgstr "" "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\n" -" app is correctly configured for future use." +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\n" -" the QR code again with your authenticator app." +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\n" -" authentication secret. Make sure you add the new secret to\n" -" your authenticator app." -msgstr "" -"Das Authetication-Secret wurde erfolgreich aktualisiert. " -"Stelle sicher, dass du das neue Secret in deiner Authenticator-App hinzufügst." +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 Password muss {} Zeichen oder länger sein und mindestens drei der folgenden " -"vier Zeichentypen enthalten: Großbuchstaben, Kleinbuchstaben, Ziffern, Satzzeichen / Sonderzeichen." +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 Password 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/test_action.py b/ckanext/security/tests/test_action.py new file mode 100644 index 0000000..918f1a9 --- /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_TABULIST_KEY +from passlib.hash import pbkdf2_sha512 + + +class TestAction(object): + + @pytest.mark.ckan_config(u'ckanext.security.tabulist_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 tabu list + + user_obj.password = "new_password2" + action._append_password(context, user_obj) # 2nd entry in tabu list + + user_obj.password = "new_password3" + action._append_password(context, user_obj) # will replace the last entry in tabu list + + # 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_TABULIST_KEY in actual_user['plugin_extras'] + assert len(actual_user['plugin_extras'][PLUGIN_EXTRAS_TABULIST_KEY]) == 2 + assert pbkdf2_sha512.verify("new_password3", actual_user['plugin_extras'][PLUGIN_EXTRAS_TABULIST_KEY][0]) + assert pbkdf2_sha512.verify("new_password2", actual_user['plugin_extras'][PLUGIN_EXTRAS_TABULIST_KEY][1]) + + @pytest.mark.usefixtures("with_plugins") + @pytest.mark.ckan_config("ckan.plugins", "security") + @pytest.mark.ckan_config(u'ckanext.security.tabulist_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_TABULIST_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_TABULIST_KEY]) == 2 \ No newline at end of file diff --git a/ckanext/security/tests/test_validators.py b/ckanext/security/tests/test_validators.py index 8191cd2..2312d42 100644 --- a/ckanext/security/tests/test_validators.py +++ b/ckanext/security/tests/test_validators.py @@ -1,7 +1,12 @@ +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_TABULIST_KEY class TestValidators(object): @@ -72,3 +77,36 @@ 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.tabulist_item_count', u'10') + def test_password_on_tabu_list_should_fail(self): + """ If password is on tabu list, 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_tabu_list_should_succeed(self): + """ If password is not on tabu list, 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_TABULIST_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 09b8148..6fc56e4 100644 --- a/ckanext/security/validators.py +++ b/ckanext/security/validators.py @@ -5,15 +5,30 @@ from ckan import authz 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_TABULIST_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)) +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): value = data[key] @@ -32,9 +47,29 @@ def user_password_validator(key, data, errors, context): any(x in string.punctuation for x in value) ] if len(value) < _min_password_length() or sum(rules) < 3: - raise Invalid(_("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.").format(_min_password_length())) + raise Invalid(_(PASSWORD_ERROR).format(_min_password_length(), string.punctuation)) + + # Check that the new password is not on the tabu list + tabulist_item_count = tk.asint(config.get('ckanext.security.tabulist_item_count', 0)) + + # feature enabled? Check needed, because feature could be disabled later + if tabulist_item_count > 0 and _user_is_editing_self(context): + if _password_in_tabulist(context, value): + raise Invalid(_("Your password is not allowed. Please choose a different one.")) + + +def _password_in_tabulist(context, new_password_plain): + """Return True, if the new password can verify any hash from the tabu list -> then it must be used before.""" + model = context['model'] + user_obj = model.User.get(context['user']) + + if user_obj.plugin_extras and PLUGIN_EXTRAS_TABULIST_KEY in user_obj.plugin_extras: + password_tabulist = user_obj.plugin_extras[PLUGIN_EXTRAS_TABULIST_KEY] + for entry in password_tabulist: + if entry and pbkdf2_sha512.verify(new_password_plain, entry): + return True + + return False def old_username_validator(key, data, errors, context): From e436b96d1a37f18634a219b9d78ce6d199f61992 Mon Sep 17 00:00:00 2001 From: Timo Scheffler Date: Mon, 28 Apr 2025 16:41:43 +0200 Subject: [PATCH 08/12] only invalidate session on logout if there is a session object --- ckanext/security/plugin/flask_plugin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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() From c0c7e3a7c9b425059e07da238b585dbe7b870980 Mon Sep 17 00:00:00 2001 From: Timo Scheffler Date: Wed, 14 May 2025 10:39:47 +0200 Subject: [PATCH 09/12] if saml2auth plugin is present, modify their password generation to comply with security --- ckanext/security/helpers.py | 20 +++++++++++++++++++- ckanext/security/plugin/__init__.py | 8 ++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/ckanext/security/helpers.py b/ckanext/security/helpers.py index 8930cdd..04297f4 100644 --- a/ckanext/security/helpers.py +++ b/ckanext/security/helpers.py @@ -1,5 +1,6 @@ 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 @@ -9,6 +10,7 @@ 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() @@ -19,4 +21,20 @@ def password_rules_hint(): if tabulist_item_count and int(tabulist_item_count) > 0: return password_hint + " " + _(TABU_LIST_HINT).format(tabulist_item_count) - return password_hint \ No newline at end of file + 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/plugin/__init__.py b/ckanext/security/plugin/__init__.py index 341e8d9..c1046e6 100644 --- a/ckanext/security/plugin/__init__.py +++ b/ckanext/security/plugin/__init__.py @@ -12,6 +12,7 @@ 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 @@ -44,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') From 794ce7b0a374e4323c661188ca875b8a5476e25a Mon Sep 17 00:00:00 2001 From: Timo Scheffler Date: Wed, 16 Jul 2025 08:40:11 +0200 Subject: [PATCH 10/12] Review: Rename tabu_list to blacklist. (squash to "Add blacklist for recently used passwords") --- ckanext/security/config_declaration.yaml | 2 +- ckanext/security/constants.py | 2 +- ckanext/security/helpers.py | 10 +++++----- ckanext/security/logic/action.py | 22 ++++++++++----------- ckanext/security/tests/test_action.py | 24 +++++++++++------------ ckanext/security/tests/test_validators.py | 14 ++++++------- ckanext/security/validators.py | 20 +++++++++---------- 7 files changed, 47 insertions(+), 47 deletions(-) diff --git a/ckanext/security/config_declaration.yaml b/ckanext/security/config_declaration.yaml index 5022b1f..3343438 100644 --- a/ckanext/security/config_declaration.yaml +++ b/ckanext/security/config_declaration.yaml @@ -30,4 +30,4 @@ groups: - key: ckanext.security.disable_password_reset_override - key: ckanext.security.enable_totp - key: ckanext.security.mfa_help_link - - key: ckanext.security.tabulist_item_count \ No newline at end of file + - key: ckanext.security.blacklist_item_count \ No newline at end of file diff --git a/ckanext/security/constants.py b/ckanext/security/constants.py index d34ec0f..0f2901a 100644 --- a/ckanext/security/constants.py +++ b/ckanext/security/constants.py @@ -1 +1 @@ -PLUGIN_EXTRAS_TABULIST_KEY = 'password_tabu_list' +PLUGIN_EXTRAS_BLACKLIST_KEY = 'password_blacklist' diff --git a/ckanext/security/helpers.py b/ckanext/security/helpers.py index 04297f4..a8e3e83 100644 --- a/ckanext/security/helpers.py +++ b/ckanext/security/helpers.py @@ -4,7 +4,7 @@ from ckanext.security.validators import _min_password_length, PASSWORD_ERROR -TABU_LIST_HINT = "Your password must not be the same as any of your last {} passwords." +BLACKLIST_HINT = "Your password must not be the same as any of your last {} passwords." def security_enable_totp(): @@ -16,10 +16,10 @@ def password_rules_hint(): min_password_length = _min_password_length() password_hint = _(PASSWORD_ERROR).format(min_password_length, string.punctuation) - # if enabled, add a hint about tabu list passwords - tabulist_item_count = config.get('ckanext.security.tabulist_item_count') - if tabulist_item_count and int(tabulist_item_count) > 0: - return password_hint + " " + _(TABU_LIST_HINT).format(tabulist_item_count) + # 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 diff --git a/ckanext/security/logic/action.py b/ckanext/security/logic/action.py index 87c45e5..455ab96 100644 --- a/ckanext/security/logic/action.py +++ b/ckanext/security/logic/action.py @@ -14,7 +14,7 @@ reset_address_throttle, reset_totp ) -from ckanext.security.constants import PLUGIN_EXTRAS_TABULIST_KEY +from ckanext.security.constants import PLUGIN_EXTRAS_BLACKLIST_KEY def security_throttle_user_reset(context, data_dict): @@ -85,7 +85,7 @@ def user_update(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 tabu list + # 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) @@ -96,13 +96,13 @@ def user_update(up_func, context, data_dict): @chained_action def user_create(up_func, context, data_dict): """ - Store the password hash in the tabu list on user creation. + Store the password hash in the blacklist on user creation. """ # Call the original user_update function rval = up_func(context, data_dict) - # Store the password hash in the tabu list + # 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) @@ -116,8 +116,8 @@ def _append_password(context, user_obj): Append the new password hash to the list of forbidden passwords """ - tabulist_item_count = asint(config.get('ckanext.security.tabulist_item_count', 0)) - if tabulist_item_count == 0: + 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: @@ -125,12 +125,12 @@ def _append_password(context, user_obj): if user_obj.plugin_extras is None: user_obj.plugin_extras = {} - if PLUGIN_EXTRAS_TABULIST_KEY not in user_obj.plugin_extras: - user_obj.plugin_extras[PLUGIN_EXTRAS_TABULIST_KEY] = [] + if PLUGIN_EXTRAS_BLACKLIST_KEY not in user_obj.plugin_extras: + user_obj.plugin_extras[PLUGIN_EXTRAS_BLACKLIST_KEY] = [] - max_appended_elements = int(tabulist_item_count) - 1 - new_list = [user_obj.password] + user_obj.plugin_extras[PLUGIN_EXTRAS_TABULIST_KEY][:max_appended_elements] - user_obj.plugin_extras[PLUGIN_EXTRAS_TABULIST_KEY] = new_list + 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. diff --git a/ckanext/security/tests/test_action.py b/ckanext/security/tests/test_action.py index 918f1a9..8543da4 100644 --- a/ckanext/security/tests/test_action.py +++ b/ckanext/security/tests/test_action.py @@ -3,13 +3,13 @@ import ckanext.security.logic.action as action import pytest from ckan.plugins import toolkit as tk -from ckanext.security.constants import PLUGIN_EXTRAS_TABULIST_KEY +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.tabulist_item_count', u'2') + @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 @@ -23,25 +23,25 @@ def test_append_password(self): # WHEN user_obj.password = "new_password1" - action._append_password(context, user_obj) # 1st entry in tabu list + action._append_password(context, user_obj) # 1st entry in blacklist user_obj.password = "new_password2" - action._append_password(context, user_obj) # 2nd entry in tabu list + 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 tabu list + 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_TABULIST_KEY in actual_user['plugin_extras'] - assert len(actual_user['plugin_extras'][PLUGIN_EXTRAS_TABULIST_KEY]) == 2 - assert pbkdf2_sha512.verify("new_password3", actual_user['plugin_extras'][PLUGIN_EXTRAS_TABULIST_KEY][0]) - assert pbkdf2_sha512.verify("new_password2", actual_user['plugin_extras'][PLUGIN_EXTRAS_TABULIST_KEY][1]) + 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.tabulist_item_count', u'10') + @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.""" @@ -57,6 +57,6 @@ def test_user_update_password_append_is_called(self): assert 'plugin_extras' in actual_user assert actual_user['plugin_extras'] is not None - assert PLUGIN_EXTRAS_TABULIST_KEY in actual_user['plugin_extras'] + 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_TABULIST_KEY]) == 2 \ No newline at end of file + 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 index 2312d42..c62bee7 100644 --- a/ckanext/security/tests/test_validators.py +++ b/ckanext/security/tests/test_validators.py @@ -6,7 +6,7 @@ from passlib.handlers.pbkdf2 import pbkdf2_sha512 import ckanext.security.validators as validators -from ckanext.security.constants import PLUGIN_EXTRAS_TABULIST_KEY +from ckanext.security.constants import PLUGIN_EXTRAS_BLACKLIST_KEY class TestValidators(object): @@ -80,9 +80,9 @@ def test_invalid_no_digits_and_no_uppercase_letters(self): @pytest.mark.usefixtures("with_plugins") @pytest.mark.ckan_config("ckan.plugins", "security") - @pytest.mark.ckan_config(u'ckanext.security.tabulist_item_count', u'10') - def test_password_on_tabu_list_should_fail(self): - """ If password is on tabu list, validator should throw an exception. """ + @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) @@ -94,14 +94,14 @@ def test_password_on_tabu_list_should_fail(self): with pytest.raises(Invalid): validators.user_password_validator("pw", {"pw": PASSWORD}, None, context) - def test_password_not_on_tabu_list_should_succeed(self): - """ If password is not on tabu list, validator should not throw an exception. """ + 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_TABULIST_KEY: [hashed_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} diff --git a/ckanext/security/validators.py b/ckanext/security/validators.py index 6fc56e4..3955d6e 100644 --- a/ckanext/security/validators.py +++ b/ckanext/security/validators.py @@ -6,7 +6,7 @@ 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_TABULIST_KEY +from ckanext.security.constants import PLUGIN_EXTRAS_BLACKLIST_KEY from passlib.hash import pbkdf2_sha512 DEFAULT_MIN_PASSWORD_LENGTH = 10 @@ -49,23 +49,23 @@ def user_password_validator(key, data, errors, context): 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 tabu list - tabulist_item_count = tk.asint(config.get('ckanext.security.tabulist_item_count', 0)) + # 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 tabulist_item_count > 0 and _user_is_editing_self(context): - if _password_in_tabulist(context, value): + 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_tabulist(context, new_password_plain): - """Return True, if the new password can verify any hash from the tabu list -> then it must be used before.""" +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_TABULIST_KEY in user_obj.plugin_extras: - password_tabulist = user_obj.plugin_extras[PLUGIN_EXTRAS_TABULIST_KEY] - for entry in password_tabulist: + 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 From c73b8d36b8a982f879ca1d7217130b402da0e07c Mon Sep 17 00:00:00 2001 From: Timo Scheffler Date: Wed, 16 Jul 2025 08:40:22 +0200 Subject: [PATCH 11/12] Add blacklist_item_count example line to README.md --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 3ac0f1a..8014f24 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,9 @@ ckanext.security.mfa_help_link = https://data.govt.nz/catalogue-guide/releasing- # 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? From f4cb3cf2436dfe9ef1f22cd2a5d25045197dcba8 Mon Sep 17 00:00:00 2001 From: seitenbau-govdata Date: Fri, 29 Aug 2025 10:02:50 +0200 Subject: [PATCH 12/12] Fix mistake in german translation --- .../i18n/de/LC_MESSAGES/ckanext-security.mo | Bin 1802 -> 1802 bytes .../i18n/de/LC_MESSAGES/ckanext-security.po | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/ckanext/security/plugin/i18n/de/LC_MESSAGES/ckanext-security.mo b/ckanext/security/plugin/i18n/de/LC_MESSAGES/ckanext-security.mo index 91d52428c176f6f1ef4d2f4c120ac57c361420eb..c6aaadaedc8018206c1722094c9a6ef3f3770027 100644 GIT binary patch delta 14 VcmeC;>*CwM!Nyp!nUjr?5da-A11JCh delta 14 VcmeC;>*CwM!N!=fnUjr?5da*~0~i1R diff --git a/ckanext/security/plugin/i18n/de/LC_MESSAGES/ckanext-security.po b/ckanext/security/plugin/i18n/de/LC_MESSAGES/ckanext-security.po index 163d5de..6dfa8fd 100755 --- a/ckanext/security/plugin/i18n/de/LC_MESSAGES/ckanext-security.po +++ b/ckanext/security/plugin/i18n/de/LC_MESSAGES/ckanext-security.po @@ -37,7 +37,7 @@ msgstr "Das Authentication-Secret wurde erfolgreich aktualisiert. Stelle sicher, #: 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 Password muss {} Zeichen oder länger sein und mindestens drei der folgenden vier Zeichentypen enthalten: Großbuchstaben, Kleinbuchstaben, Ziffern, Satzzeichen / Sonderzeichen ({})." +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."