From 88ac670093904df6fb32d6d179ce24c02bbab456 Mon Sep 17 00:00:00 2001 From: asdf Date: Tue, 9 Dec 2025 23:00:27 -0800 Subject: [PATCH 1/2] . --- ws.php | 1 - 1 file changed, 1 deletion(-) 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.', From a605adbfffc5b0450cbe16772e56d103ec5ca3df Mon Sep 17 00:00:00 2001 From: asdf Date: Tue, 9 Dec 2025 23:49:52 -0800 Subject: [PATCH 2/2] New Feature --- admin/include/add_core_tabs.inc.php | 3 +- admin/include/functions.php | 5 +- admin/include/functions_upgrade.php | 1 + admin/security_center.php | 323 ++++++++++++++++++ .../default/template/security_center.tpl | 201 +++++++++++ include/constants.php | 2 + include/functions_user.inc.php | 216 +++++++++++- install.php | 2 +- install/db/182-database.php | 39 +++ install/piwigo_structure-mysql.sql | 24 ++ language/en_UK/admin.lang.php | 31 +- register.php | 5 +- 12 files changed, 838 insertions(+), 14 deletions(-) create mode 100644 admin/security_center.php create mode 100644 admin/themes/default/template/security_center.tpl create mode 100644 install/db/182-database.php 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} + +
+
+
+

{'Security Center'|translate}

+

{'Login attempt tracking'|translate}

+
+ +
+ + {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} +
+
+ +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + +
+
+
+ + {'Reset'|translate} +
+
+ +
+ + + + +
+ +
+ {if count($ATTEMPTS) == 0} +

{'No login attempt has been recorded yet.'|translate}

+ {else} + + + + + + + + + + + + + + + + + {foreach from=$ATTEMPTS item=attempt} + + + + + + + + + + + + + {/foreach} + +
{'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}
{$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:'-'}
+ {/if} +
+ + {if $PAGINATION.nb_pages > 1} +
+ {if $PAGINATION.page > 1} + + {/if} + {'Page %d of %d'|translate:$PAGINATION.page:$PAGINATION.nb_pages} ({$PAGINATION.total} {'rows'|translate}) + {if $PAGINATION.page < $PAGINATION.nb_pages} + + {/if} +
+ {/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);