WIP REST API: using tokens to authenticate as user or impersonate a user without the password and optional limited application rights

This commit is contained in:
ralf 2023-06-30 20:45:31 +02:00
parent f4699543c3
commit 9359e3eee5
6 changed files with 194 additions and 9 deletions

View File

@ -11,7 +11,7 @@
/* Basic information about this app */
$setup_info['api']['name'] = 'api';
$setup_info['api']['title'] = 'EGroupware API';
$setup_info['api']['version'] = '23.1';
$setup_info['api']['version'] = '23.1.001';
$setup_info['api']['versions']['current_header'] = '1.29';
// maintenance release in sync with changelog in doc/rpm-build/debian.changes
$setup_info['api']['versions']['maintenance_release'] = '23.1.20230620';
@ -53,6 +53,7 @@ $setup_info['api']['tables'][] = 'egw_ea_identities';
$setup_info['api']['tables'][] = 'egw_ea_valid';
$setup_info['api']['tables'][] = 'egw_ea_notifications';
$setup_info['api']['tables'][] = 'egw_addressbook_shared';
$setup_info['api']['tables'][] = 'egw_tokens';
// hooks used by vfs_home_hooks to manage user- and group-directories for the new stream based VFS
$setup_info['api']['hooks']['addaccount'] = array('EGroupware\\Api\\Vfs\\Hooks::addAccount', 'EGroupware\\Api\\Mail\\Hooks::addaccount');

View File

@ -320,7 +320,7 @@ $phpgw_baseline = array(
'fs_created' => array('type' => 'timestamp','precision' => '8','nullable' => False),
'fs_modified' => array('type' => 'timestamp','precision' => '8','nullable' => False),
'fs_mime' => array('type' => 'ascii','precision' => '96','nullable' => False),
'fs_size' => array('type' => 'int','precision' => '8', 'default' => '0'),
'fs_size' => array('type' => 'int','precision' => '8','default' => '0'),
'fs_creator' => array('type' => 'int','meta' => 'user','precision' => '4','nullable' => False),
'fs_modifier' => array('type' => 'int','meta' => 'user','precision' => '4'),
'fs_active' => array('type' => 'bool','nullable' => False,'default' => 't'),
@ -525,5 +525,23 @@ $phpgw_baseline = array(
'fk' => array(),
'ix' => array('contact_id','shared_with'),
'uc' => array(array('shared_by','shared_with','contact_id'))
),
'egw_tokens' => array(
'fd' => array(
'token_id' => array('type' => 'int','precision' => '4','nullable' => False),
'account_id' => array('type' => 'int','meta' => 'user','precision' => '4','nullable' => False,'comment' => '0=all users'),
'token_hash' => array('type' => 'ascii','precision' => '128','nullable' => False,'comment' => 'hash of token'),
'token_limits' => array('type' => 'ascii','meta' => 'json','precision' => '4096','comment' => 'limit run rights of session'),
'token_created' => array('type' => 'timestamp','nullable' => False),
'token_created_by' => array('type' => 'int','meta' => 'user','precision' => '4','nullable' => False),
'token_valid_until' => array('type' => 'timestamp'),
'token_revoked' => array('type' => 'timestamp'),
'token_revoked_by' => array('type' => 'int','meta' => 'user','precision' => '4'),
'token_remark' => array('type' => 'varchar','precision' => 255)
),
'pk' => array('token_id'),
'fk' => array(),
'ix' => array('account_id'),
'uc' => array()
)
);
);

View File

@ -848,4 +848,33 @@ function api_upgrade21_1_002()
function api_upgrade21_1_003()
{
return $GLOBALS['setup_info']['api']['currentver'] = '23.1';
}
/**
* Add table for user tokens which can be used instead of password
*
* @return string
*/
function api_upgrade23_1()
{
$GLOBALS['egw_setup']->oProc->CreateTable('egw_tokens',array(
'fd' => array(
'token_id' => array('type' => 'int','precision' => '4','nullable' => False),
'account_id' => array('type' => 'int','meta' => 'user','precision' => '4','nullable' => False,'comment' => '0=all users'),
'token_hash' => array('type' => 'ascii','precision' => '128','nullable' => False,'comment' => 'hash of token'),
'token_limits' => array('type' => 'ascii','meta' => 'json','precision' => '4096','comment' => 'limit run rights of session'),
'token_created' => array('type' => 'timestamp','nullable' => False),
'token_created_by' => array('type' => 'int','meta' => 'user','precision' => '4','nullable' => False),
'token_valid_until' => array('type' => 'timestamp'),
'token_revoked' => array('type' => 'timestamp'),
'token_revoked_by' => array('type' => 'int','meta' => 'user','precision' => '4'),
'token_remark' => array('type' => 'varchar','precision' => 255)
),
'pk' => array('token_id'),
'fk' => array(),
'ix' => array('account_id'),
'uc' => array()
));
return $GLOBALS['setup_info']['api']['currentver'] = '23.1.001';
}

