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 description }}}
- {description}
- {{{ else }}}
- [[admin/settings/api:no-description]]
- {{{ end }}}
-
- {./lastSeen}
-