feat: token rolling API for admins

+ tests
isekai-main
Julian Lam 2 years ago
parent ce23caf7e6
commit 4f524e9f94

@ -22,5 +22,6 @@
"no-description": "No description specified.", "no-description": "No description specified.",
"actions": "Actions", "actions": "Actions",
"delete-confirm": "Are you sure you wish to delete this token? It will not be recoverable." "delete-confirm": "Are you sure you wish to delete this token? It will not be recoverable.",
"roll-confirm": "Are you sure you wish to regenerate this token? The old token will be immediately revoked and will not be recoverable."
} }

@ -0,0 +1,24 @@
TokenObject:
type: object
properties:
uid:
type: number
description: A valid user id
description:
type: string
description: Optional descriptor to differentiate tokens.
token:
type: string
description: An API token that can be called against this API via Bearer Authentication.
timestamp:
type: number
timestampISO:
type: string
description: An ISO 8601 formatted date string (complementing `timestamp`)
lastSeen:
type: number
nullable: true
lastSeenISO:
type: string
description: An ISO 8601 formatted date string (complementing `lastSeen`)
nullable: true

@ -11,14 +11,8 @@ get:
allOf: allOf:
- type: object - type: object
properties: properties:
lastSeen: tokens:
type: object type: array
description: A key-value set of API tokens and a UNIX timestamp of when it was last used items:
properties: {} $ref: ../../../components/schemas/admin/tokenObject.yaml#/TokenObject
additionalProperties: {}
lastSeenISO:
type: object
description: A key-value set of API tokens and an ISO 8601 formatted date string of when it was last used
properties: {}
additionalProperties: {}
- $ref: ../../../components/schemas/CommonProps.yaml#/CommonProps - $ref: ../../../components/schemas/CommonProps.yaml#/CommonProps

@ -198,6 +198,12 @@ paths:
$ref: 'write/admin/analytics.yaml' $ref: 'write/admin/analytics.yaml'
/admin/analytics/{set}: /admin/analytics/{set}:
$ref: 'write/admin/analytics/set.yaml' $ref: 'write/admin/analytics/set.yaml'
/admin/tokens:
$ref: 'write/admin/tokens.yaml'
/admin/tokens/{token}:
$ref: 'write/admin/tokens/token.yaml'
/admin/tokens/{token}/roll:
$ref: 'write/admin/tokens/token/roll.yaml'
/files/: /files/:
$ref: 'write/files.yaml' $ref: 'write/files.yaml'
/files/folder: /files/folder:

@ -0,0 +1,32 @@
post:
tags:
- admin
summary: create token
description: This operation creates a new API token for access to the Read and Write APIs.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
uid:
type: number
description: The generated token will make calls against NodeBB as this user.
example: 1
description:
type: string
description: Optional descriptor to differentiate tokens.
example: 'My new token.'
responses:
'200':
description: token successfully created
content:
application/json:
schema:
type: object
properties:
status:
$ref: ../../components/schemas/Status.yaml#/Status
response:
$ref: ../../components/schemas/admin/tokenObject.yaml#/TokenObject

@ -0,0 +1,89 @@
get:
tags:
- admin
summary: get token
description: This operation retrieves an API token and its associated metadata
parameters:
- in: path
name: token
schema:
type: string
required: true
description: a valid API token
example: 4eb506f8-a173-4693-a41b-e23604bc973a
responses:
'200':
description: token successfully retrieved
content:
application/json:
schema:
type: object
properties:
status:
$ref: ../../../components/schemas/Status.yaml#/Status
response:
$ref: ../../../components/schemas/admin/tokenObject.yaml#/TokenObject
put:
tags:
- admin
summary: update token
description: This operation updates a token's metadata.
parameters:
- in: path
name: token
schema:
type: string
required: true
description: a valid API token
example: 4eb506f8-a173-4693-a41b-e23604bc973a
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
uid:
type: number
description: The generated token will make calls against NodeBB as this user.
example: 1
description:
type: string
description: Optional descriptor to differentiate tokens.
example: 'My new token.'
responses:
'200':
description: Token metadata updated.
content:
application/json:
schema:
type: object
properties:
status:
$ref: ../../../components/schemas/Status.yaml#/Status
response:
$ref: ../../../components/schemas/admin/tokenObject.yaml#/TokenObject
delete:
tags:
- admin
summary: revoke token
description: This operation revokes a token and removes it from the database
parameters:
- in: path
name: token
schema:
type: string
required: true
description: a valid API token
example: 4eb506f8-a173-4693-a41b-e23604bc973a
responses:
'200':
description: Token metadata updated.
content:
application/json:
schema:
type: object
properties:
status:
$ref: ../../../components/schemas/Status.yaml#/Status
response: {}

