backend: add current password validation to change password endpoint. Update OpenAPI and tests

This commit is contained in:
juancarmore 2025-08-22 23:43:39 +02:00
parent 3413e21e0e
commit 6d43e94889
10 changed files with 53 additions and 23 deletions

View File

@ -5,7 +5,12 @@ content:
schema: schema:
type: object type: object
properties: properties:
currentPassword:
type: string
description: The current password of the user.
example: "currentPassword123"
newPassword: newPassword:
type: string type: string
minLength: 5
description: The new password for the user. description: The new password for the user.
example: "newSecurePassword123" example: "newSecurePassword123"

View File

@ -0,0 +1,8 @@
description: Bad Request — The current password is invalid
content:
application/json:
schema:
$ref: '../../schemas/error.yaml'
example:
error: 'Change Password Error'
message: 'Invalid current password'

View File

@ -28,6 +28,8 @@
responses: responses:
'200': '200':
$ref: '../../components/responses/internal/success-change-password.yaml' $ref: '../../components/responses/internal/success-change-password.yaml'
'400':
$ref: '../../components/responses/internal/error-invalid-password.yaml'
'401': '401':
$ref: '../../components/responses/unauthorized-error.yaml' $ref: '../../components/responses/unauthorized-error.yaml'
'422': '422':

View File

@ -24,11 +24,11 @@ export const changePassword = async (req: Request, res: Response) => {
return rejectRequestFromMeetError(res, error); return rejectRequestFromMeetError(res, error);
} }
const { newPassword } = req.body as { newPassword: string }; const { currentPassword, newPassword } = req.body as { currentPassword: string; newPassword: string };
try { try {
const userService = container.get(UserService); const userService = container.get(UserService);
await userService.changePassword(user.username, newPassword); await userService.changePassword(user.username, currentPassword, newPassword);
return res.status(200).json({ message: 'Password changed successfully' }); return res.status(200).json({ message: 'Password changed successfully' });
} catch (error) { } catch (error) {
handleError(res, error, 'changing password'); handleError(res, error, 'changing password');

View File

@ -3,7 +3,8 @@ import { z } from 'zod';
import { rejectUnprocessableRequest } from '../../models/error.model.js'; import { rejectUnprocessableRequest } from '../../models/error.model.js';
const ChangePasswordRequestSchema = z.object({ const ChangePasswordRequestSchema = z.object({
newPassword: z.string().min(4, 'New password must be at least 4 characters long') currentPassword: z.string(),
newPassword: z.string().min(5, 'New password must be at least 5 characters long')
}); });
export const validateChangePasswordRequest = (req: Request, res: Response, next: NextFunction) => { export const validateChangePasswordRequest = (req: Request, res: Response, next: NextFunction) => {

View File

@ -72,6 +72,10 @@ export const errorInvalidCredentials = (): OpenViduMeetError => {
return new OpenViduMeetError('Login Error', 'Invalid username or password', 404); return new OpenViduMeetError('Login Error', 'Invalid username or password', 404);
}; };
export const errorInvalidPassword = (): OpenViduMeetError => {
return new OpenViduMeetError('Change Password Error', 'Invalid current password', 400);
};
export const errorUnauthorized = (): OpenViduMeetError => { export const errorUnauthorized = (): OpenViduMeetError => {
return new OpenViduMeetError('Authentication Error', 'Unauthorized', 401); return new OpenViduMeetError('Authentication Error', 'Unauthorized', 401);
}; };

View File

@ -2,7 +2,7 @@ import { User, UserDTO, UserRole } from '@typings-ce';
import { inject, injectable } from 'inversify'; import { inject, injectable } from 'inversify';
import INTERNAL_CONFIG from '../config/internal-config.js'; import INTERNAL_CONFIG from '../config/internal-config.js';
import { PasswordHelper } from '../helpers/password.helper.js'; import { PasswordHelper } from '../helpers/password.helper.js';
import { internalError } from '../models/error.model.js'; import { errorInvalidPassword, internalError } from '../models/error.model.js';
import { MeetStorageService } from './index.js'; import { MeetStorageService } from './index.js';
@injectable() @injectable()
@ -29,13 +29,19 @@ export class UserService {
}; };
} }
async changePassword(username: string, newPassword: string) { async changePassword(username: string, currentPassword: string, newPassword: string) {
const user = await this.storageService.getUser(username); const user = await this.storageService.getUser(username);
if (!user) { if (!user) {
throw internalError(`getting user ${username} for password change`); throw internalError(`getting user ${username} for password change`);
} }
const isCurrentPasswordValid = await PasswordHelper.verifyPassword(currentPassword, user.passwordHash);
if (!isCurrentPasswordValid) {
throw errorInvalidPassword();
}
user.passwordHash = await PasswordHelper.hashPassword(newPassword); user.passwordHash = await PasswordHelper.hashPassword(newPassword);
await this.storageService.saveUser(user); await this.storageService.saveUser(user);
} }

