Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Passwordless | Reset password with passcodes handle case for non-existent users #2915

Merged
merged 3 commits into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions cypress/integration/ete/reset_password_passcode.7.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -756,4 +756,29 @@ describe('Password reset recovery flows - with Passcodes', () => {
});
});
});

context('NON_EXISTENT user', () => {
it('shows the passcode page with no account info, and using passcode returns error', () => {
const emailAddress = randomMailosaurEmail();
cy.visit(`/reset-password?usePasscodesResetPassword=true`);

cy.contains('Reset password');
cy.get('input[name=email]').type(emailAddress);
cy.get('[data-cy="main-form-submit-button"]').click();

// passcode page
cy.url().should('include', '/reset-password/email-sent');
cy.contains('Enter your one-time code');
cy.contains('Don’t have an account?');

cy.get('input[name=code]').clear().type('123456');
cy.contains('Submit one-time code').click();

cy.url().should('include', '/reset-password/code');
cy.contains('Enter your one-time code');
cy.contains('Don’t have an account?');

cy.contains('Incorrect code');
});
});
});
14 changes: 14 additions & 0 deletions src/client/pages/PasscodeEmailSent.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ WithPasscode.story = {
name: 'with passcode',
};

export const WithNoAccountInfo = () => (
<PasscodeEmailSent
passcodeAction="#"
expiredPage="#"
changeEmailPage="#"
email="example@theguardian.com"
passcode="123456"
noAccountInfo
/>
);
WithNoAccountInfo.story = {
name: 'with no account info',
};

