diff --git a/public/language/en-GB/admin/settings/api.json b/public/language/en-GB/admin/settings/api.json index 746b99068b..34bb73d536 100644 --- a/public/language/en-GB/admin/settings/api.json +++ b/public/language/en-GB/admin/settings/api.json @@ -3,17 +3,20 @@ "settings": "Settings", "lead-text": "From this page you can configure access to the Write API in NodeBB.", "intro": "By default, the Write API authenticates users based on their session cookie, but NodeBB also supports Bearer authentication via tokens generated via this page.", + "warning": "Be advised — treat tokens like passwords. If they are leaked, your account should be considered compromised.", "docs": "Click here to access the full API specification", "require-https": "Require API usage via HTTPS only", "require-https-caveat": "Note: Some installations involving load balancers may proxy their requests to NodeBB using HTTP, in which case this option should remain disabled.", "uid": "User ID", + "token": "Token", "uid-help-text": "Specify a User ID to associate with this token. If the user ID is 0, it will be considered a master token, which can assume the identity of other users based on the _uid parameter", "description": "Description", - "last-seen-ago": "Last used .", - "last-seen-on": "Last used on .", + "last-seen": "Last seen", + "created": "Created", + "create-token": "Create Token", + "master-token": "Master token", "last-seen-never": "This key has never been used.", - "no-description": "No description specified.", - "token-on-save": "Token will be generated once form is saved" + "no-description": "No description specified." } \ No newline at end of file diff --git a/public/scss/admin/admin.scss b/public/scss/admin/admin.scss index c244fe3498..1be7f930f5 100644 --- a/public/scss/admin/admin.scss +++ b/public/scss/admin/admin.scss @@ -9,6 +9,7 @@ @import "./manage/privileges"; @import "./manage/tags"; @import "./manage/groups"; +@import "./settings/api"; @import "./appearance/customise"; @import "./extend/plugins"; @import "./extend/rewards"; diff --git a/public/scss/admin/settings/api.scss b/public/scss/admin/settings/api.scss new file mode 100644 index 0000000000..41c8e61f92 --- /dev/null +++ b/public/scss/admin/settings/api.scss @@ -0,0 +1,7 @@ +.template-admin-settings-api { + [data-action="copy"]:active { + i::before { + content: '\f00c'; + } + } +} \ No newline at end of file diff --git a/public/src/admin/settings/api.js b/public/src/admin/settings/api.js index ba3667e40e..55023a0d64 100644 --- a/public/src/admin/settings/api.js +++ b/public/src/admin/settings/api.js @@ -1,53 +1,78 @@ 'use strict'; -define('admin/settings/api', ['settings', 'alerts', 'hooks'], function (settings, alerts, hooks) { +define('admin/settings/api', ['settings', 'clipboard', 'bootbox', 'benchpress', 'api'], function (settings, clipboard, bootbox, Benchpress, api) { const ACP = {}; ACP.init = function () { settings.load('core.api', $('.core-api-settings')); - $('#save').on('click', saveSettings); - - hooks.on('filter:settings.sorted-list.loadItem', ({ item }) => { - if (!ajaxify.data.lastSeen[item.token]) { - item.lastSeen = '[[admin/settings/api:last-seen-never]]'; - return { item }; - } - - const cutoffMs = 1000 * 60 * 60 * 24 * Math.max(0, parseInt(config.timeagoCutoff, 10)); - let translationSuffix = 'ago'; - if (cutoffMs > 0 && Date.now() - ajaxify.data.lastSeen[item.token] > cutoffMs) { - translationSuffix = 'on'; - } - item.lastSeen = `[[admin/settings/api:last-seen-${translationSuffix}, ${ajaxify.data.lastSeenISO[item.token]}]]`; - - return { item }; + $('#save').on('click', () => { + settings.save('core.api', $('.core-api-settings')); }); - hooks.on('action:settings.sorted-list.loaded', ({ listEl }) => { - $(listEl).find('.timeago').timeago(); - }); + // Click to copy + const copyEls = document.querySelectorAll('[data-component="acp/tokens"] [data-action="copy"]'); + new clipboard(copyEls); - hooks.on('action:settings.sorted-list.itemLoaded', ({ element }) => { - element.addEventListener('click', (ev) => { - if (ev.target.closest('input[readonly]')) { - // Select entire input text - ev.target.selectionStart = 0; - ev.target.selectionEnd = ev.target.value.length; - } - }); - }); + handleTokenCreation(); }; - function saveSettings() { - settings.save('core.api', $('.core-api-settings'), function () { - alerts.alert({ - type: 'success', - alert_id: 'core.api-saved', - title: 'Settings Saved', - timeout: 5000, + async function handleTokenCreation() { + const createEl = document.querySelector('[data-action="create"]'); + if (createEl) { + createEl.addEventListener('click', async () => { + const html = await Benchpress.render('admin/partials/edit-token-modal', {}); + bootbox.dialog({ + title: '[[admin/settings/api:create-token]]', + message: html, + buttons: { + submit: { + label: '[[modules:bootbox.submit]]', + className: 'btn-primary', + callback: parseCreateForm, + }, + }, + }); }); - ajaxify.refresh(); - }); + } + } + + async function parseCreateForm() { + const modal = this; + const formEl = this.get(0).querySelector('form'); + const tokensTableBody = document.querySelector('[data-component="acp/tokens"] tbody'); + const valid = formEl.reportValidity(); + if (formEl && valid) { + const formData = new FormData(formEl); + const uid = formData.get('uid'); + const description = formData.get('description'); + // const qs = new URLSearchParams(payload).toString(); + + const token = await api.post('/admin/tokens', { uid, description }).catch(app.alertError); + + const now = new Date(); + const rowEl = (await app.parseAndTranslate(ajaxify.data.template.name, 'tokens', { + tokens: [{ + token, + uid, + description, + timestamp: now.getTime(), + timestampISO: now.toISOString(), + lastSeen: null, + lastSeenISO: new Date(0).toISOString(), + }], + })).get(0); + + if (tokensTableBody) { + tokensTableBody.append(rowEl); + $(rowEl).find('.timeago').timeago(); + } else { + ajaxify.refresh(); + } + + modal.modal('hide'); + } + + return false; } return ACP; diff --git a/src/api/users.js b/src/api/users.js index 9b37d84c2f..ef0ef52500 100644 --- a/src/api/users.js +++ b/src/api/users.js @@ -20,6 +20,8 @@ const translator = require('../translator'); const sockets = require('../socket.io'); const utils = require('../utils'); +const api = require('.'); + const usersAPI = module.exports; const hasAdminPrivilege = async (uid, privilege) => { @@ -313,19 +315,7 @@ usersAPI.generateToken = async (caller, { uid, description }) => { throw new Error('[[error:invalid-uid]]'); } - const settings = await meta.settings.get('core.api'); - settings.tokens = settings.tokens || []; - - const newToken = { - token: utils.generateUUID(), - uid: caller.uid, - description: description || '', - timestamp: Date.now(), - }; - settings.tokens.push(newToken); - await meta.settings.set('core.api', settings); - - return newToken; + return await api.utils.tokens.generate({ uid, description }); }; usersAPI.deleteToken = async (caller, { uid, token }) => { @@ -334,15 +324,8 @@ usersAPI.deleteToken = async (caller, { uid, token }) => { throw new Error('[[error:invalid-uid]]'); } - const settings = await meta.settings.get('core.api'); - const beforeLen = settings.tokens.length; - settings.tokens = settings.tokens.filter(tokenObj => tokenObj.token !== token); - if (beforeLen !== settings.tokens.length) { - await meta.settings.set('core.api', settings); - return true; - } - - return false; + await api.utils.tokens.delete(token); + return true; }; const getSessionAsync = util.promisify((sid, callback) => { diff --git a/src/api/utils.js b/src/api/utils.js index d114d23b6a..7bfc7c4e6e 100644 --- a/src/api/utils.js +++ b/src/api/utils.js @@ -34,16 +34,16 @@ utils.tokens.get = async (tokens) => { ]); tokenObjs.forEach((tokenObj, idx) => { + tokenObj.token = tokens[idx]; tokenObj.lastSeen = lastSeen[idx]; + tokenObj.lastSeenISO = new Date(lastSeen[idx]).toISOString(); + tokenObj.timestampISO = new Date(parseInt(tokenObj.timestamp, 10)).toISOString(); }); return singular ? tokenObjs[0] : tokenObjs; }; utils.tokens.generate = async ({ uid, description }) => { - const token = srcUtils.generateUUID(); - const timestamp = Date.now(); - if (parseInt(uid, 10) !== 0) { const uidExists = await user.exists(uid); if (!uidExists) { @@ -51,6 +51,17 @@ utils.tokens.generate = async ({ uid, description }) => { } } + const token = srcUtils.generateUUID(); + const timestamp = Date.now(); + + return utils.tokens.add({ token, uid, description, timestamp }); +}; + +utils.tokens.add = async ({ token, uid, description = '', timestamp = Date.now() }) => { + if (!token || uid === undefined) { + throw new Error('[[error:invalid-data]]'); + } + await Promise.all([ db.setObject(`token:${token}`, { uid, description, timestamp }), db.sortedSetAdd(`tokens:createtime`, timestamp, token), @@ -60,8 +71,11 @@ utils.tokens.generate = async ({ uid, description }) => { return token; }; -utils.tokens.update = async (token, { description }) => { - await db.setObject(`token:${token}`, { description }); +utils.tokens.update = async (token, { uid, description }) => { + await Promise.all([ + db.setObject(`token:${token}`, { uid, description }), + db.sortedSetAdd(`tokens:uid`, uid, token), + ]); return await utils.tokens.get(token); }; diff --git a/src/controllers/admin/settings.js b/src/controllers/admin/settings.js index 08ecbd01ad..e13511816c 100644 --- a/src/controllers/admin/settings.js +++ b/src/controllers/admin/settings.js @@ -111,14 +111,6 @@ settingsController.social = async function (req, res) { }; settingsController.api = async (req, res) => { - const { tokens } = await meta.settings.get('core.api'); - const scores = await api.utils.tokens.getLastSeen(tokens.map(t => t.token)); - - const [lastSeen, lastSeenISO] = tokens.reduce((memo, cur, idx) => { - memo[0][cur.token] = scores[idx]; - memo[1][cur.token] = new Date(scores[idx]).toISOString(); - return memo; - }, [{}, {}]); - - res.render('admin/settings/api', { lastSeen, lastSeenISO }); + const tokens = await api.utils.tokens.list(); + res.render('admin/settings/api', { tokens }); }; diff --git a/src/controllers/write/admin.js b/src/controllers/write/admin.js index adac0a6ef2..6bb95e7492 100644 --- a/src/controllers/write/admin.js +++ b/src/controllers/write/admin.js @@ -28,3 +28,21 @@ Admin.getAnalyticsData = async (req, res) => { units: req.query.units, })); }; + +Admin.generateToken = async (req, res) => { + const { uid, description } = req.body; + helpers.formatApiResponse(200, res, await api.utils.tokens.generate({ uid, description })); +}; + +Admin.updateToken = async (req, res) => { + // todo: token rolling via req.body + const { uid, description } = req.body; + const { token } = req.params; + + helpers.formatApiResponse(200, res, await api.utils.tokens.update(token, { uid, description })); +}; + +Admin.deleteToken = async (req, res) => { + const { token } = req.params; + helpers.formatApiResponse(200, res, await api.utils.tokens.delete(token)); +}; diff --git a/src/routes/write/admin.js b/src/routes/write/admin.js index 0cda6327fb..65df00d574 100644 --- a/src/routes/write/admin.js +++ b/src/routes/write/admin.js @@ -15,5 +15,9 @@ module.exports = function () { setupApiRoute(router, 'get', '/analytics', [...middlewares], controllers.write.admin.getAnalyticsKeys); setupApiRoute(router, 'get', '/analytics/:set', [...middlewares], controllers.write.admin.getAnalyticsData); + setupApiRoute(router, 'post', '/tokens', [...middlewares], controllers.write.admin.generateToken); + setupApiRoute(router, 'put', '/tokens/:token', [...middlewares], controllers.write.admin.updateToken); + setupApiRoute(router, 'delete', '/tokens/:token', [...middlewares], controllers.write.admin.deleteToken); + return router; }; diff --git a/src/upgrades/3.1.0/migrate_api_tokens.js b/src/upgrades/3.1.0/migrate_api_tokens.js new file mode 100644 index 0000000000..dadf84e07d --- /dev/null +++ b/src/upgrades/3.1.0/migrate_api_tokens.js @@ -0,0 +1,38 @@ +'use strict'; + +const assert = require('assert'); +const winston = require('winston'); + +const db = require('../../database'); +const meta = require('../../meta'); +const api = require('../../api'); + +module.exports = { + name: 'Migrate tokens away from sorted-list implementation', + timestamp: Date.UTC(2023, 4, 2), + method: async () => { + const { tokens = [] } = await meta.settings.get('core.api'); + + await Promise.all(tokens.map(async (tokenObj) => { + const { token, uid, description } = tokenObj; + await api.utils.tokens.add({ token, uid, description }); + })); + + // Validate + const oldCount = await db.sortedSetCard('settings:core.api:sorted-list:tokens'); + const newCount = await db.sortedSetCard('tokens:createtime'); + try { + if (oldCount > 0) { + assert.strictEqual(oldCount, newCount); + } + + // Delete old tokens + await meta.settings.set('core.api', { + tokens: [], + }); + await db.delete('settings:core.api:sorted-lists'); + } catch (e) { + winston.warn('Old token count does not match migrated tokens count, leaving old tokens behind.'); + } + }, +}; diff --git a/src/views/admin/partials/api/sorted-list/item.tpl b/src/views/admin/partials/api/sorted-list/item.tpl deleted file mode 100644 index c34836e758..0000000000 --- a/src/views/admin/partials/api/sorted-list/item.tpl +++ /dev/null @@ -1,21 +0,0 @@ -
  • -
    -
    - {{{ if uid }}}uid {uid}{{{ else }}}master{{{ end }}} - {{{ if token }}}{{{ else }}}[[admin/settings/api:token-on-save]]{{{ end }}}
    -

    - {{{ if description }}} - {description} - {{{ else }}} - [[admin/settings/api:no-description]] - {{{ end }}} -
    - {./lastSeen} -

    -
    -
    - - -
    -
    -
  • \ No newline at end of file diff --git a/src/views/admin/partials/api/sorted-list/form.tpl b/src/views/admin/partials/edit-token-modal.tpl similarity index 71% rename from src/views/admin/partials/api/sorted-list/form.tpl rename to src/views/admin/partials/edit-token-modal.tpl index 2f129b43e4..87644f6dcd 100644 --- a/src/views/admin/partials/api/sorted-list/form.tpl +++ b/src/views/admin/partials/edit-token-modal.tpl @@ -1,15 +1,13 @@ -
    - - +
    - +

    [[admin/settings/api:uid-help-text]]

    - +
    \ No newline at end of file diff --git a/src/views/admin/settings/api.tpl b/src/views/admin/settings/api.tpl index 19af4cb568..1f76b5e97c 100644 --- a/src/views/admin/settings/api.tpl +++ b/src/views/admin/settings/api.tpl @@ -1,14 +1,64 @@

    [[admin/settings/api:lead-text]]

    [[admin/settings/api:intro]]

    -

    - +

    [[admin/settings/api:warning]]

    +

    + [[admin/settings/api:docs]] +

    -
    + + + + + + + + + + + + {{{ each tokens }}} + + + + + + + + {{{ end }}} + +
    [[admin/settings/api:token]][[admin/settings/api:description]][[admin/settings/api:uid]][[admin/settings/api:last-seen]][[admin/settings/api:created]]
    + + + {./token} + + {{{ if ./description }}} + {./description} + {{{ else }}} + [[admin/settings/api:no-description]] + {{{ end }}} + + {{{ if (./uid == "0") }}} + [[admin/settings/api:master-token]] + {{{ else }}} + {./uid} + {{{ end }}} + + {{{ if ./lastSeen }}} + + {{{ else }}} + [[admin/settings/api:last-seen-never]] + {{{ end }}} + + +
    [[admin/settings/api:settings]]
    @@ -20,17 +70,6 @@
    - -
    -
    [[admin/settings/api:tokens]]
    -
    -
    - -
      - -
      -
      -
      diff --git a/test/tokens.js b/test/tokens.js index a855794cb9..65ccfbffb9 100644 --- a/test/tokens.js +++ b/test/tokens.js @@ -81,10 +81,22 @@ describe('API tokens', () => { describe('.update()', () => { it('should update the description of a token', async () => { - await api.utils.tokens.update(token, { description: 'foobar' }); + await api.utils.tokens.update(token, { uid: 0, description: 'foobar' }); const tokenObj = await api.utils.tokens.get(token); assert(tokenObj); + assert.strictEqual(parseInt(tokenObj.uid, 10), 0); + assert.strictEqual(tokenObj.description, 'foobar'); + }); + + it('should update the uid of a token', async () => { + await api.utils.tokens.update(token, { uid: 1, description: 'foobar' }); + const tokenObj = await api.utils.tokens.get(token); + const uid = await db.sortedSetScore('tokens:uid', token); + + assert(tokenObj); + assert.strictEqual(parseInt(tokenObj.uid, 10), 1); + assert.strictEqual(parseInt(uid, 10), 1); assert.strictEqual(tokenObj.description, 'foobar'); }); });