diff --git a/src/yunohost/utils/password.py b/src/yunohost/utils/password.py new file mode 100644 index 000000000..bacf91502 --- /dev/null +++ b/src/yunohost/utils/password.py @@ -0,0 +1,244 @@ +# -*- coding: utf-8 -*- + +""" License + + Copyright (C) 2013 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 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 + +PWDDICT_PATH = '/usr/local/share/dict/cracklib/' + +class HintException(Exception): + + def __init__(self, message): + # Call the base class constructor with the parameters it needs + super(HintException, self).__init__(message) + + self.criticity = 'error' + + def warn_only(self): + return m18n.n(e.args[0] + '_warn', **e.kwargs) + + def error(self): + return m18n.n(e.args[0], **e.kwargs) + + +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() + + def validate(self, password, old=None, validator=None): + """ + 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']: + e = HintException('password_length', + min_length=min_length) + else: + e = HintException('password_length_warn', + min_length=self.config['min_length.error'], + better_length=self.config['min_length.warn'] + raise e + + 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.match(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+'): + 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"]: + 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.error'] + warn_dict = self.config['cracklib.warn'] + + self._check_cracklib_list(password, old, error_dict) + + if error_dict == warn_dict: + 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): + """ + 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 + hash = sha1(password).hexdigest() + range = hash[:5] + needle = (hash[5:]) + + try: + hash_list = download_text('https://api.pwnedpasswords.com/range/' + + range) + except MoulinetteError as e: + if not silent: + raise + else: + if hash_list.find(needle) != -1: + raise HintException('password_listed') + + def _check_cracklib_list(self, password, old=None, pwd_dict): + try: + cracklib.VeryFascistCheck(password, old, + 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) + + def _get_config(self, name=None): + """ + Build profile config from settings + """ + + if name is None: + if self.config is not None: + return self.config + self.config = {} + self.config['mode'] = _get_setting('mode') + for validator in self.validators: + self._get_config(validator) + self.config['min_length.error'] = _get_setting('min_length.error') + self.config['min_length.warn'] = _get_setting('min_length.warn') + self.config['cracklib.error'] = _get_setting('cracklib.error') + self.config['cracklib.warn'] = _get_setting('cracklib.warn') + + return self.config + + self.config[name] = settings_get('security.password' + self.profile + '.' + + name) + + 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': + + def _get_setting(self, setting): + return settings_get('security.password' + self.profile + '.' + setting) +