mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
Merge pull request #196 from YunoHost/cracklib
[enh] Automatically check for weak password
This commit is contained in:
commit
fe452a6de6
9 changed files with 253 additions and 6 deletions
|
@ -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:
|
||||
|
|
BIN
data/other/password/100000-most-used.txt.gz
Normal file
BIN
data/other/password/100000-most-used.txt.gz
Normal file
Binary file not shown.
1
debian/install
vendored
1
debian/install
vendored
|
@ -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/
|
||||
|
|
|
@ -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)",
|
||||
|
|
|
@ -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...
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
196
src/yunohost/utils/password.py
Normal file
196
src/yunohost/utils/password.py
Normal 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)
|
Loading…
Add table
Reference in a new issue