[enh] PasswordValidator without Moulinette

This commit is contained in:
ljf 2018-08-28 08:56:12 +02:00
parent 67e82111c1
commit 783c512628
9 changed files with 147 additions and 235 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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)",

View file

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

View file

@ -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}),
])

View file

@ -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

View file

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

View file

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