Merge branch 'develop' of https://github.com/NodeBB/NodeBB into develop
commit
1f03d41471
@ -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
|
@ -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
|
@ -0,0 +1,7 @@
|
|||||||
|
.template-admin-settings-api {
|
||||||
|
[data-action="copy"]:active {
|
||||||
|
i::before {
|
||||||
|
content: '\f00c';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
@ -1,21 +0,0 @@
|
|||||||
<li data-type="item" class="list-group-item">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-9">
|
|
||||||
<span class="badge bg-primary">{{{ if uid }}}uid {uid}{{{ else }}}master{{{ end }}}</span>
|
|
||||||
{{{ if token }}}<input class="form-control-plaintext" type="text" readonly="readonly" value="{token}" size="32" />{{{ else }}}<em class="text-warning">[[admin/settings/api:token-on-save]]</em>{{{ end }}}<br />
|
|
||||||
<p>
|
|
||||||
{{{ if description }}}
|
|
||||||
{description}
|
|
||||||
{{{ else }}}
|
|
||||||
<em>[[admin/settings/api:no-description]]</em>
|
|
||||||
{{{ end }}}
|
|
||||||
<br />
|
|
||||||
<small class="text-info">{./lastSeen}</small>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-3 text-end">
|
|
||||||
<button type="button" data-type="edit" class="btn btn-info">Edit</button>
|
|
||||||
<button type="button" data-type="remove" class="btn btn-danger">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
@ -1,15 +1,13 @@
|
|||||||
<form>
|
<form role="form">
|
||||||
<input type="hidden" name="token" />
|
|
||||||
<input type="hidden" name="timestamp" />
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label" for="uid">[[admin/settings/api:uid]]</label>
|
<label class="form-label" for="uid">[[admin/settings/api:uid]]</label>
|
||||||
<input type="text" inputmode="numeric" pattern="\d+" name="uid" class="form-control" placeholder="1" />
|
<input type="text" inputmode="numeric" pattern="\d+" name="uid" class="form-control" placeholder="0" value="{./uid}" />
|
||||||
<p class="form-text">
|
<p class="form-text">
|
||||||
[[admin/settings/api:uid-help-text]]
|
[[admin/settings/api:uid-help-text]]
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label" for="description">[[admin/settings/api:description]]</label>
|
<label class="form-label" for="description">[[admin/settings/api:description]]</label>
|
||||||
<input type="text" name="description" class="form-control" placeholder="Description" />
|
<input type="text" name="description" class="form-control" placeholder="Description" value="{./description}" />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
@ -0,0 +1,178 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const nconf = require('nconf');
|
||||||
|
|
||||||
|
const db = require('./mocks/databasemock');
|
||||||
|
const api = require('../src/api');
|
||||||
|
const user = require('../src/user');
|
||||||
|
const utils = require('../src/utils');
|
||||||
|
|
||||||
|
describe('API tokens', () => {
|
||||||
|
let token;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Generate a different token for use in each test
|
||||||
|
token = await api.utils.tokens.generate({ uid: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('.list()', () => {
|
||||||
|
it('should list available tokens', async () => {
|
||||||
|
await api.utils.tokens.generate({ uid: 0 });
|
||||||
|
const tokens = await api.utils.tokens.list();
|
||||||
|
|
||||||
|
assert(Array.isArray(tokens));
|
||||||
|
assert.strictEqual(tokens.length, 2);
|
||||||
|
assert.strictEqual(parseInt(tokens[0].uid, 10), 0);
|
||||||
|
assert.strictEqual(parseInt(tokens[1].uid, 10), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('.create()', () => {
|
||||||
|
it('should fail to create a token for a user that does not exist', async () => {
|
||||||
|
assert.rejects(api.utils.tokens.generate({ uid: 1 }), { message: '[[error:no-user]]' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a token for a user that exists', async () => {
|
||||||
|
const uid = await user.create({ username: utils.generateUUID().slice(0, 8) });
|
||||||
|
const token = await api.utils.tokens.generate({ uid });
|
||||||
|
const tokenObj = await api.utils.tokens.get(token);
|
||||||
|
|
||||||
|
assert(tokenObj);
|
||||||
|
assert.strictEqual(parseInt(tokenObj.uid, 10), uid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('.get()', () => {
|
||||||
|
it('should retrieve a token', async () => {
|
||||||
|
const tokenObj = await api.utils.tokens.get(token);
|
||||||
|
|
||||||
|
assert(tokenObj);
|
||||||
|
assert.strictEqual(parseInt(tokenObj.uid, 10), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should retrieve multiple tokens', async () => {
|
||||||
|
const second = await api.utils.tokens.generate({ uid: 0 });
|
||||||
|
const tokens = await api.utils.tokens.get([token, second]);
|
||||||
|
|
||||||
|
assert(Array.isArray(tokens));
|
||||||
|
tokens.forEach((t) => {
|
||||||
|
assert(t);
|
||||||
|
assert.strictEqual(parseInt(t.uid, 10), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail if you pass in invalid data', async () => {
|
||||||
|
assert.rejects(api.utils.tokens.get(token), { message: '[[error:invalid-data]]' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show lastSeen and lastSeenISO as undefined/null if it is a new token', async () => {
|
||||||
|
const { lastSeen, lastSeenISO } = await api.utils.tokens.get(token);
|
||||||
|
|
||||||
|
assert.strictEqual(lastSeen, null);
|
||||||
|
assert.strictEqual(lastSeenISO, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show lastSeenISO as an ISO formatted datetime string if the token has been used', async () => {
|
||||||
|
const now = new Date();
|
||||||
|
await db.sortedSetAdd('tokens:lastSeen', now.getTime(), token);
|
||||||
|
const { lastSeen, lastSeenISO } = await api.utils.tokens.get(token);
|
||||||
|
|
||||||
|
assert.strictEqual(lastSeen, now.getTime());
|
||||||
|
assert.strictEqual(lastSeenISO, now.toISOString());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('.generate()', () => {
|
||||||
|
it('should generate a new token', async () => {
|
||||||
|
const second = await api.utils.tokens.generate({ uid: 0 });
|
||||||
|
const token = await api.utils.tokens.get(second);
|
||||||
|
|
||||||
|
assert(token);
|
||||||
|
assert(await db.exists(`token:${second}`));
|
||||||
|
assert.equal(await db.sortedSetScore(`tokens:uid`, second), 0);
|
||||||
|
assert.strictEqual(parseInt(token.uid, 10), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('.update()', () => {
|
||||||
|
it('should update the description of a token', async () => {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('.roll()', () => {
|
||||||
|
it('should invalidate the old token', async () => {
|
||||||
|
const newToken = await api.utils.tokens.roll(token);
|
||||||
|
assert(newToken);
|
||||||
|
|
||||||
|
const gets = await api.utils.tokens.get([token, newToken]);
|
||||||
|
assert.strictEqual(gets[0], null);
|
||||||
|
assert(gets[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should change a token but leave all other metadata intact', async () => {
|
||||||
|
await api.utils.tokens.update(token, { uid: 1, description: 'foobar' });
|
||||||
|
const newToken = await api.utils.tokens.roll(token);
|
||||||
|
const tokenObj = await api.utils.tokens.get(newToken);
|
||||||
|
|
||||||
|
assert.strictEqual(parseInt(tokenObj.uid, 10), 1);
|
||||||
|
assert.strictEqual(tokenObj.description, 'foobar');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('.delete()', () => {
|
||||||
|
it('should delete a token from a system', async () => {
|
||||||
|
await api.utils.tokens.delete(token);
|
||||||
|
|
||||||
|
assert.strictEqual(await db.exists(`token:${token}`), false);
|
||||||
|
assert.strictEqual(await db.sortedSetScore(`tokens:uid`, token), null);
|
||||||
|
assert.strictEqual(await db.sortedSetScore(`tokens:createtime`, token), null);
|
||||||
|
assert.strictEqual(await db.sortedSetScore(`tokens:lastSeen`, token), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('.log()', () => {
|
||||||
|
it('should record the current time as the last seen time of that token', async () => {
|
||||||
|
await api.utils.tokens.log(token);
|
||||||
|
|
||||||
|
assert(await db.sortedSetScore(`tokens:lastSeen`, token));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('.getLastSeen()', () => {
|
||||||
|
it('should retrieve the time the token was last seen', async () => {
|
||||||
|
await api.utils.tokens.log(token);
|
||||||
|
const time = await api.utils.tokens.getLastSeen([token]);
|
||||||
|
|
||||||
|
assert(time[0]);
|
||||||
|
assert(isFinite(time[0]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null if the token has never been seen', async () => {
|
||||||
|
const time = await api.utils.tokens.getLastSeen([token]);
|
||||||
|
|
||||||
|
assert.strictEqual(time[0], null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await api.utils.tokens.delete(token);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue