From 67e82111c14721acb5d3384b28b5a3a3cfcb1ccc Mon Sep 17 00:00:00 2001 From: ljf Date: Tue, 28 Aug 2018 03:09:43 +0200 Subject: [PATCH] [enh] Validate pwd with Online Pwned List --- data/actionsmap/yunohost.yml | 12 ++++++ debian/install | 1 + locales/en.json | 12 ++++++ src/yunohost/settings.py | 45 ++++++++++++++++++--- src/yunohost/tools.py | 38 ++++++++--------- src/yunohost/user.py | 4 +- src/yunohost/utils/password.py | 74 ++++++++++++++++++---------------- 7 files changed, 121 insertions(+), 65 deletions(-) diff --git a/data/actionsmap/yunohost.yml b/data/actionsmap/yunohost.yml index 9c8ac191c..6108da07b 100644 --- a/data/actionsmap/yunohost.yml +++ b/data/actionsmap/yunohost.yml @@ -1450,6 +1450,18 @@ tools: pattern: *pattern_password required: True + ### tools_validatepw() + validatepw: + action_help: Validate a password + api: PUT /validatepw + arguments: + -p: + full: --password + extra: + password: ask_password + pattern: *pattern_password + required: True + ### tools_maindomain() maindomain: action_help: Check the current main domain, or change it diff --git a/debian/install b/debian/install index e9c79e963..c616db73a 100644 --- a/debian/install +++ b/debian/install @@ -4,6 +4,7 @@ data/bash-completion.d/yunohost /etc/bash_completion.d/ data/actionsmap/* /usr/share/moulinette/actionsmap/ data/hooks/* /usr/share/yunohost/hooks/ data/other/yunoprompt.service /etc/systemd/system/ +data/other/password/* /usr/local/share/dict/cracklib/ data/other/* /usr/share/yunohost/yunohost-config/moulinette/ data/templates/* /usr/share/yunohost/templates/ data/helpers /usr/share/yunohost/ diff --git a/locales/en.json b/locales/en.json index 074512311..c876f1832 100644 --- a/locales/en.json +++ b/locales/en.json @@ -329,6 +329,18 @@ "packages_no_upgrade": "There is no package to upgrade", "packages_upgrade_critical_later": "Critical packages ({packages:s}) will be upgraded later", "packages_upgrade_failed": "Unable to upgrade all of the packages", + "password_length_error": "Password needs to be at least {min_length} characters long", + "password_length_warn": "It would be better if your password was at least {min_length} characters long", + "password_length_warn_error": "Password needs to be at least {min_length} characters long. {better} would be better", + "password_length_warn_warn": "It would be better if your password was at least {better} characters long", + "password_numeric_error": "Password needs to contain at least one numeric character", + "password_numeric_warn": "It would better if your password contained at least one numeric character", + "password_upper_lower_error": "Password needs to contain at least one lower and one upper case character", + "password_upper_lower_warn": "It would be bettre if your password contained at least one lower and one upper case character", + "password_special_error": "Password needs to contain at least one special character.", + "password_special_warn": "It would be better if your password contained at least one special character", + "password_listed_error": "Password is in a well known list. Please make it unique.", + "password_listed_warn": "Password is in a known list. It would be better to make it unique.", "path_removal_failed": "Unable to remove path {:s}", "pattern_backup_archive_name": "Must be a valid filename with max 30 characters, and alphanumeric and -_. characters only", "pattern_domain": "Must be a valid domain name (e.g. my-domain.org)", diff --git a/src/yunohost/settings.py b/src/yunohost/settings.py index e694a861a..2151ee6fd 100644 --- a/src/yunohost/settings.py +++ b/src/yunohost/settings.py @@ -30,17 +30,50 @@ SETTINGS_PATH_OTHER_LOCATION = "/etc/yunohost/settings-%s.json" # * enum (in form a python list) # we don't store the value in default options +PWD_CHOICES = ["error", "warn_only", "disabled"] +PWD_DEFAULT_ERROR = {"type": "enum", "default": "error", + "choices": PWD_CHOICES} + DEFAULTS = OrderedDict([ ("example.bool", {"type": "bool", "default": True}), ("example.int", {"type": "int", "default": 42}), ("example.string", {"type": "string", "default": "yolo swag"}), ("example.enum", {"type": "enum", "default": "a", "choices": ["a", "b", "c"]}), - # Control the way password are checked - # -1 No control - # 0 Just display weak password info in debug - # 1 Warn user about weak password - # 2 Raise an error when the user put a weak password - ("security.password.check_mode", {"type": "int", "default": 2}), + + # Password Validation + + ("security.password.admin.mode", PWD_DEFAULT_ERROR), + ("security.password.user.mode", PWD_DEFAULT_ERROR), + ("security.password.admin.length", PWD_DEFAULT_ERROR), + ("security.password.user.length", PWD_DEFAULT_ERROR), + ("security.password.admin.min_length.error", {"type": "int", "default": 8}), + ("security.password.user.min_length.error", {"type": "int", "default": 8}), + ("security.password.admin.min_length.warn", {"type": "int", "default": 12}), + ("security.password.user.min_length.warn", {"type": "int", "default": 8}), + ("security.password.admin.special", PWD_DEFAULT_ERROR), + ("security.password.user.special", PWD_DEFAULT_ERROR), + ("security.password.admin.numeric", PWD_DEFAULT_ERROR), + ("security.password.user.numeric", PWD_DEFAULT_ERROR), + ("security.password.admin.upper_lower", PWD_DEFAULT_ERROR), + ("security.password.user.upper_lower", PWD_DEFAULT_ERROR), + ("security.password.admin.ynh_common_list", PWD_DEFAULT_ERROR), + ("security.password.user.ynh_common_list", PWD_DEFAULT_ERROR), + ("security.password.admin.common_list", PWD_DEFAULT_ERROR), + ("security.password.user.common_list", PWD_DEFAULT_ERROR), + ("security.password.admin.cracklib_list", PWD_DEFAULT_ERROR), + ("security.password.user.cracklib_list", PWD_DEFAULT_ERROR), + ("security.password.admin.cracklib_list.error", {"type": "string", "default": + "1000000-most-used"}), + ("security.password.admin.cracklib_list.warn", {"type": "string", "default": + "1000000-most-used"}), + ("security.password.user.cracklib_list.error", {"type": "string", "default": + "100000-most-used"}), + ("security.password.user.cracklib_list.warn", {"type": "string", "default": + "1000000-most-used"}), + ("security.password.admin.online_pwned_list", {"type": "enum", "default": "disabled", + "choices": PWD_CHOICES}), + ("security.password.user.online_pwned_list", {"type": "enum", "default": "disabled", + "choices": PWD_CHOICES}), ]) diff --git a/src/yunohost/tools.py b/src/yunohost/tools.py index b46d61259..ccfbbc28a 100644 --- a/src/yunohost/tools.py +++ b/src/yunohost/tools.py @@ -129,8 +129,9 @@ def tools_adminpw(auth, new_password): """ from yunohost.user import _hash_user_password + from yunohost.utils.password import PasswordValidator - _check_password(new_password) + PasswordValidator('admin').validate(new_password) try: auth.update("cn=admin", { "userPassword": _hash_user_password(new_password), @@ -143,6 +144,18 @@ def tools_adminpw(auth, new_password): logger.success(m18n.n('admin_password_changed')) +def tools_validatepw(password): + """ + Validate password + + Keyword argument: + password + + """ + from yunohost.utils.password import PasswordValidator + PasswordValidator('user').validate(password) + + @is_unit_operation() def tools_maindomain(operation_logger, auth, new_domain=None): """ @@ -275,7 +288,8 @@ def tools_postinstall(operation_logger, domain, password, ignore_dyndns=False, # Check password if not force_password: - _check_password(password) + from yunohost.utils.password import PasswordValidator + PasswordValidator('admin').validate(password) if not ignore_dyndns: # Check if yunohost dyndns can handle the given domain @@ -1057,23 +1071,3 @@ class Migration(object): def description(self): return m18n.n("migration_description_%s" % self.id) -def _check_password(password): - security_level = settings_get('security.password.check_mode') - if security_level == -1: - return - try: - if password in ["yunohost", "olinuxino", "olinux"]: - raise MoulinetteError(errno.EINVAL, m18n.n('password_too_weak') + - ' : it is based on a (reversed) dictionary word' ) - - try: - cracklib.VeryFascistCheck(password) - except ValueError as e: - raise MoulinetteError(errno.EINVAL, m18n.n('password_too_weak') + " : " + str(e) ) - except MoulinetteError as e: - if security_level >= 2: - raise - elif security_level == 1: - logger.warn(e.strerror) - else: - logger.debug(e.strerror) diff --git a/src/yunohost/user.py b/src/yunohost/user.py index ae7edfa2e..4299f0df7 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -120,7 +120,7 @@ def user_create(operation_logger, auth, username, firstname, lastname, mail, pas from yunohost.app import app_ssowatconf # Ensure sufficiently complex password - _check_password(password) + PasswordValidator('user').validate(password) # Validate uniqueness of username and mail in LDAP auth.validate_uniqueness({ @@ -309,7 +309,7 @@ def user_update(operation_logger, auth, username, firstname=None, lastname=None, if change_password: # Ensure sufficiently complex password - _check_password(change_password) + PasswordValidator('user').validate(change_password) new_attr_dict['userPassword'] = _hash_user_password(change_password) diff --git a/src/yunohost/utils/password.py b/src/yunohost/utils/password.py index bacf91502..1a660aea0 100644 --- a/src/yunohost/utils/password.py +++ b/src/yunohost/utils/password.py @@ -32,21 +32,25 @@ from moulinette.utils.log import getActionLogger from moulinette.utils.network import download_text from yunohost.settings import settings_get +logger = logging.getLogger('yunohost.utils.password') + PWDDICT_PATH = '/usr/local/share/dict/cracklib/' class HintException(Exception): - def __init__(self, message): + def __init__(self, message, **kwargs): # Call the base class constructor with the parameters it needs super(HintException, self).__init__(message) - + self.kwargs = kwargs self.criticity = 'error' + @property def warn_only(self): - return m18n.n(e.args[0] + '_warn', **e.kwargs) + return m18n.n(self.args[0] + '_warn', **self.kwargs) + @property def error(self): - return m18n.n(e.args[0], **e.kwargs) + return m18n.n(self.args[0] + '_error', **self.kwargs) class PasswordValidator(object): @@ -114,13 +118,12 @@ class PasswordValidator(object): if len(password) < self.config['min_length.error']: if self.config['min_length.warn'] == self.config['min_length.error']: - e = HintException('password_length', - min_length=min_length) + raise HintException('password_length', + min_length=self.config['min_length.error']) else: - e = HintException('password_length_warn', + raise HintException('password_length_warn', min_length=self.config['min_length.error'], - better_length=self.config['min_length.warn'] - raise e + better_length=self.config['min_length.warn']) if len(password) < self.config['min_length.warn']: e = HintException('password_length', @@ -133,7 +136,7 @@ class PasswordValidator(object): Check if password contains numeric characters """ - if re.match(r'\d', password) is None: + if re.search(r'\d', password) is None: raise HintException('password_numeric') def check_upper_lower(self, password, old=None): @@ -150,7 +153,7 @@ class PasswordValidator(object): Check if password contains at least one special character """ - if re.match(r'\w+'): + if re.match(r'^\w*$', password): raise HintException('password_special') def check_ynh_common_list(self, password, old=None): @@ -158,7 +161,8 @@ class PasswordValidator(object): Check if password is a common ynh password """ - if password in ["yunohost", "olinuxino", "olinux"]: + if password in ["yunohost", "olinuxino", "olinux", "raspberry", "admin", + "root", "test"]: raise HintException('password_listed') def check_cracklib_list(self, password, old=None): @@ -167,8 +171,8 @@ class PasswordValidator(object): https://github.com/danielmiessler/SecLists/tree/master/Passwords/Common-Credentials """ - error_dict = self.config['cracklib.error'] - warn_dict = self.config['cracklib.warn'] + error_dict = self.config['cracklib_list.error'] + warn_dict = self.config['cracklib_list.warn'] self._check_cracklib_list(password, old, error_dict) @@ -190,7 +194,7 @@ class PasswordValidator(object): from moulinette.utils.network import download_text hash = sha1(password).hexdigest() range = hash[:5] - needle = (hash[5:]) + needle = (hash[5:].upper()) try: hash_list = download_text('https://api.pwnedpasswords.com/range/' + @@ -202,7 +206,7 @@ class PasswordValidator(object): if hash_list.find(needle) != -1: raise HintException('password_listed') - def _check_cracklib_list(self, password, old=None, pwd_dict): + def _check_cracklib_list(self, password, old, pwd_dict): try: cracklib.VeryFascistCheck(password, old, os.path.join(PWDDICT_PATH, pwd_dict)) @@ -212,33 +216,33 @@ class PasswordValidator(object): if str(e) not in ["is too simple", "is a palindrome"]: raise HintException('password_listed', pwd_list=pwd_dict) - def _get_config(self, name=None): + def _get_config(self): """ Build profile config from settings """ - if name is None: - if self.config is not None: - return self.config - self.config = {} - self.config['mode'] = _get_setting('mode') - for validator in self.validators: - self._get_config(validator) - self.config['min_length.error'] = _get_setting('min_length.error') - self.config['min_length.warn'] = _get_setting('min_length.warn') - self.config['cracklib.error'] = _get_setting('cracklib.error') - self.config['cracklib.warn'] = _get_setting('cracklib.warn') + def _set_param(name): + self.config[name] = self._get_setting(name) + if self.config[name] == 'error' and self.config['mode'] == 'warn_only': + self.config[name] = self.config['mode'] + elif self.config[name] in ['error', 'warn_only'] and \ + self.config['mode'] == 'disabled': + self.config[name] = 'disabled' + + if self.config is not None: return self.config + self.config = {} + self.config['mode'] = self._get_setting('mode') + for validator in self.validators: + _set_param(validator) + for param in ['min_length.', 'cracklib_list.']: + self.config[param + 'error'] = self._get_setting(param + 'error') + self.config[param + 'warn'] = self._get_setting(param + 'warn') - self.config[name] = settings_get('security.password' + self.profile + '.' - + name) + return self.config - if self.config[name] == 'error' and self.config['mode'] == 'warn_only': - self.config[name] = self.config['mode'] - elif self.config[name] in ['error', 'warn_only'] and - self.config['mode'] == 'disabled': def _get_setting(self, setting): - return settings_get('security.password' + self.profile + '.' + setting) + return settings_get('security.password.' + self.profile + '.' + setting)