fixes #2355 implement API key management system

- Added API key get, creation, editing, and revocation methods.

- Updated the profile template to include API key management features.

- Updated the database schema to support the new API key system, including additional fields for key management.

- Added client-side JavaScript functionality to handle API key operations and display responses.

- Update tools/htm.ws with the new way to authenticate.

- Restriction of certain api methods when used with an api key

- Backward compatibility with older apps
This commit is contained in:
Linty
2025-06-09 20:35:57 +02:00
parent 2624be1c90
commit ae740ba3af
20 changed files with 1937 additions and 102 deletions
+28
View File
@@ -460,6 +460,34 @@ $conf['session_use_ip_address'] = true;
// session").
$conf['session_gc_probability'] = 1;
// +-----------------------------------------------------------------------+
// | api key |
// +-----------------------------------------------------------------------+
// api_key_duration: available duration options (in days) for API key creation.
// Array of predefined durations that will be displayed in the select dropdown
// when creating a new API key. Use 'custom' to allow users to set a specific
// expiration date with a date picker input.
$conf['api_key_duration'] = ['30', '90', '180', '365', 'custom'];
// The following API methods are prohibited when making requests with an API key.
// These restrictions are in place for security reasons and to prevent unauthorized
// access to sensitive operations that require higher-level authentication.
$conf['api_key_forbidden_methods'] = array(
// users
'pwg.users.generatePasswordLink',
'pwg.users.getAuthKey',
'pwg.users.setMainUser',
'pwg.users.setInfo',
// plugins
'pwg.plugins.performAction',
// themes
'pwg.themes.performAction',
// extensions
'pwg.extensions.ignoreUpdate',
'pwg.extensions.update',
);
// +-----------------------------------------------------------------------+
// | debug/performance |
// +-----------------------------------------------------------------------+
+7
View File
@@ -153,6 +153,13 @@ SELECT data
*/
function pwg_session_write($session_id, $data)
{
// when the request is authenticated via api_key (PWG_API_KEY_REQUEST),
// you do not want the session to be written to the database (no user session persistence)
// this avoids polluting the session table with stateless API accesses
if (defined('PWG_API_KEY_REQUEST'))
{
return true;
}
$query = '
REPLACE INTO '.SESSIONS_TABLE.'
(id,data,expiration)
+254 -4
View File
@@ -1661,14 +1661,28 @@ function get_recent_photos_sql($db_field)
*
* @return bool
*/
function auth_key_login($auth_key)
function auth_key_login($auth_key, $connection_by_header=false)
{
global $conf, $user, $page;
if (!preg_match('/^[a-z0-9]{30}$/i', $auth_key))
$valid_key = false;
$secret_key = null;
if (preg_match('/^[a-z0-9]{30}$/i', $auth_key))
{
return false;
$valid_key = 'auth_key';
}
else if (
preg_match('/^pkid-\d{8}-[a-z0-9]{20}:[a-z0-9]{40}$/i', $auth_key)
and $connection_by_header
)
{
$valid_key = 'api_key';
$tmp_key = explode(':', $auth_key);
$auth_key = $tmp_key[0];
$secret_key = $tmp_key[1];
}
if (!$valid_key) return false;
$query = '
SELECT
@@ -1689,6 +1703,22 @@ SELECT
$key = $keys[0];
// the key is an api_key
if ('api_key' === $valid_key)
{
// check secret
if (!pwg_password_verify($secret_key, $key['apikey_secret']))
{
return false;
}
// is the key is revoked?
if (null != $key['revoked_on'])
{
return false;
}
}
// is the key still valid?
if (strtotime($key['expired_on']) < strtotime($key['dbnow']))
{
@@ -1697,12 +1727,34 @@ SELECT
}
// admin/webmaster/guest can't get connected with authentication keys
if (!in_array($key['status'], array('normal','generic')))
if ('auth_key' === $valid_key and !in_array($key['status'], array('normal','generic')))
{
return false;
}
$user['id'] = $key['user_id'];
// update last used key
single_update(
USER_AUTH_KEYS_TABLE,
array('last_used_on' => $key['dbnow']),
array(
'user_id' => $user['id'],
'auth_key' => $key['auth_key']
),
);
// set the type of connection
$_SESSION['connected_with'] = $valid_key;
// if the connection is made via an API key in the header,
// access is authenticated without creating a persistent user session
// this enables stateless authentication for API calls
if ($connection_by_header)
{
return true;
}
log_user($user['id'], false);
trigger_notify('login_success', $key['username']);
@@ -1771,6 +1823,7 @@ SELECT
'created_on' => $now,
'duration' => $conf['auth_key_duration'],
'expired_on' => $expiration,
'key_type' => 'auth_key',
);
single_insert(USER_AUTH_KEYS_TABLE, $key);
@@ -1799,6 +1852,7 @@ UPDATE '.USER_AUTH_KEYS_TABLE.'
SET expired_on = NOW()
WHERE user_id = '.$user_id.'
AND expired_on > NOW()
AND key_type = \'auth_key\'
;';
pwg_query($query);
}
@@ -2383,4 +2437,200 @@ SELECT
'account' => $updates
);
}
/**
* Create a new api_key
*
* @since 16
* @param int $user_id
* @param int|null $duration
* @param string $key_name
* @return array auth_key / apikey_secret / apikey_name /
* user_id / created_on / duration / expired_on / key_type
*/
function create_api_key($user_id, $duration, $key_name)
{
$key_id = 'pkid-'.date('Ymd').'-'.generate_key(20);
$key_secret = generate_key(40);
list($dbnow) = pwg_db_fetch_row(pwg_query('SELECT NOW();'));
$key = array(
'auth_key' => $key_id,
'apikey_secret' => pwg_password_hash($key_secret),
'apikey_name' => $key_name,
'user_id' => $user_id,
'created_on' => $dbnow,
'key_type' => 'api_key'
);
if (!empty($duration))
{
$query = '
SELECT
ADDDATE(NOW(), INTERVAL '.($duration * 60 * 60 * 24).' SECOND)
;';
list($expiration) = pwg_db_fetch_row(pwg_query($query));
$key['duration'] = $duration;
}
$key['expired_on'] = $expiration;
single_insert(USER_AUTH_KEYS_TABLE, $key);
$key['apikey_secret'] = $key_secret;
return $key;
}
/**
* Revoke a api_key
*
* @since 16
* @param int $user_id
* @param string $pkid
* @return string|bool
*/
function revoke_api_key($user_id, $pkid)
{
$query = '
SELECT
COUNT(*),
NOW()
FROM `'.USER_AUTH_KEYS_TABLE.'`
WHERE auth_key = "'.$pkid.'"
AND user_id = '.$user_id.'
;';
list($key, $now) = pwg_db_fetch_row(pwg_query($query));
if ($key == 0)
{
return l10n('API Key not found');
}
single_update(
USER_AUTH_KEYS_TABLE,
array('revoked_on' => $now),
array(
'auth_key' => $pkid,
'user_id' => $user_id
)
);
return true;
}
/**
* Edit a api_key
*
* @since 16
* @param int $user_id
* @param string $pkid
* @return string|bool
*/
function edit_api_key($user_id, $pkid, $api_name)
{
$query = '
SELECT
COUNT(*)
FROM `'.USER_AUTH_KEYS_TABLE.'`
WHERE auth_key = "'.$pkid.'"
AND user_id = '.$user_id.'
;';
list($key) = pwg_db_fetch_row(pwg_query($query));
if ($key == 0)
{
return l10n('API Key not found');
}
single_update(
USER_AUTH_KEYS_TABLE,
array('apikey_name' => $api_name),
array(
'auth_key' => $pkid,
'user_id' => $user_id
)
);
return true;
}
/**
* Get all api_key
*
* @since 16
* @param string $user_id
* @return array|false
*/
function get_api_key($user_id)
{
$query = '
SELECT *
FROM `'.USER_AUTH_KEYS_TABLE.'`
WHERE user_id = '.$user_id.'
AND key_type = "api_key"
;';
$api_keys = query2array($query);
if (!$api_keys) return false;
$query = '
SELECT
NOW()
;';
list($now) = pwg_db_fetch_row(pwg_query($query));
foreach ($api_keys as $i => $api_key)
{
$api_key['apikey_secret'] = str_repeat("*", 40);
unset($api_key['auth_key_id'], $api_key['user_id'], $api_key['key_type']);
$api_key['created_on_format'] = format_date($api_key['created_on'], array('day', 'month', 'year'));
$api_key['expired_on_format'] = format_date($api_key['expired_on'], array('day', 'month', 'year'));
$api_key['last_used_on_since'] =
$api_key['last_used_on']
? time_since($api_key['last_used_on'], 'day')
: l10n('Never');
$expired_on = str2DateTime($api_key['expired_on']);
$now = str2DateTime($now);
$api_key['is_expired'] = $expired_on < $now;
if ($api_key['is_expired'])
{
$api_key['expiration'] = l10n('Expired');
}
else
{
$diff = dateDiff($now, $expired_on);
if ($diff->days > 0)
{
$api_key['expiration'] = l10n('%d days', $diff->days);
}
elseif ($diff->h > 0)
{
$api_key['expiration'] = l10n('%d hours', $diff->h);
}
else
{
$api_key['expiration'] = l10n('%d minutes', $diff->i);
}
}
$api_key['expired_on_since'] = time_since($api_key['expired_on'], 'day');
$api_key['revoked_on_since'] =
$api_key['revoked_on']
? time_since($api_key['revoked_on'], 'day')
: null;
$api_key['revoked_on_message'] =
$api_key['revoked_on']
? l10n('This API key was manually revoked on %s', format_date($api_key['revoked_on'], array('day', 'month', 'year')))
: null;
$api_keys[$i] = $api_key;
}
return $api_keys;
}
?>
+39
View File
@@ -56,6 +56,44 @@ if (isset($_GET['auth']))
auth_key_login($_GET['auth']);
}
// HTTP_AUTHORIZATION api_key
if (
defined('IN_WS')
and isset($_SERVER['HTTP_AUTHORIZATION'])
and !empty($_SERVER['HTTP_AUTHORIZATION'])
and isset($_REQUEST['method'])
)
{
$auth_header = pwg_db_real_escape_string($_SERVER['HTTP_AUTHORIZATION']) ?? null;
if ($auth_header)
{
$authenticate = auth_key_login($auth_header, true);
if (!$authenticate)
{
include_once(PHPWG_ROOT_PATH.'include/ws_init.inc.php');
$service->sendResponse(new PwgError(401, 'Invalid api_key'));
exit;
}
define('PWG_API_KEY_REQUEST', true);
// set pwg_token for api_key request
if (isset($_POST['pwg_token']))
{
$_POST['pwg_token'] = get_pwg_token();
}
if (isset($_GET['pwg_token']))
{
$_GET['pwg_token'] = get_pwg_token();
}
// logger
global $logger;
$logger->info('[api_key][pkid='.explode(':', $auth_header)[0].'][method='.$_REQUEST['method'].']');
}
}
if (
defined('IN_WS')
and isset($_REQUEST['method'])
@@ -70,6 +108,7 @@ if (
$service->sendResponse(new PwgError(999, 'Invalid username/password'));
exit();
}
$_SESSION['connected_with'] = 'pwg.images.uploadAsync';
}
$page['user_use_cache'] = true;
+27
View File
@@ -517,6 +517,11 @@ Request format: ".@$this->_requestFormat." Response format: ".@$this->_responseF
return new PwgError(401, 'Access denied');
}
if (!$this->isAuthorizedMethodForAPIKEY())
{
return new PwgError(401, 'Access denied');
}
// parameter check and data correction
$signature = $method['signature'];
$missing_params = array();
@@ -679,5 +684,27 @@ Request format: ".@$this->_requestFormat." Response format: ".@$this->_responseF
}
return $res;
}
function isAuthorizedMethodForAPIKEY()
{
global $conf;
// if the request is made with an API key (via header or session API key),
// we check whether the requested method is on the
// list of prohibited methods ($conf['api_key_forbidden_methods']) for API keys
// if it is, access is refused (false)
if (
defined('PWG_API_KEY_REQUEST')
OR (isset($_SESSION['connected_with']) AND 'ws_session_login_api_key' === $_SESSION['connected_with'])
)
{
if (in_array($_REQUEST['method'], $conf['api_key_forbidden_methods']))
{
return false;
}
}
return true;
}
}
?>
+26 -2
View File
@@ -347,8 +347,24 @@ DELETE FROM '. RATE_TABLE .'
*/
function ws_session_login($params, &$service)
{
if (try_log_user($params['username'], $params['password'], false))
if (defined('PWG_API_KEY_REQUEST'))
{
return new PwgError(401, 'Cannot use this method with an api key');
}
if (preg_match('/^pkid-\d{8}-[a-z0-9]{20}$/i', $params['username']))
{
$secret = pwg_db_real_escape_string($params['password']);
$authenticate = auth_key_login($params['username'].':'.$secret);
if ($authenticate)
{
$_SESSION['connected_with'] = 'ws_session_login_api_key';
return true;
}
}
else if (try_log_user($params['username'], $params['password'], false))
{
$_SESSION['connected_with'] = 'ws_session_login';
return true;
}
return new PwgError(999, 'Invalid username/password');
@@ -362,6 +378,11 @@ function ws_session_login($params, &$service)
*/
function ws_session_logout($params, &$service)
{
if (defined('PWG_API_KEY_REQUEST'))
{
return new PwgError(401, 'Cannot use this method with an api key');
}
if (!is_a_guest())
{
logout_user();
@@ -390,11 +411,13 @@ function ws_session_getStatus($params, &$service)
$res['current_datetime'] = $dbnow;
$res['version'] = PHPWG_VERSION;
$res['save_visits'] = do_log();
$res['connected_with'] = $_SESSION['connected_with'] ?? null;
// Piwigo Remote Sync does not support receiving the new (version 14) output "save_visits"
if (isset($_SERVER['HTTP_USER_AGENT']) and preg_match('/^PiwigoRemoteSync/', $_SERVER['HTTP_USER_AGENT']))
{
unset($res['save_visits']);
unset($res['connected_with']);
}
// Piwigo Remote Sync does not support receiving the available sizes
@@ -1151,4 +1174,5 @@ SELECT
'summary' => $search_summary
);
}
?>
?>
+156
View File
@@ -629,6 +629,8 @@ SELECT '.$conf['user_fields']['password'].' AS password
$params['password'] = $params['new_password'];
}
// Unset admin field also new and conf password
unset(
$params['new_password'],
$params['conf_new_password'],
@@ -949,4 +951,158 @@ function ws_set_main_user($params, &$service)
conf_update_param('webmaster_id', $params['user_id']);
return 'The main user has been changed.';
}
/**
* API method
* Create a new api key for the current user
* @since 15
* @param mixed[] $params
*/
function ws_create_api_key($params, &$service)
{
global $user, $logger;
if (is_a_guest() OR !can_manage_api_key()) return new PwgError(401, 'Acces Denied');
if (get_pwg_token() != $params['pwg_token'])
{
return new PwgError(403, 'Invalid security token');
}
if ($params['duration'] < 1 OR $params['duration'] > 999999)
{
return new PwgError(400, 'Invalid duration max days is 999999');
}
if (strlen($params['key_name']) > 100)
{
return new PwgError(400, 'Key name is too long');
}
$key_name = pwg_db_real_escape_string($params['key_name']);
$duration = 0 == $params['duration'] ? 1 : $params['duration'];
$secret = create_api_key($user['id'], $duration, $key_name);
$logger->info('[api_key][user_id='.$user['id'].'][action=create][key_name='.$params['key_name'].']');
return $secret;
}
/**
* API method
* Revoke a api key for the current user
* @since 15
* @param mixed[] $params
*/
function ws_revoke_api_key($params, &$service)
{
global $user, $logger;
if (is_a_guest() OR !can_manage_api_key()) return new PwgError(401, 'Acces Denied');
if (get_pwg_token() != $params['pwg_token'])
{
return new PwgError(403, l10n('Invalid security token'));
}
if (!preg_match('/^pkid-\d{8}-[a-z0-9]{20}$/i', $params['pkid']))
{
return new PwgError(403, l10n('Invalid pkid format'));
}
$revoked_key = revoke_api_key($user['id'], $params['pkid']);
if (true !== $revoked_key)
{
return new PwgError(403, $revoked_key);
}
$logger->info('[api_key][user_id='.$user['id'].'][action=revoke][pkid='.$params['pkid'].']');
return l10n('API Key has been successfully revoked.');
}
/**
* API method
* Edit a api key for the current user
* @since 15
* @param mixed[] $params
*/
function ws_edit_api_key($params, &$service)
{
global $user, $logger;
if (is_a_guest())
{
return new PwgError(401, 'Acces Denied');
}
if (!can_manage_api_key())
{
return new PwgError(401, 'Acces Denied');
}
if (get_pwg_token() != $params['pwg_token'])
{
return new PwgError(403, l10n('Invalid security token'));
}
if (!preg_match('/^pkid-\d{8}-[a-z0-9]{20}$/i', $params['pkid']))
{
return new PwgError(403, l10n('Invalid pkid format'));
}
$key_name = pwg_db_real_escape_string($params['key_name']);
$edited_key = edit_api_key($user['id'], $params['pkid'], $key_name);
if (true !== $edited_key)
{
return new PwgError(403, $edited_key);
}
$logger->info('[api_key][user_id='.$user['id'].'][action=edit][pkid='.$params['pkid'].'][new_name='.$key_name.']');
return l10n('API Key has been successfully edited.');
}
/**
* API method
* Get all api key for the current user
* @since 15
* @param mixed[] $params
*/
function ws_get_api_key($params, &$service)
{
global $user;
if (is_a_guest())
{
return new PwgError(401, 'Acces Denied');
}
if (!can_manage_api_key())
{
return new PwgError(401, 'Acces Denied');
}
if (get_pwg_token() != $params['pwg_token'])
{
return new PwgError(403, 'Invalid security token');
}
$api_keys = get_api_key($user['id']);
return $api_keys ?? l10n('No API key found');
}
function can_manage_api_key()
{
// You can manage your api key only if you are connected via identification.php
if (isset($_SESSION['connected_with']) and 'pwg_ui' === $_SESSION['connected_with'])
{
return true;
}
return false;
}
?>