@@ -362,6 +362,53 @@ loadingSize = 30px
&:last-child
margin-bottom 0

.auth0-lock-captcha
position: relative;
height: 72px;
margin-bottom: 10px;
background: #FFFFFF;
border: 1px solid #EEEEEE;
border-radius: 3px;
margin-top: 24px;

.auth0-lock-captcha-image
position: absolute;
width: 120px;
height: 40px;
left: 16px;
top: 16px;
background-size: contain;

.auth0-lock-captcha-refresh
position: absolute;
width: 40px;
height: 40px;
right: 16px;
top: 16px;
border: 1px solid #eee;
border-radius: 3px;
padding: 0;

svg, .test-titi
position: absolute;
top: 12.5px;
left: 12.5px;
width: 15px;
height: 15px;
margin: 0;
padding: 0;
background-color: transparent;

path
fill: #888;

.auth0-lock-input-block.auth0-lock-input-captcha
svg.auth0-lock-icon
width: 20px
height: 20px
top: 10px
left: 9.5px

.auth0-lock-input-wrap
border-radius 3px
border 1px solid #f1f1f1
@@ -1,6 +1,6 @@
{
"name": "auth0-lock",
"version": "11.19.0",
"version": "11.20.0",
"description": "Auth0 Lock",
"author": "Auth0 <support@auth0.com> (http://auth0.com)",
"license": "MIT",
@@ -94,7 +94,7 @@
"zuul-ngrok": "4.0.0"
},
"dependencies": {
"auth0-js": "^9.11.2",
"auth0-js": "^9.12.0",
"auth0-password-policies": "^1.0.2",
"blueimp-md5": "2.3.1",
"immutable": "^3.7.3",
@@ -12,7 +12,8 @@ jest.mock('connection/database/actions');

const mockId = 1;
jest.mock('core/index', () => ({
id: () => mockId
id: () => mockId,
captcha: () => undefined
}));

import LoginPane from 'connection/database/login_pane';
@@ -58,8 +59,8 @@ describe('LoginPane', () => {
).toMatchSnapshot();
});
it('when lock does not have the screen `forgotPassword`', () => {
databaseIndexMock.hasScreen.mockImplementation(
(l, screenName) => (screenName === 'forgotPassword' ? false : true)
databaseIndexMock.hasScreen.mockImplementation((l, screenName) =>
screenName === 'forgotPassword' ? false : true
);
expectComponent(<LoginPane {...defaultProps} />).toMatchSnapshot();
});
@@ -28,6 +28,12 @@ export function logIn(id, needsMFA = false) {

const fields = [usernameField, 'password'];

const captcha = c.getFieldValue(m, 'captcha');
if (captcha) {
params['captcha'] = captcha;
fields.push('captcha');
}

const mfaCode = c.getFieldValue(m, 'mfa_code');
if (needsMFA) {
params['mfa_code'] = mfaCode;
@@ -39,7 +45,12 @@ export function logIn(id, needsMFA = false) {
return showLoginMFAActivity(id);
}

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

next();
});
}

