From 65dbf1989aaf994d2e647acfc17597c0a38865a4 Mon Sep 17 00:00:00 2001 From: Mark Pitsilos Date: Sun, 31 Mar 2024 22:53:32 +0300 Subject: [PATCH] feat(clerk-js,localizations,shared,types): Prompt user to reset pwned password at sign-in --- .changeset/sour-kings-fry.md | 8 + package-lock.json | 14 +- .../components/SignIn/AlternativeMethods.tsx | 63 ++++++-- .../ui/components/SignIn/SignInFactorOne.tsx | 20 ++- .../SignIn/SignInFactorOnePasswordCard.tsx | 11 +- .../SignIn/__tests__/SignInFactorOne.test.tsx | 147 ++++++++++++++++++ .../elements/contexts/FlowMetadataContext.tsx | 1 + packages/localizations/src/en-US.ts | 5 + packages/shared/src/error.ts | 4 + packages/types/src/localization.ts | 4 + 10 files changed, 254 insertions(+), 23 deletions(-) create mode 100644 .changeset/sour-kings-fry.md diff --git a/.changeset/sour-kings-fry.md b/.changeset/sour-kings-fry.md new file mode 100644 index 00000000000..9c785717d9d --- /dev/null +++ b/.changeset/sour-kings-fry.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. diff --git a/package-lock.json b/package-lock.json index 0b8f6b095b8..a3b7409d621 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36691,10 +36691,10 @@ }, "packages/chrome-extension": { "name": "@clerk/chrome-extension", - "version": "0.6.14", + "version": "0.6.16", "license": "MIT", "dependencies": { - "@clerk/clerk-js": "4.70.4", + "@clerk/clerk-js": "4.70.6", "@clerk/clerk-react": "4.30.7" }, "devDependencies": { @@ -36711,7 +36711,7 @@ }, "packages/clerk-js": { "name": "@clerk/clerk-js", - "version": "4.70.4", + "version": "4.70.6", "license": "MIT", "dependencies": { "@clerk/localizations": "1.26.16", @@ -37050,10 +37050,10 @@ }, "packages/expo": { "name": "@clerk/clerk-expo", - "version": "0.20.9", + "version": "0.20.11", "license": "MIT", "dependencies": { - "@clerk/clerk-js": "4.70.4", + "@clerk/clerk-js": "4.70.6", "@clerk/clerk-react": "4.30.7", "@clerk/shared": "1.3.3", "base-64": "1.0.0", @@ -37216,7 +37216,7 @@ }, "packages/remix": { "name": "@clerk/remix", - "version": "3.1.21", + "version": "3.2.0", "license": "MIT", "dependencies": { "@clerk/backend": "0.38.3", @@ -37317,7 +37317,7 @@ }, "packages/themes": { "name": "@clerk/themes", - "version": "1.7.9", + "version": "1.7.10", "license": "MIT", "dependencies": { "@clerk/types": "3.62.1", diff --git a/packages/clerk-js/src/ui/components/SignIn/AlternativeMethods.tsx b/packages/clerk-js/src/ui/components/SignIn/AlternativeMethods.tsx index ae32166e897..2daab7b16af 100644 --- a/packages/clerk-js/src/ui/components/SignIn/AlternativeMethods.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/AlternativeMethods.tsx @@ -12,11 +12,14 @@ 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 }; @@ -28,24 +31,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 ( - + {card.error} {onBackLinkClick && } - + {/*TODO: extract main in its own component */} { elementDescriptor={descriptors.main} gap={6} > - {asForgotPassword && resetPasswordFactor && ( + {isReset && resetPasswordFactor && ( { textElementDescriptor={descriptors.alternativeMethodsBlockButtonText} arrowElementDescriptor={descriptors.alternativeMethodsBlockButtonArrow} isDisabled={card.isLoading} - onClick={() => onFactorSelected(resetPasswordFactor)} + onClick={() => { + card.setError(undefined); + onFactorSelected(resetPasswordFactor); + }} /> )} {hasAnyStrategy && ( <> - {asForgotPassword && ( + {isReset && ( { arrowElementDescriptor={descriptors.alternativeMethodsBlockButtonArrow} key={i} isDisabled={card.isLoading} - onClick={() => onFactorSelected(factor)} + onClick={() => { + card.setError(undefined); + onFactorSelected(factor); + }} /> ))} @@ -149,3 +158,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; + } +} diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx index 804fe73186c..e08a4a42565 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOne.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { withRedirectToHomeSingleSessionGuard } 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'; @@ -35,6 +35,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<{ @@ -57,6 +58,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. @@ -93,11 +96,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 ( { selectFactor(f); toggle?.(); @@ -126,6 +136,10 @@ export function _SignInFactorOne(): JSX.Element { }} onForgotPasswordMethodClick={resetPasswordFactor ? toggleForgotPasswordStrategies : toggleAllStrategies} onShowAlternativeMethodsClick={toggleAllStrategies} + onPasswordPwned={() => { + setIsPasswordPwned(true); + toggleForgotPasswordStrategies(); + }} /> ); case 'email_code': diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOnePasswordCard.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOnePasswordCard.tsx index 37e697949f9..e2e9d87d484 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOnePasswordCard.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOnePasswordCard.tsx @@ -1,4 +1,4 @@ -import { isUserLockedError } from '@clerk/shared/error'; +import { isPasswordPwnedError, isUserLockedError } from '@clerk/shared/error'; import type { ResetPasswordCodeFactor } from '@clerk/types'; import React from 'react'; @@ -16,6 +16,7 @@ type SignInFactorOnePasswordProps = { onForgotPasswordMethodClick: React.MouseEventHandler | undefined; onShowAlternativeMethodsClick: React.MouseEventHandler | undefined; onFactorPrepare: (f: ResetPasswordCodeFactor) => void; + onPasswordPwned?: () => void; }; const usePasswordControl = (props: SignInFactorOnePasswordProps) => { @@ -44,7 +45,7 @@ const usePasswordControl = (props: SignInFactorOnePasswordProps) => { }; export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps) => { - const { onShowAlternativeMethodsClick } = props; + const { onShowAlternativeMethodsClick, onPasswordPwned } = props; const card = useCardState(); const { setActive } = useCoreClerk(); const signIn = useCoreSignIn(); @@ -80,6 +81,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); }); }; diff --git a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorOne.test.tsx b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorOne.test.tsx index b0f8a75cea8..e59de573ffb 100644 --- a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorOne.test.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInFactorOne.test.tsx @@ -226,6 +226,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(, { 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('Enter the code sent to your email address'); + }); + }); + + 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(, { 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('Enter the code sent to your phone number'); + }); + }); + + 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(, { 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', () => { diff --git a/packages/clerk-js/src/ui/elements/contexts/FlowMetadataContext.tsx b/packages/clerk-js/src/ui/elements/contexts/FlowMetadataContext.tsx index 35bcd088afd..df308814979 100644 --- a/packages/clerk-js/src/ui/elements/contexts/FlowMetadataContext.tsx +++ b/packages/clerk-js/src/ui/elements/contexts/FlowMetadataContext.tsx @@ -25,6 +25,7 @@ type FlowMetadata = { | 'emailLinkStatus' | 'alternativeMethods' | 'forgotPasswordMethods' + | 'passwordPwnedMethods' | 'havingTrouble' | 'ssoCallback' | 'popover' diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index f23c9d58a15..f71cc819ea7 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -149,6 +149,9 @@ export const enUS: LocalizationResource = { subtitle: 'to continue to {{applicationName}}', actionLink: 'Use another method', }, + passwordPwned: { + title: 'Password compromised', + }, forgotPasswordAlternativeMethods: { title: 'Forgot Password?', label__alternativeMethods: 'Or, sign in with another method.', @@ -722,6 +725,8 @@ export const enUS: LocalizationResource = { 'Sign up unsuccessful due to failed security validations. Please refresh the page to try again or reach out to support for more assistance.', form_password_pwned: 'This password has been found as part of a breach and can not be used, please try another password instead.', + form_password_pwned__sign_in: + 'This password has been found as part of a breach and can not be used, please reset your password.', form_username_invalid_length: '', form_username_invalid_character: '', form_param_format_invalid: '', diff --git a/packages/shared/src/error.ts b/packages/shared/src/error.ts index 9ac224afe7b..4c8e574bad6 100644 --- a/packages/shared/src/error.ts +++ b/packages/shared/src/error.ts @@ -73,6 +73,10 @@ export function parseErrors(data: ClerkAPIErrorJSON[] = []): ClerkAPIError[] { return data.length > 0 ? data.map(parseError) : []; } +export function isPasswordPwnedError(err: any) { + return isClerkAPIResponseError(err) && err.errors?.[0]?.code === 'form_password_pwned'; +} + export function parseError(error: ClerkAPIErrorJSON): ClerkAPIError { return { code: error.code, diff --git a/packages/types/src/localization.ts b/packages/types/src/localization.ts index 1075dee38da..b78173da815 100644 --- a/packages/types/src/localization.ts +++ b/packages/types/src/localization.ts @@ -174,6 +174,9 @@ type _LocalizationResource = { subtitle: LocalizationValue; actionLink: LocalizationValue; }; + passwordPwned: { + title: LocalizationValue; + }; forgotPasswordAlternativeMethods: { title: LocalizationValue; label__alternativeMethods: LocalizationValue; @@ -760,6 +763,7 @@ type UnstableErrors = WithParamName<{ captcha_unavailable: LocalizationValue; captcha_invalid: LocalizationValue; form_password_pwned: LocalizationValue; + form_password_pwned__sign_in: LocalizationValue; form_username_invalid_length: LocalizationValue; form_username_invalid_character: LocalizationValue; form_param_format_invalid: LocalizationValue;