Coverage for gws-app/gws/plugin/auth_provider/ldap/__init__.py: 93%
195 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-23 20:06 +0200
1"""LDAP authentication provider.
3Authenticates users against an LDAP or Active Directory server. The server is
4given as an LDAP URL, a subset of the RFC 2255 form::
6 ldap://host:port/baseDN?searchAttribute
8``searchAttribute`` is the attribute that holds the login name. If ``ssl`` is
9configured, the connection uses ``ldaps``. A bind DN and a password can be
10given; this DN must be allowed to search the directory. Without a bind DN,
11the provider binds anonymously. The connection is tested at configuration
12time.
14Authentication with ``username`` and ``password`` credentials works as
15follows:
17- connect to the server, binding as the bind DN if configured,
18- search below the base DN for the entry with ``searchAttribute = username``;
19 more than one entry is an error,
20- reject Active Directory accounts with the ``ACCOUNTDISABLE`` flag in
21 ``userAccountControl``,
22- bind as the found DN with the password,
23- connect again and assign roles from the ``users`` rules.
25A ``users`` rule assigns its ``roles`` if the account is one of the entries
26found by the ``matches`` filter, or if the account is listed in the
27``member``, ``members`` or ``uniqueMember`` attribute of a group found by the
28``memberOf`` filter. The attributes of the entry are passed to the user
29record. If the entry has no ``displayName``, ``displayNameFormat`` creates
30one from the attributes.
32Example::
34 auth.providers+ {
35 type "ldap"
36 url "ldap://ldap.example.com:389/dc=example,dc=com?uid"
37 bindDN "cn=admin,dc=example,dc=com"
38 bindPassword "secret"
39 activeDirectory false
40 users [
41 { matches "(uid=admin)" roles ["admin"] }
42 { memberOf "(cn=editors)" roles ["editor"] }
43 ]
44 }
46References:
47 https://datatracker.ietf.org/doc/html/rfc2255
48"""
50from typing import Optional
52import contextlib
54import ldap
55import ldap.filter
57import gws
58import gws.base.auth
59import gws.lib.net
62class UserRule(gws.Data):
63 """Rule that assigns GWS roles to LDAP accounts."""
65 roles: list[str]
66 """GWS roles assigned to matching accounts."""
67 matches: Optional[str]
68 """LDAP filter the account has to match."""
69 memberOf: Optional[str]
70 """LDAP filter for groups the account has to be a member of."""
73class SSLConfig(gws.Config):
74 """SSL settings for the LDAP connection."""
76 ca: Optional[gws.FilePath]
77 """Path to the CA certificate file."""
78 crt: Optional[gws.FilePath]
79 """Path to the client certificate file."""
80 key: Optional[gws.FilePath]
81 """Path to the client key file."""
84@gws.ext.config.authProvider('ldap')
85class Config(gws.base.auth.provider.Config):
86 """Authentication against an LDAP or Active Directory server."""
88 activeDirectory: bool = True
89 """The LDAP server is an Active Directory."""
90 bindDN: Optional[str]
91 """DN to bind as for user lookups, anonymous bind if empty."""
92 bindPassword: Optional[str]
93 """Password for the bind DN."""
94 displayNameFormat: Optional[gws.FormatStr]
95 """Format string for the user's display name."""
96 users: list[UserRule]
97 """Rules that assign GWS roles to LDAP accounts."""
98 timeout: gws.Duration = '30'
99 """Network timeout for LDAP connections."""
100 url: str
101 """LDAP server URL."""
102 ssl: Optional[SSLConfig]
103 """SSL settings, enables ldaps."""
106@gws.ext.object.authProvider('ldap')
107class Object(gws.base.auth.provider.Object):
108 """LDAP authentication provider."""
110 serverUrl: str
111 """Server URL without path and query, with the ``ldap`` or ``ldaps`` scheme."""
112 baseDN: str
113 """Base DN for searches."""
114 loginAttribute: str
115 """Attribute that holds the login name."""
116 timeout: int
117 """Network timeout in seconds."""
118 ssl: Optional[SSLConfig]
119 """SSL settings, or ``None``."""
120 activeDirectory: bool
121 """The server is an Active Directory."""
122 bindDN: str
123 """DN to bind as, empty for an anonymous bind."""
124 bindPassword: str
125 """Password for the bind DN."""
126 displayNameFormat: str
127 """Format string for the display name, empty if none."""
128 userRules: list[UserRule]
129 """Rules that assign roles to accounts."""
131 def configure(self):
132 self.timeout = self.cfg('timeout', default=30)
134 self.activeDirectory = self.cfg('activeDirectory', default=True)
135 self.bindDN = self.cfg('bindDN', default='')
136 self.bindPassword = self.cfg('bindPassword', default='')
137 self.displayNameFormat = self.cfg('displayNameFormat', default='')
138 self.ssl = self.cfg('ssl')
139 self.userRules = self.cfg('users', default=[])
141 proto = 'ldaps' if self.ssl else 'ldap'
142 p = gws.lib.net.parse_url(self.cfg('url'))
144 self.serverUrl = proto + '://' + p.netloc
145 self.baseDN = p.path.strip('/')
146 self.loginAttribute = p.query
148 try:
149 with self._connection():
150 gws.log.debug(f'LDAP connection {self.uid!r} ok')
151 except Exception as exc:
152 raise gws.Error(f'LDAP connection error: {exc.__class__.__name__}') from exc
154 def authenticate(self, method, credentials):
155 username = credentials.get('username', '').strip()
156 password = credentials.get('password', '')
157 if not username or not password:
158 return
160 with self._connection() as conn:
161 rec = self._get_user_record(conn, username, password)
162 if not rec:
163 return
165 # NB need to rebind as admin
166 with self._connection() as conn:
167 return self._make_user(conn, rec)
169 def get_user(self, local_uid):
170 with self._connection() as conn:
171 users = self._find(conn, _make_filter({self.loginAttribute: local_uid}))
172 if len(users) == 1:
173 return self._make_user(conn, users[0])
175 ##
177 def _get_user_record(self, conn, username, password):
178 """Find the entry for a login name and bind as it with the password."""
179 users = self._find(conn, _make_filter({self.loginAttribute: username}))
181 if len(users) == 0:
182 return
183 if len(users) > 1:
184 raise gws.AuthenticationError(f'multiple entries for {username!r}')
186 rec = users[0]
188 # check for AD disabled accounts
189 uac = str(rec.get('userAccountControl', ''))
190 if uac and uac.isdigit() and (int(uac) & _MS_ACCOUNTDISABLE):
191 raise gws.ForbiddenError('ACCOUNTDISABLE flag set')
193 try:
194 conn.simple_bind_s(rec['dn'], password)
195 return rec
196 except ldap.INVALID_CREDENTIALS:
197 raise gws.AuthenticationError(f'wrong password for {username!r}')
198 except ldap.LDAPError as exc:
199 gws.log.exception()
200 raise gws.ForbiddenError(f'LDAP error {exc!r}') from exc
202 def _make_user(self, conn, rec):
203 """Create a user from an LDAP entry."""
204 user_rec = dict(rec)
205 user_rec['roles'] = self._roles_for_user(conn, rec)
207 if not user_rec.get('displayName') and self.displayNameFormat:
208 user_rec['displayName'] = gws.u.format_map(self.displayNameFormat, rec)
210 login = ''
211 for k in list(user_rec):
212 if k.lower() == self.loginAttribute.lower():
213 login = user_rec.pop(k)
214 break
215 user_rec['localUid'] = user_rec['loginName'] = login
217 return gws.base.auth.user.from_record(self, user_rec)
219 def _roles_for_user(self, conn, rec):
220 """Return the roles of all ``users`` rules that match the entry."""
221 user_dn = rec['dn']
222 roles = set()
224 for u in self.userRules:
225 if u.get('matches') and self._matches(conn, user_dn, u.matches):
226 roles.update(u.roles)
228 if u.get('memberOf') and self._find(conn, _member_filter(u.memberOf, user_dn)):
229 roles.update(u.roles)
231 return sorted(roles)
233 def _matches(self, conn, dn, flt):
234 try:
235 res = conn.search_s(dn, ldap.SCOPE_BASE, flt)
236 return bool(res)
237 except ldap.NO_SUCH_OBJECT:
238 return False
240 def _find(self, conn, flt):
241 """Search below the base DN and return the entries as dicts."""
242 try:
243 res = conn.search_s(self.baseDN, ldap.SCOPE_SUBTREE, flt)
244 except ldap.NO_SUCH_OBJECT:
245 return []
247 dcts = []
249 for dn, data in res:
250 if dn:
251 d = _as_dict(data)
252 d['dn'] = dn
253 dcts.append(d)
255 return dcts
257 @contextlib.contextmanager
258 def _connection(self):
259 """Open a connection, bound as the bind DN if configured."""
260 conn = ldap.initialize(self.serverUrl)
261 conn.set_option(ldap.OPT_NETWORK_TIMEOUT, self.timeout)
263 if self.ssl:
264 if self.root.app.developer_option('ldap.ssl_insecure'):
265 conn.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
266 else:
267 conn.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_DEMAND)
268 if self.ssl.ca:
269 conn.set_option(ldap.OPT_X_TLS_CACERTFILE, self.ssl.ca)
270 if self.ssl.crt and self.ssl.key:
271 conn.set_option(ldap.OPT_X_TLS_CERTFILE, self.ssl.crt)
272 conn.set_option(ldap.OPT_X_TLS_KEYFILE, self.ssl.key)
273 conn.set_option(ldap.OPT_X_TLS_NEWCTX, 0)
275 if self.activeDirectory:
276 # see https://www.python-ldap.org/faq.html#usage
277 conn.set_option(ldap.OPT_REFERRALS, 0)
279 try:
280 if self.bindDN:
281 conn.simple_bind_s(self.bindDN, self.bindPassword)
282 yield conn
283 finally:
284 conn.unbind_s()
287def _as_dict(data):
288 """Convert attribute values to strings, unwrapping single values."""
289 d = {}
291 for k, v in data.items():
292 if not v:
293 continue
294 if not isinstance(v, list):
295 v = [v]
296 v = [gws.u.to_str(s) for s in v]
297 d[k] = v[0] if len(v) == 1 else v
299 return d
302def _make_filter(filter_dict):
303 """Create an AND filter that matches the given attribute values."""
304 conds = []
305 for k, v in filter_dict.items():
306 conds.append(f'({k}={_escape(v)})')
307 return '(&' + ''.join(conds) + ')'
310def _member_filter(group_filter, user_dn):
311 """Build a filter for groups that match ``group_filter`` and list ``user_dn`` as a member.
313 The server evaluates the membership, so this also works for Active Directory groups
314 whose member list exceeds ``MaxValRange`` and is returned in ranges.
315 """
316 if not group_filter.startswith('('):
317 group_filter = f'({group_filter})'
318 dn = _escape(user_dn)
319 return f'(&{group_filter}(|(member={dn})(members={dn})(uniqueMember={dn})))'
322def _escape(s):
323 return ldap.filter.escape_filter_chars(s, 1)
326# https://support.microsoft.com/en-us/help/305144
328_MS_SCRIPT = 0x0001
329_MS_ACCOUNTDISABLE = 0x0002
330_MS_HOMEDIR_REQUIRED = 0x0008
331_MS_LOCKOUT = 0x0010
332_MS_PASSWD_NOTREQD = 0x0020
333_MS_PASSWD_CANT_CHANGE = 0x0040
334_MS_ENCRYPTED_TEXT_PWD_ALLOWED = 0x0080
335_MS_TEMP_DUPLICATE_ACCOUNT = 0x0100
336_MS_NORMAL_ACCOUNT = 0x0200
337_MS_INTERDOMAIN_TRUST_ACCOUNT = 0x0800
338_MS_WORKSTATION_TRUST_ACCOUNT = 0x1000
339_MS_SERVER_TRUST_ACCOUNT = 0x2000
340_MS_DONT_EXPIRE_PASSWORD = 0x10000
341_MS_MNS_LOGON_ACCOUNT = 0x20000
342_MS_SMARTCARD_REQUIRED = 0x40000
343_MS_TRUSTED_FOR_DELEGATION = 0x80000
344_MS_NOT_DELEGATED = 0x100000
345_MS_USE_DES_KEY_ONLY = 0x200000
346_MS_DONT_REQ_PREAUTH = 0x400000
347_MS_PASSWORD_EXPIRED = 0x800000
348_MS_TRUSTED_TO_AUTH_FOR_DELEGATION = 0x1000000
349_MS_PARTIAL_SECRETS_ACCOUNT = 0x04000000