diff --git a/api/setup/setup.inc.php b/api/setup/setup.inc.php index 49e9fb8348..6579a0a841 100644 --- a/api/setup/setup.inc.php +++ b/api/setup/setup.inc.php @@ -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'); diff --git a/api/setup/tables_current.inc.php b/api/setup/tables_current.inc.php index 5e7e507294..fe49317222 100644 --- a/api/setup/tables_current.inc.php +++ b/api/setup/tables_current.inc.php @@ -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() ) -); +); \ No newline at end of file diff --git a/api/setup/tables_update.inc.php b/api/setup/tables_update.inc.php index c2d118186e..3cc80dfc02 100644 --- a/api/setup/tables_update.inc.php +++ b/api/setup/tables_update.inc.php @@ -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'; } \ No newline at end of file diff --git a/api/src/Auth/Token.php b/api/src/Auth/Token.php new file mode 100644 index 0000000000..799814d111 --- /dev/null +++ b/api/src/Auth/Token.php @@ -0,0 +1,105 @@ + + * @copyright (c) 2023 by Ralf Becker + * @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:", 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; + } +} \ No newline at end of file diff --git a/api/src/Session.php b/api/src/Session.php index 5eb299d1e6..01270ba240 100644 --- a/api/src/Session.php +++ b/api/src/Session.php @@ -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; diff --git a/api/src/loader.php b/api/src/loader.php index e55c4ed1ba..f291b7b91d 100644 --- a/api/src/loader.php +++ b/api/src/loader.php @@ -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'];