diff --git a/.babelrc b/.babelrc index 7d9a57227..21383130e 100644 --- a/.babelrc +++ b/.babelrc @@ -1,7 +1,6 @@ { "plugins": [ "transform-strict-mode", - "transform-es2015-spread", "transform-object-rest-spread", "transform-class-properties" ] diff --git a/.gitignore b/.gitignore index c9409dfc6..71bd95669 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,8 @@ node_modules # compiled source code lib .env + +src/db/sandbox/ +.idea +*-compiled.js +*-compiled.js.map diff --git a/package.json b/package.json index a9896dd57..fe456cd82 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "ms-users", + "version": "0.0.1", "description": "Core of the microservice for handling users", "main": "./lib/index.js", "scripts": { "compile": "rm -rf ./lib; babel -d ./lib ./src", "prepublish": "npm run lint && npm run compile", - "test": "npm run lint && ./test/docker.sh", + "test": "npm run lint && bash ./test/docker.sh", "start": "node ./bin/mservice.js | bunyan -o short", "semantic-release": "semantic-release pre && npm publish && semantic-release post", "doc": "apidoc -i ./src/actions -v -o ./docs", @@ -33,12 +34,14 @@ "jsonwebtoken": "^7.0.1", "lodash": "^4.5.0", "moment": "^2.14.1", + "mongodb": "^2.2.4", + "mongoose-validator": "^1.2.5", "ms-amqp-transport": "^3.1.0", "ms-conf": "^2.1.0", "ms-mailer-client": "^3.0.0", "ms-mailer-templates": "^0.7.0", "ms-validation": "^2.0.0", - "mservice": "^2.6.0", + "mservice": "file:///C:\\dev\\nodejs\\ms\\mservice", "node-uuid": "^1.4.7", "password-generator": "^2.0.2", "redis-filtered-sort": "^1.2.0", @@ -49,9 +52,8 @@ "devDependencies": { "apidoc": "^0.16.1", "babel-cli": "^6.10.1", - "babel-eslint": "^6.0.5", + "babel-eslint": "^6.1.0", "babel-plugin-transform-class-properties": "^6.10.2", - "babel-plugin-transform-es2015-spread": "^6.0.0", "babel-plugin-transform-object-rest-spread": "^6.0.0", "babel-plugin-transform-strict-mode": "^6.0.0", "babel-register": "^6.5.2", @@ -64,7 +66,7 @@ "eslint-plugin-mocha": "^4.0.0", "faker": "^3.0.1", "isparta": "^4.0.0", - "istanbul": "^0.4.2", + "istanbul": "^0.4.4", "json": "^9.0.4", "latest-version-cli": "^1.0.0", "mocha": "^2.4.5", diff --git a/rm.txt b/rm.txt new file mode 100644 index 000000000..ef3d63228 --- /dev/null +++ b/rm.txt @@ -0,0 +1 @@ +test/docker.sh customizing testable files \ No newline at end of file diff --git a/src/actions/activate.js b/src/actions/activate.js index ac33c6b26..bcc92a72a 100644 --- a/src/actions/activate.js +++ b/src/actions/activate.js @@ -1,57 +1,23 @@ const Promise = require('bluebird'); -const Errors = require('common-errors'); -const redisKey = require('../utils/key.js'); const emailVerification = require('../utils/send-email.js'); const jwt = require('../utils/jwt.js'); -const userExists = require('../utils/userExists.js'); -const { USERS_INDEX, USERS_DATA, USERS_ACTIVE_FLAG } = require('../constants.js'); +const { User } = require('../model/usermodel'); +const { MAIL_ACTIVATE } = require('../constants'); /** - * @api {amqp} .activate Activate User - * @apiVersion 1.0.0 - * @apiName ActivateUser - * @apiGroup Users - * - * @apiDescription This method allows one to activate user by 2 means: providing a username or encoded verification token. - * When only username is provided, no verifications will be performed and user will be set to active. In contrary, `token` - * would be verified. This allows for 2 scenarios: admin activating a user, or user completing verification challenge. In - * case of success output would contain user object - * - * @apiParam (Payload) {String} username - currently email of the user - * @apiParam (Payload) {String} token - if present, would be used against test challenge - * @apiParam (Payload) {String} [remoteip] - not used, but is reserved for security log in the future - * @apiParam (Payload) {String} [audience] - additional metadata will be pushed there from custom hooks - * + * Activate existing users + * @param opts + * @return {Promise} */ module.exports = function verifyChallenge(opts) { // TODO: add security logs // var remoteip = opts.remoteip; const { token, username } = opts; - const { redis, config } = this; + const { config } = this; const audience = opts.audience || config.defaultAudience; function verifyToken() { - return emailVerification.verify.call(this, token, 'activate', config.validation.ttl > 0); - } - - function activateAccount(user) { - const userKey = redisKey(user, USERS_DATA); - - // WARNING: `persist` is very important, otherwise we will lose user's information in 30 days - // set to active & persist - return redis - .pipeline() - .hget(userKey, USERS_ACTIVE_FLAG) - .hset(userKey, USERS_ACTIVE_FLAG, 'true') - .persist(userKey) - .sadd(USERS_INDEX, user) - .exec() - .spread(function pipeResponse(isActive) { - const status = isActive[1]; - if (status === 'true') { - throw new Errors.HttpStatusError(417, `Account ${user} was already activated`); - } - }); + return emailVerification.verify.call(this, token, MAIL_ACTIVATE, config.validation.ttl > 0); } function hook(user) { @@ -60,8 +26,8 @@ module.exports = function verifyChallenge(opts) { return Promise .bind(this, username) - .then(username ? userExists : verifyToken) - .tap(activateAccount) + .then(username ? User.getUsername : verifyToken) + .tap(User.activate) .tap(hook) .then(user => [user, audience]) .spread(jwt.login); diff --git a/src/actions/alias.js b/src/actions/alias.js index 6a6db122b..e1f8daa0e 100644 --- a/src/actions/alias.js +++ b/src/actions/alias.js @@ -1,51 +1,21 @@ const Promise = require('bluebird'); -const Errors = require('common-errors'); -const getInternalData = require('../utils/getInternalData.js'); -const isActive = require('../utils/isActive.js'); -const isBanned = require('../utils/isBanned.js'); -const key = require('../utils/key.js'); -const { USERS_DATA, USERS_METADATA, USERS_PUBLIC_INDEX, USERS_ALIAS_TO_LOGIN, USERS_ALIAS_FIELD } = require('../constants.js'); +const isActive = require('../utils/isActive'); +const isBanned = require('../utils/isBanned'); +const { User } = require('../model/usermodel'); /** - * @api {amqp} .alias Add alias to user - * @apiVersion 1.0.0 - * @apiName AddAlias - * @apiGroup Users - * - * @apiDescription Adds alias to existing username. This alias must be unique across system, as - * well as obide strict restrictions - ascii chars only, include numbers and dot. It's used to obfuscate - * username in public interfaces - * - * @apiParam (Payload) {String} username - currently email of the user - * @apiParam (Payload) {String{3..15}} alias - chosen alias - * + * Assign alias to user + * @param opts + * @return {Promise} */ module.exports = function assignAlias(opts) { - const { redis, config: { jwt: { defaultAudience } } } = this; const { username, alias } = opts; return Promise .bind(this, username) - .then(getInternalData) + .then(User.getOne) .tap(isActive) .tap(isBanned) - .then(data => { - if (data[USERS_ALIAS_FIELD]) { - throw new Errors.HttpStatusError(417, 'alias is already assigned'); - } - - return redis.hsetnx(USERS_ALIAS_TO_LOGIN, alias, username); - }) - .then(assigned => { - if (assigned === 0) { - throw new Errors.HttpStatusError(409, 'alias was already taken'); - } - - return redis - .pipeline() - .sadd(USERS_PUBLIC_INDEX, username) - .hset(key(username, USERS_DATA), USERS_ALIAS_FIELD, alias) - .hset(key(username, USERS_METADATA, defaultAudience), USERS_ALIAS_FIELD, JSON.stringify(alias)) - .exec(); - }); + .then(data => ({ username, alias, data })) + .then(User.setAlias); }; diff --git a/src/actions/ban.js b/src/actions/ban.js index ab0e02adf..3d80f0aa1 100644 --- a/src/actions/ban.js +++ b/src/actions/ban.js @@ -1,46 +1,5 @@ const Promise = require('bluebird'); -const mapValues = require('lodash/mapValues'); -const stringify = JSON.stringify.bind(JSON); - -const redisKey = require('../utils/key.js'); -const userExists = require('../utils/userExists.js'); -const { - USERS_DATA, USERS_METADATA, - USERS_BANNED_FLAG, USERS_TOKENS, USERS_BANNED_DATA, -} = require('../constants.js'); - -function lockUser({ username, reason, whom, remoteip }) { - const { redis, config } = this; - const { jwt: { defaultAudience } } = config; - const data = { - banned: true, - [USERS_BANNED_DATA]: { - reason: reason || '', - whom: whom || '', - remoteip: remoteip || '', - }, - }; - - return redis - .pipeline() - .hset(redisKey(username, USERS_DATA), USERS_BANNED_FLAG, 'true') - // set .banned on metadata for filtering & sorting users by that field - .hmset(redisKey(username, USERS_METADATA, defaultAudience), mapValues(data, stringify)) - .del(redisKey(username, USERS_TOKENS)) - .exec(); -} - -function unlockUser({ username }) { - const { redis, config } = this; - const { jwt: { defaultAudience } } = config; - - return redis - .pipeline() - .hdel(redisKey(username, USERS_DATA), USERS_BANNED_FLAG) - // remove .banned on metadata for filtering & sorting users by that field - .hdel(redisKey(username, USERS_METADATA, defaultAudience), 'banned', USERS_BANNED_DATA) - .exec(); -} +const { User } = require('../model/usermodel'); /** * @api {amqp} .ban Lock or Unlock user @@ -61,7 +20,7 @@ function unlockUser({ username }) { module.exports = function banUser(opts) { return Promise .bind(this, opts.username) - .then(userExists) + .then(User.getUsername) .then(username => ({ ...opts, username })) - .then(opts.ban ? lockUser : unlockUser); + .then(opts.ban ? User.lock : User.unlock); }; diff --git a/src/actions/challenge.js b/src/actions/challenge.js index 60d8caffc..c96c2f474 100644 --- a/src/actions/challenge.js +++ b/src/actions/challenge.js @@ -1,8 +1,8 @@ const Promise = require('bluebird'); -const Errors = require('common-errors'); const emailChallenge = require('../utils/send-email.js'); -const getInternalData = require('../utils/getInternalData.js'); -const isActive = require('../utils/isActive.js'); +const isActive = require('../utils/isActive'); +const { User } = require('../model/usermodel'); +const { ModelError, ERR_ACCOUNT_NOT_ACTIVATED, ERR_USERNAME_ALREADY_ACTIVE } = require('../model/modelError'); /** * @api {amqp} .challenge Creates user challenges @@ -28,9 +28,9 @@ module.exports = function sendChallenge(message) { return Promise .bind(this, username) - .then(getInternalData) + .then(User.getOne) .tap(isActive) - .throw(new Errors.HttpStatusError(417, `${username} is already active`)) - .catchReturn({ statusCode: 412 }, username) + .throw(new ModelError(ERR_USERNAME_ALREADY_ACTIVE, username)) + .catchReturn({ code: ERR_ACCOUNT_NOT_ACTIVATED }, username) .then(emailChallenge.send); }; diff --git a/src/actions/getInternalData.js b/src/actions/getInternalData.js index f06999fa6..b06293ed6 100644 --- a/src/actions/getInternalData.js +++ b/src/actions/getInternalData.js @@ -1,6 +1,6 @@ const Promise = require('bluebird'); -const getInternalData = require('../utils/getInternalData.js'); const pick = require('lodash/pick'); +const { User } = require('../model/usermodel'); /** * @api {amqp} .getInternalData Retrieve Internal Data @@ -21,7 +21,7 @@ module.exports = function internalData(message) { return Promise .bind(this, message.username) - .then(getInternalData) + .then(User.getOne) .then(data => { return fields ? pick(data, fields) : data; }); diff --git a/src/actions/getMetadata.js b/src/actions/getMetadata.js index 2272bd37d..ab186bd9d 100644 --- a/src/actions/getMetadata.js +++ b/src/actions/getMetadata.js @@ -1,54 +1,18 @@ const Promise = require('bluebird'); -const Errors = require('common-errors'); -const getMetadata = require('../utils/getMetadata.js'); -const userExists = require('../utils/userExists.js'); +const isPublic = require('../utils/isPublic'); const noop = require('lodash/noop'); -const get = require('lodash/get'); -const isArray = Array.isArray; -const { USERS_ALIAS_FIELD } = require('../constants.js'); - -function isPublic(username, audiences) { - return metadata => { - let notFound = true; +const { User } = require('../model/usermodel'); - // iterate over passed audiences - audiences.forEach(audience => { - if (notFound && get(metadata, [audience, USERS_ALIAS_FIELD]) === username) { - notFound = false; - } - }); - - if (notFound) { - throw new Errors.HttpStatusError(404, 'username was not found'); - } - }; -} +const isArray = Array.isArray; -/** - * @api {amqp} .getMetadata Retrieve Public Data - * @apiVersion 1.0.0 - * @apiName getMetadata - * @apiGroup Users - * - * @apiDescription This should be used to retrieve user's publicly available data. It contains 2 modes: - * data that is available when the user requests data about him or herself and when someone else tries - * to get data about a given user on the system. For instance, if you want to view someone's public profile - * - * @apiParam (Payload) {String} username - user's username, can be `alias` or real `username`. - * If it's a real username - then all the data is returned. - * @apiParam (Payload) {String[]} audience - which namespace of metadata should be used, can be string or array of strings - * @apiParam (Payload) {Object} fields - must contain an object of `[audience]: String[]` mapping - * @apiParam (Payload) {String[]} fields.* - fields to return from a passed audience - * - */ module.exports = function getMetadataAction(message) { const { audience: _audience, username, fields } = message; const audience = isArray(_audience) ? _audience : [_audience]; return Promise .bind(this, username) - .then(userExists) + .then(User.getUsername) .then(realUsername => [realUsername, audience, fields]) - .spread(getMetadata) + .spread(User.getMeta) .tap(message.public ? isPublic(username, audience) : noop); }; diff --git a/src/actions/list.js b/src/actions/list.js index e57ea3eac..1a658ca55 100644 --- a/src/actions/list.js +++ b/src/actions/list.js @@ -1,78 +1,8 @@ const Promise = require('bluebird'); -const redisKey = require('../utils/key.js'); -const mapValues = require('lodash/mapValues'); -const fsort = require('redis-filtered-sort'); -const JSONParse = JSON.parse.bind(JSON); -const { USERS_INDEX, USERS_PUBLIC_INDEX, USERS_METADATA } = require('../constants.js'); +const { User } = require('../model/usermodel'); -/** - * @api {amqp} .list Retrieve Registered Users - * @apiVersion 1.0.0 - * @apiName ListUsers - * @apiGroup Users - * - * @apiDescription This method allows to list user that are registered and activated in the system. They can be sorted & filtered by - * any metadata field. Furthermore, it retrieves metadata based on the supplied audience and returns array of users similar to `info` - * endpoint - * - * @apiParam (Payload) {Number} [offset=0] - cursor for pagination - * @apiParam (Payload) {Number} [limit=10] - profiles per page - * @apiParam (Payload) {String="ASC","DESC"} [order=ASC] - sort order - * @apiParam (Payload) {String} [criteria] - if supplied, sort will be performed based on this field - * @apiParam (Payload) {String} audience - which namespace of metadata should be used for filtering & retrieving - * @apiParam (Payload) {Boolean} [public=false] - when `true` returns only publicly marked users - * @apiParam (Payload) {Object} - filter to use, consult https://github.com/makeomatic/redis-filtered-sort, can already be stringified - */ module.exports = function iterateOverActiveUsers(opts) { - const { redis } = this; - const { criteria, audience, filter } = opts; - const strFilter = typeof filter === 'string' ? filter : fsort.filter(filter || {}); - const order = opts.order || 'ASC'; - const offset = opts.offset || 0; - const limit = opts.limit || 10; - const metaKey = redisKey('*', USERS_METADATA, audience); - const index = opts.public ? USERS_PUBLIC_INDEX : USERS_INDEX; - - return redis - .fsort(index, metaKey, criteria, order, strFilter, offset, limit) - .then(ids => { - const length = +ids.pop(); - if (length === 0 || ids.length === 0) { - return [ - ids || [], - [], - length, - ]; - } - - const pipeline = redis.pipeline(); - ids.forEach(id => { - pipeline.hgetall(redisKey(id, USERS_METADATA, audience)); - }); - return Promise.all([ - ids, - pipeline.exec(), - length, - ]); - }) - .spread((ids, props, length) => { - const users = ids.map(function remapData(id, idx) { - const data = props[idx][1]; - const account = { - id, - metadata: { - [audience]: data ? mapValues(data, JSONParse) : {}, - }, - }; - - return account; - }); - - return { - users, - cursor: offset + limit, - page: Math.floor(offset / limit) + 1, - pages: Math.ceil(length / limit), - }; - }); + return Promise + .bind(this, opts) + .then(User.getList); }; diff --git a/src/actions/login.js b/src/actions/login.js index 5811dcb8b..7dd6fae6f 100644 --- a/src/actions/login.js +++ b/src/actions/login.js @@ -1,13 +1,10 @@ const Promise = require('bluebird'); -const Errors = require('common-errors'); const scrypt = require('../utils/scrypt.js'); -const redisKey = require('../utils/key.js'); const jwt = require('../utils/jwt.js'); -const moment = require('moment'); -const isActive = require('../utils/isActive.js'); -const isBanned = require('../utils/isBanned.js'); -const getInternalData = require('../utils/getInternalData.js'); const noop = require('lodash/noop'); +const isActive = require('../utils/isActive'); +const isBanned = require('../utils/isBanned'); +const { User, Attempts } = require('../model/usermodel'); /** * @api {amqp} .login User Authentication @@ -26,74 +23,30 @@ const noop = require('lodash/noop'); */ module.exports = function login(opts) { const config = this.config.jwt; - const { redis } = this; const { password } = opts; const { lockAfterAttempts, defaultAudience } = config; const audience = opts.audience || defaultAudience; const remoteip = opts.remoteip || false; const verifyIp = remoteip && lockAfterAttempts > 0; - // references for data from login attempts - let remoteipKey; - let loginAttempts; - - function checkLoginAttempts(data) { - const pipeline = redis.pipeline(); - const username = data.username; - remoteipKey = redisKey(username, 'ip', remoteip); - - pipeline.incrby(remoteipKey, 1); - if (config.keepLoginAttempts > 0) { - pipeline.expire(remoteipKey, config.keepLoginAttempts); - } - - return pipeline - .exec() - .spread(function incremented(incrementValue) { - const err = incrementValue[0]; - if (err) { - this.log.error('Redis error:', err); - return; - } - - loginAttempts = incrementValue[1]; - if (loginAttempts > lockAfterAttempts) { - const duration = moment().add(config.keepLoginAttempts, 'seconds').toNow(true); - const msg = `You are locked from making login attempts for the next ${duration}`; - throw new Errors.HttpStatusError(429, msg); - } - }); - } + const theAttempts = new Attempts(this); function verifyHash(data) { return scrypt.verify(data.password, password); } - function dropLoginCounter() { - loginAttempts = 0; - return redis.del(remoteipKey); - } - function getUserInfo({ username }) { return jwt.login.call(this, username, audience); } - function enrichError(err) { - if (remoteip) { - err.loginAttempts = loginAttempts; - } - - throw err; - } - return Promise .bind(this, opts.username) - .then(getInternalData) - .tap(verifyIp ? checkLoginAttempts : noop) + .then(User.getOne) + .then(data => ({ ...data, remoteip })) + .tap(verifyIp ? ({ username, ip }) => theAttempts.check(username, ip) : noop) .tap(verifyHash) - .tap(verifyIp ? dropLoginCounter : noop) + .tap(verifyIp ? ({ username, ip }) => theAttempts.drop(username, ip) : noop) .tap(isActive) .tap(isBanned) - .then(getUserInfo) - .catch(verifyIp ? enrichError : e => { throw e; }); + .then(getUserInfo); }; diff --git a/src/actions/register.js b/src/actions/register.js index b04586013..24762829c 100644 --- a/src/actions/register.js +++ b/src/actions/register.js @@ -1,82 +1,16 @@ const Promise = require('bluebird'); -const Errors = require('common-errors'); -const setMetadata = require('../utils/updateMetadata.js'); const scrypt = require('../utils/scrypt.js'); -const redisKey = require('../utils/key.js'); const emailValidation = require('../utils/send-email.js'); const jwt = require('../utils/jwt.js'); -const uuid = require('node-uuid'); -const { USERS_INDEX, USERS_DATA, USERS_ACTIVE_FLAG, MAIL_REGISTER } = require('../constants.js'); +const verifyGoogleCaptcha = require('../utils/verifyGoogleCaptcha'); +const { MAIL_REGISTER } = require('../constants.js'); const isDisposable = require('../utils/isDisposable.js'); const mxExists = require('../utils/mxExists.js'); -const makeCaptchaCheck = require('../utils/checkCaptcha.js'); -const userExists = require('../utils/userExists.js'); -const aliasExists = require('../utils/aliasExists.js'); const noop = require('lodash/noop'); -const assignAlias = require('./alias.js'); const hasOwnProperty = Object.prototype.hasOwnProperty; -/** - * Verify ip limits - * @param {redisCluster} redis - * @param {Object} registrationLimits - * @param {String} ipaddress - * @return {Function} - */ -function checkLimits(redis, registrationLimits, ipaddress) { - const { ip: { time, times } } = registrationLimits; - const ipaddressLimitKey = redisKey('reg-limit', ipaddress); - const now = Date.now(); - const old = now - time; - - return function iplimits() { - return redis - .pipeline() - .zadd(ipaddressLimitKey, now, uuid.v4()) - .pexpire(ipaddressLimitKey, time) - .zremrangebyscore(ipaddressLimitKey, '-inf', old) - .zcard(ipaddressLimitKey) - .exec() - .then(props => { - const cardinality = props[3][1]; - if (cardinality > times) { - const msg = 'You can\'t register more users from your ipaddress now'; - throw new Errors.HttpStatusError(429, msg); - } - }); - }; -} - -/** - * Creates user with a given hash - */ -function createUser(redis, username, activate, deleteInactiveAccounts, userDataKey) { - /** - * Input from scrypt.hash - */ - return function create(hash) { - const pipeline = redis.pipeline(); - - pipeline.hsetnx(userDataKey, 'password', hash); - pipeline.hsetnx(userDataKey, USERS_ACTIVE_FLAG, activate); - - return pipeline - .exec() - .spread(function insertedUserData(passwordSetResponse) { - if (passwordSetResponse[1] === 0) { - throw new Errors.HttpStatusError(412, `User "${username}" already exists`); - } - - if (!activate && deleteInactiveAccounts >= 0) { - // WARNING: IF USER IS NOT VERIFIED WITHIN - // [by default 30] DAYS - IT WILL BE REMOVED FROM DATABASE - return redis.expire(userDataKey, deleteInactiveAccounts); - } - - return null; - }); - }; -} +const { User, Utils } = require('../model/usermodel'); +const { ModelError, ERR_USERNAME_NOT_EXISTS, ERR_ACCOUNT_MUST_BE_ACTIVATED, ERR_USERNAME_ALREADY_EXISTS } = require('../model/modelError'); /** * @api {amqp} .register Create User @@ -102,8 +36,7 @@ function createUser(redis, username, activate, deleteInactiveAccounts, userDataK * @apiParam (Payload) {Boolean} [skipChallenge=false] - if `activate` is `false` disables sending challenge */ module.exports = function registerUser(message) { - const { redis, config } = this; - const { deleteInactiveAccounts, captcha: captchaConfig, registrationLimits } = config; + const { config: { registrationLimits } } = this; // message const { username, alias, password, audience, ipaddress, skipChallenge, activate } = message; @@ -115,7 +48,7 @@ module.exports = function registerUser(message) { // make sure that if alias is truthy then activate is also truthy if (alias && !activate) { - throw new Errors.HttpStatusError(400, 'Account must be activated when setting alias during registration'); + throw new ModelError(ERR_ACCOUNT_MUST_BE_ACTIVATED); } let promise = Promise.bind(this, username); @@ -123,7 +56,7 @@ module.exports = function registerUser(message) { // optional captcha verification if (captcha) { logger.debug('verifying captcha'); - promise = promise.tap(makeCaptchaCheck(redis, username, captcha, captchaConfig)); + promise = promise.tap(Utils.checkCaptcha.call(this, username, captcha, verifyGoogleCaptcha)); } if (registrationLimits) { @@ -136,20 +69,17 @@ module.exports = function registerUser(message) { } if (registrationLimits.ip && ipaddress) { - promise = promise.tap(checkLimits(redis, registrationLimits, ipaddress)); + promise = promise.tap(Utils.checkIPLimits.call(this, ipaddress)); } } - // shared user key - const userDataKey = redisKey(username, USERS_DATA); - // step 2, verify that user _still_ does not exist promise = promise // verify user does not exist at this point - .tap(userExists) - .throw(new Errors.HttpStatusError(409, `"${username}" already exists`)) - .catchReturn({ statusCode: 404 }, username) - .tap(alias ? aliasExists(alias, true) : noop) + .tap(User.getUsername) + .throw(new ModelError(ERR_USERNAME_ALREADY_EXISTS, username)) + .catchReturn({ code: ERR_USERNAME_NOT_EXISTS }, username) + .tap(alias ? () => User.checkAlias.call(this, alias) : noop) // step 3 - encrypt password .then(() => { if (password) { @@ -165,7 +95,7 @@ module.exports = function registerUser(message) { }) .then(scrypt.hash) // step 4 - create user if it wasn't created by some1 else trying to use race-conditions - .then(createUser(redis, username, activate, deleteInactiveAccounts, userDataKey)) + .then((hash) => User.create.call(this, username, alias, hash, activate)) // step 5 - save metadata if present .return({ username, @@ -177,7 +107,7 @@ module.exports = function registerUser(message) { }, }, }) - .then(setMetadata) + .then(User.setMeta) .return(username); // no instant activation -> send email or skip it based on the settings @@ -189,21 +119,10 @@ module.exports = function registerUser(message) { // perform instant activation return promise - // add to redis index - .then(() => redis.sadd(USERS_INDEX, username)) - // call hook + // call hook .return(['users:activate', username, audience]) .spread(this.hook) - // assign alias if specified - .tap(() => { - if (!alias) { - return null; - } - - // adds on-registration alias to the user - return assignAlias.call(this, { username, alias }); - }) - // login user + // login user .return([username, audience]) .spread(jwt.login); }; diff --git a/src/actions/remove.js b/src/actions/remove.js index 4d4f6c45d..ebf2909f9 100644 --- a/src/actions/remove.js +++ b/src/actions/remove.js @@ -1,18 +1,8 @@ const Promise = require('bluebird'); -const Errors = require('common-errors'); -const key = require('../utils/key'); -const getInternalData = require('../utils/getInternalData'); -const getMetadata = require('../utils/getMetadata'); -const { - USERS_INDEX, - USERS_PUBLIC_INDEX, - USERS_ALIAS_TO_LOGIN, - USERS_DATA, - USERS_METADATA, - USERS_TOKENS, - USERS_ALIAS_FIELD, - USERS_ADMIN_ROLE, -} = require('../constants'); +const { USERS_ADMIN_ROLE } = require('../constants'); +const { User } = require('../model/usermodel'); +const { ModelError, ERR_ADMIN_IS_UNTOUCHABLE } = require('../model/modelError'); + /** * @api {amqp} .remove Remove User @@ -28,33 +18,14 @@ module.exports = function removeUser({ username }) { const audience = this.config.jwt.defaultAudience; return Promise.props({ - internal: getInternalData.call(this, username), - meta: getMetadata.call(this, username, audience), + internal: User.getOne.call(this, username), + meta: User.getMeta.call(this, username, audience), }) .then(({ internal, meta }) => { const isAdmin = (meta[audience].roles || []).indexOf(USERS_ADMIN_ROLE) >= 0; if (isAdmin) { - throw new Errors.HttpStatusError(400, 'can\'t remove admin user from the system'); - } - - const transaction = this.redis.multi(); - const alias = internal[USERS_ALIAS_FIELD]; - if (alias) { - transaction.hdel(USERS_ALIAS_TO_LOGIN, alias); + throw new ModelError(ERR_ADMIN_IS_UNTOUCHABLE); } - - // clean indices - transaction.srem(USERS_PUBLIC_INDEX, username); - transaction.srem(USERS_INDEX, username); - - // remove metadata & internal data - transaction.del(key(username, USERS_DATA)); - transaction.del(key(username, USERS_METADATA, audience)); - - // remove auth tokens - transaction.del(key(username, USERS_TOKENS)); - - // complete it - return transaction.exec(); + return User.remove.call(this, username, internal); }); }; diff --git a/src/actions/requestPassword.js b/src/actions/requestPassword.js index 41797db37..7994c58be 100644 --- a/src/actions/requestPassword.js +++ b/src/actions/requestPassword.js @@ -1,8 +1,9 @@ const Promise = require('bluebird'); const emailValidation = require('../utils/send-email.js'); -const getInternalData = require('../utils/getInternalData.js'); -const isActive = require('../utils/isActive.js'); -const isBanned = require('../utils/isBanned.js'); +const isActive = require('../utils/isActive'); +const isBanned = require('../utils/isBanned'); +const { User } = require('../model/usermodel'); + /** * @api {amqp} .requestPassword Reset Password @@ -28,7 +29,7 @@ module.exports = function requestPassword(opts) { return Promise .bind(this, username) - .then(getInternalData) + .then(User.getOne) .tap(isActive) .tap(isBanned) .then(() => emailValidation.send.call(this, username, action)) diff --git a/src/actions/updateMetadata.js b/src/actions/updateMetadata.js index a05f0e21b..2284ac9cb 100644 --- a/src/actions/updateMetadata.js +++ b/src/actions/updateMetadata.js @@ -1,6 +1,5 @@ const Promise = require('bluebird'); -const updateMetadata = require('../utils/updateMetadata.js'); -const userExists = require('../utils/userExists.js'); +const { User } = require('../model/usermodel'); /** * @api {amqp} .updateMetadata Update Metadata @@ -21,7 +20,7 @@ const userExists = require('../utils/userExists.js'); module.exports = function updateMetadataAction(message) { return Promise .bind(this, message.username) - .then(userExists) + .then(User.getUsername) .then(username => ({ ...message, username })) - .then(updateMetadata); + .then(User.setMeta); }; diff --git a/src/actions/updatePassword.js b/src/actions/updatePassword.js index 5921b8bde..30249f572 100644 --- a/src/actions/updatePassword.js +++ b/src/actions/updatePassword.js @@ -1,13 +1,11 @@ const Promise = require('bluebird'); const scrypt = require('../utils/scrypt.js'); -const redisKey = require('../utils/key.js'); const jwt = require('../utils/jwt.js'); const emailChallenge = require('../utils/send-email.js'); -const getInternalData = require('../utils/getInternalData.js'); -const isActive = require('../utils/isActive.js'); -const isBanned = require('../utils/isBanned.js'); -const userExists = require('../utils/userExists.js'); -const { USERS_DATA } = require('../constants.js'); +const isActive = require('../utils/isActive'); +const isBanned = require('../utils/isBanned'); +const { User, Attempts } = require('../model/usermodel'); + /** * Verifies token and deletes it if it matches @@ -25,7 +23,7 @@ function tokenReset(token) { function usernamePasswordReset(username, password) { return Promise .bind(this, username) - .then(getInternalData) + .then(User.getOne) .tap(isActive) .tap(isBanned) .tap(data => scrypt.verify(data.password, password)) @@ -38,20 +36,14 @@ function usernamePasswordReset(username, password) { * @param {String} password */ function setPassword(_username, password) { - const { redis } = this; - return Promise .bind(this, _username) - .then(userExists) + .then(User.getUsername) .then(username => Promise.props({ username, hash: scrypt.hash(password), })) - .then(({ username, hash }) => - redis - .hset(redisKey(username, USERS_DATA), 'password', hash) - .return(username) - ); + .then(User.setPassword); } /** @@ -71,7 +63,6 @@ function setPassword(_username, password) { * @apiParam (Payload) {String} [remoteip] - will be used for rate limiting if supplied */ module.exports = exports = function updatePassword(opts) { - const { redis } = this; const { newPassword: password, remoteip } = opts; const invalidateTokens = !!opts.invalidateTokens; @@ -92,8 +83,8 @@ module.exports = exports = function updatePassword(opts) { } if (remoteip) { - promise = promise.tap(function resetLock(username) { - return redis.del(redisKey(username, 'ip', remoteip)); + promise = promise.tap(username => { + return (new Attempts(this)).drop(username, remoteip); }); } diff --git a/src/actions/verify.js b/src/actions/verify.js index f7d95e654..56bdd4da0 100644 --- a/src/actions/verify.js +++ b/src/actions/verify.js @@ -1,6 +1,6 @@ const Promise = require('bluebird'); const jwt = require('../utils/jwt.js'); -const getMetadata = require('../utils/getMetadata.js'); +const { User } = require('../model/usermodel'); /** * @api {amqp} .verify JWT verification @@ -31,7 +31,7 @@ module.exports = function verify(opts) { const username = decoded.username; return Promise.props({ username, - metadata: getMetadata.call(this, username, audience), + metadata: User.getMeta.call(this, username, audience), }); }); }; diff --git a/src/custom/cappasity-users-activate.js b/src/custom/cappasity-users-activate.js index 1709a4636..88b271723 100644 --- a/src/custom/cappasity-users-activate.js +++ b/src/custom/cappasity-users-activate.js @@ -1,15 +1,15 @@ const find = require('lodash/find'); const moment = require('moment'); -const setMetadata = require('../utils/updateMetadata.js'); +const { User } = require('../model/usermodel'); /** * Adds metadata from billing into usermix * @param {String} username + * @param {String} audience * @return {Promise} */ module.exports = function mixPlan(username, audience) { - const { amqp, config } = this; - const { payments } = config; + const { amqp, config: payments } = this; const route = [payments.prefix, payments.routes.planGet].join('.'); const id = 'free'; @@ -19,22 +19,18 @@ module.exports = function mixPlan(username, audience) { .then(function mix(plan) { const subscription = find(plan.subs, ['name', 'month']); const nextCycle = moment().add(1, 'month').valueOf(); - const update = { - username, - audience, - metadata: { - $set: { - plan: id, - agreement: id, - nextCycle, - models: subscription.models, - modelPrice: subscription.price, - subscriptionPrice: '0', - subscriptionInterval: 'month', - }, + const metadata = { + $set: { + plan: id, + agreement: id, + nextCycle, + models: subscription.models, + modelPrice: subscription.price, + subscriptionPrice: '0', + subscriptionInterval: 'month', }, }; - return setMetadata.call(this, update); + return User.setMeta.call(this, { username, audience, metadata }); }); }; diff --git a/src/defaults.js b/src/defaults.js index 3f0646c90..9091cfe5d 100644 --- a/src/defaults.js +++ b/src/defaults.js @@ -1,5 +1,5 @@ const path = require('path'); - +const resolveMessage = require('./messageResolver.js'); /** * Contains default options for users microservice * @type {Object} @@ -19,6 +19,8 @@ module.exports = { initRoutes: true, // automatically init router initRouter: true, + // onComplete handler with error wrapping + onComplete: resolveMessage, }, captcha: { secret: 'put-your-real-gcaptcha-secret-here', diff --git a/src/messageResolver.js b/src/messageResolver.js new file mode 100644 index 000000000..9ae552e10 --- /dev/null +++ b/src/messageResolver.js @@ -0,0 +1,16 @@ +/** + * Created by Stainwoortsel on 02.07.2016. + */ +const { httpErrorMapper } = require('./model/modelError'); + +module.exports = function resolveMessage(err, data) { + if (err) { + if (err.name === 'ModelError') { + throw httpErrorMapper(err); + } + + throw err; + } + + return data; +}; diff --git a/src/model/modelError.js b/src/model/modelError.js new file mode 100644 index 000000000..05fd81545 --- /dev/null +++ b/src/model/modelError.js @@ -0,0 +1,147 @@ +/** + * Created by Stainwoortsel on 17.06.2016. + */ +const findKey = require('lodash/findKey'); +const mapValues = require('lodash/mapValues'); +const isFunction = require('lodash/isFunction'); +const fmt = require('util').format; +const Errors = require('common-errors'); + +/** + * Generate error structure + * @param code + * @param http + * @param msg + */ +const genErr = (code, http, msg) => ({ code, http, msg }); + +/** + * Maping error code helper function + * @param e + */ +const mapErr = (e) => e.code; + +/** + * Error types structure + * @type {Object} + */ + +const ERR_DEFAULT = genErr(0, 500, 'Internal error'); + +/** + * Error types with inner code, HTTP-code and error message + */ +const ErrorTypes = { + ERR_ALIAS_ALREADY_ASSIGNED: + genErr(100, 417, 'alias is already assigned'), + ERR_ALIAS_ALREADY_TAKEN: + genErr(101, 409, 'alias was already taken'), + ERR_ALIAS_ALREADY_EXISTS: + genErr(102, 409, (alias) => (`"${alias}" already exists`)), + ERR_USERNAME_ALREADY_ACTIVE: + genErr(110, 417, (username) => (`${username} is already active`)), + ERR_USERNAME_ALREADY_EXISTS: + genErr(111, 409, (username) => (`"${username}" already exists`)), + ERR_USERNAME_NOT_EXISTS: + genErr(112, 404, (username) => (`${username} does not exists`)), + ERR_USERNAME_NOT_FOUND: + genErr(113, 404, 'username was not found'), + ERR_ACCOUNT_MUST_BE_ACTIVATED: + genErr(120, 400, 'Account must be activated when setting alias during registration'), + ERR_ACCOUNT_NOT_ACTIVATED: + genErr(121, 412, 'Account hasn\'t been activated'), + ERR_ACCOUNT_ALREADY_ACTIVATED: + genErr(122, 417, (user) => (`Account ${user} was already activated`)), + ERR_ACCOUNT_ALREADY_DISACTIVATED: + genErr(122, 417, (user) => (`Account ${user} was already disactivated`)), + ERR_ACCOUNT_IS_LOCKED: + genErr(123, 423, 'Account has been locked'), + ERR_ACCOUNT_IS_ALREADY_EXISTS: + genErr(124, 412, (username) => (`User "${username}" already exists`)), + ERR_ADMIN_IS_UNTOUCHABLE: + genErr(130, 400, 'can\'t remove admin user from the system'), + ERR_CAPTCHA_WRONG_USERNAME: + genErr(140, 412, 'Captcha challenge you\'ve solved can not be used, please complete it again'), // eslint-disable-line + ERR_CAPTCHA_ERROR_RESPONSE: + genErr(141, 412, (errData) => fmt('Captcha response: %s', errData)), + ERR_EMAIL_DISPOSABLE: + genErr(150, 400, 'you must use non-disposable email to register'), + ERR_EMAIL_NO_MX: + genErr(151, 400, (hostname) => (`no MX record was found for hostname ${hostname}`)), + ERR_EMAIL_ALREADY_SENT: + genErr(152, 429, 'We\'ve already sent you an email, if it doesn\'t come - please try again in a little while or send us an email'), // eslint-disable-line + ERR_TOKEN_INVALID: + genErr(160, 403, 'invalid token'), + ERR_TOKEN_AUDIENCE_MISMATCH: + genErr(161, 403, 'audience mismatch'), + ERR_TOKEN_MISS_EMAIL: + genErr(162, 403, 'Decoded token misses references to email and/or secret'), + ERR_TOKEN_EXPIRED: + genErr(163, 404, 'token expired or is invalid'), + ERR_TOKEN_FORGED: + genErr(164, 403, 'token has expired or was forged'), + ERR_TOKEN_BAD_EMAIL: + genErr(165, 412, 'associated email doesn\'t match token'), + ERR_TOKEN_CANT_DECODE: + genErr(166, 403, 'could not decode token'), + ERR_PASSWORD_INVALID: + genErr(170, 500, 'invalid password passed'), + ERR_PASSWORD_INVALID_HASH: + genErr(171, 500, 'invalid password hash retrieved from storage'), + ERR_PASSWORD_INCORRECT: + genErr(172, 403, 'incorrect password'), + ERR_PASSWORD_SCRYPT_ERROR: + genErr(173, 403, (err) => (err.scrypt_err_message || err.message)), + ERR_ATTEMPTS_LOCKED: + genErr(180, 429, (duration) => (`You are locked from making login attempts for the next ${duration}`)), + ERR_ATTEMPTS_TO_MUCH_REGISTERED: + genErr(181, 429, 'You can\'t register more users from your ipaddress now'), +}; + +/** + * Error codes map + * @type {Object} + */ +const ErrorCodes = mapValues(ErrorTypes, mapErr); + +/** + * Model error class + * @type {Class} + */ +const ModelError = Errors.helpers.generateClass('ModelError', { + extends: Errors.Error, + name: 'ModelError', + args: ['code', 'data'], + generateMessage: function generateMessage() { + const key = findKey(ErrorTypes, { code: this.code }); + const err = ErrorTypes[key]; + return isFunction(err.msg) ? err.msg.call(this, this.data) : err.msg; + }, +}); + +/** + * Make http-map to the inner error (middleware) + * @param e + * @returns {*} + * @constructor + */ +const httpErrorMapper = function _HttpErrorMapper(e = null) { + const mapMEToHttp = function mapToHttp(_e) { + const key = findKey(ErrorTypes, { code: _e.code }); + const err = ErrorTypes[key]; + return new Errors.HttpStatusError(err.http, _e.generateMessage()); + }; + + if (e instanceof ModelError) { + return mapMEToHttp(e); + } + + if (e instanceof Errors.HttpStatusError) { + return e; + } + + return new Errors.HttpStatusError(ERR_DEFAULT.http, ERR_DEFAULT.msg); +}; + + +module.exports = { ...ErrorCodes, ModelError, httpErrorMapper }; diff --git a/src/model/storages/mongostorage.js b/src/model/storages/mongostorage.js new file mode 100644 index 000000000..bb1184db9 --- /dev/null +++ b/src/model/storages/mongostorage.js @@ -0,0 +1,491 @@ +/** + * Created by Stainwoortsel on 01.08.2016. + */ +const Promise = require('bluebird'); +const storage = require('./storages/redisstorage'); +const moment = require('moment'); +const mongoose = require('mongoose'); //для самопроверки ) +const { Schema } = mongoose; + + + +const { + ModelError, + ERR_ALIAS_ALREADY_ASSIGNED, ERR_ALIAS_ALREADY_TAKEN, ERR_USERNAME_NOT_EXISTS, ERR_USERNAME_NOT_FOUND, + ERR_TOKEN_FORGED, ERR_CAPTCHA_WRONG_USERNAME, ERR_ATTEMPTS_TO_MUCH_REGISTERED, + ERR_ALIAS_ALREADY_EXISTS, ERR_ACCOUNT_IS_ALREADY_EXISTS, ERR_ACCOUNT_ALREADY_ACTIVATED, +} = require('../modelError'); +/* + ERR_USERNAME_ALREADY_ACTIVE, + ERR_USERNAME_ALREADY_EXISTS, ERR_ACCOUNT_MUST_BE_ACTIVATED, + ERR_ACCOUNT_NOT_ACTIVATED, ERR_ACCOUNT_IS_LOCKED + ERR_ADMIN_IS_UNTOUCHABLE, ERR_CAPTCHA_ERROR_RESPONSE, ERR_EMAIL_DISPOSABLE, + ERR_EMAIL_NO_MX, ERR_EMAIL_ALREADY_SENT, ERR_TOKEN_INVALID, ERR_TOKEN_AUDIENCE_MISMATCH, ERR_TOKEN_MISS_EMAIL, + ERR_TOKEN_EXPIRED, ERR_TOKEN_BAD_EMAIL, ERR_TOKEN_CANT_DECODE, ERR_PASSWORD_INVALID, + ERR_PASSWORD_INVALID_HASH, ERR_PASSWORD_INCORRECT, ERR_PASSWORD_SCRYPT_ERROR, + + */ + +const { + USERS_DATA, USERS_METADATA, USERS_ALIAS_TO_LOGIN, + USERS_ACTIVE_FLAG, USERS_INDEX, USERS_PUBLIC_INDEX, + USERS_ALIAS_FIELD, USERS_TOKENS, USERS_BANNED_FLAG, USERS_BANNED_DATA, +} = require('../../constants'); + +/** + * Adapter pattern class with user model methods + */ +exports.User = { + UserModel: null, + + /** + * Initialize the storage + */ + init() { + exports.User.TokenModel = new Schema({ + 'key': { type: 'String', required: true }, + 'token': { type: 'String', required: true }, + 'expires': { type: 'Date', required: true } + }); + exports.User.UserModel = this.mongo.model('User', new Schema({ + 'username': { type: 'String', required: true, unique: true }, + 'alias': { type: 'String' }, + 'password': { type: 'String', required: true }, + 'isbanned': { type: 'Boolean', default: false }, + 'isactive': { type: 'Boolean', default: false }, + 'ispublic': { type: 'Boolean', default: false }, + 'registered': { type: 'Date' }, + 'meta': { type: 'Mixed' }, + 'tokens': { type: 'Mixed' } // вместо массива проще сделать K->V чтобы было легче работать + })); + // поиск по внутренней структуре докумнета тоже может работать, если использовать не могус, а нативный монго + // почитать про это + // мета: сохранять как { audience: { key: value } } и не надо будет извращаться с поиском + // по метаданным, потому что не будет стрингифай + // + + }, + + + /** + * Get user by username + * @param username + * @returns {Object} + */ + getOne(username) { + //return exports.User.UserModel.findOne({ 'username' : username }); + return exports.User.UserModel + .find({ $or:[ { 'username': username }, { 'alias': username } ]}) + .limit(1) + .exec() + .then((data) => { + if(data.length === 0) throw new ModelError(ERR_USERNAME_NOT_EXISTS); + + return data; + }); + }, + + /** + * TODO: needed + * Get list of users by params + * @param opts + * @returns {Array} + */ + getList(opts) { + // TODO: check list of options + // criteria поле для поиска -- название, и поле для сортировки + // если что, смотреть LUA + + // filter -- это переменная redis filter format, находится в redis-filtered-sort пакете + const { criteria, audience, filter } = opts; + const strFilter = typeof filter === 'string' ? filter : fsort.filter(filter || {}); + const order = opts.order || 'ASC'; + const offset = opts.offset || 0; + const limit = opts.limit || 10; + const index = opts.public ? USERS_PUBLIC_INDEX : USERS_INDEX; + + const metaKey = generateKey('*', USERS_METADATA, audience); + + return exports.User.UserModel.find(opts).exec(); + }, + + /** + * TODO: needed + * Get metadata of user + * @param username + * @param audiences + * @param fields + * @param _public + * @returns {Object} + */ + getMeta(username, audiences, fields = {}, _public = null) { + //TODO: check this method + return mongoose.connection.users.find({ 'username': username, 'meta..': '' }); + }, + + /** + * Get ~real~ username by username or alias + * @param username + * @returns {String} username + */ + getUsername(username) { + return exports.User.UserModel.find({ $or:[ { 'username': username }, { 'alias': username } ]}) + .limit(1) + .exec() + .then((data) => { + return data.username; + }); + }, + + /** + * Check alias existence + * @param alias + * @returns {*} + */ + checkAlias(alias) { + return exports.User.UserModel + .find({ alias }) + .limit(1) + .exec() + .then((user) => { + if(user.alias) { + throw new ModelError(ERR_ALIAS_ALREADY_EXISTS, user.alias); + } + + return user.username; + }); + }, + + /** + * Sets alias to the user by username + * @param opts + * @returns {*} + */ + setAlias(opts) { + const { config } = this; + const { jwt: { defaultAudience } } = config; + const { username, alias, data } = opts; + + // TODO: same logic as in redis, but I feel something wrong... + if (data && data[USERS_ALIAS_FIELD]) { + throw new ModelError(ERR_ALIAS_ALREADY_ASSIGNED); + } + + return exports.User.UserModel + .find({ username, alias: { $exists: true } }) + .limit(1) + .exec() + .then((item) => { + if(item.length > 0) { + throw new ModelError(ERR_ALIAS_ALREADY_TAKEN); + } + + const data = { alias }; + data[defaultAudience] = {}; + data[defaultAudience][USERS_ALIAS_FIELD] = alias; + + return exports.User.UserModel.findOneAndUpdate({ username }, data); + }); + }, + + /** + * Set user password + * @param username + * @param hash + * @returns {String} username + */ + setPassword(username, hash) { + return exports.User.UserModel.findOneAndUpdate({ username }, { password: hash }); + }, + + /** + * Updates metadata of user by username and audience + * @param username + * @param audience + * @param metadata + * @returns {Object} + */ + setMeta(opts) { + // ... + }, + + /** + * Create user account with alias and password + * @param username + * @param alias + * @param hash + * @param activate + * @returns {*} + */ + create(username, alias, hash, activate) { + const { config } = this; + const { deleteInactiveAccounts } = config; + const userDataKey = generateKey(username, USERS_DATA); + const pipeline = redis.pipeline(); + + const data = { + username, + alias, + password: hash, + isactive: activare + }; + +//TODO: refactor it! by using UNIQUE in schema and error catching + return exports.User.UserModel + .find({ username }) + .limit(1) + .exec() + .then((data) => { + if(data.length) { + throw new ModelError(ERR_ACCOUNT_IS_ALREADY_EXISTS, username); + } + + const usr = new exports.User.UserModel(data); + return usr + .save() + .then(() => { + if (!activate && deleteInactiveAccounts >= 0) { +// TODO: expires logic! + } + + return null; + + }) + // setting alias, if we can + .tap(activate && alias ? () => exports.User.setAlias.call(this, { username, alias }) : noop); + }); + }, + + /** + * Remove user + * @param username + * @param data + * @returns {*} + */ + remove(username, data) { + return exports.User.UserModel.findOneAndDelete({ username }); + }, + + /** + * Activate user + * @param username + * @returns {*} + */ + activate(username) { + // TODO: error on already activated + return exports.User.UserModel.findOneAndUpdate({ username }, { isactive: true }); + }, + + /** + * Disactivate user + * @param username + * @returns {*} + */ + disactivate(username) { + // TODO: error on already disactivated + return exports.User.UserModel.findOneAndUpdate({ username }, { isactive: false }); + }, + + /** + * Ban user + * @param username + * @param opts + * @returns {*} + */ + lock(opts) { + const { username, reason, whom, remoteip } = opts; // to guarantee writing only those three variables to metadata from opts + const data = { + isbanned: true, + [USERS_BANNED_DATA]: { + reason: reason || '', + whom: whom || '', + remoteip: remoteip || '', + }, + tokens: [] + }; + + return exports.User.UserModel.findOneAndUpdate({ username }, data); + }, + + /** + * Unlock banned user + * @param { username } + * @returns {*} + */ + unlock({ username }) { + return exports.User.UserModel.findOneAndUpdate({ username }, { isbanned: false }); + }, +}; + +/** + * Adapter pattern class for user login attempts counting + */ +class AttemptsClass { + /** + * Attempts class constructor, _context parameter is the context of service + * @param _context + */ + constructor(_context) { + this.context = _context; + this.loginAttempts = 0; + } + + /** + * Check login attempts + * @param username + * @param ip + * @returns {*} + */ + check(username, ip) { + const { config: { jwt: { lockAfterAttempts }, keepLoginAttempts } } = this.context; + return Promise.bind(this.context, { username, ip }) + .then(storage.Attempts.check) + .then((attempts) => { + if (attempts === null) return; + + this.loginAttempts = attempts; + if (this.loginAttempts > lockAfterAttempts) { + const duration = moment().add(keepLoginAttempts, 'seconds').toNow(true); + const verifyIp = ip && lockAfterAttempts > 0; + + const err = new ModelError(ERR_ATTEMPTS_LOCKED, duration); + if (verifyIp) { + err.loginAttempts = this.loginAttempts; + } + + throw err; + } + }); + } + + /** + * Drop login attempts + * @param username + * @param ip + * @returns {*} + */ + drop(username, ip) { + this.loginAttempts = 0; + return storage.Attempts.drop.call(this.context, username, ip); + } + + /** + * Get attempts count + * @returns {integer} + */ + count() { + return this.loginAttempts; + } +} +exports.Attempts = AttemptsClass; + +/** + * Adapter pattern class for user tokens + */ +exports.Tokens = { + /** + * Add the token + * @param username + * @param token + * @returns {*} + */ + add(username, token) { + return storage.Tokens.add.call(this, username, token); + }, + + /** + * Drop the token + * @param username + * @param token + * @returns {*} + */ + drop(username, token = null) { + return storage.Tokens.drop.call(this, username, token); + }, + + /** + * Get last token score + * @param username + * @param token + * @returns {integer} + */ + lastAccess(username, token) { + return storage.Tokens.lastAccess.call(this, username, token); + }, + + /** + * Get special email throttle state + * @param type + * @param email + * @returns {bool} state + */ + getEmailThrottleState(type, email) { + return storage.Tokens.getEmailThrottleState.call(this, type, email); + }, + + /** + * Set special email throttle state + * @param type + * @param email + * @returns {*} + */ + setEmailThrottleState(type, email) { + return storage.Tokens.setEmailThrottleState.call(this, type, email); + }, + + /** + * Get special email throttle token + * @param type + * @param token + * @returns {string} email + */ + getEmailThrottleToken(type, token) { + return storage.Tokens.getEmailThrottleToken.call(this, type, token); + }, + + /** + * Set special email throttle token + * @param type + * @param email + * @param token + * @returns {*} + */ + setEmailThrottleToken(type, email, token) { + return storage.Tokens.setEmailThrottleToken.call(this, type, email, token); + }, + + /** + * Drop special email throttle token + * @param type + * @param token + * @returns {*} + */ + dropEmailThrottleToken(type, token) { + return storage.Tokens.dropEmailThrottleToken.call(this, type, token); + }, +}; + +/** + * Adapter pattern class for util methods with IP + */ +exports.Utils = { + /** + * Check IP limits for registration + * @param ipaddress + * @returns {*} + */ + checkIPLimits(ipaddress) { + return storage.Utils.checkIPLimits.call(this, ipaddress); + }, + + /** + * Check captcha + * @param username + * @param captcha + * @param next + * @returns {*} + */ + checkCaptcha(username, captcha, next = null) { + return storage.Utils.checkCaptcha.call(this, username, captcha, next); + }, +}; diff --git a/src/model/storages/redisstorage.js b/src/model/storages/redisstorage.js new file mode 100644 index 000000000..53584a04d --- /dev/null +++ b/src/model/storages/redisstorage.js @@ -0,0 +1,754 @@ +/** + * Created by Stainwoortsel on 17.06.2016. + */ +const remapMeta = require('../../utils/remapMeta'); +const mapMetaResponse = require('../../utils/mapMetaResponse'); +const mapValues = require('lodash/mapValues'); +const sha256 = require('../../utils/sha256.js'); +const fsort = require('redis-filtered-sort'); +const uuid = require('node-uuid'); +const noop = require('lodash/noop'); +const get = require('lodash/get'); +const is = require('is'); +const { + ModelError, + ERR_ALIAS_ALREADY_ASSIGNED, ERR_ALIAS_ALREADY_TAKEN, ERR_USERNAME_NOT_EXISTS, ERR_USERNAME_NOT_FOUND, + ERR_TOKEN_FORGED, ERR_CAPTCHA_WRONG_USERNAME, ERR_ATTEMPTS_TO_MUCH_REGISTERED, + ERR_ALIAS_ALREADY_EXISTS, ERR_ACCOUNT_IS_ALREADY_EXISTS, ERR_ACCOUNT_ALREADY_ACTIVATED, + ERR_ACCOUNT_ALREADY_DISACTIVATED, +} = require('../modelError'); +/* + ERR_USERNAME_ALREADY_ACTIVE, + ERR_USERNAME_ALREADY_EXISTS, ERR_ACCOUNT_MUST_BE_ACTIVATED, + ERR_ACCOUNT_NOT_ACTIVATED, ERR_ACCOUNT_IS_LOCKED + ERR_ADMIN_IS_UNTOUCHABLE, ERR_CAPTCHA_ERROR_RESPONSE, ERR_EMAIL_DISPOSABLE, + ERR_EMAIL_NO_MX, ERR_EMAIL_ALREADY_SENT, ERR_TOKEN_INVALID, ERR_TOKEN_AUDIENCE_MISMATCH, ERR_TOKEN_MISS_EMAIL, + ERR_TOKEN_EXPIRED, ERR_TOKEN_BAD_EMAIL, ERR_TOKEN_CANT_DECODE, ERR_PASSWORD_INVALID, + ERR_PASSWORD_INVALID_HASH, ERR_PASSWORD_INCORRECT, ERR_PASSWORD_SCRYPT_ERROR, + +*/ + +// JSON +const JSONStringify = JSON.stringify.bind(JSON); +const JSONParse = JSON.parse.bind(JSON); + +// constants +const { + USERS_DATA, USERS_METADATA, USERS_ALIAS_TO_LOGIN, + USERS_ACTIVE_FLAG, USERS_INDEX, USERS_PUBLIC_INDEX, + USERS_ALIAS_FIELD, USERS_TOKENS, USERS_BANNED_FLAG, USERS_BANNED_DATA, +} = require('../../constants'); + +/** + * Generate hash key string + * @param args + * @returns {string} + */ +const generateKey = (...args) => { + const SEPARATOR = '!'; + return args.join(SEPARATOR); +}; + +exports.User = { + + /** + * Initialize the storage + */ + init() { + // ... + }, + + /** + * Get user by username + * @param username + * @returns {Object} + */ + getOne(username) { + const userKey = generateKey(username, USERS_DATA); + const { redis } = this; + + return redis + .pipeline() + .hget(USERS_ALIAS_TO_LOGIN, username) + .exists(userKey) + .hgetallBuffer(userKey) + .exec() + .spread((aliasToUsername, exists, data) => { + if (aliasToUsername[1]) { + return exports.User.getOne.call(this, aliasToUsername[1]); + } + + if (!exists[1]) { + throw new ModelError(ERR_USERNAME_NOT_EXISTS); + } + + return { ...data[1], username }; + }); + }, + + /** + * Get list of users by params + * @param opts + * @returns {Array} + */ + getList(opts) { + const { redis } = this; + const { criteria, audience, filter } = opts; + const strFilter = typeof filter === 'string' ? filter : fsort.filter(filter || {}); + const order = opts.order || 'ASC'; + const offset = opts.offset || 0; + const limit = opts.limit || 10; + const index = opts.public ? USERS_PUBLIC_INDEX : USERS_INDEX; + + const metaKey = generateKey('*', USERS_METADATA, audience); + + return redis + .fsort(index, metaKey, criteria, order, strFilter, offset, limit) + .then(ids => { + const length = +ids.pop(); + if (length === 0 || ids.length === 0) { + return [ + ids || [], + [], + length, + ]; + } + + const pipeline = redis.pipeline(); + ids.forEach(id => { + pipeline.hgetallBuffer(generateKey(id, USERS_METADATA, audience)); + }); + return Promise.all([ + ids, + pipeline.exec(), + length, + ]); + }) + .spread((ids, props, length) => { + const users = ids.map(function remapData(id, idx) { + const data = props[idx][1]; + const account = { + id, + metadata: { + [audience]: data ? mapValues(data, JSONParse) : {}, + }, + }; + + return account; + }); + + return { + users, + cursor: offset + limit, + page: Math.floor(offset / limit) + 1, + pages: Math.ceil(length / limit), + }; + }); + }, + + /** + * Get metadata of user + * @param username + * @param _audiences + * @param fields + * @param _public + * @returns {Object} + */ + getMeta(username, _audiences, fields = {}, _public = null) { + const { redis } = this; + const audiences = Array.isArray(_audiences) ? _audiences : [_audiences]; + const audience = Array.isArray(_audiences) ? _audiences[0] : _audiences; + + return Promise + .map(audiences, _audience => { + return redis.hgetallBuffer(generateKey(username, USERS_METADATA, _audience)); + }) + .then(data => { + return remapMeta(data, audiences, fields); + }) + .tap(_public ? (metadata) => { + if (get(metadata, [audience, USERS_ALIAS_FIELD]) === username) { + return; + } + + throw new ModelError(ERR_USERNAME_NOT_FOUND); + } : noop); + }, + + /** + * Get ~real~ username by username or alias + * @param username + * @returns {String} username + */ + getUsername(username) { + const { redis } = this; + + return redis + .pipeline() + .hget(USERS_ALIAS_TO_LOGIN, username) + .exists(generateKey(username, USERS_DATA)) + .exec() + .spread((alias, exists) => { + if (alias[1]) { + return alias[1]; + } + + if (!exists[1]) { + throw new ModelError(ERR_USERNAME_NOT_EXISTS, username); + } + + return username; + }); + }, + + checkAlias(alias) { + const { redis } = this; + + return redis + .hget(USERS_ALIAS_TO_LOGIN, alias) + .then(username => { + if (username) { + throw new ModelError(ERR_ALIAS_ALREADY_EXISTS, alias); + } + + return username; + }); + }, + + /** + * Set up alias of user + * @param opts + * @returns {*} + */ + setAlias(opts) { + const { redis, config } = this; + const { jwt: { defaultAudience } } = config; + const { username, alias, data } = opts; + + if (data && data[USERS_ALIAS_FIELD]) { + throw new ModelError(ERR_ALIAS_ALREADY_ASSIGNED); + } + + return redis + .hsetnx(USERS_ALIAS_TO_LOGIN, alias, username) + .then(assigned => { + if (assigned === 0) { + throw new ModelError(ERR_ALIAS_ALREADY_TAKEN); + } + + return redis + .pipeline() + .sadd(USERS_PUBLIC_INDEX, username) + .hset(generateKey(username, USERS_DATA), USERS_ALIAS_FIELD, alias) + .hset(generateKey(username, USERS_METADATA, defaultAudience), USERS_ALIAS_FIELD, JSON.stringify(alias)) + .exec(); + }); + }, + + /** + * Set user password + * @param username + * @param hash + * @returns {String} username + */ + setPassword(username, hash) { + const { redis } = this; + + return redis + .hset(generateKey(username, USERS_DATA), 'password', hash) + .return(username); + }, + + + /** + * Process metadata update operation for a passed audience (inner method) + * @param {Object} pipeline + * @param {String} key (audience) + * @param {Object} metadata + * @returns {object} + */ + handleAudience(pipeline, key, metadata) { + const $remove = metadata.$remove; + const $removeOps = ($remove && $remove.length) || 0; + if ($removeOps > 0) { + pipeline.hdel(key, $remove); + } + + const $set = metadata.$set; + const $setKeys = $set && Object.keys($set); + const $setLength = ($setKeys && $setKeys.length) || 0; + if ($setLength > 0) { + pipeline.hmset(key, mapValues($set, JSONStringify)); + } + + const $incr = metadata.$incr; + const $incrFields = $incr && Object.keys($incr); + const $incrLength = ($incrFields && $incrFields.length) || 0; + if ($incrLength > 0) { + $incrFields.forEach(fieldName => { + pipeline.hincrby(key, fieldName, $incr[fieldName]); + }); + } + + return { $removeOps, $setLength, $incrLength, $incrFields }; + }, + + /** + * Updates metadata of user by username and audience + * @param username + * @param audience + * @param metadata + * @returns {Object} + */ + setMeta(opts) { + const { redis } = this; + const { username, audience, metadata, script } = opts; + const audiences = is.array(audience) ? audience : [audience]; + const keys = audiences.map(aud => generateKey(username, USERS_METADATA, aud)); + + if (metadata) { + const pipe = redis.pipeline(); + const metaOps = is.array(metadata) ? metadata : [metadata]; + const operations = metaOps.map((meta, idx) => exports.User.handleAudience(pipe, keys[idx], meta)); + return pipe.exec().then(res => mapMetaResponse(operations, res)); + } + + return exports.User.executeUpdateMetaScript.call(this, username, audience, script); + }, + + /** + * Update meta of user by using direct script + * @param username + * @param audience + * @param script + * @returns {Object} + */ + executeUpdateMetaScript(username, audiences, script) { + const { redis } = this; + const keys = audiences.map(aud => generateKey(username, USERS_METADATA, aud)); + + // dynamic scripts + const $scriptKeys = Object.keys(script); + const scripts = $scriptKeys.map(scriptName => { + const { lua, argv = [] } = script[scriptName]; + const sha = sha256(lua); + const name = `ms_users_${sha}`; + if (!is.fn(redis[name])) { + redis.defineCommand(name, { lua }); + } + return redis[name](keys.length, keys, argv); + }); + + // mapScriptResponse implementation + return Promise.all(scripts).then(res => { + const output = {}; + $scriptKeys.forEach((fieldName, idx) => { + output[fieldName] = res[idx]; + }); + return output; + }); + }, + + /** + * Create user account with alias and password + * @param username + * @param alias + * @param hash + * @param activate + * @returns {*} + */ + create(username, alias, hash, activate) { + const { redis, config } = this; + const { deleteInactiveAccounts } = config; + const userDataKey = generateKey(username, USERS_DATA); + const pipeline = redis.pipeline(); + + pipeline + // add password + .hsetnx(userDataKey, 'password', hash) + // set activation flag + .hsetnx(userDataKey, USERS_ACTIVE_FLAG, activate); + + // if we can activate user + if (activate) { + // store username to index + pipeline.sadd(USERS_INDEX, username); + } + + // well done! let's execute + return pipeline + .exec() + .spread(function insertedUserData(passwordSetResponse) { + if (passwordSetResponse[1] === 0) { + throw new ModelError(ERR_ACCOUNT_IS_ALREADY_EXISTS, username); + } + + if (!activate && deleteInactiveAccounts >= 0) { + // WARNING: IF USER IS NOT VERIFIED WITHIN + // [by default 30] DAYS - IT WILL BE REMOVED FROM DATABASE + return redis.expire(userDataKey, deleteInactiveAccounts); + } + + return null; + }) + // setting alias, if we can + .tap(activate && alias ? () => exports.User.setAlias.call(this, { username, alias }) : noop); + }, + + /** + * Remove user + * @param username + * @param data + * @returns {*} + */ + remove(username, data) { + const { redis, config } = this; + const { jwt: { defaultAudience } } = config; + + const audience = defaultAudience; + const transaction = redis.multi(); + const alias = data[USERS_ALIAS_FIELD]; + if (alias) { + transaction.hdel(USERS_ALIAS_TO_LOGIN, alias); + } + + // clean indices + transaction.srem(USERS_PUBLIC_INDEX, username); + transaction.srem(USERS_INDEX, username); + + // remove metadata & internal data + transaction.del(generateKey(username, USERS_DATA)); + transaction.del(generateKey(username, USERS_METADATA, audience)); + + // remove auth tokens + transaction.del(generateKey(username, USERS_TOKENS)); + + // complete it + return transaction.exec(); + }, + + /** + * Activate user + * @param username + * @returns {*} + */ + activate(username) { + const { redis } = this; + const userKey = generateKey(username, USERS_DATA); + + // WARNING: `persist` is very important, otherwise we will lose user's information in 30 days + // set to active & persist + return redis + .pipeline() + .hget(userKey, USERS_ACTIVE_FLAG) + .hset(userKey, USERS_ACTIVE_FLAG, 'true') + .persist(userKey) + .sadd(USERS_INDEX, username) + .exec() + .spread(function pipeResponse(isActive) { + const status = isActive[1]; + if (status === 'true') { + throw new ModelError(ERR_ACCOUNT_ALREADY_ACTIVATED, username); + } + }); + }, + + /** + * Disactivate user + * @param username + * @returns {*} + */ + disactivate(username) { + const { redis } = this; + const userKey = generateKey(username, USERS_DATA); + + return redis + .pipeline() + .hget(userKey, USERS_ACTIVE_FLAG) + .hset(userKey, USERS_ACTIVE_FLAG, 'false') + .exec() + .spread(function pipeResponse(isActive) { + const status = isActive[1]; + if (status === 'false') { + throw new ModelError(ERR_ACCOUNT_ALREADY_DISACTIVATED, username); + } + }); + }, + + /** + * Ban user + * @param username + * @param opts + * @returns {*} + */ + lock(opts) { + const { redis, config } = this; + const { jwt: { defaultAudience } } = config; + + const { username, reason, whom, remoteip } = opts; // to guarantee writing only those three variables to metadata from opts + const data = { + banned: true, + [USERS_BANNED_DATA]: { + reason: reason || '', + whom: whom || '', + remoteip: remoteip || '', + }, + }; + + return redis + .pipeline() + .hset(generateKey(username, USERS_DATA), USERS_BANNED_FLAG, 'true') + // set .banned on metadata for filtering & sorting users by that field + .hmset(generateKey(username, USERS_METADATA, defaultAudience), mapValues(data, JSONStringify)) + .del(generateKey(username, USERS_TOKENS)) + .exec(); + }, + + /** + * Unlock banned user + * @param username + * @returns {*} + */ + unlock({ username }) { + const { redis, config } = this; + const { jwt: { defaultAudience } } = config; + + return redis + .pipeline() + .hdel(generateKey(username, USERS_DATA), USERS_BANNED_FLAG) + // remove .banned on metadata for filtering & sorting users by that field + .hdel(generateKey(username, USERS_METADATA, defaultAudience), 'banned', USERS_BANNED_DATA) + .exec(); + }, +}; + +exports.Attempts = { + /** + * Check login attempts + * @param username + * @param ip + * @returns {*} + */ + check: function check({ username, ip }) { + const { redis, config } = this; + const ipKey = generateKey(username, 'ip', ip); + const pipeline = redis.pipeline(); + + pipeline.incrby(ipKey, 1); + if (config.keepLoginAttempts > 0) { + pipeline.expire(ipKey, config.keepLoginAttempts); + } + + return pipeline + .exec() + .spread(function incremented(incrementValue) { + const err = incrementValue[0]; + if (err) { + this.log.error('Redis error:', err); + return null; + } + return incrementValue[1]; + }); + }, + + /** + * Drop login attempts + * @param username + * @param ip + * @returns {*} + */ + drop: function drop(username, ip) { + const { redis } = this; + const ipKey = generateKey(username, 'ip', ip); + return redis.del(ipKey); + }, +}; + +exports.Tokens = { + /** + * Add the token + * @param username + * @param token + * @returns {*} + */ + add(username, token) { + const { redis } = this; + return redis.zadd(generateKey(username, USERS_TOKENS), Date.now(), token); + }, + + /** + * Drop the token + * @param username + * @param token + * @returns {*} + */ + drop(username, token = null) { + const { redis } = this; + return token ? + redis.zrem(generateKey(username, USERS_TOKENS), token) : + redis.del(generateKey(username, USERS_TOKENS)); + }, + + /** + * Get last token score + * @param username + * @param token + * @returns {integer} + */ + lastAccess(username, token) { + const { redis, config } = this; + const { jwt: { ttl } } = config; + const tokensHolder = generateKey(username, USERS_TOKENS); + return redis.zscoreBuffer(tokensHolder, token).then(_score => { + // parseResponse + const score = parseInt(_score, 10); + + // throw if token not found or expired + if (isNaN(score) || Date.now() > score + ttl) { + throw new ModelError(ERR_TOKEN_FORGED); + } + + return score; + }); + }, + + /** + * Get special email throttle state + * @param type + * @param email + * @returns {bool} state + */ + getEmailThrottleState(type, email) { + const { redis } = this; + const throttleEmailsKey = generateKey(`vthrottle-${type}`, email); + return redis.get(throttleEmailsKey); + }, + + /** + * Set special email throttle state + * @param type + * @param email + * @returns {*} + */ + setEmailThrottleState(type, email) { + const { redis, config } = this; + const throttleEmailsKey = generateKey(`vthrottle-${type}`, email); + const { validation: { throttle } } = config; + + const throttleArgs = [throttleEmailsKey, 1, 'NX']; + if (throttle > 0) { + throttleArgs.splice(2, 0, 'EX', throttle); + } + return redis.set(throttleArgs); + }, + + /** + * Get special email throttle token + * @param type + * @param token + * @returns {string} email + */ + getEmailThrottleToken(type, token) { + const { redis } = this; + const secretKey = generateKey(`vsecret-${type}`, token); + return redis.get(secretKey); + }, + + /** + * Set special email throttle token + * @param type + * @param email + * @param token + * @returns {*} + */ + setEmailThrottleToken(type, email, token) { + const { redis, config } = this; + const { validation: { ttl } } = config; + + const secretKey = generateKey(`vsecret-${type}`, token); + const args = [secretKey, email]; + if (ttl > 0) { + args.push('EX', ttl); + } + return redis.set(args); + }, + + /** + * Drop special email throttle token + * @param type + * @param token + * @returns {*} + */ + dropEmailThrottleToken(type, token) { + const { redis } = this; + const secretKey = generateKey(`vsecret-${type}`, token); + return redis.del(secretKey); + }, + +}; + +exports.Utils = { + /** + * Check IP limits for registration + * @param ipaddress + * @returns {*} + */ + checkIPLimits(ipaddress) { + // TODO: КРИВО, в оригинале есть проверка на существование registrationLimits + + // if (registrationLimits.ip && ipaddress) { + // promise = promise.tap(checkLimits(redis, registrationLimits, ipaddress)); + // } + + const { redis, config } = this; + const { registrationLimits: { ip: { time, times } } } = config; + const ipaddressLimitKey = generateKey('reg-limit', ipaddress); + const now = Date.now(); + const old = now - time; + + return function iplimits() { + return redis + .pipeline() + .zadd(ipaddressLimitKey, now, uuid.v4()) + .pexpire(ipaddressLimitKey, time) + .zremrangebyscore(ipaddressLimitKey, '-inf', old) + .zcard(ipaddressLimitKey) + .exec() + .then(props => { + const cardinality = props[3][1]; + if (cardinality > times) { + throw new ModelError(ERR_ATTEMPTS_TO_MUCH_REGISTERED); + } + }); + }; + }, + + /** + * Check captcha + * @param username + * @param captcha + * @param next + * @returns {*} + */ + checkCaptcha(username, captcha, next = null) { + const { redis, config } = this; + const { captcha: captchaConfig } = config; + + return () => { + const captchaCacheKey = captcha.response; + return redis + .pipeline() + .set(captchaCacheKey, username, 'EX', captchaConfig.ttl, 'NX') + .get(captchaCacheKey) + .exec() + .spread(function captchaCacheResponse(setResponse, getResponse) { + if (getResponse[1] !== username) { + throw new ModelError(ERR_CAPTCHA_WRONG_USERNAME); + } + }) + // check google captcha + .then(next ? () => next.call(this, captcha) : noop); + }; + }, +}; + diff --git a/src/model/usermodel.js b/src/model/usermodel.js new file mode 100644 index 000000000..7726efe8a --- /dev/null +++ b/src/model/usermodel.js @@ -0,0 +1,330 @@ +/** + * Created by Stainwoortsel on 17.06.2016. + */ +const Promise = require('bluebird'); +const storage = require('./storages/redisstorage'); +const { ModelError, ERR_ATTEMPTS_LOCKED } = require('./modelError'); +const moment = require('moment'); + +/** + * Adapter pattern class with user model methods + */ +exports.User = { + + /** + * Initialize the model + */ + init() { + storage.User.init.call(this); + }, + + /** + * Get user by username + * @param username + * @returns {Object} + */ + getOne(username) { + return storage.User.getOne.call(this, username); + }, + + /** + * Get list of users by params + * @param opts + * @returns {Array} + */ + getList(opts) { + return storage.User.getList.call(this, opts); + }, + + /** + * Get metadata of user + * @param username + * @param audiences + * @param fields + * @param _public + * @returns {Object} + */ + getMeta(username, audiences, fields = {}, _public = null) { + return storage.User.getMeta.call(this, username, audiences, fields, _public); + }, + + /** + * Get ~real~ username by username or alias + * @param username + * @returns {String} username + */ + getUsername(username) { + return storage.User.getUsername.call(this, username); + }, + + /** + * Check alias existence + * @param alias + * @returns {*} + */ + checkAlias(alias) { + return storage.User.checkAlias.call(this, alias); + }, + + /** + * Sets alias to the user by username + * @param opts + * @returns {*} + */ + setAlias(opts) { + return storage.User.setAlias.call(this, opts); + }, + + /** + * Set user password + * @param username + * @param hash + * @returns {String} username + */ + setPassword(username, hash) { + return storage.User.setPassword.call(this, username, hash); + }, + + /** + * Updates metadata of user by username and audience + * @param username + * @param audience + * @param metadata + * @returns {Object} + */ + setMeta(opts) { + return storage.User.setMeta.call(this, opts); + }, + + /** + * Create user account with alias and password + * @param username + * @param alias + * @param hash + * @param activate + * @returns {*} + */ + create(username, alias, hash, activate) { + return storage.User.create.call(this, username, alias, hash, activate); + }, + + /** + * Remove user + * @param username + * @param data + * @returns {*} + */ + remove(username, data) { + return storage.User.remove.call(this, username, data); + }, + + /** + * Activate user + * @param username + * @returns {*} + */ + activate(username) { + return storage.User.activate.call(this, username); + }, + + /** + * Disactivate user + * @param username + * @returns {*} + */ + disactivate(username) { + return storage.User.disactivate.call(this, username); + }, + + /** + * Ban user + * @param username + * @param opts + * @returns {*} + */ + lock(opts) { + return storage.User.lock.call(this, opts); + }, + + /** + * Unlock banned user + * @param opts + * @returns {*} + */ + unlock(opts) { + return storage.User.unlock.call(this, opts); + }, +}; + +/** + * Adapter pattern class for user login attempts counting + */ +class AttemptsClass { + /** + * Attempts class constructor, _context parameter is the context of service + * @param _context + */ + constructor(_context) { + this.context = _context; + this.loginAttempts = 0; + } + + /** + * Check login attempts + * @param username + * @param ip + * @returns {*} + */ + check(username, ip) { + const { config: { jwt: { lockAfterAttempts }, keepLoginAttempts } } = this.context; + return Promise.bind(this.context, { username, ip }) + .then(storage.Attempts.check) + .then((attempts) => { + if (attempts === null) return; + + this.loginAttempts = attempts; + if (this.loginAttempts > lockAfterAttempts) { + const duration = moment().add(keepLoginAttempts, 'seconds').toNow(true); + const verifyIp = ip && lockAfterAttempts > 0; + + const err = new ModelError(ERR_ATTEMPTS_LOCKED, duration); + if (verifyIp) { + err.loginAttempts = this.loginAttempts; + } + + throw err; + } + }); + } + + /** + * Drop login attempts + * @param username + * @param ip + * @returns {*} + */ + drop(username, ip) { + this.loginAttempts = 0; + return storage.Attempts.drop.call(this.context, username, ip); + } + + /** + * Get attempts count + * @returns {integer} + */ + count() { + return this.loginAttempts; + } +} +exports.Attempts = AttemptsClass; + + /** + * Adapter pattern class for user tokens + */ +exports.Tokens = { + /** + * Add the token + * @param username + * @param token + * @returns {*} + */ + add(username, token) { + return storage.Tokens.add.call(this, username, token); + }, + + /** + * Drop the token + * @param username + * @param token + * @returns {*} + */ + drop(username, token = null) { + return storage.Tokens.drop.call(this, username, token); + }, + + /** + * Get last token score + * @param username + * @param token + * @returns {integer} + */ + lastAccess(username, token) { + return storage.Tokens.lastAccess.call(this, username, token); + }, + + /** + * Get special email throttle state + * @param type + * @param email + * @returns {bool} state + */ + getEmailThrottleState(type, email) { + return storage.Tokens.getEmailThrottleState.call(this, type, email); + }, + + /** + * Set special email throttle state + * @param type + * @param email + * @returns {*} + */ + setEmailThrottleState(type, email) { + return storage.Tokens.setEmailThrottleState.call(this, type, email); + }, + + /** + * Get special email throttle token + * @param type + * @param token + * @returns {string} email + */ + getEmailThrottleToken(type, token) { + return storage.Tokens.getEmailThrottleToken.call(this, type, token); + }, + + /** + * Set special email throttle token + * @param type + * @param email + * @param token + * @returns {*} + */ + setEmailThrottleToken(type, email, token) { + return storage.Tokens.setEmailThrottleToken.call(this, type, email, token); + }, + + /** + * Drop special email throttle token + * @param type + * @param token + * @returns {*} + */ + dropEmailThrottleToken(type, token) { + return storage.Tokens.dropEmailThrottleToken.call(this, type, token); + }, +}; + +/** + * Adapter pattern class for util methods with IP + */ +exports.Utils = { + /** + * Check IP limits for registration + * @param ipaddress + * @returns {*} + */ + checkIPLimits(ipaddress) { + return storage.Utils.checkIPLimits.call(this, ipaddress); + }, + + /** + * Check captcha + * @param username + * @param captcha + * @param next + * @returns {*} + */ + checkCaptcha(username, captcha, next = null) { + return storage.Utils.checkCaptcha.call(this, username, captcha, next); + }, +}; diff --git a/src/users.js b/src/users.js index dfea8b384..7cba05ee5 100644 --- a/src/users.js +++ b/src/users.js @@ -4,7 +4,7 @@ const Errors = require('common-errors'); const merge = require('lodash/merge'); const fsort = require('redis-filtered-sort'); const { NotImplementedError } = Errors; -const defaultOpts = require('./defaults.js'); +const _defaultOpts = require('./defaults.js'); /** * @namespace Users @@ -15,7 +15,7 @@ module.exports = class Users extends Mservice { * Configuration options for the service * @type {Object} */ - static defaultOpts = defaultOpts; + static defaultOpts = _defaultOpts; /** * @namespace Users @@ -31,7 +31,7 @@ module.exports = class Users extends Mservice { this.log.fatal('Invalid configuration:', error.toJSON()); throw error; } - +// повесить логику выбора адаптера можно повесить сюда, экшн долежн выполниться позже, можно лочить принятие сообщений внутри роута this.on('plugin:connect:amqp', (amqp) => { this._mailer = new Mailer(amqp, config.mailer); }); @@ -75,3 +75,4 @@ module.exports = class Users extends Mservice { initFakeAccounts = require('./accounts/init-dev.js'); }; + diff --git a/src/utils/aliasExists.js b/src/utils/aliasExists.js deleted file mode 100644 index 0800375cc..000000000 --- a/src/utils/aliasExists.js +++ /dev/null @@ -1,24 +0,0 @@ -const Errors = require('common-errors'); -const { USERS_ALIAS_TO_LOGIN } = require('../constants.js'); - -function resolveAlias(alias) { - return this.redis - .hget(USERS_ALIAS_TO_LOGIN, alias) - .then(username => { - if (username) { - throw new Errors.HttpStatusError(409, `"${alias}" already exists`); - } - - return username; - }); -} - -module.exports = function aliasExists(alias, thunk) { - if (thunk) { - return function resolveAliasThunk() { - return resolveAlias.call(this, alias); - }; - } - - return resolveAlias.call(this, alias); -}; diff --git a/src/utils/checkCaptcha.js b/src/utils/checkCaptcha.js deleted file mode 100644 index ce4711ffc..000000000 --- a/src/utils/checkCaptcha.js +++ /dev/null @@ -1,47 +0,0 @@ -const Promise = require('bluebird'); -const Errors = require('common-errors'); -const request = require('request-promise'); -const defaults = require('lodash/defaults'); -const pick = require('lodash/pick'); -const fmt = require('util').format; - -/** - * Performs captcha check, returns thukn - * @param {redisCluster} redis - * @param {String} username - * @param {String} captcha - * @param {Object} captchaConfig - * @return {Function} - */ -module.exports = function makeCaptchaCheck(redis, username, captcha, captchaConfig) { - const { secret, ttl, uri } = captchaConfig; - return function checkCaptcha() { - const captchaCacheKey = captcha.response; - return redis - .pipeline() - .set(captchaCacheKey, username, 'EX', ttl, 'NX') - .get(captchaCacheKey) - .exec() - .spread(function captchaCacheResponse(setResponse, getResponse) { - if (getResponse[1] !== username) { - const msg = 'Captcha challenge you\'ve solved can not be used, please complete it again'; - throw new Errors.HttpStatusError(412, msg); - } - }) - .then(function verifyGoogleCaptcha() { - return request - .post({ uri, qs: defaults(captcha, { secret }), json: true }) - .then(function captchaSuccess(body) { - if (!body.success) { - return Promise.reject({ statusCode: 200, error: body }); - } - - return true; - }) - .catch(function captchaError(err) { - const errData = JSON.stringify(pick(err, ['statusCode', 'error'])); - throw new Errors.HttpStatusError(412, fmt('Captcha response: %s', errData)); - }); - }); - }; -}; diff --git a/src/utils/getInternalData.js b/src/utils/getInternalData.js deleted file mode 100644 index ef5e0506e..000000000 --- a/src/utils/getInternalData.js +++ /dev/null @@ -1,26 +0,0 @@ -const Errors = require('common-errors'); -const redisKey = require('../utils/key.js'); -const { USERS_DATA, USERS_ALIAS_TO_LOGIN } = require('../constants.js'); - -module.exports = function getInternalData(username) { - const { redis } = this; - const userKey = redisKey(username, USERS_DATA); - - return redis - .pipeline() - .hget(USERS_ALIAS_TO_LOGIN, username) - .exists(userKey) - .hgetallBuffer(userKey) - .exec() - .spread((aliasToUsername, exists, data) => { - if (aliasToUsername[1]) { - return getInternalData.call(this, aliasToUsername[1]); - } - - if (!exists[1]) { - throw new Errors.HttpStatusError(404, `"${username}" does not exists`); - } - - return { ...data[1], username }; - }); -}; diff --git a/src/utils/getMetadata.js b/src/utils/getMetadata.js deleted file mode 100644 index 22c619496..000000000 --- a/src/utils/getMetadata.js +++ /dev/null @@ -1,34 +0,0 @@ -const mapValues = require('lodash/mapValues'); -const pick = require('lodash/pick'); -const redisKey = require('../utils/key.js'); -const Promise = require('bluebird'); -const { isArray } = Array; -const JSONParse = JSON.parse.bind(JSON); -const { USERS_METADATA } = require('../constants.js'); - -module.exports = function getMetadata(username, _audiences, fields = {}) { - const { redis } = this; - const audiences = isArray(_audiences) ? _audiences : [_audiences]; - - return Promise.map(audiences, audience => { - return redis.hgetall(redisKey(username, USERS_METADATA, audience)); - }) - .then(function remapAudienceData(data) { - const output = {}; - audiences.forEach(function transform(aud, idx) { - const datum = data[idx]; - - if (datum) { - const pickFields = fields[aud]; - output[aud] = mapValues(datum, JSONParse); - if (pickFields) { - output[aud] = pick(output[aud], pickFields); - } - } else { - output[aud] = {}; - } - }); - - return output; - }); -}; diff --git a/src/utils/isActive.js b/src/utils/isActive.js index 69c8fafc6..3e2da704a 100644 --- a/src/utils/isActive.js +++ b/src/utils/isActive.js @@ -1,10 +1,10 @@ const Promise = require('bluebird'); -const Errors = require('common-errors'); +const { ModelError, ERR_ACCOUNT_NOT_ACTIVATED } = require('../model/modelError'); const { USERS_ACTIVE_FLAG } = require('../constants.js'); module.exports = function isBanned(data) { if (String(data[USERS_ACTIVE_FLAG]) !== 'true') { - return Promise.reject(new Errors.HttpStatusError(412, 'Account hasn\'t been activated')); + return Promise.reject(new ModelError(ERR_ACCOUNT_NOT_ACTIVATED)); } return Promise.resolve(data); diff --git a/src/utils/isBanned.js b/src/utils/isBanned.js index 197a38915..c82c8977c 100644 --- a/src/utils/isBanned.js +++ b/src/utils/isBanned.js @@ -1,11 +1,10 @@ const Promise = require('bluebird'); -const Errors = require('common-errors'); +const { ModelError, ERR_ACCOUNT_IS_LOCKED } = require('../model/modelError'); const { USERS_BANNED_FLAG } = require('../constants.js'); module.exports = function isBanned(data) { if (String(data[USERS_BANNED_FLAG]) === 'true') { - return Promise.reject(new Errors.HttpStatusError(423, 'Account has been locked')); + return Promise.reject(new ModelError(ERR_ACCOUNT_IS_LOCKED)); } - return Promise.resolve(data); }; diff --git a/src/utils/isDisposable.js b/src/utils/isDisposable.js index 53169b18b..4f78e5043 100644 --- a/src/utils/isDisposable.js +++ b/src/utils/isDisposable.js @@ -1,5 +1,5 @@ -const Errors = require('common-errors'); const disposableDomains = require('disposable-email-domains'); +const { ModelError, ERR_EMAIL_DISPOSABLE } = require('../model/modelError'); // init pointers const disposablePointers = {}; @@ -17,7 +17,7 @@ module.exports = function isDisposable(email) { const domain = email.split('@')[1]; return function testDisposable() { if (disposablePointers[domain]) { - throw new Errors.HttpStatusError(400, 'you must use non-disposable email to register'); + throw new ModelError(ERR_EMAIL_DISPOSABLE); } return email; diff --git a/src/utils/isPublic.js b/src/utils/isPublic.js new file mode 100644 index 000000000..486928ebc --- /dev/null +++ b/src/utils/isPublic.js @@ -0,0 +1,24 @@ +/** + * Created by Stainwoortsel on 03.07.2016. + */ +const get = require('lodash/get'); +const { ModelError, ERR_USERNAME_NOT_FOUND } = require('../model/modelError'); +const { USERS_ALIAS_FIELD } = require('../constants.js'); + +module.exports = function isPublic(username, audiences) { + return metadata => { + let notFound = true; + + // iterate over passed audiences + audiences.forEach(audience => { + if (notFound && get(metadata, [audience, USERS_ALIAS_FIELD]) === username) { + notFound = false; + } + }); + + if (notFound) { + throw new ModelError(ERR_USERNAME_NOT_FOUND); + } + }; +}; + diff --git a/src/utils/jwt.js b/src/utils/jwt.js index 63202b25f..2aa52fe5a 100644 --- a/src/utils/jwt.js +++ b/src/utils/jwt.js @@ -1,11 +1,9 @@ -const Errors = require('common-errors'); const Promise = require('bluebird'); const jwt = Promise.promisifyAll(require('jsonwebtoken')); -const redisKey = require('./key.js'); -const getMetadata = require('../utils/getMetadata.js'); const FlakeId = require('flake-idgen'); const flakeIdGen = new FlakeId(); -const { USERS_TOKENS } = require('../constants.js'); +const { User, Tokens } = require('../model/usermodel'); +const { ModelError, ERR_TOKEN_INVALID, ERR_TOKEN_AUDIENCE_MISMATCH } = require('../model/modelError'); /** * Logs user in and returns JWT and User Object @@ -14,7 +12,7 @@ const { USERS_TOKENS } = require('../constants.js'); * @return {Promise} */ exports.login = function login(username, _audience) { - const { redis, config } = this; + const { config } = this; const { jwt: jwtConfig } = config; const { hashingFunction: algorithm, defaultAudience, secret } = jwtConfig; let audience = _audience || defaultAudience; @@ -35,20 +33,20 @@ exports.login = function login(username, _audience) { } return Promise.props({ - lastAccessUpdated: redis.zadd(redisKey(username, USERS_TOKENS), Date.now(), token), + lastAccessUpdated: Tokens.add.call(this, username, token), jwt: token, username, - metadata: getMetadata.call(this, username, audience), + metadata: User.getMeta.call(this, username, audience), }) - .then(function remap(props) { - return { - jwt: props.jwt, - user: { - username: props.username, - metadata: props.metadata, - }, - }; - }); + .then(function remap(props) { + return { + jwt: props.jwt, + user: { + username: props.username, + metadata: props.metadata, + }, + }; + }); }; /** @@ -58,7 +56,7 @@ exports.login = function login(username, _audience) { * @return {Promise} */ exports.logout = function logout(token, audience) { - const { redis, config } = this; + const { config } = this; const { jwt: jwtConfig } = config; const { hashingFunction: algorithm, secret, issuer } = jwtConfig; @@ -66,10 +64,10 @@ exports.logout = function logout(token, audience) { .verifyAsync(token, secret, { issuer, audience, algorithms: [algorithm] }) .catch(err => { this.log.debug('error decoding token', err); - throw new Errors.HttpStatusError(403, 'Invalid Token'); + throw new ModelError(ERR_TOKEN_INVALID); }) - .then(function decodedToken(decoded) { - return redis.zrem(redisKey(decoded.username, USERS_TOKENS), token); + .then(decoded => { + return Tokens.drop.call(this, decoded.username, token); }) .return({ success: true }); }; @@ -79,9 +77,10 @@ exports.logout = function logout(token, audience) { * @param {String} username */ exports.reset = function reset(username) { - return this.redis.del(redisKey(username, USERS_TOKENS)); + return Tokens.drop.call(this, username); }; + /** * Verifies token and returns decoded version of it * @param {String} token @@ -90,39 +89,26 @@ exports.reset = function reset(username) { * @return {Promise} */ exports.verify = function verifyToken(token, audience, peek) { - const { redis, config } = this; + const { config } = this; const { jwt: jwtConfig } = config; - const { hashingFunction: algorithm, secret, ttl, issuer } = jwtConfig; + const { hashingFunction: algorithm, secret, issuer } = jwtConfig; return jwt .verifyAsync(token, secret, { issuer, algorithms: [algorithm] }) .catch(err => { this.log.debug('invalid token passed: %s', token, err); - throw new Errors.HttpStatusError(403, 'invalid token'); + throw new ModelError(ERR_TOKEN_INVALID); }) - .then(function decodedToken(decoded) { + .then(decoded => { if (audience.indexOf(decoded.aud) === -1) { - throw new Errors.HttpStatusError(403, 'audience mismatch'); + throw new ModelError(ERR_TOKEN_AUDIENCE_MISMATCH); } const { username } = decoded; - const tokensHolder = redisKey(username, USERS_TOKENS); - let lastAccess = redis.zscore(tokensHolder, token).then(function getLastAccess(_score) { - // parseResponse - const score = parseInt(_score, 10); - - // throw if token not found or expired - if (isNaN(score) || Date.now() > score + ttl) { - throw new Errors.HttpStatusError(403, 'token has expired or was forged'); - } - - return score; - }); + let lastAccess = Tokens.lastAccess.call(this, username, token); if (!peek) { - lastAccess = lastAccess.then(function refreshLastAccess() { - return redis.zadd(tokensHolder, Date.now(), token); - }); + lastAccess = lastAccess.then(() => Tokens.add.call(this, username, token)); } return lastAccess.return(decoded); diff --git a/src/utils/mapMetaResponse.js b/src/utils/mapMetaResponse.js new file mode 100644 index 000000000..0d29ffd51 --- /dev/null +++ b/src/utils/mapMetaResponse.js @@ -0,0 +1,39 @@ +/** + * Created by Stainwoortsel on 05.06.2016. + */ +/** + * Is a common method for mapping User.setMeta ops + * @param operations + * @param responses + * @returns {Promise} + */ +module.exports = function mapMetaResponse(operations, responses) { + let cursor = 0; + return Promise.map(operations, props => { + const { $removeOps, $setLength, $incrLength, $incrFields } = props; + const output = {}; + + if ($removeOps > 0) { + output.$remove = responses[cursor][1]; + cursor++; + } + + if ($setLength > 0) { + output.$set = responses[cursor][1]; + cursor++; + } + + if ($incrLength > 0) { + const $incrResponse = output.$incr = {}; + $incrFields.forEach(fieldName => { + $incrResponse[fieldName] = responses[cursor][1]; + cursor++; + }); + } + + return output; + }) + .then(ops => { + return ops.length > 1 ? ops : ops[0]; + }); +}; diff --git a/src/utils/mxExists.js b/src/utils/mxExists.js index d0344a4a3..d62020bad 100644 --- a/src/utils/mxExists.js +++ b/src/utils/mxExists.js @@ -1,7 +1,6 @@ -const Errors = require('common-errors'); const Promise = require('bluebird'); const dns = Promise.promisifyAll(require('dns')); - +const { ModelError, ERR_EMAIL_NO_MX } = require('../model/modelError'); /** * Checks whether MX record exists or not * @param {String} email @@ -27,7 +26,7 @@ module.exports = function mxExists(email) { return null; } - throw new Errors.HttpStatusError(400, `no MX record was found for hostname ${hostname}`); + throw new ModelError(ERR_EMAIL_NO_MX, hostname); }); }; }; diff --git a/src/utils/remapMeta.js b/src/utils/remapMeta.js new file mode 100644 index 000000000..adb0b005b --- /dev/null +++ b/src/utils/remapMeta.js @@ -0,0 +1,25 @@ +/** + * Created by Stainwoortsel on 18.06.2016. + */ +const mapValues = require('lodash/mapValues'); +const pick = require('lodash/pick'); +const JSONParse = JSON.parse.bind(JSON); + +module.exports = function remapMeta(data, audiences, fields) { + const output = {}; + audiences.forEach(function transform(aud, idx) { + const datum = data[idx]; + + if (datum) { + const pickFields = fields[aud]; + output[aud] = mapValues(datum, JSONParse); + if (pickFields) { + output[aud] = pick(output[aud], pickFields); + } + } else { + output[aud] = {}; + } + }); + + return output; +}; diff --git a/src/utils/scrypt.js b/src/utils/scrypt.js index d23a225ec..4ff76a807 100644 --- a/src/utils/scrypt.js +++ b/src/utils/scrypt.js @@ -1,28 +1,33 @@ -const Errors = require('common-errors'); const Promise = require('bluebird'); const scrypt = Promise.promisifyAll(require('scrypt')); const bytes = require('bytes'); +const { ModelError, ERR_PASSWORD_INVALID, ERR_PASSWORD_INCORRECT, + ERR_PASSWORD_SCRYPT_ERROR, ERR_PASSWORD_INVALID_HASH } = require('../model/modelError'); // setup scrypt const scryptParams = scrypt.paramsSync(0.1, bytes('32mb')); exports.hash = function hashPassword(password) { if (!password) { - throw new Errors.HttpStatusError(500, 'invalid password passed'); + throw new ModelError(ERR_PASSWORD_INVALID); } return scrypt.kdfAsync(Buffer.from(password), scryptParams); }; exports.verify = function verifyPassword(hash, password) { + if (!Buffer.isBuffer(hash) || hash.length === 0) { + throw new ModelError(ERR_PASSWORD_INVALID_HASH); + } + return scrypt .verifyKdfAsync(hash, Buffer.from(password)) .catch(function scryptError(err) { - throw new Errors.HttpStatusError(403, err.message || err.scrypt_err_message); + throw new ModelError(ERR_PASSWORD_SCRYPT_ERROR, err); }) .then(function verifyResult(result) { if (result !== true) { - throw new Errors.HttpStatusError(403, 'incorrect password'); + throw new ModelError(ERR_PASSWORD_INCORRECT); } }); }; diff --git a/src/utils/send-email.js b/src/utils/send-email.js index 24ae64b73..74839b4d1 100644 --- a/src/utils/send-email.js +++ b/src/utils/send-email.js @@ -1,13 +1,17 @@ const Errors = require('common-errors'); const Promise = require('bluebird'); // jshint ignore: line const uuid = require('node-uuid'); -const redisKey = require('../utils/key.js'); const crypto = require('crypto'); const URLSafeBase64 = require('urlsafe-base64'); const render = require('ms-mailer-templates'); const { updatePassword } = require('../actions/updatePassword.js'); const generatePassword = require('password-generator'); const { MAIL_ACTIVATE, MAIL_RESET, MAIL_PASSWORD, MAIL_REGISTER } = require('../constants.js'); +const { Tokens } = require('../model/usermodel'); +const { ModelError, ERR_TOKEN_CANT_DECODE, ERR_EMAIL_ALREADY_SENT, ERR_TOKEN_BAD_EMAIL, + ERR_TOKEN_EXPIRED, ERR_TOKEN_MISS_EMAIL } = require('../model/modelError'); + +// TODO: merge this code with master!!! /** * Throttled error @@ -16,7 +20,7 @@ const { MAIL_ACTIVATE, MAIL_RESET, MAIL_PASSWORD, MAIL_REGISTER } = require('../ function isThrottled(compare) { return function comparator(reply) { if (!!reply === compare) { - throw new Errors.HttpStatusError(429, 'We\'ve already sent you an email, if it doesn\'t come - please try again in a little while or send us an email'); // eslint-disable-line + throw new ModelError(ERR_EMAIL_ALREADY_SENT); } }; } @@ -86,7 +90,7 @@ exports.safeDecode = function safeDecode(algorithm, secret, string) { .bind(this) .catch(function remapError(err) { this.log.warn('cant decode token', err); - throw new Errors.HttpStatusError(403, 'could not decode token'); + throw new ModelError(ERR_TOKEN_CANT_DECODE); }); }; @@ -98,17 +102,17 @@ exports.safeDecode = function safeDecode(algorithm, secret, string) { * @return {Promise} */ exports.send = function sendEmail(email, type = MAIL_ACTIVATE, wait = false) { - const { redis, config, mailer } = this; + const { config, mailer } = this; const { validation, server } = config; - const { ttl, throttle, subjects, senders, paths, secret, algorithm, email: mailingAccount } = validation; // eslint-disable-line + const { subjects, senders, paths, secret, algorithm, email: mailingAccount } = validation; // eslint-disable-line // method specific stuff - const throttleEmailsKey = redisKey(`vthrottle-${type}`, email); const activationSecret = uuid.v4(); const logger = this.log.child({ action: 'sendEmail', email }); - return redis - .get(throttleEmailsKey) + return Promise + .bind(this, [type, email]) + .spread(Tokens.getEmailThrottleState) .then(isThrottled(true)) .then(function generateContent() { // generate context @@ -148,21 +152,11 @@ exports.send = function sendEmail(email, type = MAIL_ACTIVATE, wait = false) { return updatePassword.call(this, email, context.password); } - const throttleArgs = [throttleEmailsKey, 1, 'NX']; - if (throttle > 0) { - throttleArgs.splice(2, 0, 'EX', throttle); - } - return redis - .set(throttleArgs) + return Promise + .bind(this, [type, email, activationSecret]) + .spread(Tokens.setEmailThrottleState) .then(isThrottled(false)) - .then(function updateSecret() { - const secretKey = redisKey(`vsecret-${type}`, activationSecret); - const args = [secretKey, email]; - if (ttl > 0) { - args.push('EX', ttl); - } - return redis.set(args); - }); + .then(() => Tokens.setEmailThrottleToken.call(this, type, email, activationSecret)); }) .then(function definedSubjectAndSend({ context, emailTemplate }) { const mail = { @@ -199,35 +193,35 @@ exports.send = function sendEmail(email, type = MAIL_ACTIVATE, wait = false) { * @return {Promise} */ exports.verify = function verifyToken(string, namespace = MAIL_ACTIVATE, expires) { - const { redis, config } = this; - const { validation } = config; - const { secret: validationSecret, algorithm } = validation; + const { config: { validation: { secret: validationSecret, algorithm } } } = this; return exports .safeDecode .call(this, algorithm, validationSecret, string) - .then(function inspectResult(message) { + .then(message => { const { email, token } = message; if (!email || !token) { - const msg = 'Decoded token misses references to email and/or secret'; - throw new Errors.HttpStatusError(403, msg); + throw new ModelError(ERR_TOKEN_MISS_EMAIL); } - const secretKey = redisKey(`vsecret-${namespace}`, token); - return redis - .get(secretKey) - .then(function inspectAssociatedData(associatedEmail) { + return Promise +// .bind(this) +// .then(() => Tokens.getEmailThrottleToken(namespace, token)) +// .then(function inspectAssociatedData(associatedEmail) { + .bind(this, [namespace, token]) + .spread(Tokens.getEmailThrottleToken) + .then(associatedEmail => { if (!associatedEmail) { - throw new Errors.HttpStatusError(404, 'token expired or is invalid'); + throw new ModelError(ERR_TOKEN_EXPIRED); } if (associatedEmail !== email) { - throw new Errors.HttpStatusError(412, 'associated email doesn\'t match token'); + throw new ModelError(ERR_TOKEN_BAD_EMAIL); } if (expires) { - return redis.del(secretKey); + return Tokens.dropEmailThrottleToken.call(this, namespace, token); } return null; diff --git a/src/utils/updateMetadata.js b/src/utils/updateMetadata.js deleted file mode 100644 index 3e2e49a48..000000000 --- a/src/utils/updateMetadata.js +++ /dev/null @@ -1,127 +0,0 @@ -/* eslint-disable no-mixed-operators */ - -const Promise = require('bluebird'); -const mapValues = require('lodash/mapValues'); -const redisKey = require('../utils/key.js'); -const JSONStringify = JSON.stringify.bind(JSON); -const is = require('is'); -const sha256 = require('./sha256.js'); -const { USERS_METADATA } = require('../constants.js'); - -/** - * Process metadata update operation for a passed audience - * @param {Object} pipeline - * @param {String} audience - * @param {Object} metadata - */ -function handleAudience(pipeline, key, metadata) { - const $remove = metadata.$remove; - const $removeOps = $remove && $remove.length || 0; - if ($removeOps > 0) { - pipeline.hdel(key, $remove); - } - - const $set = metadata.$set; - const $setKeys = $set && Object.keys($set); - const $setLength = $setKeys && $setKeys.length || 0; - if ($setLength > 0) { - pipeline.hmset(key, mapValues($set, JSONStringify)); - } - - const $incr = metadata.$incr; - const $incrFields = $incr && Object.keys($incr); - const $incrLength = $incrFields && $incrFields.length || 0; - if ($incrLength > 0) { - $incrFields.forEach(fieldName => { - pipeline.hincrby(key, fieldName, $incr[fieldName]); - }); - } - - return { $removeOps, $setLength, $incrLength, $incrFields }; -} - -/** - * Maps updateMetadata ops - * @param {Array} responses - * @param {Array} operations - * @return {Object|Array} - */ -function mapMetaResponse(operations, responses) { - let cursor = 0; - return Promise.map(operations, props => { - const { $removeOps, $setLength, $incrLength, $incrFields } = props; - const output = {}; - - if ($removeOps > 0) { - output.$remove = responses[cursor][1]; - cursor++; - } - - if ($setLength > 0) { - output.$set = responses[cursor][1]; - cursor++; - } - - if ($incrLength > 0) { - const $incrResponse = output.$incr = {}; - $incrFields.forEach(fieldName => { - $incrResponse[fieldName] = responses[cursor][1]; - cursor++; - }); - } - - return output; - }) - .then(ops => { - return ops.length > 1 ? ops : ops[0]; - }); -} - -/** - * Handle script, mutually exclusive with metadata - * @param {Array} scriptKeys - * @param {Array} responses - */ -function mapScriptResponse(scriptKeys, responses) { - const output = {}; - scriptKeys.forEach((fieldName, idx) => { - output[fieldName] = responses[idx]; - }); - return output; -} - -/** - * Updates metadata on a user object - * @param {Object} opts - * @return {Promise} - */ -module.exports = function updateMetadata(opts) { - const { redis } = this; - const { username, audience, metadata, script } = opts; - const audiences = is.array(audience) ? audience : [audience]; - - // keys - const keys = audiences.map(aud => redisKey(username, USERS_METADATA, aud)); - - // if we have meta, then we can - if (metadata) { - const pipe = redis.pipeline(); - const metaOps = is.array(metadata) ? metadata : [metadata]; - const operations = metaOps.map((meta, idx) => handleAudience(pipe, keys[idx], meta)); - return pipe.exec().then(res => mapMetaResponse(operations, res)); - } - - // dynamic scripts - const $scriptKeys = Object.keys(script); - const scripts = $scriptKeys.map(scriptName => { - const { lua, argv = [] } = script[scriptName]; - const sha = sha256(lua); - const name = `ms_users_${sha}`; - if (!is.fn(redis[name])) { - redis.defineCommand(name, { lua }); - } - return redis[name](keys.length, keys, argv); - }); - - return Promise.all(scripts).then(res => mapScriptResponse($scriptKeys, res)); -}; diff --git a/src/utils/userExists.js b/src/utils/userExists.js deleted file mode 100644 index be39d8c12..000000000 --- a/src/utils/userExists.js +++ /dev/null @@ -1,23 +0,0 @@ -const Errors = require('common-errors'); -const redisKey = require('../utils/key.js'); -const { USERS_DATA, USERS_ALIAS_TO_LOGIN } = require('../constants.js'); - -module.exports = function userExists(username) { - return this - .redis - .pipeline() - .hget(USERS_ALIAS_TO_LOGIN, username) - .exists(redisKey(username, USERS_DATA)) - .exec() - .spread((alias, exists) => { - if (alias[1]) { - return alias[1]; - } - - if (!exists[1]) { - throw new Errors.HttpStatusError(404, `"${username}" does not exists`); - } - - return username; - }); -}; diff --git a/src/utils/verifyGoogleCaptcha.js b/src/utils/verifyGoogleCaptcha.js new file mode 100644 index 000000000..a1c559b7f --- /dev/null +++ b/src/utils/verifyGoogleCaptcha.js @@ -0,0 +1,24 @@ +/** + * Created by Stainwoortsel on 05.06.2016. + */ +const defaults = require('lodash/defaults'); +const request = require('request-promise'); +const pick = require('lodash/pick'); +const { ModelError, ERR_CAPTCHA_ERROR_RESPONSE } = require('../model/modelError'); + +module.exports = function verifyGoogleCaptcha(captcha) { + const { config: { captcha: { captchaConfig: { secret, uri } } } } = this; + return request + .post({ uri, qs: defaults(captcha, { secret }), json: true }) + .then(function captchaSuccess(body) { + if (!body.success) { + return Promise.reject({ statusCode: 200, error: body }); + } + + return true; + }) + .catch(function captchaError(err) { + const errData = JSON.stringify(pick(err, ['statusCode', 'error'])); + throw new ModelError(ERR_CAPTCHA_ERROR_RESPONSE, errData); + }); +}; diff --git a/test/config.js b/test/config.js index 789ab96a5..8f4bc2015 100644 --- a/test/config.js +++ b/test/config.js @@ -3,6 +3,13 @@ const { expect } = require('chai'); global.Promise = require('bluebird'); +global.MONGO = { + standalone: { + connectionString: 'mongodb://mongo/msusers', + options: {}, + } +}; + global.AMQP = { connection: { host: 'rabbitmq', @@ -20,6 +27,7 @@ global.REDIS = { const config = { amqp: global.AMQP, redis: global.REDIS, + mongo: global.MONGO, logger: true, debug: true, validation: { diff --git a/test/docker-compose-mongo.yml b/test/docker-compose-mongo.yml new file mode 100644 index 000000000..f995db618 --- /dev/null +++ b/test/docker-compose-mongo.yml @@ -0,0 +1,64 @@ +version: '2' + +services: + mongo-conf: + container_name: mongo-conf + hostname: mongo-conf + expose: + - "27010" + - "28017" + image: stainwoortsel/mongo-config + + mongo-sh-01: + container_name: mongo-sh-01 + hostname: mongo-sh-01 + expose: + - "27020" + - "28017" + image: stainwoortsel/mongo-shard + + mongo-sh-02: + container_name: mongo-sh-02 + hostname: mongo-sh-02 + links: + - mongo-sh-01 + expose: + - "27020" + - "28017" + image: stainwoortsel/mongo-shard + + mongos: + container_name: mongos + hostname: mongos + links: + - mongo-conf + - mongo-sh-01 + - mongo-sh-02 + command: mongos --configdb mongo-conf:27010 --port 27100 + expose: + - "27010" + - "27020" + - "27017" + - "28017" + - "27100" + ports: + - "27100:27100" + image: stainwoortsel/mongo-mongos + + tester: + container_name: tester + image: makeomatic/node:6.2.2-tester + links: + - mongo-conf + - mongo-sh-01 + - mongo-sh-02 + - rabbitmq + - mongos + working_dir: /src + volumes: + - ${PWD}:/src + environment: + NODE_ENV: "test" + DEBUG: ${DEBUG} + SKIP_REBUILD: ${SKIP_REBUILD} + command: tail -f /dev/null diff --git a/test/docker-compose.yml b/test/docker-compose.yml index ae0d37533..68b5409b7 100644 --- a/test/docker-compose.yml +++ b/test/docker-compose.yml @@ -1,6 +1,14 @@ version: '2' services: + mongo: + image: mvertes/alpine-mongo + container_name: mongo + hostname: mongo + expose: + - "27017" + - "28017" + redis-1: container_name: redis-1 hostname: redis-1 @@ -57,6 +65,7 @@ services: - redis-1 - redis-2 - redis-3 + - mongo - rabbitmq - redis-client working_dir: /src diff --git a/test/suites/activate.js b/test/suites/activate.js index eeee52f90..8705cd638 100644 --- a/test/suites/activate.js +++ b/test/suites/activate.js @@ -1,9 +1,9 @@ /* global inspectPromise */ const { expect } = require('chai'); -const redisKey = require('../../src/utils/key.js'); const URLSafeBase64 = require('urlsafe-base64'); describe('#activate', function activateSuite() { + const { Tokens } = require('../../src/model/usermodel'); const headers = { routingKey: 'users.activate' }; const emailValidation = require('../../src/utils/send-email.js'); const email = 'v@aminev.me'; @@ -44,8 +44,7 @@ describe('#activate', function activateSuite() { }); beforeEach(function pretest() { - const secretKey = redisKey('vsecret-activate', this.uuid); - return this.users.redis.set(secretKey, email); + return Tokens.setEmailThrottleToken.call(this.users, 'activate', email, this.uuid); }); it('must reject activation when account is already activated', function test() { @@ -66,8 +65,7 @@ describe('#activate', function activateSuite() { }); beforeEach('insert token', function pretest() { - const secretKey = redisKey('vsecret-activate', this.uuid); - return this.users.redis.set(secretKey, email); + return Tokens.setEmailThrottleToken.call(this.users, 'activate', email, this.uuid); }); it('must activate account when challenge token is correct and not expired', function test() { diff --git a/test/suites/list.js b/test/suites/list.js index af8e9cad3..ba1cedff4 100644 --- a/test/suites/list.js +++ b/test/suites/list.js @@ -1,6 +1,6 @@ /* global inspectPromise */ const { expect } = require('chai'); -const redisKey = require('../../src/utils/key.js'); +const { User } = require('../../src/model/usermodel'); const ld = require('lodash'); describe('#list', function listSuite() { @@ -12,28 +12,24 @@ describe('#list', function listSuite() { beforeEach(global.startService); afterEach(global.clearRedis); - beforeEach(function populateRedis() { + beforeEach(function populateUsers() { const audience = this.users._config.jwt.defaultAudience; const promises = []; const { USERS_INDEX, USERS_METADATA } = require('../../src/constants.js'); ld.times(105, () => { - const user = { - id: faker.internet.email(), + const userData = { + username: faker.internet.email(), + audience, metadata: { - firstName: faker.name.firstName(), - lastName: faker.name.lastName(), + $set: { + firstName: faker.name.firstName(), + lastName: faker.name.lastName(), + }, }, }; - - promises.push(this.users._redis - .pipeline() - .sadd(USERS_INDEX, user.id) - .hmset( - redisKey(user.id, USERS_METADATA, audience), - ld.mapValues(user.metadata, JSON.stringify.bind(JSON)) - ) - .exec() + promises.push( + User.create.call(this.users, userData.username, '', '123', true).then(() => User.setMeta.call(this.users, userData)) ); }); @@ -52,6 +48,7 @@ describe('#list', function listSuite() { }, headers) .reflect() .then(result => { + try { expect(result.isFulfilled()).to.be.eq(true); expect(result.value().page).to.be.eq(6); diff --git a/test/suites/login.js b/test/suites/login.js index 76303ab50..9acd78f09 100644 --- a/test/suites/login.js +++ b/test/suites/login.js @@ -1,9 +1,9 @@ /* global inspectPromise */ const { expect } = require('chai'); -const redisKey = require('../../src/utils/key.js'); const ld = require('lodash'); describe('#login', function loginSuite() { + const { User } = require('../../src/model/usermodel'); const headers = { routingKey: 'users.login' }; const user = { username: 'v@makeomatic.ru', password: 'nicepassword', audience: '*.localhost' }; const userWithValidPassword = { username: 'v@makeomatic.ru', password: 'nicepassword1', audience: '*.localhost' }; @@ -79,7 +79,7 @@ describe('#login', function loginSuite() { describe('account: banned', function suite() { beforeEach(function pretest() { - return this.users.redis.hset(redisKey(user.username, USERS_DATA), USERS_BANNED_FLAG, 'true'); + return User.lock.call(this.users, { username: user.username }); }); it('must reject login', function test() { diff --git a/test/suites/register.js b/test/suites/register.js index 157d049e8..e30910ef1 100644 --- a/test/suites/register.js +++ b/test/suites/register.js @@ -47,7 +47,7 @@ describe('#register', function registerSuite() { audience: 'matic.ninja', alias: 'bondthebest', }; - + return this.users .router(opts, headers) .reflect() diff --git a/test/suites/remove.js b/test/suites/remove.js index e7ba7f7ef..83507d887 100644 --- a/test/suites/remove.js +++ b/test/suites/remove.js @@ -1,9 +1,9 @@ /* global inspectPromise, globalRegisterUser */ const assert = require('assert'); -const { USERS_ADMIN_ROLE } = require('../../src/constants'); describe('#remove', function registerSuite() { const headers = { routingKey: 'users.remove' }; + const { USERS_ADMIN_ROLE } = require('../../src/constants'); beforeEach(global.startService); afterEach(global.clearRedis); diff --git a/test/suites/requestPassword.js b/test/suites/requestPassword.js index 4e41db972..ad5f6bb8f 100644 --- a/test/suites/requestPassword.js +++ b/test/suites/requestPassword.js @@ -1,8 +1,8 @@ /* global inspectPromise */ const { expect } = require('chai'); -const redisKey = require('../../src/utils/key.js'); describe('#requestPassword', function requestPasswordSuite() { + const { User } = require('../../src/model/usermodel'); const headers = { routingKey: 'users.requestPassword' }; const username = 'v@makeomatic.ru'; const audience = 'requestPassword'; @@ -27,7 +27,7 @@ describe('#requestPassword', function requestPasswordSuite() { describe('account: inactive', function suite() { beforeEach(function pretest() { - return this.users.redis.hset(redisKey(username, USERS_DATA), USERS_ACTIVE_FLAG, 'false'); + return User.disactivate.call(this.users, username); }); it('must fail when account is inactive', function test() { @@ -43,7 +43,7 @@ describe('#requestPassword', function requestPasswordSuite() { describe('account: banned', function suite() { beforeEach(function pretest() { - return this.users.redis.hset(redisKey(username, USERS_DATA), USERS_BANNED_FLAG, 'true'); + return User.lock.call(this.users, { username }); }); it('must fail when account is banned', function test() { diff --git a/test/suites/updateMetadata.js b/test/suites/updateMetadata.js index edf66ae32..b667696ca 100644 --- a/test/suites/updateMetadata.js +++ b/test/suites/updateMetadata.js @@ -78,7 +78,6 @@ describe('#updateMetadata', function getMetadataSuite() { .then(inspectPromise()) .then(data => { const [mainData, extraData] = data; - expect(mainData.$set).to.be.eq('OK'); expect(mainData.$incr.b).to.be.eq(2); expect(extraData.$incr.b).to.be.eq(3); diff --git a/test/suites/updatePassword.js b/test/suites/updatePassword.js index 379406182..906ab9237 100644 --- a/test/suites/updatePassword.js +++ b/test/suites/updatePassword.js @@ -1,8 +1,8 @@ /* global inspectPromise */ const { expect } = require('chai'); -const redisKey = require('../../src/utils/key.js'); describe('#updatePassword', function updatePasswordSuite() { + const { User } = require('../../src/model/usermodel'); const headers = { routingKey: 'users.updatePassword' }; const username = 'v@makeomatic.ru'; const password = '123'; @@ -29,7 +29,7 @@ describe('#updatePassword', function updatePasswordSuite() { describe('user: inactive', function suite() { beforeEach(function pretest() { - return this.users.redis.hset(redisKey(username, USERS_DATA), USERS_ACTIVE_FLAG, 'false'); + return User.disactivate.call(this.users, username); }); it('must reject updating password for an inactive account on username+password update', function test() { @@ -45,7 +45,7 @@ describe('#updatePassword', function updatePasswordSuite() { describe('user: banned', function suite() { beforeEach(function pretest() { - return this.users.redis.hset(redisKey(username, USERS_DATA), USERS_BANNED_FLAG, 'true'); + return User.lock.call(this.users, { username }); }); it('must reject updating password for an inactive account on username+password update', function test() {