export const WithPasscodeError = () => (
<PasscodeEmailSent
shortRequestId="123e4567"
Expand Down
3 changes: 3 additions & 0 deletions src/client/pages/PasscodeEmailSent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type Props = {
passcode?: string;
recaptchaSiteKey?: string;
timeUntilTokenExpiry?: number;
noAccountInfo?: boolean;
};

type PasscodeEmailSentProps = EmailSentProps & Props;
Expand Down Expand Up @@ -68,6 +69,7 @@ export const PasscodeEmailSent = ({
timeUntilTokenExpiry,
expiredPage,
shortRequestId,
noAccountInfo,
}: PasscodeEmailSentProps) => {
const [recaptchaErrorMessage, setRecaptchaErrorMessage] = useState('');
const [recaptchaErrorContext, setRecaptchaErrorContext] =
Expand Down Expand Up @@ -147,6 +149,7 @@ export const PasscodeEmailSent = ({
formError={formError}
queryString={queryString}
shortRequestId={shortRequestId}
noAccountInfo={noAccountInfo}
/>
</MinimalLayout>
);
Expand Down
1 change: 1 addition & 0 deletions src/client/pages/ResetPasswordEmailSentPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const ResetPasswordEmailSentPage = () => {
fieldErrors={fieldErrors}
passcode={token}
expiredPage={buildUrl('/reset-password/expired')}
noAccountInfo
/>
);
}
Expand Down
38 changes: 37 additions & 1 deletion src/server/controllers/sendChangePasswordEmail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ const setEncryptedCookieOkta = (
* - [x] With only password authenticator
* - [x] With only email authenticator
* - [x] Non-ACTIVE user states
* - [ ] Non-Existent users
* - [x] Non-Existent users - In `sendEmailInOkta` method
*
* @param {Request} req - Express request object
* @param {ResponseWithRequestState} res - Express response object
Expand Down Expand Up @@ -472,6 +472,11 @@ const changePasswordEmailIdx = async (
flagStatus: false,
});

// track the success metrics
trackMetric(
`OktaIDXResetPasswordSend::${user.status as Status}::Success`,
);

// now that the placeholder password has been set, the user will be in
// 1. ACTIVE users - has email + password authenticator (okta idx email verified)
// or 2. ACTIVE users - has only password authenticator (okta idx email not verified)
Expand All @@ -495,6 +500,9 @@ const changePasswordEmailIdx = async (

logger.error('Okta changePasswordEmailIdx failed', error);

// track the failure metrics
trackMetric(`OktaIDXResetPasswordSend::${user.status as Status}::Failure`);

// don't throw the error, so we can fall back to okta classic flow
}
};
Expand Down Expand Up @@ -812,6 +820,34 @@ export const sendEmailInOkta = async (
error.status === 404 &&
error.code === 'E0000007'
) {
// if we're using passcodes, then show the email sent page with OTP input
// even if the user doesn't exist
if (passcodesEnabled && usePasscodesResetPassword) {
// set the encrypted state cookie to persist the email and stateHandle
// which we need to persist during the passcode reset flow
setEncryptedStateCookie(res, {
email,
stateHandle: `02.id.${crypto.randomBytes(30).toString('base64')}`, // generate a 40 character random string to use in the 46 character stateHandle
// 30 minutes in the future
stateHandleExpiresAt: new Date(
Date.now() + 30 * 60 * 1000,
).toISOString(),
userState: 'NON_EXISTENT', // set the user state to non-existent, so we can handle this case if the user attempts to submit the passcode
});

// track the success metrics
trackMetric(`OktaIDXResetPasswordSend::NON_EXISTENT::Success`);

// show the email sent page, with passcode instructions
return res.redirect(
303,
addQueryParamsToPath(
'/reset-password/email-sent',
res.locals.queryParams,
),
);
}

setEncryptedCookieOkta(res, email);

return res.redirect(
Expand Down
18 changes: 17 additions & 1 deletion src/server/lib/encryptedStateCookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,27 @@ export const setEncryptedStateCookie = (
res: Response,
state: EncryptedState,
) => {
// validate and modify any fields before encrypting
const validated: EncryptedState = {
...state,
// We only need the first part of the state handle before the delimiter
// which is also much shorter to reduce the size of the cookie, but everything
// continues to work as expected
stateHandle: state.stateHandle?.split('~')[0],
};

// remove any undefined values
const cleaned: EncryptedState = Object.fromEntries(
Object.entries(validated).filter(([, value]) => value !== undefined),
);

// encrypt the state
const encrypted = encrypt(
JSON.stringify(state),
JSON.stringify(cleaned),
getConfiguration().encryptionSecretKey, // prevent the key from lingering in memory by only calling when needed
);

// set the cookie
return res.cookie(
encryptedStateCookieName,
encrypted,
Expand Down
2 changes: 1 addition & 1 deletion src/server/lib/okta/idx/shared/errorHandling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const handlePasscodeError = ({
const html = renderer(emailSentPage, {
requestState: mergeRequestState(state, {
queryParams: {
returnUrl: state.queryParams.returnUrl,
...state.queryParams,
emailSentSuccess: false,
},
pageData: {
Expand Down
1 change: 1 addition & 0 deletions src/server/models/Metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type ConditionalMetrics =
| 'OktaIDXInteract'
| 'OktaIDXRegister'
| 'OktaIDXResetPasswordSend'
| `OktaIDXResetPasswordSend::NON_EXISTENT`
| `OktaIDXResetPasswordSend::${Status}`
| `OktaIDX::${IDXPath}`
| 'OktaRegistration'
Expand Down
39 changes: 38 additions & 1 deletion src/server/routes/resetPassword.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
import { OAuthError } from '@/server/models/okta/Error';
import { forgotPassword } from '@/server/lib/okta/api/users';
import { buildUrlWithQueryParams } from '@/shared/lib/routeUtils';
import { GenericErrors, RegistrationErrors } from '@/shared/model/Errors';
import { logger } from '@/server/lib/serverSideLogger';

// reset password email form
router.get('/reset-password', (req: Request, res: ResponseWithRequestState) => {
Expand Down Expand Up @@ -145,9 +147,18 @@ router.post(

// make sure we have the encrypted state cookie and the code otherwise redirect to the reset page
if (encryptedState?.stateHandle && code) {
const { stateHandle } = encryptedState;
const { stateHandle, userState } = encryptedState;

try {
// check for non-existent user state
// in this case throw an error to show the user the passcode is invalid
if (userState === 'NON_EXISTENT') {
throw new OAuthError({
error: 'api.authn.error.PASSCODE_INVALID',
error_description: RegistrationErrors.PASSCODE_INVALID,
});
}

// submit the passcode to Okta
const challengeAnswerResponse = await submitPasscode({
passcode: code,
Expand Down Expand Up @@ -250,6 +261,32 @@ router.post(
if (res.headersSent) {
return;
}

// log the error
logger.error(`${req.method} ${req.originalUrl} Error`, error);

// handle any other error, show generic error message
const html = renderer('/reset-password/code', {
requestState: mergeRequestState(res.locals, {
queryParams: {
...res.locals.queryParams,
emailSentSuccess: false,
},
pageData: {
email: encryptedState?.email,
timeUntilTokenExpiry: convertExpiresAtToExpiryTimeInMs(
encryptedState?.stateHandleExpiresAt,
),
formError: {
message: GenericErrors.DEFAULT,
severity: 'UNEXPECTED',
},
token: code,
},
}),
pageTitle: 'Check Your Inbox',
});
return res.type('html').send(html);
}
}

Expand Down