Added verify password to users router and tests. (#4747)

* Added verify password to users router and tests.

* Added more tests to support more coverage.

* Added additional tests to spec. Removed condition from verifyPassword function where authData null keys condition wasn't necessary.

* Removed POST handling from verifyPassword.

* Refactored handleLogin and handleVerifyPassword to use shared helper function to validate the password provided in the request.

* Refactored verifyPassword and login to not use try/catch. Parent promise returns the error. Moved login specific functions to login handler.

* Added account lockout policy to verify password function. Added test spec for account lockout in verify password.

* no message

* Merged new changes from master. Made changes as requested from comments.

* We cannot remove hidden properties from the helper before returning to the login function. The password expiration check in the login function is dependent on some hidden properties, otherwise three password policy tests fail.
This commit is contained in:
Johnny
2018-06-13 20:19:53 +02:00
committed by Florent Vilmart
parent 4d81f8fc30
commit c8b303a9d2
2 changed files with 627 additions and 93 deletions

View File

@@ -0,0 +1,494 @@
"use strict";
const rp = require('request-promise');
const MockEmailAdapterWithOptions = require('./MockEmailAdapterWithOptions');
const verifyPassword = function (login, password, isEmail = false) {
const body = (!isEmail) ? { username: login, password } : { email: login, password };
return rp.get({
url: Parse.serverURL + '/verifyPassword',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-REST-API-Key': 'rest'
},
body,
json: true
}).then((res) => res)
.catch((err) => err);
};
const isAccountLockoutError = function (username, password, duration, waitTime) {
return new Promise((resolve, reject) => {
setTimeout(() => {
Parse.User.logIn(username, password)
.then(() => 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("Verify User Password", () => {
it('fails to verify password when masterKey has locked out user', (done) => {
const user = new Parse.User();
const ACL = new Parse.ACL();
ACL.setPublicReadAccess(false);
ACL.setPublicWriteAccess(false);
user.setUsername('testuser');
user.setPassword('mypass');
user.setACL(ACL);
user.signUp().then(() => {
return Parse.User.logIn('testuser', 'mypass');
}).then((user) => {
equal(user.get('username'), 'testuser');
// Lock the user down
const ACL = new Parse.ACL();
user.setACL(ACL);
return user.save(null, { useMasterKey: true });
}).then(() => {
expect(user.getACL().getPublicReadAccess()).toBe(false);
return rp.get({
url: Parse.serverURL + '/verifyPassword',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-REST-API-Key': 'rest'
},
qs: {
username: 'testuser',
password: 'mypass',
}
});
}).then((res) => {
fail(res);
done();
}).catch((err) => {
expect(err.statusCode).toBe(404);
expect(err.error).toMatch('{"code":101,"error":"Invalid username/password."}');
done();
});
});
it('fails to verify password when username is not provided in query string REST API', (done) => {
const user = new Parse.User();
user.save({
username: 'testuser',
password: 'mypass',
email: 'my@user.com'
}).then(() => {
return rp.get({
url: Parse.serverURL + '/verifyPassword',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-REST-API-Key': 'rest'
},
qs: {
username: '',
password: 'mypass',
}
});
}).then((res) => {
fail(res);
done();
}).catch((err) => {
expect(err.statusCode).toBe(400);
expect(err.error).toMatch('{"code":200,"error":"username/email is required."}');
done();
});
});
it('fails to verify password when email is not provided in query string REST API', (done) => {
const user = new Parse.User();
user.save({
username: 'testuser',
password: 'mypass',
email: 'my@user.com'
}).then(() => {
return rp.get({
url: Parse.serverURL + '/verifyPassword',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-REST-API-Key': 'rest'
},
qs: {
email: '',
password: 'mypass',
}
});
}).then((res) => {
fail(res);
done();
}).catch((err) => {
expect(err.statusCode).toBe(400);
expect(err.error).toMatch('{"code":200,"error":"username/email is required."}');
done();
});
});
it('fails to verify password when username is not provided with json payload REST API', (done) => {
const user = new Parse.User();
user.save({
username: 'testuser',
password: 'mypass',
email: 'my@user.com'
}).then(() => {
return verifyPassword('', 'mypass');
}).then((res) => {
expect(res.statusCode).toBe(400);
expect(JSON.stringify(res.error)).toMatch('{"code":200,"error":"username/email is required."}');
done();
}).catch((err) => {
fail(err);
done();
});
});
it('fails to verify password when email is not provided with json payload REST API', (done) => {
const user = new Parse.User();
user.save({
username: 'testuser',
password: 'mypass',
email: 'my@user.com'
}).then(() => {
return verifyPassword('', 'mypass', true);
}).then((res) => {
expect(res.statusCode).toBe(400);
expect(JSON.stringify(res.error)).toMatch('{"code":200,"error":"username/email is required."}');
done();
}).catch((err) => {
fail(err);
done();
});
});
it('fails to verify password when password is not provided with json payload REST API', (done) => {
const user = new Parse.User();
user.save({
username: 'testuser',
password: 'mypass',
email: 'my@user.com'
}).then(() => {
return verifyPassword('testuser', '');
}).then((res) => {
expect(res.statusCode).toBe(400);
expect(JSON.stringify(res.error)).toMatch('{"code":201,"error":"password is required."}');
done();
}).catch((err) => {
fail(err);
done();
});
});
it('fails to verify password when username matches but password does not match hash with json payload REST API', (done) => {
const user = new Parse.User();
user.save({
username: 'testuser',
password: 'mypass',
email: 'my@user.com'
}).then(() => {
return verifyPassword('testuser', 'wrong password');
}).then((res) => {
expect(res.statusCode).toBe(404);
expect(JSON.stringify(res.error)).toMatch('{"code":101,"error":"Invalid username/password."}');
done();
}).catch((err) => {
fail(err);
done();
});
});
it('fails to verify password when email matches but password does not match hash with json payload REST API', (done) => {
const user = new Parse.User();
user.save({
username: 'testuser',
password: 'mypass',
email: 'my@user.com'
}).then(() => {
return verifyPassword('my@user.com', 'wrong password', true);
}).then((res) => {
expect(res.statusCode).toBe(404);
expect(JSON.stringify(res.error)).toMatch('{"code":101,"error":"Invalid username/password."}');
done();
}).catch((err) => {
fail(err);
done();
});
});
it('fails to verify password when typeof username does not equal string REST API', (done) => {
const user = new Parse.User();
user.save({
username: 'testuser',
password: 'mypass',
email: 'my@user.com'
}).then(() => {
return verifyPassword(123, 'mypass');
}).then((res) => {
expect(res.statusCode).toBe(404);
expect(JSON.stringify(res.error)).toMatch('{"code":101,"error":"Invalid username/password."}');
done();
}).catch((err) => {
fail(err);
done();
});
});
it('fails to verify password when typeof email does not equal string REST API', (done) => {
const user = new Parse.User();
user.save({
username: 'testuser',
password: 'mypass',
email: 'my@user.com'
}).then(() => {
return verifyPassword(123, 'mypass', true);
}).then((res) => {
expect(res.statusCode).toBe(404);
expect(JSON.stringify(res.error)).toMatch('{"code":101,"error":"Invalid username/password."}');
done();
}).catch((err) => {
fail(err);
done();
});
});
it('fails to verify password when typeof password does not equal string REST API', (done) => {
const user = new Parse.User();
user.save({
username: 'testuser',
password: 'mypass',
email: 'my@user.com'
}).then(() => {
return verifyPassword('my@user.com', 123, true);
}).then((res) => {
expect(res.statusCode).toBe(404);
expect(JSON.stringify(res.error)).toMatch('{"code":101,"error":"Invalid username/password."}');
done();
}).catch((err) => {
fail(err);
done();
});
});
it('fails to verify password when username cannot be found REST API', (done) => {
verifyPassword('mytestuser', 'mypass')
.then((res) => {
expect(res.statusCode).toBe(404);
expect(JSON.stringify(res.error)).toMatch('{"code":101,"error":"Invalid username/password."}');
done();
}).catch((err) => {
fail(err);
done();
});
});
it('fails to verify password when email cannot be found REST API', (done) => {
verifyPassword('my@user.com', 'mypass', true)
.then((res) => {
expect(res.statusCode).toBe(404);
expect(JSON.stringify(res.error)).toMatch('{"code":101,"error":"Invalid username/password."}');
done();
}).catch((err) => {
fail(err);
done();
});
});
it('fails to verify password when preventLoginWithUnverifiedEmail is set to true REST API', (done) => {
reconfigureServer({
publicServerURL: "http://localhost:8378/",
appName: 'emailVerify',
verifyUserEmails: true,
preventLoginWithUnverifiedEmail: true,
emailAdapter: MockEmailAdapterWithOptions({
fromAddress: 'parse@example.com',
apiKey: 'k',
domain: 'd',
}),
}).then(() => {
const user = new Parse.User();
return user.save({
username: 'unverified-user',
password: 'mypass',
email: 'unverified-email@user.com'
});
}).then(() => {
return verifyPassword('unverified-email@user.com', 'mypass', true);
}).then((res) => {
expect(res.statusCode).toBe(400);
expect(JSON.stringify(res.error)).toMatch('{"code":205,"error":"User email is not verified."}');
done();
}).catch((err) => {
fail(err);
done();
});
});
it('verify password lock account if failed verify password attempts are above threshold', done => {
reconfigureServer({
appName: 'lockout threshold',
accountLockout: {
duration: 1,
threshold: 2
},
publicServerURL: "http://localhost:8378/"
})
.then(() => {
const user = new Parse.User();
return user.save({
username: 'testuser',
password: 'mypass',
email: 'my@user.com'
})
})
.then(() => {
return verifyPassword('testuser', 'wrong password');
})
.then(() => {
return verifyPassword('testuser', 'wrong password');
})
.then(() => {
return verifyPassword('testuser', 'wrong password');
})
.then(() => {
return isAccountLockoutError('testuser', 'wrong password', 1, 1);
})
.then(() => {
done();
})
.catch(err => {
fail('lock account after failed login attempts test failed: ' + JSON.stringify(err));
done();
});
});
it('succeed in verifying password when username and email are provided and password matches hash with json payload REST API', (done) => {
const user = new Parse.User();
user.save({
username: 'testuser',
password: 'mypass',
email: 'my@user.com'
}).then(() => {
return rp.get({
url: Parse.serverURL + '/verifyPassword',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-REST-API-Key': 'rest'
},
body: {
username: 'testuser',
email: 'my@user.com',
password: 'mypass'
},
json: true
}).then((res) => res)
.catch((err) => err);
}).then((res) => {
expect(typeof res).toBe('object');
expect(typeof res['objectId']).toEqual('string');
expect(res.hasOwnProperty('sessionToken')).toEqual(false);
expect(res.hasOwnProperty('password')).toEqual(false);
done();
}).catch((err) => {
fail(err);
done();
});
});
it('succeed in verifying password when username and password matches hash with json payload REST API', (done) => {
const user = new Parse.User();
user.save({
username: 'testuser',
password: 'mypass',
email: 'my@user.com'
}).then(() => {
return verifyPassword('testuser', 'mypass');
}).then((res) => {
expect(typeof res).toBe('object');
expect(typeof res['objectId']).toEqual('string');
expect(res.hasOwnProperty('sessionToken')).toEqual(false);
expect(res.hasOwnProperty('password')).toEqual(false);
done();
});
});
it('succeed in verifying password when email and password matches hash with json payload REST API', (done) => {
const user = new Parse.User();
user.save({
username: 'testuser',
password: 'mypass',
email: 'my@user.com'
}).then(() => {
return verifyPassword('my@user.com', 'mypass', true);
}).then((res) => {
expect(typeof res).toBe('object');
expect(typeof res['objectId']).toEqual('string');
expect(res.hasOwnProperty('sessionToken')).toEqual(false);
expect(res.hasOwnProperty('password')).toEqual(false);
done();
});
});
it('succeed to verify password when username and password provided in query string REST API', (done) => {
const user = new Parse.User();
user.save({
username: 'testuser',
password: 'mypass',
email: 'my@user.com'
}).then(() => {
return rp.get({
url: Parse.serverURL + '/verifyPassword',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-REST-API-Key': 'rest'
},
qs: {
username: 'testuser',
password: 'mypass',
}
});
}).then((res) => {
expect(typeof res).toBe('string');
const body = JSON.parse(res);
expect(typeof body['objectId']).toEqual('string');
expect(body.hasOwnProperty('sessionToken')).toEqual(false);
expect(body.hasOwnProperty('password')).toEqual(false);
done();
});
});
it('succeed to verify password when email and password provided in query string REST API', (done) => {
const user = new Parse.User();
user.save({
username: 'testuser',
password: 'mypass',
email: 'my@user.com'
}).then(() => {
return rp.get({
url: Parse.serverURL + '/verifyPassword',
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-REST-API-Key': 'rest'
},
qs: {
email: 'my@user.com',
password: 'mypass',
}
});
}).then((res) => {
expect(typeof res).toBe('string');
const body = JSON.parse(res);
expect(typeof body['objectId']).toEqual('string');
expect(body.hasOwnProperty('sessionToken')).toEqual(false);
expect(body.hasOwnProperty('password')).toEqual(false);
done();
});
});
it('succeed to verify password with username when user1 has username === user2 email REST API', (done) => {
const user1 = new Parse.User();
user1.save({
username: 'email@user.com',
password: 'mypass1',
email: '1@user.com'
}).then(() => {
const user2 = new Parse.User();
return user2.save({
username: 'user2',
password: 'mypass2',
email: 'email@user.com'
});
}).then(() => {
return verifyPassword('email@user.com', 'mypass1');
}).then((res) => {
expect(typeof res).toBe('object');
expect(typeof res['objectId']).toEqual('string');
expect(res.hasOwnProperty('sessionToken')).toEqual(false);
expect(res.hasOwnProperty('password')).toEqual(false);
done();
});
});
})

