mirror of
https://github.com/Unleash/unleash.git
synced 2025-03-18 00:19:49 +01:00
feat: Disallow repeating last 5 passwords. (#7552)
We'll store hashes for the last 5 passwords, fetch them all for the user wanting to change their password, and make sure the password does not verify against any of the 5 stored hashes. Includes some password-related UI/UX improvements and refactors. Also some fixes related to reset password rate limiting (instead of an unhandled exception), and token expiration on error. --------- Co-authored-by: Nuno Góis <github@nunogois.com>
This commit is contained in:
parent
ef3ef877b3
commit
f65afff6c1
@ -14,6 +14,7 @@ import type { IUser } from 'interfaces/user';
|
|||||||
import useAdminUsersApi from 'hooks/api/actions/useAdminUsersApi/useAdminUsersApi';
|
import useAdminUsersApi from 'hooks/api/actions/useAdminUsersApi/useAdminUsersApi';
|
||||||
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
|
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
|
||||||
import useToast from 'hooks/useToast';
|
import useToast from 'hooks/useToast';
|
||||||
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
|
|
||||||
const StyledUserAvatar = styled(UserAvatar)(({ theme }) => ({
|
const StyledUserAvatar = styled(UserAvatar)(({ theme }) => ({
|
||||||
width: theme.spacing(5),
|
width: theme.spacing(5),
|
||||||
@ -37,7 +38,7 @@ const ChangePassword = ({
|
|||||||
const [validPassword, setValidPassword] = useState(false);
|
const [validPassword, setValidPassword] = useState(false);
|
||||||
const { classes: themeStyles } = useThemeStyles();
|
const { classes: themeStyles } = useThemeStyles();
|
||||||
const { changePassword } = useAdminUsersApi();
|
const { changePassword } = useAdminUsersApi();
|
||||||
const { setToastData } = useToast();
|
const { setToastData, setToastApiError } = useToast();
|
||||||
|
|
||||||
const updateField: React.ChangeEventHandler<HTMLInputElement> = (event) => {
|
const updateField: React.ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
@ -66,8 +67,9 @@ const ChangePassword = ({
|
|||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.warn(error);
|
const formattedError = formatUnknownError(error);
|
||||||
setError(PASSWORD_FORMAT_MESSAGE);
|
setError(formattedError);
|
||||||
|
setToastApiError(formattedError);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -134,7 +136,7 @@ const ChangePassword = ({
|
|||||||
/>
|
/>
|
||||||
<PasswordMatcher
|
<PasswordMatcher
|
||||||
started={Boolean(data.password && data.confirm)}
|
started={Boolean(data.password && data.confirm)}
|
||||||
matchingPasswords={data.password === data.confirm}
|
passwordsDoNotMatch={data.password !== data.confirm}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</Dialogue>
|
</Dialogue>
|
||||||
|
@ -21,7 +21,7 @@ export const NewUser = () => {
|
|||||||
const { authDetails } = useAuthDetails();
|
const { authDetails } = useAuthDetails();
|
||||||
const { setToastApiError } = useToast();
|
const { setToastApiError } = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [apiError, setApiError] = useState(false);
|
const [apiError, setApiError] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const {
|
const {
|
||||||
@ -61,10 +61,13 @@ export const NewUser = () => {
|
|||||||
if (res.status === OK) {
|
if (res.status === OK) {
|
||||||
navigate('/login?reset=true');
|
navigate('/login?reset=true');
|
||||||
} else {
|
} else {
|
||||||
setApiError(true);
|
setApiError(
|
||||||
|
'Something went wrong when attempting to update your password. This could be due to unstable internet connectivity. If retrying the request does not work, please try again later.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setApiError(true);
|
const error = formatUnknownError(e);
|
||||||
|
setApiError(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -199,8 +202,12 @@ export const NewUser = () => {
|
|||||||
Set a password for your account.
|
Set a password for your account.
|
||||||
</Typography>
|
</Typography>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={apiError && isValidToken}
|
condition={isValidToken}
|
||||||
show={<ResetPasswordError />}
|
show={
|
||||||
|
<ResetPasswordError>
|
||||||
|
{apiError}
|
||||||
|
</ResetPasswordError>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<ResetPasswordForm onSubmit={onSubmit} />
|
<ResetPasswordForm onSubmit={onSubmit} />
|
||||||
</>
|
</>
|
||||||
|
@ -1,36 +0,0 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
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(<PasswordTab />);
|
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
|
@ -34,10 +34,20 @@ export const PasswordTab = () => {
|
|||||||
const [oldPassword, setOldPassword] = useState('');
|
const [oldPassword, setOldPassword] = useState('');
|
||||||
const { changePassword } = usePasswordApi();
|
const { changePassword } = usePasswordApi();
|
||||||
|
|
||||||
|
const passwordsDoNotMatch = password !== confirmPassword;
|
||||||
|
const sameAsOldPassword = oldPassword === confirmPassword;
|
||||||
|
const allPasswordsFilled =
|
||||||
|
password.length > 0 &&
|
||||||
|
confirmPassword.length > 0 &&
|
||||||
|
oldPassword.length > 0;
|
||||||
|
|
||||||
|
const hasError =
|
||||||
|
!allPasswordsFilled || passwordsDoNotMatch || sameAsOldPassword;
|
||||||
|
|
||||||
const submit = async (e: SyntheticEvent) => {
|
const submit = async (e: SyntheticEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
if (hasError) {
|
||||||
return;
|
return;
|
||||||
} else if (!validPassword) {
|
} else if (!validPassword) {
|
||||||
setError(PASSWORD_FORMAT_MESSAGE);
|
setError(PASSWORD_FORMAT_MESSAGE);
|
||||||
@ -125,8 +135,9 @@ export const PasswordTab = () => {
|
|||||||
/>
|
/>
|
||||||
<PasswordMatcher
|
<PasswordMatcher
|
||||||
data-loading
|
data-loading
|
||||||
started={Boolean(password && confirmPassword)}
|
started={allPasswordsFilled}
|
||||||
matchingPasswords={password === confirmPassword}
|
passwordsDoNotMatch={passwordsDoNotMatch}
|
||||||
|
sameAsOldPassword={sameAsOldPassword}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
data-loading
|
data-loading
|
||||||
@ -134,6 +145,7 @@ export const PasswordTab = () => {
|
|||||||
color='primary'
|
color='primary'
|
||||||
type='submit'
|
type='submit'
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
|
disabled={hasError}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -11,6 +11,7 @@ import ResetPasswordForm from '../common/ResetPasswordForm/ResetPasswordForm';
|
|||||||
import ResetPasswordError from '../common/ResetPasswordError/ResetPasswordError';
|
import ResetPasswordError from '../common/ResetPasswordError/ResetPasswordError';
|
||||||
import { useAuthResetPasswordApi } from 'hooks/api/actions/useAuthResetPasswordApi/useAuthResetPasswordApi';
|
import { useAuthResetPasswordApi } from 'hooks/api/actions/useAuthResetPasswordApi/useAuthResetPasswordApi';
|
||||||
import { useAuthDetails } from 'hooks/api/getters/useAuth/useAuthDetails';
|
import { useAuthDetails } from 'hooks/api/getters/useAuth/useAuthDetails';
|
||||||
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
|
|
||||||
const StyledDiv = styled('div')(({ theme }) => ({
|
const StyledDiv = styled('div')(({ theme }) => ({
|
||||||
width: '350px',
|
width: '350px',
|
||||||
@ -32,7 +33,7 @@ const ResetPassword = () => {
|
|||||||
const { authDetails } = useAuthDetails();
|
const { authDetails } = useAuthDetails();
|
||||||
const ref = useLoading(loading || actionLoading);
|
const ref = useLoading(loading || actionLoading);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [hasApiError, setHasApiError] = useState(false);
|
const [apiError, setApiError] = useState('');
|
||||||
const passwordDisabled = authDetails?.defaultHidden === true;
|
const passwordDisabled = authDetails?.defaultHidden === true;
|
||||||
|
|
||||||
const onSubmit = async (password: string) => {
|
const onSubmit = async (password: string) => {
|
||||||
@ -40,12 +41,15 @@ const ResetPassword = () => {
|
|||||||
const res = await resetPassword({ token, password });
|
const res = await resetPassword({ token, password });
|
||||||
if (res.status === OK) {
|
if (res.status === OK) {
|
||||||
navigate('/login?reset=true');
|
navigate('/login?reset=true');
|
||||||
setHasApiError(false);
|
setApiError('');
|
||||||
} else {
|
} else {
|
||||||
setHasApiError(true);
|
setApiError(
|
||||||
|
'Something went wrong when attempting to update your password. This could be due to unstable internet connectivity. If retrying the request does not work, please try again later.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setHasApiError(true);
|
const error = formatUnknownError(e);
|
||||||
|
setApiError(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -62,10 +66,9 @@ const ResetPassword = () => {
|
|||||||
Reset password
|
Reset password
|
||||||
</StyledTypography>
|
</StyledTypography>
|
||||||
|
|
||||||
<ConditionallyRender
|
<ResetPasswordError>
|
||||||
condition={hasApiError}
|
{apiError}
|
||||||
show={<ResetPasswordError />}
|
</ResetPasswordError>
|
||||||
/>
|
|
||||||
<ResetPasswordForm onSubmit={onSubmit} />
|
<ResetPasswordForm onSubmit={onSubmit} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,16 @@
|
|||||||
import { Alert, AlertTitle } from '@mui/material';
|
import { Alert, AlertTitle } from '@mui/material';
|
||||||
|
|
||||||
const ResetPasswordError = () => {
|
interface IResetPasswordErrorProps {
|
||||||
|
children: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResetPasswordError = ({ children }: IResetPasswordErrorProps) => {
|
||||||
|
if (!children) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert severity='error' data-loading>
|
<Alert severity='error' data-loading>
|
||||||
<AlertTitle>Unable to reset password</AlertTitle>
|
<AlertTitle>Unable to reset password</AlertTitle>
|
||||||
Something went wrong when attempting to update your password. This
|
{children}
|
||||||
could be due to unstable internet connectivity. If retrying the
|
|
||||||
request does not work, please try again later.
|
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,59 +1,55 @@
|
|||||||
import { styled, Typography } from '@mui/material';
|
import { styled } from '@mui/material';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import CheckIcon from '@mui/icons-material/Check';
|
import CheckIcon from '@mui/icons-material/Check';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
|
||||||
interface IPasswordMatcherProps {
|
interface IPasswordMatcherProps {
|
||||||
started: boolean;
|
started: boolean;
|
||||||
matchingPasswords: boolean;
|
passwordsDoNotMatch: boolean;
|
||||||
|
sameAsOldPassword?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledMatcherContainer = styled('div')(({ theme }) => ({
|
const StyledMatcher = styled('div', {
|
||||||
position: 'relative',
|
shouldForwardProp: (prop) => prop !== 'error',
|
||||||
paddingTop: theme.spacing(0.5),
|
})<{ error: boolean }>(({ theme, error }) => ({
|
||||||
}));
|
|
||||||
|
|
||||||
const StyledMatcher = styled(Typography, {
|
|
||||||
shouldForwardProp: (prop) => prop !== 'matchingPasswords',
|
|
||||||
})<{ matchingPasswords: boolean }>(({ theme, matchingPasswords }) => ({
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: '-8px',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
color: matchingPasswords
|
lineHeight: 1,
|
||||||
? theme.palette.primary.main
|
color: error ? theme.palette.error.main : theme.palette.primary.main,
|
||||||
: theme.palette.error.main,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const StyledMatcherCheckIcon = styled(CheckIcon)(({ theme }) => ({
|
const StyledMatcherCheckIcon = styled(CheckIcon)({
|
||||||
marginRight: '5px',
|
marginRight: '5px',
|
||||||
}));
|
});
|
||||||
|
|
||||||
|
const StyledMatcherErrorIcon = styled(CloseIcon)({
|
||||||
|
marginRight: '5px',
|
||||||
|
});
|
||||||
|
|
||||||
const PasswordMatcher = ({
|
const PasswordMatcher = ({
|
||||||
started,
|
started,
|
||||||
matchingPasswords,
|
passwordsDoNotMatch,
|
||||||
|
sameAsOldPassword = false,
|
||||||
}: IPasswordMatcherProps) => {
|
}: IPasswordMatcherProps) => {
|
||||||
|
const error = passwordsDoNotMatch || sameAsOldPassword;
|
||||||
|
|
||||||
|
if (!started) return null;
|
||||||
|
|
||||||
|
const label = passwordsDoNotMatch
|
||||||
|
? 'Passwords do not match'
|
||||||
|
: sameAsOldPassword
|
||||||
|
? 'Cannot be the same as the old password'
|
||||||
|
: 'Passwords match';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledMatcherContainer>
|
<StyledMatcher data-loading error={error}>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={started}
|
condition={error}
|
||||||
show={
|
show={<StyledMatcherErrorIcon />}
|
||||||
<StyledMatcher
|
elseShow={<StyledMatcherCheckIcon />}
|
||||||
variant='body2'
|
/>{' '}
|
||||||
data-loading
|
<span>{label}</span>
|
||||||
matchingPasswords={matchingPasswords}
|
|
||||||
>
|
|
||||||
<StyledMatcherCheckIcon />{' '}
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={matchingPasswords}
|
|
||||||
show={<Typography> Passwords match</Typography>}
|
|
||||||
elseShow={
|
|
||||||
<Typography> Passwords do not match</Typography>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</StyledMatcher>
|
</StyledMatcher>
|
||||||
}
|
|
||||||
/>
|
|
||||||
</StyledMatcherContainer>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -95,7 +95,7 @@ const ResetPasswordForm = ({ onSubmit }: IResetPasswordProps) => {
|
|||||||
|
|
||||||
<PasswordMatcher
|
<PasswordMatcher
|
||||||
started={started}
|
started={started}
|
||||||
matchingPasswords={matchingPasswords}
|
passwordsDoNotMatch={!matchingPasswords}
|
||||||
/>
|
/>
|
||||||
<StyledButton
|
<StyledButton
|
||||||
variant='contained'
|
variant='contained'
|
||||||
|
@ -13,6 +13,7 @@ import type {
|
|||||||
import type { Db } from './db';
|
import type { Db } from './db';
|
||||||
|
|
||||||
const TABLE = 'users';
|
const TABLE = 'users';
|
||||||
|
const PASSWORD_HASH_TABLE = 'used_passwords';
|
||||||
|
|
||||||
const USER_COLUMNS_PUBLIC = [
|
const USER_COLUMNS_PUBLIC = [
|
||||||
'id',
|
'id',
|
||||||
@ -25,6 +26,8 @@ const USER_COLUMNS_PUBLIC = [
|
|||||||
'scim_id',
|
'scim_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const USED_PASSWORDS = ['user_id', 'password_hash', 'used_at'];
|
||||||
|
|
||||||
const USER_COLUMNS = [...USER_COLUMNS_PUBLIC, 'login_attempts', 'created_at'];
|
const USER_COLUMNS = [...USER_COLUMNS_PUBLIC, 'login_attempts', 'created_at'];
|
||||||
|
|
||||||
const emptify = (value) => {
|
const emptify = (value) => {
|
||||||
@ -71,6 +74,30 @@ class UserStore implements IUserStore {
|
|||||||
this.logger = getLogger('user-store.ts');
|
this.logger = getLogger('user-store.ts');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPasswordsPreviouslyUsed(userId: number): Promise<string[]> {
|
||||||
|
const previouslyUsedPasswords = await this.db(PASSWORD_HASH_TABLE)
|
||||||
|
.select('password_hash')
|
||||||
|
.where({ user_id: userId });
|
||||||
|
return previouslyUsedPasswords.map((row) => row.password_hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePasswordsUsedMoreThanNTimesAgo(
|
||||||
|
userId: number,
|
||||||
|
keepLastN: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.db.raw(
|
||||||
|
`
|
||||||
|
WITH UserPasswords AS (
|
||||||
|
SELECT user_id, password_hash, used_at, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY used_at DESC) AS rn
|
||||||
|
FROM ${PASSWORD_HASH_TABLE}
|
||||||
|
WHERE user_id = ?)
|
||||||
|
DELETE FROM ${PASSWORD_HASH_TABLE} WHERE user_id = ? AND (user_id, password_hash, used_at) NOT IN (SELECT user_id, password_hash, used_at FROM UserPasswords WHERE rn <= ?
|
||||||
|
);
|
||||||
|
`,
|
||||||
|
[userId, userId, keepLastN],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async update(id: number, fields: IUserUpdateFields): Promise<User> {
|
async update(id: number, fields: IUserUpdateFields): Promise<User> {
|
||||||
await this.activeUsers()
|
await this.activeUsers()
|
||||||
.where('id', id)
|
.where('id', id)
|
||||||
@ -177,10 +204,25 @@ class UserStore implements IUserStore {
|
|||||||
return item.password_hash;
|
return item.password_hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setPasswordHash(userId: number, passwordHash: string): Promise<void> {
|
async setPasswordHash(
|
||||||
return this.activeUsers().where('id', userId).update({
|
userId: number,
|
||||||
|
passwordHash: string,
|
||||||
|
disallowNPreviousPasswords: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.activeUsers().where('id', userId).update({
|
||||||
password_hash: passwordHash,
|
password_hash: passwordHash,
|
||||||
});
|
});
|
||||||
|
// We apparently set this to null, but you should be allowed to have null, so need to allow this
|
||||||
|
if (passwordHash) {
|
||||||
|
await this.db(PASSWORD_HASH_TABLE).insert({
|
||||||
|
user_id: userId,
|
||||||
|
password_hash: passwordHash,
|
||||||
|
});
|
||||||
|
await this.deletePasswordsUsedMoreThanNTimesAgo(
|
||||||
|
userId,
|
||||||
|
disallowNPreviousPasswords,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async incLoginAttempts(user: User): Promise<void> {
|
async incLoginAttempts(user: User): Promise<void> {
|
||||||
|
11
src/lib/error/password-previously-used.ts
Normal file
11
src/lib/error/password-previously-used.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { UnleashError } from './unleash-error';
|
||||||
|
|
||||||
|
export class PasswordPreviouslyUsedError extends UnleashError {
|
||||||
|
statusCode = 400;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
message: string = `You've previously used this password. Please use a new password.`,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
11
src/lib/error/rate-limit-error.ts
Normal file
11
src/lib/error/rate-limit-error.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { UnleashError } from './unleash-error';
|
||||||
|
|
||||||
|
export class RateLimitError extends UnleashError {
|
||||||
|
statusCode = 429;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
message: string = `We're currently receiving too much traffic. Please try again later.`,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
@ -29,6 +29,8 @@ export const UnleashApiErrorTypes = [
|
|||||||
'OwaspValidationError',
|
'OwaspValidationError',
|
||||||
'ForbiddenError',
|
'ForbiddenError',
|
||||||
'ExceedsLimitError',
|
'ExceedsLimitError',
|
||||||
|
'PasswordPreviouslyUsedError',
|
||||||
|
'RateLimitError',
|
||||||
// server errors; not the end user's fault
|
// server errors; not the end user's fault
|
||||||
'InternalError',
|
'InternalError',
|
||||||
] as const;
|
] as const;
|
||||||
|
@ -17,6 +17,7 @@ async function getSetup() {
|
|||||||
await stores.userStore.setPasswordHash(
|
await stores.userStore.setPasswordHash(
|
||||||
currentUser.id,
|
currentUser.id,
|
||||||
await bcrypt.hash(oldPassword, 10),
|
await bcrypt.hash(oldPassword, 10),
|
||||||
|
5,
|
||||||
);
|
);
|
||||||
|
|
||||||
const config = createTestConfig({
|
const config = createTestConfig({
|
||||||
@ -53,7 +54,6 @@ 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(1);
|
|
||||||
const { request, base, userStore } = await getSetup();
|
const { request, base, userStore } = await getSetup();
|
||||||
await request
|
await request
|
||||||
.post(`${base}/api/admin/user/change-password`)
|
.post(`${base}/api/admin/user/change-password`)
|
||||||
|
@ -549,7 +549,9 @@ test('Should throttle password reset email', async () => {
|
|||||||
await expect(attempt1).resolves.toBeInstanceOf(URL);
|
await expect(attempt1).resolves.toBeInstanceOf(URL);
|
||||||
|
|
||||||
const attempt2 = service.createResetPasswordEmail('known@example.com');
|
const attempt2 = service.createResetPasswordEmail('known@example.com');
|
||||||
await expect(attempt2).resolves.toBe(undefined);
|
await expect(attempt2).rejects.toThrow(
|
||||||
|
'You can only send one new reset password email per minute, per user. Please try again later.',
|
||||||
|
);
|
||||||
|
|
||||||
jest.runAllTimers();
|
jest.runAllTimers();
|
||||||
|
|
||||||
|
@ -12,7 +12,6 @@ import User, {
|
|||||||
import isEmail from '../util/is-email';
|
import isEmail from '../util/is-email';
|
||||||
import type { AccessService } from './access-service';
|
import type { AccessService } from './access-service';
|
||||||
import type ResetTokenService from './reset-token-service';
|
import type ResetTokenService from './reset-token-service';
|
||||||
import InvalidTokenError from '../error/invalid-token-error';
|
|
||||||
import NotFoundError from '../error/notfound-error';
|
import NotFoundError from '../error/notfound-error';
|
||||||
import OwaspValidationError from '../error/owasp-validation-error';
|
import OwaspValidationError from '../error/owasp-validation-error';
|
||||||
import type { EmailService } from './email-service';
|
import type { EmailService } from './email-service';
|
||||||
@ -38,6 +37,8 @@ import PasswordMismatch from '../error/password-mismatch';
|
|||||||
import type EventService from '../features/events/event-service';
|
import type EventService from '../features/events/event-service';
|
||||||
|
|
||||||
import { SYSTEM_USER, SYSTEM_USER_AUDIT } from '../types';
|
import { SYSTEM_USER, SYSTEM_USER_AUDIT } from '../types';
|
||||||
|
import { PasswordPreviouslyUsedError } from '../error/password-previously-used';
|
||||||
|
import { RateLimitError } from '../error/rate-limit-error';
|
||||||
|
|
||||||
export interface ICreateUser {
|
export interface ICreateUser {
|
||||||
name?: string;
|
name?: string;
|
||||||
@ -62,6 +63,7 @@ export interface ILoginUserRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const saltRounds = 10;
|
const saltRounds = 10;
|
||||||
|
const disallowNPreviousPasswords = 5;
|
||||||
|
|
||||||
class UserService {
|
class UserService {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
@ -164,7 +166,11 @@ class UserService {
|
|||||||
username,
|
username,
|
||||||
});
|
});
|
||||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||||
await this.store.setPasswordHash(user.id, passwordHash);
|
await this.store.setPasswordHash(
|
||||||
|
user.id,
|
||||||
|
passwordHash,
|
||||||
|
disallowNPreviousPasswords,
|
||||||
|
);
|
||||||
await this.accessService.setUserRootRole(
|
await this.accessService.setUserRootRole(
|
||||||
user.id,
|
user.id,
|
||||||
RoleName.ADMIN,
|
RoleName.ADMIN,
|
||||||
@ -232,7 +238,11 @@ class UserService {
|
|||||||
|
|
||||||
if (password) {
|
if (password) {
|
||||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||||
await this.store.setPasswordHash(user.id, passwordHash);
|
await this.store.setPasswordHash(
|
||||||
|
user.id,
|
||||||
|
passwordHash,
|
||||||
|
disallowNPreviousPasswords,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userCreated = await this.getUser(user.id);
|
const userCreated = await this.getUser(user.id);
|
||||||
@ -388,11 +398,32 @@ class UserService {
|
|||||||
async changePassword(userId: number, password: string): Promise<void> {
|
async changePassword(userId: number, password: string): Promise<void> {
|
||||||
this.validatePassword(password);
|
this.validatePassword(password);
|
||||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||||
await this.store.setPasswordHash(userId, passwordHash);
|
|
||||||
|
await this.store.setPasswordHash(
|
||||||
|
userId,
|
||||||
|
passwordHash,
|
||||||
|
disallowNPreviousPasswords,
|
||||||
|
);
|
||||||
await this.sessionService.deleteSessionsForUser(userId);
|
await this.sessionService.deleteSessionsForUser(userId);
|
||||||
await this.resetTokenService.expireExistingTokensForUser(userId);
|
await this.resetTokenService.expireExistingTokensForUser(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async changePasswordWithPreviouslyUsedPasswordCheck(
|
||||||
|
userId: number,
|
||||||
|
password: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const previouslyUsed =
|
||||||
|
await this.store.getPasswordsPreviouslyUsed(userId);
|
||||||
|
const usedBefore = previouslyUsed.some((previouslyUsed) =>
|
||||||
|
bcrypt.compareSync(password, previouslyUsed),
|
||||||
|
);
|
||||||
|
if (usedBefore) {
|
||||||
|
throw new PasswordPreviouslyUsedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.changePassword(userId, password);
|
||||||
|
}
|
||||||
|
|
||||||
async changePasswordWithVerification(
|
async changePasswordWithVerification(
|
||||||
userId: number,
|
userId: number,
|
||||||
newPassword: string,
|
newPassword: string,
|
||||||
@ -406,7 +437,10 @@ class UserService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.changePassword(userId, newPassword);
|
await this.changePasswordWithPreviouslyUsedPasswordCheck(
|
||||||
|
userId,
|
||||||
|
newPassword,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserForToken(token: string): Promise<TokenUserSchema> {
|
async getUserForToken(token: string): Promise<TokenUserSchema> {
|
||||||
@ -437,15 +471,16 @@ class UserService {
|
|||||||
async resetPassword(token: string, password: string): Promise<void> {
|
async resetPassword(token: string, password: string): Promise<void> {
|
||||||
this.validatePassword(password);
|
this.validatePassword(password);
|
||||||
const user = await this.getUserForToken(token);
|
const user = await this.getUserForToken(token);
|
||||||
const allowed = await this.resetTokenService.useAccessToken({
|
|
||||||
|
await this.changePasswordWithPreviouslyUsedPasswordCheck(
|
||||||
|
user.id,
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.resetTokenService.useAccessToken({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
token,
|
token,
|
||||||
});
|
});
|
||||||
if (allowed) {
|
|
||||||
await this.changePassword(user.id, password);
|
|
||||||
} else {
|
|
||||||
throw new InvalidTokenError();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createResetPasswordEmail(
|
async createResetPasswordEmail(
|
||||||
@ -460,7 +495,9 @@ class UserService {
|
|||||||
throw new NotFoundError(`Could not find ${receiverEmail}`);
|
throw new NotFoundError(`Could not find ${receiverEmail}`);
|
||||||
}
|
}
|
||||||
if (this.passwordResetTimeouts[receiver.id]) {
|
if (this.passwordResetTimeouts[receiver.id]) {
|
||||||
return;
|
throw new RateLimitError(
|
||||||
|
'You can only send one new reset password email per minute, per user. Please try again later.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetLink = await this.resetTokenService.createResetPasswordUrl(
|
const resetLink = await this.resetTokenService.createResetPasswordUrl(
|
||||||
|
@ -28,7 +28,12 @@ export interface IUserStore extends Store<IUser, number> {
|
|||||||
getAllWithId(userIdList: number[]): Promise<IUser[]>;
|
getAllWithId(userIdList: number[]): Promise<IUser[]>;
|
||||||
getByQuery(idQuery: IUserLookup): Promise<IUser>;
|
getByQuery(idQuery: IUserLookup): Promise<IUser>;
|
||||||
getPasswordHash(userId: number): Promise<string>;
|
getPasswordHash(userId: number): Promise<string>;
|
||||||
setPasswordHash(userId: number, passwordHash: string): Promise<void>;
|
setPasswordHash(
|
||||||
|
userId: number,
|
||||||
|
passwordHash: string,
|
||||||
|
disallowNPreviousPasswords: number,
|
||||||
|
): Promise<void>;
|
||||||
|
getPasswordsPreviouslyUsed(userId: number): Promise<string[]>;
|
||||||
incLoginAttempts(user: IUser): Promise<void>;
|
incLoginAttempts(user: IUser): Promise<void>;
|
||||||
successfullyLogin(user: IUser): Promise<void>;
|
successfullyLogin(user: IUser): Promise<void>;
|
||||||
count(): Promise<number>;
|
count(): Promise<number>;
|
||||||
|
15
src/migrations/20240705111827-used-passwords-table.js
Normal file
15
src/migrations/20240705111827-used-passwords-table.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
exports.up = function(db, cb) {
|
||||||
|
db.runSql(`
|
||||||
|
CREATE TABLE used_passwords(user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
used_at TIMESTAMP WITH TIME ZONE DEFAULT (now() AT time zone 'utc'),
|
||||||
|
PRIMARY KEY (user_id, password_hash)
|
||||||
|
);
|
||||||
|
INSERT INTO used_passwords(user_id, password_hash) SELECT id, password_hash FROM users WHERE password_hash IS NOT NULL;
|
||||||
|
CREATE INDEX used_passwords_pw_hash_idx ON used_passwords(password_hash);
|
||||||
|
`, cb)
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function(db, cb) {
|
||||||
|
db.runSql(`DROP TABLE used_passwords;`, cb);
|
||||||
|
};
|
@ -13,6 +13,7 @@ function mergeAll<T>(objects: Partial<T>[]): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createTestConfig(config?: IUnleashOptions): IUnleashConfig {
|
export function createTestConfig(config?: IUnleashOptions): IUnleashConfig {
|
||||||
|
getLogger.setMuteError(true);
|
||||||
const testConfig: IUnleashOptions = {
|
const testConfig: IUnleashOptions = {
|
||||||
getLogger,
|
getLogger,
|
||||||
authentication: { type: IAuthType.NONE, createAdminUser: false },
|
authentication: { type: IAuthType.NONE, createAdminUser: false },
|
||||||
|
@ -177,14 +177,14 @@ test('Trying to reset password with same token twice does not work', async () =>
|
|||||||
.post('/auth/reset/password')
|
.post('/auth/reset/password')
|
||||||
.send({
|
.send({
|
||||||
token,
|
token,
|
||||||
password,
|
password: `${password}test`,
|
||||||
})
|
})
|
||||||
.expect(200);
|
.expect(200);
|
||||||
await app.request
|
await app.request
|
||||||
.post('/auth/reset/password')
|
.post('/auth/reset/password')
|
||||||
.send({
|
.send({
|
||||||
token,
|
token,
|
||||||
password,
|
password: `${password}othertest`,
|
||||||
})
|
})
|
||||||
.expect(401)
|
.expect(401)
|
||||||
.expect((res) => {
|
.expect((res) => {
|
||||||
@ -245,7 +245,7 @@ test('Calling reset endpoint with already existing session should logout/destroy
|
|||||||
.post('/auth/reset/password')
|
.post('/auth/reset/password')
|
||||||
.send({
|
.send({
|
||||||
token,
|
token,
|
||||||
password,
|
password: `${password}newpassword`,
|
||||||
})
|
})
|
||||||
.expect(200);
|
.expect(200);
|
||||||
await request.get('/api/admin/projects').expect(401); // we no longer have a valid session after using the reset password endpoint
|
await request.get('/api/admin/projects').expect(401); // we no longer have a valid session after using the reset password endpoint
|
||||||
|
@ -27,6 +27,7 @@ import {
|
|||||||
USER_UPDATED,
|
USER_UPDATED,
|
||||||
} from '../../../lib/types';
|
} from '../../../lib/types';
|
||||||
import { CUSTOM_ROOT_ROLE_TYPE } from '../../../lib/util';
|
import { CUSTOM_ROOT_ROLE_TYPE } from '../../../lib/util';
|
||||||
|
import { PasswordPreviouslyUsedError } from '../../../lib/error/password-previously-used';
|
||||||
|
|
||||||
let db: ITestDb;
|
let db: ITestDb;
|
||||||
let stores: IUnleashStores;
|
let stores: IUnleashStores;
|
||||||
@ -511,3 +512,77 @@ test('should support a custom root role id when logging in and creating user via
|
|||||||
expect(permissions).toHaveLength(1);
|
expect(permissions).toHaveLength(1);
|
||||||
expect(permissions[0].permission).toBe(CREATE_ADDON);
|
expect(permissions[0].permission).toBe(CREATE_ADDON);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Should not be able to use any of previous 5 passwords', () => {
|
||||||
|
test('throws exception when trying to use a previously used password', async () => {
|
||||||
|
const name = 'same-password-is-not-allowed';
|
||||||
|
const email = `${name}@test.com`;
|
||||||
|
const password = 'externalScreaming$123';
|
||||||
|
const user = await userService.createUser({
|
||||||
|
email,
|
||||||
|
rootRole: customRole.id,
|
||||||
|
name,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
userService.changePasswordWithPreviouslyUsedPasswordCheck(
|
||||||
|
user.id,
|
||||||
|
password,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(new PasswordPreviouslyUsedError());
|
||||||
|
});
|
||||||
|
test('Is still able to change password to one not used', async () => {
|
||||||
|
const name = 'new-password-is-allowed';
|
||||||
|
const email = `${name}@test.com`;
|
||||||
|
const password = 'externalScreaming$123';
|
||||||
|
const user = await userService.createUser({
|
||||||
|
email,
|
||||||
|
rootRole: customRole.id,
|
||||||
|
name,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
userService.changePasswordWithPreviouslyUsedPasswordCheck(
|
||||||
|
user.id,
|
||||||
|
'internalScreaming$123',
|
||||||
|
),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
test('Remembers 5 passwords', async () => {
|
||||||
|
const name = 'remembers-5-passwords-like-a-boss';
|
||||||
|
const email = `${name}@test.com`;
|
||||||
|
const password = 'externalScreaming$123';
|
||||||
|
const user = await userService.createUser({
|
||||||
|
email,
|
||||||
|
rootRole: customRole.id,
|
||||||
|
name,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await userService.changePasswordWithPreviouslyUsedPasswordCheck(
|
||||||
|
user.id,
|
||||||
|
`${password}${i}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await expect(
|
||||||
|
userService.changePasswordWithPreviouslyUsedPasswordCheck(
|
||||||
|
user.id,
|
||||||
|
`${password}`,
|
||||||
|
),
|
||||||
|
).resolves.not.toThrow(); // We've added 5 new passwords, so the original should work again
|
||||||
|
});
|
||||||
|
test('Can bypass check by directly calling the changePassword method', async () => {
|
||||||
|
const name = 'can-bypass-check-like-a-boss';
|
||||||
|
const email = `${name}@test.com`;
|
||||||
|
const password = 'externalScreaming$123';
|
||||||
|
const user = await userService.createUser({
|
||||||
|
email,
|
||||||
|
rootRole: customRole.id,
|
||||||
|
name,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
userService.changePassword(user.id, `${password}`),
|
||||||
|
).resolves.not.toThrow(); // By bypassing the check, we can still set the same password as currently set
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -60,7 +60,7 @@ test('Should require email or username', async () => {
|
|||||||
test('should set password_hash for user', async () => {
|
test('should set password_hash for user', async () => {
|
||||||
const store = stores.userStore;
|
const store = stores.userStore;
|
||||||
const user = await store.insert({ email: 'admin@mail.com' });
|
const user = await store.insert({ email: 'admin@mail.com' });
|
||||||
await store.setPasswordHash(user.id, 'rubbish');
|
await store.setPasswordHash(user.id, 'rubbish', 5);
|
||||||
const hash = await store.getPasswordHash(user.id);
|
const hash = await store.getPasswordHash(user.id);
|
||||||
|
|
||||||
expect(hash).toBe('rubbish');
|
expect(hash).toBe('rubbish');
|
||||||
|
21
src/test/fixtures/fake-user-store.ts
vendored
21
src/test/fixtures/fake-user-store.ts
vendored
@ -8,12 +8,17 @@ import type {
|
|||||||
|
|
||||||
class UserStoreMock implements IUserStore {
|
class UserStoreMock implements IUserStore {
|
||||||
data: IUser[];
|
data: IUser[];
|
||||||
|
previousPasswords: Map<number, string[]>;
|
||||||
idSeq: number;
|
idSeq: number;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.idSeq = 1;
|
this.idSeq = 1;
|
||||||
this.data = [];
|
this.data = [];
|
||||||
|
this.previousPasswords = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
getPasswordsPreviouslyUsed(userId: number): Promise<string[]> {
|
||||||
|
return Promise.resolve(this.previousPasswords.get(userId) || []);
|
||||||
}
|
}
|
||||||
countServiceAccounts(): Promise<number> {
|
countServiceAccounts(): Promise<number> {
|
||||||
return Promise.resolve(0);
|
return Promise.resolve(0);
|
||||||
@ -47,7 +52,7 @@ class UserStoreMock implements IUserStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async get(key: number): Promise<IUser> {
|
async get(key: number): Promise<IUser> {
|
||||||
return this.data.find((u) => u.id === key);
|
return this.data.find((u) => u.id === key)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
async insert(user: User): Promise<User> {
|
async insert(user: User): Promise<User> {
|
||||||
@ -86,6 +91,9 @@ class UserStoreMock implements IUserStore {
|
|||||||
const u = this.data.find((a) => a.id === userId);
|
const u = this.data.find((a) => a.id === userId);
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
u.passwordHash = passwordHash;
|
u.passwordHash = passwordHash;
|
||||||
|
const previousPasswords = this.previousPasswords.get(userId) || [];
|
||||||
|
previousPasswords.push(passwordHash);
|
||||||
|
this.previousPasswords.set(userId, previousPasswords.slice(1, 6));
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,7 +140,7 @@ class UserStoreMock implements IUserStore {
|
|||||||
|
|
||||||
upsert(user: ICreateUser): Promise<IUser> {
|
upsert(user: ICreateUser): Promise<IUser> {
|
||||||
this.data.splice(this.data.findIndex((u) => u.email === user.email));
|
this.data.splice(this.data.findIndex((u) => u.email === user.email));
|
||||||
this.data.push({
|
const userToReturn = {
|
||||||
id: this.data.length + 1,
|
id: this.data.length + 1,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
isAPI: false,
|
isAPI: false,
|
||||||
@ -143,13 +151,14 @@ class UserStoreMock implements IUserStore {
|
|||||||
username: user.username ?? '',
|
username: user.username ?? '',
|
||||||
email: user.email ?? '',
|
email: user.email ?? '',
|
||||||
...user,
|
...user,
|
||||||
});
|
};
|
||||||
return Promise.resolve(undefined);
|
this.data.push(userToReturn);
|
||||||
|
return Promise.resolve(userToReturn);
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
getUserByPersonalAccessToken(secret: string): Promise<IUser> {
|
getUserByPersonalAccessToken(secret: string): Promise<IUser> {
|
||||||
return Promise.resolve(undefined);
|
throw new Error('Not implemented');
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
Loading…
Reference in New Issue
Block a user