Skip to content

Commit

Permalink
[16.0][MIG] password_security
Browse files Browse the repository at this point in the history
  • Loading branch information
astirpe committed Apr 18, 2023
1 parent 730a058 commit ecb8082
Show file tree
Hide file tree
Showing 25 changed files with 695 additions and 2,305 deletions.
9 changes: 2 additions & 7 deletions password_security/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
{
"name": "Password Security",
"summary": "Allow admin to set password security requirements.",
"version": "15.0.1.0.0",
"version": "16.0.1.0.0",
"author": "LasLabs, "
"Onestein, "
"Kaushal Prajapati, "
"Tecnativa, "
"initOS GmbH, "
Expand All @@ -27,12 +28,6 @@
"security/ir.model.access.csv",
"security/res_users_pass_history.xml",
],
"assets": {
"web.assets_common": [
"/password_security/static/src/js/password_gauge.js",
"/password_security/static/lib/zxcvbn/zxcvbn.min.js",
],
},
"demo": [
"demo/res_users.xml",
],
Expand Down
73 changes: 23 additions & 50 deletions password_security/controllers/main.py
Original file line number Diff line number Diff line change
@@ -1,42 +1,30 @@
# Copyright 2015 LasLabs Inc.
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).

import operator
import logging

from werkzeug.exceptions import BadRequest

from odoo import http
from odoo.exceptions import UserError
from odoo.http import request

from odoo.addons.auth_signup.controllers.main import AuthSignupHome
from odoo.addons.web.controllers.main import Session, ensure_db

from odoo.addons.web.controllers.home import ensure_db

class PasswordSecuritySession(Session):
@http.route()
def change_password(self, fields):
new_password = operator.itemgetter("new_password")(
dict(list(map(operator.itemgetter("name", "value"), fields)))
)
user_id = request.env.user
user_id._check_password(new_password)
return super(PasswordSecuritySession, self).change_password(fields)
_logger = logging.getLogger(__name__)


class PasswordSecurityHome(AuthSignupHome):
def do_signup(self, qcontext):
password = qcontext.get("password")
user_id = request.env.user
user_id._check_password(password)
return super(PasswordSecurityHome, self).do_signup(qcontext)

@http.route("/password_security/estimate", auth="none", type="json")
def estimate(self, password):
return request.env["res.users"].get_estimation(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(PasswordSecurityHome, self).web_login(*args, **kw)
response = super().web_login(*args, **kw)
if not request.params.get("login_success"):
return response
# Now, I'm an authenticated user
Expand All @@ -52,35 +40,20 @@ def web_login(self, *args, **kw):

@http.route()
def web_auth_signup(self, *args, **kw):
"""Try to catch all the possible exceptions not already handled in the parent method"""

try:
return super(PasswordSecurityHome, self).web_auth_signup(*args, **kw)
except UserError as e:
qcontext = self.get_auth_signup_qcontext()
qcontext["error"] = str(e)
return request.render("auth_signup.signup", qcontext)
except Exception:
raise BadRequest from None # HTTPError: 400 Client Error: BAD REQUEST

@http.route()
def web_auth_reset_password(self, *args, **kw):
"""It provides hook to disallow front-facing resets inside of min
Unfortuantely had to reimplement some core logic here because of
nested logic in parent
"""
qcontext = self.get_auth_signup_qcontext()
if (
request.httprequest.method == "POST"
and qcontext.get("login")
and "error" not in qcontext
and "token" not in qcontext
):
login = qcontext.get("login")
user_ids = request.env.sudo().search(
[("login", "=", login)],
limit=1,
)
if not user_ids:
user_ids = request.env.sudo().search(
[("email", "=", login)],
limit=1,
)
user_ids._validate_pass_reset()
return super(PasswordSecurityHome, self).web_auth_reset_password(*args, **kw)
try:
return super().web_auth_signup(*args, **kw)
except Exception as e:
# Here we catch any generic exception since UserError is already
# handled in parent method 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
2 changes: 1 addition & 1 deletion password_security/demo/res_users.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
-->
<odoo>
<record id="base.user_root" model="res.users">
<field eval="datetime.now()" name="password_write_date" />
<field name="password_write_date" eval="datetime.now()" />
</record>
</odoo>
5 changes: 4 additions & 1 deletion password_security/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
# Copyright 2015 LasLabs Inc.
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).

