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>
|
||||
<input type="hidden" name="token" />
|
||||
<input type="hidden" name="timestamp" />
|
||||
<form role="form">
|
||||
<div class="mb-3">
|
||||
<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">
|
||||
[[admin/settings/api:uid-help-text]]
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<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>
|
||||
</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