py.lib

Yohn Y. 2021-10-23 Child:1668cc57225b

21:ad6778cf8cf5 Go to Latest

py.lib/ldap_utils/ldap.py

+ Работа с LDAP

History
     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