Skip to content

Commit

Permalink
Support captchas in reset password flow (#2547)
Browse files Browse the repository at this point in the history
### Changes

Adds support for captchas in the reset password flow for classic login.

### References

https://auth0team.atlassian.net/browse/IAMRISK-3340

### Testing


https://github.com/auth0/lock/assets/121056923/6d58e601-ba48-42df-8af2-3a23305f6d5c


### Checklist

* [x] I have read the [Auth0 general contribution
guidelines](https://github.com/auth0/open-source-template/blob/master/GENERAL-CONTRIBUTING.md)
* [x] I have read the [Auth0 Code of
Conduct](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md)
* [x] All code quality tools/guidelines have been run/followed
* [x] All relevant assets have been compiled

---------

Co-authored-by: Steve Hobbs <steve.hobbs@okta.com>
srijonsaha and stevehobbsdev authored Apr 30, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
1 parent 826cd47 commit 37bfbab
Showing 63 changed files with 281 additions and 101 deletions.
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -121,7 +121,7 @@
"webpack-dev-server": "^4.11.1"
},
"dependencies": {
"auth0-js": "^9.23.3",
"auth0-js": "^9.26.0",
"auth0-password-policies": "^1.0.2",
"blueimp-md5": "^2.19.0",
"classnames": "^2.3.2",
Original file line number Diff line number Diff line change
@@ -29,13 +29,13 @@ exports[`email passwordless renders a captcha 1`] = `
/>
<div
data-__type="captcha_pane"
data-flow="passwordless"
data-i18n={
{
"html": [Function],
"str": [Function],
}
}
data-isPasswordless={true}
data-lock="model"
data-onReload={[Function]}
/>
Original file line number Diff line number Diff line change
@@ -21,13 +21,13 @@ exports[`sms passwordless renders a captcha 1`] = `
/>
<div
data-__type="captcha_pane"
data-flow="passwordless"
data-i18n={
{
"html": [Function],
"str": [Function],
}
}
data-isPasswordless={true}
data-lock="model"
data-onReload={[Function]}
/>
71 changes: 52 additions & 19 deletions src/connection/captcha.js
Original file line number Diff line number Diff line change
@@ -4,22 +4,45 @@ import * as i18n from '../i18n';
import { swap, updateEntity } from '../store/index';
import webApi from '../core/web_api';

export const Flow = Object.freeze({
DEFAULT: 'default',
PASSWORDLESS: 'passwordless',
PASSWORD_RESET: 'password_reset',
});

/**
* Return the captcha config object based on the type of flow.
*
* @param {Object} m model
* @param {Flow} flow Which flow the captcha is being rendered in
*/
export function getCaptchaConfig(m, flow) {
if (flow === Flow.PASSWORD_RESET) {
return l.passwordResetCaptcha(m);
} else if (flow === Flow.PASSWORDLESS) {
return l.passwordlessCaptcha(m);
} else {
return l.captcha(m);
}
}

/**
* Display the error message of missing captcha in the header of lock.
*
* @param {Object} m model
* @param {Number} id
* @param {Boolean} isPasswordless Whether the captcha is being rendered in a passwordless flow
* @param {Flow} flow Which flow the captcha is being rendered in
*/
export function showMissingCaptcha(m, id, isPasswordless = false) {
const captchaConfig = isPasswordless ? l.passwordlessCaptcha(m) : l.captcha(m);
export function showMissingCaptcha(m, id, flow = Flow.DEFAULT) {
const captchaConfig = getCaptchaConfig(m, flow);

const captchaError = (
captchaConfig.get('provider') === 'recaptcha_v2' ||
captchaConfig.get('provider') === 'recaptcha_enterprise' ||
captchaConfig.get('provider') === 'hcaptcha' ||
captchaConfig.get('provider') === 'auth0_v2' ||
captchaConfig.get('provider') === 'friendly_captcha'
captchaConfig.get('provider') === 'friendly_captcha' ||
captchaConfig.get('provider') === 'arkose'
) ? 'invalid_recaptcha' : 'invalid_captcha';

const errorMessage = i18n.html(m, ['error', 'login', captchaError]);
@@ -37,20 +60,20 @@ export function showMissingCaptcha(m, id, isPasswordless = false) {
*
* @param {Object} m model
* @param {Object} params
* @param {Boolean} isPasswordless Whether the captcha is being rendered in a passwordless flow
* @param {Flow} flow Which flow the captcha is being rendered in
* @param {Object} fields
*
* @returns {Boolean} returns true if is required and missing the response from the user
*/
export function setCaptchaParams(m, params, isPasswordless, fields) {
const captchaConfig = isPasswordless ? l.passwordlessCaptcha(m) : l.captcha(m);
export function setCaptchaParams(m, params, flow, fields) {
const captchaConfig = getCaptchaConfig(m, flow);
const isCaptchaRequired = captchaConfig && captchaConfig.get('required');

if (!isCaptchaRequired) {
return true;
}
const captcha = c.getFieldValue(m, 'captcha');
//captcha required and missing
// captcha required and missing
if (!captcha) {
return false;
}
@@ -64,12 +87,21 @@ export function setCaptchaParams(m, params, isPasswordless, fields) {
* Get a new challenge and display the new captcha image.
*
* @param {number} id The id of the Lock instance.
* @param {Boolean} isPasswordless Whether the captcha is being rendered in a passwordless flow.
* @param {Flow} flow Which flow the captcha is being rendered in.
* @param {boolean} wasInvalid A boolean indicating if the previous captcha was invalid.
* @param {Function} [next] A callback.
*/
export function swapCaptcha(id, isPasswordless, wasInvalid, next) {
if (isPasswordless) {
export function swapCaptcha(id, flow, wasInvalid, next) {
if (flow === Flow.PASSWORD_RESET) {
return webApi.getPasswordResetChallenge(id, (err, newCaptcha) => {
if (!err && newCaptcha) {
swap(updateEntity, 'lock', id, l.setPasswordResetCaptcha, newCaptcha, wasInvalid);
}
if (next) {
next();
}
});
} else if (flow === Flow.PASSWORDLESS) {
return webApi.getPasswordlessChallenge(id, (err, newCaptcha) => {
if (!err && newCaptcha) {
swap(updateEntity, 'lock', id, l.setPasswordlessCaptcha, newCaptcha, wasInvalid);
@@ -78,13 +110,14 @@ export function swapCaptcha(id, isPasswordless, wasInvalid, next) {
next();
}
});
} else {
return webApi.getChallenge(id, (err, newCaptcha) => {
if (!err && newCaptcha) {
swap(updateEntity, 'lock', id, l.setCaptcha, newCaptcha, wasInvalid);
}
if (next) {
next();
}
});
}
return webApi.getChallenge(id, (err, newCaptcha) => {
if (!err && newCaptcha) {
swap(updateEntity, 'lock', id, l.setCaptcha, newCaptcha, wasInvalid);
}
if (next) {
next();
}
});
}
36 changes: 26 additions & 10 deletions src/connection/database/actions.js
Original file line number Diff line number Diff line change
@@ -19,7 +19,7 @@ import {
} from './index';

import * as i18n from '../../i18n';
import { setCaptchaParams, showMissingCaptcha, swapCaptcha } from '../captcha';
import { Flow, setCaptchaParams, showMissingCaptcha, swapCaptcha } from '../captcha';

export function logIn(id, needsMFA = false) {
const m = read(getEntity, 'lock', id);
@@ -33,7 +33,7 @@ export function logIn(id, needsMFA = false) {
};

const fields = [usernameField, 'password'];
const isCaptchaValid = setCaptchaParams(m, params, false, fields);
const isCaptchaValid = setCaptchaParams(m, params, Flow.DEFAULT, fields);

if (!isCaptchaValid) {
return showMissingCaptcha(m, id);
@@ -53,7 +53,7 @@ export function logIn(id, needsMFA = false) {

if (error) {
const wasInvalid = error && error.code === 'invalid_captcha';
return swapCaptcha(id, false, wasInvalid, next);
return swapCaptcha(id, Flow.DEFAULT, wasInvalid, next);
}

next();
@@ -88,7 +88,7 @@ export function signUp(id) {
autoLogin: shouldAutoLogin(m)
};

const isCaptchaValid = setCaptchaParams(m, params, false, fields);
const isCaptchaValid = setCaptchaParams(m, params, Flow.DEFAULT, fields);
if (!isCaptchaValid) {
return showMissingCaptcha(m, id);
}
@@ -131,7 +131,7 @@ export function signUp(id) {

const wasInvalidCaptcha = error && error.code === 'invalid_captcha';

swapCaptcha(id, false, wasInvalidCaptcha, () => {
swapCaptcha(id, Flow.DEFAULT, wasInvalidCaptcha, () => {
setTimeout(() => signUpError(id, error), 250);
});
};
@@ -218,7 +218,7 @@ export function signUpError(id, error) {

if (errorKey === 'invalid_captcha') {
errorMessage = i18n.html(m, ['error', 'login', errorKey]);
return swapCaptcha(id, false, true, () => {
return swapCaptcha(id, Flow.DEFAULT, true, () => {
swap(updateEntity, 'lock', id, l.setSubmitting, false, errorMessage);
});
}
@@ -244,7 +244,12 @@ export function resetPassword(id) {
email: c.getFieldValue(m, 'email')
};

webApi.resetPassword(id, params, (error, ...args) => {
const isCaptchaValid = setCaptchaParams(m, params, Flow.PASSWORD_RESET, ['email']);
if (!isCaptchaValid) {
return showMissingCaptcha(m, id, Flow.PASSWORD_RESET);
}

webApi.resetPassword(id, params, error => {
if (error) {
setTimeout(() => resetPasswordError(id, error), 250);
} else {
@@ -280,12 +285,23 @@ function resetPasswordSuccess(id) {

function resetPasswordError(id, error) {
const m = read(getEntity, 'lock', id);
let key = error.code;

if (error.code === 'invalid_captcha') {
const captchaConfig = l.passwordResetCaptcha(m);
key = (
captchaConfig.get('provider') === 'recaptcha_v2' ||
captchaConfig.get('provider') === 'recaptcha_enterprise'
) ? 'invalid_recaptcha' : 'invalid_captcha';
}

const errorMessage =
i18n.html(m, ['error', 'forgotPassword', error.code]) ||
i18n.html(m, ['error', 'forgotPassword', key]) ||
i18n.html(m, ['error', 'forgotPassword', 'lock.fallback']);

swap(updateEntity, 'lock', id, l.setSubmitting, false, errorMessage);

swapCaptcha(id, Flow.PASSWORD_RESET, error.code === 'invalid_captcha', () => {
swap(updateEntity, 'lock', id, l.setSubmitting, false, errorMessage);
});
}

export function showLoginActivity(id, fields = ['password']) {
4 changes: 2 additions & 2 deletions src/connection/database/login_pane.jsx
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@ import EmailPane from '../../field/email/email_pane';
import UsernamePane from '../../field/username/username_pane';
import PasswordPane from '../../field/password/password_pane';
import { showResetPasswordActivity } from './actions';
import { swapCaptcha } from '../captcha';
import { Flow, swapCaptcha } from '../captcha';
import { hasScreen, forgotPasswordLink } from './index';
import * as l from '../../core/index';
import CaptchaPane from '../../field/captcha/captcha_pane';
@@ -64,7 +64,7 @@ export default class LoginPane extends React.Component {
l.captcha(lock) &&
l.captcha(lock).get('required') &&
(isHRDDomain(lock, databaseUsernameValue(lock)) || !sso) ? (
<CaptchaPane i18n={i18n} lock={lock} onReload={() => swapCaptcha(l.id(lock), false, false)} />
<CaptchaPane i18n={i18n} lock={lock} onReload={() => swapCaptcha(l.id(lock), Flow.DEFAULT, false)} />
) : null;

const dontRememberPassword =
9 changes: 9 additions & 0 deletions src/connection/database/reset_password_pane.jsx
Original file line number Diff line number Diff line change
@@ -2,6 +2,8 @@ import PropTypes from 'prop-types';
import React from 'react';
import EmailPane from '../../field/email/email_pane';
import * as l from '../../core/index';
import CaptchaPane from '../../field/captcha/captcha_pane';
import { Flow, swapCaptcha } from '../../connection/captcha';

export default class ResetPasswordPane extends React.Component {
static propTypes = {
@@ -12,6 +14,12 @@ export default class ResetPasswordPane extends React.Component {
render() {
const { emailInputPlaceholder, header, i18n, lock } = this.props;

const captchaPane =
l.passwordResetCaptcha(lock) &&
l.passwordResetCaptcha(lock).get('required') ? (
<CaptchaPane i18n={i18n} lock={lock} flow={Flow.PASSWORD_RESET} onReload={() => swapCaptcha(l.id(lock), Flow.PASSWORD_RESET, false, null)} />
) : null;

return (
<div>
{header}
@@ -21,6 +29,7 @@ export default class ResetPasswordPane extends React.Component {
placeholder={emailInputPlaceholder}
strictValidation={false}
/>
{captchaPane}
</div>
);
}
6 changes: 3 additions & 3 deletions src/connection/enterprise/actions.js
Original file line number Diff line number Diff line change
@@ -9,7 +9,7 @@ import { getFieldValue, hideInvalidFields } from '../../field/index';
import { emailLocalPart } from '../../field/email';
import { logIn as coreLogIn } from '../../core/actions';
import * as l from '../../core/index';
import { setCaptchaParams, showMissingCaptcha, swapCaptcha } from '../captcha';
import { Flow, setCaptchaParams, showMissingCaptcha, swapCaptcha } from '../captcha';

// TODO: enterprise connections should not depend on database
// connections. However, we now allow a username input to contain also
@@ -53,7 +53,7 @@ export function logIn(id) {
return logInSSO(id, ssoConnection, params);
}

const isCaptchaValid = setCaptchaParams(m, params, false, fields);
const isCaptchaValid = setCaptchaParams(m, params, Flow.DEFAULT, fields);

if (!isCaptchaValid && !ssoConnection) {
return showMissingCaptcha(m, id);
@@ -85,7 +85,7 @@ function logInActiveFlow(id, params) {
},
(id, error, fields, next) => {
const wasCaptchaInvalid = error && error.code === 'invalid captcha';
swapCaptcha(id, false, wasCaptchaInvalid, next);
swapCaptcha(id, Flow.DEFAULT, wasCaptchaInvalid, next);
}
);
}
4 changes: 2 additions & 2 deletions src/connection/enterprise/hrd_pane.jsx
Original file line number Diff line number Diff line change
@@ -3,7 +3,7 @@ import React from 'react';
import UsernamePane from '../../field/username/username_pane';
import PasswordPane from '../../field/password/password_pane';
import CaptchaPane from '../../field/captcha/captcha_pane';
import { swapCaptcha } from '../captcha';
import { Flow, swapCaptcha } from '../captcha';
import * as l from '../../core/index';

export default class HRDPane extends React.Component {
@@ -12,7 +12,7 @@ export default class HRDPane extends React.Component {

const captchaPane =
l.captcha(model) && l.captcha(model).get('required') ? (
<CaptchaPane i18n={i18n} lock={model} onReload={() => swapCaptcha(l.id(model), false, false)} />
<CaptchaPane i18n={i18n} lock={model} onReload={() => swapCaptcha(l.id(model), Flow.DEFAULT, false)} />
) : null;

return (
Loading

0 comments on commit 37bfbab

Please sign in to comment.