diff --git a/data/actionsmap/yunohost.yml b/data/actionsmap/yunohost.yml index bb51c6f7f..4e4aec9f3 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 @@ -1456,6 +1457,7 @@ tools: password: ask_new_admin_password pattern: *pattern_password required: True + comment: good_practices_about_admin_password ### tools_maindomain() maindomain: @@ -1493,9 +1495,13 @@ 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 + --force-password: + help: Use this if you really want to set a weak password + action: store_true ### tools_update() update: diff --git a/data/other/password/100000-most-used.txt.gz b/data/other/password/100000-most-used.txt.gz new file mode 100644 index 000000000..43887119b Binary files /dev/null and b/data/other/password/100000-most-used.txt.gz differ diff --git a/debian/install b/debian/install index e9c79e963..b540ca749 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/share/yunohost/other/password/ 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 0cacc5f82..8e1ee3331 100644 --- a/locales/en.json +++ b/locales/en.json @@ -196,6 +196,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,6 +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_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", "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 de15c1116..efdac70d7 100644 --- a/src/yunohost/app.py +++ b/src/yunohost/app.py @@ -2207,11 +2207,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: @@ -2269,6 +2273,9 @@ def _parse_action_args_in_yunohost_format(args, action_args, auth=None): raise MoulinetteError(errno.EINVAL, 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 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 aba6e32b3..d2526316e 100644 --- a/src/yunohost/settings.py +++ b/src/yunohost/settings.py @@ -29,12 +29,16 @@ 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 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"]}), + + # Password Validation + # -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}), ]) @@ -90,9 +94,12 @@ def settings_set(key, value): received_type=type(value).__name__, expected_type=key_type)) elif key_type == "int": if not isinstance(value, int) or isinstance(value, bool): - raise MoulinetteError(errno.EINVAL, m18n.n( - 'global_settings_bad_type_for_setting', setting=key, - received_type=type(value).__name__, expected_type=key_type)) + if isinstance(value, str): + value=int(value) + else: + raise MoulinetteError(errno.EINVAL, m18n.n( + 'global_settings_bad_type_for_setting', setting=key, + received_type=type(value).__name__, expected_type=key_type)) elif key_type == "string": if not isinstance(value, basestring): raise MoulinetteError(errno.EINVAL, m18n.n( diff --git a/src/yunohost/tools.py b/src/yunohost/tools.py index ccf489a92..7ef029fd7 100644 --- a/src/yunohost/tools.py +++ b/src/yunohost/tools.py @@ -32,6 +32,7 @@ import logging import subprocess import pwd import socket +import cracklib from xmlrpclib import Fault from importlib import import_module from collections import OrderedDict @@ -127,6 +128,10 @@ def tools_adminpw(auth, new_password): """ from yunohost.user import _hash_user_password + from yunohost.utils.password import assert_password_is_strong_enough + + assert_password_is_strong_enough("admin", new_password) + try: auth.update("cn=admin", { "userPassword": _hash_user_password(new_password), @@ -250,7 +255,8 @@ def _is_inside_container(): @is_unit_operation() -def tools_postinstall(operation_logger, domain, password, ignore_dyndns=False): +def tools_postinstall(operation_logger, domain, password, ignore_dyndns=False, + force_password=False): """ YunoHost post-install @@ -261,6 +267,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 @@ -268,6 +276,10 @@ def tools_postinstall(operation_logger, domain, password, ignore_dyndns=False): raise MoulinetteError(errno.EPERM, m18n.n('yunohost_already_installed')) + # Check password + if not force_password: + assert_password_is_strong_enough("admin", password) + if not ignore_dyndns: # Check if yunohost dyndns can handle the given domain # (i.e. is it a .nohost.me ? a .noho.st ?) @@ -299,6 +311,7 @@ def tools_postinstall(operation_logger, domain, password, ignore_dyndns=False): else: dyndns = False + operation_logger.start() logger.info(m18n.n('yunohost_installing')) @@ -1045,3 +1058,4 @@ class Migration(object): @property def description(self): return m18n.n("migration_description_%s" % self.id) + diff --git a/src/yunohost/user.py b/src/yunohost/user.py index 8fd445af1..990ec4c8e 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -32,6 +32,7 @@ import crypt import random import string import subprocess +import cracklib from moulinette import m18n from moulinette.core import MoulinetteError @@ -116,6 +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 + assert_password_is_strong_enough("user", password) # Validate uniqueness of username and mail in LDAP auth.validate_uniqueness({ @@ -283,6 +288,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 = {} @@ -307,6 +313,9 @@ def user_update(operation_logger, auth, username, firstname=None, lastname=None, new_attr_dict['cn'] = new_attr_dict['displayName'] = firstname + ' ' + lastname if change_password: + # Ensure sufficiently complex password + assert_password_is_strong_enough("user", password) + new_attr_dict['userPassword'] = _hash_user_password(change_password) if mail: diff --git a/src/yunohost/utils/password.py b/src/yunohost/utils/password.py new file mode 100644 index 000000000..68e51056b --- /dev/null +++ b/src/yunohost/utils/password.py @@ -0,0 +1,196 @@ +# -*- coding: utf-8 -*- + +""" License + + 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 + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses + +""" + +import sys +import os +import json +import string +import subprocess + +SMALL_PWD_LIST = ["yunohost", "olinuxino", "olinux", "raspberry", "admin", + "root", "test", "rpi"] + +MOST_USED_PASSWORDS = '/usr/share/yunohost/other/password/100000-most-used.txt' + +# 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): + + def __init__(self, profile): + """ + Initialize a password validator. + + 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): + """ + 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 == -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", "") + + listed = password in SMALL_PWD_LIST or self.is_in_most_used_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) + + return ("success", "") + + 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 + lowers = 0 + others = 0 + + for character in password: + if character in string.digits: + digits = digits + 1 + elif character in string.ascii_uppercase: + uppers = uppers + 1 + elif character in string.ascii_lowercase: + lowers = lowers + 1 + else: + others = others + 1 + + return (length, digits, lowers, uppers, others) + + def strength_level(self, password): + """ + Computes the strength of a password and compares + it to the STRENGTH_LEVELS. + + Returns an int corresponding to the highest STRENGTH_LEVEL + satisfied by the password. + """ + + strength = self.strength(password) + + 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 + # Otherwise, the strength of the password is at least of the current level. + strength_level = level + 1 + + return strength_level + + def is_in_most_used_list(self, password): + + # Decompress file if compressed + if os.path.exists("%s.gz" % MOST_USED_PASSWORDS): + os.system("gzip -fd %s.gz" % MOST_USED_PASSWORDS) + + # Grep the password in the file + # We use '-f -' to feed the pattern (= the password) through + # stdin to avoid it being shown in ps -ef --forest... + command = "grep -q -f - %s" % MOST_USED_PASSWORDS + p = subprocess.Popen(command.split(), stdin=subprocess.PIPE) + p.communicate(input=password) + return not bool(p.returncode) + + +# 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 + pwd = getpass.getpass("") + #print("usage: password.py PASSWORD") + else: + pwd = sys.argv[1] + status, msg = PasswordValidator('user').validation_summary(pwd) + print(msg) + sys.exit(0)