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:
committed by
Florent Vilmart
parent
f6516a1d1e
commit
28bd37884d
200
src/AccountLockout.js
Normal file
200
src/AccountLockout.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user