diff --git a/password_security/README.rst b/password_security/README.rst new file mode 100644 index 0000000000..110b17538e --- /dev/null +++ b/password_security/README.rst @@ -0,0 +1,140 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================= +Password Security +================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--auth-lightgray.png?logo=github + :target: https://github.com/OCA/server-auth/tree/19.0/password_security + :alt: OCA/server-auth +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-auth-19-0/server-auth-19-0-password_security + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/server-auth&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows admin to set company-level password security +requirements and enforces them on the user. + +It contains features such as + +- Password expiration days +- Password length requirement +- Password minimum number of lowercase letters +- Password minimum number of uppercase letters +- Password minimum number of numbers +- Password minimum number of special characters + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Navigate to General Settings under Configuration Scroll down to the +``Password Policy`` section Set the policies to your liking. + +Password complexity requirements will be enforced upon next password +change for any user in that company. + +**Settings & Defaults** + +These are defined at the company level: + ++------------------------------+------------------+ +| Parameter | Default value | ++==============================+==================+ +| Password expiration (days) | 60 | ++------------------------------+------------------+ +| Minimum hours before reset | 24 | ++------------------------------+------------------+ +| Password history | 30 | ++------------------------------+------------------+ +| Lowercase characters | 1 | ++------------------------------+------------------+ +| Uppercase characters | 1 | ++------------------------------+------------------+ +| Numeric characters | 1 | ++------------------------------+------------------+ +| Special characters | 1 | ++------------------------------+------------------+ + +Usage +===== + +Configure using above instructions for each company that should have +password security mandates. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* LasLabs + +Contributors +~~~~~~~~~~~~ + +- James Foster +- Dave Lasley +- Kaushal Prajapati +- Petar Najman +- Shepilov Vladislav +- Florian Kantelberg +- Carlos Jimeno +- Dhara Solanki +- `Open Source Integrators `_: + + - Chandresh Thakkar + - Daniel Reis + +- `Onestein `_: + + - Andrea Stirpe + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/server-auth `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/password_security/__init__.py b/password_security/__init__.py new file mode 100644 index 0000000000..86710ee5be --- /dev/null +++ b/password_security/__init__.py @@ -0,0 +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 new file mode 100644 index 0000000000..5b75100d1b --- /dev/null +++ b/password_security/__manifest__.py @@ -0,0 +1,34 @@ +# Copyright 2015 LasLabs Inc. +# Copyright 2018 Modoolar . +# Copyright 2019 initOS GmbH +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +{ + "name": "Password Security", + "summary": "Allow admin to set password security requirements.", + "version": "19.0.1.0.0", + "author": "LasLabs, " + "Onestein, " + "Kaushal Prajapati, " + "Tecnativa, " + "initOS GmbH, " + "Omar Nasr, " + "Odoo Community Association (OCA)", + "category": "Base", + "depends": [ + "auth_signup", + "auth_password_policy_signup", + "auth_totp", + ], + "website": "https://github.com/OCA/server-auth", + "license": "LGPL-3", + "data": [ + "views/res_config_settings_views.xml", + "security/ir.model.access.csv", + "security/res_users_pass_history.xml", + ], + "demo": [ + "demo/res_users.xml", + ], + "post_init_hook": "init_config_parameters", + "installable": True, +} diff --git a/password_security/controllers/__init__.py b/password_security/controllers/__init__.py new file mode 100644 index 0000000000..93d5306411 --- /dev/null +++ b/password_security/controllers/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2015 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from . import main +from . import home diff --git a/password_security/controllers/home.py b/password_security/controllers/home.py new file mode 100644 index 0000000000..06aafccf8b --- /dev/null +++ b/password_security/controllers/home.py @@ -0,0 +1,23 @@ +# Copyright 2022 brain-tec AG (https://bt-group.com) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). + +from odoo import http +from odoo.http import request + +from odoo.addons.auth_totp.controllers.home import Home + + +class PasswordSecurity2FAHome(Home): + @http.route() + def web_totp(self, redirect=None, **kwargs): + already_logged_in = request.session.uid + result = super().web_totp(redirect, **kwargs) + if already_logged_in or not ( + request.session.uid and request.env.user._password_has_expired() + ): + return result + # Mot de passe expiré : déconnexion forcée + request.env.user.action_expire_password() + request.session.logout(keep_db=True) + redirect = request.env.user.partner_id._get_signup_url() + return request.redirect(redirect) diff --git a/password_security/controllers/main.py b/password_security/controllers/main.py new file mode 100644 index 0000000000..7b86dbdcd7 --- /dev/null +++ b/password_security/controllers/main.py @@ -0,0 +1,59 @@ +# Copyright 2015 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import logging + +from werkzeug.exceptions import BadRequest + +from odoo import http +from odoo.http import request + +from odoo.addons.auth_signup.controllers.main import AuthSignupHome +from odoo.addons.web.controllers.home import ensure_db + +_logger = logging.getLogger(__name__) + + +class PasswordSecurityHome(AuthSignupHome): + def do_signup(self, qcontext): + password = qcontext.get("password") + user = request.env.user + user._check_password(password) + return super().do_signup(qcontext) + + @http.route() + def web_login(self, *args, **kw): + ensure_db() + response = super().web_login(*args, **kw) + if not request.params.get("login_success"): + return response + if not request.env.user: + return response + # Utilisateur authentifié — avec 2FA, une seconde étape est nécessaire + if not (request.session.uid and request.env.user._password_has_expired()): + return response + # Mot de passe expiré : déconnexion forcée + request.env.user.action_expire_password() + request.session.logout(keep_db=True) + request.params["login_success"] = False + redirect = request.env.user.partner_id._get_signup_url() + return request.redirect(redirect) + + @http.route() + def web_auth_signup(self, *args, **kw): + """Intercepte toutes les exceptions non gérées par la méthode parente.""" + + try: + qcontext = self.get_auth_signup_qcontext() + except Exception: + raise BadRequest from None # HTTPError: 400 Client Error: BAD REQUEST + + try: + return super().web_auth_signup(*args, **kw) + except Exception as e: + # UserError est déjà géré par la méthode parente web_auth_signup() + qcontext["error"] = str(e) + response = request.render("auth_signup.signup", qcontext) + response.headers["X-Frame-Options"] = "SAMEORIGIN" + response.headers["Content-Security-Policy"] = "frame-ancestors 'self'" + return response diff --git a/password_security/demo/res_users.xml b/password_security/demo/res_users.xml new file mode 100644 index 0000000000..e095e9959d --- /dev/null +++ b/password_security/demo/res_users.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/password_security/models/__init__.py b/password_security/models/__init__.py new file mode 100644 index 0000000000..a5464bb83b --- /dev/null +++ b/password_security/models/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2015 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from . import res_config_settings +from . import res_users +from . import res_users_pass_history diff --git a/password_security/models/res_config_settings.py b/password_security/models/res_config_settings.py new file mode 100644 index 0000000000..311eefd7f9 --- /dev/null +++ b/password_security/models/res_config_settings.py @@ -0,0 +1,64 @@ +# Copyright 2018 Modoolar +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo import fields, models + + +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( + string="Days", + default=0, + config_parameter="password_security.expiration_days", + help="How many days until passwords expire", + ) + password_minimum = fields.Integer( + 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( + 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_numeric = fields.Integer( + string="Numeric", + default=0, + config_parameter="password_security.numeric", + help="Require number of numeric digits", + ) + password_special = fields.Integer( + 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 new file mode 100644 index 0000000000..377caef796 --- /dev/null +++ b/password_security/models/res_users.py @@ -0,0 +1,225 @@ +# Copyright 2016 LasLabs Inc. +# Copyright 2017 Kaushal Prajapati . +# Copyright 2018 Modoolar . +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import re +from datetime import datetime, timedelta + +from odoo import api, fields, models +from odoo.exceptions import UserError, ValidationError + + +def delta_now(**kwargs): + return datetime.now() + timedelta(**kwargs) + + +class ResUsers(models.Model): + _inherit = "res.users" + + password_write_date = fields.Datetime( + "Last password update", default=fields.Datetime.now, readonly=True + ) + password_history_ids = fields.One2many( + string="Password History", + comodel_name="res.users.pass.history", + inverse_name="user_id", + readonly=True, + ) + + def write(self, vals): + if vals.get("password"): + 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() + pwd_params = self._get_all_password_params() + data.update( + { + "password_lower": pwd_params["lower"], + "password_upper": pwd_params["upper"], + "password_numeric": pwd_params["numeric"], + "password_special": pwd_params["special"], + } + ) + return data + + def _check_password_policy(self, passwords): + result = super()._check_password_policy(passwords) + + for password in passwords: + if not password: + continue + self._check_password(password) + + return result + + def password_match_message(self): + self.ensure_one() + message = [] + pwd_params = self._get_all_password_params() + if pwd_params["lower"]: + message.append( + self.env._( + "\n* Lowercase letter (at least %s characters)", pwd_params["lower"] + ) + ) + if pwd_params["upper"]: + message.append( + self.env._( + "\n* Uppercase letter (at least %s characters)", pwd_params["upper"] + ) + ) + if pwd_params["numeric"]: + message.append( + self.env._( + "\n* Numeric digit (at least %s characters)", pwd_params["numeric"] + ) + ) + if pwd_params["special"]: + message.append( + self.env._( + "\n* Special character (at least %s characters)", + pwd_params["special"], + ) + ) + if message: + message = [self.env._("Must contain the following:")] + message + + if pwd_params["minlength"]: + message = [ + self.env._( + "Password must be %d characters or more.", pwd_params["minlength"] + ) + ] + message + return "\r".join(message) + + def _check_password(self, password): + self._check_password_rules(password) + self._check_password_history(password) + return True + + def _check_password_rules(self, password): + self.ensure_one() + if not password: + return True + pwd_params = self._get_all_password_params() + password_regex = [ + "^", + "(?=.*?[a-z]){" + str(pwd_params["lower"]) + ",}", + "(?=.*?[A-Z]){" + str(pwd_params["upper"]) + ",}", + "(?=.*?\\d){" + str(pwd_params["numeric"]) + ",}", + r"(?=.*?[\W_]){" + str(pwd_params["special"]) + ",}", + f".{{{pwd_params['minlength']},}}$", + ] + if not re.search("".join(password_regex), password): + raise ValidationError(self.password_match_message()) + + return True + + def _password_has_expired(self): + self.ensure_one() + if not self.password_write_date: + return True + + 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 > pwd_params["expiration_days"] + + def action_expire_password(self): + for user in self: + user.mapped("partner_id").signup_prepare(signup_type="reset") + + def _validate_pass_reset(self): + """Validations avant de lancer l'envoi de l'email de réinitialisation. + + :raises: UserError si la réinitialisation n'est pas autorisée + :return: True si la réinitialisation est autorisée + """ + pwd_params = self._get_all_password_params() + for user in self: + if pwd_params["minimum_hours"] <= 0: + continue + write_date = user.password_write_date + delta = timedelta(hours=pwd_params["minimum_hours"]) + if write_date + delta > datetime.now(): + raise UserError( + self.env._( + "Passwords can only be reset every %d hour(s). " + "Please contact an administrator for assistance.", + pwd_params["minimum_hours"], + ) + ) + return True + + def _check_password_history(self, password): + """Valide le mot de passe proposé par rapport à l'historique. + + :raises: UserError si le mot de passe a déjà été utilisé + """ + crypt = self._crypt_context() + pwd_params = self._get_all_password_params() + for user in self: + if not pwd_params["history"]: # désactivé + recent_passes = self.env["res.users.pass.history"] + elif pwd_params["history"] < 0: # illimité + recent_passes = user.password_history_ids + else: + recent_passes = user.password_history_ids[: pwd_params["history"]] + if recent_passes.filtered( + lambda r: crypt.verify(password, r.password_crypt) + ): + raise UserError( + self.env._( + "Cannot use the most recent %d passwords", + pwd_params["history"], + ) + ) + + def _set_encrypted_password(self, uid, pw): + """Sauvegarde le hash du mot de passe dans l'historique.""" + res = super()._set_encrypted_password(uid, pw) + + self.env["res.users.pass.history"].create( + { + "user_id": uid, + "password_crypt": pw, + } + ) + return res + + def action_reset_password(self): + """Interdit la réinitialisation du mot de passe dans le délai minimum.""" + if not self.env.context.get("install_mode") and not self.env.context.get( + "create_user" + ): + if not self.env.user._is_admin(): + users = self.filtered(lambda user: user.active) + users._validate_pass_reset() + return super().action_reset_password() diff --git a/password_security/models/res_users_pass_history.py b/password_security/models/res_users_pass_history.py new file mode 100644 index 0000000000..f04c4a1b76 --- /dev/null +++ b/password_security/models/res_users_pass_history.py @@ -0,0 +1,25 @@ +# Copyright 2016 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo import fields, models + + +class ResUsersPassHistory(models.Model): + _name = "res.users.pass.history" + _description = "Res Users Password History" + + _order = "user_id, date desc, id desc" + + user_id = fields.Many2one( + string="User", + comodel_name="res.users", + ondelete="cascade", + index=True, + ) + password_crypt = fields.Char( + string="Encrypted Password", + ) + date = fields.Datetime( + default=lambda s: fields.Datetime.now(), + index=True, + ) diff --git a/password_security/post_install.py b/password_security/post_install.py new file mode 100644 index 0000000000..1df798c2c4 --- /dev/null +++ b/password_security/post_install.py @@ -0,0 +1,18 @@ +# 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/pyproject.toml b/password_security/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/password_security/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/password_security/readme/CONFIGURE.md b/password_security/readme/CONFIGURE.md new file mode 100644 index 0000000000..77ebcb88a6 --- /dev/null +++ b/password_security/readme/CONFIGURE.md @@ -0,0 +1,11 @@ +Navigate to General Settings under Configuration Scroll down to the +`Password Policy` section Set the policies to your liking. + +Password complexity requirements will be enforced upon next password +change for any user in that company. + +**Settings & Defaults** + +These are defined at the company level: + +[TABLE] diff --git a/password_security/readme/CONTRIBUTORS.md b/password_security/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..0844e75fea --- /dev/null +++ b/password_security/readme/CONTRIBUTORS.md @@ -0,0 +1,23 @@ +- James Foster \<\> + +- Dave Lasley \<\> + +- Kaushal Prajapati \<\> + +- Petar Najman \<\> + +- Shepilov Vladislav \<\> + +- Florian Kantelberg \<\> + +- Carlos Jimeno \<\> + +- Dhara Solanki \<\> + +- [Open Source Integrators](https://opensourceintegrators.com) + + > - Chandresh Thakkar \<\> + > - Daniel Reis \<\> + +- [Onestein](https://www.onestein.nl): + - Andrea Stirpe \<\> diff --git a/password_security/readme/DESCRIPTION.md b/password_security/readme/DESCRIPTION.md new file mode 100644 index 0000000000..dfb72e8926 --- /dev/null +++ b/password_security/readme/DESCRIPTION.md @@ -0,0 +1,11 @@ +This module allows admin to set company-level password security +requirements and enforces them on the user. + +It contains features such as + +- Password expiration days +- Password length requirement +- Password minimum number of lowercase letters +- Password minimum number of uppercase letters +- Password minimum number of numbers +- Password minimum number of special characters diff --git a/password_security/readme/USAGE.md b/password_security/readme/USAGE.md new file mode 100644 index 0000000000..0cc766664c --- /dev/null +++ b/password_security/readme/USAGE.md @@ -0,0 +1,2 @@ +Configure using above instructions for each company that should have +password security mandates. diff --git a/password_security/security/ir.model.access.csv b/password_security/security/ir.model.access.csv new file mode 100644 index 0000000000..6dafcf7511 --- /dev/null +++ b/password_security/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_res_users_pass_history,access_res_users_pass_history,model_res_users_pass_history,base.group_user,1,0,1,0 +access_res_users_pass_history_portal,access_res_users_pass_history portal,model_res_users_pass_history,base.group_portal,1,0,1,0 diff --git a/password_security/security/res_users_pass_history.xml b/password_security/security/res_users_pass_history.xml new file mode 100644 index 0000000000..9a8668338f --- /dev/null +++ b/password_security/security/res_users_pass_history.xml @@ -0,0 +1,31 @@ + + + + + Res Users Pass History Access + + + + [ + ('user_id', '=', user.id) + ] + + + + Res Users Pass History Access + 0 + 0 + 1 + 0 + + + + [ + (1, '=', 1) + ] + + + diff --git a/password_security/static/description/icon.png b/password_security/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/password_security/static/description/icon.png differ diff --git a/password_security/tests/__init__.py b/password_security/tests/__init__.py new file mode 100644 index 0000000000..8b6d601f42 --- /dev/null +++ b/password_security/tests/__init__.py @@ -0,0 +1,7 @@ +from . import test_change_password +from . import test_res_users +from . import test_login +from . import test_password_history +from . import test_reset_password +from . import test_signup +from . import test_totp diff --git a/password_security/tests/test_change_password.py b/password_security/tests/test_change_password.py new file mode 100644 index 0000000000..7d23de3120 --- /dev/null +++ b/password_security/tests/test_change_password.py @@ -0,0 +1,27 @@ +# Copyright 2023 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo.exceptions import UserError, ValidationError +from odoo.tests.common import TransactionCase, new_test_user + + +class TestChangePassword(TransactionCase): + def setUp(self): + super().setUp() + self.username = "jackoneill" + self.passwd = "!asdQWE12345_3" + self.user = new_test_user(self.env, self.username, password=self.passwd) + + def test_01_change_password_fail(self): + """Doit échouer si le nouveau mot de passe est trop faible.""" + with self.assertRaises(ValidationError): + self.user.password = "jackoneill" + + def test_02_change_password_success(self): + """Doit réussir avec un mot de passe fort.""" + self.user.password = "!asdQWE12345_4" + + def test_03_change_password_history(self): + """Doit interdire la réutilisation d'un ancien mot de passe.""" + with self.assertRaises(UserError): + self.user.password = self.passwd diff --git a/password_security/tests/test_login.py b/password_security/tests/test_login.py new file mode 100644 index 0000000000..51cee07254 --- /dev/null +++ b/password_security/tests/test_login.py @@ -0,0 +1,101 @@ +# Copyright 2023 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from datetime import datetime, timedelta +from unittest import mock + +from odoo import http +from odoo.exceptions import UserError, ValidationError +from odoo.modules.registry import Registry +from odoo.tests.common import HOST, HttpCase, Opener, get_db_name, new_test_user, tagged + + +@tagged("-at_install", "post_install") +class TestPasswordSecurityLogin(HttpCase): + def setUp(self): + super().setUp() + self.username = "jackoneill" + self.passwd = "!asdQWE12345_3" + + new_test_user(self.env, self.username, password=self.passwd) + + def login(self, username, password): + """Authentification avec les identifiants fournis.""" + self.session = http.root.session_store.new() + # Odoo 19 : Opener prend l'instance HttpCase, pas un cursor + self.opener = Opener(self) + self.opener.cookies.set("session_id", self.session.sid, domain=HOST, path="/") + + with mock.patch("odoo.http.db_filter") as db_filter: + db_filter.side_effect = lambda dbs, host=None: [get_db_name()] + res_post = self.url_open( + "/web/login", + data={ + "login": username, + "password": password, + "csrf_token": http.Request.csrf_token(self), + }, + ) + res_post.raise_for_status() + + return res_post + + def test_01_create_user_fail(self): + """Doit échouer lors de la création avec un mot de passe trop court.""" + with self.assertRaises(UserError): + new_test_user(self.env, "new_user", password="abc") + + def test_02_create_user_fail(self): + """Doit échouer lors de la création avec un mot de passe faible.""" + with self.assertRaises(ValidationError): + new_test_user(self.env, "new_user", password="abcdefgh") + + def test_03_web_login_success(self): + """Doit permettre l'authentification.""" + response = self.login(self.username, self.passwd) + self.assertEqual(response.request.path_url, "/odoo") + self.assertEqual(response.status_code, 200) + + def test_04_web_login_fail(self): + """Doit échouer avec un mauvais mot de passe.""" + response = self.login(self.username, "wrong") + self.assertEqual(response.request.path_url, "/web/login") + self.assertEqual(response.status_code, 200) + self.assertIn( + "Wrong login/password", + response.text, + ) + + def test_05_web_login_expire_pass(self): + """Doit expirer le mot de passe si nécessaire.""" + three_days_ago = datetime.now() - timedelta(days=3) + + with Registry(get_db_name()).cursor() as cr: + env = self.env(cr) + user = env["res.users"].search([("login", "=", self.username)]) + user.password_write_date = three_days_ago + self.env["ir.config_parameter"].sudo().set_param( + "password_security.expiration_days", 1 + ) + + response = self.login(self.username, self.passwd) + self.assertIn("/web/reset_password", response.request.path_url) + + def test_06_web_login_log_out_if_expired(self): + """Doit déconnecter l'utilisateur si le mot de passe a expiré.""" + response = self.login(self.username, self.passwd) + self.assertEqual(response.request.path_url, "/odoo") + self.assertEqual(response.status_code, 200) + + three_days_ago = datetime.now() - timedelta(days=3) + + with Registry(get_db_name()).cursor() as cr: + env = self.env(cr) + user = env["res.users"].search([("login", "=", self.username)]) + user.password_write_date = three_days_ago + self.env["ir.config_parameter"].sudo().set_param( + "password_security.expiration_days", 1 + ) + + response = self.login(self.username, self.passwd) + self.assertIn("/web/reset_password", response.request.path_url) diff --git a/password_security/tests/test_password_history.py b/password_security/tests/test_password_history.py new file mode 100644 index 0000000000..52871c3034 --- /dev/null +++ b/password_security/tests/test_password_history.py @@ -0,0 +1,42 @@ +# Copyright 2023 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase, new_test_user + + +class TestPasswordHistory(TransactionCase): + def setUp(self): + super().setUp() + self.username = "jackoneill" + self.passwd = "!asdQWE12345_3" + self.user = new_test_user(self.env, self.username, password=self.passwd) + + def test_01_history_is_saved(self): + """Doit sauvegarder l'historique des mots de passe.""" + self.assertEqual(len(self.user.password_history_ids), 1) + self.user.password = "!asdQWE12345_4" + self.user.invalidate_recordset() + self.assertEqual(len(self.user.password_history_ids), 2) + + def test_02_history_is_limited(self): + """Doit limiter la vérification à l'historique configuré.""" + self.env["ir.config_parameter"].sudo().set_param("password_security.history", 1) + self.user.password = "!asdQWE12345_4" + # Forcer le rafraîchissement du cache ORM après _set_encrypted_password + self.user.invalidate_recordset() + # Le premier mot de passe n'est plus dans l'historique actif (history=1) + self.user.password = self.passwd + + def test_03_history_disabled(self): + """Doit désactiver la vérification d'historique si history=0.""" + self.env["ir.config_parameter"].sudo().set_param("password_security.history", 0) + self.user.password = self.passwd + + def test_04_history_unlimited(self): + """Doit vérifier tout l'historique si history=-1.""" + self.env["ir.config_parameter"].sudo().set_param( + "password_security.history", -1 + ) + with self.assertRaises(UserError): + self.user.password = self.passwd diff --git a/password_security/tests/test_res_users.py b/password_security/tests/test_res_users.py new file mode 100644 index 0000000000..5392c77238 --- /dev/null +++ b/password_security/tests/test_res_users.py @@ -0,0 +1,168 @@ +# Copyright 2015 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import time + +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase + + +class TestResUsers(TransactionCase): + def setUp(self): + super().setUp() + self.login = "foslabs@example.com" + self.partner_vals = { + "name": "Partner", + "is_company": False, + "email": self.login, + } + self.password = "asdQWE123$%^" + self.vals = { + "name": "User", + "login": self.login, + "password": self.password, + } + self.model_obj = self.env["res.users"] + + def _new_record(self): + partner_id = self.env["res.partner"].create(self.partner_vals) + self.vals["partner_id"] = partner_id.id + return self.model_obj.create(self.vals) + + def test_password_write_date_is_saved_on_create(self): + rec_id = self._new_record() + self.assertTrue( + rec_id.password_write_date, + "Password write date was not saved to db.", + ) + + def test_password_write_date_is_updated_on_write(self): + rec_id = self._new_record() + old_write_date = rec_id.password_write_date + time.sleep(2) + rec_id.write({"password": "asdQWE123$%^2"}) + rec_id.invalidate_recordset() + new_write_date = rec_id.password_write_date + self.assertNotEqual( + old_write_date, + new_write_date, + "Password write date was not updated on write.", + ) + + def test_does_not_update_write_date_if_password_unchanged(self): + rec_id = self._new_record() + old_write_date = rec_id.password_write_date + time.sleep(2) + rec_id.write({"name": "Luser"}) + rec_id.invalidate_recordset() + new_write_date = rec_id.password_write_date + self.assertEqual( + old_write_date, + new_write_date, + "Password not changed but write date updated anyway.", + ) + + def test_check_password_returns_true_for_valid_password(self): + rec_id = self._new_record() + self.assertTrue( + rec_id._check_password("asdQWE123$%^3"), + "Password is valid but check failed.", + ) + + def test_check_password_raises_error_for_invalid_password(self): + rec_id = self._new_record() + with self.assertRaises(UserError): + rec_id._check_password("password") + + def test_save_password_crypt(self): + rec_id = self._new_record() + self.assertEqual( + 1, + len(rec_id.password_history_ids), + ) + + def test_check_password_crypt(self): + """Il doit lever UserError si le mot de passe a déjà été utilisé.""" + rec_id = self._new_record() + with self.assertRaises(UserError): + rec_id.write({"password": self.password}) + + def test_password_is_expired_if_record_has_no_write_date(self): + rec_id = self._new_record() + rec_id.write({"password_write_date": None}) + rec_id.invalidate_recordset() + self.assertTrue( + rec_id._password_has_expired(), + "Record has no password write date but check failed.", + ) + + def test_an_old_password_is_expired(self): + rec_id = self._new_record() + old_write_date = "1970-01-01 00:00:00" + rec_id.write({"password_write_date": old_write_date}) + rec_id.invalidate_recordset() + self.assertTrue( + rec_id._password_has_expired(), + "Password is out of date but check failed.", + ) + + def test_a_new_password_is_not_expired(self): + rec_id = self._new_record() + self.assertFalse( + rec_id._password_has_expired(), + "Password was just created but has already expired.", + ) + + def test_validate_pass_reset_error(self): + """Doit lever UserError si reset dans le délai minimum.""" + rec_id = self._new_record() + with self.assertRaises(UserError): + rec_id._validate_pass_reset() + + def test_validate_pass_reset_allow(self): + """Doit autoriser le reset hors délai minimum.""" + rec_id = self._new_record() + rec_id.password_write_date = "2016-01-01" + self.assertEqual( + True, + rec_id._validate_pass_reset(), + ) + + def test_validate_pass_reset_zero(self): + """Doit autoriser le reset quand minimum_hours <= 0.""" + rec_id = self._new_record() + 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): + 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") + + def test_user_with_admin_rights_can_create_users(self): + """Un utilisateur ERP Manager peut créer des utilisateurs sans mot de passe.""" + manager = self.model_obj.create( + { + "login": "test_erp_manager", + "name": "Test ERP Manager", + "password": "asdQWE123$%^", + "groups_id": [(4, self.env.ref("base.group_erp_manager").id)], + } + ) + test1 = self.model_obj.with_user(manager).create( + { + "login": "test1", + "name": "test1", + } + ) + test1.unlink() diff --git a/password_security/tests/test_reset_password.py b/password_security/tests/test_reset_password.py new file mode 100644 index 0000000000..aea76f53f6 --- /dev/null +++ b/password_security/tests/test_reset_password.py @@ -0,0 +1,91 @@ +# Copyright 2023 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from unittest import mock + +from odoo import http +from odoo.exceptions import UserError +from odoo.tests.common import HOST, HttpCase, Opener, get_db_name, new_test_user, tagged + + +@tagged("-at_install", "post_install") +class TestPasswordSecurityResetPassword(HttpCase): + def setUp(self): + super().setUp() + self.username = "jackoneill" + self.passwd = "!asdQWE12345_3" + new_test_user(self.env, self.username, password=self.passwd) + + def reset_password(self, username): + """Réinitialisation du mot de passe via le formulaire web.""" + self.session = http.root.session_store.new() + # Odoo 19 : Opener prend l'instance HttpCase, pas un cursor + self.opener = Opener(self) + self.opener.cookies.set("session_id", self.session.sid, domain=HOST, path="/") + + with mock.patch("odoo.http.db_filter") as db_filter: + db_filter.side_effect = lambda dbs, host=None: [get_db_name()] + res_post = self.url_open( + "/web/reset_password", + data={ + "login": username, + "name": username, + "csrf_token": http.Request.csrf_token(self), + }, + ) + res_post.raise_for_status() + + return res_post + + def test_01_reset_password_fail(self): + """Doit échouer si reset dans le délai minimum.""" + min_hours = 24 + self.env["ir.config_parameter"].sudo().set_param( + "password_security.minimum_hours", min_hours + ) + + response = self.reset_password(self.username) + + self.assertEqual(response.request.path_url, "/web/reset_password") + self.assertEqual(response.status_code, 200) + self.assertIn( + f"Passwords can only be reset every {min_hours} hour(s). " + "Please contact an administrator for assistance.", + response.text, + ) + + def test_02_reset_password_success(self): + """Doit réussir si la vérification du délai est désactivée.""" + self.env["ir.config_parameter"].sudo().set_param( + "password_security.minimum_hours", 0 + ) + + response = self.reset_password(self.username) + + self.assertEqual(response.request.path_url, "/web/reset_password") + self.assertEqual(response.status_code, 200) + self.assertIn( + "Password reset instructions sent to your email", + response.text, + ) + + def test_03_reset_password_admin(self): + """Doit réussir si l'admin réinitialise, échouer pour un non-admin.""" + self.env["ir.config_parameter"].sudo().set_param( + "password_security.minimum_hours", 24 + ) + + # Admin peut réinitialiser sans restriction + self.assertTrue(self.env.user._is_admin()) + self.env["res.users"].reset_password(self.username) + + # Utilisateur non-admin : erreur levée + non_admin = self.env["res.users"].create( + { + "login": "test_non_admin", + "name": "Test Non Admin", + "password": "!asdQWE12345_4", + } + ) + with self.assertRaises(UserError): + self.env["res.users"].with_user(non_admin).reset_password(self.username) diff --git a/password_security/tests/test_signup.py b/password_security/tests/test_signup.py new file mode 100644 index 0000000000..cd205b0cd0 --- /dev/null +++ b/password_security/tests/test_signup.py @@ -0,0 +1,149 @@ +# Copyright 2016 LasLabs Inc. +# Copyright 2023 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from unittest import mock + +from requests.exceptions import HTTPError + +from odoo import http +from odoo.exceptions import ValidationError +from odoo.tests.common import HOST, HttpCase, Opener, get_db_name, tagged + +from odoo.addons.auth_signup.models.res_users import SignupError + + +class EndTestException(Exception): + """Permet d'isoler les ressources en levant une exception de test.""" + + +@tagged("-at_install", "post_install") +class TestPasswordSecuritySignup(HttpCase): + def signup(self, username, password): + """Inscription d'un utilisateur via le formulaire web.""" + self.session = http.root.session_store.new() + # Odoo 19 : Opener prend l'instance HttpCase, pas un cursor + self.opener = Opener(self) + self.opener.cookies.set("session_id", self.session.sid, domain=HOST, path="/") + + with mock.patch("odoo.http.db_filter") as db_filter: + db_filter.side_effect = lambda dbs, host=None: [get_db_name()] + res_post = self.url_open( + "/web/signup", + data={ + "login": username, + "name": username, + "password": password, + "confirm_password": password, + "csrf_token": http.Request.csrf_token(self), + }, + ) + res_post.raise_for_status() + + return res_post + + def test_01_signup_user_fail(self): + """Doit échouer avec un mot de passe faible.""" + response = self.signup("jackoneill", "jackoneill") + + self.assertEqual(response.request.path_url, "/web/signup") + self.assertEqual(response.status_code, 200) + self.assertIn( + "Must contain the following:", + response.text, + ) + + def test_02_signup_user_success(self): + """Doit réussir avec un mot de passe fort.""" + response = self.signup("jackoneill", "!asdQWE12345_3") + + self.assertEqual( + response.request.path_url, "/web/login_successful?account_created=True" + ) + self.assertEqual(response.status_code, 200) + + def test_03_create_user_signup(self): + """Le mot de passe est vérifié lors de l'inscription.""" + partner = self.env["res.partner"].create({"name": "test partner"}) + vals = { + "name": "Test User", + "login": "test_user", + "email": "test_user@odoo.com", + "password": "test_user_password", + "partner_id": partner.id, + } + + with self.assertRaises(SignupError): + self.env["res.users"].signup(vals) + + vals["password"] = "asdQWE12345_3" + login, pwd = self.env["res.users"].signup(vals) + + created_user = self.env["res.users"].search([("login", "=", "test_user")]) + self.assertEqual(login, "test_user") + password_write_date = created_user.password_write_date + self.assertTrue(password_write_date) + + with self.assertRaises(ValidationError): + created_user.password = "test_user_password" + self.assertEqual(password_write_date, created_user.password_write_date) + + created_user.password = "!asdQWE12345_3" + self.assertNotEqual(password_write_date, created_user.password_write_date) + + def test_04_web_auth_signup_invalid_qcontext(self): + """Doit lever AttributeError.""" + with mock.patch("odoo.http.db_filter") as db_filter: + db_filter.side_effect = lambda dbs, host=None: [get_db_name()] + with self.assertRaises(AttributeError): + self.url_open( + "/web/signup", + data={ + "csrf_token": http.Request.csrf_token(self), + }, + ) + + def test_05_web_auth_signup_invalid_qcontext(self): + """Doit lever EndTestException sur le qcontext.""" + self.session = http.root.session_store.new() + self.opener = Opener(self) + self.opener.cookies.set("session_id", self.session.sid, domain=HOST, path="/") + + with mock.patch( + "odoo.addons.auth_signup.controllers.main.AuthSignupHome.get_auth_signup_qcontext" + ) as qcontext: + qcontext.side_effect = EndTestException + with self.assertRaises(HTTPError): + self.signup("jackoneill", "!asdQWE12345_3") + + def test_06_web_auth_signup_invalid_render(self): + """Doit afficher le formulaire d'inscription en cas d'erreur.""" + self.session = http.root.session_store.new() + self.opener = Opener(self) + self.opener.cookies.set("session_id", self.session.sid, domain=HOST, path="/") + + with mock.patch("odoo.http.db_filter") as db_filter: + db_filter.side_effect = lambda dbs, host=None: [get_db_name()] + response = self.url_open( + "/web/signup", + data={ + "login": "test@test.com", + "password": "!asdQWE12345_7", + "confirm_password": "!asdQWE12345_7", + "csrf_token": http.Request.csrf_token(self), + }, + ) + + self.assertEqual(response.request.path_url, "/web/signup") + self.assertEqual(response.status_code, 200) + self.assertIn( + "Signup: no name or partner given for new user", + response.text, + ) + self.assertIn("X-Frame-Options", response.headers) + self.assertEqual(response.headers["X-Frame-Options"], "SAMEORIGIN") + + self.assertIn("Content-Security-Policy", response.headers) + self.assertEqual( + response.headers["Content-Security-Policy"], "frame-ancestors 'self'" + ) diff --git a/password_security/tests/test_totp.py b/password_security/tests/test_totp.py new file mode 100644 index 0000000000..8ffdfe1b1c --- /dev/null +++ b/password_security/tests/test_totp.py @@ -0,0 +1,30 @@ +# Copyright 2022 Braintec AG +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + + +from odoo.tests import HttpCase, tagged + + +@tagged("post_install", "-at_install") +class TestTOTP(HttpCase): + def test_totp(self): + # Création d'un utilisateur de test (pas de données de démo en CI) + test_user = self.env["res.users"].create( + { + "login": "test_totp_user", + "name": "Test TOTP User", + "password": "!asdQWE12345_3", + } + ) + uid = test_user.id + + self.authenticate(user="test_totp_user", password="!asdQWE12345_3") + self.assertEqual(self.session.uid, uid) + + self.assertEqual(test_user._password_has_expired(), False) + self.assertEqual(test_user.partner_id.signup_type, False) + test_user.action_expire_password() + self.assertEqual(test_user.partner_id.signup_type, "reset") + + self.logout() + self.assertNotEqual(self.session.uid, uid) diff --git a/password_security/views/res_config_settings_views.xml b/password_security/views/res_config_settings_views.xml new file mode 100644 index 0000000000..7b68255d35 --- /dev/null +++ b/password_security/views/res_config_settings_views.xml @@ -0,0 +1,82 @@ + + + + + res.config.settings.form.password_security + res.config.settings + + + + + + + + Password expires in days. + + + + + User can change password in hours again. + + + + + + Disallow reuse of + + previous passwords. + +
+ Use negative number for infinite, or 0 to disable +
+
+ + + Minimum number of lowercase characters + + + + + + Minimum number of uppercase characters + + + + + + Minimum number of numeric characters + + + + + + Minimum number of special characters + + + +
+
+ + + + + + +
+
+