2023-11-24 13:47:59 +01:00
|
|
|
<?php
|
|
|
|
App::uses('AppModel', 'Model');
|
|
|
|
|
2023-12-22 22:49:23 +01:00
|
|
|
/**
|
|
|
|
* @property User $User
|
|
|
|
*/
|
2023-11-24 13:47:59 +01:00
|
|
|
class UserLoginProfile extends AppModel
|
|
|
|
{
|
|
|
|
public $actsAs = array(
|
|
|
|
'AuditLog',
|
|
|
|
'Containable',
|
|
|
|
'SysLogLogable.SysLogLogable' => array(
|
|
|
|
'userModel' => 'User',
|
|
|
|
'userKey' => 'user_id',
|
|
|
|
'change' => 'full'
|
|
|
|
),
|
|
|
|
);
|
|
|
|
|
|
|
|
public $validate = [
|
|
|
|
'status' => [
|
|
|
|
'rule' => '/^(trusted|malicious)$/',
|
|
|
|
'message' => 'Must be one of: trusted, malicious'
|
|
|
|
],
|
|
|
|
];
|
|
|
|
|
|
|
|
public $order = array("UserLoginProfile.id" => "DESC");
|
|
|
|
|
|
|
|
public $belongsTo = [
|
2023-12-22 22:49:23 +01:00
|
|
|
'User' => [
|
|
|
|
'className' => 'User',
|
|
|
|
'foreignKey' => 'user_id',
|
|
|
|
'conditions' => '',
|
|
|
|
'fields' => '',
|
|
|
|
'order' => ''
|
|
|
|
]
|
|
|
|
];
|
|
|
|
|
|
|
|
const BROWSER_CACHE_DIR = APP . DS . 'tmp' . DS . 'browscap';
|
|
|
|
const BROWSER_INI_FILE = APP . DS . 'files' . DS . 'browscap'. DS . 'browscap.ini'; // Browscap file managed by MISP - https://browscap.org/stream?q=Lite_PHP_BrowsCapINI
|
|
|
|
const GEOIP_DB_FILE = APP . DS . 'files' . DS . 'geo-open' . DS . 'GeoOpen-Country.mmdb'; // GeoIP file managed by MISP - https://data.public.lu/en/datasets/geo-open-ip-address-geolocation-per-country-in-mmdb-format/
|
2023-11-24 13:47:59 +01:00
|
|
|
|
2023-12-22 22:49:23 +01:00
|
|
|
private $userProfile;
|
2023-11-24 13:47:59 +01:00
|
|
|
|
|
|
|
private $knownUserProfiles = [];
|
|
|
|
|
2024-01-28 00:40:46 +01:00
|
|
|
private function browscapGetBrowser()
|
2023-12-22 22:49:23 +01:00
|
|
|
{
|
2023-11-24 13:47:59 +01:00
|
|
|
$logger = new \Monolog\Logger('name');
|
2024-01-28 00:40:46 +01:00
|
|
|
|
|
|
|
if (function_exists('apcu_fetch')) {
|
|
|
|
App::uses('ApcuCacheTool', 'Tools');
|
|
|
|
$cache = new ApcuCacheTool('misp:browscap');
|
|
|
|
} else {
|
|
|
|
$fileCache = new \Doctrine\Common\Cache\FilesystemCache(UserLoginProfile::BROWSER_CACHE_DIR);
|
|
|
|
$cache = new \Roave\DoctrineSimpleCache\SimpleCacheAdapter($fileCache);
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
$bc = new \BrowscapPHP\Browscap($cache, $logger);
|
|
|
|
return $bc->getBrowser();
|
|
|
|
} catch (\BrowscapPHP\Exception $e) {
|
|
|
|
$this->log("Browscap - building new cache from browscap.ini file.", LOG_INFO);
|
|
|
|
$bcUpdater = new \BrowscapPHP\BrowscapUpdater($cache, $logger);
|
|
|
|
$bcUpdater->convertFile(UserLoginProfile::BROWSER_INI_FILE);
|
|
|
|
}
|
|
|
|
|
|
|
|
$bc = new \BrowscapPHP\Browscap($cache, $logger);
|
|
|
|
return $bc->getBrowser();
|
2023-11-24 13:47:59 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
public function beforeSave($options = [])
|
|
|
|
{
|
|
|
|
$this->data['UserLoginProfile']['hash'] = $this->hash($this->data['UserLoginProfile']);
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2024-01-04 09:55:56 +01:00
|
|
|
public function hash(array $data)
|
2023-12-22 22:49:23 +01:00
|
|
|
{
|
2023-11-24 13:47:59 +01:00
|
|
|
unset($data['hash']);
|
|
|
|
unset($data['created_at']);
|
|
|
|
return md5(serialize($data));
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* slow function - don't call it too often
|
|
|
|
* @return array
|
|
|
|
*/
|
2023-12-22 22:49:23 +01:00
|
|
|
public function _getUserProfile()
|
|
|
|
{
|
2023-11-24 13:47:59 +01:00
|
|
|
if (!$this->userProfile) {
|
|
|
|
// below uses https://github.com/browscap/browscap-php
|
|
|
|
if (class_exists('\BrowscapPHP\Browscap')) {
|
2024-01-28 00:40:46 +01:00
|
|
|
$browser = $this->browscapGetBrowser();
|
2023-11-24 13:47:59 +01:00
|
|
|
} else {
|
|
|
|
// a primitive OS & browser extraction capability
|
2023-12-22 22:49:23 +01:00
|
|
|
$ua = $_SERVER['HTTP_USER_AGENT'] ?? null;
|
2023-11-24 13:47:59 +01:00
|
|
|
$browser = new stdClass();
|
|
|
|
$browser->browser_name_pattern = $ua;
|
|
|
|
if (mb_strpos($ua, 'Linux') !== false) $browser->platform = "Linux";
|
|
|
|
else if (mb_strpos($ua, 'Windows') !== false) $browser->platform = "Windows";
|
|
|
|
else if (mb_strpos($ua, 'like Mac OS X') !== false) $browser->platform = "ipadOS";
|
|
|
|
else if (mb_strpos($ua, 'Mac OS X') !== false) $browser->platform = "macOS";
|
|
|
|
else if (mb_strpos($ua, 'Android') !== false) $browser->platform = 'Android';
|
|
|
|
else $browser->platform = 'unknown';
|
|
|
|
$browser->browser = "browser";
|
|
|
|
}
|
|
|
|
$ip = $this->_remoteIp();
|
|
|
|
if (class_exists('GeoIp2\Database\Reader')) {
|
2024-01-03 14:07:44 +01:00
|
|
|
try {
|
|
|
|
$geoDbReader = new GeoIp2\Database\Reader(UserLoginProfile::GEOIP_DB_FILE);
|
|
|
|
$record = $geoDbReader->country($ip);
|
|
|
|
$country = $record->country->isoCode;
|
|
|
|
} catch (InvalidArgumentException $e) {
|
|
|
|
$this->logException("Could not get country code for IP address", $e);
|
|
|
|
$country = 'None';
|
|
|
|
}
|
2023-11-24 13:47:59 +01:00
|
|
|
} else {
|
|
|
|
$country = 'None';
|
|
|
|
}
|
|
|
|
$this->userProfile = [
|
2023-12-22 22:49:23 +01:00
|
|
|
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null,
|
2023-11-24 13:47:59 +01:00
|
|
|
'ip' => $ip,
|
2023-12-22 22:49:23 +01:00
|
|
|
'accept_lang' => $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? null,
|
2023-11-24 13:47:59 +01:00
|
|
|
'geoip' => $country,
|
|
|
|
'ua_pattern' => $browser->browser_name_pattern,
|
|
|
|
'ua_platform' => $browser->platform,
|
|
|
|
'ua_browser' => $browser->browser
|
|
|
|
];
|
|
|
|
}
|
|
|
|
return $this->userProfile;
|
|
|
|
}
|
|
|
|
|
2024-01-04 11:20:53 +01:00
|
|
|
/**
|
|
|
|
* @param array $logEntry
|
|
|
|
* @return array|false|string[]
|
|
|
|
* @throws JsonException
|
|
|
|
*/
|
2024-01-03 18:41:47 +01:00
|
|
|
public function _fromLog(array $logEntry)
|
2023-12-22 22:49:23 +01:00
|
|
|
{
|
2024-01-04 11:20:53 +01:00
|
|
|
if (!$logEntry['change']) {
|
|
|
|
return false;
|
2024-01-03 18:41:47 +01:00
|
|
|
}
|
2024-01-04 11:20:53 +01:00
|
|
|
|
|
|
|
$data = ["user_agent" => "", "ip" => "", "accept_lang" => "", "geoip" => "", "ua_pattern" => "", "ua_platform" => "", "ua_browser" => ""];
|
|
|
|
$data = array_merge($data, JsonTool::decode($logEntry['change']));
|
2023-12-29 13:47:38 +01:00
|
|
|
if ($data['user_agent'] === "") {
|
2023-12-22 22:49:23 +01:00
|
|
|
return false;
|
|
|
|
}
|
2024-01-04 11:20:53 +01:00
|
|
|
$data['ip'] = $logEntry['ip'];
|
|
|
|
$data['timestamp'] = $logEntry['created'];
|
2023-11-24 13:47:59 +01:00
|
|
|
return $data;
|
|
|
|
}
|
|
|
|
|
2023-12-22 22:49:23 +01:00
|
|
|
public function _isSimilar($a, $b)
|
|
|
|
{
|
2023-11-24 13:47:59 +01:00
|
|
|
// if one is not initialized
|
|
|
|
if (!$a || !$b) return false;
|
|
|
|
// transition for old logs where UA was not known
|
|
|
|
if (!$a['ua_browser'])
|
|
|
|
return false;
|
|
|
|
// really similar session, from same browser, region, but different IP
|
2023-12-29 13:47:38 +01:00
|
|
|
if ($a['ua_browser'] === $b['ua_browser'] &&
|
|
|
|
$a['ua_platform'] === $b['ua_platform'] &&
|
|
|
|
$a['accept_lang'] === $b['accept_lang'] &&
|
|
|
|
$a['geoip'] === $b['geoip']) {
|
2023-11-24 13:47:59 +01:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
// similar browser pattern, OS and region
|
2023-12-29 13:47:38 +01:00
|
|
|
if ($a['ua_pattern'] === $b['ua_pattern'] &&
|
|
|
|
$a['ua_platform'] === $b['ua_platform'] &&
|
|
|
|
$a['accept_lang'] === $b['accept_lang'] &&
|
|
|
|
$a['geoip'] === $b['geoip']) {
|
2023-11-24 13:47:59 +01:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2023-12-29 13:47:38 +01:00
|
|
|
public function _isIdentical(array $a, array $b)
|
2023-12-22 22:49:23 +01:00
|
|
|
{
|
2023-12-29 13:47:38 +01:00
|
|
|
if ($a['ip'] === $b['ip'] &&
|
|
|
|
$a['ua_browser'] === $b['ua_browser'] &&
|
|
|
|
$a['ua_platform'] === $b['ua_platform'] &&
|
|
|
|
$a['accept_lang'] === $b['accept_lang'] &&
|
|
|
|
$a['geoip'] === $b['geoip']) {
|
2023-11-24 13:47:59 +01:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2024-01-04 12:08:14 +01:00
|
|
|
/**
|
|
|
|
* @param array $userProfileToCheck
|
|
|
|
* @param int $userId
|
|
|
|
* @return mixed|string
|
|
|
|
*/
|
2023-12-29 13:47:38 +01:00
|
|
|
public function _getTrustStatus(array $userProfileToCheck, $userId = null)
|
2023-12-22 22:49:23 +01:00
|
|
|
{
|
2023-12-29 13:47:38 +01:00
|
|
|
if (!$userId) {
|
|
|
|
$userId = AuthComponent::user('id');
|
2023-11-24 13:47:59 +01:00
|
|
|
}
|
|
|
|
// load Singleton / caching
|
2023-12-29 13:47:38 +01:00
|
|
|
if (!isset($this->knownUserProfiles[$userId])) {
|
|
|
|
$this->knownUserProfiles[$userId] = $this->find('all', [
|
|
|
|
'conditions' => ['UserLoginProfile.user_id' => $userId],
|
2024-01-04 12:08:14 +01:00
|
|
|
'recursive' => -1,
|
2023-12-29 13:47:38 +01:00
|
|
|
]);
|
2023-11-24 13:47:59 +01:00
|
|
|
}
|
|
|
|
// perform check on all entries, and stop when check OK
|
2023-12-29 13:47:38 +01:00
|
|
|
foreach ($this->knownUserProfiles[$userId] as $knownUserProfile) {
|
2023-11-24 13:47:59 +01:00
|
|
|
// when it is the same
|
|
|
|
if ($this->_isIdentical($knownUserProfile['UserLoginProfile'], $userProfileToCheck)) {
|
|
|
|
return $knownUserProfile['UserLoginProfile']['status'];
|
|
|
|
}
|
|
|
|
// if it is similar, more complex ruleset
|
|
|
|
if ($this->_isSimilar($knownUserProfile['UserLoginProfile'], $userProfileToCheck)) {
|
|
|
|
return 'likely ' . $knownUserProfile['UserLoginProfile']['status'];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// bad news, iterated over all and no similar found
|
|
|
|
return 'unknown';
|
|
|
|
}
|
|
|
|
|
2023-12-22 22:49:23 +01:00
|
|
|
public function _isTrusted()
|
|
|
|
{
|
2023-11-24 13:47:59 +01:00
|
|
|
if (strpos($this->_getTrustStatus($this->_getUserProfile()), 'trusted') !== false) {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2023-12-22 22:49:23 +01:00
|
|
|
public function _isSuspicious()
|
|
|
|
{
|
2023-11-24 13:47:59 +01:00
|
|
|
// previously marked loginuserprofile as malicious by the user
|
|
|
|
if (strpos($this->_getTrustStatus($this->_getUserProfile()), 'malicious') !== false) {
|
2023-12-22 22:49:23 +01:00
|
|
|
return __('A user reported a similar login profile as malicious.');
|
2023-11-24 13:47:59 +01:00
|
|
|
}
|
2024-01-04 12:08:14 +01:00
|
|
|
|
2023-11-24 13:47:59 +01:00
|
|
|
// same IP as previous malicious user
|
2024-01-04 12:08:14 +01:00
|
|
|
$maliciousWithSameIP = $this->hasAny([
|
|
|
|
'UserLoginProfile.ip' => $this->_getUserProfile()['ip'],
|
|
|
|
'UserLoginProfile.status' => 'malicious'
|
|
|
|
]);
|
2023-11-24 13:47:59 +01:00
|
|
|
if ($maliciousWithSameIP) {
|
2023-12-22 22:49:23 +01:00
|
|
|
return __('The source IP was reported as as malicious by a user.');
|
2023-11-24 13:47:59 +01:00
|
|
|
}
|
|
|
|
// LATER - use other data to identify suspicious logins, such as:
|
|
|
|
// - what with use-case where a user marks something as legitimate, but is marked by someone else as suspicious?
|
|
|
|
// - warning lists
|
|
|
|
// - ...
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2024-01-04 12:08:14 +01:00
|
|
|
public function emailNewLogin(array $user)
|
2023-12-22 22:49:23 +01:00
|
|
|
{
|
2023-11-24 13:47:59 +01:00
|
|
|
if (!Configure::read('MISP.disable_emailing')) {
|
|
|
|
$date_time = date('c');
|
|
|
|
|
|
|
|
$body = new SendEmailTemplate('userloginprofile_newlogin');
|
|
|
|
$body->set('userLoginProfile', $this->User->UserLoginProfile->_getUserProfile());
|
|
|
|
$body->set('baseurl', Configure::read('MISP.baseurl'));
|
|
|
|
$body->set('misp_org', Configure::read('MISP.org'));
|
|
|
|
$body->set('date_time', $date_time);
|
|
|
|
// Fetch user that contains also PGP or S/MIME keys for e-mail encryption
|
2023-12-29 13:47:38 +01:00
|
|
|
$this->User->sendEmail($user, $body, false, "[" . Configure::read('MISP.org') . " MISP] New sign in.");
|
2023-11-24 13:47:59 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-04 09:55:56 +01:00
|
|
|
public function emailReportMalicious(array $user, array $userLoginProfile)
|
2023-12-22 22:49:23 +01:00
|
|
|
{
|
2023-11-24 13:47:59 +01:00
|
|
|
// inform the org admin
|
|
|
|
$date_time = $userLoginProfile['timestamp']; // LATER not ideal as timestamp is string without timezone info
|
|
|
|
$body = new SendEmailTemplate('userloginprofile_report_malicious');
|
|
|
|
$body->set('userLoginProfile', $userLoginProfile);
|
|
|
|
$body->set('username', $user['User']['email']);
|
|
|
|
$body->set('baseurl', Configure::read('MISP.baseurl'));
|
|
|
|
$body->set('misp_org', Configure::read('MISP.org'));
|
|
|
|
$body->set('date_time', $date_time);
|
2024-01-04 09:55:56 +01:00
|
|
|
|
|
|
|
$orgAdmins = array_keys($this->User->getOrgAdminsForOrg($user['User']['org_id']));
|
|
|
|
$admins = array_keys($this->User->getSiteAdmins());
|
|
|
|
$allAdmins = array_unique(array_merge($orgAdmins, $admins));
|
|
|
|
|
|
|
|
$subject = __("[%s MISP] Suspicious login reported.", Configure::read('MISP.org'));
|
|
|
|
foreach ($allAdmins as $adminUserId) {
|
2023-11-24 13:47:59 +01:00
|
|
|
$admin = $this->User->find('first', array(
|
|
|
|
'recursive' => -1,
|
2024-01-04 09:55:56 +01:00
|
|
|
'conditions' => ['User.id' => $adminUserId]
|
2023-11-24 13:47:59 +01:00
|
|
|
));
|
2024-01-04 09:55:56 +01:00
|
|
|
$this->User->sendEmail($admin, $body, false, $subject);
|
2023-11-24 13:47:59 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-04 09:55:56 +01:00
|
|
|
public function email_suspicious(array $user, $suspiciousness_reason)
|
2023-12-22 22:49:23 +01:00
|
|
|
{
|
2023-11-24 13:47:59 +01:00
|
|
|
if (!Configure::read('MISP.disable_emailing')) {
|
|
|
|
$date_time = date('c');
|
|
|
|
// inform the user
|
|
|
|
$body = new SendEmailTemplate('userloginprofile_suspicious');
|
|
|
|
$body->set('userLoginProfile', $this->_getUserProfile());
|
|
|
|
$body->set('username', $user['User']['email']);
|
|
|
|
$body->set('baseurl', Configure::read('MISP.baseurl'));
|
|
|
|
$body->set('misp_org', Configure::read('MISP.org'));
|
|
|
|
$body->set('date_time', $date_time);
|
|
|
|
$body->set('suspiciousness_reason', $suspiciousness_reason);
|
|
|
|
// inform the user
|
2023-12-29 13:47:38 +01:00
|
|
|
$this->User->sendEmail($user, $body, false, "[" . Configure::read('MISP.org') . " MISP] Suspicious login with your account.");
|
|
|
|
|
2023-11-24 13:47:59 +01:00
|
|
|
// inform the org admin
|
|
|
|
$body = new SendEmailTemplate('userloginprofile_suspicious_orgadmin');
|
|
|
|
$body->set('userLoginProfile', $this->_getUserProfile());
|
|
|
|
$body->set('username', $user['User']['email']);
|
|
|
|
$body->set('baseurl', Configure::read('MISP.baseurl'));
|
|
|
|
$body->set('misp_org', Configure::read('MISP.org'));
|
|
|
|
$body->set('date_time', $date_time);
|
|
|
|
$body->set('suspiciousness_reason', $suspiciousness_reason);
|
2023-12-29 13:47:38 +01:00
|
|
|
|
2024-01-04 09:55:56 +01:00
|
|
|
$orgAdmins = array_keys($this->User->getOrgAdminsForOrg($user['User']['org_id']));
|
|
|
|
foreach ($orgAdmins as $orgAdminID) {
|
2023-11-24 13:47:59 +01:00
|
|
|
$org_admin = $this->User->find('first', array(
|
|
|
|
'recursive' => -1,
|
2024-01-04 09:55:56 +01:00
|
|
|
'conditions' => ['User.id' => $orgAdminID]
|
2023-11-24 13:47:59 +01:00
|
|
|
));
|
2023-12-29 13:47:38 +01:00
|
|
|
$this->User->sendEmail($org_admin, $body, false, "[" . Configure::read('MISP.org') . " MISP] Suspicious login detected.");
|
2023-11-29 15:17:29 +01:00
|
|
|
}
|
2023-11-24 13:47:59 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|