105
api/src/Auth/Token.php Normal file
View File

@ -0,0 +1,105 @@
<?php
/**
* EGroupware Api: Access token for limited user access instead of passwords
*
* @link https://www.egroupware.org
* @package api
* @subpackage auth
* @author Ralf Becker <rb@egroupware.org>
* @copyright (c) 2023 by Ralf Becker <rb@egroupware.org>
* @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
*/
namespace EGroupware\Api\Auth;
use EGroupware\Api;
/**
* Token can be used instead of password to create sessions, which are:
* - optionally more limited in the allowed apps than the user
* - can be created to be valid to impersonate arbitrary user e.g. for REST API
*/
class Token extends APi\Storage\Base
{
const APP = 'api';
const TABLE = 'egw_tokens';
const PREFIX = 'token';
const TOKEN_REGEXP = '/^'.self::PREFIX.'(\d+)_(.*)$/';
/**
* Constructor
* @throws Api\Exception\WrongParameter
*/
public function __construct()
{
parent::__construct(self::APP, self::TABLE, null, '', true, 'object');
}
/**
* Authenticate a user with a token
*
* @param string $user
* @param string $token must start with "token<token_id>:", or function will return null
* @param ?array& $limits on return limits of token
* @return bool|null null: $token is no token, probably a password, false: invalid token, true: valid token for $user
* @throws \Exception
*/
public static function authenticate(string $user, string $token, array& $limits=null)
{
if (!preg_match(self::TOKEN_REGEXP, $token, $matches))
{
return null; // no a token
}
if (!($data = self::getInstance()->read([
'token_id' => $matches[1],
'account_id' => [0, Api\Accounts::getInstance()->name2id($user)],
'token_revoked' => null,
'(token_valid_until IS NULL OR token_valid_until > NOW())'
])) || !password_verify($matches[2], $data['token_hash']))
{
return false; // wrong/invalid token
}
$limits = $data['token_limits'] ? json_decode($data['token_limits'], true) : null;
return true;
}
/**
* Create a token and return it
*
* @param int $account_id
* @param ?DateTime $until
* @param ?string $remark
* @param ?array $limits app-name => rights pairs, run rights are everything evaluation to true,
* the rights can be an array with more granulate rights, but the app needs to check this itself!
* @return string
*/
public static function create(int $account_id, DateTime $until=null, string $remark=null, array $limits=null): string
{
$token = Api\Auth::randomstring(16);
$inst = self::getInstance();
$inst->init([
'account_id' => $account_id,
'token_hash' => password_hash($token, PASSWORD_DEFAULT),
'token_created' => new Api\DateTime(),
'token_created_by' => $GLOBALS['egw_info']['user']['account_id'],
'token_valid_until' => $until,
'token_remark' => $remark,
'token_limits' => $limits ? json_encode($limits) : null,
]);
if (!($token_id = $inst->save()))
{
throw new Api\Exception('Error storing token');
}
return self::PREFIX.$token_id.'_'.$token;
}
private static self $instance;
public static function getInstance()
{
if (!isset(self::$instance))
{
self::$instance = new self();
}
return self::$instance;
}
}

View File

