1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-24 01:18:01 +02:00

feat: change own password confirmation (#3894)

This commit is contained in:
Mateusz Kwasniewski 2023-06-05 11:58:25 +02:00 committed by GitHub
parent ae1136075e
commit 5ec59c6e92
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 139 additions and 13 deletions

View File

@ -0,0 +1,42 @@
import { screen } from '@testing-library/react';
import { render } from 'utils/testRenderer';
import { testServerRoute, testServerSetup } from 'utils/testServer';
import { PasswordTab } from './PasswordTab';
import userEvent from '@testing-library/user-event';
import { UIProviderContainer } from '../../../providers/UIProvider/UIProviderContainer';
const server = testServerSetup();
testServerRoute(server, '/api/admin/ui-config', {});
testServerRoute(server, '/api/admin/auth/simple/settings', {
disabled: false,
});
testServerRoute(server, '/api/admin/user/change-password', {}, 'post', 401);
testServerRoute(server, '/auth/reset/validate-password', {}, 'post');
test('should render authorization error on missing old password', async () => {
const user = userEvent.setup();
render(
<UIProviderContainer>
<PasswordTab />
</UIProviderContainer>
);
await screen.findByText('Change password');
const passwordInput = await screen.findByLabelText('Password');
await user.clear(passwordInput);
await user.type(passwordInput, 'IAmThePass1!@');
const confirmPasswordInput = await screen.findByLabelText(
'Confirm password'
);
await user.clear(confirmPasswordInput);
await user.type(confirmPasswordInput, 'IAmThePass1!@');
await screen.findByText('Passwords match');
const saveButton = await screen.findByText('Save');
await user.click(saveButton);
await screen.findByText('Authentication required');
});

View File

@ -11,6 +11,7 @@ import useAuthSettings from 'hooks/api/getters/useAuthSettings/useAuthSettings';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import { SyntheticEvent, useState } from 'react'; import { SyntheticEvent, useState } from 'react';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { AuthenticationError } from 'utils/apiUtils';
const StyledForm = styled('form')(({ theme }) => ({ const StyledForm = styled('form')(({ theme }) => ({
display: 'flex', display: 'flex',
@ -27,8 +28,10 @@ export const PasswordTab = () => {
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
const [validPassword, setValidPassword] = useState(false); const [validPassword, setValidPassword] = useState(false);
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [authenticationError, setAuthenticationError] = useState<string>();
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [oldPassword, setOldPassword] = useState('');
const { changePassword } = usePasswordApi(); const { changePassword } = usePasswordApi();
const submit = async (e: SyntheticEvent) => { const submit = async (e: SyntheticEvent) => {
@ -41,10 +44,12 @@ export const PasswordTab = () => {
} else { } else {
setLoading(true); setLoading(true);
setError(undefined); setError(undefined);
setAuthenticationError(undefined);
try { try {
await changePassword({ await changePassword({
password, password,
confirmPassword, confirmPassword,
oldPassword,
}); });
setToastData({ setToastData({
title: 'Password changed successfully', title: 'Password changed successfully',
@ -53,7 +58,12 @@ export const PasswordTab = () => {
}); });
} catch (error: unknown) { } catch (error: unknown) {
const formattedError = formatUnknownError(error); const formattedError = formatUnknownError(error);
if (error instanceof AuthenticationError) {
setAuthenticationError(formattedError);
} else {
setError(formattedError); setError(formattedError);
}
setToastApiError(formattedError); setToastApiError(formattedError);
} }
} }
@ -79,6 +89,18 @@ export const PasswordTab = () => {
callback={setValidPassword} callback={setValidPassword}
data-loading data-loading
/> />
<PasswordField
data-loading
label="Old password"
name="oldPassword"
value={oldPassword}
error={Boolean(authenticationError)}
helperText={authenticationError}
autoComplete="current-password"
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => setOldPassword(e.target.value)}
/>
<PasswordField <PasswordField
data-loading data-loading
label="Password" label="Password"

View File

@ -3,6 +3,7 @@ import useAPI from '../useApi/useApi';
interface IChangePasswordPayload { interface IChangePasswordPayload {
password: string; password: string;
confirmPassword: string; confirmPassword: string;
oldPassword: string;
} }
export const usePasswordApi = () => { export const usePasswordApi = () => {

View File

@ -15,11 +15,12 @@ export const testServerRoute = (
server: SetupServerApi, server: SetupServerApi,
path: string, path: string,
json: object, json: object,
method: 'get' | 'post' | 'put' | 'delete' = 'get' method: 'get' | 'post' | 'put' | 'delete' = 'get',
status: number = 200
) => { ) => {
server.use( server.use(
rest[method](path, (req, res, ctx) => { rest[method](path, (req, res, ctx) => {
return res(ctx.json(json)); return res(ctx.status(status), ctx.json(json));
}) })
); );
}; };

View File

@ -9,6 +9,9 @@ export const passwordSchema = {
password: { password: {
type: 'string', type: 'string',
}, },
oldPassword: {
type: 'string',
},
confirmPassword: { confirmPassword: {
type: 'string', type: 'string',
}, },

View File

@ -5,13 +5,19 @@ import { createTestConfig } from '../../../../test/config/test-config';
import createStores from '../../../../test/fixtures/store'; import createStores from '../../../../test/fixtures/store';
import getApp from '../../../app'; import getApp from '../../../app';
import User from '../../../types/user'; import User from '../../../types/user';
import bcrypt from 'bcryptjs';
const currentUser = new User({ id: 1337, email: 'test@mail.com' }); const currentUser = new User({ id: 1337, email: 'test@mail.com' });
const oldPassword = 'old-pass';
async function getSetup() { async function getSetup() {
const base = `/random${Math.round(Math.random() * 1000)}`; const base = `/random${Math.round(Math.random() * 1000)}`;
const stores = createStores(); const stores = createStores();
await stores.userStore.insert(currentUser); await stores.userStore.insert(currentUser);
await stores.userStore.setPasswordHash(
currentUser.id,
await bcrypt.hash(oldPassword, 10),
);
const config = createTestConfig({ const config = createTestConfig({
preHook: (a) => { preHook: (a) => {
@ -47,20 +53,44 @@ test('should return current user', async () => {
const owaspPassword = 't7GTx&$Y9pcsnxRv6'; const owaspPassword = 't7GTx&$Y9pcsnxRv6';
test('should allow user to change password', async () => { test('should allow user to change password', async () => {
expect.assertions(2); expect.assertions(1);
const { request, base, userStore } = await getSetup(); const { request, base, userStore } = await getSetup();
const before = await userStore.get(currentUser.id);
// @ts-ignore
expect(before.passwordHash).toBeFalsy();
await request await request
.post(`${base}/api/admin/user/change-password`) .post(`${base}/api/admin/user/change-password`)
.send({ password: owaspPassword, confirmPassword: owaspPassword }) .send({
password: owaspPassword,
confirmPassword: owaspPassword,
oldPassword,
})
.expect(200); .expect(200);
const updated = await userStore.get(currentUser.id); const updated = await userStore.get(currentUser.id);
// @ts-ignore // @ts-ignore
expect(updated.passwordHash).toBeTruthy(); expect(updated.passwordHash).toBeTruthy();
}); });
test('should not allow user to change password with incorrect old password', async () => {
const { request, base } = await getSetup();
await request
.post(`${base}/api/admin/user/change-password`)
.send({
password: owaspPassword,
confirmPassword: owaspPassword,
oldPassword: 'incorrect',
})
.expect(401);
});
test('should not allow user to change password without providing old password', async () => {
const { request, base } = await getSetup();
await request
.post(`${base}/api/admin/user/change-password`)
.send({
password: owaspPassword,
confirmPassword: owaspPassword,
})
.expect(400);
});
test('should deny if password and confirmPassword are not equal', async () => { test('should deny if password and confirmPassword are not equal', async () => {
expect.assertions(0); expect.assertions(0);
const { request, base } = await getSetup(); const { request, base } = await getSetup();

View File

@ -102,7 +102,8 @@ class UserController extends Controller {
requestBody: createRequestSchema('passwordSchema'), requestBody: createRequestSchema('passwordSchema'),
responses: { responses: {
200: emptyResponse, 200: emptyResponse,
400: { description: 'passwordMismatch' }, 400: { description: 'password mismatch' },
401: { description: 'incorrect old password' },
}, },
}), }),
], ],
@ -167,10 +168,14 @@ class UserController extends Controller {
res: Response, res: Response,
): Promise<void> { ): Promise<void> {
const { user } = req; const { user } = req;
const { password, confirmPassword } = req.body; const { password, confirmPassword, oldPassword } = req.body;
if (password === confirmPassword) { if (password === confirmPassword && oldPassword != null) {
this.userService.validatePassword(password); this.userService.validatePassword(password);
await this.userService.changePassword(user.id, password); await this.userService.changePasswordWithVerification(
user.id,
password,
oldPassword,
);
res.status(200).end(); res.status(200).end();
} else { } else {
res.status(400).end(); res.status(400).end();

View File

@ -362,6 +362,22 @@ class UserService {
await this.resetTokenService.expireExistingTokensForUser(userId); await this.resetTokenService.expireExistingTokensForUser(userId);
} }
async changePasswordWithVerification(
userId: number,
newPassword: string,
oldPassword: string,
): Promise<void> {
const currentPasswordHash = await this.store.getPasswordHash(userId);
const match = await bcrypt.compare(oldPassword, currentPasswordHash);
if (!match) {
throw new PasswordMismatch(
`The old password you provided is invalid. If you have forgotten your password, visit ${this.baseUriPath}/forgotten-password or get in touch with your instance administrator.`,
);
}
await this.changePassword(userId, newPassword);
}
async getUserForToken(token: string): Promise<TokenUserSchema> { async getUserForToken(token: string): Promise<TokenUserSchema> {
const { createdBy, userId } = await this.resetTokenService.isValid( const { createdBy, userId } = await this.resetTokenService.isValid(
token, token,

View File

@ -3146,6 +3146,9 @@ The provider you choose for your addon dictates what properties the \`parameters
"confirmPassword": { "confirmPassword": {
"type": "string", "type": "string",
}, },
"oldPassword": {
"type": "string",
},
"password": { "password": {
"type": "string", "type": "string",
}, },
@ -12784,7 +12787,10 @@ If the provided project does not exist, the list of events will be empty.",
"description": "This response has no body.", "description": "This response has no body.",
}, },
"400": { "400": {
"description": "passwordMismatch", "description": "password mismatch",
},
"401": {
"description": "incorrect old password",
}, },
}, },
"tags": [ "tags": [