Skip to content

Commit

Permalink
feat(clerk-js,localizations,shared,types): Prompt user to reset pwned…
Browse files Browse the repository at this point in the history
… password at sign-in (#3034)
  • Loading branch information
yourtallness committed Mar 29, 2024
1 parent 9879949 commit fc3ffd8
Show file tree
Hide file tree
Showing 38 changed files with 1,124 additions and 63 deletions.
8 changes: 8 additions & 0 deletions .changeset/flat-geese-applaud.md
@@ -0,0 +1,8 @@
---
'@clerk/localizations': minor
'@clerk/clerk-js': minor
'@clerk/shared': minor
'@clerk/types': minor
---

Support for prompting a user to reset their password if it is found to be compromised during sign-in.
68 changes: 53 additions & 15 deletions packages/clerk-js/src/ui/components/SignIn/AlternativeMethods.tsx
Expand Up @@ -12,11 +12,13 @@ import { SignInSocialButtons } from './SignInSocialButtons';
import { useResetPasswordFactor } from './useResetPasswordFactor';
import { withHavingTrouble } from './withHavingTrouble';

type AlternativeMethodsMode = 'forgot' | 'pwned' | 'default';

export type AlternativeMethodsProps = {
onBackLinkClick: React.MouseEventHandler | undefined;
onFactorSelected: (factor: SignInFactor) => void;
currentFactor: SignInFactor | undefined | null;
asForgotPassword?: boolean;
mode?: AlternativeMethodsMode;
};

export type AlternativeMethodListProps = AlternativeMethodsProps & { onHavingTroubleClick: React.MouseEventHandler };
Expand All @@ -28,26 +30,24 @@ export const AlternativeMethods = (props: AlternativeMethodsProps) => {
};

const AlternativeMethodsList = (props: AlternativeMethodListProps) => {
const { onBackLinkClick, onHavingTroubleClick, onFactorSelected, asForgotPassword = false } = props;
const { onBackLinkClick, onHavingTroubleClick, onFactorSelected, mode = 'default' } = props;
const card = useCardState();
const resetPasswordFactor = useResetPasswordFactor();
const { firstPartyFactors, hasAnyStrategy } = useAlternativeStrategies({
filterOutFactor: props?.currentFactor,
});

const flowPart = determineFlowPart(mode);
const cardTitleKey = determineTitle(mode);
const isReset = determineIsReset(mode);

return (
<Flow.Part part={asForgotPassword ? 'forgotPasswordMethods' : 'alternativeMethods'}>
<Flow.Part part={flowPart}>
<Card.Root>
<Card.Content>
<Header.Root showLogo>
<Header.Title
localizationKey={localizationKeys(
asForgotPassword ? 'signIn.forgotPasswordAlternativeMethods.title' : 'signIn.alternativeMethods.title',
)}
/>
{!asForgotPassword && (
<Header.Subtitle localizationKey={localizationKeys('signIn.alternativeMethods.subtitle')} />
)}
<Header.Title localizationKey={cardTitleKey} />
{!isReset && <Header.Subtitle localizationKey={localizationKeys('signIn.alternativeMethods.subtitle')} />}
</Header.Root>
<Card.Alert>{card.error}</Card.Alert>
{/*TODO: extract main in its own component */}
Expand All @@ -56,15 +56,18 @@ const AlternativeMethodsList = (props: AlternativeMethodListProps) => {
elementDescriptor={descriptors.main}
gap={6}
>
{asForgotPassword && resetPasswordFactor && (
{isReset && resetPasswordFactor && (
<Button
localizationKey={getButtonLabel(resetPasswordFactor)}
elementDescriptor={descriptors.alternativeMethodsBlockButton}
isDisabled={card.isLoading}
onClick={() => onFactorSelected(resetPasswordFactor)}
onClick={() => {
card.setError(undefined);
onFactorSelected(resetPasswordFactor);
}}
/>
)}
{asForgotPassword && hasAnyStrategy && (
{isReset && hasAnyStrategy && (
<Divider
dividerText={localizationKeys('signIn.forgotPasswordAlternativeMethods.label__alternativeMethods')}
/>
Expand All @@ -90,7 +93,10 @@ const AlternativeMethodsList = (props: AlternativeMethodListProps) => {
key={i}
textVariant='buttonLarge'
isDisabled={card.isLoading}
onClick={() => onFactorSelected(factor)}
onClick={() => {
card.setError(undefined);
onFactorSelected(factor);
}}
/>
))}
</Flex>
Expand Down Expand Up @@ -161,3 +167,35 @@ export function getButtonIcon(factor: SignInFactor) {

return icons[factor.strategy as keyof typeof icons];
}

function determineFlowPart(mode: AlternativeMethodsMode) {
switch (mode) {
case 'forgot':
return 'forgotPasswordMethods';
case 'pwned':
return 'passwordPwnedMethods';
default:
return 'alternativeMethods';
}
}

function determineTitle(mode: AlternativeMethodsMode): LocalizationKey {
switch (mode) {
case 'forgot':
return localizationKeys('signIn.forgotPasswordAlternativeMethods.title');
case 'pwned':
return localizationKeys('signIn.passwordPwned.title');
default:
return localizationKeys('signIn.alternativeMethods.title');
}
}

function determineIsReset(mode: AlternativeMethodsMode): boolean {
switch (mode) {
case 'forgot':
case 'pwned':
return true;
default:
return false;
}
}
20 changes: 17 additions & 3 deletions packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx
Expand Up @@ -3,7 +3,7 @@ import React from 'react';

import { withRedirectToAfterSignIn } from '../../common';
import { useCoreSignIn, useEnvironment } from '../../contexts';
import { ErrorCard, LoadingCard, withCardStateProvider } from '../../elements';
import { ErrorCard, LoadingCard, useCardState, withCardStateProvider } from '../../elements';
import { useAlternativeStrategies } from '../../hooks/useAlternativeStrategies';
import { localizationKeys } from '../../localization';
import { useRouter } from '../../router';
Expand Down Expand Up @@ -36,6 +36,7 @@ export function _SignInFactorOne(): JSX.Element {
const { preferredSignInStrategy } = useEnvironment().displayConfig;
const availableFactors = signIn.supportedFirstFactors;
const router = useRouter();
const card = useCardState();

const lastPreparedFactorKeyRef = React.useRef('');
const [{ currentFactor }, setFactor] = React.useState<{
Expand All @@ -58,6 +59,8 @@ export function _SignInFactorOne(): JSX.Element {

const [showForgotPasswordStrategies, setShowForgotPasswordStrategies] = React.useState(false);

const [isPasswordPwned, setIsPasswordPwned] = React.useState(false);

React.useEffect(() => {
// Handle the case where a user lands on alternative methods screen,
// clicks a social button but then navigates back to sign in.
Expand Down Expand Up @@ -94,11 +97,18 @@ export function _SignInFactorOne(): JSX.Element {
const canGoBack = factorHasLocalStrategy(currentFactor);

const toggle = showAllStrategies ? toggleAllStrategies : toggleForgotPasswordStrategies;
const backHandler = () => {
card.setError(undefined);
setIsPasswordPwned(false);
toggle?.();
};

const mode = showForgotPasswordStrategies ? (isPasswordPwned ? 'pwned' : 'forgot') : 'default';

return (
<AlternativeMethods
asForgotPassword={showForgotPasswordStrategies}
onBackLinkClick={canGoBack ? toggle : undefined}
mode={mode}
onBackLinkClick={canGoBack ? backHandler : undefined}
onFactorSelected={f => {
selectFactor(f);
toggle?.();
Expand Down Expand Up @@ -135,6 +145,10 @@ export function _SignInFactorOne(): JSX.Element {
}}
onForgotPasswordMethodClick={resetPasswordFactor ? toggleForgotPasswordStrategies : toggleAllStrategies}
onShowAlternativeMethodsClick={toggleAllStrategies}
onPasswordPwned={() => {
setIsPasswordPwned(true);
toggleForgotPasswordStrategies();
}}
/>
);
case 'email_code':
Expand Down
@@ -1,4 +1,4 @@
import { isUserLockedError } from '@clerk/shared/error';
import { isPasswordPwnedError, isUserLockedError } from '@clerk/shared/error';
import { useClerk } from '@clerk/shared/react';
import type { ResetPasswordCodeFactor } from '@clerk/types';
import React from 'react';
Expand All @@ -17,6 +17,7 @@ type SignInFactorOnePasswordProps = {
onForgotPasswordMethodClick: React.MouseEventHandler | undefined;
onShowAlternativeMethodsClick: React.MouseEventHandler | undefined;
onFactorPrepare: (f: ResetPasswordCodeFactor) => void;
onPasswordPwned?: () => void;
};

const usePasswordControl = (props: SignInFactorOnePasswordProps) => {
Expand Down Expand Up @@ -45,7 +46,7 @@ const usePasswordControl = (props: SignInFactorOnePasswordProps) => {
};

export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps) => {
const { onShowAlternativeMethodsClick } = props;
const { onShowAlternativeMethodsClick, onPasswordPwned } = props;
const card = useCardState();
const { setActive } = useClerk();
const signIn = useCoreSignIn();
Expand Down Expand Up @@ -81,6 +82,12 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps)
return clerk.__internal_navigateWithError('..', err.errors[0]);
}

if (isPasswordPwnedError(err) && onPasswordPwned) {
card.setError({ ...err.errors[0], code: 'form_password_pwned__sign_in' });
onPasswordPwned();
return;
}

handleError(err, [passwordControl], card.setError);
});
};
Expand Down
Expand Up @@ -227,6 +227,153 @@ describe('SignInFactorOne', () => {
});
});
});

it('Prompts the user to reset their password via email if it has been pwned', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPassword();
f.withPreferredSignInStrategy({ strategy: 'password' });
f.startSignInWithEmailAddress({
supportPassword: true,
supportEmailCode: true,
supportResetPassword: true,
});
});
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));

const errJSON = {
code: 'form_password_pwned',
long_message:
'Password has been found in an online data breach. For account safety, please reset your password.',
message: 'Password has been found in an online data breach. For account safety, please reset your password.',
meta: { param_name: 'password' },
};

fixtures.signIn.attemptFirstFactor.mockRejectedValueOnce(
new ClerkAPIResponseError('Error', {
data: [errJSON],
status: 422,
}),
);

await runFakeTimers(async () => {
const { userEvent } = render(<SignInFactorOne />, { wrapper });
await userEvent.type(screen.getByLabelText('Password'), '123456');
await userEvent.click(screen.getByText('Continue'));

await waitFor(() => {
screen.getByText('Password compromised');
screen.getByText(
'This password has been found as part of a breach and can not be used, please reset your password.',
);
screen.getByText('Or, sign in with another method');
});

await userEvent.click(screen.getByText('Reset your password'));
screen.getByText('First, enter the code sent to your email ID');
});
});

it('Prompts the user to reset their password via phone if it has been pwned', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPassword();
f.withPreferredSignInStrategy({ strategy: 'password' });
f.startSignInWithPhoneNumber({
supportPassword: true,
supportPhoneCode: true,
supportResetPassword: true,
});
});
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));

