Skip to content

Commit

Permalink
Feature/user login profiles2 (MISP#9379)
Browse files Browse the repository at this point in the history
* 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 3f6fb46.

* 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 <[email protected]>
  • Loading branch information
cvandeplas and iglocska authored Nov 24, 2023
1 parent 18625a9 commit 7e2cb89
Show file tree
Hide file tree
Showing 26 changed files with 36,744 additions and 77 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -117,3 +120,4 @@ vagrant/*.log
/app/View/Emails/text/Custom/*
!/app/View/Emails/text/Custom/empty

.vscode/launch.json
27 changes: 27 additions & 0 deletions INSTALL/MYSQL.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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`
--
Expand Down
2 changes: 2 additions & 0 deletions app/Console/Command/AppShell.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
94 changes: 45 additions & 49 deletions app/Controller/AppController.php
Original file line number Diff line number Diff line change
Expand Up @@ -433,17 +433,17 @@ private function __loginByAuthKey()
// 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();
Expand Down Expand Up @@ -557,7 +557,9 @@ private function __verifyUser(array $user)
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();
Expand All @@ -576,8 +578,9 @@ private function __verifyUser(array $user)
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');
}
Expand All @@ -594,8 +597,9 @@ private function __verifyUser(array $user)
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');
}
Expand Down Expand Up @@ -1121,17 +1125,13 @@ private function __customAuthentication($server)
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]);
Expand All @@ -1142,34 +1142,30 @@ private function __customAuthentication($server)
$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'));
Expand Down
7 changes: 7 additions & 0 deletions app/Controller/Component/ACLComponent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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('*'),
Expand Down
166 changes: 166 additions & 0 deletions app/Controller/UserLoginProfilesController.php
Original file line number Diff line number Diff line change
@@ -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;
}

}
Loading

0 comments on commit 7e2cb89

Please sign in to comment.