from . import res_company, res_config_settings, res_users, res_users_pass_history
from . import res_company
from . import res_config_settings
from . import res_users
from . import res_users_pass_history
7 changes: 1 addition & 6 deletions password_security/models/res_company.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,6 @@ class ResCompany(models.Model):
default=60,
help="How many days until passwords expire",
)
password_length = fields.Integer(
"Characters",
default=12,
help="Minimum number of characters",
)
password_lower = fields.Integer(
"Lowercase",
default=1,
Expand Down Expand Up @@ -58,5 +53,5 @@ class ResCompany(models.Model):

@api.constrains("password_estimate")
def _check_password_estimate(self):
if 0 > self.password_estimate > 4:
if self.password_estimate < 0 or self.password_estimate > 4:
raise ValidationError(_("The estimation must be between 0 and 4."))
3 changes: 0 additions & 3 deletions password_security/models/res_config_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@ class ResConfigSettings(models.TransientModel):
password_history = fields.Integer(
related="company_id.password_history", readonly=False
)
password_length = fields.Integer(
related="company_id.password_length", readonly=False
)
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(
Expand Down
71 changes: 41 additions & 30 deletions password_security/models/res_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@


def delta_now(**kwargs):
dt = datetime.now() + timedelta(**kwargs)
return fields.Datetime.to_string(dt)
return datetime.now() + timedelta(**kwargs)


class ResUsers(models.Model):
Expand All @@ -40,35 +39,28 @@ class ResUsers(models.Model):
readonly=True,
)

@api.model
def create(self, vals):
vals["password_write_date"] = fields.Datetime.now()
return super(ResUsers, self).create(vals)

def write(self, vals):
if vals.get("password"):
self._check_password(vals["password"])
vals["password_write_date"] = fields.Datetime.now()
return super(ResUsers, self).write(vals)
return super().write(vals)

@api.model
def get_password_policy(self):
data = super(ResUsers, self).get_password_policy()
data = super().get_password_policy()
company_id = self.env.user.company_id
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_length": company_id.password_length,
"password_estimate": company_id.password_estimate,
}
)
return data

def _check_password_policy(self, passwords):
result = super(ResUsers, self)._check_password_policy(passwords)
result = super()._check_password_policy(passwords)

for password in passwords:
if not password:
Expand Down Expand Up @@ -107,10 +99,12 @@ def password_match_message(self):
)
if message:
message = [_("Must contain the following:")] + message
if company_id.password_length:

params = self.env["ir.config_parameter"].sudo()
minlength = params.get_param("auth_password_policy.minlength", default=0)
if minlength:
message = [
_("Password must be %d characters or more.")
% company_id.password_length
_("Password must be %d characters or more.") % int(minlength)
] + message
return "\r".join(message)

Expand All @@ -124,20 +118,25 @@ def _check_password_rules(self, password):
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)
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(company_id.password_length),
".{%d,}$" % int(minlength),
]
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:
raise UserError(estimation["feedback"]["warning"])
if estimation["feedback"]["warning"]:
raise UserError(estimation["feedback"]["warning"])
else:
raise UserError(_("Choose a stronger password!"))

return True

Expand All @@ -154,8 +153,8 @@ def _password_has_expired(self):

def action_expire_password(self):
expiration = delta_now(days=+1)
for rec_id in self:
rec_id.mapped("partner_id").signup_prepare(
for user in self:
user.mapped("partner_id").signup_prepare(
signup_type="reset", expiration=expiration
)

Expand All @@ -164,11 +163,11 @@ def _validate_pass_reset(self):
:raises: UserError on invalidated pass reset attempt
:return: True on allowed reset
"""
for rec_id in self:
pass_min = rec_id.company_id.password_minimum
for user in self:
pass_min = user.company_id.password_minimum
if pass_min <= 0:
pass
write_date = rec_id.password_write_date
continue
write_date = user.password_write_date
delta = timedelta(hours=pass_min)
if write_date + delta > datetime.now():
raise UserError(
Expand All @@ -185,23 +184,35 @@ def _check_password_history(self, password):
:raises: UserError on reused password
"""
crypt = self._crypt_context()
for rec_id in self:
recent_passes = rec_id.company_id.password_history
if recent_passes < 0:
recent_passes = rec_id.password_history_ids
for user in self:
password_history = user.company_id.password_history
if not password_history: # disabled
recent_passes = self.env["res.users.pass.history"]
elif password_history < 0: # unlimited
recent_passes = user.password_history_ids
else:
recent_passes = rec_id.password_history_ids[0 : recent_passes - 1]
recent_passes = user.password_history_ids[:password_history]
if recent_passes.filtered(
lambda r: crypt.verify(password, r.password_crypt)
):
raise UserError(
_("Cannot use the most recent %d passwords")
% rec_id.company_id.password_history
% user.company_id.password_history
)

def _set_encrypted_password(self, uid, pw):
"""It saves password crypt history for history rules"""
res = super(ResUsers, self)._set_encrypted_password(uid, pw)
res = super()._set_encrypted_password(uid, pw)

self.write({"password_history_ids": [(0, 0, {"password_crypt": pw})]})
return res

def action_reset_password(self):
"""Disallow password resets inside of Minimum Hours"""
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()
3 changes: 3 additions & 0 deletions password_security/readme/CONTRIBUTORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@

* Chandresh Thakkar <[email protected]>
* Daniel Reis <[email protected]>

* `Onestein <https://www.onestein.nl>`_:
* Andrea Stirpe <[email protected]>
Loading

0 comments on commit ecb8082

Please sign in to comment.