fix(core): Use JWT as reset password token (#6714)

* use jwt to reset password

* increase expiration time to 1d

* drop user id query string

* refactor

* use service instead of package in tests

* sqlite migration

* postgres migration

* mysql migration

* remove unused properties

* remove userId from FE

* fix test for users.api

* move migration to the common folder

* move type assertion to the jwt.service

* Add jwt secret as a readonly property

* use signData instead of sign in user.controller

* remove base class

* remove base class

* add tests
This commit is contained in:
Ricardo Espinoza 2023-07-24 17:40:17 -04:00 committed by GitHub
parent c2511a829c
commit 89f44021b9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 209 additions and 146 deletions

View file

@ -169,6 +169,7 @@ import { SourceControlService } from '@/environments/sourceControl/sourceControl
import { SourceControlController } from '@/environments/sourceControl/sourceControl.controller.ee'; import { SourceControlController } from '@/environments/sourceControl/sourceControl.controller.ee';
import { ExecutionRepository } from '@db/repositories'; import { ExecutionRepository } from '@db/repositories';
import type { ExecutionEntity } from '@db/entities/ExecutionEntity'; import type { ExecutionEntity } from '@db/entities/ExecutionEntity';
import { JwtService } from './services/jwt.service';
const exec = promisify(callbackExec); const exec = promisify(callbackExec);
@ -463,6 +464,7 @@ export class Server extends AbstractServer {
const internalHooks = Container.get(InternalHooks); const internalHooks = Container.get(InternalHooks);
const mailer = Container.get(UserManagementMailer); const mailer = Container.get(UserManagementMailer);
const postHog = this.postHog; const postHog = this.postHog;
const jwtService = Container.get(JwtService);
const controllers: object[] = [ const controllers: object[] = [
new EventBusController(), new EventBusController(),
@ -477,6 +479,7 @@ export class Server extends AbstractServer {
mailer, mailer,
repositories, repositories,
logger, logger,
jwtService,
}), }),
new TagsController({ config, repositories, externalHooks }), new TagsController({ config, repositories, externalHooks }),
new TranslationController(config, this.credentialTypes), new TranslationController(config, this.credentialTypes),
@ -489,6 +492,7 @@ export class Server extends AbstractServer {
activeWorkflowRunner, activeWorkflowRunner,
logger, logger,
postHog, postHog,
jwtService,
}), }),
Container.get(SamlController), Container.get(SamlController),
Container.get(SourceControlController), Container.get(SourceControlController),

View file

