Merge pull request #9473 from JakubOnderka/logging

chg: [internal] Do not log in audit log last_api_access
pull/9477/head
Jakub Onderka 2024-01-04 16:38:02 +01:00 committed by GitHub
commit edd6d3f157
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 163 additions and 126 deletions

View File

@ -79,7 +79,7 @@ class UserLoginProfilesController extends AppController
'fields' => ['UserLoginProfile.*']
));
if (empty($profile)) {
throw new NotFoundException(__('Invalid UserLoginProfile'));
throw new NotFoundException(__('Invalid user login profile'));
}
if ($this->UserLoginProfile->delete($id)) {
$this->loadModel('Log');
@ -87,13 +87,13 @@ class UserLoginProfilesController extends AppController
$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.');
return $this->RestResponse->saveSuccessResponse('UserLoginProfile', 'admin_delete', $id, $this->response->type(), 'User login profile 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->Flash->error(__('User login profile was not deleted'));
$this->redirect(array('admin'=> false, 'controller' => 'userLoginProfiles', 'action' => 'index', $profile['UserLoginProfile']['user_id']));
}
}
@ -110,7 +110,7 @@ class UserLoginProfilesController extends AppController
{
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->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)
@ -123,9 +123,9 @@ class UserLoginProfilesController extends AppController
'recursive' => -1
));
unset($user['User']['password']);
$this->UserLoginProfile->email_report_malicious($user, $userLoginProfile);
$this->UserLoginProfile->emailReportMalicious($user, $userLoginProfile);
// change account info to force password change, redirect to new password page.
$this->User->id = $this->Auth->user('id');
$this->User->id = $this->Auth->user('id');
$this->User->saveField('change_pw', 1);
$this->redirect(array('controller' => 'users', 'action' => 'change_pw'));
return;
@ -153,14 +153,13 @@ class UserLoginProfilesController extends AppController
$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) {
$exists = $this->UserLoginProfile->hasAny([
'UserLoginProfile.hash' => $data['hash']
]);
if (!$exists) {
// no row yet, save it.
$this->UserLoginProfile->save($data);
}
return $data;
}
}

View File

