mirror of
https://github.com/NodeBB/NodeBB.git
synced 2025-10-26 08:36:12 +01:00
feat: token rolling API for admins
+ tests
This commit is contained in:
@@ -22,5 +22,6 @@
|
||||
"no-description": "No description specified.",
|
||||
"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."
|
||||
}
|
||||
24
public/openapi/components/schemas/admin/tokenObject.yaml
Normal file
24
public/openapi/components/schemas/admin/tokenObject.yaml
Normal file
@@ -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:
|
||||
- type: object
|
||||
properties:
|
||||
lastSeen:
|
||||
type: object
|
||||
description: A key-value set of API tokens and a UNIX timestamp of when it was last used
|
||||
properties: {}
|
||||
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: {}
|
||||
tokens:
|
||||
type: array
|
||||
items:
|
||||
$ref: ../../../components/schemas/admin/tokenObject.yaml#/TokenObject
|
||||
- $ref: ../../../components/schemas/CommonProps.yaml#/CommonProps
|
||||
@@ -198,6 +198,12 @@ paths:
|
||||
$ref: 'write/admin/analytics.yaml'
|
||||
/admin/analytics/{set}:
|
||||
$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/:
|
||||
$ref: 'write/files.yaml'
|
||||
/files/folder:
|
||||
|
||||
32
public/openapi/write/admin/tokens.yaml
Normal file
32
public/openapi/write/admin/tokens.yaml
Normal file
@@ -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
|
||||
89
public/openapi/write/admin/tokens/token.yaml
Normal file
89
public/openapi/write/admin/tokens/token.yaml
Normal file
@@ -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: {}
|
||||
25
public/openapi/write/admin/tokens/token/roll.yaml
Normal file
25
public/openapi/write/admin/tokens/token/roll.yaml
Normal file
@@ -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';
|
||||
|
||||
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 = {};
|
||||
|
||||
ACP.init = function () {
|
||||
@@ -39,6 +39,10 @@ define('admin/settings/api', ['settings', 'clipboard', 'bootbox', 'benchpress',
|
||||
case 'delete':
|
||||
handleTokenDeletion(subselector);
|
||||
break;
|
||||
|
||||
case 'roll':
|
||||
handleTokenRolling(subselector);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -57,24 +61,13 @@ define('admin/settings/api', ['settings', 'clipboard', 'bootbox', 'benchpress',
|
||||
const description = formData.get('description');
|
||||
|
||||
try {
|
||||
const token = await api.post('/admin/tokens', { uid, description });
|
||||
|
||||
const tokenObj = await api.post('/admin/tokens', { uid, description });
|
||||
if (!tokensTableBody) {
|
||||
modal.modal('hide');
|
||||
return ajaxify.refresh();
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const tokenObj = {
|
||||
token,
|
||||
uid,
|
||||
description,
|
||||
timestamp: now.getTime(),
|
||||
timestampISO: now.toISOString(),
|
||||
lastSeen: null,
|
||||
lastSeenISO: new Date(0).toISOString(),
|
||||
};
|
||||
ajaxify.data.tokens.append(tokenObj);
|
||||
ajaxify.data.tokens.push(tokenObj);
|
||||
const rowEl = (await app.parseAndTranslate(ajaxify.data.template.name, 'tokens', {
|
||||
tokens: [tokenObj],
|
||||
})).get(0);
|
||||
@@ -83,7 +76,7 @@ define('admin/settings/api', ['settings', 'clipboard', 'bootbox', 'benchpress',
|
||||
$(rowEl).find('.timeago').timeago();
|
||||
modal.modal('hide');
|
||||
} catch (e) {
|
||||
app.alertError(e);
|
||||
alerts.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +119,7 @@ define('admin/settings/api', ['settings', 'clipboard', 'bootbox', 'benchpress',
|
||||
$(newEl).find('.timeago').timeago();
|
||||
modal.modal('hide');
|
||||
} catch (e) {
|
||||
app.alertError(e);
|
||||
alerts.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +149,7 @@ define('admin/settings/api', ['settings', 'clipboard', 'bootbox', 'benchpress',
|
||||
try {
|
||||
await api.del(`/admin/tokens/${token}`);
|
||||
} catch (e) {
|
||||
app.alertError(e);
|
||||
alerts.error(e);
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -18,9 +18,8 @@ const plugins = require('../plugins');
|
||||
const events = require('../events');
|
||||
const translator = require('../translator');
|
||||
const sockets = require('../socket.io');
|
||||
const utils = require('../utils');
|
||||
|
||||
const api = require('.');
|
||||
// const api = require('.');
|
||||
|
||||
const usersAPI = module.exports;
|
||||
|
||||
@@ -310,15 +309,18 @@ usersAPI.unmute = async function (caller, data) {
|
||||
};
|
||||
|
||||
usersAPI.generateToken = async (caller, { uid, description }) => {
|
||||
const api = require('.');
|
||||
await hasAdminPrivilege(caller.uid, 'settings');
|
||||
if (parseInt(uid, 10) !== parseInt(caller.uid, 10)) {
|
||||
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 }) => {
|
||||
const api = require('.');
|
||||
await hasAdminPrivilege(caller.uid, 'settings');
|
||||
if (parseInt(uid, 10) !== parseInt(caller.uid, 10)) {
|
||||
throw new Error('[[error:invalid-uid]]');
|
||||
|
||||
@@ -36,7 +36,7 @@ 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.lastSeenISO = lastSeen[idx] ? new Date(lastSeen[idx]).toISOString() : null;
|
||||
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);
|
||||
};
|
||||
|
||||
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) => {
|
||||
await Promise.all([
|
||||
db.delete(`token:${token}`),
|
||||
|
||||
@@ -31,7 +31,8 @@ Admin.getAnalyticsData = async (req, res) => {
|
||||
|
||||
Admin.generateToken = async (req, res) => {
|
||||
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) => {
|
||||
@@ -39,13 +40,19 @@ Admin.getToken = async (req, res) => {
|
||||
};
|
||||
|
||||
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.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) => {
|
||||
const { token } = req.params;
|
||||
helpers.formatApiResponse(200, res, await api.utils.tokens.delete(token));
|
||||
|
||||
@@ -10,6 +10,7 @@ const meta = require('../meta');
|
||||
const controllers = require('../controllers');
|
||||
const helpers = require('../controllers/helpers');
|
||||
const plugins = require('../plugins');
|
||||
const api = require('../api');
|
||||
const { generateToken } = require('../middleware/csrf');
|
||||
|
||||
let loginStrategies = [];
|
||||
@@ -45,8 +46,8 @@ Auth.getLoginStrategies = function () {
|
||||
};
|
||||
|
||||
Auth.verifyToken = async function (token, done) {
|
||||
const { tokens = [] } = await meta.settings.get('core.api');
|
||||
const tokenObj = tokens.find(t => t.token === token);
|
||||
const tokens = await api.utils.tokens.list();
|
||||
const tokenObj = tokens.filter((t => t.token === token)).pop();
|
||||
const uid = tokenObj ? tokenObj.uid : undefined;
|
||||
|
||||
if (uid !== undefined) {
|
||||
|
||||
@@ -19,6 +19,7 @@ module.exports = function () {
|
||||
setupApiRoute(router, 'get', '/tokens/:token', [...middlewares], controllers.write.admin.getToken);
|
||||
setupApiRoute(router, 'put', '/tokens/:token', [...middlewares], controllers.write.admin.updateToken);
|
||||
setupApiRoute(router, 'delete', '/tokens/:token', [...middlewares], controllers.write.admin.deleteToken);
|
||||
setupApiRoute(router, 'post', '/tokens/:token/roll', [...middlewares], controllers.write.admin.rollToken);
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
<button type="button" class="btn btn-link" data-action="edit">
|
||||
<i class="fa fa-edit"></i>
|
||||
</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">
|
||||
<i class="fa fa-trash"></i>
|
||||
</button>
|
||||
|
||||
41
test/api.js
41
test/api.js
@@ -56,8 +56,23 @@ describe('API', async () => {
|
||||
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: {
|
||||
'/groups/{slug}/pending/{uid}': [
|
||||
{
|
||||
@@ -71,6 +86,13 @@ describe('API', async () => {
|
||||
example: '', // to be defined later...
|
||||
},
|
||||
],
|
||||
'/admin/tokens/{token}': [
|
||||
{
|
||||
in: 'path',
|
||||
name: 'token',
|
||||
example: '', // to be defined later...
|
||||
},
|
||||
],
|
||||
},
|
||||
patch: {},
|
||||
delete: {
|
||||
@@ -134,6 +156,13 @@ describe('API', async () => {
|
||||
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);
|
||||
|
||||
// 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
|
||||
await groups.create({
|
||||
name: 'Test Group',
|
||||
|
||||
Reference in New Issue
Block a user