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
10
README.md
10
README.md
@@ -206,6 +206,7 @@ The client keys used with Parse are no longer necessary with Parse Server. If yo
|
||||
* `loggerAdapter` - The default behavior/transport (File) can be changed by creating an adapter class (see [`LoggerAdapter.js`](https://github.com/ParsePlatform/parse-server/blob/master/src/Adapters/Logger/LoggerAdapter.js)).
|
||||
* `sessionLength` - The length of time in seconds that a session should be valid for. Defaults to 31536000 seconds (1 year).
|
||||
* `revokeSessionOnPasswordReset` - When a user changes their password, either through the reset password email or while logged in, all sessions are revoked if this is true. Set to false if you don't want to revoke sessions.
|
||||
* `accountLockout` - Lock account when a malicious user is attempting to determine an account password by trial and error.
|
||||
|
||||
##### Logging
|
||||
|
||||
@@ -259,7 +260,14 @@ var server = ParseServer({
|
||||
// Your API key from mailgun.com
|
||||
apiKey: 'key-mykey',
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// account lockout policy setting (OPTIONAL) - defaults to undefined
|
||||
// if the account lockout policy is set and there are more than `threshold` number of failed login attempts then the `login` api call returns error code `Parse.Error.OBJECT_NOT_FOUND` with error message `Your account is locked due to multiple failed login attempts. Please try again after <duration> minute(s)`. After `duration` minutes of no login attempts, the application will allow the user to try login again.
|
||||
accountLockout: {
|
||||
duration: 5, // duration policy setting determines the number of minutes that a locked-out account remains locked out before automatically becoming unlocked. Set it to a value greater than 0 and less than 100000.
|
||||
threshold: 3, // threshold policy setting determines the number of failed sign-in attempts that will cause a user account to be locked. Set it to an integer value greater than 0 and less than 1000.
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
318
spec/AccountLockoutPolicy.spec.js
Normal file
318
spec/AccountLockoutPolicy.spec.js
Normal file
@@ -0,0 +1,318 @@
|
||||
"use strict";
|
||||
|
||||
const Config = require("../src/Config");
|
||||
|
||||
var loginWithWrongCredentialsShouldFail = function(username, password) {
|
||||
return new Promise((resolve, reject) => {
|
||||
Parse.User.logIn(username, password)
|
||||
.then(user => reject('login should have failed'))
|
||||
.catch(err => {
|
||||
if (err.message === 'Invalid username/password.') {
|
||||
resolve();
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
var isAccountLockoutError = function(username, password, duration, waitTime) {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
Parse.User.logIn(username, password)
|
||||
.then(user => reject('login should have failed'))
|
||||
.catch(err => {
|
||||
if (err.message === 'Your account is locked due to multiple failed login attempts. Please try again after ' + duration + ' minute(s)') {
|
||||
resolve();
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}, waitTime);
|
||||
});
|
||||
};
|
||||
|
||||
describe("Account Lockout Policy: ", () => {
|
||||
|
||||
it('account should not be locked even after failed login attempts if account lockout policy is not set', done => {
|
||||
reconfigureServer({
|
||||
appName: 'unlimited',
|
||||
publicServerURL: 'http://localhost:1337/1',
|
||||
})
|
||||
.then(() => {
|
||||
var user = new Parse.User();
|
||||
user.setUsername('username1');
|
||||
user.setPassword('password');
|
||||
return user.signUp(null);
|
||||
})
|
||||
.then(user => {
|
||||
return loginWithWrongCredentialsShouldFail('username1', 'incorrect password 1');
|
||||
})
|
||||
.then(() => {
|
||||
return loginWithWrongCredentialsShouldFail('username1', 'incorrect password 2');
|
||||
})
|
||||
.then(() => {
|
||||
return loginWithWrongCredentialsShouldFail('username1', 'incorrect password 3');
|
||||
})
|
||||
.then(() => done())
|
||||
.catch(err => {
|
||||
fail('allow unlimited failed login attempts failed: ' + JSON.stringify(err));
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('throw error if duration is set to an invalid number', done => {
|
||||
reconfigureServer({
|
||||
appName: 'duration',
|
||||
accountLockout: {
|
||||
duration: 'invalid value',
|
||||
threshold: 5
|
||||
},
|
||||
publicServerURL: "https://my.public.server.com/1"
|
||||
})
|
||||
.then(() => {
|
||||
var config = new Config('test');
|
||||
fail('set duration to an invalid number test failed');
|
||||
done();
|
||||
})
|
||||
.catch(err => {
|
||||
if (err && err === 'Account lockout duration should be greater than 0 and less than 100000') {
|
||||
done();
|
||||
} else {
|
||||
fail('set duration to an invalid number test failed: ' + JSON.stringify(err));
|
||||
done();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('throw error if threshold is set to an invalid number', done => {
|
||||
reconfigureServer({
|
||||
appName: 'threshold',
|
||||
accountLockout: {
|
||||
duration: 5,
|
||||
threshold: 'invalid number'
|
||||
},
|
||||
publicServerURL: "https://my.public.server.com/1"
|
||||
})
|
||||
.then(() => {
|
||||
var config = new Config('test');
|
||||
fail('set threshold to an invalid number test failed');
|
||||
done();
|
||||
})
|
||||
.catch(err => {
|
||||
if (err && err === 'Account lockout threshold should be an integer greater than 0 and less than 1000') {
|
||||
done();
|
||||
} else {
|
||||
fail('set threshold to an invalid number test failed: ' + JSON.stringify(err));
|
||||
done();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('throw error if threshold is < 1', done => {
|
||||
reconfigureServer({
|
||||
appName: 'threshold',
|
||||
accountLockout: {
|
||||
duration: 5,
|
||||
threshold: 0
|
||||
},
|
||||
publicServerURL: "https://my.public.server.com/1"
|
||||
})
|
||||
.then(() => {
|
||||
var config = new Config('test');
|
||||
fail('threshold value < 1 is invalid test failed');
|
||||
done();
|
||||
})
|
||||
.catch(err => {
|
||||
if (err && err === 'Account lockout threshold should be an integer greater than 0 and less than 1000') {
|
||||
done();
|
||||
} else {
|
||||
fail('threshold value < 1 is invalid test failed: ' + JSON.stringify(err));
|
||||
done();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('throw error if threshold is > 999', done => {
|
||||
reconfigureServer({
|
||||
appName: 'threshold',
|
||||
accountLockout: {
|
||||
duration: 5,
|
||||
threshold: 1000
|
||||
},
|
||||
publicServerURL: "https://my.public.server.com/1"
|
||||
})
|
||||
.then(() => {
|
||||
var config = new Config('test');
|
||||
fail('threshold value > 999 is invalid test failed');
|
||||
done();
|
||||
})
|
||||
.catch(err => {
|
||||
if (err && err === 'Account lockout threshold should be an integer greater than 0 and less than 1000') {
|
||||
done();
|
||||
} else {
|
||||
fail('threshold value > 999 is invalid test failed: ' + JSON.stringify(err));
|
||||
done();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('throw error if duration is <= 0', done => {
|
||||
reconfigureServer({
|
||||
appName: 'duration',
|
||||
accountLockout: {
|
||||
duration: 0,
|
||||
threshold: 5
|
||||
},
|
||||
publicServerURL: "https://my.public.server.com/1"
|
||||
})
|
||||
.then(() => {
|
||||
var config = new Config('test');
|
||||
fail('duration value < 1 is invalid test failed');
|
||||
done();
|
||||
})
|
||||
.catch(err => {
|
||||
if (err && err === 'Account lockout duration should be greater than 0 and less than 100000') {
|
||||
done();
|
||||
} else {
|
||||
fail('duration value < 1 is invalid test failed: ' + JSON.stringify(err));
|
||||
done();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('throw error if duration is > 99999', done => {
|
||||
reconfigureServer({
|
||||
appName: 'duration',
|
||||
accountLockout: {
|
||||
duration: 100000,
|
||||
threshold: 5
|
||||
},
|
||||
publicServerURL: "https://my.public.server.com/1"
|
||||
})
|
||||
.then(() => {
|
||||
var config = new Config('test');
|
||||
fail('duration value > 99999 is invalid test failed');
|
||||
done();
|
||||
})
|
||||
.catch(err => {
|
||||
if (err && err === 'Account lockout duration should be greater than 0 and less than 100000') {
|
||||
done();
|
||||
} else {
|
||||
fail('duration value > 99999 is invalid test failed: ' + JSON.stringify(err));
|
||||
done();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('lock account if failed login attempts are above threshold', done => {
|
||||
reconfigureServer({
|
||||
appName: 'lockout threshold',
|
||||
accountLockout: {
|
||||
duration: 1,
|
||||
threshold: 2
|
||||
},
|
||||
publicServerURL: "http://localhost:8378/1"
|
||||
})
|
||||
.then(() => {
|
||||
var user = new Parse.User();
|
||||
user.setUsername("username2");
|
||||
user.setPassword("failedLoginAttemptsThreshold");
|
||||
return user.signUp();
|
||||
})
|
||||
.then(() => {
|
||||
return loginWithWrongCredentialsShouldFail('username2', 'wrong password');
|
||||
})
|
||||
.then(() => {
|
||||
return loginWithWrongCredentialsShouldFail('username2', 'wrong password');
|
||||
})
|
||||
.then(() => {
|
||||
return isAccountLockoutError('username2', 'wrong password', 1, 1);
|
||||
})
|
||||
.then(() => {
|
||||
done();
|
||||
})
|
||||
.catch(err => {
|
||||
fail('lock account after failed login attempts test failed: ' + JSON.stringify(err));
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('lock account for accountPolicy.duration minutes if failed login attempts are above threshold', done => {
|
||||
reconfigureServer({
|
||||
appName: 'lockout threshold',
|
||||
accountLockout: {
|
||||
duration: 0.05, // 0.05*60 = 3 secs
|
||||
threshold: 2
|
||||
},
|
||||
publicServerURL: "http://localhost:8378/1"
|
||||
})
|
||||
.then(() => {
|
||||
var user = new Parse.User();
|
||||
user.setUsername("username3");
|
||||
user.setPassword("failedLoginAttemptsThreshold");
|
||||
return user.signUp();
|
||||
})
|
||||
.then(() => {
|
||||
return loginWithWrongCredentialsShouldFail('username3', 'wrong password');
|
||||
})
|
||||
.then(() => {
|
||||
return loginWithWrongCredentialsShouldFail('username3', 'wrong password');
|
||||
})
|
||||
.then(() => {
|
||||
return isAccountLockoutError('username3', 'wrong password', 0.05, 1);
|
||||
})
|
||||
.then(() => {
|
||||
// account should still be locked even after 2 seconds.
|
||||
return isAccountLockoutError('username3', 'wrong password', 0.05, 2000);
|
||||
})
|
||||
.then(() => {
|
||||
done();
|
||||
})
|
||||
.catch(err => {
|
||||
fail('account should be locked for duration mins test failed: ' + JSON.stringify(err));
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('allow login for locked account after accountPolicy.duration minutes', done => {
|
||||
reconfigureServer({
|
||||
appName: 'lockout threshold',
|
||||
accountLockout: {
|
||||
duration: 0.05, // 0.05*60 = 3 secs
|
||||
threshold: 2
|
||||
},
|
||||
publicServerURL: "http://localhost:8378/1"
|
||||
})
|
||||
.then(() => {
|
||||
var user = new Parse.User();
|
||||
user.setUsername("username4");
|
||||
user.setPassword("correct password");
|
||||
return user.signUp();
|
||||
})
|
||||
.then(() => {
|
||||
return loginWithWrongCredentialsShouldFail('username4', 'wrong password');
|
||||
})
|
||||
.then(() => {
|
||||
return loginWithWrongCredentialsShouldFail('username4', 'wrong password');
|
||||
})
|
||||
.then(() => {
|
||||
// allow locked user to login after 3 seconds with a valid userid and password
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
Parse.User.logIn('username4', 'correct password')
|
||||
.then(user => resolve())
|
||||
.catch(err => reject(err));
|
||||
}, 3001);
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
done();
|
||||
})
|
||||
.catch(err => {
|
||||
fail('allow login for locked account after accountPolicy.duration minutes test failed: ' + JSON.stringify(err));
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
})
|
||||
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