Browse Source
[9.0][ADD] Password Security Settings (#531)
[9.0][ADD] Password Security Settings (#531)
* [ADD] res_users_password_security: New module * Create new module to lock down user passwords * [REF] res_users_password_security: PR Review fixes * Also add beta pass history rule * [ADD] res_users_password_security: Pass history and min time * Add pass history memory and threshold * Add minimum time for pass resets through web reset * Begin controller tests * Fix copyright, wrong year for new file * Add tests for password_security_home * Left to do web_auth_reset_password * Fix minimum reset threshold and finish tests * Bug fixes per review * [REF] password_security: PR review improvements * Change tech name to password_security * Use new except format * Limit 1 & new api * Cascade deletion for pass history * [REF] password_security: Fix travis + style * Fix travis errors * self to cls * Better variable names in tests * [FIX] password_security: Fix travis errorspull/566/head
Dave Lasley
8 years ago
committed by
Holger Brunn
18 changed files with 1016 additions and 0 deletions
-
90password_security/README.rst
-
6password_security/__init__.py
-
23password_security/__openerp__.py
-
5password_security/controllers/__init__.py
-
93password_security/controllers/main.py
-
12password_security/exceptions.py
-
7password_security/models/__init__.py
-
51password_security/models/res_company.py
-
158password_security/models/res_users.py
-
26password_security/models/res_users_pass_history.py
-
2password_security/security/ir.model.access.csv
-
19password_security/security/res_users_pass_history.xml
-
BINpassword_security/static/description/icon.png
-
7password_security/tests/__init__.py
-
269password_security/tests/test_password_security_home.py
-
58password_security/tests/test_password_security_session.py
-
148password_security/tests/test_res_users.py
-
42password_security/views/res_company_view.xml
@ -0,0 +1,90 @@ |
|||||
|
.. image:: https://img.shields.io/badge/license-LGPL--3-blue.svg |
||||
|
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html |
||||
|
:alt: License: LGPL-3 |
||||
|
|
||||
|
================= |
||||
|
Password Security |
||||
|
================= |
||||
|
|
||||
|
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 |
||||
|
|
||||
|
Configuration |
||||
|
============= |
||||
|
|
||||
|
# Navigate to company you would like to set requirements on |
||||
|
# Click the ``Password Policy`` page |
||||
|
# 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: |
||||
|
|
||||
|
===================== ======= =================================================== |
||||
|
Name Default Description |
||||
|
===================== ======= =================================================== |
||||
|
password_expiration 60 Days until passwords expire |
||||
|
password_length 12 Minimum number of characters in password |
||||
|
password_lower True Require lowercase letter in password |
||||
|
password_upper True Require uppercase letters in password |
||||
|
password_numeric True Require number in password |
||||
|
password_special True Require 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 |
||||
|
===================== ======= =================================================== |
||||
|
|
||||
|
Known Issues / Roadmap |
||||
|
====================== |
||||
|
|
||||
|
|
||||
|
Bug Tracker |
||||
|
=========== |
||||
|
|
||||
|
Bugs are tracked on `GitHub Issues |
||||
|
<https://github.com/LasLabs/odoo-base/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 detailed and welcomed feedback. |
||||
|
|
||||
|
|
||||
|
Credits |
||||
|
======= |
||||
|
|
||||
|
Images |
||||
|
------ |
||||
|
|
||||
|
* Odoo Community Association: `Icon <https://github.com/OCA/maintainer-tools/blob/master/template/module/static/description/icon.svg>`_. |
||||
|
|
||||
|
Contributors |
||||
|
------------ |
||||
|
|
||||
|
* James Foster <jfoster@laslabs.com> |
||||
|
* Dave Lasley <dave@laslabs.com> |
||||
|
|
||||
|
Maintainer |
||||
|
---------- |
||||
|
|
||||
|
.. image:: https://odoo-community.org/logo.png |
||||
|
:alt: Odoo Community Association |
||||
|
:target: https://odoo-community.org |
||||
|
|
||||
|
This module is maintained by the OCA. |
||||
|
|
||||
|
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. |
||||
|
|
||||
|
To contribute to this module, please visit https://odoo-community.org. |
@ -0,0 +1,6 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2015 LasLabs Inc. |
||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
||||
|
|
||||
|
from . import controllers |
||||
|
from . import models |
@ -0,0 +1,23 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2015 LasLabs Inc. |
||||
|
# 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': '9.0.1.0.2', |
||||
|
'author': "LasLabs, Odoo Community Association (OCA)", |
||||
|
'category': 'Base', |
||||
|
'depends': [ |
||||
|
'auth_crypt', |
||||
|
'auth_signup', |
||||
|
], |
||||
|
"website": "https://laslabs.com", |
||||
|
"license": "LGPL-3", |
||||
|
"data": [ |
||||
|
'views/res_company_view.xml', |
||||
|
'security/ir.model.access.csv', |
||||
|
'security/res_users_pass_history.xml', |
||||
|
], |
||||
|
'installable': True, |
||||
|
} |
@ -0,0 +1,5 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2015 LasLabs Inc. |
||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
||||
|
|
||||
|
from . import main |
@ -0,0 +1,93 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2015 LasLabs Inc. |
||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
||||
|
|
||||
|
import operator |
||||
|
|
||||
|
from openerp import http |
||||
|
from openerp.http import request |
||||
|
from openerp.addons.auth_signup.controllers.main import AuthSignupHome |
||||
|
from openerp.addons.web.controllers.main import ensure_db, Session |
||||
|
|
||||
|
from ..exceptions import PassError |
||||
|
|
||||
|
|
||||
|
class PasswordSecuritySession(Session): |
||||
|
|
||||
|
@http.route() |
||||
|
def change_password(self, fields): |
||||
|
new_password = operator.itemgetter('new_password')( |
||||
|
dict(map(operator.itemgetter('name', 'value'), fields)) |
||||
|
) |
||||
|
user_id = request.env.user |
||||
|
user_id.check_password(new_password) |
||||
|
return super(PasswordSecuritySession, self).change_password(fields) |
||||
|
|
||||
|
|
||||
|
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() |
||||
|
def web_login(self, *args, **kw): |
||||
|
ensure_db() |
||||
|
response = super(PasswordSecurityHome, self).web_login(*args, **kw) |
||||
|
if not request.httprequest.method == 'POST': |
||||
|
return response |
||||
|
uid = request.session.authenticate( |
||||
|
request.session.db, |
||||
|
request.params['login'], |
||||
|
request.params['password'] |
||||
|
) |
||||
|
if not uid: |
||||
|
return response |
||||
|
users_obj = request.env['res.users'].sudo() |
||||
|
user_id = users_obj.browse(request.uid) |
||||
|
if not user_id._password_has_expired(): |
||||
|
return response |
||||
|
user_id.action_expire_password() |
||||
|
redirect = user_id.partner_id.signup_url |
||||
|
return http.redirect_with_hash(redirect) |
||||
|
|
||||
|
@http.route() |
||||
|
def web_auth_signup(self, *args, **kw): |
||||
|
try: |
||||
|
return super(PasswordSecurityHome, self).web_auth_signup( |
||||
|
*args, **kw |
||||
|
) |
||||
|
except PassError as e: |
||||
|
qcontext = self.get_auth_signup_qcontext() |
||||
|
qcontext['error'] = e.message |
||||
|
return request.render('auth_signup.signup', qcontext) |
||||
|
|
||||
|
@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 |
||||
|
) |
@ -0,0 +1,12 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2015 LasLabs Inc. |
||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
||||
|
|
||||
|
from openerp.exceptions import Warning as UserError |
||||
|
|
||||
|
|
||||
|
class PassError(UserError): |
||||
|
""" Example: When you try to create an insecure password.""" |
||||
|
def __init__(self, msg): |
||||
|
self.message = msg |
||||
|
super(PassError, self).__init__(msg) |
@ -0,0 +1,7 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2015 LasLabs Inc. |
||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
||||
|
|
||||
|
from . import res_users |
||||
|
from . import res_company |
||||
|
from . import res_users_pass_history |
@ -0,0 +1,51 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2015 LasLabs Inc. |
||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
||||
|
|
||||
|
from openerp import models, fields |
||||
|
|
||||
|
|
||||
|
class ResCompany(models.Model): |
||||
|
_inherit = 'res.company' |
||||
|
|
||||
|
password_expiration = fields.Integer( |
||||
|
'Days', |
||||
|
default=60, |
||||
|
help='How many days until passwords expire', |
||||
|
) |
||||
|
password_length = fields.Integer( |
||||
|
'Characters', |
||||
|
default=12, |
||||
|
help='Minimum number of characters', |
||||
|
) |
||||
|
password_lower = fields.Boolean( |
||||
|
'Lowercase', |
||||
|
default=True, |
||||
|
help='Require lowercase letters', |
||||
|
) |
||||
|
password_upper = fields.Boolean( |
||||
|
'Uppercase', |
||||
|
default=True, |
||||
|
help='Require uppercase letters', |
||||
|
) |
||||
|
password_numeric = fields.Boolean( |
||||
|
'Numeric', |
||||
|
default=True, |
||||
|
help='Require numeric digits', |
||||
|
) |
||||
|
password_special = fields.Boolean( |
||||
|
'Special', |
||||
|
default=True, |
||||
|
help='Require 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', |
||||
|
) |
@ -0,0 +1,158 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2015 LasLabs Inc. |
||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
||||
|
|
||||
|
import re |
||||
|
|
||||
|
from datetime import datetime, timedelta |
||||
|
|
||||
|
from openerp import api, fields, models, _ |
||||
|
|
||||
|
from ..exceptions import PassError |
||||
|
|
||||
|
|
||||
|
def delta_now(**kwargs): |
||||
|
dt = datetime.now() + timedelta(**kwargs) |
||||
|
return fields.Datetime.to_string(dt) |
||||
|
|
||||
|
|
||||
|
class ResUsers(models.Model): |
||||
|
_inherit = 'res.users' |
||||
|
|
||||
|
password_write_date = fields.Datetime( |
||||
|
'Last password update', |
||||
|
readonly=True, |
||||
|
) |
||||
|
password_history_ids = fields.One2many( |
||||
|
string='Password History', |
||||
|
comodel_name='res.users.pass.history', |
||||
|
inverse_name='user_id', |
||||
|
readonly=True, |
||||
|
) |
||||
|
|
||||
|
@api.model |
||||
|
def create(self, vals): |
||||
|
vals['password_write_date'] = fields.Datetime.now() |
||||
|
return super(ResUsers, self).create(vals) |
||||
|
|
||||
|
@api.multi |
||||
|
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) |
||||
|
|
||||
|
@api.multi |
||||
|
def password_match_message(self): |
||||
|
self.ensure_one() |
||||
|
company_id = self.company_id |
||||
|
message = [] |
||||
|
if company_id.password_lower: |
||||
|
message.append('* ' + _('Lowercase letter')) |
||||
|
if company_id.password_upper: |
||||
|
message.append('* ' + _('Uppercase letter')) |
||||
|
if company_id.password_numeric: |
||||
|
message.append('* ' + _('Numeric digit')) |
||||
|
if company_id.password_special: |
||||
|
message.append('* ' + _('Special character')) |
||||
|
if len(message): |
||||
|
message = [_('Must contain the following:')] + message |
||||
|
if company_id.password_length: |
||||
|
message = [ |
||||
|
_('Password must be %d characters or more.') % |
||||
|
company_id.password_length |
||||
|
] + message |
||||
|
return '\r'.join(message) |
||||
|
|
||||
|
@api.multi |
||||
|
def check_password(self, password): |
||||
|
self.ensure_one() |
||||
|
if not password: |
||||
|
return True |
||||
|
company_id = self.company_id |
||||
|
password_regex = ['^'] |
||||
|
if company_id.password_lower: |
||||
|
password_regex.append('(?=.*?[a-z])') |
||||
|
if company_id.password_upper: |
||||
|
password_regex.append('(?=.*?[A-Z])') |
||||
|
if company_id.password_numeric: |
||||
|
password_regex.append(r'(?=.*?\d)') |
||||
|
if company_id.password_special: |
||||
|
password_regex.append(r'(?=.*?\W)') |
||||
|
password_regex.append('.{%d,}$' % company_id.password_length) |
||||
|
if not re.search(''.join(password_regex), password): |
||||
|
raise PassError(_(self.password_match_message())) |
||||
|
return True |
||||
|
|
||||
|
@api.multi |
||||
|
def _password_has_expired(self): |
||||
|
self.ensure_one() |
||||
|
if not self.password_write_date: |
||||
|
return True |
||||
|
write_date = fields.Datetime.from_string(self.password_write_date) |
||||
|
today = fields.Datetime.from_string(fields.Datetime.now()) |
||||
|
days = (today - write_date).days |
||||
|
return days > self.company_id.password_expiration |
||||
|
|
||||
|
@api.multi |
||||
|
def action_expire_password(self): |
||||
|
expiration = delta_now(days=+1) |
||||
|
for rec_id in self: |
||||
|
rec_id.mapped('partner_id').signup_prepare( |
||||
|
signup_type="reset", expiration=expiration |
||||
|
) |
||||
|
|
||||
|
@api.multi |
||||
|
def _validate_pass_reset(self): |
||||
|
""" It provides validations before initiating a pass reset email |
||||
|
:raises: PassError on invalidated pass reset attempt |
||||
|
:return: True on allowed reset |
||||
|
""" |
||||
|
for rec_id in self: |
||||
|
pass_min = rec_id.company_id.password_minimum |
||||
|
if pass_min <= 0: |
||||
|
pass |
||||
|
write_date = fields.Datetime.from_string( |
||||
|
rec_id.password_write_date |
||||
|
) |
||||
|
delta = timedelta(hours=pass_min) |
||||
|
if write_date + delta > datetime.now(): |
||||
|
raise PassError( |
||||
|
_('Passwords can only be reset every %d hour(s). ' |
||||
|
'Please contact an administrator for assistance.') % |
||||
|
pass_min, |
||||
|
) |
||||
|
return True |
||||
|
|
||||
|
@api.multi |
||||
|
def _set_password(self, password): |
||||
|
""" It validates proposed password against existing history |
||||
|
:raises: PassError on reused password |
||||
|
""" |
||||
|
crypt = self._crypt_context()[0] |
||||
|
for rec_id in self: |
||||
|
recent_passes = rec_id.company_id.password_history |
||||
|
if recent_passes < 0: |
||||
|
recent_passes = rec_id.password_history_ids |
||||
|
else: |
||||
|
recent_passes = rec_id.password_history_ids[ |
||||
|
0:recent_passes-1 |
||||
|
] |
||||
|
if len(recent_passes.filtered( |
||||
|
lambda r: crypt.verify(password, r.password_crypt) |
||||
|
)): |
||||
|
raise PassError( |
||||
|
_('Cannot use the most recent %d passwords') % |
||||
|
rec_id.company_id.password_history |
||||
|
) |
||||
|
super(ResUsers, self)._set_password(password) |
||||
|
|
||||
|
@api.multi |
||||
|
def _set_encrypted_password(self, encrypted): |
||||
|
""" It saves password crypt history for history rules """ |
||||
|
super(ResUsers, self)._set_encrypted_password(encrypted) |
||||
|
self.write({ |
||||
|
'password_history_ids': [(0, 0, { |
||||
|
'password_crypt': encrypted, |
||||
|
})], |
||||
|
}) |
@ -0,0 +1,26 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2016 LasLabs Inc. |
||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
||||
|
|
||||
|
from openerp import fields, models |
||||
|
|
||||
|
|
||||
|
class ResUsersPassHistory(models.Model): |
||||
|
_name = 'res.users.pass.history' |
||||
|
_description = 'Res Users Password History' |
||||
|
|
||||
|
_order = 'user_id, date 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, |
||||
|
) |
@ -0,0 +1,2 @@ |
|||||
|
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 |
@ -0,0 +1,19 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
|
|
||||
|
<!-- |
||||
|
Copyright 2016 LasLabs Inc. |
||||
|
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
||||
|
--> |
||||
|
|
||||
|
<odoo> |
||||
|
|
||||
|
<record model="ir.rule" id="res_users_pass_history_rule"> |
||||
|
<field name="name">Res Users Pass History Access</field> |
||||
|
<field name="model_id" ref="base.model_ir_sequence"/> |
||||
|
<field name="groups" eval="[(4, ref('base.group_user'))]"/> |
||||
|
<field name="domain_force">[ |
||||
|
('user_id', '=', user.id) |
||||
|
]</field> |
||||
|
</record> |
||||
|
|
||||
|
</odoo> |
After Width: 128 | Height: 128 | Size: 9.2 KiB |
@ -0,0 +1,7 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2015 LasLabs Inc. |
||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
||||
|
|
||||
|
from . import test_res_users |
||||
|
from . import test_password_security_home |
||||
|
from . import test_password_security_session |
@ -0,0 +1,269 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2016 LasLabs Inc. |
||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
||||
|
|
||||
|
import mock |
||||
|
|
||||
|
from contextlib import contextmanager |
||||
|
|
||||
|
from openerp.tests.common import TransactionCase |
||||
|
from openerp.http import Response |
||||
|
|
||||
|
from ..controllers import main |
||||
|
|
||||
|
|
||||
|
IMPORT = 'openerp.addons.password_security.controllers.main' |
||||
|
|
||||
|
|
||||
|
class EndTestException(Exception): |
||||
|
""" It allows for isolation of resources by raise """ |
||||
|
|
||||
|
|
||||
|
class MockResponse(object): |
||||
|
def __new__(cls): |
||||
|
return mock.Mock(spec=Response) |
||||
|
|
||||
|
|
||||
|
class MockPassError(main.PassError): |
||||
|
def __init__(self): |
||||
|
super(MockPassError, self).__init__('Message') |
||||
|
|
||||
|
|
||||
|
class TestPasswordSecurityHome(TransactionCase): |
||||
|
|
||||
|
def setUp(self): |
||||
|
super(TestPasswordSecurityHome, self).setUp() |
||||
|
self.PasswordSecurityHome = main.PasswordSecurityHome |
||||
|
self.password_security_home = self.PasswordSecurityHome() |
||||
|
self.passwd = 'I am a password!' |
||||
|
self.qcontext = { |
||||
|
'password': self.passwd, |
||||
|
} |
||||
|
|
||||
|
@contextmanager |
||||
|
def mock_assets(self): |
||||
|
""" It mocks and returns assets used by this controller """ |
||||
|
methods = ['do_signup', 'web_login', 'web_auth_signup', |
||||
|
'web_auth_reset_password', |
||||
|
] |
||||
|
with mock.patch.multiple( |
||||
|
main.AuthSignupHome, **{m: mock.DEFAULT for m in methods} |
||||
|
) as _super: |
||||
|
mocks = {} |
||||
|
for method in methods: |
||||
|
mocks[method] = _super[method] |
||||
|
mocks[method].return_value = MockResponse() |
||||
|
with mock.patch('%s.request' % IMPORT) as request: |
||||
|
with mock.patch('%s.ensure_db' % IMPORT) as ensure: |
||||
|
with mock.patch('%s.http' % IMPORT) as http: |
||||
|
http.redirect_with_hash.return_value = \ |
||||
|
MockResponse() |
||||
|
mocks.update({ |
||||
|
'request': request, |
||||
|
'ensure_db': ensure, |
||||
|
'http': http, |
||||
|
}) |
||||
|
yield mocks |
||||
|
|
||||
|
def test_do_signup_check(self): |
||||
|
""" It should check password on user """ |
||||
|
with self.mock_assets() as assets: |
||||
|
check_password = assets['request'].env.user.check_password |
||||
|
check_password.side_effect = EndTestException |
||||
|
with self.assertRaises(EndTestException): |
||||
|
self.password_security_home.do_signup(self.qcontext) |
||||
|
check_password.assert_called_once_with( |
||||
|
self.passwd, |
||||
|
) |
||||
|
|
||||
|
def test_do_signup_return(self): |
||||
|
""" It should return result of super """ |
||||
|
with self.mock_assets() as assets: |
||||
|
res = self.password_security_home.do_signup(self.qcontext) |
||||
|
self.assertEqual(assets['do_signup'](), res) |
||||
|
|
||||
|
def test_web_login_ensure_db(self): |
||||
|
""" It should verify available db """ |
||||
|
with self.mock_assets() as assets: |
||||
|
assets['ensure_db'].side_effect = EndTestException |
||||
|
with self.assertRaises(EndTestException): |
||||
|
self.password_security_home.web_login() |
||||
|
|
||||
|
def test_web_login_super(self): |
||||
|
""" It should call superclass w/ proper args """ |
||||
|
expect_list = [1, 2, 3] |
||||
|
expect_dict = {'test1': 'good1', 'test2': 'good2'} |
||||
|
with self.mock_assets() as assets: |
||||
|
assets['web_login'].side_effect = EndTestException |
||||
|
with self.assertRaises(EndTestException): |
||||
|
self.password_security_home.web_login( |
||||
|
*expect_list, **expect_dict |
||||
|
) |
||||
|
assets['web_login'].assert_called_once_with( |
||||
|
*expect_list, **expect_dict |
||||
|
) |
||||
|
|
||||
|
def test_web_login_no_post(self): |
||||
|
""" It should return immediate result of super when not POST """ |
||||
|
with self.mock_assets() as assets: |
||||
|
assets['request'].httprequest.method = 'GET' |
||||
|
assets['request'].session.authenticate.side_effect = \ |
||||
|
EndTestException |
||||
|
res = self.password_security_home.web_login() |
||||
|
self.assertEqual( |
||||
|
assets['web_login'](), res, |
||||
|
) |
||||
|
|
||||
|
def test_web_login_authenticate(self): |
||||
|
""" It should attempt authentication to obtain uid """ |
||||
|
with self.mock_assets() as assets: |
||||
|
assets['request'].httprequest.method = 'POST' |
||||
|
authenticate = assets['request'].session.authenticate |
||||
|
request = assets['request'] |
||||
|
authenticate.side_effect = EndTestException |
||||
|
with self.assertRaises(EndTestException): |
||||
|
self.password_security_home.web_login() |
||||
|
authenticate.assert_called_once_with( |
||||
|
request.session.db, |
||||
|
request.params['login'], |
||||
|
request.params['password'], |
||||
|
) |
||||
|
|
||||
|
def test_web_login_authenticate_fail(self): |
||||
|
""" It should return super result if failed auth """ |
||||
|
with self.mock_assets() as assets: |
||||
|
authenticate = assets['request'].session.authenticate |
||||
|
request = assets['request'] |
||||
|
request.httprequest.method = 'POST' |
||||
|
request.env['res.users'].sudo.side_effect = EndTestException |
||||
|
authenticate.return_value = False |
||||
|
res = self.password_security_home.web_login() |
||||
|
self.assertEqual( |
||||
|
assets['web_login'](), res, |
||||
|
) |
||||
|
|
||||
|
def test_web_login_get_user(self): |
||||
|
""" It should get the proper user as sudo """ |
||||
|
with self.mock_assets() as assets: |
||||
|
request = assets['request'] |
||||
|
request.httprequest.method = 'POST' |
||||
|
sudo = request.env['res.users'].sudo() |
||||
|
sudo.browse.side_effect = EndTestException |
||||
|
with self.assertRaises(EndTestException): |
||||
|
self.password_security_home.web_login() |
||||
|
sudo.browse.assert_called_once_with( |
||||
|
request.uid |
||||
|
) |
||||
|
|
||||
|
def test_web_login_valid_pass(self): |
||||
|
""" It should return parent result if pass isn't expired """ |
||||
|
with self.mock_assets() as assets: |
||||
|
request = assets['request'] |
||||
|
request.httprequest.method = 'POST' |
||||
|
user = request.env['res.users'].sudo().browse() |
||||
|
user.action_expire_password.side_effect = EndTestException |
||||
|
user._password_has_expired.return_value = False |
||||
|
res = self.password_security_home.web_login() |
||||
|
self.assertEqual( |
||||
|
assets['web_login'](), res, |
||||
|
) |
||||
|
|
||||
|
def test_web_login_expire_pass(self): |
||||
|
""" It should expire password if necessary """ |
||||
|
with self.mock_assets() as assets: |
||||
|
request = assets['request'] |
||||
|
request.httprequest.method = 'POST' |
||||
|
user = request.env['res.users'].sudo().browse() |
||||
|
user.action_expire_password.side_effect = EndTestException |
||||
|
user._password_has_expired.return_value = True |
||||
|
with self.assertRaises(EndTestException): |
||||
|
self.password_security_home.web_login() |
||||
|
|
||||
|
def test_web_login_redirect(self): |
||||
|
""" It should redirect w/ hash to reset after expiration """ |
||||
|
with self.mock_assets() as assets: |
||||
|
request = assets['request'] |
||||
|
request.httprequest.method = 'POST' |
||||
|
user = request.env['res.users'].sudo().browse() |
||||
|
user._password_has_expired.return_value = True |
||||
|
res = self.password_security_home.web_login() |
||||
|
self.assertEqual( |
||||
|
assets['http'].redirect_with_hash(), res, |
||||
|
) |
||||
|
|
||||
|
def test_web_auth_signup_valid(self): |
||||
|
""" It should return super if no errors """ |
||||
|
with self.mock_assets() as assets: |
||||
|
res = self.password_security_home.web_auth_signup() |
||||
|
self.assertEqual( |
||||
|
assets['web_auth_signup'](), res, |
||||
|
) |
||||
|
|
||||
|
def test_web_auth_signup_invalid_qcontext(self): |
||||
|
""" It should catch PassError and get signup qcontext """ |
||||
|
with self.mock_assets() as assets: |
||||
|
with mock.patch.object( |
||||
|
main.AuthSignupHome, 'get_auth_signup_qcontext', |
||||
|
) as qcontext: |
||||
|
assets['web_auth_signup'].side_effect = MockPassError |
||||
|
qcontext.side_effect = EndTestException |
||||
|
with self.assertRaises(EndTestException): |
||||
|
self.password_security_home.web_auth_signup() |
||||
|
|
||||
|
def test_web_auth_signup_invalid_render(self): |
||||
|
""" It should render & return signup form on invalid """ |
||||
|
with self.mock_assets() as assets: |
||||
|
with mock.patch.object( |
||||
|
main.AuthSignupHome, 'get_auth_signup_qcontext', spec=dict |
||||
|
) as qcontext: |
||||
|
assets['web_auth_signup'].side_effect = MockPassError |
||||
|
res = self.password_security_home.web_auth_signup() |
||||
|
assets['request'].render.assert_called_once_with( |
||||
|
'auth_signup.signup', qcontext(), |
||||
|
) |
||||
|
self.assertEqual( |
||||
|
assets['request'].render(), res, |
||||
|
) |
||||
|
|
||||
|
def test_web_auth_reset_password_fail_login(self): |
||||
|
""" It should raise from failed _validate_pass_reset by login """ |
||||
|
with self.mock_assets() as assets: |
||||
|
with mock.patch.object( |
||||
|
main.AuthSignupHome, 'get_auth_signup_qcontext', spec=dict |
||||
|
) as qcontext: |
||||
|
qcontext['login'] = 'login' |
||||
|
search = assets['request'].env.sudo().search |
||||
|
assets['request'].httprequest.method = 'POST' |
||||
|
user = mock.MagicMock() |
||||
|
user._validate_pass_reset.side_effect = MockPassError |
||||
|
search.return_value = user |
||||
|
with self.assertRaises(MockPassError): |
||||
|
self.password_security_home.web_auth_reset_password() |
||||
|
|
||||
|
def test_web_auth_reset_password_fail_email(self): |
||||
|
""" It should raise from failed _validate_pass_reset by email """ |
||||
|
with self.mock_assets() as assets: |
||||
|
with mock.patch.object( |
||||
|
main.AuthSignupHome, 'get_auth_signup_qcontext', spec=dict |
||||
|
) as qcontext: |
||||
|
qcontext['login'] = 'login' |
||||
|
search = assets['request'].env.sudo().search |
||||
|
assets['request'].httprequest.method = 'POST' |
||||
|
user = mock.MagicMock() |
||||
|
user._validate_pass_reset.side_effect = MockPassError |
||||
|
search.side_effect = [[], user] |
||||
|
with self.assertRaises(MockPassError): |
||||
|
self.password_security_home.web_auth_reset_password() |
||||
|
|
||||
|
def test_web_auth_reset_password_success(self): |
||||
|
""" It should return parent response on no validate errors """ |
||||
|
with self.mock_assets() as assets: |
||||
|
with mock.patch.object( |
||||
|
main.AuthSignupHome, 'get_auth_signup_qcontext', spec=dict |
||||
|
) as qcontext: |
||||
|
qcontext['login'] = 'login' |
||||
|
assets['request'].httprequest.method = 'POST' |
||||
|
res = self.password_security_home.web_auth_reset_password() |
||||
|
self.assertEqual( |
||||
|
assets['web_auth_reset_password'](), res, |
||||
|
) |
@ -0,0 +1,58 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2016 LasLabs Inc. |
||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
||||
|
|
||||
|
import mock |
||||
|
|
||||
|
from contextlib import contextmanager |
||||
|
|
||||
|
from openerp.tests.common import TransactionCase |
||||
|
|
||||
|
from ..controllers import main |
||||
|
|
||||
|
|
||||
|
IMPORT = 'openerp.addons.password_security.controllers.main' |
||||
|
|
||||
|
|
||||
|
class EndTestException(Exception): |
||||
|
""" It allows for isolation of resources by raise """ |
||||
|
|
||||
|
|
||||
|
class TestPasswordSecuritySession(TransactionCase): |
||||
|
|
||||
|
def setUp(self): |
||||
|
super(TestPasswordSecuritySession, self).setUp() |
||||
|
self.PasswordSecuritySession = main.PasswordSecuritySession |
||||
|
self.password_security_session = self.PasswordSecuritySession() |
||||
|
self.passwd = 'I am a password!' |
||||
|
self.fields = [ |
||||
|
{'name': 'new_password', 'value': self.passwd}, |
||||
|
] |
||||
|
|
||||
|
@contextmanager |
||||
|
def mock_assets(self): |
||||
|
""" It mocks and returns assets used by this controller """ |
||||
|
with mock.patch('%s.request' % IMPORT) as request: |
||||
|
yield { |
||||
|
'request': request, |
||||
|
} |
||||
|
|
||||
|
def test_change_password_check(self): |
||||
|
""" It should check password on request user """ |
||||
|
with self.mock_assets() as assets: |
||||
|
check_password = assets['request'].env.user.check_password |
||||
|
check_password.side_effect = EndTestException |
||||
|
with self.assertRaises(EndTestException): |
||||
|
self.password_security_session.change_password(self.fields) |
||||
|
check_password.assert_called_once_with( |
||||
|
self.passwd, |
||||
|
) |
||||
|
|
||||
|
def test_change_password_return(self): |
||||
|
""" It should return result of super """ |
||||
|
with self.mock_assets(): |
||||
|
with mock.patch.object(main.Session, 'change_password') as chg: |
||||
|
res = self.password_security_session.change_password( |
||||
|
self.fields |
||||
|
) |
||||
|
self.assertEqual(chg(), res) |
@ -0,0 +1,148 @@ |
|||||
|
# -*- coding: utf-8 -*- |
||||
|
# Copyright 2015 LasLabs Inc. |
||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
||||
|
|
||||
|
import time |
||||
|
|
||||
|
from openerp.tests.common import TransactionCase |
||||
|
|
||||
|
from ..exceptions import PassError |
||||
|
|
||||
|
|
||||
|
class TestResUsers(TransactionCase): |
||||
|
|
||||
|
def setUp(self): |
||||
|
super(TestResUsers, self).setUp() |
||||
|
self.login = 'foslabs@example.com' |
||||
|
self.partner_vals = { |
||||
|
'name': 'Partner', |
||||
|
'is_company': False, |
||||
|
'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'] |
||||
|
|
||||
|
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.refresh() |
||||
|
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.refresh() |
||||
|
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(PassError): |
||||
|
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): |
||||
|
""" It should raise PassError if previously used """ |
||||
|
rec_id = self._new_record() |
||||
|
with self.assertRaises(PassError): |
||||
|
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.refresh() |
||||
|
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.refresh() |
||||
|
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_expire_password_generates_token(self): |
||||
|
rec_id = self._new_record() |
||||
|
rec_id.sudo().action_expire_password() |
||||
|
rec_id.refresh() |
||||
|
token = rec_id.partner_id.signup_token |
||||
|
self.assertTrue( |
||||
|
token, |
||||
|
'A token was not generated.', |
||||
|
) |
||||
|
|
||||
|
def test_validate_pass_reset_error(self): |
||||
|
""" It should throw PassError on reset inside min threshold """ |
||||
|
rec_id = self._new_record() |
||||
|
with self.assertRaises(PassError): |
||||
|
rec_id._validate_pass_reset() |
||||
|
|
||||
|
def test_validate_pass_reset_allow(self): |
||||
|
""" It should allow reset pass when outside threshold """ |
||||
|
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): |
||||
|
""" It should allow reset pass when <= 0 """ |
||||
|
rec_id = self._new_record() |
||||
|
rec_id.company_id.password_minimum = 0 |
||||
|
self.assertEqual( |
||||
|
True, rec_id._validate_pass_reset(), |
||||
|
) |
@ -0,0 +1,42 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
|
||||
|
<!-- |
||||
|
Copyright 2015 LasLabs Inc. |
||||
|
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). |
||||
|
--> |
||||
|
|
||||
|
<odoo> |
||||
|
|
||||
|
<record id="view_company_form" model="ir.ui.view"> |
||||
|
<field name="name">res.company.form</field> |
||||
|
<field name="model">res.company</field> |
||||
|
<field name="inherit_id" ref="base.view_company_form" /> |
||||
|
<field name="arch" type="xml"> |
||||
|
<xpath expr="//notebook" position="inside"> |
||||
|
<page string="Password Policy"> |
||||
|
<group> |
||||
|
<group string="Timings"> |
||||
|
<field name="password_expiration" /> |
||||
|
<field name="password_minimum" /> |
||||
|
</group> |
||||
|
<group string="Extra"> |
||||
|
<field name="password_length" /> |
||||
|
<field name="password_history" /> |
||||
|
</group> |
||||
|
</group> |
||||
|
<group name="chars_grp" string="Required Characters"> |
||||
|
<group> |
||||
|
<field name="password_lower" /> |
||||
|
<field name="password_upper" /> |
||||
|
</group> |
||||
|
<group> |
||||
|
<field name="password_numeric" /> |
||||
|
<field name="password_special" /> |
||||
|
</group> |
||||
|
</group> |
||||
|
</page> |
||||
|
</xpath> |
||||
|
</field> |
||||
|
</record> |
||||
|
|
||||
|
</odoo> |
Write
Preview
Loading…
Cancel
Save
Reference in new issue