Feature/user login profiles2 (#9379)

* new: [userloginprofiles] start over with previous code

* fix: [user_login_profiles] fixes catching up the backlog

* chg: [userloginprofile] email to org_admin for suspicious login

* chg: [userloginprofile] only inform new device

* chg: [userloginprofiles] view_login_history instead of view_auth_history

* chg: [userloginprofile] make login history visually better

* chg: [userloginprofile] inform admins of malicious report

* fix: [userloginprofile] cleanup

* fix: [userloginprofile] fixes Attribute include in Console

* fix: [userloginprofile] db schema and changes

* chg: [CI] log emails

* chg: [PyMISP] branch change

* chg: [test] test

* fix: [userloginprofile] unique rows

* fix: [userloginprofile] unique rows

* chg: [cleanup]

* Revert "chg: [PyMISP] branch change"

This reverts commit 3f6fb46fee.

* fix: [userloginprofile] fix worksers with monolog=1.25 browcap=5.1

* fix: [db] dump schema version

* fix: [CI] newer php versions

* fix: [composer] php version

* fix: [php] revert to normal php7.4 tests

---------

Co-authored-by: iglocska <andras.iklody@gmail.com>
pull/9432/head
Christophe Vandeplas 2023-11-24 13:47:59 +01:00 committed by GitHub
parent 18625a9638
commit 7e2cb89f97
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 36744 additions and 77 deletions

View File

@ -21,7 +21,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-22.04]
php: ['7.2', '7.3', '7.4']
php: ['7.4']
# Steps represent a sequence of tasks that will be executed as part of the job
steps:

4
.gitignore vendored
View File

