Browse Source

[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 errors
pull/566/head
Dave Lasley 8 years ago
committed by Holger Brunn
parent
commit
4acdcdf39e
  1. 90
      password_security/README.rst
  2. 6
      password_security/__init__.py
  3. 23
      password_security/__openerp__.py
  4. 5
      password_security/controllers/__init__.py
  5. 93
      password_security/controllers/main.py
  6. 12
      password_security/exceptions.py
  7. 7
      password_security/models/__init__.py
  8. 51
      password_security/models/res_company.py
  9. 158
      password_security/models/res_users.py
  10. 26
      password_security/models/res_users_pass_history.py
  11. 2
      password_security/security/ir.model.access.csv
  12. 19
      password_security/security/res_users_pass_history.xml
  13. BIN
      password_security/static/description/icon.png
  14. 7
      password_security/tests/__init__.py
  15. 269
      password_security/tests/test_password_security_home.py
  16. 58
      password_security/tests/test_password_security_session.py
  17. 148
      password_security/tests/test_res_users.py
  18. 42
      password_security/views/res_company_view.xml

90
password_security/README.rst

@ -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.

6
password_security/__init__.py

@ -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

23
password_security/__openerp__.py

@ -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,
}

5
password_security/controllers/__init__.py

@ -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

93
password_security/controllers/main.py

@ -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
)

12
password_security/exceptions.py

@ -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)

7
password_security/models/__init__.py

@ -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

51
password_security/models/res_company.py

@ -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',
)

158
password_security/models/res_users.py

@ -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,
})],
})

26
password_security/models/res_users_pass_history.py

@ -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,
)

2
password_security/security/ir.model.access.csv

@ -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

19
password_security/security/res_users_pass_history.xml

@ -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>

BIN
password_security/static/description/icon.png

After

Width: 128  |  Height: 128  |  Size: 9.2 KiB

7
password_security/tests/__init__.py

@ -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

269
password_security/tests/test_password_security_home.py

@ -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,
)

58
password_security/tests/test_password_security_session.py

@ -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)

148
password_security/tests/test_res_users.py

@ -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(),
)

42
password_security/views/res_company_view.xml

@ -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>
Loading…
Cancel
Save