前言
公司管理员工信息以及组织架构的后台系统要和Active Directory目录服务系统打通,后台系统使用PHP开发,
折腾了二十多天,终于上线了,期间碰到过各种疑难问题,不过总算在GOOGLE大叔的帮忙下还有运维部AD管理员的帮助下解决了。
LDAP协议定义
LDAP(Lightweight Directory Access Protocol)轻量目录访问协议,定义了目录服务实现以及访问规范。
目录定义
A directory is a specialized database specifically designed for searching and browsing,
in additional to supporting basic lookup and update functions.
LDAP协议实现
0.基于TCP/IP的应用层协议 默认端口389 加密端口636
1.客户端发送命令,服务器端响应
2.目录主要操作
2.0 用户验证(bind操作)
2.1 添加节点
2.2 更新节点
2.3 移动节点
2.4 删除节点
2.5 节点搜索
3.节点类型
3.0 节点属性规范(SCHEMA)
4.节点
4.0 目录里的对象
4.1 属性即是节点的数据
4.2 目录中通过DN(Distinguished Name)唯一标识(可以认为是路径)
4.2.0 节点DN = RDN(Relative Distinguished Name) + 父节点的DN
4.3 目录是TREE结构,节点可以有子节点,也可以有父节点
5.属性
5.0 同一个属性可以有多个值
5.1 包含属性名称,属性类型
6.节点唯一标识DN说明
6.0 示例: dn:CN=John Doe,OU=Texas,DC=example,DC=com
6.1 从右到左 根节点 -> 子节点
6.2 DC:所在控制域 OU:组织单元 CN:通用名称
7.目录规范(SCHEMA)
7.0 目录节点相关规则
7.1 Attribute Syntaxes
7.2 Matching Rules
7.3 Matching Rule Uses
7.4 Attribute Types
7.5 Object Classes
7.6 Name Forms
7.7 Content Rules
7.8 Structure Rule
LDAP服务器端的实现
openLDAP,Active Directory(Microsoft)等等,除了实现协议之外的功能,还对它进行了扩展
LDAP应用场景
0.单点登录(用户管理)
1.局域网资源统一管理
封装的简单PHP类
适合AD服务器 其他的LDAP服务器需要做相应的修改
zend框架有个开源的LDAP库实现 完全面向对象
1 <?php 2 /** 3 * @description LDAP客户端类 4 * 5 * @author WadeYu 6 * @date 2015-04-28 7 * @version 0.0.1 8 */ 9 class o_ldap{ 10 private $_conn = NULL; 11 private $_sErrLog = ''; 12 private $_sOperLog = ''; 13 private $_aOptions = array( 14 'host' => 'ldap://xxx.com', 15 'port' => '389', 16 'dnSuffix' => 'OU=xx,OU=xx,DC=xx,DC=com', 17 'loginUser' => '', 18 'loginPass' => '', 19 ); 20 private $_aAllowAttrName = array( 21 'objectClass', 22 'objectGUID', //AD对象ID 23 'userPassword', //AD密码不是这个字段 密码暂时不能通过程序设置 24 'unicodePwd', //AD密码专用字段 $unicodePwd = mb_convert_encoding('"' . $newPassword . '"', 'utf-16le'); 25 'cn', //comman name 兄弟节点不能相同 26 'ou', //organizationalUnit 27 'description', //员工填工号 28 'displayName', //中文名 29 'name', //姓名 30 'sAMAccountName', //英文名(RTX账号,唯一) 31 'userPrincipalName', //登陆用户名 和 英文名一致 32 'ProtectedFromAccidentalDeletion', //对象删除保护 33 'givenName', //姓 34 'sn', //名 35 'employeeNumber', //一卡通卡号 36 'mail', 37 'mailNickname', 38 'manager', //上级 (节点路径 示例:CN=Texas Poker9,OU=Texas Poker,OU=Dept,OU=BoyaaSZ,DC=by,DC=com) 39 'title', //头衔 40 'pager', //性别 0男 1女 -1未知 41 'userAccountControl', //用户账号策略(暂时不能设置) 资料说明地址:https://support.microsoft.com/en-gb/kb/305144 42 'department', 43 'managedBy',//部门负责人 44 'distinguishedName', 45 'pwdLastSet', //等于0时 下次登录时需要修改密码 46 ); 47 48 public function __construct(array $aOptions = array()){ 49 if (!extension_loaded('ldap')){ 50 $this->_log('LDAP extension not be installed.',true); 51 } 52 $this->setOption($aOptions); 53 } 54 55 /** 56 * @return exit || true 57 */ 58 public function connect($force = false){ 59 if (!$this->_conn || $force){ 60 $host = $this->_aOptions['host']; 61 $port = $this->_aOptions['port']; 62 $this->_conn = ldap_connect($host,$port); 63 if ($this->_conn === false){ 64 $this->_log("Connect LDAP SERVER Failure.[host:{ $post}:{ $port}]",true); 65 } 66 ldap_set_option($this->_conn, LDAP_OPT_PROTOCOL_VERSION, 3); 67 ldap_set_option($this->_conn, LDAP_OPT_REFERRALS, 3); 68 $this->_bind(); 69 } 70 return true; 71 } 72 73 /** 74 * @return exit || true 75 */ 76 private function _bind(){ 77 $u = $this->_aOptions['loginUser']; 78 $p = $this->_aOptions['loginPass']; 79 $ret = @ldap_bind($this->_conn,$u,$p); 80 if ($ret === false){ 81 $this->_log(__FUNCTION__.'----'.$this->_getLastExecErrLog().'----'."u:{ $u},p:{ $p}",true); 82 } 83 return $ret; 84 } 85 86 public function setOption(array $aOptions = array()){ 87 foreach($this->_aOptions as $k => $v){ 88 if (isset($aOptions[$k])){ 89 $this->_aOptions[$k] = $aOptions[$k]; 90 } 91 } 92 } 93 94 public function getOption($field,$default = ''){ 95 return isset($this->_aOptions[$field]) ? $this->_aOptions[$field] : $default; 96 } 97 98 /** 99 * @description 查询$dn下符合属性条件的节点 返回$limit条 100 * 101 * @return array [count:x,[[prop:[count:xx,[],[]]],....]] 102 */ 103 public function getEntryList($dn,$aAttrFilter,array $aField=array(),$limit = 0,$bFixedDn = true){ 104 if (!$dn = trim($dn)){ 105 return array(); 106 } 107 if (!$this->_checkDn($dn)){ 108 return array(); 109 } 110 $limit = max(0,intval($limit)); 111 $this->connect(); 112 if ($bFixedDn){ 113 $dn = $this->_getFullDn($dn); 114 } 115 $aOldTmp = $aAttrFilter; 116 $this->_checkAttr($aAttrFilter); 117 if (!$aAttrFilter){ 118 $this->_log(__FUNCTION__.'---无效的搜索属性---'.json_encode($aOldTmp)); 119 return array(); 120 } 121 $sAttrFilter = $this->_mkAttrFilter($aAttrFilter); 122 $attrOnly = 0; 123 $this->_log(__FUNCTION__."---DN:{ $dn}---sAttr:{ $sAttrFilter}",false,'oper'); 124 $rs = @ldap_search($this->_conn,$dn,$sAttrFilter,$aField,$attrOnly,$limit); 125 if ($rs === false){ 126 $this->_log(__FUNCTION__."---dn:{ $dn}---sAttr:{ $sAttrFilter}---" . $this->_getLastExecErrLog()); 127 return array(); 128 } 129 $aRet = @ldap_get_entries($this->_conn,$rs); 130 ldap_free_result($rs); 131 if ($aRet === false){ 132 $this->_log(__FUNCTION__.'---'.$this->_getLastExecErrLog()); 133 return array(); 134 } 135 return $aRet; 136 } 137 138 /** 139 * @description 删除节点 暂时不考虑递归删除 140 * 141 * @return boolean 142 */ 143 public function delEntry($dn,$bFixedDn = true,$force = 0){ 144 if (!$dn = trim($dn)){ 145 return false; 146 } 147 if (!$this->_checkDn($dn)){ 148 return false; 149 } 150 if ($bFixedDn){ 151 $dn = $this->_getFullDn($dn); 152 } 153 $this->_log(__FUNCTION__."---DN:{ $dn}",false,'oper'); 154 $this->connect(); 155 /*if($force){ 156 $aEntryList = $this->getEntryList($dn,array('objectClass'=>'*'),array('objectClass')); 157 if ($aEntryList && ($aEntryList['count'] > 0)){ 158 for($i = 0; $i < $aEntryList['count']; $i++){ 159 $aDel[] = $aEntryList[$i]['dn']; 160 } 161 } 162 $aDel = array_reverse($aDel); //默认顺序 祖先->子孙 需要先删除子孙节点 163 $ret = true; 164 foreach($aDel as $k => $v){ 165 $ret &= @ldap_delete($this->_conn,$v); 166 } 167 if ($ret === false){ 168 $this->_log(__FUNCTION__.'dn(recursive):'.$dn.'----'.$this->_getLastExecErrLog()); 169 } 170 return $ret; 171 }*/ 172 $ret = @ldap_delete($this->_conn,$dn); 173 if ($ret === false){ 174 $this->_log(__FUNCTION__.'----dn:'.$dn.'-----'.$this->_getLastExecErrLog()); 175 } 176 return $ret; 177 } 178 179 /** 180 * @description 更新节点 181 * 182 * @return boolean 183 */ 184 public function updateEntry($dn,$aAttr = array(),$bFixedDn = true){ 185 if (!$dn = trim($dn)){ 186 return false; 187 } 188 $this->_checkAttr($aAttr); 189 if (!$aAttr){ 190 return false; 191 } 192 if (!$this->_checkDn($dn)){ 193 return false; 194 } 195 if ($bFixedDn){ 196 $dn = $this->_getFullDn($dn); 197 } 198 $this->_log(__FUNCTION__."---DN:{ $dn}---aAttr:".str_replace("\n",'',var_export($aAttr,true)),false,'oper'); 199 $this->connect(); 200 $ret = @ldap_modify($this->_conn,$dn,$aAttr); 201 if ($ret === false){ 202 $this->_log(__FUNCTION__.'---'.$this->_getLastExecErrLog().'---dn:'.$dn.'---attr:'.json_encode($aAttr)); 203 } 204 return $ret; 205 } 206 207 /** 208 * @description 添加节点 209 * 210 * @return boolean 211 */ 212 public function addEntry($dn,$aAttr = array(), $type = 'employee'/*employee,group*/){ 213 if (!$dn = trim($dn)){ 214 return false; 215 } 216 $this->_checkAttr($aAttr); 217 if (!$aAttr){ 218 return false; 219 } 220 if (!$this->_checkDn($dn)){ 221 return false; 222 } 223 $aAttr['objectClass'] = (array)$this->_getObjectClass($type); 224 $this->_log(__FUNCTION__."---DN:{ $dn}---aAttr:".str_replace("\n",'',var_export($aAttr,true)),false,'oper'); 225 $this->connect(); 226 $dn = $this->_getFullDn($dn); 227 $ret = @ldap_add($this->_conn,$dn,$aAttr); 228 if ($ret === false){ 229 $this->_log(__FUNCTION__.'----dn:'.$dn.'----aAttr:'.json_encode($aAttr).'-----'.$this->_getLastExecErrLog()); 230 } 231 return $ret; 232 } 233 234 /** 235 * @description 移动叶子节点 v3版才支持此方法 236 * 237 * @param $newDn 相对于$parentDn 238 * @param $parentDn 完整DN 239 * @param $bMoveRecur 240 * 241 * @return boolean 242 */ 243 public function moveEntry($oldDn,$newDn,$parentDn,$bDelOld = true,$bFixDn = true,$bMoveRecur = true){ 244 //对于AD服务器 此方法可以移动用户节点以及组织节点 245 //$newDn只能包含一个 比如OU=xxx 246 $oldDn = trim($oldDn); 247 $newDn = trim($newDn); 248 $parentDn = trim($parentDn); 249 if(!$oldDn || !$newDn || ($bFixDn && !$parentDn)){ 250 return false; 251 } 252 if(!$this->_checkDn($oldDn) || !$this->_checkDn($newDn) || !$this->_checkDn($parentDn)){ 253 return false; 254 } 255 $this->connect(); 256 if($bFixDn){ 257 $oldDn = $this->_getFullDn($oldDn); 258 $parentDn = $this->_getFullDn($parentDn); 259 } 260 $this->_log(__FUNCTION__."---DN:{ $oldDn} -> { $newDn},{ $parentDn}",false,'oper'); 261 $aTmpMove = $aDelDn = array(); 262 $aTmpMove[] = array('old'=>$oldDn,'new'=>$newDn); 263 /*if($bMoveRecur){ 264 $aDelDn[] = $oldDn; 265 $aTmpList = $this->getEntryList($oldDn,array('objectClass'=>'*'),array('objectClass'),0,0); 266 if($aTmpList && ($aTmpList['count'] > 1)){ 267 for($i = 1; $i < $aTmpList['count']; $i++){ 268 if(!in_array('user',$aTmpList[$i]['objectclass'])){ //$bDelOld=true时,用户节点移动时会自动删除 269 $aDelDn[] = $aTmpList[$i]['dn']; 270 } 271 $aTmpSep = explode($oldDn,$aTmpList[$i]['dn']); 272 $aTmpMove[] = array( 273 'old' => $aTmpList[$i]['dn'], 274 'new' => $aTmpSep[0] . $newDn, 275 ); 276 } 277 } 278 }*/ 279 $bFlag = true; 280 foreach($aTmpMove as $k => $v){ 281 $bTmpFlag = ldap_rename($this->_conn,$v['old'],$v['new'],$parentDn,(boolean)$bDelOld); 282 if(!$bTmpFlag){ 283 $this->_log(__FUNCTION__."---o:{ $v['old']}-n:{ $v['new']}-p:{ $parentDn}-recur:{ $bMoveRecur}-----".$this->_getLastExecErrLog()); 284 } 285 $bFlag &= $bTmpFlag; 286 } 287 /*if(!$bFlag){ 288 $this->_log(__FUNCTION__."---o:{$oldDn}-n:{$newDn}-p:{$parentDn}-recur:{$bMoveRecur}-----".$this->_getLastExecErrLog()); 289 }*/ 290 /*if($bFlag && $bDelOld && $aDelDn){ 291 $aDelDn = array_reverse($aDelDn); 292 foreach($aDelDn as $k => $v){ 293 $this->delEntry($v,false); 294 } 295 }*/ 296 return $bFlag; 297 } 298 299 public function modEntry($dn,$act = 'add',$aAttr = array()){ 300 return false; 301 $dn = $this->_getFullDn($dn); 302 $this->_log(__FUNCTION__."---DN:{ $dn}---Act:{ $act}---aAttr:".str_replace("\n",'',var_export($aAttr,true)),false,'oper'); 303 $this->connect(); 304 $ret = false; 305 switch($act){ 306 case 'add': $ret = ldap_mod_add($this->_conn,$dn,$aAttr); break; 307 case 'replace': $ret = ldap_mod_replace($this->_conn,$dn,$aAttr); break; 308 case 'del': $ret = ldap_mod_del($this->_conn,$dn,$aAttr); break; 309 } 310 if(!$ret){ 311 $this->_log(__FUNCTION__."---dn:{ $dn}---act:{ $act}---attr:".json_encode($aAttr).'---'.$this->_getLastExecErrLog()); 312 } 313 return $ret; 314 } 315 316 /** 317 * @description 批量添加节点 318 * 319 * @return boolean 320 */ 321 public function addBatchEntry($aNodeList = array()){ 322 } 323 324 public function getAttrKv(array $aAttr = array()){ 325 if(!isset($aAttr['count']) || ($aAttr['count'] < 1)){ 326 return array(); 327 } 328 $aRet = array(); 329 for($i = 0; $i < $aAttr['count']; $i++){ 330 $field = $aAttr[$i]; 331 if (!isset($aAttr[$field])){ 332 return array(); 333 } 334 unset($aAttr[$field]['count']); 335 $aRet[$field] = $aAttr[$field]; 336 } 337 if(isset($aAttr['dn'])){ //dn是字符串 338 $aRet['dn'] = $aAttr['dn']; 339 } 340 return $aRet; 341 } 342 343 private function _getObjectClass($type = 'employee'){ 344 $aRet = array(); 345 switch($type){ 346 case 'employee' : $aRet = array('top','person','organizationalPerson','user'); break; 347 case 'group' : $aRet = array('top','organizationalUnit'); break; 348 } 349 return $aRet; 350 } 351 352 public function getFullDn($partDn = ''){ 353 return $this->_getFullDn($partDn); 354 } 355 356 private function _getFullDn($partDn = ''){ 357 $partDn = trim($partDn); 358 $partDn = rtrim($partDn,','); 359 return "{ $partDn},{ $this->_aOptions['dnSuffix']}"; 360 } 361 362 private function _checkDn($dn = ''){ 363 $dn = trim($dn,','); 364 $aDn = explode(',',$dn); 365 foreach($aDn as $k => $v){ 366 $aTmp = explode('=',$v); 367 $aTmp[0] = strtolower(trim($aTmp[0])); 368 $aTmp[1] = trim($aTmp[1]); 369 $flag = false; 370 switch($aTmp[0]){ //distingushed name 暂时只允许这3个field 371 case 'dc': $flag = $this->_checkDc($aTmp[1]); break; 372 case 'ou': $flag = $this->_checkOu($aTmp[1]); break; 373 case 'cn': $flag = $this->_checkCn($aTmp[1]); break; 374 } 375 if (!$flag){ 376 $this->_log(__FUNCTION__.'----无效的节点路径----dn:'.$dn); 377 return false; 378 } 379 } 380 return true; 381 } 382 383 private function _checkOu($ou = ''){ 384 if (!$ou){ 385 return false; 386 } 387 if (preg_match('/[^a-zA-Z\s\d\.&\'\d]/',$ou)){ 388 $this->_log(__FUNCTION__.'----OU只能包含字母数字空格以及点'); 389 return false; 390 } 391 return true; 392 } 393 394 private function _checkCn($cn = ''){ 395 if (!$cn){ 396 return false; 397 } 398 return true; 399 } 400 401 private function _checkDc($dc = ''){ 402 if (!$dc){ 403 return false; 404 } 405 if (preg_match('/[^a-zA-Z]/',$dc)){ 406 $this->_log(__FUNCTION__.'----DC只能包含英文字母'); 407 return false; 408 } 409 return true; 410 } 411 412 private function _mkAttrFilter(array $aAttrFilter = array()){ 413 $sStr = '(&'; 414 foreach($aAttrFilter as $k => $v){ 415 $v = (string)$v; 416 if($k === 'objectGUID'){ 417 $v = $this->_GUIDtoStr($v); 418 } 419 $v = addcslashes($v,'()='); 420 $sStr .= "({ $k}={ $v})"; 421 } 422 $sStr .= ')'; 423 return $sStr; 424 } 425 426 //来自PHP.NET http://php.net/manual/en/function.ldap-search.php 427 //http://php.net/manual/en/function.ldap-get-values-len.php 428 //GUID关键字 429 private function _GUIDtoStr($binary_guid){ 430 $hex_guid = unpack("H*hex", $binary_guid); 431 $hex = $hex_guid["hex"]; 432 $j = 0;$str = '\\'; 433 for($i = 0; $i < strlen($hex); $i++){ 434 if($j == 2){ 435 $str .= '\\'; 436 $j = 0; 437 } 438 $str .= $hex[$i]; 439 $j++; 440 } 441 return $str; 442 /*$hex1 = substr($hex, -26, 2) . substr($hex, -28, 2) . substr($hex, -30, 2) . substr($hex, -32, 2); 443 $hex2 = substr($hex, -22, 2) . substr($hex, -24, 2); 444 $hex3 = substr($hex, -18, 2) . substr($hex, -20, 2); 445 $hex4 = substr($hex, -16, 4); 446 $hex5 = substr($hex, -12, 12); 447 $guid_str = $hex1 . "-" . $hex2 . "-" . $hex3 . "-" . $hex4 . "-" . $hex5; 448 return $guid_str;*/ 449 } 450 451 private func