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:
parent
ae1136075e
commit
5ec59c6e92
@ -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');
|
||||||
|
});
|
@ -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"
|
||||||
|
@ -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 = () => {
|
||||||
|
@ -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));
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -9,6 +9,9 @@ export const passwordSchema = {
|
|||||||
password: {
|
password: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
},
|
},
|
||||||
|
oldPassword: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
confirmPassword: {
|
confirmPassword: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
},
|
},
|
||||||
|
@ -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();
|
||||||
|
@ -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();
|
||||||
|
@ -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,
|
||||||
|
@ -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": [
|
||||||
|
Loading…
Reference in New Issue
Block a user