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:
parent
23ea21babf
commit
b55c85783b
17
package.json
17
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",
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 }));
|
||||
|
@ -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() {
|
||||
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
131
src/lib/db/reset-token-store.ts
Normal file
131
src/lib/db/reset-token-store.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
}
|
24
src/lib/error/invalid-token-error.ts
Normal file
24
src/lib/error/invalid-token-error.ts
Normal 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;
|
29
src/lib/error/owasp-validation-error.ts
Normal file
29
src/lib/error/owasp-validation-error.ts
Normal 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;
|
24
src/lib/error/used-token-error.ts
Normal file
24
src/lib/error/used-token-error.ts
Normal 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;
|
@ -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 });
|
||||
|
@ -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();
|
||||
|
@ -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) {
|
||||
|
@ -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');
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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();
|
||||
|
89
src/lib/routes/auth/reset-password-controller.ts
Normal file
89
src/lib/routes/auth/reset-password-controller.ts
Normal 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;
|
@ -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();
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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');
|
||||
});
|
||||
|
@ -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}`);
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
110
src/lib/services/reset-token-service.ts
Normal file
110
src/lib/services/reset-token-service.ts
Normal 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;
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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 = {
|
||||
|
@ -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!');
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
© {{ 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> <a href="*|FACEBOOK:PROFILEURL|*">Friend on Facebook</a> <a href="*|FORWARD|*">Forward to Friend</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" class="footerContent" style="padding-top:0;" mc:edit="footer_content01">
|
||||
<em>Copyright © {{ 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>
|
@ -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.
|
||||
|
@ -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(
|
||||
|
26
src/migrations/20210409120136-create-reset-token-table.js
Normal file
26
src/migrations/20210409120136-create-reset-token-table.js
Normal 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,
|
||||
};
|
@ -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);
|
||||
};
|
@ -16,7 +16,7 @@ test.before(async () => {
|
||||
stores = db.stores;
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
test.after.always(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
|
@ -15,7 +15,7 @@ test.before(async () => {
|
||||
stores = db.stores;
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
test.after.always(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
|
@ -14,7 +14,7 @@ test.before(async () => {
|
||||
stores = db.stores;
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
test.after.always(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
|
@ -13,7 +13,7 @@ test.before(async () => {
|
||||
stores = db.stores;
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
test.after.always(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
|
@ -13,7 +13,7 @@ test.before(async () => {
|
||||
stores = db.stores;
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
test.after.always(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
|
@ -13,7 +13,7 @@ test.before(async () => {
|
||||
stores = db.stores;
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
test.after.always(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
|
@ -15,7 +15,7 @@ test.before(async () => {
|
||||
stores = db.stores;
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
test.after.always(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
|
@ -14,7 +14,7 @@ test.before(async () => {
|
||||
stores = db.stores;
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
test.after.always(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
|
@ -15,7 +15,7 @@ test.before(async () => {
|
||||
reset = db.reset;
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
test.after.always(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
|
@ -14,7 +14,7 @@ test.before(async () => {
|
||||
stores = db.stores;
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
test.after.always(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
|
@ -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
|
||||
|
@ -13,7 +13,7 @@ test.before(async () => {
|
||||
stores = db.stores;
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
test.after.always(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
|
@ -13,7 +13,7 @@ test.before(async () => {
|
||||
stores = db.stores;
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
test.after.always(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
|
@ -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();
|
||||
});
|
||||
|
||||
|
181
src/test/e2e/api/auth/reset-password-controller.e2e.test.ts
Normal file
181
src/test/e2e/api/auth/reset-password-controller.e2e.test.ts
Normal 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));
|
||||
},
|
||||
);
|
@ -13,7 +13,7 @@ test.before(async () => {
|
||||
stores = db.stores;
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
test.after.always(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
|
@ -15,7 +15,7 @@ test.before(async () => {
|
||||
stores = db.stores;
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
test.after.always(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
|
@ -22,7 +22,7 @@ test.before(async () => {
|
||||
stores = db.stores;
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
test.after.always(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -25,6 +25,7 @@ function createApp(stores, adminAuthentication = 'none', preHook) {
|
||||
authentication: {
|
||||
customHook: () => {},
|
||||
},
|
||||
unleashUrl: 'http://localhost:4242',
|
||||
getLogger,
|
||||
};
|
||||
const services = createServices(stores, config);
|
||||
|
100
src/test/e2e/services/reset-token-service.e2e.test.ts
Normal file
100
src/test/e2e/services/reset-token-service.e2e.test.ts
Normal 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);
|
||||
});
|
@ -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);
|
||||
|
@ -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 =>
|
||||
|
4
src/test/fixtures/fake-addon-store.js
vendored
4
src/test/fixtures/fake-addon-store.js
vendored
@ -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),
|
||||
};
|
||||
};
|
||||
|
33
src/test/fixtures/fake-feature-toggle-store.js
vendored
33
src/test/fixtures/fake-feature-toggle-store.js
vendored
@ -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 => {
|
||||
|
50
src/test/fixtures/fake-reset-token-store.ts
vendored
Normal file
50
src/test/fixtures/fake-reset-token-store.ts
vendored
Normal 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
133
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=
|
||||
|
Loading…
Reference in New Issue
Block a user