Coverage for gws-app/gws/plugin/auth_provider/ldap/__init__.py: 93%

185 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +0200

1"""LDAP authentication provider. 

2 

3Authenticates users against an LDAP or Active Directory server. The server is 

4given as an LDAP URL, a subset of the RFC 2255 form:: 

5 

6 ldap://host:port/baseDN?searchAttribute 

7 

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. 

13 

14Authentication with ``username`` and ``password`` credentials works as 

15follows: 

16 

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. 

24 

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. 

31 

32Example:: 

33 

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 } 

45 

46References: 

47 https://datatracker.ietf.org/doc/html/rfc2255 

48""" 

49 

50from typing import Optional 

51 

52import contextlib 

53 

54import ldap 

55import ldap.filter 

56 

57import gws 

58import gws.base.auth 

59import gws.lib.net 

60 

61 

62class UserSpec(gws.Data): 

63 """Rule that assigns GWS roles to LDAP accounts.""" 

64 

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.""" 

71 

72 

73class SSLConfig(gws.Config): 

74 """SSL settings for the LDAP connection.""" 

75 

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.""" 

82 

83 

84@gws.ext.config.authProvider('ldap') 

85class Config(gws.base.auth.provider.Config): 

86 """Authentication against an LDAP or Active Directory server.""" 

