1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

Reset token (#786)

feat: Add Reset token functionality

This allows admin users to create a reset token for other users. Thus allowing resetting their password.

Co-authored-by: Fredrik Oseberg <fredrik.no@gmail.com>

fixes: #778
This commit is contained in:
Christopher Kolstad 2021-04-16 15:29:23 +02:00 committed by GitHub
parent 23ea21babf
commit b55c85783b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
63 changed files with 1813 additions and 283 deletions

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
14

View File

@ -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",

View File

@ -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;
}

View File

@ -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 }));

View File

@ -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() {

View File

@ -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),
};
};

View File

@ -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<IResetToken> {
const row = await this.db<IResetTokenTable>(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<IResetToken> {
const [row] = await this.db<IResetTokenTable>(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<boolean> {
try {
await this.db<IResetTokenTable>(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<void> {
return this.db(TABLE)
.where(reset_token)
.del();
}
async deleteAll(): Promise<void> {
return this.db(TABLE).del();
}
async deleteExpired(): Promise<void> {
return this.db(TABLE)
.where('expires_at', '<', new Date())
.del();
}
async expireExistingTokensForUser(user_id: number): Promise<void> {
await this.db<IResetTokenTable>(TABLE)
.where({ user_id })
.update({
expires_at: new Date(),
});
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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 });

View File

@ -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();

View File

@ -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) {

View File

@ -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');

View File

@ -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) {

View File

@ -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);
}

View File

@ -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();

View File

@ -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<void> {
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<void> {
const { password } = req.body;
try {
this.userService.validatePassword(password);
res.status(200).end();
} catch (e) {
handleErrors(res, this.logger, e);
}
}
async validateToken(
req: Request<unknown, unknown, unknown, IValidateQuery>,
res: Response,
): Promise<void> {
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<unknown, unknown, IChangePasswordBody, unknown>,
res: Response,
): Promise<void> {
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;

View File

@ -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();
};
/**

View File

@ -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);

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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');
});

View File

@ -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}`);

View File

@ -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,
};
};

View File

@ -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<boolean> {
try {
await this.isValid(token.token);
await this.store.useToken(token);
return true;
} catch (e) {
return false;
}
}
async isValid(token: string): Promise<IResetToken> {
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<URL> {
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<URL> {
const path = '/#/new-user';
return this.createResetUrl(forUser, creator, path);
}
async createResetPasswordUrl(
forUser: number,
creator: string,
): Promise<URL> {
const path = '/#/reset-password';
return this.createResetUrl(forUser, creator, path);
}
async createToken(
tokenUser: number,
creator: string,
expiryDelta: number = ONE_DAY,
): Promise<IResetToken> {
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<string> {
return bcrypt.hash(crypto.randomBytes(32), 10);
}
}
module.exports = ResetTokenService;

View File

@ -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);
}
}

View File

@ -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 = {

View File

@ -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!');

View File

@ -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<User> {
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<ITokenUser> {
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<void> {
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<URL> {
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;

View File

@ -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 {

View File

@ -1,22 +1,528 @@
<!DOCTYPE html>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Reset your password {{ name }}</title>
</head>
<body>
<header>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>*|MC:SUBJECT|*</title>
<style type="text/css">
/* /\/\/\/\/\/\/\/\/ CLIENT-SPECIFIC STYLES /\/\/\/\/\/\/\/\/ */
#outlook a{padding:0;} /* Force Outlook to provide a "view in browser" message */
.ReadMsgBody{width:100%;} .ExternalClass{width:100%;} /* Force Hotmail to display emails at full width */
.ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div {line-height: 100%;} /* Force Hotmail to display normal line spacing */
body, table, td, p, a, li, blockquote{-webkit-text-size-adjust:100%; -ms-text-size-adjust:100%;} /* Prevent WebKit and Windows mobile changing default text sizes */
table, td{mso-table-lspace:0pt; mso-table-rspace:0pt;} /* Remove spacing between tables in Outlook 2007 and up */
img{-ms-interpolation-mode:bicubic;} /* Allow smoother rendering of resized image in Internet Explorer */
</header>
<section>
Someone has requested a reset of your password. If this was you, great, click here <a href="{{ resetLink }}"
title="Password reset link">{{resetLink}}</a>
If this was not you, you might want to check your password still works
</section>
/* /\/\/\/\/\/\/\/\/ RESET STYLES /\/\/\/\/\/\/\/\/ */
body{margin:0; padding:0;}
img{border:0; height:auto; line-height:100%; outline:none; text-decoration:none;}
table{border-collapse:collapse !important;}
body, #bodyTable, #bodyCell{height:100% !important; margin:0; padding:0; width:100% !important;}
<footer>
&copy; {{ year }} - Unleash
</footer>
</body>
</html>
/* /\/\/\/\/\/\/\/\/ TEMPLATE STYLES /\/\/\/\/\/\/\/\/ */
/* ========== Page Styles ========== */
#bodyCell{padding:20px;}
#templateContainer{width:600px;}
/**
* @tab Page
* @section background style
* @tip Set the background color and top border for your email. You may want to choose colors that match your company's branding.
* @theme page
*/
body, #bodyTable{
/*@editable*/ background-color:#DEE0E2;
}
/**
* @tab Page
* @section background style
* @tip Set the background color and top border for your email. You may want to choose colors that match your company's branding.
* @theme page
*/
#bodyCell{
/*@editable*/ border-top:4px solid #BBBBBB;
}
/**
* @tab Page
* @section email border
* @tip Set the border for your email.
*/
#templateContainer{
/*@editable*/ border:1px solid #BBBBBB;
}
/**
* @tab Page
* @section heading 1
* @tip Set the styling for all first-level headings in your emails. These should be the largest of your headings.
* @style heading 1
*/
h1{
/*@editable*/ color:#202020 !important;
display:block;
/*@editable*/ font-family:Helvetica;
/*@editable*/ font-size:26px;
/*@editable*/ font-style:normal;
/*@editable*/ font-weight:bold;
/*@editable*/ line-height:100%;
/*@editable*/ letter-spacing:normal;
margin-top:0;
margin-right:0;
margin-bottom:10px;
margin-left:0;
/*@editable*/ text-align:left;
}
/**
* @tab Page
* @section heading 2
* @tip Set the styling for all second-level headings in your emails.
* @style heading 2
*/
h2{
/*@editable*/ color:#404040 !important;
display:block;
/*@editable*/ font-family:Helvetica;
/*@editable*/ font-size:20px;
/*@editable*/ font-style:normal;
/*@editable*/ font-weight:bold;
/*@editable*/ line-height:100%;
/*@editable*/ letter-spacing:normal;
margin-top:0;
margin-right:0;
margin-bottom:10px;
margin-left:0;
/*@editable*/ text-align:left;
}
/**
* @tab Page
* @section heading 3
* @tip Set the styling for all third-level headings in your emails.
* @style heading 3
*/
h3{
/*@editable*/ color:#606060 !important;
display:block;
/*@editable*/ font-family:Helvetica;
/*@editable*/ font-size:16px;
/*@editable*/ font-style:italic;
/*@editable*/ font-weight:normal;
/*@editable*/ line-height:100%;
/*@editable*/ letter-spacing:normal;
margin-top:0;
margin-right:0;
margin-bottom:10px;
margin-left:0;
/*@editable*/ text-align:left;
}
/**
* @tab Page
* @section heading 4
* @tip Set the styling for all fourth-level headings in your emails. These should be the smallest of your headings.
* @style heading 4
*/
h4{
/*@editable*/ color:#808080 !important;
display:block;
/*@editable*/ font-family:Helvetica;
/*@editable*/ font-size:14px;
/*@editable*/ font-style:italic;
/*@editable*/ font-weight:normal;
/*@editable*/ line-height:100%;
/*@editable*/ letter-spacing:normal;
margin-top:0;
margin-right:0;
margin-bottom:10px;
margin-left:0;
/*@editable*/ text-align:left;
}
/* ========== Header Styles ========== */
/**
* @tab Header
* @section preheader style
* @tip Set the background color and bottom border for your email's preheader area.
* @theme header
*/
#templatePreheader{
/*@editable*/ background-color:#fff;
/*@editable*/ border-bottom:1px solid #CCCCCC;
}
/**
* @tab Header
* @section preheader text
* @tip Set the styling for your email's preheader text. Choose a size and color that is easy to read.
*/
.preheaderContent{
/*@editable*/ color:#808080;
/*@editable*/ font-family:Helvetica;
/*@editable*/ font-size:10px;
/*@editable*/ line-height:125%;
/*@editable*/ text-align:left;
}
/**
* @tab Header
* @section preheader link
* @tip Set the styling for your email's preheader links. Choose a color that helps them stand out from your text.
*/
.preheaderContent a:link, .preheaderContent a:visited, /* Yahoo! Mail Override */ .preheaderContent a .yshortcuts /* Yahoo! Mail Override */{
/*@editable*/ color:#fff;
/*@editable*/ font-weight:normal;
/*@editable*/ text-decoration:underline;
}
/**
* @tab Header
* @section header style
* @tip Set the background color and borders for your email's header area.
* @theme header
*/
#templateHeader{
/*@editable*/ background-color:#fff;
/*@editable*/ border-top:1px solid #FFFFFF;
/*@editable*/ border-bottom:1px solid #CCCCCC;
}
/**
* @tab Header
* @section header text
* @tip Set the styling for your email's header text. Choose a size and color that is easy to read.
*/
.headerContent{
/*@editable*/ color:#505050;
/*@editable*/ font-family:Helvetica;
/*@editable*/ font-size:20px;
/*@editable*/ font-weight:bold;
/*@editable*/ line-height:100%;
/*@editable*/ padding-top:0;
/*@editable*/ padding-right:0;
/*@editable*/ padding-bottom:0;
/*@editable*/ padding-left:0;
/*@editable*/ text-align:left;
/*@editable*/ vertical-align:middle;
}
/**
* @tab Header
* @section header link
* @tip Set the styling for your email's header links. Choose a color that helps them stand out from your text.
*/
.headerContent a:link, .headerContent a:visited, /* Yahoo! Mail Override */ .headerContent a .yshortcuts /* Yahoo! Mail Override */{
/*@editable*/ color:#fff;
/*@editable*/ font-weight:normal;
/*@editable*/ text-decoration:underline;
}
#headerImage{
height:auto;
max-width:600px;
}
/* ========== Body Styles ========== */
/**
* @tab Body
* @section body style
* @tip Set the background color and borders for your email's body area.
*/
#templateBody{
/*@editable*/ background-color:#fff;
}
/**
* @tab Body
* @section body text
* @tip Set the styling for your email's main content text. Choose a size and color that is easy to read.
* @theme main
*/
.bodyContent{
/*@editable*/ color:#505050;
/*@editable*/ font-family:Helvetica;
/*@editable*/ font-size:14px;
/*@editable*/ line-height:150%;
/*@editable*/ border-bottom: 1px solid #CCCCCC;
padding-top:20px;
padding-right:20px;
padding-bottom:20px;
padding-left:20px;
/*@editable*/ text-align:left;
}
.bodyContent img{
display:inline;
height:auto;
max-width:560px;
}
.resetPasswordLink {
text-decoration: none;
color: #fff;
background-color: #607d8b;
border-radius: 25px;
padding: 0.75rem;
text-align: center;
}
.resetPasswordLink:hover {
text-decoration: none;
color: #fff;
}
/* ========== Footer Styles ========== */
/**
* @tab Footer
* @section footer style
* @tip Set the background color and borders for your email's footer area.
* @theme footer
*/
#templateFooter{
/*@editable*/ background-color:#fff;
/*@editable*/ border-top:1px solid #FFFFFF;
}
/**
* @tab Footer
* @section footer text
* @tip Set the styling for your email's footer text. Choose a size and color that is easy to read.
* @theme footer
*/
.footerContent{
/*@editable*/ color:#808080;
/*@editable*/ font-family:Helvetica;
/*@editable*/ font-size:10px;
/*@editable*/ line-height:150%;
padding-top:20px;
padding-right:20px;
padding-bottom:20px;
padding-left:20px;
/*@editable*/ text-align:left;
}
/**
* @tab Footer
* @section footer link
* @tip Set the styling for your email's footer links. Choose a color that helps them stand out from your text.
*/
.footerContent a:link, .footerContent a:visited, /* Yahoo! Mail Override */ .footerContent a .yshortcuts, .footerContent a span /* Yahoo! Mail Override */{
/*@editable*/ color:#606060;
/*@editable*/ font-weight:normal;
/*@editable*/ text-decoration:underline;
}
/* /\/\/\/\/\/\/\/\/ MOBILE STYLES /\/\/\/\/\/\/\/\/ */
@media only screen and (max-width: 480px){
/* /\/\/\/\/\/\/ CLIENT-SPECIFIC MOBILE STYLES /\/\/\/\/\/\/ */
body, table, td, p, a, li, blockquote{-webkit-text-size-adjust:none !important;} /* Prevent Webkit platforms from changing default text sizes */
body{width:100% !important; min-width:100% !important;} /* Prevent iOS Mail from adding padding to the body */
/* /\/\/\/\/\/\/ MOBILE RESET STYLES /\/\/\/\/\/\/ */
#bodyCell{padding:10px !important;}
/* /\/\/\/\/\/\/ MOBILE TEMPLATE STYLES /\/\/\/\/\/\/ */
/* ======== Page Styles ======== */
/**
* @tab Mobile Styles
* @section template width
* @tip Make the template fluid for portrait or landscape view adaptability. If a fluid layout doesn't work for you, set the width to 300px instead.
*/
#templateContainer{
max-width:600px !important;
/*@editable*/ width:100% !important;
}
/**
* @tab Mobile Styles
* @section heading 1
* @tip Make the first-level headings larger in size for better readability on small screens.
*/
h1{
/*@editable*/ font-size:24px !important;
/*@editable*/ line-height:100% !important;
}
/**
* @tab Mobile Styles
* @section heading 2
* @tip Make the second-level headings larger in size for better readability on small screens.
*/
h2{
/*@editable*/ font-size:20px !important;
/*@editable*/ line-height:100% !important;
}
/**
* @tab Mobile Styles
* @section heading 3
* @tip Make the third-level headings larger in size for better readability on small screens.
*/
h3{
/*@editable*/ font-size:18px !important;
/*@editable*/ line-height:100% !important;
}
/**
* @tab Mobile Styles
* @section heading 4
* @tip Make the fourth-level headings larger in size for better readability on small screens.
*/
h4{
/*@editable*/ font-size:16px !important;
/*@editable*/ line-height:100% !important;
}
/* ======== Header Styles ======== */
#templatePreheader{display:none !important;} /* Hide the template preheader to save space */
/**
* @tab Mobile Styles
* @section header image
* @tip Make the main header image fluid for portrait or landscape view adaptability, and set the image's original width as the max-width. If a fluid setting doesn't work, set the image width to half its original size instead.
*/
#headerImage{
height:auto !important;
/*@editable*/ max-width:600px !important;
/*@editable*/ width:100% !important;
}
/**
* @tab Mobile Styles
* @section header text
* @tip Make the header content text larger in size for better readability on small screens. We recommend a font size of at least 16px.
*/
.headerContent{
/*@editable*/ font-size:20px !important;
/*@editable*/ line-height:125% !important;
}
/* ======== Body Styles ======== */
/**
* @tab Mobile Styles
* @section body text
* @tip Make the body content text larger in size for better readability on small screens. We recommend a font size of at least 16px.
*/
.bodyContent{
/*@editable*/ font-size:18px !important;
/*@editable*/ line-height:125% !important;
}
/* ======== Footer Styles ======== */
/**
* @tab Mobile Styles
* @section footer text
* @tip Make the body content text larger in size for better readability on small screens.
*/
.footerContent{
/*@editable*/ font-size:14px !important;
/*@editable*/ line-height:115% !important;
}
.footerContent a{display:block !important;} /* Place footer social and utility links on their own lines, for easier access */
}
</style>
</head>
<body leftmargin="0" marginwidth="0" topmargin="0" marginheight="0" offset="0">
<center>
<table align="center" border="0" cellpadding="0" cellspacing="0" height="100%" width="100%" id="bodyTable">
<tr>
<td align="center" valign="top" id="bodyCell">
<!-- BEGIN TEMPLATE // -->
<table border="0" cellpadding="0" cellspacing="0" id="templateContainer">
<tr>
<td align="center" valign="top">
<!-- BEGIN PREHEADER // -->
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="templatePreheader">
<tr>
<td valign="top" class="preheaderContent" style="padding-top:10px; padding-right:20px; padding-bottom:10px; padding-left:20px;" mc:edit="preheader_content00">
Someone has requested a password reset on your unleash account. Please find the reset link in the email below.
</td>
<!-- *|IFNOT:ARCHIVE_PAGE|* -->
<td valign="top" width="180" class="preheaderContent" style="padding-top:10px; padding-right:20px; padding-bottom:10px; padding-left:0;" mc:edit="preheader_content01">
Email not displaying correctly?<br /><a href="*|ARCHIVE|*" target="_blank">View it in your browser</a>.
</td>
<!-- *|END:IF|* -->
</tr>
</table>
<!-- // END PREHEADER -->
</td>
</tr>
<tr>
<td align="center" valign="top">
<!-- BEGIN HEADER // -->
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateHeader">
<tr>
<td valign="top" class="headerContent">
<img src="https://www.unleash-hosted.com/wp-content/uploads/2019/12/unleash-hosted-logo-v2-mini-fit.png" style="max-width:600px;" id="headerImage" mc:label="header_image" mc:edit="header_image" mc:allowdesigner mc:allowtext />
</td>
</tr>
</table>
<!-- // END HEADER -->
</td>
</tr>
<tr>
<td align="center" valign="top">
<!-- BEGIN BODY // -->
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateBody">
<tr>
<td valign="top" class="bodyContent" mc:edit="body_content">
<h1>Reset password</h1>
<br />
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.
<br />
<br />
<br />
<a class="resetPasswordLink" href="{{{ resetLink }}}" target="_blank" rel="noopener noreferrer">Reset password<a/>
</td>
</tr>
</table>
<!-- // END BODY -->
</td>
</tr>
<tr>
<td align="center" valign="top">
<!-- BEGIN FOOTER // -->
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateFooter">
<tr>
<td valign="top" class="footerContent" mc:edit="footer_content00">
<a href="https://github.com/Unleash/unleash">Follow us on Github</a>&nbsp;&nbsp;&nbsp;<a href="*|FACEBOOK:PROFILEURL|*">Friend on Facebook</a>&nbsp;&nbsp;&nbsp;<a href="*|FORWARD|*">Forward to Friend</a>&nbsp;
</td>
</tr>
<tr>
<td valign="top" class="footerContent" style="padding-top:0;" mc:edit="footer_content01">
<em>Copyright &copy; {{ year }} | Unleash | All rights reserved.</em>
<br />
<br />
<strong>Our mailing address is: team@getunleash.io</strong>
<br />
</td>
</tr>
</table>
<!-- // END FOOTER -->
</td>
</tr>
</table>
<!-- // END TEMPLATE -->
</td>
</tr>
</table>
</center>
</body>
</html>

