1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-20 00:08:02 +01:00

Feat: Api-Tokens (#774)

fixes: #774
This commit is contained in:
Ivar Conradi Østhus 2021-03-29 19:58:11 +02:00 committed by GitHub
parent f2de0aba65
commit dfb890c638
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1101 additions and 19 deletions

View File

@ -95,7 +95,7 @@
"prom-client": "^13.1.0",
"response-time": "^2.3.2",
"serve-favicon": "^2.5.0",
"unleash-frontend": "3.14.1",
"unleash-frontend": "3.15.0",
"uuid": "^8.3.2",
"yargs": "^16.0.3"
},

View File

@ -1,5 +1,7 @@
import { responseTimeMetrics } from './middleware/response-time-metrics';
import rbacMiddleware from './middleware/rbac-middleware';
import apiTokenMiddleware from './middleware/api-token-middleware';
import { AuthenticationType } from './types/core';
const express = require('express');
@ -48,20 +50,32 @@ module.exports = function(config, services = {}) {
app.use(`${baseUriPath}/oas`, express.static('docs/api/oas'));
}
if (config.adminAuthentication === 'unsecure') {
if (config.adminAuthentication === AuthenticationType.none) {
noAuthentication(baseUriPath, app);
}
// Deprecated. Will go away in v4.
if (config.adminAuthentication === AuthenticationType.unsecure) {
app.use(baseUriPath, apiTokenMiddleware(config, services));
simpleAuthentication(baseUriPath, app);
}
if (config.adminAuthentication === 'none') {
noAuthentication(baseUriPath, app);
if (config.adminAuthentication === AuthenticationType.enterprise) {
app.use(baseUriPath, apiTokenMiddleware(config, services));
config.authentication.customHook(app, config, services);
}
if (config.adminAuthentication === AuthenticationType.custom) {
app.use(baseUriPath, apiTokenMiddleware(config, services));
config.authentication.customHook(app, config, services);
}
app.use(baseUriPath, rbacMiddleware(config, services));
if (typeof config.preRouterHook === 'function') {
config.preRouterHook(app);
}
app.use(baseUriPath, rbacMiddleware(config, services));
// Setup API routes
app.use(`${baseUriPath}/`, new IndexRouter(config, services).router);

View File

@ -0,0 +1,120 @@
import { EventEmitter } from 'events';
import { Knex } from 'knex';
import metricsHelper from '../metrics-helper';
import { DB_TIME } from '../events';
import { Logger, LogProvider } from '../logger';
import NotFoundError from '../error/notfound-error';
const TABLE = 'api_tokens';
interface ITokenTable {
id: number;
secret: string;
username: string;
type: ApiTokenType;
expires_at?: Date;
created_at: Date;
seen_at?: Date;
}
export enum ApiTokenType {
CLIENT = 'client',
ADMIN = 'admin',
}
export interface IApiTokenCreate {
secret: string;
username: string;
type: ApiTokenType;
expiresAt?: Date;
}
export interface IApiToken extends IApiTokenCreate {
createdAt: Date;
seenAt?: Date;
}
const toRow = (newToken: IApiTokenCreate) => ({
username: newToken.username,
secret: newToken.secret,
type: newToken.type,
expires_at: newToken.expiresAt,
});
const toToken = (row: ITokenTable): IApiToken => ({
secret: row.secret,
username: row.username,
type: row.type,
expiresAt: row.expires_at,
createdAt: row.created_at,
});
export class ApiTokenStore {
private logger: Logger;
private timer: Function;
private db: Knex;
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
this.db = db;
this.logger = getLogger('api-tokens.js');
this.timer = (action: string) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'api-tokens',
action,
});
}
async getAll(): Promise<IApiToken[]> {
const stopTimer = this.timer('getAll');
const rows = await this.db<ITokenTable>(TABLE);
stopTimer();
return rows.map(toToken);
}
async getAllActive(): Promise<IApiToken[]> {
const stopTimer = this.timer('getAllActive');
const rows = await this.db<ITokenTable>(TABLE)
.where('expires_at', '>', new Date())
.orWhere('expires_at', 'IS', null);
stopTimer();
return rows.map(toToken);
}
async insert(newToken: IApiTokenCreate): Promise<IApiToken> {
const [row] = await this.db<ITokenTable>(TABLE).insert(
toRow(newToken),
['created_at'],
);
return { ...newToken, createdAt: row.created_at };
}
async delete(secret: string): Promise<void> {
return this.db<ITokenTable>(TABLE)
.where({ secret })
.del();
}
async setExpiry(secret: string, expiresAt: Date): Promise<IApiToken> {
const rows = await this.db<ITokenTable>(TABLE)
.update({ expires_at: expiresAt })
.where({ secret })
.returning('*');
if (rows.length > 0) {
return toToken(rows[0]);
}
throw new NotFoundError('Could not find api-token.');
}
async markSeenAt(secrets: string[]): Promise<void> {
const now = new Date();
try {
await this.db(TABLE)
.whereIn('secrets', secrets)
.update({ seen_at: now });
} catch (err) {
this.logger.error('Could not update lastSeen, error: ', err);
}
}
}

