asset.comments.length}
diff --git a/client/coral-embed-stream/src/LoadMore.js b/client/coral-embed-stream/src/LoadMore.js
index e33828bc1..bf89793d0 100644
--- a/client/coral-embed-stream/src/LoadMore.js
+++ b/client/coral-embed-stream/src/LoadMore.js
@@ -24,22 +24,45 @@ const loadMoreComments = (assetId, comments, loadMore, parentId) => {
});
};
-const LoadMore = ({assetId, comments, loadMore, moreComments, parentId}) => (
- moreComments
- ?
- : null
-);
+class LoadMore extends React.Component {
+
+ componentDidMount () {
+ this.initialState = true;
+ }
+
+ replyCountFormat = (count) => {
+ if (count === 1) {
+ return lang.t('viewReply');
+ }
+
+ if (this.initialState) {
+ return lang.t('viewAllRepliesInitial', count);
+ } else {
+ return lang.t('viewAllReplies', count);
+ }
+ }
+
+ render () {
+ const {assetId, comments, loadMore, moreComments, parentId, replyCount, topLevel} = this.props;
+ return moreComments
+ ?
+ : null;
+ }
+}
LoadMore.propTypes = {
assetId: PropTypes.string.isRequired,
comments: PropTypes.array.isRequired,
moreComments: PropTypes.bool.isRequired,
+ topLevel: PropTypes.bool.isRequired,
+ replyCount: PropTypes.number,
loadMore: PropTypes.func.isRequired
};
diff --git a/client/coral-embed-stream/style/default.css b/client/coral-embed-stream/style/default.css
index 68bac3110..629efd29f 100644
--- a/client/coral-embed-stream/style/default.css
+++ b/client/coral-embed-stream/style/default.css
@@ -425,6 +425,8 @@ button.coral-load-more {
cursor: pointer;
padding: 10px;
border-radius: 2px;
+ line-height: 1em;
+ text-transform: capitalize;
}
button.coral-load-more:hover {
diff --git a/client/coral-framework/actions/auth.js b/client/coral-framework/actions/auth.js
index ba1952e29..9dedaa2f2 100644
--- a/client/coral-framework/actions/auth.js
+++ b/client/coral-framework/actions/auth.js
@@ -48,7 +48,7 @@ export const fetchSignIn = (formData) => (dispatch) => {
dispatch(signInRequest());
return coralApi('/auth/local', {method: 'POST', body: formData})
.then(({user}) => {
- const isAdmin = !!user.roles.filter(i => i === 'ADMIN').length;
+ const isAdmin = !!user && !!user.roles.filter(i => i === 'ADMIN').length;
dispatch(signInSuccess(user, isAdmin));
dispatch(hideSignInDialog());
})
diff --git a/client/coral-framework/translations.json b/client/coral-framework/translations.json
index d115aedca..b327a7097 100644
--- a/client/coral-framework/translations.json
+++ b/client/coral-framework/translations.json
@@ -4,14 +4,17 @@
"successUpdateSettings": "The changes you have made have been applied to the comment stream on this article",
"successNameUpdate": "Your username has been updated",
"contentNotAvailable": "This content is not available",
- "loadMore": "View more",
- "bannedAccountMsg": "Your account is currently suspended. This means that you cannot Like, Flag, or write comments. Please contact moderator@fakeurl.com for more information",
+ "bannedAccountMsg": "Your account is currently suspended. This means that you cannot Like, Report, or write comments. Please contact us if you have any questions.",
"editName": {
- "msg": "Your account is currently suspended because your username has been deemed inappropriate. To restore your account, please enter a new username. You may contact moderator@fakeurl.com for more information.",
+ "msg": "Your account is currently suspended because your username has been deemed inappropriate. To restore your account, please enter a new username. Please contact us if you have any questions.",
"label": "New Username",
"button": "Submit",
"error": "Usernames can contain letters, numbers and _ only"
},
+ "viewMoreComments": "view more comments",
+ "viewReply": "view reply",
+ "viewAllRepliesInitial": "view all {0} replies",
+ "viewAllReplies": "view {0} replies",
"newCount": "View {0} more {1}",
"comment": "comment",
"comments": "comments",
@@ -41,9 +44,12 @@
"successUpdateSettings": "La configuración de este articulo fue actualizada",
"successBioUpdate": "Tu bio fue actualizada",
"contentNotAvailable": "El contenido no se encuentra disponible",
- "bannedAccountMsg": "Tu cuenta se encuentra suspendida. Esto significa que no puedes dar Like, Marcar o escribir commentarios. Por favor, contacta moderator@fakeurl for more information",
+ "bannedAccountMsg": "Tu cuenta se encuentra suspendida. Esto significa que no puedes dar Like, Marcar o escribir commentarios.",
"editNameMsg": "",
- "loadMore": "Ver más",
+ "viewMoreComments": "Var commentarios más",
+ "viewReply": "ver respuesta",
+ "viewAllRepliesInitial": "ver todas las {0} respuestas",
+ "viewAllReplies": "ver {0} respuestas",
"newCount": "Ver {0} {1} más",
"comment": "commentario",
"comments": "commentarios",
diff --git a/client/coral-plugin-flags/translations.json b/client/coral-plugin-flags/translations.json
index caac6cbf1..255e13f3c 100644
--- a/client/coral-plugin-flags/translations.json
+++ b/client/coral-plugin-flags/translations.json
@@ -7,8 +7,8 @@
"step-1-header": "Report an issue",
"step-2-header": "Help us understand",
"step-3-header": "Thank you for your input",
- "flag-username": "Flag username",
- "flag-comment": "Flag comment",
+ "flag-username": "Report username",
+ "flag-comment": "Report comment",
"continue": "Continue",
"done": "Done",
"no-agree-comment": "I don't agree with this comment",
@@ -20,8 +20,8 @@
"no-like-bio": "I don't like this bio",
"marketing": "This looks like an ad/marketing",
"user-impersonating": "This user is impersonating",
- "thank-you": "We value your safety and feedback. A moderator will review your flag.",
- "flag-reason": "Reason for flag (Optional)",
+ "thank-you": "We value your safety and feedback. A moderator will review your report.",
+ "flag-reason": "Reason for reporting (Optional)",
"other": "Other"
},
"es": {
diff --git a/errors.js b/errors.js
index 6d9abbcfd..6cd8d8da0 100644
--- a/errors.js
+++ b/errors.js
@@ -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
};
diff --git a/graph/loaders/metrics.js b/graph/loaders/metrics.js
index a6fe5afe4..a0408ed4d 100644
--- a/graph/loaders/metrics.js
+++ b/graph/loaders/metrics.js
@@ -3,6 +3,53 @@ const DataLoader = require('dataloader');
const {objectCacheKeyFn} = require('./util');
const ActionModel = require('../../models/action');
+const CommentModel = require('../../models/comment');
+
+/**
+ * Returns the assets which have had comments made within the last time period.
+ */
+const getAssetActivityMetrics = ({loaders: {Assets}}, {from, to, limit}) => {
+ let assetMetrics = [];
+
+ return CommentModel.aggregate([
+ {$match: {
+ parent_id: null,
+ created_at: {
+ $gt: from,
+ $lt: to
+ }
+ }},
+ {$group: {
+ _id: '$asset_id',
+ commentCount: {
+ $sum: 1
+ }
+ }},
+ {$project: {
+ _id: false,
+ asset_id: '$_id',
+ commentCount: '$commentCount'
+ }},
+ {$sort: {
+ commentCount: -1
+ }},
+ {$limit: limit}
+ ])
+ .then((results) => {
+ assetMetrics = results;
+
+ return Assets.getByID.loadMany(results.map((result) => result.asset_id));
+ })
+ .then((assets) => assets.map((asset, i) => {
+
+ // We're leveraging the fact that the comments returned by the aggregation
+ // query are in the request order that we just made, it's what the
+ // Assets.getByID loader does.
+ asset.commentCount = assetMetrics[i].commentCount;
+
+ return asset;
+ }));
+};
/**
* Returns a list of assets with action metadata included on the models.
@@ -208,10 +255,11 @@ module.exports = (context) => ({
cacheKeyFn: objectCacheKeyFn('from', 'to')
}),
Assets: {
- get: ({from, to, sort, limit}) => getAssetMetrics(context, {from, to, sort, limit})
+ get: ({from, to, sort, limit}) => getAssetMetrics(context, {from, to, sort, limit}),
+ getActivity: ({from, to, limit}) => getAssetActivityMetrics(context, {from, to, limit}),
},
Comments: {
- get: ({from, to, sort, limit}) => getCommentMetrics(context, {from, to, sort, limit})
+ get: ({from, to, sort, limit}) => getCommentMetrics(context, {from, to, sort, limit}),
}
}
});
diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js
index d577ddddb..ce11fcae9 100644
--- a/graph/resolvers/root_query.js
+++ b/graph/resolvers/root_query.js
@@ -63,6 +63,10 @@ const RootQuery = {
return null;
}
+ if (sort === 'ACTIVITY') {
+ return Assets.getActivity({from, to, limit});
+ }
+
return Assets.get({from, to, sort, limit});
},
diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql
index 9655354fb..306a6f22a 100644
--- a/graph/typeDefs.graphql
+++ b/graph/typeDefs.graphql
@@ -496,6 +496,22 @@ enum USER_STATUS {
APPROVED
}
+# Metrics for the assets.
+enum ASSET_METRICS_SORT {
+
+ # Represents a LikeAction.
+ LIKE
+
+ # Represents a FlagAction.
+ FLAG
+
+ # Represents a don't agree action.
+ DONTAGREE
+
+ # Represents activity.
+ ACTIVITY
+}
+
type RootQuery {
# Site wide settings and defaults.
@@ -526,7 +542,7 @@ type RootQuery {
# Asset metrics related to user actions are saturated into the assets
# returned.
- assetMetrics(from: Date!, to: Date!, sort: ACTION_TYPE!, limit: Int = 10): [Asset!]
+ assetMetrics(from: Date!, to: Date!, sort: ASSET_METRICS_SORT!, limit: Int = 10): [Asset!]
# Comment metrics related to user actions are saturated into the comments
# returned.
diff --git a/models/user.js b/models/user.js
index ea4195b8a..e1cbb466b 100644
--- a/models/user.js
+++ b/models/user.js
@@ -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}
diff --git a/package.json b/package.json
index ec94e1db3..909452231 100644
--- a/package.json
+++ b/package.json
@@ -63,6 +63,7 @@
"express": "^4.14.0",
"express-session": "^1.14.2",
"gql-merge": "^0.0.4",
+ "form-data": "^2.1.2",
"graphql": "^0.8.2",
"graphql-errors": "^2.1.0",
"graphql-server-express": "^0.5.0",
@@ -78,12 +79,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"
},
@@ -138,7 +141,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",
diff --git a/services/passport.js b/services/passport.js
index 229541521..4ec80bb60 100644
--- a/services/passport.js
+++ b/services/passport.js
@@ -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');
diff --git a/services/users.js b/services/users.js
index 90ca2fae2..cd10c418c 100644
--- a/services/users.js
+++ b/services/users.js
@@ -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
diff --git a/test/services/users.js b/test/services/users.js
index 51ba01403..94aa92432 100644
--- a/test/services/users.js
+++ b/test/services/users.js
@@ -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!-')
- .then((user) => {
- expect(user).to.equal(false);
- });
- });
-
});
describe('#createLocalUser', () => {
diff --git a/views/admin.ejs b/views/admin.ejs
index dafd5e3cc..d4d635dcc 100644
--- a/views/admin.ejs
+++ b/views/admin.ejs
@@ -85,6 +85,7 @@
+