const errJSON = {
code: 'form_password_pwned',
long_message:
'Password has been found in an online data breach. For account safety, please reset your password.',
message: 'Password has been found in an online data breach. For account safety, please reset your password.',
meta: { param_name: 'password' },
};

fixtures.signIn.attemptFirstFactor.mockRejectedValueOnce(
new ClerkAPIResponseError('Error', {
data: [errJSON],
status: 422,
}),
);

await runFakeTimers(async () => {
const { userEvent } = render(<SignInFactorOne />, { wrapper });
await userEvent.type(screen.getByLabelText('Password'), '123456');
await userEvent.click(screen.getByText('Continue'));

await waitFor(() => {
screen.getByText('Password compromised');
screen.getByText(
'This password has been found as part of a breach and can not be used, please reset your password.',
);
screen.getByText('Or, sign in with another method');
});

await userEvent.click(screen.getByText('Reset your password'));
screen.getByText('First, enter the code sent to your phone');
});
});

it('entering a pwned password, then going back and clicking forgot password should result in the correct title', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
f.withPassword();
f.withPreferredSignInStrategy({ strategy: 'password' });
f.startSignInWithEmailAddress({
supportPassword: true,
supportEmailCode: true,
supportResetPassword: true,
});
});
fixtures.signIn.prepareFirstFactor.mockReturnValueOnce(Promise.resolve({} as SignInResource));