View File

@ -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.

View File

@ -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(

View File

@ -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,
};

View File

@ -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);
};

View File

@ -16,7 +16,7 @@ test.before(async () => {
stores = db.stores;
});
test.after(async () => {
test.after.always(async () => {
await db.destroy();
});

View File

@ -15,7 +15,7 @@ test.before(async () => {
stores = db.stores;
});
test.after(async () => {
test.after.always(async () => {
await db.destroy();
});

View File

@ -14,7 +14,7 @@ test.before(async () => {
stores = db.stores;
});
test.after(async () => {
test.after.always(async () => {
await db.destroy();
});

View File

@ -13,7 +13,7 @@ test.before(async () => {
stores = db.stores;
});
test.after(async () => {
test.after.always(async () => {
await db.destroy();
});

View File

@ -13,7 +13,7 @@ test.before(async () => {
stores = db.stores;
});
test.after(async () => {
test.after.always(async () => {
await db.destroy();
});

View File

@ -13,7 +13,7 @@ test.before(async () => {
stores = db.stores;
});
test.after(async () => {
test.after.always(async () => {
await db.destroy();
});

View File

@ -15,7 +15,7 @@ test.before(async () => {
stores = db.stores;
});
test.after(async () => {
test.after.always(async () => {
await db.destroy();
});

View File

@ -14,7 +14,7 @@ test.before(async () => {
stores = db.stores;
});
test.after(async () => {
test.after.always(async () => {
await db.destroy();
});

View File

@ -15,7 +15,7 @@ test.before(async () => {
reset = db.reset;
});
test.after(async () => {
test.after.always(async () => {
await db.destroy();
});

View File

@ -14,7 +14,7 @@ test.before(async () => {
stores = db.stores;
});
test.after(async () => {
test.after.always(async () => {
await db.destroy();
});

View File

@ -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

View File

@ -13,7 +13,7 @@ test.before(async () => {
stores = db.stores;
});
test.after(async () => {
test.after.always(async () => {
await db.destroy();
});

View File

@ -13,7 +13,7 @@ test.before(async () => {
stores = db.stores;
});
test.after(async () => {
test.after.always(async () => {
await db.destroy();
});

View File

@ -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();
});

View File

@ -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<Error>(
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));
},
);

View File

@ -13,7 +13,7 @@ test.before(async () => {
stores = db.stores;
});
test.after(async () => {
test.after.always(async () => {
await db.destroy();
});

View File

@ -15,7 +15,7 @@ test.before(async () => {
stores = db.stores;
});
test.after(async () => {
test.after.always(async () => {
await db.destroy();
});

View File

@ -22,7 +22,7 @@ test.before(async () => {
stores = db.stores;
});
test.after(async () => {
test.after.always(async () => {
await db.destroy();
});

View File

@ -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(),
]);
}

View File

@ -25,6 +25,7 @@ function createApp(stores, adminAuthentication = 'none', preHook) {
authentication: {
customHook: () => {},
},
unleashUrl: 'http://localhost:4242',
getLogger,
};
const services = createServices(stores, config);

View File

@ -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<NotFoundError>(async () =>
resetTokenService.isValid(firstToken.token),
);
const validToken = await resetTokenService.isValid(secondToken.token);
t.is(secondToken.token, validToken.token);
});

View File

@ -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);

View File

@ -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 =>

View File

@ -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),
};
};

View File

@ -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 => {

View File

@ -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<IResetToken> {
const row = this.data.find(tokens => tokens.token === token);
if (!row) {
throw new NotFoundError();
}
return row;
}
async insert(newToken: IResetTokenCreate): Promise<IResetToken> {
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<void> {
this.data.splice(
this.data.findIndex(token => token.token === reset_token),
1,
);
return Promise.resolve();
}
async deleteExpired(): Promise<void> {
throw new Error('Not implemented in mock');
}
}

133
yarn.lock
View File

@ -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=