Source code for univention.password

# -*- coding: utf-8 -*-
#
# Copyright 2010-2022 Univention GmbH
#
# https://www.univention.de/
#
# All rights reserved.
#
# The source code of this program is made available
# under the terms of the GNU Affero General Public License version 3
# (GNU AGPL V3) as published by the Free Software Foundation.
#
# Binary versions of this program provided by Univention to you as
# well as other copyrighted, protected or trademarked materials like
# Logos, graphics, fonts, specific documentations and configurations,
# cryptographic keys etc. are subject to a license agreement between
# you and Univention and not subject to the GNU AGPL V3.
#
# In the case you use this program under the terms of the GNU AGPL V3,
# the program is provided 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 with the Debian GNU/Linux or Univention distribution in file
# /usr/share/common-licenses/AGPL-3; if not, see
# <https://www.gnu.org/licenses/>.

import cracklib
import os
from random import SystemRandom
import re
import string
import univention.uldap
import univention.debug as ud
import univention.config_registry as ucr
from ldap.filter import filter_format

try:
	from samba import check_password_quality as samba_check_password_quality
except ImportError:
	def samba_check_password_quality(*args, **kwargs):
		ud.debug(ud.LDAP, ud.ERROR, 'samba_check_password_quality() is not available in Python 2. Not checking password quality.')
		return True  # not available, use Python 3


