[ADD] password_security: Thanks to LasLabs, Odoo Community Association (OCA) for this module. As we are not using the module as it is, we did some required changes in order to improve this module to next level.
So, Now with the flectra password_security is part of core addons.
This commit is contained in:
parent
ec66961de6
commit
78e64b594d
63
addons/password_security/README.rst
Normal file
63
addons/password_security/README.rst
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
.. 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 0 Minimum number of lowercase letter in password
|
||||||
|
password_upper 0 Minimum number of uppercase letters in password
|
||||||
|
password_numeric 0 Minimum number of number in password
|
||||||
|
password_special 0 Minimum number of unique 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
|
||||||
|
===================== ======= ===================================================
|
||||||
|
|
||||||
|
|
||||||
|
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>
|
5
addons/password_security/__init__.py
Normal file
5
addons/password_security/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Copyright 2015 LasLabs Inc.
|
||||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
|
||||||
|
|
||||||
|
from . import controllers
|
||||||
|
from . import models
|
25
addons/password_security/__manifest__.py
Normal file
25
addons/password_security/__manifest__.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# 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': '11.0.1.0.0',
|
||||||
|
'author': "LasLabs, Odoo Community Association (OCA), FlectraHQ",
|
||||||
|
'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',
|
||||||
|
],
|
||||||
|
"demo": [
|
||||||
|
'demo/res_users.xml',
|
||||||
|
],
|
||||||
|
'installable': True,
|
||||||
|
}
|
4
addons/password_security/controllers/__init__.py
Normal file
4
addons/password_security/controllers/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# Copyright 2015 LasLabs Inc.
|
||||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
|
||||||
|
|
||||||
|
from . import main
|
93
addons/password_security/controllers/main.py
Normal file
93
addons/password_security/controllers/main.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
# Copyright 2015 LasLabs Inc.
|
||||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
|
||||||
|
|
||||||
|
import operator
|
||||||
|
|
||||||
|
from flectra import http
|
||||||
|
from flectra.http import request
|
||||||
|
from flectra.addons.auth_signup.controllers.main import AuthSignupHome
|
||||||
|
from flectra.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()
|
||||||
|
request.session.logout(keep_db=True)
|
||||||
|
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
|
||||||
|
)
|
16
addons/password_security/demo/res_users.xml
Normal file
16
addons/password_security/demo/res_users.xml
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Copyright 2016 LasLabs Inc.
|
||||||
|
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
|
||||||
|
-->
|
||||||
|
|
||||||
|
<flectra>
|
||||||
|
|
||||||
|
<record id="base.user_root" model="res.users">
|
||||||
|
<field name="password_write_date"
|
||||||
|
eval="datetime.now()"
|
||||||
|
/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</flectra>
|
12
addons/password_security/exceptions.py
Normal file
12
addons/password_security/exceptions.py
Normal file
@ -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 flectra.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)
|
6
addons/password_security/models/__init__.py
Normal file
6
addons/password_security/models/__init__.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# 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
|
47
addons/password_security/models/res_company.py
Normal file
47
addons/password_security/models/res_company.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# Copyright 2015 LasLabs Inc.
|
||||||
|
# Copyright 2004-TODAY FlectraHQ.
|
||||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
|
||||||
|
|
||||||
|
from flectra 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.Integer(
|
||||||
|
'Lowercase',
|
||||||
|
help='Require lowercase letters',
|
||||||
|
)
|
||||||
|
password_upper = fields.Integer(
|
||||||
|
'Uppercase',
|
||||||
|
help='Require uppercase letters',
|
||||||
|
)
|
||||||
|
password_numeric = fields.Integer(
|
||||||
|
'Numeric',
|
||||||
|
help='Require numeric digits',
|
||||||
|
)
|
||||||
|
password_special = fields.Integer(
|
||||||
|
'Special',
|
||||||
|
help='Require unique 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',
|
||||||
|
)
|
162
addons/password_security/models/res_users.py
Normal file
162
addons/password_security/models/res_users.py
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
# Copyright 2015 LasLabs Inc.
|
||||||
|
# Copyright 2004-TODAY FlectraHQ.
|
||||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from flectra 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 _check_password(self, password):
|
||||||
|
self._check_password_rules(password)
|
||||||
|
self._check_password_history(password)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@api.multi
|
||||||
|
def _check_password_rules(self, password):
|
||||||
|
self.ensure_one()
|
||||||
|
if not password:
|
||||||
|
return True
|
||||||
|
company_id = self.company_id
|
||||||
|
message = []
|
||||||
|
if company_id.password_lower and sum(map(str.islower, password)) < \
|
||||||
|
company_id.password_lower:
|
||||||
|
message.append('\n ' + _('Lowercase letter (At least ' +
|
||||||
|
str(company_id.password_lower) +
|
||||||
|
' character)')
|
||||||
|
)
|
||||||
|
if company_id.password_upper and sum(map(str.isupper, password)) < \
|
||||||
|
company_id.password_upper:
|
||||||
|
message.append('\n ' + _('Uppercase letter (At least ' +
|
||||||
|
str(company_id.password_upper) +
|
||||||
|
' character)')
|
||||||
|
)
|
||||||
|
if company_id.password_numeric and sum(map(str.isdigit, password)) < \
|
||||||
|
company_id.password_numeric:
|
||||||
|
message.append('\n ' + _('Numeric digit (At least ' +
|
||||||
|
str(company_id.password_numeric) +
|
||||||
|
' numeric)')
|
||||||
|
)
|
||||||
|
if company_id.password_special and len(set('[~!@#$%^&*()_+{}":;\']+$'
|
||||||
|
).intersection(
|
||||||
|
password)) < company_id.password_numeric:
|
||||||
|
message.append('\n ' + _('Special character (At least ' +
|
||||||
|
str(company_id.password_special) +
|
||||||
|
' character of [ ~ ! @ # $ % ^ & * ( )_+ {'
|
||||||
|
' } " : ; \' ])')
|
||||||
|
)
|
||||||
|
if company_id.password_length and len(password) < \
|
||||||
|
company_id.password_length:
|
||||||
|
message = [_('Password must be %d characters or more.') %
|
||||||
|
company_id.password_length] + message
|
||||||
|
if len(message) > 0:
|
||||||
|
raise PassError('\r'.join(message))
|
||||||
|
else:
|
||||||
|
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 _check_password_history(self, password):
|
||||||
|
""" It validates proposed password against existing history
|
||||||
|
:raises: PassError on reused password
|
||||||
|
"""
|
||||||
|
crypt = self._crypt_context()
|
||||||
|
for rec_id in self:
|
||||||
|
recent_passes = rec_id.company_id.password_history
|
||||||
|
if recent_passes < 0:
|
||||||
|
recent_passes = rec_id.password_history_ids
|
||||||
|
else:
|
||||||
|
recent_passes = rec_id.password_history_ids[
|
||||||
|
0:recent_passes-1
|
||||||
|
]
|
||||||
|
if 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
|
||||||
|
)
|
||||||
|
|
||||||
|
@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,
|
||||||
|
})],
|
||||||
|
})
|
25
addons/password_security/models/res_users_pass_history.py
Normal file
25
addons/password_security/models/res_users_pass_history.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Copyright 2016 LasLabs Inc.
|
||||||
|
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
|
||||||
|
|
||||||
|
from flectra 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
addons/password_security/security/ir.model.access.csv
Normal file
2
addons/password_security/security/ir.model.access.csv
Normal file
@ -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
|
|
20
addons/password_security/security/res_users_pass_history.xml
Normal file
20
addons/password_security/security/res_users_pass_history.xml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Copyright 2016 LasLabs Inc.
|
||||||
|
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
|
||||||
|
-->
|
||||||
|
|
||||||
|
<flectra>
|
||||||
|
|
||||||
|
<record id="res_users_pass_history_rule" model="ir.rule">
|
||||||
|
<field name="name">Res Users Pass History Access</field>
|
||||||
|
<field name="model_id"
|
||||||
|
ref="password_security.model_res_users_pass_history"/>
|
||||||
|
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||||
|
<field name="domain_force">[
|
||||||
|
('user_id', '=', user.id)
|
||||||
|
]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</flectra>
|
BIN
addons/password_security/static/description/icon.png
Normal file
BIN
addons/password_security/static/description/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.2 KiB |
7
addons/password_security/tests/__init__.py
Normal file
7
addons/password_security/tests/__init__.py
Normal file
@ -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
|
281
addons/password_security/tests/test_password_security_home.py
Normal file
281
addons/password_security/tests/test_password_security_home.py
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
# -*- 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 flectra.tests.common import TransactionCase
|
||||||
|
from flectra.http import Response
|
||||||
|
|
||||||
|
from ..controllers import main
|
||||||
|
|
||||||
|
|
||||||
|
IMPORT = 'flectra.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_log_out_if_expired(self):
|
||||||
|
"""It should log out user if password expired"""
|
||||||
|
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
|
||||||
|
self.password_security_home.web_login()
|
||||||
|
|
||||||
|
logout_mock = request.session.logout
|
||||||
|
logout_mock.assert_called_once_with(keep_db=True)
|
||||||
|
|
||||||
|
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 flectra.tests.common import TransactionCase
|
||||||
|
|
||||||
|
from ..controllers import main
|
||||||
|
|
||||||
|
|
||||||
|
IMPORT = 'flectra.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
addons/password_security/tests/test_res_users.py
Normal file
148
addons/password_security/tests/test_res_users.py
Normal file
@ -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 flectra.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
addons/password_security/views/res_company_view.xml
Normal file
42
addons/password_security/views/res_company_view.xml
Normal file
@ -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).
|
||||||
|
-->
|
||||||
|
|
||||||
|
<flectra>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
</flectra>
|
Loading…
Reference in New Issue
Block a user