diff --git a/CHANGELOG b/CHANGELOG index f900d2ba11..0acbfc911b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +* Add constraint on user password * Remove Property field * Add MultiValueMixin and ValueMixin * Use sql type in column creation diff --git a/MANIFEST.in b/MANIFEST.in index 04504c8562..873dc7561a 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -22,3 +22,4 @@ include trytond/res/view/*.xml include trytond/res/locale/*.po include trytond/tests/tryton.cfg include trytond/tests/*.xml +include trytond/tests/forbidden.txt diff --git a/doc/topics/configuration.rst b/doc/topics/configuration.rst index 8ad759635b..1ed05e6183 100644 --- a/doc/topics/configuration.rst +++ b/doc/topics/configuration.rst @@ -228,6 +228,28 @@ attempts over a period of `timeout`. Default: `5` +password +-------- + +length +~~~~~~ + +The minimal length required for the user password. + +Default: `8` + +forbidden +~~~~~~~~~ + +The path to a file containing one forbidden password per line. + +entropy +~~~~~~~ + +The ratio of non repeated characters for the user password. + +Default: `0.75` + report ------ diff --git a/setup.py b/setup.py index 657a5d9450..0bd92418fe 100644 --- a/setup.py +++ b/setup.py @@ -57,7 +57,7 @@ setup(name=name, 'trytond.ir.module': ['*.xml'], 'trytond.ir.ui': ['*.xml', '*.rng', '*.rnc'], 'trytond.res': ['tryton.cfg', '*.xml', 'view/*.xml', 'locale/*.po'], - 'trytond.tests': ['tryton.cfg', '*.xml'], + 'trytond.tests': ['tryton.cfg', '*.xml', 'forbidden.txt'], }, scripts=['bin/trytond', 'bin/trytond-admin', 'bin/trytond-cron'], classifiers=[ diff --git a/trytond/config.py b/trytond/config.py index 464f259e1a..bfdc2e4807 100644 --- a/trytond/config.py +++ b/trytond/config.py @@ -63,6 +63,9 @@ class TrytonConfigParser(ConfigParser.RawConfigParser): self.set('session', 'authentications', 'password') self.set('session', 'timeout', 600) self.set('session', 'max_attempt', 5) + self.add_section('password') + self.set('password', 'length', 8) + self.set('password', 'entropy', 0.75) self.add_section('report') self.set('report', 'unoconv', 'pipe,name=trytond;urp;StarOffice.ComponentContext') diff --git a/trytond/res/user.py b/trytond/res/user.py index 7387bfbe02..280fdc7839 100644 --- a/trytond/res/user.py +++ b/trytond/res/user.py @@ -1,6 +1,7 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. "User" +from __future__ import division import copy import string import random @@ -9,6 +10,7 @@ import time import datetime import logging import uuid +import mmap from functools import wraps from itertools import groupby, ifilter from operator import attrgetter @@ -116,6 +118,17 @@ class User(ModelSQL, ModelView): 'for logging purpose.\n' 'Instead you must inactivate them.'), 'wrong_password': 'Wrong password!', + 'password_length': "The password is too short.", + 'password_forbidden': "The password is forbidden.", + 'password_name': ( + "The password can not be the same as user name."), + 'password_login': ( + "The password can not be the same as user login."), + 'password_email': ( + "The password can not be the same as user email."), + 'password_entropy': ( + "The password contains too much times " + "the same characters."), }) @classmethod @@ -200,6 +213,10 @@ class User(ModelSQL, ModelView): def set_password(cls, users, name, value): if value == 'x' * 10: return + + if Transaction().user and value: + cls.validate_password(value, users) + to_write = [] for user in users: to_write.extend([[user], { @@ -207,6 +224,36 @@ class User(ModelSQL, ModelView): }]) cls.write(*to_write) + @classmethod + def validate_password(cls, password, users): + length = config.getint('password', 'length', default=0) + if length > 0: + password_b = password + if isinstance(password, unicode): + password_b = password.encode('utf-8') + if len(password_b) < length: + cls.raise_user_error('password_length') + path = config.get('password', 'forbidden', default=None) + if path: + with open(path, 'r') as f: + forbidden = mmap.mmap( + f.fileno(), 0, access=mmap.ACCESS_READ) + if forbidden.find(password) >= 0: + cls.raise_user_error('password_forbidden') + entropy = config.getfloat('password', 'entropy', default=0) + if entropy: + if len(set(password)) / len(password) < entropy: + cls.raise_user_error('password_entropy') + for user in users: + # Use getattr to allow to use non User instances + for test, error in [ + (getattr(user, 'name', ''), 'password_name'), + (getattr(user, 'login', ''), 'password_login'), + (getattr(user, 'email', ''), 'password_email'), + ]: + if test and password.lower() == test.lower(): + cls.raise_user_error(error) + @staticmethod def get_sessions(users, name): Session = Pool().get('ir.session') diff --git a/trytond/tests/forbidden.txt b/trytond/tests/forbidden.txt new file mode 100644 index 0000000000..f3097ab130 --- /dev/null +++ b/trytond/tests/forbidden.txt @@ -0,0 +1 @@ +password diff --git a/trytond/tests/test_user.py b/trytond/tests/test_user.py index 110f622aef..2a76ac79fd 100644 --- a/trytond/tests/test_user.py +++ b/trytond/tests/test_user.py @@ -1,11 +1,12 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of this # repository contains the full copyright notices and license terms. - +import os import unittest from trytond.tests.test_tryton import activate_module, with_transaction from trytond.pool import Pool from trytond.res.user import bcrypt from trytond.config import config +from trytond.error import UserError class UserTestCase(unittest.TestCase): @@ -20,6 +21,20 @@ class UserTestCase(unittest.TestCase): config.set('session', 'authentications', 'password') self.addCleanup(config.set, 'session', 'authentications', methods) + length = config.get('password', 'length') + config.set('password', 'length', 4) + self.addCleanup(config.set, 'password', 'length', length) + + forbidden = config.get('password', 'forbidden', default='') + config.set( + 'password', 'forbidden', + os.path.join(os.path.dirname(__file__), 'forbidden.txt')) + self.addCleanup(config.set, 'password', 'forbidden', forbidden) + + entropy = config.get('password', 'entropy') + config.set('password', 'entropy', 0.9) + self.addCleanup(config.set, 'password', 'entropy', entropy) + def create_user(self, login, password, hash_method=None): pool = Pool() User = pool.get('res.user') @@ -79,6 +94,64 @@ class UserTestCase(unittest.TestCase): user = self.create_user('user', '12345') self.assertIsNone(user.password_hash) + @with_transaction() + def test_validate_password_length(self): + "Test validate password length" + pool = Pool() + User = pool.get('res.user') + + with self.assertRaises(UserError): + User.validate_password(u'123', []) + User.validate_password(u'1234', []) + + @with_transaction() + def test_validate_password_forbidden(self): + "Test validate password forbidden" + pool = Pool() + User = pool.get('res.user') + + with self.assertRaises(UserError): + User.validate_password(u'password', []) + + @with_transaction() + def test_validate_password_entropy(self): + "Test validate password entropy" + pool = Pool() + User = pool.get('res.user') + + with self.assertRaises(UserError): + User.validate_password('aaaaaa', []) + + @with_transaction() + def test_validate_password_name(self): + "Test validate password name" + pool = Pool() + User = pool.get('res.user') + user = User(name='name') + + with self.assertRaises(UserError): + User.validate_password('name', [user]) + + @with_transaction() + def test_validate_password_login(self): + "Test validate password login" + pool = Pool() + User = pool.get('res.user') + user = User(login='login') + + with self.assertRaises(UserError): + User.validate_password('login', [user]) + + @with_transaction() + def test_validate_password_email(self): + "Test validate password email" + pool = Pool() + User = pool.get('res.user') + user = User(email='email') + + with self.assertRaises(UserError): + User.validate_password('email', [user]) + def suite(): return unittest.TestLoader().loadTestsFromTestCase(UserTestCase)