Merge pull request #412 from coralproject/recaptcha-support

recaptcha + RL support
This commit is contained in:
Kim Gardner
2017-03-16 12:16:50 -04:00
committed by GitHub
15 changed files with 411 additions and 64 deletions
+12 -3
View File
@@ -2,9 +2,13 @@ import * as actions from '../constants/auth';
import coralApi from 'coral-framework/helpers/response';
// Log In.
export const handleLogin = (email, password) => dispatch => {
export const handleLogin = (email, password, recaptchaResponse) => dispatch => {
dispatch({type: actions.LOGIN_REQUEST});
return coralApi('/auth/local', {method: 'POST', body: {email, password}})
const params = {method: 'POST', body: {email, password}};
if (recaptchaResponse) {
params.headers = {'X-Recaptcha-Response': recaptchaResponse};
}
return coralApi('/auth/local', params)
.then(({user}) => {
if (!user) {
return dispatch(checkLoginFailure('not logged in'));
@@ -14,7 +18,12 @@ export const handleLogin = (email, password) => dispatch => {
dispatch(checkLoginSuccess(user, isAdmin));
})
.catch(error => {
dispatch({type: actions.LOGIN_FAILURE, message: error.translation_key});
if (error.translation_key === 'LOGIN_MAXIMUM_EXCEEDED') {
dispatch({type: actions.LOGIN_MAXIMUM_EXCEEDED, message: error.translation_key});
} else {
dispatch({type: actions.LOGIN_FAILURE, message: error.translation_key});
}
});
};
@@ -4,6 +4,7 @@ import styles from './NotFound.css';
import {Button, TextField, Alert, Success} from 'coral-ui';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations';
import Recaptcha from 'react-recaptcha';
const lang = new I18n(translations);
class AdminLogin extends React.Component {
@@ -18,13 +19,22 @@ class AdminLogin extends React.Component {
this.props.handleLogin(this.state.email, this.state.password);
}
onRecaptchaLoad = () => {
// do something?
}
onRecaptchaVerify = (recaptchaResponse) => {
this.props.handleLogin(this.state.email, this.state.password, recaptchaResponse);
}
handleRequestPassword = e => {
e.preventDefault();
this.props.requestPasswordReset(this.state.email);
}
render () {
const {errorMessage} = this.props;
const {errorMessage, loginMaxExceeded} = this.props;
const signInForm = (
<form onSubmit={this.handleSignIn}>
{errorMessage && <Alert>{lang.t(`errors.${errorMessage}`)}</Alert>}
@@ -49,6 +59,15 @@ class AdminLogin extends React.Component {
this.setState({requestPassword: true});
}}>Request a new one.</a>
</p>
{
loginMaxExceeded &&
<Recaptcha
sitekey={process.env.TALK_RECAPTCHA_PUBLIC}
render='explicit'
theme='dark'
onloadCallback={this.onRecaptchaLoad}
verifyCallback={this.onRecaptchaVerify} />
}
</form>
);
const requestPasswordForm = (
@@ -84,6 +103,7 @@ class AdminLogin extends React.Component {
}
AdminLogin.propTypes = {
loginMaxExceeded: PropTypes.bool.isRequired,
handleLogin: PropTypes.func.isRequired,
passwordRequestSuccess: PropTypes.string,
loginError: PropTypes.string
+1
View File
@@ -11,6 +11,7 @@ export const LOGOUT_FAILURE = 'LOGOUT_FAILURE';
export const LOGIN_REQUEST = 'LOGIN_REQUEST';
export const LOGIN_SUCCESS = 'LOGIN_SUCCESS';
export const LOGIN_FAILURE = 'LOGIN_FAILURE';
export const LOGIN_MAXIMUM_EXCEEDED = 'LOGIN_MAXIMUM_EXCEEDED';
export const FETCH_FORGOT_PASSWORD_REQUEST = 'FETCH_FORGOT_PASSWORD_REQUEST';
export const FETCH_FORGOT_PASSWORD_SUCCESS = 'FETCH_FORGOT_PASSWORD_SUCCESS';
@@ -16,12 +16,14 @@ class LayoutContainer extends Component {
loggedIn,
loadingUser,
loginError,
loginMaxExceeded,
passwordRequestSuccess
} = this.props.auth;
const {handleLogout} = this.props;
if (loadingUser) { return <FullLoading />; }
if (!isAdmin) {
return <AdminLogin
loginMaxExceeded={loginMaxExceeded}
handleLogin={this.props.handleLogin}
requestPasswordReset={this.props.requestPasswordReset}
passwordRequestSuccess={passwordRequestSuccess}
@@ -38,7 +40,7 @@ const mapStateToProps = state => ({
const mapDispatchToProps = dispatch => ({
checkLogin: () => dispatch(checkLogin()),
handleLogin: (username, password) => dispatch(handleLogin(username, password)),
handleLogin: (username, password, recaptchaResponse) => dispatch(handleLogin(username, password, recaptchaResponse)),
requestPasswordReset: email => dispatch(requestPasswordReset(email)),
handleLogout: () => dispatch(logout())
});
+7
View File
@@ -6,6 +6,7 @@ const initialState = Map({
user: null,
isAdmin: false,
loginError: null,
loginMaxExceeded: false,
passwordRequestSuccess: null
});
@@ -29,12 +30,18 @@ export default function auth (state = initialState, action) {
return initialState;
case actions.LOGIN_REQUEST:
return state.set('loginError', null);
case actions.LOGIN_SUCCESS:
return state.set('loginMaxExceeded', false).set('loginError', null);
case actions.LOGIN_FAILURE:
return state.set('loginError', action.message);
case actions.FETCH_FORGOT_PASSWORD_REQUEST:
return state.set('passwordRequestSuccess', null);
case actions.FETCH_FORGOT_PASSWORD_SUCCESS:
return state.set('passwordRequestSuccess', 'If you have a registered account, a password reset link was sent to that email.');
case actions.LOGIN_MAXIMUM_EXCEEDED:
return state
.set('loginMaxExceeded', true)
.set('loginError', action.message);
default :
return state;
}
+4 -2
View File
@@ -1,7 +1,8 @@
{
"en": {
"errors": {
"NOT_AUTHORIZED": "Your username or password is not recognized by our system."
"NOT_AUTHORIZED": "Your username or password is not recognized by our system.",
"LOGIN_MAXIMUM_EXCEEDED": "You have made too many unsuccessful password attempts. Please wait."
},
"community": {
"username_and_email": "Username and Email",
@@ -145,7 +146,8 @@
},
"es": {
"errors": {
"NOT_AUTHORIZED": "Acción no autorizada."
"NOT_AUTHORIZED": "Acción no autorizada.",
"LOGIN_MAXIMUM_EXCEEDED": "Ha realizado demasiados intentos fallidos de contraseña. Por favor espera."
},
"community": {
"username_and_email": "Usuario y E-mail",
+7 -1
View File
@@ -148,6 +148,11 @@ const ErrPermissionUpdateUsername = new APIError('You do not have permission to
status: 500
});
const ErrLoginAttemptMaximumExceeded = new APIError('You have made too many incorrect password attempts.', {
translation_key: 'LOGIN_MAXIMUM_EXCEEDED',
status: 429
});
module.exports = {
ExtendableError,
APIError,
@@ -168,5 +173,6 @@ module.exports = {
ErrNotAuthorized,
ErrPermissionUpdateUsername,
ErrSettingsInit,
ErrInstallLock
ErrInstallLock,
ErrLoginAttemptMaximumExceeded
};
+20
View File
@@ -1,4 +1,5 @@
const mongoose = require('../services/mongoose');
const bcrypt = require('bcrypt');
const uuid = require('uuid');
// USER_ROLES is the array of roles that is permissible as a user role.
@@ -146,6 +147,25 @@ UserSchema.method('hasRoles', function(...roles) {
});
});
/**
* This verifies that a password is valid.
*/
UserSchema.method('verifyPassword', function(password) {
return new Promise((resolve, reject) => {
bcrypt.compare(password, this.password, (err, res) => {
if (err) {
return reject(err);
}
if (!res) {
return resolve(false);
}
return resolve(true);
});
});
});
/**
* All the graph operations that are available for a user.
* @type {Array}
+3 -1
View File
@@ -62,6 +62,7 @@
"env-rewrite": "^1.0.2",
"express": "^4.14.0",
"express-session": "^1.14.2",
"form-data": "^2.1.2",
"graphql": "^0.8.2",
"graphql-errors": "^2.1.0",
"graphql-server-express": "^0.5.0",
@@ -77,12 +78,14 @@
"mongoose": "^4.6.5",
"morgan": "^1.7.0",
"natural": "^0.4.0",
"node-fetch": "^1.6.3",
"nodemailer": "^2.6.4",
"parse-duration": "^0.1.1",
"passport": "^0.3.2",
"passport-facebook": "^2.1.1",
"passport-local": "^1.0.0",
"react-apollo": "^0.10.0",
"react-recaptcha": "^2.2.6",
"redis": "^2.6.5",
"uuid": "^2.0.3"
},
@@ -137,7 +140,6 @@
"mocha": "^3.1.2",
"mocha-junit-reporter": "^1.12.1",
"nightwatch": "^0.9.11",
"node-fetch": "^1.6.3",
"nodemon": "^1.11.0",
"postcss-loader": "^1.1.0",
"postcss-modules": "^0.5.2",
+236 -26
View File
@@ -1,9 +1,12 @@
const passport = require('passport');
const UsersService = require('./users');
const SettingsService = require('./settings');
const fetch = require('node-fetch');
const FormData = require('form-data');
const LocalStrategy = require('passport-local').Strategy;
const FacebookStrategy = require('passport-facebook').Strategy;
const errors = require('../errors');
const debug = require('debug')('talk:passport');
//==============================================================================
// SESSION SERIALIZATION
@@ -74,27 +77,233 @@ function ValidateUserLogin(loginProfile, user, done) {
// STRATEGIES
//==============================================================================
/**
* This looks at the request headers to see if there is a recaptcha response on
* the input request.
*/
const CheckIfRecaptcha = (req) => {
let response = req.get('X-Recaptcha-Response');
if (response && response.length > 0) {
return true;
}
return false;
};
/**
* This checks the user to see if the current email profile needs to get checked
* for recaptcha compliance before being allowed to login.
*/
const CheckIfNeedsRecaptcha = (user, email) => {
// Get the profile representing the local account.
let profile = user.profiles.find((profile) => profile.id === email);
// This should never get to this point, if it does, don't let this past.
if (!profile) {
throw new Error('ID indicated by loginProfile is not on user object');
}
if (profile.metadata && profile.metadata.recaptcha_required) {
return true;
}
return false;
};
/**
* This stores the Recaptcha secret.
*/
const RECAPTCHA_SECRET = process.env.TALK_RECAPTCHA_SECRET;
/**
* This is true when the recaptcha secret is provided and the Recaptcha feature
* is to be enabled.
*/
const RECAPTCHA_ENABLED = RECAPTCHA_SECRET && RECAPTCHA_SECRET.length > 0;
if (!RECAPTCHA_ENABLED) {
console.log('Recaptcha is not enabled for login/signup abuse prevention, set TALK_RECAPTCHA_SECRET to enable Recaptcha.');
}
/**
* This sends the request details down Google to check to see if the response is
* genuine or not.
* @return {Promise} resolves with the success status of the recaptcha
*/
const CheckRecaptcha = async (req) => {
// Ask Google to verify the recaptcha response: https://developers.google.com/recaptcha/docs/verify
const form = new FormData();
form.append('secret', RECAPTCHA_SECRET);
form.append('response', req.get('X-Recaptcha-Response'));
form.append('remoteip', req.ip);
// Perform the request.
let res = await fetch('https://www.google.com/recaptcha/api/siteverify', {
method: 'POST',
body: form,
headers: form.getHeaders()
});
// Parse the JSON response.
let json = await res.json();
return json.success;
};
/**
* This records a login attempt failure as well as optionally flags an account
* for requiring a recaptcha in the future outside the temporary window.
* @return {Promise} resolves with nothing if rate limit not exeeded, errors if
* there is a rate limit error
*/
const HandleFailedAttempt = async (email, userNeedsRecaptcha) => {
try {
await UsersService.recordLoginAttempt(email);
} catch (err) {
if (err === errors.ErrLoginAttemptMaximumExceeded && !userNeedsRecaptcha && RECAPTCHA_ENABLED) {
debug(`flagging user email=${email}`);
await UsersService.flagForRecaptchaRequirement(email, true);
}
throw err;
}
};
passport.use(new LocalStrategy({
usernameField: 'email',
passwordField: 'password'
}, (email, password, done) => {
UsersService
.findLocalUser(email, password)
.then((user) => {
if (!user) {
return done(null, false, {message: 'Incorrect email/password combination'});
passwordField: 'password',
passReqToCallback: true
}, async (req, email, password, done) => {
// We need to check if this request has a recaptcha on it at all, if it does,
// we must verify it first. If verification fails, we fail the request early.
// We can only do this obviously when recaptcha is enabled.
let hasRecaptcha = CheckIfRecaptcha(req);
let recaptchaPassed = false;
if (RECAPTCHA_ENABLED && hasRecaptcha) {
try {
// Check to see if this recaptcha passed.
recaptchaPassed = await CheckRecaptcha(req);
} catch (err) {
return done(err);
}
if (!recaptchaPassed) {
try {
await HandleFailedAttempt(email);
} catch (err) {
return done(err);
}
// Define the loginProfile being used to perform an additional
// verificaiton.
let loginProfile = {id: email, provider: 'local'};
return done(null, false, {message: 'Incorrect recaptcha'});
}
}
// Validate the user login.
return ValidateUserLogin(loginProfile, user, done);
})
.catch((err) => {
done(err);
});
debug(`hasRecaptcha=${hasRecaptcha}, recaptchaPassed=${recaptchaPassed}`);
// If the request didn't have a recaptcha, check to see if we did need one by
// checking the rate limit against failed attempts on this email
// address/login.
if (!hasRecaptcha) {
try {
await UsersService.checkLoginAttempts(email);
} catch (err) {
if (err === errors.ErrLoginAttemptMaximumExceeded) {
// This says, we didn't have a recaptcha, yet we needed one.. Reject
// here.
try {
await HandleFailedAttempt(email);
} catch (err) {
return done(err);
}
return done(null, false, {message: 'Incorrect recaptcha'});
}
// Some other unexpected error occured.
return done(err);
}
}
// Let's find the user for which this login is connected to.
let user;
try {
user = await UsersService.findLocalUser(email);
} catch (err) {
return done(err);
}
debug(`user=${user != null}`);
// If the user doesn't exist, then mark this as a failed attempt at logging in
// this non-existant user and continue.
if (!user) {
try {
await HandleFailedAttempt(email);
} catch (err) {
return done(err);
}
return done(null, false, {message: 'Incorrect email/password combination'});
}
// Let's check if the user indeed needed recaptcha in order to authenticate.
// We can only do this obviously when recaptcha is enabled.
let userNeedsRecaptcha = false;
if (RECAPTCHA_ENABLED && user) {
userNeedsRecaptcha = CheckIfNeedsRecaptcha(user, email);
}
debug(`userNeedsRecaptcha=${userNeedsRecaptcha}`);
// Let's check now if their password is correct.
let userPasswordCorrect;
try {
userPasswordCorrect = await user.verifyPassword(password);
} catch (err) {
return done(err);
}
debug(`userPasswordCorrect=${userPasswordCorrect}`);
// If their password wasn't correct, mark their attempt as failed and
// continue.
if (!userPasswordCorrect) {
try {
await HandleFailedAttempt(email, userNeedsRecaptcha);
} catch (err) {
return done(err);
}
return done(null, false, {message: 'Incorrect email/password combination'});
}
// If the user needed a recaptcha, yet we have gotten this far, this indicates
// that the password was correct, so let's unflag their account for logins. We
// can only do this obviously when recaptcha is enabled. The account wouldn't
// have been flagged otherwise.
if (RECAPTCHA_ENABLED && userNeedsRecaptcha) {
try {
await UsersService.flagForRecaptchaRequirement(email, false);
} catch (err) {
return done(err);
}
}
// Define the loginProfile being used to perform an additional
// verificaiton.
let loginProfile = {id: email, provider: 'local'};
// Perform final steps to login the user.
return ValidateUserLogin(loginProfile, user, done);
}));
if (process.env.TALK_FACEBOOK_APP_ID && process.env.TALK_FACEBOOK_APP_SECRET && process.env.TALK_ROOT_URL) {
@@ -102,17 +311,18 @@ if (process.env.TALK_FACEBOOK_APP_ID && process.env.TALK_FACEBOOK_APP_SECRET &&
clientID: process.env.TALK_FACEBOOK_APP_ID,
clientSecret: process.env.TALK_FACEBOOK_APP_SECRET,
callbackURL: `${process.env.TALK_ROOT_URL}/api/v1/auth/facebook/callback`,
passReqToCallback: true,
profileFields: ['id', 'displayName', 'picture.type(large)']
}, (accessToken, refreshToken, profile, done) => {
UsersService
.findOrCreateExternalUser(profile)
.then((user) => {
return ValidateUserLogin(profile, user, done);
})
.catch((err) => {
done(err);
});
}, async (req, accessToken, refreshToken, profile, done) => {
let user;
try {
user = await UsersService.findOrCreateExternalUser(profile);
} catch (err) {
return done(err);
}
return ValidateUserLogin(profile, user, done);
}));
} else {
console.error('Facebook cannot be enabled, missing one of TALK_FACEBOOK_APP_ID, TALK_FACEBOOK_APP_SECRET, TALK_ROOT_URL');
+86 -18
View File
@@ -3,11 +3,16 @@ const jwt = require('jsonwebtoken');
const Wordlist = require('./wordlist');
const errors = require('../errors');
const uuid = require('uuid');
const redis = require('./redis');
const redisClient = redis.createClient();
const UserModel = require('../models/user');
const USER_STATUS = require('../models/user').USER_STATUS;
const USER_ROLES = require('../models/user').USER_ROLES;
const RECAPTCHA_WINDOW_SECONDS = 60 * 10; // 10 minutes.
const RECAPTCHA_INCORRECT_TRIGGER = 5; // after 3 incorrect attempts, recaptcha will be required.
const ActionsService = require('./actions');
// In the event that the TALK_SESSION_SECRET is missing but we are testing, then
@@ -30,13 +35,9 @@ const SALT_ROUNDS = 10;
module.exports = class UsersService {
/**
* Finds a user given their email address that we have for them in the system
* and ensures that the retuned user matches the password passed in as well.
* @param {string} email - email to look up the user by
* @param {string} password - password to match against the found user
* @param {Function} done [description]
* Returns a user (if found) for the given email address.
*/
static findLocalUser(email, password) {
static findLocalUser(email) {
if (!email || typeof email !== 'string') {
return Promise.reject('email is required for findLocalUser');
}
@@ -48,32 +49,99 @@ module.exports = class UsersService {
provider: 'local'
}
}
})
.then((user) => {
if (!user) {
return false;
}
});
}
return new Promise((resolve, reject) => {
bcrypt.compare(password, user.password, (err, res) => {
/**
* This records an unsucesfull login attempt for the given email address. If
* the maximum has been reached, the promise will be rejected with:
*
* errors.ErrLoginAttemptMaximumExceeded
*
* Indicating that the account should be flagged as "login recaptcha required"
* where future login attempts must be made with the recaptcha flag.
*/
static recordLoginAttempt(email) {
const rdskey = `la[${email.toLowerCase().trim()}]`;
return new Promise((resolve, reject) => {
redisClient
.multi()
.incr(rdskey)
.expire(rdskey, RECAPTCHA_WINDOW_SECONDS)
.exec((err, replies) => {
if (err) {
return reject(err);
}
if (!res) {
return resolve(false);
// if this is new or has no expiry
if (replies[0] === 1 || replies[1] === -1) {
// then expire it after the timeout
redisClient.expire(rdskey, RECAPTCHA_WINDOW_SECONDS);
}
return resolve(user);
if (replies[0] >= RECAPTCHA_INCORRECT_TRIGGER) {
return reject(errors.ErrLoginAttemptMaximumExceeded);
}
resolve();
});
});
});
}
/**
* This checks to see if the current login attempts against a user exeeds the
* maximum value allowed, if so, it rejects with:
*
* errors.ErrLoginAttemptMaximumExceeded
*/
static checkLoginAttempts(email) {
const rdskey = `la[${email.toLowerCase().trim()}]`;
return new Promise((resolve, reject) => {
redisClient
.get(rdskey, (err, reply) => {
if (err) {
return reject(err);
}
if (!reply) {
return resolve();
}
if (reply >= RECAPTCHA_INCORRECT_TRIGGER) {
return reject(errors.ErrLoginAttemptMaximumExceeded);
}
resolve();
});
});
}
/**
* Sets or unsets the recaptcha_required flag on a user's local profile.
*/
static flagForRecaptchaRequirement(email, required) {
return UserModel.update({
profiles: {
$elemMatch: {
id: email.toLowerCase(),
provider: 'local'
}
}
}, {
$set: {
'profiles.$.metadata.recaptcha_required': required
}
});
}
/**
* Merges two users together by taking all the profiles on a given user and
* pushing them into the source user followed by deleting the destination user's
* user account. This will not merge the roles associated with the source user.
* user account. This will
* not merge the roles associated with the source user.
* @param {String} dstUserID id of the user to which is the target of the merge
* @param {String} srcUserID id of the user to which is the source of the merge
* @return {Promise} resolves when the users are merged
+2 -10
View File
@@ -64,22 +64,14 @@ describe('services.UsersService', () => {
describe('#findLocalUser', () => {
it('should find a user when we give the right credentials', () => {
it('should find a user', () => {
return UsersService
.findLocalUser(mockUsers[0].profiles[0].id, '1Coral!-')
.findLocalUser(mockUsers[0].profiles[0].id)
.then((user) => {
expect(user).to.have.property('username', mockUsers[0].username);
});
});
it('should not find the user when we give the wrong credentials', () => {
return UsersService
.findLocalUser(mockUsers[0].profiles[0].id, '1Coral!-<nope>')
.then((user) => {
expect(user).to.equal(false);
});
});
});
describe('#createLocalUser', () => {
+1
View File
@@ -85,6 +85,7 @@
</head>
<body>
<div id="root"></div>
<script src='https://www.google.com/recaptcha/api.js?render=explicit' async defer></script>
<script src="<%= basePath %>/bundle.js" charset="utf-8"></script>
</body>
</html>
+3
View File
@@ -118,6 +118,9 @@ module.exports = {
'process.env': {
'VERSION': `"${require('./package.json').version}"`
}
}),
new webpack.EnvironmentPlugin({
TALK_RECAPTCHA_PUBLIC: JSON.stringify(process.env.TALK_RECAPTCHA_PUBLIC)
})
],
resolve: {
+5 -1
View File
@@ -2979,7 +2979,7 @@ form-data@^0.2.0:
combined-stream "~0.0.4"
mime-types "~2.0.3"
form-data@~2.1.1:
form-data@^2.1.2, form-data@~2.1.1:
version "2.1.2"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.1.2.tgz#89c3534008b97eada4cbb157d58f6f5df025eae4"
dependencies:
@@ -6452,6 +6452,10 @@ react-onclickoutside@^5.7.1:
version "5.8.4"
resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-5.8.4.tgz#a2c673a8d1b104a550e565574b95419feb12cc3f"
react-recaptcha@^2.2.6:
version "2.2.6"
resolved "https://registry.yarnpkg.com/react-recaptcha/-/react-recaptcha-2.2.6.tgz#bb44c1948a39b37d5a41920c73db833e5d8524f9"
react-redux@^4.4.5:
version "4.4.6"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-4.4.6.tgz#4b9d32985307a11096a2dd61561980044fcc6209"