mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-20 00:08:02 +01:00
parent
f2de0aba65
commit
dfb890c638
@ -95,7 +95,7 @@
|
|||||||
"prom-client": "^13.1.0",
|
"prom-client": "^13.1.0",
|
||||||
"response-time": "^2.3.2",
|
"response-time": "^2.3.2",
|
||||||
"serve-favicon": "^2.5.0",
|
"serve-favicon": "^2.5.0",
|
||||||
"unleash-frontend": "3.14.1",
|
"unleash-frontend": "3.15.0",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"yargs": "^16.0.3"
|
"yargs": "^16.0.3"
|
||||||
},
|
},
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { responseTimeMetrics } from './middleware/response-time-metrics';
|
import { responseTimeMetrics } from './middleware/response-time-metrics';
|
||||||
import rbacMiddleware from './middleware/rbac-middleware';
|
import rbacMiddleware from './middleware/rbac-middleware';
|
||||||
|
import apiTokenMiddleware from './middleware/api-token-middleware';
|
||||||
|
import { AuthenticationType } from './types/core';
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
|
||||||
@ -48,20 +50,32 @@ module.exports = function(config, services = {}) {
|
|||||||
app.use(`${baseUriPath}/oas`, express.static('docs/api/oas'));
|
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);
|
simpleAuthentication(baseUriPath, app);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.adminAuthentication === 'none') {
|
if (config.adminAuthentication === AuthenticationType.enterprise) {
|
||||||
noAuthentication(baseUriPath, app);
|
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') {
|
if (typeof config.preRouterHook === 'function') {
|
||||||
config.preRouterHook(app);
|
config.preRouterHook(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(baseUriPath, rbacMiddleware(config, services));
|
|
||||||
|
|
||||||
// Setup API routes
|
// Setup API routes
|
||||||
app.use(`${baseUriPath}/`, new IndexRouter(config, services).router);
|
app.use(`${baseUriPath}/`, new IndexRouter(config, services).router);
|
||||||
|
|
||||||
|
120
src/lib/db/api-token-store.ts
Normal file
120
src/lib/db/api-token-store.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -131,11 +131,11 @@ class FeatureToggleStore {
|
|||||||
return rows.map(this.rowToFeature);
|
return rows.map(this.rowToFeature);
|
||||||
}
|
}
|
||||||
|
|
||||||
async lastSeenToggles(togleNames) {
|
async lastSeenToggles(toggleNames) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
try {
|
try {
|
||||||
await this.db(TABLE)
|
await this.db(TABLE)
|
||||||
.whereIn('name', togleNames)
|
.whereIn('name', toggleNames)
|
||||||
.update({ last_seen_at: now });
|
.update({ last_seen_at: now });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error('Could not update lastSeen, error: ', err);
|
this.logger.error('Could not update lastSeen, error: ', err);
|
||||||
|
@ -19,6 +19,7 @@ const ProjectStore = require('./project-store');
|
|||||||
const TagStore = require('./tag-store');
|
const TagStore = require('./tag-store');
|
||||||
const TagTypeStore = require('./tag-type-store');
|
const TagTypeStore = require('./tag-type-store');
|
||||||
const AddonStore = require('./addon-store');
|
const AddonStore = require('./addon-store');
|
||||||
|
const { ApiTokenStore } = require('./api-token-store');
|
||||||
|
|
||||||
module.exports.createStores = (config, eventBus) => {
|
module.exports.createStores = (config, eventBus) => {
|
||||||
const { getLogger } = config;
|
const { getLogger } = config;
|
||||||
@ -55,5 +56,6 @@ module.exports.createStores = (config, eventBus) => {
|
|||||||
tagTypeStore: new TagTypeStore(db, eventBus, getLogger),
|
tagTypeStore: new TagTypeStore(db, eventBus, getLogger),
|
||||||
addonStore: new AddonStore(db, eventBus, getLogger),
|
addonStore: new AddonStore(db, eventBus, getLogger),
|
||||||
accessStore: new AccessStore(db, eventBus, getLogger),
|
accessStore: new AccessStore(db, eventBus, getLogger),
|
||||||
|
apiTokenStore: new ApiTokenStore(db, eventBus, getLogger),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
161
src/lib/middleware/api-token-middleware.test.ts
Normal file
161
src/lib/middleware/api-token-middleware.test.ts
Normal 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);
|
||||||
|
});
|
35
src/lib/middleware/api-token-middleware.ts
Normal file
35
src/lib/middleware/api-token-middleware.ts
Normal 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;
|
@ -66,8 +66,8 @@ function defaultOptions() {
|
|||||||
baseUriPath: process.env.BASE_URI_PATH || '',
|
baseUriPath: process.env.BASE_URI_PATH || '',
|
||||||
unleashUrl: process.env.UNLEASH_URL || 'http://localhost:4242',
|
unleashUrl: process.env.UNLEASH_URL || 'http://localhost:4242',
|
||||||
serverMetrics: true,
|
serverMetrics: true,
|
||||||
enableLegacyRoutes: false,
|
enableLegacyRoutes: false, // deprecated. Remove in v4,
|
||||||
extendedPermissions: false,
|
extendedPermissions: false, // deprecated. Remove in v4,
|
||||||
publicFolder,
|
publicFolder,
|
||||||
versionCheck: {
|
versionCheck: {
|
||||||
url:
|
url:
|
||||||
@ -76,13 +76,18 @@ function defaultOptions() {
|
|||||||
enable: process.env.CHECK_VERSION || 'true',
|
enable: process.env.CHECK_VERSION || 'true',
|
||||||
},
|
},
|
||||||
enableRequestLogger: false,
|
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: {},
|
ui: {},
|
||||||
importFile: process.env.IMPORT_FILE,
|
importFile: process.env.IMPORT_FILE,
|
||||||
importKeepExisting: process.env.IMPORT_KEEP_EXISTING || false,
|
importKeepExisting: process.env.IMPORT_KEEP_EXISTING || false,
|
||||||
dropBeforeImport: process.env.IMPORT_DROP_BEFORE_IMPORT || false,
|
dropBeforeImport: process.env.IMPORT_DROP_BEFORE_IMPORT || false,
|
||||||
getLogger: defaultLogProvider,
|
getLogger: defaultLogProvider,
|
||||||
customContextFields: [],
|
customContextFields: [], // deprecated. Remove in v4,
|
||||||
disableDBMigration: false,
|
disableDBMigration: false,
|
||||||
start: true,
|
start: true,
|
||||||
keepAliveTimeout: 60 * 1000,
|
keepAliveTimeout: 60 * 1000,
|
||||||
|
@ -20,6 +20,9 @@ const UPDATE_ADDON = 'UPDATE_ADDON';
|
|||||||
const DELETE_ADDON = 'DELETE_ADDON';
|
const DELETE_ADDON = 'DELETE_ADDON';
|
||||||
const READ_ROLE = 'READ_ROLE';
|
const READ_ROLE = 'READ_ROLE';
|
||||||
const UPDATE_ROLE = 'UPDATE_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 = {
|
module.exports = {
|
||||||
ADMIN,
|
ADMIN,
|
||||||
@ -42,4 +45,7 @@ module.exports = {
|
|||||||
UPDATE_ADDON,
|
UPDATE_ADDON,
|
||||||
READ_ROLE,
|
READ_ROLE,
|
||||||
UPDATE_ROLE,
|
UPDATE_ROLE,
|
||||||
|
CREATE_API_TOKEN,
|
||||||
|
UPDATE_API_TOKEN,
|
||||||
|
DELETE_API_TOKEN,
|
||||||
};
|
};
|
||||||
|
144
src/lib/routes/admin-api/api-token-controller.ts
Normal file
144
src/lib/routes/admin-api/api-token-controller.ts
Normal 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;
|
@ -14,6 +14,7 @@ const StateController = require('./state');
|
|||||||
const TagController = require('./tag');
|
const TagController = require('./tag');
|
||||||
const TagTypeController = require('./tag-type');
|
const TagTypeController = require('./tag-type');
|
||||||
const AddonController = require('./addon');
|
const AddonController = require('./addon');
|
||||||
|
const ApiTokenController = require('./api-token-controller');
|
||||||
const apiDef = require('./api-def.json');
|
const apiDef = require('./api-def.json');
|
||||||
|
|
||||||
class AdminApi extends Controller {
|
class AdminApi extends Controller {
|
||||||
@ -58,6 +59,10 @@ class AdminApi extends Controller {
|
|||||||
new TagTypeController(config, services).router,
|
new TagTypeController(config, services).router,
|
||||||
);
|
);
|
||||||
this.app.use('/addons', new AddonController(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) {
|
index(req, res) {
|
||||||
|
6
src/lib/routes/unleash-types.ts
Normal file
6
src/lib/routes/unleash-types.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { Request } from 'express';
|
||||||
|
import User from '../user';
|
||||||
|
|
||||||
|
export interface IAuthRequest extends Request {
|
||||||
|
user: User;
|
||||||
|
}
|
106
src/lib/services/api-token-service.ts
Normal file
106
src/lib/services/api-token-service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -9,6 +9,7 @@ const AddonService = require('./addon-service');
|
|||||||
const ContextService = require('./context-service');
|
const ContextService = require('./context-service');
|
||||||
const VersionService = require('./version-service');
|
const VersionService = require('./version-service');
|
||||||
const { AccessService } = require('./access-service');
|
const { AccessService } = require('./access-service');
|
||||||
|
const { ApiTokenService } = require('./api-token-service');
|
||||||
|
|
||||||
module.exports.createServices = (stores, config) => {
|
module.exports.createServices = (stores, config) => {
|
||||||
const accessService = new AccessService(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 addonService = new AddonService(stores, config, tagTypeService);
|
||||||
const contextService = new ContextService(stores, config);
|
const contextService = new ContextService(stores, config);
|
||||||
const versionService = new VersionService(stores, config);
|
const versionService = new VersionService(stores, config);
|
||||||
|
const apiTokenService = new ApiTokenService(stores, config);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessService,
|
accessService,
|
||||||
@ -39,5 +41,6 @@ module.exports.createServices = (stores, config) => {
|
|||||||
clientMetricsService,
|
clientMetricsService,
|
||||||
contextService,
|
contextService,
|
||||||
versionService,
|
versionService,
|
||||||
|
apiTokenService,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
16
src/lib/types/core.ts
Normal file
16
src/lib/types/core.ts
Normal 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',
|
||||||
|
}
|
21
src/migrations/20210322104356-api-tokens-table.js
Normal file
21
src/migrations/20210322104356-api-tokens-table.js
Normal 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);
|
||||||
|
};
|
@ -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();
|
||||||
|
};
|
272
src/test/e2e/api/admin/api-token.e2e.test.ts
Normal file
272
src/test/e2e/api/admin/api-token.e2e.test.ts
Normal 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);
|
||||||
|
});
|
@ -11,17 +11,26 @@ const { createServices } = require('../../../lib/services');
|
|||||||
|
|
||||||
const eventBus = new EventEmitter();
|
const eventBus = new EventEmitter();
|
||||||
|
|
||||||
function createApp(stores, adminAuthentication = 'none', preHook) {
|
function createApp(
|
||||||
|
stores,
|
||||||
|
adminAuthentication = 'none',
|
||||||
|
preHook,
|
||||||
|
extendedPermissions = false,
|
||||||
|
) {
|
||||||
const config = {
|
const config = {
|
||||||
stores,
|
stores,
|
||||||
eventBus,
|
eventBus,
|
||||||
preHook,
|
preHook,
|
||||||
adminAuthentication,
|
adminAuthentication,
|
||||||
|
extendedPermissions,
|
||||||
secret: 'super-secret',
|
secret: 'super-secret',
|
||||||
session: {
|
session: {
|
||||||
db: true,
|
db: true,
|
||||||
age: 4000,
|
age: 4000,
|
||||||
},
|
},
|
||||||
|
authentication: {
|
||||||
|
customHook: () => {},
|
||||||
|
},
|
||||||
getLogger,
|
getLogger,
|
||||||
};
|
};
|
||||||
const services = createServices(stores, config);
|
const services = createServices(stores, config);
|
||||||
@ -40,8 +49,8 @@ module.exports = {
|
|||||||
return supertest.agent(app);
|
return supertest.agent(app);
|
||||||
},
|
},
|
||||||
|
|
||||||
async setupAppWithCustomAuth(stores, preHook) {
|
async setupAppWithCustomAuth(stores, preHook, extendedPermissions) {
|
||||||
const app = createApp(stores, 'custom', preHook);
|
const app = createApp(stores, 'custom', preHook, extendedPermissions);
|
||||||
return supertest.agent(app);
|
return supertest.agent(app);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
114
src/test/e2e/services/api-token-service.e2e.test.ts
Normal file
114
src/test/e2e/services/api-token-service.e2e.test.ts
Normal 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);
|
||||||
|
});
|
@ -6545,10 +6545,10 @@ universalify@^0.1.0:
|
|||||||
resolved "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz"
|
resolved "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz"
|
||||||
integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
|
integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
|
||||||
|
|
||||||
unleash-frontend@3.14.1:
|
unleash-frontend@3.15.0:
|
||||||
version "3.14.1"
|
version "3.15.0"
|
||||||
resolved "https://registry.yarnpkg.com/unleash-frontend/-/unleash-frontend-3.14.1.tgz#791e32d230fa865cf9339fc58b66fa771c1bf05e"
|
resolved "https://registry.yarnpkg.com/unleash-frontend/-/unleash-frontend-3.15.0.tgz#44692fa923a562a5bc01fd68b50a4744c9c70f96"
|
||||||
integrity sha512-LdRCOgpddrhBdjEJxwp4ywk0fv5yrbfZf8zB+yxMMtBsCc/2dB2KlcyRg099LHw5z2bNuDNP7tYOse437QeYGA==
|
integrity sha512-sPj8xFNzo0SW0+mUyi0GNLfD1+LFP9fxmhGkydr6NChqVfS4vIGljnv8Jxco85t8diBdDNnqxKOtJfEsi5VESQ==
|
||||||
|
|
||||||
unpipe@1.0.0, unpipe@~1.0.0:
|
unpipe@1.0.0, unpipe@~1.0.0:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
|
Loading…
Reference in New Issue
Block a user