mirror of
https://github.com/wassname/talk.git
synced 2026-07-24 13:20:47 +08:00
Added initial recaptcha + RL support
This commit is contained in:
@@ -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",
|
||||
@@ -139,7 +140,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",
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
+2
-1
@@ -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",
|
||||
@@ -76,6 +77,7 @@
|
||||
"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",
|
||||
@@ -136,7 +138,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
@@ -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
@@ -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
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user