Skip to content

Commit 339b37e

Browse files
committed
Add Support for Microsoft AD Auth
- add support for ldap auth using double bind method - improve configurability of ldap module - add strict tests to ldap module intialization - update ldap examples in settings.py
1 parent 9e2df07 commit 339b37e

File tree

5 files changed

+396
-66
lines changed

5 files changed

+396
-66
lines changed

gui/dsiprouter.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2413,7 +2413,7 @@ def intializeAuthModules():
24132413
)
24142414
auth_mod = importlib.util.module_from_spec(spec)
24152415
spec.loader.exec_module(auth_mod)
2416-
auth_mod.initialize()
2416+
auth_mod.initialize(settings)
24172417
auth_modules.append(auth_mod)
24182418

24192419
def guiLicenseCheck(tag):
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
import ldap, ldap.filter, ldapurl, sys
2+
from typing import Any, Dict, Generator, List, Union
3+
from shared import IO
4+
from modules.api.auth.ldap.functions import filterValidSearchResults, filterSearchValuesByRdn
5+
6+
7+
class LdapAuthenticator(object):
8+
"""
9+
A wrapper around ldap connections adding support for failover between multiple LDAP servers
10+
"""
11+
12+
def __init__(self, ldap_urls: List[str], ldap_debug=False, **ldap_settings: Dict[str, Any]) -> None:
13+
"""
14+
Initialize the LDAP objects
15+
16+
:param ldap_urls: URLs of the LDAP servers to connect to
17+
"""
18+
19+
# all the parameter that will be initialized
20+
self._base_dn: str
21+
self._required_group: Union[str, None]
22+
self._referrals: int
23+
self._network_timeout: int
24+
self._search_timeout: int
25+
self._double_bind: bool
26+
self._bind_filter: Union[str, None]
27+
self._bind_dn: Union[str, None]
28+
self._bind_pass: Union[str, None]
29+
self._user_filter: Union[str, None]
30+
self._user_attr = Union[str, None]
31+
self._clients: List[ldap.ldapobject.ReconnectLDAPObject] = []
32+
self.__bind_idx: Union[int, None] = None
33+
self.__debug: bool = ldap_debug
34+
35+
# required settings
36+
if not isinstance(ldap_urls, list):
37+
raise ValueError('"ldap_urls" must be a list of strings')
38+
if len(ldap_urls) == 0:
39+
raise ValueError('"ldap_urls" cannot be empty')
40+
41+
# optional settings
42+
self._base_dn = ldap_settings.get('base_dn', '')
43+
if not ldap.dn.is_dn(self._base_dn):
44+
raise ValueError('"base_dn" is not a valid distinguished name')
45+
46+
self._required_group = ldap_settings.get('required_group', None)
47+
if not isinstance(self._required_group, (str, type(None))):
48+
raise ValueError('"required_group" must be a string')
49+
50+
referrals = ldap_settings.get('referrals', 0)
51+
if not isinstance(referrals, (int, bool)):
52+
raise ValueError('"referrals" must be an integer or boolean')
53+
self._referrals = int(referrals)
54+
55+
self._network_timeout = ldap_settings.get('network_timeout', 3)
56+
if not isinstance(self._network_timeout, int):
57+
raise ValueError('"network_timeout" must be an integer')
58+
59+
self._search_timeout = ldap_settings.get('search_timeout', 5)
60+
if not isinstance(self._search_timeout, int):
61+
raise ValueError('"search_timeout" must be an integer')
62+
63+
# single bind auth mode
64+
if 'bind_filter' in ldap_settings:
65+
self._double_bind = False
66+
self._bind_filter = ldap_settings['bind_filter']
67+
if not isinstance(self._bind_filter, str):
68+
raise ValueError('"bind_filter" must be a string')
69+
try:
70+
_ = ldap.filter.filter_format(self._bind_filter, ['test_username'])
71+
except TypeError as ex:
72+
raise ValueError(f'"bind_filter" is invalid ({str(ex)})')
73+
self._bind_dn = None
74+
self._bind_pass = None
75+
# double bind auth mode
76+
elif 'bind_dn' in ldap_settings and 'bind_pass' in ldap_settings:
77+
self._double_bind = True
78+
self._bind_dn = ldap_settings['bind_dn']
79+
self._bind_pass = ldap_settings['bind_pass']
80+
# no dn validation because it could be a plain username as well
81+
if not isinstance(self._bind_dn, str):
82+
raise ValueError('"bind_dn" must be a string')
83+
if not isinstance(self._bind_pass, str):
84+
raise ValueError('"bind_pass" must be a string')
85+
self._bind_filter = None
86+
# not a valid use case
87+
else:
88+
raise ValueError('invalid combination of module settings')
89+
90+
# dependent settings
91+
if self._double_bind is True or self._required_group is not None:
92+
if 'user_filter' not in ldap_settings:
93+
raise ValueError('missing required setting "user_filter"')
94+
self._user_filter = ldap_settings['user_filter']
95+
if not isinstance(self._user_filter, str):
96+
raise ValueError('"user_filter" must be a string')
97+
try:
98+
_ = ldap.filter.filter_format(self._user_filter, ['test_username'])
99+
except TypeError as ex:
100+
raise ValueError(f'"user_filter" is invalid ({str(ex)})')
101+
if 'user_attr' not in ldap_settings:
102+
raise ValueError('missing required setting "user_attr"')
103+
self._user_attr = ldap_settings['user_attr']
104+
if not isinstance(self._user_attr, str):
105+
raise ValueError('"user_attr" must be a string')
106+
else:
107+
self._user_filter = None
108+
self._user_attr = None
109+
110+
# create the ldap objects
111+
for url in ldap_urls:
112+
try:
113+
url_obj = ldapurl.LDAPUrl(url)
114+
except ValueError:
115+
raise ValueError(f'ldap url "{url}" is not valid')
116+
117+
client = ldap.ldapobject.ReconnectLDAPObject(
118+
uri=url_obj.initializeUrl(),
119+
trace_level=1 if self.__debug else 0,
120+
trace_file=sys.stderr if self.__debug else None,
121+
retry_max=1,
122+
retry_delay=0
123+
)
124+
125+
client.set_option(ldap.OPT_PROTOCOL_VERSION, 3)
126+
if url_obj.urlscheme == 'ldaps':
127+
client.set_option(ldap.OPT_X_TLS, ldap.OPT_X_TLS_DEMAND)
128+
client.set_option(ldap.OPT_X_TLS_DEMAND, True)
129+
client.set_option(ldap.OPT_X_TLS_NEWCTX, 0)
130+
131+
client.set_option(ldap.OPT_REFERRALS, referrals)
132+
client.set_option(ldap.OPT_NETWORK_TIMEOUT, self._network_timeout)
133+
134+
self._clients.append(client)
135+
136+
# allow passing to dict() and iterable()
137+
def __iter__(self) -> Generator[tuple[str, Any], Any, None]:
138+
for k, v in self._asDict().items():
139+
yield k, v
140+
141+
# only return select attributes in the iterable/dict representation
142+
def _asDict(self) -> Dict[str, Any]:
143+
return {
144+
'base_dn': self._base_dn,
145+
'required_group': self._required_group,
146+
'referrals': self._referrals,
147+
'network_timeout': self._network_timeout,
148+
'search_timeout': self._search_timeout,
149+
'double_bind': self._double_bind,
150+
'bind_filter': self._bind_filter,
151+
'bind_dn': self._bind_dn,
152+
'bind_pass': self._bind_pass,
153+
'user_filter': self._user_filter,
154+
'user_attr': self._user_attr,
155+
}
156+
157+
# TODO: allow updating attributes on the fly (clients would have to be recreated)
158+
159+
def validateConnection(self) -> None:
160+
"""
161+
Check if a connection to one of the ldap servers can be made
162+
"""
163+
164+
for client in self._clients:
165+
try:
166+
client.reconnect(
167+
client._uri,
168+
client._retry_max,
169+
client._retry_delay
170+
)
171+
return None
172+
except (ldap.SERVER_DOWN, ldap.TIMEOUT):
173+
continue
174+
175+
raise ldap.SERVER_DOWN('ldap connection(s) failed')
176+
177+
def validateBind(self) -> None:
178+
"""
179+
Check if the bind settings are valid
180+
"""
181+
182+
if self._double_bind is False:
183+
return None
184+
185+
for client in self._clients:
186+
try:
187+
client.simple_bind_s(
188+
self._bind_dn,
189+
self._bind_pass
190+
)
191+
client.unbind_s()
192+
return None
193+
except ldap.LDAPError:
194+
continue
195+
196+
raise ldap.LDAPError('ldap bind failed')
197+
198+
def bind(self, username: str, password: str) -> None:
199+
"""
200+
Bind to the ldap server
201+
"""
202+
203+
if self.__bind_idx is not None:
204+
self.unbind()
205+
206+
for idx, client in zip(range(len(self._clients)), self._clients):
207+
try:
208+
# double bind auth mode
209+
if self._double_bind:
210+
try:
211+
client.simple_bind_s(
212+
self._bind_dn,
213+
self._bind_pass
214+
)
215+
216+
res = filterValidSearchResults(
217+
client.search_st(
218+
self._base_dn,
219+
ldap.SCOPE_SUBTREE,
220+
ldap.filter.filter_format(self._user_filter, [username]),
221+
[self._user_attr],
222+
timeout=self._search_timeout
223+
)
224+
)
225+
finally:
226+
client.unbind_s()
227+
228+
if len(res) == 0:
229+
raise ldap.NO_SUCH_OBJECT('user not found')
230+
if len(res) > 1:
231+
IO.logwarn(f'multiple records found searching attribute {self._user_attr} for user {username}')
232+
if self.__debug:
233+
IO.printwarn(f'multiple records found searching attribute {self._user_attr} for user {username}')
234+
235+
user_login = res[0][1][self._user_attr][0].decode('utf-8')
236+
if self.__debug:
237+
IO.printdbg(f'found {self._user_attr} "{user_login}" for username "{username}"')
238+
client.simple_bind_s(
239+
user_login,
240+
password
241+
)
242+
self.__bind_idx = idx
243+
return None
244+
245+
# single bind auth mode
246+
client.simple_bind_s(
247+
ldap.filter.filter_format(self._bind_filter, [username]),
248+
password
249+
)
250+
self.__bind_idx = idx
251+
return None
252+
except (ldap.SERVER_DOWN, ldap.TIMEOUT):
253+
continue
254+
255+
raise ldap.LDAPError('ldap bind failed')
256+
257+
def unbind(self) -> None:
258+
"""
259+
Bind to the ldap server
260+
"""
261+
262+
if self.__bind_idx is None:
263+
return None
264+
265+
try:
266+
self._clients[self.__bind_idx].unbind_s()
267+
self.__bind_idx = None
268+
return None
269+
except ldap.SERVER_DOWN:
270+
return None
271+
272+
def queryUser(self, username: str, attrs: Union[List[str], None] = None) -> Dict[str, List[str]]:
273+
"""
274+
Perform an ldap query
275+
"""
276+
277+
if self.__bind_idx is None:
278+
raise Exception('not bound to any ldap servers')
279+
280+
client = self._clients[self.__bind_idx]
281+
client.reconnect(
282+
client._uri,
283+
client._retry_max,
284+
client._retry_delay
285+
)
286+
287+
res = filterValidSearchResults(
288+
client.search_st(
289+
self._base_dn,
290+
ldap.SCOPE_SUBTREE,
291+
ldap.filter.filter_format(self._user_filter, [username]),
292+
[self._user_attr],
293+
timeout=self._search_timeout
294+
)
295+
)
296+
297+
if len(res) == 0:
298+
raise ldap.NO_SUCH_OBJECT('user not found')
299+
vals = res[0][1]
300+
301+
return {
302+
k: filterSearchValuesByRdn(v, 'CN') for k, v in vals.items() if k in attrs
303+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import ldap
2+
from typing import Dict, List, Tuple
3+
4+
5+
def filterValidSearchResults(
6+
raw_result: List[Tuple[str, Dict[str, List[bytes]]]]
7+
) -> List[Tuple[str, Dict[str, List[bytes]]]]:
8+
return [
9+
res for res in raw_result if res[0] is not None
10+
]
11+
12+
def filterSearchValuesByRdn(raw_values: List[bytes], rdn: str) -> List[str]:
13+
rdn_filter = f'{rdn}='
14+
return [
15+
next(
16+
(dn for dn in ldap.dn.explode_dn(val) if rdn_filter in dn),
17+
''
18+
).replace(rdn_filter, '') for val in raw_values
19+
]

0 commit comments

Comments
 (0)