@ -111,15 +111,7 @@ export function validatePassword(password?: string): string {
* Remove sensitive properties from the user to return to the client. * Remove sensitive properties from the user to return to the client.
*/ */
export function sanitizeUser(user: User, withoutKeys?: string[]): PublicUser { export function sanitizeUser(user: User, withoutKeys?: string[]): PublicUser {
const { const { password, updatedAt, apiKey, authIdentities, ...rest } = user;
password,
resetPasswordToken,
resetPasswordTokenExpiration,
updatedAt,
apiKey,
authIdentities,
...rest
} = user;
if (withoutKeys) { if (withoutKeys) {
withoutKeys.forEach((key) => { withoutKeys.forEach((key) => {
// @ts-ignore // @ts-ignore

View file

@ -38,7 +38,6 @@ export function issueJWT(user: User): JwtToken {
const signedToken = jwt.sign(payload, config.getEnv('userManagement.jwtSecret'), { const signedToken = jwt.sign(payload, config.getEnv('userManagement.jwtSecret'), {
expiresIn: expiresIn / 1000 /* in seconds */, expiresIn: expiresIn / 1000 /* in seconds */,
algorithm: 'HS256',
}); });
return { return {

View file

@ -1,4 +1,4 @@
import { IsNull, MoreThanOrEqual, Not } from 'typeorm'; import { IsNull, Not } from 'typeorm';
import validator from 'validator'; import validator from 'validator';
import { Get, Post, RestController } from '@/decorators'; import { Get, Post, RestController } from '@/decorators';
import { import {
@ -28,6 +28,8 @@ import { UserService } from '@/user/user.service';
import { License } from '@/License'; import { License } from '@/License';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { RESPONSE_ERROR_MESSAGES } from '@/constants'; import { RESPONSE_ERROR_MESSAGES } from '@/constants';
import { TokenExpiredError } from 'jsonwebtoken';
import type { JwtService, JwtPayload } from '@/services/jwt.service';
@RestController() @RestController()
export class PasswordResetController { export class PasswordResetController {
@ -43,6 +45,8 @@ export class PasswordResetController {
private readonly userRepository: UserRepository; private readonly userRepository: UserRepository;
private readonly jwtService: JwtService;
constructor({ constructor({
config, config,
logger, logger,
@ -50,6 +54,7 @@ export class PasswordResetController {
internalHooks, internalHooks,
mailer, mailer,
repositories, repositories,
jwtService,
}: { }: {
config: Config; config: Config;
logger: ILogger; logger: ILogger;
@ -57,6 +62,7 @@ export class PasswordResetController {
internalHooks: IInternalHooksClass; internalHooks: IInternalHooksClass;
mailer: UserManagementMailer; mailer: UserManagementMailer;
repositories: Pick<IDatabaseCollections, 'User'>; repositories: Pick<IDatabaseCollections, 'User'>;
jwtService: JwtService;
}) { }) {
this.config = config; this.config = config;
this.logger = logger; this.logger = logger;
@ -64,6 +70,7 @@ export class PasswordResetController {
this.internalHooks = internalHooks; this.internalHooks = internalHooks;
this.mailer = mailer; this.mailer = mailer;
this.userRepository = repositories.User; this.userRepository = repositories.User;
this.jwtService = jwtService;
} }
/** /**
@ -139,7 +146,15 @@ export class PasswordResetController {
const baseUrl = getInstanceBaseUrl(); const baseUrl = getInstanceBaseUrl();
const { id, firstName, lastName } = user; const { id, firstName, lastName } = user;
const url = await UserService.generatePasswordResetUrl(user);
const resetPasswordToken = this.jwtService.signData(
{ sub: id },
{
expiresIn: '1d',
},
);
const url = await UserService.generatePasswordResetUrl(baseUrl, resetPasswordToken);
try { try {
await this.mailer.passwordReset({ await this.mailer.passwordReset({
@ -175,11 +190,11 @@ export class PasswordResetController {
*/ */
@Get('/resolve-password-token') @Get('/resolve-password-token')
async resolvePasswordToken(req: PasswordResetRequest.Credentials) { async resolvePasswordToken(req: PasswordResetRequest.Credentials) {
const { token: resetPasswordToken, userId: id } = req.query; const { token: resetPasswordToken } = req.query;
if (!resetPasswordToken || !id) { if (!resetPasswordToken) {
this.logger.debug( this.logger.debug(
'Request to resolve password token failed because of missing password reset token or user ID in query string', 'Request to resolve password token failed because of missing password reset token',
{ {
queryString: req.query, queryString: req.query,
}, },
@ -187,47 +202,46 @@ export class PasswordResetController {
throw new BadRequestError(''); throw new BadRequestError('');
} }
// Timestamp is saved in seconds const decodedToken = this.verifyResetPasswordToken(resetPasswordToken);
const currentTimestamp = Math.floor(Date.now() / 1000);
const user = await this.userRepository.findOne({ const user = await this.userRepository.findOne({
where: { where: {
id, id: decodedToken.sub,
resetPasswordToken,
resetPasswordTokenExpiration: MoreThanOrEqual(currentTimestamp),
}, },
relations: ['globalRole'], relations: ['globalRole'],
}); });
if (!user?.isOwner && !Container.get(License).isWithinUsersLimit()) { if (!user?.isOwner && !Container.get(License).isWithinUsersLimit()) {
this.logger.debug( this.logger.debug(
'Request to resolve password token failed because the user limit was reached', 'Request to resolve password token failed because the user limit was reached',
{ userId: id }, { userId: decodedToken.sub },
); );
throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
} }
if (!user) { if (!user) {
this.logger.debug( this.logger.debug(
'Request to resolve password token failed because no user was found for the provided user ID and reset password token', 'Request to resolve password token failed because no user was found for the provided user ID',
{ {
userId: id, userId: decodedToken.sub,
resetPasswordToken, resetPasswordToken,
}, },
); );
throw new NotFoundError(''); throw new NotFoundError('');
} }
this.logger.info('Reset-password token resolved successfully', { userId: id }); this.logger.info('Reset-password token resolved successfully', { userId: user.id });
void this.internalHooks.onUserPasswordResetEmailClick({ user }); void this.internalHooks.onUserPasswordResetEmailClick({ user });
} }
/** /**
* Verify password reset token and user ID and update password. * Verify password reset token and update password.
*/ */
@Post('/change-password') @Post('/change-password')
async changePassword(req: PasswordResetRequest.NewPassword, res: Response) { async changePassword(req: PasswordResetRequest.NewPassword, res: Response) {
const { token: resetPasswordToken, userId, password } = req.body; const { token: resetPasswordToken, password } = req.body;
if (!resetPasswordToken || !userId || !password) { if (!resetPasswordToken || !password) {
this.logger.debug( this.logger.debug(
'Request to change password failed because of missing user ID or password or reset password token in payload', 'Request to change password failed because of missing user ID or password or reset password token in payload',
{ {
@ -239,23 +253,17 @@ export class PasswordResetController {
const validPassword = validatePassword(password); const validPassword = validatePassword(password);
// Timestamp is saved in seconds const decodedToken = this.verifyResetPasswordToken(resetPasswordToken);
const currentTimestamp = Math.floor(Date.now() / 1000);
const user = await this.userRepository.findOne({ const user = await this.userRepository.findOne({
where: { where: { id: decodedToken.sub },
id: userId,
resetPasswordToken,
resetPasswordTokenExpiration: MoreThanOrEqual(currentTimestamp),
},
relations: ['authIdentities'], relations: ['authIdentities'],
}); });
if (!user) { if (!user) {
this.logger.debug( this.logger.debug(
'Request to resolve password token failed because no user was found for the provided user ID and reset password token', 'Request to resolve password token failed because no user was found for the provided user ID',
{ {
userId,
resetPasswordToken, resetPasswordToken,
}, },
); );
@ -264,13 +272,11 @@ export class PasswordResetController {
const passwordHash = await hashPassword(validPassword); const passwordHash = await hashPassword(validPassword);
await this.userRepository.update(userId, { await this.userRepository.update(user.id, {
password: passwordHash, password: passwordHash,
resetPasswordToken: null,
resetPasswordTokenExpiration: null,
}); });
this.logger.info('User password updated successfully', { userId }); this.logger.info('User password updated successfully', { userId: user.id });
await issueCookie(res, user); await issueCookie(res, user);
@ -290,4 +296,23 @@ export class PasswordResetController {
await this.externalHooks.run('user.password.update', [user.email, passwordHash]); await this.externalHooks.run('user.password.update', [user.email, passwordHash]);
} }
private verifyResetPasswordToken(resetPasswordToken: string) {
let decodedToken: JwtPayload;
try {
decodedToken = this.jwtService.verifyToken(resetPasswordToken);
return decodedToken;
} catch (e) {
if (e instanceof TokenExpiredError) {
this.logger.debug('Reset password token expired', {
resetPasswordToken,
});
throw new NotFoundError('');
}
this.logger.debug('Error verifying token', {
resetPasswordToken,
});
throw new BadRequestError('');
}
}
} }

View file

@ -49,6 +49,7 @@ import { plainToInstance } from 'class-transformer';
import { License } from '@/License'; import { License } from '@/License';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { RESPONSE_ERROR_MESSAGES } from '@/constants'; import { RESPONSE_ERROR_MESSAGES } from '@/constants';
import type { JwtService } from '@/services/jwt.service';
@Authorized(['global', 'owner']) @Authorized(['global', 'owner'])
@RestController('/users') @RestController('/users')
@ -73,6 +74,8 @@ export class UsersController {
private mailer: UserManagementMailer; private mailer: UserManagementMailer;
private jwtService: JwtService;
private postHog?: PostHogClient; private postHog?: PostHogClient;
constructor({ constructor({
@ -83,6 +86,7 @@ export class UsersController {
repositories, repositories,
activeWorkflowRunner, activeWorkflowRunner,
mailer, mailer,
jwtService,
postHog, postHog,
}: { }: {
config: Config; config: Config;
@ -95,6 +99,7 @@ export class UsersController {
>; >;
activeWorkflowRunner: ActiveWorkflowRunner; activeWorkflowRunner: ActiveWorkflowRunner;
mailer: UserManagementMailer; mailer: UserManagementMailer;
jwtService: JwtService;
postHog?: PostHogClient; postHog?: PostHogClient;
}) { }) {
this.config = config; this.config = config;
@ -107,6 +112,7 @@ export class UsersController {
this.sharedWorkflowRepository = repositories.SharedWorkflow; this.sharedWorkflowRepository = repositories.SharedWorkflow;
this.activeWorkflowRunner = activeWorkflowRunner; this.activeWorkflowRunner = activeWorkflowRunner;
this.mailer = mailer; this.mailer = mailer;
this.jwtService = jwtService;
this.postHog = postHog; this.postHog = postHog;
} }
@ -382,7 +388,17 @@ export class UsersController {
if (!user) { if (!user) {
throw new NotFoundError('User not found'); throw new NotFoundError('User not found');
} }
const link = await UserService.generatePasswordResetUrl(user);
const resetPasswordToken = this.jwtService.signData(
{ sub: user.id },
{
expiresIn: '1d',
},
);
const baseUrl = getInstanceBaseUrl();
const link = await UserService.generatePasswordResetUrl(baseUrl, resetPasswordToken);
return { return {
link, link,
}; };

View file

@ -55,13 +55,6 @@ export class User extends AbstractEntity implements IUser {
@IsString({ message: 'Password must be of type string.' }) @IsString({ message: 'Password must be of type string.' })
password: string; password: string;
@Column({ type: String, nullable: true })
resetPasswordToken?: string | null;
// Expiration timestamp saved in seconds
@Column({ type: Number, nullable: true })
resetPasswordTokenExpiration?: number | null;
@Column({ @Column({
type: jsonColumnType, type: jsonColumnType,
nullable: true, nullable: true,

View file

@ -0,0 +1,29 @@
import type { MigrationContext, ReversibleMigration } from '@db/types';
import { TableColumn } from 'typeorm';
export class RemoveResetPasswordColumns1690000000030 implements ReversibleMigration {
async up({ queryRunner, tablePrefix }: MigrationContext) {
await queryRunner.dropColumn(`${tablePrefix}user`, 'resetPasswordToken');
await queryRunner.dropColumn(`${tablePrefix}user`, 'resetPasswordTokenExpiration');
}
async down({ queryRunner, tablePrefix }: MigrationContext) {
await queryRunner.addColumn(
`${tablePrefix}user`,
new TableColumn({
name: 'resetPasswordToken',
type: 'varchar',
isNullable: true,
}),
);
await queryRunner.addColumn(
`${tablePrefix}user`,
new TableColumn({
name: 'resetPasswordTokenExpiration',
type: 'int',
isNullable: true,
}),
);
}
}

View file

@ -42,6 +42,7 @@ import { MigrateIntegerKeysToString1690000000001 } from './1690000000001-Migrate
import { SeparateExecutionData1690000000030 } from './1690000000030-SeparateExecutionData'; import { SeparateExecutionData1690000000030 } from './1690000000030-SeparateExecutionData';
import { FixExecutionDataType1690000000031 } from './1690000000031-FixExecutionDataType'; import { FixExecutionDataType1690000000031 } from './1690000000031-FixExecutionDataType';
import { RemoveSkipOwnerSetup1681134145997 } from './1681134145997-RemoveSkipOwnerSetup'; import { RemoveSkipOwnerSetup1681134145997 } from './1681134145997-RemoveSkipOwnerSetup';
import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns';
export const mysqlMigrations: Migration[] = [ export const mysqlMigrations: Migration[] = [
InitialMigration1588157391238, InitialMigration1588157391238,
@ -87,4 +88,5 @@ export const mysqlMigrations: Migration[] = [
SeparateExecutionData1690000000030, SeparateExecutionData1690000000030,
FixExecutionDataType1690000000031, FixExecutionDataType1690000000031,
RemoveSkipOwnerSetup1681134145997, RemoveSkipOwnerSetup1681134145997,
RemoveResetPasswordColumns1690000000030,
]; ];

View file

@ -39,6 +39,7 @@ import { AddUserActivatedProperty1681134145996 } from './1681134145996-AddUserAc
import { MigrateIntegerKeysToString1690000000000 } from './1690000000000-MigrateIntegerKeysToString'; import { MigrateIntegerKeysToString1690000000000 } from './1690000000000-MigrateIntegerKeysToString';
import { SeparateExecutionData1690000000020 } from './1690000000020-SeparateExecutionData'; import { SeparateExecutionData1690000000020 } from './1690000000020-SeparateExecutionData';
import { RemoveSkipOwnerSetup1681134145997 } from './1681134145997-RemoveSkipOwnerSetup'; import { RemoveSkipOwnerSetup1681134145997 } from './1681134145997-RemoveSkipOwnerSetup';
import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns';
export const postgresMigrations: Migration[] = [ export const postgresMigrations: Migration[] = [
InitialMigration1587669153312, InitialMigration1587669153312,
@ -81,4 +82,5 @@ export const postgresMigrations: Migration[] = [
MigrateIntegerKeysToString1690000000000, MigrateIntegerKeysToString1690000000000,
SeparateExecutionData1690000000020, SeparateExecutionData1690000000020,
RemoveSkipOwnerSetup1681134145997, RemoveSkipOwnerSetup1681134145997,
RemoveResetPasswordColumns1690000000030,
]; ];

View file

@ -39,6 +39,7 @@ import { MigrateIntegerKeysToString1690000000002 } from './1690000000002-Migrate
import { SeparateExecutionData1690000000010 } from './1690000000010-SeparateExecutionData'; import { SeparateExecutionData1690000000010 } from './1690000000010-SeparateExecutionData';
import { RemoveSkipOwnerSetup1681134145997 } from './1681134145997-RemoveSkipOwnerSetup'; import { RemoveSkipOwnerSetup1681134145997 } from './1681134145997-RemoveSkipOwnerSetup';
import { FixMissingIndicesFromStringIdMigration1690000000020 } from './1690000000020-FixMissingIndicesFromStringIdMigration'; import { FixMissingIndicesFromStringIdMigration1690000000020 } from './1690000000020-FixMissingIndicesFromStringIdMigration';
import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns';
const sqliteMigrations: Migration[] = [ const sqliteMigrations: Migration[] = [
InitialMigration1588102412422, InitialMigration1588102412422,
@ -81,6 +82,7 @@ const sqliteMigrations: Migration[] = [
SeparateExecutionData1690000000010, SeparateExecutionData1690000000010,
RemoveSkipOwnerSetup1681134145997, RemoveSkipOwnerSetup1681134145997,
FixMissingIndicesFromStringIdMigration1690000000020, FixMissingIndicesFromStringIdMigration1690000000020,
RemoveResetPasswordColumns1690000000030,
]; ];
export { sqliteMigrations }; export { sqliteMigrations };

View file

@ -0,0 +1,18 @@
import { Service } from 'typedi';
import * as jwt from 'jsonwebtoken';
import config from '@/config';
@Service()
export class JwtService {
private readonly userManagementSecret = config.getEnv('userManagement.jwtSecret');
public signData(payload: object, options: jwt.SignOptions = {}): string {
return jwt.sign(payload, this.userManagementSecret, options);
}
public verifyToken(token: string, options: jwt.VerifyOptions = {}) {
return jwt.verify(token, this.userManagementSecret, options) as jwt.JwtPayload;
}
}
export type JwtPayload = jwt.JwtPayload;

View file

@ -1,10 +1,8 @@
import type { EntityManager, FindOptionsWhere } from 'typeorm'; import type { EntityManager, FindOptionsWhere } from 'typeorm';
import { In } from 'typeorm'; import { In } from 'typeorm';
import { v4 as uuid } from 'uuid';
import * as Db from '@/Db'; import * as Db from '@/Db';
import { User } from '@db/entities/User'; import { User } from '@db/entities/User';
import type { IUserSettings } from 'n8n-workflow'; import type { IUserSettings } from 'n8n-workflow';
import { getInstanceBaseUrl } from '../UserManagement/UserManagementHelper';
export class UserService { export class UserService {
static async get(where: FindOptionsWhere<User>): Promise<User | null> { static async get(where: FindOptionsWhere<User>): Promise<User | null> {
@ -25,16 +23,9 @@ export class UserService {
return Db.collections.User.update(id, { settings: { ...currentSettings, ...userSettings } }); return Db.collections.User.update(id, { settings: { ...currentSettings, ...userSettings } });
} }
static async generatePasswordResetUrl(user: User): Promise<string> { static async generatePasswordResetUrl(instanceBaseUrl: string, token: string): Promise<string> {
user.resetPasswordToken = uuid(); const url = new URL(`${instanceBaseUrl}/change-password`);
const { id, resetPasswordToken } = user; url.searchParams.append('token', token);
const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 7200;
await Db.collections.User.update(id, { resetPasswordToken, resetPasswordTokenExpiration });
const baseUrl = getInstanceBaseUrl();
const url = new URL(`${baseUrl}/change-password`);
url.searchParams.append('userId', id);
url.searchParams.append('token', resetPasswordToken);
return url.toString(); return url.toString();
} }
} }

View file

@ -10,13 +10,17 @@ import {
randomEmail, randomEmail,
randomInvalidPassword, randomInvalidPassword,
randomName, randomName,
randomString,
randomValidPassword, randomValidPassword,
} from './shared/random'; } from './shared/random';
import * as testDb from './shared/testDb'; import * as testDb from './shared/testDb';
import { setCurrentAuthenticationMethod } from '@/sso/ssoHelpers'; import { setCurrentAuthenticationMethod } from '@/sso/ssoHelpers';
import { ExternalHooks } from '@/ExternalHooks'; import { ExternalHooks } from '@/ExternalHooks';
import { JwtService } from '@/services/jwt.service';
import { Container } from 'typedi';
jest.mock('@/UserManagement/email/NodeMailer'); jest.mock('@/UserManagement/email/NodeMailer');
config.set('userManagement.jwtSecret', randomString(5, 10));
let globalOwnerRole: Role; let globalOwnerRole: Role;
let globalMemberRole: Role; let globalMemberRole: Role;
@ -24,6 +28,7 @@ let owner: User;
const externalHooks = utils.mockInstance(ExternalHooks); const externalHooks = utils.mockInstance(ExternalHooks);
const testServer = utils.setupTestServer({ endpointGroups: ['passwordReset'] }); const testServer = utils.setupTestServer({ endpointGroups: ['passwordReset'] });
const jwtService = Container.get(JwtService);
beforeAll(async () => { beforeAll(async () => {
globalOwnerRole = await testDb.getGlobalOwnerRole(); globalOwnerRole = await testDb.getGlobalOwnerRole();
@ -51,10 +56,6 @@ describe('POST /forgot-password', () => {
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(response.body).toEqual({}); expect(response.body).toEqual({});
const user = await Db.collections.User.findOneByOrFail({ email: payload.email });
expect(user.resetPasswordToken).toBeDefined();
expect(user.resetPasswordTokenExpiration).toBeGreaterThan(Math.ceil(Date.now() / 1000));
}), }),
); );
}); });
@ -66,9 +67,6 @@ describe('POST /forgot-password', () => {
.post('/forgot-password') .post('/forgot-password')
.send({ email: owner.email }) .send({ email: owner.email })
.expect(500); .expect(500);
const storedOwner = await Db.collections.User.findOneByOrFail({ email: owner.email });
expect(storedOwner.resetPasswordToken).toBeNull();
}); });
test('should fail if SAML is authentication method', async () => { test('should fail if SAML is authentication method', async () => {
@ -84,8 +82,6 @@ describe('POST /forgot-password', () => {
.send({ email: member.email }) .send({ email: member.email })
.expect(403); .expect(403);
const storedOwner = await Db.collections.User.findOneByOrFail({ email: member.email });
expect(storedOwner.resetPasswordToken).toBeNull();
await setCurrentAuthenticationMethod('email'); await setCurrentAuthenticationMethod('email');
}); });
@ -100,8 +96,6 @@ describe('POST /forgot-password', () => {
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(response.body).toEqual({}); expect(response.body).toEqual({});
const storedOwner = await Db.collections.User.findOneByOrFail({ email: owner.email });
expect(storedOwner.resetPasswordToken).not.toBeNull();
await setCurrentAuthenticationMethod('email'); await setCurrentAuthenticationMethod('email');
}); });
@ -119,9 +113,6 @@ describe('POST /forgot-password', () => {
for (const invalidPayload of invalidPayloads) { for (const invalidPayload of invalidPayloads) {
const response = await testServer.authlessAgent.post('/forgot-password').send(invalidPayload); const response = await testServer.authlessAgent.post('/forgot-password').send(invalidPayload);
expect(response.statusCode).toBe(400); expect(response.statusCode).toBe(400);
const storedOwner = await Db.collections.User.findOneByOrFail({ email: owner.email });
expect(storedOwner.resetPasswordToken).toBeNull();
} }
}); });
@ -142,13 +133,7 @@ describe('GET /resolve-password-token', () => {
}); });
test('should succeed with valid inputs', async () => { test('should succeed with valid inputs', async () => {
const resetPasswordToken = uuid(); const resetPasswordToken = jwtService.signData({ sub: owner.id });
const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 100;
await Db.collections.User.update(owner.id, {
resetPasswordToken,
resetPasswordTokenExpiration,
});
const response = await testServer.authlessAgent const response = await testServer.authlessAgent
.get('/resolve-password-token') .get('/resolve-password-token')
@ -172,21 +157,17 @@ describe('GET /resolve-password-token', () => {
}); });
test('should fail if user is not found', async () => { test('should fail if user is not found', async () => {
const token = jwtService.signData({ sub: 'test' });
const response = await testServer.authlessAgent const response = await testServer.authlessAgent
.get('/resolve-password-token') .get('/resolve-password-token')
.query({ userId: owner.id, token: uuid() }); .query({ userId: owner.id, token });
expect(response.statusCode).toBe(404); expect(response.statusCode).toBe(404);
}); });
test('should fail if token is expired', async () => { test('should fail if token is expired', async () => {
const resetPasswordToken = uuid(); const resetPasswordToken = jwtService.signData({ sub: owner.id }, { expiresIn: '-1h' });
const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) - 1;
await Db.collections.User.update(owner.id, {
resetPasswordToken,
resetPasswordTokenExpiration,
});
const response = await testServer.authlessAgent const response = await testServer.authlessAgent
.get('/resolve-password-token') .get('/resolve-password-token')
@ -197,17 +178,10 @@ describe('GET /resolve-password-token', () => {
}); });
describe('POST /change-password', () => { describe('POST /change-password', () => {
const resetPasswordToken = uuid();
const passwordToStore = randomValidPassword(); const passwordToStore = randomValidPassword();
test('should succeed with valid inputs', async () => { test('should succeed with valid inputs', async () => {
const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 100; const resetPasswordToken = jwtService.signData({ sub: owner.id });
await Db.collections.User.update(owner.id, {
resetPasswordToken,
resetPasswordTokenExpiration,
});
const response = await testServer.authlessAgent.post('/change-password').send({ const response = await testServer.authlessAgent.post('/change-password').send({
token: resetPasswordToken, token: resetPasswordToken,
userId: owner.id, userId: owner.id,
@ -234,12 +208,7 @@ describe('POST /change-password', () => {
}); });
test('should fail with invalid inputs', async () => { test('should fail with invalid inputs', async () => {
const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 100; const resetPasswordToken = jwtService.signData({ sub: owner.id });
await Db.collections.User.update(owner.id, {
resetPasswordToken,
resetPasswordTokenExpiration,
});
const invalidPayloads = [ const invalidPayloads = [
{ token: uuid() }, { token: uuid() },
@ -272,12 +241,7 @@ describe('POST /change-password', () => {
}); });
test('should fail when token has expired', async () => { test('should fail when token has expired', async () => {
const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) - 1; const resetPasswordToken = jwtService.signData({ sub: owner.id }, { expiresIn: '-1h' });
await Db.collections.User.update(owner.id, {
resetPasswordToken,
resetPasswordTokenExpiration,
});
const response = await testServer.authlessAgent.post('/change-password').send({ const response = await testServer.authlessAgent.post('/change-password').send({
token: resetPasswordToken, token: resetPasswordToken,

View file

@ -50,6 +50,7 @@ import * as testDb from '../../shared/testDb';
import { AUTHLESS_ENDPOINTS, PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from '../constants'; import { AUTHLESS_ENDPOINTS, PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from '../constants';
import type { EndpointGroup, SetupProps, TestServer } from '../types'; import type { EndpointGroup, SetupProps, TestServer } from '../types';
import { mockInstance } from './mocking'; import { mockInstance } from './mocking';
import { JwtService } from '@/services/jwt.service';
/** /**
* Plugin to prefix a path segment into a request URL pathname. * Plugin to prefix a path segment into a request URL pathname.
@ -182,6 +183,7 @@ export const setupTestServer = ({
const externalHooks = Container.get(ExternalHooks); const externalHooks = Container.get(ExternalHooks);
const internalHooks = Container.get(InternalHooks); const internalHooks = Container.get(InternalHooks);
const mailer = Container.get(UserManagementMailer); const mailer = Container.get(UserManagementMailer);
const jwtService = Container.get(JwtService);
const repositories = Db.collections; const repositories = Db.collections;
for (const group of functionEndpoints) { for (const group of functionEndpoints) {
@ -238,6 +240,7 @@ export const setupTestServer = ({
internalHooks, internalHooks,
mailer, mailer,
repositories, repositories,
jwtService,
}), }),
); );
break; break;
@ -260,6 +263,7 @@ export const setupTestServer = ({
repositories, repositories,
activeWorkflowRunner: Container.get(ActiveWorkflowRunner), activeWorkflowRunner: Container.get(ActiveWorkflowRunner),
logger, logger,
jwtService,
}), }),
); );
break; break;

View file

@ -78,7 +78,6 @@ describe('GET /users', () => {
personalizationAnswers, personalizationAnswers,
globalRole, globalRole,
password, password,
resetPasswordToken,
isPending, isPending,
apiKey, apiKey,
} = user; } = user;
@ -89,7 +88,6 @@ describe('GET /users', () => {
expect(lastName).toBeDefined(); expect(lastName).toBeDefined();
expect(personalizationAnswers).toBeUndefined(); expect(personalizationAnswers).toBeUndefined();
expect(password).toBeUndefined(); expect(password).toBeUndefined();
expect(resetPasswordToken).toBeUndefined();
expect(isPending).toBe(false); expect(isPending).toBe(false);
expect(globalRole).toBeDefined(); expect(globalRole).toBeDefined();
expect(apiKey).not.toBeDefined(); expect(apiKey).not.toBeDefined();
@ -254,7 +252,6 @@ describe('POST /users/:id', () => {
lastName, lastName,
personalizationAnswers, personalizationAnswers,
password, password,
resetPasswordToken,
globalRole, globalRole,
isPending, isPending,
apiKey, apiKey,
@ -266,7 +263,6 @@ describe('POST /users/:id', () => {
expect(lastName).toBe(memberData.lastName); expect(lastName).toBe(memberData.lastName);
expect(personalizationAnswers).toBeNull(); expect(personalizationAnswers).toBeNull();
expect(password).toBeUndefined(); expect(password).toBeUndefined();
expect(resetPasswordToken).toBeUndefined();
expect(isPending).toBe(false); expect(isPending).toBe(false);
expect(globalRole).toBeDefined(); expect(globalRole).toBeDefined();
expect(apiKey).not.toBeDefined(); expect(apiKey).not.toBeDefined();
@ -404,14 +400,12 @@ describe('POST /users', () => {
} }
const storedUser = await Db.collections.User.findOneByOrFail({ id }); const storedUser = await Db.collections.User.findOneByOrFail({ id });
const { firstName, lastName, personalizationAnswers, password, resetPasswordToken } = const { firstName, lastName, personalizationAnswers, password } = storedUser;
storedUser;
expect(firstName).toBeNull(); expect(firstName).toBeNull();
expect(lastName).toBeNull(); expect(lastName).toBeNull();
expect(personalizationAnswers).toBeNull(); expect(personalizationAnswers).toBeNull();
expect(password).toBeNull(); expect(password).toBeNull();
expect(resetPasswordToken).toBeNull();
} }
}); });

View file

@ -0,0 +1,42 @@
import config from '@/config';
import { JwtService } from '@/services/jwt.service';
import { randomString } from '../../integration/shared/random';
import * as jwt from 'jsonwebtoken';
describe('JwtService', () => {
config.set('userManagement.jwtSecret', randomString(5, 10));
const jwtService = new JwtService();
beforeEach(() => {
jest.clearAllMocks();
});
test('Should sign input with user management secret', async () => {
const userId = 1;
const token = jwtService.signData({ sub: userId });
expect(typeof token).toBe('string');
const secret = config.get('userManagement.jwtSecret');
const decodedToken = jwt.verify(token, secret);
expect(decodedToken).toHaveProperty('sub');
expect(decodedToken).toHaveProperty('iat');
expect(decodedToken?.sub).toBe(userId);
});
test('Should verify token with user management secret', async () => {
const userId = 1;
const secret = config.get('userManagement.jwtSecret');
const token = jwt.sign({ sub: userId }, secret);
const decodedToken = jwt.verify(token, secret);
expect(decodedToken).toHaveProperty('sub');
expect(decodedToken?.sub).toBe(userId);
});
});

View file

@ -67,14 +67,14 @@ export async function sendForgotPasswordEmail(
export async function validatePasswordToken( export async function validatePasswordToken(
context: IRestApiContext, context: IRestApiContext,
params: { token: string; userId: string }, params: { token: string },
): Promise<void> { ): Promise<void> {
await makeRestApiRequest(context, 'GET', '/resolve-password-token', params); await makeRestApiRequest(context, 'GET', '/resolve-password-token', params);
} }
export async function changePassword( export async function changePassword(
context: IRestApiContext, context: IRestApiContext,
params: { token: string; password: string; userId: string }, params: { token: string; password: string },
): Promise<void> { ): Promise<void> {
await makeRestApiRequest(context, 'POST', '/change-password', params); await makeRestApiRequest(context, 'POST', '/change-password', params);
} }

View file

@ -226,15 +226,11 @@ export const useUsersStore = defineStore(STORES.USERS, {
const rootStore = useRootStore(); const rootStore = useRootStore();
await sendForgotPasswordEmail(rootStore.getRestApiContext, params); await sendForgotPasswordEmail(rootStore.getRestApiContext, params);
}, },
async validatePasswordToken(params: { token: string; userId: string }): Promise<void> { async validatePasswordToken(params: { token: string }): Promise<void> {
const rootStore = useRootStore(); const rootStore = useRootStore();
await validatePasswordToken(rootStore.getRestApiContext, params); await validatePasswordToken(rootStore.getRestApiContext, params);
}, },
async changePassword(params: { async changePassword(params: { token: string; password: string }): Promise<void> {
token: string;
password: string;
userId: string;
}): Promise<void> {
const rootStore = useRootStore(); const rootStore = useRootStore();
await changePassword(rootStore.getRestApiContext, params); await changePassword(rootStore.getRestApiContext, params);
}, },

View file

@ -75,23 +75,15 @@ export default defineComponent({
}, },
], ],
}; };
const token =
!this.$route.query.token || typeof this.$route.query.token !== 'string' const token = this.getResetToken();
? null
: this.$route.query.token;
const userId =
!this.$route.query.userId || typeof this.$route.query.userId !== 'string'
? null
: this.$route.query.userId;
try { try {
if (!token) { if (!token) {
throw new Error(this.$locale.baseText('auth.changePassword.missingTokenError')); throw new Error(this.$locale.baseText('auth.changePassword.missingTokenError'));
} }
if (!userId) {
throw new Error(this.$locale.baseText('auth.changePassword.missingUserIdError'));
}
await this.usersStore.validatePasswordToken({ token, userId }); await this.usersStore.validatePasswordToken({ token });
} catch (e) { } catch (e) {
this.showMessage({ this.showMessage({
title: this.$locale.baseText('auth.changePassword.tokenValidationError'), title: this.$locale.baseText('auth.changePassword.tokenValidationError'),
@ -118,20 +110,18 @@ export default defineComponent({
this.password = e.value; this.password = e.value;
} }
}, },
getResetToken(): string | null {
return !this.$route.query.token || typeof this.$route.query.token !== 'string'
? null
: this.$route.query.token;
},
async onSubmit() { async onSubmit() {
try { try {
this.loading = true; this.loading = true;
const token = const token = this.getResetToken();
!this.$route.query.token || typeof this.$route.query.token !== 'string'
? null
: this.$route.query.token;
const userId =
!this.$route.query.userId || typeof this.$route.query.userId !== 'string'
? null
: this.$route.query.userId;
if (token && userId) { if (token) {
await this.usersStore.changePassword({ token, userId, password: this.password }); await this.usersStore.changePassword({ token, password: this.password });
this.showMessage({ this.showMessage({
type: 'success', type: 'success',