@@ -241,3 +252,21 @@ export function toggleTermsAcceptance(id) {
export function showLoginMFAActivity(id, fields = ['mfa_code']) {
swap(updateEntity, 'lock', id, setScreen, 'mfaLogin', fields);
}

/**
* Get a new challenge and display the new captcha image.
*
* @param {number} id The id of the Lock instance.
* @param {boolean} wasInvalid A boolean indicating if the previous captcha was invalid.
* @param {Function} [next] A callback.
*/
export function swapCaptcha(id, wasInvalid, next) {
return webApi.getChallenge(id, (err, newCaptcha) => {
if (!err && newCaptcha) {
swap(updateEntity, 'lock', id, l.setCaptcha, newCaptcha, wasInvalid);
}
if (next) {
next();
}
});
}
@@ -3,9 +3,10 @@ import React from 'react';
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 { showResetPasswordActivity, swapCaptcha } from './actions';
import { hasScreen, forgotPasswordLink } from './index';
import * as l from '../../core/index';
import CaptchaPane from '../../field/captcha/captcha_pane';

export default class LoginPane extends React.Component {
handleDontRememberPasswordClick(e) {
@@ -49,6 +50,11 @@ export default class LoginPane extends React.Component {
/>
);

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

const dontRememberPassword =
showForgotPasswordLink && hasScreen(lock, 'forgotPassword') ? (
<p className="auth0-lock-alternative">
@@ -72,6 +78,7 @@ export default class LoginPane extends React.Component {
placeholder={passwordInputPlaceholder}
hidden={!showPassword}
/>
{captchaPane}
{dontRememberPassword}
</div>
);
@@ -8,6 +8,7 @@ import trim from 'trim';
import * as gp from '../avatar/gravatar_provider';
import { dataFns } from '../utils/data_utils';
import { clientConnections, hasFreeSubscription } from './client/index';
import * as captchaField from '../field/captcha';

const { get, init, remove, reset, set, tget, tset, tremove } = dataFns(['core']);

@@ -400,6 +401,15 @@ export function defaultADUsernameFromEmailPrefix(m) {
return get(m, 'defaultADUsernameFromEmailPrefix', true);
}

export function setCaptcha(m, value, wasInvalid) {
m = captchaField.reset(m, wasInvalid);
return set(m, 'captcha', Immutable.fromJS(value));
}

export function captcha(m) {
return get(m, 'captcha');
}

export function prefill(m) {
return get(m, 'prefill', {});
}
@@ -534,6 +544,12 @@ export function loginErrorMessage(m, error, type) {
code = 'password_change_required';
}

if (code === 'invalid_captcha') {
return captcha(m).get('type') === 'code'
? i18n.html(m, 'captchaCodeInputPlaceholder')
: i18n.html(m, 'captchaMathInputPlaceholder');
}

return (
i18n.html(m, ['error', 'login', code]) || i18n.html(m, ['error', 'login', 'lock.fallback'])
);
@@ -5,6 +5,8 @@ import { fetchSSOData } from './sso/data';
import * as l from './index';
import { isADEnabled } from '../connection/enterprise'; // shouldn't depend on this
import sync, { isSuccess } from '../sync';
import webApi from './web_api';
import { setCaptcha } from '../core/index';

export function syncRemoteData(m) {
if (l.useTenantInfo(m)) {
@@ -44,5 +46,14 @@ export function syncRemoteData(m) {
}
});

m = sync(m, 'captcha', {
syncFn: (m, cb) => {
webApi.getChallenge(m.get('id'), (err, r) => {
cb(null, r);
});
},
successFn: setCaptcha
});

return m;
}
@@ -56,6 +56,10 @@ class Auth0WebAPI {
return this.clients[lockID].getProfile(token, callback);
}

getChallenge(lockID, callback) {
return this.clients[lockID].getChallenge(callback);
}

getSSOData(lockID, ...args) {
return this.clients[lockID].getSSOData(...args);
}
@@ -105,6 +105,14 @@ export function normalizeError(error, domain) {
};
}

if (error.code === 'invalid_captcha') {
return {
code: 'invalid_captcha',
code: 'invalid_captcha',
description: error.description
};
}

const result = {
error: error.code ? error.code : error.statusCode || error.error,
description: error.description || error.code
@@ -190,6 +190,10 @@ class Auth0APIClient {
return this.client.client.getSSOData(...params);
}

getChallenge(...params) {
return this.client.client.getChallenge(...params);
}

getUserCountry(cb) {
return this.client.client.getUserCountry(cb);
}
@@ -0,0 +1,17 @@
import { setField, setFieldShowInvalid } from './index';

export function validate(captcha) {
return !!captcha;
}

export function set(m, captcha, wasInvalid) {
m = setField(m, 'captcha', captcha, validate);
if (wasInvalid) {
m = setFieldShowInvalid(m, 'captcha', true);
}
return m;
}

export function reset(m, wasInvalid) {
return set(m, '', wasInvalid);
}
@@ -0,0 +1,51 @@
/* eslint-disable no-nested-ternary */

import React from 'react';
import PropTypes from 'prop-types';
import CaptchaInput from '../../ui/input/captcha_input';
import * as l from '../../core/index';
import { swap, updateEntity } from '../../store/index';
import * as captchaField from '../captcha';
import { getFieldValue, isFieldVisiblyInvalid } from '../index';

const Captcha = ({ lock, i18n, onReload }) => {
const lockId = l.id(lock);

function handleChange(e) {
swap(updateEntity, 'lock', lockId, captchaField.set, e.target.value);
}

const captcha = l.captcha(lock);

const placeholder =
captcha.get('type') === 'code'
? i18n.str(`captchaCodeInputPlaceholder`)
: i18n.str(`captchaMathInputPlaceholder`);

const value = getFieldValue(lock, 'captcha');
const isValid = !isFieldVisiblyInvalid(lock, 'captcha');

return (
<CaptchaInput
lockId={lockId}
image={captcha.get('image')}
placeholder={placeholder}
isValid={isValid}
onChange={handleChange}
onReload={onReload}
value={value}
/>
);
};

Captcha.propTypes = {
lock: PropTypes.object.isRequired,
error: PropTypes.bool,
onReload: PropTypes.func.isRequired
};

Captcha.defaultProps = {
error: false
};

export default Captcha;
@@ -61,6 +61,8 @@ export default {
databaseSignUpInstructions: '',
databaseAlternativeSignUpInstructions: 'or',
emailInputPlaceholder: 'yours@example.com',
captchaCodeInputPlaceholder: 'Enter the code shown above',
captchaMathInputPlaceholder: 'Solve the formula shown above',
enterpriseLoginIntructions: 'Login with your corporate credentials.',
enterpriseActiveLoginInstructions: 'Please enter your corporate credentials at %s.',
failedLabel: 'Failed!',
@@ -60,6 +60,8 @@ export default {
databaseSignUpInstructions: '',
databaseAlternativeSignUpInstructions: 'o',
emailInputPlaceholder: 'correo@ejemplo.com',
captchaCodeInputPlaceholder: 'Introduzca el código de arriba',
captchaMathInputPlaceholder: 'Resuelva la formula de arriba',
enterpriseLoginIntructions: 'Inicie sesión con sus credenciales corporativas.',
enterpriseActiveLoginInstructions: 'Ingrese las credenciales corporativas de %s.',
failedLabel: 'Error!',