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",
|
||||
"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"
|
||||
},
|
||||
|
@ -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);
|
||||
|
||||
|
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);
|
||||
}
|
||||
|
||||
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);
|
||||
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
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 || '',
|
||||
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,
|
||||
|
@ -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,
|
||||
};
|
||||
|
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 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) {
|
||||
|
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 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
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();
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
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"
|
||||
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"
|
||||
|
Loading…
Reference in New Issue
Block a user