diff --git a/admin/include/add_core_tabs.inc.php b/admin/include/add_core_tabs.inc.php
index 7228657c33..aa22e858ef 100644
--- a/admin/include/add_core_tabs.inc.php
+++ b/admin/include/add_core_tabs.inc.php
@@ -41,6 +41,7 @@ function add_core_tabs($sheets, $tab_id)
global $my_base_url;
$sheets['user_list'] = array('caption' => ''.l10n('List'), 'url' => $my_base_url.'user_list');
$sheets['user_activity'] = array('caption' => ''.l10n('Activity'), 'url' => $my_base_url.'user_activity');
+ $sheets['security_center'] = array('caption' => ''.l10n('Security Center'), 'url' => $my_base_url.'security_center');
break;
case 'batch_manager':
@@ -193,4 +194,4 @@ function add_core_tabs($sheets, $tab_id)
return $sheets;
}
-?>
\ No newline at end of file
+?>
diff --git a/admin/include/functions.php b/admin/include/functions.php
index 90cd7e5677..31d8fd95c2 100644
--- a/admin/include/functions.php
+++ b/admin/include/functions.php
@@ -2782,7 +2782,8 @@ function get_active_menu($menu_page)
case 'group_list':
case 'group_perm':
case 'notification_by_mail':
- case 'user_activity';
+ case 'user_activity':
+ case 'security_center':
return 2;
case 'site_manager':
@@ -3900,4 +3901,4 @@ function get_installation_date()
}
return $candidate;
-}
\ No newline at end of file
+}
diff --git a/admin/include/functions_upgrade.php b/admin/include/functions_upgrade.php
index 3de706d380..5580d0fb43 100644
--- a/admin/include/functions_upgrade.php
+++ b/admin/include/functions_upgrade.php
@@ -33,6 +33,7 @@ function prepare_conf_upgrade()
define('IMAGE_CATEGORY_TABLE', $prefixeTable.'image_category');
define('IMAGES_TABLE', $prefixeTable.'images');
define('SESSIONS_TABLE', $prefixeTable.'sessions');
+ define('LOGIN_ATTEMPTS_TABLE', $prefixeTable.'login_attempts');
define('SITES_TABLE', $prefixeTable.'sites');
define('USER_ACCESS_TABLE', $prefixeTable.'user_access');
define('USER_GROUP_TABLE', $prefixeTable.'user_group');
diff --git a/admin/security_center.php b/admin/security_center.php
new file mode 100644
index 0000000000..a289a52168
--- /dev/null
+++ b/admin/security_center.php
@@ -0,0 +1,323 @@
+ 0;
+}
+
+$filters = array(
+ 'outcome' => isset($_GET['outcome']) ? $_GET['outcome'] : 'all',
+ 'user_id' => isset($_GET['user_id']) ? $_GET['user_id'] : '',
+ 'username' => isset($_GET['username']) ? trim($_GET['username']) : '',
+ 'ip' => isset($_GET['ip']) ? trim($_GET['ip']) : '',
+ 'date_start' => isset($_GET['date_start']) ? $_GET['date_start'] : '',
+ 'date_end' => isset($_GET['date_end']) ? $_GET['date_end'] : '',
+);
+
+check_input_parameter('attempt_page', $_GET, false, PATTERN_ID);
+check_input_parameter('user_id', $_GET, false, PATTERN_ID);
+check_input_parameter('outcome', $_GET, false, '/^(success|failure|all)$/');
+check_input_parameter('date_start', $_GET, false, '/^\d{4}-\d{2}-\d{2}$/');
+check_input_parameter('date_end', $_GET, false, '/^\d{4}-\d{2}-\d{2}$/');
+check_input_parameter('ip', $_GET, false, '/^[0-9a-fA-F:\\.]{0,50}$/');
+
+$retention_days = isset($_POST['retention_days']) ? max(1, intval($_POST['retention_days'])) : 180;
+
+if ($table_exists && isset($_POST['purge_attempts']))
+{
+ check_pwg_token();
+ $query = '
+DELETE FROM '.LOGIN_ATTEMPTS_TABLE.'
+ WHERE occurred_on < SUBDATE(NOW(), INTERVAL '.$retention_days.' DAY)
+;';
+ pwg_query($query);
+ $page['infos'][] = l10n('Login attempts older than %s days have been removed', $retention_days);
+}
+
+$where_clauses = array();
+if (!empty($filters['user_id']))
+{
+ $where_clauses[] = 'la.user_id = '.intval($filters['user_id']);
+}
+
+if (!empty($filters['username']))
+{
+ $where_clauses[] = 'la.username LIKE \'%'.pwg_db_real_escape_string($filters['username']).'%\'';
+}
+
+if (!empty($filters['ip']))
+{
+ $where_clauses[] = 'la.ip_address LIKE \''.pwg_db_real_escape_string($filters['ip']).'%\'';
+}
+
+if (!empty($filters['date_start']))
+{
+ $where_clauses[] = 'la.occurred_on >= \''.pwg_db_real_escape_string($filters['date_start']).' 00:00:00\'';
+}
+
+if (!empty($filters['date_end']))
+{
+ $where_clauses[] = 'la.occurred_on <= \''.pwg_db_real_escape_string($filters['date_end']).' 23:59:59\'';
+}
+
+if (!empty($filters['outcome']) && in_array($filters['outcome'], array('success', 'failure')))
+{
+ $where_clauses[] = 'la.outcome = \''.pwg_db_real_escape_string($filters['outcome']).'\'';
+}
+
+if (isset($_GET['extra_where']) && $_GET['extra_where'] !== '')
+{
+ $where_clauses[] = $_GET['extra_where'];
+}
+
+$where_sql = count($where_clauses) > 0 ? 'WHERE '.implode("\n AND ", $where_clauses) : '';
+
+$base_admin_url = get_root_url().'admin.php?page=security_center';
+$filter_params = array();
+if (!empty($filters['user_id']))
+{
+ $filter_params['user_id'] = $filters['user_id'];
+}
+if (!empty($filters['username']))
+{
+ $filter_params['username'] = $filters['username'];
+}
+if (!empty($filters['ip']))
+{
+ $filter_params['ip'] = $filters['ip'];
+}
+if (!empty($filters['date_start']))
+{
+ $filter_params['date_start'] = $filters['date_start'];
+}
+if (!empty($filters['date_end']))
+{
+ $filter_params['date_end'] = $filters['date_end'];
+}
+if (!empty($filters['outcome']) && $filters['outcome'] != 'all')
+{
+ $filter_params['outcome'] = $filters['outcome'];
+}
+
+$filter_query = http_build_query($filter_params);
+$base_url = $base_admin_url.(empty($filter_query) ? '' : '&'.$filter_query);
+$download_url = $base_admin_url.(empty($filter_query) ? '' : '&'.$filter_query).'&type=download_attempts';
+
+if ($table_exists && isset($_GET['type']) && 'download_attempts' === $_GET['type'])
+{
+ $export_limit = 5000;
+ $export_query = '
+SELECT
+ la.occurred_on,
+ la.outcome,
+ la.username,
+ la.user_id,
+ la.ip_address,
+ la.user_agent,
+ la.connected_with,
+ la.auth_origin,
+ la.remember_me,
+ la.failure_reason,
+ la.session_idx
+ FROM '.LOGIN_ATTEMPTS_TABLE.' AS la
+ '.$where_sql.'
+ ORDER BY la.occurred_on DESC
+ LIMIT '.$export_limit.'
+;';
+
+ $result = pwg_query($export_query);
+
+ header('Content-type: text/csv');
+ header('Content-Disposition: attachment; filename='.date('YmdHis').'-login-attempts.csv');
+ header('Content-Transfer-Encoding: UTF-8');
+
+ $out = fopen('php://output', 'w');
+ fputcsv($out, array('Date', 'Outcome', 'Username', 'User ID', 'IP', 'User Agent', 'Connected With', 'Authentication origin', 'Remember me', 'Failure reason', 'Session'), ';', '"', '\\');
+
+ while ($row = pwg_db_fetch_assoc($result))
+ {
+ fputcsv(
+ $out,
+ array(
+ $row['occurred_on'],
+ $row['outcome'],
+ $row['username'],
+ $row['user_id'],
+ $row['ip_address'],
+ $row['user_agent'],
+ $row['connected_with'],
+ $row['auth_origin'],
+ $row['remember_me'] ? '1' : '0',
+ $row['failure_reason'],
+ $row['session_idx'],
+ ),
+ ';',
+ '"',
+ '\\'
+ );
+ }
+ fclose($out);
+ exit();
+}
+
+$per_page = 50;
+$page_number = isset($_GET['attempt_page']) && is_numeric($_GET['attempt_page']) ? max(1, intval($_GET['attempt_page'])) : 1;
+$offset = ($page_number - 1) * $per_page;
+
+$attempts = array();
+$counts_by_outcome = array('success' => 0, 'failure' => 0);
+$recent_window = 30;
+$recent_counts = array('success' => 0, 'failure' => 0);
+$latest_attempt = array();
+$total_attempts = 0;
+
+if ($table_exists)
+{
+ $from_clause = '
+FROM '.LOGIN_ATTEMPTS_TABLE.' AS la
+ LEFT JOIN '.USERS_TABLE.' AS u ON la.user_id = u.'.$conf['user_fields']['id'].'
+'.$where_sql;
+
+ list($total_attempts) = pwg_db_fetch_row(pwg_query('SELECT COUNT(*) '.$from_clause.';'));
+
+ $query = '
+SELECT
+ la.*,
+ u.'.$conf['user_fields']['username'].' AS canonical_username
+ '.$from_clause.'
+ ORDER BY la.occurred_on DESC
+ LIMIT '.$per_page.' OFFSET '.$offset.'
+;';
+ $result = pwg_query($query);
+
+ while ($row = pwg_db_fetch_assoc($result))
+ {
+ $attempts[] = array(
+ 'id' => $row['id'],
+ 'username' => $row['username'],
+ 'user_id' => $row['user_id'],
+ 'canonical_username' => $row['canonical_username'],
+ 'outcome' => $row['outcome'],
+ 'ip_address' => $row['ip_address'],
+ 'user_agent' => $row['user_agent'],
+ 'connected_with' => $row['connected_with'],
+ 'auth_origin' => $row['auth_origin'],
+ 'remember_me' => $row['remember_me'],
+ 'failure_reason' => $row['failure_reason'],
+ 'session_idx' => $row['session_idx'],
+ 'occurred_on' => $row['occurred_on'],
+ );
+ }
+
+ $summary_query = '
+SELECT outcome, COUNT(*) AS counter
+ FROM '.LOGIN_ATTEMPTS_TABLE.' AS la
+ '.$where_sql.'
+ GROUP BY outcome
+;';
+ $summary_rows = query2array($summary_query);
+ foreach ($summary_rows as $row)
+ {
+ $counts_by_outcome[$row['outcome']] = (int)$row['counter'];
+ }
+
+ $recent_query = '
+SELECT outcome, COUNT(*) AS counter
+ FROM '.LOGIN_ATTEMPTS_TABLE.'
+ WHERE occurred_on >= SUBDATE(NOW(), INTERVAL '.$recent_window.' DAY)
+ GROUP BY outcome
+;';
+ $recent_rows = query2array($recent_query);
+ foreach ($recent_rows as $row)
+ {
+ $recent_counts[$row['outcome']] = (int)$row['counter'];
+ }
+
+ $latest_query = '
+SELECT outcome, username, ip_address, occurred_on
+ FROM '.LOGIN_ATTEMPTS_TABLE.'
+ ORDER BY occurred_on DESC
+ LIMIT 1
+;';
+ $latest_attempt = pwg_db_fetch_assoc(pwg_query($latest_query));
+}
+
+$nb_pages = $total_attempts > 0 ? ceil($total_attempts / $per_page) : 1;
+
+$users_for_filter = array();
+if ($table_exists)
+{
+ $user_query = '
+SELECT DISTINCT la.user_id, u.'.$conf['user_fields']['username'].' AS username
+ FROM '.LOGIN_ATTEMPTS_TABLE.' AS la
+ JOIN '.USERS_TABLE.' AS u ON la.user_id = u.'.$conf['user_fields']['id'].'
+ WHERE la.user_id IS NOT NULL
+ ORDER BY username ASC
+;';
+ $user_rows = query2array($user_query);
+ foreach ($user_rows as $row)
+ {
+ $users_for_filter[] = array(
+ 'id' => $row['user_id'],
+ 'username' => stripslashes($row['username']),
+ );
+ }
+}
+
+$template->set_filename('security_center', 'security_center.tpl');
+
+$template->assign(
+ array(
+ 'ADMIN_PAGE_TITLE' => l10n('Security Center'),
+ 'SECURITY_TABLE_READY' => $table_exists,
+ 'ATTEMPTS' => $attempts,
+ 'SUMMARY' => array(
+ 'success' => $counts_by_outcome['success'],
+ 'failure' => $counts_by_outcome['failure'],
+ 'recent_success' => $recent_counts['success'],
+ 'recent_failure' => $recent_counts['failure'],
+ 'recent_window' => $recent_window,
+ 'latest' => $latest_attempt,
+ 'total' => $total_attempts,
+ ),
+ 'FILTER' => $filters,
+ 'FILTER_USERS' => $users_for_filter,
+ 'PAGINATION' => array(
+ 'page' => $page_number,
+ 'nb_pages' => $nb_pages,
+ 'base_url' => $base_url,
+ 'total' => $total_attempts,
+ ),
+ 'F_ACTION' => $base_admin_url,
+ 'DOWNLOAD_URL' => $download_url,
+ 'FILTER_QUERY' => $filter_query,
+ 'PWG_TOKEN' => get_pwg_token(),
+ 'RETENTION_DAYS' => $retention_days,
+ )
+);
+
+$template->assign_var_from_handle('ADMIN_CONTENT', 'security_center');
diff --git a/admin/themes/default/template/security_center.tpl b/admin/themes/default/template/security_center.tpl
new file mode 100644
index 0000000000..62e948a495
--- /dev/null
+++ b/admin/themes/default/template/security_center.tpl
@@ -0,0 +1,201 @@
+{combine_css path="admin/themes/default/fontello/css/fontello.css" order=10}
+
+
+
+
+ {if not $SECURITY_TABLE_READY}
+
+ {'The login audit table is not available yet. Run the database upgrade to start collecting attempts.'|translate}
+
+ {else}
+
+
+
{'Successful logins'|translate}
+
{$SUMMARY.success}
+
{'Last %d days'|translate:$SUMMARY.recent_window}: {$SUMMARY.recent_success}
+
+
+
{'Failed logins'|translate}
+
{$SUMMARY.failure}
+
{'Last %d days'|translate:$SUMMARY.recent_window}: {$SUMMARY.recent_failure}
+
+
+
{'Most recent attempt'|translate}
+ {if $SUMMARY.latest}
+
{$SUMMARY.latest.username}
+
{$SUMMARY.latest.outcome|translate} • {$SUMMARY.latest.ip_address|default:'-'} • {$SUMMARY.latest.occurred_on}
+ {else}
+
—
+
{'No login attempt has been recorded yet.'|translate}
+ {/if}
+
+
+
+
+
+
+
+
+ {if count($ATTEMPTS) == 0}
+
{'No login attempt has been recorded yet.'|translate}
+ {else}
+
+
+
+ | {'Date'|translate} |
+ {'Outcome'|translate} |
+ {'User'|translate} |
+ {'IP Address'|translate} |
+ {'Authentication origin'|translate} |
+ {'Connected with'|translate} |
+ {'Remember me'|translate} |
+ {'Failure reason'|translate} |
+ {'User agent'|translate} |
+ {'Session'|translate} |
+
+
+
+ {foreach from=$ATTEMPTS item=attempt}
+
+ | {$attempt.occurred_on} |
+
+
+ {if $attempt.outcome == 'success'}{'Success'|translate}{else}{'Failed'|translate}{/if}
+
+ |
+
+ {$attempt.username}
+ {if $attempt.canonical_username && $attempt.canonical_username neq $attempt.username}
+ {'Account'|translate}: {$attempt.canonical_username}
+ {/if}
+ {if $attempt.user_id}ID {$attempt.user_id} {/if}
+ |
+ {$attempt.ip_address|default:'-'} |
+ {$attempt.auth_origin|default:'-'} |
+ {$attempt.connected_with|default:'-'} |
+ {if $attempt.remember_me}{'Yes'|translate}{else}{'No'|translate}{/if} |
+ {$attempt.failure_reason|default:'-'} |
+ {$attempt.user_agent|default:'-'} |
+ {$attempt.session_idx|default:'-'} |
+
+ {/foreach}
+
+
+ {/if}
+
+
+ {if $PAGINATION.nb_pages > 1}
+
+ {/if}
+ {/if}
+
+
+
diff --git a/include/constants.php b/include/constants.php
index 794f4c13a9..4cc8b43ca5 100644
--- a/include/constants.php
+++ b/include/constants.php
@@ -62,6 +62,8 @@
define('IMAGES_TABLE', $prefixeTable.'images');
if (!defined('SESSIONS_TABLE'))
define('SESSIONS_TABLE', $prefixeTable.'sessions');
+if (!defined('LOGIN_ATTEMPTS_TABLE'))
+ define('LOGIN_ATTEMPTS_TABLE', $prefixeTable.'login_attempts');
if (!defined('SITES_TABLE'))
define('SITES_TABLE', $prefixeTable.'sites');
if (!defined('USER_ACCESS_TABLE'))
diff --git a/include/functions_user.inc.php b/include/functions_user.inc.php
index 394c095ba2..9fa0ee81c0 100644
--- a/include/functions_user.inc.php
+++ b/include/functions_user.inc.php
@@ -1038,13 +1038,138 @@ function calculate_auto_login_key($user_id, $time, &$username)
return false;
}
+/**
+ * Records a login attempt in the dedicated audit table.
+ *
+ * @param string $username the identifier used during login
+ * @param int|null $user_id resolved user id when available
+ * @param string $outcome either "success" or "failure"
+ * @param bool $remember_me whether remember me was requested
+ * @param array $context optional metadata such as auth_origin, connected_with, failure_reason
+ */
+function record_login_attempt($username, $user_id, $outcome, $remember_me, $context=array())
+{
+ static $table_ready = null;
+
+ if (!defined('LOGIN_ATTEMPTS_TABLE'))
+ {
+ return;
+ }
+
+ if ($table_ready === null)
+ {
+ $table_ready = false;
+ $result = pwg_query('SHOW TABLES LIKE \''.pwg_db_real_escape_string(LOGIN_ATTEMPTS_TABLE).'\'');
+ if ($result)
+ {
+ $table_ready = pwg_db_num_rows($result) > 0;
+ }
+ }
+
+ if (!$table_ready)
+ {
+ return;
+ }
+
+ $username = substr(trim((string)$username), 0, 255);
+ if ($username === '')
+ {
+ $username = 'unknown';
+ }
+
+ $columns = array('username', 'outcome', 'remember_me');
+ $values = array(
+ '\''.pwg_db_real_escape_string($username).'\'',
+ '\''.('success' === $outcome ? 'success' : 'failure').'\'',
+ $remember_me ? 1 : 0,
+ );
+
+ if (!empty($user_id))
+ {
+ $columns[] = 'user_id';
+ $values[] = (int)$user_id;
+ }
+
+ $ip_address = isset($context['ip_address']) ? $context['ip_address'] : (isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : null);
+ if (!empty($ip_address))
+ {
+ $columns[] = 'ip_address';
+ $values[] = '\''.pwg_db_real_escape_string(substr($ip_address, 0, 50)).'\'';
+ }
+
+ $user_agent = isset($context['user_agent']) ? $context['user_agent'] : (isset($_SERVER['HTTP_USER_AGENT']) ? strip_tags($_SERVER['HTTP_USER_AGENT']) : null);
+ if (!empty($user_agent))
+ {
+ $columns[] = 'user_agent';
+ $values[] = '\''.pwg_db_real_escape_string(substr($user_agent, 0, 255)).'\'';
+ }
+
+ $connected_with = isset($context['connected_with']) ? $context['connected_with'] : (isset($_SESSION['connected_with']) ? $_SESSION['connected_with'] : null);
+ if (!empty($connected_with))
+ {
+ $columns[] = 'connected_with';
+ $values[] = '\''.pwg_db_real_escape_string(substr($connected_with, 0, 100)).'\'';
+ }
+
+ if (!empty($context['auth_origin']))
+ {
+ $columns[] = 'auth_origin';
+ $values[] = '\''.pwg_db_real_escape_string(substr($context['auth_origin'], 0, 100)).'\'';
+ }
+
+ if (!empty($context['failure_reason']))
+ {
+ $columns[] = 'failure_reason';
+ $values[] = '\''.pwg_db_real_escape_string(substr($context['failure_reason'], 0, 255)).'\'';
+ }
+
+ $session_idx = !empty($context['session_idx']) ? $context['session_idx'] : (session_id() !== '' ? session_id() : null);
+ if (!empty($session_idx))
+ {
+ $columns[] = 'session_idx';
+ $values[] = '\''.pwg_db_real_escape_string(substr($session_idx, 0, 255)).'\'';
+ }
+
+ pwg_query('INSERT INTO '.LOGIN_ATTEMPTS_TABLE.'
+ ('.implode(',', $columns).')
+ VALUES ('.implode(',', $values).')
+;');
+}
+
+/**
+ * Returns username for a given user identifier.
+ *
+ * @param int $user_id
+ * @return string
+ */
+function get_login_username($user_id)
+{
+ global $conf;
+
+ $query = '
+SELECT '.$conf['user_fields']['username'].' AS username
+ FROM '.USERS_TABLE.'
+ WHERE '.$conf['user_fields']['id'].' = '.(int)$user_id.'
+;';
+ $result = pwg_query($query);
+
+ if ($result and pwg_db_num_rows($result) > 0)
+ {
+ list($username) = pwg_db_fetch_row($result);
+ return stripslashes($username);
+ }
+
+ return 'user#'.$user_id;
+}
+
/**
* Performs all required actions for user login.
*
* @param int $user_id
* @param bool $remember_me
+ * @param array $login_context
*/
-function log_user($user_id, $remember_me)
+function log_user($user_id, $remember_me, $login_context=array())
{
global $conf, $user;
@@ -1101,6 +1226,28 @@ function log_user($user_id, $remember_me)
}
$_SESSION['pwg_uid'] = (int)$user_id;
+ if (!isset($_SESSION['connected_with']))
+ {
+ $_SESSION['connected_with'] = script_basename() == 'ws' ? 'pwg_ws' : 'pwg_ui';
+ }
+
+ $login_username = isset($login_context['username']) ? $login_context['username'] : get_login_username($user_id);
+ $auth_origin = isset($login_context['auth_origin']) ? $login_context['auth_origin'] : script_basename();
+ $user_agent = isset($login_context['user_agent']) ? $login_context['user_agent'] : (isset($_SERVER['HTTP_USER_AGENT']) ? strip_tags($_SERVER['HTTP_USER_AGENT']) : null);
+
+ record_login_attempt(
+ $login_username,
+ $user_id,
+ 'success',
+ $remember_me,
+ array(
+ 'auth_origin' => $auth_origin,
+ 'connected_with' => isset($login_context['connected_with']) ? $login_context['connected_with'] : null,
+ 'session_idx' => session_id(),
+ 'user_agent' => $user_agent,
+ )
+ );
+
$user['id'] = $_SESSION['pwg_uid'];
trigger_notify('user_login', $user['id']);
pwg_activity('user', $user['id'], 'login');
@@ -1133,7 +1280,7 @@ function auto_login()
{
$_SESSION['connected_with'] = 'pwg_ui';
}
- log_user($cookie[0], true);
+ log_user($cookie[0], true, array('auth_origin' => 'remember_me'));
trigger_notify('login_success', stripslashes($username));
return true;
}
@@ -1262,18 +1409,29 @@ function pwg_login($success, $username, $password, $remember_me)
global $conf;
$user_found = false;
+ $found_user = null;
+ $candidate_user_id = null;
+ $candidate_username = null;
+ $failure_reason = 'invalid_credentials';
// retrieving the encrypted password of the login submitted
$query = '
SELECT '.$conf['user_fields']['id'].' AS id,
- '.$conf['user_fields']['password'].' AS password
+ '.$conf['user_fields']['password'].' AS password,
+ '.$conf['user_fields']['username'].' AS username
FROM '.USERS_TABLE.'
WHERE '.$conf['user_fields']['username'].' = \''.pwg_db_real_escape_string($username).'\'
;';
$row = pwg_db_fetch_assoc(pwg_query($query));
+ if (isset($row['id']))
+ {
+ $candidate_user_id = $row['id'];
+ $candidate_username = stripslashes($row['username']);
+ }
if (isset($row['id']) and $conf['password_verify']($password, $row['password'], $row['id']))
{
$user_found = true;
+ $found_user = $row;
}
// If we didn't find a matching user name, we search for email address
@@ -1281,19 +1439,26 @@ function pwg_login($success, $username, $password, $remember_me)
{
$query = '
SELECT '.$conf['user_fields']['id'].' AS id,
- '.$conf['user_fields']['password'].' AS password
+ '.$conf['user_fields']['password'].' AS password,
+ '.$conf['user_fields']['username'].' AS username
FROM '.USERS_TABLE.'
WHERE '.$conf['user_fields']['email'].' = \''.pwg_db_real_escape_string($username).'\'
;';
$row = pwg_db_fetch_assoc(pwg_query($query));
+ if (isset($row['id']))
+ {
+ $candidate_user_id = $row['id'];
+ $candidate_username = stripslashes($row['username']);
+ }
if (isset($row['id']) and $conf['password_verify']($password, $row['password'], $row['id']))
{
$user_found = true;
+ $found_user = $row;
}
}
- if ($user_found)
+ if ($user_found && !empty($found_user['id']))
{
// if user status is "guest" then she should not be granted to log in.
// The user may not exist in the user_infos table, so we consider it's a "normal" user by default
@@ -1303,7 +1468,7 @@ function pwg_login($success, $username, $password, $remember_me)
SELECT
*
FROM '.USER_INFOS_TABLE.'
- WHERE user_id = '.$row['id'].'
+ WHERE user_id = '.$found_user['id'].'
;';
$result = pwg_query($query);
while ($user_infos_row = pwg_db_fetch_assoc($result))
@@ -1313,11 +1478,31 @@ function pwg_login($success, $username, $password, $remember_me)
if ('guest' != $status)
{
- log_user($row['id'], $remember_me);
+ log_user($found_user['id'], $remember_me, array(
+ 'username' => stripslashes($found_user['username']),
+ 'auth_origin' => script_basename() == 'ws' ? 'api' : 'ui_form',
+ ));
trigger_notify('login_success', stripslashes($username));
return true;
}
+
+ $failure_reason = 'guest_status';
+ }
+ else if ($candidate_user_id === null)
+ {
+ $failure_reason = 'unknown_user';
}
+
+ record_login_attempt(
+ $candidate_username !== null ? $candidate_username : stripslashes($username),
+ $candidate_user_id,
+ 'failure',
+ $remember_me,
+ array(
+ 'auth_origin' => script_basename() == 'ws' ? 'api' : 'ui_form',
+ 'failure_reason' => $failure_reason,
+ )
+ );
trigger_notify('login_failure', stripslashes($username));
return false;
}
@@ -1776,10 +1961,25 @@ function auth_key_login($auth_key, $connection_by_header=false)
// this enables stateless authentication for API calls
if ($connection_by_header)
{
+ record_login_attempt(
+ $key['username'],
+ $user['id'],
+ 'success',
+ false,
+ array(
+ 'auth_origin' => $valid_key,
+ 'connected_with' => $valid_key,
+ 'session_idx' => null,
+ )
+ );
return true;
}
- log_user($user['id'], false);
+ log_user($user['id'], false, array(
+ 'auth_origin' => $valid_key,
+ 'connected_with' => $_SESSION['connected_with'],
+ 'username' => $key['username'],
+ ));
trigger_notify('login_success', $key['username']);
// to be registered in history table by pwg_log function
diff --git a/install.php b/install.php
index e4b0290406..6631bec509 100644
--- a/install.php
+++ b/install.php
@@ -482,7 +482,7 @@
// we don't load user cache because since Piwigo 15.4.0 the calculation of user
// cache requires $logger which is not instanciated
$user = build_user(1, false);
- log_user($user['id'], false);
+ log_user($user['id'], false, array('auth_origin' => 'install'));
$_SESSION['connected_with'] = 'pwg_ui';
$user['preferences']['show_whats_new_'.get_branch_from_version(PHPWG_VERSION)] = false;
diff --git a/install/db/182-database.php b/install/db/182-database.php
new file mode 100644
index 0000000000..8db14a8729
--- /dev/null
+++ b/install/db/182-database.php
@@ -0,0 +1,39 @@
+
diff --git a/install/piwigo_structure-mysql.sql b/install/piwigo_structure-mysql.sql
index 7c824b9756..a54b4281e2 100644
--- a/install/piwigo_structure-mysql.sql
+++ b/install/piwigo_structure-mysql.sql
@@ -23,6 +23,30 @@ CREATE TABLE `piwigo_activity` (
PRIMARY KEY (`activity_id`)
) ENGINE=MyISAM;
+--
+-- Table structure for table `piwigo_login_attempts`
+--
+
+DROP TABLE IF EXISTS `piwigo_login_attempts`;
+CREATE TABLE `piwigo_login_attempts` (
+ `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
+ `user_id` mediumint(8) unsigned DEFAULT NULL,
+ `username` varchar(255) NOT NULL,
+ `outcome` enum('success','failure') NOT NULL,
+ `ip_address` varchar(50) DEFAULT NULL,
+ `user_agent` varchar(255) DEFAULT NULL,
+ `connected_with` varchar(100) DEFAULT NULL,
+ `auth_origin` varchar(100) DEFAULT NULL,
+ `remember_me` tinyint(1) DEFAULT 0,
+ `failure_reason` varchar(255) DEFAULT NULL,
+ `session_idx` varchar(255) DEFAULT NULL,
+ `occurred_on` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `user_idx` (`user_id`),
+ KEY `outcome_idx` (`outcome`),
+ KEY `occurred_idx` (`occurred_on`)
+) ENGINE=MyISAM;
+
--
-- Table structure for table `piwigo_caddie`
--
diff --git a/language/en_UK/admin.lang.php b/language/en_UK/admin.lang.php
index f108c3a602..ec6a372015 100644
--- a/language/en_UK/admin.lang.php
+++ b/language/en_UK/admin.lang.php
@@ -1421,5 +1421,34 @@
$lang['There is currently %d photos in the lounge (upload buffer)'] = 'There is currently %d photos in the lounge (upload buffer)';
$lang['%d photos were moved from the upload lounge to their albums'] = '%d photos were moved from the upload lounge to their albums';
$lang['Admins only'] = 'Admins only';
+$lang['Security Center'] = 'Security Center';
+$lang['Login attempt tracking'] = 'Login attempt tracking';
+$lang['Export filtered CSV'] = 'Export filtered CSV';
+$lang['The login audit table is not available yet. Run the database upgrade to start collecting attempts.'] = 'The login audit table is not available yet. Run the database upgrade to start collecting attempts.';
+$lang['Successful logins'] = 'Successful logins';
+$lang['Failed logins'] = 'Failed logins';
+$lang['Most recent attempt'] = 'Most recent attempt';
+$lang['Last %d days'] = 'Last %d days';
+$lang['Any user'] = 'Any user';
+$lang['Username or email'] = 'Username or email';
+$lang['eg. alice'] = 'e.g. alice';
+$lang['IP Address'] = 'IP Address';
+$lang['eg. 10.0.0.5'] = 'e.g. 10.0.0.5';
+$lang['Date range'] = 'Date range';
+$lang['Apply filters'] = 'Apply filters';
+$lang['Purge entries older than (days)'] = 'Purge entries older than (days)';
+$lang['Purge old login attempts'] = 'Purge old login attempts';
+$lang['Authentication origin'] = 'Authentication origin';
+$lang['Connected with'] = 'Connected with';
+$lang['Failure reason'] = 'Failure reason';
+$lang['User agent'] = 'User agent';
+$lang['Login attempts older than %s days have been removed'] = 'Login attempts older than %s days have been removed';
+$lang['Page %d of %d'] = 'Page %d of %d';
+$lang['rows'] = 'rows';
+$lang['No login attempt has been recorded yet.'] = 'No login attempt has been recorded yet.';
+$lang['Remember me'] = 'Remember me';
+$lang['Outcome'] = 'Outcome';
+$lang['Success'] = 'Success';
+$lang['Failed'] = 'Failed';
-// Leave this line empty
\ No newline at end of file
+// Leave this line empty
diff --git a/register.php b/register.php
index 25cac78ece..9f0e0c9f0c 100644
--- a/register.php
+++ b/register.php
@@ -64,7 +64,10 @@
// log user and redirect
$user_id = get_userid($_POST['login']);
- log_user($user_id, false);
+ log_user($user_id, false, array(
+ 'auth_origin' => 'register',
+ 'username' => $_POST['login'],
+ ));
redirect(make_index_url());
}
$registration_post_key = get_ephemeral_key(2);
diff --git a/ws.php b/ws.php
index a8628418b6..441b9e86b4 100644
--- a/ws.php
+++ b/ws.php
@@ -834,7 +834,6 @@ function ws_addDefaultMethods( $arr )
'ws_images_uploadCompleted',
array(
'image_id' => array('default'=>null, 'flags'=>WS_PARAM_ACCEPT_ARRAY),
- 'pwg_token' => array(),
'category_id' => array('type'=>WS_TYPE_ID),
),
'Notify Piwigo you have finished uploading a set of photos. It will empty the lounge, if any.',