@ -0,0 +1,25 @@
post:
tags:
- admin
summary: regenerate token
description: This operation regenerates an existing token. The previous token is immediately invalidated.
parameters:
- in: path
name: token
schema:
type: string
required: true
description: a valid API token
example: 4eb506f8-a173-4693-a41b-e23604bc973a
responses:
'200':
description: Token regenerated.
content:
application/json:
schema:
type: object
properties:
status:
$ref: ../../../../components/schemas/Status.yaml#/Status
response:
$ref: ../../../../components/schemas/admin/tokenObject.yaml#/TokenObject

@ -1,6 +1,6 @@
'use strict'; 'use strict';
define('admin/settings/api', ['settings', 'clipboard', 'bootbox', 'benchpress', 'api'], function (settings, clipboard, bootbox, Benchpress, api) { define('admin/settings/api', ['settings', 'clipboard', 'bootbox', 'benchpress', 'api', 'alerts'], function (settings, clipboard, bootbox, Benchpress, api, alerts) {
const ACP = {}; const ACP = {};
ACP.init = function () { ACP.init = function () {
@ -39,6 +39,10 @@ define('admin/settings/api', ['settings', 'clipboard', 'bootbox', 'benchpress',
case 'delete': case 'delete':
handleTokenDeletion(subselector); handleTokenDeletion(subselector);
break; break;
case 'roll':
handleTokenRolling(subselector);
break;
} }
} }
}); });
@ -57,24 +61,13 @@ define('admin/settings/api', ['settings', 'clipboard', 'bootbox', 'benchpress',
const description = formData.get('description'); const description = formData.get('description');
try { try {
const token = await api.post('/admin/tokens', { uid, description }); const tokenObj = await api.post('/admin/tokens', { uid, description });
if (!tokensTableBody) { if (!tokensTableBody) {
modal.modal('hide'); modal.modal('hide');
return ajaxify.refresh(); return ajaxify.refresh();
} }
const now = new Date(); ajaxify.data.tokens.push(tokenObj);
const tokenObj = {
token,
uid,
description,
timestamp: now.getTime(),
timestampISO: now.toISOString(),
lastSeen: null,
lastSeenISO: new Date(0).toISOString(),
};
ajaxify.data.tokens.append(tokenObj);
const rowEl = (await app.parseAndTranslate(ajaxify.data.template.name, 'tokens', { const rowEl = (await app.parseAndTranslate(ajaxify.data.template.name, 'tokens', {
tokens: [tokenObj], tokens: [tokenObj],
})).get(0); })).get(0);
@ -83,7 +76,7 @@ define('admin/settings/api', ['settings', 'clipboard', 'bootbox', 'benchpress',
$(rowEl).find('.timeago').timeago(); $(rowEl).find('.timeago').timeago();
modal.modal('hide'); modal.modal('hide');
} catch (e) { } catch (e) {
app.alertError(e); alerts.error(e);
} }
} }
@ -126,7 +119,7 @@ define('admin/settings/api', ['settings', 'clipboard', 'bootbox', 'benchpress',
$(newEl).find('.timeago').timeago(); $(newEl).find('.timeago').timeago();
modal.modal('hide'); modal.modal('hide');
} catch (e) { } catch (e) {
app.alertError(e); alerts.error(e);
} }
} }
@ -156,7 +149,7 @@ define('admin/settings/api', ['settings', 'clipboard', 'bootbox', 'benchpress',
try { try {
await api.del(`/admin/tokens/${token}`); await api.del(`/admin/tokens/${token}`);
} catch (e) { } catch (e) {
app.alertError(e); alerts.error(e);
} }
rowEl.remove(); rowEl.remove();
@ -164,5 +157,26 @@ define('admin/settings/api', ['settings', 'clipboard', 'bootbox', 'benchpress',
}); });
} }
async function handleTokenRolling(el) {
const rowEl = el.closest('[data-token]');
const token = rowEl.getAttribute('data-token');
bootbox.confirm('[[admin/settings/api:roll-confirm]]', async (ok) => {
if (ok) {
try {
const tokenObj = await api.post(`/admin/tokens/${token}/roll`);
const newEl = (await app.parseAndTranslate(ajaxify.data.template.name, 'tokens', {
tokens: [tokenObj],
})).get(0);
rowEl.replaceWith(newEl);
$(newEl).find('.timeago').timeago();
} catch (e) {
alerts.error(e);
}
}
});
}
return ACP; return ACP;
}); });

