diff --git a/password_security/__init__.py b/password_security/__init__.py index df8f34c6c9..86710ee5be 100644 --- a/password_security/__init__.py +++ b/password_security/__init__.py @@ -1,4 +1,5 @@ # Copyright 2015 LasLabs Inc. # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from .post_install import init_config_parameters from . import controllers, models diff --git a/password_security/__manifest__.py b/password_security/__manifest__.py index 237250eed3..958bbe906d 100644 --- a/password_security/__manifest__.py +++ b/password_security/__manifest__.py @@ -5,7 +5,7 @@ { "name": "Password Security", "summary": "Allow admin to set password security requirements.", - "version": "17.0.1.0.0", + "version": "17.0.2.0.0", "author": "LasLabs, " "Onestein, " "Kaushal Prajapati, " @@ -28,5 +28,6 @@ "demo": [ "demo/res_users.xml", ], + "post_init_hook": "init_config_parameters", "installable": True, } diff --git a/password_security/migrations/17.0.2.0.0/post-migration.py b/password_security/migrations/17.0.2.0.0/post-migration.py new file mode 100644 index 0000000000..344eae5265 --- /dev/null +++ b/password_security/migrations/17.0.2.0.0/post-migration.py @@ -0,0 +1,27 @@ +# Copyright 2024 Akretion France (http://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from openupgradelib import openupgrade + + +@openupgrade.migrate() +def migrate(env, version): + env.cr.execute( + f"SELECT {openupgrade.get_legacy_name('password_expiration')}, " + f"{openupgrade.get_legacy_name('password_minimum')}, " + f"{openupgrade.get_legacy_name('password_history')}, " + f"{openupgrade.get_legacy_name('password_lower')}, " + f"{openupgrade.get_legacy_name('password_upper')}, " + f"{openupgrade.get_legacy_name('password_numeric')}, " + f"{openupgrade.get_legacy_name('password_special')} " + "FROM res_company ORDER BY id LIMIT 1" + ) + res = env.cr.fetchone() + env["ir.config_parameter"].set_param("password_security.expiration_days", res[0]) + env["ir.config_parameter"].set_param("password_security.minimum_hours", res[1]) + env["ir.config_parameter"].set_param("password_security.history", res[2]) + env["ir.config_parameter"].set_param("password_security.lower", res[3]) + env["ir.config_parameter"].set_param("password_security.upper", res[4]) + env["ir.config_parameter"].set_param("password_security.numeric", res[5]) + env["ir.config_parameter"].set_param("password_security.special", res[6]) diff --git a/password_security/migrations/17.0.2.0.0/pre-migration.py b/password_security/migrations/17.0.2.0.0/pre-migration.py new file mode 100644 index 0000000000..7405d77864 --- /dev/null +++ b/password_security/migrations/17.0.2.0.0/pre-migration.py @@ -0,0 +1,57 @@ +# Copyright 2024 Akretion France (http://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from openupgradelib import openupgrade + + +@openupgrade.migrate() +def migrate(env, version): + openupgrade.rename_fields( + env, + [ + ( + "res.company", + "res_company", + "password_expiration", + openupgrade.get_legacy_name("password_expiration"), + ), + ( + "res.company", + "res_company", + "password_lower", + openupgrade.get_legacy_name("password_lower"), + ), + ( + "res.company", + "res_company", + "password_upper", + openupgrade.get_legacy_name("password_upper"), + ), + ( + "res.company", + "res_company", + "password_numeric", + openupgrade.get_legacy_name("password_numeric"), + ), + ( + "res.company", + "res_company", + "password_special", + openupgrade.get_legacy_name("password_special"), + ), + ( + "res.company", + "res_company", + "password_history", + openupgrade.get_legacy_name("password_history"), + ), + ( + "res.company", + "res_company", + "password_minimum", + openupgrade.get_legacy_name("password_minimum"), + ), + ], + ) diff --git a/password_security/models/__init__.py b/password_security/models/__init__.py index 87db2d54d2..a5464bb83b 100644 --- a/password_security/models/__init__.py +++ b/password_security/models/__init__.py @@ -1,7 +1,6 @@ # Copyright 2015 LasLabs Inc. # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). -from . import res_company from . import res_config_settings from . import res_users from . import res_users_pass_history diff --git a/password_security/models/res_company.py b/password_security/models/res_company.py deleted file mode 100644 index 031563efdc..0000000000 --- a/password_security/models/res_company.py +++ /dev/null @@ -1,46 +0,0 @@ -# Copyright 2016 LasLabs Inc. -# Copyright 2017 Kaushal Prajapati . -# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). - -from odoo import fields, models - - -class ResCompany(models.Model): - _inherit = "res.company" - - password_expiration = fields.Integer( - "Days", - default=60, - help="How many days until passwords expire", - ) - password_lower = fields.Integer( - "Lowercase", - default=1, - help="Require number of lowercase letters", - ) - password_upper = fields.Integer( - "Uppercase", - default=1, - help="Require number of uppercase letters", - ) - password_numeric = fields.Integer( - "Numeric", - default=1, - help="Require number of numeric digits", - ) - password_special = fields.Integer( - "Special", - default=1, - help="Require number of unique special characters", - ) - password_history = fields.Integer( - "History", - default=30, - help="Disallow reuse of this many previous passwords - use negative " - "number for infinite, or 0 to disable", - ) - password_minimum = fields.Integer( - "Minimum Hours", - default=24, - help="Amount of hours until a user may change password again", - ) diff --git a/password_security/models/res_config_settings.py b/password_security/models/res_config_settings.py index 3bc6c93052..3e27ccc2ec 100644 --- a/password_security/models/res_config_settings.py +++ b/password_security/models/res_config_settings.py @@ -6,20 +6,58 @@ class ResConfigSettings(models.TransientModel): _inherit = "res.config.settings" + # Imagine that the ir.config_parameter password_security.numeric has a default value of 1. + # If the user sets the value to 0 on the config page, the ir.config_parameter is deleted... but + # when the ir.config_parameter is not present in the database, Odoo displays the default value + # on the config page => Odoo displays 1 ! + # So, when the users sets the value of 0 on the config page, he will see 1 + # after saving the page !!! + # If the default value is 0 (like auth_password_policy.minlength in the + # module auth_password_policy of the official addons), there is no problem. + # So the solution to avoid this problem and have a non-null default value: + # 1) define the ir.config_parameter fields on res.config.settings with default=0 + # 2) initialize the ir.config_parameter with a default value in the init script + # So the default value of the fields below are written in post_install.py password_expiration = fields.Integer( - related="company_id.password_expiration", readonly=False + string="Days", + default=0, + config_parameter="password_security.expiration_days", + help="How many days until passwords expire", ) password_minimum = fields.Integer( - related="company_id.password_minimum", readonly=False + string="Minimum Hours", + default=0, + config_parameter="password_security.minimum_hours", + help="Number of hours until a user may change password again", ) password_history = fields.Integer( - related="company_id.password_history", readonly=False + string="History", + default=0, + config_parameter="password_security.history", + help="Disallow reuse of this many previous passwords - use negative " + "number for infinite, or 0 to disable", + ) + password_lower = fields.Integer( + string="Lowercase", + default=0, + config_parameter="password_security.lower", + help="Require number of lowercase letters", + ) + password_upper = fields.Integer( + string="Uppercase", + default=0, + config_parameter="password_security.upper", + help="Require number of uppercase letters", ) - password_lower = fields.Integer(related="company_id.password_lower", readonly=False) - password_upper = fields.Integer(related="company_id.password_upper", readonly=False) password_numeric = fields.Integer( - related="company_id.password_numeric", readonly=False + string="Numeric", + default=0, + config_parameter="password_security.numeric", + help="Require number of numeric digits", ) password_special = fields.Integer( - related="company_id.password_special", readonly=False + string="Special", + default=0, + config_parameter="password_security.special", + help="Require number of unique special characters", ) diff --git a/password_security/models/res_users.py b/password_security/models/res_users.py index c080223f20..0d3d9fc62f 100644 --- a/password_security/models/res_users.py +++ b/password_security/models/res_users.py @@ -32,16 +32,37 @@ def write(self, vals): vals["password_write_date"] = fields.Datetime.now() return super().write(vals) + @api.model + def _get_all_password_params(self): + params = self.env["ir.config_parameter"].sudo() + res = { + "minlength": int( + params.get_param("auth_password_policy.minlength", default=0) + ), + "expiration_days": int( + params.get_param("password_security.expiration_days", default=60) + ), + "minimum_hours": int( + params.get_param("password_security.minimum_hours", default=60) + ), + "history": int(params.get_param("password_security.history", default=30)), + "lower": int(params.get_param("password_security.lower", default=1)), + "upper": int(params.get_param("password_security.upper", default=1)), + "numeric": int(params.get_param("password_security.numeric", default=1)), + "special": int(params.get_param("password_security.special", default=1)), + } + return res + @api.model def get_password_policy(self): data = super().get_password_policy() - company_id = self.env.user.company_id + pwd_params = self._get_all_password_params() data.update( { - "password_lower": company_id.password_lower, - "password_upper": company_id.password_upper, - "password_numeric": company_id.password_numeric, - "password_special": company_id.password_special, + "password_lower": pwd_params["lower"], + "password_upper": pwd_params["upper"], + "password_numeric": pwd_params["numeric"], + "password_special": pwd_params["special"], } ) return data @@ -58,36 +79,31 @@ def _check_password_policy(self, passwords): def password_match_message(self): self.ensure_one() - company_id = self.company_id message = [] - if company_id.password_lower: + pwd_params = self._get_all_password_params() + if pwd_params["lower"]: message.append( - _("\n* Lowercase letter (at least %s characters)") - % str(company_id.password_lower) + _("\n* Lowercase letter (at least %s characters)") % pwd_params["lower"] ) - if company_id.password_upper: + if pwd_params["upper"]: message.append( - _("\n* Uppercase letter (at least %s characters)") - % str(company_id.password_upper) + _("\n* Uppercase letter (at least %s characters)") % pwd_params["upper"] ) - if company_id.password_numeric: + if pwd_params["numeric"]: message.append( - _("\n* Numeric digit (at least %s characters)") - % str(company_id.password_numeric) + _("\n* Numeric digit (at least %s characters)") % pwd_params["numeric"] ) - if company_id.password_special: + if pwd_params["special"]: message.append( _("\n* Special character (at least %s characters)") - % str(company_id.password_special) + % pwd_params["special"] ) if message: message = [_("Must contain the following:")] + message - params = self.env["ir.config_parameter"].sudo() - minlength = params.get_param("auth_password_policy.minlength", default=0) - if minlength: + if pwd_params["minlength"]: message = [ - _("Password must be %d characters or more.") % int(minlength) + _("Password must be %d characters or more.") % pwd_params["minlength"] ] + message return "\r".join(message) @@ -100,16 +116,14 @@ def _check_password_rules(self, password): self.ensure_one() if not password: return True - company_id = self.company_id - params = self.env["ir.config_parameter"].sudo() - minlength = params.get_param("auth_password_policy.minlength", default=0) + pwd_params = self._get_all_password_params() password_regex = [ "^", - "(?=.*?[a-z]){" + str(company_id.password_lower) + ",}", - "(?=.*?[A-Z]){" + str(company_id.password_upper) + ",}", - "(?=.*?\\d){" + str(company_id.password_numeric) + ",}", - r"(?=.*?[\W_]){" + str(company_id.password_special) + ",}", - ".{%d,}$" % int(minlength), + "(?=.*?[a-z]){" + str(pwd_params["lower"]) + ",}", + "(?=.*?[A-Z]){" + str(pwd_params["upper"]) + ",}", + "(?=.*?\\d){" + str(pwd_params["numeric"]) + ",}", + r"(?=.*?[\W_]){" + str(pwd_params["special"]) + ",}", + ".{%d,}$" % pwd_params["minlength"], ] if not re.search("".join(password_regex), password): raise ValidationError(self.password_match_message()) @@ -121,11 +135,12 @@ def _password_has_expired(self): if not self.password_write_date: return True - if not self.company_id.password_expiration: + pwd_params = self._get_all_password_params() + if not pwd_params["expiration_days"]: return False days = (fields.Datetime.now() - self.password_write_date).days - return days > self.company_id.password_expiration + return days > pwd_params["expiration_days"] def action_expire_password(self): expiration = delta_now(days=+1) @@ -139,19 +154,19 @@ def _validate_pass_reset(self): :raises: UserError on invalidated pass reset attempt :return: True on allowed reset """ + pwd_params = self._get_all_password_params() for user in self: - pass_min = user.company_id.password_minimum - if pass_min <= 0: + if pwd_params["minimum_hours"] <= 0: continue write_date = user.password_write_date - delta = timedelta(hours=pass_min) + delta = timedelta(hours=pwd_params["minimum_hours"]) if write_date + delta > datetime.now(): raise UserError( _( "Passwords can only be reset every %d hour(s). " "Please contact an administrator for assistance." ) - % pass_min + % pwd_params["minimum_hours"] ) return True @@ -160,20 +175,19 @@ def _check_password_history(self, password): :raises: UserError on reused password """ crypt = self._crypt_context() + pwd_params = self._get_all_password_params() for user in self: - password_history = user.company_id.password_history - if not password_history: # disabled + if not pwd_params["history"]: # disabled recent_passes = self.env["res.users.pass.history"] - elif password_history < 0: # unlimited + elif pwd_params["history"] < 0: # unlimited recent_passes = user.password_history_ids else: - recent_passes = user.password_history_ids[:password_history] + recent_passes = user.password_history_ids[: pwd_params["history"]] if recent_passes.filtered( lambda r: crypt.verify(password, r.password_crypt) ): raise UserError( - _("Cannot use the most recent %d passwords") - % user.company_id.password_history + _("Cannot use the most recent %d passwords") % pwd_params["history"] ) def _set_encrypted_password(self, uid, pw): diff --git a/password_security/post_install.py b/password_security/post_install.py new file mode 100644 index 0000000000..55f8b5c088 --- /dev/null +++ b/password_security/post_install.py @@ -0,0 +1,17 @@ +# Copyright 2024 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +# to understand why we created this init script, read the comment in res_config_settings.py +def init_config_parameters(env): + defaultvalues = { + "password_security.expiration_days": 60, + "password_security.minimum_hours": 24, + "password_security.history": 30, + "password_security.lower": 1, + "password_security.upper": 1, + "password_security.numeric": 1, + "password_security.special": 1, + } + for key, value in defaultvalues.items(): + env["ir.config_parameter"].set_param(key, value) diff --git a/password_security/tests/__init__.py b/password_security/tests/__init__.py index 5263d51b80..22c54b7ed0 100644 --- a/password_security/tests/__init__.py +++ b/password_security/tests/__init__.py @@ -4,4 +4,3 @@ from . import test_password_history from . import test_reset_password from . import test_signup -from . import test_migration diff --git a/password_security/tests/test_change_password.py b/password_security/tests/test_change_password.py index 3793c5d748..429160a1e7 100644 --- a/password_security/tests/test_change_password.py +++ b/password_security/tests/test_change_password.py @@ -92,8 +92,8 @@ def test_04_change_password_check_password_history(self): """It should fail when chosen password was previously used""" # Set password history limit + self.env["ir.config_parameter"].sudo().set_param("password_security.history", 3) user = self.env["res.users"].search([("login", "=", "admin")], limit=1) - user.company_id.password_history = 3 self.assertEqual(len(user.password_history_ids), 0) # Change password: password history records created diff --git a/password_security/tests/test_login.py b/password_security/tests/test_login.py index 1059592b58..341986b0cc 100644 --- a/password_security/tests/test_login.py +++ b/password_security/tests/test_login.py @@ -85,7 +85,9 @@ def test_05_web_login_expire_pass(self): env = self.env(cr) user = env["res.users"].search([("login", "=", self.username)]) user.password_write_date = three_days_ago - user.company_id.password_expiration = 1 + self.env["ir.config_parameter"].sudo().set_param( + "password_security.expiration_days", 1 + ) # Try to log in response = self.login(self.username, self.passwd) @@ -110,7 +112,9 @@ def test_06_web_login_log_out_if_expired(self): env = self.env(cr) user = env["res.users"].search([("login", "=", self.username)]) user.password_write_date = three_days_ago - user.company_id.password_expiration = 1 + self.env["ir.config_parameter"].sudo().set_param( + "password_security.expiration_days", 1 + ) # Try to access just a page req_page1 = self.url_open("/web") diff --git a/password_security/tests/test_migration.py b/password_security/tests/test_migration.py deleted file mode 100644 index be1bcf42e0..0000000000 --- a/password_security/tests/test_migration.py +++ /dev/null @@ -1,33 +0,0 @@ -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo.modules.migration import load_script -from odoo.tests.common import TransactionCase - - -class PasswordSecurityMigration(TransactionCase): - def test_01_migration(self): - """Test the migration of the password_length value into minlength""" - - # minlength has default value - ICP = self.env["ir.config_parameter"] - old_value = ICP.get_param("auth_password_policy.minlength") - - if self.env["res.company"]._fields.get("password_length"): - # set different password_length for multiple companies - company1 = self.env["res.company"].create({"name": "company1"}) - company2 = self.env["res.company"].create({"name": "company2"}) - company3 = self.env["res.company"].create({"name": "company3"}) - company1.password_length = 8 - company2.password_length = 15 - company3.password_length = 11 - - # run migration script - mod = load_script( - "password_security/migrations/16.0.1.0.0/pre-migration.py", - "pre-migration", - ) - mod.migrate(self.env.cr, "16.0.1.0.0") - - # minlength updated to maximum value - new_value = ICP.get_param("auth_password_policy.minlength") - self.assertNotEqual(int(old_value), 15) - self.assertEqual(int(new_value), 15) diff --git a/password_security/tests/test_password_history.py b/password_security/tests/test_password_history.py index be8fe30563..96cbbb3c4b 100644 --- a/password_security/tests/test_password_history.py +++ b/password_security/tests/test_password_history.py @@ -10,15 +10,12 @@ def test_check_password_history(self): set_param = self.env["ir.config_parameter"].sudo().set_param set_param("auth_password_policy.minlength", 0) user = self.env.ref("base.user_admin") - user.company_id.update( - { - "password_lower": 0, - "password_history": 1, - "password_numeric": 0, - "password_special": 0, - "password_upper": 0, - } - ) + param = self.env["ir.config_parameter"].sudo() + param.set_param("password_security.history", 1) + param.set_param("password_security.lower", 0) + param.set_param("password_security.numeric", 0) + param.set_param("password_security.special", 0) + param.set_param("password_security.upper", 0) self.assertEqual(len(user.password_history_ids), 0) @@ -30,7 +27,7 @@ def test_check_password_history(self): user.write({"password": "admit"}) self.assertEqual(len(user.password_history_ids), 2) - user.company_id.password_history = 2 + param.set_param("password_security.history", 2) with self.assertRaises(UserError): user.write({"password": "admin"}) with self.assertRaises(UserError): @@ -38,10 +35,10 @@ def test_check_password_history(self): user.write({"password": "badminton"}) self.assertEqual(len(user.password_history_ids), 3) - user.company_id.password_history = 0 + param.set_param("password_security.history", 0) user.write({"password": "badminton"}) self.assertEqual(len(user.password_history_ids), 4) - user.company_id.password_history = -1 + param.set_param("password_security.history", -1) with self.assertRaises(UserError): user.write({"password": "admin"}) diff --git a/password_security/tests/test_res_users.py b/password_security/tests/test_res_users.py index 8d8ea57145..8491d317ef 100644 --- a/password_security/tests/test_res_users.py +++ b/password_security/tests/test_res_users.py @@ -17,12 +17,10 @@ def setUp(self): "email": self.login, } self.password = "asdQWE123$%^" - self.main_comp = self.env.ref("base.main_company") self.vals = { "name": "User", "login": self.login, "password": self.password, - "company_id": self.main_comp.id, } self.model_obj = self.env["res.users"] @@ -143,14 +141,21 @@ def test_validate_pass_reset_allow(self): def test_validate_pass_reset_zero(self): """It should allow reset pass when <= 0""" rec_id = self._new_record() - rec_id.company_id.password_minimum = 0 + self.env["ir.config_parameter"].sudo().set_param( + "password_security.minimum_hours", 0 + ) self.assertEqual( True, rec_id._validate_pass_reset(), ) def test_underscore_is_special_character(self): - self.assertTrue(self.main_comp.password_special) + password_special = int( + self.env["ir.config_parameter"] + .sudo() + .get_param("password_security.special", default=1) + ) + self.assertTrue(password_special) rec_id = self._new_record() rec_id._check_password("asdQWE12345_3") diff --git a/password_security/tests/test_reset_password.py b/password_security/tests/test_reset_password.py index 62da5ed788..04b54ac897 100644 --- a/password_security/tests/test_reset_password.py +++ b/password_security/tests/test_reset_password.py @@ -39,7 +39,10 @@ def reset_password(self, username): def test_01_reset_password_fail(self): """It should fail when reset password below Minimum Hours""" # Enable check on Minimum Hours - self.env.company.password_minimum = 24 + min_hours = 24 + self.env["ir.config_parameter"].sudo().set_param( + "password_security.minimum_hours", min_hours + ) # Reset password response = self.reset_password("jackoneill") @@ -49,8 +52,7 @@ def test_01_reset_password_fail(self): self.assertEqual(response.status_code, 200) self.assertIn( "Passwords can only be reset every %s hour(s). " - "Please contact an administrator for assistance." - % self.env.company.password_minimum, + "Please contact an administrator for assistance." % min_hours, response.text, ) @@ -58,7 +60,9 @@ def test_02_reset_password_success(self): """It should succeed when check on Minimum Hours is disabled""" # Disable check on Minimum Hours - self.env.company.password_minimum = 0 + self.env["ir.config_parameter"].sudo().set_param( + "password_security.minimum_hours", 0 + ) # Reset password response = self.reset_password("jackoneill") @@ -74,7 +78,9 @@ def test_02_reset_password_success(self): def test_03_reset_password_admin(self): """It should succeed when reset password is executed by Admin""" # Enable check on Minimum Hours - self.env.company.password_minimum = 24 + self.env["ir.config_parameter"].sudo().set_param( + "password_security.minimum_hours", 24 + ) # Executed by Admin: no error is raised self.assertTrue(self.env.user._is_admin())