├── README.md ├── adhandler.py ├── convert_ldap_objectguid.py ├── handler.py ├── ldap专业术语概述.md ├── requirements.txt └── 修改AD查询1000条限制.md /README.md: -------------------------------------------------------------------------------- 1 | #### RayKuan @ 2017-09-03 10:25:29 2 | 3 | # msldap 4 | 概述:使用python的pyldap模块操作Microsoft Active Directory 5 | 6 | 在adhandler.py文件中封装以下功能:   7 | 8 | 1、获取域用户信息 9 | 10 | 2、获取域密码策略 11 | 12 | 3、修改及重置域用户密码 13 | 14 | 其他功能可以在此基础上做扩展 15 | 16 | 依赖python第三方库:python-ldap 17 | 18 | 需要AD server颁发证书才能通过ldaps协议636端口修改域用户信息 19 | 20 | AD server证书颁发步骤: 21 | 22 | ① AD上需要安装证书服务 23 | 24 | ② 连接AD的主机上使用http://ad-server-ip/certsrv/打开浏览器申请证书 25 | 26 | ③ 如果连接AD的主机是Linux,需要安装openssl包,制作CA证书 27 | 28 | 29 | ## 更新 30 | 2018-02-13 去掉django配置,修改优化adhandler.py文件 31 | 32 | ## 参考 33 | 原文件为handler.py(注:原文出处已不得知,如有侵权请告知删除),对此文件做了汉化和修改 34 | 35 | ## 版权 36 | 本项目采用GNU协议。如果你更改了此项目的代码用于自己的项目,请开源你更改的代码 37 | -------------------------------------------------------------------------------- /adhandler.py: -------------------------------------------------------------------------------- 1 | import re 2 | import datetime 3 | import ldap 4 | import logging 5 | import random 6 | log = logging.getLogger('django') 7 | 8 | 9 | # Python LDAP连接AD服务器初始化需配置的全部参数 10 | BASE_DN = 'dc=test,dc=com' 11 | HOST = random.choice(['192.168.22.101', '192.168.22.102']) 12 | BIND_DN = 'CN=ldap,OU=运维部,OU=测试集团,DC=test,DC=com' 13 | BIND_RDN = 'ldap@test.com' 14 | BIND_PWD = 'P@ssw0rd' 15 | CERT_FILE = './cert/ad_test.pem' 16 | LDAP_URI = 'ldaps://%s:636' % (HOST,) 17 | 18 | 19 | class ActiveDirectory: 20 | # 自定义配置当用户账号出现如下几种状态时能否修改密码 21 | # ['acct_pwd_expired', 'acct_expired', 'acct_disabled', 'acct_locked'] 22 | can_change_pwd_states = ['acct_pwd_expired'] 23 | domain_pwd_policy = {} # 全局域密码策略 24 | granular_pwd_policy = {} # 细颗粒度的DN密码策略keys are policy DNs 25 | 26 | def __init__(self): 27 | self.conn = None 28 | self.base_dn = BASE_DN 29 | self.host = HOST 30 | self.bind_dn = BIND_DN 31 | self.bind_rdn = BIND_RDN 32 | self.bind_pwd = BIND_PWD 33 | self.cert_file = CERT_FILE 34 | self.uri = LDAP_URI 35 | ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) 36 | 37 | try: 38 | self.conn = ldap.initialize(self.uri) 39 | self.conn.set_option(ldap.OPT_REFERRALS, 0) 40 | self.conn.set_option(ldap.OPT_PROTOCOL_VERSION, 3) 41 | self.conn.set_option(ldap.OPT_X_TLS, ldap.OPT_X_TLS_DEMAND) 42 | self.conn.set_option(ldap.OPT_X_TLS_DEMAND, True) 43 | self.conn.set_option(ldap.OPT_DEBUG_LEVEL, 255) 44 | self.conn.set_option(ldap.OPT_X_TLS_CACERTFILE, self.cert_file) 45 | self.conn.simple_bind_s(self.bind_rdn, self.bind_pwd) 46 | 47 | if not self.is_admin(self.bind_dn): 48 | # 如果绑定的用户名不属于管理员组就抛出异常 49 | raise Exception('绑定的用户必须有管理员权限') 50 | self.get_pwd_policies() # 获取全局域密码策略 51 | except ldap.LDAPError as e: 52 | raise e 53 | 54 | def is_admin(self, search_dn, admin=0): 55 | # Recursively look at what groups search_dn is a member of. 56 | # If we find a search_dn is a member of the builtin Administrators group, return true. 57 | 58 | if not self.conn: 59 | return None 60 | try: 61 | results = self.conn.search_s(search_dn, ldap.SCOPE_BASE, '(memberOf=*)', ['memberOf']) 62 | except ldap.LDAPError as e: 63 | raise e 64 | if not results: 65 | return 0 66 | if ('CN=Administrators,CN=Builtin,' + self.base_dn).lower() in [g.decode().lower() for g in results[0][1].get('memberOf', None)]: 67 | return 1 68 | group_list = [] 69 | for group in results[0][1]['memberOf']: 70 | group_list.append(group) 71 | if group not in group_list: 72 | admin |= self.is_admin(group.decode()) 73 | # Break early once we detect admin 74 | if admin: 75 | return admin 76 | return admin 77 | 78 | def get_pwd_policies(self): 79 | # 获取密码策略方法 80 | default_policy_container = self.base_dn 81 | default_policy_attribs = [ 82 | 'maxPwdAge', 83 | 'minPwdLength', 84 | 'pwdHistoryLength', 85 | 'pwdProperties', 86 | 'lockoutThreshold', 87 | 'lockOutObservationWindow', 88 | 'lockoutDuration' 89 | ] 90 | 91 | default_policy_map = { 92 | 'maxPwdAge': 'pwd_ttl', 93 | 'minPwdLength': 'pwd_length_min', 94 | 'pwdHistoryLength': 'pwd_history_depth', 95 | 'pwdProperties': 'pwd_complexity_enforced', 96 | 'lockoutThreshold': 'pwd_lockout_threshold', 97 | 'lockOutObservationWindow': 'pwd_lockout_window', 98 | 'lockoutDuration': 'pwd_lockout_ttl' 99 | } 100 | 101 | granular_policy_container = 'CN=Password Settings Container,CN=System,%s' % (self.base_dn,) 102 | granular_policy_filter = '(objectClass=msDS-PasswordSettings)' 103 | 104 | granular_policy_attribs = [ 105 | 'msDS-LockoutDuration', 106 | 'msDS-LockoutObservationWindow', 107 | 'msDS-PasswordSettingsPrecedence', 108 | 'msDS-MaximumPasswordAge', 109 | 'msDS-LockoutThreshold', 110 | 'msDS-MinimumPasswordLength', 111 | 'msDS-PasswordComplexityEnabled', 112 | 'msDS-PasswordHistoryLength' 113 | ] 114 | 115 | granular_policy_map = { 116 | 'msDS-MaximumPasswordAge': 'pwd_ttl', 117 | 'msDS-MinimumPasswordLength': 'pwd_length_min', 118 | 'msDS-PasswordComplexityEnabled': 'pwd_complexity_enforced', 119 | 'msDS-PasswordHistoryLength': 'pwd_history_depth', 120 | 'msDS-LockoutThreshold': 'pwd_lockout_threshold', 121 | 'msDS-LockoutObservationWindow': 'pwd_lockout_window', 122 | 'msDS-LockoutDuration': 'pwd_lockout_ttl', 123 | 'msDS-PasswordSettingsPrecedence': 'pwd_policy_priority' 124 | } 125 | 126 | if not self.conn: 127 | return None 128 | try: 129 | # AD域范围内的策略. 130 | results = self.conn.search_s(default_policy_container, ldap.SCOPE_BASE) 131 | except ldap.LDAPError as e: 132 | raise e 133 | dpp = dict([(default_policy_map[k], results[0][1][k][0]) for k in default_policy_map.keys()]) 134 | dpp["pwd_policy_priority"] = 0 # 0表示不用对它计算优先级 135 | self.domain_pwd_policy = self.sanitize_pwd_policy(dpp) 136 | # Server 2008r2 only. Per-group policies in CN=Password Settings Container,CN=System 137 | results = self.conn.search_s(granular_policy_container, ldap.SCOPE_ONELEVEL, granular_policy_filter, 138 | granular_policy_attribs) 139 | for policy in results: 140 | gpp = dict([(granular_policy_map[k], policy[1][k][0]) for k in granular_policy_map.keys()]) 141 | self.granular_pwd_policy[policy[0]] = self.sanitize_pwd_policy(gpp) 142 | self.granular_pwd_policy[policy[0]]['pwd_policy_dn'] = policy[0] 143 | 144 | def sanitize_pwd_policy(self, pwd_policy): 145 | # 密码策略 146 | valid_policy_entries = [ 147 | 'pwd_ttl', 148 | 'pwd_length_min', 149 | 'pwd_history_depth', 150 | 'pwd_complexity_enforced', 151 | 'pwd_lockout_threshold', 152 | 'pwd_lockout_window', 153 | 'pwd_lockout_ttl', 154 | 'pwd_policy_priority' 155 | ] 156 | 157 | if len(set(valid_policy_entries) - set(pwd_policy.keys())) != 0: 158 | return None 159 | 160 | # 密码历史记录次数限制 161 | pwd_policy['pwd_history_depth'] = int(pwd_policy['pwd_history_depth']) 162 | 163 | # 最短密码长度 164 | pwd_policy['pwd_length_min'] = int(pwd_policy['pwd_length_min']) 165 | 166 | # 密码复杂度要求 167 | pwd_policy['pwd_complexity_enforced'] = ( 168 | int(pwd_policy['pwd_complexity_enforced']) & 0x1 169 | if pwd_policy['pwd_complexity_enforced'] not in ['TRUE', 'FALSE'] 170 | else int({'TRUE': 1, 'FALSE': 0}[pwd_policy['pwd_complexity_enforced']]) 171 | ) 172 | 173 | # 密码最长使用多久后会要求用户更改密1970 timestamp 15552000 174 | pwd_policy['pwd_ttl'] = self.ad_time_to_seconds(pwd_policy['pwd_ttl']) 175 | 176 | # 密码尝试失败次数过多导致帐户锁定后,帐户的锁定时长(单位是秒) 177 | pwd_policy['pwd_lockout_ttl'] = self.ad_time_to_seconds(pwd_policy['pwd_lockout_ttl']) 178 | 179 | # 密码计数器出现错误后多长时间进行重置(单位是秒) 180 | pwd_policy['pwd_lockout_window'] = self.ad_time_to_seconds(pwd_policy['pwd_lockout_window']) 181 | 182 | # 锁定用户帐户前允许的密码尝试失败次数(0代表不锁定) 183 | pwd_policy['pwd_lockout_threshold'] = int(pwd_policy['pwd_lockout_threshold']) 184 | 185 | # 同一用户在使用不同密码策略的多个组中具有成员资格时,建立优先次序 186 | pwd_policy['pwd_policy_priority'] = int(pwd_policy['pwd_policy_priority']) 187 | return pwd_policy 188 | 189 | # AD's date format is 100 nanosecond intervals since Jan 1 1601 in GMT. 190 | # To convert to seconds, divide by 10000000. 191 | # To convert to UNIX, convert to positive seconds and add 11644473600 to be seconds since Jan 1 1970 (epoch). 192 | 193 | def ad_time_to_seconds(self, ad_time): 194 | return -(int(ad_time) / 10000000) 195 | 196 | def ad_seconds_to_unix(self, ad_seconds): 197 | return (int(ad_seconds) + 11644473600) if int(ad_seconds) != 0 else 0 198 | 199 | def ad_time_to_unix(self, ad_time): 200 | # A value of 0 or 0x7FFFFFFFFFFFFFFF (9223372036854775807) indicates that the account never expires. 201 | # FIXME: Better handling of account-expires! 202 | ad_time = ad_time.decode() 203 | if ad_time == "9223372036854775807": 204 | ad_time = "0" 205 | ad_seconds = self.ad_time_to_seconds(ad_time) 206 | return -self.ad_seconds_to_unix(ad_seconds) 207 | 208 | def user_authn(self, user, user_pwd): 209 | # 通过传入的user和user_pwd去绑定ldap查找DN, 成功返回True抛出异常则验证失败 210 | try: 211 | status = self.get_user_status(user) 212 | if status.get('code', None) != '200': 213 | return status 214 | status = status['data'] 215 | bind_dn = status['user_dn'] 216 | user_conn = ldap.initialize(self.uri) 217 | user_conn.simple_bind_s(bind_dn, user_pwd) 218 | except ldap.INVALID_CREDENTIALS as e: 219 | raise Exception(self.parse_invalid_credentials(e, bind_dn)) 220 | except ldap.LDAPError as e: 221 | raise e 222 | return {'code': '200', 'result': 'success', 'msg': '用户和密码验证成功', 'data': status} 223 | 224 | def get_user_status(self, user, ou=None): 225 | if ou: 226 | user_base = "ou=%s,%s" % (ou, self.base_dn) 227 | else: 228 | user_base = self.base_dn 229 | # user_filter = "(sAMAccountName=%s)" % (user,) 230 | # searchFiltername = 'sAMAccountName' 231 | searchFilter = '(&(objectClass=person)(sAMAccountName=%s))' % (user,) 232 | user_scope = ldap.SCOPE_SUBTREE 233 | status_attribs = [ 234 | 'pwdLastSet', 235 | 'accountExpires', 236 | 'userAccountControl', 237 | 'memberOf', 238 | 'msDS-User-Account-Control-Computed', 239 | 'msDS-UserPasswordExpiryTimeComputed', 240 | 'msDS-ResultantPSO', 241 | 'lockoutTime', 242 | 'sAMAccountName', 243 | 'displayName', 244 | 'mail', 245 | 'company', 246 | 'department', 247 | 'title', 248 | 'mobile', 249 | 'l' 250 | ] 251 | 252 | user_status = { 253 | 'user_dn': '', 254 | 'user_id': '', 255 | 'user_displayname': '', 256 | 'acct_pwd_expiry_enabled': '', 257 | 'acct_pwd_expiry': '', 258 | 'acct_pwd_last_set': '', 259 | 'acct_pwd_expired': '', 260 | 'acct_pwd_policy': '', 261 | 'acct_disabled': '', 262 | 'acct_locked': '', 263 | 'acct_locked_expiry': '', 264 | 'acct_expired': '', 265 | 'acct_expiry': '', 266 | 'acct_can_change_pwd': '', 267 | 'acct_bad_states': [], 268 | 'mail': '', 269 | 'company': '', 270 | 'department': '', 271 | 'title': '', 272 | 'mobile': '', 273 | 'region': '' 274 | } 275 | 276 | bad_states = ['acct_locked', 'acct_disabled', 'acct_expired', 'acct_pwd_expired'] 277 | 278 | try: 279 | # 查询status_attribs列表中指定的用户信息 280 | results_tmp = self.conn.search_s(user_base, user_scope, searchFilter, status_attribs) 281 | results = [] 282 | except ldap.LDAPError as e: 283 | raise e 284 | 285 | if len(results_tmp) != 1: 286 | for s in range(len(results_tmp)): 287 | if results_tmp[s][0]: 288 | results.append(results_tmp[s]) 289 | else: 290 | results = results_tmp[0][0] 291 | if len(results) != 1: # sAMAccountName must be unique 292 | return {'code': '525', 'result': 'failed', 'msg': '账户不存在'} 293 | 294 | result = results[0] 295 | user_dn = result[0] 296 | user_attribs = result[1] 297 | 298 | # UserAccountControl用于属性表示帐户的行为和特征,具体参见微软官方对照表 299 | uac = int(user_attribs.get('userAccountControl', None)[0]) 300 | 301 | # msDS-User-Account-Control-Computed属性也表示详细的帐户特征 302 | uac_live = int(user_attribs.get('msDS-User-Account-Control-Computed', None)[0]) 303 | 304 | s = user_status 305 | s['user_dn'] = user_dn 306 | s['user_id'] = user_attribs.get('sAMAccountName', None)[0].decode() 307 | s['user_displayname'] = user_attribs.get('displayName', None)[0].decode() 308 | 309 | if user_attribs.get('mail', None): 310 | s['mail'] = user_attribs.get('mail', None)[0].decode() 311 | 312 | if user_attribs.get('company', None): 313 | s['company'] = user_attribs.get('company', None)[0].decode() 314 | 315 | if user_attribs.get('department', None): 316 | s['department'] = user_attribs.get('department', None)[0].decode() 317 | 318 | if user_attribs.get('title', None): 319 | s['title'] = user_attribs.get('title', None)[0].decode() 320 | 321 | if user_attribs.get('mobile', None): 322 | s['mobile'] = user_attribs.get('mobile', None)[0].decode() 323 | 324 | if user_attribs.get('l', None): 325 | s['region'] = user_attribs.get('l', None)[0].decode() 326 | 327 | # AD密码复杂度要求不能超过2个单词在displayName列表中 328 | s['user_displayname_tokenized'] = [a for a in re.split('[,.\-_ #\t]+', (s['user_displayname'])) if len(a) > 2] 329 | 330 | # uac_live(msDS-User-Account-Control-Computed)中包含了账号被锁、账号被禁用状态 331 | s['acct_locked'] = (1 if (uac_live & 0x00000010) else 0) 332 | s['acct_disabled'] = (1 if (uac & 0x00000002) else 0) 333 | 334 | # 账号过期的时间戳 335 | s['acct_expiry'] = self.ad_time_to_unix(user_attribs.get('accountExpires', None)[0]) 336 | 337 | # 账号是否过期0代表不过期1代表过期(此语句须保证在Linux系统才能正确运行,window因时间取值格式不兼容会抛出OSError) 338 | s['acct_expired'] = (0 if datetime.datetime.fromtimestamp(s['acct_expiry']) > datetime.datetime.now() or s['acct_expiry'] == 0 else 1) 339 | 340 | # 最后一次修改密码的时间戳 341 | s['acct_pwd_last_set'] = self.ad_time_to_unix(user_attribs.get('pwdLastSet', None)[0]) 342 | 343 | # 帐户的密码是否设置永不过期(1代表永不过期) 344 | s['acct_pwd_expiry_enabled'] = (0 if (uac & 0x00010000) else 1) 345 | 346 | # 对于密码过期,需要确定哪些策略(如果有的话)适用于该用户 347 | # msDS-ResultantPSO(对于Server 2008+之后的版本如果应用了PSO多元密码策略将会提交此属性) 348 | # 如果没有提交msDS-ResultantPSO属性,就用默认的域策略 349 | if 'msDS-ResultantPSO' in user_attribs and user_attribs.get('msDS-ResultantPSO', None)[0] in self.granular_pwd_policy: 350 | s['acct_pwd_policy'] = self.granular_pwd_policy[user_attribs.get('msDS-ResultantPSO', None)[0]] 351 | else: 352 | s['acct_pwd_policy'] = self.domain_pwd_policy 353 | 354 | # If account is locked, expiry comes from lockoutTime + policy lockout ttl. 355 | # lockoutTime is only reset to 0 on next successful login. 356 | s['acct_locked_expiry'] = (self.ad_time_to_unix(user_attribs.get('lockoutTime', None)[0]) + s['acct_pwd_policy']['pwd_lockout_ttl'] if s['acct_locked'] else 0) 357 | 358 | # msDS-UserPasswordExpiryTimeComputed表示账号什么时间将会过期,如果从不过期这个值会很大 359 | s['acct_pwd_expiry'] = self.ad_time_to_unix(user_attribs['msDS-UserPasswordExpiryTimeComputed'][0]) 360 | 361 | # 表示账号是否过期(0代表未过期, 1代表过期) 362 | s['acct_pwd_expired'] = (1 if (uac_live & 0x00800000) else 0) 363 | 364 | for state in bad_states: 365 | if s[state]: 366 | s['acct_bad_states'].append(state) 367 | 368 | # 如果s['acct_bad_states']存在,但self.can_change_pwd_states不存在,则该用户不能修改密码 369 | s['acct_can_change_pwd'] = (0 if (len(set(s['acct_bad_states']) - set(self.can_change_pwd_states)) != 0) else 1) 370 | # return s 371 | return {'code': '200', 'result': 'success', 'msg': '成功获取用户信息', 'data': s} 372 | 373 | def change_pwd(self, user, current_pwd, new_pwd): 374 | # 通过旧密码修改账户密码 375 | # 密码需满足/长度/复杂度/历史记录/三个要求 376 | # 必须存在用户, not be priv'd, 状态必须在自定义的self.can_change_pwd_states列表中 377 | status = self.get_user_status(user) 378 | if status.get('code', None) != '200': 379 | return status 380 | status = status['data'] 381 | user_dn = status['user_dn'] 382 | 383 | # 限制管理员远程修改密码 384 | # if self.is_admin(user_dn): 385 | # return {'code': '5551', 'result': 'failed', 'msg': '%s 是管理员账号不能用此工具修改密码' % (user,)} 386 | 387 | # 判断全局配置中是否有不能修改密码项 388 | if not status['acct_can_change_pwd']: 389 | # raise self.user_cannot_change_pwd(user, status, self.can_change_pwd_states) 390 | return {'code': '5553', 'result': 'failed', 'msg': '%s 不能修改密码: %s' % (user, ', '.join((set(status['acct_bad_states']) - set(self.can_change_pwd_states))))} 391 | 392 | # 新密码必须遵循域策略 393 | if len(new_pwd) < status['acct_pwd_policy']['pwd_length_min']: 394 | msg = '新密码最少%d位长度本次只提交了%d位' % (status['acct_pwd_policy']['pwd_length_min'], len(new_pwd)) 395 | return {'code': '5552', 'result': 'failed', 'msg': '%s %s' % (user, msg)} 396 | 397 | # 新密码复杂度检查username/displayname需满足四项中的最少三项 398 | if status['acct_pwd_policy']['pwd_complexity_enforced']: 399 | patterns = [ 400 | r'.*(?P[0-9]).*', 401 | r'.*(?P[a-z]).*', 402 | r'.*(?P[A-Z]).*', 403 | r'.*(?P[~!@#$%^&*_\-+=`|\\(){}\[\]:;"\'<>,.?/]).*' 404 | ] 405 | matches = [] 406 | for pattern in patterns: 407 | match = re.match(pattern, new_pwd) 408 | if match and match.groupdict() and match.groupdict().keys(): 409 | matches.append(list(match.groupdict().keys())) 410 | if len(matches) < 3: 411 | msg = '新密码必须包含(大、小写、数字、特殊字符)其中三种, 当前只符合%d种.' % (len(matches),) 412 | return {'code': '5552', 'result': 'failed', 'msg': '%s %s' % (user, msg)} 413 | 414 | # 密码不能包含用户名 415 | if status['user_id'].lower() in new_pwd.lower(): 416 | msg = '密码不能包含用户名' 417 | return {'code': '5552', 'result': 'failed', 'msg': '%s %s' % (user, msg)} 418 | 419 | # 密码不能包含displayname 420 | for e in status['user_displayname_tokenized']: 421 | if len(e) > 2 and e.lower() in new_pwd.lower(): 422 | msg = '密码不能包含在(%s)中两个以上的字符, 但是发现有: %s.' % (', '.join(status['user_displayname_tokenized']), e) 423 | return {'code': '5552', 'result': 'failed', 'msg': '%s %s' % (user, msg)} 424 | 425 | # Encode密码并且修改,如果服务器未通过, 历史记录错误会增加. 426 | current_pwd = ('\"' + current_pwd + '\"').encode('utf-16-le') 427 | new_pwd = ('\"' + new_pwd + '\"').encode('utf-16-le') 428 | pass_mod = [(ldap.MOD_DELETE, 'unicodePwd', [current_pwd]), (ldap.MOD_ADD, 'unicodePwd', [new_pwd])] 429 | 430 | try: 431 | self.conn.modify_s(user_dn, pass_mod) 432 | except ldap.CONSTRAINT_VIOLATION as e: 433 | # If the exceptions's 'info' field begins with: 434 | # 00000056 - 旧密码不匹配 435 | # 0000052D - 新密码不符合复杂度要求 436 | e = eval(str(e)) 437 | msg = e['desc'] 438 | if e['info'].startswith('00000056'): 439 | return {'code': '52e', 'result': 'failed', 'msg': '旧密码验证失败'} 440 | elif e['info'].startswith('0000052D'): 441 | msg = '新密码不能和最近%d次使用过的相同.' % (status['acct_pwd_policy']['pwd_history_depth'],) 442 | return {'code': '5552', 'result': 'failed', 'msg': '%s 新密码不符合要求: %s' % (user, msg)} 443 | except ldap.LDAPError as e: 444 | raise e 445 | 446 | return {'code': '200', 'result': 'success', 'msg': '修改密码成功', 'data': ''} 447 | 448 | def set_pwd(self, user, new_pwd): 449 | # 重置密码,只需提供新密码不验证旧密码,用户必须存在 450 | status = self.get_user_status(user) 451 | if status.get('code', None) != '200': 452 | return status 453 | status = status['data'] 454 | user_dn = status['user_dn'] 455 | 456 | # 限制管理员远程修改密码 457 | # if self.is_admin(user_dn): 458 | # return {'code': '5551', 'result': 'failed', 'msg': '%s 是管理员账号不能用此工具修改密码' % (user,)} 459 | 460 | # 新密码必须符合密码策略最小长度 461 | if len(new_pwd) < status['acct_pwd_policy']['pwd_length_min']: 462 | msg = '新密码最少%d位长度本次只提交了%d位' % (status['acct_pwd_policy']['pwd_length_min'], len(new_pwd)) 463 | return {'code': '5552', 'result': 'failed', 'msg': '%s 新密码不符合要求: %s' % (user, msg)} 464 | 465 | # 判断全局配置中是否有不能修改密码项 466 | if not status['acct_can_change_pwd']: 467 | # raise self.user_cannot_change_pwd(user, status, self.can_change_pwd_states) 468 | return {'code': '5553', 'result': 'failed', 'msg': '%s 不能修改密码: %s' % (user, ', '.join((set(status['acct_bad_states']) - set(self.can_change_pwd_states))))} 469 | 470 | # 新密码复杂度检查username/displayname需满足四项中的最少三项 471 | if status['acct_pwd_policy']['pwd_complexity_enforced']: 472 | patterns = [ 473 | r'.*(?P[0-9]).*', 474 | r'.*(?P[a-z]).*', 475 | r'.*(?P[A-Z]).*', 476 | r'.*(?P[~!@#$%^&*_\-+=`|\\(){}\[\]:;"\'<>,.?/]).*' 477 | ] 478 | matches = [] 479 | for pattern in patterns: 480 | match = re.match(pattern, new_pwd) 481 | if match and match.groupdict() and match.groupdict().keys(): 482 | matches.append(list(match.groupdict().keys())) 483 | if len(matches) < 3: 484 | msg = '新密码必须包含(大、小写、数字、特殊字符)其中三种, 当前只符合%d种.' % (len(matches),) 485 | return {'code': '5552', 'result': 'failed', 'msg': '%s %s' % (user, msg)} 486 | 487 | # 密码不能包含用户名 488 | if status['user_id'].lower() in new_pwd.lower(): 489 | return {'code': '5552', 'result': 'failed', 'msg': '%s 新密码不能包含用户名' % (user,)} 490 | 491 | # 密码不能包含displayname 492 | for e in status['user_displayname_tokenized']: 493 | if len(e) > 2 and e.lower() in new_pwd.lower(): 494 | msg = '密码不能包含在(%s)中两个以上的字符, 但是发现有: %s.' % (', '.join(status['user_displayname_tokenized']), e) 495 | return {'code': '5552', 'result': 'failed', 'msg': '%s %s' % (user, msg)} 496 | 497 | # new_pwd = ('\"' + new_pwd + '\"', "iso-8859-1").encode('utf-16-le') 498 | new_pwd = ('\"' + new_pwd + '\"').encode('utf-16-le') 499 | pass_mod = [(ldap.MOD_REPLACE, 'unicodePwd', [new_pwd])] 500 | try: 501 | self.conn.modify_s(user_dn, pass_mod) 502 | except ldap.LDAPError as e: 503 | raise e 504 | 505 | return {'code': '200', 'result': 'success', 'msg': '重置密码成功', 'data': ''} 506 | 507 | def force_change_pwd(self, user): 508 | # 将密码过期的用户状态强制修改为正常状态 509 | # 不验证旧密码,用户必须存在 510 | status = self.get_user_status(user) 511 | if status.get('code', None) != '200': 512 | return status 513 | status = status['data'] 514 | user_dn = status['user_dn'] 515 | 516 | # 限制管理员远程修改密码 517 | # if self.is_admin(user_dn): 518 | # return {'code': '5551', 'result': 'failed', 'msg': '%s 是管理员账号不能用此工具修改密码' % (user,)} 519 | 520 | if status['acct_pwd_expiry_enabled']: 521 | mod = [(ldap.MOD_REPLACE, 'pwdLastSet', [0])] 522 | try: 523 | self.conn.modify_s(user_dn, mod) 524 | except ldap.LDAPError as e: 525 | raise e 526 | return {'code': '200', 'result': 'success', 'msg': '修改密码状态成功', 'data': ''} 527 | 528 | def parse_invalid_credentials(self, e, user_dn): 529 | if not isinstance(e, ldap.INVALID_CREDENTIALS): 530 | return None 531 | ldapcodes = { 532 | '525': '账号不存在', 533 | '52e': '账号密码错误', 534 | '530': '该账号登录受限, 一段时间内不允许登录', 535 | '531': '该账号登录受限, 在此workstation不允许登录', 536 | '532': '该账号密码已过期', 537 | '533': '该账号被禁用', 538 | '701': '该账号已过期', 539 | '773': '该账户本次登录需强制修改密码', 540 | '775': '该账户被锁定' 541 | } 542 | 543 | ldapcode_pattern = r".*AcceptSecurityContext error, data (?P[^,]+)," 544 | e = eval(str(e)) 545 | # m = re.match(ldapcode_pattern, e[0]['info']) 546 | m = re.match(ldapcode_pattern, e['info']) 547 | result = {} 548 | 549 | if not m or not len(m.groups()) > 0 or m.group('ldapcode') not in ldapcodes: 550 | result['code'] = '52e' 551 | result['result'] = 'failed' 552 | result['msg'] = '%s 账号密码验证失败' % (user_dn,) 553 | return result 554 | 555 | code = m.group('ldapcode') 556 | 557 | if code == '525': 558 | result['code'] = '525' 559 | result['result'] = 'failed' 560 | result['msg'] = '%s 账号不存在' % (user_dn,) 561 | return result 562 | 563 | if code == '52e': 564 | result['code'] = '52e' 565 | result['result'] = 'failed' 566 | result['msg'] = '%s 账号密码验证失败' % (user_dn,) 567 | return result 568 | 569 | if code == '530': 570 | result['code'] = '530' 571 | result['result'] = 'failed' 572 | result['msg'] = '%s 该账号此时段被限制登录' % (user_dn,) 573 | return result 574 | 575 | if code == '531': 576 | result['code'] = '531' 577 | result['result'] = 'failed' 578 | result['errmsg'] = '%s 账号在此workstation被限制登录' % (user_dn,) 579 | return result 580 | 581 | if code == '532': 582 | result['code'] = '532' 583 | result['result'] = 'failed' 584 | result['msg'] = '%s 该账号密码已过期' % (user_dn,) 585 | return result 586 | 587 | if code == '533': 588 | result['code'] = '533' 589 | result['result'] = 'failed' 590 | result['msg'] = '%s 该账号被禁用' % (user_dn,) 591 | return result 592 | 593 | if code == '701': 594 | result['code'] = '701' 595 | result['result'] = 'failed' 596 | result['msg'] = '%s 该账号已过期' % (user_dn,) 597 | return result 598 | 599 | if code == '773': 600 | result['code'] = '773' 601 | result['result'] = 'failed' 602 | result['msg'] = '%s 该账号密码被管理员设置为过期(下次登录需强制修改密码)' % (user_dn,) 603 | return result 604 | 605 | if code == '775': 606 | result['code'] = '775' 607 | result['result'] = 'failed' 608 | result['msg'] = '%s 密码错误次数过多账户被锁,需联系管理员解锁' % (user_dn,) 609 | return result 610 | 611 | 612 | if __name__ == '__main__': 613 | # pass 614 | a = ActiveDirectory() 615 | str1 = 'CN=张三,OU=研发,OU=测试集团,DC=test,DC=com' 616 | str2 = 'CN=ldap,OU=运维,OU=测试集团,DC=test,DC=com' 617 | str3 = 'CN=yf zuzhang,OU=研发,OU=测试集团,DC=test,DC=com' 618 | str4 = 'CN=yw os,OU=运维,OU=测试集团,DC=test,DC=com' 619 | # res = a.is_admin(str2) 620 | # res = a.get_user_status('rayk') 621 | res = a.user_authn('rayk', 'Ray#8023') 622 | # res = a.change_pwd('rayk', 'Ray#8023', 'rayk@111') 623 | # res = a.user_authn_pwd_verify('rayk', 'rayk@111') 624 | import pprint 625 | pprint.pprint(res) 626 | -------------------------------------------------------------------------------- /convert_ldap_objectguid.py: -------------------------------------------------------------------------------- 1 | # source: http://stackoverflow.com/questions/25299971/python-ldap-converting-objectguid-to-hex-string-and-back 2 | 3 | def guid2hexstring(val): 4 | s = ['\\%02X' % ord(x) for x in val] 5 | return ''.join(s) 6 | 7 | guid = ldapobject.get('objectGUID', [''])[0] # 'Igr\xafb\x19ME\xb2P9c\xfb\xa0\xe2w' 8 | guid2string(guid).replace("\\", "") # '496772AF62194D45B2503963FBA0E277' 9 | 10 | #and back to a value you can use in an ldap search filter 11 | 12 | guid = ''.join(['\\%s' % guid[i:i+2] for i in range(0, len(guid), 2)]) # '\\49\\67\\72\\AF\\62\\19\\4D\\45\\B2\\50\\39\\63\\FB\\A0\\E2\\77' 13 | 14 | searchfilter = ('(objectGUID=%s)' % guid) 15 | 16 | # alternatively, 17 | # this works to convert to LDAP Browser-compatible GUID 18 | def guid_to_string(binary_guid): 19 | import uuid 20 | return str(uuid.UUID(bytes_le=binary_guid)).lower() 21 | 22 | 23 | # 如何通过微软active directory objectGUID 查询对象信息 24 | # bytes类型的 objectGUID 与十六进制 str类型 objectGUID 相互转换 25 | objectGUID = b'Igr\xafb\x19ME\xb2P9c\xfb\xa0\xe2w' 26 | str_hex_objectGUID = str(uuid.UUID(bytes=objectGUID).hex).upper() # '496772AF62194D45B2503963FBA0E277' 27 | bytes_objectGUID = uuid.UUID(hex=str_hex_objectGUID).bytes # b'Igr\xafb\x19ME\xb2P9c\xfb\xa0\xe2w' 28 | 29 | # str类型 objectGUID 转换为支持 ldap search filter的guid 30 | guid = ''.join(['\\%s' % str_hex_objectGUID[i:i+2] for i in range(0, len(str_hex_objectGUID), 2)]) # '\\49\\67\\72\\AF\\62\\19\\4D\\45\\B2\\50\\39\\63\\FB\\A0\\E2\\77' 31 | 32 | search_filter = ('(objectGUID=%s)' % guid) 33 | ldap.conn.search_s(self.base_dn, user_scope, search_filter) 34 | -------------------------------------------------------------------------------- /handler.py: -------------------------------------------------------------------------------- 1 | import ldap 2 | import datetime 3 | import re 4 | 5 | 6 | class ActiveDirectory(object): 7 | # User configurable 8 | # Which account states will you allow to change their own password? 9 | # Any combination of: 10 | # ['acct_pwd_expired', 'acct_expired', 'acct_disabled', 'acct_locked'] 11 | can_change_pwd_states = ['acct_pwd_expired'] 12 | 13 | # Internal 14 | domain_pwd_policy = {} 15 | granular_pwd_policy = {} # keys are policy DNs 16 | 17 | def __init__(self, host, base, bind_dn, bind_pwd): 18 | ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, 0) 19 | ldap.set_option(ldap.OPT_REFERRALS, 0) 20 | ldap.set_option(ldap.OPT_PROTOCOL_VERSION, 3) 21 | self.conn = None 22 | self.host = host 23 | self.uri = "ldaps://%s" %(host,) 24 | self.base = base 25 | self.bind_dn = bind_dn 26 | 27 | try: 28 | self.conn = ldap.initialize(self.uri) 29 | self.conn.simple_bind_s(bind_dn, bind_pwd) 30 | if not self.is_admin(bind_dn): 31 | return None 32 | self.get_pwd_policies() 33 | except ldap.LDAPError as e: 34 | raise self.ldap_error(e) 35 | 36 | def user_authn_pwd_verify(self, user, user_pwd): 37 | # Attempt to bind but only throw an exception if the password is incorrect or the account 38 | # is in a state that would preclude changing the password. 39 | try: 40 | self.user_authn(user, user_pwd) 41 | except (self.authn_failure_time, self.authn_failure_workstation, 42 | (self.authn_failure_pwd_expired_natural if 'acct_pwd_expired' in self.can_change_pwd_states else None), 43 | (self.authn_failure_pwd_expired_admin if 'acct_pwd_expired' in self.can_change_pwd_states else None), 44 | (self.authn_failure_acct_disabled if 'acct_disabled' in self.can_change_pwd_states else None), 45 | (self.authn_failure_acct_expired if 'acct_expired' in self.can_change_pwd_states else None), 46 | (self.authn_failure_acct_locked if 'acct_locked' in self.can_change_pwd_states else None)): 47 | return True 48 | except Exception as e: 49 | return False 50 | return True 51 | 52 | def user_authn(self, user, user_pwd): 53 | # Look up DN for user, bind using current_pwd. 54 | # Return true on success, exception on failure. 55 | try: 56 | status = self.get_user_status(user) 57 | bind_dn = status['user_dn'] 58 | user_conn = ldap.initialize(self.uri) 59 | user_conn.simple_bind_s(bind_dn, user_pwd) 60 | except ldap.INVALID_CREDENTIALS as e: 61 | raise self.parse_invalid_credentials(e, bind_dn) 62 | except ldap.LDAPError as e: 63 | raise self.ldap_error(e) 64 | return True 65 | 66 | def change_pwd(self, user, current_pwd, new_pwd): 67 | # Change user's account using their own creds 68 | # This forces adherence to length/complexity/history 69 | # They must exist, not be priv'd, and can change pwd per can_change_pwd_states 70 | status = self.get_user_status(user) 71 | user_dn = status['user_dn'] 72 | if self.is_admin(user_dn): 73 | raise self.user_protected(user) 74 | if not status['acct_can_change_pwd']: 75 | raise self.user_cannot_change_pwd(user, status, self.can_change_pwd_states) 76 | # The new password must respect policy 77 | if not len(new_pwd) >= status['acct_pwd_policy']['pwd_length_min']: 78 | msg = 'New password for %s must be at least %d characters, submitted password has only %d.' % ( 79 | user, status['acct_pwd_policy']['pwd_length_min'], len(new_pwd)) 80 | raise self.pwd_vette_failure(user, new_pwd, msg, status) 81 | # Check Complexity - 3of4 and username/displayname check 82 | if status['acct_pwd_policy']['pwd_complexity_enforced']: 83 | patterns = [r'.*(?P[0-9]).*', r'.*(?P[a-z]).*', r'.*(?P[A-Z]).*', 84 | r'.*(?P[~!@#$%^&*_\-+=`|\\(){}\[\]:;"\'<>,.?/]).*'] 85 | matches = [] 86 | for pattern in patterns: 87 | match = re.match(pattern, new_pwd) 88 | if match and match.groupdict() and match.groupdict().keys(): 89 | matches.append(match.groupdict().keys()[0]) 90 | if len(matches) < 3: 91 | msg = 'New password for %s must contain 3 of 4 character types (lowercase, uppercase, digit, special), only found %s.' % ( 92 | user, (', ').join(matches)) 93 | raise self.pwd_vette_failure(user, new_pwd, msg, status) 94 | # The new password must not contain user's username 95 | if status['user_id'].lower() in new_pwd.lower(): 96 | msg = 'New password for %s must not contain their username.' % (user) 97 | raise self.pwd_vette_failure(user, new_pwd, msg, status) 98 | # The new password must not contain word from displayname 99 | for e in status['user_displayname_tokenized']: 100 | if len(e) > 2 and e.lower() in new_pwd.lower(): 101 | msg = 'New password for %s must not contain a word longer than 2 characters from your name in our system (%s), found %s.' % ( 102 | user, (', ').join(status['user_displayname_tokenized']), e) 103 | raise self.pwd_vette_failure(user, new_pwd, msg, status) 104 | # Encode password and attempt change. If server is unwilling, history is likely fault. 105 | current_pwd = str('\"' + current_pwd + '\"').encode('utf-16-le') 106 | new_pwd = str('\"' + new_pwd + '\"').encode('utf-16-le') 107 | pass_mod = [(ldap.MOD_DELETE, 'unicodePwd', [current_pwd]), (ldap.MOD_ADD, 'unicodePwd', [new_pwd])] 108 | try: 109 | self.conn.modify_s(user_dn, pass_mod) 110 | except ldap.CONSTRAINT_VIOLATION as e: 111 | # If the exceptions's 'info' field begins with: 112 | # 00000056 - Current passwords do not match 113 | # 0000052D - New password violates length/complexity/history 114 | msg = e[0]['desc'] 115 | if e[0]['info'].startswith('00000056'): 116 | # Incorrect current password. 117 | raise self.authn_failure(user, self.uri) 118 | elif e[0]['info'].startswith('0000052D'): 119 | msg = 'New password for %s must not match any of the past %d passwords.' % ( 120 | user, status['acct_pwd_policy']['pwd_history_depth']) 121 | raise self.pwd_vette_failure(user, new_pwd, msg, status) 122 | except ldap.LDAPError as e: 123 | raise self.ldap_error(e) 124 | 125 | def set_pwd(self, user, new_pwd): 126 | # Change the user's password using priv'd creds 127 | # They must exist, not be priv'd 128 | status = self.get_user_status(user) 129 | user_dn = status['user_dn'] 130 | if self.is_admin(user_dn): 131 | raise self.user_protected(user) 132 | # Even priv'd user must respect min password length. 133 | if not len(new_pwd) >= status['acct_pwd_policy']['pwd_length_min']: 134 | msg = 'New password for %s must be at least %d characters, submitted password has only %d.' % ( 135 | user, status['acct_pwd_policy']['pwd_length_min'], len(new_pwd)) 136 | raise self.pwd_vette_failure(user, new_pwd, msg, status) 137 | new_pwd = str('\"' + new_pwd + '\"', "iso-8859-1").encode('utf-16-le') 138 | pass_mod = [((ldap.MOD_REPLACE, 'unicodePwd', [new_pwd]))] 139 | try: 140 | self.conn.modify_s(user_dn, pass_mod) 141 | except ldap.LDAPError as e: 142 | raise self.ldap_error(e) 143 | 144 | def force_change_pwd(self, user): 145 | # They must exist, not be priv'd 146 | status = self.get_user_status(user) 147 | user_dn = status['user_dn'] 148 | if self.is_admin(user_dn): 149 | raise self.user_protected(user) 150 | if status['acct_pwd_expiry_enabled']: 151 | mod = [(ldap.MOD_REPLACE, 'pwdLastSet', [0])] 152 | try: 153 | self.conn.modify_s(user_dn, mod) 154 | except ldap.LDAPError as e: 155 | raise self.ldap_error(e) 156 | 157 | def get_user_status(self, user, ou=None): 158 | if ou: 159 | user_base = "ou=%s,%s" % (ou, self.base) 160 | else: 161 | user_base = self.base 162 | user_filter = "(sAMAccountName=%s)" % (user) 163 | searchFiltername = 'sAMAccountName' 164 | searchFilter = '(&(objectClass=person)(sAMAccountName=%s))' % (user) 165 | user_scope = ldap.SCOPE_SUBTREE 166 | status_attribs = ['pwdLastSet', 'accountExpires', 'userAccountControl', 'memberOf', 167 | 'msDS-User-Account-Control-Computed', 'msDS-UserPasswordExpiryTimeComputed', 168 | 'msDS-ResultantPSO', 'lockoutTime', 'sAMAccountName', 'displayName'] 169 | user_status = {'user_dn': '', 'user_id': '', 'user_displayname': '', 'acct_pwd_expiry_enabled': '', 170 | 'acct_pwd_expiry': '', 'acct_pwd_last_set': '', 'acct_pwd_expired': '', 'acct_pwd_policy': '', 171 | 'acct_disabled': '', 'acct_locked': '', 'acct_locked_expiry': '', 'acct_expired': '', 172 | 'acct_expiry': '', 'acct_can_change_pwd': '', 'acct_bad_states': []} 173 | bad_states = ['acct_locked', 'acct_disabled', 'acct_expired', 'acct_pwd_expired'] 174 | try: 175 | # search for user 176 | results_tmp = self.conn.search_s(user_base, user_scope, searchFilter, status_attribs) 177 | results = [] 178 | except ldap.LDAPError as e: 179 | raise self.ldap_error(e) 180 | if len(results_tmp) != 1: 181 | for s in range(len(results_tmp)): 182 | if results_tmp[s][0] != None: 183 | results.append(results_tmp[s]) 184 | else: 185 | result = results_tmp[0][0] 186 | if len(results) != 1: # sAMAccountName must be unique 187 | raise self.user_not_found(user) 188 | result = results[0] 189 | user_dn = result[0] 190 | user_attribs = result[1] 191 | uac = int(user_attribs['userAccountControl'][0]) 192 | uac_live = int(user_attribs['msDS-User-Account-Control-Computed'][0]) 193 | s = user_status 194 | s['user_dn'] = user_dn 195 | s['user_id'] = user_attribs['sAMAccountName'][0] 196 | s['user_displayname'] = user_attribs['displayName'][0] 197 | # AD complexity will not allow a word longer than 2 characters as part of displayName 198 | s['user_displayname_tokenized'] = [a for a in re.split('[,.\-_ #\t]+', s['user_displayname']) if len(a) > 2] 199 | # uac_live (msDS-User-Account-Control-Computed) contains current locked, pwd_expired status 200 | s['acct_locked'] = (1 if (uac_live & 0x00000010) else 0) 201 | s['acct_disabled'] = (1 if (uac & 0x00000002) else 0) 202 | s['acct_expiry'] = self.ad_time_to_unix(user_attribs['accountExpires'][0]) 203 | s['acct_expired'] = (0 if datetime.datetime.fromtimestamp(s['acct_expiry']) > datetime.datetime.now() or s[ 204 | 'acct_expiry'] == 0 else 1) 205 | s['acct_pwd_last_set'] = self.ad_time_to_unix(user_attribs['pwdLastSet'][0]) 206 | s['acct_pwd_expiry_enabled'] = (0 if (uac & 0x00010000) else 1) 207 | # For password expiration need to determine which policy, if any, applies to this user. 208 | # msDS-ResultantPSO will be present in Server 2008+ and if the user has a PSO applied. 209 | # If not present, use the domain default. 210 | if 'msDS-ResultantPSO' in user_attribs and user_attribs['msDS-ResultantPSO'][0] in self.granular_pwd_policy: 211 | s['acct_pwd_policy'] = self.granular_pwd_policy[user_attribs['msDS-ResultantPSO'][0]] 212 | else: 213 | s['acct_pwd_policy'] = self.domain_pwd_policy 214 | # If account is locked, expiry comes from lockoutTime + policy lockout ttl. 215 | # lockoutTime is only reset to 0 on next successful login. 216 | s['acct_locked_expiry'] = ( 217 | self.ad_time_to_unix(user_attribs['lockoutTime'][0]) + s['acct_pwd_policy']['pwd_lockout_ttl'] if s[ 218 | 'acct_locked'] else 0) 219 | # msDS-UserPasswordExpiryTimeComputed is when a password expires. If never it is very high. 220 | s['acct_pwd_expiry'] = self.ad_time_to_unix(user_attribs['msDS-UserPasswordExpiryTimeComputed'][0]) 221 | s['acct_pwd_expired'] = (1 if (uac_live & 0x00800000) else 0) 222 | for state in bad_states: 223 | if s[state]: 224 | s['acct_bad_states'].append(state) 225 | # If there is something in s['acct_bad_states'] not in self.can_change_pwd_states, they can't change pwd. 226 | s['acct_can_change_pwd'] = (0 if (len(set(s['acct_bad_states']) - set(self.can_change_pwd_states)) != 0) else 1) 227 | return s 228 | 229 | def get_pwd_policies(self): 230 | default_policy_container = self.base 231 | default_policy_attribs = ['maxPwdAge', 'minPwdLength', 'pwdHistoryLength', 'pwdProperties', 'lockoutThreshold', 232 | 'lockOutObservationWindow', 'lockoutDuration'] 233 | default_policy_map = {'maxPwdAge': 'pwd_ttl', 'minPwdLength': 'pwd_length_min', 234 | 'pwdHistoryLength': 'pwd_history_depth', 'pwdProperties': 'pwd_complexity_enforced', 235 | 'lockoutThreshold': 'pwd_lockout_threshold', 236 | 'lockOutObservationWindow': 'pwd_lockout_window', 'lockoutDuration': 'pwd_lockout_ttl'} 237 | granular_policy_container = 'CN=Password Settings Container,CN=System,%s' % (self.base) 238 | granular_policy_filter = '(objectClass=msDS-PasswordSettings)' 239 | granular_policy_attribs = ['msDS-LockoutDuration', 'msDS-LockoutObservationWindow', 240 | 'msDS-PasswordSettingsPrecedence', 'msDS-MaximumPasswordAge', 241 | 'msDS-LockoutThreshold', 'msDS-MinimumPasswordLength', 242 | 'msDS-PasswordComplexityEnabled', 'msDS-PasswordHistoryLength'] 243 | granular_policy_map = {'msDS-MaximumPasswordAge': 'pwd_ttl', 'msDS-MinimumPasswordLength': 'pwd_length_min', 244 | 'msDS-PasswordComplexityEnabled': 'pwd_complexity_enforced', 245 | 'msDS-PasswordHistoryLength': 'pwd_history_depth', 246 | 'msDS-LockoutThreshold': 'pwd_lockout_threshold', 247 | 'msDS-LockoutObservationWindow': 'pwd_lockout_window', 248 | 'msDS-LockoutDuration': 'pwd_lockout_ttl', 249 | 'msDS-PasswordSettingsPrecedence': 'pwd_policy_priority'} 250 | if not self.conn: 251 | return None 252 | try: 253 | # Load domain-wide policy. 254 | results = self.conn.search_s(default_policy_container, ldap.SCOPE_BASE) 255 | except ldap.LDAPError as e: 256 | raise self.ldap_error(e) 257 | dpp = dict([(default_policy_map[k], results[0][1][k][0]) for k in default_policy_map.keys()]) 258 | dpp["pwd_policy_priority"] = 0 # 0 Indicates don't use it in priority calculations 259 | self.domain_pwd_policy = self.sanitize_pwd_policy(dpp) 260 | # Server 2008r2 only. Per-group policies in CN=Password Settings Container,CN=System 261 | results = self.conn.search_s(granular_policy_container, ldap.SCOPE_ONELEVEL, granular_policy_filter, 262 | granular_policy_attribs) 263 | for policy in results: 264 | gpp = dict([(granular_policy_map[k], policy[1][k][0]) for k in granular_policy_map.keys()]) 265 | self.granular_pwd_policy[policy[0]] = self.sanitize_pwd_policy(gpp) 266 | self.granular_pwd_policy[policy[0]]['pwd_policy_dn'] = policy[0] 267 | 268 | def sanitize_pwd_policy(self, pwd_policy): 269 | valid_policy_entries = ['pwd_ttl', 'pwd_length_min', 'pwd_history_depth', 'pwd_complexity_enforced', 270 | 'pwd_lockout_threshold', 'pwd_lockout_window', 'pwd_lockout_ttl', 'pwd_policy_priority'] 271 | if len(set(valid_policy_entries) - set(pwd_policy.keys())) != 0: 272 | return None 273 | pwd_policy['pwd_history_depth'] = int(pwd_policy['pwd_history_depth']) 274 | pwd_policy['pwd_length_min'] = int(pwd_policy['pwd_length_min']) 275 | pwd_policy['pwd_complexity_enforced'] = ( 276 | int(pwd_policy['pwd_complexity_enforced']) & 0x1 if pwd_policy['pwd_complexity_enforced'] not in ['TRUE', 277 | 'FALSE'] else int( 278 | {'TRUE': 1, 'FALSE': 0}[pwd_policy['pwd_complexity_enforced']])) 279 | pwd_policy['pwd_ttl'] = self.ad_time_to_seconds(pwd_policy['pwd_ttl']) 280 | pwd_policy['pwd_lockout_ttl'] = self.ad_time_to_seconds(pwd_policy['pwd_lockout_ttl']) 281 | pwd_policy['pwd_lockout_window'] = self.ad_time_to_seconds(pwd_policy['pwd_lockout_window']) 282 | pwd_policy['pwd_lockout_threshold'] = int(pwd_policy['pwd_lockout_threshold']) 283 | pwd_policy['pwd_policy_priority'] = int(pwd_policy['pwd_policy_priority']) 284 | return pwd_policy 285 | 286 | def is_admin(self, search_dn, admin=0): 287 | # Recursively look at what groups search_dn is a member of. 288 | # If we find a search_dn is a member of the builtin Administrators group, return true. 289 | if not self.conn: 290 | return None 291 | try: 292 | results = self.conn.search_s(search_dn, ldap.SCOPE_BASE, '(memberOf=*)', ['memberOf']) 293 | except ldap.LDAPError as e: 294 | raise self.ldap_error(e) 295 | if not results: 296 | return 0 297 | if ('CN=Administrators,CN=Builtin,' + self.base).lower() in [g.lower() for g in results[0][1]['memberOf']]: 298 | return 1 299 | for group in results[0][1]['memberOf']: 300 | admin |= self.is_admin(group) 301 | # Break early once we detect admin 302 | if admin: 303 | return admin 304 | return admin 305 | 306 | # AD's date format is 100 nanosecond intervals since Jan 1 1601 in GMT. 307 | # To convert to seconds, divide by 10000000. 308 | # To convert to UNIX, convert to positive seconds and add 11644473600 to be seconds since Jan 1 1970 (epoch). 309 | 310 | def ad_time_to_seconds(self, ad_time): 311 | return -(int(ad_time) / 10000000) 312 | 313 | def ad_seconds_to_unix(self, ad_seconds): 314 | return ((int(ad_seconds) + 11644473600) if int(ad_seconds) != 0 else 0) 315 | 316 | def ad_time_to_unix(self, ad_time): 317 | # A value of 0 or 0x7FFFFFFFFFFFFFFF (9223372036854775807) indicates that the account never expires. 318 | # FIXME: Better handling of account-expires! 319 | if ad_time == "9223372036854775807": 320 | ad_time = "0" 321 | ad_seconds = self.ad_time_to_seconds(ad_time) 322 | return -self.ad_seconds_to_unix(ad_seconds) 323 | 324 | # Exception creators 325 | 326 | def parse_invalid_credentials(self, e, user_dn): 327 | if not isinstance(e, ldap.INVALID_CREDENTIALS): 328 | return None 329 | ldapcodes = {'525': 'user not found', 330 | '52e': 'invalid credentials', 331 | '530': 'user not permitted to logon at this time', 332 | '531': 'user not permitted to logon at this workstation', 333 | '532': 'password expired', 334 | '533': 'account disabled', 335 | '701': 'account expired', 336 | '773': 'forced expired password', 337 | '775': 'account locked'} 338 | ldapcode_pattern = r".*AcceptSecurityContext error, data (?P[^,]+)," 339 | m = re.match(ldapcode_pattern, e[0]['info']) 340 | if not m or not len(m.groups()) > 0 or m.group('ldapcode') not in ldapcodes: 341 | return self.authn_failure(e, user_dn, self.uri) 342 | code = m.group('ldapcode') 343 | if code == '525': 344 | return self.user_not_found(user_dn, code) 345 | if code == '52e': 346 | return self.authn_failure(user_dn, self.uri) 347 | if code == '530': 348 | return self.authn_failure_time(user_dn, self.uri) 349 | if code == '531': 350 | return self.authn_failure_workstation(user_dn, self.uri) 351 | if code == '532': 352 | return self.authn_failure_pwd_expired_natural(user_dn, self.uri) 353 | if code == '533': 354 | return self.authn_failure_acct_disabled(user_dn, self.uri) 355 | if code == '701': 356 | return self.authn_failure_acct_expired(user_dn, self.uri) 357 | if code == '773': 358 | return self.authn_failure_pwd_expired_admin(user_dn, self.uri) 359 | if code == '775': 360 | return self.authn_failure_acct_locked(user_dn, self.uri) 361 | 362 | # Exceptions 363 | 364 | class authn_failure(Exception): 365 | def __init__(self, user_dn, host): 366 | self.user_dn = user_dn 367 | self.host = host 368 | self.msg = 'user_dn="%s" host="%s" incorrect current password or generic authn failure' % (user_dn, host) 369 | 370 | def __str__(self): 371 | return str(self.msg) 372 | 373 | class authn_failure_time(Exception): 374 | def __init__(self, user_dn, host): 375 | self.user_dn = user_dn 376 | self.host = host 377 | self.msg = 'user_dn="%s" host="%s" user_dn has time of day login restrictions and cannot login at this time' % ( 378 | user_dn, host) 379 | 380 | def __str__(self): 381 | return str(self.msg) 382 | 383 | class authn_failure_workstation(Exception): 384 | def __init__(self, user_dn, host): 385 | self.user_dn = user_dn 386 | self.host = host 387 | self.msg = 'user_dn="%s" host="%s" user_dn has workstation login restrictions and cannot login at this workstation' % ( 388 | user_dn, host) 389 | 390 | def __str__(self): 391 | return str(self.msg) 392 | 393 | class authn_failure_pwd_expired_natural(Exception): 394 | def __init__(self, user_dn, host): 395 | self.user_dn = user_dn 396 | self.host = host 397 | self.msg = 'user_dn="%s" host="%s" user_dn\'s password has expired naturally' % (user_dn, host) 398 | 399 | def __str__(self): 400 | return str(self.msg) 401 | 402 | class authn_failure_pwd_expired_admin(Exception): 403 | def __init__(self, user_dn, host): 404 | self.user_dn = user_dn 405 | self.host = host 406 | self.msg = 'user_dn="%s" host="%s" user_dn\'s password has been administratively expired (force change on next login)' % ( 407 | user_dn, host) 408 | 409 | def __str__(self): 410 | return str(self.msg) 411 | 412 | class authn_failure_acct_disabled(Exception): 413 | def __init__(self, user_dn, host): 414 | self.user_dn = user_dn 415 | self.host = host 416 | self.msg = 'user_dn="%s" host="%s" user_dn account disabled' % (user_dn, host) 417 | 418 | def __str__(self): 419 | return str(self.msg) 420 | 421 | class authn_failure_acct_expired(Exception): 422 | def __init__(self, user_dn, host): 423 | self.user_dn = user_dn 424 | self.host = host 425 | self.msg = 'user_dn="%s" host="%s" user_dn account expired' % (user_dn, host) 426 | 427 | def __str__(self): 428 | return str(self.msg) 429 | 430 | class authn_failure_acct_locked(Exception): 431 | def __init__(self, user_dn, host): 432 | self.user_dn = user_dn 433 | self.host = host 434 | self.msg = 'user_dn="%s" host="%s" user_dn account locked due to excessive authentication failures' % ( 435 | user_dn, host) 436 | 437 | def __str__(self): 438 | return str(self.msg) 439 | 440 | class user_not_found(Exception): 441 | def __init__(self, user): 442 | self.msg = 'Could not locate user %s.' % (user) 443 | 444 | def __str__(self): 445 | return str(self.msg) 446 | 447 | class user_protected(Exception): 448 | def __init__(self, user): 449 | self.msg = '%s is a protected user; their password cannot be changed using this tool.' % (user) 450 | 451 | def __str__(self): 452 | return str(self.msg) 453 | 454 | class user_cannot_change_pwd(Exception): 455 | def __init__(self, user, status, can_change_pwd_states): 456 | self.status = status 457 | self.msg = '%s cannot change password for the following reasons: %s' % ( 458 | user, ', '.join((set(status['acct_bad_states']) - set(can_change_pwd_states)))) 459 | 460 | def __str__(self): 461 | return str(self.msg.rstrip() + '.') 462 | 463 | class pwd_vette_failure(Exception): 464 | def __init__(self, user, new_pwd, msg, status): 465 | self.user = user 466 | self.new_pwd = new_pwd 467 | self.msg = msg 468 | self.status = status 469 | 470 | def __str__(self): 471 | return str(self.msg) 472 | 473 | class ldap_error(ldap.LDAPError): 474 | def __init__(self, e): 475 | self.msg = 'LDAP Error. desc: %s info: %s' % (e[0]['desc'], e[0]['info']) 476 | 477 | def __str__(self): 478 | return str(self.msg) 479 | -------------------------------------------------------------------------------- /ldap专业术语概述.md: -------------------------------------------------------------------------------- 1 | ### 一、概述 2 | 3 | LDAP:Lightweight Directory Access Protocol 轻量级目录访问协议 4 | 5 | LDAP协议基于X.500标准, 与X.500不同,LDAP支持TCP/IP, 是跨平台的和标准的协议 6 | 7 | ### 二、基本概念 8 | 9 | 在LDAP中信息以树状方式组织,在树状信息中的基本数据单元是条目,而每个条目由属性构成,属性中存储有属性值 10 | 11 |                         O(test.com) 12 | 13 | / / 14 | 15 | ou1 ou2 16 | 17 | / / 18 | 19 | test 20 | 21 | 22 | **O:Organization 组织** 23 | 24 | 根的表示方法(参考LDAP Server) 25 | 26 | a. 组织名称(x.500) 假设组织名称为test则o=test 27 | 28 | b. 域名 假设组织域名为test.com则o=test.com 或 dc=test, dc=com 29 | 30 | **OU:** 31 | Organization Unit 组织单元 32 | 33 | **Entry:** 34 | 条目, 记录, 由DN唯一标识 35 | 36 | **DN:** 37 | Distinguished Name,每个叶子结点到根的路径就是DN, 如: cn=test, ou=ou1, o=test.com 38 | 39 | **RDN:** 40 | Relative Distinguished Name,叶子结点本身的名字是RDN, 如:test就是RDN 41 | 42 | **Base DN:** 43 | 基准DN,指定LDAP search的起始DN, 即从哪个DN下开始搜索 44 | 45 | 如搜索组织单元为ou1,则base DN为ou=ou1,o=O 或 ou=ou1,dc=test,dc=com 46 | 47 | **AttributeType:** 48 | 属性类型 49 | 50 | **ObjectClass:** 51 | 对象类,由多个attributetype(属性类型)组成, 每个条目(Entry)必须属于某个或多个对象类(Object Class) 52 | 53 | **schema文件:** 54 | 定义对象类、属性类型、语法和匹配规则, 有系统schema,用户也可自定义schema文件 55 | 56 | **LDIF:** 57 | LDAP Interchange Format, 是指存储LDAP配置信息及目录内容的标准文本文件格式。 58 | 59 | LDIF文件常用来向目录导入或更改记录信息, 基本格式:AttributeName: value如 60 | 61 | 属性名 冒号 空格 属性值 62 | 63 | dn: dc=zy,dc=net 64 | 65 | objectclass: dcObject 66 | 67 | objectclass: organization 68 | 69 | dc: zy 70 | 71 | o: test 72 | 73 | **监听端口** 74 | 75 | TCP/IP port: 389 76 | 77 | SSL port: 636 78 | 79 | ### 三、Search filter: 80 | 81 | 每个表达式都放在括号内, 多个表达式用与(&), 或(|), 非(!)等连结 82 | 83 | (&(filter1)(filter2)...(filtern)) 表示filter1,filter2,...,filtern同时满足 84 | 85 | (|(filter1)(filter2)...(filtern)) 表示filter1,filter2,...,filtern至少有一个满足 86 | 87 | (!(filter)) 表示非filter 88 | 89 | filter支持通配符*(wildcard), *表示零或多个字符, 如(objectclass=*),指列出所有类型的记录(不分类) 90 | 91 | ### 四、LDAP客户端和LDAP服务器端交互过程 92 | 93 | **1.** 绑定。LDAP客户端与服务器建立连接。可匿名绑定,也可用户名+密码绑定(参考LDAP Server, AD不支持匿名查询)。 94 | 95 | **2.** LDAP客户端向LDAP服务器发出查询、添加、修改、删除entry等操作。 96 | 97 | **3.** 解除绑定。LDAP客户端与LDAP服务器断开连接。 98 | 99 | ### 五、LDAP软件 100 | 101 | 常见的LDAP服务器:Microsoft Active Directory, IBM Domino, openldap 102 | 103 | 常见的LDAP客户端: JXplorer 104 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | python-ldap 2 | -------------------------------------------------------------------------------- /修改AD查询1000条限制.md: -------------------------------------------------------------------------------- 1 | ``` 2 | 在测试中,常遇到需要测试我们系统的AD/LDAP大用户量展现和下载功能,但win2003server似乎限制该查询数量为1000, 3 | 使用包括ldap browser在内的工具,也不能将AD server上面的10000用户展现完全。 4 | 这个问题在微软的网站给出了解决方案,在微软网站搜索“ad 1000“就能找到:http://support.microsoft.com/kb/315071 5 | 6 | 其原因是windows2003server出于性能负荷的考虑,将LDAP查询的数量限制为1000个。 7 | 当AD中的条目(user/group/ou)数量超过1000条时,使用LDAP查询工具进行查询时,就会导致查询结果返回出错。 8 | 9 | 在使用的AD用户数超过1000导入用户时,就会有出错信息提示。而且每次最多也只能够展示和导入1000个用户。 10 | 解决办法可以参见微软网站,这里也作一个记录,以免以后遗忘。 11 | 步骤如下(建议查看微软网站的描述,其中可控参数描述得很详细): 12 | 1. 在「开始」-〉「运行」-〉输入:「ntdsutil」,回车; 13 | 2. 输入:「ldap policies」,回车; 14 | 3. 输入:「connections」,回车; 15 | 4. 输入:「connect to domain cimc.com」本案例中域名是:cimc.com 16 | 5. 连接提示出现后,输入:「quit」,回车; 17 | 6. 输入:「show values」,确认当前的最大返回数;(默认是1000) 18 | 7. 输入:「set maxpagesize to 10000」,将最大返回数改为10000(最大返回数可以根据实际情况自行定义)。 19 | 8. 再度输入:「show values」,确认当前的最大返回数(显示为:1000(10000))。 20 | 9. 输入:「commit changes」以确认修改。 21 | 10. 再次输入:「show values」,确认当前的最大返回数为10000。 22 | 11. 输入:「quit」,退出设置状态; 23 | 12. 输入:「quit」,退出当前命令 24 | ``` 25 | --------------------------------------------------------------------------------