Merge pull request #196 from YunoHost/cracklib

[enh] Automatically check for weak password
This commit is contained in:
Alexandre Aubin 2018-11-04 15:59:21 +01:00 committed by GitHub
commit fe452a6de6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 253 additions and 6 deletions

View file

@ -125,6 +125,7 @@ user:
pattern: &pattern_password pattern: &pattern_password
- !!str ^.{3,}$ - !!str ^.{3,}$
- "pattern_password" - "pattern_password"
comment: good_practices_about_user_password
-q: -q:
full: --mailbox-quota full: --mailbox-quota
help: Mailbox size quota help: Mailbox size quota
@ -1456,6 +1457,7 @@ tools:
password: ask_new_admin_password password: ask_new_admin_password
pattern: *pattern_password pattern: *pattern_password
required: True required: True
comment: good_practices_about_admin_password
### tools_maindomain() ### tools_maindomain()
maindomain: maindomain:
@ -1493,9 +1495,13 @@ tools:
password: ask_new_admin_password password: ask_new_admin_password
pattern: *pattern_password pattern: *pattern_password
required: True required: True
comment: good_practices_about_admin_password
--ignore-dyndns: --ignore-dyndns:
help: Do not subscribe domain to a DynDNS service help: Do not subscribe domain to a DynDNS service
action: store_true action: store_true
--force-password:
help: Use this if you really want to set a weak password
action: store_true
### tools_update() ### tools_update()
update: update:

Binary file not shown.

1
debian/install vendored
View file