@ -18,9 +18,8 @@ const plugins = require('../plugins');
const events = require('../events'); const events = require('../events');
const translator = require('../translator'); const translator = require('../translator');
const sockets = require('../socket.io'); const sockets = require('../socket.io');
const utils = require('../utils');
const api = require('.'); // const api = require('.');
const usersAPI = module.exports; const usersAPI = module.exports;
@ -310,15 +309,18 @@ usersAPI.unmute = async function (caller, data) {
}; };
usersAPI.generateToken = async (caller, { uid, description }) => { usersAPI.generateToken = async (caller, { uid, description }) => {
const api = require('.');
await hasAdminPrivilege(caller.uid, 'settings'); await hasAdminPrivilege(caller.uid, 'settings');
if (parseInt(uid, 10) !== parseInt(caller.uid, 10)) { if (parseInt(uid, 10) !== parseInt(caller.uid, 10)) {
throw new Error('[[error:invalid-uid]]'); throw new Error('[[error:invalid-uid]]');
} }
return await api.utils.tokens.generate({ uid, description }); const tokenObj = await api.utils.tokens.generate({ uid, description });
return tokenObj.token;
}; };
usersAPI.deleteToken = async (caller, { uid, token }) => { usersAPI.deleteToken = async (caller, { uid, token }) => {
const api = require('.');
await hasAdminPrivilege(caller.uid, 'settings'); await hasAdminPrivilege(caller.uid, 'settings');
if (parseInt(uid, 10) !== parseInt(caller.uid, 10)) { if (parseInt(uid, 10) !== parseInt(caller.uid, 10)) {
throw new Error('[[error:invalid-uid]]'); throw new Error('[[error:invalid-uid]]');

@ -36,7 +36,7 @@ utils.tokens.get = async (tokens) => {
tokenObjs.forEach((tokenObj, idx) => { tokenObjs.forEach((tokenObj, idx) => {
tokenObj.token = tokens[idx]; tokenObj.token = tokens[idx];
tokenObj.lastSeen = lastSeen[idx]; tokenObj.lastSeen = lastSeen[idx];
tokenObj.lastSeenISO = new Date(lastSeen[idx]).toISOString(); tokenObj.lastSeenISO = lastSeen[idx] ? new Date(lastSeen[idx]).toISOString() : null;
tokenObj.timestampISO = new Date(parseInt(tokenObj.timestamp, 10)).toISOString(); tokenObj.timestampISO = new Date(parseInt(tokenObj.timestamp, 10)).toISOString();
}); });
@ -80,6 +80,28 @@ utils.tokens.update = async (token, { uid, description }) => {
return await utils.tokens.get(token); return await utils.tokens.get(token);
}; };
utils.tokens.roll = async (token) => {
const [createTime, uid, lastSeen] = await db.sortedSetsScore([`tokens:createtime`, `tokens:uid`, `tokens:lastSeen`], token);
const newToken = srcUtils.generateUUID();
const updates = [
db.rename(`token:${token}`, `token:${newToken}`),
db.sortedSetRemove(`tokens:createtime`, token),
db.sortedSetRemove(`tokens:uid`, token),
db.sortedSetRemove(`tokens:lastSeen`, token),
db.sortedSetAdd(`tokens:createtime`, createTime, newToken),
db.sortedSetAdd(`tokens:uid`, uid, newToken),
];
if (lastSeen) {
updates.push(db.sortedSetAdd(`tokens:lastSeen`, lastSeen, newToken));
}
await Promise.all(updates);
return newToken;
};
utils.tokens.delete = async (token) => { utils.tokens.delete = async (token) => {
await Promise.all([ await Promise.all([
db.delete(`token:${token}`), db.delete(`token:${token}`),

@ -31,7 +31,8 @@ Admin.getAnalyticsData = async (req, res) => {
Admin.generateToken = async (req, res) => { Admin.generateToken = async (req, res) => {
const { uid, description } = req.body; const { uid, description } = req.body;
helpers.formatApiResponse(200, res, await api.utils.tokens.generate({ uid, description })); const token = await api.utils.tokens.generate({ uid, description });
helpers.formatApiResponse(200, res, await api.utils.tokens.get(token));
}; };
Admin.getToken = async (req, res) => { Admin.getToken = async (req, res) => {
@ -39,13 +40,19 @@ Admin.getToken = async (req, res) => {
}; };
Admin.updateToken = async (req, res) => { Admin.updateToken = async (req, res) => {
// todo: token rolling via req.body
const { uid, description } = req.body; const { uid, description } = req.body;
const { token } = req.params; const { token } = req.params;
helpers.formatApiResponse(200, res, await api.utils.tokens.update(token, { uid, description })); helpers.formatApiResponse(200, res, await api.utils.tokens.update(token, { uid, description }));
}; };
Admin.rollToken = async (req, res) => {
let { token } = req.params;
token = await api.utils.tokens.roll(token);
helpers.formatApiResponse(200, res, await api.utils.tokens.get(token));
};
Admin.deleteToken = async (req, res) => { Admin.deleteToken = async (req, res) => {
const { token } = req.params; const { token } = req.params;
helpers.formatApiResponse(200, res, await api.utils.tokens.delete(token)); helpers.formatApiResponse(200, res, await api.utils.tokens.delete(token));

@ -10,6 +10,7 @@ const meta = require('../meta');
const controllers = require('../controllers'); const controllers = require('../controllers');
const helpers = require('../controllers/helpers'); const helpers = require('../controllers/helpers');
const plugins = require('../plugins'); const plugins = require('../plugins');
const api = require('../api');
const { generateToken } = require('../middleware/csrf'); const { generateToken } = require('../middleware/csrf');
let loginStrategies = []; let loginStrategies = [];
@ -45,8 +46,8 @@ Auth.getLoginStrategies = function () {
}; };
Auth.verifyToken = async function (token, done) { Auth.verifyToken = async function (token, done) {
const { tokens = [] } = await meta.settings.get('core.api'); const tokens = await api.utils.tokens.list();
const tokenObj = tokens.find(t => t.token === token); const tokenObj = tokens.filter((t => t.token === token)).pop();
const uid = tokenObj ? tokenObj.uid : undefined; const uid = tokenObj ? tokenObj.uid : undefined;
if (uid !== undefined) { if (uid !== undefined) {

@ -19,6 +19,7 @@ module.exports = function () {
setupApiRoute(router, 'get', '/tokens/:token', [...middlewares], controllers.write.admin.getToken); setupApiRoute(router, 'get', '/tokens/:token', [...middlewares], controllers.write.admin.getToken);
setupApiRoute(router, 'put', '/tokens/:token', [...middlewares], controllers.write.admin.updateToken); setupApiRoute(router, 'put', '/tokens/:token', [...middlewares], controllers.write.admin.updateToken);
setupApiRoute(router, 'delete', '/tokens/:token', [...middlewares], controllers.write.admin.deleteToken); setupApiRoute(router, 'delete', '/tokens/:token', [...middlewares], controllers.write.admin.deleteToken);
setupApiRoute(router, 'post', '/tokens/:token/roll', [...middlewares], controllers.write.admin.rollToken);
return router; return router;
}; };

@ -60,6 +60,9 @@
<button type="button" class="btn btn-link" data-action="edit"> <button type="button" class="btn btn-link" data-action="edit">
<i class="fa fa-edit"></i> <i class="fa fa-edit"></i>
</button> </button>
<button type="button" class="btn btn-link" data-action="roll">
<i class="fa fa-refresh"></i>
</button>
<button type="button" class="btn btn-link link-danger" data-action="delete"> <button type="button" class="btn btn-link link-danger" data-action="delete">
<i class="fa fa-trash"></i> <i class="fa fa-trash"></i>
</button> </button>

@ -56,8 +56,23 @@ describe('API', async () => {
example: '', // to be defined later... example: '', // to be defined later...
}, },
], ],
'/admin/tokens/{token}': [
{
in: 'path',
name: 'token',
example: '', // to be defined later...
},
],
},
post: {
'/admin/tokens/{token}/roll': [
{
in: 'path',
name: 'token',
example: '', // to be defined later...
},
],
}, },
post: {},
put: { put: {
'/groups/{slug}/pending/{uid}': [ '/groups/{slug}/pending/{uid}': [
{ {
@ -71,6 +86,13 @@ describe('API', async () => {
example: '', // to be defined later... example: '', // to be defined later...
}, },
], ],
'/admin/tokens/{token}': [
{
in: 'path',
name: 'token',
example: '', // to be defined later...
},
],
}, },
patch: {}, patch: {},
delete: { delete: {
@ -134,6 +156,13 @@ describe('API', async () => {
example: '', // to be defined later... example: '', // to be defined later...
}, },
], ],
'/admin/tokens/{token}': [
{
in: 'path',
name: 'token',
example: '', // to be defined later...
},
],
}, },
}; };
@ -170,6 +199,16 @@ describe('API', async () => {
} }
await groups.join('administrators', adminUid); await groups.join('administrators', adminUid);
// Create api token for testing read/updating/deletion
const token = await api.utils.tokens.generate({ uid: adminUid });
mocks.get['/admin/tokens/{token}'][0].example = token;
mocks.put['/admin/tokens/{token}'][0].example = token;
mocks.delete['/admin/tokens/{token}'][0].example = token;
// Create another token for testing rolling
const token2 = await api.utils.tokens.generate({ uid: adminUid });
mocks.post['/admin/tokens/{token}/roll'][0].example = token2;
// Create sample group // Create sample group
await groups.create({ await groups.create({
name: 'Test Group', name: 'Test Group',

Loading…
Cancel
Save