mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
[enh] PasswordValidator without Moulinette
This commit is contained in:
parent
67e82111c1
commit
783c512628
9 changed files with 147 additions and 235 deletions
BIN
data/other/password/100000-most-used.hwm
Normal file
BIN
data/other/password/100000-most-used.hwm
Normal file
Binary file not shown.
BIN
data/other/password/100000-most-used.pwd
Normal file
BIN
data/other/password/100000-most-used.pwd
Normal file
Binary file not shown.
BIN
data/other/password/100000-most-used.pwi
Normal file
BIN
data/other/password/100000-most-used.pwi
Normal file
Binary file not shown.
|
@ -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)",
|
||||
|
|
|
@ -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...
|
||||
|
|
|
@ -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}),
|
||||
])
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue