py.lib
21:ad6778cf8cf5 Browse Files
+ Работа с LDAP
1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/ldap_utils/ldap.py Sat Oct 23 21:28:27 2021 +0300 1.3 @@ -0,0 +1,171 @@ 1.4 +# coding: utf-8 1.5 + 1.6 +from ldap3 import Server, Connection, SIMPLE, SUBTREE, IP_V4_PREFERRED 1.7 +from ldap3.core.exceptions import LDAPException, LDAPInvalidCredentialsResult 1.8 +from ldap3.utils.conv import escape_filter_chars 1.9 +from collections import namedtuple 1.10 +from typing import List, Optional, Dict, Union 1.11 + 1.12 + 1.13 +class LdapError(Exception): 1.14 + pass 1.15 + 1.16 + 1.17 +class LdapServerError(LdapError): 1.18 + pass 1.19 + 1.20 + 1.21 +class LdapUserError(LdapError): 1.22 + pass 1.23 + 1.24 + 1.25 +class LdapNotFound(LdapUserError): 1.26 + def __init__(self, filter: str, attribs: List[str]): 1.27 + _attribs = ', '.join(attribs) 1.28 + super().__init__(f'Данные согласно фильтру в LDAP не найдены: {filter}, attrib={_attribs}') 1.29 + 1.30 + 1.31 +class LdapAuthDenied(LdapUserError): 1.32 + def __init__(self, user): 1.33 + super(LdapAuthDenied, self).__init__(f'Авторизация пользователя "{user}" отклонена сервером') 1.34 + 1.35 + 1.36 +LdapUserInfo = namedtuple('LdapUserInfo', [ 1.37 + 'name', 'mail', 'groups', 1.38 +]) 1.39 + 1.40 +ACC_STATUS_ACCOUNTDISABLE = 2 1.41 +ACC_STATUS_LOCKOUT = 16 1.42 +ACC_STATUS_PASSWORD_EXPIRED = 8388608 1.43 +ACC_STATUS_NORMAL_ACCOUNT = 512 1.44 +ACC_STATUS_MASK = ACC_STATUS_ACCOUNTDISABLE | ACC_STATUS_LOCKOUT \ 1.45 + | ACC_STATUS_PASSWORD_EXPIRED | ACC_STATUS_NORMAL_ACCOUNT 1.46 + 1.47 +ACC_STATUS_FAIL_MASK = ACC_STATUS_ACCOUNTDISABLE | ACC_STATUS_LOCKOUT | ACC_STATUS_PASSWORD_EXPIRED 1.48 + 1.49 + 1.50 +class LdapRes(object): 1.51 + def __init__(self, dn: str, attribs: Dict[str, Union[str, List[str]]]): 1.52 + self.dn = dn 1.53 + self.attribs = attribs 1.54 + 1.55 + def __str__(self): 1.56 + return self.dn 1.57 + 1.58 + 1.59 +class LdapConfig(object): 1.60 + def __init__(self, 1.61 + server_uri: str, 1.62 + user_name: str, passwd: str, 1.63 + base_dn: Optional[str] = None, 1.64 + timeout: int = 150, reconnects: int = 3, 1.65 + auth_domain: str = '' 1.66 + ): 1.67 + 1.68 + self.server_uri = server_uri # URL Доступа к северу: 'ldap://servername:port/' 1.69 + self.user_name = user_name # Имя пользователя для подключения к серверу LDAP 1.70 + self.passwd = passwd # Пароль для подключения к серверу LDAP 1.71 + self.base_dn = base_dn # База поиска 1.72 + self.timeout = timeout # Выставляемые таймауты на операцию 1.73 + self.reconnects = reconnects # Количество попыток переподключиться 1.74 + self.auth_domain = auth_domain # Имя домена авторизации 1.75 + 1.76 + def __str__(self): 1.77 + return f'{self.user_name}@{self.server_uri} ({self.base_dn} timeout="{self.timeout}" auth_domain="{self.auth_domain}")' 1.78 + 1.79 + 1.80 +class Ldap(object): 1.81 + escape_filter_chars = escape_filter_chars 1.82 + 1.83 + def __init__(self, config: LdapConfig): 1.84 + self.server = Server(config.server_uri, connect_timeout=config.timeout, mode=IP_V4_PREFERRED) 1.85 + self.config = config 1.86 + 1.87 + def get_connection(self) -> Connection: 1.88 + _reconnects = 1 1.89 + 1.90 + # Подготавливаем имя пользователя к авторизации в AD 1.91 + if self.config.auth_domain != '': 1.92 + _ldap_auth_uname = f'{self.config.auth_domain}\\{self.config.user_name}' 1.93 + else: 1.94 + _ldap_auth_uname = self.config.user_name 1.95 + 1.96 + while True: 1.97 + _reconnects += 1 1.98 + try: 1.99 + ldap_connection = Connection(self.server, authentication=SIMPLE, 1.100 + user=_ldap_auth_uname, password=self.config.passwd, 1.101 + check_names=True, 1.102 + auto_referrals=False, raise_exceptions=True, auto_range=True, 1.103 + ) 1.104 + 1.105 + ldap_connection.open() 1.106 + if not ldap_connection.bind(): 1.107 + continue # Пытаемся переподключиться при ошибках 1.108 + 1.109 + break 1.110 + 1.111 + except LDAPInvalidCredentialsResult: 1.112 + raise LdapAuthDenied(self.config.user_name) 1.113 + 1.114 + except LDAPException as e: 1.115 + if _reconnects > self.config.reconnects: 1.116 + # Возбуждаем исключение после того как попробовали несколько раз 1.117 + raise LdapServerError(f'Ошибка подключения к серверу: {e}') 1.118 + 1.119 + if ldap_connection is None: 1.120 + raise LdapServerError('Не выполнено подключение к серверу') 1.121 + 1.122 + return ldap_connection 1.123 + 1.124 + def search(self, ldap_filter: str, 1.125 + attribs: Optional[List[str]] = None, 1.126 + base_dn: Optional[str] = None): 1.127 + 1.128 + if base_dn is None: 1.129 + if not self.config.base_dn: 1.130 + raise LdapUserError('Не задан Base DN для поиска в LDAP') 1.131 + 1.132 + base_dn = self.config.base_dn 1.133 + 1.134 + if attribs is None: 1.135 + attribs = ['cn'] 1.136 + 1.137 + ldap_connection = self.get_connection() 1.138 + ldap_connection.search(base_dn, ldap_filter, attributes=attribs) 1.139 + if ldap_connection.result['result'] != 0: 1.140 + raise LdapServerError(f'Не могу выполнить запрос к серверу LDAP: ' 1.141 + f'{ldap_connection.result.get("description")}') 1.142 + 1.143 + for itm in ldap_connection.response: 1.144 + yield LdapRes(dn=itm['dn'], attribs=itm['attributes']) 1.145 + 1.146 + @staticmethod 1.147 + def filter_group_names(group): 1.148 + group_res = list(filter(lambda x: x.lower().startswith('cn'), group.split(','))) 1.149 + if group_res: 1.150 + try: 1.151 + return group_res[0].split('=')[1] 1.152 + except IndexError: 1.153 + raise LdapError(f'Имя группы с неожиданным форматом: {group_res[0]}') 1.154 + else: 1.155 + raise LdapError(f'Нет атрибута CN в имени: {group}') 1.156 + 1.157 + 1.158 + @staticmethod 1.159 + def decode_acc_status(acc_status): 1.160 + res = [] 1.161 + 1.162 + if acc_status & ACC_STATUS_ACCOUNTDISABLE != 0: 1.163 + res.append('ACCOUNTDISABLE') 1.164 + 1.165 + if acc_status & ACC_STATUS_NORMAL_ACCOUNT != 0: 1.166 + res.append('NORMAL_ACCOUNT') 1.167 + 1.168 + if acc_status & ACC_STATUS_LOCKOUT != 0: 1.169 + res.append('LOCKOUT') 1.170 + 1.171 + if acc_status & ACC_STATUS_PASSWORD_EXPIRED != 0: 1.172 + res.append('PASSWORD_EXPIRED') 1.173 + 1.174 + return res