@ -1332,34 +1332,37 @@ class UsersController extends AppController
private function _postlogin()
{
$this->User->extralog($this->Auth->user(), "login");
$authUser = $this->Auth->user();
$this->User->extralog($authUser, "login");
$this->User->Behaviors->disable('SysLogLogable.SysLogLogable');
$this->User->id = $this->Auth->user('id');
$user = $this->User->find('first', array(
'conditions' => array(
'User.id' => $this->Auth->user('id')
'User.id' => $authUser['id'],
),
'fields' => ['User.id', 'User.current_login', 'User.last_login'],
'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');
$lastUserLogin = $user['User']['last_login'];
if ($lastUserLogin) {
$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));
}
if (Configure::read('Security.alert_on_suspicious_logins')) {
try {
// there are reasons to believe there is evil happening, suspicious. Inform user and (org)admins.
$suspiciousness_reason = $this->User->UserLoginProfile->_isSuspicious();
if ($suspiciousness_reason) {
$suspiciousnessReason = $this->User->UserLoginProfile->_isSuspicious();
if ($suspiciousnessReason) {
// 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);
$this->Log->createLogEntry($authUser, 'auth_alert', 'User', $authUser['id'], 'Suspicious login.', $suspiciousnessReason);
// 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.
// 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);
@ -1367,11 +1370,12 @@ class UsersController extends AppController
// 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);
$this->User->UserLoginProfile->emailNewLogin($authUser);
}
} catch (Exception $e) {
// At first login after code update and before DB schema update we might end up with problems.
// Just catch it cleanly here to prevent problems.
$this->log($e->getMessage(), LOG_WARNING);
}
}
@ -3065,7 +3069,7 @@ class UsersController extends AppController
* @return array
* @throws NotFoundException
*/
private function __adminFetchConditions($id, $edit = True)
private function __adminFetchConditions($id, $edit = true)
{
if (empty($id)) {
throw new NotFoundException(__('Invalid user'));
@ -3076,7 +3080,7 @@ class UsersController extends AppController
if (!$user['Role']['perm_site_admin']) {
$conditions['User.org_id'] = $user['org_id']; // org admin
if ($edit) {
$conditions['Role.perm_site_admin'] = False;
$conditions['Role.perm_site_admin'] = false;
}
}
return $conditions;
@ -3133,69 +3137,65 @@ 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)) {
public function view_login_history($userId = null)
{
if ($userId && $this->_isAdmin()) { // org and site admins
$userExists = $this->User->hasAny($this->__adminFetchConditions($userId));
if (!$userExists) {
throw new NotFoundException(__('Invalid user'));
}
} else {
$user_id = $this->Auth->user('id');
$userId = $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'))
'Log.user_id' => $userId,
'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'),
'order' => array('Log.id DESC'),
'limit' => 100 // relatively high limit, as we'll be grouping data afterwards.
));
$lst = array();
$profiles = [];
$prevProfile = null;
$prevCreatedLast = null;
$prevCreatedFirst = null;
$prevLogEntry = null;
$prevActions = array();
$actions_translator = [
$actionsTranslator = [
'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.
$maxRows = 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 = $this->User->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)) {
if ($this->User->UserLoginProfile->_isSimilar($loginProfile, $prevProfile)) {
// continue find as same type of login
$prevCreatedFirst = $logEntry['Log']['created'];
$prevActions[] = $actions_translator[$logEntry['Log']['action']] ?? $logEntry['Log']['action'];
$prevActions[] = $actionsTranslator[$logEntry['Log']['action']];
} else {
// add as new entry
if (null != $prevProfile) {
if (null !== $prevProfile) {
$actionsString = ''; // count actions
foreach(array_count_values($prevActions) as $action => $cnt) {
foreach (array_count_values($prevActions) as $action => $cnt) {
$actionsString .= $action . ' (' . $cnt . "x) ";
}
$lst[] = array(
'status' => $this->UserLoginProfile->_getTrustStatus($prevProfile, $user_id),
$profiles[] = [
'status' => $this->User->UserLoginProfile->_getTrustStatus($prevProfile, $userId),
'platform' => $prevProfile['ua_platform'],
'browser' => $prevProfile['ua_browser'],
'region' => $prevProfile['geoip'],
@ -3204,40 +3204,47 @@ class UsersController extends AppController
'last_seen' => $prevCreatedLast,
'first_seen' => $prevCreatedFirst,
'actions' => $actionsString,
'actions_button' => ('unknown' == $this->UserLoginProfile->_getTrustStatus($prevProfile, $user_id)) ? true : false,
'id' => $prevLogEntry);
'actions_button' => ('unknown' == $this->User->UserLoginProfile->_getTrustStatus($prevProfile, $userId)) ? true : false,
'id' => $prevLogEntry
];
}
// build new entry
$prevProfile = $loginProfile;
$prevCreatedFirst = $prevCreatedLast = $logEntry['Log']['created'];
$prevActions[] = $actions_translator[$logEntry['Log']['action']] ?? $logEntry['Log']['action'];
$prevActions[] = $actionsTranslator[$logEntry['Log']['action']];
$prevLogEntry = $logEntry['Log']['id'];
$rows += 1;
if ($rows == $max_rows) break;
$rows++;
if ($rows === $maxRows) {
break;
}
}
}
// add last entry
$actionsString = ''; // count actions
foreach(array_count_values($prevActions) as $action => $cnt) {
$actionsString .= $action . ' (' . $cnt . "x) ";
if (null !== $prevProfile) {
$actionsString = ''; // count actions
foreach (array_count_values($prevActions) as $action => $cnt) {
$actionsString .= $action . ' (' . $cnt . "x) ";
}
$profiles[] = array(
'status' => $this->User->UserLoginProfile->_getTrustStatus($prevProfile, $userId),
'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->User->UserLoginProfile->_getTrustStatus($prevProfile, $userId)) ? true : false,
'id' => $prevLogEntry
);
}
$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);
$this->set('data', $profiles);
$this->set('user_id', $userId);
}
public function logout401() {
public function logout401()
{
# You should read the documentation in docs/CONFIG.ApacheSecureAuth.md
# before using this endpoint. It is not useful without webserver config
# changes.
@ -3262,16 +3269,9 @@ class UsersController extends AppController
if (empty($this->request->data['User']['email'])) {
throw new MethodNotAllowedException(__('No email provided, cannot generate password reset message.'));
}
$user = [
'id' => 0,
'email' => 'SYSTEM',
'Organisation' => [
'name' => 'SYSTEM'
]
];
$this->loadModel('Log');
$this->Log->createLogEntry($user, 'forgot', 'User', 0, 'Password reset requested for: ' . $this->request->data['User']['email']);
$this->User->forgotRouter($this->request->data['User']['email'], $this->_remoteIp());
$this->Log->createLogEntry('SYSTEM', 'forgot', 'User', 0, 'Password reset requested for: ' . $this->request->data['User']['email']);
$this->User->forgotRouter($this->request->data['User']['email'], $this->User->_remoteIp());
$message = __('Password reset request submitted. If a valid user is found, you should receive an e-mail with a temporary reset link momentarily. Please be advised that this link is only valid for 10 minutes.');
if ($this->_isRest()) {
return $this->RestResponse->saveSuccessResponse('User', 'forgot', false, $this->response->type(), $message);
@ -3308,5 +3308,4 @@ class UsersController extends AppController
return $this->__pw_change(['User' => $user], 'password_reset', $abortPost, $token, true);
}
}
}

View File

@ -68,6 +68,13 @@ class SecurityAudit
];
}
if (!Configure::read('Security.alert_on_suspicious_logins')) {
$output['Login'][] = [
'warning',
__('Warning about suspicious logins is disabled. You can enable alert by setting `Security.alert_on_suspicious_logins` to `true`.'),
];
}
if (empty(Configure::read('Security.disable_browser_cache'))) {
$output['Browser'][] = [
'warning',

View File

@ -25,6 +25,7 @@ class AuditLogBehavior extends ModelBehavior
'date_modified' => true, // User
'current_login' => true, // User
'last_login' => true, // User
'last_api_access' => true, // User
'newsread' => true, // User
'unique_ips' => true, // User
'proposal_email_lock' => true, // Event

View File

@ -3,16 +3,30 @@ App::uses('AppModel', 'Model');
class Log extends AppModel
{
const WARNING_ACTIONS = array(
const WARNING_ACTIONS = [
'warning',
'change_pw',
'login_fail',
'version_warning',
'auth_fail'
);
const ERROR_ACTIONS = array(
];
const ERROR_ACTIONS = [
'error'
);
];
const AUTH_ACTIONS = [
'auth',
'auth_fail',
'auth_alert',
'change_pw',
'login',
'login_fail',
'logout',
'password_reset',
'forgot',
];
public $validate = array(
'action' => array(
'rule' => array(

View File

@ -1032,18 +1032,18 @@ class User extends AppModel
}
/**
* @param int $org_id
* @param int $orgId
* @param int|false $excludeUserId
* @return array
* @return array User ID => Email
*/
public function getOrgAdminsForOrg($org_id, $excludeUserId = false)
public function getOrgAdminsForOrg($orgId, $excludeUserId = false)
{
$adminRoles = $this->Role->find('column', array(
'conditions' => array('perm_admin' => 1),
'fields' => array('Role.id')
));
$conditions = array(
'User.org_id' => $org_id,
'User.org_id' => $orgId,
'User.disabled' => 0,
'User.role_id' => $adminRoles
);
@ -1059,7 +1059,12 @@ class User extends AppModel
));
}
public function getSiteAdmins($excludeUserId = false) {
/**
* @param int|false $excludeUserId
* @return array User ID => Email
*/
public function getSiteAdmins($excludeUserId = false)
{
$adminRoles = $this->Role->find('column', array(
'conditions' => array('perm_site_admin' => 1),
'fields' => array('Role.id')

View File

@ -14,7 +14,6 @@ class UserLoginProfile extends AppModel
'userKey' => 'user_id',
'change' => 'full'
),
'Containable'
);
public $validate = [
@ -61,7 +60,7 @@ class UserLoginProfile extends AppModel
return true;
}
public function hash($data)
public function hash(array $data)
{
unset($data['hash']);
unset($data['created_at']);
@ -126,15 +125,24 @@ class UserLoginProfile extends AppModel
return $this->userProfile;
}
public function _fromLog($logEntry)
/**
* @param array $logEntry
* @return array|false|string[]
* @throws JsonException
*/
public function _fromLog(array $logEntry)
{
if (!$logEntry['change']) {
return false;
}
$data = ["user_agent" => "", "ip" => "", "accept_lang" => "", "geoip" => "", "ua_pattern" => "", "ua_platform" => "", "ua_browser" => ""];
$data = array_merge($data, JsonTool::decode($logEntry['change']) ?? []);
$data['ip'] = $logEntry['ip'];
$data['timestamp'] = $logEntry['created'];
$data = array_merge($data, JsonTool::decode($logEntry['change']));
if ($data['user_agent'] === "") {
return false;
}
$data['ip'] = $logEntry['ip'];
$data['timestamp'] = $logEntry['created'];
return $data;
}
@ -174,6 +182,11 @@ class UserLoginProfile extends AppModel
return false;
}
/**
* @param array $userProfileToCheck
* @param int $userId
* @return mixed|string
*/
public function _getTrustStatus(array $userProfileToCheck, $userId = null)
{
if (!$userId) {
@ -183,7 +196,7 @@ class UserLoginProfile extends AppModel
if (!isset($this->knownUserProfiles[$userId])) {
$this->knownUserProfiles[$userId] = $this->find('all', [
'conditions' => ['UserLoginProfile.user_id' => $userId],
'recursive' => 0
'recursive' => -1,
]);
}
// perform check on all entries, and stop when check OK
@ -215,15 +228,12 @@ class UserLoginProfile extends AppModel
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.*')]
);
$maliciousWithSameIP = $this->hasAny([
'UserLoginProfile.ip' => $this->_getUserProfile()['ip'],
'UserLoginProfile.status' => 'malicious'
]);
if ($maliciousWithSameIP) {
return __('The source IP was reported as as malicious by a user.');
}
@ -234,7 +244,7 @@ class UserLoginProfile extends AppModel
return false;
}
public function email_newlogin($user)
public function emailNewLogin(array $user)
{
if (!Configure::read('MISP.disable_emailing')) {
$date_time = date('c');
@ -249,7 +259,7 @@ class UserLoginProfile extends AppModel
}
}
public function email_report_malicious($user, $userLoginProfile)
public function emailReportMalicious(array $user, array $userLoginProfile)
{
// inform the org admin
$date_time = $userLoginProfile['timestamp']; // LATER not ideal as timestamp is string without timezone info
@ -259,19 +269,22 @@ class UserLoginProfile extends AppModel
$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) {
$orgAdmins = array_keys($this->User->getOrgAdminsForOrg($user['User']['org_id']));
$admins = array_keys($this->User->getSiteAdmins());
$allAdmins = array_unique(array_merge($orgAdmins, $admins));
$subject = __("[%s MISP] Suspicious login reported.", Configure::read('MISP.org'));
foreach ($allAdmins as $adminUserId) {
$admin = $this->User->find('first', array(
'recursive' => -1,
'conditions' => ['User.email' => $admin_email]
'conditions' => ['User.id' => $adminUserId]
));
$this->User->sendEmail($admin, $body, false, "[" . Configure::read('MISP.org') . " MISP] Suspicious login reported.");
$this->User->sendEmail($admin, $body, false, $subject);
}
}
public function email_suspicious($user, $suspiciousness_reason)
public function email_suspicious(array $user, $suspiciousness_reason)
{
if (!Configure::read('MISP.disable_emailing')) {
$date_time = date('c');
@ -295,11 +308,11 @@ class UserLoginProfile extends AppModel
$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) {
$orgAdmins = array_keys($this->User->getOrgAdminsForOrg($user['User']['org_id']));
foreach ($orgAdmins as $orgAdminID) {
$org_admin = $this->User->find('first', array(
'recursive' => -1,
'conditions' => ['User.email' => $org_admin_email]
'conditions' => ['User.id' => $orgAdminID]
));
$this->User->sendEmail($org_admin, $body, false, "[" . Configure::read('MISP.org') . " MISP] Suspicious login detected.");
}

View File

@ -89,7 +89,7 @@ class EcsLog implements CakeLogInterface
'message' => $message,
];
if (in_array($action, ['auth', 'auth_fail', 'auth_alert', 'change_pw', 'login', 'login_fail', 'logout', 'password_reset'], true)) {
if (in_array($action, Log::AUTH_ACTIONS, true)) {
$message['event']['category'] = 'authentication';
if (in_array($action, ['auth_fail', 'login_fail'], true)) {
@ -338,8 +338,8 @@ class EcsLog implements CakeLogInterface
];
}
}
} else {
} else if (session_status() === PHP_SESSION_ACTIVE) {
// include session data just when session is active to avoid unnecessary session starting
App::uses('AuthComponent', 'Controller/Component');
$authUser = AuthComponent::user();
if (!empty($authUser)) {

View File

@ -25,7 +25,7 @@ if (!function_exists('str_contains')) {
}
}
foreach($data as $entry ){
foreach ($data as $entry) {
$platform = h(strtolower($entry['platform']));
if (str_contains($platform, 'win')) $platform = 'windows';
if (str_contains($platform, 'macosx')) $platform = 'apple';
@ -34,7 +34,7 @@ foreach($data as $entry ){
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 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>
@ -79,5 +79,4 @@ echo '</div>';
if (!$this->request->is('ajax')) {
echo $this->element('/genericElements/SideMenu/side_menu', array('menuList' => 'globalActions', 'menuItem' => 'view'));
}