[docs]class CheckFailed(Exception): pass
[docs]class Check(object): def __init__(self, lo, username=None): self.ConfigRegistry = ucr.ConfigRegistry() self.ConfigRegistry.load() self.username = username self.enableQualityCheck = False self.checkHistory = False self.min_length = -1 if not lo: self._getConnection() else: self.lo = lo self._systemPolicy() if self.username: self._userPolicy(self.username) def _getConnection(self): if os.path.exists('/etc/ldap.secret'): self.lo = univention.uldap.getAdminConnection() elif os.path.exists('/etc/machine.secret'): self.lo = univention.uldap.getMachineConnection(start_tls=2) else: self.lo = univention.uldap.access(host=self.ConfigRegistry.get('ldap/master'), base=self.ConfigRegistry.get('ldap/base'), start_tls=2) def _systemPolicy(self): if self.ConfigRegistry.get('password/quality/credit/digits', '0'): cracklib.DIG_CREDIT = int(self.ConfigRegistry.get('password/quality/credit/digits', '0')) * -1 if self.ConfigRegistry.get('password/quality/credit/upper', '0'): cracklib.UP_CREDIT = int(self.ConfigRegistry.get('password/quality/credit/upper', '0')) * -1 if self.ConfigRegistry.get('password/quality/credit/lower', '0'): cracklib.LOW_CREDIT = int(self.ConfigRegistry.get('password/quality/credit/lower', '0')) * -1 if self.ConfigRegistry.get('password/quality/credit/other', '0'): cracklib.OTH_CREDIT = int(self.ConfigRegistry.get('password/quality/credit/other', '0')) * -1 self.forbidden_chars = self.ConfigRegistry.get('password/quality/forbidden/chars', '') self.required_chars = self.ConfigRegistry.get('password/quality/required/chars', '') # to be compatible with UCS 2.3 kerberos check_cracklib.py if self.ConfigRegistry.get('password/quality/length/min', None): self.min_length = int(self.ConfigRegistry.get('password/quality/length/min')) if self.ConfigRegistry.get('password/quality/ascii_lowercase', None): cracklib.ASCII_LOWERCASE = self.ConfigRegistry.get('password/quality/ascii_lowercase') if self.ConfigRegistry.get('password/quality/ascii_uppercase', None): cracklib.ASCII_UPPERCASE = self.ConfigRegistry.get('password/quality/ascii_uppercase') if self.ConfigRegistry.get('password/quality/diff_ok', None): cracklib.DIFF_OK = int(self.ConfigRegistry.get('password/quality/diff_ok')) # optionally activate Microsoft standard criteria self.mspolicy = self.ConfigRegistry.get('password/quality/mspolicy', None) # normalize True values self.mspolicy = self.ConfigRegistry.is_true(value=self.mspolicy) or self.mspolicy def _userPolicy(self, username): # username or kerberos principal try: if '@' in self.username: dn = self.lo.searchDn(filter_format('krb5PrincipalName=%s', [username]))[0] else: dn = self.lo.searchDn(filter_format('(&(uid=%s)(|(&(objectClass=posixAccount)(objectClass=shadowAccount))(objectClass=sambaSamAccount)(&(objectClass=person)(objectClass=organizationalPerson)(objectClass=inetOrgPerson))))', [username]))[0] except IndexError: raise CheckFailed('User was not found.') policy_result = self.lo.getPolicies(dn) self.min_length = -1 self.history_length = -1 if policy_result.get('univentionPolicyPWHistory'): if policy_result['univentionPolicyPWHistory'].get('univentionPWLength'): self.min_length = int(policy_result['univentionPolicyPWHistory']['univentionPWLength']['value'][0]) if policy_result['univentionPolicyPWHistory'].get('univentionPWHistoryLen'): self.history_length = int(policy_result['univentionPolicyPWHistory']['univentionPWHistoryLen']['value'][0]) if policy_result['univentionPolicyPWHistory'].get('univentionPWQualityCheck'): univentionPasswordQualityCheck = policy_result['univentionPolicyPWHistory']['univentionPWQualityCheck']['value'][0].decode('ASCII', 'replace') self.enableQualityCheck = self.ConfigRegistry.is_true(value=univentionPasswordQualityCheck) self.pwhistory = self.lo.search(base=dn, attr=['pwhistory'])[0][1].get('pwhistory')
[docs] def check(self, password, username=None, displayname=None): if self.min_length > 0: if len(password) < self.min_length: raise CheckFailed('Password is too short') else: cracklib.MIN_LENGTH = 4 # this seems to be the lowest valid value # Workaround for users/user, which instanciates Check() without a username: # We need the username here, but if we would pass it to Check() then # _userPolicy would be run, changing the behavior in users/user. if not username: username = self.username # Todo: check history if self.enableQualityCheck: if self.mspolicy in (True, 'sufficient'): # See https://docs.microsoft.com/de-de/windows/security/threat-protection/security-policy-settings/password-must-meet-complexity-requirements if not samba_check_password_quality(password): raise CheckFailed('Password does not meet the password complexity requirements.') if username and len(username) > 3 and username.lower() in password.lower(): raise CheckFailed('Password contains user account name.') if displayname: for namepart in re.split('[-,._# \t]+', displayname): if len(namepart) > 3 and namepart.lower() in password.lower(): raise CheckFailed('Password contains parts of the full user name.') if self.mspolicy == 'sufficient': return True # skip all other checks for c in self.forbidden_chars: if c in password: raise CheckFailed('Password contains forbidden characters') if self.required_chars: for c in self.required_chars: if c in password: break else: raise CheckFailed('Password does not contain one of required characters: "%s"' % self.required_chars) cracklib.MIN_LENGTH = self.min_length try: if cracklib.VeryFascistCheck(password) == password: return True except ValueError as exc: raise CheckFailed(str(exc))
# def test_case1(): # pwdCheck = univention.password.Check(univention.uldap.getMachineConnection(), 'stefan') # pwdCheck.check('univention') # # def test_case2(): # pwdCheck = univention.password.Check(univention.uldap.getMachineConnection(), None) # self.enableQualityCheck = False #True # self.pwhistory = ['xxxx yyyy'] # self.min_length = 8 # self.history_length = 3 # pwdCheck.check('univention') # # def test_case3(): # pwdCheck = univention.password.Check(univention.uldap.getMachineConnection(), None) # self.enableQualityCheck = True # pwdCheck.check('univention')
[docs]def password_config(scope=None): """ Read password configuration options from UCR. :param scope: UCR scope in which password configuration options are searched for. Default is None. :type scope: :class:`str` :return: Password configuration options. :rtype: :class:`dict` """ default_cfg = { 'digits': ucr.ucr.get_int('password/quality/credit/digits', 6), 'lower': ucr.ucr.get_int('password/quality/credit/lower', 6), 'other': ucr.ucr.get_int('password/quality/credit/other', 0), 'upper': ucr.ucr.get_int('password/quality/credit/upper', 6), 'forbidden': ucr.ucr.get('password/quality/forbidden/chars', '0Ol1I'), 'min_length': ucr.ucr.get_int('password/quality/length/min', 24), } if scope: cfg = { 'digits': ucr.ucr.get_int('password/%s/quality/credit/digits' % scope, default_cfg.get('digits')), 'lower': ucr.ucr.get_int('password/%s/quality/credit/lower' % scope, default_cfg.get('lower')), 'other': ucr.ucr.get_int('password/%s/quality/credit/other' % scope, default_cfg.get('other')), 'upper': ucr.ucr.get_int('password/%s/quality/credit/upper' % scope, default_cfg.get('upper')), 'forbidden': ucr.ucr.get('password/%s/quality/forbidden/chars' % scope, default_cfg.get('forbidden')), 'min_length': ucr.ucr.get_int('password/%s/quality/length/min' % scope, default_cfg.get('min_length')), } else: cfg = default_cfg return cfg
[docs]def generate_password(digits=6, lower=6, other=0, upper=6, forbidden='', min_length=24): """ Generate random password using given parameters. Whitespaces are implicitly forbidden. :param digits: Minimal number of digits in generated password. 0 excludes it from the password. :type digits: :class:`int` :param lower: Minimal number of lowercase ASCII letters in generated password. 0 excludes it from the password. :type lower: :class:`int` :param other: Minimal number of special characters in generated password. 0 excludes it from the password. :type other: :class:`int` :param upper: Minimal number of uppercase ASCII letters in generated password. 0 excludes it from the password. :type upper: :class:`int` :param forbidden: Forbidden characters in generated password. :type forbidden: :class:`str` :param min_length: Minimal length of generated password. :type min_length: :class:`int` :return: Randomly generated password. :rtype: :class:`str` :raises ValueError: In case any password quality precondition fails. """ special_characters = string.punctuation forbidden_chars = forbidden or '' exclude_characters = set(forbidden_chars) | set(string.whitespace) if 0 > digits or 0 > lower or 0 > other or 0 > upper: raise ValueError('Number of digits, lower, upper or other characters can not be negative') elif 0 >= digits + lower + other + upper: raise ValueError('At least one from the: digits, lower, upper or other characters must be positive number') available_chars = set(string.printable) - exclude_characters if not available_chars: raise ValueError('All available characters are excluded by the rule: %r', (exclude_characters,)) rnd = SystemRandom() digit_characters = ''.join(set(string.digits) - set(exclude_characters)) if digits > 0 else '' ascii_lowercase = ''.join(set(string.ascii_lowercase) - set(exclude_characters)) if lower > 0 else '' ascii_uppercase = ''.join(set(string.ascii_uppercase) - set(exclude_characters)) if upper > 0 else '' special_characters = ''.join(set(special_characters) - set(exclude_characters)) if other > 0 else '' random_list = [] if digits > 0: if digit_characters: random_list.extend(rnd.choices(digit_characters, k=digits)) else: raise ValueError('There are %s digits requested but digits pool is empty' % (digits,)) if lower > 0: if ascii_lowercase: random_list.extend(rnd.choices(ascii_lowercase, k=lower)) else: raise ValueError('There are %s lowercase characters requested but lowercase pool is empty' % (lower,)) if upper > 0: if ascii_uppercase: random_list.extend(rnd.choices(ascii_uppercase, k=upper)) else: raise ValueError('There are %s uppercase characters requested but uppercase pool is empty' % (upper,)) if other > 0: if special_characters: random_list.extend(rnd.choices(special_characters, k=other)) else: raise ValueError('There are %s special characters requested but special characters pool is empty' % (other,)) if min_length > len(random_list): available_char_pool = ''.join(set(digit_characters + ascii_lowercase + ascii_uppercase + special_characters)) random_list.extend(rnd.choices(available_char_pool, k=min_length - len(random_list))) rnd.shuffle(random_list) res = ''.join(random_list) return res