@ -33,6 +33,7 @@ tools/mkdocs
/app/tmp/cache/persistent/myapp*
/app/tmp/cache/views/myapp*
/app/tmp/cache/misp_feed*
/app/tmp/browscap/*
/app/files/*
/app/tmp/cache/feeds/*.cache
/app/tmp/cache/feeds/*.cache.gz
@ -71,6 +72,8 @@ app/Lib/EventWarning/Custom/*
!/app/files/empty
/app/files/terms/*
!/app/files/terms/empty
!/app/files/browscap
!/app/files/geo-open
/app/webroot/img/logo.png
/app/webroot/img/custom/*
!/app/webroot/img/custom/empty
@ -117,3 +120,4 @@ vagrant/*.log
/app/View/Emails/text/Custom/*
!/app/View/Emails/text/Custom/empty
.vscode/launch.json

View File

@ -1424,6 +1424,33 @@ CREATE TABLE IF NOT EXISTS `users` (
-- --------------------------------------------------------
--
-- Table structure for table `user_login_profiles`
--
CREATE TABLE `user_login_profiles` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`user_id` int(11) NOT NULL,
`status` varchar(191) DEFAULT NULL,
`ip` varchar(191) DEFAULT NULL,
`user_agent` varchar(191) DEFAULT NULL,
`accept_lang` varchar(191) DEFAULT NULL,
`geoip` varchar(191) DEFAULT NULL,
`ua_platform` varchar(191) DEFAULT NULL,
`ua_browser` varchar(191) DEFAULT NULL,
`ua_pattern` varchar(191) DEFAULT NULL,
`hash` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `hash` (`hash`),
KEY `ip` (`ip`),
KEY `status` (`status`),
KEY `geoip` (`geoip`),
INDEX `user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------
--
-- Table structure for table `warninglists`
--

View File

@ -19,6 +19,8 @@
App::uses('AppModel', 'Model');
App::uses('BackgroundJobsTool', 'Tools');
require_once dirname(__DIR__) . '/../Model/Attribute.php'; // FIXME workaround bug where Vendor/symfony/polyfill-php80/Resources/stubs/Attribute.php is loaded instead
/**
* Application Shell
*

View File

@ -433,17 +433,17 @@ class AppController extends Controller
// User found in the db, add the user info to the session
if (Configure::read('MISP.log_auth')) {
$this->loadModel('Log');
$this->Log->create();
$log = array(
'org' => $user['Organisation']['name'],
'model' => 'User',
'model_id' => $user['id'],
'email' => $user['email'],
'action' => 'auth',
'title' => "Successful authentication using API key ($authKeyToStore)",
'change' => 'HTTP method: ' . $_SERVER['REQUEST_METHOD'] . PHP_EOL . 'Target: ' . $this->request->here,
);
$this->Log->save($log);
$this->UserLoginProfile = ClassRegistry::init('UserLoginProfile');
$change = $this->UserLoginProfile->_getUserProfile();
$change['http_method'] = $_SERVER['REQUEST_METHOD'];
$change['target'] = $this->request->here;
$this->Log->createLogEntry(
$user,
'auth',
'User',
$user['id'],
"Successful authentication using API key ($authKeyToStore)",
json_encode($change));
}
$this->User->updateAPIAccessTime($user);
$this->Session->renew();
@ -557,7 +557,9 @@ class AppController extends Controller
if ($user['disabled'] || (isset($user['logged_by_authkey']) && $user['logged_by_authkey']) && !$this->User->checkIfUserIsValid($user)) {
if ($this->_shouldLog('disabled:' . $user['id'])) {
$this->Log = ClassRegistry::init('Log');
$this->Log->createLogEntry($user, 'auth_fail', 'User', $user['id'], 'Login attempt by disabled user.');
$this->UserLoginProfile = ClassRegistry::init('UserLoginProfile');
$change = $this->UserLoginProfile->_getUserProfile();
$this->Log->createLogEntry($user, 'auth_fail', 'User', $user['id'], 'Login attempt by disabled user.', json_encode($change));
}
$this->Auth->logout();
@ -576,8 +578,9 @@ class AppController extends Controller
if ($user['authkey_expiration'] < $time) {
if ($this->_shouldLog('expired:' . $user['authkey_id'])) {
$this->Log = ClassRegistry::init('Log');
$this->Log->createLogEntry($user, 'auth_fail', 'User', $user['id'], "Login attempt by expired auth key {$user['authkey_id']}.");
}
$this->UserLoginProfile = ClassRegistry::init('UserLoginProfile');
$change = $this->UserLoginProfile->_getUserProfile();
$this->Log->createLogEntry($user, 'auth_fail', 'User', $user['id'], "Login attempt by expired auth key {$user['authkey_id']}.", json_encode($change)); }
$this->Auth->logout();
throw new ForbiddenException('Auth key is expired');
}
@ -594,8 +597,9 @@ class AppController extends Controller
if (!$cidrTool->contains($remoteIp)) {
if ($this->_shouldLog('not_allowed_ip:' . $user['authkey_id'] . ':' . $remoteIp)) {
$this->Log = ClassRegistry::init('Log');
$this->Log->createLogEntry($user, 'auth_fail', 'User', $user['id'], "Login attempt from not allowed IP address {$remoteIp} for auth key {$user['authkey_id']}.");
}
$this->UserLoginProfile = ClassRegistry::init('UserLoginProfile');
$change = $this->UserLoginProfile->_getUserProfile();
$this->Log->createLogEntry($user, 'auth_fail', 'User', $user['id'], "Login attempt from not allowed IP address {$remoteIp} for auth key {$user['authkey_id']}.", json_encode($change)); }
$this->Auth->logout();
throw new ForbiddenException('It is not possible to use this Auth key from your IP address');
}
@ -1121,17 +1125,13 @@ class AppController extends Controller
if (isset($server[$headerNamespace . $header]) && !empty($server[$headerNamespace . $header])) {
if (Configure::read('Plugin.CustomAuth_only_allow_source') && Configure::read('Plugin.CustomAuth_only_allow_source') !== $this->_remoteIp()) {
$this->Log = ClassRegistry::init('Log');
$this->Log->create();
$log = array(
'org' => 'SYSTEM',
'model' => 'User',
'model_id' => 0,
'email' => 'SYSTEM',
'action' => 'auth_fail',
'title' => 'Failed authentication using external key (' . trim($server[$headerNamespace . $header]) . ') - the user has not arrived from the expected address. Instead the request came from: ' . $this->_remoteIp(),
'change' => null,
);
$this->Log->save($log);
$this->Log->createLogEntry(
'SYSTEM',
'auth_fail',
'User',
0,
'Failed authentication using external key (' . trim($server[$headerNamespace . $header]) . ') - the user has not arrived from the expected address. Instead the request came from: ' . $this->_remoteIp(),
null);
$this->__preAuthException($authName . ' authentication failed. Contact your MISP support for additional information at: ' . Configure::read('MISP.contact'));
}
$temp = $this->_checkExternalAuthUser($server[$headerNamespace . $header]);
@ -1142,34 +1142,30 @@ class AppController extends Controller
$this->Session->write(AuthComponent::$sessionKey, $user['User']);
if (Configure::read('MISP.log_auth')) {
$this->Log = ClassRegistry::init('Log');
$this->Log->create();
$log = array(
'org' => $user['User']['Organisation']['name'],
'model' => 'User',
'model_id' => $user['User']['id'],
'email' => $user['User']['email'],
'action' => 'auth',
'title' => 'Successful authentication using ' . $authName . ' key',
'change' => 'HTTP method: ' . $_SERVER['REQUEST_METHOD'] . PHP_EOL . 'Target: ' . $this->request->here,
);
$this->Log->save($log);
$change = $this->UserLoginProfile->_getUserProfile();
$change['http_method'] = $_SERVER['REQUEST_METHOD'];
$change['target'] = $this->request->here;
$this->Log->createLogEntry(
$user,
'auth',
'User',
$user['User']['id'],
'Successful authentication using ' . $authName . ' key',
json_encode($change));
}
$result = true;
} else {
// User not authenticated correctly
// reset the session information
$this->Log = ClassRegistry::init('Log');
$this->Log->create();
$log = array(
'org' => 'SYSTEM',
'model' => 'User',
'model_id' => 0,
'email' => 'SYSTEM',
'action' => 'auth_fail',
'title' => 'Failed authentication using external key (' . trim($server[$headerNamespace . $header]) . ')',
'change' => null,
);
$this->Log->save($log);
$change = $this->UserLoginProfile->_getUserProfile();
$this->Log->createLogEntry(
'SYSTEM',
'auth_fail',
'User',
0,
'Failed authentication using external key (' . trim($server[$headerNamespace . $header]) . ')',
json_encode($change));
if (Configure::read('CustomAuth_required')) {
$this->Session->destroy();
$this->__preAuthException($authName . ' authentication failed. Contact your MISP support for additional information at: ' . Configure::read('MISP.contact'));

View File

@ -781,6 +781,13 @@ class ACLComponent extends Component
'viewPeriodicSummary' => ['*'],
'getGpgPublicKey' => array('*'),
'unsubscribe' => ['*'],
'view_login_history' => ['*']
),
'userLoginProfiles' => array(
'index' => ['*'],
'trust' => ['*'],
'malicious' => ['*'],
'admin_delete' => ['perm_admin']
),
'userSettings' => array(
'index' => array('*'),

View File

@ -0,0 +1,166 @@
<?php
App::uses('AppController', 'Controller');
/**
* @property UserLoginProfile $UserLoginProfile
*/
class UserLoginProfilesController extends AppController
{
public $components = array(
'CRUD',
'RequestHandler'
);
public $paginate = array(
'limit' => 60,
'order' => array(
'UserLoginProfile.created_at' => 'DESC',
)
);
public function index($user_id = null)
{
$delete_buttons = false;
// normal user
$conditions = ['user_id' => $this->Auth->user('id')];
// org admin can see people from their own org
if (!$this->_isSiteAdmin() && $this->_isAdmin()) {
$conditions = ['User.org_id' => $this->Auth->user('org_id'),
'user_id' => $user_id];
$delete_buttons = true;
}
// full admin can see all users
else if ($this->_isSiteAdmin()) {
$conditions = ['user_id' => $user_id];
$delete_buttons = true;
}
$this->CRUD->index([
'conditions' => $conditions
]);
if ($this->IndexFilter->isRest()) {
return $this->restResponsePayload;
}
$this->set('title_for_layout', __('UserLoginProfiles'));
$this->set('menuData', [
'menuList' => $this->_isSiteAdmin() ? 'admin' : 'globalActions',
'menuItem' => 'authkeys_index',
]);
$this->set('delete_buttons', $delete_buttons);
}
/**
* @param int|array $id
* @return array
* @throws NotFoundException
*/
private function __deleteFetchConditions($id)
{
if (empty($id)) {
throw new NotFoundException(__('Invalid userloginprofile'));
}
$conditions = ['UserLoginProfile.id' => $id];
if ($this->_isSiteAdmin()) {
// no additional filter for siteadmins
}
else if ($this->_isAdmin()) {
$conditions['User.org_id'] = $this->Auth->user('org_id'); // org admin
}
else {
$conditions['UserLoginProfile.user_id'] = $this->Auth->user('id'); // normal user
}
return $conditions;
}
public function admin_delete($id)
{
if ($this->request->is('post') || $this->request->is('delete')) {
$profile = $this->UserLoginProfile->find('first', array(
'conditions' => $this->__deleteFetchConditions($id), // only allow (org/site) admins or own user to delete their data
'fields' => ['UserLoginProfile.*']
));
if (empty($profile)) {
throw new NotFoundException(__('Invalid UserLoginProfile'));
}
if ($this->UserLoginProfile->delete($id)) {
$this->loadModel('Log');
$fieldsDescrStr = 'UserLoginProfile (' . $id . '): deleted';
$this->Log->createLogEntry($this->Auth->user(), 'delete', 'UserLoginProfile', $id, $fieldsDescrStr, json_encode($profile));
if ($this->_isRest()) {
return $this->RestResponse->saveSuccessResponse('UserLoginProfile', 'admin_delete', $id, $this->response->type(), 'UserLoginProfile deleted.');
} else {
$this->Flash->success(__('UserLoginProfile deleted'));
$this->redirect(array('admin'=> false, 'controller' => 'userLoginProfiles', 'action' => 'index', $profile['UserLoginProfile']['user_id']));
}
}
$this->Flash->error(__('UserLoginProfile was not deleted'));
$this->redirect(array('admin'=> false, 'controller' => 'userLoginProfiles', 'action' => 'index', $profile['UserLoginProfile']['user_id']));
}
}
public function trust($logId)
{
if ($this->request->is('post')) {
$this->__setTrust($logId, 'trusted');
}
$this->redirect(array('controller' => 'users', 'action' => 'view_login_history'));
}
public function malicious($logId)
{
if ($this->request->is('post')) {
$userLoginProfile = $this->__setTrust($logId, 'malicious');
$this->Flash->info(__('You marked a login suspicious. You must change your password NOW !'));
$this->loadModel('Log');
$details = 'User reported suspicious login for log ID: '. $logId;
// raise an alert (the SIEM component should ensure (org)admins are informed)
$this->Log->createLogEntry($this->Auth->user(), 'auth_alert', 'User', $this->Auth->user('id'), 'Suspicious login reported.', $details);
// inform (org)admins of the report, they might want to action this...
$user = $this->User->find('first', array(
'conditions' => array(
'User.id' => $this->Auth->user('id')
),
'recursive' => -1
));
unset($user['User']['password']);
$this->UserLoginProfile->email_report_malicious($user, $userLoginProfile);
// change account info to force password change, redirect to new password page.
$this->User->id = $this->Auth->user('id');
$this->User->saveField('change_pw', 1);
$this->redirect(array('controller' => 'users', 'action' => 'change_pw'));
return;
}
$this->redirect(array('controller' => 'users', 'action' => 'view_login_history'));
}
private function __setTrust($logId, $status)
{
$user = $this->Auth->user();
$this->loadModel('Log');
$log = $this->Log->find('first', array(
'conditions' => array(
'Log.user_id' => $user['id'],
'Log.id' => $logId,
'OR' => array ('Log.action' => array('login', 'login_fail', 'auth', 'auth_fail'))
),
'fields' => array('Log.action', 'Log.created', 'Log.ip', 'Log.change', 'Log.id'),
'order' => array('Log.created DESC')
));
$data = $this->UserLoginProfile->_fromLog($log['Log']);
if (!$loginProfile) return $data; // skip if empty logs
$data['status'] = $status;
$data['user_id'] = $user['id'];
$data['hash'] = $this->UserLoginProfile->hash($data);
// add the userLoginProfile trust status if it not already there, based on the hash
$result = $this->UserLoginProfile->find('count', array(
'conditions' => array('UserLoginProfile.hash' => $data['hash'])
));
if ($result == 0) {
// no row yet, save it.
$this->UserLoginProfile->save($data);
}
return $data;
}
}

