mirror of https://github.com/MISP/MISP
Merge branch 'develop' of github.com:MISP/MISP into develop
commit
b2734bf22d
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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`
|
||||
--
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -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'));
|
||||
|
|
|
@ -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('*'),
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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;';
|
||||
|
|
|
@ -2781,9 +2781,9 @@ class Attribute extends AppModel
|
|||
|
||||
}
|
||||
if (!empty($tagActions['detach'])) {
|
||||
$conditions = [];
|
||||
$conditions = ['OR' => []];
|
||||
foreach ($tagActions['detach'] as $detach) {
|
||||
$conditions[] = [
|
||||
$conditions['OR'][] = [
|
||||
'AND' => [
|
||||
'attribute_id' => $detach['attribute_id'],
|
||||
'tag_id' => $detach['tag_id']
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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>
|
|
@ -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(
|
||||
' <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']))))
|
||||
);
|
||||
|
|
|
@ -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 " ";
|
||||
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(
|
||||
' <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'));
|
||||
|
||||
}
|
|
@ -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.
|
@ -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();
|
||||
|
|
|
@ -9560,5 +9560,5 @@
|
|||
"uuid": false
|
||||
}
|
||||
},
|
||||
"db_version": "116"
|
||||
"db_version": "117"
|
||||
}
|
|
@ -2,5 +2,6 @@
|
|||
class EmailConfig {
|
||||
public $default = array(
|
||||
'transport' => 'Debug',
|
||||
'log' => true
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue