This is an automated email from the ASF dual-hosted git repository. jfthomps pushed a commit to branch VCL-1153_personal_access_tokens in repository https://gitbox.apache.org/repos/asf/vcl.git
commit 3992e01dfcb6ab340d1979a59811f98179ce178f Author: Josh Thompson <[email protected]> AuthorDate: Wed Dec 17 15:11:51 2025 -0500 VCL-1153 - add support for personal access tokens to use with web API vcl.sql, update-vcl.sql: added personalaccesstoken table states.php: -added these states to $noHTMLwrappers and userPreferences: - AJaddAccessToken - AJdeleteAccessToken - AJtokenList userpreferences.php: -modified userpreferences: added new "Manage Tokens" section of user preferences -added AJtokenList -added AJaddAccessToken -added AJdeleteAccessToken -modified printUserprefJavascript: set tokens section to be hidden on page load; added call to initTokens utils.php: -modified checkAccess: modified conditional checking for $_SERVER['HTTP_X_USER'] not being set to also include a check for $_SERVER['HTTP_X_AUTHORIZATION'] not being set; added section to validate Bearer token if $_SERVER['HTTP_X_AUTHORIZATION'] is set -added createUserAccessToken -added deleteUserAccessToken -added getUserAccessTokens -added validateUserAccessToken -modified getDojoHTML: added dijit.form.ValidationTextBox and dijit.form.Button to userpreferences mode; added userpreferences and submitgeneralprefs modes to block of code that generates javascript header content vcl.css: -added .tokenfieldsetbuffer -added .tokenrow -added .tokenname -added .tokenexp -added .newtoke -added .newtokenbox userpreferences.js: initial commit --- mysql/update-vcl.sql | 24 ++++++ mysql/vcl.sql | 24 ++++++ web/.ht-inc/states.php | 9 +++ web/.ht-inc/userpreferences.php | 111 ++++++++++++++++++++++++++ web/.ht-inc/utils.php | 171 +++++++++++++++++++++++++++++++++++++++- web/css/vcl.css | 31 ++++++++ web/js/userpreferences.js | 111 ++++++++++++++++++++++++++ 7 files changed, 480 insertions(+), 1 deletion(-) diff --git a/mysql/update-vcl.sql b/mysql/update-vcl.sql index da77a52e..e02ed20d 100644 --- a/mysql/update-vcl.sql +++ b/mysql/update-vcl.sql @@ -1274,6 +1274,30 @@ CREATE TABLE IF NOT EXISTS `openstackimagerevision` ( -- -------------------------------------------------------- +-- +-- Table structure for table `personalaccesstoken` +-- + +CREATE TABLE IF NOT EXISTS `personalaccesstoken` ( + `id` smallint(5) unsigned NOT NULL AUTO_INCREMENT, + `userid` mediumint(8) unsigned NOT NULL, + `name` varchar(60) NOT NULL, + `created` datetime NOT NULL DEFAULT current_timestamp(), + `expires` datetime NOT NULL DEFAULT current_timestamp(), + `tokenkey` varchar(8) NOT NULL, + `tokenhash` varchar(64) NOT NULL, + `salt` varchar(8) NOT NULL, + `deleted` tinyint(3) unsigned NOT NULL DEFAULT 0, + `datedeleted` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `userid` (`userid`), + KEY `tokenkey` (`tokenkey`), + KEY `expires` (`expires`), + KEY `deleted` (`deleted`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci; + +-- -------------------------------------------------------- + -- -- Table structure change for table `provisioning` -- diff --git a/mysql/vcl.sql b/mysql/vcl.sql index 197939f7..5209619a 100644 --- a/mysql/vcl.sql +++ b/mysql/vcl.sql @@ -898,6 +898,30 @@ CREATE TABLE IF NOT EXISTS `OStype` ( -- -------------------------------------------------------- +-- +-- Table structure for table `personalaccesstoken` +-- + +CREATE TABLE IF NOT EXISTS `personalaccesstoken` ( + `id` smallint(5) unsigned NOT NULL AUTO_INCREMENT, + `userid` mediumint(8) unsigned NOT NULL, + `name` varchar(60) NOT NULL, + `created` datetime NOT NULL DEFAULT current_timestamp(), + `expires` datetime NOT NULL DEFAULT current_timestamp(), + `tokenkey` varchar(8) NOT NULL, + `tokenhash` varchar(64) NOT NULL, + `salt` varchar(8) NOT NULL, + `deleted` tinyint(3) unsigned NOT NULL DEFAULT 0, + `datedeleted` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `userid` (`userid`), + KEY `tokenkey` (`tokenkey`), + KEY `expires` (`expires`), + KEY `deleted` (`deleted`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci + +-- -------------------------------------------------------- + -- -- Table structure for table `platform` -- diff --git a/web/.ht-inc/states.php b/web/.ht-inc/states.php index 9ac803e0..c92809c5 100644 --- a/web/.ht-inc/states.php +++ b/web/.ht-inc/states.php @@ -228,6 +228,9 @@ $noHTMLwrappers = array('sendRDPfile', 'AJsetTZoffset', 'AJconfirmDeleteGroup', 'AJsubmitDeleteGroup', + 'AJaddAccessToken', + 'AJdeleteAccessToken', + 'AJtokenList', ); # main @@ -337,11 +340,17 @@ $actions['mode']['confirmrdpprefs'] = "confirmUserPrefs"; $actions['args']['confirmrdpprefs'] = 1; $actions['mode']['submituserprefs'] = "submitUserPrefs"; $actions['mode']['submitgeneralprefs'] = "submitGeneralPreferences"; +$actions['mode']['AJaddAccessToken'] = "AJaddAccessToken"; +$actions['mode']['AJdeleteAccessToken'] = "AJdeleteAccessToken"; +$actions['mode']['AJtokenList'] = "AJtokenList"; $actions['pages']['userpreferences'] = "userPreferences"; $actions['pages']['confirmpersonalprefs'] = "userPreferences"; $actions['pages']['confirmrdpprefs'] = "userPreferences"; $actions['pages']['submituserprefs'] = "userPreferences"; $actions['pages']['submitgeneralprefs'] = "userPreferences"; +$actions['pages']['AJaddAccessToken'] = "userPreferences"; +$actions['pages']['AJdeleteAccessToken'] = "userPreferences"; +$actions['pages']['AJtokenList'] = "userPreferences"; # manage groups $actions['mode']['viewGroups'] = "viewGroups"; # entry diff --git a/web/.ht-inc/userpreferences.php b/web/.ht-inc/userpreferences.php index 52daf03d..15ac460c 100644 --- a/web/.ht-inc/userpreferences.php +++ b/web/.ht-inc/userpreferences.php @@ -76,6 +76,8 @@ function userpreferences() { print "</li>\n"; print " <li><a href=#uiprefs onclick=\"javascript:show('uiprefs'); "; print "return false\">" . i("General Preferences") . "</a></li>\n"; + print " <li><a href=#tokens onclick=\"javascript:show('tokens'); "; + print "return false\">" . i("Manage Tokens") . "</a></li>\n"; print " </ul>\n"; print " </div>\n"; print " </TD>\n"; @@ -354,7 +356,42 @@ function userpreferences() { print " <INPUT type=submit value=\"" . i("Submit General Preferences") . "\">\n"; print " </FORM>\n"; print " </fieldset>\n"; + print " </div>\n"; # end uiprefs + + # tokens + print " <div id=tokens class=hidden>\n"; + print " <fieldset>\n"; + print " <legend>" . i("Manage Personal Access Tokens") . "</legend>\n"; + print " <div class=\"tokenfieldsetbuffer\">\n"; + print i("Personal Access Tokens are used to access the VCL XMLRPC API.") . "<br><br>\n"; + print "<div id=tokenlist class=hidden>Existing tokens:<br></div>\n"; + $cont = addContinuationsEntry('AJtokenList'); + print "<input type=hidden id=tokenlistcont value=\"$cont\">\n"; + $cont = addContinuationsEntry('AJdeleteAccessToken'); + print "<input type=hidden id=deletetokencont value=\"$cont\">\n"; + print "<div id=notokens class=hidden>You don't have any existing tokens. Use the form below to create one.<br></div>\n"; + print "<div id=createdtokendiv class=hidden>\n"; + print "<br><strong>New Token</strong>\n"; + print "<div class=\"newtokenbox\">\n"; + print "<span id=\"newtoken\"></span> "; + print "<a id=\"newtokena\">"; + print "<img src=\"images/copy_icon.png\" style=\"height: 1.1em; width: 1em;\"></a><br>\n"; + print "</div>\n"; + print "<span class=\"newtokennote\"><strong>" . i('Note') . ":</strong> " . i('This is the only time the token value will be displayed. Copy it now.'). "</span>\n"; + print "</div>\n"; + print "<br>\n"; + print "<strong>Create New Token</strong>:<br>\n"; + print "<div class=\"newtoken\">\n"; + $errmsg = i('Token name can be 2-60 characters and include A-Z, a-z, 0-9, spaces, and these characters - @ # ( ) _ :'); + print labeledFormItem('tokenname', i('Token Name'), 'text', '^([-A-Za-z0-9@#\(\)_: ]){2,60}$', 1, '', $errmsg); + print "</div>\n"; + print "<div id=tokenerrmsg class=\"hidden msgboxerror\"></div>\n"; + print dijitButton('addtokenbtn', i('Create Token'), 'createToken();'); print " </div>\n"; + print " </fieldset>\n"; + $cont = addContinuationsEntry('AJaddAccessToken', array(), 1800); + print " <input type=hidden id=addtokencont value=\"$cont\">\n"; + print " </div>\n"; # end tokens print " </TD>\n"; print " </TR>\n"; print "</table>\n"; @@ -677,6 +714,78 @@ function processUserPrefsInput($checks=1) { return $return; } +//////////////////////////////////////////////////////////////////////////////// +/// +/// \fn AJtokenList() +/// +/// \brief sends a lit of logged in user's tokens +/// +//////////////////////////////////////////////////////////////////////////////// +function AJtokenList() { + $tokens = getUserAccessTokens(); + sendJSON(array('tokens' => $tokens)); +} + +//////////////////////////////////////////////////////////////////////////////// +/// +/// \fn AJaddAccessToken() +/// +/// \brief adds a new token and sends info about it back to user +/// +//////////////////////////////////////////////////////////////////////////////// +function AJaddAccessToken() { + $tokenname = processInputVar("name", ARG_STRING, ''); + if(! preg_match('/^([-A-Za-z0-9@#\(\)_: ]){2,60}$/', $tokenname)) { + $arr = array('status' => 'invalidname', + 'msg' => i("Submitted name is invalid")); + sendJSON($arr); + return; + } + $tokens = getUserAccessTokens(); + foreach($tokens as $token) { + if($tokenname == $token['name']) { + $arr = array('status' => 'duplicatename', + 'msg' => i("Token with this name already exists")); + sendJSON($arr); + return; + } + } + $data = createUserAccessToken($tokenname); + $value = $data['value']; + $arr = array('status' => 'success', + 'id' => $data['tokenid'], + 'expires' => $data['expires'], + 'name' => $tokenname, + 'value' => $value); + sendJSON($arr); + return; +} + +//////////////////////////////////////////////////////////////////////////////// +/// +/// \fn AJdeleteAccessToken() +/// +/// \brief deletes a token +/// +//////////////////////////////////////////////////////////////////////////////// +function AJdeleteAccessToken() { + global $user, $mysqli_link_vcl; + $tokenid = processInputVar("tokenid", ARG_NUMERIC); + $tokens = getUserAccessTokens(); + if(! array_key_exists($tokenid, $tokens)) { + $arr = array('status' => 'invalidtokenid', + 'msg' => i("Submitted token is invalid")); + sendJSON($arr); + return; + } + $cnt = deleteUserAccessToken($tokenid); + if($cnt == 0) + $arr = array('status' => 'failed', 'msg' => i('Error deleting token'), 'id' => $tokenid); + else + $arr = array('status' => 'success', 'id' => $tokenid); + sendJSON($arr); +} + //////////////////////////////////////////////////////////////////////////////// /// /// \fn printUserprefJavascript() @@ -694,6 +803,7 @@ function show(id) { obj.className = "hidden"; document.getElementById("rdpfile").className = "hidden"; document.getElementById("uiprefs").className = "hidden"; + document.getElementById("tokens").className = "hidden"; document.getElementById("status").className = "hidden"; if(id == 'personal' && ! obj) id = 'rdpfile'; @@ -728,6 +838,7 @@ HTMLdone; print <<<HTMLdone document.getElementById("preflinks").className = "shown"; document.getElementById("status").className = "visible"; +initTokens(); </script> HTMLdone; diff --git a/web/.ht-inc/utils.php b/web/.ht-inc/utils.php index 191341b1..8fcfb4dd 100644 --- a/web/.ht-inc/utils.php +++ b/web/.ht-inc/utils.php @@ -397,7 +397,26 @@ function checkAccess() { dbDisconnect(); exit; } - if(! isset($_SERVER['HTTP_X_USER'])) { + if(! isset($_SERVER['HTTP_X_USER']) && ! isset($_SERVER['HTTP_X_AUTHORIZATION'])) { + printXMLRPCerror(3); # access denied + dbDisconnect(); + exit; + } + if(isset($_SERVER['HTTP_X_AUTHORIZATION'])) { + $typetest = substr($_SERVER['HTTP_X_AUTHORIZATION'], 0, 7); + if($typetest != 'Bearer ') { + printXMLRPCerror(3); # access denied + dbDisconnect(); + exit; + } + $token = substr($_SERVER['HTTP_X_AUTHORIZATION'], 7, 88); + if(! $tokenownerid = validateUserAccessToken($token)) { + printXMLRPCerror(3); # access denied + dbDisconnect(); + exit; + } + if($user = getUserInfo($tokenownerid, 1, 1)) + return; printXMLRPCerror(3); # access denied dbDisconnect(); exit; @@ -11932,6 +11951,136 @@ function getNodePath($nodeid) { return $path; } +//////////////////////////////////////////////////////////////////////////////// +/// +/// \fn createUserAccessToken($name) +/// +/// \param $name - name of token +/// +/// \return array with keys tokenid, value, expires +/// +/// \brief generates a new personal access token and adds it to the database +/// +//////////////////////////////////////////////////////////////////////////////// +function createUserAccessToken($name) { + global $user; + do { + $token = base64_encode(openssl_random_pseudo_bytes(64)); + $tokenkey = substr(sha1($token), 0, 8); + $query = "SELECT id FROM personalaccesstoken WHERE tokenkey = '$tokenkey'"; + $qh = doQuery($query); + } while (mysqli_num_rows($qh)); + $salt = generateString(8); + $tokenhash = hash('sha256', "$salt$token"); + $query = "INSERT INTO personalaccesstoken " + . "(userid, " + . "name, " + . "created, " + . "expires, " + . "tokenkey, " + . "tokenhash, " + . "salt, " + . "deleted) " + . "VALUES " + . "({$user['id']}, " + . "'$name', " + . "NOW(), " + . "DATE_ADD(NOW(), INTERVAL 1 YEAR), " + . "'$tokenkey', " + . "'$tokenhash', " + . "'$salt', " + . "0)"; + $qh = doQuery($query); + $tokenid = dbLastInsertID(); + $data = getUserAccessTokens($tokenid); + return array('tokenid' => $tokenid, + 'value' => $token, + 'expires' => $data[$tokenid]['expires']); +} + +//////////////////////////////////////////////////////////////////////////////// +/// +/// \fn deleteUserAccessToken($id) +/// +/// \param $id - id of token from database +/// +/// \return number of tokens affected +/// +/// \brief set a token as deleted in the database +/// +//////////////////////////////////////////////////////////////////////////////// +function deleteUserAccessToken($id) { + global $user, $mysqli_link_vcl; + $query = "UPDATE personalaccesstoken " + . "SET deleted = 1, " + . "datedeleted = NOW() " + . "WHERE id = $id AND " + . "userid = {$user['id']}"; + doQuery($query); + $cnt = mysqli_affected_rows($mysqli_link_vcl); + return $cnt; +} + +//////////////////////////////////////////////////////////////////////////////// +/// +/// \fn getUserAccessTokens($id) +/// +/// \param $id - (optional) id of token from database +/// +/// \return array of tokens with these keys for each one: id, name, expires +/// +/// \brief gets information about tokens for logged in user +/// +//////////////////////////////////////////////////////////////////////////////// +function getUserAccessTokens($id=0) { + global $user; + $query = "SELECT id, " + . "name, " + . "expires " + . "FROM personalaccesstoken " + . "WHERE userid = '{$user['id']}' AND " + . "expires > NOW() AND " + . "deleted = 0"; + if($id != 0) + $query .= " AND id = $id"; + $tokens = array(); + $qh = doQuery($query); + while($row = mysqli_fetch_assoc($qh)) { + $tokens[$row['id']] = $row; + } + return $tokens; +} + +//////////////////////////////////////////////////////////////////////////////// +/// +/// \fn validateUserAccessToken($token) +/// +/// \param $token - token submitted by user +/// +/// \return 0 if invalid, id of user token belongs to if valid +/// +/// \brief checks if the token matches a token in the database, and if so, +/// returns the id of the user it belongs to +/// +//////////////////////////////////////////////////////////////////////////////// +function validateUserAccessToken($token) { + $tokenkey = substr(sha1($token), 0, 8); + $query = "SELECT userid, " + . "tokenhash, " + . "salt " + . "FROM personalaccesstoken " + . "WHERE tokenkey = '$tokenkey' AND " + . "expires > NOW() AND " + . "deleted = 0"; + $qh = doQuery($query); + while($row = mysqli_fetch_assoc($qh)) { + $tokenhash = hash('sha256', "{$row['salt']}$token"); + if($row['tokenhash'] === $tokenhash) + return $row['userid']; + } + return 0; +} + //////////////////////////////////////////////////////////////////////////////// /// /// \fn sortKeepIndex($a, $b) @@ -13945,6 +14094,8 @@ function getDojoHTML($refresh) { case 'submitgeneralprefs': $filename = 'vclUserPreferences.js'; $dojoRequires = array('dojo.parser', + 'dijit.form.ValidationTextBox', + 'dijit.form.Button', 'dijit.form.Textarea'); break; case 'viewstats': @@ -14352,6 +14503,24 @@ function getDojoHTML($refresh) { $rt .= "</script>\n"; return $rt; + case "userpreferences": + case 'submitgeneralprefs': + $rt .= "<style type=\"text/css\">\n"; + $rt .= " @import \"themes/$skin/css/dojo/$skin.css\";\n"; + $rt .= "</style>\n"; + $rt .= "<script type=\"text/javascript\" src=\"js/userpreferences.js?v=$v\"></script>\n"; + $rt .= "<script type=\"text/javascript\" src=\"dojo/dojo/dojo.js\"\n"; + $rt .= " djConfig=\"parseOnLoad: true, locale: '$jslocale'\">\n"; + $rt .= "</script>\n"; + $rt .= $customfile; + $rt .= "<script type=\"text/javascript\">\n"; + $rt .= " dojo.addOnLoad(function() {\n"; + foreach($dojoRequires as $req) + $rt .= " dojo.require(\"$req\");\n"; + $rt .= " });\n"; + $rt .= "</script>\n"; + return $rt; + case "viewstats": $rt .= "<style type=\"text/css\">\n"; $rt .= " @import \"themes/$skin/css/dojo/$skin.css\";\n"; diff --git a/web/css/vcl.css b/web/css/vcl.css index 1d7287fd..6f2d5b57 100644 --- a/web/css/vcl.css +++ b/web/css/vcl.css @@ -172,6 +172,37 @@ body { font-size: 15px; } +.tokenfieldsetbuffer { + padding: 6px 12px; +} + +.tokenrow { + border: 1px solid black; + padding: 4px; + margin-top: -1px; + display: flex; + justify-content: space-between; +} + +.tokenname { + font-weight: bold; +} + +.tokenexp { + font-size: 0.8em; +} + +.newtoken { + margin: 4px 0; +} + +.newtokenbox { + padding: 6px; + margin: 2px 0; + border: 1px solid black; + background-color: #f2f2f2; +} + .whenusefieldset { border-width: 0px; } diff --git a/web/js/userpreferences.js b/web/js/userpreferences.js new file mode 100755 index 00000000..76f1e055 --- /dev/null +++ b/web/js/userpreferences.js @@ -0,0 +1,111 @@ +var newtokenid = 0; + +function initTokens() { + RPCwrapper({continuation: dojo.byId('tokenlistcont').value}, tokenListCB, 1); +} + +function tokenListCB(data, ioArgs) { + var keys = Object.keys(data.items.tokens); + var len = keys.length + for(var i = 0; i < len; i++) { + console.log(data.items.tokens[keys[i]]); + var item = data.items.tokens[keys[i]]; + addTokenToList(item.id, item.name, item.expires); + } + checkTokenListLength(); +} + +function createToken() { + dojo.addClass('tokenerrmsg', 'hidden'); + var data = { + name: dijit.byId('tokenname').value, + continuation: dojo.byId('addtokencont').value + }; + dijit.byId('addtokenbtn').set('disabled', true); + RPCwrapper(data, createTokenCB, 1); +} + +function createTokenCB(data, ioArgs) { + if(data.items.status == 'invalidname') { + dijit.byId('tokenname').isValid(); + dijit.byId('addtokenbtn').set('disabled', false); + return; + } + if(data.items.status == 'duplicatename') { + dojo.removeClass('tokenerrmsg', 'hidden'); + dojo.byId('tokenerrmsg').innerHTML = data.items.msg; + dijit.byId('addtokenbtn').set('disabled', false); + return; + } + newtokenid = data.items.id; + addTokenToList(data.items.id, data.items.name, data.items.expires); + dojo.removeClass('createdtokendiv', 'hidden'); + dojo.byId('newtoken').innerHTML = data.items.value; + dojo.byId('newtokena').onclick = function() {navigator.clipboard.writeText(data.items.value)}; + dijit.byId('tokenname').reset(); + dijit.byId('addtokenbtn').set('disabled', false); +} + +function checkTokenListLength() { + if(dojo.byId('tokenlist').childNodes.length <= 2) { + dojo.addClass('tokenlist', 'hidden'); + dojo.removeClass('notokens', 'hidden'); + } + else { + dojo.removeClass('tokenlist', 'hidden'); + dojo.addClass('notokens', 'hidden'); + } +} + +function addTokenToList(id, name, expires) { + var ce = document.createElement.bind(document); + var rowspan = ce('span'); + rowspan.setAttribute('id', id + 'span'); + rowspan.setAttribute('class', 'tokenrow'); + var txtspan = ce('span'); + rowspan.appendChild(txtspan); + var namespan = ce('span'); + namespan.setAttribute('class', 'tokenname'); + namespan.innerHTML = name; + txtspan.appendChild(namespan); + txtspan.appendChild(ce('br')); + var expspan = ce('span'); + expspan.setAttribute('class', 'tokenexp'); + expspan.innerHTML = 'Expires: ' + expires; + txtspan.appendChild(expspan); + var btn = new dijit.form.Button({ + id: id + 'delbtn', + label: _('Delete'), + onClick: function() { + deleteToken(id); + } + }, document.createElement('div')); + rowspan.appendChild(btn.domNode); + dojo.byId('tokenlist').appendChild(rowspan); + checkTokenListLength(); +} + +function deleteToken(id) { + dijit.byId(id + 'delbtn').set('disabled', true); + var data = { + tokenid: id, + continuation: dojo.byId('deletetokencont').value + }; + RPCwrapper(data, deleteTokenCB, 1); +} + +function deleteTokenCB(data, ioArgs) { + if(data.items.status == 'failed') { + dijit.byId(data.items.id + 'delbtn').set('disabled', false); + alert(data.items.msg); + return; + } + var id = data.items.id; + dijit.byId(id + 'delbtn').destroy(); + dojo.destroy(id + 'span'); + if(newtokenid == data.items.id) { + dojo.byId('createdtokendiv').innerHTML = ''; + dojo.addClass('createdtokendiv', 'hidden'); + } + checkTokenListLength(); +}
