diff --git a/spec/VerifyUserPassword.spec.js b/spec/VerifyUserPassword.spec.js new file mode 100644 index 00000000..28bd7c38 --- /dev/null +++ b/spec/VerifyUserPassword.spec.js @@ -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(); + }); + }); +}) diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 29f1efe5..2e8af004 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -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); }); } }