View File

@ -1241,6 +1241,7 @@ class UsersController extends AppController
// Password is converted to hashed form automatically
$this->User->save(['id' => $this->Auth->user('id'), 'password' => $passwordToSave], false, ['password']);
}
// login was successful, do everything that is needed such as logging and more:
$this->_postlogin();
} else {
$dataSourceConfig = ConnectionManager::getDataSource('default')->config;
@ -1249,10 +1250,12 @@ class UsersController extends AppController
if (str_replace("//", "/", $this->webroot . $this->Session->read('Auth.redirect')) == $this->webroot && $this->Session->read('Message.auth.message') == $this->Auth->authError) {
$this->Session->delete('Message.auth');
}
// don't display "invalid user" before first login attempt
// Login was failed, do everything that is needed such as blocklisting, logging and more
// Also don't display "invalid user" before first login attempt
if ($this->request->is('post') || $this->request->is('put')) {
$this->Flash->error(__('Invalid username or password, try again'));
if (isset($this->request->data['User']['email'])) {
// increase bruteforce attempt and log
$this->Bruteforce->insert($this->request->data['User']['email']);
}
}
@ -1339,6 +1342,7 @@ class UsersController extends AppController
'recursive' => -1
));
unset($user['User']['password']);
// update login timestamp and welcome user
$this->User->updateLoginTimes($user['User']);
$lastUserLogin = $user['User']['last_login'];
$this->User->Behaviors->enable('SysLogLogable.SysLogLogable');
@ -1346,6 +1350,25 @@ class UsersController extends AppController
$readableDatetime = (new DateTime())->setTimestamp($lastUserLogin)->format('D, d M y H:i:s O'); // RFC822
$this->Flash->info(__('Welcome! Last login was on %s', $readableDatetime));
}
// there are reasons to believe there is evil happening, suspicious. Inform user and (org)admins.
$suspiciousness_reason = $this->User->UserLoginProfile->_isSuspicious();
if ($suspiciousness_reason) {
// raise an alert (the SIEM component should ensure (org)admins are informed)
$this->loadModel('Log');
$this->Log->createLogEntry($this->Auth->user(), 'auth_alert', 'User', $this->Auth->user('id'), 'Suspicious login.', $suspiciousness_reason);
// Line below commented out to NOT inform user/org admin of the suspicious login.
// The reason is that we want to prevent other user actions cause trouble.
// However this also means we're sitting on data that could be used to detect new evil logins.
// As we're generating alerts, the sysadmin should be keeping an eye on these
// $this->User->UserLoginProfile->email_suspicious($user, $suspiciousness_reason);
}
// verify UserLoginProfile trust status and perform informative actions
if(!$this->User->UserLoginProfile->_isTrusted()) {
// send email to inform the user
$this->User->UserLoginProfile->email_newlogin($user);
}
// no state changes are ever done via GET requests, so it is safe to return to the original page:
$this->redirect($this->Auth->redirectUrl());
}
@ -3103,6 +3126,111 @@ class UsersController extends AppController
$this->render('/genericTemplates/confirm');
}
}
public function view_login_history($user_id = null) {
if ($user_id && $this->_isAdmin()) { // org and site admins
$user = $this->User->find('first', array(
'recursive' => -1,
'conditions' => $this->__adminFetchConditions($user_id),
'contain' => [
'UserSetting',
'Role',
'Organisation'
]
));
if (empty($user)) {
throw new NotFoundException(__('Invalid user'));
}
} else {
$user_id = $this->Auth->user('id');
}
$this->loadModel('UserLoginProfile');
$this->loadModel('Log');
$logs = $this->Log->find('all', array(
'conditions' => array(
'Log.user_id' => $user_id,
'OR' => array ('Log.action' => array('login', 'login_fail', 'auth', 'auth_fail'))
),
'fields' => array('Log.action', 'Log.created', 'Log.ip', 'Log.change', 'Log.id'),
'order' => array('Log.created DESC'),
'limit' => 100 // relatively high limit, as we'll be grouping data afterwards.
));
$lst = array();
$prevProfile = null;
$prevCreatedLast = null;
$prevCreatedFirst = null;
$prevLogEntry = null;
$prevActions = array();
$actions_translator = [
'auth_fail' => 'API:failed',
'auth' => 'API:login',
'login' => 'web:login',
'login_fail' => 'web:failed'
];
$max_rows = 6; // limit to a few rows, to prevent cluttering the interface.
// We didn't filter the data at SQL query too much, nor by age, as we want to show "enough" data, even if old
$rows = 0;
// group authentications by type of loginprofile, to make the list shorter
foreach($logs as $logEntry) {
$loginProfile = $this->UserLoginProfile->_fromLog($logEntry['Log']);
if (!$loginProfile) continue; // skip if empty log
$loginProfile['ip'] = $logEntry['Log']['ip'] ?? null; // transitional workaround
if ($this->UserLoginProfile->_isSimilar($loginProfile, $prevProfile)) {
// continue find as same type of login
$prevCreatedFirst = $logEntry['Log']['created'];
$prevActions[] = $actions_translator[$logEntry['Log']['action']] ?? $logEntry['Log']['action'];
} else {
// add as new entry
if (null != $prevProfile) {
$actionsString = ''; // count actions
foreach(array_count_values($prevActions) as $action => $cnt) {
$actionsString .= $action . ' (' . $cnt . "x) ";
}
$lst[] = array(
'status' => $this->UserLoginProfile->_getTrustStatus($prevProfile, $user_id),
'platform' => $prevProfile['ua_platform'],
'browser' => $prevProfile['ua_browser'],
'region' => $prevProfile['geoip'],
'ip' => $prevProfile['ip'],
'accept_lang' => $prevProfile['accept_lang'],
'last_seen' => $prevCreatedLast,
'first_seen' => $prevCreatedFirst,
'actions' => $actionsString,
'actions_button' => ('unknown' == $this->UserLoginProfile->_getTrustStatus($prevProfile, $user_id)) ? true : false,
'id' => $prevLogEntry);
}
// build new entry
$prevProfile = $loginProfile;
$prevCreatedFirst = $prevCreatedLast = $logEntry['Log']['created'];
$prevActions[] = $actions_translator[$logEntry['Log']['action']] ?? $logEntry['Log']['action'];
$prevLogEntry = $logEntry['Log']['id'];
$rows += 1;
if ($rows == $max_rows) break;
}
}
// add last entry
$actionsString = ''; // count actions
foreach(array_count_values($prevActions) as $action => $cnt) {
$actionsString .= $action . ' (' . $cnt . "x) ";
}
$lst[] = array(
'status' => $this->UserLoginProfile->_getTrustStatus($prevProfile, $user_id),
'platform' => $prevProfile['ua_platform'],
'browser' => $prevProfile['ua_browser'],
'region' => $prevProfile['geoip'],
'ip' => $prevProfile['ip'],
'accept_lang' => $prevProfile['accept_lang'],
'last_seen' => $prevCreatedLast,
'first_seen' => $prevCreatedFirst,
'actions' => $actionsString,
'actions_button' => ('unknown' == $this->UserLoginProfile->_getTrustStatus($prevProfile, $user_id)) ? true : false,
'id' => $prevLogEntry);
$this->set('data', $lst);
$this->set('user_id', $user_id);
}
public function logout401() {
# You should read the documentation in docs/CONFIG.ApacheSecureAuth.md
# before using this endpoint. It is not useful without webserver config

View File

@ -85,7 +85,8 @@ class AppModel extends Model
93 => false, 94 => false, 95 => true, 96 => false, 97 => true, 98 => false,
99 => false, 100 => false, 101 => false, 102 => false, 103 => false, 104 => false,
105 => false, 106 => false, 107 => false, 108 => false, 109 => false, 110 => false,
111 => false, 112 => false, 113 => true, 114 => false, 115 => false, 116 => false
111 => false, 112 => false, 113 => true, 114 => false, 115 => false, 116 => false,
117 => false
);
const ADVANCED_UPDATES_DESCRIPTION = array(
@ -1980,6 +1981,28 @@ class AppModel extends Model
case 116:
$sqlArray[] = "ALTER TABLE `event_reports` modify `content` mediumtext";
break;
case 117:
$sqlArray[] = "CREATE TABLE `user_login_profiles` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`user_id` int(11) NOT NULL,
`status` varchar(191) DEFAULT NULL,
`ip` varchar(191) DEFAULT NULL,
`user_agent` varchar(191) DEFAULT NULL,
`accept_lang` varchar(191) DEFAULT NULL,
`geoip` varchar(191) DEFAULT NULL,
`ua_platform` varchar(191) DEFAULT NULL,
`ua_browser` varchar(191) DEFAULT NULL,
`ua_pattern` varchar(191) DEFAULT NULL,
`hash` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `hash` (`hash`),
KEY `ip` (`ip`),
KEY `status` (`status`),
KEY `geoip` (`geoip`),
INDEX `user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
break;
case 'fixNonEmptySharingGroupID':
$sqlArray[] = 'UPDATE `events` SET `sharing_group_id` = 0 WHERE `distribution` != 4;';
$sqlArray[] = 'UPDATE `attributes` SET `sharing_group_id` = 0 WHERE `distribution` != 4;';

View File

@ -8,7 +8,6 @@ class Bruteforce extends AppModel
public function insert($username)
{
$this->Log = ClassRegistry::init('Log');
$this->Log->create();
$ip = $this->_remoteIp();
$expire = Configure::check('SecureAuth.expire') ? Configure::read('SecureAuth.expire') : 300;
$amount = Configure::check('SecureAuth.amount') ? Configure::read('SecureAuth.amount') : 5;
@ -21,36 +20,32 @@ class Bruteforce extends AppModel
);
$this->save($bruteforceEntry);
$title = 'Failed login attempt using username ' . $username . ' from IP: ' . $ip . '.';
$this->UserLoginProfile = ClassRegistry::init('UserLoginProfile');
$change = $this->UserLoginProfile->_getUserProfile();
if ($this->isBlocklisted($username)) {
$change = 'This has tripped the bruteforce protection after ' . $amount . ' failed attempts. The source IP/username is now blocklisted for ' . $expire . ' seconds.';
} else {
$change = '';
$title .= ' Blocked against bruteforcing.';
$change['details'] = 'This has tripped the bruteforce protection after ' . $amount . ' failed attempts. The source IP/username is now blocklisted for ' . $expire . ' seconds.';
}
// lookup the real user details
$this->User = ClassRegistry::init('User');
$user = $this->User->find('first', array(
'conditions' => array('User.email' => $username),
'fields' => array('User.id', 'Organisation.name'),
'fields' => array('User.id', 'Organisation.name', 'User.email'),
'recursive' => 0));
$user = array_merge($user, $user['User']);
if ($user) {
$org = $user['Organisation']['name'];
$userId = $user['User']['id'];
} else {
$org = 'SYSTEM';
$user = 'SYSTEM';
$userId = 0;
}
$log = array(
'org' => $org,
'model' => 'User',
'model_id' => $userId,
'email' => $username,
'user_id' => $userId,
'action' => 'login_fail',
'title' => $title,
'change' => $change
);
$this->Log->save($log);
$this->Log->createLogEntry(
$user,
'login_fail',
'User',
$userId,
$title,
json_encode($change));
}
public function clean()

View File

@ -26,6 +26,7 @@ class Log extends AppModel
'attachTags',
'auth',
'auth_fail',
'auth_alert',
'blocklisted',
'captureRelations',
'change_pw',
@ -99,7 +100,7 @@ class Log extends AppModel
public $logMeta = array(
'email' => array('values' => array('email'), 'name' => 'Emails'),
'auth_issues' => array('values' => array('login_fail', 'auth_fail'), 'name' => 'Authentication issues')
'auth_issues' => array('values' => array('login_fail', 'auth_fail', 'auth_alert'), 'name' => 'Authentication issues')
);
public $logMetaAdmin = array(

View File

@ -214,6 +214,7 @@ class User extends AppModel
),
'Post',
'UserSetting',
'UserLoginProfile'
// 'AuthKey' - readd once the initial update storm is over
);
@ -1053,6 +1054,27 @@ class User extends AppModel
));
}
public function getSiteAdmins($excludeUserId = false) {
$adminRoles = $this->Role->find('column', array(
'conditions' => array('perm_site_admin' => 1),
'fields' => array('Role.id')
));
$conditions = array(
'User.disabled' => 0,
'User.role_id' => $adminRoles
);
if ($excludeUserId) {
$conditions['User.id !='] = $excludeUserId;
}
return $this->find('list', array(
'recursive' => -1,
'conditions' => $conditions,
'fields' => array(
'User.id', 'User.email'
)
));
}
public function verifyPassword($user_id, $password)
{
$currentUser = $this->find('first', array(
@ -1261,6 +1283,7 @@ class User extends AppModel
}
if ($action == 'login') {
$description = "User (" . $user['id'] . "): " . $user['email'];
$fieldsResult = json_encode($this->UserLoginProfile->_getUserProfile());
} elseif ($action == 'logout') {
$description = "User (" . $user['id'] . "): " . $user['email'];
} elseif ($action == 'edit') {

View File

@ -0,0 +1,307 @@
<?php
App::uses('AppModel', 'Model');
class UserLoginProfile extends AppModel
{
public $actsAs = array(
'AuditLog',
'Containable',
'SysLogLogable.SysLogLogable' => array(
'userModel' => 'User',
'userKey' => 'user_id',
'change' => 'full'
),
'Containable'
);
public $validate = [
'status' => [
'rule' => '/^(trusted|malicious)$/',
'message' => 'Must be one of: trusted, malicious'
],
];
public $order = array("UserLoginProfile.id" => "DESC");
public $belongsTo = [
'User' => [
'className' => 'User',
'foreignKey' => 'user_id',
'conditions' => '',
'fields' => '',
'order' => ''
]];
protected $browscapCacheDir = APP . DS . 'tmp' . DS . 'browscap';
protected $browscapIniFile = APP . DS . 'files' . DS . 'browscap'. DS . 'browscap.ini'; // Browscap file managed by MISP - https://browscap.org/stream?q=Lite_PHP_BrowsCapINI
protected $geoIpDbFile = 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/
private $knownUserProfiles = [];
public function _buildBrowscapCache() {
$this->log("Browscap - building new cache from browscap.ini file.", "info");
$fileCache = new \Doctrine\Common\Cache\FilesystemCache($this->browscapCacheDir);
$cache = new \Roave\DoctrineSimpleCache\SimpleCacheAdapter($fileCache);
$logger = new \Monolog\Logger('name');
$bc = new \BrowscapPHP\BrowscapUpdater($cache, $logger);
$bc->convertFile($this->browscapIniFile);
}
public function beforeSave($options = [])
{
$this->data['UserLoginProfile']['hash'] = $this->hash($this->data['UserLoginProfile']);
return true;
}
public function hash($data) {
unset($data['hash']);
unset($data['created_at']);
return md5(serialize($data));
}
/**
* slow function - don't call it too often
* @return array
*/
public function _getUserProfile() {
if (!$this->userProfile) {
// below uses https://github.com/browscap/browscap-php
if (class_exists('\BrowscapPHP\Browscap')) {
try {
$fileCache = new \Doctrine\Common\Cache\FilesystemCache($this->browscapCacheDir);
$cache = new \Roave\DoctrineSimpleCache\SimpleCacheAdapter($fileCache);
$logger = new \Monolog\Logger('name');
$bc = new \BrowscapPHP\Browscap($cache, $logger);
$browser = $bc->getBrowser();
} catch (\BrowscapPHP\Exception $e) {
$this->_buildBrowscapCache();
return $this->_getUserProfile();
}
} else {
// a primitive OS & browser extraction capability
$ua = env('HTTP_USER_AGENT');
$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')) {
$geoDbReader = new GeoIp2\Database\Reader($this->geoIpDbFile);
$record = $geoDbReader->country($ip);
$country = $record->country->isoCode;
} else {
$country = 'None';
}
$this->userProfile = [
'user_agent' => env('HTTP_USER_AGENT'),
'ip' => $ip,
'accept_lang' => env('HTTP_ACCEPT_LANGUAGE'),
'geoip' => $country,
'ua_pattern' => $browser->browser_name_pattern,
'ua_platform' => $browser->platform,
'ua_browser' => $browser->browser
];
}
return $this->userProfile;
}
public function _fromLog($logEntry) {
$data = json_decode('{"user_agent": "", "ip": "", "accept_lang":"", "geoip":"", "ua_pattern":"", "ua_platform":"", "ua_browser":""}', true);
$data = array_merge($data, json_decode($logEntry['change'], true) ?? []);
$data['ip'] = $logEntry['ip'];
$data['timestamp'] = $logEntry['created'];
if ($data['user_agent'] == "") return false;
return $data;
}
public function _isSimilar($a, $b) {
// 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
if ($a['ua_browser'] == $b['ua_browser'] &&
$a['ua_platform'] == $b['ua_platform'] &&
$a['accept_lang'] == $b['accept_lang'] &&
$a['geoip'] == $b['geoip']) {
return true;
}
// similar browser pattern, OS and region
if ($a['ua_pattern'] == $b['ua_pattern'] &&
$a['ua_platform'] == $b['ua_platform'] &&
$a['accept_lang'] == $b['accept_lang'] &&
$a['geoip'] == $b['geoip']) {
return true;
}
return false;
}
public function _isIdentical($a, $b) {
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']) {
return true;
}
return false;
}
public function _getTrustStatus($userProfileToCheck, $user_id = null) {
if (!$user_id) {
$user_id = AuthComponent::user('id');
}
// load Singleton / caching
if (!isset($this->knownUserProfiles[$user_id])) {
$this->knownUserProfiles[$user_id] = $this->find('all', [
'conditions' => ['UserLoginProfile.user_id' => $user_id],
'recursive' => 0],
);
}
// perform check on all entries, and stop when check OK
foreach ($this->knownUserProfiles[$user_id] as $knownUserProfile) {
// 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';
}
public function _isTrusted() {
if (strpos($this->_getTrustStatus($this->_getUserProfile()), 'trusted') !== false) {
return true;
}
return false;
}
public function _isSuspicious() {
// previously marked loginuserprofile as malicious by the user
if (strpos($this->_getTrustStatus($this->_getUserProfile()), 'malicious') !== false) {
return _('A user reported a similar login profile as malicious.');
}
// same IP as previous malicious user
$maliciousWithSameIP = $this->find('first', [
'conditions' => [
'UserLoginProfile.ip' => $this->_getUserProfile()['ip'],
'UserLoginProfile.status' => 'malicious'
],
'recursive' => 0,
'fields' => array('UserLoginProfile.*')],
);
if ($maliciousWithSameIP) {
return _('The source IP was reported as as malicious by a user.');
}
// 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;
}
public function email_newlogin($user) {
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
$result = $this->User->sendEmail($user, $body, false, "[" . Configure::read('MISP.org') . " MISP] New sign in.");
if ($result) {
// all is well, email sent to user
} else {
// email flow system already logs errors
}
}
}
public function email_report_malicious($user, $userLoginProfile) {
// 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);
$org_admins = $this->User->getOrgAdminsForOrg($user['User']['org_id']);
$admins = $this->User->getSiteAdmins();
$all_admins = array_unique(array_merge($org_admins, $admins));
foreach($all_admins as $admin_email) {
$admin = $this->User->find('first', array(
'recursive' => -1,
'conditions' => ['User.email' => $admin_email]
));
$result = $this->User->sendEmail($admin, $body, false, "[" . Configure::read('MISP.org') . " MISP] Suspicious login reported.");
if ($result) {
// all is well, email sent to user
} else {
// email flow system already logs errors
}
}
}
public function email_suspicious($user, $suspiciousness_reason) {
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
$result = $this->User->sendEmail($user, $body, false, "[" . Configure::read('MISP.org') . " MISP] Suspicious login with your account.");
if ($result) {
// all is well, email sent to user
} else {
// email flow system already logs errors
}
// 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);
$org_admins = $this->User->getOrgAdminsForOrg($user['User']['org_id']);
foreach($org_admins as $org_admin_email) {
$org_admin = $this->User->find('first', array(
'recursive' => -1,
'conditions' => ['User.email' => $org_admin_email]
));
$result = $this->User->sendEmail($org_admin, $body, false, "[" . Configure::read('MISP.org') . " MISP] Suspicious login detected.");
if ($result) {
// all is well, email sent to user
} else {
// email flow system already logs errors
}
}
}
}
}

View File

@ -0,0 +1,19 @@
Hello,
Your account on MISP <?= $misp_org; ?> was just signed into from a new device or location.
- When: <?= $date_time; ?>
- Operating System: <?= $userLoginProfile['ua_platform']; ?>
- Browser: <?= $userLoginProfile['ua_browser']; ?>
- Location: <?= $userLoginProfile['geoip']; ?>
- IP: <?= $userLoginProfile['ip']; ?>
Follow this link to confirm if was you: <?php echo $baseurl . '/users/view_login_history/'; ?>
I you don't recognize this activity, please markt the login as suspicious and IMMEDIATELY to reset your password.

View File

@ -0,0 +1,20 @@
Dear Organisational MISP admin,
Please know that a user from your organisation reported a suspicious or malicious login with their account on MISP <?= $misp_org; ?>.
The following information relates to the login:
- When: <?= $date_time; ?>
- Account used: <?= $username; ?>
- Operating System: <?= $userLoginProfile['ua_platform']; ?>
- Browser: <?= $userLoginProfile['ua_browser']; ?>
- Location: <?= $userLoginProfile['geoip']; ?>
- IP: <?= $userLoginProfile['ip']; ?>
The affected user was forced to change their password.

View File

@ -0,0 +1,25 @@
Hello,
A suspicious login happened with your account on MISP <?= $misp_org; ?>.
We believe it is suspicious because: <?= $suspiciousness_reason; ?>
The following information relates to the login:
- When: <?= $date_time; ?>
- Account used: <?= $username; ?>
- Operating System: <?= $userLoginProfile['ua_platform']; ?>
- Browser: <?= $userLoginProfile['ua_browser']; ?>
- Location: <?= $userLoginProfile['geoip']; ?>
- IP: <?= $userLoginProfile['ip']; ?>
Follow this link to confirm if was you: <?php echo $baseurl . '/users/view_login_history/'; ?>
I you don't recognize this activity, please markt the login as suspicious and IMMEDIATELY to reset your password.

View File

@ -0,0 +1,24 @@
Dear Organisational MISP admin,
A suspicious login happened with an account in your organisation on MISP <?= $misp_org; ?>.
We believe it is suspicious because: <?= $suspiciousness_reason; ?>
The following information relates to the login:
- When: <?= $date_time; ?>
- Account used: <?= $username; ?>
- Operating System: <?= $userLoginProfile['ua_platform']; ?>
- Browser: <?= $userLoginProfile['ua_browser']; ?>
- Location: <?= $userLoginProfile['geoip']; ?>
- IP: <?= $userLoginProfile['ip']; ?>
The affected user was informed and asked to validate the connection.
You will be informed in an additional email if the user confirms it as malicious.

View File

@ -0,0 +1,107 @@
<?php
echo sprintf('<div%s>', empty($ajax) ? ' class="index"' : '');
echo $this->element('genericElements/IndexTable/index_table', [
'data' => [
'data' => $data,
'top_bar' => [
'pull' => 'right',
'children' => [
[
'type' => 'search',
'button' => __('Filter'),
'placeholder' => __('Enter value to search'),
'searchKey' => 'quickFilter',
]
]
],
'fields' => [
[
'name' => '#',
'sort' => 'UserLoginProfile.id',
'data_path' => 'UserLoginProfile.id',
],
[
'name' => __('User'),
'sort' => 'User.email',
'data_path' => 'User.email',
'element' => empty($user_id) ? 'links' : 'generic_field',
'url' => $baseurl . '/users/view',
'url_params_data_paths' => ['User.id'],
],
[
'name' => __('IP'),
'sort' => 'UserLoginProfile.ip',
'data_path' => 'UserLoginProfile.ip',
],
[
'name' => __('User-Agent'),
'sort' => 'UserLoginProfile.user_agent',
'data_path' => 'UserLoginProfile.user_agent',
],
[
'name' => ('Reported on'),
'data_path' => 'UserLoginProfile.created_at',
'element' => 'datetime',
'empty' => __('Never'),
],
[
'name' => __('Status'),
'sort' => 'UserLoginProfile.status',
'data_path' => 'UserLoginProfile.status',
],
[
'name' => __('Accept Language'),
'sort' => 'UserLoginProfile.accept_lang',
'data_path' => 'UserLoginProfile.accept_lang',
],
[
'name' => __('GeoIP'),
'sort' => 'UserLoginProfile.geoip',
'data_path' => 'UserLoginProfile.geoip',
],
[
'name' => __('UA.pattern'),
'sort' => 'UserLoginProfile.ua_pattern',
'data_path' => 'UserLoginProfile.ua_',
],
[
'name' => __('UA.Platform'),
'sort' => 'UserLoginProfile.ua_platform',
'data_path' => 'UserLoginProfile.ua_platform',
],
[
'name' => __('UA.Browser'),
'sort' => 'UserLoginProfile.ua_browser',
'data_path' => 'UserLoginProfile.ua_browser',
]
],
'title' => empty($ajax) ? __('UserLoginProfile Index') : false,
'description' => empty($ajax) ? __('A list of confirmed authentication profiles bound to a user. This is used by the backend to identify suspicious connections from a user and raise alerts.') : false,
'pull' => 'right',
'actions' => [
[
'class' => 'modal-open',
'url' => "$baseurl/admin/UserLoginProfiles/delete",
'url_params_data_paths' => ['UserLoginProfile.id'],
'postLink' => true,
'postLinkConfirm' => __('Are you sure you want to delete this profile?'),
'icon' => 'trash',
'title' => __('Delete entry'),
'requirement' => $delete_buttons
]
]
]
]);
echo '</div>';
if (empty($ajax)) {
echo $this->element('/genericElements/SideMenu/side_menu', $menuData);
}
?>
<script type="text/javascript">
var passedArgsArray = <?php echo $passedArgs; ?>;
$(function() {
$('#quickFilterButton').click(function() {
runIndexQuickFilter();
});
});
</script>

View File

@ -177,7 +177,7 @@ if ($isAdmin && $isTotp) {
'js' => array('vis', 'jquery-ui.min', 'network-distribution-graph')
));
echo sprintf(
'<div class="users view"><div class="row-fluid"><div class="span8" style="margin:0px;">%s</div></div>%s%s<div style="margin-top:20px;">%s%s</div></div>',
'<div class="users view"><div class="row-fluid"><div class="span8" style="margin:0px;">%s</div></div>%s%s%s<div style="margin-top:20px;">%s%s</div></div>',
sprintf(
'<h2>%s</h2>%s',
__('User %s', h($user['User']['email'])),
@ -200,6 +200,15 @@ if ($isAdmin && $isTotp) {
),
__('Review user logs')
),
sprintf(
'&nbsp;<a href="%s" class="btn btn-inverse">%s</a>',
sprintf(
'%s/users/view_login_history/%s',
$baseurl,
h($user['User']['id'])
),
__('Review user logins')
),
$me['Role']['perm_auth'] ? $this->element('/genericElements/accordion', array('title' => __('Auth keys'), 'url' => '/auth_keys/index/' . h($user['User']['id']))) : '',
$this->element('/genericElements/accordion', array('title' => 'Events', 'url' => '/events/index/searchemail:' . urlencode(h($user['User']['email']))))
);

View File

@ -0,0 +1,77 @@
<?php
echo sprintf('<div%s>', !$this->request->is('ajax') ? ' class="index"' : '');
?>
<style>
.device-overview {
display: flex;
flex-direction: column;
align-items: center;
margin: 20px 0;
padding: 20px;
border: 1px solid #ddd;
border-radius: 10px;
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.1);
max-width: 400px;
}
.device-name {
font-size: 15px;
}
</style>
<?php
foreach($data as $entry ){
$platform = h(strtolower($entry['platform']));
if (str_contains($platform, 'win')) $platform = 'windows';
if (str_contains($platform, 'macosx')) $platform = 'apple';
if (str_contains($platform, 'ios')) $platform = 'apple';
$bgcolor = 'white';
if (str_contains($entry['status'], 'malicious')) $bgcolor = '#ffdddd';
elseif (str_contains($entry['status'], 'trusted')) $bgcolor = '#ddffdd';
?>
<div class="device-overview"style="background-color:<?= $bgcolor ?>;">
<div>
<span class="device-icon fab fa-<?= h(strtolower($platform))?>" style="font-size:30px;"></span>
<span class="device-icon fab fa-<?= h(strtolower($entry['browser']))?>" style="font-size:30px;"></span>
</div>
<h3 class="device-name"><?= h($entry['platform']) ?>, <?= h($entry['browser']) ?></h3>
<div class="device-location">
<?= $this->Icon->countryFlag($entry['region']);?>
<span class="device-info"><?= h($entry['region']). ' ('. h($entry['ip']). ')' ?></span>
</div>
<div><?= h($entry['actions']) ?></div>
<div><?= h($entry['first_seen'])." - ".h($entry['last_seen']) ?></div>
<?php if ('malicious' == $entry['status']) { ?>
<i class="fas fa-bug" style="color:red; font-size:30px;"></i>
<?php } elseif ('trusted' == $entry['status']) { ?>
<i class="fas fa-shield-alt" style="color:green; font-size:30px;"></i>
<?php } elseif (str_contains($entry['status'], 'likely')) {
echo ("<div>".h($entry['status'])."</div>");
}
if ('unknown' == $entry['status'] || str_contains($entry['status'], 'likely')) {
echo "<div>";
echo $this->Form->postLink(__('This was me'),array('controller' => 'userLoginProfiles', 'action'=>'trust', $entry['id']),array('class' => 'btn btn-inverse', 'style' => '', 'confirm' => __('Are you sure you want to mark this device as trusted?')));
echo "&nbsp;";
echo $this->Form->postLink(__('Report malicious'),array('controller' => 'userLoginProfiles', 'action'=>'malicious', $entry['id']),array('class' => 'btn btn-inverse','confirm' => __('Was this connection suspicious or malicious? If yes, you will be forced to change your password.')));
echo "</div>";
} ?>
</div>
<?php
}
echo sprintf(
'&nbsp;<a href="%s" class="btn btn-inverse">%s</a>',
sprintf(
'%s/userLoginProfiles/index/%s',
$baseurl,
$user_id
),
__('Review user login profiles')
);
echo '</div>';
if (!$this->request->is('ajax')) {
echo $this->element('/genericElements/SideMenu/side_menu', array('menuList' => 'globalActions', 'menuItem' => 'view'));
}

View File

@ -2,7 +2,7 @@
"prefer-stable": true,
"minimum-stability": "dev",
"require": {
"php": ">=7.2.0,<8.0.0",
"php": ">=7.4.0,<8.0.0",
"ext-json": "*",
"ext-mbstring": "*",
"ext-xml": "*",
@ -11,9 +11,11 @@
"ext-pcre": "*",
"kamisama/cake-resque": "4.1.2",
"pear/crypt_gpg": "1.6.7",
"monolog/monolog": "1.24.0",
"monolog/monolog": "1.25.3",
"spomky-labs/otphp": "^10.0",
"bacon/bacon-qr-code": "^2.0"
"bacon/bacon-qr-code": "^2.0",
"geoip2/geoip2": "~2.0",
"browscap/browscap-php": "5.1.0"
},
"require-dev": {
"phpunit/phpunit": "^8",

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -93,6 +93,8 @@ if (!empty($failed)) {
trigger_error("CakePHP core could not be found. Check the value of CAKE_CORE_INCLUDE_PATH in APP/webroot/index.php. It should point to the directory containing your " . DS . "cake core directory and your " . DS . "vendors root directory.", E_USER_ERROR);
}
require_once dirname(__DIR__) . '/Model/Attribute.php'; // FIXME workaround bug where Vendor/symfony/polyfill-php80/Resources/stubs/Attribute.php is loaded instead
App::uses('Dispatcher', 'Routing');
$Dispatcher = new Dispatcher();

View File

@ -9560,5 +9560,5 @@
"uuid": false
}
},
"db_version": "116"
"db_version": "117"
}

View File

@ -2,5 +2,6 @@
class EmailConfig {
public $default = array(
'transport' => 'Debug',
'log' => true
);
}