Adds ability to set an account lockout policy (#2601)

* Adds ability to set account lockout policy

* change fit to it in tests
This commit is contained in:
Diwakar Cherukumilli
2016-09-02 17:00:47 -07:00
committed by Florent Vilmart
parent f6516a1d1e
commit 28bd37884d
10 changed files with 614 additions and 24 deletions

200
src/AccountLockout.js Normal file
View File

@@ -0,0 +1,200 @@
// This class handles the Account Lockout Policy settings.
import Config from './Config';
export class AccountLockout {
constructor(user, config) {
this._user = user;
this._config = config;
}
/**
* set _failed_login_count to value
*/
_setFailedLoginCount(value) {
let query = {
username: this._user.username,
};
const updateFields = {
_failed_login_count: value
};
return this._config.database.update('_User', query, updateFields);
}
/**
* check if the _failed_login_count field has been set
*/
_isFailedLoginCountSet() {
return new Promise((resolve, reject) => {
const query = {
username: this._user.username,
_failed_login_count: { $exists: true }
};
this._config.database.find('_User', query)
.then(users => {
if (Array.isArray(users) && users.length > 0) {
resolve(true);
} else {
resolve(false);
}
})
.catch(err => {
reject(err);
});
});
}
/**
* if _failed_login_count is NOT set then set it to 0
* else do nothing
*/
_initFailedLoginCount() {
return new Promise((resolve, reject) => {
this._isFailedLoginCountSet()
.then(failedLoginCountIsSet => {
if (!failedLoginCountIsSet) {
return this._setFailedLoginCount(0);
} else {
return Promise.resolve();
}
})
.then(() => {
resolve();
})
.catch(err => {
reject(err);
});
});
}
/**
* increment _failed_login_count by 1
*/
_incrementFailedLoginCount() {
const query = {
username: this._user.username,
};
const updateFields = {_failed_login_count: {__op: 'Increment', amount: 1}};
return this._config.database.update('_User', query, updateFields);
}
/**
* if the failed login count is greater than the threshold
* then sets lockout expiration to 'currenttime + accountPolicy.duration', i.e., account is locked out for the next 'accountPolicy.duration' minutes
* else do nothing
*/
_setLockoutExpiration() {
return new Promise((resolve, reject) => {
const query = {
username: this._user.username,
_failed_login_count: { $gte: this._config.accountLockout.threshold },
};
const now = new Date();
const updateFields = {
_account_lockout_expires_at: Parse._encode(new Date(now.getTime() + this._config.accountLockout.duration*60*1000))
};
this._config.database.update('_User', query, updateFields)
.then(() => {
resolve();
})
.catch(err => {
if (err && err.code && err.message && err.code === 101 && err.message === 'Object not found.') {
resolve(); // nothing to update so we are good
} else {
reject(err); // unknown error
}
});
});
}
/**
* if _account_lockout_expires_at > current_time and _failed_login_count > threshold
* reject with account locked error
* else
* resolve
*/
_notLocked() {
return new Promise((resolve, reject) => {
const query = {
username: this._user.username,
_account_lockout_expires_at: { $gt: Parse._encode(new Date()) },
_failed_login_count: {$gte: this._config.accountLockout.threshold}
};
this._config.database.find('_User', query)
.then(users => {
if (Array.isArray(users) && users.length > 0) {
reject(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Your account is locked due to multiple failed login attempts. Please try again after ' + this._config.accountLockout.duration + ' minute(s)'));
} else {
resolve();
}
})
.catch(err => {
reject(err);
});
});
}
/**
* set and/or increment _failed_login_count
* if _failed_login_count > threshold
* set the _account_lockout_expires_at to current_time + accountPolicy.duration
* else
* do nothing
*/
_handleFailedLoginAttempt() {
return new Promise((resolve, reject) => {
this._initFailedLoginCount()
.then(() => {
return this._incrementFailedLoginCount();
})
.then(() => {
return this._setLockoutExpiration();
})
.then(() => {
resolve();
})
.catch(err => {
reject(err);
});
});
}
/**
* handle login attempt if the Account Lockout Policy is enabled
*/
handleLoginAttempt(loginSuccessful) {
if (!this._config.accountLockout) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
this._notLocked()
.then(() => {
if (loginSuccessful) {
return this._setFailedLoginCount(0);
} else {
return this._handleFailedLoginAttempt();
}
})
.then(() => {
resolve();
})
.catch(err => {
reject(err);
});
});
}
}
export default AccountLockout;

View File

