杂集一(yii自动登陆过程浅析)
最近有个系统使用yii开发的,里面有不少细节上的调整。
一、yii自动登陆
yii自动生成的骨架中有一个登陆功能,勾选后,系统会保存部分认证信息,以后再登陆是系统会读取这些认证信息,这样就直接跳过了登陆的过程。
我们的系统中保留了这部分功能,但是使用过程中发现有部分信息没有保存,所以登陆成功后会出现很多问题,所以我仔细的看了登陆的流程。
请求login方法后
1 public function login() 2 { 3 if($this->_identity===null) 4 { 5 $this->_identity=new UserIdentity($this->username,$this->password); 6 $this->_identity->authenticate(); 7 } 8 if($this->_identity->errorCode===UserIdentity::ERROR_NONE) 9 { 10 $duration=$this->rememberMe ? 3600*24*30 : 0; // 30 days 11 Yii::app()->user->login($this->_identity,$duration); 12 return true; 13 } 14 else 15 return false; 16 }
系统首先发现还没有创建认证标识 _identity,所以,系统首先会建立UserIdentity的实例化对象,然后调用该对象的authenticate方法。
1 public function authenticate() 2 { 3 $username = strtolower($this->username); 4 $user = User::model()->find('LOWER(username)=?', array($username)); 5 if($user === null){ 6 $this->errorCode = self::ERROR_USERNAME_INVALID; 7 }elseif (!$user->validatePassword($this->password)) { 8 $this->errorCode = self::ERROR_PASSWORD_INVALID; 9 }else{ 10 $this->_id = $user->id; 11 $this->username = $user->username; 12 $this->errorCode = self::ERROR_NONE; 13 } 14 return $this->errorCode; 15 }
这个过程其实比较简单易懂,就是拿用户名去数据库检索,然后匹配密码,根据匹配结果返回不同的认证结果。当然这边需要用户名作为唯一认证,如果不是需要拿用户表主键。
$duration=$this->rememberMe ? 3600*24*30 : 0; // 30 days Yii::app()->user->login($this->_identity,$duration);
认证成功后,系统开始执行实际的登陆过程。Yii::app()->user实例化出一个cwebuser对象,然后调用这个对象中的login方法
1 public function login($identity,$duration=0) 2 { 3 $id=$identity->getId(); 4 $states=$identity->getPersistentStates(); 5 if($this->beforeLogin($id,$states,false)) 6 { 7 $this->changeIdentity($id,$identity->getName(),$states); 8 9 if($duration>0) 10 { 11 if($this->allowAutoLogin) 12 $this->saveToCookie($duration); 13 else 14 throw new CException(Yii::t('yii','{class}.allowAutoLogin must be set true in order to use cookie-based authentication.', 15 array('{class}'=>get_class($this)))); 16 } 17 18 $this->afterLogin(false); 19 } 20 return !$this->getIsGuest(); 21 }
首先获取当前登陆用户的id标识,然后调用$identity->getPersistentStates()获取认证对象的返回需要持久化的身份状态。调用CWebUser的beforelogin方法
1 protected function beforeLogin($id,$states,$fromCookie) 2 { 3 return true; 4 }
然后将id,用户名和之前获取的持久化认证身份状态一起存入session中
1 protected function changeIdentity($id,$name,$states) 2 { 3 Yii::app()->getSession()->regenerateID(); 4 $this->setId($id); 5 $this->setName($name); 6 $this->loadIdentityStates($states); 7 }
这样就实现了登陆的流程。然后上面如果勾选了自动登陆则定义cookie存储的默认时间为30天,发现存储的时间戳不为0时,开始自动登陆流程的操作,当配置文件中同样配置了允许自动登陆时,将认证信息保存到cookie中,否则抛出异常。所以当抛出allowAutoLogin must be set true in order to use cookie-based authentication.这样的异常页面时,需要将配置文件中allowAutoLogin打开。
yii在将认证信息保存到cookie时做了一些操作。
1 protected function saveToCookie($duration) 2 { 3 $app=Yii::app(); 4 $cookie=$this->createIdentityCookie($this->getStateKeyPrefix()); 5 $cookie->expire=time()+$duration; 6 $data=array( 7 $this->getId(), 8 $this->getName(), 9 $duration, 10 $this->saveIdentityStates(), 11 ); 12 $cookie->value=$app->getSecurityManager()->hashData(serialize($data)); 13 $app->getRequest()->getCookies()->add($cookie->name,$cookie); 14 }
1 protected function createIdentityCookie($name) 2 { 3 $cookie=new CHttpCookie($name,''); 4 if(is_array($this->identityCookie)) 5 { 6 foreach($this->identityCookie as $name=>$value) 7 $cookie->$name=$value; 8 } 9 return $cookie; 10 }
利用认证前缀名创建一个cookie,然后获取id,name和持久化认证身份状态信息,经过序列化之后一系列操作流程将这些信息存放到cookie中。
保存cookie的过程。首先实例化一个安全管理器组件,然后挑用这个安全管理器组件的hashData()
public function hashData($data,$key=null) { return $this->computeHMAC($data,$key).$data; }
这里调用了computerHAMC()用来生成HMAC的私钥。如果没有明确指定密钥,那么会生成和使用随机密钥。
1 protected function computeHMAC($data,$key=null) 2 { 3 if($key===null) 4 $key=$this->getValidationKey(); 5 6 if(function_exists('hash_hmac')) 7 return hash_hmac($this->hashAlgorithm, $data, $key); 8 9 if(!strcasecmp($this->hashAlgorithm,'sha1')) 10 { 11 $pack='H40'; 12 $func='sha1'; 13 } 14 else 15 { 16 $pack='H32'; 17 $func='md5'; 18 } 19 if($this->strlen($key) > 64) 20 $key=pack($pack, $func($key)); 21 if($this->strlen($key) < 64) 22 $key=str_pad($key, 64, chr(0)); 23 $key=$this->substr($key,0,64); 24 return $func((str_repeat(chr(0x5C), 64) ^ $key) . pack($pack, $func((str_repeat(chr(0x36), 64) ^ $key) . $data))); 25 }
1 public function getValidationKey() 2 { 3 if($this->_validationKey!==null) 4 return $this->_validationKey; 5 else 6 { 7 if(($key=Yii::app()->getGlobalState(self::STATE_VALIDATION_KEY))!==null) 8 $this->setValidationKey($key); 9 else 10 { 11 $key=$this->generateRandomKey(); 12 $this->setValidationKey($key); 13 Yii::app()->setGlobalState(self::STATE_VALIDATION_KEY,$key); 14 } 15 return $this->_validationKey; 16 } 17 }
CApplication->getGlobalState()这边再往下越来越深,基本超越我目前的接受范围,就不深入挖掘了。不过$this->detachEventHandler('onEndRequest',array($this,'saveGlobalState'));这句话让我有了不小的感触,但是还不能那么准确的说出这是什么样的感觉。这感觉就好像是我编程过程中一直缺失的部分。这段我感觉就是生成一个全局范围的状态数组。至于数组的内容就是从持久存储加载状态数据。如果没有获取到则建立一个空的全局状态数组。
1 public function getGlobalState($key,$defaultValue=null) 2 { 3 if($this->_globalState===null) 4 $this->loadGlobalState(); 5 if(isset($this->_globalState[$key])) 6 return $this->_globalState[$key]; 7 else 8 return $defaultValue; 9 }
如果上面返回持久化存储状态信息失败为空,就随机生成一个新的key
1 protected function generateRandomKey() 2 { 3 return sprintf('%08x%08x%08x%08x',mt_rand(),mt_rand(),mt_rand(),mt_rand()); 4 }
再将这个生成的key存储到持久化存储信息中。key生成后需要将key写入认证key中
1 public function setValidationKey($value) 2 { 3 if(!empty($value)) 4 $this->_validationKey=$value; 5 else 6 throw new CException(Yii::t('yii','CSecurityManager.validationKey cannot be empty.')); 7 }
key生成后,如果系统存在hash_hmac方法,就调用hash_hmac生成一个hash值。如果系统不支持hash_hmac函数,则判断当前使用的加密方式,分别为sha1和md5,然后根据不同情况对key进行处理,最终得到一个64位的串。然后再得到加密串。(其实我已经混乱了。。。)然后将这个加密串写入cookie就可以了。(本来我还觉得能拿到加密串反向或许可以推出密码,现在看来是不可逆并且逆向过程也会死人-_-)
完成存储cookie过程后,页面接下来跳转到来源页。登陆过程至此完成。
退出过程相较前面的登陆,工作就少得多了。毕竟摧毁起来比建立要方便的多。
1 public function logout($destroySession=true) 2 { 3 if($this->beforeLogout()) 4 { 5 if($this->allowAutoLogin) 6 { 7 Yii::app()->getRequest()->getCookies()->remove($this->getStateKeyPrefix()); 8 if($this->identityCookie!==null) 9 { 10 $cookie=$this->createIdentityCookie($this->getStateKeyPrefix()); 11 $cookie->value=null; 12 $cookie->expire=0; 13 Yii::app()->getRequest()->getCookies()->add($cookie->name,$cookie); 14 } 15 } 16 if($destroySession) 17 Yii::app()->getSession()->destroy(); 18 else 19 $this->clearStates(); 20 $this->afterLogout(); 21 } 22 }
就是删除相关cookie和session,为防止cookie清除的不干净,还顺带重定义认证cookie的内容,存空,置为会话cookie即可。所以,如果执行了登出操作,下一次就不会自动登陆,所以,这边假设是直接关闭浏览器的。如果没有设置自动登陆,session被清除,也算登出了。
自动登陆,但CWebUser实例化后会调用init()
1 public function init() 2 { 3 parent::init(); 4 Yii::app()->getSession()->open(); 5 if($this->getIsGuest() && $this->allowAutoLogin) 6 $this->restoreFromCookie(); 7 else if($this->autoRenewCookie && $this->allowAutoLogin) 8 $this->renewCookie(); 9 if($this->autoUpdateFlash) 10 $this->updateFlash(); 11 12 $this->updateAuthStatus(); 13 }
如果还没有登陆且开启了自动登录则执行restoreFromCookie()即从cookie获取登陆信息。如果不是来宾帐号且开启了自动登陆并允许更新cookie则更新cookie,我对前面解析的机制还不理解,不过通过一些调试手段知道执行了第一个if的内容
1 protected function restoreFromCookie() 2 { 3 $app=Yii::app(); 4 $request=$app->getRequest(); 5 $cookie=$request->getCookies()->itemAt($this->getStateKeyPrefix()); 6 if($cookie && !empty($cookie->value) && ($data=$app->getSecurityManager()->validateData($cookie->value))!==false) 7 { 8 $data=@unserialize($data); 9 if(is_array($data) && isset($data[0],$data[1],$data[2],$data[3])) 10 { 11 list($id,$name,$duration,$states)=$data; 12 if($this->beforeLogin($id,$states,true)) 13 { 14 $this->changeIdentity($id,$name,$states); 15 if($this->autoRenewCookie) 16 { 17 $cookie->expire=time()+$duration; 18 $request->getCookies()->add($cookie->name,$cookie); 19 } 20 $this->afterLogin(true); 21 } 22 } 23 } 24 }
这个操作就是获取cookie值,然后放进安全管理组件中进行判断是否被篡改
1 public function validateData($data,$key=null) 2 { 3 $len=$this->strlen($this->computeHMAC('test')); 4 if($this->strlen($data)>=$len) 5 { 6 $hmac=$this->substr($data,0,$len); 7 $data2=$this->substr($data,$len,$this->strlen($data)); 8 return $hmac===$this->computeHMAC($data2,$key)?$data2:false; 9 } 10 else 11 return false; 12 }
这个过程我已经无力理解了,跳过。。。。
cookie验证通过后,将cookie中数据反序列化并放入session中,登陆完成。
分析完上面的内容,我发现,牛逼就是牛逼。。。。
由于我们的代码缺少存储持续化存储的身份状态,所以反序列化后很多字段没有值,还是需要从数据库中再拉取一便,但是系统认为主要的认证信息齐全了,不需要再查询数据库了,所以就会产生一些bug。可以考虑登陆的时候调用setPersistentStates()来存储需要用到的认证信息,但是目前也不知道那些信息需要存储,每次都要添加其实并不方便,也可以登陆完成后再执行一遍查询操作,更新user信息。这样效率上有影响。所以我们最终决定改变现有流程,选择自动登陆后(名称改为记住密码)将用户名密码加密后存储到cookie中,打开登陆页面后解密后放进用户名密码栏,省去输入用户名密码的时间,实际上不会自动登陆。
1 $duration = 0; 2 if($this->rememberMe){ 3 setcookie('username', $this->username, time() + 60*60*24*7 ); 4 setcookie('password', $this->password, time() + 60*60*24*7 ); 5}
加密的过程处于对客户方的尊重,代码就不展示了。就是将系统的cookie有效期置位0,即设为会话即可。
if(isset($_COOKIE['username'])){
$username = $_COOKIE['ofly_username'];
$model->username = $username;
}
登陆时解密逆向一下,这样就可以记录用户名,密码了。