87 

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[UserSpec] 

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.""" 

104 

105 

106@gws.ext.object.authProvider('ldap') 

107class Object(gws.base.auth.provider.Object): 

108 """LDAP authentication provider.""" 

109 

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 users: list[UserSpec] 

129 """Rules that assign roles to accounts.""" 

130 

131 def configure(self): 

132 self.timeout = self.cfg('timeout', default=30) 

133 

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.users = self.cfg('users', default=[]) 

140 

141 proto = 'ldaps' if self.ssl else 'ldap' 

142 p = gws.lib.net.parse_url(self.cfg('url')) 

143 

144 self.serverUrl = proto + '://' + p.netloc 

145 self.baseDN = p.path.strip('/') 

146 self.loginAttribute = p.query 

147 

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 

153 

154 def authenticate(self, method, credentials): 

155 username = credentials.get('username', '').strip() 

156 password = credentials.get('password', '').strip() 

157 if not username or not password: 

158 return 

159 

160 with self._connection() as conn: 

161 rec = self._get_user_record(conn, username, password) 

162 if not rec: 

163 return 

164 

165 # NB need to rebind as admin 

166 with self._connection() as conn: 

167 return self._make_user(conn, rec) 

168 

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]) 

174 

175 ## 

176 

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})) 

180 

181 if len(users) == 0: 

182 return 

183 if len(users) > 1: 

184 raise gws.AuthenticationError(f'multiple entries for {username!r}') 

185 

186 rec = users[0] 

187 

188 # check for AD disabled accounts 

189 uac = str(rec.get('userAccountControl', '')) 

190 if uac and uac.isdigit(): 

191 if int(uac) & _MS_ACCOUNTDISABLE: 

192 raise gws.ForbiddenError('ACCOUNTDISABLE flag set') 

193 

194 try: 

195 conn.simple_bind_s(rec['dn'], password) 

196 return rec 

197 except ldap.INVALID_CREDENTIALS: 

198 raise gws.AuthenticationError(f'wrong password for {username!r}') 

199 except ldap.LDAPError as exc: 

200 gws.log.exception() 

201 raise gws.ForbiddenError(f'LDAP error {exc.__class__.__name__}') from exc 

202 

203 def _make_user(self, conn, rec): 

204 """Create a user from an LDAP entry.""" 

205 user_rec = dict(rec) 

206 user_rec['roles'] = self._roles_for_user(conn, rec) 

207 

208 if not user_rec.get('displayName') and self.displayNameFormat: 

209 user_rec['displayName'] = gws.u.format_map(self.displayNameFormat, rec) 

210 

211 login = user_rec.pop(self.loginAttribute, '') 

212 user_rec['localUid'] = user_rec['loginName'] = login 

213 

214 return gws.base.auth.user.from_record(self, user_rec) 

215 

216 def _roles_for_user(self, conn, rec): 

217 """Return the roles of all ``users`` rules that match the entry.""" 

218 user_dn = rec['dn'] 

219 roles = set() 

220 

221 for u in self.users: 

222 if u.get('matches'): 

223 for dct in self._find(conn, u.matches): 

224 if dct['dn'] == user_dn: 

225 roles.update(u.roles) 

226 

227 if u.get('memberOf'): 

228 for dct in self._find(conn, u.memberOf): 

229 if _is_member_of(dct, user_dn): 

230 roles.update(u.roles) 

231 

232 return sorted(roles) 

233 

234 def _find(self, conn, flt): 

235 """Search below the base DN and return the entries as dicts.""" 

236 try: 

237 res = conn.search_s(self.baseDN, ldap.SCOPE_SUBTREE, flt) 

238 except ldap.NO_SUCH_OBJECT: 

239 return [] 

240 

241 dcts = [] 

242 

243 for dn, data in res: 

244 if dn: 

245 d = _as_dict(data) 

246 d['dn'] = dn 

247 dcts.append(d) 

248 

249 return dcts 

250 

251 @contextlib.contextmanager 

252 def _connection(self): 

253 """Open a connection, bound as the bind DN if configured.""" 

254 conn = ldap.initialize(self.serverUrl) 

255 conn.set_option(ldap.OPT_NETWORK_TIMEOUT, self.timeout) 

256 

257 if self.ssl: 

258 if self.root.app.developer_option('ldap.ssl_insecure'): 

259 conn.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) 

260 else: 

261 conn.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_DEMAND) 

262 if self.ssl.ca: 

263 conn.set_option(ldap.OPT_X_TLS_CACERTFILE, self.ssl.ca) 

264 if self.ssl.crt and self.ssl.key: 

265 conn.set_option(ldap.OPT_X_TLS_CERTFILE, self.ssl.crt) 

266 conn.set_option(ldap.OPT_X_TLS_KEYFILE, self.ssl.key) 

267 conn.set_option(ldap.OPT_X_TLS_NEWCTX, 0) 

268 

269 if self.activeDirectory: 

270 # see https://www.python-ldap.org/faq.html#usage 

271 conn.set_option(ldap.OPT_REFERRALS, 0) 

272 

273 if self.bindDN: 

274 conn.simple_bind_s(self.bindDN, self.bindPassword) 

275 

276 try: 

277 yield conn 

278 finally: 

279 conn.unbind_s() 

280 

281 

282def _as_dict(data): 

283 """Convert attribute values to strings, unwrapping single values.""" 

284 d = {} 

285 

286 for k, v in data.items(): 

287 if not v: 

288 continue 

289 if not isinstance(v, list): 

290 v = [v] 

291 v = [gws.u.to_str(s) for s in v] 

292 d[k] = v[0] if len(v) == 1 else v 

293 

294 return d 

295 

296 

297def _make_filter(filter_dict): 

298 """Create an AND filter that matches the given attribute values.""" 

299 conds = ''.join( 

300 '({}={})'.format( 

301 ldap.filter.escape_filter_chars(k, 1), 

302 ldap.filter.escape_filter_chars(v, 1), 

303 ) 

304 for k, v in filter_dict.items() 

305 ) 

306 return '(&' + conds + ')' 

307 

308 

309def _is_member_of(group_dict, user_dn): 

310 """Check if a DN is listed as a member of a group entry.""" 

311 for key in 'member', 'members', 'uniqueMember': 

312 if key in group_dict and user_dn in group_dict[key]: 

313 return True 

314 

315 

316# https://support.microsoft.com/en-us/help/305144 

317 

318_MS_SCRIPT = 0x0001 

319_MS_ACCOUNTDISABLE = 0x0002 

320_MS_HOMEDIR_REQUIRED = 0x0008 

321_MS_LOCKOUT = 0x0010 

322_MS_PASSWD_NOTREQD = 0x0020 

323_MS_PASSWD_CANT_CHANGE = 0x0040 

324_MS_ENCRYPTED_TEXT_PWD_ALLOWED = 0x0080 

325_MS_TEMP_DUPLICATE_ACCOUNT = 0x0100 

326_MS_NORMAL_ACCOUNT = 0x0200 

327_MS_INTERDOMAIN_TRUST_ACCOUNT = 0x0800 

328_MS_WORKSTATION_TRUST_ACCOUNT = 0x1000 

329_MS_SERVER_TRUST_ACCOUNT = 0x2000 

330_MS_DONT_EXPIRE_PASSWORD = 0x10000 

331_MS_MNS_LOGON_ACCOUNT = 0x20000 

332_MS_SMARTCARD_REQUIRED = 0x40000 

333_MS_TRUSTED_FOR_DELEGATION = 0x80000 

334_MS_NOT_DELEGATED = 0x100000 

335_MS_USE_DES_KEY_ONLY = 0x200000 

336_MS_DONT_REQ_PREAUTH = 0x400000 

337_MS_PASSWORD_EXPIRED = 0x800000 

338_MS_TRUSTED_TO_AUTH_FOR_DELEGATION = 0x1000000 

339_MS_PARTIAL_SECRETS_ACCOUNT = 0x04000000