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