From 07300704bc11dbd2f197e12f61a562a03edab735 Mon Sep 17 00:00:00 2001 From: ralf Date: Mon, 3 Jul 2023 17:09:26 +0200 Subject: [PATCH] WIP Mail Rest API: UI for application passwords/tokens for admin --- admin/inc/class.admin_hooks.inc.php | 1 + admin/js/app.ts | 19 ++ admin/src/Token.php | 268 +++++++++++++++++++++++++ admin/templates/default/app.css | 11 + admin/templates/default/token.edit.xet | 60 ++++++ admin/templates/default/tokens.xet | 66 ++++++ api/setup/setup.inc.php | 4 +- api/setup/tables_current.inc.php | 8 +- api/setup/tables_update.inc.php | 31 ++- api/src/Auth/Token.php | 182 +++++++++++++++-- 10 files changed, 625 insertions(+), 25 deletions(-) create mode 100644 admin/src/Token.php create mode 100644 admin/templates/default/token.edit.xet create mode 100644 admin/templates/default/tokens.xet diff --git a/admin/inc/class.admin_hooks.inc.php b/admin/inc/class.admin_hooks.inc.php index d3deb3ceb5..531c56ef64 100644 --- a/admin/inc/class.admin_hooks.inc.php +++ b/admin/inc/class.admin_hooks.inc.php @@ -72,6 +72,7 @@ class admin_hooks if (! $GLOBALS['egw']->acl->check('account_access',16,'admin')) { $file['Bulk password reset'] = Egw::link('/index.php','menuaction=admin.admin_passwordreset.index&ajax=true'); + $file['Application passwords'] = Egw::link('/index.php', 'menuaction=admin.EGroupware\\Admin\\Token.index&ajax=true'); } if (! $GLOBALS['egw']->acl->check('group_access',1,'admin')) diff --git a/admin/js/app.ts b/admin/js/app.ts index 71c7ef655e..a4bce4e51d 100644 --- a/admin/js/app.ts +++ b/admin/js/app.ts @@ -19,6 +19,7 @@ import {egw} from "../../api/js/jsapi/egw_global.js"; import {egwAction, egwActionObject} from '../../api/js/egw_action/egw_action.js'; import {LitElement} from "@lion/core"; import {et2_nextmatch} from "../../api/js/etemplate/et2_extension_nextmatch"; +import {et2_DOMWidget} from "../../api/js/etemplate/et2_core_DOMWidget"; /** * UI for Admin @@ -1643,6 +1644,24 @@ class AdminApp extends EgwApp } }, this).sendRequest(); } + + /** + * Clickhandler to copy given text or widget content to clipboard + * @param _widget + * @param _text default widget content + */ + copyClipboard(_widget : et2_DOMWidget, _text? : string, _event? : Event) + { + let value = _text || (typeof _widget.get_value === 'function' ? _widget.get_value() : _widget.options.value); + let node = _widget.getDOMNode() !== _widget ? _widget.getDOMNode() : _widget; + this.egw.copyTextToClipboard(value, node, _event).then((success) => + { + if(success !== false) + { + this.egw.message(this.egw.lang("Copied '%1' to clipboard", value), 'success'); + } + }); + } } app.classes.admin = AdminApp; \ No newline at end of file diff --git a/admin/src/Token.php b/admin/src/Token.php new file mode 100644 index 0000000000..651c6dce66 --- /dev/null +++ b/admin/src/Token.php @@ -0,0 +1,268 @@ + + * @package admin + * @copyright (c) 2023 by Ralf Becker + * @license https://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + */ + +namespace EGroupware\Admin; + +use EGroupware\Api; + +class Token +{ + const APP = 'admin'; + + /** + * Methods callable via menuaction GET parameter + * + * @var array + */ + public $public_functions = [ + 'index' => true, + 'edit' => true, + ]; + + /** + * Instance of our business object + * + * @var Api\Auth\Token + */ + protected $token; + + /** + * Constructor + */ + public function __construct() + { + $this->token = new Api\Auth\Token(); + } + + /** + * Edit a host + * + * @param array $content =null + */ + public function edit(array $content=null) + { + if (!is_array($content)) + { + if (!empty($_GET['token_id'])) + { + if (!($content = $this->token->read(['token_id' => $_GET['token_id']]))) + { + Api\Framework::window_close(lang('Token not found!')); + } + } + else + { + $content = $this->token->init(); + if (empty($GLOBALS['egw_info']['user']['apps']['admin'])) + { + $content['account_id'] = $GLOBALS['egw_info']['user']['account_id']; + } + } + } + elseif (!empty($content['button'])) + { + try { + $button = key($content['button']); + unset($content['button']); + switch($button) + { + case 'save': + case 'apply': + $content['token_limits'] = Api\Auth\Token::apps2limits($content['token_apps']); + if (empty($content['token_id'])) + { + $content = Api\Auth\Token::create($content['account_id'] ?: 0, $content['token_valid_until'], $content['token_remark'], + $content['token_limits']); + Api\Framework::refresh_opener(lang('Token created.'), + self::APP, $this->token->data['token_id'],'add'); + $button = 'apply'; // must not close window to show token + } + elseif (!$this->token->save($content)) + { + Api\Framework::refresh_opener(lang('Token saved.'), + self::APP, $this->token->data['token_id'],'edit'); + $content = array_merge($content, $this->token->data); + } + else + { + throw new \Exception(lang('Error storing token!')); + } + if ($button === 'save') + { + Api\Framework::window_close(); // does NOT return + } + break; + + case 'delete': + if (!$this->token->revoke($content['token_id'])) + { + Api\Framework::message(lang('Error revoking token!')); + } + else + { + Api\Framework::refresh_opener(lang('Token revoked.'), + Bo::APP, $content['token_id'], 'update'); + + Api\Framework::window_close(); // does NOT return + } + } + } + catch(\Exception $e) { + Api\Framework::message($e->getMessage(), 'error'); + } + } + $content['token_apps'] = Api\Auth\Token::limits2apps($content['token_limits']); + if (empty($content['account_id'])) $content['account_id'] = ''; + $readonlys = [ + 'button[delete]' => !$content['token_id'], + 'account_id' => empty($GLOBALS['egw_info']['user']['apps']['admin']), + ]; + $tmpl = new Api\Etemplate(self::APP.'.token.edit'); + $tmpl->exec(self::APP.'.'.self::class.'.edit', $content, [], $readonlys, $content, 2); + } + + /** + * Fetch rows to display + * + * @param array $query + * @param array& $rows =null + * @param array& $readonlys =null + */ + public function get_rows($query, array &$rows=null, array &$readonlys=null) + { + $total = $this->token->get_rows($query, $rows, $readonlys); + foreach($rows as &$row) + { + $row['token_apps'] = Api\Auth\Token::limits2apps($row['token_limits']); + if ($row['token_revoked']) + { + $row['class'] = 'revoked'; + } + } + return $total; + } + + /** + * Index + * + * @param array $content =null + */ + public function index(array $content=null) + { + if (!is_array($content) || empty($content['nm'])) + { + $content = [ + 'nm' => [ + 'get_rows' => self::APP.'.'.__CLASS__.'.get_rows', + 'no_filter' => true, // disable the diverse filters we not (yet) use + 'no_filter2' => true, + 'no_cat' => true, + 'order' => 'token_id',// IO name of the column to sort after (optional for the sortheaders) + 'sort' => 'DESC',// IO direction of the sort: 'ASC' or 'DESC' + 'row_id' => 'token_id', + 'actions' => $this->get_actions(), + 'placeholder_actions' => array('add'), + 'add_link' => Api\Egw::link('/index.php', 'menuaction='.self::APP.'.'.self::class.'.edit'), + ] + ]; + } + elseif(!empty($content['nm']['action'])) + { + try { + Api\Framework::message($this->action($content['nm']['action'], + $content['nm']['selected'], $content['nm']['select_all'])); + } + catch (\Exception $ex) { + Api\Framework::message($ex->getMessage(), 'error'); + } + } + $tmpl = new Api\Etemplate(self::APP.'.tokens'); + $tmpl->exec(self::APP.'.'.self::class.'.index', $content, [ + 'account_id' => ['0' => lang('All users')] + ], [], ['nm' => $content['nm']]); + } + + /** + * Return actions for cup list + * + * @param array $cont values for keys license_(nation|year|cat) + * @return array + */ + protected function get_actions() + { + return [ + 'edit' => [ + 'caption' => 'Edit', + 'default' => true, + 'allowOnMultiple' => false, + 'url' => 'menuaction='.self::APP.'.'.self::class.'.edit&token_id=$id', + 'popup' => '640x480', + 'group' => $group=0, + ], + 'add' => [ + 'caption' => 'Create', + 'url' => 'menuaction='.self::APP.'.'.self::class.'.edit', + 'popup' => '640x400', + 'group' => $group, + ], + 'activate' => [ + 'caption' => 'Activate', + 'confirm' => 'Active this token again', + 'enableClass' => 'revoked', + 'group' => $group=5, + ], + 'revoke' => [ + 'caption' => 'Revoke', + 'confirm' => 'Revoke this token', + 'icon' => 'delete', + 'disableClass' => 'revoked', + 'group' => $group, + ], + ]; + } + + /** + * Execute action on list + * + * @param string $action + * @param array|int $selected + * @param boolean $select_all + * @returns string with success message + * @throws Api\Exception\AssertionFailed + */ + protected function action($action, $selected, $select_all) + { + $success = 0; + try { + switch($action) + { + case 'revoke': + case 'activate': + $revoke = $action === 'revoke'; + foreach($selected as $token_id) + { + Api\Auth\Token::revoke($token_id, $revoke); + ++$success; + } + return lang('%1 token %2.', $success, $revoke ? lang('revoked') : lang('activated again')); + + default: + throw new Api\Exception\AssertionFailed('To be implemented ;)'); + } + } + catch(\Exception $e) { + if ($success) { + $e = new \Exception($e->getMessage().', '.lang('%1 successful', $success), $e); + } + throw $e; + } + } +} \ No newline at end of file diff --git a/admin/templates/default/app.css b/admin/templates/default/app.css index 3bc9352f01..eeda34ed22 100644 --- a/admin/templates/default/app.css +++ b/admin/templates/default/app.css @@ -187,4 +187,15 @@ Admin command border-left: 0; border-bottom: 0; background: white; +} + +/** + * Application passwords / tokens + */ +/** + * EPL application Firewall + */ +tr.revoked > td * { + color: grey !important; + font-style: italic; } \ No newline at end of file diff --git a/admin/templates/default/token.edit.xet b/admin/templates/default/token.edit.xet new file mode 100644 index 0000000000..c4a47b96eb --- /dev/null +++ b/admin/templates/default/token.edit.xet @@ -0,0 +1,60 @@ + + + + + \ No newline at end of file diff --git a/admin/templates/default/tokens.xet b/admin/templates/default/tokens.xet new file mode 100644 index 0000000000..f97ecb7cdd --- /dev/null +++ b/admin/templates/default/tokens.xet @@ -0,0 +1,66 @@ + + + + + + + \ No newline at end of file diff --git a/api/setup/setup.inc.php b/api/setup/setup.inc.php index 6579a0a841..b4c77e753f 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.001'; +$setup_info['api']['version'] = '23.1.002'; $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'; @@ -139,4 +139,4 @@ $setup_info['groupdav']['author'] = $setup_info['groupdav']['maintainer'] = arra ); $setup_info['groupdav']['license'] = 'GPL'; $setup_info['groupdav']['hooks']['preferences'] = 'EGroupware\\Api\\CalDAV\\Hooks::menus'; -$setup_info['groupdav']['hooks']['settings'] = 'EGroupware\\Api\\CalDAV\\Hooks::settings'; \ No newline at end of file +$setup_info['groupdav']['hooks']['settings'] = 'EGroupware\\Api\\CalDAV\\Hooks::settings'; diff --git a/api/setup/tables_current.inc.php b/api/setup/tables_current.inc.php index fe49317222..960c736908 100644 --- a/api/setup/tables_current.inc.php +++ b/api/setup/tables_current.inc.php @@ -528,7 +528,7 @@ $phpgw_baseline = array( ), 'egw_tokens' => array( 'fd' => array( - 'token_id' => array('type' => 'int','precision' => '4','nullable' => False), + 'token_id' => array('type' => 'auto','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'), @@ -537,11 +537,13 @@ $phpgw_baseline = array( '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) + 'token_remark' => array('type' => 'varchar','precision' => '1024'), + 'token_updated' => array('type' => 'timestamp'), + 'token_updated_by' => array('type' => 'int','meta' => 'user','precision' => '4') ), '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 3cc80dfc02..6d42891bf8 100644 --- a/api/setup/tables_update.inc.php +++ b/api/setup/tables_update.inc.php @@ -859,7 +859,7 @@ function api_upgrade23_1() { $GLOBALS['egw_setup']->oProc->CreateTable('egw_tokens',array( 'fd' => array( - 'token_id' => array('type' => 'int','precision' => '4','nullable' => False), + 'token_id' => array('type' => 'auto','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'), @@ -868,7 +868,9 @@ function api_upgrade23_1() '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) + 'token_remark' => array('type' => 'varchar','precision' => 1024), + 'token_updated' => array('type' => 'timestamp'), + 'token_updated_by' => array('type' => 'int','meta' => 'user','precision' => '4') ), 'pk' => array('token_id'), 'fk' => array(), @@ -876,5 +878,28 @@ function api_upgrade23_1() 'uc' => array() )); - return $GLOBALS['setup_info']['api']['currentver'] = '23.1.001'; + return $GLOBALS['setup_info']['api']['currentver'] = '23.1.002'; +} + +function api_upgrade23_1_001() +{ + $GLOBALS['egw_setup']->oProc->AlterColumn('egw_tokens','token_id',array( + 'type' => 'auto', + 'precision' => '4', + 'nullable' => False + )); + $GLOBALS['egw_setup']->oProc->AlterColumn('egw_tokens','token_remark',array( + 'type' => 'varchar', + 'precision' => '1024' + )); + $GLOBALS['egw_setup']->oProc->AddColumn('egw_tokens','token_updated',array( + 'type' => 'timestamp' + )); + $GLOBALS['egw_setup']->oProc->AddColumn('egw_tokens','token_updated_by',array( + 'type' => 'int', + 'meta' => 'user', + 'precision' => '4' + )); + + return $GLOBALS['setup_info']['api']['currentver'] = '23.1.002'; } \ No newline at end of file diff --git a/api/src/Auth/Token.php b/api/src/Auth/Token.php index 799814d111..1eed01e374 100644 --- a/api/src/Auth/Token.php +++ b/api/src/Auth/Token.php @@ -33,6 +33,7 @@ class Token extends APi\Storage\Base public function __construct() { parent::__construct(self::APP, self::TABLE, null, '', true, 'object'); + $this->convert_all_timestamps(); } /** @@ -42,55 +43,202 @@ class Token extends APi\Storage\Base * @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 + return null; // not a token } - if (!($data = self::getInstance()->read([ + try { + $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 + ]); + if (!password_verify($matches[2], $data['token_hash'])) + { + return false; // invalid token password + } + $limits = $data['token_limits']; + return true; + } + catch (Api\Exception\NotFound $e) { + return false; // token not found } - $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 ?\DateTimeInterface $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 + * @return array full token record plus token under key "token" + * @throws Api\Exception\NoPermission\Admin if non-admin user tries to create token for anyone else + * @throws Api\Exception\NotFound if token_id does NOT exist + * @throws Api\Db\Exception if token could not be stored */ - public static function create(int $account_id, DateTime $until=null, string $remark=null, array $limits=null): string + public static function create(int $account_id, \DateTimeInterface $until=null, string $remark=null, array $limits=null): array { + if (empty($GLOBALS['egw_info']['user']['apps']['admin'])) + { + $account_id = $GLOBALS['egw_info']['user']['account_id']; + } $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, + 'token_limits' => $limits, ]); - if (!($token_id = $inst->save())) + $inst->save(); + + return $inst->data+[ + 'token' => self::PREFIX.$inst->data['token_id'].'_'.$token, + ]; + } + + /** + * Revoke or (re-)activate a token + * + * @param int $token_id + * @param bool $revoke true: revoke, false: (re-)activate + * @throws Api\Exception\NoPermission\Admin if non-admin user tries to create token for anyone else + * @throws Api\Exception\NotFound if token_id does NOT exist + * @throws Api\Db\Exception if token could not be stored + */ + public static function revoke(int $token_id, bool $revoke=true) + { + $inst = self::getInstance(); + $inst->read($token_id); + return $inst->save([ + 'token_revoked_by' => $GLOBALS['egw_info']['user']['account_id'], + 'token_revoked' => $revoke ? $inst->now : null, + ]); + } + + /** + * saves the content of data to the db + * + * @param array $keys =null if given $keys are copied to data before saveing => allows a save as + * @param string|array $extra_where =null extra where clause, eg. to check an etag, returns true if no affected rows! + * @return int|boolean 0 on success, or errno != 0 on error, or true if $extra_where is given and no rows affected + * @throws Api\Exception\NoPermission\Admin if non-admin user tries to create token for anyone else + * @throws Api\Exception\NotFound if token_id does NOT exist + * @throws Api\Db\Exception if token could not be stored + */ + function save($keys=null,$extra_where=null) + { + if (is_array($keys) && count($keys)) $this->data_merge($keys); + + if (empty($GLOBALS['egw_info']['user']['apps']['admin']) && $this->data['account_id'] != $GLOBALS['egw_info']['user']['account_id']) { - throw new Api\Exception('Error storing token'); + throw new Api\Exception\NoPermission\Admin(); } - return self::PREFIX.$token_id.'_'.$token; + + if (empty($this->data['token_id'])) + { + $this->data['token_created_by'] = $GLOBALS['egw_info']['user']['account_id']; + $this->data['token_created'] = $this->now; + } + else + { + $this->data['token_updated_by'] = $GLOBALS['egw_info']['user']['account_id']; + $this->data['token_updated'] = $this->now; + } + if (($ret = parent::save(null, $extra_where))) + { + throw new Api\Db\Exception(lang('Error storing token')); + } + return $ret; + } + + /** + * reads row matched by key and puts all cols in the data array + * + * @param array $keys array with keys in form internalName => value, may be a scalar value if only one key + * @param string|array $extra_cols ='' string or array of strings to be added to the SELECT, eg. "count(*) as num" + * @param string $join ='' sql to do a join, added as is after the table-name, eg. ", table2 WHERE x=y" or + * @return array data if row could be retrieved + * @throws Api\Exception\NotFound if entry was NOT found + */ + function read($keys,$extra_cols='',$join='') + { + if (!($data = parent::read($keys, $extra_cols, $join))) + { + throw new Api\Exception\NotFound(); + } + return $data; + } + + /** + * Convert limits to allowed apps + * + * @param array|null $limits + * @return array of app-names + */ + public static function limits2apps(array $limits=null): array + { + return $limits ? array_keys(array_filter($limits)) : []; + } + + /** + * Convert apps to (default, value === true) limits + * + * @param array $apps + * @return array|null + */ + public static function apps2limits(array $apps): ?array + { + return $apps ? array_combine($apps, array_fill(0, count($apps), true)) : null; + } + + /** + * Changes the data from the db-format to your work-format + * + * @param array $data =null if given works on that array and returns result, else works on internal data-array + * @return array + */ + function db2data($data=null) + { + if (($intern = !is_array($data))) + { + $data =& $this->data; + } + + if (is_string($data['token_limits'])) + { + $data['token_limits'] = json_decode($data['token_limits'], true); + } + + return parent::db2data($intern ? null : $data); // important to use null, if $intern! + } + + /** + * Changes the data from your work-format to the db-format + * + * @param array $data =null if given works on that array and returns result, else works on internal data-array + * @return array + */ + function data2db($data=null) + { + if (($intern = !is_array($data))) + { + $data =& $this->data; + } + + if (is_array($data['token_limits'])) + { + $data['token_limits'] = $data['token_limits'] ? json_encode($data['token_limits']) : null; + } + + return parent::data2db($intern ? null : $data); } private static self $instance;