diff --git a/package.json b/package.json index 10555ef02c..b29865e358 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/lib/app.ts b/src/lib/app.ts index ec0425af66..28ca0bbda5 100644 --- a/src/lib/app.ts +++ b/src/lib/app.ts @@ -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); diff --git a/src/lib/db/api-token-store.ts b/src/lib/db/api-token-store.ts new file mode 100644 index 0000000000..3de5768aab --- /dev/null +++ b/src/lib/db/api-token-store.ts @@ -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 { + const stopTimer = this.timer('getAll'); + const rows = await this.db(TABLE); + stopTimer(); + return rows.map(toToken); + } + + async getAllActive(): Promise { + const stopTimer = this.timer('getAllActive'); + const rows = await this.db(TABLE) + .where('expires_at', '>', new Date()) + .orWhere('expires_at', 'IS', null); + stopTimer(); + return rows.map(toToken); + } + + async insert(newToken: IApiTokenCreate): Promise { + const [row] = await this.db(TABLE).insert( + toRow(newToken), + ['created_at'], + ); + return { ...newToken, createdAt: row.created_at }; + } + + async delete(secret: string): Promise { + return this.db(TABLE) + .where({ secret }) + .del(); + } + + async setExpiry(secret: string, expiresAt: Date): Promise { + const rows = await this.db(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 { + 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); + } + } +} diff --git a/src/lib/db/feature-toggle-store.js b/src/lib/db/feature-toggle-store.js index 59d7327eb0..6e5bc9b16d 100644 --- a/src/lib/db/feature-toggle-store.js +++ b/src/lib/db/feature-toggle-store.js @@ -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); diff --git a/src/lib/db/index.js b/src/lib/db/index.js index 316efb74fb..1ffcd8463f 100644 --- a/src/lib/db/index.js +++ b/src/lib/db/index.js @@ -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), }; }; diff --git a/src/lib/middleware/api-token-middleware.test.ts b/src/lib/middleware/api-token-middleware.test.ts new file mode 100644 index 0000000000..61e3ee4d82 --- /dev/null +++ b/src/lib/middleware/api-token-middleware.test.ts @@ -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); +}); diff --git a/src/lib/middleware/api-token-middleware.ts b/src/lib/middleware/api-token-middleware.ts new file mode 100644 index 0000000000..ddb7a84ceb --- /dev/null +++ b/src/lib/middleware/api-token-middleware.ts @@ -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; diff --git a/src/lib/options.js b/src/lib/options.js index 258c12fcca..720d52dd85 100644 --- a/src/lib/options.js +++ b/src/lib/options.js @@ -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, diff --git a/src/lib/permissions.js b/src/lib/permissions.js index 75e174db2c..23d497caf5 100644 --- a/src/lib/permissions.js +++ b/src/lib/permissions.js @@ -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, }; diff --git a/src/lib/routes/admin-api/api-token-controller.ts b/src/lib/routes/admin-api/api-token-controller.ts new file mode 100644 index 0000000000..d8d553f7ed --- /dev/null +++ b/src/lib/routes/admin-api/api-token-controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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; diff --git a/src/lib/routes/admin-api/index.js b/src/lib/routes/admin-api/index.js index d3ecab16ad..c418d3601c 100644 --- a/src/lib/routes/admin-api/index.js +++ b/src/lib/routes/admin-api/index.js @@ -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) { diff --git a/src/lib/routes/unleash-types.ts b/src/lib/routes/unleash-types.ts new file mode 100644 index 0000000000..03b54bcfc8 --- /dev/null +++ b/src/lib/routes/unleash-types.ts @@ -0,0 +1,6 @@ +import { Request } from 'express'; +import User from '../user'; + +export interface IAuthRequest extends Request { + user: User; +} diff --git a/src/lib/services/api-token-service.ts b/src/lib/services/api-token-service.ts new file mode 100644 index 0000000000..4a723ea1d6 --- /dev/null +++ b/src/lib/services/api-token-service.ts @@ -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 { + try { + this.activeTokens = await this.getAllActiveTokens(); + } finally { + // eslint-disable-next-line no-unsafe-finally + return; + } + } + + public async getAllTokens(): Promise { + return this.store.getAll(); + } + + public async getAllActiveTokens(): Promise { + 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 { + return this.store.setExpiry(secret, expiresAt); + } + + public async delete(secret: string): Promise { + return this.store.delete(secret); + } + + public async creteApiToken( + creteTokenRequest: CreateTokenRequest, + ): Promise { + 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; + } +} diff --git a/src/lib/services/index.js b/src/lib/services/index.js index fad6f67dd8..1800206f10 100644 --- a/src/lib/services/index.js +++ b/src/lib/services/index.js @@ -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, }; }; diff --git a/src/lib/types/core.ts b/src/lib/types/core.ts new file mode 100644 index 0000000000..38b794df5b --- /dev/null +++ b/src/lib/types/core.ts @@ -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', +} diff --git a/src/migrations/20210322104356-api-tokens-table.js b/src/migrations/20210322104356-api-tokens-table.js new file mode 100644 index 0000000000..fa6099e65b --- /dev/null +++ b/src/migrations/20210322104356-api-tokens-table.js @@ -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); +}; diff --git a/src/migrations/20210322104357-api-tokens-convert-enterprise.js b/src/migrations/20210322104357-api-tokens-convert-enterprise.js new file mode 100644 index 0000000000..de78690048 --- /dev/null +++ b/src/migrations/20210322104357-api-tokens-convert-enterprise.js @@ -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(); +}; diff --git a/src/test/e2e/api/admin/api-token.e2e.test.ts b/src/test/e2e/api/admin/api-token.e2e.test.ts new file mode 100644 index 0000000000..ef54e15261 --- /dev/null +++ b/src/test/e2e/api/admin/api-token.e2e.test.ts @@ -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); +}); diff --git a/src/test/e2e/helpers/test-helper.js b/src/test/e2e/helpers/test-helper.js index 57a7690c3e..19d74d8b17 100644 --- a/src/test/e2e/helpers/test-helper.js +++ b/src/test/e2e/helpers/test-helper.js @@ -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); }, }; diff --git a/src/test/e2e/services/api-token-service.e2e.test.ts b/src/test/e2e/services/api-token-service.e2e.test.ts new file mode 100644 index 0000000000..0a3d1f7413 --- /dev/null +++ b/src/test/e2e/services/api-token-service.e2e.test.ts @@ -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); +}); diff --git a/yarn.lock b/yarn.lock index 8d4631bae3..b39a212ab8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"