CI=&get_instance(); } public function begin($user) { $hours=$this->duration($user->session_duration_hours??24); $limit=$this->deviceLimit($user->max_active_devices??1); $loginAt=date('Y-m-d H:i:s'); $expiresAt=date('Y-m-d H:i:s',time()+$hours*3600); $result=array('allowed'=>true,'login_key'=>bin2hex(random_bytes(32)),'login_at'=>$loginAt,'expires_at'=>$expiresAt,'duration_hours'=>$hours,'device_limit'=>$limit); if(!$this->ready())return$result; $db=$this->CI->db;$db->trans_begin(); try{ $db->query('SELECT id FROM users WHERE id=? FOR UPDATE',array((int)$user->id)); $this->expireStale((int)$user->id); $active=(int)$db->where(array('user_id'=>(int)$user->id,'status'=>'active'))->where('expires_at >',date('Y-m-d H:i:s'))->count_all_results('user_login_sessions'); if($active>=$limit){ $this->attempt((int)$user->id,(string)$user->username,'device_limit','Batas perangkat aktif telah tercapai.',$active,$limit,null); if(!$db->trans_status())throw new RuntimeException('Log pembatasan perangkat gagal disimpan.'); $db->trans_commit(); return array('allowed'=>false,'active_count'=>$active,'device_limit'=>$limit,'message'=>'Akun ini sudah aktif pada '.$active.' perangkat (batas '.$limit.'). Akhiri salah satu sesi aktif melalui menu Profil sebelum login di perangkat lain.'); } $device=$this->device(); $db->insert('user_login_sessions',array( 'user_id'=>(int)$user->id,'login_key'=>$result['login_key'],'session_id_hash'=>$this->sessionHash(), 'login_at'=>$loginAt,'expires_at'=>$expiresAt,'last_seen_at'=>$loginAt,'ip_address'=>$device['ip'], 'user_agent'=>$device['agent'],'device_type'=>$device['type'],'platform'=>$device['platform'], 'browser'=>$device['browser'],'status'=>'active' )); $sessionId=(int)$db->insert_id(); $this->attempt((int)$user->id,(string)$user->username,'success','Login berhasil.',$active+1,$limit,$sessionId); if(!$db->trans_status())throw new RuntimeException('Session login gagal dicatat.'); $db->trans_commit();return$result; }catch(Throwable$e){$db->trans_rollback();throw$e;} } public function recordInvalid($user,$username) { if(!$this->attemptsReady())return; $id=$user?(int)$user->id:null;$limit=$user?$this->deviceLimit($user->max_active_devices??1):1; $this->attempt($id,substr((string)$username,0,100),'invalid_credentials','Username atau password tidak valid.',0,$limit,null); } public function validateCurrent() { $userId=(int)$this->CI->session->userdata('user_id');if($userId<1)return array('valid'=>false,'reason'=>'invalid'); $expires=(string)$this->CI->session->userdata('auth_expires_at'); if($expires!==''&&strtotime($expires)<=time()){ $this->endCurrent('expired','Masa login 24 jam atau sesuai pengaturan telah berakhir.');return array('valid'=>false,'reason'=>'expired'); } if(!$this->ready()){ if($expires===''){ $loginAt=date('Y-m-d H:i:s');$expires=date('Y-m-d H:i:s',time()+86400); $this->CI->session->set_userdata(array('auth_started_at'=>$loginAt,'auth_expires_at'=>$expires)); } return array('valid'=>true); } $loginKey=(string)$this->CI->session->userdata('auth_login_key'); if($loginKey==='')return$this->adoptExisting($userId); $row=$this->CI->db->where(array('user_id'=>$userId,'login_key'=>$loginKey))->get('user_login_sessions')->row(); if(!$row||$row->status!=='active')return array('valid'=>false,'reason'=>'revoked'); if(strtotime($row->expires_at)<=time()){ $this->CI->db->where('id',$row->id)->update('user_login_sessions',array('status'=>'expired','ended_at'=>date('Y-m-d H:i:s'),'end_reason'=>'Batas waktu login tercapai.')); return array('valid'=>false,'reason'=>'expired'); } $device=$this->device(); $this->CI->db->where(array('id'=>$row->id,'status'=>'active'))->update('user_login_sessions',array('session_id_hash'=>$this->sessionHash(),'last_seen_at'=>date('Y-m-d H:i:s'),'ip_address'=>$device['ip'])); $this->CI->session->set_userdata(array('auth_started_at'=>$row->login_at,'auth_expires_at'=>$row->expires_at)); return array('valid'=>true,'row'=>$row); } private function adoptExisting($userId) { $user=$this->CI->db->get_where('users',array('id'=>$userId))->row();if(!$user)return array('valid'=>false,'reason'=>'invalid'); $created=$this->begin($user);if(!$created['allowed'])return array('valid'=>false,'reason'=>'device_limit'); $this->CI->session->set_userdata(array('auth_login_key'=>$created['login_key'],'auth_started_at'=>$created['login_at'],'auth_expires_at'=>$created['expires_at'])); return array('valid'=>true,'adopted'=>true); } public function endCurrent($status='logged_out',$reason='Logout oleh pengguna.') { if(!$this->ready())return; $key=(string)$this->CI->session->userdata('auth_login_key');$user=(int)$this->CI->session->userdata('user_id');if($key===''||$user<1)return; $this->CI->db->where(array('user_id'=>$user,'login_key'=>$key,'status'=>'active'))->update('user_login_sessions',array('status'=>$status,'ended_at'=>date('Y-m-d H:i:s'),'end_reason'=>substr($reason,0,255))); } public function activeSessions($userId) { if(!$this->ready())return array();$this->expireStale((int)$userId);$current=(string)$this->CI->session->userdata('auth_login_key'); $rows=$this->CI->db->where('user_id',(int)$userId)->where_in('status',array('active','logged_out','expired','revoked'))->order_by("status='active'",'DESC',false)->order_by('last_seen_at','DESC')->limit(100)->get('user_login_sessions')->result(); foreach($rows as$row)$row->is_current=hash_equals((string)$row->login_key,$current); return$rows; } public function attempts($userId) { if(!$this->attemptsReady())return array();return$this->CI->db->where('user_id',(int)$userId)->order_by('attempted_at','DESC')->limit(100)->get('user_login_attempts')->result(); } public function revoke($id,$userId) { if(!$this->ready())throw new RuntimeException('Migration keamanan session belum dijalankan.'); $row=$this->CI->db->where(array('id'=>(int)$id,'user_id'=>(int)$userId))->get('user_login_sessions')->row(); if(!$row)throw new RuntimeException('Session perangkat tidak ditemukan.'); if($row->status==='active')$this->CI->db->where('id',$row->id)->update('user_login_sessions',array('status'=>'revoked','ended_at'=>date('Y-m-d H:i:s'),'end_reason'=>'Diakhiri dari pengaturan perangkat.')); $this->deleteNativeSession($row->session_id_hash); return(array('current'=>hash_equals((string)$row->login_key,(string)$this->CI->session->userdata('auth_login_key')))); } public function revokeOthers($userId) { if(!$this->ready())return;$current=(string)$this->CI->session->userdata('auth_login_key'); $rows=$this->CI->db->where(array('user_id'=>(int)$userId,'status'=>'active'))->where('login_key !=',$current)->get('user_login_sessions')->result(); foreach($rows as$row){$this->CI->db->where('id',$row->id)->update('user_login_sessions',array('status'=>'revoked','ended_at'=>date('Y-m-d H:i:s'),'end_reason'=>'Password akun diubah.'));$this->deleteNativeSession($row->session_id_hash);} } public function activeCount($userId) { if(!$this->ready())return 1;$this->expireStale((int)$userId);return(int)$this->CI->db->where(array('user_id'=>(int)$userId,'status'=>'active'))->where('expires_at >',date('Y-m-d H:i:s'))->count_all_results('user_login_sessions'); } private function expireStale($userId) { if(!$this->ready())return;$this->CI->db->where(array('user_id'=>(int)$userId,'status'=>'active'))->where('expires_at <=',date('Y-m-d H:i:s'))->update('user_login_sessions',array('status'=>'expired','ended_at'=>date('Y-m-d H:i:s'),'end_reason'=>'Batas waktu login tercapai.')); } private function attempt($userId,$username,$result,$reason,$active,$limit,$sessionId) { if(!$this->attemptsReady())return;$device=$this->device();$this->CI->db->insert('user_login_attempts',array('user_id'=>$userId?:null,'username'=>substr($username,0,100),'attempted_at'=>date('Y-m-d H:i:s'),'ip_address'=>$device['ip'],'user_agent'=>$device['agent'],'device_type'=>$device['type'],'platform'=>$device['platform'],'browser'=>$device['browser'],'result'=>$result,'reason'=>substr($reason,0,255),'active_session_count'=>max(0,(int)$active),'device_limit'=>$this->deviceLimit($limit),'login_session_id'=>$sessionId)); } private function deleteNativeSession($hash) { if($hash===''||!$this->CI->db->table_exists('ci_sessions'))return;$this->CI->db->query('DELETE FROM ci_sessions WHERE SHA2(id,256)=?',array($hash)); } private function sessionHash(){return hash('sha256',(string)session_id());} private function duration($hours){$hours=(int)$hours;return in_array($hours,$this->durations,true)?$hours:24;} private function deviceLimit($limit){return max(1,min(10,(int)$limit));} private function ready(){return$this->CI->db->table_exists('user_login_sessions')&&$this->CI->db->field_exists('session_duration_hours','users')&&$this->CI->db->field_exists('max_active_devices','users');} private function attemptsReady(){return$this->CI->db->table_exists('user_login_attempts');} private function device() { $agent=substr((string)$this->CI->input->user_agent(),0,512);$browser='Browser lain';$platform='Perangkat lain';$type='Desktop'; foreach(array('Edg/'=>'Microsoft Edge','OPR/'=>'Opera','Chrome/'=>'Google Chrome','Firefox/'=>'Mozilla Firefox','Safari/'=>'Safari')as$needle=>$label)if(stripos($agent,$needle)!==false){$browser=$label;break;} foreach(array('Windows'=>'Windows','Android'=>'Android','iPhone'=>'iOS','iPad'=>'iPadOS','Macintosh'=>'macOS','Linux'=>'Linux')as$needle=>$label)if(stripos($agent,$needle)!==false){$platform=$label;break;} if(preg_match('/iPad|Tablet/i',$agent))$type='Tablet';elseif(preg_match('/Mobile|Android|iPhone/i',$agent))$type='Mobile'; return array('ip'=>substr((string)$this->CI->input->ip_address(),0,45),'agent'=>$agent,'browser'=>$browser,'platform'=>$platform,'type'=>$type); } }