diff --git a/data/other/password/100000-most-used.hwm b/data/other/password/100000-most-used.hwm new file mode 100644 index 000000000..e78de4a46 Binary files /dev/null and b/data/other/password/100000-most-used.hwm differ diff --git a/data/other/password/100000-most-used.pwd b/data/other/password/100000-most-used.pwd new file mode 100644 index 000000000..c794b0550 Binary files /dev/null and b/data/other/password/100000-most-used.pwd differ diff --git a/data/other/password/100000-most-used.pwi b/data/other/password/100000-most-used.pwi new file mode 100644 index 000000000..6b32139da Binary files /dev/null and b/data/other/password/100000-most-used.pwi differ diff --git a/locales/en.json b/locales/en.json index c876f1832..6275250ae 100644 --- a/locales/en.json +++ b/locales/en.json @@ -329,18 +329,15 @@ "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.", + "password_too_simple_1": "Password needs to be at least 6 characters long", + "password_too_simple_2": "Password needs to be at least 8 characters long and contains digit, upper and lower characters", + "password_too_simple_3": "Password needs to be at least 8 characters long and contains digit, upper, lower and special characters", + "password_too_simple_4": "Password needs to be at least 12 characters long and contains digit, upper, lower and special characters", + "password_listed_1": "This password is in a well known list. Please make it unique. Password needs to be at least 6 characters long", + "password_listed_2": "This password is in a well known list. Please make it unique. Password needs to be at least 8 characters long and contains digit, upper and lower characters", + "password_listed_3": "This password is in a well known list. Please make it unique. Password needs to be at least 8 characters long and contains digit, upper, lower and special characters", + "password_listed_4": "This password is in a well known list. Please make it unique. Password needs to be at least 12 characters long and contains digit, upper, lower and special characters", + "password_advice": "Advice: a good password is at least 8 characters and contains digit, upper, lower and special characters", "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/app.py b/src/yunohost/app.py index f405a4070..db0bb1bee 100644 --- a/src/yunohost/app.py +++ b/src/yunohost/app.py @@ -2252,8 +2252,8 @@ def _parse_action_args_in_yunohost_format(args, action_args, auth=None): m18n.n('app_argument_choice_invalid', name=arg_name, choices='yes, no, y, n, 1, 0')) elif arg_type == 'password': - from yunohost.tools import _check_password - _check_password(arg_value) + from yunohost.utils.password import LoggerPasswordValidator + LoggerPasswordValidator('user').validate(arg_value) args_dict[arg_name] = arg_value # END loop over action_args... diff --git a/src/yunohost/settings.py b/src/yunohost/settings.py index 2151ee6fd..3178b268d 100644 --- a/src/yunohost/settings.py +++ b/src/yunohost/settings.py @@ -30,6 +30,7 @@ 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_MODE = ["disabled", "weak", "strong"] PWD_CHOICES = ["error", "warn_only", "disabled"] PWD_DEFAULT_ERROR = {"type": "enum", "default": "error", "choices": PWD_CHOICES} @@ -41,39 +42,9 @@ DEFAULTS = OrderedDict([ ("example.enum", {"type": "enum", "default": "a", "choices": ["a", "b", "c"]}), # 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}), + # -1 disabled, 0 alert if listed, 1 6-letter, 2 normal, 3 strong, 4 strongest + ("security.password.admin.strength", {"type": "int", "default": 2}), + ("security.password.user.strength", {"type": "int", "default": 1}), ]) diff --git a/src/yunohost/tools.py b/src/yunohost/tools.py index ccfbbc28a..f18bdf8c9 100644 --- a/src/yunohost/tools.py +++ b/src/yunohost/tools.py @@ -129,9 +129,9 @@ def tools_adminpw(auth, new_password): """ from yunohost.user import _hash_user_password - from yunohost.utils.password import PasswordValidator + from yunohost.utils.password import LoggerPasswordValidator - PasswordValidator('admin').validate(new_password) + LoggerPasswordValidator('admin').validate(new_password) try: auth.update("cn=admin", { "userPassword": _hash_user_password(new_password), @@ -152,8 +152,8 @@ def tools_validatepw(password): password """ - from yunohost.utils.password import PasswordValidator - PasswordValidator('user').validate(password) + from yunohost.utils.password import LoggerPasswordValidator + LoggerPasswordValidator('user').validate(password) @is_unit_operation() @@ -288,8 +288,8 @@ def tools_postinstall(operation_logger, domain, password, ignore_dyndns=False, # Check password if not force_password: - from yunohost.utils.password import PasswordValidator - PasswordValidator('admin').validate(password) + from yunohost.utils.password import LoggerPasswordValidator + LoggerPasswordValidator('admin').validate(password) if not ignore_dyndns: # Check if yunohost dyndns can handle the given domain diff --git a/src/yunohost/user.py b/src/yunohost/user.py index 4299f0df7..d08e39e8c 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -39,7 +39,6 @@ from moulinette.core import MoulinetteError from moulinette.utils.log import getActionLogger from yunohost.service import service_status from yunohost.log import is_unit_operation -from yunohost.tools import _check_password logger = getActionLogger('yunohost.user') @@ -120,7 +119,8 @@ def user_create(operation_logger, auth, username, firstname, lastname, mail, pas from yunohost.app import app_ssowatconf # Ensure sufficiently complex password - PasswordValidator('user').validate(password) + from yunohost.utils.password import LoggerPasswordValidator + LoggerPasswordValidator('user').validate(password) # Validate uniqueness of username and mail in LDAP auth.validate_uniqueness({ @@ -309,7 +309,8 @@ def user_update(operation_logger, auth, username, firstname=None, lastname=None, if change_password: # Ensure sufficiently complex password - PasswordValidator('user').validate(change_password) + from yunohost.utils.password import LoggerPasswordValidator + LoggerPasswordValidator('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 1a660aea0..1e6bcf813 100644 --- a/src/yunohost/utils/password.py +++ b/src/yunohost/utils/password.py @@ -20,229 +20,172 @@ """ import sys -import re import os -import errno -import logging import cracklib -from moulinette import m18n -from moulinette.core import MoulinetteError -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') - +import string +ASCII_UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +ASCII_LOWERCASE = "abcdefghijklmnopqrstuvwxyz" PWDDICT_PATH = '/usr/local/share/dict/cracklib/' - -class HintException(Exception): - - 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(self.args[0] + '_warn', **self.kwargs) - - @property - def error(self): - return m18n.n(self.args[0] + '_error', **self.kwargs) - +SMALL_PWD_LIST = ["yunohost", "olinuxino", "olinux", "raspberry", "admin", + "root", "test", "rpi"] +PWD_LIST_FILE = '100000-most-used' +ACTIVATE_ONLINE_PWNED_LIST = False class PasswordValidator(object): """ PasswordValidator class validate password - Derivated from Nextcloud (AGPL-3) - https://github.com/nextcloud/password_policy/blob/fc4f77052cc248b4e68f0e9fb0d381ab7faf28ad/lib/PasswordValidator.php """ - # List of validators (order is important) - # We keep the online_pwned_list at the end to check only if needed - validators = ['length', 'numeric', 'upper_lower', 'special', - 'ynh_common_list', 'cracklib_list', 'online_pwned_list'] - def __init__(self, profile): - self.profile = profile - self.config = None - self._get_config() + # Unlisted, length, digits, lowers, uppers, others + strength_lvl = [ + [100000, 6, 0, 0, 0, 0], + [100000, 8, 1, 1, 1, 0], + [320000000, 8, 1, 1, 1, 1], + [320000000, 12, 1, 1, 1, 1], + ] - def validate(self, password, old=None, validator=None): + def __init__(self, validation_strength): + self.validation_strength = validation_strength + + def validate(self, password): """ Validate a password and raise error or display a warning """ - - result = {'error': [], 'warn_only': []} - # Check a specific validator only if enabled - if validator is not None and self.config[validator] == 'disabled': - return result - elif validator is not None: - try: - getattr(self, 'check_' + validator)(password, old) - except HintException as e: - criticity = self.config[validator] - if "warn_only" in [e.criticity, criticity]: - criticity = "warn_only" - result[criticity].append(e) - return result - - # Run all validators - for validator in self.validators: - if result['error'] and validator.endswith('_list'): - break - res = self.validate(password, old, validator) - result['error'] = result['error'] + res['error'] - result['warn_only'] = result['warn_only'] + res['warn_only'] - - # Build a concatenate message - message = [] - for error in result['error']: - message.append(error.error) - for warn in result['warn_only']: - message.append(warn.warn_only) - message = "\n".join(message) - - # Raise an error or warn the user according to criticity - if result['error']: - raise MoulinetteError(errno.EINVAL, message) - elif result['warn_only']: - logger.warn(message) - return result['warn_only'] - - def check_length(self, password, old=None): - """ - Check if password matches the minimum length defined by the admin - """ - - if len(password) < self.config['min_length.error']: - if self.config['min_length.warn'] == self.config['min_length.error']: - raise HintException('password_length', - min_length=self.config['min_length.error']) - else: - raise HintException('password_length_warn', - min_length=self.config['min_length.error'], - better_length=self.config['min_length.warn']) - - if len(password) < self.config['min_length.warn']: - e = HintException('password_length', - min_length=self.config['min_length.warn']) - e.criticity = 'warn_only' - raise e - - def check_numeric(self, password, old=None): - """ - Check if password contains numeric characters - """ - - if re.search(r'\d', password) is None: - raise HintException('password_numeric') - - def check_upper_lower(self, password, old=None): - """ - Check if password contains at least one upper and one lower case - character - """ - - if password.lower() == password or password.upper() == password: - raise HintException('password_upper_lower') - - def check_special(self, password, old=None): - """ - Check if password contains at least one special character - """ - - if re.match(r'^\w*$', password): - raise HintException('password_special') - - def check_ynh_common_list(self, password, old=None): - """ - Check if password is a common ynh password - """ - - if password in ["yunohost", "olinuxino", "olinux", "raspberry", "admin", - "root", "test"]: - raise HintException('password_listed') - - def check_cracklib_list(self, password, old=None): - """ - Check password with cracklib dictionnary from the config - https://github.com/danielmiessler/SecLists/tree/master/Passwords/Common-Credentials - """ - - error_dict = self.config['cracklib_list.error'] - warn_dict = self.config['cracklib_list.warn'] - - self._check_cracklib_list(password, old, error_dict) - - if error_dict == warn_dict: + if self.validation_strength <= 0: return - try: - self._check_cracklib_list(password, old, warn_dict) - except HintException as e: - e.criticity = 'warn_only' - raise e - def check_online_pwned_list(self, password, old=None, silent=True): + self.strength = self.compute(password, ACTIVATE_ONLINE_PWNED_LIST) + if self.strength < self.validation_strength: + if self.listed: + return "password_listed_" + str(self.validation_strength) + else: + return "password_too_simple_" + str(self.validation_strength) + + def compute(self, password, online=False): + # Indicators + length = len(password) + digits = 0 + uppers = 0 + lowers = 0 + others = 0 + + for character in password: + if character in string.digits: + digits = digits + 1 + elif character in ASCII_UPPERCASE: + uppers = uppers + 1 + elif character in ASCII_LOWERCASE: + lowers = lowers + 1 + else: + others = others + 1 + + # Check small list + unlisted = 0 + if password not in SMALL_PWD_LIST: + unlisted = len(SMALL_PWD_LIST) + + # Check big list + size_list = 100000 + if unlisted > 0 and not self.is_in_cracklib_list(password, PWD_LIST_FILE): + unlisted = size_list if online else 320000000 + + # Check online big list + if unlisted > size_list and online and not self.is_in_online_pwned_list(password): + unlisted = 320000000 + + self.listed = unlisted < 320000000 + return self.compare(unlisted, length, digits, lowers, uppers, others) + + def compare(self, unlisted, length, digits, lowers, uppers, others): + strength = 0 + + for i, config in enumerate(self.strength_lvl): + if unlisted < config[0] or length < config[1] \ + or digits < config[2] or lowers < config[3] \ + or uppers < config[4] or others < config[5]: + break + strength = i + 1 + return strength + + def is_in_online_pwned_list(self, password, silent=True): """ Check if a password is in the list of breached passwords from haveibeenpwned.com """ from hashlib import sha1 - from moulinette.utils.network import download_text + import requests hash = sha1(password).hexdigest() range = hash[:5] needle = (hash[5:].upper()) try: - hash_list = download_text('https://api.pwnedpasswords.com/range/' + - range) - except MoulinetteError as e: + hash_list =requests.get('https://api.pwnedpasswords.com/range/' + + range, timeout=30) + except e: if not silent: raise else: if hash_list.find(needle) != -1: - raise HintException('password_listed') + return True + return False - def _check_cracklib_list(self, password, old, pwd_dict): + def is_in_cracklib_list(self, password, pwd_dict): try: - cracklib.VeryFascistCheck(password, old, + cracklib.VeryFascistCheck(password, None, os.path.join(PWDDICT_PATH, pwd_dict)) except ValueError as e: # We only want the dictionnary check of cracklib, not the is_simple # test. if str(e) not in ["is too simple", "is a palindrome"]: - raise HintException('password_listed', pwd_list=pwd_dict) + return True - def _get_config(self): + +class ProfilePasswordValidator(PasswordValidator): + def __init__(self, profile): + self.profile = profile + import json + try: + settings = json.load(open('/etc/yunohost/settings.json', "r")) + self.validation_strength = int(settings["security.password." + profile + + '.strength']) + except Exception as e: + self.validation_strength = 2 if profile == 'admin' else 1 + return + +class LoggerPasswordValidator(ProfilePasswordValidator): + """ + PasswordValidator class validate password + """ + + def validate(self, password): """ - Build profile config from settings + Validate a password and raise error or display a warning """ + if self.validation_strength == -1: + return + import errno + import logging + from moulinette import m18n + from moulinette.core import MoulinetteError + from moulinette.utils.log import getActionLogger - def _set_param(name): - self.config[name] = self._get_setting(name) + logger = logging.getLogger('yunohost.utils.password') - 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' + error = super(LoggerPasswordValidator, self).validate(password) + if error is not None: + raise MoulinetteError(1, m18n.n(error)) - 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') + if self.strength < 3: + logger.info(m18n.n('password_advice')) - return self.config +if __name__ == '__main__': + if len(sys.argv) < 2: + print("usage: password.py PASSWORD") + + result = ProfilePasswordValidator('user').validate(sys.argv[1]) + if result is not None: + sys.exit(result) - def _get_setting(self, setting): - return settings_get('security.password.' + self.profile + '.' + setting) -