fix(core): Make password-reset urls valid only for single-use (#7622)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2023-11-07 15:35:43 +01:00 committed by GitHub
parent b3470fd64d
commit 60314248f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 206 additions and 168 deletions

View file

@ -272,15 +272,7 @@ export class Server extends AbstractServer {
), ),
Container.get(MeController), Container.get(MeController),
new NodeTypesController(config, nodeTypes), new NodeTypesController(config, nodeTypes),
new PasswordResetController( Container.get(PasswordResetController),
logger,
externalHooks,
internalHooks,
mailer,
userService,
jwtService,
mfaService,
),
Container.get(TagsController), Container.get(TagsController),
new TranslationController(config, this.credentialTypes), new TranslationController(config, this.credentialTypes),
new UsersController( new UsersController(

View file

@ -44,6 +44,11 @@ export function issueJWT(user: User): JwtToken {
}; };
} }
export const createPasswordSha = (user: User) =>
createHash('sha256')
.update(user.password.slice(user.password.length / 2))
.digest('hex');
export async function resolveJwtContent(jwtPayload: JwtPayload): Promise<User> { export async function resolveJwtContent(jwtPayload: JwtPayload): Promise<User> {
const user = await Db.collections.User.findOne({ const user = await Db.collections.User.findOne({
where: { id: jwtPayload.id }, where: { id: jwtPayload.id },
@ -52,9 +57,7 @@ export async function resolveJwtContent(jwtPayload: JwtPayload): Promise<User> {
let passwordHash = null; let passwordHash = null;
if (user?.password) { if (user?.password) {
passwordHash = createHash('sha256') passwordHash = createPasswordSha(user);
.update(user.password.slice(user.password.length / 2))
.digest('hex');
} }
// currently only LDAP users during synchronization // currently only LDAP users during synchronization

View file

@ -1,5 +1,9 @@
import { Response } from 'express';
import { rateLimit } from 'express-rate-limit';
import { Service } from 'typedi';
import { IsNull, 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 {
BadRequestError, BadRequestError,
@ -14,23 +18,17 @@ import {
validatePassword, validatePassword,
} from '@/UserManagement/UserManagementHelper'; } from '@/UserManagement/UserManagementHelper';
import { UserManagementMailer } from '@/UserManagement/email'; import { UserManagementMailer } from '@/UserManagement/email';
import { Response } from 'express';
import { PasswordResetRequest } from '@/requests'; import { PasswordResetRequest } from '@/requests';
import { IExternalHooksClass, IInternalHooksClass } from '@/Interfaces';
import { issueCookie } from '@/auth/jwt'; import { issueCookie } from '@/auth/jwt';
import { isLdapEnabled } from '@/Ldap/helpers'; import { isLdapEnabled } from '@/Ldap/helpers';
import { isSamlCurrentAuthenticationMethod } from '@/sso/ssoHelpers'; import { isSamlCurrentAuthenticationMethod } from '@/sso/ssoHelpers';
import { UserService } from '@/services/user.service'; import { UserService } from '@/services/user.service';
import { License } from '@/License'; import { License } from '@/License';
import { Container } from 'typedi';
import { RESPONSE_ERROR_MESSAGES, inTest } from '@/constants'; import { RESPONSE_ERROR_MESSAGES, inTest } from '@/constants';
import { TokenExpiredError } from 'jsonwebtoken';
import type { JwtPayload } from '@/services/jwt.service';
import { JwtService } from '@/services/jwt.service';
import { MfaService } from '@/Mfa/mfa.service'; import { MfaService } from '@/Mfa/mfa.service';
import { Logger } from '@/Logger'; import { Logger } from '@/Logger';
import { rateLimit } from 'express-rate-limit'; import { ExternalHooks } from '@/ExternalHooks';
import { InternalHooks } from '@/InternalHooks';
const throttle = rateLimit({ const throttle = rateLimit({
windowMs: 5 * 60 * 1000, // 5 minutes windowMs: 5 * 60 * 1000, // 5 minutes
@ -38,16 +36,17 @@ const throttle = rateLimit({
message: { message: 'Too many requests' }, message: { message: 'Too many requests' },
}); });
@Service()
@RestController() @RestController()
export class PasswordResetController { export class PasswordResetController {
constructor( constructor(
private readonly logger: Logger, private readonly logger: Logger,
private readonly externalHooks: IExternalHooksClass, private readonly externalHooks: ExternalHooks,
private readonly internalHooks: IInternalHooksClass, private readonly internalHooks: InternalHooks,
private readonly mailer: UserManagementMailer, private readonly mailer: UserManagementMailer,
private readonly userService: UserService, private readonly userService: UserService,
private readonly jwtService: JwtService,
private readonly mfaService: MfaService, private readonly mfaService: MfaService,
private readonly license: License,
) {} ) {}
/** /**
@ -92,7 +91,7 @@ export class PasswordResetController {
relations: ['authIdentities', 'globalRole'], relations: ['authIdentities', 'globalRole'],
}); });
if (!user?.isOwner && !Container.get(License).isWithinUsersLimit()) { if (!user?.isOwner && !this.license.isWithinUsersLimit()) {
this.logger.debug( this.logger.debug(
'Request to send password reset email failed because the user limit was reached', 'Request to send password reset email failed because the user limit was reached',
); );
@ -123,29 +122,16 @@ export class PasswordResetController {
throw new UnprocessableRequestError('forgotPassword.ldapUserPasswordResetUnavailable'); throw new UnprocessableRequestError('forgotPassword.ldapUserPasswordResetUnavailable');
} }
const baseUrl = getInstanceBaseUrl(); const url = this.userService.generatePasswordResetUrl(user);
const { id, firstName, lastName } = user; const { id, firstName, lastName } = user;
const resetPasswordToken = this.jwtService.signData(
{ sub: id },
{
expiresIn: '20m',
},
);
const url = this.userService.generatePasswordResetUrl(
baseUrl,
resetPasswordToken,
user.mfaEnabled,
);
try { try {
await this.mailer.passwordReset({ await this.mailer.passwordReset({
email, email,
firstName, firstName,
lastName, lastName,
passwordResetUrl: url, passwordResetUrl: url,
domain: baseUrl, domain: getInstanceBaseUrl(),
}); });
} catch (error) { } catch (error) {
void this.internalHooks.onEmailFailed({ void this.internalHooks.onEmailFailed({
@ -173,9 +159,9 @@ export class PasswordResetController {
*/ */
@Get('/resolve-password-token') @Get('/resolve-password-token')
async resolvePasswordToken(req: PasswordResetRequest.Credentials) { async resolvePasswordToken(req: PasswordResetRequest.Credentials) {
const { token: resetPasswordToken } = req.query; const { token } = req.query;
if (!resetPasswordToken) { if (!token) {
this.logger.debug( this.logger.debug(
'Request to resolve password token failed because of missing password reset token', 'Request to resolve password token failed because of missing password reset token',
{ {
@ -185,32 +171,17 @@ export class PasswordResetController {
throw new BadRequestError(''); throw new BadRequestError('');
} }
const decodedToken = this.verifyResetPasswordToken(resetPasswordToken); const user = await this.userService.resolvePasswordResetToken(token);
if (!user) throw new NotFoundError('');
const user = await this.userService.findOne({ if (!user?.isOwner && !this.license.isWithinUsersLimit()) {
where: { id: decodedToken.sub },
relations: ['globalRole'],
});
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: decodedToken.sub }, { userId: user.id },
); );
throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED); throw new UnauthorizedError(RESPONSE_ERROR_MESSAGES.USERS_QUOTA_REACHED);
} }
if (!user) {
this.logger.debug(
'Request to resolve password token failed because no user was found for the provided user ID',
{
userId: decodedToken.sub,
resetPasswordToken,
},
);
throw new NotFoundError('');
}
this.logger.info('Reset-password token resolved successfully', { userId: user.id }); this.logger.info('Reset-password token resolved successfully', { userId: user.id });
void this.internalHooks.onUserPasswordResetEmailClick({ user }); void this.internalHooks.onUserPasswordResetEmailClick({ user });
} }
@ -220,9 +191,9 @@ export class PasswordResetController {
*/ */
@Post('/change-password') @Post('/change-password')
async changePassword(req: PasswordResetRequest.NewPassword, res: Response) { async changePassword(req: PasswordResetRequest.NewPassword, res: Response) {
const { token: resetPasswordToken, password, mfaToken } = req.body; const { token, password, mfaToken } = req.body;
if (!resetPasswordToken || !password) { if (!token || !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',
{ {
@ -234,22 +205,8 @@ export class PasswordResetController {
const validPassword = validatePassword(password); const validPassword = validatePassword(password);
const decodedToken = this.verifyResetPasswordToken(resetPasswordToken); const user = await this.userService.resolvePasswordResetToken(token);
if (!user) throw new NotFoundError('');
const user = await this.userService.findOne({
where: { id: decodedToken.sub },
relations: ['authIdentities', 'globalRole'],
});
if (!user) {
this.logger.debug(
'Request to resolve password token failed because no user was found for the provided user ID',
{
resetPasswordToken,
},
);
throw new NotFoundError('');
}
if (user.mfaEnabled) { if (user.mfaEnabled) {
if (!mfaToken) throw new BadRequestError('If MFA enabled, mfaToken is required.'); if (!mfaToken) throw new BadRequestError('If MFA enabled, mfaToken is required.');
@ -285,23 +242,4 @@ 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

@ -411,23 +411,8 @@ export class UsersController {
throw new NotFoundError('User not found'); throw new NotFoundError('User not found');
} }
const resetPasswordToken = this.jwtService.signData( const link = this.userService.generatePasswordResetUrl(user);
{ sub: user.id }, return { link };
{
expiresIn: '1d',
},
);
const baseUrl = getInstanceBaseUrl();
const link = this.userService.generatePasswordResetUrl(
baseUrl,
resetPasswordToken,
user.mfaEnabled,
);
return {
link,
};
} }
@Authorized(['global', 'owner']) @Authorized(['global', 'owner'])

View file

@ -10,8 +10,8 @@ export class JwtService {
return jwt.sign(payload, this.userManagementSecret, options); return jwt.sign(payload, this.userManagementSecret, options);
} }
public verifyToken(token: string, options: jwt.VerifyOptions = {}) { public verifyToken<T = JwtPayload>(token: string, options: jwt.VerifyOptions = {}) {
return jwt.verify(token, this.userManagementSecret, options) as jwt.JwtPayload; return jwt.verify(token, this.userManagementSecret, options) as T;
} }
} }

View file

@ -7,10 +7,18 @@ import { UserRepository } from '@/databases/repositories';
import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper';
import type { PublicUser } from '@/Interfaces'; import type { PublicUser } from '@/Interfaces';
import type { PostHogClient } from '@/posthog'; import type { PostHogClient } from '@/posthog';
import { type JwtPayload, JwtService } from './jwt.service';
import { TokenExpiredError } from 'jsonwebtoken';
import { Logger } from '@/Logger';
import { createPasswordSha } from '@/auth/jwt';
@Service() @Service()
export class UserService { export class UserService {
constructor(private readonly userRepository: UserRepository) {} constructor(
private readonly logger: Logger,
private readonly userRepository: UserRepository,
private readonly jwtService: JwtService,
) {}
async findOne(options: FindOneOptions<User>) { async findOne(options: FindOneOptions<User>) {
return this.userRepository.findOne({ relations: ['globalRole'], ...options }); return this.userRepository.findOne({ relations: ['globalRole'], ...options });
@ -54,15 +62,57 @@ export class UserService {
return this.userRepository.update(userId, { settings: { ...settings, ...newSettings } }); return this.userRepository.update(userId, { settings: { ...settings, ...newSettings } });
} }
generatePasswordResetUrl(instanceBaseUrl: string, token: string, mfaEnabled: boolean) { generatePasswordResetToken(user: User, expiresIn = '20m') {
return this.jwtService.signData(
{ sub: user.id, passwordSha: createPasswordSha(user) },
{ expiresIn },
);
}
generatePasswordResetUrl(user: User) {
const instanceBaseUrl = getInstanceBaseUrl();
const url = new URL(`${instanceBaseUrl}/change-password`); const url = new URL(`${instanceBaseUrl}/change-password`);
url.searchParams.append('token', token); url.searchParams.append('token', this.generatePasswordResetToken(user));
url.searchParams.append('mfaEnabled', mfaEnabled.toString()); url.searchParams.append('mfaEnabled', user.mfaEnabled.toString());
return url.toString(); return url.toString();
} }
async resolvePasswordResetToken(token: string): Promise<User | undefined> {
let decodedToken: JwtPayload & { passwordSha: string };
try {
decodedToken = this.jwtService.verifyToken(token);
} catch (e) {
if (e instanceof TokenExpiredError) {
this.logger.debug('Reset password token expired', { token });
} else {
this.logger.debug('Error verifying token', { token });
}
return;
}
const user = await this.userRepository.findOne({
where: { id: decodedToken.sub },
relations: ['authIdentities', 'globalRole'],
});
if (!user) {
this.logger.debug(
'Request to resolve password token failed because no user was found for the provided user ID',
{ userId: decodedToken.sub, token },
);
return;
}
if (createPasswordSha(user) !== decodedToken.passwordSha) {
this.logger.debug('Password updated since this token was generated');
return;
}
return user;
}
async toPublic(user: User, options?: { withInviteUrl?: boolean; posthog?: PostHogClient }) { async toPublic(user: User, options?: { withInviteUrl?: boolean; posthog?: PostHogClient }) {
const { password, updatedAt, apiKey, authIdentities, ...rest } = user; const { password, updatedAt, apiKey, authIdentities, ...rest } = user;

View file

@ -1,14 +1,14 @@
import Container from 'typedi';
import config from '@/config'; import config from '@/config';
import * as Db from '@/Db'; import * as Db from '@/Db';
import type { Role } from '@db/entities/Role'; import type { Role } from '@db/entities/Role';
import type { User } from '@db/entities/User'; import type { User } from '@db/entities/User';
import * as testDb from './../shared/testDb';
import * as utils from '../shared/utils';
import { randomPassword } from '@/Ldap/helpers'; import { randomPassword } from '@/Ldap/helpers';
import { randomDigit, randomString, randomValidPassword, uniqueId } from '../shared/random';
import { TOTPService } from '@/Mfa/totp.service'; import { TOTPService } from '@/Mfa/totp.service';
import Container from 'typedi'; import { UserService } from '@/services/user.service';
import { JwtService } from '@/services/jwt.service'; import { randomDigit, randomString, randomValidPassword, uniqueId } from '../shared/random';
import * as testDb from '../shared/testDb';
import * as utils from '../shared/utils';
jest.mock('@/telemetry'); jest.mock('@/telemetry');
@ -206,7 +206,7 @@ describe('Change password with MFA enabled', () => {
}); });
test('POST /change-password should fail due to missing MFA token', async () => { test('POST /change-password should fail due to missing MFA token', async () => {
const { user } = await testDb.createUserWithMfaEnabled(); await testDb.createUserWithMfaEnabled();
const newPassword = randomValidPassword(); const newPassword = randomValidPassword();
@ -216,11 +216,11 @@ describe('Change password with MFA enabled', () => {
.post('/change-password') .post('/change-password')
.send({ password: newPassword, token: resetPasswordToken }); .send({ password: newPassword, token: resetPasswordToken });
expect(response.statusCode).toBe(400); expect(response.statusCode).toBe(404);
}); });
test('POST /change-password should fail due to invalid MFA token', async () => { test('POST /change-password should fail due to invalid MFA token', async () => {
const { user } = await testDb.createUserWithMfaEnabled(); await testDb.createUserWithMfaEnabled();
const newPassword = randomValidPassword(); const newPassword = randomValidPassword();
@ -232,7 +232,7 @@ describe('Change password with MFA enabled', () => {
mfaToken: randomDigit(), mfaToken: randomDigit(),
}); });
expect(response.statusCode).toBe(400); expect(response.statusCode).toBe(404);
}); });
test('POST /change-password should update password', async () => { test('POST /change-password should update password', async () => {
@ -242,9 +242,7 @@ describe('Change password with MFA enabled', () => {
config.set('userManagement.jwtSecret', randomString(5, 10)); config.set('userManagement.jwtSecret', randomString(5, 10));
const jwtService = Container.get(JwtService); const resetPasswordToken = Container.get(UserService).generatePasswordResetToken(user);
const resetPasswordToken = jwtService.signData({ sub: user.id });
const mfaToken = new TOTPService().generateTOTP(rawSecret); const mfaToken = new TOTPService().generateTOTP(rawSecret);

View file

@ -1,8 +1,9 @@
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { compare } from 'bcryptjs'; import { compare } from 'bcryptjs';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { License } from '@/License'; import { mock } from 'jest-mock-extended';
import { License } from '@/License';
import * as Db from '@/Db'; import * as Db from '@/Db';
import config from '@/config'; import config from '@/config';
import type { Role } from '@db/entities/Role'; import type { Role } from '@db/entities/Role';
@ -10,6 +11,7 @@ import type { User } from '@db/entities/User';
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 { JwtService } from '@/services/jwt.service';
import { UserService } from '@/services/user.service';
import { UserManagementMailer } from '@/UserManagement/email'; import { UserManagementMailer } from '@/UserManagement/email';
import * as utils from './shared/utils/'; import * as utils from './shared/utils/';
@ -33,6 +35,7 @@ const externalHooks = utils.mockInstance(ExternalHooks);
const mailer = utils.mockInstance(UserManagementMailer, { isEmailSetUp: true }); const mailer = utils.mockInstance(UserManagementMailer, { isEmailSetUp: true });
const testServer = utils.setupTestServer({ endpointGroups: ['passwordReset'] }); const testServer = utils.setupTestServer({ endpointGroups: ['passwordReset'] });
const jwtService = Container.get(JwtService); const jwtService = Container.get(JwtService);
let userService: UserService;
beforeAll(async () => { beforeAll(async () => {
globalOwnerRole = await testDb.getGlobalOwnerRole(); globalOwnerRole = await testDb.getGlobalOwnerRole();
@ -45,6 +48,7 @@ beforeEach(async () => {
member = await testDb.createUser({ globalRole: globalMemberRole }); member = await testDb.createUser({ globalRole: globalMemberRole });
externalHooks.run.mockReset(); externalHooks.run.mockReset();
jest.replaceProperty(mailer, 'isEmailSetUp', true); jest.replaceProperty(mailer, 'isEmailSetUp', true);
userService = Container.get(UserService);
}); });
describe('POST /forgot-password', () => { describe('POST /forgot-password', () => {
@ -127,7 +131,7 @@ describe('POST /forgot-password', () => {
describe('GET /resolve-password-token', () => { describe('GET /resolve-password-token', () => {
test('should succeed with valid inputs', async () => { test('should succeed with valid inputs', async () => {
const resetPasswordToken = jwtService.signData({ sub: owner.id }); const resetPasswordToken = userService.generatePasswordResetToken(owner);
const response = await testServer.authlessAgent const response = await testServer.authlessAgent
.get('/resolve-password-token') .get('/resolve-password-token')
@ -137,17 +141,15 @@ describe('GET /resolve-password-token', () => {
}); });
test('should fail with invalid inputs', async () => { test('should fail with invalid inputs', async () => {
const first = await testServer.authlessAgent await testServer.authlessAgent
.get('/resolve-password-token') .get('/resolve-password-token')
.query({ token: uuid() }); .query({ token: uuid() })
.expect(404);
const second = await testServer.authlessAgent await testServer.authlessAgent
.get('/resolve-password-token') .get('/resolve-password-token')
.query({ userId: owner.id }); .query({ userId: owner.id })
.expect(400);
for (const response of [first, second]) {
expect(response.statusCode).toBe(400);
}
}); });
test('should fail if user is not found', async () => { test('should fail if user is not found', async () => {
@ -161,7 +163,18 @@ describe('GET /resolve-password-token', () => {
}); });
test('should fail if token is expired', async () => { test('should fail if token is expired', async () => {
const resetPasswordToken = jwtService.signData({ sub: owner.id }, { expiresIn: '-1h' }); const resetPasswordToken = userService.generatePasswordResetToken(owner, '-1h');
const response = await testServer.authlessAgent
.get('/resolve-password-token')
.query({ userId: owner.id, token: resetPasswordToken });
expect(response.statusCode).toBe(404);
});
test('should fail after password has changed', async () => {
const updatedUser = mock<User>({ ...owner, password: 'another-password' });
const resetPasswordToken = userService.generatePasswordResetToken(updatedUser);
const response = await testServer.authlessAgent const response = await testServer.authlessAgent
.get('/resolve-password-token') .get('/resolve-password-token')
@ -175,7 +188,7 @@ describe('POST /change-password', () => {
const passwordToStore = randomValidPassword(); const passwordToStore = randomValidPassword();
test('should succeed with valid inputs', async () => { test('should succeed with valid inputs', async () => {
const resetPasswordToken = jwtService.signData({ sub: owner.id }); const resetPasswordToken = userService.generatePasswordResetToken(owner);
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,
@ -202,7 +215,7 @@ describe('POST /change-password', () => {
}); });
test('should fail with invalid inputs', async () => { test('should fail with invalid inputs', async () => {
const resetPasswordToken = jwtService.signData({ sub: owner.id }); const resetPasswordToken = userService.generatePasswordResetToken(owner);
const invalidPayloads = [ const invalidPayloads = [
{ token: uuid() }, { token: uuid() },
@ -236,7 +249,7 @@ describe('POST /change-password', () => {
}); });
test('should fail when token has expired', async () => { test('should fail when token has expired', async () => {
const resetPasswordToken = jwtService.signData({ sub: owner.id }, { expiresIn: '-1h' }); const resetPasswordToken = userService.generatePasswordResetToken(owner, '-1h');
const response = await testServer.authlessAgent.post('/change-password').send({ const response = await testServer.authlessAgent.post('/change-password').send({
token: resetPasswordToken, token: resetPasswordToken,
@ -252,7 +265,7 @@ describe('POST /change-password', () => {
test('owner should be able to reset its password when quota:users = 1', async () => { test('owner should be able to reset its password when quota:users = 1', async () => {
jest.spyOn(Container.get(License), 'getUsersLimit').mockReturnValueOnce(1); jest.spyOn(Container.get(License), 'getUsersLimit').mockReturnValueOnce(1);
const resetPasswordToken = jwtService.signData({ sub: owner.id }); const resetPasswordToken = userService.generatePasswordResetToken(owner);
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,
@ -281,7 +294,7 @@ describe('POST /change-password', () => {
test('member should not be able to reset its password when quota:users = 1', async () => { test('member should not be able to reset its password when quota:users = 1', async () => {
jest.spyOn(Container.get(License), 'getUsersLimit').mockReturnValueOnce(1); jest.spyOn(Container.get(License), 'getUsersLimit').mockReturnValueOnce(1);
const resetPasswordToken = jwtService.signData({ sub: member.id }); const resetPasswordToken = userService.generatePasswordResetToken(member);
const response = await testServer.authlessAgent.post('/change-password').send({ const response = await testServer.authlessAgent.post('/change-password').send({
token: resetPasswordToken, token: resetPasswordToken,
userId: member.id, userId: member.id,

View file

@ -236,19 +236,7 @@ export const setupTestServer = ({
registerController(app, config, Container.get(MeController)); registerController(app, config, Container.get(MeController));
break; break;
case 'passwordReset': case 'passwordReset':
registerController( registerController(app, config, Container.get(PasswordResetController));
app,
config,
new PasswordResetController(
logger,
externalHooks,
internalHooks,
mailer,
userService,
Container.get(JwtService),
mfaService,
),
);
break; break;
case 'owner': case 'owner':
registerController( registerController(

View file

@ -0,0 +1,73 @@
import Container from 'typedi';
import jwt from 'jsonwebtoken';
import { Logger } from '@/Logger';
import config from '@/config';
import { User } from '@db/entities/User';
import { UserRepository } from '@db/repositories';
import { UserService } from '@/services/user.service';
import { mockInstance } from '../../integration/shared/utils';
describe('UserService', () => {
config.set('userManagement.jwtSecret', 'random-secret');
mockInstance(Logger);
const repository = mockInstance(UserRepository);
const service = Container.get(UserService);
const testUser = Object.assign(new User(), {
id: '1234',
password: 'passwordHash',
mfaEnabled: false,
});
beforeEach(() => {
jest.resetAllMocks();
});
describe('generatePasswordResetToken', () => {
it('should generate valid password-reset tokens', () => {
const token = service.generatePasswordResetToken(testUser);
const decoded = jwt.decode(token) as jwt.JwtPayload;
expect(decoded.sub).toEqual(testUser.id);
expect(decoded.exp! - decoded.iat!).toEqual(1200); // Expires in 20 minutes
expect(decoded.passwordSha).toEqual(
'31513c5a9e3c5afe5c06d5675ace74e8bc3fadd9744ab5d89c311f2a62ccbd39',
);
});
});
describe('resolvePasswordResetToken', () => {
it('should not return a user if the token in invalid', async () => {
const user = await service.resolvePasswordResetToken('invalid-token');
expect(user).toBeUndefined();
});
it('should not return a user if the token in expired', async () => {
const token = service.generatePasswordResetToken(testUser, '-1h');
const user = await service.resolvePasswordResetToken(token);
expect(user).toBeUndefined();
});
it('should not return a user if the user does not exist in the DB', async () => {
repository.findOne.mockResolvedValueOnce(null);
const token = service.generatePasswordResetToken(testUser);
const user = await service.resolvePasswordResetToken(token);
expect(user).toBeUndefined();
});
it('should not return a user if the password sha does not match', async () => {
const token = service.generatePasswordResetToken(testUser);
const updatedUser = Object.create(testUser);
updatedUser.password = 'something-else';
repository.findOne.mockResolvedValueOnce(updatedUser);
const user = await service.resolvePasswordResetToken(token);
expect(user).toBeUndefined();
});
it('should not return the user if all checks pass', async () => {
const token = service.generatePasswordResetToken(testUser);
repository.findOne.mockResolvedValueOnce(testUser);
const user = await service.resolvePasswordResetToken(token);
expect(user).toEqual(testUser);
});
});
});

View file

@ -93,7 +93,7 @@
"auth.changePassword.passwordUpdatedMessage": "You can now sign in with your new password", "auth.changePassword.passwordUpdatedMessage": "You can now sign in with your new password",
"auth.changePassword.passwordsMustMatchError": "Passwords must match", "auth.changePassword.passwordsMustMatchError": "Passwords must match",
"auth.changePassword.reenterNewPassword": "Re-enter new password", "auth.changePassword.reenterNewPassword": "Re-enter new password",
"auth.changePassword.tokenValidationError": "Issue validating invite token", "auth.changePassword.tokenValidationError": "Invalid password-reset token",
"auth.defaultPasswordRequirements": "8+ characters, at least 1 number and 1 capital letter", "auth.defaultPasswordRequirements": "8+ characters, at least 1 number and 1 capital letter",
"auth.validation.missingParameters": "Missing token or user id", "auth.validation.missingParameters": "Missing token or user id",
"auth.email": "Email", "auth.email": "Email",

View file

@ -103,10 +103,8 @@ export default defineComponent({
await this.usersStore.validatePasswordToken({ token }); await this.usersStore.validatePasswordToken({ token });
} catch (e) { } catch (e) {
this.showMessage({ this.showError(e, this.$locale.baseText('auth.changePassword.tokenValidationError'));
title: this.$locale.baseText('auth.changePassword.tokenValidationError'), void this.$router.replace({ name: VIEWS.SIGNIN });
type: 'error',
});
} }
}, },
methods: { methods: {

View file

@ -121,7 +121,7 @@ export default defineComponent({
async onSubmit(values: { [key: string]: string | boolean }) { async onSubmit(values: { [key: string]: string | boolean }) {
if (!this.inviterId || !this.inviteeId) { if (!this.inviterId || !this.inviteeId) {
this.showError( this.showError(
new Error(this.$locale.baseText('auth.changePassword.tokenValidationError')), new Error(this.$locale.baseText('auth.signup.tokenValidationError')),
this.$locale.baseText('auth.signup.setupYourAccountError'), this.$locale.baseText('auth.signup.setupYourAccountError'),
); );
return; return;