@ -4,6 +4,7 @@ data/bash-completion.d/yunohost /etc/bash_completion.d/
data/actionsmap/* /usr/share/moulinette/actionsmap/ data/actionsmap/* /usr/share/moulinette/actionsmap/
data/hooks/* /usr/share/yunohost/hooks/ data/hooks/* /usr/share/yunohost/hooks/
data/other/yunoprompt.service /etc/systemd/system/ data/other/yunoprompt.service /etc/systemd/system/
data/other/password/* /usr/share/yunohost/other/password/
data/other/* /usr/share/yunohost/yunohost-config/moulinette/ data/other/* /usr/share/yunohost/yunohost-config/moulinette/
data/templates/* /usr/share/yunohost/templates/ data/templates/* /usr/share/yunohost/templates/
data/helpers /usr/share/yunohost/ data/helpers /usr/share/yunohost/

View file

@ -196,6 +196,8 @@
"global_settings_setting_example_string": "Example string option", "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_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.", "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_failed": "Script execution failed: {path:s}",
"hook_exec_not_terminated": "Script execution hasn\u2019t terminated: {path:s}", "hook_exec_not_terminated": "Script execution hasn\u2019t terminated: {path:s}",
"hook_list_by_invalid": "Invalid property to list hook by", "hook_list_by_invalid": "Invalid property to list hook by",
@ -329,6 +331,11 @@
"packages_no_upgrade": "There is no package to upgrade", "packages_no_upgrade": "There is no package to upgrade",
"packages_upgrade_critical_later": "Critical packages ({packages:s}) will be upgraded later", "packages_upgrade_critical_later": "Critical packages ({packages:s}) will be upgraded later",
"packages_upgrade_failed": "Unable to upgrade all of the packages", "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}", "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_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)", "pattern_domain": "Must be a valid domain name (e.g. my-domain.org)",

View file

@ -2207,11 +2207,15 @@ def _parse_action_args_in_yunohost_format(args, action_args, auth=None):
for domain in domain_list(auth)['domains']: for domain in domain_list(auth)['domains']:
msignals.display("- {}".format(domain)) msignals.display("- {}".format(domain))
if arg_type == 'user': elif arg_type == 'user':
msignals.display(m18n.n('users_available')) msignals.display(m18n.n('users_available'))
for user in user_list(auth)['users'].keys(): for user in user_list(auth)['users'].keys():
msignals.display("- {}".format(user)) msignals.display("- {}".format(user))
elif arg_type == 'password':
msignals.display(m18n.n('good_practices_about_user_password'))
try: try:
input_string = msignals.prompt(ask_string, is_password) input_string = msignals.prompt(ask_string, is_password)
except NotImplementedError: except NotImplementedError:
@ -2269,6 +2273,9 @@ def _parse_action_args_in_yunohost_format(args, action_args, auth=None):
raise MoulinetteError(errno.EINVAL, raise MoulinetteError(errno.EINVAL,
m18n.n('app_argument_choice_invalid', m18n.n('app_argument_choice_invalid',
name=arg_name, choices='yes, no, y, n, 1, 0')) 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 args_dict[arg_name] = arg_value
# END loop over action_args... # END loop over action_args...

View file

@ -29,12 +29,16 @@ SETTINGS_PATH_OTHER_LOCATION = "/etc/yunohost/settings-%s.json"
# * string # * string
# * enum (in form a python list) # * enum (in form a python list)
# we don't store the value in default options
DEFAULTS = OrderedDict([ DEFAULTS = OrderedDict([
("example.bool", {"type": "bool", "default": True}), ("example.bool", {"type": "bool", "default": True}),
("example.int", {"type": "int", "default": 42}), ("example.int", {"type": "int", "default": 42}),
("example.string", {"type": "string", "default": "yolo swag"}), ("example.string", {"type": "string", "default": "yolo swag"}),
("example.enum", {"type": "enum", "default": "a", "choices": ["a", "b", "c"]}), ("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)) received_type=type(value).__name__, expected_type=key_type))
elif key_type == "int": elif key_type == "int":
if not isinstance(value, int) or isinstance(value, bool): if not isinstance(value, int) or isinstance(value, bool):
raise MoulinetteError(errno.EINVAL, m18n.n( if isinstance(value, str):
'global_settings_bad_type_for_setting', setting=key, value=int(value)
received_type=type(value).__name__, expected_type=key_type)) 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": elif key_type == "string":
if not isinstance(value, basestring): if not isinstance(value, basestring):
raise MoulinetteError(errno.EINVAL, m18n.n( raise MoulinetteError(errno.EINVAL, m18n.n(

View file

@ -32,6 +32,7 @@ import logging
import subprocess import subprocess
import pwd import pwd
import socket import socket
import cracklib
from xmlrpclib import Fault from xmlrpclib import Fault
from importlib import import_module from importlib import import_module
from collections import OrderedDict from collections import OrderedDict
@ -127,6 +128,10 @@ def tools_adminpw(auth, new_password):
""" """
from yunohost.user import _hash_user_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: try:
auth.update("cn=admin", { auth.update("cn=admin", {
"userPassword": _hash_user_password(new_password), "userPassword": _hash_user_password(new_password),
@ -250,7 +255,8 @@ def _is_inside_container():
@is_unit_operation() @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 YunoHost post-install
@ -261,6 +267,8 @@ def tools_postinstall(operation_logger, domain, password, ignore_dyndns=False):
password -- YunoHost admin password password -- YunoHost admin password
""" """
from yunohost.utils.password import assert_password_is_strong_enough
dyndns_provider = "dyndns.yunohost.org" dyndns_provider = "dyndns.yunohost.org"
# Do some checks at first # Do some checks at first
@ -268,6 +276,10 @@ def tools_postinstall(operation_logger, domain, password, ignore_dyndns=False):
raise MoulinetteError(errno.EPERM, raise MoulinetteError(errno.EPERM,
m18n.n('yunohost_already_installed')) m18n.n('yunohost_already_installed'))
# Check password
if not force_password:
assert_password_is_strong_enough("admin", password)
if not ignore_dyndns: if not ignore_dyndns:
# Check if yunohost dyndns can handle the given domain # Check if yunohost dyndns can handle the given domain
# (i.e. is it a .nohost.me ? a .noho.st ?) # (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: else:
dyndns = False dyndns = False
operation_logger.start() operation_logger.start()
logger.info(m18n.n('yunohost_installing')) logger.info(m18n.n('yunohost_installing'))
@ -1045,3 +1058,4 @@ class Migration(object):
@property @property
def description(self): def description(self):
return m18n.n("migration_description_%s" % self.id) return m18n.n("migration_description_%s" % self.id)

View file

@ -32,6 +32,7 @@ import crypt
import random import random
import string import string
import subprocess import subprocess
import cracklib
from moulinette import m18n from moulinette import m18n
from moulinette.core import MoulinetteError 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.domain import domain_list, _get_maindomain
from yunohost.hook import hook_callback from yunohost.hook import hook_callback
from yunohost.app import app_ssowatconf 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 # Validate uniqueness of username and mail in LDAP
auth.validate_uniqueness({ 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.domain import domain_list
from yunohost.app import app_ssowatconf from yunohost.app import app_ssowatconf
from yunohost.utils.password import assert_password_is_strong_enough
attrs_to_fetch = ['givenName', 'sn', 'mail', 'maildrop'] attrs_to_fetch = ['givenName', 'sn', 'mail', 'maildrop']
new_attr_dict = {} 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 new_attr_dict['cn'] = new_attr_dict['displayName'] = firstname + ' ' + lastname
if change_password: if change_password:
# Ensure sufficiently complex password
assert_password_is_strong_enough("user", password)
new_attr_dict['userPassword'] = _hash_user_password(change_password) new_attr_dict['userPassword'] = _hash_user_password(change_password)
if mail: if mail:

View file

@ -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.<profile>.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)