View File

@@ -1,11 +1,11 @@
// These methods handle the User-related routes.
import Parse from 'parse/node';
import Config from '../Config';
import Parse from 'parse/node';
import Config from '../Config';
import AccountLockout from '../AccountLockout';
import ClassesRouter from './ClassesRouter';
import rest from '../rest';
import Auth from '../Auth';
import ClassesRouter from './ClassesRouter';
import rest from '../rest';
import Auth from '../Auth';
import passwordCrypto from '../password';
export class UsersRouter extends ClassesRouter {
@@ -18,7 +18,7 @@ export class UsersRouter extends ClassesRouter {
* Removes all "_" prefixed properties from an object, except "__type"
* @param {Object} obj An object.
*/
static removeHiddenProperties (obj) {
static removeHiddenProperties(obj) {
for (var key in obj) {
if (obj.hasOwnProperty(key)) {
// Regexp comes from Parse.Object.prototype.validate
@@ -29,6 +29,105 @@ export class UsersRouter extends ClassesRouter {
}
}
/**
* Validates a password request in login and verifyPassword
* @param {Object} req The request
* @returns {Object} User object
* @private
*/
_authenticateUserFromRequest(req) {
return new Promise((resolve, reject) => {
// Use query parameters instead if provided in url
let payload = req.body;
if (!payload.username && req.query.username || !payload.email && req.query.email) {
payload = req.query;
}
const {
username,
email,
password,
} = payload;
// TODO: use the right error codes / descriptions.
if (!username && !email) {
throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'username/email is required.');
}
if (!password) {
throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'password is required.');
}
if (typeof password !== 'string'
|| email && typeof email !== 'string'
|| username && typeof username !== 'string') {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
}
let user;
let isValidPassword = false;
let query;
if (email && username) {
query = { email, username };
} else if (email) {
query = { email };
} else {
query = { $or: [{ username }, { email: username }] };
}
return req.config.database.find('_User', query)
.then((results) => {
if (!results.length) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
}
if (results.length > 1) { // corner case where user1 has username == user2 email
req.config.loggerController.warn('There is a user which email is the same as another user\'s username, logging in based on username');
user = results.filter((user) => user.username === username)[0];
} else {
user = results[0];
}
return passwordCrypto.compare(password, user.password);
})
.then((correct) => {
isValidPassword = correct;
const 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.');
}
// Ensure the user isn't locked out
// A locked out user won't be able to login
// To lock a user out, just set the ACL to `masterKey` only ({}).
// Empty ACL is OK
if (!req.auth.isMaster && user.ACL && Object.keys(user.ACL).length == 0) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
}
if (req.config.verifyUserEmails && req.config.preventLoginWithUnverifiedEmail && !user.emailVerified) {
throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'User email is not verified.');
}
delete user.password;
// Sometimes the authData still has null on that keys
// https://github.com/parse-community/parse-server/issues/935
if (user.authData) {
Object.keys(user.authData).forEach((provider) => {
if (user.authData[provider] === null) {
delete user.authData[provider];
}
});
if (Object.keys(user.authData).length == 0) {
delete user.authData;
}
}
return resolve(user);
}).catch((error) => {
return reject(error);
});
});
}
handleMe(req) {
if (!req.info || !req.info.sessionToken) {
throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'invalid session token');
@@ -56,74 +155,12 @@ export class UsersRouter extends ClassesRouter {
}
handleLogIn(req) {
// Use query parameters instead if provided in url
let payload = req.body;
if (!payload.username && req.query.username || !payload.email && req.query.email) {
payload = req.query;
}
const {
username,
email,
password,
} = payload;
// TODO: use the right error codes / descriptions.
if (!username && !email) {
throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'username/email is required.');
}
if (!password) {
throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'password is required.');
}
if (typeof password !== 'string'
|| email && typeof email !== 'string'
|| username && typeof username !== 'string') {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
}
let user;
let isValidPassword = false;
let query;
if (email && username) {
query = { email, username };
} else if (email) {
query = { email };
} else {
query = { $or: [{ username } , { email: username }] };
}
return req.config.database.find('_User', query)
.then((results) => {
if (!results.length) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
}
return this._authenticateUserFromRequest(req)
.then((res) => {
if (results.length > 1) { // corner case where user1 has username == user2 email
req.config.loggerController.warn('There is a user which email is the same as another user\'s username, logging in based on username');
user = results.filter((user) => user.username === username)[0];
} else {
user = results[0];
}
user = res;
return passwordCrypto.compare(password, user.password);
})
.then((correct) => {
isValidPassword = correct;
const 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.');
}
// Ensure the user isn't locked out
// A locked out user won't be able to login
// To lock a user out, just set the ACL to `masterKey` only ({}).
// Empty ACL is OK
if (!req.auth.isMaster && user.ACL && Object.keys(user.ACL).length == 0) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
}
if (req.config.verifyUserEmails && req.config.preventLoginWithUnverifiedEmail && !user.emailVerified) {
throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'User email is not verified.');
}
// handle password expiry policy
if (req.config.passwordPolicy && req.config.passwordPolicy.maxPasswordAge) {
let changedAt = user._password_changed_at;
@@ -132,8 +169,8 @@ export class UsersRouter extends ClassesRouter {
// password was created before expiry policy was enabled.
// simply update _User object so that it will start enforcing from now
changedAt = new Date();
req.config.database.update('_User', {username: user.username},
{_password_changed_at: Parse._encode(changedAt)});
req.config.database.update('_User', { username: user.username },
{ _password_changed_at: Parse._encode(changedAt) });
} else {
// check whether the password has expired
if (changedAt.__type == 'Date') {
@@ -146,43 +183,45 @@ export class UsersRouter extends ClassesRouter {
}
}
delete user.password;
// Remove hidden properties.
UsersRouter.removeHiddenProperties(user);
// Sometimes the authData still has null on that keys
// https://github.com/parse-community/parse-server/issues/935
if (user.authData) {
Object.keys(user.authData).forEach((provider) => {
if (user.authData[provider] === null) {
delete user.authData[provider];
}
});
if (Object.keys(user.authData).length == 0) {
delete user.authData;
}
}
const {
sessionData,
createSession
} = Auth.createSession(req.config, { userId: user.objectId, createdWith: {
'action': 'login',
'authProvider': 'password'
}, installationId: req.info.installationId });
} = Auth.createSession(req.config, {
userId: user.objectId, createdWith: {
'action': 'login',
'authProvider': 'password'
}, installationId: req.info.installationId
});
user.sessionToken = sessionData.sessionToken;
req.config.filesController.expandFilesInObject(req.config, user);
return createSession();
}).then(() => {
})
.then(() => {
return { response: user };
});
}
handleVerifyPassword(req) {
return this._authenticateUserFromRequest(req)
.then((user) => {
// Remove hidden properties.
UsersRouter.removeHiddenProperties(user);
return { response: user };
}).catch((error) => {
throw error;
});
}
handleLogOut(req) {
const success = {response: {}};
const success = { response: {} };
if (req.info && req.info.sessionToken) {
return rest.find(req.config, Auth.master(req.config), '_Session',
{ sessionToken: req.info.sessionToken }, undefined, req.info.clientSDK
@@ -287,6 +326,7 @@ export class UsersRouter extends ClassesRouter {
this.route('POST', '/logout', req => { return this.handleLogOut(req); });
this.route('POST', '/requestPasswordReset', req => { return this.handleResetRequest(req); });
this.route('POST', '/verificationEmailRequest', req => { return this.handleVerificationEmailRequest(req); });
this.route('GET', '/verifyPassword', req => { return this.handleVerifyPassword(req); });
}
}