mirror of
				https://github.com/NodeBB/NodeBB.git
				synced 2025-10-31 11:05:54 +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