@@ -57,10 +57,16 @@ const transformKeyValueForUpdate = (className, restKey, restValue, parseFormatSc
key = '_email_verify_token_expires_at';
timeField = true;
break;
case '_account_lockout_expires_at':
key = '_account_lockout_expires_at';
timeField = true;
break;
case '_failed_login_count':
key = '_failed_login_count';
break;
case '_rperm':
case '_wperm':
return {key: key, value: restValue};
break;
}
if ((parseFormatSchema.fields[key] && parseFormatSchema.fields[key].type === 'Pointer') || (!parseFormatSchema.fields[key] && restValue && restValue.__type == 'Pointer')) {
@@ -155,6 +161,13 @@ function transformQueryKeyValue(className, key, value, schema) {
}
return {key: '_id', value}
}
case '_account_lockout_expires_at':
if (valueAsDate(value)) {
return {key: '_account_lockout_expires_at', value: valueAsDate(value)}
}
break;
case '_failed_login_count':
return {key, value};
case 'sessionToken': return {key: '_session_token', value}
case '_rperm':
case '_wperm':
@@ -231,6 +244,11 @@ const parseObjectKeyValueToMongoObjectKeyValue = (restKey, restValue, schema) =>
transformedValue = transformTopLevelAtom(restValue);
coercedToDate = typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue
return {key: '_email_verify_token_expires_at', value: coercedToDate};
case '_account_lockout_expires_at':
transformedValue = transformTopLevelAtom(restValue);
coercedToDate = typeof transformedValue === 'string' ? new Date(transformedValue) : transformedValue
return {key: '_account_lockout_expires_at', value: coercedToDate};
case '_failed_login_count':
case '_rperm':
case '_wperm':
case '_email_verify_token':
@@ -730,6 +748,8 @@ const mongoObjectToParseObject = (className, mongoObject, schema) => {
case '_perishable_token':
case '_tombstone':
case '_email_verify_token_expires_at':
case '_account_lockout_expires_at':
case '_failed_login_count':
// Those keys will be deleted if needed in the DB Controller
restObject[key] = mongoObject[key];
break;

View File

@@ -53,7 +53,7 @@ const toPostgresValue = value => {
}
const transformValue = value => {
if (value.__type == 'Pointer') {
if (value.__type === 'Pointer') {
return value.objectId;
}
return value;
@@ -171,7 +171,7 @@ const buildWhereClause = ({ schema, query, index }) => {
if (fieldName.indexOf('.') >= 0) {
let components = fieldName.split('.').map((cmpt, index) => {
if (index == 0) {
if (index === 0) {
return `"${cmpt}"`;
}
return `'${cmpt}'`; 
@@ -431,6 +431,8 @@ export class PostgresStorageAdapter {
if (className === '_User') {
fields._email_verify_token_expires_at = {type: 'Date'};
fields._email_verify_token = {type: 'String'};
fields._account_lockout_expires_at = {type: 'Date'};
fields._failed_login_count = {type: 'Number'};
fields._perishable_token = {type: 'String'};
}
let index = 2;
@@ -439,7 +441,7 @@ export class PostgresStorageAdapter {
let parseType = fields[fieldName];
// Skip when it's a relation
// We'll create the tables later
if (parseType.type == 'Relation') {
if (parseType.type === 'Relation') {
relations.push(fieldName)
return;
}
@@ -641,18 +643,26 @@ export class PostgresStorageAdapter {
columnsArray.push(fieldName);
if (!schema.fields[fieldName] && className === '_User') {
if (fieldName == '_email_verify_token') {
if (fieldName === '_email_verify_token' ||
fieldName === '_failed_login_count' ||
fieldName === '_perishable_token') {
valuesArray.push(object[fieldName]);
}
if (fieldName == '_email_verify_token_expires_at') {
if (fieldName === '_email_verify_token_expires_at') {
if (object[fieldName]) {
valuesArray.push(object[fieldName].iso);
} else {
valuesArray.push(null);
}
}
if (fieldName == '_perishable_token') {
valuesArray.push(object[fieldName].iso);
if (fieldName === '_account_lockout_expires_at') {
if (object[fieldName]) {
valuesArray.push(object[fieldName].iso);
} else {
valuesArray.push(null);
}
}
return;
}
@@ -690,7 +700,6 @@ export class PostgresStorageAdapter {
break;
default:
throw `Type ${schema.fields[fieldName].type} not supported yet`;
break;
}
});
@@ -738,7 +747,7 @@ export class PostgresStorageAdapter {
let index = 2;
let where = buildWhereClause({ schema, index, query })
values.push(...where.values);
if (Object.keys(query).length == 0) {
if (Object.keys(query).length === 0) {
where.pattern = 'TRUE';
}
let qs = `WITH deleted AS (DELETE FROM $1:name WHERE ${where.pattern} RETURNING *) SELECT count(*) FROM deleted`;
@@ -870,13 +879,13 @@ export class PostgresStorageAdapter {
index += 2;
} else if (typeof fieldValue === 'object'
&& schema.fields[fieldName]
&& schema.fields[fieldName].type == 'Object') {
&& schema.fields[fieldName].type === 'Object') {
updatePatterns.push(`$${index}:name = $${index + 1}`);
values.push(fieldName, fieldValue);
index += 2;
} else if (Array.isArray(fieldValue)
&& schema.fields[fieldName]
&& schema.fields[fieldName].type == 'Array') {
&& schema.fields[fieldName].type === 'Array') {
let expectedType = parseTypeToPostgresType(schema.fields[fieldName]);
if (expectedType === 'text[]') {
updatePatterns.push(`$${index}:name = $${index + 1}::text[]`);
@@ -905,7 +914,7 @@ export class PostgresStorageAdapter {
let createValue = Object.assign({}, query, update);
return this.createObject(className, schema, createValue).catch((err) => {
// ignore duplicate value errors as it's upsert
if (err.code == Parse.Error.DUPLICATE_VALUE) {
if (err.code === Parse.Error.DUPLICATE_VALUE) {
return this.findOneAndUpdate(className, schema, query, update);
}
throw err;
@@ -992,6 +1001,9 @@ export class PostgresStorageAdapter {
if (object._email_verify_token_expires_at) {
object._email_verify_token_expires_at = { __type: 'Date', iso: object._email_verify_token_expires_at.toISOString() };
}
if (object._account_lockout_expires_at) {
object._account_lockout_expires_at = { __type: 'Date', iso: object._account_lockout_expires_at.toISOString() };
}
for (let fieldName in object) {
if (object[fieldName] === null) {
@@ -1052,7 +1064,7 @@ export class PostgresStorageAdapter {
debug('performInitialization');
let promises = VolatileClassesSchemas.map((schema) => {
return this.createTable(schema.className, schema).catch((err) =>{
if (err.code === PostgresDuplicateRelationError || err.code == Parse.Error.INVALID_CLASS_NAME) {
if (err.code === PostgresDuplicateRelationError || err.code === Parse.Error.INVALID_CLASS_NAME) {
return Promise.resolve();
}
throw err;

View File

@@ -48,6 +48,7 @@ export class Config {
this.verifyUserEmails = cacheInfo.verifyUserEmails;
this.preventLoginWithUnverifiedEmail = cacheInfo.preventLoginWithUnverifiedEmail;
this.emailVerifyTokenValidityDuration = cacheInfo.emailVerifyTokenValidityDuration;
this.accountLockout = cacheInfo.accountLockout;
this.appName = cacheInfo.appName;
this.analyticsController = cacheInfo.analyticsController;
@@ -76,13 +77,16 @@ export class Config {
revokeSessionOnPasswordReset,
expireInactiveSessions,
sessionLength,
emailVerifyTokenValidityDuration
emailVerifyTokenValidityDuration,
accountLockout
}) {
const emailAdapter = userController.adapter;
if (verifyUserEmails) {
this.validateEmailConfiguration({emailAdapter, appName, publicServerURL, emailVerifyTokenValidityDuration});
}
this.validateAccountLockoutPolicy(accountLockout);
if (typeof revokeSessionOnPasswordReset !== 'boolean') {
throw 'revokeSessionOnPasswordReset must be a boolean value';
}
@@ -96,7 +100,19 @@ export class Config {
this.validateSessionConfiguration(sessionLength, expireInactiveSessions);
}
static validateEmailConfiguration({emailAdapter, appName, publicServerURL, emailVerifyTokenValidityDuration}) {
static validateAccountLockoutPolicy(accountLockout) {
if (accountLockout) {
if (typeof accountLockout.duration !== 'number' || accountLockout.duration <= 0 || accountLockout.duration > 99999) {
throw 'Account lockout duration should be greater than 0 and less than 100000';
}
if (!Number.isInteger(accountLockout.threshold) || accountLockout.threshold < 1 || accountLockout.threshold > 999) {
throw 'Account lockout threshold should be an integer greater than 0 and less than 1000';
}
}
}
static validateEmailConfiguration({emailAdapter, appName, publicServerURL, emailVerifyTokenValidityDuration}) {
if (!emailAdapter) {
throw 'An emailAdapter is required for e-mail verification and password resets.';
}

View File

@@ -43,7 +43,7 @@ const transformObjectACL = ({ ACL, ...result }) => {
return result;
}
const specialQuerykeys = ['$and', '$or', '_rperm', '_wperm', '_perishable_token', '_email_verify_token', '_email_verify_token_expires_at'];
const specialQuerykeys = ['$and', '$or', '_rperm', '_wperm', '_perishable_token', '_email_verify_token', '_email_verify_token_expires_at', '_account_lockout_expires_at', '_failed_login_count'];
const validateQuery = query => {
if (query.ACL) {
throw new Parse.Error(Parse.Error.INVALID_QUERY, 'Cannot query on ACL.');
@@ -166,6 +166,8 @@ const filterSensitiveData = (isMaster, aclGroup, className, object) => {
delete object._perishable_token;
delete object._tombstone;
delete object._email_verify_token_expires_at;
delete object._failed_login_count;
delete object._account_lockout_expires_at;
if ((aclGroup.indexOf(object.objectId) > -1)) {
return object;
@@ -182,7 +184,7 @@ const filterSensitiveData = (isMaster, aclGroup, className, object) => {
// acl: a list of strings. If the object to be updated has an ACL,
// one of the provided strings must provide the caller with
// write permissions.
const specialKeysForUpdate = ['_hashed_password', '_perishable_token', '_email_verify_token', '_email_verify_token_expires_at'];
const specialKeysForUpdate = ['_hashed_password', '_perishable_token', '_email_verify_token', '_email_verify_token_expires_at', '_account_lockout_expires_at', '_failed_login_count'];
DatabaseController.prototype.update = function(className, query, update, {
acl,
many,

View File

@@ -124,6 +124,7 @@ class ParseServer {
verifyUserEmails = defaults.verifyUserEmails,
preventLoginWithUnverifiedEmail = defaults.preventLoginWithUnverifiedEmail,
emailVerifyTokenValidityDuration,
accountLockout,
cacheAdapter,
emailAdapter,
publicServerURL,
@@ -211,6 +212,7 @@ class ParseServer {
verifyUserEmails: verifyUserEmails,
preventLoginWithUnverifiedEmail: preventLoginWithUnverifiedEmail,
emailVerifyTokenValidityDuration: emailVerifyTokenValidityDuration,
accountLockout: accountLockout,
allowClientClassCreation: allowClientClassCreation,
authDataManager: authDataManager(oauth, enableAnonymousUsers),
appName: appName,
@@ -297,7 +299,7 @@ class ParseServer {
let appRouter = new PromiseRouter(routes, appId);
appRouter.use(middlewares.allowCrossDomain);
appRouter.use(middlewares.handleParseHeaders);
batch.mountOnto(appRouter);
api.use(appRouter.expressRouter());

View File

@@ -2,6 +2,7 @@
import deepcopy from 'deepcopy';
import Config from '../Config';
import AccountLockout from '../AccountLockout';
import ClassesRouter from './ClassesRouter';
import PromiseRouter from '../PromiseRouter';
import rest from '../rest';
@@ -80,6 +81,8 @@ export class UsersRouter extends ClassesRouter {
}
let user;
let isValidPassword = false;
return req.config.database.find('_User', { username: req.body.username })
.then((results) => {
if (!results.length) {
@@ -90,11 +93,15 @@ export class UsersRouter extends ClassesRouter {
if (req.config.verifyUserEmails && req.config.preventLoginWithUnverifiedEmail && !user.emailVerified) {
throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'User email is not verified.');
}
return passwordCrypto.compare(req.body.password, user.password);
}).then((correct) => {
if (!correct) {
})
.then((correct) => {
isValidPassword = correct;
let accountLockoutPolicy = new AccountLockout(user, req.config);
return accountLockoutPolicy.handleLoginAttempt(isValidPassword);
})
.then(() => {
if (!isValidPassword) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
}

View File

@@ -163,6 +163,11 @@ export default {
help: "Email verification token validity duration",
action: numberParser("emailVerifyTokenValidityDuration")
},
"accountLockout": {
env: "PARSE_SERVER_ACCOUNT_LOCKOUT",
help: "account lockout policy for failed login attempts",
action: objectParser
},
"appName": {
env: "PARSE_SERVER_APP_NAME",
help: "Sets the app name"