View File

@ -190,13 +190,13 @@ export const getProfile = async (cookie: string) => {
.send(); .send();
}; };
export const changePassword = async (newPassword: string, cookie: string) => { export const changePassword = async (currentPassword: string, newPassword: string, cookie: string) => {
checkAppIsRunning(); checkAppIsRunning();
return await request(app) return await request(app)
.post(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/users/change-password`) .post(`${INTERNAL_CONFIG.INTERNAL_API_BASE_PATH_V1}/users/change-password`)
.set('Cookie', cookie) .set('Cookie', cookie)
.send({ newPassword }); .send({ currentPassword, newPassword });
}; };
export const createRoom = async (options: MeetRoomOptions = {}): Promise<MeetRoom> => { export const createRoom = async (options: MeetRoomOptions = {}): Promise<MeetRoom> => {

View File

@ -1,4 +1,4 @@
import { afterEach, beforeAll, describe, expect, it } from '@jest/globals'; import { beforeAll, describe, expect, it } from '@jest/globals';
import { Express } from 'express'; import { Express } from 'express';
import request from 'supertest'; import request from 'supertest';
import INTERNAL_CONFIG from '../../../../src/config/internal-config.js'; import INTERNAL_CONFIG from '../../../../src/config/internal-config.js';
@ -34,6 +34,7 @@ describe('User API Security Tests', () => {
describe('Change Password Tests', () => { describe('Change Password Tests', () => {
const changePasswordRequest = { const changePasswordRequest = {
currentPassword: MEET_ADMIN_SECRET,
newPassword: 'newpassword123' newPassword: 'newpassword123'
}; };
@ -43,17 +44,15 @@ describe('User API Security Tests', () => {
adminCookie = await loginUser(); adminCookie = await loginUser();
}); });
afterEach(async () => {
// Reset password
await changePassword(MEET_ADMIN_SECRET, adminCookie);
});
it('should succeed when user is authenticated as admin', async () => { it('should succeed when user is authenticated as admin', async () => {
const response = await request(app) const response = await request(app)
.post(`${USERS_PATH}/change-password`) .post(`${USERS_PATH}/change-password`)
.set('Cookie', adminCookie) .set('Cookie', adminCookie)
.send(changePasswordRequest); .send(changePasswordRequest);
expect(response.status).toBe(200); expect(response.status).toBe(200);
// Reset password
await changePassword(changePasswordRequest.newPassword, MEET_ADMIN_SECRET, adminCookie);
}); });
it('should fail when user is not authenticated', async () => { it('should fail when user is not authenticated', async () => {

View File

@ -1,4 +1,4 @@
import { afterEach, beforeAll, describe, expect, it } from '@jest/globals'; import { beforeAll, describe, expect, it } from '@jest/globals';
import { MEET_ADMIN_SECRET } from '../../../../src/environment.js'; import { MEET_ADMIN_SECRET } from '../../../../src/environment.js';
import { expectValidationError } from '../../../helpers/assertion-helpers.js'; import { expectValidationError } from '../../../helpers/assertion-helpers.js';
import { changePassword, loginUser, startTestServer } from '../../../helpers/request-helpers.js'; import { changePassword, loginUser, startTestServer } from '../../../helpers/request-helpers.js';
@ -11,21 +11,26 @@ describe('Users API Tests', () => {
adminCookie = await loginUser(); adminCookie = await loginUser();
}); });
afterEach(async () => {
// Reset password
await changePassword(MEET_ADMIN_SECRET, adminCookie);
});
describe('Change Password Tests', () => { describe('Change Password Tests', () => {
it('should successfully change password', async () => { it('should successfully change password', async () => {
const response = await changePassword('newpassword123', adminCookie); const newPassword = 'newpassword123';
const response = await changePassword(MEET_ADMIN_SECRET, newPassword, adminCookie);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body).toHaveProperty('message', 'Password changed successfully'); expect(response.body).toHaveProperty('message', 'Password changed successfully');
// Reset password
await changePassword(newPassword, MEET_ADMIN_SECRET, adminCookie);
}); });
it('should fail when new password is not 4 characters long', async () => { it('should fail when current password is incorrect', async () => {
const response = await changePassword('123', adminCookie); const response = await changePassword('wrongpassword', 'newpassword123', adminCookie);
expectValidationError(response, 'newPassword', 'New password must be at least 4 characters long'); expect(response.status).toBe(400);
expect(response.body).toHaveProperty('message', 'Invalid current password');
});
it('should fail when new password is not 5 characters long', async () => {
const response = await changePassword(MEET_ADMIN_SECRET, '1234', adminCookie);
expectValidationError(response, 'newPassword', 'New password must be at least 5 characters long');
}); });
}); });
}); });