diff --git a/actions/resetPassword.js b/actions/resetPassword.js index e11acbdd9..4b533e650 100644 --- a/actions/resetPassword.js +++ b/actions/resetPassword.js @@ -1,15 +1,49 @@ /* * Copyright (C) 2014 TopCoder Inc., All Rights Reserved. * - * @version 1.0 - * @author LazyChild + * @version 1.1 + * @author LazyChild, isv + * + * changes in 1.1 + * - implemented generateResetToken function */ "use strict"; var async = require('async'); +var stringUtils = require("../common/stringUtils.js"); +var moment = require('moment-timezone'); + +var NotFoundError = require('../errors/NotFoundError'); var BadRequestError = require('../errors/BadRequestError'); var UnauthorizedError = require('../errors/UnauthorizedError'); var ForbiddenError = require('../errors/ForbiddenError'); +var TOKEN_ALPHABET = stringUtils.ALPHABET_ALPHA_EN + stringUtils.ALPHABET_DIGITS_EN; + +/** + * Looks up for the user account matching specified handle (either TopCoder handle or social login username) or email. + * If user account is not found then NotFoundError is returned to callback; otherwise ID for found user account is + * passed to callback. + * + * @param {String} handle - the handle to check. + * @param {String} email - the email to check. + * @param {Object} api - the action hero api object. + * @param {Object} dbConnectionMap - the database connection map. + * @param {Function} callback - the callback function. + */ +var resolveUserByHandleOrEmail = function (handle, email, api, dbConnectionMap, callback) { + api.dataAccess.executeQuery("find_user_by_handle_or_email", { handle: handle, email: email }, dbConnectionMap, + function (err, result) { + if (err) { + callback(err); + return; + } + if (result && result[0]) { + callback(null, result[0]); + } else { + callback(new NotFoundError("User does not exist")); + } + }); +}; /** * This is the function that stub reset password @@ -21,24 +55,21 @@ var ForbiddenError = require('../errors/ForbiddenError'); function resetPassword(api, connection, next) { var result, helper = api.helper; async.waterfall([ - function(cb) { - if (connection.params.handle == "nonValid") { + function (cb) { + if (connection.params.handle === "nonValid") { cb(new BadRequestError("The handle you entered is not valid")); - return; - } else if (connection.params.handle == "badLuck") { + } else if (connection.params.handle === "badLuck") { cb(new Error("Unknown server error. Please contact support.")); - return; - } else if (connection.params.token == "unauthorized_token") { + } else if (connection.params.token === "unauthorized_token") { cb(new UnauthorizedError("Authentication credentials were missing or incorrect.")); - return; - } else if (connection.params.token == "forbidden_token") { + } else if (connection.params.token === "forbidden_token") { cb(new ForbiddenError("The request is understood, but it has been refused or access is not allowed.")); - return; + } else { + result = { + "description": "Your password has been reset!" + }; + cb(); } - result = { - "description": "Your password has been reset!" - }; - cb(); } ], function (err) { if (err) { @@ -50,45 +81,55 @@ function resetPassword(api, connection, next) { }); } + /** - * This is the function that stub reset token + * Generates the token for resetting the password for specified user account. First checks if non-expired token already + * exists for the user. If so then BadRequestError is passed to callback. Otherwise a new token is generated and saved + * to cache and returned to callback. * + * @param {Number} userHandle - handle of user to generate token for. + * @param {String} userEmailAddress - email address of user to email generated token to. * @param {Object} api - The api object that is used to access the global infrastructure - * @param {Object} connection - The connection object for the current request - * @param {Function} next - The callback to be called after this function is done + * @param {Function} callback - the callback function. */ -function generateResetToken(api, connection, next) { - var result, helper = api.helper; - async.waterfall([ - function(cb) { - if (connection.params.handle == "nonValid" || connection.params.email == "nonValid@test.com") { - cb(new BadRequestError("The handle you entered is not valid")); - return; - } else if (connection.params.handle == "badLuck" || connection.params.email == "badLuck@test.com") { - cb(new Error("Unknown server error. Please contact support.")); - return; - } +var generateResetToken = function (userHandle, userEmailAddress, api, callback) { + var tokenCacheKey = 'tokens-' + userHandle + '-reset-token', + current, + expireDate, + expireDateString, + emailParams; - if (connection.params.handle == "googleSocial" || connection.params.email == "googleSocial@test.com") { - result = { - "socialLogin": "Google" - }; - } else { - result = { - "token": "a3cbG" - }; - } - cb(); - } - ], function (err) { + api.helper.getCachedValue(tokenCacheKey, function (err, token) { if (err) { - helper.handleError(api, connection, err); + callback(err); + } else if (token) { + // Non-expired token already exists for this user - raise an error + callback(new BadRequestError("You have already requested the reset token, please find it in your email inbox." + + " If it's not there. Please contact support@topcoder.com.")); } else { - connection.response = result; + // There is no token - generate new one + var newToken = stringUtils.generateRandomString(TOKEN_ALPHABET, 6), + lifetime = api.config.general.defaultResetPasswordTokenCacheLifetime; + api.cache.save(tokenCacheKey, newToken, lifetime); + + // Send email with token to user + current = new Date(); + expireDate = current.setSeconds(current.getSeconds() + lifetime / 1000); + expireDateString = moment(expireDate).tz('America/New_York').format('YYYY-MM-DD HH:mm:ss z'); + emailParams = { + handle: userHandle, + token: newToken, + expiry: expireDateString, + template: 'reset_token_email', + subject: api.config.general.resetPasswordTokenEmailSubject, + toAddress: userEmailAddress + }; + api.tasks.enqueue("sendEmail", emailParams, 'default'); + + callback(null, newToken); } - next(connection, true); }); -} +}; /** * Reset password API. @@ -113,8 +154,8 @@ exports.resetPassword = { * Generate reset token API. */ exports.generateResetToken = { - "name": "generateResetToken", - "description": "generateResetToken", + name: "generateResetToken", + description: "generateResetToken", inputs: { required: [], optional: ["handle", "email"] @@ -122,8 +163,49 @@ exports.generateResetToken = { blockedConnectionTypes: [], outputExample: {}, version: 'v2', + cacheEnabled: false, + transaction: 'read', + databases: ["common_oltp"], run: function (api, connection, next) { api.log("Execute generateResetToken#run", 'debug'); - generateResetToken(api, connection, next); + if (connection.dbConnectionMap) { + async.waterfall([ + function (cb) { // Find the user either by handle or by email + // Get handle, email from request parameters + var handle = (connection.params.handle || '').trim(), + email = (connection.params.email || '').trim(), + byHandle = (handle !== ''), + byEmail = (email !== ''); + + // Validate the input parameters, either handle or email but not both must be provided + if (byHandle && byEmail) { + cb(new BadRequestError("Both handle and email are specified")); + } else if (!byHandle && !byEmail) { + cb(new BadRequestError("Either handle or email must be specified")); + } else { + resolveUserByHandleOrEmail(handle, email, api, connection.dbConnectionMap, cb); + } + }, function (result, cb) { + if (result.social_login_provider_name !== '') { + // For social login accounts return the provider name + cb(null, null, result.social_login_provider_name); + } else { + // Generate reset password token for user + generateResetToken(result.handle, result.email_address, api, cb); + } + } + ], function (err, newToken, socialProviderName) { + if (err) { + api.helper.handleError(api, connection, err); + } else if (newToken) { + connection.response = {successful: true}; + } else if (socialProviderName) { + connection.response = {socialProvider: socialProviderName}; + } + next(connection, true); + }); + } else { + api.helper.handleNoConnection(api, connection, next); + } } }; diff --git a/apiary.apib b/apiary.apib index 2c27f55a4..c0a42b45f 100644 --- a/apiary.apib +++ b/apiary.apib @@ -1220,17 +1220,17 @@ Register a new user. ## Generate Reset Token [/users/resetToken/?handle={handle}&email={email}] ### Generate Reset Token [GET] -- return token for topcoder user +- return "successful" flag set to true - return social provider name for social login user + Parameters - + handle (optional, string, `iRabbit`) ... Member Handle - + email (optional, string, `test@test.com`) ... Email Address + + handle (optional, string, `iRabbit`) ... Member Handle or Social Login Username + + email (optional, string, `test@test.com`) ... Member Email (mutually exclusive with handle parameter) + Response 200 (application/json) { - "token":"a3cbG" + "successful":"true" } + Response 200 (application/json) @@ -1244,7 +1244,31 @@ Register a new user. { "name":"Bad Request", "value":"400", - "description":"The handle you entered is not valid" + "description":"Either handle or email must be specified" + } + ++ Response 400 (application/json) + + { + "name":"Bad Request", + "value":"400", + "description":"Both handle and email are specified" + } + ++ Response 400 (application/json) + + { + "name":"Bad Request", + "value":"400", + "description":"You have already requested the reset token, please find it in your email inbox. If it's not there. Please contact support@topcoder.com." + } + ++ Response 404 (application/json) + + { + "name":"Not Found", + "value":"404", + "description":"User does not exist" } + Response 500 (application/json) @@ -1263,6 +1287,7 @@ Register a new user. "description":"Servers are up but overloaded. Try again later." } + ## Reset Password [/users/resetPassword/{handle}] ### Reset Password [POST] + Parameters diff --git a/common/stringUtils.js b/common/stringUtils.js index 460eb2261..0187d02a6 100644 --- a/common/stringUtils.js +++ b/common/stringUtils.js @@ -1,8 +1,11 @@ /* - * Copyright (C) 2013 TopCoder Inc., All Rights Reserved. + * Copyright (C) 2013-2014 TopCoder Inc., All Rights Reserved. * - * Version: 1.0 - * Author: TCSASSEMBLER + * Version: 1.1 + * Author: isv + * + * changes in 1.1: + * - add generateRandomString function. */ "use strict"; @@ -39,6 +42,23 @@ exports.containsOnly = function (string, alphabet) { return true; }; +/** + * Generates random string of specified length using the symbols from the specified alphabet. + * + * @param {String} alphabet - alphabet to use for string generation. + * @param {Number} length - the length for the string to be generated. + * @since 1.1 + */ +exports.generateRandomString = function (alphabet, length) { + var text = '', i, index; + for (i = 0; i < length; i = i + 1) { + index = Math.random() * alphabet.length; + text += alphabet.charAt(index); + } + + return text; +}; + exports.ALPHABET_ALPHA_UPPER_EN = ALPHABET_ALPHA_UPPER_EN; exports.ALPHABET_ALPHA_LOWER_EN = ALPHABET_ALPHA_LOWER_EN; exports.ALPHABET_ALPHA_EN = ALPHABET_ALPHA_EN; diff --git a/config.js b/config.js index 3c19ce519..050a2da62 100644 --- a/config.js +++ b/config.js @@ -2,7 +2,7 @@ * Copyright (C) 2013 - 2014 TopCoder Inc., All Rights Reserved. * * @author vangavroche, Ghost_141, kurtrips, Sky_, isv - * @version 1.17 + * @version 1.19 * changes in 1.1: * - add defaultCacheLifetime parameter * changes in 1.2: @@ -41,6 +41,9 @@ * - add welcome email property. * Changes in 1.17: * - add maxRSSLength. + * changes in 1.19: + * - add defaultResetPasswordTokenCacheLifetime property. + * - add resetPasswordTokenEmailSubject property. */ "use strict"; @@ -82,6 +85,8 @@ config.general = { defaultCacheLifetime : process.env.CACHE_EXPIRY || 1000 * 60 * 10, //10 min default defaultAuthMiddlewareCacheLifetime : process.env.AUTH_MIDDLEWARE_CACHE_EXPIRY || 1000 * 60 * 10, //10 min default defaultUserCacheLifetime: process.env.USER_CACHE_EXPIRY || 1000 * 60 * 60 * 24, //24 hours default + defaultResetPasswordTokenCacheLifetime: process.env.RESET_PASSWORD_TOKEN_CACHE_EXPIRY ? parseInt(process.env.RESET_PASSWORD_TOKEN_CACHE_EXPIRY, 10) : 1000 * 60 * 30, //30 min + resetPasswordTokenEmailSubject: process.env.RESET_PASSWORD_TOKEN_EMAIL_SUBJECT || "TopCoder Account Password Reset", cachePrefix: '', oauthClientId: process.env.OAUTH_CLIENT_ID || "CMaBuwSnY0Vu68PLrWatvvu3iIiGPh7t", //auth0 secret is encoded in base64! diff --git a/deploy/ci.sh b/deploy/ci.sh index daa862b44..74016d8ef 100644 --- a/deploy/ci.sh +++ b/deploy/ci.sh @@ -1,10 +1,16 @@ #!/bin/bash # -# Copyright (C) 2013 TopCoder Inc., All Rights Reserved. +# Copyright (C) 2013-2014 TopCoder Inc., All Rights Reserved. # -# Version: 1.0 -# Author: vangavroche, delemach +# Version: 1.1 +# Author: vangavroche, delemach, isv +# +# changes in 1.1: +# - added RESET_PASSWORD_TOKEN_CACHE_EXPIRY environment variable +# - added RESET_PASSWORD_TOKEN_EMAIL_SUBJECT environment variable +# - added REDIS_HOST environment variable +# - added REDIS_PORT environment variable # export CACHE_EXPIRY=-1 @@ -71,3 +77,10 @@ export GRANT_FORUM_ACCESS=false export DEV_FORUM_JNDI=jnp://env.topcoder.com:1199 export ACTIONHERO_CONFIG=./config.js + +## The period for expiring the generated tokens for password resetting +export RESET_PASSWORD_TOKEN_CACHE_EXPIRY=1800000 +export RESET_PASSWORD_TOKEN_EMAIL_SUBJECT=TopCoder Account Password Reset + +export REDIS_HOST=localhost +export REDIS_PORT=6379 diff --git a/deploy/development.bat b/deploy/development.bat index 6653a7489..b507fef79 100644 --- a/deploy/development.bat +++ b/deploy/development.bat @@ -2,9 +2,15 @@ REM REM Copyright (C) 2014 TopCoder Inc., All Rights Reserved. REM -REM Version: 1.0 -REM Author: TrePe +REM Version: 1.1 +REM Author: TrePe, isv REM +REM Changes in 1.1 +REM - added RESET_PASSWORD_TOKEN_CACHE_EXPIRY environment variable +REM - added RESET_PASSWORD_TOKEN_EMAIL_SUBJECT environment variable +REM - added REDIS_HOST environment variable +REM - added REDIS_PORT environment variable + REM tests rely on caching being off. But set this to a real value (or remove) while coding. @@ -70,3 +76,9 @@ set DEV_FORUM_JNDI=jnp://env.topcoder.com:1199 set ACTIONHERO_CONFIG=./config.js +REM The period for expiring the generated tokens for password resetting (in milliseconds) +set RESET_PASSWORD_TOKEN_CACHE_EXPIRY=15000 +set RESET_PASSWORD_TOKEN_EMAIL_SUBJECT=TopCoder Account Password Reset + +rem set REDIS_HOST=localhost +rem set REDIS_PORT=6379 diff --git a/deploy/development.sh b/deploy/development.sh index e56c3bde2..0cb45296c 100755 --- a/deploy/development.sh +++ b/deploy/development.sh @@ -1,12 +1,17 @@ #!/bin/bash # -# Copyright (C) 2013 TopCoder Inc., All Rights Reserved. +# Copyright (C) 2013-2014 TopCoder Inc., All Rights Reserved. # -# Version: 1.1 -# Author: vangavroche, TCSASSEMBLER +# Version: 1.2 +# Author: vangavroche, isv # changes in 1.1: # - add JIRA_USERNAME and JIRA_PASSWORD +# changes in 1.2: +# - added RESET_PASSWORD_TOKEN_CACHE_EXPIRY environment variable +# - added RESET_PASSWORD_TOKEN_EMAIL_SUBJECT environment variable +# - added REDIS_HOST environment variable +# - added REDIS_PORT environment variable # # tests rely on caching being off. But set this to a real value (or remove) while coding. @@ -72,3 +77,10 @@ export GRANT_FORUM_ACCESS=false export DEV_FORUM_JNDI=jnp://env.topcoder.com:1199 export ACTIONHERO_CONFIG=./config.js + +## The period for expiring the generated tokens for password resetting +export RESET_PASSWORD_TOKEN_CACHE_EXPIRY=1800000 +export RESET_PASSWORD_TOKEN_EMAIL_SUBJECT=TopCoder Account Password Reset + +export REDIS_HOST=localhost +export REDIS_PORT=6379 diff --git a/docs/Module Assembly - TopCoder Node JS Generate Reset Token API v1.0.doc b/docs/Module Assembly - TopCoder Node JS Generate Reset Token API v1.0.doc new file mode 100644 index 000000000..4fed4521c Binary files /dev/null and b/docs/Module Assembly - TopCoder Node JS Generate Reset Token API v1.0.doc differ diff --git a/mail_templates/reset_token_email/html.ejs b/mail_templates/reset_token_email/html.ejs new file mode 100644 index 000000000..c02976460 --- /dev/null +++ b/mail_templates/reset_token_email/html.ejs @@ -0,0 +1,20 @@ +

+Hi, <%= handle %>, +

+ +

+We have received a request to recover your password. If you requested this, please use the token <%= token %> to continue the process. +

+ +

+The token will expire at <%= expiry %>. +

+ + +

+If you did not make the request and/or believe you got this message in error, please email support@topcoder.com to let us know. +

+ +

+-TopCoder +

\ No newline at end of file diff --git a/mail_templates/reset_token_email/style.css b/mail_templates/reset_token_email/style.css new file mode 100644 index 000000000..05d671dc3 --- /dev/null +++ b/mail_templates/reset_token_email/style.css @@ -0,0 +1 @@ +p {} \ No newline at end of file diff --git a/mail_templates/reset_token_email/text.ejs b/mail_templates/reset_token_email/text.ejs new file mode 100644 index 000000000..f29443f8f --- /dev/null +++ b/mail_templates/reset_token_email/text.ejs @@ -0,0 +1,9 @@ +Hi, <%= handle %>, + +We have received a request to recover your password. If you requested this, please use the token <%= token %> to continue the process. + +The token will expire at <%= expiry %>. + +If you did not make the request and/or believe you got this message in error, please email support@topcoder.com to let us know. + +-TopCoder \ No newline at end of file diff --git a/package.json b/package.json index 46560036b..b69616314 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "validator": "~3.5.0", "adm-zip": "0.4.4", "mkdirp": "0.3.5", - "archiver": "~0.6.1" + "archiver": "~0.6.1", + "redis": "0.10.1" }, "devDependencies": { "supertest": "0.8.1", diff --git a/queries/find_user_by_handle_or_email b/queries/find_user_by_handle_or_email new file mode 100644 index 000000000..8bd0553d2 --- /dev/null +++ b/queries/find_user_by_handle_or_email @@ -0,0 +1,23 @@ +SELECT u.handle, NVL(slp.name, '') AS social_login_provider_name, e.address AS email_address +FROM ( + SELECT u.user_id, 0 + FROM user u + WHERE '' <> '@handle@' + AND u.handle_lower = LOWER('@handle@') + UNION ALL + SELECT e.user_id, 1 + FROM email e + WHERE '' <> '@email@' + AND LOWER(e.address) = LOWER('@email@') + UNION ALL + SELECT s.user_id, 2 + FROM user_social_login s + WHERE '' <> '@handle@' AND LOWER(s.social_user_name) = LOWER('@handle@') + OR + '' <> '@email@' AND LOWER(s.social_email) = LOWER('@email@') + ORDER BY 2 +) tmp +INNER JOIN user u ON u.user_id = tmp.user_id +LEFT JOIN email e ON e.user_id = tmp.user_id +LEFT JOIN user_social_login usl ON usl.user_id = tmp.user_id +LEFT JOIN social_login_provider slp ON slp.social_login_provider_id = usl.social_login_provider_id diff --git a/queries/find_user_by_handle_or_email.json b/queries/find_user_by_handle_or_email.json new file mode 100644 index 000000000..b435c3257 --- /dev/null +++ b/queries/find_user_by_handle_or_email.json @@ -0,0 +1,5 @@ +{ + "name": "find_user_by_handle_or_email", + "db": "common_oltp", + "sqlfile": "find_user_by_handle_or_email" +} \ No newline at end of file diff --git a/test/sqls/resetPassword/common_oltp__clean b/test/sqls/resetPassword/common_oltp__clean new file mode 100644 index 000000000..39a48c56c --- /dev/null +++ b/test/sqls/resetPassword/common_oltp__clean @@ -0,0 +1,11 @@ +DELETE FROM user_role_xref WHERE login_id BETWEEN 400011 AND 400099; +DELETE FROM user_social_login WHERE user_id BETWEEN 400011 AND 400099; +DELETE FROM user_address_xref WHERE user_id BETWEEN 400011 AND 400099; +DELETE FROM email WHERE user_id BETWEEN 400011 AND 400099; +DELETE FROM security_user WHERE login_id BETWEEN 400011 AND 400099; +DELETE FROM user WHERE user_id BETWEEN 400011 AND 400099; +DELETE FROM informixoltp:coder WHERE coder_id BETWEEN 400011 AND 400099; +DELETE FROM address WHERE address_id BETWEEN 40000001 AND 40000099; + + + diff --git a/test/sqls/resetPassword/common_oltp__insert_test_data b/test/sqls/resetPassword/common_oltp__insert_test_data new file mode 100644 index 000000000..5f6f51dd2 --- /dev/null +++ b/test/sqls/resetPassword/common_oltp__insert_test_data @@ -0,0 +1,154 @@ +INSERT INTO user (user_id, handle, status, timezone_id) VALUES (400011, 'normal_user_11', 'A', 143); +INSERT INTO security_user (login_id, user_id, password, create_user_id) VALUES (400011, 'normal_user_11', '4EjPjy6o+/C+dqNPnxIy9A==', NULL); +INSERT INTO informixoltp:coder (coder_id) VALUES (400011); +INSERT INTO email (user_id, email_id, email_type_id, address, create_date, modify_date, primary_ind, status_id) + VALUES (400011, 400011, 1, 'normal_user_11@test.com', CURRENT, CURRENT, 1, 1); +INSERT INTO address(address_id, address_type_id, address1, address2, city, state_code, zip, country_code, create_date, modify_date, address3, province) + VALUES (40000001, 2, 'address1', NULL, 'city', 'ME', '04043', '840', '2008-08-01 16:37:48.000', '2008-08-01 16:37:48.000', NULL, NULL); +INSERT INTO user_address_xref(user_id,address_id) VALUES (400011, 40000001); + + +INSERT INTO user (user_id, handle, status, timezone_id) VALUES (400012, 'normal_user_12', 'A', 143); +INSERT INTO security_user (login_id, user_id, password, create_user_id) VALUES (400012, 'normal_user_12', '4EjPjy6o+/C+dqNPnxIy9A==', NULL); +INSERT INTO informixoltp:coder (coder_id) VALUES (400012); +INSERT INTO email (user_id, email_id, email_type_id, address, create_date, modify_date, primary_ind, status_id) + VALUES (400012, 400012, 1, 'normal_user_12@test.com', CURRENT, CURRENT, 1, 1); +INSERT INTO address(address_id, address_type_id, address1, address2, city, state_code, zip, country_code, create_date, modify_date, address3, province) + VALUES (40000002, 2, 'address1', NULL, 'city', 'ME', '04043', '840', '2008-08-01 16:37:48.000', '2008-08-01 16:37:48.000', NULL, NULL); +INSERT INTO user_address_xref(user_id, address_id) VALUES (400012, 40000002); +INSERT INTO user_social_login (social_user_id, user_id, social_login_provider_id, social_user_name) + VALUES ('fb40002', 400012, 1, 'user2'); + +INSERT INTO user (user_id, handle, status, timezone_id) VALUES (400013, 'normal_user_13', 'A', 143); +INSERT INTO security_user (login_id, user_id, password, create_user_id) VALUES (400013, 'normal_user_13', '4EjPjy6o+/C+dqNPnxIy9A==', NULL); +INSERT INTO informixoltp:coder (coder_id) VALUES (400013); +INSERT INTO email (user_id, email_id, email_type_id, address, create_date, modify_date, primary_ind, status_id) + VALUES (400013, 400013, 1, 'normal_user_13@test.com', CURRENT, CURRENT, 1, 1); +INSERT INTO address(address_id, address_type_id, address1, address2, city, state_code, zip, country_code, create_date, modify_date, address3, province) + VALUES (40000003, 2, 'address1', NULL, 'city', 'ME', '04043', '840', '2008-08-01 16:37:48.000', '2008-08-01 16:37:48.000', NULL, NULL); +INSERT INTO user_address_xref(user_id, address_id) VALUES (400013, 40000003); + +INSERT INTO user (user_id, handle, status, timezone_id) VALUES (400014, 'normal_user_14', 'U', 143); +INSERT INTO security_user (login_id, user_id, password, create_user_id) VALUES (400014, 'normal_user_14', '4EjPjy6o+/C+dqNPnxIy9A==', NULL); +INSERT INTO informixoltp:coder (coder_id) VALUES (400014); +INSERT INTO email (user_id, email_id, email_type_id, address, create_date, modify_date, primary_ind, status_id) + VALUES (400014, 400014, 1, 'normal_user_14@test.com', CURRENT, CURRENT, 1, 1); +INSERT INTO address(address_id, address_type_id, address1, address2, city, state_code, zip, country_code, create_date, modify_date, address3, province) + VALUES (40000004, 2, 'address1', NULL, 'city', 'ME', '04043', '840', '2008-08-01 16:37:48.000', '2008-08-01 16:37:48.000', NULL, NULL); +INSERT INTO user_address_xref(user_id, address_id) VALUES (400014, 40000004); + +INSERT INTO user (user_id, handle, status, timezone_id) VALUES (400015, 'normal_user_15', 'A', 143); +INSERT INTO security_user (login_id, user_id, password, create_user_id) VALUES (400015, 'normal_user_15', '4EjPjy6o+/C+dqNPnxIy9A==', NULL); +INSERT INTO informixoltp:coder (coder_id) VALUES (400015); +INSERT INTO email (user_id, email_id, email_type_id, address, create_date, modify_date, primary_ind, status_id) + VALUES (400015, 400015, 1, 'normal_user_15@test.com', CURRENT, CURRENT, 1, 1); +INSERT INTO address(address_id, address_type_id, address1, address2, city, state_code, zip, country_code, create_date, modify_date, address3, province) + VALUES (40000005, 2, 'address1', NULL, 'city', 'ME', '04043', '840', '2008-08-01 16:37:48.000', '2008-08-01 16:37:48.000', NULL, NULL); +INSERT INTO user_address_xref(user_id,address_id) VALUES (400015, 40000005); + +INSERT INTO user (user_id, handle, status, timezone_id) VALUES (400016, 'admin_user_16', 'A', 143); +INSERT INTO security_user (login_id, user_id, password, create_user_id) VALUES (400016, 'admin_user_16', '4EjPjy6o+/C+dqNPnxIy9A==', NULL); +INSERT INTO informixoltp:coder (coder_id) VALUES (400016); +INSERT INTO email (user_id, email_id, email_type_id, address, create_date, modify_date, primary_ind, status_id) + VALUES (400016, 400016, 1, 'admin_user_16@test.com', CURRENT, CURRENT, 1, 1); +INSERT INTO address(address_id, address_type_id, address1, address2, city, state_code, zip, country_code, create_date, modify_date, address3, province) + VALUES (40000006, 2, 'address1', NULL, 'city', 'ME', '04043', '840', '2008-08-01 16:37:48.000', '2008-08-01 16:37:48.000', NULL, NULL); +INSERT INTO user_address_xref(user_id,address_id) VALUES (400016, 40000006); +INSERT INTO user_social_login (social_user_id, user_id, social_login_provider_id, social_user_name) + VALUES ('fb40006', 400016, 1, 'admin6'); +INSERT INTO user_role_xref (user_role_id, login_id, role_id, create_user_id, security_status_id) VALUES (400016, 400016, 2087, 400016, 1); + +INSERT INTO user (user_id, handle, status, timezone_id) VALUES (400017, 'normal_user_17', 'A', 143); +INSERT INTO security_user (login_id, user_id, password, create_user_id) VALUES (400017, 'normal_user_17', '4EjPjy6o+/C+dqNPnxIy9A==', NULL); +INSERT INTO informixoltp:coder (coder_id) VALUES (400017); +INSERT INTO email (user_id, email_id, email_type_id, address, create_date, modify_date, primary_ind, status_id) + VALUES (400017, 400017, 1, 'normal_user_17@test.com', CURRENT, CURRENT, 1, 1); +INSERT INTO address(address_id, address_type_id, address1, address2, city, state_code, zip, country_code, create_date, modify_date, address3, province) + VALUES (40000007, 2, 'address1', NULL, 'city', 'ME', '04043', '840', '2008-08-01 16:37:48.000', '2008-08-01 16:37:48.000', NULL, NULL); +INSERT INTO user_address_xref(user_id, address_id) VALUES (400017, 40000007); +INSERT INTO user_social_login (social_user_id, user_id, social_login_provider_id, social_user_name) + VALUES ('fb40007', 400017, 1, 'common_handle'); + +INSERT INTO user (user_id, handle, status, timezone_id) VALUES (400018, 'common_handle', 'A', 143); +INSERT INTO security_user (login_id, user_id, password, create_user_id) VALUES (400018, 'common_handle', '4EjPjy6o+/C+dqNPnxIy9A==', NULL); +INSERT INTO informixoltp:coder (coder_id) VALUES (400018); +INSERT INTO email (user_id, email_id, email_type_id, address, create_date, modify_date, primary_ind, status_id) + VALUES (400018, 400018, 1, 'normal_user_18@test.com', CURRENT, CURRENT, 1, 1); +INSERT INTO address(address_id, address_type_id, address1, address2, city, state_code, zip, country_code, create_date, modify_date, address3, province) + VALUES (40000008, 2, 'address1', NULL, 'city', 'ME', '04043', '840', '2008-08-01 16:37:48.000', '2008-08-01 16:37:48.000', NULL, NULL); +INSERT INTO user_address_xref(user_id, address_id) VALUES (400018, 40000008); +INSERT INTO user_social_login (social_user_id, user_id, social_login_provider_id, social_user_name) + VALUES ('gg40008', 400018, 2, 'user8'); + +INSERT INTO user (user_id, handle, status, timezone_id) VALUES (400019, 'normal_user_19', 'A', 143); +INSERT INTO security_user (login_id, user_id, password, create_user_id) VALUES (400019, 'normal_user_19', '4EjPjy6o+/C+dqNPnxIy9A==', NULL); +INSERT INTO informixoltp:coder (coder_id) VALUES (400019); +INSERT INTO email (user_id, email_id, email_type_id, address, create_date, modify_date, primary_ind, status_id) + VALUES (400019, 400019, 1, 'normal_user_19@test.com', CURRENT, CURRENT, 1, 1); +INSERT INTO address(address_id, address_type_id, address1, address2, city, state_code, zip, country_code, create_date, modify_date, address3, province) + VALUES (40000009, 2, 'address1', NULL, 'city', 'ME', '04043', '840', '2008-08-01 16:37:48.000', '2008-08-01 16:37:48.000', NULL, NULL); +INSERT INTO user_address_xref(user_id, address_id) VALUES (400019, 40000009); +INSERT INTO user_social_login (social_user_id, user_id, social_login_provider_id, social_user_name) + VALUES ('fb40009', 400019, 1, 'common_handle2'); + +INSERT INTO user (user_id, handle, status, timezone_id) VALUES (400020, 'common_handle2', 'A', 143); +INSERT INTO security_user (login_id, user_id, password, create_user_id) VALUES (400020, 'common_handle2', '4EjPjy6o+/C+dqNPnxIy9A==', NULL); +INSERT INTO informixoltp:coder (coder_id) VALUES (400020); +INSERT INTO email (user_id, email_id, email_type_id, address, create_date, modify_date, primary_ind, status_id) + VALUES (400020, 400020, 1, 'normal_user_20@test.com', CURRENT, CURRENT, 1, 1); +INSERT INTO address(address_id, address_type_id, address1, address2, city, state_code, zip, country_code, create_date, modify_date, address3, province) + VALUES (40000010, 2, 'address1', NULL, 'city', 'ME', '04043', '840', '2008-08-01 16:37:48.000', '2008-08-01 16:37:48.000', NULL, NULL); +INSERT INTO user_address_xref(user_id, address_id) VALUES (400020, 40000010); + +INSERT INTO user (user_id, handle, status, timezone_id) VALUES (400021, 'normal_user_21', 'A', 143); +INSERT INTO security_user (login_id, user_id, password, create_user_id) VALUES (400021, 'normal_user_21', '4EjPjy6o+/C+dqNPnxIy9A==', NULL); +INSERT INTO informixoltp:coder (coder_id) VALUES (400021); +INSERT INTO email (user_id, email_id, email_type_id, address, create_date, modify_date, primary_ind, status_id) + VALUES (400021, 400021, 1, 'normal_user_21@test.com', CURRENT, CURRENT, 1, 1); +INSERT INTO address(address_id, address_type_id, address1, address2, city, state_code, zip, country_code, create_date, modify_date, address3, province) + VALUES (40000011, 2, 'address1', NULL, 'city', 'ME', '04043', '840', '2008-08-01 16:37:48.000', '2008-08-01 16:37:48.000', NULL, NULL); +INSERT INTO user_address_xref(user_id, address_id) VALUES (400021, 40000011); +INSERT INTO user_social_login (social_user_id, user_id, social_login_provider_id, social_user_name, social_email) + VALUES ('tw40010', 400021, 3, 'user21', 'social.email21@test.com'); + +INSERT INTO user (user_id, handle, status, timezone_id) VALUES (400022, 'normal_user_22', 'A', 143); +INSERT INTO security_user (login_id, user_id, password, create_user_id) VALUES (400022, 'normal_user_22', '4EjPjy6o+/C+dqNPnxIy9A==', NULL); +INSERT INTO informixoltp:coder (coder_id) VALUES (400022); +INSERT INTO email (user_id, email_id, email_type_id, address, create_date, modify_date, primary_ind, status_id) + VALUES (400022, 400022, 1, 'normal_user_22@test.com', CURRENT, CURRENT, 1, 1); +INSERT INTO address(address_id, address_type_id, address1, address2, city, state_code, zip, country_code, create_date, modify_date, address3, province) + VALUES (40000012, 2, 'address1', NULL, 'city', 'ME', '04043', '840', '2008-08-01 16:37:48.000', '2008-08-01 16:37:48.000', NULL, NULL); +INSERT INTO user_address_xref(user_id, address_id) VALUES (400022, 40000012); +INSERT INTO user_social_login (social_user_id, user_id, social_login_provider_id, social_user_name, social_email) + VALUES ('fb40011', 400022, 1, 'user22', 'common_email@test.com'); + +INSERT INTO user (user_id, handle, status, timezone_id) VALUES (400023, 'normal_user_23', 'A', 143); +INSERT INTO security_user (login_id, user_id, password, create_user_id) VALUES (400023, 'normal_user_23', '4EjPjy6o+/C+dqNPnxIy9A==', NULL); +INSERT INTO informixoltp:coder (coder_id) VALUES (400023); +INSERT INTO email (user_id, email_id, email_type_id, address, create_date, modify_date, primary_ind, status_id) + VALUES (400023, 400023, 1, 'common_email@test.com', CURRENT, CURRENT, 1, 1); +INSERT INTO address(address_id, address_type_id, address1, address2, city, state_code, zip, country_code, create_date, modify_date, address3, province) + VALUES (40000013, 2, 'address1', NULL, 'city', 'ME', '04043', '840', '2008-08-01 16:37:48.000', '2008-08-01 16:37:48.000', NULL, NULL); +INSERT INTO user_address_xref(user_id, address_id) VALUES (400023, 40000013); +INSERT INTO user_social_login (social_user_id, user_id, social_login_provider_id, social_user_name) + VALUES ('gg40012', 400023, 2, 'user23'); + +INSERT INTO user (user_id, handle, status, timezone_id) VALUES (400024, 'normal_user_24', 'A', 143); +INSERT INTO security_user (login_id, user_id, password, create_user_id) VALUES (400024, 'normal_user_24', '4EjPjy6o+/C+dqNPnxIy9A==', NULL); +INSERT INTO informixoltp:coder (coder_id) VALUES (400024); +INSERT INTO email (user_id, email_id, email_type_id, address, create_date, modify_date, primary_ind, status_id) + VALUES (400024, 400024, 1, 'normal_user_24@test.com', CURRENT, CURRENT, 1, 1); +INSERT INTO address(address_id, address_type_id, address1, address2, city, state_code, zip, country_code, create_date, modify_date, address3, province) + VALUES (40000014, 2, 'address1', NULL, 'city', 'ME', '04043', '840', '2008-08-01 16:37:48.000', '2008-08-01 16:37:48.000', NULL, NULL); +INSERT INTO user_address_xref(user_id, address_id) VALUES (400024, 40000014); +INSERT INTO user_social_login (social_user_id, user_id, social_login_provider_id, social_user_name, social_email) + VALUES ('fb40013', 400024, 1, 'user24', 'common_email2@test.com'); + +INSERT INTO user (user_id, handle, status, timezone_id) VALUES (400025, 'normal_user_25', 'A', 143); +INSERT INTO security_user (login_id, user_id, password, create_user_id) VALUES (400025, 'normal_user_25', '4EjPjy6o+/C+dqNPnxIy9A==', NULL); +INSERT INTO informixoltp:coder (coder_id) VALUES (400025); +INSERT INTO email (user_id, email_id, email_type_id, address, create_date, modify_date, primary_ind, status_id) + VALUES (400025, 400025, 1, 'common_email2@test.com', CURRENT, CURRENT, 1, 1); +INSERT INTO address(address_id, address_type_id, address1, address2, city, state_code, zip, country_code, create_date, modify_date, address3, province) + VALUES (40000015, 2, 'address1', NULL, 'city', 'ME', '04043', '840', '2008-08-01 16:37:48.000', '2008-08-01 16:37:48.000', NULL, NULL); +INSERT INTO user_address_xref(user_id, address_id) VALUES (400025, 40000015); diff --git a/test/test.resetPassword.js b/test/test.resetPassword.js new file mode 100644 index 000000000..3209535ec --- /dev/null +++ b/test/test.resetPassword.js @@ -0,0 +1,311 @@ +/* + * Copyright (C) 2014 TopCoder Inc., All Rights Reserved. + * + * @version 1.0 + * @author isv + */ +"use strict"; + +/*global describe, it, before, beforeEach, after, afterEach, __dirname */ +/*jslint node: true, stupid: true, unparam: true */ + +/** + * Module dependencies. + */ +var request = require('supertest'); +var assert = require('chai').assert; +var async = require('async'); +var testHelper = require('./helpers/testHelper'); +var stringUtils = require("../common/stringUtils.js"); +var redis = require('redis'); + +var API_ENDPOINT = process.env.API_ENDPOINT || 'http://localhost:8080'; +var SQL_DIR = __dirname + "/sqls/resetPassword/"; +var DATABASE_NAME = "common_oltp"; +var TOKEN_LIFETIME = require('../config').config.general.defaultResetPasswordTokenCacheLifetime; +var IS_FAKE_REDIS_USED = require('../config').config.redis.fake; +if (typeof TOKEN_LIFETIME === 'string') { + TOKEN_LIFETIME = parseInt(TOKEN_LIFETIME, 10); +} + +var CLIENT_ID = require('../config').config.general.oauthClientId; +var SECRET = require('../config').config.general.oauthClientSecret; +var jwt = require('jsonwebtoken'); + +describe('Test Generate Reset Token API', function () { + + /** + * Gets the token which must have been generated for the specified user and saved to Redis database. + * + * @param {String} handle - the username to get the token for. + * @param {Function} callback - the callback function. + */ + function getCachedToken(handle, callback) { + var client = redis.createClient(); + client.get('tokens-' + handle + '-reset-token', function (err, value) { + callback(err, JSON.parse(value)); + }); + client.quit(); + } + + /** + * Delays the execution of current thread to let the token generated previously ot expire. + */ + function delay() { + var delayPeriod = TOKEN_LIFETIME + 1000, + now = new Date(), + desiredTime = new Date(); + desiredTime.setTime(now.getTime() + delayPeriod); + while (now < desiredTime) { + now = new Date(); + } + console.log("The token should have expired."); + } + + /** + * Clear database + * @param {Function} done the callback + */ + function clearDb(done) { + testHelper.runSqlFile(SQL_DIR + "common_oltp__clean", DATABASE_NAME, done); + } + + /** + * This function is run before all tests. + * Generate tests data. + * @param {Function} done the callback + */ + before(function (done) { + async.waterfall([ + clearDb, + function (cb) { + testHelper.runSqlFile(SQL_DIR + "common_oltp__insert_test_data", DATABASE_NAME, cb); + } + ], done); + }); + + /** + * This function is run after all tests. + * Clean up all data. + * @param {Function} done the callback + */ + after(function (done) { + clearDb(done); + }); + + /** + * Tests the generateResetToken action against failure test case. Posts a request for generating the token for + * user specified by handle or email and expects the server to respond with HTTP response of specified status + * providing the specified expected error details. + * + * @param {String} handle - a handle for user to pass to tested action. + * @param {String} email - an email for user to pass to tested action. + * @param {Number} expectedStatusCode - status code for HTTP response expected to be returned from server. + * @param {String} expectedErrorMessage - error message expected to be returned from server. + * @param {Function} callback - a callback to be called when test finishes. + */ + function testFailureScenario(handle, email, expectedStatusCode, expectedErrorMessage, callback) { + var queryParams = '?'; + if (handle !== null) { + queryParams += 'handle=' + handle; + } + if (email !== null) { + queryParams += '&email=' + email; + } + + request(API_ENDPOINT) + .get('/v2/users/resetToken' + queryParams) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(expectedStatusCode) + .end(function (err, res) { + if (err) { + callback(err); + return; + } + var body = res.body; + assert.equal(body.error.details, expectedErrorMessage); + callback(); + }); + } + + /** + * Tests the generateResetToken action against success test case. Posts a request for generating the token for + * user specified by handle or email and expects the server to respond with HTTP response of 200 OK status and + * return generated token or social login provider name in case the handle or email corresponds to social login. + * + * @param {String} handle - a handle for user to pass to tested action. + * @param {String} email - an email for user to pass to tested action. + * @param {String} socialLoginProvider - a name for social login provider in case specified handle is from social + * login. + * @param {Function} callback - a callback to be called when test finishes. + * @param {String} handleForEmail - a user handle corresponding to specified email address. This is just for tests + * which pass email and expect the token to be generated. + * @param {boolean} skipCheckingTokenInRedis - flag indicating if test has skip checking the token for presence in + * Redis database. + */ + function testSuccessScenario(handle, email, socialLoginProvider, callback, handleForEmail, skipCheckingTokenInRedis) { + var queryParams = '?'; + if (handle !== null) { + queryParams += 'handle=' + handle; + } + if (email !== null) { + queryParams += '&email=' + email; + } + + request(API_ENDPOINT) + .get('/v2/users/resetToken' + queryParams) + .set('Accept', 'application/json') + .expect('Content-Type', /json/) + .expect(200) + .end(function (err, res) { + assert.notOk(err, 'There should be no error for successful scenario'); + + var body = res.body, + alphabet = stringUtils.ALPHABET_ALPHA_EN + stringUtils.ALPHABET_DIGITS_EN, + i, + ch; + + if (socialLoginProvider === null) { + assert.ok(body.successful, "There is no successful returned"); + assert.isTrue(body.successful, "Wrong successful flag is returned"); + if (!IS_FAKE_REDIS_USED && !skipCheckingTokenInRedis) { + async.waterfall([ + function (cb) { + if (handle) { + getCachedToken(handle, cb); + } else { + getCachedToken(handleForEmail, cb); + } + } + ], function (err, token) { + assert.ok(token, 'The token is not stored in Redis database'); + assert.equal(token.value.length, 6, "Token of wrong length returned"); + for (i = 0; i < token.value.length; i = i + 1) { + ch = token.value.charAt(i); + assert.isTrue(alphabet.indexOf(ch) >= 0, "Token contains wrong character '" + ch + "'"); + } + callback(err); + }); + } else { + callback(); + } + } else { + assert.ok(body.socialProvider, "There is no social login provider name returned"); + assert.equal(body.socialProvider, socialLoginProvider, "Wrong social login provider name returned"); + callback(); + } + }); + } + + // Failure test cases + it('Neither handle nor email are provided - should return HTTP 400', function (done) { + testFailureScenario(null, null, 400, 'Either handle or email must be specified', done); + }); + + it('Both handle and email are provided - should return HTTP 400', function (done) { + testFailureScenario("heffan", "foo@bar.com", 400, 'Both handle and email are specified', done); + }); + + it('Both empty handle and email are provided - should return HTTP 400', function (done) { + testFailureScenario("", "", 400, 'Either handle or email must be specified', done); + }); + + it('Empty handle provided - should return HTTP 400', function (done) { + testFailureScenario("", null, 400, 'Either handle or email must be specified', done); + }); + + it('Empty email provided - should return HTTP 400', function (done) { + testFailureScenario(null, "", 400, 'Either handle or email must be specified', done); + }); + + it('Non-existing handle is provided - should return HTTP 404', function (done) { + testFailureScenario("Undioiwfibiiv3vb3i", null, 404, 'User does not exist', done); + }); + + it('Non-existing email is provided - should return HTTP 404', function (done) { + testFailureScenario(null, '912837197@akjsdnakd.com', 404, 'User does not exist', done); + }); + + it('Non-expired token already exists - should return HTTP 400', function (done) { + // Increasing timeout as there is a need for thread to sleep in this test case in order to cause the + // generated token to expire + this.timeout(TOKEN_LIFETIME * 2); + + async.waterfall([ + function (cb) { + testSuccessScenario('normal_user_11', null, null, cb, null, false); + }, function (cb) { + testFailureScenario('normal_user_11', null, 400, "You have already requested the reset token, " + + "please find it in your email inbox. If it's not there. Please contact support@topcoder.com.", + cb); + }, function (cb) { + console.log("\nWaiting for generated token to expire to prevent multiple test suite execution to fail (" + + (TOKEN_LIFETIME + 1000) / 1000 + " sec)..."); + delay(); + cb(); + } + ], function (err) { + done(err); + }); + }); + + // Accuracy test cases + it('Existing TopCoder username is provided - should respond with HTTP 200 and return token', function (done) { + testSuccessScenario('normal_user_13', null, null, done, null, false); + }); + + it('Existing email address is provided - should respond with HTTP 200 and return token', function (done) { + testSuccessScenario(null, 'normal_user_14@test.com', null, done, 'normal_user_14', false); + }); + + it('Existing social login handle is provided - should respond with HTTP 200 and provider name', function (done) { + testSuccessScenario('user2', null, 'Facebook', done, null, true); + }); + + it('Existing social login email is provided - should respond with HTTP 200 and provider name', function (done) { + testSuccessScenario(null, 'social.email21@test.com', 'Twitter', done, null, true); + }); + + it('Username that matches handle for TC user account (which also have a social login username) and social ' + + 'login username for another TC user account is provided - should respond with HTTP 200 and social ' + + 'provider name for user with matching TC handle', function (done) { + testSuccessScenario('common_handle', null, 'Google', done, null, true); + }); + + it('Username that matches handle for TC user account (which does not have a social login username) and social ' + + 'login username for another TC user account is provided - should respond with HTTP 200 and generated ' + + 'token for user with matching TC handle', function (done) { + testSuccessScenario('common_handle2', null, null, done, null, false); + }); + + it('Email address that matches email for TC user account (which also have a social login username) and social ' + + 'login email for another TC user account is provided - should respond with HTTP 200 and social ' + + 'provider name for user with matching TC email address', function (done) { + testSuccessScenario(null, 'common_email@test.com', 'Google', done, null, true); + }); + + it('Email address that matches email for TC user account (which does not have a social login account) and social ' + + 'login email for another TC user account is provided - should respond with HTTP 200 and generated ' + + 'token for user with matching TC email', function (done) { + testSuccessScenario(null, 'common_email2@test.com', null, done, 'normal_user_25', false); + }); + + it('Requesting new token once previous has expired - should respond with HTTP 200 and new token', function (done) { + // Increasing timeout as there is a need for thread to sleep in this test case in order to cause the + // generated token to expire + this.timeout(TOKEN_LIFETIME * 2); + + async.waterfall([ + function (cb) { + testSuccessScenario('normal_user_15', null, null, cb, null, false); + }, function (cb) { + console.log("\nWaiting for generated token to expire (" + (TOKEN_LIFETIME + 1000) / 1000 + " sec)..."); + delay(); + testSuccessScenario('normal_user_15', null, null, cb, null, true); + } + ], function (err) { + done(err); + }); + }); +});