@ -193,6 +193,13 @@ class Session
*/
protected $action;
/**
* Limit apps available in a session, when not null
*
* @var array|null app-name => true or array pairs
*/
public $limits=null;
/**
* Constructor just loads up some defaults from cookies
*
@ -394,7 +401,7 @@ class Session
{
if (isset($_SESSION[$name]))
{
$_SESSION[$name] = unserialize(trim(mdecrypt_generic(self::$mcrypt,$_SESSION[$name])));
$_SESSION[$name] = unserialize(trim(mdecrypt_generic(self::$mcrypt,$_SESSION[$name])), ['allowed_classes' => true]);
//error_log(__METHOD__."() 'decrypting' session var $name: gettype($name) = ".gettype($_SESSION[$name]));
}
}
@ -514,7 +521,7 @@ class Session
if (($blocked = $this->login_blocked($login,$user_ip)) || // too many unsuccessful attempts
!empty($GLOBALS['egw_info']['server']['global_denied_users'][$this->account_lid]) ||
$auth_check && !$GLOBALS['egw']->auth->authenticate($this->account_lid, $this->passwd, $this->passwd_type) ||
$auth_check && !$this->authenticate() ||
$this->account_id && $GLOBALS['egw']->accounts->get_type($this->account_id) == 'g')
{
$this->reason = $blocked ? 'blocked, too many attempts' : 'bad login or password';
@ -704,6 +711,22 @@ class Session
}
}
/**
* Authenticate user with password or token
*
* @return bool
* @throws \Exception
*/
public function authenticate()
{
$is_valid_token = Auth\Token::authenticate($this->account_lid, $this->passwd, $this->limits);
if (!isset($is_valid_token))
{
return $GLOBALS['egw']->auth->authenticate($this->account_lid, $this->passwd, $this->passwd_type);
}
return $is_valid_token;
}
/**
* Check if password authentication is required or given token is sufficient
*
@ -970,7 +993,9 @@ class Session
'session_action' => $_SERVER['PHP_SELF'],
'session_flags' => $session_flags,
// we need the install-id to differ between several installations sharing one tmp-dir
'session_install_id' => $GLOBALS['egw_info']['server']['install_id']
'session_install_id' => $GLOBALS['egw_info']['server']['install_id'],
// we need to preserve the limits
'session_limits' => $this->limits,
);
}
@ -1291,6 +1316,9 @@ class Session
}
$session =& $_SESSION[self::EGW_SESSION_VAR];
// we need to restore the limits
$this->limits = $session['session_limits'];
if ($session['session_dla'] <= time() - $GLOBALS['egw_info']['server']['sessions_timeout'])
{
if (self::ERROR_LOG_DEBUG) error_log(__METHOD__."('$sessionid') session timed out!");
@ -1959,6 +1987,10 @@ class Session
$GLOBALS['egw']->datetime->__construct(); // to set tz_offset from the now read prefs
}
$user['apps'] = $GLOBALS['egw']->applications->read_repository();
if (!empty($this->limits))
{
$user['apps'] = array_intersect_key($user['apps'], array_filter($this->limits));
}
$user['domain'] = $this->account_domain;
$user['sessionid'] = $this->sessionid;
$user['kp3'] = $this->kp3;

View File

@ -72,7 +72,7 @@ if (Session::init_handler())
require_once($file);
}
}
$GLOBALS['egw'] = unserialize($_SESSION[Session::EGW_OBJECT_CACHE]);
$GLOBALS['egw'] = unserialize($_SESSION[Session::EGW_OBJECT_CACHE], ['allowed_classes' => true]);
if (is_object($GLOBALS['egw']) && ($GLOBALS['egw'] instanceof Egw)) // only egw object has wakeup2, setups egw_minimal eg. has not!
{
@ -109,7 +109,7 @@ $GLOBALS['egw_info']['user']['domain'] = Session::search_instance(
$GLOBALS['egw_info']['server'] += $GLOBALS['egw_domain'][$GLOBALS['egw_info']['user']['domain']];
// the egw-object instanciates all sub-classes (eg. $GLOBALS['egw']->db) and the egw_info array
// the egw-object instantiates all sub-classes (eg. $GLOBALS['egw']->db) and the egw_info array
$GLOBALS['egw'] = new Egw(array_keys($GLOBALS['egw_domain']));
if ($GLOBALS['egw_info']['flags']['currentapp'] != 'login' && !$GLOBALS['egw_info']['server']['show_domain_selectbox'])
@ -117,7 +117,7 @@ if ($GLOBALS['egw_info']['flags']['currentapp'] != 'login' && !$GLOBALS['egw_inf
unset($GLOBALS['egw_domain']); // we kill this for security reasons
}
// saving the the egw_info array and the egw-object in the session
// saving the egw_info array and the egw-object in the session
if ($GLOBALS['egw_info']['flags']['currentapp'] != 'login')
{
$_SESSION[Session::EGW_INFO_CACHE] = $GLOBALS['egw_info'];