View File

@ -131,11 +131,11 @@ class FeatureToggleStore {
return rows.map(this.rowToFeature);
}
async lastSeenToggles(togleNames) {
async lastSeenToggles(toggleNames) {
const now = new Date();
try {
await this.db(TABLE)
.whereIn('name', togleNames)
.whereIn('name', toggleNames)
.update({ last_seen_at: now });
} catch (err) {
this.logger.error('Could not update lastSeen, error: ', err);

View File

@ -19,6 +19,7 @@ const ProjectStore = require('./project-store');
const TagStore = require('./tag-store');
const TagTypeStore = require('./tag-type-store');
const AddonStore = require('./addon-store');
const { ApiTokenStore } = require('./api-token-store');
module.exports.createStores = (config, eventBus) => {
const { getLogger } = config;
@ -55,5 +56,6 @@ module.exports.createStores = (config, eventBus) => {
tagTypeStore: new TagTypeStore(db, eventBus, getLogger),
addonStore: new AddonStore(db, eventBus, getLogger),
accessStore: new AccessStore(db, eventBus, getLogger),
apiTokenStore: new ApiTokenStore(db, eventBus, getLogger),
};
};

View File

@ -0,0 +1,161 @@
import test from 'ava';
import sinon from 'sinon';
import apiTokenMiddleware from './api-token-middleware';
import getLogger from '../../test/fixtures/no-logger';
import User from '../user';
import { CLIENT } from '../permissions';
let config: any;
test.beforeEach(() => {
config = {
getLogger,
authentication: {
enableApiToken: true,
},
};
});
test('should not do anything if request does not contain a authorization', async t => {
const apiTokenService = {
getUserForToken: sinon.fake(),
};
const func = apiTokenMiddleware(config, { apiTokenService });
const cb = sinon.fake();
const req = {
header: sinon.fake(),
};
await func(req, undefined, cb);
t.true(req.header.calledOnce);
t.true(cb.calledOnce);
});
test('should not add user if unknown token', async t => {
const apiTokenService = {
getUserForToken: sinon.fake(),
};
const func = apiTokenMiddleware(config, { apiTokenService });
const cb = sinon.fake();
const req = {
header: sinon.fake.returns('some-token'),
user: undefined,
};
await func(req, undefined, cb);
t.true(cb.called);
t.true(req.header.called);
t.falsy(req.user);
});
test('should add user if unknown token', async t => {
const apiUser = new User({
isAPI: true,
username: 'default',
permissions: [CLIENT],
});
const apiTokenService = {
getUserForToken: sinon.fake.returns(apiUser),
};
const func = apiTokenMiddleware(config, { apiTokenService });
const cb = sinon.fake();
const req = {
header: sinon.fake.returns('some-known-token'),
user: undefined,
};
await func(req, undefined, cb);
t.true(cb.called);
t.true(req.header.called);
t.is(req.user, apiUser);
});
test('should not add user if disabled', async t => {
const apiUser = new User({
isAPI: true,
username: 'default',
permissions: [CLIENT],
});
const apiTokenService = {
getUserForToken: sinon.fake.returns(apiUser),
};
const disabledConfig = {
getLogger,
authentication: {
enableApiToken: false,
},
};
const func = apiTokenMiddleware(disabledConfig, { apiTokenService });
const cb = sinon.fake();
const req = {
header: sinon.fake.returns('some-known-token'),
user: undefined,
};
await func(req, undefined, cb);
t.true(cb.called);
t.falsy(req.user);
});
test('should call next if apiTokenService throws', async t => {
getLogger.setMuteError(true);
const apiTokenService = {
getUserForToken: () => {
throw new Error('hi there, i am stupid');
},
};
const func = apiTokenMiddleware(config, { apiTokenService });
const cb = sinon.fake();
const req = {
header: sinon.fake.returns('some-token'),
user: undefined,
};
await func(req, undefined, cb);
t.true(cb.called);
getLogger.setMuteError(false);
});
test('should call next if apiTokenService throws x2', async t => {
const apiTokenService = {
getUserForToken: () => {
throw new Error('hi there, i am stupid');
},
};
const func = apiTokenMiddleware(config, { apiTokenService });
const cb = sinon.fake();
const req = {
header: sinon.fake.returns('some-token'),
user: undefined,
};
await func(req, undefined, cb);
t.true(cb.called);
});

View File

@ -0,0 +1,35 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { IUnleashConfig } from '../types/core';
const apiAccessMiddleware = (
config: IUnleashConfig,
{ apiTokenService }: any,
): any => {
const logger = config.getLogger('/middleware/api-token.ts');
logger.info('Enabling api-token middleware');
if(!config.authentication.enableApiToken) {
return (req, res, next) => next();
}
return (req, res, next) => {
if (req.user) {
return next();
}
try {
const userToken = req.header('authorization');
const user = apiTokenService.getUserForToken(userToken);
if (user) {
req.user = user;
}
} catch (error) {
logger.error(error);
}
return next();
};
};
module.exports = apiAccessMiddleware;
export default apiAccessMiddleware;

View File

@ -66,8 +66,8 @@ function defaultOptions() {
baseUriPath: process.env.BASE_URI_PATH || '',
unleashUrl: process.env.UNLEASH_URL || 'http://localhost:4242',
serverMetrics: true,
enableLegacyRoutes: false,
extendedPermissions: false,
enableLegacyRoutes: false, // deprecated. Remove in v4,
extendedPermissions: false, // deprecated. Remove in v4,
publicFolder,
versionCheck: {
url:
@ -76,13 +76,18 @@ function defaultOptions() {
enable: process.env.CHECK_VERSION || 'true',
},
enableRequestLogger: false,
adminAuthentication: process.env.ADMIN_AUTHENTICATION || 'unsecure',
adminAuthentication: process.env.ADMIN_AUTHENTICATION || 'unsecure', // deprecated. Remove in v4,
authentication: {
enableApiToken: process.env.AUTH_ENABLE_API_TOKEN || true,
type: process.env.AUTH_TYPE || 'open-source',
customHook: () => {},
},
ui: {},
importFile: process.env.IMPORT_FILE,
importKeepExisting: process.env.IMPORT_KEEP_EXISTING || false,
dropBeforeImport: process.env.IMPORT_DROP_BEFORE_IMPORT || false,
getLogger: defaultLogProvider,
customContextFields: [],
customContextFields: [], // deprecated. Remove in v4,
disableDBMigration: false,
start: true,
keepAliveTimeout: 60 * 1000,

View File

@ -20,6 +20,9 @@ const UPDATE_ADDON = 'UPDATE_ADDON';
const DELETE_ADDON = 'DELETE_ADDON';
const READ_ROLE = 'READ_ROLE';
const UPDATE_ROLE = 'UPDATE_ROLE';
const UPDATE_API_TOKEN = 'UPDATE_API_TOKEN';
const CREATE_API_TOKEN = 'CREATE_API_TOKEN';
const DELETE_API_TOKEN = 'DELETE_API_TOKEN';
module.exports = {
ADMIN,
@ -42,4 +45,7 @@ module.exports = {
UPDATE_ADDON,
READ_ROLE,
UPDATE_ROLE,
CREATE_API_TOKEN,
UPDATE_API_TOKEN,
DELETE_API_TOKEN,
};

View File

@ -0,0 +1,144 @@
import { Response } from 'express';
import Controller from '../controller';
import {
ADMIN,
CREATE_API_TOKEN,
DELETE_API_TOKEN,
UPDATE_API_TOKEN,
} from '../../permissions';
import { ApiTokenService } from '../../services/api-token-service';
import { Logger, LogProvider } from '../../logger';
import { ApiTokenType } from '../../db/api-token-store';
import { AccessService } from '../../services/access-service';
import { IAuthRequest } from '../unleash-types';
import { isRbacEnabled } from '../../util/feature-enabled';
import User from '../../user';
interface IExperimentalFlags {
[key: string]: boolean;
}
interface IConfig {
getLogger: LogProvider;
extendedPermissions: boolean;
experimental: IExperimentalFlags;
}
interface IServices {
apiTokenService: ApiTokenService;
accessService: AccessService;
}
class ApiTokenController extends Controller {
private apiTokenService: ApiTokenService;
private accessService: AccessService;
private extendedPermissions: boolean;
private isRbacEnabled: boolean;
private logger: Logger;
constructor(config: IConfig, services: IServices) {
super(config);
this.apiTokenService = services.apiTokenService;
this.accessService = services.accessService;
this.extendedPermissions = config.extendedPermissions;
this.isRbacEnabled = isRbacEnabled(config);
this.logger = config.getLogger('api-token-controller.js');
this.get('/', this.getAllApiTokens);
this.post('/', this.createApiToken, CREATE_API_TOKEN);
this.put('/:token', this.updateApiToken, UPDATE_API_TOKEN);
this.delete('/:token', this.deleteApiToken, DELETE_API_TOKEN);
}
private isTokenAdmin(user: User) {
if (this.isRbacEnabled) {
return this.accessService.hasPermission(user, UPDATE_API_TOKEN);
}
if (this.extendedPermissions) {
return user.permissions.some(
t => t === UPDATE_API_TOKEN || t === ADMIN,
);
}
return true;
}
async getAllApiTokens(req: IAuthRequest, res: Response): Promise<void> {
const { user } = req;
const isAdmin = this.isTokenAdmin(user);
const tokens = await this.apiTokenService.getAllTokens();
if (isAdmin) {
res.json({ tokens });
} else {
const filteredTokens = tokens.filter(
t => !(t.type === ApiTokenType.ADMIN),
);
res.json({ tokens: filteredTokens });
}
}
async createApiToken(req: IAuthRequest, res: Response): Promise<any> {
const { username, type, expiresAt } = req.body;
if (!username || !type) {
this.logger.error(req.body);
return res.status(400).send();
}
const tokenType =
type.toLowerCase() === 'admin'
? ApiTokenType.ADMIN
: ApiTokenType.CLIENT;
try {
const token = await this.apiTokenService.creteApiToken({
type: tokenType,
username,
expiresAt,
});
return res.status(201).json(token);
} catch (error) {
this.logger.error('error creating api-token', error);
return res.status(500);
}
}
async deleteApiToken(req: IAuthRequest, res: Response): Promise<void> {
const { token } = req.params;
try {
await this.apiTokenService.delete(token);
res.status(200).end();
} catch (error) {
this.logger.error('error creating api-token', error);
res.status(500);
}
}
async updateApiToken(req: IAuthRequest, res: Response): Promise<any> {
const { token } = req.params;
const { expiresAt } = req.body;
if (!expiresAt) {
this.logger.error(req.body);
return res.status(400).send();
}
try {
await this.apiTokenService.updateExpiry(token, expiresAt);
return res.status(200).end();
} catch (error) {
this.logger.error('error creating api-token', error);
return res.status(500);
}
}
}
module.exports = ApiTokenController;
export default ApiTokenController;

View File

@ -14,6 +14,7 @@ const StateController = require('./state');
const TagController = require('./tag');
const TagTypeController = require('./tag-type');
const AddonController = require('./addon');
const ApiTokenController = require('./api-token-controller');
const apiDef = require('./api-def.json');
class AdminApi extends Controller {
@ -58,6 +59,10 @@ class AdminApi extends Controller {
new TagTypeController(config, services).router,
);
this.app.use('/addons', new AddonController(config, services).router);
this.app.use(
'/api-tokens',
new ApiTokenController(config, services).router,
);
}
index(req, res) {

View File

@ -0,0 +1,6 @@
import { Request } from 'express';
import User from '../user';
export interface IAuthRequest extends Request {
user: User;
}

View File

@ -0,0 +1,106 @@
import crypto from 'crypto';
import { ApiTokenStore, IApiToken, ApiTokenType } from '../db/api-token-store';
import { Logger, LogProvider } from '../logger';
import { ADMIN, CLIENT } from '../permissions';
import User from '../user';
const ONE_MINUTE = 60_000;
interface IStores {
apiTokenStore: ApiTokenStore;
settingStore: any;
}
interface IConfig {
getLogger: LogProvider;
baseUriPath: string;
}
interface CreateTokenRequest {
username: string;
type: ApiTokenType;
expiresAt?: Date;
}
export class ApiTokenService {
private store: ApiTokenStore;
private config: IConfig;
private logger: Logger;
private timer: NodeJS.Timeout;
private activeTokens: IApiToken[] = [];
constructor(stores: IStores, config: IConfig) {
this.store = stores.apiTokenStore;
this.config = config;
this.logger = config.getLogger('/services/api-token-service.ts');
this.fetchActiveTokens();
this.timer = setInterval(
() => this.fetchActiveTokens(),
ONE_MINUTE,
).unref();
}
private async fetchActiveTokens(): Promise<void> {
try {
this.activeTokens = await this.getAllActiveTokens();
} finally {
// eslint-disable-next-line no-unsafe-finally
return;
}
}
public async getAllTokens(): Promise<IApiToken[]> {
return this.store.getAll();
}
public async getAllActiveTokens(): Promise<IApiToken[]> {
return this.store.getAllActive();
}
public getUserForToken(secret: string): User | undefined {
const token = this.activeTokens.find(t => t.secret === secret);
if (token) {
const permissions =
token.type === ApiTokenType.ADMIN ? [ADMIN] : [CLIENT];
return new User({
isAPI: true,
username: token.username,
permissions,
});
}
return undefined;
}
public async updateExpiry(
secret: string,
expiresAt: Date,
): Promise<IApiToken> {
return this.store.setExpiry(secret, expiresAt);
}
public async delete(secret: string): Promise<void> {
return this.store.delete(secret);
}
public async creteApiToken(
creteTokenRequest: CreateTokenRequest,
): Promise<IApiToken> {
const secret = this.generateSecretKey();
const createNewToken = { ...creteTokenRequest, secret };
return this.store.insert(createNewToken);
}
private generateSecretKey() {
return crypto.randomBytes(32).toString('hex');
}
destroy() {
clearInterval(this.timer);
this.timer = null;
}
}

View File

@ -9,6 +9,7 @@ const AddonService = require('./addon-service');
const ContextService = require('./context-service');
const VersionService = require('./version-service');
const { AccessService } = require('./access-service');
const { ApiTokenService } = require('./api-token-service');
module.exports.createServices = (stores, config) => {
const accessService = new AccessService(stores, config);
@ -26,6 +27,7 @@ module.exports.createServices = (stores, config) => {
const addonService = new AddonService(stores, config, tagTypeService);
const contextService = new ContextService(stores, config);
const versionService = new VersionService(stores, config);
const apiTokenService = new ApiTokenService(stores, config);
return {
accessService,
@ -39,5 +41,6 @@ module.exports.createServices = (stores, config) => {
clientMetricsService,
contextService,
versionService,
apiTokenService,
};
};

16
src/lib/types/core.ts Normal file
View File

@ -0,0 +1,16 @@
import { LogProvider } from '../logger';
export interface IUnleashConfig {
getLogger: LogProvider;
authentication: {
enableApiToken: boolean;
};
}
export enum AuthenticationType {
none = 'none',
unsecure = 'unsecure', // deprecated. Remove in v4
custom = 'custom',
openSource = 'open-source',
enterprise = 'enterprise',
}

View File

@ -0,0 +1,21 @@
'use strict';
exports.up = function(db, cb) {
db.runSql(
`CREATE TABLE IF NOT EXISTS api_tokens
(
secret text not null PRIMARY KEY,
username text not null,
type text not null,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
expires_at TIMESTAMP WITH TIME ZONE,
seen_at TIMESTAMP WITH TIME ZONE
);
`,
cb,
);
};
exports.down = function(db, cb) {
db.runSql('DROP TABLE IF EXISTS api_tokens;', cb);
};

View File

@ -0,0 +1,43 @@
'use strict';
const async = require('async');
const settingsId = 'unleash.enterprise.api.keys';
const toApiToken = legacyToken => {
return {
secret: legacyToken.key,
username: legacyToken.username,
createdAt: legacyToken.created || new Date(),
type: legacyToken.priviliges.some(n => n === 'ADMIN')
? 'admin'
: 'client',
};
};
exports.up = function(db, cb) {
db.runSql(
`SELECT * from settings where name = '${settingsId}';`,
(err, results) => {
if (results.rowCount === 1) {
const legacyTokens = results.rows[0].content.keys;
const inserts = legacyTokens.map(toApiToken).map(t =>
db.runSql.bind(
db,
`INSERT INTO api_tokens (secret, username, type, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT DO NOTHING;`,
[t.secret, t.username, t.type, t.createdAt],
),
);
async.series(inserts, cb);
} else {
cb();
}
},
);
};
exports.down = function(db, cb) {
cb();
};

View File

@ -0,0 +1,272 @@
'use strict';
import test from 'ava';
import { setupApp, setupAppWithCustomAuth } from '../../helpers/test-helper';
import dbInit from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger';
import { ApiTokenType, IApiToken } from '../../../../lib/db/api-token-store';
import User from '../../../../lib/user';
import { CREATE_API_TOKEN, CREATE_FEATURE } from '../../../../lib/permissions';
let stores;
let db;
test.before(async () => {
db = await dbInit('token_api_serial', getLogger);
stores = db.stores;
});
test.after(async () => {
await db.destroy();
});
test.afterEach.always(async () => {
const tokens = await stores.apiTokenStore.getAll();
const deleteAll = tokens.map((t: IApiToken) =>
stores.apiTokenStore.delete(t.secret),
);
await Promise.all(deleteAll);
});
test.serial('returns empty list of tokens', async t => {
t.plan(1);
const request = await setupApp(stores);
return request
.get('/api/admin/api-tokens')
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.is(res.body.tokens.length, 0);
});
});
test.serial('creates new client token', async t => {
t.plan(4);
const request = await setupApp(stores);
return request
.post('/api/admin/api-tokens')
.send({
username: 'default-client',
type: 'client',
})
.set('Content-Type', 'application/json')
.expect(201)
.expect(res => {
t.is(res.body.username, 'default-client');
t.is(res.body.type, 'client');
t.truthy(res.body.createdAt);
t.true(res.body.secret.length > 16);
});
});
test.serial('creates new admin token', async t => {
t.plan(5);
const request = await setupApp(stores);
return request
.post('/api/admin/api-tokens')
.send({
username: 'default-admin',
type: 'admin',
})
.set('Content-Type', 'application/json')
.expect(201)
.expect(res => {
t.is(res.body.username, 'default-admin');
t.is(res.body.type, 'admin');
t.truthy(res.body.createdAt);
t.falsy(res.body.expiresAt);
t.true(res.body.secret.length > 16);
});
});
test.serial('creates new admin token with expiry', async t => {
t.plan(1);
const request = await setupApp(stores);
const expiresAt = new Date();
const expiresAtAsISOStr = JSON.parse(JSON.stringify(expiresAt));
return request
.post('/api/admin/api-tokens')
.send({
username: 'default-admin',
type: 'admin',
expiresAt,
})
.set('Content-Type', 'application/json')
.expect(201)
.expect(res => {
t.is(res.body.expiresAt, expiresAtAsISOStr);
});
});
test.serial('update admin token with expiry', async t => {
t.plan(2);
const request = await setupApp(stores);
const tokenSecret = 'random-secret-update';
await stores.apiTokenStore.insert({
username: 'test',
secret: tokenSecret,
type: ApiTokenType.CLIENT,
});
await request
.put(`/api/admin/api-tokens/${tokenSecret}`)
.send({
expiresAt: new Date(),
})
.set('Content-Type', 'application/json')
.expect(200);
return request
.get('/api/admin/api-tokens')
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.is(res.body.tokens.length, 1);
t.truthy(res.body.tokens[0].expiresAt);
});
});
test.serial('creates a lot of client tokens', async t => {
t.plan(4);
const request = await setupApp(stores);
const requests = [];
for (let i = 0; i < 10; i++) {
requests.push(
request
.post('/api/admin/api-tokens')
.send({
username: 'default-client',
type: 'client',
})
.set('Content-Type', 'application/json')
.expect(201),
);
}
await Promise.all(requests);
t.plan(2);
return request
.get('/api/admin/api-tokens')
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.is(res.body.tokens.length, 10);
t.is(res.body.tokens[2].type, 'client');
});
});
test.serial('removes api token', async t => {
t.plan(1);
const request = await setupApp(stores);
const tokenSecret = 'random-secret';
await stores.apiTokenStore.insert({
username: 'test',
secret: tokenSecret,
type: ApiTokenType.CLIENT,
});
await request
.delete(`/api/admin/api-tokens/${tokenSecret}`)
.set('Content-Type', 'application/json')
.expect(200);
return request
.get('/api/admin/api-tokens')
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.is(res.body.tokens.length, 0);
});
});
test.serial('none-admins should only get client tokens', async t => {
t.plan(2);
const user = new User({ email: 'custom-user@mail.com', permissions: [] });
const preHook = app => {
app.use('/api/', (req, res, next) => {
req.user = user;
next();
});
};
const request = await setupAppWithCustomAuth(stores, preHook, true);
await stores.apiTokenStore.insert({
username: 'test',
secret: '1234',
type: ApiTokenType.CLIENT,
});
await stores.apiTokenStore.insert({
username: 'test',
secret: 'sdfsdf2d',
type: ApiTokenType.ADMIN,
});
return request
.get('/api/admin/api-tokens')
.expect('Content-Type', /json/)
.expect(200)
.expect(res => {
t.is(res.body.tokens.length, 1);
t.is(res.body.tokens[0].type, ApiTokenType.CLIENT);
});
});
test.serial('Only token-admins should be allowed to create token', async t => {
t.plan(0);
const user = new User({
email: 'custom-user@mail.com',
permissions: [CREATE_FEATURE],
});
const preHook = app => {
app.use('/api/', (req, res, next) => {
req.user = user;
next();
});
};
const request = await setupAppWithCustomAuth(stores, preHook, true);
return request
.post('/api/admin/api-tokens')
.send({
username: 'default-admin',
type: 'admin',
})
.set('Content-Type', 'application/json')
.expect(403);
});
test.serial('Token-admin should be allowed to create token', async t => {
t.plan(0);
const user = new User({
email: 'custom-user@mail.com',
permissions: [CREATE_API_TOKEN],
});
const preHook = app => {
app.use('/api/', (req, res, next) => {
req.user = user;
next();
});
};
const request = await setupAppWithCustomAuth(stores, preHook, true);
return request
.post('/api/admin/api-tokens')
.send({
username: 'default-admin',
type: 'admin',
})
.set('Content-Type', 'application/json')
.expect(201);
});

View File

@ -11,17 +11,26 @@ const { createServices } = require('../../../lib/services');
const eventBus = new EventEmitter();
function createApp(stores, adminAuthentication = 'none', preHook) {
function createApp(
stores,
adminAuthentication = 'none',
preHook,
extendedPermissions = false,
) {
const config = {
stores,
eventBus,
preHook,
adminAuthentication,
extendedPermissions,
secret: 'super-secret',
session: {
db: true,
age: 4000,
},
authentication: {
customHook: () => {},
},
getLogger,
};
const services = createServices(stores, config);
@ -40,8 +49,8 @@ module.exports = {
return supertest.agent(app);
},
async setupAppWithCustomAuth(stores, preHook) {
const app = createApp(stores, 'custom', preHook);
async setupAppWithCustomAuth(stores, preHook, extendedPermissions) {
const app = createApp(stores, 'custom', preHook, extendedPermissions);
return supertest.agent(app);
},
};

View File

@ -0,0 +1,114 @@
import test from 'ava';
import dbInit from '../helpers/database-init';
import getLogger from '../../fixtures/no-logger';
import { ApiTokenService } from '../../../lib/services/api-token-service';
import { ApiTokenType, IApiToken } from '../../../lib/db/api-token-store';
let db;
let stores;
let apiTokenService: ApiTokenService;
test.before(async () => {
db = await dbInit('api_tokens_serial', getLogger);
stores = db.stores;
// projectStore = stores.projectStore;
apiTokenService = new ApiTokenService(stores, {
getLogger,
baseUriPath: '/test',
});
});
test.after(async () => {
await db.destroy();
});
test.afterEach(async () => {
const tokens = await stores.apiTokenStore.getAll();
const deleteAll = tokens.map((t: IApiToken) =>
stores.apiTokenStore.delete(t.secret),
);
await Promise.all(deleteAll);
});
test.serial('should have empty list of tokens', async t => {
const allTokens = await apiTokenService.getAllTokens();
const activeTokens = await apiTokenService.getAllTokens();
t.is(allTokens.length, 0);
t.is(activeTokens.length, 0);
});
test.serial('should create client token', async t => {
const token = await apiTokenService.creteApiToken({
username: 'default-client',
type: ApiTokenType.CLIENT,
});
const allTokens = await apiTokenService.getAllTokens();
t.is(allTokens.length, 1);
t.true(token.secret.length > 32);
t.is(token.type, ApiTokenType.CLIENT);
t.is(token.username, 'default-client');
t.is(allTokens[0].secret, token.secret);
});
test.serial('should create admin token', async t => {
const token = await apiTokenService.creteApiToken({
username: 'admin',
type: ApiTokenType.ADMIN,
});
t.true(token.secret.length > 32);
t.is(token.type, ApiTokenType.ADMIN);
});
test.serial('should set expiry of token', async t => {
const time = new Date('2022-01-01');
await apiTokenService.creteApiToken({
username: 'default-client',
type: ApiTokenType.CLIENT,
expiresAt: time,
});
const [token] = await apiTokenService.getAllTokens();
t.deepEqual(token.expiresAt, time);
});
test.serial('should update expiry of token', async t => {
const time = new Date('2022-01-01');
const newTime = new Date('2023-01-01');
const token = await apiTokenService.creteApiToken({
username: 'default-client',
type: ApiTokenType.CLIENT,
expiresAt: time,
});
await apiTokenService.updateExpiry(token.secret, newTime);
const [updatedToken] = await apiTokenService.getAllTokens();
t.deepEqual(updatedToken.expiresAt, newTime);
});
test.serial('should only return valid tokens', async t => {
const today = new Date();
const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000);
await apiTokenService.creteApiToken({
username: 'default-expired',
type: ApiTokenType.CLIENT,
expiresAt: new Date('2021-01-01'),
});
const activeToken = await apiTokenService.creteApiToken({
username: 'default-valid',
type: ApiTokenType.CLIENT,
expiresAt: tomorrow,
});
const tokens = await apiTokenService.getAllActiveTokens();
t.is(tokens.length, 1);
t.is(activeToken.secret, tokens[0].secret);
});

View File

@ -6545,10 +6545,10 @@ universalify@^0.1.0:
resolved "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz"
integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
unleash-frontend@3.14.1:
version "3.14.1"
resolved "https://registry.yarnpkg.com/unleash-frontend/-/unleash-frontend-3.14.1.tgz#791e32d230fa865cf9339fc58b66fa771c1bf05e"
integrity sha512-LdRCOgpddrhBdjEJxwp4ywk0fv5yrbfZf8zB+yxMMtBsCc/2dB2KlcyRg099LHw5z2bNuDNP7tYOse437QeYGA==
unleash-frontend@3.15.0:
version "3.15.0"
resolved "https://registry.yarnpkg.com/unleash-frontend/-/unleash-frontend-3.15.0.tgz#44692fa923a562a5bc01fd68b50a4744c9c70f96"
integrity sha512-sPj8xFNzo0SW0+mUyi0GNLfD1+LFP9fxmhGkydr6NChqVfS4vIGljnv8Jxco85t8diBdDNnqxKOtJfEsi5VESQ==
unpipe@1.0.0, unpipe@~1.0.0:
version "1.0.0"