const errJSON = {
code: 'form_password_pwned',
long_message:
'Password has been found in an online data breach. For account safety, please reset your password.',
message: 'Password has been found in an online data breach. For account safety, please reset your password.',
meta: { param_name: 'password' },
};

fixtures.signIn.attemptFirstFactor.mockRejectedValueOnce(
new ClerkAPIResponseError('Error', {
data: [errJSON],
status: 422,
}),
);

await runFakeTimers(async () => {
const { userEvent } = render(<SignInFactorOne />, { wrapper });
await userEvent.type(screen.getByLabelText('Password'), '123456');
await userEvent.click(screen.getByText('Continue'));

await waitFor(() => {
screen.getByText('Password compromised');
screen.getByText(
'This password has been found as part of a breach and can not be used, please reset your password.',
);
screen.getByText('Or, sign in with another method');
});

// Go back
await userEvent.click(screen.getByText('Back'));

// Choose to reset password via "Forgot password" instead
await userEvent.click(screen.getByText(/Forgot password/i));
screen.getByText('Forgot Password?');
expect(
screen.queryByText(
'This password has been found as part of a breach and can not be used, please reset your password.',
),
).not.toBeInTheDocument();
});
});
});

describe('Forgot Password', () => {
Expand Down
Expand Up @@ -25,6 +25,7 @@ type FlowMetadata = {
| 'emailLinkStatus'
| 'alternativeMethods'
| 'forgotPasswordMethods'
| 'passwordPwnedMethods'
| 'havingTrouble'
| 'ssoCallback'
| 'popover'
Expand Down

0 comments on commit fc3ffd8

Please sign in to comment.