diff --git a/package.json b/package.json index baffb6c1d2..cde8806d58 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "dependencies": { "async": "^3.1.0", "basic-auth": "^2.0.1", + "bcrypt": "^5.0.1", "compression": "^1.7.3", "connect-session-knex": "^2.0.0", "cookie-parser": "^1.4.4", @@ -92,13 +93,14 @@ "mustache": "^4.1.0", "node-fetch": "^2.6.1", "nodemailer": "^6.5.0", + "owasp-password-strength-test": "^1.3.0", "parse-database-url": "^0.3.0", "pg": "^8.0.3", "pkginfo": "^0.4.1", "prom-client": "^13.1.0", "response-time": "^2.3.2", "serve-favicon": "^2.5.0", - "unleash-frontend": "4.0.0-alpha.1", + "unleash-frontend": "4.0.0-alpha.2", "uuid": "^8.3.2", "yargs": "^16.0.3" }, @@ -111,6 +113,8 @@ "@types/nodemailer": "^6.4.1", "@typescript-eslint/eslint-plugin": "^4.15.2", "@typescript-eslint/parser": "^4.15.2", + "@types/bcrypt": "^3.0.0", + "@types/owasp-password-strength-test": "^1.3.0", "ava": "^3.7.0", "copyfiles": "^2.4.1", "coveralls": "^3.1.0", diff --git a/src/lib/app.ts b/src/lib/app.ts index 28ca0bbda5..e904937090 100644 --- a/src/lib/app.ts +++ b/src/lib/app.ts @@ -15,6 +15,7 @@ const unleashDbSession = require('./middleware/session-db'); const requestLogger = require('./middleware/request-logger'); const simpleAuthentication = require('./middleware/simple-authentication'); +const ossAuthentication = require('./middleware/oss-authentication'); const noAuthentication = require('./middleware/no-authentication'); const secureHeaders = require('./middleware/secure-headers'); @@ -57,7 +58,12 @@ module.exports = function(config, services = {}) { // Deprecated. Will go away in v4. if (config.adminAuthentication === AuthenticationType.unsecure) { app.use(baseUriPath, apiTokenMiddleware(config, services)); - simpleAuthentication(baseUriPath, app); + simpleAuthentication(app, config, services); + } + + if (config.adminAuthentication === AuthenticationType.openSource) { + app.use(baseUriPath, apiTokenMiddleware(config, services)); + ossAuthentication(app, config, services); } if (config.adminAuthentication === AuthenticationType.enterprise) { diff --git a/src/lib/db/access-store.ts b/src/lib/db/access-store.ts index a2d3ca1c63..aa05a29326 100644 --- a/src/lib/db/access-store.ts +++ b/src/lib/db/access-store.ts @@ -22,6 +22,11 @@ export interface IRole { project?: string; } +export interface IUserRole { + roleId: number; + userId: number; +} + export class AccessStore { private logger: Function; @@ -82,6 +87,13 @@ export class AccessStore { .andWhere('type', 'project'); } + async getRootRoles(): Promise { + return this.db + .select(['id', 'name', 'type', 'project', 'description']) + .from(T.ROLES) + .andWhere('type', 'root'); + } + async removeRolesForProject(projectId: string): Promise { return this.db(T.ROLES) .where({ @@ -122,6 +134,20 @@ export class AccessStore { .delete(); } + async removeRolesOfTypeForUser( + userId: number, + roleType: string, + ): Promise { + const rolesToRemove = this.db(T.ROLES) + .select('id') + .where({ type: roleType }); + + return this.db(T.ROLE_USER) + .where({ user_id: userId }) + .whereIn('role_id', rolesToRemove) + .delete(); + } + async createRole( name: string, type: string, @@ -160,4 +186,18 @@ export class AccessStore { }) .delete(); } + + async getRootRoleForAllUsers(): Promise { + const rows = await this.db + .select('id', 'user_id') + .distinctOn('user_id') + .from(`${T.ROLES} AS r`) + .leftJoin(`${T.ROLE_USER} AS ru`, 'r.id', 'ru.role_id') + .where('r.type', '=', 'root'); + + return rows.map(row => ({ + roleId: +row.id, + userId: +row.user_id, + })); + } } diff --git a/src/lib/db/user-store.js b/src/lib/db/user-store.ts similarity index 68% rename from src/lib/db/user-store.js rename to src/lib/db/user-store.ts index fce3a7c2d8..4360a6d4b3 100644 --- a/src/lib/db/user-store.js +++ b/src/lib/db/user-store.ts @@ -1,9 +1,10 @@ /* eslint camelcase: "off" */ -'use strict'; +import { Knex } from 'knex'; +import { Logger, LogProvider } from '../logger'; +import User from '../user'; const NotFoundError = require('../error/notfound-error'); -const User = require('../user'); const TABLE = 'users'; @@ -13,7 +14,7 @@ const USER_COLUMNS = [ 'username', 'email', 'image_url', - 'permissions', + 'permissions', // TODO: remove in v4 'login_attempts', 'seen_at', 'created_at', @@ -53,27 +54,57 @@ const rowToUser = row => { }); }; +export interface IUserLookup { + id?: number; + username?: string; + email?: string; +} + +export interface IUserSearch { + name?: string; + username?: string; + email: string; +} + +export interface IUserUpdateFields { + name?: string; + email?: string; +} + class UserStore { - constructor(db, getLogger) { + private db: Knex; + + private logger: Logger; + + constructor(db: Knex, getLogger: LogProvider) { this.db = db; this.logger = getLogger('user-store.js'); } - async update(id, user) { + async update(id: number, fields: IUserUpdateFields): Promise { await this.db(TABLE) .where('id', id) - .update(mapUserToColumns(user)); + .update(mapUserToColumns(fields)); return this.get({ id }); } - async insert(user) { - const [id] = await this.db(TABLE) + async insert(user: User): Promise { + const rows = await this.db(TABLE) .insert(mapUserToColumns(user)) - .returning('id'); - return this.get({ id }); + .returning(USER_COLUMNS); + return rowToUser(rows[0]); } - buildSelectUser(q) { + async upsert(user: User): Promise { + const id = await this.hasUser(user); + + if (id) { + return this.update(id, user); + } + return this.insert(user); + } + + buildSelectUser(q: IUserLookup): any { const query = this.db(TABLE); if (q.id) { return query.where('id', q.id); @@ -87,27 +118,18 @@ class UserStore { throw new Error('Can only find users with id, username or email.'); } - async hasUser(idQuery) { + async hasUser(idQuery: IUserLookup): Promise { const query = this.buildSelectUser(idQuery); const item = await query.first('id'); return item ? item.id : undefined; } - async upsert(user) { - const id = await this.hasUser(user); - - if (id) { - return this.update(id, user); - } - return this.insert(user); - } - - async getAll() { + async getAll(): Promise { const users = await this.db.select(USER_COLUMNS).from(TABLE); return users.map(rowToUser); } - async search(query) { + async search(query: IUserSearch): Promise { const users = await this.db .select(USER_COLUMNS_PUBLIC) .from(TABLE) @@ -117,7 +139,7 @@ class UserStore { return users.map(rowToUser); } - async getAllWithId(userIdList) { + async getAllWithId(userIdList: number[]): Promise { const users = await this.db .select(USER_COLUMNS_PUBLIC) .from(TABLE) @@ -125,18 +147,18 @@ class UserStore { return users.map(rowToUser); } - async get(idQuery) { + async get(idQuery: IUserLookup): Promise { const row = await this.buildSelectUser(idQuery).first(USER_COLUMNS); return rowToUser(row); } - async delete(id) { + async delete(id: number): Promise { return this.db(TABLE) .where({ id }) .del(); } - async getPasswordHash(userId) { + async getPasswordHash(userId: number): Promise { const item = await this.db(TABLE) .where('id', userId) .first('password_hash'); @@ -148,7 +170,7 @@ class UserStore { return item.password_hash; } - async setPasswordHash(userId, passwordHash) { + async setPasswordHash(userId: number, passwordHash: string): Promise { return this.db(TABLE) .where('id', userId) .update({ @@ -156,13 +178,11 @@ class UserStore { }); } - async incLoginAttempts(user) { - return this.buildSelectUser(user).increment({ - login_attempts: 1, - }); + async incLoginAttempts(user: User): Promise { + return this.buildSelectUser(user).increment('login_attempts', 1); } - async succesfullLogin(user) { + async successfullyLogin(user: User): Promise { return this.buildSelectUser(user).update({ login_attempts: 0, seen_at: new Date(), @@ -171,3 +191,4 @@ class UserStore { } module.exports = UserStore; +export default UserStore; diff --git a/src/lib/middleware/api-token-middleware.test.ts b/src/lib/middleware/api-token-middleware.test.ts index 61e3ee4d82..281d2618ff 100644 --- a/src/lib/middleware/api-token-middleware.test.ts +++ b/src/lib/middleware/api-token-middleware.test.ts @@ -96,8 +96,10 @@ test('should not add user if disabled', async t => { const disabledConfig = { getLogger, + baseUriPath: '', authentication: { enableApiToken: false, + createAdminUser: false, }, }; diff --git a/src/lib/middleware/oss-authentication.js b/src/lib/middleware/oss-authentication.js new file mode 100644 index 0000000000..a8811dd941 --- /dev/null +++ b/src/lib/middleware/oss-authentication.js @@ -0,0 +1,32 @@ +const AuthenticationRequired = require('../authentication-required'); + +function ossAuthHook(app, config) { + const { baseUriPath } = config; + + const generateAuthResponse = async () => { + return new AuthenticationRequired({ + type: 'password', + path: `${baseUriPath}/auth/simple/login`, + message: 'You must sign in order to use Unleash', + }); + }; + + app.use(`${baseUriPath}/api`, async (req, res, next) => { + if (req.session && req.session.user) { + req.user = req.session.user; + return next(); + } + if (req.user) { + return next(); + } + if (req.header('authorization')) { + // API clients should get 401 without body + return res.sendStatus(401); + } + // Admin UI users should get auth-response + const authRequired = await generateAuthResponse(); + return res.status(401).json(authRequired); + }); +} + +module.exports = ossAuthHook; diff --git a/src/lib/middleware/oss-authentication.test.js b/src/lib/middleware/oss-authentication.test.js new file mode 100644 index 0000000000..a811a77a01 --- /dev/null +++ b/src/lib/middleware/oss-authentication.test.js @@ -0,0 +1,58 @@ +'use strict'; + +const test = require('ava'); +const supertest = require('supertest'); +const { EventEmitter } = require('events'); +const store = require('../../test/fixtures/store'); +const ossAuth = require('./oss-authentication'); +const getApp = require('../app'); +const getLogger = require('../../test/fixtures/no-logger'); +const { User } = require('../server-impl'); + +const eventBus = new EventEmitter(); + +function getSetup(preRouterHook) { + const base = `/random${Math.round(Math.random() * 1000)}`; + const stores = store.createStores(); + const app = getApp({ + baseUriPath: base, + stores, + eventBus, + getLogger, + preRouterHook(_app) { + preRouterHook(_app); + ossAuth(_app, { baseUriPath: base }); + + _app.get(`${base}/api/protectedResource`, (req, res) => { + res.status(200) + .json({ message: 'OK' }) + .end(); + }); + }, + }); + + return { + base, + request: supertest(app), + }; +} + +test('should return 401 when missing user', t => { + t.plan(0); + const { base, request } = getSetup(() => {}); + + return request.get(`${base}/api/protectedResource`).expect(401); +}); + +test('should return 200 when user exists', t => { + t.plan(0); + const user = new User({ id: 1, email: 'some@mail.com' }); + const { base, request } = getSetup(app => + app.use((req, res, next) => { + req.user = user; + next(); + }), + ); + + return request.get(`${base}/api/protectedResource`).expect(200); +}); diff --git a/src/lib/middleware/simple-authentication.js b/src/lib/middleware/simple-authentication.js index a6ae11b580..d980ecce26 100644 --- a/src/lib/middleware/simple-authentication.js +++ b/src/lib/middleware/simple-authentication.js @@ -2,10 +2,11 @@ const auth = require('basic-auth'); const User = require('../user'); const AuthenticationRequired = require('../authentication-required'); -function insecureAuthentication(basePath = '', app) { - app.post(`${basePath}/api/admin/login`, (req, res) => { - const user = req.body; - req.session.user = new User({ email: user.email }); +function insecureAuthentication(app, { basePath = '' }, { userService }) { + app.post(`${basePath}/api/admin/login`, async (req, res) => { + const { email } = req.body; + const user = await userService.loginUserWithoutPassword(email, true); + req.session.user = user; res.status(200) .json(req.session.user) .end(); diff --git a/src/lib/options.js b/src/lib/options.js index 05b2537ffa..a3a836f3d1 100644 --- a/src/lib/options.js +++ b/src/lib/options.js @@ -81,6 +81,7 @@ function defaultOptions() { enableApiToken: process.env.AUTH_ENABLE_API_TOKEN || true, type: process.env.AUTH_TYPE || 'open-source', customHook: () => {}, + createAdminUser: true, }, ui: {}, importFile: process.env.IMPORT_FILE, diff --git a/src/lib/routes/admin-api/api-token-controller.ts b/src/lib/routes/admin-api/api-token-controller.ts index d8d553f7ed..e19eba6e9c 100644 --- a/src/lib/routes/admin-api/api-token-controller.ts +++ b/src/lib/routes/admin-api/api-token-controller.ts @@ -14,16 +14,7 @@ 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; -} +import { IUnleashConfig } from '../../types/core'; interface IServices { apiTokenService: ApiTokenService; @@ -41,7 +32,7 @@ class ApiTokenController extends Controller { private logger: Logger; - constructor(config: IConfig, services: IServices) { + constructor(config: IUnleashConfig, services: IServices) { super(config); this.apiTokenService = services.apiTokenService; this.accessService = services.accessService; diff --git a/src/lib/routes/admin-api/index.js b/src/lib/routes/admin-api/index.js index ff8063dcc1..51550fb579 100644 --- a/src/lib/routes/admin-api/index.js +++ b/src/lib/routes/admin-api/index.js @@ -16,6 +16,7 @@ const TagTypeController = require('./tag-type'); const AddonController = require('./addon'); const ApiTokenController = require('./api-token-controller'); const EmailController = require('./email'); +const UserAdminController = require('./user-admin'); const apiDef = require('./api-def.json'); class AdminApi extends Controller { @@ -65,6 +66,10 @@ class AdminApi extends Controller { new ApiTokenController(config, services).router, ); this.app.use('/email', new EmailController(config, services).router); + this.app.use( + '/user-admin', + new UserAdminController(config, services).router, + ); } index(req, res) { diff --git a/src/lib/routes/admin-api/user-admin.ts b/src/lib/routes/admin-api/user-admin.ts new file mode 100644 index 0000000000..d9042b0a6b --- /dev/null +++ b/src/lib/routes/admin-api/user-admin.ts @@ -0,0 +1,125 @@ +import Controller from '../controller'; +import { ADMIN } from '../../permissions'; +import { IUnleashConfig } from '../../types/core'; +import UserService from '../../services/user-service'; +import { AccessService, RoleName } from '../../services/access-service'; +import { Logger } from '../../logger'; + +class UserAdminController extends Controller { + private userService: UserService; + + private accessService: AccessService; + + private logger: Logger; + + constructor(config: IUnleashConfig, { userService, accessService }) { + super(config); + this.userService = userService; + this.accessService = accessService; + this.logger = config.getLogger('routes/user-controller.js'); + + this.get('/', this.getUsers); + this.get('/search', this.search); + this.post('/', this.createUser, ADMIN); + this.post('/validate-password', this.validatePassword); + this.put('/:id', this.updateUser, ADMIN); + this.post('/:id/change-password', this.changePassword, ADMIN); + this.delete('/:id', this.deleteUser, ADMIN); + } + + async getUsers(req, res) { + try { + const users = await this.userService.getAll(); + const rootRoles = await this.accessService.getRootRoles(); + + res.json({ users, rootRoles }); + } catch (error) { + this.logger.error(error); + res.status(500).send({ msg: 'server errors' }); + } + } + + async search(req, res) { + const { q } = req.query; + try { + const users = + q && q.length > 1 ? await this.userService.search(q) : []; + res.json(users); + } catch (error) { + this.logger.error(error); + res.status(500).send({ msg: 'server errors' }); + } + } + + async createUser(req, res) { + const { username, email, name, rootRole } = req.body; + + try { + const user = await this.userService.createUser({ + username, + email, + name, + rootRole: Number(rootRole), + }); + res.status(201).send({ ...user, rootRole }); + } catch (e) { + this.logger.warn(e.message); + res.status(400).send([{ msg: e.message }]); + } + } + + async updateUser(req, res) { + const { id } = req.params; + const { name, email, rootRole } = req.body; + + try { + const user = await this.userService.updateUser({ + id: Number(id), + name, + email, + rootRole: Number(rootRole), + }); + res.status(200).send({ ...user, rootRole }); + } catch (e) { + this.logger.warn(e.message); + res.status(400).send([{ msg: e.message }]); + } + } + + async deleteUser(req, res) { + const { id } = req.params; + + try { + await this.userService.deleteUser(+id); + res.status(200).send(); + } catch (error) { + this.logger.warn(error); + res.status(500).send(); + } + } + + async validatePassword(req, res) { + const { password } = req.body; + + try { + this.userService.validatePassword(password); + res.status(200).send(); + } catch (e) { + res.status(400).send([{ msg: e.message }]); + } + } + + async changePassword(req, res) { + const { id } = req.params; + const { password } = req.body; + + try { + await this.userService.changePassword(+id, password); + res.status(200).send(); + } catch (e) { + res.status(400).send([{ msg: e.message }]); + } + } +} + +module.exports = UserAdminController; diff --git a/src/lib/routes/admin-api/user.js b/src/lib/routes/admin-api/user.js index 02ade85c3f..6da6eaac80 100644 --- a/src/lib/routes/admin-api/user.js +++ b/src/lib/routes/admin-api/user.js @@ -5,6 +5,7 @@ const Controller = require('../controller'); class UserController extends Controller { constructor(config) { super(config); + this.get('/', this.getUser); this.get('/logout', this.logout); } @@ -25,7 +26,7 @@ class UserController extends Controller { return res.status(404).end(); } - // Depcreated, use "/logout" instead. Will be removed in later release. + // Deprecated, use "/logout" instead. Will be removed in v4. logout(req, res) { if (req.session) { req.session = null; diff --git a/src/lib/routes/auth/simple-password-provider.js b/src/lib/routes/auth/simple-password-provider.js new file mode 100644 index 0000000000..eeda498ccb --- /dev/null +++ b/src/lib/routes/auth/simple-password-provider.js @@ -0,0 +1,31 @@ +const Controller = require('../controller'); + +class PasswordProvider extends Controller { + constructor({ getLogger }, { userService }) { + super(); + this.logger = getLogger('/auth/password-provider.js'); + this.userService = userService; + + this.post('/login', this.login); + } + + async login(req, res) { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ + message: 'You must provide username and password', + }); + } + + try { + const user = await this.userService.loginUser(username, password); + req.session.user = user; + return res.status(200).json(user); + } catch (e) { + return res.status(401).json({ message: e.message }); + } + } +} + +module.exports = PasswordProvider; diff --git a/src/lib/routes/auth/simple-password-provider.test.js b/src/lib/routes/auth/simple-password-provider.test.js new file mode 100644 index 0000000000..299ec676de --- /dev/null +++ b/src/lib/routes/auth/simple-password-provider.test.js @@ -0,0 +1,83 @@ +const test = require('ava'); +const request = require('supertest'); +const express = require('express'); +const User = require('../../user'); +const PasswordProvider = require('./simple-password-provider'); + +const getLogger = () => ({ info: () => {}, error: () => {} }); + +test('Should require password', async t => { + const app = express(); + app.use(express.json()); + const userService = () => {}; + const ctr = new PasswordProvider({ getLogger }, { userService }); + + app.use('/auth/simple', ctr.router); + + const res = await request(app) + .post('/auth/simple/login') + .send({ name: 'john' }); + + t.is(400, res.status); +}); + +test('Should login user', async t => { + const username = 'ola'; + const password = 'simplepass'; + const user = new User({ username, permissions: ['ADMIN'] }); + + const app = express(); + app.use(express.json()); + app.use((req, res, next) => { + req.session = {}; + next(); + }); + const userService = { + loginUser: (u, p) => { + if (u === username && p === password) { + return user; + } + throw new Error('Wrong password'); + }, + }; + const ctr = new PasswordProvider({ getLogger }, { userService }); + + app.use('/auth/simple', ctr.router); + + const res = await request(app) + .post('/auth/simple/login') + .send({ username, password }); + + t.is(200, res.status); + t.is(user.username, res.body.username); +}); + +test('Should not login user with wrong password', async t => { + const username = 'ola'; + const password = 'simplepass'; + const user = new User({ username, permissions: ['ADMIN'] }); + + const app = express(); + app.use(express.json()); + app.use((req, res, next) => { + req.session = {}; + next(); + }); + const userService = { + loginUser: (u, p) => { + if (u === username && p === password) { + return user; + } + throw new Error('Wrong password'); + }, + }; + const ctr = new PasswordProvider({ getLogger }, { userService }); + + app.use('/auth/simple', ctr.router); + + const res = await request(app) + .post('/auth/simple/login') + .send({ username, password: 'not-correct' }); + + t.is(401, res.status); +}); diff --git a/src/lib/routes/index.ts b/src/lib/routes/index.ts index 87beeadf52..ce0c7fbaa7 100644 --- a/src/lib/routes/index.ts +++ b/src/lib/routes/index.ts @@ -8,6 +8,7 @@ const Controller = require('./controller'); const HealthCheckController = require('./health-check'); const LogoutController = require('./logout'); const api = require('./api-def'); +const SimplePasswordProvider = require('./auth/simple-password-provider'); class IndexRouter extends Controller { constructor(config, services) { @@ -15,6 +16,10 @@ class IndexRouter extends Controller { this.use('/health', new HealthCheckController(config).router); this.use('/internal-backstage', new BackstageController(config).router); this.use('/logout', new LogoutController(config).router); + this.use( + '/auth/simple', + new SimplePasswordProvider(config, services).router, + ); this.get(api.uri, this.index); this.use(api.links.admin.uri, new AdminApi(config, services).router); this.use(api.links.client.uri, new ClientApi(config, services).router); diff --git a/src/lib/services/access-service.ts b/src/lib/services/access-service.ts index da4674cf22..d2b8145d7b 100644 --- a/src/lib/services/access-service.ts +++ b/src/lib/services/access-service.ts @@ -1,4 +1,4 @@ -import { AccessStore, IRole, IUserPermission } from '../db/access-store'; +import { AccessStore, IRole, IUserPermission, IUserRole } from '../db/access-store'; import p from '../permissions'; import User from '../user'; @@ -37,13 +37,13 @@ export interface IUserWithRole { imageUrl?: string; } -interface IRoleData { +export interface IRoleData { role: IRole; users: User[]; permissions: IUserPermission[]; } -interface IPermission { +export interface IPermission { name: string; type: PermissionType; } @@ -64,6 +64,11 @@ export enum RoleType { PROJECT = 'project', } +export interface IRoleIdentifier { + roleId?: number; + roleName?: RoleName; +} + export class AccessService { public RoleName = RoleName; private store: AccessStore; @@ -101,6 +106,10 @@ export class AccessService { .some(p => p.permission === permission || p.permission === ADMIN); } + async getPermissionsForUser(user: User) { + return this.store.getPermissionsForUser(user.id); + } + getPermissions(): IPermission[] { return this.permissions; } @@ -109,22 +118,27 @@ export class AccessService { return this.store.addUserToRole(userId, roleId); } - async setUserRootRole(userId: number, roleName: RoleName ) { - const userRoles = await this.store.getRolesForUserId(userId); - const currentRootRoles = userRoles.filter(r => r.type === RoleType.ROOT); - - const roles = await this.getRoles(); - const role = roles.find(r => r.type === RoleType.ROOT && r.name === roleName); - if(role) { + async setUserRootRole(userId: number, roleId: number) { + const roles = await this.getRootRoles(); + const newRootRole = roles.find(r => r.id === roleId); + + if(newRootRole) { try { - await Promise.all(currentRootRoles.map(r => this.store.removeUserFromRole(userId, r.id))); - await this.store.addUserToRole(userId, role.id); + await this.store.removeRolesOfTypeForUser(userId, RoleType.ROOT); + await this.store.addUserToRole(userId, newRootRole.id); } catch (error) { - this.logger.warn('Could not add role=${roleName} to userId=${userId}'); + throw new Error('Could not add role=${roleName} to userId=${userId}'); } + } else { + throw new Error(`Could not find rootRole with id=${roleId}`); } } + async getUserRootRoles(userId: number) { + const userRoles = await this.store.getRolesForUserId(userId); + return userRoles.filter(r => r.type === RoleType.ROOT); + } + async removeUserFromRole(userId: number, roleId: number) { return this.store.removeUserFromRole(userId, roleId); } @@ -220,4 +234,17 @@ export class AccessService { this.logger.info(`Removing project roles for ${projectId}`); return this.store.removeRolesForProject(projectId); } + + async getRootRoleForAllUsers(): Promise { + return this.store.getRootRoleForAllUsers(); + } + + async getRootRoles(): Promise { + return this.store.getRootRoles(); + } + + async getRootRole(roleName: RoleName): Promise { + const roles = await this.store.getRootRoles(); + return roles.find(r => r.name === roleName); + } } diff --git a/src/lib/services/index.js b/src/lib/services/index.js index 5c15b8a394..26b70ddb73 100644 --- a/src/lib/services/index.js +++ b/src/lib/services/index.js @@ -11,6 +11,7 @@ const VersionService = require('./version-service'); const { EmailService } = require('./email-service'); const { AccessService } = require('./access-service'); const { ApiTokenService } = require('./api-token-service'); +const UserService = require('./user-service'); module.exports.createServices = (stores, config) => { const accessService = new AccessService(stores, config); @@ -30,6 +31,7 @@ module.exports.createServices = (stores, config) => { const versionService = new VersionService(stores, config); const apiTokenService = new ApiTokenService(stores, config); const emailService = new EmailService(config.email, config.getLogger); + const userService = new UserService(stores, config, accessService); return { accessService, @@ -45,5 +47,6 @@ module.exports.createServices = (stores, config) => { versionService, apiTokenService, emailService, + userService, }; }; diff --git a/src/lib/services/user-service.test.ts b/src/lib/services/user-service.test.ts new file mode 100644 index 0000000000..3c31bd65c0 --- /dev/null +++ b/src/lib/services/user-service.test.ts @@ -0,0 +1,102 @@ +import test from 'ava'; +import UserService from './user-service'; +import UserStoreMock from '../../test/fixtures/fake-user-store'; +import AccessServiceMock from '../../test/fixtures/access-service-mock'; +import noLogger from '../../test/fixtures/no-logger'; +import { RoleName } from './access-service'; +import { IUnleashConfig } from '../types/core'; + +const config: IUnleashConfig = { + getLogger: noLogger, + baseUriPath: '', + authentication: { enableApiToken: true, createAdminUser: false }, +}; + +test('Should create new user', async t => { + const userStore = new UserStoreMock(); + const accessService = new AccessServiceMock(); + + const service = new UserService({ userStore }, config, accessService); + const user = await service.createUser({ + username: 'test', + rootRole: 1, + }); + const storedUser = await userStore.get(user); + const allUsers = await userStore.getAll(); + + t.truthy(user.id); + t.is(user.username, 'test'); + t.is(allUsers.length, 1); + t.is(storedUser.username, 'test'); +}); + +test('Should create default user', async t => { + const userStore = new UserStoreMock(); + const accessService = new AccessServiceMock(); + const service = new UserService({ userStore }, config, accessService); + + await service.initAdminUser(); + + const user = await service.loginUser('admin', 'admin'); + t.is(user.username, 'admin'); +}); + +test('Should be a valid password', async t => { + const userStore = new UserStoreMock(); + const accessService = new AccessServiceMock(); + const service = new UserService({ userStore }, config, accessService); + + const valid = service.validatePassword('this is a strong password!'); + + t.true(valid); +}); + +test('Password must be at least 10 chars', async t => { + const userStore = new UserStoreMock(); + const accessService = new AccessServiceMock(); + const service = new UserService({ userStore }, config, accessService); + + t.throws(() => service.validatePassword('admin'), { + message: 'The password must be at least 10 characters long.', + }); +}); + +test('The password must contain at least one uppercase letter.', async t => { + const userStore = new UserStoreMock(); + const accessService = new AccessServiceMock(); + const service = new UserService({ userStore }, config, accessService); + + t.throws(() => service.validatePassword('qwertyabcde'), { + message: 'The password must contain at least one uppercase letter.', + }); +}); + +test('The password must contain at least one number', async t => { + const userStore = new UserStoreMock(); + const accessService = new AccessServiceMock(); + const service = new UserService({ userStore }, config, accessService); + + t.throws(() => service.validatePassword('qwertyabcdE'), { + message: 'The password must contain at least one number.', + }); +}); + +test('The password must contain at least one special character', async t => { + const userStore = new UserStoreMock(); + const accessService = new AccessServiceMock(); + const service = new UserService({ userStore }, config, accessService); + + t.throws(() => service.validatePassword('qwertyabcdE2'), { + message: 'The password must contain at least one special character.', + }); +}); + +test('Should be a valid password with special chars', async t => { + const userStore = new UserStoreMock(); + const accessService = new AccessServiceMock(); + const service = new UserService({ userStore }, config, accessService); + + const valid = service.validatePassword('this is a strong password!'); + + t.true(valid); +}); diff --git a/src/lib/services/user-service.ts b/src/lib/services/user-service.ts new file mode 100644 index 0000000000..03621c758f --- /dev/null +++ b/src/lib/services/user-service.ts @@ -0,0 +1,237 @@ +import assert from 'assert'; +import bcrypt from 'bcrypt'; +import owasp from 'owasp-password-strength-test'; +import Joi from 'joi'; + +import UserStore, { IUserSearch } from '../db/user-store'; +import { Logger } from '../logger'; +import { IUnleashConfig } from '../types/core'; +import User, { IUser } from '../user'; +import isEmail from '../util/is-email'; +import { AccessService, RoleName } from './access-service'; +import { ADMIN } from '../permissions'; + +export interface ICreateUser { + name?: string; + email?: string; + username?: string; + password?: string; + rootRole: number; +} + +export interface IUpdateUser { + id: number; + name?: string; + email?: string; + rootRole?: number; +} + +interface IUserWithRole extends IUser { + rootRole: number; +} + +interface IStores { + userStore: UserStore; +} + +const saltRounds = 10; + +class UserService { + private logger: Logger; + + private store: UserStore; + + private accessService: AccessService; + + constructor( + stores: IStores, + config: IUnleashConfig, + accessService: AccessService, + ) { + this.logger = config.getLogger('service/user-service.js'); + this.store = stores.userStore; + this.accessService = accessService; + + if (config.authentication && config.authentication.createAdminUser) { + process.nextTick(() => this.initAdminUser()); + } + } + + validatePassword(password: string): boolean { + const result = owasp.test(password); + if (!result.strong) { + throw new Error(result.errors[0]); + } else return true; + } + + async initAdminUser(): Promise { + const hasAdminUser = await this.store.hasUser({ username: 'admin' }); + + if (!hasAdminUser) { + // create default admin user + try { + this.logger.info( + 'Creating default user "admin" with password "admin"', + ); + const user = await this.store.insert( + new User({ + username: 'admin', + permissions: [ADMIN], // TODO: remove in v4 + }), + ); + const passwordHash = await bcrypt.hash('admin', saltRounds); + await this.store.setPasswordHash(user.id, passwordHash); + + const rootRoles = await this.accessService.getRootRoles(); + const adminRole = rootRoles.find( + r => r.name === RoleName.ADMIN, + ); + await this.accessService.setUserRootRole(user.id, adminRole.id); + } catch (e) { + this.logger.error('Unable to create default user "admin"'); + } + } + } + + async getAll(): Promise { + const users = await this.store.getAll(); + const defaultRole = await this.accessService.getRootRole(RoleName.READ); + const userRoles = await this.accessService.getRootRoleForAllUsers(); + const usersWithRootRole = users.map(u => { + const rootRole = userRoles.find(r => r.userId === u.id); + const roleId = rootRole ? rootRole.roleId : defaultRole.id; + return { ...u, rootRole: roleId }; + }); + return usersWithRootRole; + } + + async getUser(id: number): Promise { + const roles = await this.accessService.getUserRootRoles(id); + const defaultRole = await this.accessService.getRootRole(RoleName.READ); + const roleId = roles.length > 0 ? roles[0].id : defaultRole.id; + const user = await this.store.get({ id }); + return { ...user, rootRole: roleId }; + } + + async search(query: IUserSearch): Promise { + return this.store.search(query); + } + + async createUser({ + username, + email, + name, + password, + rootRole, + }: ICreateUser): Promise { + assert.ok(username || email, 'You must specify username or email'); + + if (email) { + Joi.assert(email, Joi.string().email(), 'Email'); + } + + const exists = await this.store.hasUser({ username, email }); + if (exists) { + throw new Error('User already exists'); + } + + const user = await this.store.insert( + // TODO: remove permission in v4. + new User({ username, email, name, permissions: [ADMIN] }), + ); + + await this.accessService.setUserRootRole(user.id, rootRole); + + if (password) { + const passwordHash = await bcrypt.hash(password, saltRounds); + await this.store.setPasswordHash(user.id, passwordHash); + } + + return user; + } + + async updateUser({ + id, + name, + email, + rootRole, + }: IUpdateUser): Promise { + if (email) { + Joi.assert(email, Joi.string().email(), 'Email'); + } + + if (rootRole) { + await this.accessService.setUserRootRole(id, rootRole); + } + + return this.store.update(id, { name, email }); + } + + async loginUser(usernameOrEmail: string, password: string): Promise { + const idQuery = isEmail(usernameOrEmail) + ? { email: usernameOrEmail } + : { username: usernameOrEmail }; + const user = await this.store.get(idQuery); + const passwordHash = await this.store.getPasswordHash(user.id); + + const match = await bcrypt.compare(password, passwordHash); + if (match) { + await this.store.successfullyLogin(user); + return user; + } + throw new Error('Wrong password, try again.'); + } + + /** + * Used to login users without specifying password. Used when integrating + * with external identity providers. + * + * @param usernameOrEmail + * @param autoCreateUser + * @returns + */ + async loginUserWithoutPassword( + email: string, + autoCreateUser: boolean = false, + ): Promise { + let user: User; + + try { + user = await this.store.get({ email }); + } catch (e) { + if (autoCreateUser) { + const defaultRole = await this.accessService.getRootRole( + RoleName.REGULAR, + ); + user = await this.createUser({ + email, + rootRole: defaultRole.id, + }); + } else { + throw e; + } + } + this.store.successfullyLogin(user); + return user; + } + + async changePassword(userId: number, password: string): Promise { + this.validatePassword(password); + const passwordHash = await bcrypt.hash(password, saltRounds); + return this.store.setPasswordHash(userId, passwordHash); + } + + async deleteUser(userId: number): Promise { + const roles = await this.accessService.getRolesForUser(userId); + await Promise.all( + roles.map(role => + this.accessService.removeUserFromRole(userId, role.id), + ), + ); + + await this.store.delete(userId); + } +} + +module.exports = UserService; +export default UserService; diff --git a/src/lib/types/core.ts b/src/lib/types/core.ts index 38b794df5b..b099a56ebd 100644 --- a/src/lib/types/core.ts +++ b/src/lib/types/core.ts @@ -1,9 +1,17 @@ import { LogProvider } from '../logger'; +interface IExperimentalFlags { + [key: string]: boolean; +} + export interface IUnleashConfig { getLogger: LogProvider; + baseUriPath: string; + extendedPermissions?: boolean; + experimental?: IExperimentalFlags; authentication: { enableApiToken: boolean; + createAdminUser: boolean; }; } diff --git a/src/lib/user.ts b/src/lib/user.ts index c44ebbc3b2..c04e08246e 100644 --- a/src/lib/user.ts +++ b/src/lib/user.ts @@ -14,7 +14,15 @@ export interface UserData { createdAt?: Date; } -export default class User { +export interface IUser { + id: number; + name?: string; + username?: string; + email?: string; + createdAt: Date; +} + +export default class User implements IUser { id: number; isAPI: boolean; diff --git a/src/lib/util/feature-enabled.ts b/src/lib/util/feature-enabled.ts index 5eb699c307..035cd8bc5f 100644 --- a/src/lib/util/feature-enabled.ts +++ b/src/lib/util/feature-enabled.ts @@ -1,12 +1,6 @@ -interface IExperimentalFlags { - [key: string]: boolean; -} +import { IUnleashConfig } from '../types/core'; -interface IConfig { - experimental: IExperimentalFlags; -} - -export const isRbacEnabled = (config: IConfig): boolean => { +export const isRbacEnabled = (config: IUnleashConfig): boolean => { return config && config.experimental && config.experimental.rbac; }; diff --git a/src/lib/util/is-email.ts b/src/lib/util/is-email.ts new file mode 100644 index 0000000000..a086d329f4 --- /dev/null +++ b/src/lib/util/is-email.ts @@ -0,0 +1,19 @@ +// Email address matcher. +// eslint-disable-next-line no-useless-escape +const matcher = /.+\@.+\..+/; + +/** + * Loosely validate an email address. + * + * @param {string} string + * @return {boolean} + */ +function isEmail(value: string): boolean { + return matcher.test(value); +} + +/* + * Exports. + */ + +export default isEmail; diff --git a/src/test/e2e/api/admin/api-token.e2e.test.ts b/src/test/e2e/api/admin/api-token.e2e.test.ts index ef54e15261..a9be9cca7d 100644 --- a/src/test/e2e/api/admin/api-token.e2e.test.ts +++ b/src/test/e2e/api/admin/api-token.e2e.test.ts @@ -1,5 +1,3 @@ -'use strict'; - import test from 'ava'; import { setupApp, setupAppWithCustomAuth } from '../../helpers/test-helper'; import dbInit from '../../helpers/database-init'; diff --git a/src/test/e2e/api/admin/user-admin.e2e.test.ts b/src/test/e2e/api/admin/user-admin.e2e.test.ts new file mode 100644 index 0000000000..8d34479535 --- /dev/null +++ b/src/test/e2e/api/admin/user-admin.e2e.test.ts @@ -0,0 +1,221 @@ +import test from 'ava'; +import { setupApp } from '../../helpers/test-helper'; +import dbInit from '../../helpers/database-init'; +import getLogger from '../../../fixtures/no-logger'; +import User from '../../../../lib/user'; +import UserStore from '../../../../lib/db/user-store'; +import { AccessStore, IRole } from '../../../../lib/db/access-store'; +import { RoleName } from '../../../../lib/services/access-service'; + +let stores; +let db; + +let userStore: UserStore; +let accessStore: AccessStore; +let regularRole: IRole; +let adminRole: IRole; + +test.before(async () => { + db = await dbInit('user_admin_api_serial', getLogger); + stores = db.stores; + userStore = stores.userStore; + accessStore = stores.accessStore; + const roles = await accessStore.getRootRoles(); + regularRole = roles.find(r => r.name === RoleName.REGULAR); + adminRole = roles.find(r => r.name === RoleName.ADMIN); +}); + +test.after(async () => { + await db.destroy(); +}); + +test.afterEach.always(async () => { + const users = await userStore.getAll(); + const deleteAll = users.map((u: User) => userStore.delete(u.id)); + await Promise.all(deleteAll); +}); + +test.serial('returns empty list of users', async t => { + t.plan(1); + const request = await setupApp(stores); + return request + .get('/api/admin/user-admin') + .expect('Content-Type', /json/) + .expect(200) + .expect(res => { + t.is(res.body.users.length, 0); + }); +}); + +test.serial('creates and returns all users', async t => { + t.plan(2); + const request = await setupApp(stores); + + const createUserRequests = [...Array(20).keys()].map(i => + request + .post('/api/admin/user-admin') + .send({ + email: `some${i}@getunleash.ai`, + name: `Some Name ${i}`, + rootRole: regularRole.id, + }) + .set('Content-Type', 'application/json'), + ); + + await Promise.all(createUserRequests); + + return request + .get('/api/admin/user-admin') + .expect('Content-Type', /json/) + .expect(200) + .expect(res => { + t.is(res.body.users.length, 20); + t.is(res.body.users[2].rootRole, regularRole.id); + }); +}); + +test.serial('creates regular-user without password', async t => { + t.plan(3); + const request = await setupApp(stores); + return request + .post('/api/admin/user-admin') + .send({ + email: 'some@getunelash.ai', + name: 'Some Name', + rootRole: regularRole.id, + }) + .set('Content-Type', 'application/json') + .expect(201) + .expect(res => { + t.is(res.body.email, 'some@getunelash.ai'); + t.is(res.body.rootRole, regularRole.id); + t.truthy(res.body.id); + }); +}); + +test.serial('creates admin-user with password', async t => { + t.plan(6); + const request = await setupApp(stores); + const { body } = await request + .post('/api/admin/user-admin') + .send({ + email: 'some@getunelash.ai', + name: 'Some Name', + password: 'some-strange-pass-123-GH', + rootRole: adminRole.id, + }) + .set('Content-Type', 'application/json') + .expect(201); + + t.is(body.rootRole, adminRole.id); + + const user = await userStore.get({ id: body.id }); + t.is(user.email, 'some@getunelash.ai'); + t.is(user.name, 'Some Name'); + + const passwordHash = userStore.getPasswordHash(body.id); + t.truthy(passwordHash); + + const roles = await stores.accessStore.getRolesForUserId(body.id); + t.is(roles.length, 1); + t.is(roles[0].name, RoleName.ADMIN); +}); + +test.serial('requires known root role', async t => { + t.plan(0); + const request = await setupApp(stores); + return request + .post('/api/admin/user-admin') + .send({ + email: 'some@getunelash.ai', + name: 'Some Name', + rootRole: 'Unknown', + }) + .set('Content-Type', 'application/json') + .expect(400); +}); + +test.serial('update user name', async t => { + t.plan(3); + const request = await setupApp(stores); + const { body } = await request + .post('/api/admin/user-admin') + .send({ + email: 'some@getunelash.ai', + name: 'Some Name', + rootRole: regularRole.id, + }) + .set('Content-Type', 'application/json'); + + return request + .put(`/api/admin/user-admin/${body.id}`) + .send({ + name: 'New name', + }) + .set('Content-Type', 'application/json') + .expect(200) + .expect(res => { + t.is(res.body.email, 'some@getunelash.ai'); + t.is(res.body.name, 'New name'); + // t.is(res.body.rootRole, 'Regular'); + t.is(res.body.id, body.id); + }); +}); + +test.serial('should delete user', async t => { + t.plan(0); + + const user = await userStore.insert(new User({ email: 'some@mail.com' })); + + const request = await setupApp(stores); + return request.delete(`/api/admin/user-admin/${user.id}`).expect(200); +}); + +test.serial('validator should require strong password', async t => { + t.plan(0); + + const request = await setupApp(stores); + return request + .post('/api/admin/user-admin/validate-password') + .send({ password: 'simple' }) + .expect(400); +}); + +test.serial('validator should accept strong password', async t => { + t.plan(0); + + const request = await setupApp(stores); + return request + .post('/api/admin/user-admin/validate-password') + .send({ password: 'simple123-_ASsad' }) + .expect(200); +}); + +test.serial('should change password', async t => { + t.plan(0); + + const user = await userStore.insert(new User({ email: 'some@mail.com' })); + + const request = await setupApp(stores); + return request + .post(`/api/admin/user-admin/${user.id}/change-password`) + .send({ password: 'simple123-_ASsad' }) + .expect(200); +}); + +test.serial('should search for users', async t => { + t.plan(2); + + await userStore.insert(new User({ email: 'some@mail.com' })); + await userStore.insert(new User({ email: 'another@mail.com' })); + await userStore.insert(new User({ email: 'another2@mail.com' })); + + const request = await setupApp(stores); + return request + .get('/api/admin/user-admin/search?q=another') + .expect(200) + .expect(res => { + t.is(res.body.length, 2); + t.true(res.body.some(u => u.email === 'another@mail.com')); + }); +}); diff --git a/src/test/e2e/services/access-service.e2e.test.js b/src/test/e2e/services/access-service.e2e.test.js index 34c4763351..3f412b9e97 100644 --- a/src/test/e2e/services/access-service.e2e.test.js +++ b/src/test/e2e/services/access-service.e2e.test.js @@ -17,12 +17,13 @@ let accessService; let regularUser; let superUser; +let regularRole; +let adminRole; +let readRole; const createUserWithRegularAccess = async (name, email) => { const { userStore } = stores; const user = await userStore.insert(new User({ name, email })); - const roles = await accessService.getRoles(); - const regularRole = roles.find(r => r.name === 'Regular'); await accessService.addUserToRole(user.id, regularRole.id); return user; }; @@ -32,9 +33,7 @@ const createSuperUser = async () => { const user = await userStore.insert( new User({ name: 'Alice Admin', email: 'admin@getunleash.io' }), ); - const roles = await accessService.getRoles(); - const superRole = roles.find(r => r.name === 'Admin'); - await accessService.addUserToRole(user.id, superRole.id); + await accessService.addUserToRole(user.id, adminRole.id); return user; }; @@ -43,6 +42,11 @@ test.before(async () => { stores = db.stores; // projectStore = stores.projectStore; accessService = new AccessService(stores, { getLogger }); + const roles = await accessService.getRootRoles(); + regularRole = roles.find(r => r.name === RoleName.REGULAR); + adminRole = roles.find(r => r.name === RoleName.ADMIN); + readRole = roles.find(r => r.name === RoleName.READ); + regularUser = await createUserWithRegularAccess( 'Bob Test', 'bob@getunleash.io', @@ -120,11 +124,6 @@ test.serial('should grant regular CREATE_FEATURE on all projects', async t => { const { CREATE_FEATURE } = permissions; const user = regularUser; - const roles = await accessService.getRoles(); - const regularRole = roles.find( - r => r.name === 'Regular' && r.type === 'root', - ); - await accessService.addPermissionToRole( regularRole.id, permissions.CREATE_FEATURE, @@ -137,11 +136,6 @@ test.serial('should grant regular CREATE_FEATURE on all projects', async t => { }); test.serial('cannot add CREATE_FEATURE without defining project', async t => { - const roles = await accessService.getRoles(); - const regularRole = roles.find( - r => r.name === 'Regular' && r.type === 'root', - ); - await t.throwsAsync( async () => { await accessService.addPermissionToRole( @@ -159,11 +153,6 @@ test.serial('cannot add CREATE_FEATURE without defining project', async t => { test.serial( 'cannot remove CREATE_FEATURE without defining project', async t => { - const roles = await accessService.getRoles(); - const regularRole = roles.find( - r => r.name === 'Regular' && r.type === 'root', - ); - await t.throwsAsync( async () => { await accessService.removePermissionFromRole( @@ -184,11 +173,6 @@ test.serial('should remove CREATE_FEATURE on all projects', async t => { const { CREATE_FEATURE } = permissions; const user = regularUser; - const roles = await accessService.getRoles(); - const regularRole = roles.find( - r => r.name === 'Regular' && r.type === 'root', - ); - await accessService.addPermissionToRole( regularRole.id, permissions.CREATE_FEATURE, @@ -272,10 +256,10 @@ test.serial('should grant user access to project', async t => { const roles = await accessService.getRolesForProject(project); - const regularRole = roles.find( + const projectRole = roles.find( r => r.name === 'Regular' && r.project === project, ); - await accessService.addUserToRole(sUser.id, regularRole.id); + await accessService.addUserToRole(sUser.id, projectRole.id); // Should be able to update feature toggles inside the project t.true(await accessService.hasPermission(sUser, CREATE_FEATURE, project)); @@ -299,10 +283,10 @@ test.serial('should not get access if not specifying project', async t => { const roles = await accessService.getRolesForProject(project); - const regularRole = roles.find( + const projectRole = roles.find( r => r.name === 'Regular' && r.project === project, ); - await accessService.addUserToRole(sUser.id, regularRole.id); + await accessService.addUserToRole(sUser.id, projectRole.id); // Should not be able to update feature toggles outside project t.false(await accessService.hasPermission(sUser, CREATE_FEATURE)); @@ -316,8 +300,6 @@ test.serial('should remove user from role', async t => { new User({ name: 'Some User', email: 'random123@getunleash.io' }), ); - const roles = await accessService.getRoles(); - const regularRole = roles.find(r => r.name === 'Regular'); await accessService.addUserToRole(user.id, regularRole.id); // check user has one role @@ -336,8 +318,6 @@ test.serial('should return role with users', async t => { new User({ name: 'Some User', email: 'random2223@getunleash.io' }), ); - const roles = await accessService.getRoles(); - const regularRole = roles.find(r => r.name === 'Regular'); await accessService.addUserToRole(user.id, regularRole.id); const roleWithUsers = await accessService.getRole(regularRole.id); @@ -354,8 +334,6 @@ test.serial('should return role with permissions and users', async t => { new User({ name: 'Some User', email: 'random2244@getunleash.io' }), ); - const roles = await accessService.getRoles(); - const regularRole = roles.find(r => r.name === 'Regular'); await accessService.addUserToRole(user.id, regularRole.id); const roleWithPermission = await accessService.getRole(regularRole.id); @@ -397,7 +375,7 @@ test.serial('should set root role for user', async t => { new User({ name: 'Some User', email: 'random2255@getunleash.io' }), ); - await accessService.setUserRootRole(user.id, RoleName.REGULAR); + await accessService.setUserRootRole(user.id, regularRole.id); const roles = await accessService.getRolesForUser(user.id); @@ -411,8 +389,8 @@ test.serial('should switch root role for user', async t => { new User({ name: 'Some User', email: 'random22Read@getunleash.io' }), ); - await accessService.setUserRootRole(user.id, RoleName.REGULAR); - await accessService.setUserRootRole(user.id, RoleName.READ); + await accessService.setUserRootRole(user.id, regularRole.id); + await accessService.setUserRootRole(user.id, readRole.id); const roles = await accessService.getRolesForUser(user.id); diff --git a/src/test/e2e/services/api-token-service.e2e.test.ts b/src/test/e2e/services/api-token-service.e2e.test.ts index 0a3d1f7413..ad35edea5a 100644 --- a/src/test/e2e/services/api-token-service.e2e.test.ts +++ b/src/test/e2e/services/api-token-service.e2e.test.ts @@ -9,7 +9,7 @@ let stores; let apiTokenService: ApiTokenService; test.before(async () => { - db = await dbInit('api_tokens_serial', getLogger); + db = await dbInit('api_token_service_serial', getLogger); stores = db.stores; // projectStore = stores.projectStore; apiTokenService = new ApiTokenService(stores, { diff --git a/src/test/e2e/services/user-service.e2e.test.ts b/src/test/e2e/services/user-service.e2e.test.ts new file mode 100644 index 0000000000..0a33a5e5fc --- /dev/null +++ b/src/test/e2e/services/user-service.e2e.test.ts @@ -0,0 +1,93 @@ +import test from 'ava'; +import dbInit from '../helpers/database-init'; +import getLogger from '../../fixtures/no-logger'; +import UserService from '../../../lib/services/user-service'; +import { AccessService, RoleName } from '../../../lib/services/access-service'; +import UserStore from '../../../lib/db/user-store'; +import User from '../../../lib/user'; +import { IUnleashConfig } from '../../../lib/types/core'; +import { IRole } from '../../../lib/db/access-store'; + +let db; +let stores; +let userService: UserService; +let userStore: UserStore; +let adminRole: IRole; + +test.before(async () => { + db = await dbInit('user_service_serial', getLogger); + stores = db.stores; + const config: IUnleashConfig = { + getLogger, + baseUriPath: '/test', + authentication: { + enableApiToken: false, + createAdminUser: false, + }, + }; + const accessService = new AccessService(stores, config); + userService = new UserService(stores, config, accessService); + userStore = stores.userStore; + const rootRoles = await accessService.getRootRoles(); + adminRole = rootRoles.find(r => r.name === RoleName.ADMIN); +}); + +test.after(async () => { + await db.destroy(); +}); + +test.afterEach(async () => { + const users = await userStore.getAll(); + const deleteAll = users.map((u: User) => userStore.delete(u.id)); + await Promise.all(deleteAll); +}); + +test.serial('should create initial admin user', async t => { + await userService.initAdminUser(); + await t.notThrowsAsync(userService.loginUser('admin', 'admin')); + await t.throwsAsync(userService.loginUser('admin', 'wrong-password')); +}); + +test.serial('should not be allowed to create existing user', async t => { + await userStore.insert(new User({ username: 'test', name: 'Hans Mola' })); + await t.throwsAsync( + userService.createUser({ username: 'test', rootRole: adminRole.id }), + ); +}); + +test.serial('should create user with password', async t => { + await userService.createUser({ + username: 'test', + password: 'A very strange P4ssw0rd_', + rootRole: adminRole.id, + }); + const user = await userService.loginUser( + 'test', + 'A very strange P4ssw0rd_', + ); + t.is(user.username, 'test'); +}); + +test.serial('should login for user _without_ password', async t => { + const email = 'some@test.com'; + await userService.createUser({ + email, + password: 'A very strange P4ssw0rd_', + rootRole: adminRole.id, + }); + const user = await userService.loginUserWithoutPassword(email); + t.is(user.email, email); +}); + +test.serial('should get user with root role', async t => { + const email = 'some@test.com'; + const u = await userService.createUser({ + email, + password: 'A very strange P4ssw0rd_', + rootRole: adminRole.id, + }); + const user = await userService.getUser(u.id); + t.is(user.email, email); + t.is(user.id, u.id); + t.is(user.rootRole, adminRole.id); +}); diff --git a/src/test/e2e/stores/user-store.e2e.test.js b/src/test/e2e/stores/user-store.e2e.test.js index 305ec93708..00f365e7e3 100644 --- a/src/test/e2e/stores/user-store.e2e.test.js +++ b/src/test/e2e/stores/user-store.e2e.test.js @@ -127,14 +127,14 @@ test.serial('should reset user after successful login', async t => { await store.incLoginAttempts(user); await store.incLoginAttempts(user); - await store.succesfullLogin(user); + await store.successfullyLogin(user); const storedUser = await store.get(user); t.is(storedUser.loginAttempts, 0); t.true(storedUser.seenAt >= user.seenAt); }); -test.serial('should store and get permsissions', async t => { +test.serial('should store and get permissions', async t => { const store = stores.userStore; const email = 'userWithPermissions@mail.com'; const user = new User({ diff --git a/src/test/fixtures/access-service-mock.ts b/src/test/fixtures/access-service-mock.ts new file mode 100644 index 0000000000..764632fddc --- /dev/null +++ b/src/test/fixtures/access-service-mock.ts @@ -0,0 +1,99 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { IRole } from '../../lib/db/access-store'; +import { + AccessService, + IUserWithRole, + RoleName, + IPermission, + IRoleData, +} from '../../lib/services/access-service'; +import User from '../../lib/user'; +import noLoggerProvider from './no-logger'; + +class AccessServiceMock extends AccessService { + public roleName: RoleName; + + constructor() { + super( + { accessStore: undefined, userStore: undefined }, + { getLogger: noLoggerProvider }, + ); + } + + hasPermission( + user: User, + permission: string, + projectId?: string, + ): Promise { + throw new Error('Method not implemented.'); + } + + getPermissions(): IPermission[] { + throw new Error('Method not implemented.'); + } + + addUserToRole(userId: number, roleId: number): Promise { + throw new Error('Method not implemented.'); + } + + setUserRootRole(userId: number, roleId: number): Promise { + return Promise.resolve(); + } + + removeUserFromRole(userId: number, roleId: number): Promise { + throw new Error('Method not implemented.'); + } + + addPermissionToRole( + roleId: number, + permission: string, + projectId?: string, + ): Promise { + throw new Error('Method not implemented.'); + } + + removePermissionFromRole( + roleId: number, + permission: string, + projectId?: string, + ): Promise { + throw new Error('Method not implemented.'); + } + + getRoles(): Promise { + throw new Error('Method not implemented.'); + } + + getRole(roleId: number): Promise { + throw new Error('Method not implemented.'); + } + + getRolesForProject(projectId: string): Promise { + throw new Error('Method not implemented.'); + } + + getRolesForUser(userId: number): Promise { + throw new Error('Method not implemented.'); + } + + getUsersForRole(roleId: any): Promise { + throw new Error('Method not implemented.'); + } + + getProjectRoleUsers( + projectId: string, + ): Promise<[IRole[], IUserWithRole[]]> { + throw new Error('Method not implemented.'); + } + + createDefaultProjectRoles(owner: User, projectId: string): Promise { + throw new Error('Method not implemented.'); + } + + removeDefaultProjectRoles(owner: User, projectId: string): Promise { + throw new Error('Method not implemented.'); + } +} + +export default AccessServiceMock; diff --git a/src/test/fixtures/fake-access-store.ts b/src/test/fixtures/fake-access-store.ts new file mode 100644 index 0000000000..c3aac27aa9 --- /dev/null +++ b/src/test/fixtures/fake-access-store.ts @@ -0,0 +1,87 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { + AccessStore, + IRole, + IUserRole, + IUserPermission, +} from '../../lib/db/access-store'; +import noLoggerProvider from './no-logger'; + +class AccessStoreMock extends AccessStore { + constructor() { + super(undefined, undefined, noLoggerProvider); + } + + getPermissionsForUser(userId: Number): Promise { + throw new Error('Method not implemented.'); + } + + getPermissionsForRole(roleId: number): Promise { + throw new Error('Method not implemented.'); + } + + getRoles(): Promise { + return Promise.resolve([]); + } + + getRoleWithId(id: number): Promise { + throw new Error('Method not implemented.'); + } + + getRolesForProject(projectId: string): Promise { + throw new Error('Method not implemented.'); + } + + removeRolesForProject(projectId: string): Promise { + throw new Error('Method not implemented.'); + } + + getRolesForUserId(userId: number): Promise { + return Promise.resolve([]); + } + + getUserIdsForRole(roleId: number): Promise { + throw new Error('Method not implemented.'); + } + + addUserToRole(userId: number, roleId: number): Promise { + throw new Error('Method not implemented.'); + } + + removeUserFromRole(userId: number, roleId: number): Promise { + throw new Error('Method not implemented.'); + } + + createRole( + name: string, + type: string, + project?: string, + description?: string, + ): Promise { + throw new Error('Method not implemented.'); + } + + addPermissionsToRole( + role_id: number, + permissions: string[], + projectId?: string, + ): Promise { + throw new Error('Method not implemented.'); + } + + removePermissionFromRole( + roleId: number, + permission: string, + projectId?: string, + ): Promise { + throw new Error('Method not implemented.'); + } + + getRootRoleForAllUsers(): Promise { + throw new Error('Method not implemented.'); + } +} + +module.exports = AccessStoreMock; + +export default AccessStoreMock; diff --git a/src/test/fixtures/fake-user-store.ts b/src/test/fixtures/fake-user-store.ts new file mode 100644 index 0000000000..77abcaa347 --- /dev/null +++ b/src/test/fixtures/fake-user-store.ts @@ -0,0 +1,106 @@ +import UserStore, { IUserLookup } from '../../lib/db/user-store'; +import User from '../../lib/user'; +import noLoggerProvider from './no-logger'; + +class UserStoreMock extends UserStore { + data: any[]; + + idSeq: number; + + constructor() { + super(undefined, noLoggerProvider); + this.idSeq = 1; + this.data = []; + } + + async hasUser({ + id, + username, + email, + }: IUserLookup): Promise { + const user = this.data.find(i => { + if (id && i.id === id) return true; + if (username && i.username === username) return true; + if (email && i.email === email) return true; + return false; + }); + return user; + } + + async insert(user: User): Promise { + // eslint-disable-next-line no-param-reassign + user.id = this.idSeq; + this.idSeq += 1; + this.data.push(user); + return Promise.resolve(user); + } + + async update(id: number, user: User): Promise { + // eslint-disable-next-line no-param-reassign + this.data = this.data.map(o => { + if (o.id === id) return { ...o, name: user.name }; + return o; + }); + return Promise.resolve(user); + } + + async get({ id, username, email }: IUserLookup): Promise { + const user = this.data.find(i => { + if (i.id && i.id === id) return true; + if (i.username && i.username === username) return true; + if (i.email && i.email === email) return true; + return false; + }); + if (user) { + return user; + } + throw new Error('Could not find user'); + } + + async getAll(): Promise { + return Promise.resolve(this.data); + } + + async setPasswordHash(userId: number, passwordHash: string): Promise { + const u = this.data.find(a => a.id === userId); + u.passwordHash = passwordHash; + return Promise.resolve(); + } + + async getPasswordHash(id: number): Promise { + const u = this.data.find(i => i.id === id); + return Promise.resolve(u.passwordHash); + } + + async delete(id: number): Promise { + this.data = this.data.filter(item => item.id !== id); + return Promise.resolve(); + } + + async successfullyLogin(user: User): Promise { + const u = this.data.find(i => i.id === user.id); + u.login_attempts = 0; + u.seen_at = new Date(); + return Promise.resolve(); + } + + buildSelectUser(): any { + throw new Error('Not implemented'); + } + + async search(): Promise { + throw new Error('Not implemented'); + } + + async getAllWithId(): Promise { + throw new Error('Not implemented'); + } + + async incLoginAttempts(): Promise { + throw new Error('Not implemented'); + } +} + +module.exports = UserStoreMock; + +export default UserStoreMock; diff --git a/src/test/fixtures/store.js b/src/test/fixtures/store.js index 6960c53feb..fe0762bd4f 100644 --- a/src/test/fixtures/store.js +++ b/src/test/fixtures/store.js @@ -12,6 +12,8 @@ const contextFieldStore = require('./fake-context-store'); const settingStore = require('./fake-setting-store'); const addonStore = require('./fake-addon-store'); const projectStore = require('./fake-project-store'); +const UserStore = require('./fake-user-store'); +const AccessStore = require('./fake-access-store'); module.exports = { createStores: (databaseIsUp = true) => { @@ -35,6 +37,8 @@ module.exports = { settingStore: settingStore(databaseIsUp), addonStore: addonStore(databaseIsUp), projectStore: projectStore(databaseIsUp), + userStore: new UserStore(), + accessStore: new AccessStore(), }; }, }; diff --git a/yarn.lock b/yarn.lock index 80bbf37907..82a91ced49 100644 --- a/yarn.lock +++ b/yarn.lock @@ -390,6 +390,21 @@ resolved "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.2.tgz" integrity sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw== +"@mapbox/node-pre-gyp@^1.0.0": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.3.tgz#c740c23ec1007b9278d4c28f767b6e843a88c3d3" + integrity sha512-9dTIfQW8HVCxLku5QrJ/ysS/b2MdYngs9+/oPrOTLvp3TrggdANYVW2h8FGJGDf0J7MYfp44W+c90cVJx+ASuA== + dependencies: + detect-libc "^1.0.3" + https-proxy-agent "^5.0.0" + make-dir "^3.1.0" + node-fetch "^2.6.1" + nopt "^5.0.0" + npmlog "^4.1.2" + rimraf "^3.0.2" + semver "^7.3.4" + tar "^6.1.0" + "@nodelib/fs.scandir@2.1.3": version "2.1.3" resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz" @@ -503,6 +518,11 @@ dependencies: defer-to-connect "^1.0.1" +"@types/bcrypt@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/bcrypt/-/bcrypt-3.0.0.tgz#851489a9065a067cb7f3c9cbe4ce9bed8bba0876" + integrity sha512-nohgNyv+1ViVcubKBh0+XiNJ3dO8nYu///9aJ4cgSqv70gBL+94SNy/iC2NLzKPT2Zt/QavrOkBVbZRLZmw6NQ== + "@types/body-parser@*": version "1.19.0" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.0.tgz#0685b3c47eb3006ffed117cdd55164b61f80538f" @@ -587,6 +607,11 @@ resolved "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz" integrity sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA== +"@types/owasp-password-strength-test@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@types/owasp-password-strength-test/-/owasp-password-strength-test-1.3.0.tgz#f639e38847eeb0db14bf7b70896cecd4342ac571" + integrity sha512-eKYl6svyRua5OVUFm+AXSxdBrKo7snzrCcFv0KoqKNvNgB3fJzRq3s/xphf+jNTllqYxgsx1uWLeAcL4MjLWQQ== + "@types/parse-json@^4.0.0": version "4.0.0" resolved "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz" @@ -680,6 +705,11 @@ "@typescript-eslint/types" "4.15.2" eslint-visitor-keys "^2.0.0" +abbrev@1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== + abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz" @@ -710,6 +740,13 @@ acorn@^7.1.1, acorn@^7.3.1: resolved "https://registry.npmjs.org/acorn/-/acorn-7.4.0.tgz" integrity sha512-+G7P8jJmCHr+S+cLfQxygbWhXy+8YTVGzAkpEbcLo2mLoL7tij/VG41QSHACSf5QgYRhMZYHuNc6drJaO0Da+w== +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + agent-base@^4.3.0: version "4.3.0" resolved "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz" @@ -754,6 +791,11 @@ ansi-escapes@^4.2.1, ansi-escapes@^4.3.0: dependencies: type-fest "^0.11.0" +ansi-regex@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" + integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8= + ansi-regex@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz" @@ -804,11 +846,24 @@ append-transform@^2.0.0: dependencies: default-require-extensions "^3.0.0" +aproba@^1.0.3: + version "1.2.0" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" + integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== + archy@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz" integrity sha1-+cjBN1fMHde8N5rHeyxipcKGjEA= +are-we-there-yet@~1.1.2: + version "1.1.5" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21" + integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w== + dependencies: + delegates "^1.0.0" + readable-stream "^2.0.6" + arg@^4.1.0: version "4.1.3" resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" @@ -1058,6 +1113,14 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" +bcrypt@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/bcrypt/-/bcrypt-5.0.1.tgz#f1a2c20f208e2ccdceea4433df0c8b2c54ecdf71" + integrity sha512-9BTgmrhZM2t1bNuDtrtIMVSmmxZBrJ71n8Wg+YgdjHuIWYF7SjjmCPZFB+/5i/o/PIeRpwVJR3P+NrpIItUjqw== + dependencies: + "@mapbox/node-pre-gyp" "^1.0.0" + node-addon-api "^3.1.0" + bignumber.js@^9.0.0: version "9.0.1" resolved "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.1.tgz" @@ -1283,6 +1346,11 @@ chokidar@^3.4.1: optionalDependencies: fsevents "~2.1.2" +chownr@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" + integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== + chunkd@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/chunkd/-/chunkd-2.0.1.tgz" @@ -1394,6 +1462,11 @@ code-excerpt@^3.0.0: dependencies: convert-to-spaces "^1.0.1" +code-point-at@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" + integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= + collection-visit@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz" @@ -1562,6 +1635,11 @@ connect-session-knex@^2.0.0: bluebird "^3.7.2" knex "^0.21.5" +console-control-strings@^1.0.0, console-control-strings@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" + integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= + contains-path@^0.1.0: version "0.1.0" resolved "https://registry.npmjs.org/contains-path/-/contains-path-0.1.0.tgz" @@ -1813,7 +1891,7 @@ debug@3.2.6: dependencies: ms "^2.1.1" -debug@4.3.1: +debug@4, debug@4.3.1: version "4.3.1" resolved "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz" integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== @@ -1948,6 +2026,11 @@ delayed-stream@~1.0.0: resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz" integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= +delegates@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" + integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= + depd@~1.1.0, depd@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz" @@ -1968,6 +2051,11 @@ detect-file@^1.0.0: resolved "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz" integrity sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc= +detect-libc@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" + integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= + dicer@0.2.5: version "0.2.5" resolved "https://registry.npmjs.org/dicer/-/dicer-0.2.5.tgz" @@ -2889,6 +2977,13 @@ fs-extra@^8.1.0: jsonfile "^4.0.0" universalify "^0.1.0" +fs-minipass@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" + integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== + dependencies: + minipass "^3.0.0" + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" @@ -2909,6 +3004,20 @@ functional-red-black-tree@^1.0.1: resolved "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz" integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= +gauge@~2.7.3: + version "2.7.4" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" + integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c= + dependencies: + aproba "^1.0.3" + console-control-strings "^1.0.0" + has-unicode "^2.0.0" + object-assign "^4.1.0" + signal-exit "^3.0.0" + string-width "^1.0.1" + strip-ansi "^3.0.1" + wide-align "^1.1.0" + gaxios@^1.0.2, gaxios@^1.0.4, gaxios@^1.2.1, gaxios@^1.2.2: version "1.8.4" resolved "https://registry.npmjs.org/gaxios/-/gaxios-1.8.4.tgz" @@ -3193,6 +3302,11 @@ has-symbols@^1.0.0, has-symbols@^1.0.1: resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz" integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg== +has-unicode@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" + integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk= + has-value@^0.3.1: version "0.3.1" resolved "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz" @@ -3299,6 +3413,14 @@ https-proxy-agent@^2.2.1: agent-base "^4.3.0" debug "^3.1.0" +https-proxy-agent@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2" + integrity sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA== + dependencies: + agent-base "6" + debug "4" + human-signals@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz" @@ -3562,6 +3684,13 @@ is-extglob@^2.1.1: resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz" integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= +is-fullwidth-code-point@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" + integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs= + dependencies: + number-is-nan "^1.0.0" + is-fullwidth-code-point@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz" @@ -4233,6 +4362,13 @@ lru-cache@^5.0.0: dependencies: yallist "^3.0.2" +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + lru-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3" @@ -4240,7 +4376,7 @@ lru-queue@^0.1.0: dependencies: es5-ext "~0.10.2" -make-dir@^3.0.0, make-dir@^3.0.2: +make-dir@^3.0.0, make-dir@^3.0.2, make-dir@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz" integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== @@ -4420,6 +4556,21 @@ minimist@^1.2.0, minimist@^1.2.5: resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== +minipass@^3.0.0: + version "3.1.3" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.3.tgz#7d42ff1f39635482e15f9cdb53184deebd5815fd" + integrity sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg== + dependencies: + yallist "^4.0.0" + +minizlib@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" + integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== + dependencies: + minipass "^3.0.0" + yallist "^4.0.0" + mixin-deep@^1.2.0: version "1.3.2" resolved "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz" @@ -4435,7 +4586,7 @@ mkdirp@0.x.x, mkdirp@^0.5.1, mkdirp@~0.5.0: dependencies: minimist "^1.2.5" -mkdirp@^1.0.4: +mkdirp@^1.0.3, mkdirp@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== @@ -4552,6 +4703,11 @@ nise@^4.0.4: just-extend "^4.0.2" path-to-regexp "^1.7.0" +node-addon-api@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.1.0.tgz#98b21931557466c6729e51cb77cd39c965f42239" + integrity sha512-flmrDNB06LIl5lywUz7YlNGZH/5p0M7W28k8hzd9Lshtdh1wshD2Y+U4h9LD6KObOy1f+fEVdgprPrEymjM5uw== + node-cleanup@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/node-cleanup/-/node-cleanup-2.1.2.tgz#7ac19abd297e09a7f72a71545d951b517e4dde2c" @@ -4592,6 +4748,13 @@ noms@0.0.0: inherits "^2.0.1" readable-stream "~1.0.31" +nopt@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" + integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ== + dependencies: + abbrev "1" + normalize-package-data@^2.3.2, normalize-package-data@^2.5.0: version "2.5.0" resolved "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz" @@ -4619,6 +4782,21 @@ npm-run-path@^4.0.0: dependencies: path-key "^3.0.0" +npmlog@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" + integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== + dependencies: + are-we-there-yet "~1.1.2" + console-control-strings "~1.1.0" + gauge "~2.7.3" + set-blocking "~2.0.0" + +number-is-nan@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" + integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= + nyc@^15.1.0: version "15.1.0" resolved "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz" @@ -4662,7 +4840,7 @@ oauth@0.9.x: resolved "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz" integrity sha1-vR/vr2hslrdUda7VGWQS/2DPucE= -object-assign@^4.1.1: +object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz" integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= @@ -4819,6 +4997,11 @@ os-tmpdir@~1.0.2: resolved "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz" integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= +owasp-password-strength-test@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/owasp-password-strength-test/-/owasp-password-strength-test-1.3.0.tgz#4f629e42903e8f6d279b230d657ab61e58e44b12" + integrity sha1-T2KeQpA+j20nmyMNZXq2HljkSxI= + p-cancelable@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz" @@ -5438,7 +5621,7 @@ readable-stream@1.1.x: isarray "0.0.1" string_decoder "~0.10.x" -readable-stream@^2.2.2, readable-stream@~2.3.6: +readable-stream@^2.0.6, readable-stream@^2.2.2, readable-stream@~2.3.6: version "2.3.7" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz" integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== @@ -5776,6 +5959,13 @@ semver@^7.3.2: resolved "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz" integrity sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ== +semver@^7.3.4: + version "7.3.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" + integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== + dependencies: + lru-cache "^6.0.0" + send@0.17.1: version "0.17.1" resolved "https://registry.npmjs.org/send/-/send-0.17.1.tgz" @@ -5821,7 +6011,7 @@ serve-static@1.14.1: parseurl "~1.3.3" send "0.17.1" -set-blocking@^2.0.0: +set-blocking@^2.0.0, set-blocking@~2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz" integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= @@ -5865,7 +6055,7 @@ shebang-regex@^3.0.0: resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== -signal-exit@^3.0.2: +signal-exit@^3.0.0, signal-exit@^3.0.2: version "3.0.3" resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz" integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== @@ -6129,6 +6319,23 @@ string-argv@^0.1.1: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.1.2.tgz#c5b7bc03fb2b11983ba3a72333dd0559e77e4738" integrity sha512-mBqPGEOMNJKXRo7z0keX0wlAhbBAjilUdPW13nN0PecVryZxdHIeM7TqbsSUA7VYuS00HGC6mojP7DlQzfa9ZA== +string-width@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" + integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= + dependencies: + code-point-at "^1.0.0" + is-fullwidth-code-point "^1.0.0" + strip-ansi "^3.0.0" + +"string-width@^1.0.2 || 2": + version "2.1.1" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" + integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== + dependencies: + is-fullwidth-code-point "^2.0.0" + strip-ansi "^4.0.0" + string-width@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz" @@ -6191,6 +6398,13 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" +strip-ansi@^3.0.0, strip-ansi@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" + integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= + dependencies: + ansi-regex "^2.0.0" + strip-ansi@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz" @@ -6297,6 +6511,18 @@ table@^5.2.3: slice-ansi "^2.1.0" string-width "^3.0.0" +tar@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.0.tgz#d1724e9bcc04b977b18d5c573b333a2207229a83" + integrity sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA== + dependencies: + chownr "^2.0.0" + fs-minipass "^2.0.0" + minipass "^3.0.0" + minizlib "^2.1.1" + mkdirp "^1.0.3" + yallist "^4.0.0" + tarn@^3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/tarn/-/tarn-3.0.1.tgz" @@ -6615,10 +6841,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@4.0.0-alpha.1: - version "4.0.0-alpha.1" - resolved "https://registry.yarnpkg.com/unleash-frontend/-/unleash-frontend-4.0.0-alpha.1.tgz#dff9da220e406a46d70530fcd4fa2ee838c68d22" - integrity sha512-fdUh1b9qN6W8LiBdT9v2V4m5NWq9kn2dZSiZbKScRmkP3yofObKlhVeUN6WYu7gPOGVjNz108DdgF7aEURmBkQ== +unleash-frontend@4.0.0-alpha.2: + version "4.0.0-alpha.2" + resolved "https://registry.yarnpkg.com/unleash-frontend/-/unleash-frontend-4.0.0-alpha.2.tgz#c0db66b038f79adae8189840dce18271b58b5172" + integrity sha512-6JiXrVp5AtldnWxFe2zXDP72n7DHq1dVF6sg6b5gMwhBwLUKlkw4nh27TvGVhwCCFuay82cSwfDrSErsXLXZQg== unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" @@ -6807,6 +7033,13 @@ which@^2.0.1: dependencies: isexe "^2.0.0" +wide-align@^1.1.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" + integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== + dependencies: + string-width "^1.0.2 || 2" + widest-line@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz" @@ -6902,6 +7135,11 @@ yallist@^3.0.2: resolved "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz" integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + yaml@^1.7.2: version "1.10.0" resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.0.tgz"