diff --git a/data/actionsmap/yunohost.yml b/data/actionsmap/yunohost.yml index 6108da07b..102be300d 100644 --- a/data/actionsmap/yunohost.yml +++ b/data/actionsmap/yunohost.yml @@ -125,6 +125,7 @@ user: pattern: &pattern_password - !!str ^.{3,}$ - "pattern_password" + comment: good_practices_about_user_password -q: full: --mailbox-quota help: Mailbox size quota @@ -1449,6 +1450,7 @@ tools: password: ask_new_admin_password pattern: *pattern_password required: True + comment: good_practices_about_admin_password ### tools_validatepw() validatepw: @@ -1498,6 +1500,7 @@ tools: password: ask_new_admin_password pattern: *pattern_password required: True + comment: good_practices_about_admin_password --ignore-dyndns: help: Do not subscribe domain to a DynDNS service action: store_true diff --git a/locales/en.json b/locales/en.json index 6275250ae..8b0908523 100644 --- a/locales/en.json +++ b/locales/en.json @@ -197,6 +197,8 @@ "global_settings_setting_example_string": "Example string option", "global_settings_unknown_setting_from_settings_file": "Unknown key in settings: '{setting_key:s}', discarding it and save it in /etc/yunohost/unkown_settings.json", "global_settings_unknown_type": "Unexpected situation, the setting {setting:s} appears to have the type {unknown_type:s} but it's not a type supported by the system.", + "good_practices_about_admin_password": "You are now about to define a new administration password. The password should be at least 8 characters - though it is good practice to use longer password (i.e. a passphrase) and/or to use various kind of characters (uppercase, lowercase, digits and special characters).", + "good_practices_about_user_password": "You are now about to define a new user password. The password should be at least 8 characters - though it is good practice to use longer password (i.e. a passphrase) and/or to use various kind of characters (uppercase, lowercase, digits and special characters).", "hook_exec_failed": "Script execution failed: {path:s}", "hook_exec_not_terminated": "Script execution hasn\u2019t terminated: {path:s}", "hook_list_by_invalid": "Invalid property to list hook by", @@ -329,15 +331,11 @@ "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_too_simple_1": "Password needs to be at least 6 characters long", + "password_listed": "This password is among the most used password in the world. Please choose something a bit more unique.", + "password_too_simple_1": "Password needs to be at least 8 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 db0bb1bee..13660a127 100644 --- a/src/yunohost/app.py +++ b/src/yunohost/app.py @@ -2189,11 +2189,15 @@ def _parse_action_args_in_yunohost_format(args, action_args, auth=None): for domain in domain_list(auth)['domains']: msignals.display("- {}".format(domain)) - if arg_type == 'user': + elif arg_type == 'user': msignals.display(m18n.n('users_available')) for user in user_list(auth)['users'].keys(): msignals.display("- {}".format(user)) + elif arg_type == 'password': + msignals.display(m18n.n('good_practices_about_user_password')) + + try: input_string = msignals.prompt(ask_string, is_password) except NotImplementedError: @@ -2252,8 +2256,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.utils.password import LoggerPasswordValidator - LoggerPasswordValidator('user').validate(arg_value) + from yunohost.utils.password import assert_password_is_strong_enough + assert_password_is_strong_enough('user', 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 3178b268d..d2526316e 100644 --- a/src/yunohost/settings.py +++ b/src/yunohost/settings.py @@ -29,12 +29,6 @@ SETTINGS_PATH_OTHER_LOCATION = "/etc/yunohost/settings-%s.json" # * string # * 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} - DEFAULTS = OrderedDict([ ("example.bool", {"type": "bool", "default": True}), ("example.int", {"type": "int", "default": 42}), @@ -42,8 +36,8 @@ DEFAULTS = OrderedDict([ ("example.enum", {"type": "enum", "default": "a", "choices": ["a", "b", "c"]}), # Password Validation - # -1 disabled, 0 alert if listed, 1 6-letter, 2 normal, 3 strong, 4 strongest - ("security.password.admin.strength", {"type": "int", "default": 2}), + # -1 disabled, 0 alert if listed, 1 8-letter, 2 normal, 3 strong, 4 strongest + ("security.password.admin.strength", {"type": "int", "default": 1}), ("security.password.user.strength", {"type": "int", "default": 1}), ]) diff --git a/src/yunohost/tools.py b/src/yunohost/tools.py index f18bdf8c9..c54355c36 100644 --- a/src/yunohost/tools.py +++ b/src/yunohost/tools.py @@ -54,7 +54,6 @@ from yunohost.monitor import monitor_disk, monitor_system from yunohost.utils.packages import ynh_packages_version from yunohost.utils.network import get_public_ip from yunohost.log import is_unit_operation, OperationLogger -from yunohost.settings import settings_get # FIXME this is a duplicate from apps.py APPS_SETTING_PATH = '/etc/yunohost/apps/' @@ -129,9 +128,10 @@ def tools_adminpw(auth, new_password): """ from yunohost.user import _hash_user_password - from yunohost.utils.password import LoggerPasswordValidator + from yunohost.utils.password import assert_password_is_strong_enough + + assert_password_is_strong_enough("admin", new_password) - LoggerPasswordValidator('admin').validate(new_password) try: auth.update("cn=admin", { "userPassword": _hash_user_password(new_password), @@ -152,8 +152,9 @@ def tools_validatepw(password): password """ - from yunohost.utils.password import LoggerPasswordValidator - LoggerPasswordValidator('user').validate(password) + + from yunohost.utils.password import assert_password_is_strong_enough + assert_password_is_strong_enough("user", password) @is_unit_operation() @@ -279,6 +280,8 @@ def tools_postinstall(operation_logger, domain, password, ignore_dyndns=False, password -- YunoHost admin password """ + from yunohost.utils.password import assert_password_is_strong_enough + dyndns_provider = "dyndns.yunohost.org" # Do some checks at first @@ -288,8 +291,7 @@ def tools_postinstall(operation_logger, domain, password, ignore_dyndns=False, # Check password if not force_password: - from yunohost.utils.password import LoggerPasswordValidator - LoggerPasswordValidator('admin').validate(password) + assert_password_is_strong_enough("admin", 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 d08e39e8c..083030bd4 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -117,10 +117,10 @@ def user_create(operation_logger, auth, username, firstname, lastname, mail, pas from yunohost.domain import domain_list, _get_maindomain from yunohost.hook import hook_callback from yunohost.app import app_ssowatconf + from yunohost.utils.password import assert_password_is_strong_enough # Ensure sufficiently complex password - from yunohost.utils.password import LoggerPasswordValidator - LoggerPasswordValidator('user').validate(password) + assert_password_is_strong_enough("user", password) # Validate uniqueness of username and mail in LDAP auth.validate_uniqueness({ @@ -284,6 +284,7 @@ def user_update(operation_logger, auth, username, firstname=None, lastname=None, """ from yunohost.domain import domain_list from yunohost.app import app_ssowatconf + from yunohost.utils.password import assert_password_is_strong_enough attrs_to_fetch = ['givenName', 'sn', 'mail', 'maildrop'] new_attr_dict = {} @@ -309,8 +310,7 @@ def user_update(operation_logger, auth, username, firstname=None, lastname=None, if change_password: # Ensure sufficiently complex password - from yunohost.utils.password import LoggerPasswordValidator - LoggerPasswordValidator('user').validate(change_password) + assert_password_is_strong_enough("user", 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 306be6103..6b31285e7 100644 --- a/src/yunohost/utils/password.py +++ b/src/yunohost/utils/password.py @@ -2,7 +2,7 @@ """ License - Copyright (C) 2013 YunoHost + Copyright (C) 2018 YunoHost This program is free software; you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published @@ -21,53 +21,108 @@ import sys import os +import json import cracklib - import string -ASCII_UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" -ASCII_LOWERCASE = "abcdefghijklmnopqrstuvwxyz" -PWDDICT_PATH = '/usr/local/share/dict/cracklib/' + SMALL_PWD_LIST = ["yunohost", "olinuxino", "olinux", "raspberry", "admin", "root", "test", "rpi"] -PWD_LIST_FILE = '100000-most-used' -ACTIVATE_ONLINE_PWNED_LIST = False + +MOST_USED_PASSWORDS = '/usr/local/share/dict/cracklib/100000-most-used' + +# Length, digits, lowers, uppers, others +STRENGTH_LEVELS = [ + (8, 0, 0, 0, 0), + (8, 1, 1, 1, 0), + (8, 1, 1, 1, 1), + (12, 1, 1, 1, 1), +] + +def assert_password_is_strong_enough(profile, password): + PasswordValidator(profile).validate(password) class PasswordValidator(object): - """ - PasswordValidator class validate password - """ - # 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 __init__(self, profile): + """ + Initialize a password validator. - def __init__(self, validation_strength): - self.validation_strength = validation_strength + The profile shall be either "user" or "admin" + and will correspond to a validation strength + defined via the setting "security.password..strength" + """ + + self.profile = profile + try: + # We do this "manually" instead of using settings_get() + # from settings.py because this file is also meant to be + # use as a script by ssowat. + # (or at least that's my understanding -- Alex) + settings = json.load(open('/etc/yunohost/settings.json', "r")) + setting_key = "security.password." + profile + ".strength" + self.validation_strength = int(settings[setting_key]) + except Exception as e: + # Fallback to default value if we can't fetch settings for some reason + self.validation_strength = 1 def validate(self, password): """ - Validate a password and raise error or display a warning + Check the validation_summary and trigger an exception + if the password does not pass tests. + + This method is meant to be used from inside YunoHost's code + (compared to validation_summary which is meant to be called + by ssowat) """ - if self.validation_strength <= 0: + if self.validation_strength == -1: + return + + # Note that those imports are made here and can't be put + # on top (at least not the moulinette ones) + # because the moulinette needs to be correctly initialized + # as well as modules available in python's path. + import errno + import logging + from moulinette import m18n + from moulinette.core import MoulinetteError + from moulinette.utils.log import getActionLogger + + logger = logging.getLogger('yunohost.utils.password') + + status, msg = self.validation_summary(password) + if status == "error": + raise MoulinetteError(1, m18n.n(msg)) + + def validation_summary(self, password): + """ + Check if a password is listed in the list of most used password + and if the overall strength is good enough compared to the + validation_strength defined in the constructor. + + Produces a summary-tuple comprised of a level (succes or error) + and a message key describing the issues found. + """ + if self.validation_strength < 0: return ("success", "") - self.strength = self.compute(password, ACTIVATE_ONLINE_PWNED_LIST) - if self.strength < self.validation_strength: - if self.listed: - return ("error", "password_listed_" + str(self.validation_strength)) - else: - return ("error", "password_too_simple_" + str(self.validation_strength)) + listed = password in SMALL_PWD_LIST or self.is_in_cracklib_list(password) + strength_level = self.strength_level(password) + if listed: + return ("error", "password_listed") + if strength_level < self.validation_strength: + return ("error", "password_too_simple_%s" % self.validation_strength) - if self.strength < 3: - return ("warning", 'password_advice') return ("success", "") - def compute(self, password, online=False): - # Indicators + def strength(self, password): + """ + Returns the strength of a password, defined as a tuple + containing the length of the password, the number of digits, + lowercase letters, uppercase letters, and other characters. + + For instance, "PikachuDu67" is (11, 2, 7, 2, 0) + """ + length = len(password) digits = 0 uppers = 0 @@ -77,112 +132,54 @@ class PasswordValidator(object): for character in password: if character in string.digits: digits = digits + 1 - elif character in ASCII_UPPERCASE: + elif character in string.ascii_uppercase: uppers = uppers + 1 - elif character in ASCII_LOWERCASE: + elif character in string.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) + return (length, digits, lowers, uppers, others) - # 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 + def strength_level(self, password): + """ + Computes the strength of a password and compares + it to the STRENGTH_LEVELS. - # Check online big list - if unlisted > size_list and online and not self.is_in_online_pwned_list(password): - unlisted = 320000000 + Returns an int corresponding to the highest STRENGTH_LEVEL + satisfied by the password. + """ - self.listed = unlisted < 320000000 - return self.compare(unlisted, length, digits, lowers, uppers, others) + strength = self.strength(password) - 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]: + strength_level = 0 + # Iterate over each level and its criterias + for level, level_criterias in enumerate(STRENGTH_LEVELS): + # Iterate simulatenously over the level criterias (e.g. [8, 1, 1, 1, 0]) + # and the strength of the password (e.g. [11, 2, 7, 2, 0]) + # and compare the values 1-by-1. + # If one False is found, the password does not satisfy the level + if False in [s>=c for s, c in zip(strength, level_criterias)]: break - strength = i + 1 - return strength + # Otherwise, the strength of the password is at least of the current level. + strength_level = level + 1 - 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 - import requests - hash = sha1(password).hexdigest() - range = hash[:5] - needle = (hash[5:].upper()) + return strength_level + def is_in_cracklib_list(self, password): try: - 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: - return True - return False - - def is_in_cracklib_list(self, password, pwd_dict): - try: - cracklib.VeryFascistCheck(password, None, - os.path.join(PWDDICT_PATH, pwd_dict)) + cracklib.VeryFascistCheck(password, None, MOST_USED_PASSWORDS) 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"]: return True + return False -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): - """ - 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 - - logger = logging.getLogger('yunohost.utils.password') - - status, msg = super(LoggerPasswordValidator, self).validate(password) - if status == "error": - raise MoulinetteError(1, m18n.n(msg)) - elif status == "warning": - logger.info(m18n.n(msg)) - +# This file is also meant to be used as an executable by +# SSOwat to validate password from the portal when an user +# change its password. if __name__ == '__main__': if len(sys.argv) < 2: import getpass @@ -190,8 +187,6 @@ if __name__ == '__main__': #print("usage: password.py PASSWORD") else: pwd = sys.argv[1] - status, msg = ProfilePasswordValidator('user').validate(pwd) + status, msg = PasswordValidator('user').validation_summary(pwd) print(msg) sys.exit(0) - -