diff --git a/password_security/README.rst b/password_security/README.rst index eff705daec..9f832644ec 100644 --- a/password_security/README.rst +++ b/password_security/README.rst @@ -39,6 +39,7 @@ It contains features such as * Password minimum number of uppercase letters * Password minimum number of numbers * Password minimum number of special characters +* Password strength estimation **Table of contents** @@ -70,6 +71,7 @@ These are defined at the company level: password_special 0 Minimum number of unique special character in password password_history 30 Disallow reuse of this many previous passwords password_minimum 24 Amount of hours that must pass until another reset + password_estimate 3 Required score for the strength estimation. ===================== ======= =================================================== Usage diff --git a/password_security/__manifest__.py b/password_security/__manifest__.py index 6b1391d6cd..9dd5d08391 100644 --- a/password_security/__manifest__.py +++ b/password_security/__manifest__.py @@ -19,6 +19,9 @@ "auth_password_policy_signup", ], "website": "https://github.com/OCA/server-auth", + "external_dependencies": { + "python": ["zxcvbn"], + }, "license": "LGPL-3", "data": [ "views/res_config_settings_views.xml", diff --git a/password_security/models/res_company.py b/password_security/models/res_company.py index 031563efdc..661bae5879 100644 --- a/password_security/models/res_company.py +++ b/password_security/models/res_company.py @@ -2,7 +2,8 @@ # Copyright 2017 Kaushal Prajapati . # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). -from odoo import fields, models +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError class ResCompany(models.Model): @@ -33,6 +34,11 @@ class ResCompany(models.Model): default=1, help="Require number of unique special characters", ) + password_estimate = fields.Integer( + "Estimation", + default=3, + help="Required score for the strength estimation. Between 0 and 4", + ) password_history = fields.Integer( "History", default=30, @@ -44,3 +50,8 @@ class ResCompany(models.Model): default=24, help="Amount of hours until a user may change password again", ) + + @api.constrains("password_estimate") + def _check_password_estimate(self): + if self.password_estimate < 0 or self.password_estimate > 4: + raise ValidationError(_("The estimation must be between 0 and 4.")) diff --git a/password_security/models/res_config_settings.py b/password_security/models/res_config_settings.py index 3bc6c93052..3d5c0a5060 100644 --- a/password_security/models/res_config_settings.py +++ b/password_security/models/res_config_settings.py @@ -23,3 +23,6 @@ class ResConfigSettings(models.TransientModel): password_special = fields.Integer( related="company_id.password_special", readonly=False ) + password_estimate = fields.Integer( + related="company_id.password_estimate", readonly=False + ) diff --git a/password_security/models/res_users.py b/password_security/models/res_users.py index 8a5213e4b6..50514cf218 100644 --- a/password_security/models/res_users.py +++ b/password_security/models/res_users.py @@ -3,12 +3,24 @@ # Copyright 2018 Modoolar . # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import logging import re from datetime import datetime, timedelta from odoo import _, api, fields, models from odoo.exceptions import UserError, ValidationError +_logger = logging.getLogger(__name__) +try: + import zxcvbn + + zxcvbn.feedback._ = _ +except ImportError: + _logger.debug( + "Could not import zxcvbn. Please make sure this library is available" + " in your environment." + ) + def delta_now(**kwargs): return datetime.now() + timedelta(**kwargs) @@ -42,6 +54,7 @@ def get_password_policy(self): "password_upper": company_id.password_upper, "password_numeric": company_id.password_numeric, "password_special": company_id.password_special, + "password_estimate": company_id.password_estimate, } ) return data @@ -56,6 +69,10 @@ def _check_password_policy(self, passwords): return result + @api.model + def get_estimation(self, password): + return zxcvbn.zxcvbn(password) + def password_match_message(self): self.ensure_one() company_id = self.company_id @@ -114,6 +131,13 @@ def _check_password_rules(self, password): if not re.search("".join(password_regex), password): raise ValidationError(self.password_match_message()) + estimation = self.get_estimation(password) + if estimation["score"] < company_id.password_estimate: + if estimation["feedback"]["warning"]: + raise UserError(estimation["feedback"]["warning"]) + else: + raise UserError(_("Choose a stronger password!")) + return True def _password_has_expired(self): diff --git a/password_security/readme/CONFIGURE.rst b/password_security/readme/CONFIGURE.rst index a9464d271a..c7ce2cb051 100644 --- a/password_security/readme/CONFIGURE.rst +++ b/password_security/readme/CONFIGURE.rst @@ -20,4 +20,5 @@ These are defined at the company level: password_special 0 Minimum number of unique special character in password password_history 30 Disallow reuse of this many previous passwords password_minimum 24 Amount of hours that must pass until another reset + password_estimate 3 Required score for the strength estimation. ===================== ======= =================================================== diff --git a/password_security/readme/DESCRIPTION.rst b/password_security/readme/DESCRIPTION.rst index af66c0eefd..419802680d 100644 --- a/password_security/readme/DESCRIPTION.rst +++ b/password_security/readme/DESCRIPTION.rst @@ -9,3 +9,4 @@ It contains features such as * Password minimum number of uppercase letters * Password minimum number of numbers * Password minimum number of special characters +* Password strength estimation diff --git a/password_security/tests/__init__.py b/password_security/tests/__init__.py index 5263d51b80..be4580d194 100644 --- a/password_security/tests/__init__.py +++ b/password_security/tests/__init__.py @@ -1,5 +1,6 @@ from . import test_change_password from . import test_res_users +from . import test_res_config_settings from . import test_login from . import test_password_history from . import test_reset_password diff --git a/password_security/tests/test_password_history.py b/password_security/tests/test_password_history.py index be8fe30563..170b56d36c 100644 --- a/password_security/tests/test_password_history.py +++ b/password_security/tests/test_password_history.py @@ -12,6 +12,7 @@ def test_check_password_history(self): user = self.env.ref("base.user_admin") user.company_id.update( { + "password_estimate": 0, "password_lower": 0, "password_history": 1, "password_numeric": 0, diff --git a/password_security/tests/test_res_config_settings.py b/password_security/tests/test_res_config_settings.py new file mode 100644 index 0000000000..4bdff2b59a --- /dev/null +++ b/password_security/tests/test_res_config_settings.py @@ -0,0 +1,24 @@ +# Copyright 2023 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + + +class TestConfigSettings(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.config = cls.env["res.config.settings"].create({}) + + def test_01_password_estimate_range(self): + """The estimation must be between 0 and 4""" + self.config.password_estimate = 0 + self.config.password_estimate = 2 + self.config.password_estimate = 4 + + with self.assertRaises(ValidationError): + self.config.password_estimate = 5 + + with self.assertRaises(ValidationError): + self.config.password_estimate = -1 diff --git a/password_security/views/res_config_settings_views.xml b/password_security/views/res_config_settings_views.xml index 4fb6816d2c..d591849fb8 100644 --- a/password_security/views/res_config_settings_views.xml +++ b/password_security/views/res_config_settings_views.xml @@ -85,12 +85,18 @@ -
- - Minimum number of characters - -
- +
+ + Minimum number of characters + +
+
+ + Minimum number of strength estimation + + +
+ diff --git a/requirements.txt b/requirements.txt index 6d3071a6ea..8b361aed01 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ pyjwt pysaml2 python-jose python-ldap +zxcvbn