diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000000..8351c19397 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +14 diff --git a/package.json b/package.json index 024103c438..ddc5b44075 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "connect-session-knex": "^2.0.0", "cookie-parser": "^1.4.4", "cookie-session": "^2.0.0-rc.1", + "cors": "^2.8.5", "db-migrate": "0.11.11", "db-migrate-pg": "^1.2.2", "db-migrate-shared": "^1.2.0", @@ -108,22 +109,22 @@ "@istanbuljs/nyc-config-typescript": "^1.0.1", "@passport-next/passport": "^3.1.0", "@passport-next/passport-google-oauth2": "^1.0.0", + "@types/bcrypt": "^3.0.0", "@types/express": "^4.17.11", "@types/node": "^14.14.37", "@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", + "@typescript-eslint/eslint-plugin": "^4.22.0", + "@typescript-eslint/parser": "^4.22.0", "ava": "^3.7.0", "copyfiles": "^2.4.1", "coveralls": "^3.1.0", "eslint": "^6.8.0", - "eslint-config-airbnb-base": "^14.1.0", + "eslint-config-airbnb-base": "^14.2.1", "eslint-config-airbnb-typescript": "^12.3.1", - "eslint-config-prettier": "^6.10.1", - "eslint-plugin-import": "^2.20.2", - "eslint-plugin-prettier": "^3.1.3", + "eslint-config-prettier": "^8.1.0", + "eslint-plugin-import": "^2.22.1", + "eslint-plugin-prettier": "^3.3.1", "faker": "^5.3.1", "fetch-mock": "^9.11.0", "husky": "^4.2.3", @@ -141,7 +142,7 @@ "supertest": "^5.0.0", "ts-node": "^9.1.1", "tsc-watch": "^4.2.9", - "typescript": "^4.1.5" + "typescript": "^4.2.4" }, "resolutions": { "set-value": "^2.0.1", diff --git a/src/lib/addons/addon.js b/src/lib/addons/addon.js index 499c5c8ec2..a1c0fd61f9 100644 --- a/src/lib/addons/addon.js +++ b/src/lib/addons/addon.js @@ -38,9 +38,10 @@ class Addon { return res; } if (retries > 0 && retryCodes.includes(res.status)) { - setTimeout(() => { - return this.fetchRetry(url, options, retries - 1, backoff * 2); - }, backoff); + setTimeout( + () => this.fetchRetry(url, options, retries - 1, backoff * 2), + backoff, + ); } return res; } diff --git a/src/lib/app.ts b/src/lib/app.ts index 71ab25008e..5f39069287 100644 --- a/src/lib/app.ts +++ b/src/lib/app.ts @@ -4,7 +4,7 @@ import apiTokenMiddleware from './middleware/api-token-middleware'; import { AuthenticationType } from './types/core'; const express = require('express'); - +const cors = require('cors'); const compression = require('compression'); const favicon = require('serve-favicon'); const cookieParser = require('cookie-parser'); @@ -33,6 +33,10 @@ module.exports = function(config, services = {}) { config.preHook(app, config, services); } + if (process.env.NODE_ENV === 'development') { + app.use(cors()); + } + app.use(compression()); app.use(cookieParser()); app.use(express.json({ strict: false })); diff --git a/src/lib/db/feature-toggle-store.js b/src/lib/db/feature-toggle-store.js index 6e5bc9b16d..8abc768da4 100644 --- a/src/lib/db/feature-toggle-store.js +++ b/src/lib/db/feature-toggle-store.js @@ -272,13 +272,11 @@ class FeatureToggleStore { const rows = await this.db(FEATURE_TAG_TABLE).select( FEATURE_TAG_COLUMNS, ); - return rows.map(row => { - return { - featureName: row.feature_name, - tagType: row.tag_type, - tagValue: row.tag_value, - }; - }); + return rows.map(row => ({ + featureName: row.feature_name, + tagType: row.tag_type, + tagValue: row.tag_value, + })); } async dropFeatureTags() { diff --git a/src/lib/db/index.js b/src/lib/db/index.js index 1ffcd8463f..ce649aa46d 100644 --- a/src/lib/db/index.js +++ b/src/lib/db/index.js @@ -2,6 +2,7 @@ // eslint-disable-next-line import { AccessStore } from './access-store'; +import { ResetTokenStore } from './reset-token-store'; const { createDb } = require('./db-pool'); const EventStore = require('./event-store'); @@ -57,5 +58,6 @@ module.exports.createStores = (config, eventBus) => { addonStore: new AddonStore(db, eventBus, getLogger), accessStore: new AccessStore(db, eventBus, getLogger), apiTokenStore: new ApiTokenStore(db, eventBus, getLogger), + resetTokenStore: new ResetTokenStore(db, eventBus, getLogger), }; }; diff --git a/src/lib/db/reset-token-store.ts b/src/lib/db/reset-token-store.ts new file mode 100644 index 0000000000..62ca2c2047 --- /dev/null +++ b/src/lib/db/reset-token-store.ts @@ -0,0 +1,131 @@ +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 = 'reset_tokens'; + +interface IResetTokenTable { + reset_token: string; + user_id: number; + expires_at: Date; + created_at: Date; + created_by: string; + used_at: Date; +} + +export interface IResetTokenCreate { + reset_token: string; + user_id: number; + expires_at: Date; + created_by?: string; +} + +export interface IResetToken { + userId: number; + token: string; + createdBy: string; + expiresAt: Date; + createdAt: Date; + usedAt?: Date; +} + +export interface IResetQuery { + userId: number; + token: string; +} + +export interface IResetTokenQuery { + user_id: number; + reset_token: string; +} + +const rowToResetToken = (row: IResetTokenTable): IResetToken => { + return { + userId: row.user_id, + token: row.reset_token, + expiresAt: row.expires_at, + createdAt: row.created_at, + createdBy: row.created_by, + usedAt: row.used_at, + }; +}; + +export class ResetTokenStore { + private logger: Logger; + + private timer: Function; + + private db: Knex; + + constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) { + this.db = db; + this.logger = getLogger('db/reset-token-store.js'); + this.timer = (action: string) => + metricsHelper.wrapTimer(eventBus, DB_TIME, { + store: 'reset-tokens', + action, + }); + } + + async getActive(token: string): Promise { + const row = await this.db(TABLE) + .where({ reset_token: token }) + .where('expires_at', '>', new Date()) + .first(); + if (!row) { + throw new NotFoundError('Could not find an active token'); + } + return rowToResetToken(row); + } + + async insert(newToken: IResetTokenCreate): Promise { + const [row] = await this.db(TABLE) + .insert(newToken) + .returning(['created_at']); + return { + userId: newToken.user_id, + token: newToken.reset_token, + expiresAt: newToken.expires_at, + createdAt: row.created_at, + createdBy: newToken.created_by, + }; + } + + async useToken(token: IResetQuery): Promise { + try { + await this.db(TABLE) + .update({ used_at: new Date() }) + .where({ reset_token: token.token, user_id: token.userId }); + return true; + } catch (e) { + return false; + } + } + + async delete({ reset_token }: IResetTokenQuery): Promise { + return this.db(TABLE) + .where(reset_token) + .del(); + } + + async deleteAll(): Promise { + return this.db(TABLE).del(); + } + + async deleteExpired(): Promise { + return this.db(TABLE) + .where('expires_at', '<', new Date()) + .del(); + } + + async expireExistingTokensForUser(user_id: number): Promise { + await this.db(TABLE) + .where({ user_id }) + .update({ + expires_at: new Date(), + }); + } +} diff --git a/src/lib/error/invalid-token-error.ts b/src/lib/error/invalid-token-error.ts new file mode 100644 index 0000000000..30097e6f6c --- /dev/null +++ b/src/lib/error/invalid-token-error.ts @@ -0,0 +1,24 @@ +class InvalidTokenError extends Error { + constructor() { + super(); + Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + this.message = 'Token was not valid'; + } + + toJSON(): any { + const obj = { + isJoi: true, + name: this.constructor.name, + details: [ + { + message: this.message, + }, + ], + }; + return obj; + } +} + +export default InvalidTokenError; +module.exports = InvalidTokenError; diff --git a/src/lib/error/owasp-validation-error.ts b/src/lib/error/owasp-validation-error.ts new file mode 100644 index 0000000000..17525fad43 --- /dev/null +++ b/src/lib/error/owasp-validation-error.ts @@ -0,0 +1,29 @@ +import { TestResult } from 'owasp-password-strength-test'; + +class OwaspValidationError extends Error { + private errors: string[]; + + constructor(testResult: TestResult) { + super(testResult.errors[0]); + Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + this.errors = testResult.errors; + } + + toJSON(): any { + const obj = { + isJoi: true, + name: this.constructor.name, + details: [ + { + validationErrors: this.errors, + message: this.errors[0], + }, + ], + }; + return obj; + } +} + +export default OwaspValidationError; +module.exports = OwaspValidationError; diff --git a/src/lib/error/used-token-error.ts b/src/lib/error/used-token-error.ts new file mode 100644 index 0000000000..3ac1e58f38 --- /dev/null +++ b/src/lib/error/used-token-error.ts @@ -0,0 +1,24 @@ +class UsedTokenError extends Error { + constructor(usedAt: Date) { + super(); + Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + this.message = `Token was already used at ${usedAt}`; + } + + toJSON(): any { + const obj = { + isJoi: true, + name: this.constructor.name, + details: [ + { + message: this.message, + }, + ], + }; + return obj; + } +} + +export default UsedTokenError; +module.exports = UsedTokenError; diff --git a/src/lib/middleware/api-token-middleware.test.ts b/src/lib/middleware/api-token-middleware.test.ts index 281d2618ff..36c85dde68 100644 --- a/src/lib/middleware/api-token-middleware.test.ts +++ b/src/lib/middleware/api-token-middleware.test.ts @@ -101,6 +101,7 @@ test('should not add user if disabled', async t => { enableApiToken: false, createAdminUser: false, }, + unleashUrl: 'http://localhost:4242', }; const func = apiTokenMiddleware(disabledConfig, { apiTokenService }); diff --git a/src/lib/middleware/content_type_checker.test.js b/src/lib/middleware/content_type_checker.test.js index cfa6c14951..7d90a847d8 100644 --- a/src/lib/middleware/content_type_checker.test.js +++ b/src/lib/middleware/content_type_checker.test.js @@ -11,26 +11,20 @@ const mockRequest = contentType => ({ }, }); -const returns415 = t => { - return { - status: code => { - t.is(415, code); - return { - end: t.pass, - }; - }, - }; -}; +const returns415 = t => ({ + status: code => { + t.is(415, code); + return { + end: t.pass, + }; + }, +}); -const expectNoCall = t => { - return { - status: () => { - return { - end: t.fail, - }; - }, - }; -}; +const expectNoCall = t => ({ + status: () => ({ + end: t.fail, + }), +}); test('Content-type middleware should by default only support application/json', t => { const middleware = requireContentType(); diff --git a/src/lib/middleware/oss-authentication.js b/src/lib/middleware/oss-authentication.js index a8811dd941..e3e446c25b 100644 --- a/src/lib/middleware/oss-authentication.js +++ b/src/lib/middleware/oss-authentication.js @@ -3,13 +3,12 @@ const AuthenticationRequired = require('../authentication-required'); function ossAuthHook(app, config) { const { baseUriPath } = config; - const generateAuthResponse = async () => { - return new AuthenticationRequired({ + const generateAuthResponse = async () => + 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) { diff --git a/src/lib/routes/admin-api/email.js b/src/lib/routes/admin-api/email.js index 778b3e31e3..d12cbe29f2 100644 --- a/src/lib/routes/admin-api/email.js +++ b/src/lib/routes/admin-api/email.js @@ -1,6 +1,6 @@ +import { ADMIN } from '../../permissions'; import { TemplateFormat } from '../../services/email-service'; import { handleErrors } from './util'; -import { ADMIN } from '../../permissions'; const Controller = require('../controller'); diff --git a/src/lib/routes/admin-api/user-admin.ts b/src/lib/routes/admin-api/user-admin.ts index d9042b0a6b..80e2242574 100644 --- a/src/lib/routes/admin-api/user-admin.ts +++ b/src/lib/routes/admin-api/user-admin.ts @@ -2,8 +2,11 @@ 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 { AccessService } from '../../services/access-service'; import { Logger } from '../../logger'; +import { handleErrors } from './util'; + +const getCreatorUsernameOrPassword = req => req.user.username || req.user.email; class UserAdminController extends Controller { private userService: UserService; @@ -25,6 +28,21 @@ class UserAdminController extends Controller { this.put('/:id', this.updateUser, ADMIN); this.post('/:id/change-password', this.changePassword, ADMIN); this.delete('/:id', this.deleteUser, ADMIN); + this.post('/reset-password', this.resetPassword); + } + + async resetPassword(req, res) { + try { + const requester = getCreatorUsernameOrPassword(req); + const receiver = req.body.id; + const resetPasswordUrl = await this.userService.createResetPasswordEmail( + receiver, + requester, + ); + res.json({ resetPasswordUrl }); + } catch (e) { + handleErrors(res, this.logger, e); + } } async getUsers(req, res) { diff --git a/src/lib/routes/admin-api/user.js b/src/lib/routes/admin-api/user.js index 3b2ee56361..31f7681c8c 100644 --- a/src/lib/routes/admin-api/user.js +++ b/src/lib/routes/admin-api/user.js @@ -5,7 +5,7 @@ const Controller = require('../controller'); class UserController extends Controller { constructor(config) { super(config); - + this.logger = config.getLogger('admin-api/user.js'); this.get('/', this.getUser); this.get('/logout', this.logout); } diff --git a/src/lib/routes/admin-api/util.js b/src/lib/routes/admin-api/util.js index 7bc2d1fa2e..2b1d342c27 100644 --- a/src/lib/routes/admin-api/util.js +++ b/src/lib/routes/admin-api/util.js @@ -52,6 +52,21 @@ const handleErrors = (res, logger, error) => { .status(409) .json(error) .end(); + case 'UsedTokenError': + return res + .status(403) + .json(error) + .end(); + case 'InvalidTokenError': + return res + .status(401) + .json(error) + .end(); + case 'OwaspValidationError': + return res + .status(400) + .json(error) + .end(); default: logger.error('Server failed executing request', error); return res.status(500).end(); diff --git a/src/lib/routes/auth/reset-password-controller.ts b/src/lib/routes/auth/reset-password-controller.ts new file mode 100644 index 0000000000..a0c3fbd5a8 --- /dev/null +++ b/src/lib/routes/auth/reset-password-controller.ts @@ -0,0 +1,89 @@ +import { Request, Response } from 'express'; +import Controller from '../controller'; +import UserService from '../../services/user-service'; +import { IUnleashConfig } from '../../types/core'; +import { Logger } from '../../logger'; +import { handleErrors } from '../admin-api/util'; + +interface IServices { + userService: UserService; +} + +interface IValidateQuery { + token: string; +} + +interface IChangePasswordBody { + token: string; + password: string; +} + +const UNLEASH = 'Unleash'; +class ResetPasswordController extends Controller { + userService: UserService; + + logger: Logger; + + constructor(config: IUnleashConfig, { userService }: IServices) { + super(config); + this.logger = config.getLogger( + 'lib/routes/auth/reset-password-controller.ts', + ); + this.userService = userService; + this.get('/validate', this.validateToken); + this.post('/password', this.changePassword); + this.post('/validate-password', this.validatePassword); + this.post('/password-email', this.sendResetPasswordEmail); + } + + async sendResetPasswordEmail(req: Request, res: Response): Promise { + const { email } = req.body; + + try { + await this.userService.createResetPasswordEmail(email, UNLEASH); + res.status(200).end(); + } catch (e) { + handleErrors(res, this.logger, e); + } + } + + async validatePassword(req: Request, res: Response): Promise { + const { password } = req.body; + + try { + this.userService.validatePassword(password); + res.status(200).end(); + } catch (e) { + handleErrors(res, this.logger, e); + } + } + + async validateToken( + req: Request, + res: Response, + ): Promise { + const { token } = req.query; + try { + const user = await this.userService.getUserForToken(token); + res.status(200).json(user); + } catch (e) { + handleErrors(res, this.logger, e); + } + } + + async changePassword( + req: Request, + res: Response, + ): Promise { + const { token, password } = req.body; + try { + await this.userService.resetPassword(token, password); + res.status(200).end(); + } catch (e) { + handleErrors(res, this.logger, e); + } + } +} + +export default ResetPasswordController; +module.exports = ResetPasswordController; diff --git a/src/lib/routes/controller.js b/src/lib/routes/controller.js index 30764ca222..0fb2afaa44 100644 --- a/src/lib/routes/controller.js +++ b/src/lib/routes/controller.js @@ -4,19 +4,17 @@ const { Router } = require('express'); const NoAccessError = require('../error/no-access-error'); const requireContentType = require('../middleware/content_type_checker'); -const checkPermission = permission => { - return async (req, res, next) => { - if (!permission) { - return next(); - } - if (req.checkRbac && (await req.checkRbac(permission))) { - return next(); - } - return res - .status(403) - .json(new NoAccessError(permission)) - .end(); - }; +const checkPermission = permission => async (req, res, next) => { + if (!permission) { + return next(); + } + if (req.checkRbac && (await req.checkRbac(permission))) { + return next(); + } + return res + .status(403) + .json(new NoAccessError(permission)) + .end(); }; /** diff --git a/src/lib/routes/index.ts b/src/lib/routes/index.ts index 8b2b397ac7..bf40f23a5c 100644 --- a/src/lib/routes/index.ts +++ b/src/lib/routes/index.ts @@ -1,4 +1,5 @@ import { BackstageController } from './backstage'; +import ResetPasswordController from './auth/reset-password-controller'; const AdminApi = require('./admin-api'); const ClientApi = require('./client-api'); @@ -18,6 +19,10 @@ class IndexRouter extends Controller { '/auth/simple', new SimplePasswordProvider(config, services).router, ); + this.use( + '/auth/reset', + new ResetPasswordController(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/server-impl.js b/src/lib/server-impl.js index 8ab18c20f1..424087f2ee 100644 --- a/src/lib/server-impl.js +++ b/src/lib/server-impl.js @@ -85,9 +85,9 @@ async function createApp(options) { const stop = () => { logger.info('Shutting down Unleash...'); - return closeServer({ server, metricsMonitor }).then(() => { - return destroyDatabase(stores); - }); + return closeServer({ server, metricsMonitor }).then(() => + destroyDatabase(stores), + ); }; server.keepAliveTimeout = options.keepAliveTimeout; diff --git a/src/lib/services/client-metrics/index.js b/src/lib/services/client-metrics/index.js index 8416c6a887..2c34cd91a2 100644 --- a/src/lib/services/client-metrics/index.js +++ b/src/lib/services/client-metrics/index.js @@ -88,13 +88,11 @@ module.exports = class ClientMetricsService { if (this.clientAppStore) { const appsToAnnounce = await this.clientAppStore.setUnannouncedToAnnounced(); if (appsToAnnounce.length > 0) { - const events = appsToAnnounce.map(app => { - return { - type: APPLICATION_CREATED, - createdBy: app.createdBy || 'unknown', - data: app, - }; - }); + const events = appsToAnnounce.map(app => ({ + type: APPLICATION_CREATED, + createdBy: app.createdBy || 'unknown', + data: app, + })); await this.eventStore.batchStore(events); } } diff --git a/src/lib/services/email-service.test.ts b/src/lib/services/email-service.test.ts index c901e5628c..7e2b089189 100644 --- a/src/lib/services/email-service.test.ts +++ b/src/lib/services/email-service.test.ts @@ -5,42 +5,43 @@ import noLoggerProvider from '../../test/fixtures/no-logger'; test('Can send reset email', async t => { const emailService = new EmailService( { - host: '', + host: 'test', port: 587, secure: false, auth: { user: '', - password: '', + pass: '', }, sender: 'noreply@getunleash.ai', transporterType: TransporterType.JSON, }, noLoggerProvider, ); + const resetLinkUrl = + 'https://unleash-hosted.com/reset-password?token=$2b$10$M06Ysso6KL4ueH/xR6rdSuY5GSymdIwmIkEUJMRkB.Qn26r5Gi5vW'; + const content = await emailService.sendResetMail( 'Some username', - 'test@test.com', - 'abc123', + 'test@resetLinkUrl.com', + resetLinkUrl, ); const message = JSON.parse(content.message); t.is(message.from.address, 'noreply@getunleash.ai'); - t.is(message.subject, 'Someone has requested to reset your password'); - t.true(message.html.indexOf('Some username') > 0); - t.true(message.text.indexOf('Some username') > 0); - t.true(message.html.indexOf('abc123') > 0); - t.true(message.text.indexOf('abc123') > 0); + t.is(message.subject, 'Unleash - Reset your password'); + t.true(message.html.includes(resetLinkUrl)); + t.true(message.text.includes(resetLinkUrl)); }); test('Can send welcome mail', async t => { const emailService = new EmailService( { - host: '', + host: 'test', port: 9999, secure: false, sender: 'noreply@getunleash.ai', auth: { user: '', - password: '', + pass: '', }, transporterType: TransporterType.JSON, }, @@ -53,8 +54,5 @@ test('Can send welcome mail', async t => { ); const message = JSON.parse(content.message); t.is(message.from.address, 'noreply@getunleash.ai'); - t.is( - message.subject, - 'Welcome to Unleash. Please configure your password.', - ); + t.is(message.subject, 'Welcome to Unleash'); }); diff --git a/src/lib/services/email-service.ts b/src/lib/services/email-service.ts index 60baee40dc..885c85cea4 100644 --- a/src/lib/services/email-service.ts +++ b/src/lib/services/email-service.ts @@ -7,7 +7,7 @@ import NotFoundError from '../error/notfound-error'; export interface IAuthOptions { user: string; - password: string; + pass: string; } export enum TemplateFormat { @@ -29,9 +29,8 @@ export interface IEmailOptions { transporterType: TransporterType; } -const RESET_MAIL_SUBJECT = 'Someone has requested to reset your password'; -const GETTING_STARTED_SUBJECT = - 'Welcome to Unleash. Please configure your password.'; +const RESET_MAIL_SUBJECT = 'Unleash - Reset your password'; +const GETTING_STARTED_SUBJECT = 'Welcome to Unleash'; export class EmailService { private logger: Logger; @@ -42,12 +41,12 @@ export class EmailService { constructor(email: IEmailOptions | undefined, getLogger: LogProvider) { this.logger = getLogger('services/email-service.ts'); - if (email) { + if (email && email.host) { this.sender = email.sender; if (email.transporterType === TransporterType.JSON) { this.mailer = createTransport({ jsonTransport: true }); } else { - const connectionString = `${email.auth.user}:${email.auth.password}@${email.host}:${email.port}`; + const connectionString = `${email.auth.user}:${email.auth.pass}@${email.host}:${email.port}`; this.mailer = email.secure ? createTransport(`smtps://${connectionString}`) : createTransport(`smtp://${connectionString}`); diff --git a/src/lib/services/index.js b/src/lib/services/index.js index 26b70ddb73..32601bd8ec 100644 --- a/src/lib/services/index.js +++ b/src/lib/services/index.js @@ -12,6 +12,7 @@ const { EmailService } = require('./email-service'); const { AccessService } = require('./access-service'); const { ApiTokenService } = require('./api-token-service'); const UserService = require('./user-service'); +const ResetTokenService = require('./reset-token-service'); module.exports.createServices = (stores, config) => { const accessService = new AccessService(stores, config); @@ -31,7 +32,12 @@ 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); + const resetTokenService = new ResetTokenService(stores, config); + const userService = new UserService(stores, config, { + accessService, + resetTokenService, + emailService, + }); return { accessService, @@ -48,5 +54,6 @@ module.exports.createServices = (stores, config) => { apiTokenService, emailService, userService, + resetTokenService, }; }; diff --git a/src/lib/services/reset-token-service.ts b/src/lib/services/reset-token-service.ts new file mode 100644 index 0000000000..836604fbb8 --- /dev/null +++ b/src/lib/services/reset-token-service.ts @@ -0,0 +1,110 @@ +import crypto from 'crypto'; +import bcrypt from 'bcrypt'; +import { URL } from 'url'; +import { + ResetTokenStore, + IResetToken, + IResetQuery, +} from '../db/reset-token-store'; +import { Logger } from '../logger'; +import UserStore from '../db/user-store'; +import UsedTokenError from '../error/used-token-error'; +import { IUnleashConfig } from '../types/core'; +import InvalidTokenError from '../error/invalid-token-error'; + +const ONE_DAY = 86_400_000; + +interface IStores { + resetTokenStore: ResetTokenStore; + userStore: UserStore; +} + +export default class ResetTokenService { + private store: ResetTokenStore; + + private logger: Logger; + + private readonly unleashBase: URL; + + constructor( + stores: IStores, + { + getLogger, + baseUriPath, + unleashUrl = 'http://localhost:4242', + }: IUnleashConfig, + ) { + this.store = stores.resetTokenStore; + this.logger = getLogger('/services/reset-token-service.ts'); + this.unleashBase = new URL(baseUriPath, unleashUrl); + } + + async useAccessToken(token: IResetQuery): Promise { + try { + await this.isValid(token.token); + await this.store.useToken(token); + return true; + } catch (e) { + return false; + } + } + + async isValid(token: string): Promise { + let t; + try { + t = await this.store.getActive(token); + if (!t.usedAt) { + return t; + } + } catch (e) { + throw new InvalidTokenError(); + } + throw new UsedTokenError(t.usedAt); + } + + private async createResetUrl( + forUser: number, + creator: string, + path: string, + ): Promise { + const token = await this.createToken(forUser, creator); + return Promise.resolve( + new URL(`${path}?token=${token.token}`, this.unleashBase), + ); + } + + async createWelcomeUrl(forUser: number, creator: string): Promise { + const path = '/#/new-user'; + return this.createResetUrl(forUser, creator, path); + } + + async createResetPasswordUrl( + forUser: number, + creator: string, + ): Promise { + const path = '/#/reset-password'; + return this.createResetUrl(forUser, creator, path); + } + + async createToken( + tokenUser: number, + creator: string, + expiryDelta: number = ONE_DAY, + ): Promise { + const token = await this.generateToken(); + const expiry = new Date(Date.now() + expiryDelta); + await this.store.expireExistingTokensForUser(tokenUser); + return this.store.insert({ + reset_token: token, + user_id: tokenUser, + expires_at: expiry, + created_by: creator, + }); + } + + private generateToken(): Promise { + return bcrypt.hash(crypto.randomBytes(32), 10); + } +} + +module.exports = ResetTokenService; diff --git a/src/lib/services/state-service.js b/src/lib/services/state-service.js index cd211c3fd8..1c650872b3 100644 --- a/src/lib/services/state-service.js +++ b/src/lib/services/state-service.js @@ -183,13 +183,11 @@ class StateService { const importedProjects = await this.projectStore.importProjects( projectsToImport, ); - const importedProjectEvents = importedProjects.map(project => { - return { - type: PROJECT_IMPORT, - createdBy: userName, - data: project, - }; - }); + const importedProjectEvents = importedProjects.map(project => ({ + type: PROJECT_IMPORT, + createdBy: userName, + data: project, + })); await this.eventStore.batchStore(importedProjectEvents); } } @@ -272,13 +270,11 @@ class StateService { const importedFeatureTags = await this.toggleStore.importFeatureTags( featureTagsToInsert, ); - const importedFeatureTagEvents = importedFeatureTags.map(tag => { - return { - type: FEATURE_TAG_IMPORT, - createdBy: userName, - data: tag, - }; - }); + const importedFeatureTagEvents = importedFeatureTags.map(tag => ({ + type: FEATURE_TAG_IMPORT, + createdBy: userName, + data: tag, + })); await this.eventStore.batchStore(importedFeatureTagEvents); } } @@ -294,13 +290,11 @@ class StateService { ); if (tagsToInsert.length > 0) { const importedTags = await this.tagStore.bulkImport(tagsToInsert); - const importedTagEvents = importedTags.map(tag => { - return { - type: TAG_IMPORT, - createdBy: userName, - data: tag, - }; - }); + const importedTagEvents = importedTags.map(tag => ({ + type: TAG_IMPORT, + createdBy: userName, + data: tag, + })); await this.eventStore.batchStore(importedTagEvents); } } @@ -315,13 +309,11 @@ class StateService { const importedTagTypes = await this.tagTypeStore.bulkImport( tagTypesToInsert, ); - const importedTagTypeEvents = importedTagTypes.map(tagType => { - return { - type: TAG_TYPE_IMPORT, - createdBy: userName, - data: tagType, - }; - }); + const importedTagTypeEvents = importedTagTypes.map(tagType => ({ + type: TAG_TYPE_IMPORT, + createdBy: userName, + data: tagType, + })); await this.eventStore.batchStore(importedTagTypeEvents); } } diff --git a/src/lib/services/state-util.js b/src/lib/services/state-util.js index d1b534a6b6..2b7bec7d22 100644 --- a/src/lib/services/state-util.js +++ b/src/lib/services/state-util.js @@ -2,36 +2,28 @@ const fs = require('fs'); const mime = require('mime'); const YAML = require('js-yaml'); -const readFile = file => { - return new Promise((resolve, reject) => +const readFile = file => + new Promise((resolve, reject) => fs.readFile(file, (err, v) => (err ? reject(err) : resolve(v))), ); + +const parseFile = (file, data) => + mime.getType(file) === 'text/yaml' ? YAML.safeLoad(data) : JSON.parse(data); + +const filterExisting = (keepExisting, existingArray = []) => item => { + if (keepExisting) { + const found = existingArray.find(t => t.name === item.name); + return !found; + } + return true; }; -const parseFile = (file, data) => { - return mime.getType(file) === 'text/yaml' - ? YAML.safeLoad(data) - : JSON.parse(data); -}; - -const filterExisting = (keepExisting, existingArray = []) => { - return item => { - if (keepExisting) { - const found = existingArray.find(t => t.name === item.name); - return !found; - } - return true; - }; -}; - -const filterEqual = (existingArray = []) => { - return item => { - const toggle = existingArray.find(t => t.name === item.name); - if (toggle) { - return JSON.stringify(toggle) !== JSON.stringify(item); - } - return true; - }; +const filterEqual = (existingArray = []) => item => { + const toggle = existingArray.find(t => t.name === item.name); + if (toggle) { + return JSON.stringify(toggle) !== JSON.stringify(item); + } + return true; }; module.exports = { diff --git a/src/lib/services/user-service.test.ts b/src/lib/services/user-service.test.ts index 3c31bd65c0..b0a09b6780 100644 --- a/src/lib/services/user-service.test.ts +++ b/src/lib/services/user-service.test.ts @@ -3,20 +3,35 @@ 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'; +import { ResetTokenStoreMock } from '../../test/fixtures/fake-reset-token-store'; +import ResetTokenService from './reset-token-service'; +import { EmailService } from './email-service'; +import OwaspValidationError from '../error/owasp-validation-error'; const config: IUnleashConfig = { getLogger: noLogger, baseUriPath: '', authentication: { enableApiToken: true, createAdminUser: false }, + unleashUrl: 'http://localhost:4242', + email: undefined, }; test('Should create new user', async t => { const userStore = new UserStoreMock(); const accessService = new AccessServiceMock(); + const resetTokenStore = new ResetTokenStoreMock(); + const resetTokenService = new ResetTokenService( + { userStore, resetTokenStore }, + config, + ); + const emailService = new EmailService(config.email, config.getLogger); - const service = new UserService({ userStore }, config, accessService); + const service = new UserService({ userStore }, config, { + accessService, + resetTokenService, + emailService, + }); const user = await service.createUser({ username: 'test', rootRole: 1, @@ -33,7 +48,18 @@ test('Should create new user', async t => { test('Should create default user', async t => { const userStore = new UserStoreMock(); const accessService = new AccessServiceMock(); - const service = new UserService({ userStore }, config, accessService); + const resetTokenStore = new ResetTokenStoreMock(); + const resetTokenService = new ResetTokenService( + { userStore, resetTokenStore }, + config, + ); + const emailService = new EmailService(config.email, config.getLogger); + + const service = new UserService({ userStore }, config, { + accessService, + resetTokenService, + emailService, + }); await service.initAdminUser(); @@ -44,7 +70,19 @@ test('Should create default user', async t => { test('Should be a valid password', async t => { const userStore = new UserStoreMock(); const accessService = new AccessServiceMock(); - const service = new UserService({ userStore }, config, accessService); + const resetTokenStore = new ResetTokenStoreMock(); + const resetTokenService = new ResetTokenService( + { userStore, resetTokenStore }, + config, + ); + + const emailService = new EmailService(config.email, config.getLogger); + + const service = new UserService({ userStore }, config, { + accessService, + resetTokenService, + emailService, + }); const valid = service.validatePassword('this is a strong password!'); @@ -54,47 +92,106 @@ test('Should be a valid password', async t => { 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); + const resetTokenStore = new ResetTokenStoreMock(); + const resetTokenService = new ResetTokenService( + { userStore, resetTokenStore }, + config, + ); + const emailService = new EmailService(config.email, config.getLogger); + + const service = new UserService({ userStore }, config, { + accessService, + resetTokenService, + emailService, + }); t.throws(() => service.validatePassword('admin'), { message: 'The password must be at least 10 characters long.', + instanceOf: OwaspValidationError, }); }); 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); + const resetTokenStore = new ResetTokenStoreMock(); + const resetTokenService = new ResetTokenService( + { userStore, resetTokenStore }, + config, + ); + const emailService = new EmailService(config.email, config.getLogger); + + const service = new UserService({ userStore }, config, { + accessService, + resetTokenService, + emailService, + }); t.throws(() => service.validatePassword('qwertyabcde'), { message: 'The password must contain at least one uppercase letter.', + instanceOf: OwaspValidationError, }); }); 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); + const resetTokenStore = new ResetTokenStoreMock(); + const resetTokenService = new ResetTokenService( + { userStore, resetTokenStore }, + config, + ); + + const emailService = new EmailService(config.email, config.getLogger); + const service = new UserService({ userStore }, config, { + accessService, + resetTokenService, + emailService, + }); t.throws(() => service.validatePassword('qwertyabcdE'), { message: 'The password must contain at least one number.', + instanceOf: OwaspValidationError, }); }); 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); + const resetTokenStore = new ResetTokenStoreMock(); + const resetTokenService = new ResetTokenService( + { userStore, resetTokenStore }, + config, + ); + const emailService = new EmailService(config.email, config.getLogger); + + const service = new UserService({ userStore }, config, { + accessService, + resetTokenService, + emailService, + }); t.throws(() => service.validatePassword('qwertyabcdE2'), { message: 'The password must contain at least one special character.', + instanceOf: OwaspValidationError, }); }); 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 resetTokenStore = new ResetTokenStoreMock(); + const resetTokenService = new ResetTokenService( + { userStore, resetTokenStore }, + config, + ); + const emailService = new EmailService(config.email, config.getLogger); + + const service = new UserService({ userStore }, config, { + accessService, + resetTokenService, + emailService, + }); const valid = service.validatePassword('this is a strong password!'); diff --git a/src/lib/services/user-service.ts b/src/lib/services/user-service.ts index 73aad4be73..18f82a8399 100644 --- a/src/lib/services/user-service.ts +++ b/src/lib/services/user-service.ts @@ -3,13 +3,20 @@ import bcrypt from 'bcrypt'; import owasp from 'owasp-password-strength-test'; import Joi from 'joi'; +import { URL } from 'url'; 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 { AccessService, IRoleData, RoleName } from './access-service'; import { ADMIN } from '../permissions'; +import ResetTokenService from './reset-token-service'; +import InvalidTokenError from '../error/invalid-token-error'; +import NotFoundError from '../error/notfound-error'; +import OwaspValidationError from '../error/owasp-validation-error'; +import { EmailService } from './email-service'; +import { IRole } from '../db/access-store'; export interface ICreateUser { name?: string; @@ -29,11 +36,27 @@ export interface IUpdateUser { interface IUserWithRole extends IUser { rootRole: number; } +interface IRoleDescription { + description: string; + name: string; + type: string; +} +interface ITokenUser extends IUpdateUser { + createdBy: string; + token: string; + role: IRoleDescription; +} interface IStores { userStore: UserStore; } +interface IServices { + accessService: AccessService; + resetTokenService: ResetTokenService; + emailService: EmailService; +} + const saltRounds = 10; class UserService { @@ -43,15 +66,20 @@ class UserService { private accessService: AccessService; + private resetTokenService: ResetTokenService; + + private emailService: EmailService; + constructor( stores: IStores, config: IUnleashConfig, - accessService: AccessService, + { accessService, resetTokenService, emailService }: IServices, ) { this.logger = config.getLogger('service/user-service.js'); this.store = stores.userStore; this.accessService = accessService; - + this.resetTokenService = resetTokenService; + this.emailService = emailService; if (config.authentication && config.authentication.createAdminUser) { process.nextTick(() => this.initAdminUser()); } @@ -60,7 +88,7 @@ class UserService { validatePassword(password: string): boolean { const result = owasp.test(password); if (!result.strong) { - throw new Error(result.errors[0]); + throw new OwaspValidationError(result); } else return true; } @@ -117,6 +145,10 @@ class UserService { return this.store.search(query); } + async getByEmail(email: string): Promise { + return this.store.get({ email }); + } + async createUser({ username, email, @@ -231,6 +263,61 @@ class UserService { await this.store.delete(userId); } + + async getUserForToken(token: string): Promise { + const { createdBy, userId } = await this.resetTokenService.isValid( + token, + ); + const user = await this.getUser(userId); + const role = await this.accessService.getRole(user.rootRole); + return { + token, + createdBy, + email: user.email, + name: user.name, + id: user.id, + role: { + description: role.role.description, + type: role.role.type, + name: role.role.name, + }, + }; + } + + async resetPassword(token: string, password: string): Promise { + this.validatePassword(password); + const user = await this.getUserForToken(token); + const allowed = await this.resetTokenService.useAccessToken({ + userId: user.id, + token, + }); + if (allowed) { + await this.changePassword(user.id, password); + } else { + throw new InvalidTokenError(); + } + } + + async createResetPasswordEmail( + receiverEmail: string, + requester: string, + ): Promise { + const receiver = await this.getByEmail(receiverEmail); + if (!receiver) { + throw new NotFoundError(`Could not find ${receiverEmail}`); + } + const resetLink = await this.resetTokenService.createResetPasswordUrl( + receiver.id, + requester, + ); + + await this.emailService.sendResetMail( + receiver.name, + receiver.email, + resetLink.toString(), + ); + return resetLink; + } } module.exports = UserService; diff --git a/src/lib/types/core.ts b/src/lib/types/core.ts index ca6db1903a..981ac9722e 100644 --- a/src/lib/types/core.ts +++ b/src/lib/types/core.ts @@ -1,4 +1,5 @@ import { LogProvider } from '../logger'; +import { IEmailOptions } from '../services/email-service'; interface IExperimentalFlags { [key: string]: boolean; @@ -12,6 +13,8 @@ export interface IUnleashConfig { enableApiToken: boolean; createAdminUser: boolean; }; + unleashUrl: string; + email?: IEmailOptions; } export enum AuthenticationType { diff --git a/src/mailtemplates/reset-password/reset-password.html.mustache b/src/mailtemplates/reset-password/reset-password.html.mustache index c4bc7b8f77..73abc6035d 100644 --- a/src/mailtemplates/reset-password/reset-password.html.mustache +++ b/src/mailtemplates/reset-password/reset-password.html.mustache @@ -1,22 +1,528 @@ - + - - - - Reset your password {{ name }} - - -
+ + + *|MC:SUBJECT|* + + + +
+ + + + +
+ + + + + + + + + + + + + + +
+ + + + + + + + +
+ Someone has requested a password reset on your unleash account. Please find the reset link in the email below. + + Email not displaying correctly?
View it in your browser. +
+ +
+ + + + + +
+ +
+ +
+ + + + + +
+

Reset password

+ +
+ Someone has requested to reset the password on your unleash account. If it was you, click the button below to complete the process. If not, you can ignore this email. +
+
+
+ Reset password +
+ +
+ + + + + + + + + +
+ Follow us on Github   Friend on Facebook   Forward to Friend  +
+ Copyright © {{ year }} | Unleash | All rights reserved. +
+ +
+ Our mailing address is: team@getunleash.io +
+ +
+ +
+ +
+
+ + \ No newline at end of file diff --git a/src/mailtemplates/reset-password/reset-password.plain.mustache b/src/mailtemplates/reset-password/reset-password.plain.mustache index 7b03bed5b0..b772bf2ff3 100644 --- a/src/mailtemplates/reset-password/reset-password.plain.mustache +++ b/src/mailtemplates/reset-password/reset-password.plain.mustache @@ -1,4 +1,7 @@ -Hello {{ name }} -Someone has requested a reset of your password. -If this was you, great, visit "{{ resetLink }}" to reset your password. -If this was not you, you might want to check your password still works. +Reset password + +Someone has requested to reset the password on your unleash account. + +If it was you, click the link below to complete the process. If not, you can ignore this email. + +Visit {{{ resetLink }}} to reset your password. diff --git a/src/migrations/20210322104357-api-tokens-convert-enterprise.js b/src/migrations/20210322104357-api-tokens-convert-enterprise.js index de78690048..5e7eff2f45 100644 --- a/src/migrations/20210322104357-api-tokens-convert-enterprise.js +++ b/src/migrations/20210322104357-api-tokens-convert-enterprise.js @@ -4,16 +4,12 @@ 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', - }; -}; +const toApiToken = legacyToken => ({ + 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( diff --git a/src/migrations/20210409120136-create-reset-token-table.js b/src/migrations/20210409120136-create-reset-token-table.js new file mode 100644 index 0000000000..d0881dd4fc --- /dev/null +++ b/src/migrations/20210409120136-create-reset-token-table.js @@ -0,0 +1,26 @@ +exports.up = function(db, cb) { + db.runSql( + ` + CREATE TABLE IF NOT EXISTS reset_tokens + ( + reset_token text PRIMARY KEY NOT NULL, + user_id integer, + expires_at timestamp with time zone NOT NULL, + used_at timestamp with time zone, + created_at timestamp with time zone DEFAULT now(), + created_by text, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ); + + `, + cb, + ); +}; + +exports.down = function(db, cb) { + db.runSql('DROP TABLE reset_tokens;', cb); +}; + +exports._meta = { + version: 1, +}; diff --git a/src/migrations/20210414141220-fix-misspellings-in-role-descriptions.js b/src/migrations/20210414141220-fix-misspellings-in-role-descriptions.js new file mode 100644 index 0000000000..3460451fc4 --- /dev/null +++ b/src/migrations/20210414141220-fix-misspellings-in-role-descriptions.js @@ -0,0 +1,12 @@ +exports.up = function(db, cb) { + db.runSql( + ` + UPDATE roles SET description = 'As an Editor you have access to most features in Unleash, but you can not manage users and roles in the global scope. If you create a project, you will become a project owner and receive superuser rights within the context of that project.' WHERE name = 'Regular'; + `, + cb, + ); +}; + +exports.down = function(db, cb) { + db.runSql(``, cb); +}; diff --git a/src/test/e2e/api/admin/addon.e2e.test.js b/src/test/e2e/api/admin/addon.e2e.test.js index f6fee50bda..7092fd34a0 100644 --- a/src/test/e2e/api/admin/addon.e2e.test.js +++ b/src/test/e2e/api/admin/addon.e2e.test.js @@ -16,7 +16,7 @@ test.before(async () => { stores = db.stores; }); -test.after(async () => { +test.after.always(async () => { await db.destroy(); }); 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 b7480e9a20..a6063a2013 100644 --- a/src/test/e2e/api/admin/api-token.e2e.test.ts +++ b/src/test/e2e/api/admin/api-token.e2e.test.ts @@ -15,7 +15,7 @@ test.before(async () => { stores = db.stores; }); -test.after(async () => { +test.after.always(async () => { await db.destroy(); }); diff --git a/src/test/e2e/api/admin/context.e2e.test.js b/src/test/e2e/api/admin/context.e2e.test.js index c523c3a52b..2b71d4b276 100644 --- a/src/test/e2e/api/admin/context.e2e.test.js +++ b/src/test/e2e/api/admin/context.e2e.test.js @@ -14,7 +14,7 @@ test.before(async () => { stores = db.stores; }); -test.after(async () => { +test.after.always(async () => { await db.destroy(); }); diff --git a/src/test/e2e/api/admin/event.e2e.test.js b/src/test/e2e/api/admin/event.e2e.test.js index e9ee989ceb..0ddb920c7d 100644 --- a/src/test/e2e/api/admin/event.e2e.test.js +++ b/src/test/e2e/api/admin/event.e2e.test.js @@ -13,7 +13,7 @@ test.before(async () => { stores = db.stores; }); -test.after(async () => { +test.after.always(async () => { await db.destroy(); }); diff --git a/src/test/e2e/api/admin/feature-archive.e2e.test.js b/src/test/e2e/api/admin/feature-archive.e2e.test.js index 477c47f954..bb9ab60c2f 100644 --- a/src/test/e2e/api/admin/feature-archive.e2e.test.js +++ b/src/test/e2e/api/admin/feature-archive.e2e.test.js @@ -13,7 +13,7 @@ test.before(async () => { stores = db.stores; }); -test.after(async () => { +test.after.always(async () => { await db.destroy(); }); diff --git a/src/test/e2e/api/admin/feature.auth.e2e.test.js b/src/test/e2e/api/admin/feature.auth.e2e.test.js index 75a3a52b9f..98bde882a1 100644 --- a/src/test/e2e/api/admin/feature.auth.e2e.test.js +++ b/src/test/e2e/api/admin/feature.auth.e2e.test.js @@ -13,7 +13,7 @@ test.before(async () => { stores = db.stores; }); -test.after(async () => { +test.after.always(async () => { await db.destroy(); }); diff --git a/src/test/e2e/api/admin/feature.custom-auth.e2e.test.js b/src/test/e2e/api/admin/feature.custom-auth.e2e.test.js index 078e43cec1..81c721611f 100644 --- a/src/test/e2e/api/admin/feature.custom-auth.e2e.test.js +++ b/src/test/e2e/api/admin/feature.custom-auth.e2e.test.js @@ -15,7 +15,7 @@ test.before(async () => { stores = db.stores; }); -test.after(async () => { +test.after.always(async () => { await db.destroy(); }); diff --git a/src/test/e2e/api/admin/feature.e2e.test.js b/src/test/e2e/api/admin/feature.e2e.test.js index 3a7053a39b..0462a67926 100644 --- a/src/test/e2e/api/admin/feature.e2e.test.js +++ b/src/test/e2e/api/admin/feature.e2e.test.js @@ -14,7 +14,7 @@ test.before(async () => { stores = db.stores; }); -test.after(async () => { +test.after.always(async () => { await db.destroy(); }); diff --git a/src/test/e2e/api/admin/metrics.e2e.test.js b/src/test/e2e/api/admin/metrics.e2e.test.js index 3a6ffc6e55..03a2e201ea 100644 --- a/src/test/e2e/api/admin/metrics.e2e.test.js +++ b/src/test/e2e/api/admin/metrics.e2e.test.js @@ -15,7 +15,7 @@ test.before(async () => { reset = db.reset; }); -test.after(async () => { +test.after.always(async () => { await db.destroy(); }); diff --git a/src/test/e2e/api/admin/state.e2e.test.js b/src/test/e2e/api/admin/state.e2e.test.js index 121321430e..927c0aeed3 100644 --- a/src/test/e2e/api/admin/state.e2e.test.js +++ b/src/test/e2e/api/admin/state.e2e.test.js @@ -14,7 +14,7 @@ test.before(async () => { stores = db.stores; }); -test.after(async () => { +test.after.always(async () => { await db.destroy(); }); diff --git a/src/test/e2e/api/admin/strategy.e2e.test.js b/src/test/e2e/api/admin/strategy.e2e.test.js index a249027b21..d75c350e11 100644 --- a/src/test/e2e/api/admin/strategy.e2e.test.js +++ b/src/test/e2e/api/admin/strategy.e2e.test.js @@ -14,7 +14,7 @@ test.before(async () => { stores = db.stores; }); -test.after(async () => { +test.after.always(async () => { await db.destroy(); }); @@ -42,7 +42,7 @@ test.serial('gets a strategy by name', async t => { .expect(200); }); -test.serial('cant get a strategy by name that dose not exist', async t => { +test.serial('cant get a strategy by name that does not exist', async t => { t.plan(0); const request = await setupApp(stores); return request diff --git a/src/test/e2e/api/admin/tag-types.e2e.test.js b/src/test/e2e/api/admin/tag-types.e2e.test.js index b19d493110..b343c44c6c 100644 --- a/src/test/e2e/api/admin/tag-types.e2e.test.js +++ b/src/test/e2e/api/admin/tag-types.e2e.test.js @@ -13,7 +13,7 @@ test.before(async () => { stores = db.stores; }); -test.after(async () => { +test.after.always(async () => { await db.destroy(); }); diff --git a/src/test/e2e/api/admin/tags.e2e.test.js b/src/test/e2e/api/admin/tags.e2e.test.js index ec6e75cc27..92cc81e3f9 100644 --- a/src/test/e2e/api/admin/tags.e2e.test.js +++ b/src/test/e2e/api/admin/tags.e2e.test.js @@ -13,7 +13,7 @@ test.before(async () => { stores = db.stores; }); -test.after(async () => { +test.after.always(async () => { await db.destroy(); }); diff --git a/src/test/e2e/api/admin/user-admin.e2e.test.ts b/src/test/e2e/api/admin/user-admin.e2e.test.ts index 605e95ee63..1edcd03c09 100644 --- a/src/test/e2e/api/admin/user-admin.e2e.test.ts +++ b/src/test/e2e/api/admin/user-admin.e2e.test.ts @@ -25,7 +25,7 @@ test.before(async () => { adminRole = roles.find(r => r.name === RoleName.ADMIN); }); -test.after(async () => { +test.after.always(async () => { await db.destroy(); }); diff --git a/src/test/e2e/api/auth/reset-password-controller.e2e.test.ts b/src/test/e2e/api/auth/reset-password-controller.e2e.test.ts new file mode 100644 index 0000000000..fc7148f845 --- /dev/null +++ b/src/test/e2e/api/auth/reset-password-controller.e2e.test.ts @@ -0,0 +1,181 @@ +import test from 'ava'; +import { URL } from 'url'; +import dbInit from '../../helpers/database-init'; +import getLogger from '../../../fixtures/no-logger'; + +import { + AccessService, + RoleName, +} from '../../../../lib/services/access-service'; +import ResetTokenService from '../../../../lib/services/reset-token-service'; +import UserService from '../../../../lib/services/user-service'; +import { IUnleashConfig } from '../../../../lib/types/core'; +import { setupApp } from '../../helpers/test-helper'; +import { EmailService } from '../../../../lib/services/email-service'; +import User from '../../../../lib/user'; + +let stores; +let db; +const config: IUnleashConfig = { + getLogger, + unleashUrl: 'http://localhost:3000', + baseUriPath: '', + authentication: { enableApiToken: true, createAdminUser: false }, +}; +const password = 'DtUYwi&l5I1KX4@Le'; +let userService: UserService; +let accessService: AccessService; +let resetTokenService: ResetTokenService; +let adminUser: User; +let user: User; + +const getBackendResetUrl = (url: URL): string => { + const urlString = url.toString(); + + const params = urlString.substring(urlString.indexOf('?')); + return `/auth/reset/validate${params}`; +}; + +test.before(async () => { + db = await dbInit('reset_password_api_serial', getLogger); + stores = db.stores; + accessService = new AccessService(stores, config); + const emailService = new EmailService(config.email, config.getLogger); + + userService = new UserService(stores, config, { + accessService, + resetTokenService, + emailService, + }); + resetTokenService = new ResetTokenService(stores, config); + const adminRole = await accessService.getRootRole(RoleName.ADMIN); + adminUser = await userService.createUser({ + username: 'admin@test.com', + rootRole: adminRole.id, + }); + + const userRole = await accessService.getRootRole(RoleName.EDITOR); + user = await userService.createUser({ + username: 'test@test.com', + email: 'test@test.com', + rootRole: userRole.id, + }); +}); + +test.afterEach.always(async () => { + await stores.resetTokenStore.deleteAll(); +}); + +test.after(async () => { + await db.destroy(); +}); + +test.serial('Can validate token for password reset', async t => { + const request = await setupApp(stores); + const url = await resetTokenService.createResetPasswordUrl( + user.id, + adminUser.username, + ); + const relative = getBackendResetUrl(url); + return request + .get(relative) + .expect(200) + .expect('Content-Type', /json/) + .expect(res => { + t.is(res.body.email, user.email); + }); +}); + +test.serial('Can use token to reset password', async t => { + const request = await setupApp(stores); + const url = await resetTokenService.createResetPasswordUrl( + user.id, + adminUser.username, + ); + const relative = getBackendResetUrl(url); + // Can't login before reset + t.throwsAsync( + async () => userService.loginUser(user.email, password), + { + instanceOf: Error, + }, + ); + + let token; + await request + .get(relative) + .expect(200) + .expect('Content-Type', /json/) + .expect(res => { + token = res.body.token; + }); + await request + .post('/auth/reset/password') + .send({ + token, + password, + }) + .expect(200); + const loggedInUser = await userService.loginUser(user.email, password); + t.is(user.email, loggedInUser.email); +}); + +test.serial( + 'Trying to reset password with same token twice does not work', + async t => { + const request = await setupApp(stores); + const url = await resetTokenService.createResetPasswordUrl( + user.id, + adminUser.username, + ); + const relative = getBackendResetUrl(url); + let token; + await request + .get(relative) + .expect(200) + .expect('Content-Type', /json/) + .expect(res => { + token = res.body.token; + }); + await request + .post('/auth/reset/password') + .send({ + email: user.email, + token, + password, + }) + .expect(200); + await request + .post('/auth/reset/password') + .send({ + email: user.email, + token, + password, + }) + .expect(403) + .expect(res => { + t.truthy(res.body.details[0].message); + }); + }, +); + +test.serial('Invalid token should yield 401', async t => { + const request = await setupApp(stores); + return request.get('/auth/reset/validate?token=abc123').expect(res => { + t.is(res.status, 401); + }); +}); + +test.serial( + 'Trying to change password with an invalid token should yield 401', + async t => { + const request = await setupApp(stores); + return request + .post('/auth/reset/password') + .send({ + token: 'abc123', + password, + }) + .expect(res => t.is(res.status, 401)); + }, +); diff --git a/src/test/e2e/api/client/feature.e2e.test.js b/src/test/e2e/api/client/feature.e2e.test.js index c642e1b54d..e348159bb3 100644 --- a/src/test/e2e/api/client/feature.e2e.test.js +++ b/src/test/e2e/api/client/feature.e2e.test.js @@ -13,7 +13,7 @@ test.before(async () => { stores = db.stores; }); -test.after(async () => { +test.after.always(async () => { await db.destroy(); }); diff --git a/src/test/e2e/api/client/metrics.e2e.test.js b/src/test/e2e/api/client/metrics.e2e.test.js index 95707316a3..c8e9c9b777 100644 --- a/src/test/e2e/api/client/metrics.e2e.test.js +++ b/src/test/e2e/api/client/metrics.e2e.test.js @@ -15,7 +15,7 @@ test.before(async () => { stores = db.stores; }); -test.after(async () => { +test.after.always(async () => { await db.destroy(); }); diff --git a/src/test/e2e/api/client/register.e2e.test.js b/src/test/e2e/api/client/register.e2e.test.js index d590e1e589..2b65789672 100644 --- a/src/test/e2e/api/client/register.e2e.test.js +++ b/src/test/e2e/api/client/register.e2e.test.js @@ -22,7 +22,7 @@ test.before(async () => { stores = db.stores; }); -test.after(async () => { +test.after.always(async () => { await db.destroy(); }); diff --git a/src/test/e2e/helpers/database-init.js b/src/test/e2e/helpers/database-init.js index 7c734b8fe6..9ec4428ff6 100644 --- a/src/test/e2e/helpers/database-init.js +++ b/src/test/e2e/helpers/database-init.js @@ -29,6 +29,7 @@ async function resetDatabase(stores) { stores.db('tag_types').del(), stores.db('addons').del(), stores.db('users').del(), + stores.db('reset_tokens').del(), ]); } diff --git a/src/test/e2e/helpers/test-helper.js b/src/test/e2e/helpers/test-helper.js index 9f4012679f..d97ccddd7f 100644 --- a/src/test/e2e/helpers/test-helper.js +++ b/src/test/e2e/helpers/test-helper.js @@ -25,6 +25,7 @@ function createApp(stores, adminAuthentication = 'none', preHook) { authentication: { customHook: () => {}, }, + unleashUrl: 'http://localhost:4242', getLogger, }; const services = createServices(stores, config); diff --git a/src/test/e2e/services/reset-token-service.e2e.test.ts b/src/test/e2e/services/reset-token-service.e2e.test.ts new file mode 100644 index 0000000000..a8ffb5a158 --- /dev/null +++ b/src/test/e2e/services/reset-token-service.e2e.test.ts @@ -0,0 +1,100 @@ +import test from 'ava'; +import { IUnleashConfig } from '../../../lib/types/core'; +import dbInit from '../helpers/database-init'; +import getLogger from '../../fixtures/no-logger'; +import ResetTokenService from '../../../lib/services/reset-token-service'; +import UserService from '../../../lib/services/user-service'; +import { AccessService } from '../../../lib/services/access-service'; +import NotFoundError from '../../../lib/error/notfound-error'; +import { EmailService } from '../../../lib/services/email-service'; +import User from '../../../lib/user'; + +const config: IUnleashConfig = { + getLogger, + baseUriPath: '', + authentication: { enableApiToken: true, createAdminUser: false }, + unleashUrl: 'http://localhost:3000', +}; + +let stores; +let db; +let adminUser; +let userToCreateResetFor: User; +let userIdToCreateResetFor: number; +let accessService: AccessService; +let userService: UserService; +let resetTokenService: ResetTokenService; +test.before(async () => { + db = await dbInit('reset_token_service_serial', getLogger); + stores = db.stores; + accessService = new AccessService(stores, config); + resetTokenService = new ResetTokenService(stores, config); + + const emailService = new EmailService(config.email, config.getLogger); + + userService = new UserService(stores, config, { + accessService, + resetTokenService, + emailService, + }); + + adminUser = await userService.createUser({ + username: 'admin@test.com', + rootRole: 1, + }); + + userToCreateResetFor = await userService.createUser({ + username: 'test@test.com', + rootRole: 2, + }); + userIdToCreateResetFor = userToCreateResetFor.id; +}); + +test.after.always(async () => { + db.destroy(); +}); + +test.serial('Should create a reset link', async t => { + const url = await resetTokenService.createResetPasswordUrl( + userIdToCreateResetFor, + adminUser, + ); + + t.true(url.toString().indexOf('/reset-password') > 0); +}); + +test.serial('Should create a welcome link', async t => { + const url = await resetTokenService.createWelcomeUrl( + userIdToCreateResetFor, + adminUser.username, + ); + t.true(url.toString().indexOf('/new-user') > 0); +}); + +test.serial('Tokens should be one-time only', async t => { + const token = await resetTokenService.createToken( + userIdToCreateResetFor, + adminUser, + ); + + const accessGranted = await resetTokenService.useAccessToken(token); + t.is(accessGranted, true); + const secondGo = await resetTokenService.useAccessToken(token); + t.is(secondGo, false); +}); + +test.serial('Creating a new token should expire older tokens', async t => { + const firstToken = await resetTokenService.createToken( + userIdToCreateResetFor, + adminUser, + ); + const secondToken = await resetTokenService.createToken( + userIdToCreateResetFor, + adminUser, + ); + await t.throwsAsync(async () => + resetTokenService.isValid(firstToken.token), + ); + const validToken = await resetTokenService.isValid(secondToken.token); + t.is(secondToken.token, validToken.token); +}); diff --git a/src/test/e2e/services/user-service.e2e.test.ts b/src/test/e2e/services/user-service.e2e.test.ts index 0a33a5e5fc..3765d173d3 100644 --- a/src/test/e2e/services/user-service.e2e.test.ts +++ b/src/test/e2e/services/user-service.e2e.test.ts @@ -7,6 +7,8 @@ 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'; +import ResetTokenService from '../../../lib/services/reset-token-service'; +import { EmailService } from '../../../lib/services/email-service'; let db; let stores; @@ -24,9 +26,17 @@ test.before(async () => { enableApiToken: false, createAdminUser: false, }, + unleashUrl: 'http://localhost:4242', }; const accessService = new AccessService(stores, config); - userService = new UserService(stores, config, accessService); + const resetTokenService = new ResetTokenService(stores, config); + const emailService = new EmailService(config.email, config.getLogger); + + userService = new UserService(stores, config, { + accessService, + resetTokenService, + emailService, + }); userStore = stores.userStore; const rootRoles = await accessService.getRootRoles(); adminRole = rootRoles.find(r => r.name === RoleName.ADMIN); diff --git a/src/test/e2e/stores/client-application-store.e2e.test.js b/src/test/e2e/stores/client-application-store.e2e.test.js index 661432679e..b673fb9411 100644 --- a/src/test/e2e/stores/client-application-store.e2e.test.js +++ b/src/test/e2e/stores/client-application-store.e2e.test.js @@ -159,9 +159,10 @@ test.serial('Multi row merge also works', async t => { clients.push(clientRegistration); } await clientApplicationsStore.bulkUpsert(clients); - const alteredClients = clients.map(c => { - return { appName: c.appName, icon: 'red' }; - }); + const alteredClients = clients.map(c => ({ + appName: c.appName, + icon: 'red', + })); await clientApplicationsStore.bulkUpsert(alteredClients); const stored = await Promise.all( clients.map(async c => diff --git a/src/test/fixtures/fake-addon-store.js b/src/test/fixtures/fake-addon-store.js index fa8f6d2c04..6670087e80 100644 --- a/src/test/fixtures/fake-addon-store.js +++ b/src/test/fixtures/fake-addon-store.js @@ -17,9 +17,7 @@ module.exports = () => { _addons.splice(id, 1); Promise.resolve(); }, - get: async id => { - return _addons[id]; - }, + get: async id => _addons[id], getAll: () => Promise.resolve(_addons), }; }; diff --git a/src/test/fixtures/fake-feature-toggle-store.js b/src/test/fixtures/fake-feature-toggle-store.js index b1fcac9d79..dd71c1b498 100644 --- a/src/test/fixtures/fake-feature-toggle-store.js +++ b/src/test/fixtures/fake-feature-toggle-store.js @@ -73,25 +73,25 @@ module.exports = (databaseIsUp = true) => { const activeQueryKeys = Object.keys(query).filter( t => query[t], ); - const filtered = _features.filter(feature => { - return activeQueryKeys.every(key => { + const filtered = _features.filter(feature => + activeQueryKeys.every(key => { if (key === 'namePrefix') { return feature.name.indexOf(query[key]) > -1; } if (key === 'tag') { - return query[key].some(tagQuery => { - return _featureTags + return query[key].some(tagQuery => + _featureTags .filter(t => t.featureName === feature.name) .some( tag => tag.tagType === tagQuery[0] && tag.tagValue === tagQuery[1], - ); - }); + ), + ); } return query[key].some(v => v === feature[key]); - }); - }); + }), + ); return Promise.resolve(filtered); } return Promise.resolve(_features); @@ -112,18 +112,15 @@ module.exports = (databaseIsUp = true) => { ); _featureTags.splice(index, 1); }, - getAllTagsForFeature: featureName => { - return Promise.resolve( + getAllTagsForFeature: featureName => + Promise.resolve( _featureTags .filter(f => f.featureName === featureName) - .map(t => { - return { - type: t.tagType, - value: t.tagValue, - }; - }), - ); - }, + .map(t => ({ + type: t.tagType, + value: t.tagValue, + })), + ), getAllFeatureTags: () => Promise.resolve(_featureTags), importFeatureTags: tags => { tags.forEach(tag => { diff --git a/src/test/fixtures/fake-reset-token-store.ts b/src/test/fixtures/fake-reset-token-store.ts new file mode 100644 index 0000000000..ceef32f65c --- /dev/null +++ b/src/test/fixtures/fake-reset-token-store.ts @@ -0,0 +1,50 @@ +import { EventEmitter } from 'events'; +import { + IResetToken, + IResetTokenCreate, + IResetTokenQuery, + ResetTokenStore, +} from '../../lib/db/reset-token-store'; +import noLoggerProvider from './no-logger'; +import NotFoundError from '../../lib/error/notfound-error'; + +export class ResetTokenStoreMock extends ResetTokenStore { + data: IResetToken[]; + + constructor() { + super(undefined, new EventEmitter(), noLoggerProvider); + this.data = []; + } + + async getActive(token: string): Promise { + const row = this.data.find(tokens => tokens.token === token); + if (!row) { + throw new NotFoundError(); + } + return row; + } + + async insert(newToken: IResetTokenCreate): Promise { + const token = { + userId: newToken.user_id, + token: newToken.reset_token, + expiresAt: newToken.expires_at, + createdBy: newToken.created_by, + createdAt: new Date(), + }; + this.data.push(token); + return Promise.resolve(token); + } + + async delete({ reset_token }: IResetTokenQuery): Promise { + this.data.splice( + this.data.findIndex(token => token.token === reset_token), + 1, + ); + return Promise.resolve(); + } + + async deleteExpired(): Promise { + throw new Error('Not implemented in mock'); + } +} diff --git a/yarn.lock b/yarn.lock index c0e668ce5c..adf2281b23 100644 --- a/yarn.lock +++ b/yarn.lock @@ -640,13 +640,13 @@ "@types/mime" "^1" "@types/node" "*" -"@typescript-eslint/eslint-plugin@^4.15.2": - version "4.15.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.15.2.tgz#981b26b4076c62a5a55873fbef3fe98f83360c61" - integrity sha512-uiQQeu9tWl3f1+oK0yoAv9lt/KXO24iafxgQTkIYO/kitruILGx3uH+QtIAHqxFV+yIsdnJH+alel9KuE3J15Q== +"@typescript-eslint/eslint-plugin@^4.22.0": + version "4.22.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.22.0.tgz#3d5f29bb59e61a9dba1513d491b059e536e16dbc" + integrity sha512-U8SP9VOs275iDXaL08Ln1Fa/wLXfj5aTr/1c0t0j6CdbOnxh+TruXu1p4I0NAvdPBQgoPjHsgKn28mOi0FzfoA== dependencies: - "@typescript-eslint/experimental-utils" "4.15.2" - "@typescript-eslint/scope-manager" "4.15.2" + "@typescript-eslint/experimental-utils" "4.22.0" + "@typescript-eslint/scope-manager" "4.22.0" debug "^4.1.1" functional-red-black-tree "^1.0.1" lodash "^4.17.15" @@ -654,19 +654,29 @@ semver "^7.3.2" tsutils "^3.17.1" -"@typescript-eslint/experimental-utils@4.15.2": - version "4.15.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.15.2.tgz#5efd12355bd5b535e1831282e6cf465b9a71cf36" - integrity sha512-Fxoshw8+R5X3/Vmqwsjc8nRO/7iTysRtDqx6rlfLZ7HbT8TZhPeQqbPjTyk2RheH3L8afumecTQnUc9EeXxohQ== +"@typescript-eslint/experimental-utils@4.22.0": + version "4.22.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.22.0.tgz#68765167cca531178e7b650a53456e6e0bef3b1f" + integrity sha512-xJXHHl6TuAxB5AWiVrGhvbGL8/hbiCQ8FiWwObO3r0fnvBdrbWEDy1hlvGQOAWc6qsCWuWMKdVWlLAEMpxnddg== dependencies: "@types/json-schema" "^7.0.3" - "@typescript-eslint/scope-manager" "4.15.2" - "@typescript-eslint/types" "4.15.2" - "@typescript-eslint/typescript-estree" "4.15.2" + "@typescript-eslint/scope-manager" "4.22.0" + "@typescript-eslint/types" "4.22.0" + "@typescript-eslint/typescript-estree" "4.22.0" eslint-scope "^5.0.0" eslint-utils "^2.0.0" -"@typescript-eslint/parser@^4.15.2", "@typescript-eslint/parser@^4.4.1": +"@typescript-eslint/parser@^4.22.0": + version "4.22.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.22.0.tgz#e1637327fcf796c641fe55f73530e90b16ac8fe8" + integrity sha512-z/bGdBJJZJN76nvAY9DkJANYgK3nlRstRRi74WHm3jjgf2I8AglrSY+6l7ogxOmn55YJ6oKZCLLy+6PW70z15Q== + dependencies: + "@typescript-eslint/scope-manager" "4.22.0" + "@typescript-eslint/types" "4.22.0" + "@typescript-eslint/typescript-estree" "4.22.0" + debug "^4.1.1" + +"@typescript-eslint/parser@^4.4.1": version "4.15.2" resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.15.2.tgz#c804474321ef76a3955aec03664808f0d6e7872e" integrity sha512-SHeF8xbsC6z2FKXsaTb1tBCf0QZsjJ94H6Bo51Y1aVEZ4XAefaw5ZAilMoDPlGghe+qtq7XdTiDlGfVTOmvA+Q== @@ -684,11 +694,24 @@ "@typescript-eslint/types" "4.15.2" "@typescript-eslint/visitor-keys" "4.15.2" +"@typescript-eslint/scope-manager@4.22.0": + version "4.22.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.22.0.tgz#ed411545e61161a8d702e703a4b7d96ec065b09a" + integrity sha512-OcCO7LTdk6ukawUM40wo61WdeoA7NM/zaoq1/2cs13M7GyiF+T4rxuA4xM+6LeHWjWbss7hkGXjFDRcKD4O04Q== + dependencies: + "@typescript-eslint/types" "4.22.0" + "@typescript-eslint/visitor-keys" "4.22.0" + "@typescript-eslint/types@4.15.2": version "4.15.2" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.15.2.tgz#04acf3a2dc8001a88985291744241e732ef22c60" integrity sha512-r7lW7HFkAarfUylJ2tKndyO9njwSyoy6cpfDKWPX6/ctZA+QyaYscAHXVAfJqtnY6aaTwDYrOhp+ginlbc7HfQ== +"@typescript-eslint/types@4.22.0": + version "4.22.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.22.0.tgz#0ca6fde5b68daf6dba133f30959cc0688c8dd0b6" + integrity sha512-sW/BiXmmyMqDPO2kpOhSy2Py5w6KvRRsKZnV0c4+0nr4GIcedJwXAq+RHNK4lLVEZAJYFltnnk1tJSlbeS9lYA== + "@typescript-eslint/typescript-estree@4.15.2": version "4.15.2" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.15.2.tgz#c2f7a1e94f3428d229d5ecff3ead6581ee9b62fa" @@ -702,6 +725,19 @@ semver "^7.3.2" tsutils "^3.17.1" +"@typescript-eslint/typescript-estree@4.22.0": + version "4.22.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.22.0.tgz#b5d95d6d366ff3b72f5168c75775a3e46250d05c" + integrity sha512-TkIFeu5JEeSs5ze/4NID+PIcVjgoU3cUQUIZnH3Sb1cEn1lBo7StSV5bwPuJQuoxKXlzAObjYTilOEKRuhR5yg== + dependencies: + "@typescript-eslint/types" "4.22.0" + "@typescript-eslint/visitor-keys" "4.22.0" + debug "^4.1.1" + globby "^11.0.1" + is-glob "^4.0.1" + semver "^7.3.2" + tsutils "^3.17.1" + "@typescript-eslint/visitor-keys@4.15.2": version "4.15.2" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.15.2.tgz#3d1c7979ce75bf6acf9691109bd0d6b5706192b9" @@ -710,6 +746,14 @@ "@typescript-eslint/types" "4.15.2" eslint-visitor-keys "^2.0.0" +"@typescript-eslint/visitor-keys@4.22.0": + version "4.22.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.22.0.tgz#169dae26d3c122935da7528c839f42a8a42f6e47" + integrity sha512-nnMu4F+s4o0sll6cBSsTeVsT4cwxB7zECK3dFxzEjPBii9xLpq4yqqsy/FU5zMfan6G60DKZSCXAa3sHJZrcYw== + dependencies: + "@typescript-eslint/types" "4.22.0" + eslint-visitor-keys "^2.0.0" + abbrev@1: version "1.1.1" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" @@ -1627,11 +1671,6 @@ confusing-browser-globals@^1.0.10: resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.10.tgz#30d1e7f3d1b882b25ec4933d1d1adac353d20a59" integrity sha512-gNld/3lySHwuhaVluJUKLePYirM3QNCKzVxqAdhJII9/WXKVX5PURzMVJspS1jTslSqjeuG4KMVTSouit5YPHA== -confusing-browser-globals@^1.0.9: - version "1.0.9" - resolved "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.9.tgz" - integrity sha512-KbS1Y0jMtyPgIxjO7ZzMAuUpAKMt1SzCL9fsrKsX6b0zJPTaT0SiSPmewwVZg9UAO83HVIlEhZF84LIjZ0lmAw== - connect-session-knex@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/connect-session-knex/-/connect-session-knex-2.0.0.tgz#c49003b8edd3e4cd64c701356223920abd052053" @@ -1743,6 +1782,14 @@ core-util-is@1.0.2, core-util-is@~1.0.0: resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= +cors@^2.8.5: + version "2.8.5" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" + integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== + dependencies: + object-assign "^4" + vary "^1" + cosmiconfig@^6.0.0: version "6.0.0" resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz" @@ -2306,15 +2353,6 @@ escape-string-regexp@^4.0.0: resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz" integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== -eslint-config-airbnb-base@^14.1.0: - version "14.2.0" - resolved "https://registry.npmjs.org/eslint-config-airbnb-base/-/eslint-config-airbnb-base-14.2.0.tgz" - integrity sha512-Snswd5oC6nJaevs3nZoLSTvGJBvzTfnBqOIArkf3cbyTyq9UD79wOk8s+RiL6bhca0p/eRO6veczhf6A/7Jy8Q== - dependencies: - confusing-browser-globals "^1.0.9" - object.assign "^4.1.0" - object.entries "^1.1.2" - eslint-config-airbnb-base@^14.2.0, eslint-config-airbnb-base@^14.2.1: version "14.2.1" resolved "https://registry.yarnpkg.com/eslint-config-airbnb-base/-/eslint-config-airbnb-base-14.2.1.tgz#8a2eb38455dc5a312550193b319cdaeef042cd1e" @@ -2342,12 +2380,10 @@ eslint-config-airbnb@^18.2.0: object.assign "^4.1.2" object.entries "^1.1.2" -eslint-config-prettier@^6.10.1: - version "6.11.0" - resolved "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-6.11.0.tgz" - integrity sha512-oB8cpLWSAjOVFEJhhyMZh6NOEOtBVziaqdDQ86+qhDHFbZXoRTM7pNSvFRfW/W/L/LrQ38C99J5CGuRBBzBsdA== - dependencies: - get-stdin "^6.0.0" +eslint-config-prettier@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.1.0.tgz#4ef1eaf97afe5176e6a75ddfb57c335121abc5a6" + integrity sha512-oKMhGv3ihGbCIimCAjqkdzx2Q+jthoqnXSP+d86M9tptwugycmTFdVR4IpLgq2c4SHifbwO90z2fQ8/Aio73yw== eslint-import-resolver-node@^0.3.4: version "0.3.4" @@ -2365,9 +2401,9 @@ eslint-module-utils@^2.6.0: debug "^2.6.9" pkg-dir "^2.0.0" -eslint-plugin-import@^2.20.2: +eslint-plugin-import@^2.22.1: version "2.22.1" - resolved "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.22.1.tgz" + resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.22.1.tgz#0896c7e6a0cf44109a2d97b95903c2bb689d7702" integrity sha512-8K7JjINHOpH64ozkAhpT3sd+FswIZTfMZTjdx052pnWrgRCVfp8op9tbjpAk3DdUeI/Ba4C8OjdC0r90erHEOw== dependencies: array-includes "^3.1.1" @@ -2384,10 +2420,10 @@ eslint-plugin-import@^2.20.2: resolve "^1.17.0" tsconfig-paths "^3.9.0" -eslint-plugin-prettier@^3.1.3: - version "3.1.4" - resolved "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.4.tgz" - integrity sha512-jZDa8z76klRqo+TdGDTFJSavwbnWK2ZpqGKNZ+VvweMW516pDUMmQ2koXvxEE4JhzNvTv+radye/bWGBmA6jmg== +eslint-plugin-prettier@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.3.1.tgz#7079cfa2497078905011e6f82e8dd8453d1371b7" + integrity sha512-Rq3jkcFY8RYeQLgk2cCwuc0P7SEFwDravPhsJZOQ5N4YI4DSg50NyqJ/9gdZHzQlHf8MvafSesbNJCcP/FF6pQ== dependencies: prettier-linter-helpers "^1.0.0" @@ -3070,11 +3106,6 @@ get-package-type@^0.1.0: resolved "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz" integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== -get-stdin@^6.0.0: - version "6.0.0" - resolved "https://registry.npmjs.org/get-stdin/-/get-stdin-6.0.0.tgz" - integrity sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g== - get-stream@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz" @@ -4845,7 +4876,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.0, object-assign@^4.1.1: +object-assign@^4, 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= @@ -6807,10 +6838,10 @@ typedarray@^0.0.6: resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript@^4.1.5: - version "4.1.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.5.tgz#123a3b214aaff3be32926f0d8f1f6e704eb89a72" - integrity sha512-6OSu9PTIzmn9TCDiovULTnET6BgXtDYL4Gg4szY+cGsc3JP1dQL8qvE8kShTRx1NIw4Q9IBHlwODjkjWEtMUyA== +typescript@^4.2.4: + version "4.2.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.4.tgz#8610b59747de028fda898a8aef0e103f156d0961" + integrity sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg== uid-safe@~2.1.5: version "2.1.5" @@ -6969,7 +7000,7 @@ validate-npm-package-license@^3.0.1: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" -vary@~1.1.2: +vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz" integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=